fix: 期初兜底弃用 pm_stockMonitor,全部改用实时库存扣流水回推(#44 追随修复) #45

Merged
youruo merged 1 commits from codex/daily-inventory-complete-data-0927 into main 2026-09-27 10:09:54 +08:00
4 changed files with 32 additions and 74 deletions

View File

@ -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 };

View File

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

View File

@ -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}` },

View File

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