const express = require('express'); const cloudDb = require('../config/cloudDatabase'); const snapshotDb = require('../config/dailyInventorySnapshotDb'); const { DAILY_INVENTORY_BASELINE, FLOW_COLUMNS, } = require('../config/dailyInventoryBaseline'); 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); 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}`; } /** 发货窗口:[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` ); 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 }; } /** * 分级规则(库龄 + 周转率双维度),口径与业务给定一致: * 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警 * 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流 * 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流 * 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞 * 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞 * 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸 * 边界:库龄正好 90 天不计入「慢流」(业务描述为 > 3 个月),正好 365 天仍属「3~12 个月」。 */ function buildLevelExpr() { const t = 'b.turnoverPct'; return ` CASE 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`; } /** * 每个仓库的周转率与四级库存量(一次查询覆盖全部仓库)。 * * 口径: * 库存周转天数(周转率)= 月度发货总件数 / 月度日均库存件数 × 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; } /** 实时库存表的数据时效(一次取全局即可) */ async function fetchInventoryTakenAt() { const rows = await cloudDb.query( `SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo` ); return rows[0]?.takenAt || null; } /** * GET /api/daily-inventory * 每日进销存:26 个仓库 ×(期初 / 期末 / 周转率 / 四级库存量) * * 数据源分工(全部来自数据库,无硬编码业务数值): * - 期初数量:本地快照表 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 [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] = null; const grade = base.wmsCoId != null ? gradeMap.get(String(base.wmsCoId)) : null; const opening = base.wmsCoId != null ? openingMap.get(String(base.wmsCoId)) : null; return { warehouse: base.warehouse, dbWarehouse: base.dbWarehouse, openingQty: opening ? opening.closingQty : null, openingQtyExtra: null, ...flows, 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); 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)); } // 合计行周转率按「总发货 / 总库存」重算,避免简单累加失真 totals.shipQty30d = Number(sum('shipQty30d').toFixed(2)); const totalStock = sum('closingQty'); totals.turnoverDays = totalStock > 0 ? Number((sum('shipQty30d') * 100 / totalStock).toFixed(2)) : null; 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); return res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message }); } }); module.exports = router;