Commit 0bf53482965e0906bbf2b20143847ccc1c33e172

Authored by “wangming”
1 parent ba386929

feat: implement duplicate usage record fix in inventory service

- Added a new endpoint to fix duplicate inventory usage records by merging entries based on UsageBatchId, ProductId, and StoreId.
- Implemented logic to retain the earliest record while summing quantities and amounts, marking others as invalid.
- Enhanced the service to log the repair process and provide feedback on the number of processed and invalidated records.
docs/库存使用记录-修复与测试报告.md 0 → 100644
  1 +# 库存使用记录 - 修复与测试报告
  2 +
  3 +## 一、问题与修复概要
  4 +
  5 +| 项目 | 说明 |
  6 +|------|------|
  7 +| 问题1 | 修改申请时,同一产品+门店出现多条记录 → 领用多算 |
  8 +| 修复1 | UpdateApplicationUsageRecords 按 ProductId+StoreId 合并 |
  9 +| 问题2 | 历史已有多算数据无法人工调整 |
  10 +| 修复2 | 新增 FixDuplicateUsageRecords 自动修复接口 |
  11 +| 验证点 | 作废记录不应参与领用统计 |
  12 +
  13 +---
  14 +
  15 +## 二、代码审查结论:作废记录是否会被统计?
  16 +
  17 +### ✅ 结论:作废记录(IsEffective=0)**不会**被计入领用统计
  18 +
  19 +所有涉及 `totalUsage`、领用数量统计的查询均包含 `IsEffective == StatusEnum.有效` 条件:
  20 +
  21 +| 文件 | 位置 | 说明 |
  22 +|------|------|------|
  23 +| LqInventoryUsageService | GetReceivedBatchIdsAsync、MarkReceivedAsync、CalculateAveragePriceFromInventoryAsync 等 | `x.IsEffective == StatusEnum.有效.GetHashCode()` |
  24 +| LqInventoryService | 入库前检查、可用库存、平均单价计算 | `x.IsEffective == StatusEnum.有效.GetHashCode()` |
  25 +| LqProductService | 产品作废检查、库存明细 | `x.IsEffective == StatusEnum.有效.GetHashCode()` |
  26 +| GetStoreReceiveCostStatistics | whereConditions | `u.F_IsEffective = 1` |
  27 +| LqBusinessUnitManagerSalaryService | 原生SQL | `WHERE u.F_IsEffective = 1` |
  28 +| LqDirectorSalaryService | 原生SQL | `WHERE u.F_IsEffective = 1` |
  29 +| LqStoreManagerSalaryService | 原生SQL | `WHERE u.F_IsEffective = 1` |
  30 +
  31 +**领用作废后可用库存会加回去**:作废使用记录后,该记录不再参与 totalUsage 统计,可用库存 = 总库存 - totalUsage 会自动增加。
  32 +
  33 +---
  34 +
  35 +## 三、数据库现状(MCP 查询结果)
  36 +
  37 +- 有效使用记录总数:3240 条
  38 +- **存在重复数据**:至少 5 组同一 UsageBatchId+ProductId+StoreId 有多条有效记录,需执行修复接口
  39 +
  40 +示例重复组:
  41 +- 批次 770197935005631749,产品 763991211127080197,门店 1649328471923847172:2条,总数量6
  42 +- 批次 770807754649502981,产品 763992934394627333,门店 1649328471923847187:2条,总数量4
  43 +- …
  44 +
  45 +---
  46 +
  47 +## 四、测试清单
  48 +
  49 +### 1. 修复接口测试 FixDuplicateUsageRecords
  50 +
  51 +```bash
  52 +# 1) 获取 Token
  53 +TOKEN=$(curl -s -X POST "http://localhost:2011/api/oauth/Login" \
  54 + -H "Content-Type: application/x-www-form-urlencoded" \
  55 + -d "account=admin&password=e10adc3949ba59abbe56e057f20f883e" | jq -r '.data.token')
  56 +
  57 +# 2) 执行修复
  58 +curl -X POST "http://localhost:2011/api/Extend/LqInventoryUsage/FixDuplicateUsageRecords" \
  59 + -H "Authorization: $TOKEN" \
  60 + -H "Content-Type: application/json"
  61 +```
  62 +
  63 +**验证**:
  64 +- 返回 `success: true`,`duplicateGroups`、`invalidatedCount` 合理
  65 +- 查库:同一 UsageBatchId+ProductId+StoreId 仅剩 1 条有效记录
  66 +- 查库:被合并的重复记录 `F_IsEffective = 0`
  67 +
  68 +### 2. 修改申请防重复测试
  69 +
  70 +1. 新建申请,同一产品+门店添加两条(或修改时故意提交重复)
  71 +2. 调用 `UpdateApplicationUsageRecords` 提交
  72 +3. 查库:该批次下同一产品+门店应只有 1 条有效记录,数量为合并值
  73 +
  74 +### 3. 作废后不计入统计测试
  75 +
  76 +1. 记录某产品当前可用库存 A
  77 +2. 新建申请并领用该产品若干数量,记领用后可用库存 B
  78 +3. 作废该领用记录
  79 +4. 再次查询可用库存,应恢复为 A(或接近 A,若存在其他领用)
  80 +
  81 +### 4. 领用统计接口一致性测试
  82 +
  83 +- 调用 GetStoreReceiveStatistics、GetStoreReceiveCostStatistics
  84 +- 与数据库直接 SUM 有效记录对比,结果一致
  85 +
  86 +---
  87 +
  88 +## 五、执行建议
  89 +
  90 +1. **部署后立即执行**:`POST /api/Extend/LqInventoryUsage/FixDuplicateUsageRecords` 修复历史数据
  91 +2. **接口测试**:确保 API 已启动(localhost:2011),按上述 curl 执行
  92 +3. **查库验证**:按 mcp-mysql-and-sql-validation 规范,修复后必须查库核对
  93 +
  94 +---
  95 +
  96 +## 六、实际执行结果(2025-02-13)
  97 +
  98 +### 1. 修复前数据库状态
  99 +
  100 +- 有效使用记录:3240 条
  101 +- 无效使用记录:838 条
  102 +- **重复组**:5 组(同一 UsageBatchId+ProductId+StoreId 有多条有效记录)
  103 +
  104 +| 批次ID | 产品ID | 门店ID | 重复条数 | 总数量 |
  105 +|--------|--------|--------|----------|--------|
  106 +| 784301392666821893 | 763979563993662725 | 1649328471923847179 | 2 | 20 |
  107 +| 787563597701055749 | 763987810846770437 | 1649328471923847176 | 2 | 270 |
  108 +| 770197935005631749 | 763991211127080197 | 1649328471923847172 | 2 | 6 |
  109 +| 784209727247615237 | 763991302353192197 | 1649328471923847188 | 2 | 10 |
  110 +| 770807754649502981 | 763992934394627333 | 1649328471923847187 | 2 | 4 |
  111 +
  112 +### 2. 修复接口调用结果
  113 +
  114 +```json
  115 +{
  116 + "success": true,
  117 + "message": "修复完成,处理 5 组重复数据,作废 5 条记录",
  118 + "duplicateGroups": 5,
  119 + "invalidatedCount": 5
  120 +}
  121 +```
  122 +
  123 +### 3. 修复后数据库验证
  124 +
  125 +- **重复组**:0 组 ✓(已清空)
  126 +- **有效记录**:减少 5 条(每组保留 1 条,作废 1 条)
  127 +- **无效记录**:843 条(原 838 + 新增 5)
  128 +- **合并验证**:以批次 787563597701055749 为例,保留 1 条有效(F_UsageQuantity=270, F_TotalAmount=1196.10),另 1 条已作废(F_IsEffective=-1)✓
  129 +
  130 +### 4. 幂等性验证
  131 +
  132 +再次调用修复接口,返回:
  133 +
  134 +```json
  135 +{
  136 + "success": true,
  137 + "message": "未发现重复数据",
  138 + "duplicateGroups": 0,
  139 + "invalidatedCount": 0
  140 +}
  141 +```
  142 +
  143 +✓ 幂等性正常,重复执行不会误伤数据。
  144 +
  145 +### 5. 结论
  146 +
  147 +- 修复接口工作正常
  148 +- 合并逻辑正确(数量、金额累加,保留 CreateTime 最早的一条)
  149 +- 作废记录 (F_IsEffective=-1) 不参与领用统计
  150 +- 无需后端代理修复,当前实现正确
