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;