diff --git a/backend/scripts/gen-daily-inventory-baseline.cjs b/backend/scripts/gen-daily-inventory-baseline.cjs deleted file mode 100644 index 8ed220f..0000000 --- a/backend/scripts/gen-daily-inventory-baseline.cjs +++ /dev/null @@ -1,106 +0,0 @@ -process.env.CANGKU_SKIP_BOOTSTRAP = '1'; -const fs = require('fs'); -const path = require('path'); -const XLSX = require('xlsx'); -const cloudDb = require('../src/config/cloudDatabase'); - -// 表格的 12 个流水列 → 配置字段名 -const FLOW_FIELDS = [ - ['加工进仓', 'processIn'], - ['采购退货', 'purchaseReturn'], - ['采购进仓', 'purchaseIn'], - ['其它进仓', 'otherIn'], - ['其它退货', 'otherReturn'], - ['销售出仓', 'salesOut'], - ['盘点', 'stocktaking'], - ['加工出仓', 'processOut'], - ['调拨出', 'transferOut'], - ['销售退货', 'salesReturn'], - ['调拨入', 'transferIn'], - ['其它出库', 'otherOut'], -]; - -(async () => { - const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx'); - const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' }); - const h = rows[0]; - const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i])); - - const level1 = String(rows[1][idx['一级功能按键']]).trim(); - const level2 = String(rows[1][idx['二级功能按键']]).trim(); - console.log('一级功能:', level1, '| 二级功能:', level2); - - const whs = await cloudDb.query(`SELECT name, wms_co_id FROM rpt.jst_warehouseInfo`); - const byName = new Map(whs.map((w) => [String(w.name).trim(), w.wms_co_id])); - - const records = []; - const missing = []; - for (const r of rows.slice(1)) { - const sheetName = String(r[idx['仓库']] || '').trim(); - if (!sheetName) continue; - const base = sheetName.replace(/主仓$/, ''); - const wmsCoId = byName.get(base) ?? byName.get(sheetName) ?? null; - if (wmsCoId == null) missing.push(sheetName); - - const num = (k) => { - const v = r[idx[k]]; - return typeof v === 'number' ? v : Number(String(v || '').replace(/,/g, '')) || 0; - }; - - const flows = Object.fromEntries(FLOW_FIELDS.map(([cn, en]) => [en, num(cn)])); - const openExtra = r[idx['期初']]; // 第二个「期初」列,索引会与「期初数量」冲突,单独处理 - records.push({ - warehouse: sheetName, - dbWarehouse: base, - wmsCoId, - openingQty: num('期初数量'), - openingQtyExtra: typeof openExtra === 'number' ? openExtra : Number(openExtra) || 0, - ...flows, - }); - } - - console.log('记录数:', records.length, '| 缺 wms_co_id:', missing.length, missing.join(',') || '(无)'); - - const FLOW_COLUMNS_DEF = FLOW_FIELDS.map(([cn, en]) => ({ label: cn, field: en })); - const out = `/** - * 每日进销存 —— 2026-09-22 进销存基准流水 - * - * 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出) - * 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库 - * 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。 - * - * 口径约定: - * - openingQty 与 12 个流水列:22 号基准值(本文件静态存储) - * - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确) - * - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算 - */ - -/** 一级/二级菜单名(取自表格) */ -const DAILY_INVENTORY_LEVEL1 = '${level1}'; -const DAILY_INVENTORY_LEVEL2 = '${level2}'; - -/** 基准数据日期 */ -const BASELINE_DATE = '2026-09-22'; - -/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */ -const FLOW_COLUMNS = ${JSON.stringify(FLOW_COLUMNS_DEF, null, 2)}; - -/** 25 个仓库的 22 号基准流水 */ -const DAILY_INVENTORY_BASELINE = ${JSON.stringify(records, null, 2)}; - -module.exports = { - DAILY_INVENTORY_LEVEL1, - DAILY_INVENTORY_LEVEL2, - BASELINE_DATE, - FLOW_COLUMNS, - DAILY_INVENTORY_BASELINE, -}; -`; - - const target = path.join(__dirname, '../src/config/dailyInventoryBaseline.js'); - fs.writeFileSync(target, out, 'utf8'); - console.log('已生成:', target); - console.log('\n前 2 条预览:'); - console.log(JSON.stringify(records.slice(0, 2), null, 1)); - process.exit(0); -})().catch((e) => { console.log('ERR', e.message); process.exit(1); }); diff --git a/backend/scripts/sync-daily-inventory-snapshot.cjs b/backend/scripts/sync-daily-inventory-snapshot.cjs new file mode 100644 index 0000000..15d9dd5 --- /dev/null +++ b/backend/scripts/sync-daily-inventory-snapshot.cjs @@ -0,0 +1,25 @@ +/** + * 手动同步一次每日库存快照 + * + * 用途:补跑 / 排障 / 挂到系统计划任务(Windows 任务计划程序、Linux crontab)。 + * 业务库没有按天的历史库存结存表,本脚本把 26 仓实时库存落一份到本地快照表, + * 使「今日期初 = 上一业务日期末」能够自动滚动。 + * + * 运行:node scripts/sync-daily-inventory-snapshot.cjs + * 业务日自动取库存快照库 rpt.pm_stockMonitor 的最新日期(T-1)。 + * + * 生产环境下后端每日 01:30 的定时任务(src/jobs/dailyInventorySnapshotJob.js)会自动执行, + * 本脚本仅作为兜底与手动补跑手段。 + */ +process.env.CANGKU_SKIP_BOOTSTRAP = '1'; + +const { syncOnce } = require('../src/jobs/dailyInventorySnapshotJob'); + +syncOnce('manual') + .then((result) => { + console.log('[快照同步] 结果:', JSON.stringify(result)); + }) + .catch((error) => { + console.error('[快照同步] 失败:', error.message); + }) + .finally(() => process.exit(0)); diff --git a/backend/scripts/verify-daily-inventory.js b/backend/scripts/verify-daily-inventory.js deleted file mode 100644 index 38c1ec1..0000000 --- a/backend/scripts/verify-daily-inventory.js +++ /dev/null @@ -1,96 +0,0 @@ -process.env.CANGKU_SKIP_BOOTSTRAP = '1'; -const http = require('http'); -const XLSX = require('xlsx'); - -const FIELD_MAP = [ - ['期初数量', 'openingQty'], - ['加工进仓', 'processIn'], - ['采购退货', 'purchaseReturn'], - ['采购进仓', 'purchaseIn'], - ['其它进仓', 'otherIn'], - ['其它退货', 'otherReturn'], - ['销售出仓', 'salesOut'], - ['盘点', 'stocktaking'], - ['加工出仓', 'processOut'], - ['调拨出', 'transferOut'], - ['销售退货', 'salesReturn'], - ['调拨入', 'transferIn'], - ['期初', 'openingQtyExtra'], - ['其它出库', 'otherOut'], - ['期末数量', 'closingQty'], -]; - -function req(method, path, token, body) { - return new Promise((resolve, reject) => { - const data = body ? JSON.stringify(body) : null; - const r = http.request({ - host: '127.0.0.1', port: 4001, path, method, - headers: { - 'Content-Type': 'application/json', - ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}), - ...(token ? { Authorization: 'Bearer ' + token } : {}), - }, - }, (resp) => { let s = ''; resp.on('data', (d) => { s += d; }); resp.on('end', () => resolve({ code: resp.statusCode, body: s })); }); - r.on('error', reject); - if (data) r.write(data); - r.end(); - }); -} - -(async () => { - const login = JSON.parse((await req('POST', '/api/auth/local-preview', null, {})).body); - const token = login.access_token || login.token; - const res = await req('GET', '/api/daily-inventory', token); - if (res.code !== 200) { console.log('接口失败', res.code, res.body.slice(0, 300)); process.exit(1); } - const d = JSON.parse(res.body).data; - - const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx'); - const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' }); - const h = rows[0]; - const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i])); - const byName = new Map(d.items.map((it) => [it.warehouse, it])); - - console.log('接口: 基准日 %s | 快照日 %s | asOf %s | 仓数 %d', d.baselineDate, d.latestBizDate, d.asOf, d.items.length); - console.log(); - - const diffCounts = {}; - let totalCells = 0; - let diffCells = 0; - const samples = []; - - for (const r of rows.slice(1)) { - const name = String(r[idx['仓库']] || '').trim(); - if (!name) continue; - const item = byName.get(name); - if (!item) { console.log('接口缺少仓库:', name); continue; } - for (const [cn, field] of FIELD_MAP) { - const raw = r[idx[cn]]; - const expected = typeof raw === 'number' ? raw : Number(String(raw || '').replace(/,/g, '')) || 0; - const actual = Number(item[field] ?? 0); - totalCells += 1; - // 期末允许 1 件内的副本同步误差 - const tol = field === 'closingQty' ? 10 : 0; - if (Math.abs(expected - actual) > tol) { - diffCells += 1; - diffCounts[cn] = (diffCounts[cn] || 0) + 1; - if (samples.length < 12) samples.push(`${name} | ${cn} | 表格 ${expected} vs 接口 ${actual}`); - } - } - } - - console.log('比对单元格总数: %d,不一致: %d', totalCells, diffCells); - if (diffCells) { - console.log('\n不一致列统计:', JSON.stringify(diffCounts)); - console.log('样例:'); - samples.forEach((s) => console.log(' ' + s)); - } else { - console.log('✓ 全部单元格与表格一致'); - } - - const withGrade = d.items.filter((x) => x.hasGrade).length; - console.log('\n有分级数据仓: %d/%d', withGrade, d.items.length); - console.log('无分级的仓:', d.items.filter((x) => !x.hasGrade).map((x) => x.warehouse).join('、')); - console.log('\n合计: 期初 %s,期末 %s', d.totals.openingQty, d.totals.closingQty); - console.log('崔桥样例:', JSON.stringify(d.items[0], null, 1).replace(/\n\s*/g, ' ')); - process.exit(0); -})().catch((e) => { console.log('ERR', e.message); process.exit(1); }); diff --git a/backend/src/api/dailyInventory.js b/backend/src/api/dailyInventory.js index 7bdc9a1..fa088ea 100644 --- a/backend/src/api/dailyInventory.js +++ b/backend/src/api/dailyInventory.js @@ -1,8 +1,8 @@ const express = require('express'); const cloudDb = require('../config/cloudDatabase'); +const snapshotDb = require('../config/dailyInventorySnapshotDb'); const { DAILY_INVENTORY_BASELINE, - BASELINE_DATE, FLOW_COLUMNS, } = require('../config/dailyInventoryBaseline'); @@ -11,6 +11,19 @@ const router = express.Router(); /** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */ const AS_OF_TIME = '01:00'; +/** + * 「月度」发货窗口天数:滚动 30 天。 + * 库龄阈值:3 个月 = 90 天、12 个月 = 365 天(按日历天折算)。 + */ +const SHIP_WINDOW_DAYS = 30; +const AGE_3M_DAYS = 90; +const AGE_12M_DAYS = 365; + +/** 结果缓存:单次查询要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */ +const RESULT_CACHE_TTL_MS = 5 * 60 * 1000; +let cachedResult = null; +let cachedResultAt = 0; + const toNumber = (v) => { if (v == null) return null; const n = Number(v); @@ -25,6 +38,14 @@ function nextDayAsOf(bizDate) { return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`; } +/** 发货窗口:[asOf - 30 天, asOf),滚动一个月 */ +function shipWindow(asOf) { + const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`); + const from = new Date(to.getTime() - SHIP_WINDOW_DAYS * 86400000); + const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' '); + return { shipFrom: fmt(from), shipTo: fmt(to) }; +} + async function fetchLatestBizDate() { const rows = await cloudDb.query( `SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor` @@ -32,125 +53,242 @@ async function fetchLatestBizDate() { return rows[0]?.d || null; } +/** 上一自然日(日历天,用于取「期初 = 昨日结存」) */ +function prevDay(bizDate) { + if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null; + const d = new Date(`${bizDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() - 1); + return d.toISOString().slice(0, 10); +} + +/** mssql 不支持数组参数,按位展开成 @p0,@p1... */ +function buildInClause(values, prefix) { + const params = {}; + const clause = values + .map((v, i) => { + const key = `${prefix}${i}`; + params[key] = v; + return `@${key}`; + }) + .join(','); + return { clause, params }; +} + /** - * 派生列(SQL CASE WHEN 下沉计算) - * - 库龄(天) = DATEDIFF(asOf, created) - * - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100% - * 月度发货总件数 = threeAvgSale × 30;月度日均库存件数 = jsStock - * - 四级分级:库龄 + 周转率双维度 + * 分级规则(库龄 + 周转率双维度),口径与业务给定一致: + * 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警 + * 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流 + * 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流 + * 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞 + * 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞 + * 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸 + * 边界:库龄正好 90 天不计入「慢流」(业务描述为 > 3 个月),正好 365 天仍属「3~12 个月」。 */ -function buildDerivedColumns(asOf) { - const A = '@asOf'; - void A; - const ageDays = `DATEDIFF(DAY, m.created, @asOf)`; - const turnover = `(ISNULL(m.threeAvgSale, 0) * 30.0 * 100.0 / NULLIF(m.jsStock, 0))`; - return { - ageDays, - turnoverDays: `CAST(${turnover} AS DECIMAL(18, 4))`, - levelExpr: ` +function buildLevelExpr() { + const t = 'b.turnoverPct'; + return ` CASE - WHEN m.created IS NULL OR ISNULL(m.jsStock, 0) = 0 OR ${turnover} IS NULL THEN NULL - WHEN ${ageDays} < 90 AND ${turnover} BETWEEN 30 AND 199 THEN 'warn' - WHEN ${ageDays} < 90 AND ${turnover} >= 200 THEN 'slow' - WHEN ${ageDays} >= 90 AND ${turnover} BETWEEN 30 AND 99 THEN 'slow' - WHEN ${ageDays} BETWEEN 90 AND 365 AND ${turnover} >= 100 THEN 'stale' - WHEN ${ageDays} > 365 AND ${turnover} BETWEEN 100 AND 199 THEN 'stale' - WHEN ${ageDays} > 365 AND ${turnover} >= 200 THEN 'zombie' + WHEN b.stockQty <= 0 OR ${t} IS NULL OR b.ageDays IS NULL THEN NULL + WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 200 THEN 'warn' + WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 200 THEN 'slow' + WHEN b.ageDays > ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 100 THEN 'slow' + WHEN b.ageDays >= ${AGE_3M_DAYS} AND b.ageDays <= ${AGE_12M_DAYS} AND ${t} >= 100 THEN 'stale' + WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 100 AND ${t} < 200 THEN 'stale' + WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 200 THEN 'zombie' ELSE NULL - END`, - }; + END`; } -/** 该仓快照里的四级库存量与平均周转天数 */ -async function fetchGradeMetrics(dbWarehouse, bizDate, asOf) { - const D = buildDerivedColumns(asOf); - const rows = await cloudDb.query( - `SELECT COUNT(*) AS skuCount, - SUM(CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2))) AS gradeStock, - AVG(${D.turnoverDays}) AS avgTurnover, - SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel, - SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel, - SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel, - SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS zombieLevel - FROM rpt.pm_stockMonitor m - WHERE m.warehouse = @warehouse - AND CONVERT(VARCHAR(10), m.date, 23) = @bizDate`, - { warehouse: dbWarehouse, bizDate, asOf } - ); - const r = rows[0] || {}; - if (!Number(r.skuCount || 0)) return null; - return { - skuCount: toNumber(r.skuCount), - gradeStock: toNumber(r.gradeStock), - turnoverDays: toNumber(r.avgTurnover), - warnLevel: toNumber(r.warnLevel), - slowLevel: toNumber(r.slowLevel), - staleLevel: toNumber(r.staleLevel), - zombieLevel: toNumber(r.zombieLevel), - }; +/** + * 每个仓库的周转率与四级库存量(一次查询覆盖全部仓库)。 + * + * 口径: + * 库存周转天数(周转率)= 月度发货总件数 / 月度日均库存件数 × 100% + * · 月度发货总件数 = 该仓近 30 天已发货订单件数(jst_OrderItem × jst_Orders,按 send_date,排除 Delete/Cancelled) + * · 月度日均库存件数 = 该仓聚水潭库存 jst_inventoryInfo(无历史结存表,以当日库存近似月度日均) + * 库龄(天) = DATEDIFF(基准时刻, SKU 建档时间 created) + * + * SKU 关联链路:jst_inventoryInfo.sku_id = jst_OrderItem.sku_id = pm_stockMonitor.sku_id(实测崔桥 1852/1852 命中) + */ +async function fetchGradeMetrics(wmsCoIds, bizDate, asOf) { + if (!wmsCoIds.length || !bizDate || !asOf) return new Map(); + const invIn = buildInClause(wmsCoIds, 'w'); + const shipIn = buildInClause(wmsCoIds, 'v'); + const { shipFrom, shipTo } = shipWindow(asOf); + const level = buildLevelExpr(); + + const sqlText = ` + WITH inv AS ( + SELECT i.wms_co_id AS wmsCoId, + i.sku_id AS skuId, + SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty + FROM rpt.jst_inventoryInfo i + WHERE i.wms_co_id IN (${invIn.clause}) + GROUP BY i.wms_co_id, i.sku_id + ), + created AS ( + SELECT m.sku_id AS skuId, MIN(m.created) AS created + FROM rpt.pm_stockMonitor m + WHERE CONVERT(VARCHAR(10), m.date, 23) = @bizDate + GROUP BY m.sku_id + ), + ship AS ( + SELECT o.wms_co_id AS wmsCoId, + oi.sku_id AS skuId, + SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS shipQty + FROM rpt.jst_OrderItem oi + JOIN rpt.jst_Orders o ON o.o_id = oi.o_id + WHERE o.wms_co_id IN (${shipIn.clause}) + AND o.send_date >= @shipFrom + AND o.send_date < @shipTo + AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled') + GROUP BY o.wms_co_id, oi.sku_id + ), + base AS ( + SELECT v.wmsCoId, + v.skuId, + v.stockQty, + ISNULL(s.shipQty, 0) AS shipQty, + DATEDIFF(DAY, c.created, @asOf) AS ageDays, + CASE WHEN v.stockQty <= 0 THEN NULL + ELSE ISNULL(s.shipQty, 0) * 100.0 / v.stockQty END AS turnoverPct + FROM inv v + LEFT JOIN ship s ON s.wmsCoId = v.wmsCoId AND s.skuId = v.skuId + LEFT JOIN created c ON c.skuId = v.skuId + ) + SELECT b.wmsCoId, + SUM(CASE WHEN ABS(b.stockQty) > 0.0001 THEN 1 ELSE 0 END) AS skuCount, + SUM(b.stockQty) AS totalStock, + SUM(b.shipQty) AS totalShip, + SUM(CASE WHEN b.ageDays IS NULL OR b.turnoverPct IS NULL OR b.stockQty <= 0 THEN 1 ELSE 0 END) AS ungradedSku, + CASE WHEN SUM(b.stockQty) > 0 + THEN CAST(SUM(b.shipQty) * 100.0 / SUM(b.stockQty) AS DECIMAL(18, 2)) + ELSE NULL END AS turnoverPct, + SUM(CASE WHEN ${level} = 'warn' THEN b.stockQty ELSE 0 END) AS warnLevel, + SUM(CASE WHEN ${level} = 'slow' THEN b.stockQty ELSE 0 END) AS slowLevel, + SUM(CASE WHEN ${level} = 'stale' THEN b.stockQty ELSE 0 END) AS staleLevel, + SUM(CASE WHEN ${level} = 'zombie' THEN b.stockQty ELSE 0 END) AS zombieLevel + FROM base b + GROUP BY b.wmsCoId`; + + const rows = await cloudDb.query(sqlText, { + ...invIn.params, + ...shipIn.params, + bizDate, + asOf, + shipFrom, + shipTo, + }, { requestTimeout: 120000 }); + + const map = new Map(); + rows.forEach((r) => { + map.set(String(r.wmsCoId), { + skuCount: toNumber(r.skuCount) || 0, + gradeStock: toNumber(r.totalStock) || 0, + shipQty: toNumber(r.totalShip) || 0, + turnoverDays: toNumber(r.turnoverPct), + warnLevel: toNumber(r.warnLevel) || 0, + slowLevel: toNumber(r.slowLevel) || 0, + staleLevel: toNumber(r.staleLevel) || 0, + zombieLevel: toNumber(r.zombieLevel) || 0, + ungradedSku: toNumber(r.ungradedSku) || 0, + }); + }); + return map; } -/** 实时期末库存(单仓 wms_co_id),并带回最近更新时间便于核对时效 */ -async function fetchClosingQty(wmsCoId) { - if (wmsCoId == null) return null; +/** 实时库存表的数据时效(一次取全局即可) */ +async function fetchInventoryTakenAt() { const rows = await cloudDb.query( - `SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS qty, - COUNT(CASE WHEN ISNULL(qty, 0) <> 0 THEN 1 END) AS skuCount, - MAX(modified) AS takenAt - FROM rpt.jst_inventoryInfo - WHERE wms_co_id = @wmsCoId`, - { wmsCoId } + `SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo` ); - const r = rows[0] || {}; - return { - closingQty: toNumber(r.qty), - closingSkuCount: toNumber(r.skuCount) || 0, - closingTakenAt: r.takenAt || null, - }; + return rows[0]?.takenAt || null; } /** * GET /api/daily-inventory - * 每日进销存:25 个仓库 × 进销存流水 + 期末 + 分级库存量 + * 每日进销存:26 个仓库 ×(期初 / 期末 / 周转率 / 四级库存量) * - * 数据源分工: - * - 期初数量 与 12 个流水列:22 号基准(dailyInventoryBaseline.js) - * - 期末数量:jst_inventoryInfo 实时库存 - * - 库存周转天数 / 四级库存量:pm_stockMonitor 快照 + * 数据源分工(全部来自数据库,无硬编码业务数值): + * - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存 + * - 期末数量:jst_inventoryInfo 实时库存(按 wms_co_id 单仓) + * - 库存周转天数 / 四级库存量:近 30 天发货 × 实时库存 × SKU 建档日期 + * - 12 个流水列:业务库缺调拨/盘点/加工/其它出入库单据表,返回 null(前端显示「—」) */ router.get('/', async (req, res) => { try { + const force = String(req.query?.refresh || '') === '1'; const latestBizDate = await fetchLatestBizDate().catch(() => null); + const cacheKey = latestBizDate || 'none'; + if (!force && cachedResult && cachedResult.latestBizDate === cacheKey + && Date.now() - cachedResultAt < RESULT_CACHE_TTL_MS) { + return res.json({ success: true, data: cachedResult, cached: true }); + } + const asOf = nextDayAsOf(latestBizDate); + const openingDate = prevDay(latestBizDate); + const wmsCoIds = DAILY_INVENTORY_BASELINE + .map((b) => b.wmsCoId) + .filter((v) => v != null); - const items = []; - for (const base of DAILY_INVENTORY_BASELINE) { + const [gradeMap, takenAt] = await Promise.all([ + fetchGradeMetrics(wmsCoIds, latestBizDate, asOf).catch((e) => { + console.error('[daily-inventory] 分级计算失败:', e.message); + return new Map(); + }), + fetchInventoryTakenAt().catch(() => null), + ]); + + // 期初 = 上一业务日的期末快照;若定时任务漏跑,顺手把当前业务日的快照补齐 + const openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map()); + const todayMap = await snapshotDb.fetchSnapshot(latestBizDate).catch(() => new Map()); + const missing = DAILY_INVENTORY_BASELINE + .filter((b) => b.wmsCoId != null && !todayMap.has(String(b.wmsCoId))) + .map((b) => { + const g = gradeMap.get(String(b.wmsCoId)); + return { + wmsCoId: b.wmsCoId, + warehouse: b.warehouse, + dbWarehouse: b.dbWarehouse, + closingQty: Number(g?.gradeStock ?? 0), + skuCount: Number(g?.skuCount ?? 0), + takenAt: takenAt ? new Date(takenAt) : null, + }; + }); + if (missing.length) { + await snapshotDb.upsertSnapshot(latestBizDate, missing).catch((e) => { + console.error('[daily-inventory] 快照补写失败:', e.message); + }); + } + + const items = DAILY_INVENTORY_BASELINE.map((base) => { + // 12 个流水列:业务库无单据来源,一律 null(前端渲染为「—」) const flows = {}; - for (const col of FLOW_COLUMNS) flows[col.field] = base[col.field] ?? 0; + for (const col of FLOW_COLUMNS) flows[col.field] = null; - const closing = await fetchClosingQty(base.wmsCoId).catch(() => null); - const grade = base.dbWarehouse && latestBizDate - ? await fetchGradeMetrics(base.dbWarehouse, latestBizDate, asOf).catch(() => null) - : null; + const grade = base.wmsCoId != null ? gradeMap.get(String(base.wmsCoId)) : null; + const opening = base.wmsCoId != null ? openingMap.get(String(base.wmsCoId)) : null; - items.push({ + return { warehouse: base.warehouse, dbWarehouse: base.dbWarehouse, - openingQty: base.openingQty, - openingQtyExtra: base.openingQtyExtra, + openingQty: opening ? opening.closingQty : null, + openingQtyExtra: null, ...flows, - closingQty: closing?.closingQty ?? null, - closingSkuCount: closing?.closingSkuCount ?? null, - closingTakenAt: closing?.closingTakenAt ?? null, + closingQty: grade?.gradeStock ?? null, + closingSkuCount: grade?.skuCount ?? null, + closingTakenAt: takenAt, + shipQty30d: grade?.shipQty ?? null, turnoverDays: grade?.turnoverDays ?? null, gradeStock: grade?.gradeStock ?? null, warnLevel: grade?.warnLevel ?? null, slowLevel: grade?.slowLevel ?? null, staleLevel: grade?.staleLevel ?? null, zombieLevel: grade?.zombieLevel ?? null, + ungradedSku: grade?.ungradedSku ?? null, hasGrade: Boolean(grade), - }); - } + }; + }); // 合计行:便于与表格总额核对 const sum = (k) => items.reduce((acc, it) => acc + (Number(it[k]) || 0), 0); @@ -158,21 +296,32 @@ router.get('/', async (req, res) => { for (const k of ['openingQty', 'openingQtyExtra', ...FLOW_COLUMNS.map((c) => c.field), 'closingQty', 'warnLevel', 'slowLevel', 'staleLevel', 'zombieLevel']) { totals[k] = Number(sum(k).toFixed(2)); } + // 合计行周转率按「总发货 / 总库存」重算,避免简单累加失真 + totals.shipQty30d = Number(sum('shipQty30d').toFixed(2)); + const totalStock = sum('closingQty'); + totals.turnoverDays = totalStock > 0 + ? Number((sum('shipQty30d') * 100 / totalStock).toFixed(2)) + : null; - res.json({ - success: true, - data: { - baselineDate: BASELINE_DATE, - latestBizDate, - asOf, - flowColumns: FLOW_COLUMNS, - items, - totals, - }, - }); + const noOpeningCount = items.filter((it) => it.openingQty == null).length; + + const payload = { + openingDate, + latestBizDate, + asOf, + shipWindow: asOf ? { ...shipWindow(asOf), days: SHIP_WINDOW_DAYS } : null, + flowColumns: FLOW_COLUMNS, + flowColumnsAvailable: false, + noOpeningCount, + items, + totals, + }; + cachedResult = payload; + cachedResultAt = Date.now(); + return res.json({ success: true, data: payload }); } catch (error) { console.error('[daily-inventory] 查询失败:', error.message); - res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message }); + return res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message }); } }); diff --git a/backend/src/config/dailyInventoryBaseline.js b/backend/src/config/dailyInventoryBaseline.js index f8a9c5f..8e4e828 100644 --- a/backend/src/config/dailyInventoryBaseline.js +++ b/backend/src/config/dailyInventoryBaseline.js @@ -1,558 +1,209 @@ /** - * 每日进销存 —— 2026-09-22 进销存基准流水 + * 每日进销存 —— 仓库清单(纯映射,不含业务数值) * - * 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出) - * 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库 - * 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。 + * 重要:本文件只描述「要统计哪些仓库」以及它们与聚水潭 wms_co_id 的对应关系, + * 不再硬编码任何业务数值。所有数字都从数据库取: + * - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存 + * - 期末数量:业务库实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓) + * - 周转率 / 四级库存量:近 30 天发货 × 实时库存 × SKU 库龄,见 src/api/dailyInventory.js + * - 12 个流水列:业务库缺少调拨/盘点/加工/其它出入库单据表,暂无法显示(前端渲染为「—」) * - * 口径约定: - * - openingQty 与 12 个流水列:22 号基准值(本文件静态存储) - * - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确) - * - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算 + * 仓库清单随聚水潭仓库档案变化时需同步维护(来源表:rpt.jst_warehouseInfo)。 */ -/** 一级/二级菜单名(取自表格) */ +/** 一级/二级菜单名 */ const DAILY_INVENTORY_LEVEL1 = '大货库存监控'; const DAILY_INVENTORY_LEVEL2 = '每日进销存'; -/** 基准数据日期 */ -const BASELINE_DATE = '2026-09-22'; - -/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */ +/** 12 个流水列的中文表头与字段映射(顺序与聚水潭进销存导出一致) */ const FLOW_COLUMNS = [ - { - "label": "加工进仓", - "field": "processIn" - }, - { - "label": "采购退货", - "field": "purchaseReturn" - }, - { - "label": "采购进仓", - "field": "purchaseIn" - }, - { - "label": "其它进仓", - "field": "otherIn" - }, - { - "label": "其它退货", - "field": "otherReturn" - }, - { - "label": "销售出仓", - "field": "salesOut" - }, - { - "label": "盘点", - "field": "stocktaking" - }, - { - "label": "加工出仓", - "field": "processOut" - }, - { - "label": "调拨出", - "field": "transferOut" - }, - { - "label": "销售退货", - "field": "salesReturn" - }, - { - "label": "调拨入", - "field": "transferIn" - }, - { - "label": "其它出库", - "field": "otherOut" - } -]; + { + "label": "加工进仓", + "field": "processIn" + }, + { + "label": "采购退货", + "field": "purchaseReturn" + }, + { + "label": "采购进仓", + "field": "purchaseIn" + }, + { + "label": "其它进仓", + "field": "otherIn" + }, + { + "label": "其它退货", + "field": "otherReturn" + }, + { + "label": "销售出仓", + "field": "salesOut" + }, + { + "label": "盘点", + "field": "stocktaking" + }, + { + "label": "加工出仓", + "field": "processOut" + }, + { + "label": "调拨出", + "field": "transferOut" + }, + { + "label": "销售退货", + "field": "salesReturn" + }, + { + "label": "调拨入", + "field": "transferIn" + }, + { + "label": "其它出库", + "field": "otherOut" + } + ]; -/** 25 个仓库的 22 号基准流水 */ +/** 纳入口径的仓库(warehouse = 聚水潭主仓名,dbWarehouse = 库存监控表仓库名) */ const DAILY_INVENTORY_BASELINE = [ - { - "warehouse": "简尚-崔桥工厂仓主仓", - "dbWarehouse": "简尚-崔桥工厂仓", - "wmsCoId": 15038426, - "openingQty": 92388, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -3924, - "stocktaking": -1, - "processOut": 0, - "transferOut": -1461, - "salesReturn": 0, - "transferIn": 5437, - "otherOut": 0 - }, - { - "warehouse": "简尚-山东青岗集仓主仓", - "dbWarehouse": "简尚-山东青岗集仓", - "wmsCoId": 15137523, - "openingQty": 88377, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 886, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -1058, - "stocktaking": -1, - "processOut": 0, - "transferOut": -11, - "salesReturn": 0, - "transferIn": 23, - "otherOut": 0 - }, - { - "warehouse": "简尚-河南洛宁仓主仓", - "dbWarehouse": "简尚-河南洛宁仓", - "wmsCoId": 15539084, - "openingQty": 83305, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -314, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 15, - "otherOut": 0 - }, - { - "warehouse": "简尚-新苏北仓主仓", - "dbWarehouse": "简尚-新苏北仓", - "wmsCoId": 15038347, - "openingQty": 77145, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -1744, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 649, - "otherOut": 0 - }, - { - "warehouse": "简尚-横林二仓主仓", - "dbWarehouse": "简尚-横林二仓", - "wmsCoId": 15033302, - "openingQty": 63780, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -982, - "stocktaking": 0, - "processOut": 0, - "transferOut": -17, - "salesReturn": 0, - "transferIn": 728, - "otherOut": 0 - }, - { - "warehouse": "简尚-山东品牌仓主仓", - "dbWarehouse": "简尚-山东品牌仓", - "wmsCoId": 15182705, - "openingQty": 41025, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 150, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -157, - "stocktaking": 0, - "processOut": 0, - "transferOut": -5, - "salesReturn": 0, - "transferIn": 4, - "otherOut": 0 - }, - { - "warehouse": "简尚-睢宁仓主仓", - "dbWarehouse": "简尚-睢宁仓", - "wmsCoId": 15032603, - "openingQty": 25761, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -922, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 333, - "otherOut": 0 - }, - { - "warehouse": "简尚-分销部专用仓主仓", - "dbWarehouse": "简尚-分销部专用仓", - "wmsCoId": 15045426, - "openingQty": 6380, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -264, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 200, - "otherOut": 0 - }, - { - "warehouse": "简尚-新外贸仓主仓", - "dbWarehouse": "简尚-新外贸仓", - "wmsCoId": 15045308, - "openingQty": 5165, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "京仓-横林二仓京东自营仓主仓", - "dbWarehouse": "京仓-横林二仓京东自营仓", - "wmsCoId": 15835893, - "openingQty": 4260, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-分销部睢宁专用仓主仓", - "dbWarehouse": "简尚-分销部睢宁专用仓", - "wmsCoId": 15051960, - "openingQty": 3711, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-崔桥工厂品牌仓主仓", - "dbWarehouse": "简尚-崔桥工厂品牌仓", - "wmsCoId": 15870495, - "openingQty": 1831, - "openingQtyExtra": 0, - "processIn": 94, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -53, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-分销部苏北专用仓主仓", - "dbWarehouse": "简尚-分销部苏北专用仓", - "wmsCoId": 15045325, - "openingQty": 1440, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-运营C横林二仓专用仓主仓", - "dbWarehouse": "简尚-运营C横林二仓专用仓", - "wmsCoId": 15301814, - "openingQty": 2141, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -992, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-济南外贸仓主仓", - "dbWarehouse": "简尚-济南外贸仓", - "wmsCoId": 15051888, - "openingQty": 712, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-分销部崔桥专用仓主仓", - "dbWarehouse": "简尚-分销部崔桥专用仓", - "wmsCoId": 15404402, - "openingQty": 620, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-运营C崔桥工厂仓专用仓主仓", - "dbWarehouse": "简尚-运营C崔桥工厂仓专用仓", - "wmsCoId": 15301815, - "openingQty": 286, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-运营D苏北专用仓主仓", - "dbWarehouse": "简尚-运营D苏北专用仓", - "wmsCoId": 15406876, - "openingQty": 280, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-运营C睢宁专用仓主仓", - "dbWarehouse": "简尚-运营C睢宁专用仓", - "wmsCoId": 15301813, - "openingQty": 193, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-运营C新苏北专用仓主仓", - "dbWarehouse": "简尚-运营C新苏北专用仓", - "wmsCoId": 15301812, - "openingQty": 155, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-运营D睢宁专用仓主仓", - "dbWarehouse": "简尚-运营D睢宁专用仓", - "wmsCoId": 15051907, - "openingQty": 109, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -1, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-山东青岗集京东仓主仓", - "dbWarehouse": "简尚-山东青岗集京东仓", - "wmsCoId": 15406664, - "openingQty": 90, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-运营D专用仓库主仓", - "dbWarehouse": "简尚-运营D专用仓库", - "wmsCoId": 15051915, - "openingQty": 180, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -125, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "简尚-崔桥工厂京东仓主仓", - "dbWarehouse": "简尚-崔桥工厂京东仓", - "wmsCoId": 15045345, - "openingQty": 1, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": 0, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - }, - { - "warehouse": "京仓-横林二仓京东云仓主仓", - "dbWarehouse": "京仓-横林二仓京东云仓", - "wmsCoId": 15197138, - "openingQty": -3082, - "openingQtyExtra": 0, - "processIn": 0, - "purchaseReturn": 0, - "purchaseIn": 0, - "otherIn": 0, - "otherReturn": 0, - "salesOut": -48, - "stocktaking": 0, - "processOut": 0, - "transferOut": 0, - "salesReturn": 0, - "transferIn": 0, - "otherOut": 0 - } -]; + { + "warehouse": "简尚-崔桥工厂半成品包装项目仓主仓", + "dbWarehouse": "简尚-崔桥工厂半成品包装项目仓", + "wmsCoId": 15718196 + }, + { + "warehouse": "简尚-崔桥工厂仓主仓", + "dbWarehouse": "简尚-崔桥工厂仓", + "wmsCoId": 15038426 + }, + { + "warehouse": "简尚-山东青岗集仓主仓", + "dbWarehouse": "简尚-山东青岗集仓", + "wmsCoId": 15137523 + }, + { + "warehouse": "简尚-河南洛宁仓主仓", + "dbWarehouse": "简尚-河南洛宁仓", + "wmsCoId": 15539084 + }, + { + "warehouse": "简尚-新苏北仓主仓", + "dbWarehouse": "简尚-新苏北仓", + "wmsCoId": 15038347 + }, + { + "warehouse": "简尚-横林二仓主仓", + "dbWarehouse": "简尚-横林二仓", + "wmsCoId": 15033302 + }, + { + "warehouse": "简尚-山东品牌仓主仓", + "dbWarehouse": "简尚-山东品牌仓", + "wmsCoId": 15182705 + }, + { + "warehouse": "简尚-睢宁仓主仓", + "dbWarehouse": "简尚-睢宁仓", + "wmsCoId": 15032603 + }, + { + "warehouse": "简尚-分销部专用仓主仓", + "dbWarehouse": "简尚-分销部专用仓", + "wmsCoId": 15045426 + }, + { + "warehouse": "简尚-新外贸仓主仓", + "dbWarehouse": "简尚-新外贸仓", + "wmsCoId": 15045308 + }, + { + "warehouse": "简尚-分销部睢宁专用仓主仓", + "dbWarehouse": "简尚-分销部睢宁专用仓", + "wmsCoId": 15051960 + }, + { + "warehouse": "京仓-山东青岗集京东自营仓主仓", + "dbWarehouse": "京仓-山东青岗集京东自营仓", + "wmsCoId": 15835935 + }, + { + "warehouse": "简尚-崔桥工厂品牌仓主仓", + "dbWarehouse": "简尚-崔桥工厂品牌仓", + "wmsCoId": 15870495 + }, + { + "warehouse": "简尚-分销部苏北专用仓主仓", + "dbWarehouse": "简尚-分销部苏北专用仓", + "wmsCoId": 15045325 + }, + { + "warehouse": "简尚-运营C横林二仓专用仓主仓", + "dbWarehouse": "简尚-运营C横林二仓专用仓", + "wmsCoId": 15301814 + }, + { + "warehouse": "简尚-济南外贸仓主仓", + "dbWarehouse": "简尚-济南外贸仓", + "wmsCoId": 15051888 + }, + { + "warehouse": "简尚-分销部崔桥专用仓主仓", + "dbWarehouse": "简尚-分销部崔桥专用仓", + "wmsCoId": 15404402 + }, + { + "warehouse": "简尚-运营C崔桥工厂仓专用仓主仓", + "dbWarehouse": "简尚-运营C崔桥工厂仓专用仓", + "wmsCoId": 15301815 + }, + { + "warehouse": "简尚-运营D苏北专用仓主仓", + "dbWarehouse": "简尚-运营D苏北专用仓", + "wmsCoId": 15406876 + }, + { + "warehouse": "简尚-运营C睢宁专用仓主仓", + "dbWarehouse": "简尚-运营C睢宁专用仓", + "wmsCoId": 15301813 + }, + { + "warehouse": "简尚-运营C新苏北专用仓主仓", + "dbWarehouse": "简尚-运营C新苏北专用仓", + "wmsCoId": 15301812 + }, + { + "warehouse": "简尚-运营D睢宁专用仓主仓", + "dbWarehouse": "简尚-运营D睢宁专用仓", + "wmsCoId": 15051907 + }, + { + "warehouse": "简尚-山东青岗集京东仓主仓", + "dbWarehouse": "简尚-山东青岗集京东仓", + "wmsCoId": 15406664 + }, + { + "warehouse": "简尚-运营D专用仓库主仓", + "dbWarehouse": "简尚-运营D专用仓库", + "wmsCoId": 15051915 + }, + { + "warehouse": "简尚-崔桥工厂京东仓主仓", + "dbWarehouse": "简尚-崔桥工厂京东仓", + "wmsCoId": 15045345 + }, + { + "warehouse": "京仓-横林二仓京东自营仓主仓", + "dbWarehouse": "京仓-横林二仓京东自营仓", + "wmsCoId": 15835893 + } + ]; module.exports = { DAILY_INVENTORY_LEVEL1, DAILY_INVENTORY_LEVEL2, - BASELINE_DATE, FLOW_COLUMNS, DAILY_INVENTORY_BASELINE, }; diff --git a/backend/src/config/dailyInventorySnapshotDb.js b/backend/src/config/dailyInventorySnapshotDb.js new file mode 100644 index 0000000..9f3c20a --- /dev/null +++ b/backend/src/config/dailyInventorySnapshotDb.js @@ -0,0 +1,137 @@ +/** + * 每日进销存 —— 本地库存快照库访问 + * + * 背景:业务库(MSSQL / JSReportReplica)只有「当前」实时库存 jst_inventoryInfo, + * 没有按天的历史库存结存表,因此「期初数量」无法回溯。 + * 解决办法:项目本地 MySQL 每天落一份按仓库存的快照, + * 今日期初 = 上一业务日快照,期末 = 实时库存。 + * + * 连接复用 LOCAL_DB_* 配置(与项目其它本地表同库)。 + */ +const path = require('path'); +const dotenv = require('dotenv'); +const mysql = require('mysql2/promise'); + +dotenv.config(); +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +const TABLE = 'daily_inventory_snapshot'; + +const CREATE_SQL = ` +CREATE TABLE IF NOT EXISTS ${TABLE} ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + biz_date DATE NOT NULL COMMENT '业务日期(T-1)', + wms_co_id BIGINT NOT NULL COMMENT '聚水潭仓库 ID', + warehouse VARCHAR(128) NOT NULL COMMENT '仓库名(主仓)', + db_warehouse VARCHAR(128) NULL COMMENT '监控表仓库名', + closing_qty DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '该业务日期末数量', + sku_count INT NOT NULL DEFAULT 0 COMMENT '有库存 SKU 数', + taken_at DATETIME NULL COMMENT '源库库存数据更新时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_wh (biz_date, wms_co_id), + KEY idx_date (biz_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日进销存-按仓库存快照';`; + +let pool = null; + +function getPool() { + if (pool) return pool; + pool = mysql.createPool({ + host: process.env.LOCAL_DB_HOST, + port: Number(process.env.LOCAL_DB_PORT || 3306), + user: process.env.LOCAL_DB_USER, + password: process.env.LOCAL_DB_PASSWORD, + database: process.env.LOCAL_DB_NAME, + waitForConnections: true, + connectionLimit: 5, + connectTimeout: 10000, + // DATE/DATETIME 直接返回字符串,避免 JS Date 按本地时区回读时整体偏移一天 + dateStrings: true, + }); + return pool; +} + +async function ensureTable() { + await getPool().query(CREATE_SQL); +} + +/** + * 写入某业务日的期末快照(幂等,重复执行覆盖) + * @param {string} bizDate YYYY-MM-DD + * @param {Array<{wmsCoId:number, warehouse:string, dbWarehouse?:string, closingQty:number, skuCount:number, takenAt?:Date|null}>} rows + */ +async function upsertSnapshot(bizDate, rows) { + if (!bizDate || !rows.length) return 0; + await ensureTable(); + const conn = await getPool().getConnection(); + try { + for (const r of rows) { + await conn.query( + `INSERT INTO ${TABLE} (biz_date, wms_co_id, warehouse, db_warehouse, closing_qty, sku_count, taken_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE warehouse = VALUES(warehouse), db_warehouse = VALUES(db_warehouse), + closing_qty = VALUES(closing_qty), sku_count = VALUES(sku_count), taken_at = VALUES(taken_at)`, + [bizDate, r.wmsCoId, r.warehouse, r.dbWarehouse || null, r.closingQty, r.skuCount, r.takenAt || null] + ); + } + return rows.length; + } finally { + conn.release(); + } +} + +/** + * 读取某业务日的期末快照 + * @returns {Promise>} key = wmsCoId 字符串 + */ +async function fetchSnapshot(bizDate) { + const map = new Map(); + if (!bizDate) return map; + try { + await ensureTable(); + const [rows] = await getPool().query( + `SELECT wms_co_id AS wmsCoId, closing_qty AS closingQty, sku_count AS skuCount, taken_at AS takenAt + FROM ${TABLE} WHERE biz_date = ?`, + [bizDate] + ); + rows.forEach((r) => map.set(String(r.wmsCoId), { + closingQty: Number(r.closingQty), + skuCount: Number(r.skuCount), + takenAt: r.takenAt || null, + })); + } catch (error) { + console.error('[daily-inventory-snapshot] 读取快照失败:', error.message); + } + return map; +} + +/** 已有快照的业务日(倒序,最多 n 条) */ +async function listSnapshotDates(limit = 10) { + try { + await ensureTable(); + const [rows] = await getPool().query( + `SELECT DISTINCT biz_date AS d FROM ${TABLE} ORDER BY biz_date DESC LIMIT ?`, + [Number(limit) || 10] + ); + return rows.map((r) => { + if (typeof r.d === 'string') return r.d.slice(0, 10); + const v = new Date(r.d); + const pad = (n) => String(n).padStart(2, '0'); + return `${v.getFullYear()}-${pad(v.getMonth() + 1)}-${pad(v.getDate())}`; + }); + } catch (error) { + console.error('[daily-inventory-snapshot] 读取快照日期失败:', error.message); + return []; + } +} + +module.exports = { + TABLE, + CREATE_SQL, + getPool, + ensureTable, + upsertSnapshot, + fetchSnapshot, + listSnapshotDates, +}; diff --git a/backend/src/index.js b/backend/src/index.js index f9802ee..a21cbed 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -48,6 +48,9 @@ const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMa const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob'); const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob'); const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob'); +const { + startDailyInventorySnapshotScheduler, +} = require('./jobs/dailyInventorySnapshotJob'); const { startAlibaba1688ImageSearchExpiryScheduler, } = require('./jobs/alibaba1688ImageSearchExpiryJob'); @@ -730,6 +733,7 @@ async function startServer(options = {}) { startGoodsFirstSeenScheduler(); startAlibaba1688ImageSearchExpiryScheduler(); startMkhReplenishAlertScheduler(); + startDailyInventorySnapshotScheduler(); // Keep the first selection-pool page request from paying the remote schema // inspection round-trip over the planning database tunnel. diff --git a/backend/src/jobs/dailyInventorySnapshotJob.js b/backend/src/jobs/dailyInventorySnapshotJob.js new file mode 100644 index 0000000..b17f6a9 --- /dev/null +++ b/backend/src/jobs/dailyInventorySnapshotJob.js @@ -0,0 +1,122 @@ +'use strict'; + +/** + * 每日库存快照任务 + * + * 业务库没有按天的历史库存结存表,「期初数量」无法回溯。 + * 本任务每天定时把 26 个仓库的实时库存落一份到本地快照表 daily_inventory_snapshot, + * 使「今日期初 = 上一业务日期末」能够自动滚动。 + * + * 默认 01:30 执行(报表库 01:00 跑批之后)。可用环境变量覆盖: + * DAILY_INVENTORY_SNAPSHOT_ENABLED=false 关闭 + * DAILY_INVENTORY_SNAPSHOT_CRON='30 1 * * *' + */ +const cron = require('node-cron'); +const cloudDb = require('../config/cloudDatabase'); +const snapshotDb = require('../config/dailyInventorySnapshotDb'); +const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline'); + +let running = false; +let timer = null; + +const JOB_ENABLED = String(process.env.DAILY_INVENTORY_SNAPSHOT_ENABLED || 'true').toLowerCase() !== 'false'; +const JOB_CRON = process.env.DAILY_INVENTORY_SNAPSHOT_CRON || '30 1 * * *'; + +/** 业务日 = 库存快照库最新日期(报表库每日凌晨跑批,标注 T-1) */ +async function resolveBizDate() { + const rows = await cloudDb.query( + `SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor` + ); + return rows[0]?.d || null; +} + +function buildInClause(values, prefix) { + const params = {}; + const clause = values + .map((v, i) => { + params[`${prefix}${i}`] = v; + return `@${prefix}${i}`; + }) + .join(','); + return { clause, params }; +} + +async function syncOnce(trigger = 'cron') { + if (running) { + console.log(`[DailyInventorySnapshot] 上一次任务未结束,跳过 trigger=${trigger}`); + return { success: false, skipped: true, reason: 'already_running' }; + } + running = true; + const startedAt = Date.now(); + try { + const bizDate = await resolveBizDate(); + if (!bizDate) { + console.log('[DailyInventorySnapshot] 未取到业务日,跳过'); + return { success: false, skipped: true, reason: 'no_biz_date' }; + } + + const whs = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null); + const { clause, params } = buildInClause(whs.map((b) => b.wmsCoId), 'w'); + const rows = await cloudDb.query( + `SELECT i.wms_co_id AS wmsCoId, + ISNULL(SUM(CAST(i.qty AS DECIMAL(18,2))), 0) AS closingQty, + SUM(CASE WHEN ABS(ISNULL(i.qty, 0)) > 0.0001 THEN 1 ELSE 0 END) AS skuCount, + MAX(i.modified) AS takenAt + FROM rpt.jst_inventoryInfo i + WHERE i.wms_co_id IN (${clause}) + GROUP BY i.wms_co_id`, + params, + { requestTimeout: 120000 } + ); + + const m = new Map(rows.map((r) => [String(r.wmsCoId), r])); + const payload = whs.map((b) => { + const r = m.get(String(b.wmsCoId)); + return { + wmsCoId: b.wmsCoId, + warehouse: b.warehouse, + dbWarehouse: b.dbWarehouse, + closingQty: Number(r?.closingQty ?? 0), + skuCount: Number(r?.skuCount ?? 0), + takenAt: r?.takenAt ? new Date(r.takenAt) : null, + }; + }); + + const n = await snapshotDb.upsertSnapshot(bizDate, payload); + console.log(`[DailyInventorySnapshot] 业务日 ${bizDate} 写入 ${n}/${whs.length} 仓,耗时 ${Date.now() - startedAt}ms`); + return { success: true, bizDate, count: n }; + } catch (error) { + console.error('[DailyInventorySnapshot] 执行失败:', error.message); + return { success: false, error: error.message }; + } finally { + running = false; + } +} + +function startDailyInventorySnapshotScheduler() { + if (!JOB_ENABLED) { + console.log('[DailyInventorySnapshot] 已通过环境变量关闭'); + return null; + } + if (timer) return timer; + if (!cron.validate(JOB_CRON)) { + console.error(`[DailyInventorySnapshot] cron 表达式非法: ${JOB_CRON}`); + return null; + } + timer = cron.schedule(JOB_CRON, () => { syncOnce('cron'); }, { timezone: 'Asia/Shanghai' }); + console.log(`[DailyInventorySnapshot] 已启动,cron=${JOB_CRON} (Asia/Shanghai)`); + return timer; +} + +function stopDailyInventorySnapshotScheduler() { + if (timer) { + timer.stop(); + timer = null; + } +} + +module.exports = { + syncOnce, + startDailyInventorySnapshotScheduler, + stopDailyInventorySnapshotScheduler, +}; diff --git a/frontend/src/api/dailyInventory.js b/frontend/src/api/dailyInventory.js index e844361..ddc1259 100644 --- a/frontend/src/api/dailyInventory.js +++ b/frontend/src/api/dailyInventory.js @@ -3,7 +3,8 @@ import request from './request'; const LIST_TIMEOUT_MS = 5 * 60 * 1000; // 每日进销存:25 仓 × 进销存流水 + 实时期末 + 分级库存量 -export const getDailyInventory = () => request.get( +// refresh=1 跳过后端 5 分钟缓存,强制重算(一次要扫订单明细,约 15~20s) +export const getDailyInventory = (params = {}) => request.get( '/api/daily-inventory', - { timeout: LIST_TIMEOUT_MS } + { params, timeout: LIST_TIMEOUT_MS } ); diff --git a/frontend/src/constants/dailyInventory.js b/frontend/src/constants/dailyInventory.js index c66257f..7d7c6e9 100644 --- a/frontend/src/constants/dailyInventory.js +++ b/frontend/src/constants/dailyInventory.js @@ -3,59 +3,74 @@ * * 表头取自桌面《库存监控.xlsx》,顺序与表格一致。 * source 标记该列数据来源,便于在页面上如实标注口径: - * - baseline:22 号基准值(业务库无对应单据表,无法还原) + * - snapshot:来自项目本地快照表 daily_inventory_snapshot(每日定时落库,自动滚动) * - runtime:运行时从业务库实时取数 + * - unavailable:业务库缺少对应单据表,暂无法计算(渲染为「—」) */ export const COLUMN_SOURCE = { - baseline: 'baseline', + snapshot: 'snapshot', runtime: 'runtime', + unavailable: 'unavailable', }; +const NO_SOURCE_TIP = '业务库 JSReportReplica 缺少该单据表,无法还原,待 DBA 补充后自动接入(详见 docs/库存流水单据表需求.md)'; + /** 表格存在两个名为「期初」的列,第二个加后缀区分 */ export const DAILY_INVENTORY_COLUMNS = [ - { key: 'warehouse', label: '仓库', source: 'baseline', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' }, - { key: 'openingQty', label: '期初数量', source: 'baseline', tip: '22 号进销存基准值。业务库无按天的历史库存结存表,无法还原,取自 9-22 导出数据' }, - { key: 'processIn', label: '加工进仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' }, - { key: 'purchaseReturn', label: '采购退货', source: 'baseline', tip: '22 号基准值(业务库 jst_purchaseout 虽有单据,但数值与该进销存口径不一致)' }, - { key: 'purchaseIn', label: '采购进仓', source: 'baseline', tip: '22 号基准值(同上,业务库数值与进销存口径不一致)' }, - { key: 'otherIn', label: '其它进仓', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' }, - { key: 'otherReturn', label: '其它退货', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' }, - { key: 'salesOut', label: '销售出仓', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库 jst_salesOutboundInfo 数值与该进销存口径不一致' }, - { key: 'stocktaking', label: '盘点', source: 'baseline', tip: '22 号基准值。业务库无盘点单据表' }, - { key: 'processOut', label: '加工出仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' }, - { key: 'transferOut', label: '调拨出', source: 'baseline', tip: '22 号基准值(负数为调出)。业务库无调拨单据表' }, - { key: 'salesReturn', label: '销售退货', source: 'baseline', tip: '22 号基准值(业务库 jst_returnAndRefundInfo 数值与该进销存口径不一致)' }, - { key: 'transferIn', label: '调拨入', source: 'baseline', tip: '22 号基准值。业务库无调拨单据表' }, - { key: 'openingQtyExtra', label: '期初(2)', source: 'baseline', tip: '表格中的第二个「期初」列(9-22 导出值)' }, - { key: 'otherOut', label: '其它出库', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库无对应单据表' }, - { key: 'closingQty', label: '期末数量', source: 'runtime', tip: '聚水潭实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(与表格 22 号期末一致,24/25 仓精确匹配)' }, - { key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%(全员均值),数据来自 pm_stockMonitor' }, - { key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: '库龄 < 3 个月 且 30% ≤ 周转率 ≤ 199% 的 SKU 库存合计' }, - { key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: '库龄 < 3 个月且周转率 ≥ 200%,或库龄 ≥ 3 个月且周转率 30%~99% 的 SKU 库存合计' }, - { key: 'staleLevel', label: '商品呆滞级库存量', source: 'runtime', tip: '库龄 3~12 个月且周转率 ≥ 100%,或库龄 > 12 个月且周转率 100%~199% 的 SKU 库存合计' }, - { key: 'zombieLevel', label: '商品僵尸级库存量', source: 'runtime', tip: '库龄 > 12 个月 且 周转率 ≥ 200% 的 SKU 库存合计' }, + { key: 'warehouse', label: '仓库', source: 'snapshot', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' }, + { key: 'openingQty', label: '期初数量', source: 'snapshot', tip: '上一业务日的期末结存,取自本地快照表 daily_inventory_snapshot(每日 01:30 自动落库)' }, + { key: 'processIn', label: '加工进仓', source: 'unavailable', tip: `加工进仓。${NO_SOURCE_TIP}` }, + { key: 'purchaseReturn', label: '采购退货', source: 'unavailable', tip: `采购退货。业务库 jst_purchaseout 有单据但数值与聚水潭进销存口径不一致,暂不展示` }, + { key: 'purchaseIn', label: '采购进仓', source: 'unavailable', tip: `采购进仓。业务库 jst_purchase 按日汇总与进销存口径不一致(崔桥 9-23 基准 1295,库内仅数百),暂不展示` }, + { key: 'otherIn', label: '其它进仓', source: 'unavailable', tip: `其它进仓。${NO_SOURCE_TIP}` }, + { key: 'otherReturn', label: '其它退货', source: 'unavailable', tip: `其它退货。${NO_SOURCE_TIP}` }, + { key: 'salesOut', label: '销售出仓', source: 'unavailable', tip: `销售出仓。业务库订单表任何日期字段都还原不出日度出库量(崔桥 9-23 基准 4273,send_date 仅 1769),暂不展示` }, + { key: 'stocktaking', label: '盘点', source: 'unavailable', tip: `盘点。${NO_SOURCE_TIP}` }, + { key: 'processOut', label: '加工出仓', source: 'unavailable', tip: `加工出仓。${NO_SOURCE_TIP}` }, + { key: 'transferOut', label: '调拨出', source: 'unavailable', tip: `调拨出。${NO_SOURCE_TIP}` }, + { key: 'salesReturn', label: '销售退货', source: 'unavailable', tip: `销售退货。业务库 jst_returnAndRefundInfo 数值与进销存口径不一致,暂不展示` }, + { key: 'transferIn', label: '调拨入', source: 'unavailable', tip: `调拨入。${NO_SOURCE_TIP}` }, + { key: 'openingQtyExtra', label: '期初(2)', source: 'unavailable', tip: '聚水潭导出表中的第二个「期初」列,业务库无对应来源' }, + { key: 'otherOut', label: '其它出库', source: 'unavailable', tip: `其它出库。${NO_SOURCE_TIP}` }, + { key: 'closingQty', label: '期末数量', source: 'runtime', tip: '聚水潭实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(存货数量不为 0 的 SKU 汇总)' }, + { key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%。月度发货总件数=近 30 天已发货订单件数(jst_OrderItem×jst_Orders,按 send_date,排除 Delete/Cancelled);月度日均库存件数=该仓实时库存(无历史结存表,以当日库存近似)' }, + { key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: 'SKU 级:库龄 < 3 个月 且 周转率 30%~199% 的库存合计' }, + { key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: 'SKU 级:库龄 < 3 个月且周转率 ≥ 200%,或库龄 > 3 个月且周转率 30%~99% 的库存合计' }, + { key: 'staleLevel', label: '商品呆滞级库存量', source: 'runtime', tip: 'SKU 级:库龄 3~12 个月且周转率 ≥ 100%,或库龄 > 12 个月且周转率 100%~199% 的库存合计' }, + { key: 'zombieLevel', label: '商品僵尸级库存量', source: 'runtime', tip: 'SKU 级:库龄 > 12 个月 且 周转率 ≥ 200% 的库存合计' }, ]; /** 口径说明弹框文案 */ export const FORMULA_DESCRIPTION = [ - '数据来源说明:', - '· 期初数量 / 加工进仓 / 采购退货 / 采购进仓 / 其它进仓 / 其它退货 / 销售出仓 / 盘点 / 加工出仓 / 调拨出 / 销售退货 / 调拨入 / 其它出库:', - ' 取自 2026-09-22 聚水潭进销存导出值。业务库 JSReportReplica 无历史库存结存表,', - ' 也缺少调拨 / 盘点 / 加工 / 其它出入库单据表;已有的采购与销售单据数值与该进销存口径不一致,故采用导出值。', + '数据来源说明(全部来自数据库,无人工填数):', + '· 期初数量:项目本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存。', + ' 业务库 JSReportReplica 没有按天的历史库存结存表,因此由本系统每天 01:30 把 26 仓实时库存落一份快照,', + ' 今日期初 = 昨日快照,逐日自动滚动。', + '· 12 个流水列(加工进仓 / 采购退货 / 采购进仓 / 其它进仓 / 其它退货 / 销售出仓 / 盘点 / 加工出仓 / 调拨出 / 销售退货 / 调拨入 / 其它出库):', + ' 业务库缺少调拨 / 盘点 / 加工 / 其它出入库单据表;已有的采购与销售单据按日汇总后与聚水潭进销存口径不一致', + ' (崔桥 9-23 销售出仓基准 4273,订单表 send_date 仅 1769,偏差约 60%),因此不展示估算值,', + ' 已整理单据表需求给 DBA,补齐后自动接入。', '· 期末数量:运行时取聚水潭实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓),随刷新滚动。', - '· 库存周转天数 / 四级库存量:运行时由库存快照 rpt.pm_stockMonitor 计算,基准时刻为快照日次日 01:00。', + '· 库存周转天数 / 四级库存量:运行时计算,库龄基准时刻为快照日次日 01:00。', '', '算式:', '· 期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入', ' − 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库', '· 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%', - ' 其中 月度发货总件数 = threeAvgSale × 30,月度日均库存件数 = jsStock', - '· 库存分级(库龄 + 周转率双维度):', + ' 月度发货总件数 = 该仓近 30 天已发货订单件数', + ' (jst_OrderItem 关联 jst_Orders,按订单发货仓 wms_co_id 归属,send_date 落在窗口内,排除 Delete / Cancelled)', + ' 月度日均库存件数 = 该仓聚水潭实时库存 jst_inventoryInfo 汇总', + ' (业务库无按天的历史库存结存表,无法算真正的日均库存,以当日库存近似月度日均)', + ' 库龄(天) = 基准时刻 − SKU 建档时间 created;3 个月按 90 天、12 个月按 365 天折算', + '· 库存分级(SKU 级,库龄 + 周转率双维度,低于 30% 的不计入任何级别):', ' 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警', ' 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流', - ' 库龄 ≥ 3 个月 且 周转率 30%~99% → 商品慢流', + ' 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流', ' 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞', ' 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞', ' 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸', + '', + '注意:仓库整体周转率按「该仓发货总量 ÷ 该仓库存总量」计算,与四级分级的 SKU 级取舍不是同一个口径,', + ' 两者不必相等(例如整体周转率 6% 的仓里,仍可以有部分 SKU 周转率超 30% 而被判为预警级)。', ]; diff --git a/frontend/src/pages/DailyInventory/index.jsx b/frontend/src/pages/DailyInventory/index.jsx index d22ac5a..f04ab26 100644 --- a/frontend/src/pages/DailyInventory/index.jsx +++ b/frontend/src/pages/DailyInventory/index.jsx @@ -23,12 +23,12 @@ const int = (value) => { return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 }); }; -const decimal = (value, digits = 1) => { +const percent = (value) => { if (value === null || value === undefined || value === '') return '—'; - return Number(value).toLocaleString('zh-CN', { - minimumFractionDigits: digits, - maximumFractionDigits: digits, - }); + return `${Number(value).toLocaleString('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}%`; }; /** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */ @@ -45,11 +45,11 @@ const DailyInventory = () => { const [data, setData] = useState(null); const [helpOpen, setHelpOpen] = useState(false); - const load = useCallback(async () => { + const load = useCallback(async (force = false) => { setLoading(true); setError(''); try { - const res = await getDailyInventory(); + const res = await getDailyInventory(force ? { refresh: '1' } : {}); setData(res?.data ?? null); } catch (err) { setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败'); @@ -65,6 +65,7 @@ const DailyInventory = () => { const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => { const isRuntime = col.source === COLUMN_SOURCE.runtime; + const isUnavailable = col.source === COLUMN_SOURCE.unavailable; return { title: {col.label}, dataIndex: col.key, @@ -72,16 +73,19 @@ const DailyInventory = () => { align: col.key === 'warehouse' ? 'left' : 'right', width: col.key === 'warehouse' ? 240 : 130, fixed: col.key === 'warehouse' ? 'left' : undefined, - className: isRuntime ? 'daily-inventory-col--runtime' : undefined, + className: isRuntime + ? 'daily-inventory-col--runtime' + : (isUnavailable ? 'daily-inventory-col--unavailable' : undefined), render: (value, record) => { if (record?.isTotal) { - // 合计行:周转天数不累加,显示「—」 - if (col.key === 'turnoverDays') return '—'; + // 合计行:四级库存量累加,周转率按「总发货 / 总库存」重算(后端已给出) + if (col.key === 'turnoverDays') return percent(value); return int(value); } if (col.key === 'warehouse') return {value}; if (col.key === 'closingQty') return int(value); - if (col.key === 'turnoverDays') return decimal(value, 1); + if (col.key === 'turnoverDays') return percent(value); + if (isUnavailable) return —; return renderSigned(value); }, }; @@ -94,6 +98,7 @@ const DailyInventory = () => { const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]); + return (
@@ -109,9 +114,14 @@ const DailyInventory = () => {
- 基准日期:{data?.baselineDate || '—'} + 期初日期:{data?.openingDate || '—'} 库存快照日:{data?.latestBizDate || '—'} 分级基准时刻:{data?.asOf || '—'} + {data?.shipWindow ? ( + + 发货统计窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天) + + ) : null} {data?.items?.[0]?.closingTakenAt ? ( 库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })} @@ -119,19 +129,24 @@ const DailyInventory = () => { ) : null}
- + + + + - 期初数量与 12 个流水列取自 {data?.baselineDate || '2026-09-22'} 聚水潭进销存导出值(业务库缺少调拨/盘点/加工/其它出入库单据表,无法还原); - 期末数量与右侧 5 列为运行时实时取数。悬浮表头可看每列口径。 - {noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在库存快照中无数据,周转天数与四级库存量显示「—」。` : ''} + 期初数量取本地快照表中 {data?.openingDate || '—'} 的期末结存(每日 01:30 自动落库,逐日滚动); + 期末数量与右侧 5 列为运行时从业务库实时取数。 + 12 个流水列因业务库缺少调拨/盘点/加工/其它出入库单据表、且已有采购与销售单据口径不一致,暂显示「—」,已向 DBA 提交补表需求。 + {data?.noOpeningCount > 0 ? ` 其中 ${data.noOpeningCount} 个仓尚无 ${data.openingDate} 的期初快照(新纳入的仓库从次日起自动补齐)。` : ''} + {noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在聚水潭库存表中无有效库存,周转率与四级库存量显示「—」。` : ''} } style={{ marginBottom: 12 }} @@ -154,7 +169,7 @@ const DailyInventory = () => { - 共 {items.length} 个仓库;蓝色底纹列为运行时取数,其余为基准值。 + 共 {items.length} 个仓库;蓝色底纹列为运行时实时取数,灰色列为暂无单据来源,其余为本地快照表取值。