docs/批次784301392666821893-青春驻颜美拉乳与一次性床单-测试分析.md 0 → 100644
  1 +# 批次 784301392666821893 - 青春驻颜美拉乳、一次性床单 测试分析
  2 +
  3 +## 一、客户反馈
  4 +
  5 +「出现无效的,但是又没有返回到库存」
  6 +
  7 +---
  8 +
  9 +## 二、数据现状(查库结果)
  10 +
  11 +### 1. 批次 784301392666821893 相关信息
  12 +
  13 +| 字段 | 值 |
  14 +|------|-----|
  15 +| 申请ID | 784301392687793414 |
  16 +| 审批状态 | 已通过 |
  17 +| 是否已领取 | 1(已领取) |
  18 +| 领取时间 | 2026-02-11 |
  19 +
  20 +### 2. 青春驻颜美拉乳(763983452226716933)
  21 +
  22 +| 记录ID | 数量 | 是否有效 | 创建时间 |
  23 +|--------|------|----------|----------|
  24 +| 784301392671016217 | 2 | -1(无效) | 2026-01-23 |
  25 +| 784563060160333083 | 2 | 1(有效) | 2026-01-24 |
  26 +
  27 +### 3. 一次性床单(763994300500411653)
  28 +
  29 +| 记录ID | 数量 | 是否有效 | 创建时间 |
  30 +|--------|------|----------|----------|
  31 +| 784301392671016197 | 5 | -1(无效) | 2026-01-23 |
  32 +| 784563060160333062 | 5 | 1(有效) | 2026-01-24 |
  33 +
  34 +### 4. 库存与领用统计
  35 +
  36 +| 产品 | 总库存 | 已使用(仅有效记录) | 可用库存 |
  37 +|------|--------|----------------------|----------|
  38 +| 青春驻颜美拉乳 | 834 | 195 | **639** |
  39 +| 一次性床单 | 947 | 915 | **32** |
  40 +
  41 +---
  42 +
  43 +## 三、数据来源分析
  44 +
  45 +- 无效记录(2026-01-23 创建):来自**修改申请**时被作废的**旧记录**
  46 +- 有效记录(2026-01-24 创建):修改申请后插入的**新记录**
  47 +
  48 +业务流程是:用户修改申请 → 后端作废旧记录 → 插入新记录。当前有效记录与无效记录的**数量一致**(青春驻颜美拉乳均为 2,一次性床单均为 5),说明是“替换”而不是“删除后补新”。
  49 +
  50 +---
  51 +
  52 +## 四、库存计算逻辑
  53 +
  54 +- 可用库存 = 总库存 - 已使用数量
  55 +- 已使用数量 = 仅统计 `F_IsEffective = 1` 的记录
  56 +
  57 +作废记录(`F_IsEffective = -1`)**不会**参与已使用数量统计,因此也不影响可用库存。
  58 +
  59 +---
  60 +
  61 +## 五、为何“无效记录没有返回到库存”
  62 +
  63 +- 有效记录 = 新记录(修改后保留的)
  64 +- 无效记录 = 旧记录(修改时被替换的)
  65 +
  66 +两份记录数量相同,因此:
  67 +
  68 +- 修改前:统计 1 条有效记录,数量 = 2(或 5)
  69 +- 修改后:仍统计 1 条有效记录,数量 = 2(或 5)
  70 +- 作废旧记录并没有减少“已使用数量”,所以可用库存不会增加,也不存在“返还到库存”。
  71 +
  72 +这是**按设计**的行为:在“修改申请”中,旧记录作废、新记录替换,当数量未变时,可用库存不变。
  73 +
  74 +---
  75 +
  76 +## 六、库存逻辑验证
  77 +
  78 +| 验证项 | 结果 |
  79 +|--------|------|
  80 +| 作废记录是否参与已使用统计 | 否,仅统计 `F_IsEffective = 1` |
  81 +| 青春驻颜美拉乳可用库存 | 834 - 195 = **639** |
  82 +| 一次性床单可用库存 | 947 - 915 = **32** |
  83 +
  84 +---
  85 +
  86 +## 七、若客户是“手动作废”有效记录
  87 +
  88 +若客户通过「作废使用记录」对**有效**记录(如 784563060160333083 或 784563060160333062)进行作废:
  89 +
  90 +- 作废后该记录变为 `F_IsEffective = -1`
  91 +- 已使用数量会减少
  92 +- 可用库存应增加,即发生“返回到库存”
  93 +
  94 +若此时界面仍显示未增加,可能原因包括:
  95 +
  96 +1. 前端或报表缓存未刷新
  97 +2. 调用接口未使用最新数据
  98 +3. 另有统计口径或查询条件未排除无效记录
  99 +
  100 +建议:在前端、接口和报表中统一校验「作废后可用库存是否按预期增加」。
  101 +
  102 +---
  103 +
  104 +## 八、结论与建议
  105 +
  106 +1. **库存计算逻辑正确**:作废记录未计入已使用数量,可用库存计算无误。
  107 +2. **“修改申请”场景**:旧记录作废、新记录替换且数量不变时,可用库存不会增加,属于正常逻辑。
  108 +3. **“手动作废”场景**:作废有效记录后,可用库存理论上会增加;若未增加,需要排查前端、接口或报表的展示与缓存。
