Merge branch 'main' into codex/cangku-returns-monitor-001 (sync 41 commits, keep both sides)
This commit is contained in:
commit
90fa54bd8c
@ -18,6 +18,8 @@ npm run remote:dev
|
||||
|
||||
首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。
|
||||
|
||||
拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。
|
||||
|
||||
## 验证与构建
|
||||
|
||||
```bash
|
||||
|
||||
106
backend/scripts/gen-daily-inventory-baseline.cjs
Normal file
106
backend/scripts/gen-daily-inventory-baseline.cjs
Normal 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); });
|
||||
96
backend/scripts/verify-daily-inventory.js
Normal file
96
backend/scripts/verify-daily-inventory.js
Normal 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); });
|
||||
179
backend/src/api/dailyInventory.js
Normal file
179
backend/src/api/dailyInventory.js
Normal 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;
|
||||
@ -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 };
|
||||
|
||||
558
backend/src/config/dailyInventoryBaseline.js
Normal file
558
backend/src/config/dailyInventoryBaseline.js
Normal 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,
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
13
backend/test/mkhBomApi.test.js
Normal file
13
backend/test/mkhBomApi.test.js
Normal 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' }
|
||||
);
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
9
frontend/src/api/dailyInventory.js
Normal file
9
frontend/src/api/dailyInventory.js
Normal 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 }
|
||||
);
|
||||
@ -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: [
|
||||
|
||||
61
frontend/src/constants/dailyInventory.js
Normal file
61
frontend/src/constants/dailyInventory.js
Normal 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% → 商品僵尸',
|
||||
];
|
||||
@ -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;
|
||||
}, {});
|
||||
|
||||
@ -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' },
|
||||
|
||||
177
frontend/src/pages/DailyInventory/index.jsx
Normal file
177
frontend/src/pages/DailyInventory/index.jsx
Normal 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;
|
||||
91
frontend/src/pages/DailyInventory/style.css
Normal file
91
frontend/src/pages/DailyInventory/style.css
Normal 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);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 区分取哪个仓;
|
||||
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
|
||||
{
|
||||
|
||||
15
frontend/src/utils/mkhFreshContract.js
Normal file
15
frontend/src/utils/mkhFreshContract.js
Normal 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;
|
||||
}
|
||||
23
frontend/test/mkhFreshContract.test.mjs
Normal file
23
frontend/test/mkhFreshContract.test.mjs
Normal 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 }),
|
||||
/未确认已重读/
|
||||
);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user