Merge pull request #31: 修正 MKH 已入库库存与加工在途

保留任务提交历史;验证 346 项后端测试通过。
This commit is contained in:
lunanting 2026-09-24 09:20:30 +08:00
commit 52b74aafe9
2 changed files with 53 additions and 33 deletions

View File

@ -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),

View File

@ -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', () => {