netcore/src/Modularity/Extend/NCC.Extend/LqInventoryUsageService.cs
@@ -1533,8 +1533,25 @@ namespace NCC.Extend @@ -1533,8 +1533,25 @@ namespace NCC.Extend
1533 } 1533 }
1534 1534
1535 // 2. 创建新的使用记录(不计算价格,价格在确认领用时计算) 1535 // 2. 创建新的使用记录(不计算价格,价格在确认领用时计算)
  1536 + // 按 ProductId+StoreId 合并,避免同一产品+门店出现多条导致领用多算(修复:修改申请后变成两个单据、多算领用数量的问题)
  1537 + var mergedItems = input.UsageItems
  1538 + .GroupBy(x => new { x.ProductId, x.StoreId })
  1539 + .Select(g =>
  1540 + {
  1541 + var first = g.First();
  1542 + return new LqInventoryUsageItemInput
  1543 + {
  1544 + ProductId = g.Key.ProductId,
  1545 + StoreId = g.Key.StoreId,
  1546 + UsageQuantity = g.Sum(x => x.UsageQuantity),
  1547 + UsageTime = first.UsageTime,
  1548 + RelatedConsumeId = g.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.RelatedConsumeId))?.RelatedConsumeId ?? first.RelatedConsumeId
  1549 + };
  1550 + })
  1551 + .ToList();
  1552 +
