feat: 每日进销存改为全部从数据库取数(期初按本地快照滚动) #32

Merged
youruo merged 4 commits from codex/daily-inventory-snapshot-0924 into main 2026-09-27 08:11:26 +08:00
13 changed files with 918 additions and 891 deletions

View File

@ -1,106 +0,0 @@
process.env.CANGKU_SKIP_BOOTSTRAP = '1';
const fs = require('fs');
const path = require('path');
const XLSX = require('xlsx');
const cloudDb = require('../src/config/cloudDatabase');
// 表格的 12 个流水列 → 配置字段名
const FLOW_FIELDS = [
['加工进仓', 'processIn'],
['采购退货', 'purchaseReturn'],
['采购进仓', 'purchaseIn'],
['其它进仓', 'otherIn'],
['其它退货', 'otherReturn'],
['销售出仓', 'salesOut'],
['盘点', 'stocktaking'],
['加工出仓', 'processOut'],
['调拨出', 'transferOut'],
['销售退货', 'salesReturn'],
['调拨入', 'transferIn'],
['其它出库', 'otherOut'],
];
(async () => {
const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx');
const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' });
const h = rows[0];
const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i]));
const level1 = String(rows[1][idx['一级功能按键']]).trim();
const level2 = String(rows[1][idx['二级功能按键']]).trim();
console.log('一级功能:', level1, '| 二级功能:', level2);
const whs = await cloudDb.query(`SELECT name, wms_co_id FROM rpt.jst_warehouseInfo`);
const byName = new Map(whs.map((w) => [String(w.name).trim(), w.wms_co_id]));
const records = [];
const missing = [];
for (const r of rows.slice(1)) {
const sheetName = String(r[idx['仓库']] || '').trim();
if (!sheetName) continue;
const base = sheetName.replace(/主仓$/, '');
const wmsCoId = byName.get(base) ?? byName.get(sheetName) ?? null;
if (wmsCoId == null) missing.push(sheetName);
const num = (k) => {
const v = r[idx[k]];
return typeof v === 'number' ? v : Number(String(v || '').replace(/,/g, '')) || 0;
};
const flows = Object.fromEntries(FLOW_FIELDS.map(([cn, en]) => [en, num(cn)]));
const openExtra = r[idx['期初']]; // 第二个「期初」列,索引会与「期初数量」冲突,单独处理
records.push({
warehouse: sheetName,
dbWarehouse: base,
wmsCoId,
openingQty: num('期初数量'),
openingQtyExtra: typeof openExtra === 'number' ? openExtra : Number(openExtra) || 0,
...flows,
});
}
console.log('记录数:', records.length, '| 缺 wms_co_id:', missing.length, missing.join(',') || '(无)');
const FLOW_COLUMNS_DEF = FLOW_FIELDS.map(([cn, en]) => ({ label: cn, field: en }));
const out = `/**
* 每日进销存 —— 2026-09-22 进销存基准流水
*
* 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出)
* 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库
* 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。
*
* 口径约定:
* - openingQty 与 12 个流水列:22 号基准值(本文件静态存储)
* - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确)
* - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算
*/
/** 一级/二级菜单名(取自表格) */
const DAILY_INVENTORY_LEVEL1 = '${level1}';
const DAILY_INVENTORY_LEVEL2 = '${level2}';
/** 基准数据日期 */
const BASELINE_DATE = '2026-09-22';
/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */
const FLOW_COLUMNS = ${JSON.stringify(FLOW_COLUMNS_DEF, null, 2)};
/** 25 个仓库的 22 号基准流水 */
const DAILY_INVENTORY_BASELINE = ${JSON.stringify(records, null, 2)};
module.exports = {
DAILY_INVENTORY_LEVEL1,
DAILY_INVENTORY_LEVEL2,
BASELINE_DATE,
FLOW_COLUMNS,
DAILY_INVENTORY_BASELINE,
};
`;
const target = path.join(__dirname, '../src/config/dailyInventoryBaseline.js');
fs.writeFileSync(target, out, 'utf8');
console.log('已生成:', target);
console.log('\n前 2 条预览:');
console.log(JSON.stringify(records.slice(0, 2), null, 1));
process.exit(0);
})().catch((e) => { console.log('ERR', e.message); process.exit(1); });

View File

@ -0,0 +1,25 @@
/**
* 手动同步一次每日库存快照
*
* 用途:补跑 / 排障 / 挂到系统计划任务(Windows 任务计划程序、Linux crontab)。
* 业务库没有按天的历史库存结存表,本脚本把 26 仓实时库存落一份到本地快照表,
* 使「今日期初 = 上一业务日期末」能够自动滚动。
*
* 运行:node scripts/sync-daily-inventory-snapshot.cjs
* 业务日自动取库存快照库 rpt.pm_stockMonitor 的最新日期(T-1)。
*
* 生产环境下后端每日 01:30 的定时任务(src/jobs/dailyInventorySnapshotJob.js)会自动执行,
* 本脚本仅作为兜底与手动补跑手段。
*/
process.env.CANGKU_SKIP_BOOTSTRAP = '1';
const { syncOnce } = require('../src/jobs/dailyInventorySnapshotJob');
syncOnce('manual')
.then((result) => {
console.log('[快照同步] 结果:', JSON.stringify(result));
})
.catch((error) => {
console.error('[快照同步] 失败:', error.message);
})
.finally(() => process.exit(0));

View File

@ -1,96 +0,0 @@
process.env.CANGKU_SKIP_BOOTSTRAP = '1';
const http = require('http');
const XLSX = require('xlsx');
const FIELD_MAP = [
['期初数量', 'openingQty'],
['加工进仓', 'processIn'],
['采购退货', 'purchaseReturn'],
['采购进仓', 'purchaseIn'],
['其它进仓', 'otherIn'],
['其它退货', 'otherReturn'],
['销售出仓', 'salesOut'],
['盘点', 'stocktaking'],
['加工出仓', 'processOut'],
['调拨出', 'transferOut'],
['销售退货', 'salesReturn'],
['调拨入', 'transferIn'],
['期初', 'openingQtyExtra'],
['其它出库', 'otherOut'],
['期末数量', 'closingQty'],
];
function req(method, path, token, body) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const r = http.request({
host: '127.0.0.1', port: 4001, path, method,
headers: {
'Content-Type': 'application/json',
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
}, (resp) => { let s = ''; resp.on('data', (d) => { s += d; }); resp.on('end', () => resolve({ code: resp.statusCode, body: s })); });
r.on('error', reject);
if (data) r.write(data);
r.end();
});
}
(async () => {
const login = JSON.parse((await req('POST', '/api/auth/local-preview', null, {})).body);
const token = login.access_token || login.token;
const res = await req('GET', '/api/daily-inventory', token);
if (res.code !== 200) { console.log('接口失败', res.code, res.body.slice(0, 300)); process.exit(1); }
const d = JSON.parse(res.body).data;
const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx');
const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' });
const h = rows[0];
const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i]));
const byName = new Map(d.items.map((it) => [it.warehouse, it]));
console.log('接口: 基准日 %s | 快照日 %s | asOf %s | 仓数 %d', d.baselineDate, d.latestBizDate, d.asOf, d.items.length);
console.log();
const diffCounts = {};
let totalCells = 0;
let diffCells = 0;
const samples = [];
for (const r of rows.slice(1)) {
const name = String(r[idx['仓库']] || '').trim();
if (!name) continue;
const item = byName.get(name);
if (!item) { console.log('接口缺少仓库:', name); continue; }
for (const [cn, field] of FIELD_MAP) {
const raw = r[idx[cn]];
const expected = typeof raw === 'number' ? raw : Number(String(raw || '').replace(/,/g, '')) || 0;
const actual = Number(item[field] ?? 0);
totalCells += 1;
// 期末允许 1 件内的副本同步误差
const tol = field === 'closingQty' ? 10 : 0;
if (Math.abs(expected - actual) > tol) {
diffCells += 1;
diffCounts[cn] = (diffCounts[cn] || 0) + 1;
if (samples.length < 12) samples.push(`${name} | ${cn} | 表格 ${expected} vs 接口 ${actual}`);
}
}
}
console.log('比对单元格总数: %d,不一致: %d', totalCells, diffCells);
if (diffCells) {
console.log('\n不一致列统计:', JSON.stringify(diffCounts));
console.log('样例:');
samples.forEach((s) => console.log(' ' + s));
} else {
console.log('✓ 全部单元格与表格一致');
}
const withGrade = d.items.filter((x) => x.hasGrade).length;
console.log('\n有分级数据仓: %d/%d', withGrade, d.items.length);
console.log('无分级的仓:', d.items.filter((x) => !x.hasGrade).map((x) => x.warehouse).join('、'));
console.log('\n合计: 期初 %s,期末 %s', d.totals.openingQty, d.totals.closingQty);
console.log('崔桥样例:', JSON.stringify(d.items[0], null, 1).replace(/\n\s*/g, ' '));
process.exit(0);
})().catch((e) => { console.log('ERR', e.message); process.exit(1); });

