const express = require('express'); const multer = require('multer'); const cloudDb = require('../config/cloudDatabase'); const snapshotDb = require('../config/dailyInventorySnapshotDb'); const officialDb = require('../config/dailyInventoryOfficialDb'); const { DAILY_INVENTORY_BASELINE, FLOW_COLUMNS, } = require('../config/dailyInventoryBaseline'); const { fetchFlows, AVAILABLE_FLOW_FIELDS } = require('../services/dailyInventoryFlows'); const { deriveAndStoreOpeningSnapshot } = require('../services/dailyInventoryOpening'); const { parseWorkbook, normalizeRecords, checkBalance, importOfficial, } = require('../services/dailyInventoryOfficial'); const router = express.Router(); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } }); /** 库存衡量基准时刻:快照日次日 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 中「上一业务日」的期末结存; * 快照缺失时按 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写 * - 期末数量:jst_inventoryInfo 实时库存(按 wms_co_id 单仓) * - 库存周转天数 / 四级库存量:近 30 天发货 × 实时库存 × SKU 建档日期 * - 12 个流水列:4 列可从业务库单据表还原(采购进仓/采购退货/销售出仓/销售退货, * 见 services/dailyInventoryFlows.js),其余 8 列业务库无单据表,返回 null(前端显示「—」) */ router.get('/', async (req, res) => { try { const force = String(req.query?.refresh || '') === '1'; const requestedDate = typeof req.query?.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(req.query.date) ? req.query.date : null; const systemBizDate = await fetchLatestBizDate().catch(() => null); const latestBizDate = requestedDate || systemBizDate; // 查看历史业务日时,业务库只有「当前」的实时库存与流水,不能当成该日的期末/发生额 const staleMetrics = Boolean(requestedDate && systemBizDate && requestedDate !== systemBizDate); const cacheKey = `${latestBizDate}|${staleMetrics ? 'hist' : 'live'}`; 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, officialMap, officialDates] = await Promise.all([ fetchGradeMetrics(wmsCoIds, latestBizDate, asOf).catch((e) => { console.error('[daily-inventory] 分级计算失败:', e.message); return new Map(); }), fetchInventoryTakenAt().catch(() => null), officialDb.fetchOfficial(latestBizDate).catch(() => new Map()), officialDb.listOfficialDates(30).catch(() => []), ]); // 期初 = 上一业务日的期末快照;若定时任务漏跑,顺手把当前业务日的快照补齐 let 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); }); } // 期初快照也缺失时,用 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写 if (openingDate && !openingMap.size) { await deriveAndStoreOpeningSnapshot({ cloudDb, snapshotDb, baseline: DAILY_INVENTORY_BASELINE, openingDate, latestBizDate, takenAt, }).catch((e) => { console.error('[daily-inventory] 期初快照推导失败:', e.message); }); openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map()); } // 流水列:自期初时刻(最新业务日 01:00)以来的单据合计(带符号,流出为负) let flowMap = new Map(); if (openingDate) { flowMap = await fetchFlows(cloudDb, wmsCoIds, `${latestBizDate} 01:00:00`).catch((e) => { console.error('[daily-inventory] 流水取数失败:', e.message); return new Map(); }); } const items = DAILY_INVENTORY_BASELINE.map((base) => { const official = officialMap.get(base.warehouse) || null; const grade = base.wmsCoId != null ? gradeMap.get(String(base.wmsCoId)) : null; const opening = base.wmsCoId != null ? openingMap.get(String(base.wmsCoId)) : null; const itemFlows = base.wmsCoId != null ? flowMap.get(String(base.wmsCoId)) : null; // 12 个流水列:聚水潭官方导出优先,缺失时回退业务库推算(历史业务日不做回退) const flows = {}; const flowSources = {}; for (const col of FLOW_COLUMNS) { const fromOfficial = official?.flows?.[col.field]; if (fromOfficial != null) { flows[col.field] = Number(fromOfficial); flowSources[col.field] = 'official'; continue; } const derived = itemFlows ? itemFlows[col.field] : null; if (derived != null && !staleMetrics) { flows[col.field] = Number(derived.toFixed(2)); flowSources[col.field] = 'derived'; continue; } flows[col.field] = null; flowSources[col.field] = 'none'; } // 期末:官方期末优先;无官方时,历史业务日不给实时库存(口径不对) const gradeStock = grade?.gradeStock ?? null; let closingQty = gradeStock; let closingSource = 'derived'; if (official?.closingQty != null) { closingQty = Number(official.closingQty); closingSource = 'official'; } else if (staleMetrics) { closingQty = null; closingSource = 'none'; } // 期初:官方期初优先,其次本地快照 let openingQty = opening ? opening.closingQty : null; let openingSource = opening ? 'snapshot' : 'none'; if (official?.openingQty != null) { openingQty = Number(official.openingQty); openingSource = 'official'; } else if (staleMetrics) { openingQty = null; openingSource = 'none'; } return { warehouse: base.warehouse, dbWarehouse: base.dbWarehouse, openingQty, openingQtyExtra: null, ...flows, closingQty, closingSkuCount: grade?.skuCount ?? null, closingTakenAt: takenAt, shipQty30d: grade?.shipQty ?? null, turnoverDays: grade?.turnoverDays ?? null, gradeStock, warnLevel: grade?.warnLevel ?? null, slowLevel: grade?.slowLevel ?? null, staleLevel: grade?.staleLevel ?? null, zombieLevel: grade?.zombieLevel ?? null, ungradedSku: grade?.ungradedSku ?? null, hasGrade: Boolean(grade), source: { opening: openingSource, closing: closingSource, flows: flowSources, hasOfficial: Boolean(official), }, }; }); // 合计行:便于与表格总额核对 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']) { // 若所有仓库该列均无数据(null),合计同样置空,避免误读为 0 totals[k] = items.every((it) => it[k] == null) ? null : 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 balanceMismatch = checkBalance([...officialMap.values()]); const officialFlowFields = FLOW_COLUMNS .filter((c) => items.some((it) => it.source.flows[c.field] === 'official')) .map((c) => c.field); const derivedFlowFields = FLOW_COLUMNS .filter((c) => items.some((it) => it.source.flows[c.field] === 'derived')) .map((c) => c.field); const payload = { openingDate, latestBizDate, systemBizDate, staleMetrics, asOf, shipWindow: asOf ? { ...shipWindow(asOf), days: SHIP_WINDOW_DAYS } : null, flowColumns: FLOW_COLUMNS, flowColumnsAvailable: AVAILABLE_FLOW_FIELDS, flowColumnsOfficial: officialFlowFields, flowColumnsDerived: derivedFlowFields, noOpeningCount, official: { hasData: officialMap.size > 0, warehouseCount: officialMap.size, availableDates: officialDates, balanceMismatch, }, 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 }); } }); /** * 官方期初 = 上一业务日期末、官方期末 = 当日期末。 * 导入后回写本地快照,让「今日期初 = 昨日结存」的自动推算也对齐聚水潭口径。 */ async function syncSnapshotFromOfficial(bizDate, rows) { const byId = new Map(DAILY_INVENTORY_BASELINE.map((b) => [String(b.wmsCoId), b])); const known = rows.filter((r) => r.wmsCoId != null); if (!known.length) return { prev: 0, current: 0 }; const prevDate = prevDay(bizDate); let prevCount = 0; if (prevDate) { const prev = await snapshotDb.fetchSnapshot(prevDate).catch(() => new Map()); prevCount = await snapshotDb.upsertSnapshot(prevDate, known .filter((r) => r.openingQty != null) .map((r) => ({ wmsCoId: r.wmsCoId, warehouse: r.warehouse, dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null, closingQty: Number(r.openingQty), skuCount: Number(prev.get(String(r.wmsCoId))?.skuCount || 0), takenAt: null, }))).catch((e) => { console.error('[daily-inventory] 期初快照回写失败:', e.message); return 0; }); } const current = await snapshotDb.fetchSnapshot(bizDate).catch(() => new Map()); const currentCount = await snapshotDb.upsertSnapshot(bizDate, known .filter((r) => r.closingQty != null) .map((r) => ({ wmsCoId: r.wmsCoId, warehouse: r.warehouse, dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null, closingQty: Number(r.closingQty), skuCount: Number(current.get(String(r.wmsCoId))?.skuCount || 0), takenAt: null, }))).catch((e) => { console.error('[daily-inventory] 期末快照回写失败:', e.message); return 0; }); return { prev: prevCount, current: currentCount }; } /** * GET /api/daily-inventory/official * 查询某业务日已导入的聚水潭官方进销存数据 */ router.get('/official', async (req, res) => { try { const date = String(req.query?.date || '').trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { return res.status(400).json({ success: false, error: '请提供业务日期 date=YYYY-MM-DD' }); } const map = await officialDb.fetchOfficial(date); const dates = await officialDb.listOfficialDates(30); return res.json({ success: true, data: { date, rows: [...map.values()], availableDates: dates }, }); } catch (error) { console.error('[daily-inventory] 官方数据查询失败:', error.message); return res.status(500).json({ success: false, error: '官方数据查询失败', detail: error.message }); } }); /** * POST /api/daily-inventory/official-import * 导入聚水潭进销存按仓导出(multipart: file=xlsx, date;或 JSON: {date, rows}) */ router.post('/official-import', upload.single('file'), async (req, res) => { try { const date = String((req.body && req.body.date) || req.query?.date || '').trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { return res.status(400).json({ success: false, error: '请提供业务日期 date(YYYY-MM-DD)' }); } let rows = []; let warnings = []; let sheetName = null; if (req.file && req.file.buffer) { const parsed = parseWorkbook(req.file.buffer); rows = parsed.rows; warnings = parsed.warnings; sheetName = parsed.sheetName; if (!rows.length) { return res.status(400).json({ success: false, error: '未能从文件解析出仓库行', detail: warnings.join(';') || '未找到含「仓库」列的工作表', }); } } else if (req.body && Array.isArray(req.body.rows)) { const parsed = normalizeRecords(req.body.rows); rows = parsed.rows; warnings = parsed.warnings; if (!rows.length) { return res.status(400).json({ success: false, error: 'rows 中未解析到仓库行', detail: warnings.join(';') }); } } else { return res.status(400).json({ success: false, error: '请上传 xlsx 文件(字段名 file)或提供 rows 数组' }); } const result = await importOfficial(date, rows, { source: String((req.body && req.body.source) || 'jst-export'), }); const synced = await syncSnapshotFromOfficial(date, rows); cachedResult = null; // 导入后立即使查询缓存失效 return res.json({ success: true, data: { ...result, sheetName, warnings, snapshotSynced: synced, }, }); } catch (error) { console.error('[daily-inventory] 官方数据导入失败:', error.message); return res.status(500).json({ success: false, error: '官方数据导入失败', detail: error.message }); } }); module.exports = router;