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 提交补表需求,因此「期初+流水」与「期末」暂不相等。