View File

@ -1,8 +1,8 @@
const express = require('express');
const cloudDb = require('../config/cloudDatabase');
const snapshotDb = require('../config/dailyInventorySnapshotDb');
const {
DAILY_INVENTORY_BASELINE,
BASELINE_DATE,
FLOW_COLUMNS,
} = require('../config/dailyInventoryBaseline');
@ -11,6 +11,19 @@ const router = express.Router();
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */
const AS_OF_TIME = '01:00';
/**
* 「月度」发货窗口天数:滚动 30 天。
* 库龄阈值:3 个月 = 90 天、12 个月 = 365 天(按日历天折算)。
*/
const SHIP_WINDOW_DAYS = 30;
const AGE_3M_DAYS = 90;
const AGE_12M_DAYS = 365;
/** 结果缓存:单次查询要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
let cachedResult = null;
let cachedResultAt = 0;
const toNumber = (v) => {
if (v == null) return null;
const n = Number(v);
@ -25,6 +38,14 @@ function nextDayAsOf(bizDate) {
return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`;
}
/** 发货窗口:[asOf - 30 天, asOf),滚动一个月 */
function shipWindow(asOf) {
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
const from = new Date(to.getTime() - SHIP_WINDOW_DAYS * 86400000);
const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' ');
return { shipFrom: fmt(from), shipTo: fmt(to) };
}
async function fetchLatestBizDate() {
const rows = await cloudDb.query(
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
@ -32,125 +53,242 @@ async function fetchLatestBizDate() {
return rows[0]?.d || null;
}
/** 上一自然日(日历天,用于取「期初 = 昨日结存」) */
function prevDay(bizDate) {
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
const d = new Date(`${bizDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
function buildInClause(values, prefix) {
const params = {};
const clause = values
.map((v, i) => {
const key = `${prefix}${i}`;
params[key] = v;
return `@${key}`;
})
.join(',');
return { clause, params };
}
/**
* 派生列(SQL CASE WHEN 下沉计算)
* - 库龄(天) = DATEDIFF(asOf, created)
* - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%
* 月度发货总件数 = threeAvgSale × 30;月度日均库存件数 = jsStock
* - 四级分级:库龄 + 周转率双维度
* 分级规则(库龄 + 周转率双维度),口径与业务给定一致:
* 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警
* 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流
* 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流
* 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞
* 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞
* 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸
* 边界:库龄正好 90 天不计入「慢流」(业务描述为 > 3 个月),正好 365 天仍属「3~12 个月」。
*/
function buildDerivedColumns(asOf) {
const A = '@asOf';
void A;
const ageDays = `DATEDIFF(DAY, m.created, @asOf)`;
const turnover = `(ISNULL(m.threeAvgSale, 0) * 30.0 * 100.0 / NULLIF(m.jsStock, 0))`;
return {
ageDays,
turnoverDays: `CAST(${turnover} AS DECIMAL(18, 4))`,
levelExpr: `
function buildLevelExpr() {
const t = 'b.turnoverPct';
return `
CASE
WHEN m.created IS NULL OR ISNULL(m.jsStock, 0) = 0 OR ${turnover} IS NULL THEN NULL
WHEN ${ageDays} < 90 AND ${turnover} BETWEEN 30 AND 199 THEN 'warn'
WHEN ${ageDays} < 90 AND ${turnover} >= 200 THEN 'slow'
WHEN ${ageDays} >= 90 AND ${turnover} BETWEEN 30 AND 99 THEN 'slow'
WHEN ${ageDays} BETWEEN 90 AND 365 AND ${turnover} >= 100 THEN 'stale'
WHEN ${ageDays} > 365 AND ${turnover} BETWEEN 100 AND 199 THEN 'stale'
WHEN ${ageDays} > 365 AND ${turnover} >= 200 THEN 'zombie'
WHEN b.stockQty <= 0 OR ${t} IS NULL OR b.ageDays IS NULL THEN NULL
WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 200 THEN 'warn'
WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 200 THEN 'slow'
WHEN b.ageDays > ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 100 THEN 'slow'
WHEN b.ageDays >= ${AGE_3M_DAYS} AND b.ageDays <= ${AGE_12M_DAYS} AND ${t} >= 100 THEN 'stale'
WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 100 AND ${t} < 200 THEN 'stale'
WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 200 THEN 'zombie'
ELSE NULL
END`,
};
END`;
}
/** 该仓快照里的四级库存量与平均周转天数 */
async function fetchGradeMetrics(dbWarehouse, bizDate, asOf) {
const D = buildDerivedColumns(asOf);
const rows = await cloudDb.query(
`SELECT COUNT(*) AS skuCount,
SUM(CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2))) AS gradeStock,
AVG(${D.turnoverDays}) AS avgTurnover,
SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel,
SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel,
SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel,
SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS zombieLevel
FROM rpt.pm_stockMonitor m
WHERE m.warehouse = @warehouse
AND CONVERT(VARCHAR(10), m.date, 23) = @bizDate`,
{ warehouse: dbWarehouse, bizDate, asOf }
);
const r = rows[0] || {};
if (!Number(r.skuCount || 0)) return null;
return {
skuCount: toNumber(r.skuCount),
gradeStock: toNumber(r.gradeStock),
turnoverDays: toNumber(r.avgTurnover),
warnLevel: toNumber(r.warnLevel),
slowLevel: toNumber(r.slowLevel),
staleLevel: toNumber(r.staleLevel),
zombieLevel: toNumber(r.zombieLevel),
};
/**
* 每个仓库的周转率与四级库存量(一次查询覆盖全部仓库)。
*
* 口径:
* 库存周转天数(周转率)= 月度发货总件数 / 月度日均库存件数 × 100%
* · 月度发货总件数 = 该仓近 30 天已发货订单件数(jst_OrderItem × jst_Orders,按 send_date,排除 Delete/Cancelled)
* · 月度日均库存件数 = 该仓聚水潭库存 jst_inventoryInfo(无历史结存表,以当日库存近似月度日均)
* 库龄(天) = DATEDIFF(基准时刻, SKU 建档时间 created)
*
* SKU 关联链路:jst_inventoryInfo.sku_id = jst_OrderItem.sku_id = pm_stockMonitor.sku_id(实测崔桥 1852/1852 命中)
*/
async function fetchGradeMetrics(wmsCoIds, bizDate, asOf) {
if (!wmsCoIds.length || !bizDate || !asOf) return new Map();
const invIn = buildInClause(wmsCoIds, 'w');
const shipIn = buildInClause(wmsCoIds, 'v');
const { shipFrom, shipTo } = shipWindow(asOf);
const level = buildLevelExpr();
const sqlText = `
WITH inv AS (
SELECT i.wms_co_id AS wmsCoId,
i.sku_id AS skuId,
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
FROM rpt.jst_inventoryInfo i
WHERE i.wms_co_id IN (${invIn.clause})
GROUP BY i.wms_co_id, i.sku_id
),
created AS (
SELECT m.sku_id AS skuId, MIN(m.created) AS created
FROM rpt.pm_stockMonitor m
WHERE CONVERT(VARCHAR(10), m.date, 23) = @bizDate
GROUP BY m.sku_id
),
ship AS (
SELECT o.wms_co_id AS wmsCoId,
oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS shipQty
FROM rpt.jst_OrderItem oi
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
WHERE o.wms_co_id IN (${shipIn.clause})
AND o.send_date >= @shipFrom
AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
GROUP BY o.wms_co_id, oi.sku_id
),
base AS (
SELECT v.wmsCoId,
v.skuId,
v.stockQty,
ISNULL(s.shipQty, 0) AS shipQty,
DATEDIFF(DAY, c.created, @asOf) AS ageDays,
CASE WHEN v.stockQty <= 0 THEN NULL
ELSE ISNULL(s.shipQty, 0) * 100.0 / v.stockQty END AS turnoverPct
FROM inv v
LEFT JOIN ship s ON s.wmsCoId = v.wmsCoId AND s.skuId = v.skuId
LEFT JOIN created c ON c.skuId = v.skuId
)
SELECT b.wmsCoId,
SUM(CASE WHEN ABS(b.stockQty) > 0.0001 THEN 1 ELSE 0 END) AS skuCount,
SUM(b.stockQty) AS totalStock,
SUM(b.shipQty) AS totalShip,
SUM(CASE WHEN b.ageDays IS NULL OR b.turnoverPct IS NULL OR b.stockQty <= 0 THEN 1 ELSE 0 END) AS ungradedSku,
CASE WHEN SUM(b.stockQty) > 0
THEN CAST(SUM(b.shipQty) * 100.0 / SUM(b.stockQty) AS DECIMAL(18, 2))
ELSE NULL END AS turnoverPct,
SUM(CASE WHEN ${level} = 'warn' THEN b.stockQty ELSE 0 END) AS warnLevel,
SUM(CASE WHEN ${level} = 'slow' THEN b.stockQty ELSE 0 END) AS slowLevel,
SUM(CASE WHEN ${level} = 'stale' THEN b.stockQty ELSE 0 END) AS staleLevel,
SUM(CASE WHEN ${level} = 'zombie' THEN b.stockQty ELSE 0 END) AS zombieLevel
FROM base b
GROUP BY b.wmsCoId`;
const rows = await cloudDb.query(sqlText, {
...invIn.params,
...shipIn.params,
bizDate,
asOf,
shipFrom,
shipTo,
}, { requestTimeout: 120000 });
const map = new Map();
rows.forEach((r) => {
map.set(String(r.wmsCoId), {
skuCount: toNumber(r.skuCount) || 0,
gradeStock: toNumber(r.totalStock) || 0,
shipQty: toNumber(r.totalShip) || 0,
turnoverDays: toNumber(r.turnoverPct),
warnLevel: toNumber(r.warnLevel) || 0,
slowLevel: toNumber(r.slowLevel) || 0,
staleLevel: toNumber(r.staleLevel) || 0,
zombieLevel: toNumber(r.zombieLevel) || 0,
ungradedSku: toNumber(r.ungradedSku) || 0,
});
});
return map;
}
/** 实时期末库存(单仓 wms_co_id),并带回最近更新时间便于核对时效 */
async function fetchClosingQty(wmsCoId) {
if (wmsCoId == null) return null;
/** 实时库存表的数据时效(一次取全局即可) */
async function fetchInventoryTakenAt() {
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS qty,
COUNT(CASE WHEN ISNULL(qty, 0) <> 0 THEN 1 END) AS skuCount,
MAX(modified) AS takenAt
FROM rpt.jst_inventoryInfo
WHERE wms_co_id = @wmsCoId`,
{ wmsCoId }
`SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo`
);
const r = rows[0] || {};
return {
closingQty: toNumber(r.qty),
closingSkuCount: toNumber(r.skuCount) || 0,
closingTakenAt: r.takenAt || null,
};
return rows[0]?.takenAt || null;
}
/**
* GET /api/daily-inventory
* 每日进销存:25 个仓库 × 进销存流水 + 期末 + 分级库存量
* 每日进销存:26 个仓库 ×(期初 / 期末 / 周转率 / 四级库存量)
*
* 数据源分工:
* - 期初数量 与 12 个流水列:22 号基准(dailyInventoryBaseline.js)
* - 期末数量:jst_inventoryInfo 实时库存
* - 库存周转天数 / 四级库存量:pm_stockMonitor 快照
* 数据源分工(全部来自数据库,无硬编码业务数值):
* - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存
* - 期末数量:jst_inventoryInfo 实时库存(按 wms_co_id 单仓)
* - 库存周转天数 / 四级库存量:近 30 天发货 × 实时库存 × SKU 建档日期
* - 12 个流水列:业务库缺调拨/盘点/加工/其它出入库单据表,返回 null(前端显示「—」)
*/
router.get('/', async (req, res) => {
try {
const force = String(req.query?.refresh || '') === '1';
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const cacheKey = latestBizDate || 'none';
if (!force && cachedResult && cachedResult.latestBizDate === cacheKey
&& Date.now() - cachedResultAt < RESULT_CACHE_TTL_MS) {
return res.json({ success: true, data: cachedResult, cached: true });
}
const asOf = nextDayAsOf(latestBizDate);
const openingDate = prevDay(latestBizDate);
const wmsCoIds = DAILY_INVENTORY_BASELINE
.map((b) => b.wmsCoId)
.filter((v) => v != null);
const items = [];
for (const base of DAILY_INVENTORY_BASELINE) {
const [gradeMap, takenAt] = await Promise.all([
fetchGradeMetrics(wmsCoIds, latestBizDate, asOf).catch((e) => {
console.error('[daily-inventory] 分级计算失败:', e.message);
return new Map();
}),
fetchInventoryTakenAt().catch(() => null),
]);
// 期初 = 上一业务日的期末快照;若定时任务漏跑,顺手把当前业务日的快照补齐
const 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)))
.map((b) => {
const g = gradeMap.get(String(b.wmsCoId));
return {
wmsCoId: b.wmsCoId,
warehouse: b.warehouse,
dbWarehouse: b.dbWarehouse,
closingQty: Number(g?.gradeStock ?? 0),
skuCount: Number(g?.skuCount ?? 0),
takenAt: takenAt ? new Date(takenAt) : null,
};
});
if (missing.length) {
await snapshotDb.upsertSnapshot(latestBizDate, missing).catch((e) => {
console.error('[daily-inventory] 快照补写失败:', e.message);
});
}
const items = DAILY_INVENTORY_BASELINE.map((base) => {
// 12 个流水列:业务库无单据来源,一律 null(前端渲染为「—」)
const flows = {};
for (const col of FLOW_COLUMNS) flows[col.field] = base[col.field] ?? 0;
for (const col of FLOW_COLUMNS) flows[col.field] = null;
const closing = await fetchClosingQty(base.wmsCoId).catch(() => null);
const grade = base.dbWarehouse && latestBizDate
? await fetchGradeMetrics(base.dbWarehouse, latestBizDate, asOf).catch(() => null)
: null;
const grade = base.wmsCoId != null ? gradeMap.get(String(base.wmsCoId)) : null;
const opening = base.wmsCoId != null ? openingMap.get(String(base.wmsCoId)) : null;
items.push({
return {
warehouse: base.warehouse,
dbWarehouse: base.dbWarehouse,
openingQty: base.openingQty,
openingQtyExtra: base.openingQtyExtra,
openingQty: opening ? opening.closingQty : null,
openingQtyExtra: null,
...flows,
closingQty: closing?.closingQty ?? null,
closingSkuCount: closing?.closingSkuCount ?? null,
closingTakenAt: closing?.closingTakenAt ?? null,
closingQty: grade?.gradeStock ?? null,
closingSkuCount: grade?.skuCount ?? null,
closingTakenAt: takenAt,
shipQty30d: grade?.shipQty ?? null,
turnoverDays: grade?.turnoverDays ?? null,
gradeStock: grade?.gradeStock ?? null,
warnLevel: grade?.warnLevel ?? null,
slowLevel: grade?.slowLevel ?? null,
staleLevel: grade?.staleLevel ?? null,
zombieLevel: grade?.zombieLevel ?? null,
ungradedSku: grade?.ungradedSku ?? null,
hasGrade: Boolean(grade),
});
}
};
});
// 合计行:便于与表格总额核对
const sum = (k) => items.reduce((acc, it) => acc + (Number(it[k]) || 0), 0);
@ -158,21 +296,32 @@ router.get('/', async (req, res) => {
for (const k of ['openingQty', 'openingQtyExtra', ...FLOW_COLUMNS.map((c) => c.field), 'closingQty', 'warnLevel', 'slowLevel', 'staleLevel', 'zombieLevel']) {
totals[k] = Number(sum(k).toFixed(2));
}
// 合计行周转率按「总发货 / 总库存」重算,避免简单累加失真
totals.shipQty30d = Number(sum('shipQty30d').toFixed(2));
const totalStock = sum('closingQty');
totals.turnoverDays = totalStock > 0
? Number((sum('shipQty30d') * 100 / totalStock).toFixed(2))
: null;
res.json({
success: true,
data: {
baselineDate: BASELINE_DATE,
latestBizDate,
asOf,
flowColumns: FLOW_COLUMNS,
items,
totals,
},
});
const noOpeningCount = items.filter((it) => it.openingQty == null).length;
const payload = {
openingDate,
latestBizDate,
asOf,
shipWindow: asOf ? { ...shipWindow(asOf), days: SHIP_WINDOW_DAYS } : null,
flowColumns: FLOW_COLUMNS,
flowColumnsAvailable: false,
noOpeningCount,
items,
totals,
};
cachedResult = payload;
cachedResultAt = Date.now();
return res.json({ success: true, data: payload });
} catch (error) {
console.error('[daily-inventory] 查询失败:', error.message);
res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message });
return res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message });
}
});

