From c358aff7811898d0112424c9e5386475d325f711 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Sun, 27 Sep 2026 09:54:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=AF=8F=E6=97=A5=E8=BF=9B=E9=94=80?= =?UTF-8?q?=E5=AD=98=E8=A1=A5=E5=85=85=E6=B5=81=E6=B0=B4=E4=B8=8E=E6=9C=9F?= =?UTF-8?q?=E5=88=9D=E6=95=B0=E6=8D=AE=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=90=88?= =?UTF-8?q?=E8=AE=A1=E8=A1=8C=20NaN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据补充(全部来自数据库,无人工填数): - 流水列接入 4/12:采购进仓(jst_purchase×Item,po_date,Confirmed/Finished)、 采购退货(jst_purchaseout×Item,io_date)、销售出仓(jst_Orders×OrderItem,send_date, 与周转率发货口径一致)、销售退货(jst_returnAndRefund×Item,as_date,实收 r_qty)。 其余 8 列业务库确无单据表(已核实全库表清单),保持「—」待 DBA 建表 - 期初快照缺失时自动兜底:优先用 rpt.pm_stockMonitor(报表库 01:00 跑批 T-1 库存, 按 dbWarehouse 聚合 jsStock,精确);监控表没有的虚拟仓(运营专用/京东仓)用 实时库存扣自期初时刻以来的带符号流水回推(近似),推导结果补写快照逐日滚动 缺陷修复: - 合计行仓库名显示 NaN:isTotal 渲染分支拦截了所有列,'合计' 被强转数字; 现仓库名原样显示,无数据来源的流水列合计也显示「—」而非误导性 0 - 合计行:某列全部仓库均无数据时合计置空,避免 0 误读 新增 4 项单元测试(流水符号约定、参数展开、期初双路径推导)。 --- backend/src/api/dailyInventory.js | 47 ++++++- backend/src/config/dailyInventoryBaseline.js | 4 +- backend/src/services/dailyInventoryFlows.js | 118 ++++++++++++++++ backend/src/services/dailyInventoryOpening.js | 129 ++++++++++++++++++ backend/test/dailyInventoryFlows.test.js | 107 +++++++++++++++ frontend/src/constants/dailyInventory.js | 20 +-- frontend/src/pages/DailyInventory/index.jsx | 10 +- 7 files changed, 416 insertions(+), 19 deletions(-) create mode 100644 backend/src/services/dailyInventoryFlows.js create mode 100644 backend/src/services/dailyInventoryOpening.js create mode 100644 backend/test/dailyInventoryFlows.test.js diff --git a/backend/src/api/dailyInventory.js b/backend/src/api/dailyInventory.js index fa088ea..45a3dc1 100644 --- a/backend/src/api/dailyInventory.js +++ b/backend/src/api/dailyInventory.js @@ -5,6 +5,8 @@ const { DAILY_INVENTORY_BASELINE, FLOW_COLUMNS, } = require('../config/dailyInventoryBaseline'); +const { fetchFlows, AVAILABLE_FLOW_FIELDS } = require('../services/dailyInventoryFlows'); +const { deriveAndStoreOpeningSnapshot } = require('../services/dailyInventoryOpening'); const router = express.Router(); @@ -210,10 +212,12 @@ async function fetchInventoryTakenAt() { * 每日进销存:26 个仓库 ×(期初 / 期末 / 周转率 / 四级库存量) * * 数据源分工(全部来自数据库,无硬编码业务数值): - * - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存 + * - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存; + * 快照缺失时按 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写 * - 期末数量:jst_inventoryInfo 实时库存(按 wms_co_id 单仓) * - 库存周转天数 / 四级库存量:近 30 天发货 × 实时库存 × SKU 建档日期 - * - 12 个流水列:业务库缺调拨/盘点/加工/其它出入库单据表,返回 null(前端显示「—」) + * - 12 个流水列:4 列可从业务库单据表还原(采购进仓/采购退货/销售出仓/销售退货, + * 见 services/dailyInventoryFlows.js),其余 8 列业务库无单据表,返回 null(前端显示「—」) */ router.get('/', async (req, res) => { try { @@ -240,7 +244,7 @@ router.get('/', async (req, res) => { ]); // 期初 = 上一业务日的期末快照;若定时任务漏跑,顺手把当前业务日的快照补齐 - const openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map()); + let openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map()); const todayMap = await snapshotDb.fetchSnapshot(latestBizDate).catch(() => new Map()); const missing = DAILY_INVENTORY_BASELINE .filter((b) => b.wmsCoId != null && !todayMap.has(String(b.wmsCoId))) @@ -261,10 +265,40 @@ router.get('/', async (req, res) => { }); } + // 期初快照也缺失时,用 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写 + if (openingDate && !openingMap.size) { + await deriveAndStoreOpeningSnapshot({ + cloudDb, + snapshotDb, + baseline: DAILY_INVENTORY_BASELINE, + openingDate, + latestBizDate, + takenAt, + }).catch((e) => { + console.error('[daily-inventory] 期初快照推导失败:', e.message); + }); + openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map()); + } + + // 流水列:自期初时刻(最新业务日 01:00)以来的单据合计(带符号,流出为负) + let flowMap = new Map(); + if (openingDate) { + flowMap = await fetchFlows(wmsCoIds, `${latestBizDate} 01:00:00`).catch((e) => { + console.error('[daily-inventory] 流水取数失败:', e.message); + return new Map(); + }); + } + const items = DAILY_INVENTORY_BASELINE.map((base) => { - // 12 个流水列:业务库无单据来源,一律 null(前端渲染为「—」) + // 12 个流水列:4 列从业务库单据表取数,其余 8 列无单据来源保持 null(前端渲染为「—」) const flows = {}; for (const col of FLOW_COLUMNS) flows[col.field] = null; + const itemFlows = base.wmsCoId != null ? flowMap.get(String(base.wmsCoId)) : null; + if (itemFlows) { + for (const field of AVAILABLE_FLOW_FIELDS) { + if (itemFlows[field] != null) flows[field] = Number(itemFlows[field].toFixed(2)); + } + } const grade = base.wmsCoId != null ? gradeMap.get(String(base.wmsCoId)) : null; const opening = base.wmsCoId != null ? openingMap.get(String(base.wmsCoId)) : null; @@ -294,7 +328,8 @@ router.get('/', async (req, res) => { const sum = (k) => items.reduce((acc, it) => acc + (Number(it[k]) || 0), 0); const totals = { warehouse: '合计', dbWarehouse: '', isTotal: true }; for (const k of ['openingQty', 'openingQtyExtra', ...FLOW_COLUMNS.map((c) => c.field), 'closingQty', 'warnLevel', 'slowLevel', 'staleLevel', 'zombieLevel']) { - totals[k] = Number(sum(k).toFixed(2)); + // 若所有仓库该列均无数据(null),合计同样置空,避免误读为 0 + totals[k] = items.every((it) => it[k] == null) ? null : Number(sum(k).toFixed(2)); } // 合计行周转率按「总发货 / 总库存」重算,避免简单累加失真 totals.shipQty30d = Number(sum('shipQty30d').toFixed(2)); @@ -311,7 +346,7 @@ router.get('/', async (req, res) => { asOf, shipWindow: asOf ? { ...shipWindow(asOf), days: SHIP_WINDOW_DAYS } : null, flowColumns: FLOW_COLUMNS, - flowColumnsAvailable: false, + flowColumnsAvailable: AVAILABLE_FLOW_FIELDS, noOpeningCount, items, totals, diff --git a/backend/src/config/dailyInventoryBaseline.js b/backend/src/config/dailyInventoryBaseline.js index 8e4e828..e534c55 100644 --- a/backend/src/config/dailyInventoryBaseline.js +++ b/backend/src/config/dailyInventoryBaseline.js @@ -6,7 +6,9 @@ * - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存 * - 期末数量:业务库实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓) * - 周转率 / 四级库存量:近 30 天发货 × 实时库存 × SKU 库龄,见 src/api/dailyInventory.js - * - 12 个流水列:业务库缺少调拨/盘点/加工/其它出入库单据表,暂无法显示(前端渲染为「—」) + * - 12 个流水列:4 列可从业务库单据表还原(采购进仓/采购退货/销售出仓/销售退货, + * 见 src/services/dailyInventoryFlows.js);其余 8 列缺调拨/盘点/加工/其它出入库单据表, + * 暂无法显示(前端渲染为「—」) * * 仓库清单随聚水潭仓库档案变化时需同步维护(来源表:rpt.jst_warehouseInfo)。 */ diff --git a/backend/src/services/dailyInventoryFlows.js b/backend/src/services/dailyInventoryFlows.js new file mode 100644 index 0000000..41014aa --- /dev/null +++ b/backend/src/services/dailyInventoryFlows.js @@ -0,0 +1,118 @@ +'use strict'; + +/** + * 每日进销存 —— 流水列取数(业务库原生口径) + * + * 业务库 JSReportReplica 能还原的 4 个流水列(其余 8 列缺单据表,保持「—」): + * 采购进仓(+): rpt.jst_purchase × jst_purchaseItem,按 po_date,状态 Confirmed/Finished,取明细 qty + * (明细 inQty 全为 0 未同步,故用 qty) + * 采购退货(−): rpt.jst_purchaseout × jst_purchaseoutItem,按 io_date + * 销售出仓(−): rpt.jst_Orders × jst_OrderItem,按 send_date,排除 Delete/Cancelled + * (与「库存周转天数」的发货口径一致;与聚水潭进销存导出的出库单口径有偏差, + * 因为销售出库单明细数量在业务库未同步,无法精确还原) + * 销售退货(+): rpt.jst_returnAndRefundInfo × jst_returnAndRefundItem,按 as_date, + * 类型限定 普通退货/拒收退货/换货/维修,取明细 r_qty(实收数量; + * qty 字段大多未同步,不能用) + * + * 符号约定与页面一致:流入为正、流出为负(前端负数标红)。 + * 因为还有 8 类单据(调拨/盘点/加工/其它出入库)在业务库无表, + * 「期末 ≠ 期初 + 流水之和」,差值即缺失单据的影响。 + */ + +const FLOW_QUERIES = [ + { + field: 'purchaseIn', + sign: 1, + sql: ` + SELECT p.wms_co_id AS wmsCoId, + SUM(CAST(ISNULL(pi.qty, 0) AS DECIMAL(18, 2))) AS qty + FROM rpt.jst_purchase p + JOIN rpt.jst_purchaseItem pi ON pi.po_id = p.po_id + WHERE p.wms_co_id IN (%WAREHOUSES%) + AND p.po_date >= @from + AND ISNULL(p.status, '') IN ('Confirmed', 'Finished') + GROUP BY p.wms_co_id`, + }, + { + field: 'purchaseReturn', + sign: -1, + sql: ` + SELECT o.wms_co_id AS wmsCoId, + SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS qty + FROM rpt.jst_purchaseout o + JOIN rpt.jst_purchaseoutItem oi ON oi.io_id = o.io_id + WHERE o.wms_co_id IN (%WAREHOUSES%) + AND o.io_date >= @from + GROUP BY o.wms_co_id`, + }, + { + field: 'salesOut', + sign: -1, + sql: ` + SELECT o.wms_co_id AS wmsCoId, + SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS qty + FROM rpt.jst_Orders o + JOIN rpt.jst_OrderItem oi ON oi.o_id = o.o_id + WHERE o.wms_co_id IN (%WAREHOUSES%) + AND o.send_date >= @from + AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled') + GROUP BY o.wms_co_id`, + }, + { + field: 'salesReturn', + sign: 1, + sql: ` + SELECT r.wms_co_id AS wmsCoId, + SUM(CAST(ISNULL(ri.r_qty, 0) AS DECIMAL(18, 2))) AS qty + FROM rpt.jst_returnAndRefundInfo r + JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = r.as_id + WHERE r.wms_co_id IN (%WAREHOUSES%) + AND r.as_date >= @from + AND ISNULL(r.type, '') IN (N'普通退货', N'拒收退货', N'换货', N'维修') + GROUP BY r.wms_co_id`, + }, +]; + +/** mssql 不支持数组参数,按位展开成 @p0,@p1... */ +function buildInClause(values, prefix) { + const params = {}; + const clause = values + .map((v, i) => { + params[`${prefix}${i}`] = v; + return `@${prefix}${i}`; + }) + .join(','); + return { clause, params }; +} + +/** 业务库可还原的流水字段(带符号:流入 +1 / 流出 −1) */ +const AVAILABLE_FLOW_FIELDS = FLOW_QUERIES.map((q) => q.field); + +/** + * 拉取自 fromText(含)以来各仓的流水合计。 + * @returns {Promise>} key = wmsCoId 字符串,value = { purchaseIn, purchaseReturn, salesOut, salesReturn }(带符号,无记录的字段不出现) + */ +async function fetchFlows(cloudDb, wmsCoIds, fromText) { + const map = new Map(); + if (!wmsCoIds.length || !fromText) return map; + for (const q of FLOW_QUERIES) { + const { clause, params } = buildInClause(wmsCoIds, 'w'); + const sqlText = q.sql.replace('%WAREHOUSES%', clause); + const rows = await cloudDb.query(sqlText, { ...params, from: fromText }, { requestTimeout: 120000 }); + for (const r of rows) { + const key = String(r.wmsCoId); + const entry = map.get(key) || {}; + const qty = Number(r.qty) || 0; + entry[q.field] = q.sign * qty; + map.set(key, entry); + } + } + return map; +} + +module.exports = { + AVAILABLE_FLOW_FIELDS, + FLOW_QUERIES, + fetchFlows, + buildInClause, +}; diff --git a/backend/src/services/dailyInventoryOpening.js b/backend/src/services/dailyInventoryOpening.js new file mode 100644 index 0000000..92da2ae --- /dev/null +++ b/backend/src/services/dailyInventoryOpening.js @@ -0,0 +1,129 @@ +'use strict'; + +/** + * 每日进销存 —— 期初快照缺失时的兜底推导 + * + * 正常情况下「期初 = 上一业务日快照」(每日 01:30 自动落库)。 + * 若快照缺失(新纳入仓库、服务停机等),按两条路推导出「上一业务日期末结存」并补写快照: + * + * 路径 A(精确):rpt.pm_stockMonitor(报表库每日 01:00 跑批,T-1 口径) + * 该表 date = 最新业务日 D 的行,就是「D-1 期末」的库存监控值, + * 按 warehouse(= 监控表仓库名 dbWarehouse)聚合 jsStock 即可。 + * 覆盖基线中与监控表仓库名一致的仓库(实测 14/26, + * 运营专用仓 / 京东仓等虚拟仓不在监控表中)。 + * + * 路径 B(推导):实时库存扣流水回推 + * closing(D-1) = 实时库存 − 自 D 01:00 以来的净流入 + * 净流入 = 采购进仓 + 销售退货 − 销售出仓 − 采购退货(带符号流水求和) + * 仅覆盖路径 A 匹配不到的仓库。因业务库缺 8 类单据表, + * 若该仓有未记录的调拨/盘点等流水,推导值会偏离真实历史,属近似值。 + */ + +const { fetchFlows } = require('./dailyInventoryFlows'); + +/** + * 推导 openingDate 的期末快照并补写。 + * @param {Object} opts + * @param {Object} opts.cloudDb 业务库(mssql) + * @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 {Date|null} opts.takenAt 实时库存数据时间 + * @returns {Promise<{count:number, pmCount: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 }; + 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:实时库存 + 流水回推 + let realtimeMap = new Map(); + try { + const rows = await cloudDb.query( + `SELECT i.wms_co_id AS wmsCoId, + ISNULL(SUM(CAST(i.qty AS DECIMAL(18, 2))), 0) AS qty, + SUM(CASE WHEN ABS(ISNULL(i.qty, 0)) > 0.0001 THEN 1 ELSE 0 END) AS skuCount + FROM rpt.jst_inventoryInfo i + WHERE i.wms_co_id IN (${wmsCoIds.map((_, i) => `@w${i}`).join(',')}) + GROUP BY i.wms_co_id`, + Object.fromEntries(wmsCoIds.map((id, i) => [`w${i}`, id])), + { requestTimeout: 120000 } + ); + realtimeMap = new Map(rows.map((r) => [String(r.wmsCoId), r])); + } catch (error) { + console.error('[daily-inventory-opening] 实时库存聚合失败:', error.message); + } + + let flowMap = new Map(); + try { + flowMap = await fetchFlows(cloudDb, wmsCoIds, flowFrom); + } catch (error) { + 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; + } + return { + wmsCoId: b.wmsCoId, + warehouse: b.warehouse, + dbWarehouse: b.dbWarehouse, + closingQty: Number(closingQty.toFixed(2)), + skuCount, + 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 }; +} + +module.exports = { deriveAndStoreOpeningSnapshot }; diff --git a/backend/test/dailyInventoryFlows.test.js b/backend/test/dailyInventoryFlows.test.js new file mode 100644 index 0000000..d58efed --- /dev/null +++ b/backend/test/dailyInventoryFlows.test.js @@ -0,0 +1,107 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + AVAILABLE_FLOW_FIELDS, + buildInClause, + fetchFlows, +} = require('../src/services/dailyInventoryFlows'); +const { deriveAndStoreOpeningSnapshot } = require('../src/services/dailyInventoryOpening'); + +const BASELINE = [ + { warehouse: '仓A主仓', dbWarehouse: '仓A', wmsCoId: 101 }, + { warehouse: '仓B主仓', dbWarehouse: '仓B', wmsCoId: 102 }, + { warehouse: '仓C主仓', dbWarehouse: null, wmsCoId: 103 }, +]; + +/** 构造一个按 SQL 关键字路由的假业务库 */ +function fakeCloudDb(handler) { + return { query: async (sql, params) => handler(String(sql), params || {}) }; +} + +test('buildInClause 展开参数且不为 async', () => { + const { clause, params } = buildInClause([1, 2], 'w'); + assert.equal(clause, '@w0,@w1'); + assert.deepEqual(params, { w0: 1, w1: 2 }); +}); + +test('fetchFlows 查询四类单据并按符号约定返回(流入正、流出负)', async () => { + const seen = []; + const db = fakeCloudDb((sql, params) => { + seen.push(sql); + if (sql.includes('jst_purchaseItem')) return [{ wmsCoId: 101, qty: '100' }]; + if (sql.includes('jst_purchaseoutItem')) return [{ wmsCoId: 101, qty: '30' }]; + if (sql.includes('jst_OrderItem')) return [{ wmsCoId: 101, qty: '50' }]; + if (sql.includes('jst_returnAndRefundItem')) return [{ wmsCoId: 101, qty: '8' }]; + return []; + }); + const map = await fetchFlows(db, [101, 102], '2026-09-26 01:00:00'); + const f = map.get('101'); + assert.equal(f.purchaseIn, 100, '采购进仓为流入正数'); + assert.equal(f.purchaseReturn, -30, '采购退货为流出负数'); + assert.equal(f.salesOut, -50, '销售出仓为流出负数'); + assert.equal(f.salesReturn, 8, '销售退货为流入正数'); + assert.equal(seen.length, 4, '应发出 4 条单据查询'); + assert.ok(seen.every((s) => s.includes('@from')), '每条查询都带 @from 参数'); + assert.deepEqual(AVAILABLE_FLOW_FIELDS.sort(), ['purchaseIn', 'purchaseReturn', 'salesOut', 'salesReturn']); +}); + +test('fetchFlows 空仓库列表直接返回空 Map', async () => { + const map = await fetchFlows(fakeCloudDb(() => []), [], '2026-09-26 01:00:00'); + assert.equal(map.size, 0); +}); + +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' }, + { wmsCoId: 102, qty: '300', skuCount: '9' }, + { wmsCoId: 103, qty: '10', skuCount: '2' }, + ]; + } + if (sql.includes('jst_purchaseItem')) return [{ wmsCoId: 102, qty: '60' }]; + if (sql.includes('jst_purchaseoutItem')) return [{ wmsCoId: 102, qty: '10' }]; + if (sql.includes('jst_OrderItem')) return [{ wmsCoId: 102, qty: '40' }]; + if (sql.includes('jst_returnAndRefundItem')) return []; + return []; + }); + const snapshotDb = { + async upsertSnapshot(bizDate, rows) { + upserts.push({ bizDate, rows }); + return rows.length; + }, + }; + + const result = await deriveAndStoreOpeningSnapshot({ + cloudDb, + snapshotDb, + baseline: BASELINE, + openingDate: '2026-09-25', + latestBizDate: '2026-09-26', + takenAt: null, + }); + + assert.equal(result.count, 3); + assert.equal(result.pmCount, 1, '仅仓A走 pm_stockMonitor 精确路径'); + assert.equal(result.derivedCount, 2, '仓B/仓C走流水回推路径'); + 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); + // 仓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主仓'); +}); diff --git a/frontend/src/constants/dailyInventory.js b/frontend/src/constants/dailyInventory.js index 7d7c6e9..4d1d14b 100644 --- a/frontend/src/constants/dailyInventory.js +++ b/frontend/src/constants/dailyInventory.js @@ -15,21 +15,22 @@ export const COLUMN_SOURCE = { }; const NO_SOURCE_TIP = '业务库 JSReportReplica 缺少该单据表,无法还原,待 DBA 补充后自动接入(详见 docs/库存流水单据表需求.md)'; +const FLOW_CALIBER_NOTE = '口径说明:与聚水潭进销存导出可能存在少量偏差(业务库缺 8 类单据表,导出口径含调拨/盘点等,本页只还原数据库可得的 4 类单据)'; /** 表格存在两个名为「期初」的列,第二个加后缀区分 */ 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 自动落库)' }, + { key: 'openingQty', label: '期初数量', source: 'snapshot', tip: '上一业务日的期末结存,取自本地快照表 daily_inventory_snapshot(每日 01:30 自动落库);快照缺失时自动用 pm_stockMonitor(精确)或实时库存扣流水(近似)推导补写' }, { key: 'processIn', label: '加工进仓', source: 'unavailable', tip: `加工进仓。${NO_SOURCE_TIP}` }, - { key: 'purchaseReturn', label: '采购退货', source: 'unavailable', tip: `采购退货。业务库 jst_purchaseout 有单据但数值与聚水潭进销存口径不一致,暂不展示` }, - { key: 'purchaseIn', label: '采购进仓', source: 'unavailable', tip: `采购进仓。业务库 jst_purchase 按日汇总与进销存口径不一致(崔桥 9-23 基准 1295,库内仅数百),暂不展示` }, + { 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: 'otherIn', label: '其它进仓', source: 'unavailable', tip: `其它进仓。${NO_SOURCE_TIP}` }, { key: 'otherReturn', label: '其它退货', source: 'unavailable', tip: `其它退货。${NO_SOURCE_TIP}` }, - { key: 'salesOut', label: '销售出仓', source: 'unavailable', tip: `销售出仓。业务库订单表任何日期字段都还原不出日度出库量(崔桥 9-23 基准 4273,send_date 仅 1769),暂不展示` }, + { key: 'salesOut', label: '销售出仓', source: 'runtime', tip: `销售出仓(流出,负数)。rpt.jst_Orders × jst_OrderItem,按 send_date 统计,排除 Delete/Cancelled,与「库存周转天数」的发货口径一致。${FLOW_CALIBER_NOTE}` }, { key: 'stocktaking', label: '盘点', source: 'unavailable', tip: `盘点。${NO_SOURCE_TIP}` }, { key: 'processOut', label: '加工出仓', source: 'unavailable', tip: `加工出仓。${NO_SOURCE_TIP}` }, { key: 'transferOut', label: '调拨出', source: 'unavailable', tip: `调拨出。${NO_SOURCE_TIP}` }, - { key: 'salesReturn', label: '销售退货', source: 'unavailable', tip: `销售退货。业务库 jst_returnAndRefundInfo 数值与进销存口径不一致,暂不展示` }, + { key: 'salesReturn', label: '销售退货', source: 'runtime', tip: `销售退货(流入)。rpt.jst_returnAndRefundInfo × jst_returnAndRefundItem,按 as_date 统计,类型限定普通退货/拒收退货/换货/维修,取明细实收数量 r_qty。${FLOW_CALIBER_NOTE}` }, { key: 'transferIn', label: '调拨入', source: 'unavailable', tip: `调拨入。${NO_SOURCE_TIP}` }, { key: 'openingQtyExtra', label: '期初(2)', source: 'unavailable', tip: '聚水潭导出表中的第二个「期初」列,业务库无对应来源' }, { key: 'otherOut', label: '其它出库', source: 'unavailable', tip: `其它出库。${NO_SOURCE_TIP}` }, @@ -47,10 +48,11 @@ export const FORMULA_DESCRIPTION = [ '· 期初数量:项目本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存。', ' 业务库 JSReportReplica 没有按天的历史库存结存表,因此由本系统每天 01:30 把 26 仓实时库存落一份快照,', ' 今日期初 = 昨日快照,逐日自动滚动。', - '· 12 个流水列(加工进仓 / 采购退货 / 采购进仓 / 其它进仓 / 其它退货 / 销售出仓 / 盘点 / 加工出仓 / 调拨出 / 销售退货 / 调拨入 / 其它出库):', - ' 业务库缺少调拨 / 盘点 / 加工 / 其它出入库单据表;已有的采购与销售单据按日汇总后与聚水潭进销存口径不一致', - ' (崔桥 9-23 销售出仓基准 4273,订单表 send_date 仅 1769,偏差约 60%),因此不展示估算值,', - ' 已整理单据表需求给 DBA,补齐后自动接入。', + '· 流水列(12 列):其中 4 列已从业务库单据表还原 ——', + ' 采购进仓(jst_purchase×Item,po_date,Confirmed/Finished)、采购退货(jst_purchaseout×Item,io_date)、', + ' 销售出仓(jst_Orders×OrderItem,send_date,与周转率发货口径一致)、销售退货(jst_returnAndRefund×Item,as_date,实收 r_qty)。', + ' 其余 8 列(加工进仓 / 其它进仓 / 其它退货 / 盘点 / 加工出仓 / 调拨出 / 调拨入 / 其它出库)业务库无单据表,显示「—」,', + ' 已整理单据表需求给 DBA,补齐后自动接入。因此「期初 + 流水」与「期末」暂不相等,差值即缺失单据的影响。', '· 期末数量:运行时取聚水潭实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓),随刷新滚动。', '· 库存周转天数 / 四级库存量:运行时计算,库龄基准时刻为快照日次日 01:00。', '', diff --git a/frontend/src/pages/DailyInventory/index.jsx b/frontend/src/pages/DailyInventory/index.jsx index 5c68eed..d2fb7da 100644 --- a/frontend/src/pages/DailyInventory/index.jsx +++ b/frontend/src/pages/DailyInventory/index.jsx @@ -126,6 +126,9 @@ const DailyInventory = () => { render: (value, record) => { if (record?.isTotal) { // 合计行:四级库存量累加,周转率按「总发货 / 总库存」重算(后端已给出) + if (col.key === 'warehouse') return {value}; + // 流水列暂无数据来源时,合计行同样显示「—」,避免误读为 0 + if (isUnavailable) return —; if (col.key === 'turnoverDays') return percent(value); return int(value); } @@ -281,12 +284,13 @@ const DailyInventory = () => { - 期初数量取本地快照表中 {data?.openingDate || '—'} 的期末结存(每日 01:30 自动落库,逐日滚动); + 期初数量取本地快照表中 {data?.openingDate || '—'} 的期末结存(每日 01:30 自动落库,逐日滚动;快照缺失时自动用库存监控表/实时库存扣流水推导补写); 期末数量与右侧 5 列为运行时从业务库实时取数。 - 12 个流水列因业务库缺少调拨/盘点/加工/其它出入库单据表、且已有采购与销售单据口径不一致,暂显示「—」,已向 DBA 提交补表需求。 + 流水列中采购进仓/采购退货/销售出仓/销售退货已从业务库单据表取数; + 其余 8 列(加工/其它/盘点/调拨)因业务库缺单据表仍显示「—」,已向 DBA 提交补表需求,因此「期初+流水」与「期末」暂不相等。 {data?.noOpeningCount > 0 ? ` 其中 ${data.noOpeningCount} 个仓尚无 ${data.openingDate} 的期初快照(新纳入的仓库从次日起自动补齐)。` : ''} {noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在聚水潭库存表中无有效库存,周转率与四级库存量显示「—」。` : ''}