const express = require('express'); const cloudDb = require('../config/cloudDatabase'); const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline'); const { SALES_GRADE_RULES, SALES_TOTAL_LABEL, STOCK_SALES_WINDOW_OPTIONS, STOCK_SALES_DEFAULT_DAYS, } = require('../config/stockSalesAnalysis'); const { fetchGradeRows, fetchZeroSaleSkuRows } = require('../services/stockSalesAnalysis'); const { sendWorkbookDownload } = require('../services/exportWorkbook'); const router = express.Router(); /** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨跑批,标注 T-1) */ const AS_OF_TIME = '01:00'; /** 结果缓存:一次要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */ const RESULT_CACHE_TTL_MS = 5 * 60 * 1000; const cacheByDays = new Map(); const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100; /** 快照日 + 次日 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 - N 天, asOf) */ function buildShipWindow(asOf, days) { const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`); const from = new Date(to.getTime() - 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; } async function fetchInventoryTakenAt() { const rows = await cloudDb.query( `SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo` ); return rows[0]?.takenAt || null; } /** 解析并收敛统计窗口天数,只允许白名单内的取值 */ function resolveDays(raw) { const n = Number.parseInt(String(raw ?? ''), 10); return STOCK_SALES_WINDOW_OPTIONS.includes(n) ? n : STOCK_SALES_DEFAULT_DAYS; } /** * GET /api/stock-sales-analysis * 仓库库存动销分析:仓库 × 动销等级 → SKU数量 / SKU占比 / 库存数量 / 库存占比 / 销量 */ router.get('/', async (req, res) => { try { const force = String(req.query?.refresh || '') === '1'; const days = resolveDays(req.query?.days); const latestBizDate = await fetchLatestBizDate().catch(() => null); const cached = cacheByDays.get(days); if (!force && cached && cached.latestBizDate === latestBizDate && Date.now() - cached.at < RESULT_CACHE_TTL_MS) { return res.json({ success: true, data: cached.payload, cached: true }); } const asOf = nextDayAsOf(latestBizDate); if (!asOf) { return res.status(503).json({ success: false, error: '业务库暂无库存监控数据,无法定位统计基准日', }); } const { shipFrom, shipTo } = buildShipWindow(asOf, days); const warehouses = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null); const wmsCoIds = warehouses.map((b) => b.wmsCoId); const [rows, takenAt] = await Promise.all([ fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }), fetchInventoryTakenAt().catch(() => null), ]); // (仓库, 等级) → 指标 const matrix = new Map(); rows.forEach((r) => { matrix.set(`${r.wmsCoId}::${r.gradeKey}`, r); }); const buildBucket = (grade, cell) => ({ key: grade.key, label: grade.label, tip: grade.tip, skuCount: cell?.skuCount ?? 0, stockQty: round2(cell?.stockQty ?? 0), salesQty: round2(cell?.salesQty ?? 0), dailySales: Number((cell?.dailySales ?? 0).toFixed(2)), }); const items = warehouses.map((base) => { const id = String(base.wmsCoId); const buckets = SALES_GRADE_RULES.map((grade) => ( buildBucket(grade, matrix.get(`${id}::${grade.key}`)) )); const totalSku = buckets.reduce((acc, b) => acc + b.skuCount, 0); const totalStock = buckets.reduce((acc, b) => acc + b.stockQty, 0); const totalSales = round2(buckets.reduce((acc, b) => acc + b.salesQty, 0)); return { warehouse: base.warehouse, dbWarehouse: base.dbWarehouse, wmsCoId: base.wmsCoId, hasData: totalSku > 0 || totalStock !== 0 || totalSales !== 0, buckets: buckets.map((b) => ({ ...b, skuRatio: totalSku > 0 ? b.skuCount / totalSku : null, stockRatio: totalStock !== 0 ? b.stockQty / totalStock : null, })), total: { key: '__total__', label: SALES_TOTAL_LABEL, skuCount: totalSku, skuRatio: totalSku > 0 ? 1 : null, stockQty: totalStock, stockRatio: totalStock !== 0 ? 1 : null, salesQty: totalSales, dailySales: Number((totalSales / days).toFixed(2)), }, }; }); // 全仓合计:各等级跨仓累加 + 汇总 const grandBuckets = SALES_GRADE_RULES.map((grade) => { const skuCount = items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.skuCount || 0), 0); const stockQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.stockQty || 0), 0)); const salesQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.salesQty || 0), 0)); return { key: grade.key, label: grade.label, tip: grade.tip, skuCount, stockQty, salesQty, dailySales: Number((salesQty / days).toFixed(2)), }; }); const grandSku = grandBuckets.reduce((acc, b) => acc + b.skuCount, 0); const grandStock = round2(grandBuckets.reduce((acc, b) => acc + b.stockQty, 0)); const grandSales = round2(grandBuckets.reduce((acc, b) => acc + b.salesQty, 0)); const grandTotal = { key: '__total__', label: SALES_TOTAL_LABEL, skuCount: grandSku, skuRatio: grandSku > 0 ? 1 : null, stockQty: grandStock, stockRatio: grandStock !== 0 ? 1 : null, salesQty: grandSales, dailySales: Number((grandSales / days).toFixed(2)), }; const payload = { days, windowOptions: STOCK_SALES_WINDOW_OPTIONS, latestBizDate, asOf, shipWindow: { shipFrom, shipTo, days }, takenAt, grades: SALES_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })), totalLabel: SALES_TOTAL_LABEL, items, grand: { warehouse: '全部仓库合计', hasData: grandSku > 0, buckets: grandBuckets.map((b) => ({ ...b, skuRatio: grandSku > 0 ? b.skuCount / grandSku : null, stockRatio: grandStock !== 0 ? b.stockQty / grandStock : null, })), total: grandTotal, }, emptyWarehouseCount: items.filter((it) => !it.hasData).length, }; cacheByDays.set(days, { latestBizDate, at: Date.now(), payload }); return res.json({ success: true, data: payload }); } catch (error) { console.error('[stock-sales-analysis] 查询失败:', error.message); return res.status(500).json({ success: false, error: '仓库库存动销分析数据查询失败', detail: error.message, }); } }); /** * GET /api/stock-sales-analysis/zero-sales-export * 导出「销售=0」档的 SKU 级明细。 * query: days(统计窗口)、wmsCoId(可选,逗号分隔,不传=全部仓库)、format(xlsx|csv,默认 xlsx) */ router.get('/zero-sales-export', async (req, res) => { try { const days = resolveDays(req.query?.days); const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx'; const latestBizDate = await fetchLatestBizDate().catch(() => null); const asOf = nextDayAsOf(latestBizDate); if (!asOf) { return res.status(503).json({ success: false, error: '业务库暂无库存监控数据,无法定位统计基准日', }); } const { shipFrom, shipTo } = buildShipWindow(asOf, days); // 仓库过滤:只允许出现在仓库清单里的 wmsCoId,防止任意拼参 const known = new Map( DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null) .map((b) => [String(b.wmsCoId), b]) ); const requested = String(req.query?.wmsCoId || '') .split(',') .map((v) => v.trim()) .filter((v) => known.has(v)); const wmsCoIds = requested.length > 0 ? requested : [...known.keys()]; const rows = await fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }); const sheetRows = rows.map((r) => { const base = known.get(r.wmsCoId); return [ base?.warehouse || r.wmsCoId, r.skuId, r.skuName, r.binName, r.stockQty, r.salesQty, days, latestBizDate || '', ]; }); const scopeLabel = requested.length === 1 ? `-${known.get(requested[0]).warehouse}` : ''; const fileName = `库存动销分析-销售0明细${scopeLabel}-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`; const { totalRows } = sendWorkbookDownload(res, { fileName, format, sheets: [{ name: '销售=0明细', header: ['仓库', 'SKU编码', 'SKU名称', '系统仓位', '库存数量', `销量(近${days}天)`, '统计天数', '统计基准日'], rows: sheetRows, }], }); console.log(`[stock-sales-analysis] 销售=0明细导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 窗口 ${days} 天`); return null; } catch (error) { console.error('[stock-sales-analysis] 销售=0明细导出失败:', error.message); // 响应可能已部分写出(sendWorkbookDownload 之后一般不会抛),仅在未发送时回 JSON if (!res.headersSent) { return res.status(500).json({ success: false, error: '销售=0明细导出失败', detail: error.message, }); } return null; } }); module.exports = router;