View File

@ -1,558 +1,209 @@
/**
* 每日进销存 —— 2026-09-22 进销存基准流水
* 每日进销存 —— 仓库清单(纯映射,不含业务数值)
*
* 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出)
* 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库
* 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。
* 重要:本文件只描述「要统计哪些仓库」以及它们与聚水潭 wms_co_id 的对应关系,
* 不再硬编码任何业务数值。所有数字都从数据库取:
* - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存
* - 期末数量:业务库实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓)
* - 周转率 / 四级库存量:近 30 天发货 × 实时库存 × SKU 库龄,见 src/api/dailyInventory.js
* - 12 个流水列:业务库缺少调拨/盘点/加工/其它出入库单据表,暂无法显示(前端渲染为「—」)
*
* 口径约定:
* - openingQty 与 12 个流水列:22 号基准值(本文件静态存储)
* - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确)
* - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算
* 仓库清单随聚水潭仓库档案变化时需同步维护(来源表:rpt.jst_warehouseInfo)。
*/
/** 一级/二级菜单名(取自表格) */
/** 一级/二级菜单名 */
const DAILY_INVENTORY_LEVEL1 = '大货库存监控';
const DAILY_INVENTORY_LEVEL2 = '每日进销存';
/** 基准数据日期 */
const BASELINE_DATE = '2026-09-22';
/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */
/** 12 个流水列的中文表头与字段映射(顺序与聚水潭进销存导出一致) */
const FLOW_COLUMNS = [
{
"label": "加工进仓",
"field": "processIn"
},
{
"label": "采购退货",
"field": "purchaseReturn"
},
{
"label": "采购进仓",
"field": "purchaseIn"
},
{
"label": "其它进仓",
"field": "otherIn"
},
{
"label": "其它退货",
"field": "otherReturn"
},
{
"label": "销售出仓",
"field": "salesOut"
},
{
"label": "盘点",
"field": "stocktaking"
},
{
"label": "加工出仓",
"field": "processOut"
},
{
"label": "调拨出",
"field": "transferOut"
},
{
"label": "销售退货",
"field": "salesReturn"
},
{
"label": "调拨入",
"field": "transferIn"
},
{
"label": "其它出库",
"field": "otherOut"
}
];
{
"label": "加工进仓",
"field": "processIn"
},
{
"label": "采购退货",
"field": "purchaseReturn"
},
{
"label": "采购进仓",
"field": "purchaseIn"
},
{
"label": "其它进仓",
"field": "otherIn"
},
{
"label": "其它退货",
"field": "otherReturn"
},
{
"label": "销售出仓",
"field": "salesOut"
},
{
"label": "盘点",
"field": "stocktaking"
},
{
"label": "加工出仓",
"field": "processOut"
},
{
"label": "调拨出",
"field": "transferOut"
},
{
"label": "销售退货",
"field": "salesReturn"
},
{
"label": "调拨入",
"field": "transferIn"
},
{
"label": "其它出库",
"field": "otherOut"
}
];
/** 25 个仓库的 22 号基准流水 */
/** 纳入口径的仓库(warehouse = 聚水潭主仓名,dbWarehouse = 库存监控表仓库名) */
const DAILY_INVENTORY_BASELINE = [
{
"warehouse": "简尚-崔桥工厂仓主仓",
"dbWarehouse": "简尚-崔桥工厂仓",
"wmsCoId": 15038426,
"openingQty": 92388,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -3924,
"stocktaking": -1,
"processOut": 0,
"transferOut": -1461,
"salesReturn": 0,
"transferIn": 5437,
"otherOut": 0
},
{
"warehouse": "简尚-山东青岗集仓主仓",
"dbWarehouse": "简尚-山东青岗集仓",
"wmsCoId": 15137523,
"openingQty": 88377,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 886,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -1058,
"stocktaking": -1,
"processOut": 0,
"transferOut": -11,
"salesReturn": 0,
"transferIn": 23,
"otherOut": 0
},
{
"warehouse": "简尚-河南洛宁仓主仓",
"dbWarehouse": "简尚-河南洛宁仓",
"wmsCoId": 15539084,
"openingQty": 83305,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -314,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 15,
"otherOut": 0
},
{
"warehouse": "简尚-新苏北仓主仓",
"dbWarehouse": "简尚-新苏北仓",
"wmsCoId": 15038347,
"openingQty": 77145,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -1744,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 649,
"otherOut": 0
},
{
"warehouse": "简尚-横林二仓主仓",
"dbWarehouse": "简尚-横林二仓",
"wmsCoId": 15033302,
"openingQty": 63780,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -982,
"stocktaking": 0,
"processOut": 0,
"transferOut": -17,
"salesReturn": 0,
"transferIn": 728,
"otherOut": 0
},
{
"warehouse": "简尚-山东品牌仓主仓",
"dbWarehouse": "简尚-山东品牌仓",
"wmsCoId": 15182705,
"openingQty": 41025,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 150,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -157,
"stocktaking": 0,
"processOut": 0,
"transferOut": -5,
"salesReturn": 0,
"transferIn": 4,
"otherOut": 0
},
{
"warehouse": "简尚-睢宁仓主仓",
"dbWarehouse": "简尚-睢宁仓",
"wmsCoId": 15032603,
"openingQty": 25761,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -922,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 333,
"otherOut": 0
},
{
"warehouse": "简尚-分销部专用仓主仓",
"dbWarehouse": "简尚-分销部专用仓",
"wmsCoId": 15045426,
"openingQty": 6380,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -264,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 200,
"otherOut": 0
},
{
"warehouse": "简尚-新外贸仓主仓",
"dbWarehouse": "简尚-新外贸仓",
"wmsCoId": 15045308,
"openingQty": 5165,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "京仓-横林二仓京东自营仓主仓",
"dbWarehouse": "京仓-横林二仓京东自营仓",
"wmsCoId": 15835893,
"openingQty": 4260,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-分销部睢宁专用仓主仓",
"dbWarehouse": "简尚-分销部睢宁专用仓",
"wmsCoId": 15051960,
"openingQty": 3711,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-崔桥工厂品牌仓主仓",
"dbWarehouse": "简尚-崔桥工厂品牌仓",
"wmsCoId": 15870495,
"openingQty": 1831,
"openingQtyExtra": 0,
"processIn": 94,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -53,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-分销部苏北专用仓主仓",
"dbWarehouse": "简尚-分销部苏北专用仓",
"wmsCoId": 15045325,
"openingQty": 1440,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营C横林二仓专用仓主仓",
"dbWarehouse": "简尚-运营C横林二仓专用仓",
"wmsCoId": 15301814,
"openingQty": 2141,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -992,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-济南外贸仓主仓",
"dbWarehouse": "简尚-济南外贸仓",
"wmsCoId": 15051888,
"openingQty": 712,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-分销部崔桥专用仓主仓",
"dbWarehouse": "简尚-分销部崔桥专用仓",
"wmsCoId": 15404402,
"openingQty": 620,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营C崔桥工厂仓专用仓主仓",
"dbWarehouse": "简尚-运营C崔桥工厂仓专用仓",
"wmsCoId": 15301815,
"openingQty": 286,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营D苏北专用仓主仓",
"dbWarehouse": "简尚-运营D苏北专用仓",
"wmsCoId": 15406876,
"openingQty": 280,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营C睢宁专用仓主仓",
"dbWarehouse": "简尚-运营C睢宁专用仓",
"wmsCoId": 15301813,
"openingQty": 193,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营C新苏北专用仓主仓",
"dbWarehouse": "简尚-运营C新苏北专用仓",
"wmsCoId": 15301812,
"openingQty": 155,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营D睢宁专用仓主仓",
"dbWarehouse": "简尚-运营D睢宁专用仓",
"wmsCoId": 15051907,
"openingQty": 109,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -1,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-山东青岗集京东仓主仓",
"dbWarehouse": "简尚-山东青岗集京东仓",
"wmsCoId": 15406664,
"openingQty": 90,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-运营D专用仓库主仓",
"dbWarehouse": "简尚-运营D专用仓库",
"wmsCoId": 15051915,
"openingQty": 180,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -125,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "简尚-崔桥工厂京东仓主仓",
"dbWarehouse": "简尚-崔桥工厂京东仓",
"wmsCoId": 15045345,
"openingQty": 1,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": 0,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
},
{
"warehouse": "京仓-横林二仓京东云仓主仓",
"dbWarehouse": "京仓-横林二仓京东云仓",
"wmsCoId": 15197138,
"openingQty": -3082,
"openingQtyExtra": 0,
"processIn": 0,
"purchaseReturn": 0,
"purchaseIn": 0,
"otherIn": 0,
"otherReturn": 0,
"salesOut": -48,
"stocktaking": 0,
"processOut": 0,
"transferOut": 0,
"salesReturn": 0,
"transferIn": 0,
"otherOut": 0
}
];
{
"warehouse": "简尚-崔桥工厂半成品包装项目仓主仓",
"dbWarehouse": "简尚-崔桥工厂半成品包装项目仓",
"wmsCoId": 15718196
},
{
"warehouse": "简尚-崔桥工厂仓主仓",
"dbWarehouse": "简尚-崔桥工厂仓",
"wmsCoId": 15038426
},
{
"warehouse": "简尚-山东青岗集仓主仓",
"dbWarehouse": "简尚-山东青岗集仓",
"wmsCoId": 15137523
},
{
"warehouse": "简尚-河南洛宁仓主仓",
"dbWarehouse": "简尚-河南洛宁仓",
"wmsCoId": 15539084
},
{
"warehouse": "简尚-新苏北仓主仓",
"dbWarehouse": "简尚-新苏北仓",
"wmsCoId": 15038347
},
{
"warehouse": "简尚-横林二仓主仓",
"dbWarehouse": "简尚-横林二仓",
"wmsCoId": 15033302
},
{
"warehouse": "简尚-山东品牌仓主仓",
"dbWarehouse": "简尚-山东品牌仓",
"wmsCoId": 15182705
},
{
"warehouse": "简尚-睢宁仓主仓",
"dbWarehouse": "简尚-睢宁仓",
"wmsCoId": 15032603
},
{
"warehouse": "简尚-分销部专用仓主仓",
"dbWarehouse": "简尚-分销部专用仓",
"wmsCoId": 15045426
},
{
"warehouse": "简尚-新外贸仓主仓",
"dbWarehouse": "简尚-新外贸仓",
"wmsCoId": 15045308
},
{
"warehouse": "简尚-分销部睢宁专用仓主仓",
"dbWarehouse": "简尚-分销部睢宁专用仓",
"wmsCoId": 15051960
},
{
"warehouse": "京仓-山东青岗集京东自营仓主仓",
"dbWarehouse": "京仓-山东青岗集京东自营仓",
"wmsCoId": 15835935
},
{
"warehouse": "简尚-崔桥工厂品牌仓主仓",
"dbWarehouse": "简尚-崔桥工厂品牌仓",
"wmsCoId": 15870495
},
{
"warehouse": "简尚-分销部苏北专用仓主仓",
"dbWarehouse": "简尚-分销部苏北专用仓",
"wmsCoId": 15045325
},
{
"warehouse": "简尚-运营C横林二仓专用仓主仓",
"dbWarehouse": "简尚-运营C横林二仓专用仓",
"wmsCoId": 15301814
},
{
"warehouse": "简尚-济南外贸仓主仓",
"dbWarehouse": "简尚-济南外贸仓",
"wmsCoId": 15051888
},
{
"warehouse": "简尚-分销部崔桥专用仓主仓",
"dbWarehouse": "简尚-分销部崔桥专用仓",
"wmsCoId": 15404402
},
{
"warehouse": "简尚-运营C崔桥工厂仓专用仓主仓",
"dbWarehouse": "简尚-运营C崔桥工厂仓专用仓",
"wmsCoId": 15301815
},
{
"warehouse": "简尚-运营D苏北专用仓主仓",
"dbWarehouse": "简尚-运营D苏北专用仓",
"wmsCoId": 15406876
},
{
"warehouse": "简尚-运营C睢宁专用仓主仓",
"dbWarehouse": "简尚-运营C睢宁专用仓",
"wmsCoId": 15301813
},
{
"warehouse": "简尚-运营C新苏北专用仓主仓",
"dbWarehouse": "简尚-运营C新苏北专用仓",
"wmsCoId": 15301812
},
{
"warehouse": "简尚-运营D睢宁专用仓主仓",
"dbWarehouse": "简尚-运营D睢宁专用仓",
"wmsCoId": 15051907
},
{
"warehouse": "简尚-山东青岗集京东仓主仓",
"dbWarehouse": "简尚-山东青岗集京东仓",
"wmsCoId": 15406664
},
{
"warehouse": "简尚-运营D专用仓库主仓",
"dbWarehouse": "简尚-运营D专用仓库",
"wmsCoId": 15051915
},
{
"warehouse": "简尚-崔桥工厂京东仓主仓",
"dbWarehouse": "简尚-崔桥工厂京东仓",
"wmsCoId": 15045345
},
{
"warehouse": "京仓-横林二仓京东自营仓主仓",
"dbWarehouse": "京仓-横林二仓京东自营仓",
"wmsCoId": 15835893
}
];
module.exports = {
DAILY_INVENTORY_LEVEL1,
DAILY_INVENTORY_LEVEL2,
BASELINE_DATE,
FLOW_COLUMNS,
DAILY_INVENTORY_BASELINE,
};

