Merge pull request 'feat: 每日进销存补充流水与期初数据,修复合计行 NaN' (#43) from codex/daily-inventory-complete-data-0927 into main
This commit is contained in:
commit
dc12f6753d
@ -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,
|
||||
|
||||
@ -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)。
|
||||
*/
|
||||
|
||||
118
backend/src/services/dailyInventoryFlows.js
Normal file
118
backend/src/services/dailyInventoryFlows.js
Normal file
@ -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<Map<string, Object>>} 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,
|
||||
};
|
||||
129
backend/src/services/dailyInventoryOpening.js
Normal file
129
backend/src/services/dailyInventoryOpening.js
Normal file
@ -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 };
|
||||
107
backend/test/dailyInventoryFlows.test.js
Normal file
107
backend/test/dailyInventoryFlows.test.js
Normal file
@ -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主仓');
|
||||
});
|
||||
@ -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。',
|
||||
'',
|
||||
|
||||
@ -126,6 +126,9 @@ const DailyInventory = () => {
|
||||
render: (value, record) => {
|
||||
if (record?.isTotal) {
|
||||
// 合计行:四级库存量累加,周转率按「总发货 / 总库存」重算(后端已给出)
|
||||
if (col.key === 'warehouse') return <span className="daily-inventory-warehouse">{value}</span>;
|
||||
// 流水列暂无数据来源时,合计行同样显示「—」,避免误读为 0
|
||||
if (isUnavailable) return <span className="daily-inventory-cell--na">—</span>;
|
||||
if (col.key === 'turnoverDays') return percent(value);
|
||||
return int(value);
|
||||
}
|
||||
@ -281,12 +284,13 @@ const DailyInventory = () => {
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="全部数据来自数据库,12 个流水列暂缺单据来源"
|
||||
message="全部数据来自数据库,流水列已接入 4/12"
|
||||
description={
|
||||
<span>
|
||||
期初数量取本地快照表中 <b>{data?.openingDate || '—'}</b> 的期末结存(每日 01:30 自动落库,逐日滚动);
|
||||
期初数量取本地快照表中 <b>{data?.openingDate || '—'}</b> 的期末结存(每日 01:30 自动落库,逐日滚动;快照缺失时自动用库存监控表/实时库存扣流水推导补写);
|
||||
期末数量与右侧 5 列为运行时从业务库实时取数。
|
||||
12 个流水列因业务库缺少调拨/盘点/加工/其它出入库单据表、且已有采购与销售单据口径不一致,暂显示「—」,已向 DBA 提交补表需求。
|
||||
流水列中采购进仓/采购退货/销售出仓/销售退货已从业务库单据表取数;
|
||||
其余 8 列(加工/其它/盘点/调拨)因业务库缺单据表仍显示「—」,已向 DBA 提交补表需求,因此「期初+流水」与「期末」暂不相等。
|
||||
{data?.noOpeningCount > 0 ? ` 其中 ${data.noOpeningCount} 个仓尚无 ${data.openingDate} 的期初快照(新纳入的仓库从次日起自动补齐)。` : ''}
|
||||
{noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在聚水潭库存表中无有效库存,周转率与四级库存量显示「—」。` : ''}
|
||||
</span>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user