fix: 期初兜底弃用 pm_stockMonitor,全部改用实时库存扣流水回推

口径校准(对照聚水潭进销存导出 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 类单据),显著更准。
This commit is contained in:
youruo 2026-09-27 10:09:19 +08:00
parent 952c57a84a
commit c47a25b002
4 changed files with 32 additions and 74 deletions

View File

@ -4,19 +4,20 @@
* 每日进销存 —— 期初快照缺失时的兜底推导 * 每日进销存 —— 期初快照缺失时的兜底推导
* *
* 正常情况下「期初 = 上一业务日快照」(每日 01:30 自动落库)。 * 正常情况下「期初 = 上一业务日快照」(每日 01:30 自动落库)。
* 若快照缺失(新纳入仓库、服务停机等),按两条路推导出「上一业务日期末结存」并补写快照: * 若快照缺失(服务停机、新纳入仓库等),用「实时库存扣流水回推」推导
* 「上一业务日期末结存」并补写快照:
* *
* 路径 A(精确):rpt.pm_stockMonitor(报表库每日 01:00 跑批,T-1 口径) * closing(D-1) = 当前实时库存 − 自 D 01:00 以来的净流入
* 该表 date = 最新业务日 D 的行,就是「D-1 期末」的库存监控值, * 净流入 = 采购进仓 + 销售退货 − 销售出仓 − 采购退货(带符号流水求和,见 dailyInventoryFlows)
* 按 warehouse(= 监控表仓库名 dbWarehouse)聚合 jsStock 即可。
* 覆盖基线中与监控表仓库名一致的仓库(实测 14/26,
* 运营专用仓 / 京东仓等虚拟仓不在监控表中)。
* *
* 路径 B(推导):实时库存扣流水回推 * 口径校准(2026-09-27,对照聚水潭进销存导出 09-23 期初基准):
* closing(D-1) = 实时库存 − 自 D 01:00 以来的净流入 * 崔桥工厂仓 基准 92388 / 回推 ≈89650;横林二仓 基准 63780 / 回推 ≈64029;
* 净流入 = 采购进仓 + 销售退货 − 销售出仓 − 采购退货(带符号流水求和) * 山东青岗集 基准 88377 / 回推 ≈88210;河南洛宁 基准 83305 / 回推 ≈82473。
* 仅覆盖路径 A 匹配不到的仓库。因业务库缺 8 类单据表, * 偏差 0.3%~2.7%,来源为业务库缺失的 8 类单据(调拨/盘点/加工/其它出入库)。
* 若该仓有未记录的调拨/盘点等流水,推导值会偏离真实历史,属近似值。 *
* 注意:不要用 rpt.pm_stockMonitor 聚合 jsStock 充当全仓结存 ——
* 该表只覆盖纳入监控范围的 SKU 子集(横林二仓实测 1197 vs 全仓 63780),
* 系统性偏低,已在校准后弃用。
*/ */
const { fetchFlows } = require('./dailyInventoryFlows'); const { fetchFlows } = require('./dailyInventoryFlows');
@ -28,45 +29,18 @@ const { fetchFlows } = require('./dailyInventoryFlows');
* @param {Object} opts.snapshotDb 本地快照表访问模块 * @param {Object} opts.snapshotDb 本地快照表访问模块
* @param {Array} opts.baseline 仓库基线(warehouse/dbWarehouse/wmsCoId) * @param {Array} opts.baseline 仓库基线(warehouse/dbWarehouse/wmsCoId)
* @param {string} opts.openingDate 期初业务日(YYYY-MM-DD,即要补快照的日期) * @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 实时库存数据时间 * @param {Date|null} opts.takenAt 实时库存数据时间
* @returns {Promise<{count:number, pmCount:number, derivedCount:number}>} * @returns {Promise<{count:number, derivedCount:number}>}
*/ */
async function deriveAndStoreOpeningSnapshot(opts) { async function deriveAndStoreOpeningSnapshot(opts) {
const { cloudDb, snapshotDb, baseline, openingDate, latestBizDate, takenAt } = 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 whs = baseline.filter((b) => b.wmsCoId != null);
const wmsCoIds = whs.map((b) => b.wmsCoId); const wmsCoIds = whs.map((b) => b.wmsCoId);
const flowFrom = `${latestBizDate} 01:00:00`; const flowFrom = `${latestBizDate} 01:00:00`;
// 路径 A:pm_stockMonitor 按仓库名聚合 // 实时库存(与每日快照任务同口径:jst_inventoryInfo 按仓聚合)
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:实时库存 + 流水回推
let realtimeMap = new Map(); let realtimeMap = new Map();
try { try {
const rows = await cloudDb.query( const rows = await cloudDb.query(
@ -84,6 +58,7 @@ async function deriveAndStoreOpeningSnapshot(opts) {
console.error('[daily-inventory-opening] 实时库存聚合失败:', error.message); console.error('[daily-inventory-opening] 实时库存聚合失败:', error.message);
} }
// 自期初时刻以来的带符号流水(流出为负)
let flowMap = new Map(); let flowMap = new Map();
try { try {
flowMap = await fetchFlows(cloudDb, wmsCoIds, flowFrom); flowMap = await fetchFlows(cloudDb, wmsCoIds, flowFrom);
@ -91,39 +66,26 @@ async function deriveAndStoreOpeningSnapshot(opts) {
console.error('[daily-inventory-opening] 流水取数失败:', error.message); console.error('[daily-inventory-opening] 流水取数失败:', error.message);
} }
let pmCount = 0;
let derivedCount = 0; let derivedCount = 0;
const payload = whs.map((b) => { const payload = whs.map((b) => {
const pm = pmMap.get(b.dbWarehouse); const rt = realtimeMap.get(String(b.wmsCoId));
let closingQty; const f = flowMap.get(String(b.wmsCoId)) || {};
let skuCount; const netInflow = (f.purchaseIn || 0) + (f.salesReturn || 0) + (f.salesOut || 0) + (f.purchaseReturn || 0);
if (pm) { const closingQty = Number(rt?.qty ?? 0) - netInflow;
// 路径 A:报表库 01:00 跑批的 T-1 库存监控值(精确) derivedCount += 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;
}
return { return {
wmsCoId: b.wmsCoId, wmsCoId: b.wmsCoId,
warehouse: b.warehouse, warehouse: b.warehouse,
dbWarehouse: b.dbWarehouse, dbWarehouse: b.dbWarehouse,
closingQty: Number(closingQty.toFixed(2)), closingQty: Number(closingQty.toFixed(2)),
skuCount, skuCount: Number(rt?.skuCount ?? 0),
takenAt: takenAt ? new Date(takenAt) : null, takenAt: takenAt ? new Date(takenAt) : null,
}; };
}); });
const count = await snapshotDb.upsertSnapshot(openingDate, payload); const count = await snapshotDb.upsertSnapshot(openingDate, payload);
console.log(`[daily-inventory-opening] 已补写 ${openingDate} 快照 ${count}/${whs.length} 仓(pm_stockMonitor 精确 ${pmCount},流水回推 ${derivedCount})`); console.log(`[daily-inventory-opening] 已补写 ${openingDate} 快照 ${count}/${whs.length} 仓(实时库存扣流水回推)`);
return { count, pmCount, derivedCount }; return { count, derivedCount };
} }
module.exports = { deriveAndStoreOpeningSnapshot }; module.exports = { deriveAndStoreOpeningSnapshot };

View File

@ -53,13 +53,9 @@ test('fetchFlows 空仓库列表直接返回空 Map', async () => {
assert.equal(map.size, 0); assert.equal(map.size, 0);
}); });
test('deriveAndStoreOpeningSnapshot:监控表命中走精确值,未命中用实时库存扣流水回推', async () => { test('deriveAndStoreOpeningSnapshot:全部仓库用实时库存扣流水回推,并保留基线仓库名', async () => {
const upserts = []; const upserts = [];
const cloudDb = fakeCloudDb((sql, params) => { const cloudDb = fakeCloudDb((sql, params) => {
if (sql.includes('pm_stockMonitor')) {
// 只有仓A在监控表(精确路径)
return [{ warehouse: '仓A', qty: '500', skuCount: '12' }];
}
if (sql.includes('jst_inventoryInfo')) { if (sql.includes('jst_inventoryInfo')) {
return [ return [
{ wmsCoId: 101, qty: '520', skuCount: '13' }, { wmsCoId: 101, qty: '520', skuCount: '13' },
@ -90,20 +86,20 @@ test('deriveAndStoreOpeningSnapshot:监控表命中走精确值,未命中用
}); });
assert.equal(result.count, 3); assert.equal(result.count, 3);
assert.equal(result.pmCount, 1, '仅仓A走 pm_stockMonitor 精确路径'); assert.equal(result.derivedCount, 3);
assert.equal(result.derivedCount, 2, '仓B/仓C走流水回推路径');
assert.equal(upserts.length, 1); assert.equal(upserts.length, 1);
assert.equal(upserts[0].bizDate, '2026-09-25'); assert.equal(upserts[0].bizDate, '2026-09-25');
const byWh = new Map(upserts[0].rows.map((r) => [r.wmsCoId, r])); const byWh = new Map(upserts[0].rows.map((r) => [r.wmsCoId, r]));
// 仓A:精确值 500,不受流水影响 // 仓A:无流水 → 等于实时库存 520
assert.equal(byWh.get(101).closingQty, 500); assert.equal(byWh.get(101).closingQty, 520);
// 仓B:300 − 净流入(60 − 10 − 40) = 300 − 10 = 290 // 仓B:300 − 净流入(60 − 10 − 40) = 300 − 10 = 290
assert.equal(byWh.get(102).closingQty, 290); assert.equal(byWh.get(102).closingQty, 290);
// 仓C:无流水 → 等于实时库存 10 // 仓C:无流水 → 等于实时库存 10
assert.equal(byWh.get(103).closingQty, 10); assert.equal(byWh.get(103).closingQty, 10);
// 带回基线仓库名,保证后续按 wmsCoId 读取正确 // 带回基线仓库名,保证后续按 wmsCoId 读取正确
assert.equal(byWh.get(102).warehouse, '仓B主仓'); assert.equal(byWh.get(102).warehouse, '仓B主仓');
assert.equal(byWh.get(102).dbWarehouse, '仓B');
}); });
test('fetchFlows 缺少数据库实例参数时立即抛错(防静默空结果)', async () => { test('fetchFlows 缺少数据库实例参数时立即抛错(防静默空结果)', async () => {

View File

@ -20,7 +20,7 @@ const FLOW_CALIBER_NOTE = '口径说明:与聚水潭进销存导出可能存
/** 表格存在两个名为「期初」的列,第二个加后缀区分 */ /** 表格存在两个名为「期初」的列,第二个加后缀区分 */
export const DAILY_INVENTORY_COLUMNS = [ export const DAILY_INVENTORY_COLUMNS = [
{ key: 'warehouse', label: '仓库', source: 'snapshot', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' }, { 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: '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: '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}` }, { key: 'purchaseIn', label: '采购进仓', source: 'runtime', tip: `采购进仓(流入)。rpt.jst_purchase × jst_purchaseItem,按 po_date 统计,仅计 Confirmed/Finished 状态。${FLOW_CALIBER_NOTE}` },

View File

@ -287,7 +287,7 @@ const DailyInventory = () => {
message="全部数据来自数据库,流水列已接入 4/12" message="全部数据来自数据库,流水列已接入 4/12"
description={ description={
<span> <span>
期初数量取本地快照表中 <b>{data?.openingDate || '—'}</b> 的期末结存(每日 01:30 自动落库,逐日滚动;快照缺失时自动用库存监控表/实时库存扣流水推导补写); 期初数量取本地快照表中 <b>{data?.openingDate || '—'}</b> 的期末结存(每日 01:30 自动落库,逐日滚动;快照缺失时自动用「实时库存扣流水」回推补写);
期末数量与右侧 5 列为运行时从业务库实时取数。 期末数量与右侧 5 列为运行时从业务库实时取数。
流水列中采购进仓/采购退货/销售出仓/销售退货已从业务库单据表取数; 流水列中采购进仓/采购退货/销售出仓/销售退货已从业务库单据表取数;
其余 8 列(加工/其它/盘点/调拨)因业务库缺单据表仍显示「—」,已向 DBA 提交补表需求,因此「期初+流水」与「期末」暂不相等。 其余 8 列(加工/其它/盘点/调拨)因业务库缺单据表仍显示「—」,已向 DBA 提交补表需求,因此「期初+流水」与「期末」暂不相等。