diff --git a/README.md b/README.md index c51db3c..c36d8af 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ npm run remote:dev 首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。 +拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。 + ## 验证与构建 ```bash diff --git a/backend/scripts/gen-daily-inventory-baseline.cjs b/backend/scripts/gen-daily-inventory-baseline.cjs new file mode 100644 index 0000000..8ed220f --- /dev/null +++ b/backend/scripts/gen-daily-inventory-baseline.cjs @@ -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); }); diff --git a/backend/scripts/verify-daily-inventory.js b/backend/scripts/verify-daily-inventory.js new file mode 100644 index 0000000..38c1ec1 --- /dev/null +++ b/backend/scripts/verify-daily-inventory.js @@ -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); }); diff --git a/backend/src/api/dailyInventory.js b/backend/src/api/dailyInventory.js new file mode 100644 index 0000000..7bdc9a1 --- /dev/null +++ b/backend/src/api/dailyInventory.js @@ -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; diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index 252019d..a4aa503 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -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 }; diff --git a/backend/src/config/dailyInventoryBaseline.js b/backend/src/config/dailyInventoryBaseline.js new file mode 100644 index 0000000..f8a9c5f --- /dev/null +++ b/backend/src/config/dailyInventoryBaseline.js @@ -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, +}; diff --git a/backend/src/index.js b/backend/src/index.js index ebe583b..7ee85f3 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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); diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 401e60e..f585725 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -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, }, }; diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 1718467..affb51f 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -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, diff --git a/backend/test/mkhBomApi.test.js b/backend/test/mkhBomApi.test.js new file mode 100644 index 0000000..8d333f7 --- /dev/null +++ b/backend/test/mkhBomApi.test.js @@ -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' } + ); +}); diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 93cdded..4f380e5 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -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(); + } +}); diff --git a/frontend/src/api/dailyInventory.js b/frontend/src/api/dailyInventory.js new file mode 100644 index 0000000..e844361 --- /dev/null +++ b/frontend/src/api/dailyInventory.js @@ -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 } +); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index d74aba2..e6ac086 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -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: [ diff --git a/frontend/src/constants/dailyInventory.js b/frontend/src/constants/dailyInventory.js new file mode 100644 index 0000000..c66257f --- /dev/null +++ b/frontend/src/constants/dailyInventory.js @@ -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% → 商品僵尸', +]; diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js index 4545a75..f6df6b6 100644 --- a/frontend/src/constants/sidebarPages.js +++ b/frontend/src/constants/sidebarPages.js @@ -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; +}, {}); diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index aa991d6..ab110ba 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -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' }, diff --git a/frontend/src/pages/DailyInventory/index.jsx b/frontend/src/pages/DailyInventory/index.jsx new file mode 100644 index 0000000..d22ac5a --- /dev/null +++ b/frontend/src/pages/DailyInventory/index.jsx @@ -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 }) => ( + + {children} + +); + +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 0; + return {int(n)}; +}; + +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: {col.label}, + 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 {value}; + 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 ( + +
+
+ 每日进销存 + +
+
+ 基准日期:{data?.baselineDate || '—'} + 库存快照日:{data?.latestBizDate || '—'} + 分级基准时刻:{data?.asOf || '—'} + {data?.items?.[0]?.closingTakenAt ? ( + + 库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })} + + ) : null} +
+ + + +
+ + + 期初数量与 12 个流水列取自 {data?.baselineDate || '2026-09-22'} 聚水潭进销存导出值(业务库缺少调拨/盘点/加工/其它出入库单据表,无法还原); + 期末数量与右侧 5 列为运行时实时取数。悬浮表头可看每列口径。 + {noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在库存快照中无数据,周转天数与四级库存量显示「—」。` : ''} + + } + style={{ marginBottom: 12 }} + /> + + {error ? : null} + + + 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' : '')} + /> + + + + 共 {items.length} 个仓库;蓝色底纹列为运行时取数,其余为基准值。 + + + setHelpOpen(false)} + footer={null} + width={720} + > +
+ {FORMULA_DESCRIPTION.map((line, i) => ( +

{line}

+ ))} +
+
+ + ); +}; + +export default DailyInventory; diff --git a/frontend/src/pages/DailyInventory/style.css b/frontend/src/pages/DailyInventory/style.css new file mode 100644 index 0000000..2019966 --- /dev/null +++ b/frontend/src/pages/DailyInventory/style.css @@ -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); +} diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 9e434cb..993ac5f 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -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 ( + + + + {progressView.stage} + 已用时 {refreshProgress.elapsedSeconds} 秒,完成后页面会自动显示新数据。 + 进度为查询阶段估算,实际完成以服务端返回为准。 + +
MKH-BOM 资料 diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index ec08b52..dd3c9a2 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -271,7 +271,10 @@ const AccessoryStockPanel = () => { : int(value)), }, { - title: '实时库存', + title: , dataIndex: 'realtimeStock', width: 96, align: 'right', @@ -281,7 +284,7 @@ const AccessoryStockPanel = () => { { title: , dataIndex: 'purchaseTransit', width: 120, diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index cd0ec68..5b297d6 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -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 = ; @@ -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 区分取哪个仓; // 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线) { diff --git a/frontend/src/utils/mkhFreshContract.js b/frontend/src/utils/mkhFreshContract.js new file mode 100644 index 0000000..2ba828d --- /dev/null +++ b/frontend/src/utils/mkhFreshContract.js @@ -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; +} diff --git a/frontend/test/mkhFreshContract.test.mjs b/frontend/test/mkhFreshContract.test.mjs new file mode 100644 index 0000000..261f79d --- /dev/null +++ b/frontend/test/mkhFreshContract.test.mjs @@ -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 }), + /未确认已重读/ + ); +});