From c47a25b0025eec38c892ac23b3e4fea5b4f3399f Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Sun, 27 Sep 2026 10:09:19 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=9C=9F=E5=88=9D=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E5=BC=83=E7=94=A8=20pm=5FstockMonitor=EF=BC=8C=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E6=94=B9=E7=94=A8=E5=AE=9E=E6=97=B6=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E6=89=A3=E6=B5=81=E6=B0=B4=E5=9B=9E=E6=8E=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 口径校准(对照聚水潭进销存导出 09-23 期初基准): 崔桥工厂仓 基准 92388 / pm 路径 114286 / 回推 ≈89650 横林二仓 基准 63780 / pm 路径 1197 / 回推 ≈64029 山东青岗集 基准 88377 / pm 路径 67339 / 回推 ≈88210 河南洛宁 基准 83305 / pm 路径 76992 / 回推 ≈82473 pm_stockMonitor.jsStock 只覆盖纳入监控范围的 SKU 子集(横林二仓实测 1197 vs 全仓 63780),系统性偏低,不能充当全仓结存;「实时库存扣 流水回推」与基准偏差 0.3%~2.7%(来自缺失的 8 类单据),显著更准。 --- backend/src/services/dailyInventoryOpening.js | 88 ++++++------------- backend/test/dailyInventoryFlows.test.js | 14 ++- frontend/src/constants/dailyInventory.js | 2 +- frontend/src/pages/DailyInventory/index.jsx | 2 +- 4 files changed, 32 insertions(+), 74 deletions(-) diff --git a/backend/src/services/dailyInventoryOpening.js b/backend/src/services/dailyInventoryOpening.js index 92da2ae..a6a1a9f 100644 --- a/backend/src/services/dailyInventoryOpening.js +++ b/backend/src/services/dailyInventoryOpening.js @@ -4,19 +4,20 @@ * 每日进销存 —— 期初快照缺失时的兜底推导 * * 正常情况下「期初 = 上一业务日快照」(每日 01:30 自动落库)。 - * 若快照缺失(新纳入仓库、服务停机等),按两条路推导出「上一业务日期末结存」并补写快照: + * 若快照缺失(服务停机、新纳入仓库等),用「实时库存扣流水回推」推导 + * 「上一业务日期末结存」并补写快照: * - * 路径 A(精确):rpt.pm_stockMonitor(报表库每日 01:00 跑批,T-1 口径) - * 该表 date = 最新业务日 D 的行,就是「D-1 期末」的库存监控值, - * 按 warehouse(= 监控表仓库名 dbWarehouse)聚合 jsStock 即可。 - * 覆盖基线中与监控表仓库名一致的仓库(实测 14/26, - * 运营专用仓 / 京东仓等虚拟仓不在监控表中)。 + * closing(D-1) = 当前实时库存 − 自 D 01:00 以来的净流入 + * 净流入 = 采购进仓 + 销售退货 − 销售出仓 − 采购退货(带符号流水求和,见 dailyInventoryFlows) * - * 路径 B(推导):实时库存扣流水回推 - * closing(D-1) = 实时库存 − 自 D 01:00 以来的净流入 - * 净流入 = 采购进仓 + 销售退货 − 销售出仓 − 采购退货(带符号流水求和) - * 仅覆盖路径 A 匹配不到的仓库。因业务库缺 8 类单据表, - * 若该仓有未记录的调拨/盘点等流水,推导值会偏离真实历史,属近似值。 + * 口径校准(2026-09-27,对照聚水潭进销存导出 09-23 期初基准): + * 崔桥工厂仓 基准 92388 / 回推 ≈89650;横林二仓 基准 63780 / 回推 ≈64029; + * 山东青岗集 基准 88377 / 回推 ≈88210;河南洛宁 基准 83305 / 回推 ≈82473。 + * 偏差 0.3%~2.7%,来源为业务库缺失的 8 类单据(调拨/盘点/加工/其它出入库)。 + * + * 注意:不要用 rpt.pm_stockMonitor 聚合 jsStock 充当全仓结存 —— + * 该表只覆盖纳入监控范围的 SKU 子集(横林二仓实测 1197 vs 全仓 63780), + * 系统性偏低,已在校准后弃用。 */ const { fetchFlows } = require('./dailyInventoryFlows'); @@ -28,45 +29,18 @@ const { fetchFlows } = require('./dailyInventoryFlows'); * @param {Object} opts.snapshotDb 本地快照表访问模块 * @param {Array} opts.baseline 仓库基线(warehouse/dbWarehouse/wmsCoId) * @param {string} opts.openingDate 期初业务日(YYYY-MM-DD,即要补快照的日期) - * @param {string} opts.latestBizDate 最新业务日(YYYY-MM-DD,pm_stockMonitor 的 date) + * @param {string} opts.latestBizDate 最新业务日(YYYY-MM-DD,流水起点 = 其当日 01:00) * @param {Date|null} opts.takenAt 实时库存数据时间 - * @returns {Promise<{count:number, pmCount:number, derivedCount:number}>} + * @returns {Promise<{count:number, derivedCount:number}>} */ async function deriveAndStoreOpeningSnapshot(opts) { const { cloudDb, snapshotDb, baseline, openingDate, latestBizDate, takenAt } = opts; - if (!openingDate || !latestBizDate) return { count: 0, pmCount: 0, derivedCount: 0 }; + if (!openingDate || !latestBizDate) return { count: 0, derivedCount: 0 }; const whs = baseline.filter((b) => b.wmsCoId != null); const wmsCoIds = whs.map((b) => b.wmsCoId); const flowFrom = `${latestBizDate} 01:00:00`; - // 路径 A:pm_stockMonitor 按仓库名聚合 - const dbNames = whs.map((b) => b.dbWarehouse).filter(Boolean); - let pmMap = new Map(); - if (dbNames.length) { - const params = {}; - const inCls = dbNames.map((n, i) => { - params[`n${i}`] = n; - return `@n${i}`; - }).join(','); - try { - const rows = await cloudDb.query( - `SELECT warehouse, - SUM(CAST(ISNULL(jsStock, 0) AS DECIMAL(18, 2))) AS qty, - SUM(CASE WHEN ISNULL(jsStock, 0) > 0.0001 THEN 1 ELSE 0 END) AS skuCount - FROM rpt.pm_stockMonitor - WHERE CONVERT(VARCHAR(10), date, 23) = @bizDate - AND warehouse IN (${inCls}) - GROUP BY warehouse`, - { ...params, bizDate: latestBizDate }, - { requestTimeout: 120000 } - ); - pmMap = new Map(rows.map((r) => [r.warehouse, r])); - } catch (error) { - console.error('[daily-inventory-opening] pm_stockMonitor 聚合失败:', error.message); - } - } - - // 路径 B:实时库存 + 流水回推 + // 实时库存(与每日快照任务同口径:jst_inventoryInfo 按仓聚合) let realtimeMap = new Map(); try { const rows = await cloudDb.query( @@ -84,6 +58,7 @@ async function deriveAndStoreOpeningSnapshot(opts) { console.error('[daily-inventory-opening] 实时库存聚合失败:', error.message); } + // 自期初时刻以来的带符号流水(流出为负) let flowMap = new Map(); try { flowMap = await fetchFlows(cloudDb, wmsCoIds, flowFrom); @@ -91,39 +66,26 @@ async function deriveAndStoreOpeningSnapshot(opts) { console.error('[daily-inventory-opening] 流水取数失败:', error.message); } - let pmCount = 0; let derivedCount = 0; const payload = whs.map((b) => { - const pm = pmMap.get(b.dbWarehouse); - let closingQty; - let skuCount; - if (pm) { - // 路径 A:报表库 01:00 跑批的 T-1 库存监控值(精确) - closingQty = Number(pm.qty) || 0; - skuCount = Number(pm.skuCount) || 0; - pmCount += 1; - } else { - // 路径 B:实时库存 − 净流入(带符号流水求和,流出为负) - const rt = realtimeMap.get(String(b.wmsCoId)); - const f = flowMap.get(String(b.wmsCoId)) || {}; - const netInflow = (f.purchaseIn || 0) + (f.salesReturn || 0) + (f.salesOut || 0) + (f.purchaseReturn || 0); - closingQty = Number(rt?.qty ?? 0) - netInflow; - skuCount = Number(rt?.skuCount ?? 0); - derivedCount += 1; - } + const rt = realtimeMap.get(String(b.wmsCoId)); + const f = flowMap.get(String(b.wmsCoId)) || {}; + const netInflow = (f.purchaseIn || 0) + (f.salesReturn || 0) + (f.salesOut || 0) + (f.purchaseReturn || 0); + const closingQty = Number(rt?.qty ?? 0) - netInflow; + derivedCount += 1; return { wmsCoId: b.wmsCoId, warehouse: b.warehouse, dbWarehouse: b.dbWarehouse, closingQty: Number(closingQty.toFixed(2)), - skuCount, + skuCount: Number(rt?.skuCount ?? 0), takenAt: takenAt ? new Date(takenAt) : null, }; }); const count = await snapshotDb.upsertSnapshot(openingDate, payload); - console.log(`[daily-inventory-opening] 已补写 ${openingDate} 快照 ${count}/${whs.length} 仓(pm_stockMonitor 精确 ${pmCount},流水回推 ${derivedCount})`); - return { count, pmCount, derivedCount }; + console.log(`[daily-inventory-opening] 已补写 ${openingDate} 快照 ${count}/${whs.length} 仓(实时库存扣流水回推)`); + return { count, derivedCount }; } module.exports = { deriveAndStoreOpeningSnapshot }; diff --git a/backend/test/dailyInventoryFlows.test.js b/backend/test/dailyInventoryFlows.test.js index 23e1d66..3d8e81a 100644 --- a/backend/test/dailyInventoryFlows.test.js +++ b/backend/test/dailyInventoryFlows.test.js @@ -53,13 +53,9 @@ test('fetchFlows 空仓库列表直接返回空 Map', async () => { assert.equal(map.size, 0); }); -test('deriveAndStoreOpeningSnapshot:监控表命中走精确值,未命中用实时库存扣流水回推', async () => { +test('deriveAndStoreOpeningSnapshot:全部仓库用实时库存扣流水回推,并保留基线仓库名', async () => { const upserts = []; const cloudDb = fakeCloudDb((sql, params) => { - if (sql.includes('pm_stockMonitor')) { - // 只有仓A在监控表(精确路径) - return [{ warehouse: '仓A', qty: '500', skuCount: '12' }]; - } if (sql.includes('jst_inventoryInfo')) { return [ { wmsCoId: 101, qty: '520', skuCount: '13' }, @@ -90,20 +86,20 @@ test('deriveAndStoreOpeningSnapshot:监控表命中走精确值,未命中用 }); assert.equal(result.count, 3); - assert.equal(result.pmCount, 1, '仅仓A走 pm_stockMonitor 精确路径'); - assert.equal(result.derivedCount, 2, '仓B/仓C走流水回推路径'); + assert.equal(result.derivedCount, 3); assert.equal(upserts.length, 1); assert.equal(upserts[0].bizDate, '2026-09-25'); const byWh = new Map(upserts[0].rows.map((r) => [r.wmsCoId, r])); - // 仓A:精确值 500,不受流水影响 - assert.equal(byWh.get(101).closingQty, 500); + // 仓A:无流水 → 等于实时库存 520 + assert.equal(byWh.get(101).closingQty, 520); // 仓B:300 − 净流入(60 − 10 − 40) = 300 − 10 = 290 assert.equal(byWh.get(102).closingQty, 290); // 仓C:无流水 → 等于实时库存 10 assert.equal(byWh.get(103).closingQty, 10); // 带回基线仓库名,保证后续按 wmsCoId 读取正确 assert.equal(byWh.get(102).warehouse, '仓B主仓'); + assert.equal(byWh.get(102).dbWarehouse, '仓B'); }); test('fetchFlows 缺少数据库实例参数时立即抛错(防静默空结果)', async () => { diff --git a/frontend/src/constants/dailyInventory.js b/frontend/src/constants/dailyInventory.js index 4d1d14b..33f64ee 100644 --- a/frontend/src/constants/dailyInventory.js +++ b/frontend/src/constants/dailyInventory.js @@ -20,7 +20,7 @@ const FLOW_CALIBER_NOTE = '口径说明:与聚水潭进销存导出可能存 /** 表格存在两个名为「期初」的列,第二个加后缀区分 */ export const DAILY_INVENTORY_COLUMNS = [ { key: 'warehouse', label: '仓库', source: 'snapshot', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' }, - { key: 'openingQty', label: '期初数量', source: 'snapshot', tip: '上一业务日的期末结存,取自本地快照表 daily_inventory_snapshot(每日 01:30 自动落库);快照缺失时自动用 pm_stockMonitor(精确)或实时库存扣流水(近似)推导补写' }, + { key: 'openingQty', label: '期初数量', source: 'snapshot', tip: '上一业务日的期末结存,取自本地快照表 daily_inventory_snapshot(每日 01:30 自动落库);快照缺失时自动用「实时库存扣流水」回推补写(对照聚水潭导出基准偏差 0.3%~2.7%,来自业务库缺失的 8 类单据)' }, { key: 'processIn', label: '加工进仓', source: 'unavailable', tip: `加工进仓。${NO_SOURCE_TIP}` }, { key: 'purchaseReturn', label: '采购退货', source: 'runtime', tip: `采购退货(流出,负数)。rpt.jst_purchaseout × jst_purchaseoutItem,按 io_date 统计。${FLOW_CALIBER_NOTE}` }, { key: 'purchaseIn', label: '采购进仓', source: 'runtime', tip: `采购进仓(流入)。rpt.jst_purchase × jst_purchaseItem,按 po_date 统计,仅计 Confirmed/Finished 状态。${FLOW_CALIBER_NOTE}` }, diff --git a/frontend/src/pages/DailyInventory/index.jsx b/frontend/src/pages/DailyInventory/index.jsx index d2fb7da..7dbe5c2 100644 --- a/frontend/src/pages/DailyInventory/index.jsx +++ b/frontend/src/pages/DailyInventory/index.jsx @@ -287,7 +287,7 @@ const DailyInventory = () => { message="全部数据来自数据库,流水列已接入 4/12" description={ - 期初数量取本地快照表中 {data?.openingDate || '—'} 的期末结存(每日 01:30 自动落库,逐日滚动;快照缺失时自动用库存监控表/实时库存扣流水推导补写); + 期初数量取本地快照表中 {data?.openingDate || '—'} 的期末结存(每日 01:30 自动落库,逐日滚动;快照缺失时自动用「实时库存扣流水」回推补写); 期末数量与右侧 5 列为运行时从业务库实时取数。 流水列中采购进仓/采购退货/销售出仓/销售退货已从业务库单据表取数; 其余 8 列(加工/其它/盘点/调拨)因业务库缺单据表仍显示「—」,已向 DBA 提交补表需求,因此「期初+流水」与「期末」暂不相等。