Merge branch 'main' into codex/daily-inventory-snapshot-0924(同步 PR #31)
This commit is contained in:
commit
6c753791ad
@ -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),
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user