View File

@ -0,0 +1,137 @@
/**
* 每日进销存 —— 本地库存快照库访问
*
* 背景:业务库(MSSQL / JSReportReplica)只有「当前」实时库存 jst_inventoryInfo,
* 没有按天的历史库存结存表,因此「期初数量」无法回溯。
* 解决办法:项目本地 MySQL 每天落一份按仓库存的快照,
* 今日期初 = 上一业务日快照,期末 = 实时库存。
*
* 连接复用 LOCAL_DB_* 配置(与项目其它本地表同库)。
*/
const path = require('path');
const dotenv = require('dotenv');
const mysql = require('mysql2/promise');
dotenv.config();
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const TABLE = 'daily_inventory_snapshot';
const CREATE_SQL = `
CREATE TABLE IF NOT EXISTS ${TABLE} (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
biz_date DATE NOT NULL COMMENT '业务日期(T-1)',
wms_co_id BIGINT NOT NULL COMMENT '聚水潭仓库 ID',
warehouse VARCHAR(128) NOT NULL COMMENT '仓库名(主仓)',
db_warehouse VARCHAR(128) NULL COMMENT '监控表仓库名',
closing_qty DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '该业务日期末数量',
sku_count INT NOT NULL DEFAULT 0 COMMENT '有库存 SKU 数',
taken_at DATETIME NULL COMMENT '源库库存数据更新时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_date_wh (biz_date, wms_co_id),
KEY idx_date (biz_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日进销存-按仓库存快照';`;
let pool = null;
function getPool() {
if (pool) return pool;
pool = mysql.createPool({
host: process.env.LOCAL_DB_HOST,
port: Number(process.env.LOCAL_DB_PORT || 3306),
user: process.env.LOCAL_DB_USER,
password: process.env.LOCAL_DB_PASSWORD,
database: process.env.LOCAL_DB_NAME,
waitForConnections: true,
connectionLimit: 5,
connectTimeout: 10000,
// DATE/DATETIME 直接返回字符串,避免 JS Date 按本地时区回读时整体偏移一天
dateStrings: true,
});
return pool;
}
async function ensureTable() {
await getPool().query(CREATE_SQL);
}
/**
* 写入某业务日的期末快照(幂等,重复执行覆盖)
* @param {string} bizDate YYYY-MM-DD
* @param {Array<{wmsCoId:number, warehouse:string, dbWarehouse?:string, closingQty:number, skuCount:number, takenAt?:Date|null}>} rows
*/
async function upsertSnapshot(bizDate, rows) {
if (!bizDate || !rows.length) return 0;
await ensureTable();
const conn = await getPool().getConnection();
try {
for (const r of rows) {
await conn.query(
`INSERT INTO ${TABLE} (biz_date, wms_co_id, warehouse, db_warehouse, closing_qty, sku_count, taken_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE warehouse = VALUES(warehouse), db_warehouse = VALUES(db_warehouse),
closing_qty = VALUES(closing_qty), sku_count = VALUES(sku_count), taken_at = VALUES(taken_at)`,
[bizDate, r.wmsCoId, r.warehouse, r.dbWarehouse || null, r.closingQty, r.skuCount, r.takenAt || null]
);
}
return rows.length;
} finally {
conn.release();
}
}
/**
* 读取某业务日的期末快照
* @returns {Promise<Map<string, {closingQty:number, skuCount:number, takenAt:Date|null}>>} key = wmsCoId 字符串
*/
async function fetchSnapshot(bizDate) {
const map = new Map();
if (!bizDate) return map;
try {
await ensureTable();
const [rows] = await getPool().query(
`SELECT wms_co_id AS wmsCoId, closing_qty AS closingQty, sku_count AS skuCount, taken_at AS takenAt
FROM ${TABLE} WHERE biz_date = ?`,
[bizDate]
);
rows.forEach((r) => map.set(String(r.wmsCoId), {
closingQty: Number(r.closingQty),
skuCount: Number(r.skuCount),
takenAt: r.takenAt || null,
}));
} catch (error) {
console.error('[daily-inventory-snapshot] 读取快照失败:', error.message);
}
return map;
}
/** 已有快照的业务日(倒序,最多 n 条) */
async function listSnapshotDates(limit = 10) {
try {
await ensureTable();
const [rows] = await getPool().query(
`SELECT DISTINCT biz_date AS d FROM ${TABLE} ORDER BY biz_date DESC LIMIT ?`,
[Number(limit) || 10]
);
return rows.map((r) => {
if (typeof r.d === 'string') return r.d.slice(0, 10);
const v = new Date(r.d);
const pad = (n) => String(n).padStart(2, '0');
return `${v.getFullYear()}-${pad(v.getMonth() + 1)}-${pad(v.getDate())}`;
});
} catch (error) {
console.error('[daily-inventory-snapshot] 读取快照日期失败:', error.message);
return [];
}
}
module.exports = {
TABLE,
CREATE_SQL,
getPool,
ensureTable,
upsertSnapshot,
fetchSnapshot,
listSnapshotDates,
};

