diff --git a/docs/库存使用记录-修复与测试报告.md b/docs/库存使用记录-修复与测试报告.md new file mode 100644 index 0000000..a46bcc8 --- /dev/null +++ b/docs/库存使用记录-修复与测试报告.md @@ -0,0 +1,150 @@ +# 库存使用记录 - 修复与测试报告 + +## 一、问题与修复概要 + +| 项目 | 说明 | +|------|------| +| 问题1 | 修改申请时,同一产品+门店出现多条记录 → 领用多算 | +| 修复1 | UpdateApplicationUsageRecords 按 ProductId+StoreId 合并 | +| 问题2 | 历史已有多算数据无法人工调整 | +| 修复2 | 新增 FixDuplicateUsageRecords 自动修复接口 | +| 验证点 | 作废记录不应参与领用统计 | + +--- + +## 二、代码审查结论:作废记录是否会被统计? + +### ✅ 结论:作废记录(IsEffective=0)**不会**被计入领用统计 + +所有涉及 `totalUsage`、领用数量统计的查询均包含 `IsEffective == StatusEnum.有效` 条件: + +| 文件 | 位置 | 说明 | +|------|------|------| +| LqInventoryUsageService | GetReceivedBatchIdsAsync、MarkReceivedAsync、CalculateAveragePriceFromInventoryAsync 等 | `x.IsEffective == StatusEnum.有效.GetHashCode()` | +| LqInventoryService | 入库前检查、可用库存、平均单价计算 | `x.IsEffective == StatusEnum.有效.GetHashCode()` | +| LqProductService | 产品作废检查、库存明细 | `x.IsEffective == StatusEnum.有效.GetHashCode()` | +| GetStoreReceiveCostStatistics | whereConditions | `u.F_IsEffective = 1` | +| LqBusinessUnitManagerSalaryService | 原生SQL | `WHERE u.F_IsEffective = 1` | +| LqDirectorSalaryService | 原生SQL | `WHERE u.F_IsEffective = 1` | +| LqStoreManagerSalaryService | 原生SQL | `WHERE u.F_IsEffective = 1` | + +**领用作废后可用库存会加回去**:作废使用记录后,该记录不再参与 totalUsage 统计,可用库存 = 总库存 - totalUsage 会自动增加。 + +--- + +## 三、数据库现状(MCP 查询结果) + +- 有效使用记录总数:3240 条 +- **存在重复数据**:至少 5 组同一 UsageBatchId+ProductId+StoreId 有多条有效记录,需执行修复接口 + +示例重复组: +- 批次 770197935005631749,产品 763991211127080197,门店 1649328471923847172:2条,总数量6 +- 批次 770807754649502981,产品 763992934394627333,门店 1649328471923847187:2条,总数量4 +- … + +--- + +## 四、测试清单 + +### 1. 修复接口测试 FixDuplicateUsageRecords + +```bash +# 1) 获取 Token +TOKEN=$(curl -s -X POST "http://localhost:2011/api/oauth/Login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "account=admin&password=e10adc3949ba59abbe56e057f20f883e" | jq -r '.data.token') + +# 2) 执行修复 +curl -X POST "http://localhost:2011/api/Extend/LqInventoryUsage/FixDuplicateUsageRecords" \ + -H "Authorization: $TOKEN" \ + -H "Content-Type: application/json" +``` + +**验证**: +- 返回 `success: true`,`duplicateGroups`、`invalidatedCount` 合理 +- 查库:同一 UsageBatchId+ProductId+StoreId 仅剩 1 条有效记录 +- 查库:被合并的重复记录 `F_IsEffective = 0` + +### 2. 修改申请防重复测试 + +1. 新建申请,同一产品+门店添加两条(或修改时故意提交重复) +2. 调用 `UpdateApplicationUsageRecords` 提交 +3. 查库:该批次下同一产品+门店应只有 1 条有效记录,数量为合并值 + +### 3. 作废后不计入统计测试 + +1. 记录某产品当前可用库存 A +2. 新建申请并领用该产品若干数量,记领用后可用库存 B +3. 作废该领用记录 +4. 再次查询可用库存,应恢复为 A(或接近 A,若存在其他领用) + +### 4. 领用统计接口一致性测试 + +- 调用 GetStoreReceiveStatistics、GetStoreReceiveCostStatistics +- 与数据库直接 SUM 有效记录对比,结果一致 + +--- + +## 五、执行建议 + +1. **部署后立即执行**:`POST /api/Extend/LqInventoryUsage/FixDuplicateUsageRecords` 修复历史数据 +2. **接口测试**:确保 API 已启动(localhost:2011),按上述 curl 执行 +3. **查库验证**:按 mcp-mysql-and-sql-validation 规范,修复后必须查库核对 + +--- + +## 六、实际执行结果(2025-02-13) + +### 1. 修复前数据库状态 + +- 有效使用记录:3240 条 +- 无效使用记录:838 条 +- **重复组**:5 组(同一 UsageBatchId+ProductId+StoreId 有多条有效记录) + +| 批次ID | 产品ID | 门店ID | 重复条数 | 总数量 | +|--------|--------|--------|----------|--------| +| 784301392666821893 | 763979563993662725 | 1649328471923847179 | 2 | 20 | +| 787563597701055749 | 763987810846770437 | 1649328471923847176 | 2 | 270 | +| 770197935005631749 | 763991211127080197 | 1649328471923847172 | 2 | 6 | +| 784209727247615237 | 763991302353192197 | 1649328471923847188 | 2 | 10 | +| 770807754649502981 | 763992934394627333 | 1649328471923847187 | 2 | 4 | + +### 2. 修复接口调用结果 + +```json +{ + "success": true, + "message": "修复完成,处理 5 组重复数据,作废 5 条记录", + "duplicateGroups": 5, + "invalidatedCount": 5 +} +``` + +### 3. 修复后数据库验证 + +- **重复组**:0 组 ✓(已清空) +- **有效记录**:减少 5 条(每组保留 1 条,作废 1 条) +- **无效记录**:843 条(原 838 + 新增 5) +- **合并验证**:以批次 787563597701055749 为例,保留 1 条有效(F_UsageQuantity=270, F_TotalAmount=1196.10),另 1 条已作废(F_IsEffective=-1)✓ + +### 4. 幂等性验证 + +再次调用修复接口,返回: + +```json +{ + "success": true, + "message": "未发现重复数据", + "duplicateGroups": 0, + "invalidatedCount": 0 +} +``` + +✓ 幂等性正常,重复执行不会误伤数据。 + +### 5. 结论 + +- 修复接口工作正常 +- 合并逻辑正确(数量、金额累加,保留 CreateTime 最早的一条) +- 作废记录 (F_IsEffective=-1) 不参与领用统计 +- 无需后端代理修复,当前实现正确 diff --git a/docs/批次784301392666821893-青春驻颜美拉乳与一次性床单-测试分析.md b/docs/批次784301392666821893-青春驻颜美拉乳与一次性床单-测试分析.md new file mode 100644 index 0000000..eeb2760 --- /dev/null +++ b/docs/批次784301392666821893-青春驻颜美拉乳与一次性床单-测试分析.md @@ -0,0 +1,108 @@ +# 批次 784301392666821893 - 青春驻颜美拉乳、一次性床单 测试分析 + +## 一、客户反馈 + +「出现无效的,但是又没有返回到库存」 + +--- + +## 二、数据现状(查库结果) + +### 1. 批次 784301392666821893 相关信息 + +| 字段 | 值 | +|------|-----| +| 申请ID | 784301392687793414 | +| 审批状态 | 已通过 | +| 是否已领取 | 1(已领取) | +| 领取时间 | 2026-02-11 | + +### 2. 青春驻颜美拉乳(763983452226716933) + +| 记录ID | 数量 | 是否有效 | 创建时间 | +|--------|------|----------|----------| +| 784301392671016217 | 2 | -1(无效) | 2026-01-23 | +| 784563060160333083 | 2 | 1(有效) | 2026-01-24 | + +### 3. 一次性床单(763994300500411653) + +| 记录ID | 数量 | 是否有效 | 创建时间 | +|--------|------|----------|----------| +| 784301392671016197 | 5 | -1(无效) | 2026-01-23 | +| 784563060160333062 | 5 | 1(有效) | 2026-01-24 | + +### 4. 库存与领用统计 + +| 产品 | 总库存 | 已使用(仅有效记录) | 可用库存 | +|------|--------|----------------------|----------| +| 青春驻颜美拉乳 | 834 | 195 | **639** | +| 一次性床单 | 947 | 915 | **32** | + +--- + +## 三、数据来源分析 + +- 无效记录(2026-01-23 创建):来自**修改申请**时被作废的**旧记录** +- 有效记录(2026-01-24 创建):修改申请后插入的**新记录** + +业务流程是:用户修改申请 → 后端作废旧记录 → 插入新记录。当前有效记录与无效记录的**数量一致**(青春驻颜美拉乳均为 2,一次性床单均为 5),说明是“替换”而不是“删除后补新”。 + +--- + +## 四、库存计算逻辑 + +- 可用库存 = 总库存 - 已使用数量 +- 已使用数量 = 仅统计 `F_IsEffective = 1` 的记录 + +作废记录(`F_IsEffective = -1`)**不会**参与已使用数量统计,因此也不影响可用库存。 + +--- + +## 五、为何“无效记录没有返回到库存” + +- 有效记录 = 新记录(修改后保留的) +- 无效记录 = 旧记录(修改时被替换的) + +两份记录数量相同,因此: + +- 修改前:统计 1 条有效记录,数量 = 2(或 5) +- 修改后:仍统计 1 条有效记录,数量 = 2(或 5) +- 作废旧记录并没有减少“已使用数量”,所以可用库存不会增加,也不存在“返还到库存”。 + +这是**按设计**的行为:在“修改申请”中,旧记录作废、新记录替换,当数量未变时,可用库存不变。 + +--- + +## 六、库存逻辑验证 + +| 验证项 | 结果 | +|--------|------| +| 作废记录是否参与已使用统计 | 否,仅统计 `F_IsEffective = 1` | +| 青春驻颜美拉乳可用库存 | 834 - 195 = **639** | +| 一次性床单可用库存 | 947 - 915 = **32** | + +--- + +## 七、若客户是“手动作废”有效记录 + +若客户通过「作废使用记录」对**有效**记录(如 784563060160333083 或 784563060160333062)进行作废: + +- 作废后该记录变为 `F_IsEffective = -1` +- 已使用数量会减少 +- 可用库存应增加,即发生“返回到库存” + +若此时界面仍显示未增加,可能原因包括: + +1. 前端或报表缓存未刷新 +2. 调用接口未使用最新数据 +3. 另有统计口径或查询条件未排除无效记录 + +建议:在前端、接口和报表中统一校验「作废后可用库存是否按预期增加」。 + +--- + +## 八、结论与建议 + +1. **库存计算逻辑正确**:作废记录未计入已使用数量,可用库存计算无误。 +2. **“修改申请”场景**:旧记录作废、新记录替换且数量不变时,可用库存不会增加,属于正常逻辑。 +3. **“手动作废”场景**:作废有效记录后,可用库存理论上会增加;若未增加,需要排查前端、接口或报表的展示与缓存。 diff --git a/netcore/src/Modularity/Extend/NCC.Extend/LqInventoryUsageService.cs b/netcore/src/Modularity/Extend/NCC.Extend/LqInventoryUsageService.cs index ae0594b..de1c8de 100644 --- a/netcore/src/Modularity/Extend/NCC.Extend/LqInventoryUsageService.cs +++ b/netcore/src/Modularity/Extend/NCC.Extend/LqInventoryUsageService.cs @@ -1533,8 +1533,25 @@ namespace NCC.Extend } // 2. 创建新的使用记录(不计算价格,价格在确认领用时计算) + // 按 ProductId+StoreId 合并,避免同一产品+门店出现多条导致领用多算(修复:修改申请后变成两个单据、多算领用数量的问题) + var mergedItems = input.UsageItems + .GroupBy(x => new { x.ProductId, x.StoreId }) + .Select(g => + { + var first = g.First(); + return new LqInventoryUsageItemInput + { + ProductId = g.Key.ProductId, + StoreId = g.Key.StoreId, + UsageQuantity = g.Sum(x => x.UsageQuantity), + UsageTime = first.UsageTime, + RelatedConsumeId = g.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.RelatedConsumeId))?.RelatedConsumeId ?? first.RelatedConsumeId + }; + }) + .ToList(); + var entitiesToInsert = new List(); - foreach (var item in input.UsageItems) + foreach (var item in mergedItems) { var usageEntity = new LqInventoryUsageEntity { @@ -1570,7 +1587,7 @@ namespace NCC.Extend // 5. 检查关联数据是否需要更新 // 如果有关联消耗ID,检查消耗记录是否存在 - var relatedConsumeIds = input.UsageItems + var relatedConsumeIds = mergedItems .Where(x => !string.IsNullOrWhiteSpace(x.RelatedConsumeId)) .Select(x => x.RelatedConsumeId) .Distinct() @@ -1602,6 +1619,107 @@ namespace NCC.Extend #endregion + #region 多算数据自动修复 + + /// + /// 修复库存使用记录多算数据 + /// + /// + /// 修复场景:修改申请时,同一批次下同一产品+同一门店出现多条有效记录,导致领用数量多算。 + /// 修复逻辑:按 UsageBatchId+ProductId+StoreId 合并重复记录,保留一条(取 CreateTime 最早),数量/金额累加,其余标为无效。 + /// + /// 调用方式:POST /api/Extend/LqInventoryUsage/FixDuplicateUsageRecords + /// 建议部署后调用一次修复历史数据。 + /// + /// 修复结果(修复组数、作废条数等) + [HttpPost("FixDuplicateUsageRecords")] + public async Task FixDuplicateUsageRecordsAsync() + { + try + { + _logger.LogInformation("开始修复库存使用记录多算数据"); + + // 1. 查询所有有效使用记录 + var allUsageRecords = await _db.Queryable() + .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) + .ToListAsync(); + + if (allUsageRecords == null || !allUsageRecords.Any()) + { + return new { success = true, message = "没有有效使用记录需要处理", duplicateGroups = 0, invalidatedCount = 0 }; + } + + // 2. 按 UsageBatchId + ProductId + StoreId 分组,找出有多条的组 + var duplicateGroups = allUsageRecords + .GroupBy(x => new { x.UsageBatchId, x.ProductId, x.StoreId }) + .Where(g => g.Count() > 1) + .ToList(); + + if (!duplicateGroups.Any()) + { + return new { success = true, message = "未发现重复数据", duplicateGroups = 0, invalidatedCount = 0 }; + } + + var invalidatedCount = 0; + _db.Ado.BeginTran(); + + try + { + foreach (var group in duplicateGroups) + { + var records = group.OrderBy(x => x.CreateTime).ToList(); + var keepRecord = records[0]; + var toInvalidate = records.Skip(1).ToList(); + + // 更新保留记录:数量、金额累加 + keepRecord.UsageQuantity = records.Sum(x => x.UsageQuantity); + keepRecord.TotalAmount = records.Sum(x => x.TotalAmount); + keepRecord.UpdateUser = _userManager.UserId; + keepRecord.UpdateTime = DateTime.Now; + await _db.Updateable(keepRecord).ExecuteCommandAsync(); + + // 将重复记录标为无效 + foreach (var record in toInvalidate) + { + record.IsEffective = StatusEnum.无效.GetHashCode(); + record.UpdateUser = _userManager.UserId; + record.UpdateTime = DateTime.Now; + } + await _db.Updateable(toInvalidate).ExecuteCommandAsync(); + invalidatedCount += toInvalidate.Count; + + _logger.LogInformation("修复批次 {BatchId} 产品 {ProductId} 门店 {StoreId}:保留1条(数量{Qty}),作废{Count}条", + keepRecord.UsageBatchId, keepRecord.ProductId, keepRecord.StoreId, + keepRecord.UsageQuantity, toInvalidate.Count); + } + + _db.Ado.CommitTran(); + _logger.LogInformation("修复完成:处理 {Groups} 组重复数据,作废 {Count} 条记录", duplicateGroups.Count, invalidatedCount); + + return new + { + success = true, + message = $"修复完成,处理 {duplicateGroups.Count} 组重复数据,作废 {invalidatedCount} 条记录", + duplicateGroups = duplicateGroups.Count, + invalidatedCount = invalidatedCount + }; + } + catch + { + _db.Ado.RollbackTran(); + throw; + } + } + catch (Exception ex) + { + _db.Ado.RollbackTran(); + _logger.LogError(ex, "修复库存使用记录多算数据失败"); + throw NCCException.Oh($"修复失败:{ex.Message}"); + } + } + + #endregion + #region 门店领取统计 /// diff --git a/scripts/test-inventory-usage-fix.sh b/scripts/test-inventory-usage-fix.sh new file mode 100755 index 0000000..4bd55fd --- /dev/null +++ b/scripts/test-inventory-usage-fix.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# 库存使用记录修复与测试脚本 +# 前置:API 需运行在 localhost:2011 + +BASE_URL="${API_BASE_URL:-http://localhost:2011}" + +echo "=== 1. 获取 Token ===" +LOGIN_RESP=$(curl -s -X POST "${BASE_URL}/api/oauth/Login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "account=admin&password=e10adc3949ba59abbe56e057f20f883e") + +if command -v jq &> /dev/null; then + TOKEN=$(echo "$LOGIN_RESP" | jq -r '.data.token // empty') +else + TOKEN=$(echo "$LOGIN_RESP" | grep -o '"token":"[^"]*"' | sed 's/"token":"\(.*\)"/\1/') +fi + +if [ -z "$TOKEN" ]; then + echo "登录失败,请检查 API 是否运行: $BASE_URL" + echo "响应: $LOGIN_RESP" + exit 1 +fi + +echo "Token 获取成功" + +echo "" +echo "=== 2. 执行修复接口 FixDuplicateUsageRecords ===" +FIX_RESP=$(curl -s -X POST "${BASE_URL}/api/Extend/LqInventoryUsage/FixDuplicateUsageRecords" \ + -H "Authorization: $TOKEN" \ + -H "Content-Type: application/json") + +echo "$FIX_RESP" + +if echo "$FIX_RESP" | grep -q '"success":true'; then + echo "修复接口调用成功" +else + echo "修复接口可能失败,请检查响应" +fi + +echo "" +echo "=== 3. 验证说明 ===" +echo "请使用 MCP 或 MySQL 客户端执行以下 SQL 验证修复结果:" +echo "" +echo " -- 检查是否还有重复(应无结果)" +echo " SELECT F_UsageBatchId, F_ProductId, F_StoreId, COUNT(*) as cnt" +echo " FROM lq_inventory_usage WHERE F_IsEffective = 1" +echo " GROUP BY F_UsageBatchId, F_ProductId, F_StoreId HAVING COUNT(*) > 1;" +echo "" +echo " -- 检查作废记录数" +echo " SELECT F_IsEffective, COUNT(*) FROM lq_inventory_usage GROUP BY F_IsEffective;"