Merge branch 'main' into codex/cangku-returns-monitor-001 (sync 41 commits, keep both sides)

This commit is contained in:
lunanting 2026-09-24 15:37:59 +08:00
commit 90fa54bd8c
23 changed files with 1740 additions and 79 deletions

View File

@ -18,6 +18,8 @@ npm run remote:dev
首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。
拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。
## 验证与构建
```bash

View File

@ -0,0 +1,106 @@
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,96 @@
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

@ -0,0 +1,179 @@
const express = require('express');
const cloudDb = require('../config/cloudDatabase');
const {
DAILY_INVENTORY_BASELINE,
BASELINE_DATE,
FLOW_COLUMNS,
} = require('../config/dailyInventoryBaseline');
const router = express.Router();
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */
const AS_OF_TIME = '01:00';
const toNumber = (v) => {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
/** 快照日 + 次日 01:00 作为统一的库龄/分级基准 */
function nextDayAsOf(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)} ${AS_OF_TIME}`;
}
async function fetchLatestBizDate() {
const rows = await cloudDb.query(
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
);
return rows[0]?.d || null;
}
/**
* 派生列(SQL CASE WHEN 下沉计算)
* - 库龄(天) = DATEDIFF(asOf, created)
* - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%
* 月度发货总件数 = threeAvgSale × 30;月度日均库存件数 = jsStock
* - 四级分级:库龄 + 周转率双维度
*/
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: `
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'
ELSE NULL
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),
};
}
/** 实时期末库存(单仓 wms_co_id),并带回最近更新时间便于核对时效 */
async function fetchClosingQty(wmsCoId) {
if (wmsCoId == null) return null;
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 }
);
const r = rows[0] || {};
return {
closingQty: toNumber(r.qty),
closingSkuCount: toNumber(r.skuCount) || 0,
closingTakenAt: r.takenAt || null,
};
}
/**
* GET /api/daily-inventory
* 每日进销存:25 个仓库 × 进销存流水 + 期末 + 分级库存量
*
* 数据源分工:
* - 期初数量 与 12 个流水列:22 号基准(dailyInventoryBaseline.js)
* - 期末数量:jst_inventoryInfo 实时库存
* - 库存周转天数 / 四级库存量:pm_stockMonitor 快照
*/
router.get('/', async (req, res) => {
try {
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const asOf = nextDayAsOf(latestBizDate);
const items = [];
for (const base of DAILY_INVENTORY_BASELINE) {
const flows = {};
for (const col of FLOW_COLUMNS) flows[col.field] = base[col.field] ?? 0;
const closing = await fetchClosingQty(base.wmsCoId).catch(() => null);
const grade = base.dbWarehouse && latestBizDate
? await fetchGradeMetrics(base.dbWarehouse, latestBizDate, asOf).catch(() => null)
: null;
items.push({
warehouse: base.warehouse,
dbWarehouse: base.dbWarehouse,
openingQty: base.openingQty,
openingQtyExtra: base.openingQtyExtra,
...flows,
closingQty: closing?.closingQty ?? null,
closingSkuCount: closing?.closingSkuCount ?? null,
closingTakenAt: closing?.closingTakenAt ?? 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,
hasGrade: Boolean(grade),
});
}
// 合计行:便于与表格总额核对
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));
}
res.json({
success: true,
data: {
baselineDate: BASELINE_DATE,
latestBizDate,
asOf,
flowColumns: FLOW_COLUMNS,
items,
totals,
},
});
} catch (error) {
console.error('[daily-inventory] 查询失败:', error.message);
res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message });
}
});
module.exports = router;

View File

@ -29,6 +29,10 @@ function sendError(res, error, fallback) {
res.status(status).json({ success: false, error: error.message || fallback });
}
function buildSyncQuery(query = {}) {
return { ...query, live: '1', fresh: '1' };
}
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
router.get('/list', async (req, res) => {
try {
@ -109,7 +113,9 @@ router.put('/notification-center', async (req, res) => {
router.post('/sync', async (req, res) => {
try {
const cache = clearMkhBomCache();
const snapshot = await getMkhBomList({ ...(req.body?.query || {}), live: '1' });
// fresh=1 是真正重读库存 / 加工单 / 待发货数据的协议。
// 只清缓存再传 live=1 会落到旧快照,导致按钮显示“同步成功”却数值不变。
const snapshot = await getMkhBomList(buildSyncQuery(req.body?.query));
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: { cache, snapshot } });
} catch (error) {
@ -118,3 +124,4 @@ router.post('/sync', async (req, res) => {
});
module.exports = router;
module.exports._private = { buildSyncQuery };

View File

@ -0,0 +1,558 @@
/**
* 每日进销存 —— 2026-09-22 进销存基准流水
*
* 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出)
* 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库
* 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。
*
* 口径约定:
* - openingQty 与 12 个流水列:22 号基准值(本文件静态存储)
* - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确)
* - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算
*/
/** 一级/二级菜单名(取自表格) */
const DAILY_INVENTORY_LEVEL1 = '大货库存监控';
const DAILY_INVENTORY_LEVEL2 = '每日进销存';
/** 基准数据日期 */
const BASELINE_DATE = '2026-09-22';
/** 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"
}
];
/** 25 个仓库的 22 号基准流水 */
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
}
];
module.exports = {
DAILY_INVENTORY_LEVEL1,
DAILY_INVENTORY_LEVEL2,
BASELINE_DATE,
FLOW_COLUMNS,
DAILY_INVENTORY_BASELINE,
};

View File

@ -35,6 +35,7 @@ const promotionModuleRoutes = require('./api/promotionModule');
const productMonitoringRoutes = require('./api/productMonitoring');
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
const mkhBomRoutes = require('./api/mkhBom');
const dailyInventoryRoutes = require('./api/dailyInventory');
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
const returnMonitorRoutes = require('./api/returnMonitor');
const returnStockWarningRoutes = require('./api/returnStockWarning');
@ -662,6 +663,7 @@ async function startServer(options = {}) {
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes);
cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes);
// 横林二仓客退:退货入库核销 + 每日库存监控
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);

View File

@ -191,13 +191,13 @@
// → 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。
// ⚠️ 「同步最新数据」按钮只清服务端缓存(clearMkhBomCache),**不会触发上游同步**,
// 所以点上多少次都还是 0 —— 数据源当天就没有这条库存。
// 兜底规则(**只补 0,不覆盖有效值**):
// ① inventoryInfo 品牌仓 qty > 0 → 用它(stockSource='realtime',与旧口径完全一致)
// ② 否则箱表品牌仓 `bin` 非空行 qty 合计 > 0 → 用它(stockSource='pack')
// ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0(stockSource='realtime')
// ④ 否则回退监控快照 jsStock → (stockSource='monitor')
// ⚠️ 为什么 inv 优先、pack 只补 0:两表都 > 0 且不等的 28 个成品里 **25 个是 inv 更大**
// (箱表只记装箱的货,散货不计)→ 若拿 pack 当主源会系统性低估库存、误报缺货。
// 兜底规则(2026-09-24 修正):
// ① inventoryInfo 与箱表品牌仓 `bin` 非空行都有值 → 取两者较大值
// ② 只有其中一个当前库存源有值 → 用该源
// ③ 两个当前库存源都无记录 → 回退监控快照 jsStock
// 取较大值的原因:inventoryInfo 包含散货,通常比箱表大;但新入库会先进入小时级
// 箱表、inventoryInfo 仍保留旧正数。若一律让 inventoryInfo 优先,就会把新入库库存压住;
// 取 max 既不会丢散货,也能在日更快照滞后时立即看到箱表新货。
// ⚠️ 必须过滤 `bin` 为空的行:那是**装箱记录不是库存**(品牌仓 bin 空行合计 21 件),
// 不过滤会虚增。
// 影响面(2026-09-19 实测):品牌仓 4,408 个成品里,走 ② 兜底的只有 **3 个**
@ -220,8 +220,9 @@
// (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表)
// · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓'
// AND 状态 <> '作废' ← 作废单据一律不核算
// AND 收货状态 <> '全部入库' ← 未完成(全部入库 ⟺ 状态=完成)
// · 在途加工量 = Σ(数量 − 已入库数量),按 商品编码 归集
// AND max(数量 − 已入库数量, 0) > 0 ← 只认真实剩余量
// · 在途加工量 = Σ max(数量 − 已入库数量, 0),按 商品编码 归集。
// 不再依赖「收货状态」文本:上游状态字段延迟时,已入库数量已追平的单据必须立即退出在途。
// ⚠️ 为什么走 getPool 而不是 cloudDatabase.query:
// query() 会经 rewriteReportReplicaSql() 把 `dbo.xxx` 重写成 `rpt.xxx`,
// 跨库四段名 `LS_JS_DMP.JSDataMiddlePlatform.dbo.t` 会被改坏成
@ -375,6 +376,14 @@ let productBomCache = null;
*/
let cacheEpoch = 0;
// 运营数据(库存 / 箱表 / 加工单 / 订单)使用独立代际。页面强制刷新时只淘汰
// 这些变化频繁的缓存,不重查 BOM 与商品主数据。这样既能立即看到入库/完工,
// 又避免用户每刷新一次就把所有稳定表全量重扫。
let operationalCacheEpoch = 0;
let operationalRefreshPending = null;
let operationalRefreshedAt = 0;
const OPERATIONAL_REFRESH_DEDUPE_MS = 15 * 1000;
// MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。
// 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。
const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓';
@ -1084,12 +1093,11 @@ function buildReplenishMetrics(row = {}) {
const hasRealtime = realtimeStock !== null;
let stock;
let stockSource;
if (hasRealtime && realtimeStock > 0) {
stock = realtimeStock;
stockSource = 'realtime';
} else if (packStock !== null && packStock > 0) {
stock = packStock;
stockSource = 'pack';
if ((hasRealtime && realtimeStock > 0) || (packStock !== null && packStock > 0)) {
const realtimeValue = hasRealtime && realtimeStock > 0 ? realtimeStock : 0;
const packValue = packStock !== null && packStock > 0 ? packStock : 0;
stock = Math.max(realtimeValue, packValue);
stockSource = packValue > realtimeValue ? 'pack' : 'realtime';
} else if (hasRealtime) {
stock = realtimeStock;
stockSource = 'realtime';
@ -1105,7 +1113,7 @@ function buildReplenishMetrics(row = {}) {
// 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量
const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0);
const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0);
const hasPendingWorkOrder = pendingWorkOrderQty > 0 || pendingWorkOrderCount > 0;
const hasPendingWorkOrder = pendingWorkOrderQty > 0;
// 待发货订单(见文件头 ⑩):当前未发货订单里的件数
const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0);
@ -1353,19 +1361,34 @@ async function loadPendingWorkOrderIndex() {
const request = pool.request();
request.input('workOrderWarehouse', WORK_ORDER_WAREHOUSE);
request.input('voidStatus', WORK_ORDER_VOID_STATUS);
request.input('receivedStatus', WORK_ORDER_RECEIVED_STATUS);
const result = await request.query(`
SELECT [商品编码] AS skuId,
SUM(CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))) AS pendingQty,
COUNT(DISTINCT [加工单号]) AS orderCount,
MIN(TRY_CONVERT(DATETIME, [预计加工完成时间], 111)) AS eta,
MIN(CAST([加工单号] AS NVARCHAR(40))) AS firstOrderNo
SUM(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))
ELSE 0
END) AS pendingQty,
COUNT(DISTINCT CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN [加工单号]
END) AS orderCount,
MIN(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN TRY_CONVERT(DATETIME, [预计加工完成时间], 111)
END) AS eta,
MIN(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN CAST([加工单号] AS NVARCHAR(40))
END) AS firstOrderNo
FROM ${WORK_ORDER_TABLE} WITH (NOLOCK)
WHERE [加工仓储方] = @workOrderWarehouse
AND [状态] <> @voidStatus
AND [收货状态] <> @receivedStatus
GROUP BY [商品编码]`);
GROUP BY [商品编码]
HAVING SUM(CASE
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))
ELSE 0
END) > 0`);
const bySku = new Map();
(result.recordset || []).forEach((row) => {
@ -1373,7 +1396,7 @@ async function loadPendingWorkOrderIndex() {
if (!key) return;
const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0);
const orderCount = Math.max(0, toNumber(row.orderCount) ?? 0);
if (pendingQty <= 0 && orderCount <= 0) return;
if (pendingQty <= 0) return;
bySku.set(key, {
// 原始商品编码:配件占用展开(见文件头 ⑪)需要拿它回查 BOM,故一并保留
skuId: toText(row.skuId),
@ -1390,15 +1413,16 @@ async function loadPendingWorkOrderIndex() {
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
workOrderIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) workOrderIndexCache = null;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) workOrderIndexCache = null;
throw error;
}
}
@ -1685,15 +1709,16 @@ async function loadOrderIndex() {
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
orderIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) orderIndexCache = null;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) orderIndexCache = null;
throw error;
}
}
@ -1803,15 +1828,16 @@ async function loadReplenishIndex() {
});
const epoch = cacheEpoch;
const operationalEpoch = operationalCacheEpoch;
replenishIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
if (epoch === cacheEpoch) {
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
}
return value;
} catch (error) {
if (epoch === cacheEpoch) replenishIndexCache = null;
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) replenishIndexCache = null;
throw error;
}
}
@ -2163,7 +2189,12 @@ async function getMkhBomList(query = {}) {
// 默认按可用天数升序 —— 最紧急的排最前
const sortField = String(query.sortField || 'stockDays');
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const preferLiveData = parseFlag(query.live) || parseFlag(query.fresh);
const forceFresh = parseFlag(query.fresh);
const preferLiveData = parseFlag(query.live) || forceFresh;
// fresh=1 是「真的重读运营数据」,不只是改用实时口径。同时到达的多个用户
// 共用一次刷新,15 秒内的重复请求直接复用刚刚查到的结果,保护业务云库。
if (forceFresh) await refreshMkhOperationalData();
const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([
buildMergedReplenishRows({ preferLiveData }),
@ -2240,6 +2271,13 @@ async function getMkhBomList(query = {}) {
saleWindowDays: SALE_WINDOW_DAYS,
dataMode: preferLiveData ? 'live' : 'snapshot',
refreshedAt: new Date().toISOString(),
// 前后端实时刷新协议。本地前端如果代理到未重启的旧后端,
// 将拒绝展示其返回的旧库存,而不是把缺失协议字段当成刷新成功。
freshDataContractVersion: 1,
freshDataApplied: forceFresh && operationalRefreshedAt > 0,
operationalRefreshedAt: operationalRefreshedAt
? new Date(operationalRefreshedAt).toISOString()
: null,
// 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理
workOrderDegraded: replenishIndex.workOrderDegraded === true,
workOrderWarehouse: WORK_ORDER_WAREHOUSE,
@ -2481,11 +2519,60 @@ async function listMkhBomExportRows(query = {}) {
// 页面「同步」按钮调用——清掉后下一次查询直连副本库,拿到的是上游最新快照,
// 而不是 2~15 分钟前的缓存结果。
// 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。
function clearMkhOperationalCache() {
operationalCacheEpoch += 1;
operationalRefreshedAt = 0;
replenishIndexCache = null;
workOrderIndexCache = null;
orderIndexCache = null;
return {
operationalCacheEpoch,
clearedAt: new Date().toISOString(),
};
}
/**
* 强制重读会变的运营数据。
* - single-flight:并发用户共用同一次云库查询;
* - 15 秒去重:页面初始加载、切回标签和用户连点刷新不会重复压库;
* - 只刷库存/箱表/加工单/订单,不清 BOM 与商品主数据。
*/
async function refreshMkhOperationalData() {
const now = Date.now();
if (operationalRefreshPending) {
const result = await operationalRefreshPending;
return { ...result, joined: true };
}
if (operationalRefreshedAt && now - operationalRefreshedAt < OPERATIONAL_REFRESH_DEDUPE_MS) {
return { reused: true, refreshedAt: new Date(operationalRefreshedAt).toISOString() };
}
clearMkhOperationalCache();
const refreshEpoch = operationalCacheEpoch;
const pending = (async () => {
await loadReplenishIndex();
if (refreshEpoch === operationalCacheEpoch) operationalRefreshedAt = Date.now();
return {
refreshed: true,
refreshedAt: operationalRefreshedAt ? new Date(operationalRefreshedAt).toISOString() : null,
};
})();
operationalRefreshPending = pending;
try {
return await pending;
} finally {
if (operationalRefreshPending === pending) operationalRefreshPending = null;
}
}
function clearMkhBomCache() {
// 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询,
// 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值,
// 导致按钮点完反而 2~10 分钟一直显示旧数据)。
cacheEpoch += 1;
operationalCacheEpoch += 1;
operationalRefreshPending = null;
operationalRefreshedAt = 0;
businessBomCache = null;
officialBomIndexCache = null;
@ -2507,6 +2594,7 @@ function clearMkhBomCache() {
return {
syncedAt: new Date().toISOString(),
cacheEpoch,
operationalCacheEpoch,
cleared: cleared.map(([name, entries]) => ({ name, entries })),
};
}
@ -2514,6 +2602,7 @@ function clearMkhBomCache() {
module.exports = {
getMkhBomList,
clearMkhBomCache,
refreshMkhOperationalData,
getMkhBomDetail,
listReplenishAlertRows,
listMkhBomExportRows,
@ -2575,5 +2664,8 @@ module.exports = {
EXPORT_MODES,
buildProductExportRows,
buildComponentExportRows,
clearMkhOperationalCache,
refreshMkhOperationalData,
OPERATIONAL_REFRESH_DEDUPE_MS,
},
};

View File

@ -13,6 +13,8 @@
// 目标仓库共 794 个 SKU
// 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库
// 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得)
// 该表为 0/缺行而箱及仓位库存已有正库存时,用同仓 rpt.jst_packInventoryInfo 合计补齐;
// 只补 0,不覆盖 inventoryInfo 中的正库存,避免箱表未覆盖散货时低估库存。
// 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU:
// fifteenAvgSale 近15天日均发货量(2026-09-17 由 7 天改 15 天,见下)
// jsUseDays 库存可用天数
@ -77,13 +79,39 @@ const SORTABLE_FIELDS = Object.freeze({
category: 's.category',
realtimeStock: 'realtimeStock',
avgShip15d: 'm.fifteenAvgSale',
stockDays: 'm.jsUseDays',
stockDays: 'stockDays',
warningLevel: 'warningOrder',
mkhStatus: 'mkhStatus',
location: 'location',
warehouse: 's.other_3',
});
// 实时库存优先使用库存表;库存表没有正库存时,用同仓「箱及仓位库存」补齐。
// 聚水潭上游可能已经生成箱/仓位库存,但 jst_inventoryInfo 仍停留在旧值。
// 只补 0/缺行,避免箱表只记录部分装箱库存时压低库存表中的有效库存。
const buildRealtimeStockSql = () => (
`CASE
WHEN inv.qty > 0 THEN inv.qty
WHEN ISNULL(ploc.packQty, 0) > 0 THEN ploc.packQty
WHEN inv.qty IS NOT NULL THEN inv.qty
ELSE ISNULL(m.jsStock, 0)
END`
);
// 箱表兜底生效时,监控快照的 jsUseDays 仍基于旧库存,需要同步重算。
// 无日销时返回 NULL,不能因为旧快照为 0 天而把已有库存误报为低库存。
const buildStockDaysSql = () => (
`CASE
WHEN ISNULL(inv.qty, 0) <= 0 AND ISNULL(ploc.packQty, 0) > 0 THEN
CASE
WHEN ISNULL(m.fifteenAvgSale, 0) > 0
THEN CAST(ploc.packQty / NULLIF(m.fifteenAvgSale, 0) AS DECIMAL(18, 2))
ELSE NULL
END
ELSE m.jsUseDays
END`
);
/**
* 「已配置MKH」判定表达式(2026-09-17 晚修正口径):
* 该 SKU 作为「配件商品编码」(map_outer_sku_id)出现在聚水潭 SKU 级 BOM 维护
@ -107,8 +135,9 @@ const buildConfiguredExistsSql = () => (
const buildWarningOrderSql = () => (
`CASE
WHEN cfg.configured = 0 THEN 2
WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0
WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
WHEN ${buildRealtimeStockSql()} <= 0 THEN 0
WHEN ${buildStockDaysSql()} IS NOT NULL
AND ${buildStockDaysSql()} <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
ELSE 2
END`
);
@ -171,15 +200,29 @@ OUTER APPLY (
WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id))))
ORDER BY c.createTime DESC
) cost
-- 仓位:主货位(真货位),见 buildLocationSql() 说明
OUTER APPLY (
SELECT TOP 1 NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin
FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK)
WHERE pk.sku_id = s.sku_id
AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL
AND ISNULL(pk.qty, 0) > 0
ORDER BY pk.qty DESC
) ploc
-- 仓位及箱库存:先按 SKU/仓位一次性汇总,再关联目标仓,避免对 794 个 SKU
-- 分别扫描无独立索引的上游视图。rn=1 是库存最大的主货位,packQty 是该 SKU 全仓合计。
LEFT JOIN (
SELECT ranked.sku_id, ranked.wms_co_id, ranked.bin, ranked.packQty
FROM (
SELECT
pk.sku_id,
pk.wms_co_id,
NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin,
SUM(ISNULL(pk.qty, 0)) AS binQty,
SUM(SUM(ISNULL(pk.qty, 0))) OVER (PARTITION BY pk.sku_id, pk.wms_co_id) AS packQty,
ROW_NUMBER() OVER (
PARTITION BY pk.sku_id, pk.wms_co_id
ORDER BY SUM(ISNULL(pk.qty, 0)) DESC, NULLIF(LTRIM(RTRIM(pk.bin)), N'') ASC
) AS rn
FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK)
WHERE pk.wms_co_id = @wmsCoId
AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL
GROUP BY pk.sku_id, pk.wms_co_id, NULLIF(LTRIM(RTRIM(pk.bin)), N'')
HAVING SUM(ISNULL(pk.qty, 0)) > 0
) ranked
WHERE ranked.rn = 1
) ploc ON ploc.sku_id = s.sku_id AND ploc.wms_co_id = @wmsCoId
-- 模块化配置状态:见文件头部「已配置MKH」口径说明
OUTER APPLY (
SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured
@ -362,13 +405,13 @@ const buildProjection = () => `
s.sku_id AS skuId,
s.name AS productName,
s.category AS category,
COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock,
${buildRealtimeStockSql()} AS realtimeStock,
ISNULL(ptr.transit, 0) AS purchaseTransit,
ISNULL(pin.inQty, 0) AS inQtyToday,
ISNULL(m.nearSecondSale, 0) AS outQtyToday,
${buildLocationSql()} AS location,
m.fifteenAvgSale AS avgShip15d,
m.jsUseDays AS stockDays,
${buildStockDaysSql()} AS stockDays,
m.safetyDays AS safetyDays,
m.suggestStock AS suggestStock,
s.other_3 AS warehouse,
@ -445,7 +488,7 @@ async function buildSummary(whereSql, params) {
`SELECT
COUNT_BIG(*) AS skuCount,
SUM(CASE WHEN ${buildWarningOrderSql()} = 0 THEN 1 ELSE 0 END) AS outOfStockCount,
CAST(ISNULL(SUM(COALESCE(inv.qty, m.jsStock, 0)), 0) AS BIGINT) AS totalStock,
CAST(ISNULL(SUM(${buildRealtimeStockSql()}), 0) AS BIGINT) AS totalStock,
CAST(ISNULL(SUM(ISNULL(ptr.transit, 0)), 0) AS BIGINT) AS totalPurchaseTransit,
SUM(CASE WHEN ${buildWarningOrderSql()} = 1 THEN 1 ELSE 0 END) AS lowStockDaysCount,
SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount,

View File

@ -0,0 +1,13 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { _private } = require('../src/api/mkhBom');
test('manual MKH sync always forces a live operational refresh', () => {
assert.deepEqual(
_private.buildSyncQuery({ page: 2, fresh: 0, live: 0 }),
{ page: 2, fresh: '1', live: '1' }
);
});

View File

@ -250,7 +250,7 @@ test('replenish: live refresh ignores delayed sales and stock snapshots', () =>
assert.equal(live.stockSource, 'live-missing');
});
test('replenish: box/pallet stock fills in when the daily inventory table is stale (⑦-d)', () => {
test('replenish: the larger current-stock source wins when the daily inventory table is stale (⑦-d)', () => {
// 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货
const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 });
assert.equal(fillFromZero.stock, 4);
@ -261,9 +261,14 @@ test('replenish: box/pallet stock fills in when the daily inventory table is sta
assert.equal(fillFromMissing.stock, 5);
assert.equal(fillFromMissing.stockSource, 'pack');
// 库存表有货时箱表不参与 —— 箱表只记装箱的货,拿它当主源会低估库存
const realtimeWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 100, monitorStock: 0 });
assert.equal(realtimeWins.stock, 4);
// 新入库先到箱表:库存快照仍为旧的正数 4 时,不能把箱表的 47 压住
const packWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 47, monitorStock: 0 });
assert.equal(packWins.stock, 47);
assert.equal(packWins.stockSource, 'pack');
// 散货只在 inventoryInfo 中:该表较大时仍保留完整库存
const realtimeWins = metrics({ avgSale: 1, realtimeStock: 100, packStock: 47, monitorStock: 0 });
assert.equal(realtimeWins.stock, 100);
assert.equal(realtimeWins.stockSource, 'realtime');
// 两个实时源都没货 → 库存表说 0 就认 0,不再退回更旧的快照
@ -363,16 +368,16 @@ test('replenish: pending work order fields default to empty without any order',
assert.equal(none.pendingWorkOrderEta, null);
assert.equal(none.pendingWorkOrderNo, null);
// 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理
// 已入库数量追平后,即使上游状态文本或单据计数延迟,也不再按「已排产」处理
const zeroQty = metrics({
avgSale: 1,
realtimeStock: 0,
pendingWorkOrderQty: 0,
pendingWorkOrderCount: 2,
});
assert.equal(zeroQty.hasPendingWorkOrder, true);
assert.equal(zeroQty.warningLevel, '已排产');
assert.equal(zeroQty.hasWarning, false);
assert.equal(zeroQty.hasPendingWorkOrder, false);
assert.equal(zeroQty.warningLevel, '缺货');
assert.equal(zeroQty.hasWarning, true);
});
test('replenish: products without sales stay "no sale" even with a pending work order', () => {
@ -1280,3 +1285,53 @@ test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', asyn
svc.clearMkhBomCache();
}
});
test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const originalGetPool = cloudDatabase.getPool;
let queryCount = 0;
let releaseMonitor;
let markMonitorStarted;
const monitorGate = new Promise((resolve) => { releaseMonitor = resolve; });
const monitorStarted = new Promise((resolve) => { markMonitorStarted = resolve; });
cloudDatabase.query = async (sql) => {
queryCount += 1;
if (sql.includes('pm_stockMonitor')) {
markMonitorStarted();
await monitorGate;
}
return [];
};
cloudDatabase.getPool = async () => ({
request: () => ({
input() { return this; },
async query() { queryCount += 1; return { recordset: [] }; },
}),
});
try {
svc.clearMkhBomCache();
const first = svc.refreshMkhOperationalData();
await monitorStarted;
const second = svc.refreshMkhOperationalData();
releaseMonitor();
const [firstResult, secondResult] = await Promise.all([first, second]);
assert.equal(firstResult.refreshed, true);
assert.equal(secondResult.joined, true);
assert.equal(secondResult.refreshedAt, firstResult.refreshedAt);
const afterConcurrent = queryCount;
const reused = await svc.refreshMkhOperationalData();
assert.equal(reused.reused, true);
assert.equal(queryCount, afterConcurrent,
'15 秒保护窗内不应重复访问业务云库');
} finally {
cloudDatabase.query = originalQuery;
cloudDatabase.getPool = originalGetPool;
svc.clearMkhBomCache();
}
});

View File

@ -0,0 +1,9 @@
import request from './request';
const LIST_TIMEOUT_MS = 5 * 60 * 1000;
// 每日进销存:25 仓 × 进销存流水 + 实时期末 + 分级库存量
export const getDailyInventory = () => request.get(
'/api/daily-inventory',
{ timeout: LIST_TIMEOUT_MS }
);

View File

@ -36,6 +36,12 @@ export const DEFAULT_SIDEBAR_CONFIG = {
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
],
},
{
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
children: [
{ key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 },
],
},
{
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
children: [

View File

@ -0,0 +1,61 @@
/**
* 每日进销存 —— 列定义
*
* 表头取自桌面《库存监控.xlsx》,顺序与表格一致。
* source 标记该列数据来源,便于在页面上如实标注口径:
* - baseline:22 号基准值(业务库无对应单据表,无法还原)
* - runtime:运行时从业务库实时取数
*/
export const COLUMN_SOURCE = {
baseline: 'baseline',
runtime: 'runtime',
};
/** 表格存在两个名为「期初」的列,第二个加后缀区分 */
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 库存合计' },
];
/** 口径说明弹框文案 */
export const FORMULA_DESCRIPTION = [
'数据来源说明:',
'· 期初数量 / 加工进仓 / 采购退货 / 采购进仓 / 其它进仓 / 其它退货 / 销售出仓 / 盘点 / 加工出仓 / 调拨出 / 销售退货 / 调拨入 / 其它出库:',
' 取自 2026-09-22 聚水潭进销存导出值。业务库 JSReportReplica 无历史库存结存表,',
' 也缺少调拨 / 盘点 / 加工 / 其它出入库单据表;已有的采购与销售单据数值与该进销存口径不一致,故采用导出值。',
'· 期末数量:运行时取聚水潭实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓),随刷新滚动。',
'· 库存周转天数 / 四级库存量:运行时由库存快照 rpt.pm_stockMonitor 计算,基准时刻为快照日次日 01:00。',
'',
'算式:',
'· 期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入',
' − 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库',
'· 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%',
' 其中 月度发货总件数 = threeAvgSale × 30,月度日均库存件数 = jsStock',
'· 库存分级(库龄 + 周转率双维度):',
' 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警',
' 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流',
' 库龄 ≥ 3 个月 且 周转率 30%~99% → 商品慢流',
' 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞',
' 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞',
' 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸',
];

View File

@ -1,12 +1,18 @@
const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false };
const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false };
export const SIDEBAR_PAGES = [
{ pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES },
];
export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {
acc[item.pageKey] = item;
return acc;
}, {});
const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false };
const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false };
export const SIDEBAR_PAGES = [
{ pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES },
{ pageKey: 'daily_inventory', path: '/planning/operation/daily-inventory', name: '每日进销存', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_henlin2', path: '/planning/operation/returns-inventory/henlin2', name: '横林二仓客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_subei', path: '/planning/operation/returns-inventory/subei', name: '苏北客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_shandong', path: '/planning/operation/returns-inventory/shandong', name: '山东客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_henan', path: '/planning/operation/returns-inventory/henan', name: '河南客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
];
export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {
acc[item.pageKey] = item;
return acc;
}, {});

View File

@ -119,6 +119,7 @@ const SIDEBAR_ROUTE_MAP = {
activePath: '/planning/operation/mkh-bom-data',
matchMode: 'prefix',
},
'daily-inventory': { path: '/planning/operation/daily-inventory' },
settings: { path: '/settings' },
'online-desktop-users': { path: '/settings/online-desktop-users' },
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },

