diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 401e60e..c5fdbb0 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -191,13 +191,13 @@ // → 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。 // ⚠️ 「同步最新数据」按钮只清服务端缓存(clearMkhBomCache),**不会触发上游同步**, // 所以点上多少次都还是 0 —— 数据源当天就没有这条库存。 -// 兜底规则(**只补 0,不覆盖有效值**): -// ① inventoryInfo 品牌仓 qty > 0 → 用它(stockSource='realtime',与旧口径完全一致) -// ② 否则箱表品牌仓 `bin` 非空行 qty 合计 > 0 → 用它(stockSource='pack') -// ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0(stockSource='realtime') -// ④ 否则回退监控快照 jsStock → (stockSource='monitor') -// ⚠️ 为什么 inv 优先、pack 只补 0:两表都 > 0 且不等的 28 个成品里 **25 个是 inv 更大** -// (箱表只记装箱的货,散货不计)→ 若拿 pack 当主源会系统性低估库存、误报缺货。 +// 兜底规则(2026-09-24 修正): +// ① inventoryInfo 与箱表品牌仓 `bin` 非空行都有值 → 取两者较大值 +// ② 只有其中一个当前库存源有值 → 用该源 +// ③ 两个当前库存源都无记录 → 回退监控快照 jsStock +// 取较大值的原因:inventoryInfo 包含散货,通常比箱表大;但新入库会先进入小时级 +// 箱表、inventoryInfo 仍保留旧正数。若一律让 inventoryInfo 优先,就会把新入库库存压住; +// 取 max 既不会丢散货,也能在日更快照滞后时立即看到箱表新货。 // ⚠️ 必须过滤 `bin` 为空的行:那是**装箱记录不是库存**(品牌仓 bin 空行合计 21 件), // 不过滤会虚增。 // 影响面(2026-09-19 实测):品牌仓 4,408 个成品里,走 ② 兜底的只有 **3 个** @@ -220,8 +220,9 @@ // (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表) // · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓' // AND 状态 <> '作废' ← 作废单据一律不核算 -// AND 收货状态 <> '全部入库' ← 未完成(全部入库 ⟺ 状态=完成) -// · 在途加工量 = Σ(数量 − 已入库数量),按 商品编码 归集 +// AND max(数量 − 已入库数量, 0) > 0 ← 只认真实剩余量 +// · 在途加工量 = Σ max(数量 − 已入库数量, 0),按 商品编码 归集。 +// 不再依赖「收货状态」文本:上游状态字段延迟时,已入库数量已追平的单据必须立即退出在途。 // ⚠️ 为什么走 getPool 而不是 cloudDatabase.query: // query() 会经 rewriteReportReplicaSql() 把 `dbo.xxx` 重写成 `rpt.xxx`, // 跨库四段名 `LS_JS_DMP.JSDataMiddlePlatform.dbo.t` 会被改坏成 @@ -1084,12 +1085,11 @@ function buildReplenishMetrics(row = {}) { const hasRealtime = realtimeStock !== null; let stock; let stockSource; - if (hasRealtime && realtimeStock > 0) { - stock = realtimeStock; - stockSource = 'realtime'; - } else if (packStock !== null && packStock > 0) { - stock = packStock; - stockSource = 'pack'; + if ((hasRealtime && realtimeStock > 0) || (packStock !== null && packStock > 0)) { + const realtimeValue = hasRealtime && realtimeStock > 0 ? realtimeStock : 0; + const packValue = packStock !== null && packStock > 0 ? packStock : 0; + stock = Math.max(realtimeValue, packValue); + stockSource = packValue > realtimeValue ? 'pack' : 'realtime'; } else if (hasRealtime) { stock = realtimeStock; stockSource = 'realtime'; @@ -1105,7 +1105,7 @@ function buildReplenishMetrics(row = {}) { // 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量 const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0); const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0); - const hasPendingWorkOrder = pendingWorkOrderQty > 0 || pendingWorkOrderCount > 0; + const hasPendingWorkOrder = pendingWorkOrderQty > 0; // 待发货订单(见文件头 ⑩):当前未发货订单里的件数 const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0); @@ -1353,19 +1353,34 @@ async function loadPendingWorkOrderIndex() { const request = pool.request(); request.input('workOrderWarehouse', WORK_ORDER_WAREHOUSE); request.input('voidStatus', WORK_ORDER_VOID_STATUS); - request.input('receivedStatus', WORK_ORDER_RECEIVED_STATUS); - const result = await request.query(` SELECT [商品编码] AS skuId, - SUM(CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))) AS pendingQty, - COUNT(DISTINCT [加工单号]) AS orderCount, - MIN(TRY_CONVERT(DATETIME, [预计加工完成时间], 111)) AS eta, - MIN(CAST([加工单号] AS NVARCHAR(40))) AS firstOrderNo + SUM(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4)) + ELSE 0 + END) AS pendingQty, + COUNT(DISTINCT CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN [加工单号] + END) AS orderCount, + MIN(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN TRY_CONVERT(DATETIME, [预计加工完成时间], 111) + END) AS eta, + MIN(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN CAST([加工单号] AS NVARCHAR(40)) + END) AS firstOrderNo FROM ${WORK_ORDER_TABLE} WITH (NOLOCK) WHERE [加工仓储方] = @workOrderWarehouse AND [状态] <> @voidStatus - AND [收货状态] <> @receivedStatus - GROUP BY [商品编码]`); + GROUP BY [商品编码] + HAVING SUM(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4)) + ELSE 0 + END) > 0`); const bySku = new Map(); (result.recordset || []).forEach((row) => { @@ -1373,7 +1388,7 @@ async function loadPendingWorkOrderIndex() { if (!key) return; const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0); const orderCount = Math.max(0, toNumber(row.orderCount) ?? 0); - if (pendingQty <= 0 && orderCount <= 0) return; + if (pendingQty <= 0) return; bySku.set(key, { // 原始商品编码:配件占用展开(见文件头 ⑪)需要拿它回查 BOM,故一并保留 skuId: toText(row.skuId), diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 93cdded..460ffd1 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -250,7 +250,7 @@ test('replenish: live refresh ignores delayed sales and stock snapshots', () => assert.equal(live.stockSource, 'live-missing'); }); -test('replenish: box/pallet stock fills in when the daily inventory table is stale (⑦-d)', () => { +test('replenish: the larger current-stock source wins when the daily inventory table is stale (⑦-d)', () => { // 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货 const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 }); assert.equal(fillFromZero.stock, 4); @@ -261,9 +261,14 @@ test('replenish: box/pallet stock fills in when the daily inventory table is sta assert.equal(fillFromMissing.stock, 5); assert.equal(fillFromMissing.stockSource, 'pack'); - // 库存表有货时箱表不参与 —— 箱表只记装箱的货,拿它当主源会低估库存 - const realtimeWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 100, monitorStock: 0 }); - assert.equal(realtimeWins.stock, 4); + // 新入库先到箱表:库存快照仍为旧的正数 4 时,不能把箱表的 47 压住 + const packWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 47, monitorStock: 0 }); + assert.equal(packWins.stock, 47); + assert.equal(packWins.stockSource, 'pack'); + + // 散货只在 inventoryInfo 中:该表较大时仍保留完整库存 + const realtimeWins = metrics({ avgSale: 1, realtimeStock: 100, packStock: 47, monitorStock: 0 }); + assert.equal(realtimeWins.stock, 100); assert.equal(realtimeWins.stockSource, 'realtime'); // 两个实时源都没货 → 库存表说 0 就认 0,不再退回更旧的快照 @@ -363,16 +368,16 @@ test('replenish: pending work order fields default to empty without any order', assert.equal(none.pendingWorkOrderEta, null); assert.equal(none.pendingWorkOrderNo, null); - // 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理 + // 已入库数量追平后,即使上游状态文本或单据计数延迟,也不再按「已排产」处理 const zeroQty = metrics({ avgSale: 1, realtimeStock: 0, pendingWorkOrderQty: 0, pendingWorkOrderCount: 2, }); - assert.equal(zeroQty.hasPendingWorkOrder, true); - assert.equal(zeroQty.warningLevel, '已排产'); - assert.equal(zeroQty.hasWarning, false); + assert.equal(zeroQty.hasPendingWorkOrder, false); + assert.equal(zeroQty.warningLevel, '缺货'); + assert.equal(zeroQty.hasWarning, true); }); test('replenish: products without sales stay "no sale" even with a pending work order', () => {