1536 var entitiesToInsert = new List<LqInventoryUsageEntity>(); 1553 var entitiesToInsert = new List<LqInventoryUsageEntity>();
1537 - foreach (var item in input.UsageItems) 1554 + foreach (var item in mergedItems)
1538 { 1555 {
1539 var usageEntity = new LqInventoryUsageEntity 1556 var usageEntity = new LqInventoryUsageEntity
1540 { 1557 {
@@ -1570,7 +1587,7 @@ namespace NCC.Extend @@ -1570,7 +1587,7 @@ namespace NCC.Extend
1570 1587
1571 // 5. 检查关联数据是否需要更新 1588 // 5. 检查关联数据是否需要更新
1572 // 如果有关联消耗ID,检查消耗记录是否存在 1589 // 如果有关联消耗ID,检查消耗记录是否存在
1573 - var relatedConsumeIds = input.UsageItems 1590 + var relatedConsumeIds = mergedItems
1574 .Where(x => !string.IsNullOrWhiteSpace(x.RelatedConsumeId)) 1591 .Where(x => !string.IsNullOrWhiteSpace(x.RelatedConsumeId))
1575 .Select(x => x.RelatedConsumeId) 1592 .Select(x => x.RelatedConsumeId)
1576 .Distinct() 1593 .Distinct()
@@ -1602,6 +1619,107 @@ namespace NCC.Extend @@ -1602,6 +1619,107 @@ namespace NCC.Extend
1602 1619
1603 #endregion 1620 #endregion
1604 1621
  1622 + #region 多算数据自动修复
  1623 +
  1624 + /// <summary>
  1625 + /// 修复库存使用记录多算数据
  1626 + /// </summary>
  1627 + /// <remarks>
  1628 + /// 修复场景:修改申请时,同一批次下同一产品+同一门店出现多条有效记录,导致领用数量多算。
  1629 + /// 修复逻辑:按 UsageBatchId+ProductId+StoreId 合并重复记录,保留一条(取 CreateTime 最早),数量/金额累加,其余标为无效。
  1630 + ///
  1631 + /// 调用方式:POST /api/Extend/LqInventoryUsage/FixDuplicateUsageRecords
  1632 + /// 建议部署后调用一次修复历史数据。
  1633 + /// </remarks>
  1634 + /// <returns>修复结果(修复组数、作废条数等)</returns>
  1635 + [HttpPost("FixDuplicateUsageRecords")]
  1636 + public async Task<dynamic> FixDuplicateUsageRecordsAsync()
  1637 + {
  1638 + try
  1639 + {
  1640 + _logger.LogInformation("开始修复库存使用记录多算数据");
  1641 +
  1642 + // 1. 查询所有有效使用记录
  1643 + var allUsageRecords = await _db.Queryable<LqInventoryUsageEntity>()
  1644 + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode())
  1645 + .ToListAsync();
  1646 +
  1647 + if (allUsageRecords == null || !allUsageRecords.Any())
  1648 + {
  1649 + return new { success = true, message = "没有有效使用记录需要处理", duplicateGroups = 0, invalidatedCount = 0 };
  1650 + }
  1651 +
  1652 + // 2. 按 UsageBatchId + ProductId + StoreId 分组,找出有多条的组
  1653 + var duplicateGroups = allUsageRecords
  1654 + .GroupBy(x => new { x.UsageBatchId, x.ProductId, x.StoreId })
  1655 + .Where(g => g.Count() > 1)
  1656 + .ToList();
  1657 +
  1658 + if (!duplicateGroups.Any())
  1659 + {
  1660 + return new { success = true, message = "未发现重复数据", duplicateGroups = 0, invalidatedCount = 0 };
  1661 + }
  1662 +
  1663 + var invalidatedCount = 0;
  1664 + _db.Ado.BeginTran();
  1665 +
  1666 + try
  1667 + {
  1668 + foreach (var group in duplicateGroups)
  1669 + {
  1670 + var records = group.OrderBy(x => x.CreateTime).ToList();
  1671 + var keepRecord = records[0];
  1672 + var toInvalidate = records.Skip(1).ToList();
  1673 +
  1674 + // 更新保留记录:数量、金额累加
  1675 + keepRecord.UsageQuantity = records.Sum(x => x.UsageQuantity);
  1676 + keepRecord.TotalAmount = records.Sum(x => x.TotalAmount);
  1677 + keepRecord.UpdateUser = _userManager.UserId;
  1678 + keepRecord.UpdateTime = DateTime.Now;
  1679 + await _db.Updateable(keepRecord).ExecuteCommandAsync();
  1680 +
  1681 + // 将重复记录标为无效
  1682 + foreach (var record in toInvalidate)
  1683 + {
  1684 + record.IsEffective = StatusEnum.无效.GetHashCode();
  1685 + record.UpdateUser = _userManager.UserId;
  1686 + record.UpdateTime = DateTime.Now;
  1687 + }
  1688 + await _db.Updateable(toInvalidate).ExecuteCommandAsync();
  1689 + invalidatedCount += toInvalidate.Count;
  1690 +
  1691 + _logger.LogInformation("修复批次 {BatchId} 产品 {ProductId} 门店 {StoreId}:保留1条(数量{Qty}),作废{Count}条",
  1692 + keepRecord.UsageBatchId, keepRecord.ProductId, keepRecord.StoreId,
  1693 + keepRecord.UsageQuantity, toInvalidate.Count);
  1694 + }
  1695 +
  1696 + _db.Ado.CommitTran();
  1697 + _logger.LogInformation("修复完成:处理 {Groups} 组重复数据,作废 {Count} 条记录", duplicateGroups.Count, invalidatedCount);
  1698 +
  1699 + return new
  1700 + {
  1701 + success = true,
  1702 + message = $"修复完成,处理 {duplicateGroups.Count} 组重复数据,作废 {invalidatedCount} 条记录",
  1703 + duplicateGroups = duplicateGroups.Count,
  1704 + invalidatedCount = invalidatedCount
  1705 + };
  1706 + }
  1707 + catch
  1708 + {
  1709 + _db.Ado.RollbackTran();
  1710 + throw;
  1711 + }
  1712 + }
  1713 + catch (Exception ex)
  1714 + {
  1715 + _db.Ado.RollbackTran();
  1716 + _logger.LogError(ex, "修复库存使用记录多算数据失败");
  1717 + throw NCCException.Oh($"修复失败:{ex.Message}");
  1718 + }
  1719 + }
  1720 +
  1721 + #endregion
  1722 +
1605 #region 门店领取统计 1723 #region 门店领取统计
1606 1724
1607 /// <summary> 1725 /// <summary>
scripts/test-inventory-usage-fix.sh 0 → 100755
  1 +#!/bin/bash
  2 +# 库存使用记录修复与测试脚本
  3 +# 前置:API 需运行在 localhost:2011
  4 +
  5 +BASE_URL="${API_BASE_URL:-http://localhost:2011}"
  6 +
  7 +echo "=== 1. 获取 Token ==="
  8 +LOGIN_RESP=$(curl -s -X POST "${BASE_URL}/api/oauth/Login" \
  9 + -H "Content-Type: application/x-www-form-urlencoded" \
  10 + -d "account=admin&password=e10adc3949ba59abbe56e057f20f883e")
  11 +
  12 +if command -v jq &> /dev/null; then
  13 + TOKEN=$(echo "$LOGIN_RESP" | jq -r '.data.token // empty')
  14 +else
  15 + TOKEN=$(echo "$LOGIN_RESP" | grep -o '"token":"[^"]*"' | sed 's/"token":"\(.*\)"/\1/')
  16 +fi
  17 +
  18 +if [ -z "$TOKEN" ]; then
  19 + echo "登录失败,请检查 API 是否运行: $BASE_URL"
  20 + echo "响应: $LOGIN_RESP"
  21 + exit 1
  22 +fi
  23 +
  24 +echo "Token 获取成功"
  25 +
  26 +echo ""
  27 +echo "=== 2. 执行修复接口 FixDuplicateUsageRecords ==="
  28 +FIX_RESP=$(curl -s -X POST "${BASE_URL}/api/Extend/LqInventoryUsage/FixDuplicateUsageRecords" \
  29 + -H "Authorization: $TOKEN" \
  30 + -H "Content-Type: application/json")
  31 +
  32 +echo "$FIX_RESP"
  33 +
  34 +if echo "$FIX_RESP" | grep -q '"success":true'; then
  35 + echo "修复接口调用成功"
  36 +else
  37 + echo "修复接口可能失败,请检查响应"
  38 +fi
  39 +
  40 +echo ""
  41 +echo "=== 3. 验证说明 ==="
  42 +echo "请使用 MCP 或 MySQL 客户端执行以下 SQL 验证修复结果:"
  43 +echo ""
  44 +echo " -- 检查是否还有重复(应无结果)"
  45 +echo " SELECT F_UsageBatchId, F_ProductId, F_StoreId, COUNT(*) as cnt"
  46 +echo " FROM lq_inventory_usage WHERE F_IsEffective = 1"
  47 +echo " GROUP BY F_UsageBatchId, F_ProductId, F_StoreId HAVING COUNT(*) > 1;"
  48 +echo ""
  49 +echo " -- 检查作废记录数"
  50 +echo " SELECT F_IsEffective, COUNT(*) FROM lq_inventory_usage GROUP BY F_IsEffective;"