View File

@ -0,0 +1,177 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Modal, Space, Spin, Table, Tooltip, Typography } from 'antd';
import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { getDailyInventory } from '../../api/dailyInventory';
import {
DAILY_INVENTORY_COLUMNS,
COLUMN_SOURCE,
FORMULA_DESCRIPTION,
} from '../../constants/dailyInventory.js';
import './style.css';
const { Title, Text } = Typography;
const TipTitle = ({ tip, children }) => (
<Tooltip title={tip}>
<span className="daily-inventory-th-tip">{children}</span>
</Tooltip>
);
const int = (value) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
};
const decimal = (value, digits = 1) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
/** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */
const renderSigned = (value) => {
if (value === null || value === undefined || value === '') return '—';
const n = Number(value);
if (!Number.isFinite(n) || n === 0) return <span className="daily-inventory-cell--zero">0</span>;
return <span className={n < 0 ? 'daily-inventory-cell--out' : 'daily-inventory-cell--in'}>{int(n)}</span>;
};
const DailyInventory = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [data, setData] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await getDailyInventory();
setData(res?.data ?? null);
} catch (err) {
setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败');
setData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const items = useMemo(() => data?.items ?? [], [data]);
const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => {
const isRuntime = col.source === COLUMN_SOURCE.runtime;
return {
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
dataIndex: col.key,
key: col.key,
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,
render: (value, record) => {
if (record?.isTotal) {
// 合计行:周转天数不累加,显示「—」
if (col.key === 'turnoverDays') return '—';
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);
return renderSigned(value);
},
};
}), []);
const tableRows = useMemo(() => {
if (!data?.totals) return items;
return [...items, { ...data.totals, key: '__total__' }];
}, [items, data]);
const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]);
return (
<SycmPage className="daily-inventory-page">
<div className="daily-inventory-header">
<div className="daily-inventory-header__title">
<Title level={4} style={{ margin: 0 }}>每日进销存</Title>
<Button
type="text"
icon={<InfoCircleOutlined />}
onClick={() => setHelpOpen(true)}
className="daily-inventory-header__help"
>
口径说明
</Button>
</div>
<div className="daily-inventory-header__meta">
<span>基准日期:{data?.baselineDate || '—'}</span>
<span>库存快照日:{data?.latestBizDate || '—'}</span>
<span>分级基准时刻:{data?.asOf || '—'}</span>
{data?.items?.[0]?.closingTakenAt ? (
<span>
库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })}
</span>
) : null}
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
</Space>
</div>
<Alert
type="info"
showIcon
message="数据来源混合,请注意口径"
description={
<span>
期初数量与 12 个流水列取自 <b>{data?.baselineDate || '2026-09-22'}</b> 聚水潭进销存导出值(业务库缺少调拨/盘点/加工/其它出入库单据表,无法还原);
期末数量与右侧 5 列为运行时实时取数。悬浮表头可看每列口径。
{noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在库存快照中无数据,周转天数与四级库存量显示「—」。` : ''}
</span>
}
style={{ marginBottom: 12 }}
/>
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
<Spin spinning={loading}>
<Table
className="daily-inventory-table"
columns={columns}
dataSource={tableRows}
rowKey={(record) => record.key || record.warehouse}
size="small"
bordered
pagination={false}
scroll={{ x: 'max-content', y: 'calc(100vh - 300px)' }}
rowClassName={(record) => (record?.isTotal ? 'daily-inventory-row--total' : '')}
/>
</Spin>
<Text type="secondary" className="daily-inventory-footer">
共 {items.length} 个仓库;蓝色底纹列为运行时取数,其余为基准值。
</Text>
<Modal
title="计算口径说明"
open={helpOpen}
onCancel={() => setHelpOpen(false)}
footer={null}
width={720}
>
<div className="daily-inventory-help">
{FORMULA_DESCRIPTION.map((line, i) => (
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
))}
</div>
</Modal>
</SycmPage>
);
};
export default DailyInventory;

View File

@ -0,0 +1,91 @@
.daily-inventory-page {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.daily-inventory-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.daily-inventory-header__title {
display: flex;
align-items: center;
gap: 4px;
}
.daily-inventory-header__help {
font-size: 13px;
}
.daily-inventory-header__meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: rgba(0, 0, 0, 0.55);
}
.daily-inventory-th-tip {
border-bottom: 1px dashed rgba(0, 0, 0, 0.35);
cursor: help;
white-space: nowrap;
}
.daily-inventory-table .ant-table-thead > tr > th {
white-space: nowrap;
font-size: 12px;
padding: 6px 8px;
}
.daily-inventory-table .ant-table-tbody > tr > td {
padding: 5px 8px;
font-size: 13px;
}
/* 运行时取数的列:加淡蓝底纹,与基准值区分 */
.daily-inventory-table .daily-inventory-col--runtime {
background: rgba(22, 119, 255, 0.04);
}
.daily-inventory-warehouse {
white-space: nowrap;
}
.daily-inventory-cell--out {
color: #cf1322;
}
.daily-inventory-cell--in {
color: rgba(0, 0, 0, 0.85);
}
.daily-inventory-cell--zero {
color: rgba(0, 0, 0, 0.3);
}
.daily-inventory-row--total > td {
font-weight: 600;
background: rgba(0, 0, 0, 0.03);
}
.daily-inventory-footer {
font-size: 12px;
}
.daily-inventory-help p {
margin: 0 0 6px;
line-height: 1.7;
font-size: 13px;
white-space: pre-wrap;
}
.daily-inventory-help p.is-indent {
padding-left: 16px;
color: rgba(0, 0, 0, 0.65);
}

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Card, Collapse, Dropdown, Input, Modal, Segmented, Select, Space, Spin, Switch,
Alert, Button, Card, Collapse, Dropdown, Input, Modal, Progress, Segmented, Select, Space, Spin, Switch,
Table, Tooltip, Typography, message,
} from 'antd';
import {
@ -18,6 +18,7 @@ import {
syncMkhBom,
} from '../../api/mkhBom';
import { createRequestGate } from '../../utils/requestGate';
import { requireMkhFreshContract } from '../../utils/mkhFreshContract';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
@ -33,6 +34,22 @@ const number = (value, digits = 0) => (value === null || value === undefined ||
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
const EMPTY_FILTERS = { keyword: '', scope: 'warning' };
// 页面长时打开时定期重读。后端共享 10 分钟索引缓存,因此多个页面的轮询
// 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。
const AUTO_REFRESH_MS = 2 * 60 * 1000;
const getRefreshProgress = (elapsedSeconds) => {
if (elapsedSeconds < 3) {
return { percent: 12 + elapsedSeconds * 8, stage: '正在读取实时库存、箱表和加工单…' };
}
if (elapsedSeconds < 15) {
return { percent: 36 + (elapsedSeconds - 3) * 3, stage: '正在核对待发货订单和近15天销量…' };
}
return {
percent: Math.min(95, 72 + Math.floor((elapsedSeconds - 15) / 2)),
stage: '正在合并最新数据与 BOM 配件结果…',
};
};
// 预警等级 → 标签配色
const WARNING_TONE = {
@ -263,6 +280,7 @@ const MkhBomData = () => {
// 导出(当前筛选条件下的全部数据)
const [exporting, setExporting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [refreshProgress, setRefreshProgress] = useState({ open: false, elapsedSeconds: 0, title: '' });
// 补货预警推送设置
const [alertOpen, setAlertOpen] = useState(false);
@ -277,8 +295,35 @@ const MkhBomData = () => {
const tableWrapRef = useRef(null);
const listRequestGateRef = useRef(createRequestGate());
const detailGenerationRef = useRef(0);
const refreshProgressGenerationRef = useRef(0);
const lastListRefreshAtRef = useRef(0);
const autoRefreshPendingRef = useRef(false);
const [bodyHeight, setBodyHeight] = useState(420);
const beginRefreshProgress = useCallback((title) => {
refreshProgressGenerationRef.current += 1;
const generation = refreshProgressGenerationRef.current;
setRefreshProgress({ open: true, elapsedSeconds: 0, title, generation });
return generation;
}, []);
const endRefreshProgress = useCallback((generation) => {
if (generation !== refreshProgressGenerationRef.current) return;
setRefreshProgress((current) => ({ ...current, open: false }));
}, []);
useEffect(() => {
if (!refreshProgress.open) return undefined;
const startedAt = Date.now();
const timer = window.setInterval(() => {
setRefreshProgress((current) => ({
...current,
elapsedSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1000)),
}));
}, 1000);
return () => window.clearInterval(timer);
}, [refreshProgress.open]);
useEffect(() => {
const el = tableWrapRef.current;
if (!el) return undefined;
@ -313,28 +358,72 @@ const MkhBomData = () => {
return params;
}, [page, pageSize, sorter, filters]);
const load = useCallback(async () => {
const load = useCallback(async ({ background = false } = {}) => {
const requestGeneration = listRequestGateRef.current.begin();
setLoading(true);
setError('');
let showProgress = false;
let progressGeneration = null;
if (!background) setLoading(true);
if (!background) setError('');
try {
const result = await getMkhBomList({ ...query, live: 1, _refresh: Date.now() });
const now = Date.now();
const forceFresh = !lastListRefreshAtRef.current
|| now - lastListRefreshAtRef.current >= AUTO_REFRESH_MS;
showProgress = forceFresh && !background;
if (showProgress) progressGeneration = beginRefreshProgress('正在更新最新业务数据');
// 首次打开/F5 和定时轮询才重读云库;筛选、分页只复用刚刷新的内存索引。
// 后端仍会合并多用户的并发刷新,不会对业务云库造成重复全量查询。
const result = await getMkhBomList({
...query,
live: 1,
...(forceFresh ? { fresh: 1 } : null),
_refresh: now,
});
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
if (!result || result.success === false) {
setError(result?.error || '加载 MKH-BOM 资料失败');
return;
}
setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 });
const snapshot = result?.data || null;
try {
requireMkhFreshContract(snapshot, { requireApplied: forceFresh });
} catch (contractError) {
setData({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false });
throw contractError;
}
setData(snapshot);
if (forceFresh) lastListRefreshAtRef.current = Date.now();
setError('');
} catch (requestError) {
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败');
} finally {
if (listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false);
if (!background && listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false);
if (showProgress) endRefreshProgress(progressGeneration);
}
}, [query]);
}, [beginRefreshProgress, endRefreshProgress, query]);
useEffect(() => { load(); }, [load]);
// 页面保持打开时自动更新;从后台切回且数据已超过轮询周期时立即重读。
// 后台刷新保留当前表格,不显示全页加载态;请求门仍会阻止旧响应覆盖新数据。
useEffect(() => {
const refreshIfStale = () => {
if (document.visibilityState !== 'visible' || syncing || autoRefreshPendingRef.current) return;
if (Date.now() - lastListRefreshAtRef.current < AUTO_REFRESH_MS) return;
autoRefreshPendingRef.current = true;
load({ background: true }).finally(() => {
autoRefreshPendingRef.current = false;
});
};
const timer = window.setInterval(refreshIfStale, AUTO_REFRESH_MS);
document.addEventListener('visibilitychange', refreshIfStale);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', refreshIfStale);
};
}, [load, syncing]);
const changeFilter = (key) => (value) => {
setFilters((old) => ({ ...old, [key]: value || '' }));
setPage(1);
@ -813,6 +902,7 @@ const MkhBomData = () => {
const handleSync = async () => {
setSyncing(true);
setLoading(true);
const progressGeneration = beginRefreshProgress('正在强制同步全部最新数据');
// 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。
listRequestGateRef.current.invalidate();
detailGenerationRef.current += 1;
@ -826,7 +916,8 @@ const MkhBomData = () => {
if (!result || result.success === false || !result?.data?.snapshot) {
throw new Error(result?.error || '同步接口未返回数据库快照');
}
setData(result.data.snapshot);
setData(requireMkhFreshContract(result.data.snapshot, { requireApplied: true }));
lastListRefreshAtRef.current = Date.now();
setError('');
message.success('已实时读取最新订单、库存、BOM 与仓位数据');
} catch (syncError) {
@ -837,11 +928,31 @@ const MkhBomData = () => {
} finally {
if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false);
setSyncing(false);
endRefreshProgress(progressGeneration);
}
};
const progressView = getRefreshProgress(refreshProgress.elapsedSeconds);
return (
<SycmPage className="mkh-page">
<Modal
open={refreshProgress.open}
title={refreshProgress.title}
footer={null}
closable={false}
keyboard={false}
maskClosable={false}
centered
width={480}
>
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<Progress percent={progressView.percent} status="active" strokeColor="#1677ff" />
<Text>{progressView.stage}</Text>
<Text type="secondary">已用时 {refreshProgress.elapsedSeconds} 秒,完成后页面会自动显示新数据。</Text>
<Text type="secondary">进度为查询阶段估算,实际完成以服务端返回为准。</Text>
</Space>
</Modal>
<header className="mkh-header">
<div className="mkh-header__heading">
<Title level={3}>MKH-BOM 资料</Title>

View File

@ -271,7 +271,10 @@ const AccessoryStockPanel = () => {
: int(value)),
},
{
title: '实时库存',
title: <TipTitle
title="实时库存"
tip="优先取目标仓 jst_inventoryInfo;该表为 0 或缺少记录、但箱及仓位库存已有正库存时,用同仓 jst_packInventoryInfo 合计补齐"
/>,
dataIndex: 'realtimeStock',
width: 96,
align: 'right',
@ -281,7 +284,7 @@ const AccessoryStockPanel = () => {
{
title: <TipTitle
title="采购在途数量"
tip="该商品(配件)全部采购单中『已下单 − 已入库』的未到货数量合计,取自 jst_purchaseItem.qty − inQty"
tip="采购单中『已下单 − 已入库』的数量,取自 jst_purchaseItem.qty − inQty;它与实时库存独立,上游采购到货字段滞后时可能同时显示库存和在途"
/>,
dataIndex: 'purchaseTransit',
width: 120,

View File

@ -19,6 +19,7 @@ const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncent
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
const DailyInventory = lazy(() => import('../pages/DailyInventory'));
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
const lazyLoadingElement = <PageLoadingFallback />;
@ -112,6 +113,10 @@ const router = createBrowserRouter([
path: 'planning/operation/mkh-bom-data',
element: renderLazyComponent(MkhBomData),
},
{
path: 'planning/operation/daily-inventory',
element: renderLazyComponent(DailyInventory),
},
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
{

View File

@ -0,0 +1,15 @@
export const MKH_FRESH_DATA_CONTRACT_VERSION = 1;
const OUTDATED_BACKEND_MESSAGE = '本地后端版本过旧或更新后未重启,已停止展示可能过期的库存。'
+ '请停止当前 npm run remote:dev,拉取最新 main 后重新启动。';
export function requireMkhFreshContract(snapshot, { requireApplied = false } = {}) {
const contractVersion = Number(snapshot?.freshDataContractVersion) || 0;
if (contractVersion < MKH_FRESH_DATA_CONTRACT_VERSION) {
throw new Error(OUTDATED_BACKEND_MESSAGE);
}
if (requireApplied && snapshot?.freshDataApplied !== true) {
throw new Error('后端未确认已重读最新运营数据,已停止展示可能过期的库存,请重试。');
}
return snapshot;
}

View File

@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { requireMkhFreshContract } from '../src/utils/mkhFreshContract.js';
test('MKH fresh contract accepts a backend-confirmed live refresh', () => {
const snapshot = { freshDataContractVersion: 1, freshDataApplied: true };
assert.equal(requireMkhFreshContract(snapshot, { requireApplied: true }), snapshot);
});
test('MKH fresh contract rejects an outdated local backend instead of showing stale stock', () => {
assert.throws(
() => requireMkhFreshContract({ rows: [{ stock: 4 }] }, { requireApplied: true }),
/本地后端版本过旧/
);
});
test('MKH fresh contract requires explicit refresh completion for fresh requests', () => {
assert.throws(
() => requireMkhFreshContract({ freshDataContractVersion: 1 }, { requireApplied: true }),
/未确认已重读/
);
});