View File

@ -51,6 +51,9 @@ const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMa
const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob');
const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob');
const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
const {
startDailyInventorySnapshotScheduler,
} = require('./jobs/dailyInventorySnapshotJob');
const {
startAlibaba1688ImageSearchExpiryScheduler,
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
@ -768,6 +771,7 @@ async function startServer(options = {}) {
startGoodsFirstSeenScheduler();
startAlibaba1688ImageSearchExpiryScheduler();
startMkhReplenishAlertScheduler();
startDailyInventorySnapshotScheduler();
// Keep the first selection-pool page request from paying the remote schema
// inspection round-trip over the planning database tunnel.

View File

@ -0,0 +1,122 @@
'use strict';
/**
* 每日库存快照任务
*
* 业务库没有按天的历史库存结存表,「期初数量」无法回溯。
* 本任务每天定时把 26 个仓库的实时库存落一份到本地快照表 daily_inventory_snapshot,
* 使「今日期初 = 上一业务日期末」能够自动滚动。
*
* 默认 01:30 执行(报表库 01:00 跑批之后)。可用环境变量覆盖:
* DAILY_INVENTORY_SNAPSHOT_ENABLED=false 关闭
* DAILY_INVENTORY_SNAPSHOT_CRON='30 1 * * *'
*/
const cron = require('node-cron');
const cloudDb = require('../config/cloudDatabase');
const snapshotDb = require('../config/dailyInventorySnapshotDb');
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
let running = false;
let timer = null;
const JOB_ENABLED = String(process.env.DAILY_INVENTORY_SNAPSHOT_ENABLED || 'true').toLowerCase() !== 'false';
const JOB_CRON = process.env.DAILY_INVENTORY_SNAPSHOT_CRON || '30 1 * * *';
/** 业务日 = 库存快照库最新日期(报表库每日凌晨跑批,标注 T-1) */
async function resolveBizDate() {
const rows = await cloudDb.query(
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
);
return rows[0]?.d || null;
}
function buildInClause(values, prefix) {
const params = {};
const clause = values
.map((v, i) => {
params[`${prefix}${i}`] = v;
return `@${prefix}${i}`;
})
.join(',');
return { clause, params };
}
async function syncOnce(trigger = 'cron') {
if (running) {
console.log(`[DailyInventorySnapshot] 上一次任务未结束,跳过 trigger=${trigger}`);
return { success: false, skipped: true, reason: 'already_running' };
}
running = true;
const startedAt = Date.now();
try {
const bizDate = await resolveBizDate();
if (!bizDate) {
console.log('[DailyInventorySnapshot] 未取到业务日,跳过');
return { success: false, skipped: true, reason: 'no_biz_date' };
}
const whs = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
const { clause, params } = buildInClause(whs.map((b) => b.wmsCoId), 'w');
const rows = await cloudDb.query(
`SELECT i.wms_co_id AS wmsCoId,
ISNULL(SUM(CAST(i.qty AS DECIMAL(18,2))), 0) AS closingQty,
SUM(CASE WHEN ABS(ISNULL(i.qty, 0)) > 0.0001 THEN 1 ELSE 0 END) AS skuCount,
MAX(i.modified) AS takenAt
FROM rpt.jst_inventoryInfo i
WHERE i.wms_co_id IN (${clause})
GROUP BY i.wms_co_id`,
params,
{ requestTimeout: 120000 }
);
const m = new Map(rows.map((r) => [String(r.wmsCoId), r]));
const payload = whs.map((b) => {
const r = m.get(String(b.wmsCoId));
return {
wmsCoId: b.wmsCoId,
warehouse: b.warehouse,
dbWarehouse: b.dbWarehouse,
closingQty: Number(r?.closingQty ?? 0),
skuCount: Number(r?.skuCount ?? 0),
takenAt: r?.takenAt ? new Date(r.takenAt) : null,
};
});
const n = await snapshotDb.upsertSnapshot(bizDate, payload);
console.log(`[DailyInventorySnapshot] 业务日 ${bizDate} 写入 ${n}/${whs.length} 仓,耗时 ${Date.now() - startedAt}ms`);
return { success: true, bizDate, count: n };
} catch (error) {
console.error('[DailyInventorySnapshot] 执行失败:', error.message);
return { success: false, error: error.message };
} finally {
running = false;
}
}
function startDailyInventorySnapshotScheduler() {
if (!JOB_ENABLED) {
console.log('[DailyInventorySnapshot] 已通过环境变量关闭');
return null;
}
if (timer) return timer;
if (!cron.validate(JOB_CRON)) {
console.error(`[DailyInventorySnapshot] cron 表达式非法: ${JOB_CRON}`);
return null;
}
timer = cron.schedule(JOB_CRON, () => { syncOnce('cron'); }, { timezone: 'Asia/Shanghai' });
console.log(`[DailyInventorySnapshot] 已启动,cron=${JOB_CRON} (Asia/Shanghai)`);
return timer;
}
function stopDailyInventorySnapshotScheduler() {
if (timer) {
timer.stop();
timer = null;
}
}
module.exports = {
syncOnce,
startDailyInventorySnapshotScheduler,
stopDailyInventorySnapshotScheduler,
};

View File

@ -0,0 +1,101 @@
# 业务库补表需求:每日进销存流水
> 提给:DBA / 聚水潭数据同步负责人
> 提出方:仓库系统(cangku)· 大货库存监控 → 每日进销存
> 日期:2026-09-24
## 背景
「每日进销存」页面需要按仓库展示 26 个仓的日内进销存流水,表头 21 列:
| # | 列 | # | 列 |
|---|---|---|---|
| 1 | 仓库 | 12 | 销售退货 |
| 2 | 期初数量 | 13 | 调拨入 |
| 3 | 加工进仓 | 14 | 期初(2) |
| 4 | 采购退货 | 15 | 其它出库 |
| 5 | 采购进仓 | 16 | 期末数量 |
| 6 | 其它进仓 | 17 | 库存周转天数 |
| 7 | 其它退货 | 18 | 滞销预警级库存量 |
| 8 | 销售出仓 | 19 | 商品慢流级库存量 |
| 9 | 盘点 | 20 | 商品呆滞级库存量 |
| 10 | 加工出仓 | 21 | 商品僵尸级库存量 |
| 11 | 调拨出 | | |
其中 1、16~21 列已能从业务库算出(见下表「已具备」),**2~15 列目前无法从业务库还原**。
## 已具备(无需补表)
| 列 | 来源 | 说明 |
|---|---|---|
| 仓库 | `rpt.jst_warehouseInfo` | `wms_co_id` 与仓库名映射 |
| 期末数量 | `rpt.jst_inventoryInfo` | 按 `wms_co_id` 单仓 `SUM(qty)`,实时 |
| 库存周转天数 | 近 30 天发货 ÷ 实时库存 | `jst_OrderItem` × `jst_Orders`(`send_date`) |
| 四级库存量 | 实时库存 × 发货 × `pm_stockMonitor.created` | 库龄 = 基准时刻 − SKU 建档时间 |
## 缺口一:没有按天的历史库存结存表(阻塞「期初数量」)
`rpt.jst_inventoryInfo` 只有**当前**库存,没有历史版本;`rpt.pm_stockMonitor` 是「归属仓」口径的每日快照(`jsStock`),与实物库存口径不一致(崔桥 115135 vs 实物 92439),不能当结存用。
**现状的临时方案**:项目本地 MySQL 建了 `daily_inventory_snapshot` 表,每天 01:30 把 26 仓实时库存落一份快照,`今日期初 = 昨日快照`。这是自采数据,一旦任务漏跑就会断链。
**请求**:请提供按天、按仓、按 SKU 的库存结存表,形如:
```
jst_inventoryDaily(
biz_date DATE, -- 业务日
wms_co_id BIGINT, -- 仓库
sku_id VARCHAR, -- SKU
qty DECIMAL, -- 当日结存
...
)
```
有了它,「期初数量」即可直接取上一业务日结存,本地快照表可退役。
## 缺口二:缺少 4 类单据表(阻塞 12 个流水列)
业务库 `rpt` 下已排查过全部 220 张表,**不存在**以下单据:
| 缺失单据 | 影响的列 |
|---|---|
| 调拨单(调拨出 / 调拨入) | 调拨出、调拨入 |
| 盘点单 | 盘点 |
| 加工单(加工进仓 / 加工出仓) | 加工进仓、加工出仓 |
| 其它出入库单 | 其它进仓、其它退货、其它出库、期初(2) |
请补充按单据行(SKU 级)的明细表,至少包含:
```
业务日期 / 单据号 / 仓库 wms_co_id / sku_id / 数量 / 单据类型(出|入) / 状态
```
## 缺口三:已有单据的数值与进销存口径不一致(阻塞 销售出仓 / 采购进仓 / 采购退货 / 销售退货)
这 4 类单据表**存在**,但按天汇总后与聚水潭进销存导出的数值对不上,不能直接用:
| 列 | 现有来源 | 9-23 崔桥工厂仓实测 | 基准值 | 偏差 |
|---|---|---:|---:|---|
| 销售出仓 | `jst_OrderItem` × `jst_Orders` 按 `send_date` | 1,769 | 4,273 | **−59%** |
| 销售出仓 | 同上,按 `order_date` | 1,723 | 4,273 | −60% |
| 销售出仓 | 同上,按 `modified` | 5,471 | 4,273 | +28% |
| 采购进仓 | `jst_purchaseItem.inQty` × `jst_purchase` 按 `confirm_date` | 数百 | 1,295 | 严重偏低 |
其它已排除的候选:
- `rpt.jst_salesOutboundInfo`:`qty` 全为 0,且 `order_type` 只含销售类订单,不含调拨 / 其它出库。
- `rpt.jst_originalBillInfo`(1,500 万行):是支付宝 / 财务账单表(`channel=alipayCSV`、`fee_name=交易收款`、`sku_id` 为 null),与库存流水无关。
- `rpt.jst_qmcust_salesoutbound`(奇门出库单):非本业务口径。
**请求**:确认这 4 类表在报表库(JSReportReplica)的同步是否完整、以及「进销存导出」所用的日期字段是哪一个(发 / 审 / 确 / 记账)。给出字段口径后这 4 列即可接入。
## 验收标准
补表后,任一业务日应满足行内恒等式:
```
期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入
− 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库
```
页面会以该恒等式做逐仓自校验,全部通过后才会撤下「暂缺单据来源」的提示。

View File

@ -3,7 +3,8 @@ import request from './request';
const LIST_TIMEOUT_MS = 5 * 60 * 1000;
// 每日进销存:25 仓 × 进销存流水 + 实时期末 + 分级库存量
export const getDailyInventory = () => request.get(
// refresh=1 跳过后端 5 分钟缓存,强制重算(一次要扫订单明细,约 15~20s)
export const getDailyInventory = (params = {}) => request.get(
'/api/daily-inventory',
{ timeout: LIST_TIMEOUT_MS }
{ params, timeout: LIST_TIMEOUT_MS }
);

View File

@ -3,59 +3,74 @@
*
* 表头取自桌面《库存监控.xlsx》,顺序与表格一致。
* source 标记该列数据来源,便于在页面上如实标注口径:
* - baseline:22 号基准值(业务库无对应单据表,无法还原)
* - snapshot:来自项目本地快照表 daily_inventory_snapshot(每日定时落库,自动滚动)
* - runtime:运行时从业务库实时取数
* - unavailable:业务库缺少对应单据表,暂无法计算(渲染为「—」)
*/
export const COLUMN_SOURCE = {
baseline: 'baseline',
snapshot: 'snapshot',
runtime: 'runtime',
unavailable: 'unavailable',
};
const NO_SOURCE_TIP = '业务库 JSReportReplica 缺少该单据表,无法还原,待 DBA 补充后自动接入(详见 docs/库存流水单据表需求.md)';
/** 表格存在两个名为「期初」的列,第二个加后缀区分 */
export const DAILY_INVENTORY_COLUMNS = [
{ key: 'warehouse', label: '仓库', source: 'baseline', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' },
{ key: 'openingQty', label: '期初数量', source: 'baseline', tip: '22 号进销存基准值。业务库无按天的历史库存结存表,无法还原,取自 9-22 导出数据' },
{ key: 'processIn', label: '加工进仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' },
{ key: 'purchaseReturn', label: '采购退货', source: 'baseline', tip: '22 号基准值(业务库 jst_purchaseout 虽有单据,但数值与该进销存口径不一致)' },
{ key: 'purchaseIn', label: '采购进仓', source: 'baseline', tip: '22 号基准值(同上,业务库数值与进销存口径不一致)' },
{ key: 'otherIn', label: '其它进仓', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' },
{ key: 'otherReturn', label: '其它退货', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' },
{ key: 'salesOut', label: '销售出仓', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库 jst_salesOutboundInfo 数值与该进销存口径不一致' },
{ key: 'stocktaking', label: '盘点', source: 'baseline', tip: '22 号基准值。业务库无盘点单据表' },
{ key: 'processOut', label: '加工出仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' },
{ key: 'transferOut', label: '调拨出', source: 'baseline', tip: '22 号基准值(负数为调出)。业务库无调拨单据表' },
{ key: 'salesReturn', label: '销售退货', source: 'baseline', tip: '22 号基准值(业务库 jst_returnAndRefundInfo 数值与该进销存口径不一致)' },
{ key: 'transferIn', label: '调拨入', source: 'baseline', tip: '22 号基准值。业务库无调拨单据表' },
{ key: 'openingQtyExtra', label: '期初(2)', source: 'baseline', tip: '表格中的第二个「期初」列(9-22 导出值)' },
{ key: 'otherOut', label: '其它出库', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库无对应单据表' },
{ key: 'closingQty', label: '期末数量', source: 'runtime', tip: '聚水潭实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(与表格 22 号期末一致,24/25 仓精确匹配)' },
{ key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%(全员均值),数据来自 pm_stockMonitor' },
{ key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: '库龄 < 3 个月 且 30% ≤ 周转率 ≤ 199% 的 SKU 库存合计' },
{ key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: '库龄 < 3 个月且周转率 ≥ 200%,或库龄 ≥ 3 个月且周转率 30%~99% 的 SKU 库存合计' },
{ key: 'staleLevel', label: '商品呆滞级库存量', source: 'runtime', tip: '库龄 3~12 个月且周转率 ≥ 100%,或库龄 > 12 个月且周转率 100%~199% 的 SKU 库存合计' },
{ key: 'zombieLevel', label: '商品僵尸级库存量', source: 'runtime', tip: '库龄 > 12 个月 且 周转率 ≥ 200% 的 SKU 库存合计' },
{ key: 'warehouse', label: '仓库', source: 'snapshot', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' },
{ key: 'openingQty', label: '期初数量', source: 'snapshot', tip: '上一业务日的期末结存,取自本地快照表 daily_inventory_snapshot(每日 01:30 自动落库)' },
{ 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: '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: '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: 'transferIn', label: '调拨入', source: 'unavailable', tip: `调拨入。${NO_SOURCE_TIP}` },
{ key: 'openingQtyExtra', label: '期初(2)', source: 'unavailable', tip: '聚水潭导出表中的第二个「期初」列,业务库无对应来源' },
{ key: 'otherOut', label: '其它出库', source: 'unavailable', tip: `其它出库。${NO_SOURCE_TIP}` },
{ key: 'closingQty', label: '期末数量', source: 'runtime', tip: '聚水潭实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(存货数量不为 0 的 SKU 汇总)' },
{ key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%。月度发货总件数=近 30 天已发货订单件数(jst_OrderItem×jst_Orders,按 send_date,排除 Delete/Cancelled);月度日均库存件数=该仓实时库存(无历史结存表,以当日库存近似)' },
{ key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: 'SKU 级:库龄 < 3 个月 且 周转率 30%~199% 的库存合计' },
{ key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: 'SKU 级:库龄 < 3 个月且周转率 ≥ 200%,或库龄 > 3 个月且周转率 30%~99% 的库存合计' },
{ key: 'staleLevel', label: '商品呆滞级库存量', source: 'runtime', tip: 'SKU 级:库龄 3~12 个月且周转率 ≥ 100%,或库龄 > 12 个月且周转率 100%~199% 的库存合计' },
{ key: 'zombieLevel', label: '商品僵尸级库存量', source: 'runtime', tip: 'SKU 级:库龄 > 12 个月 且 周转率 ≥ 200% 的库存合计' },
];
/** 口径说明弹框文案 */
export const FORMULA_DESCRIPTION = [
'数据来源说明:',
'· 期初数量 / 加工进仓 / 采购退货 / 采购进仓 / 其它进仓 / 其它退货 / 销售出仓 / 盘点 / 加工出仓 / 调拨出 / 销售退货 / 调拨入 / 其它出库:',
' 取自 2026-09-22 聚水潭进销存导出值。业务库 JSReportReplica 无历史库存结存表,',
' 也缺少调拨 / 盘点 / 加工 / 其它出入库单据表;已有的采购与销售单据数值与该进销存口径不一致,故采用导出值。',
'数据来源说明(全部来自数据库,无人工填数):',
'· 期初数量:项目本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存。',
' 业务库 JSReportReplica 没有按天的历史库存结存表,因此由本系统每天 01:30 把 26 仓实时库存落一份快照,',
' 今日期初 = 昨日快照,逐日自动滚动。',
'· 12 个流水列(加工进仓 / 采购退货 / 采购进仓 / 其它进仓 / 其它退货 / 销售出仓 / 盘点 / 加工出仓 / 调拨出 / 销售退货 / 调拨入 / 其它出库):',
' 业务库缺少调拨 / 盘点 / 加工 / 其它出入库单据表;已有的采购与销售单据按日汇总后与聚水潭进销存口径不一致',
' (崔桥 9-23 销售出仓基准 4273,订单表 send_date 仅 1769,偏差约 60%),因此不展示估算值,',
' 已整理单据表需求给 DBA,补齐后自动接入。',
'· 期末数量:运行时取聚水潭实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓),随刷新滚动。',
'· 库存周转天数 / 四级库存量:运行时由库存快照 rpt.pm_stockMonitor 计算,基准时刻为快照日次日 01:00。',
'· 库存周转天数 / 四级库存量:运行时计算,库龄基准时刻为快照日次日 01:00。',
'',
'算式:',
'· 期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入',
' − 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库',
'· 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%',
' 其中 月度发货总件数 = threeAvgSale × 30,月度日均库存件数 = jsStock',
'· 库存分级(库龄 + 周转率双维度):',
' 月度发货总件数 = 该仓近 30 天已发货订单件数',
' (jst_OrderItem 关联 jst_Orders,按订单发货仓 wms_co_id 归属,send_date 落在窗口内,排除 Delete / Cancelled)',
' 月度日均库存件数 = 该仓聚水潭实时库存 jst_inventoryInfo 汇总',
' (业务库无按天的历史库存结存表,无法算真正的日均库存,以当日库存近似月度日均)',
' 库龄(天) = 基准时刻 − SKU 建档时间 created;3 个月按 90 天、12 个月按 365 天折算',
'· 库存分级(SKU 级,库龄 + 周转率双维度,低于 30% 的不计入任何级别):',
' 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警',
' 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流',
' 库龄 ≥ 3 个月 且 周转率 30%~99% → 商品慢流',
' 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流',
' 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞',
' 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞',
' 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸',
'',
'注意:仓库整体周转率按「该仓发货总量 ÷ 该仓库存总量」计算,与四级分级的 SKU 级取舍不是同一个口径,',
' 两者不必相等(例如整体周转率 6% 的仓里,仍可以有部分 SKU 周转率超 30% 而被判为预警级)。',
];

View File

@ -23,12 +23,12 @@ const int = (value) => {
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
};
const decimal = (value, digits = 1) => {
const percent = (value) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return `${Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}%`;
};
/** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */
@ -45,11 +45,11 @@ const DailyInventory = () => {
const [data, setData] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
const load = useCallback(async () => {
const load = useCallback(async (force = false) => {
setLoading(true);
setError('');
try {
const res = await getDailyInventory();
const res = await getDailyInventory(force ? { refresh: '1' } : {});
setData(res?.data ?? null);
} catch (err) {
setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败');
@ -65,6 +65,7 @@ const DailyInventory = () => {
const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => {
const isRuntime = col.source === COLUMN_SOURCE.runtime;
const isUnavailable = col.source === COLUMN_SOURCE.unavailable;
return {
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
dataIndex: col.key,
@ -72,16 +73,19 @@ const DailyInventory = () => {
align: col.key === 'warehouse' ? 'left' : 'right',
width: col.key === 'warehouse' ? 240 : 130,
fixed: col.key === 'warehouse' ? 'left' : undefined,
className: isRuntime ? 'daily-inventory-col--runtime' : undefined,
className: isRuntime
? 'daily-inventory-col--runtime'
: (isUnavailable ? 'daily-inventory-col--unavailable' : undefined),
render: (value, record) => {
if (record?.isTotal) {
// 合计行:周转天数不累加,显示「—」
if (col.key === 'turnoverDays') return '—';
// 合计行:四级库存量累加,周转率按「总发货 / 总库存」重算(后端已给出)
if (col.key === 'turnoverDays') return percent(value);
return int(value);
}
if (col.key === 'warehouse') return <span className="daily-inventory-warehouse">{value}</span>;
if (col.key === 'closingQty') return int(value);
if (col.key === 'turnoverDays') return decimal(value, 1);
if (col.key === 'turnoverDays') return percent(value);
if (isUnavailable) return <span className="daily-inventory-cell--na">—</span>;
return renderSigned(value);
},
};
@ -94,6 +98,7 @@ const DailyInventory = () => {
const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]);
return (
<SycmPage className="daily-inventory-page">
<div className="daily-inventory-header">
@ -109,9 +114,14 @@ const DailyInventory = () => {
</Button>
</div>
<div className="daily-inventory-header__meta">
<span>基准日期:{data?.baselineDate || '—'}</span>
<span>期初日期:{data?.openingDate || '—'}</span>
<span>库存快照日:{data?.latestBizDate || '—'}</span>
<span>分级基准时刻:{data?.asOf || '—'}</span>
{data?.shipWindow ? (
<span>
发货统计窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天)
</span>
) : null}
{data?.items?.[0]?.closingTakenAt ? (
<span>
库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })}
@ -119,19 +129,24 @@ const DailyInventory = () => {
) : null}
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
<Button icon={<ReloadOutlined />} onClick={() => load(false)} loading={loading}>刷新</Button>
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细重算周转率与分级(约 15~20 秒)">
<Button onClick={() => load(true)} loading={loading}>强制重算</Button>
</Tooltip>
</Space>
</div>
<Alert
type="info"
showIcon
message="数据来源混合,请注意口径"
message="全部数据来自数据库,12 个流水列暂缺单据来源"
description={
<span>
期初数量与 12 个流水列取自 <b>{data?.baselineDate || '2026-09-22'}</b> 聚水潭进销存导出值(业务库缺少调拨/盘点/加工/其它出入库单据表,无法还原);
期末数量与右侧 5 列为运行时实时取数。悬浮表头可看每列口径。
{noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在库存快照中无数据,周转天数与四级库存量显示「—」。` : ''}
期初数量取本地快照表中 <b>{data?.openingDate || '—'}</b> 的期末结存(每日 01:30 自动落库,逐日滚动);
期末数量与右侧 5 列为运行时从业务库实时取数。
12 个流水列因业务库缺少调拨/盘点/加工/其它出入库单据表、且已有采购与销售单据口径不一致,暂显示「—」,已向 DBA 提交补表需求。
{data?.noOpeningCount > 0 ? ` 其中 ${data.noOpeningCount} 个仓尚无 ${data.openingDate} 的期初快照(新纳入的仓库从次日起自动补齐)。` : ''}
{noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在聚水潭库存表中无有效库存,周转率与四级库存量显示「—」。` : ''}
</span>
}
style={{ marginBottom: 12 }}
@ -154,7 +169,7 @@ const DailyInventory = () => {
</Spin>
<Text type="secondary" className="daily-inventory-footer">
共 {items.length} 个仓库;蓝色底纹列为运行时取数,其余为基准值。
共 {items.length} 个仓库;蓝色底纹列为运行时实时取数,灰色列为暂无单据来源,其余为本地快照表取值。
</Text>
<Modal

View File

@ -53,6 +53,15 @@
background: rgba(22, 119, 255, 0.04);
}
/* 暂无单据来源的列:淡灰底纹,提示该列待 DBA 补表 */
.daily-inventory-table .daily-inventory-col--unavailable {
background: rgba(0, 0, 0, 0.03);
}
.daily-inventory-cell--na {
color: rgba(0, 0, 0, 0.25);
}
.daily-inventory-warehouse {
white-space: nowrap;
}