const express = require('express'); const cloudDb = require('../config/cloudDatabase'); const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline'); const { PICKING_BIN_GRADE_RULES, PICKING_BIN_WINDOW_OPTIONS, PICKING_BIN_DEFAULT_DAYS, PICKING_BIN_TOTAL_LABEL, PICKING_BIN_BIN_TYPES, } = require('../config/pickingBinSalesAnalysis'); const { fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis'); 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 cacheByKey = new Map(); const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100; const round4 = (v) => Math.round((Number(v) || 0) * 10000) / 10000; /** 快照日 + 次日 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 fetchBinTakenAt() { const rows = await cloudDb.query( `SELECT MAX(updateTime) AS takenAt FROM rpt.jst_packInventoryInfo` ); return rows[0]?.takenAt || null; } /** 解析并收敛统计窗口天数,只允许白名单内的取值 */ function resolveDays(raw) { const n = Number.parseInt(String(raw ?? ''), 10); return PICKING_BIN_WINDOW_OPTIONS.includes(n) ? n : PICKING_BIN_DEFAULT_DAYS; } /** 解析仓库过滤,只允许出现在仓库清单里的 wmsCoId,防止任意拼参 */ function resolveWarehouses(raw) { const known = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null); const requested = String(raw || '') .split(',') .map((v) => v.trim()) .filter(Boolean); const picked = requested.length > 0 ? known.filter((b) => requested.includes(String(b.wmsCoId))) : known; return picked; } const GRADE_MAP = new Map(PICKING_BIN_GRADE_RULES.map((g) => [g.key, g])); /** 明细行按「仓库 × 商品编码」去重后的商品个数 */ function dedupeCount(list) { return new Set(list.map((it) => `${it.wmsCoId}::${it.skuId}`)).size; } /** * GET /api/picking-bin-sales-analysis * 拣选库位动销分析:仓库 × 仓位 × 商品编码 → 库存数量 / 商品销量 / 商品日销 / 动销等级 * 动销等级按「商品日销」判定(商品整档销量 ÷ 天数),不做仓位分摊。 */ router.get('/', async (req, res) => { try { const force = String(req.query?.refresh || '') === '1'; const days = resolveDays(req.query?.days); const warehouses = resolveWarehouses(req.query?.wmsCoId); const wmsCoIds = warehouses.map((b) => String(b.wmsCoId)); const cacheKey = `${days}::${wmsCoIds.join('|')}`; const latestBizDate = await fetchLatestBizDate().catch(() => null); const cached = cacheByKey.get(cacheKey); 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 [rows, takenAt] = await Promise.all([ fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }), fetchBinTakenAt().catch(() => null), ]); const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse])); const items = rows.map((r) => ({ wmsCoId: r.wmsCoId, binName: r.binName, binType: r.binType, skuId: r.skuId, warehouse: whMap.get(r.wmsCoId) || r.wmsCoId, stockQty: r.stockQty, skuCount: r.skuCount, salesQty: round2(r.salesQty), dailySales: round4(r.dailySales), gradeKey: r.gradeKey, gradeLabel: GRADE_MAP.get(r.gradeKey)?.label || '', })); // 每个「仓库 × 商品」只计一次销量,避免同一商品跨多个仓位被重复累加 const skuSalesMap = new Map(); rows.forEach((r) => { const key = `${r.wmsCoId}::${r.skuId}`; if (!skuSalesMap.has(key)) skuSalesMap.set(key, r.salesQty); }); const dedupeSales = (list) => { const seen = new Set(); return list.reduce((acc, it) => { const key = `${it.wmsCoId}::${it.skuId}`; if (seen.has(key)) return acc; seen.add(key); return acc + (skuSalesMap.get(key) || 0); }, 0); }; // 各动销等级汇总:商品数按「仓库 × 商品」去重,库存按明细行求和(无重复) const summary = PICKING_BIN_GRADE_RULES.map((grade) => { const hit = items.filter((it) => it.gradeKey === grade.key); const salesQty = round2(dedupeSales(hit)); return { key: grade.key, label: grade.label, tip: grade.tip, rowCount: hit.length, skuCount: dedupeCount(hit), stockQty: round2(hit.reduce((acc, it) => acc + it.stockQty, 0)), salesQty, dailySales: round4(salesQty / days), }; }); const dedupeAll = new Set(items.map((it) => `${it.wmsCoId}::${it.skuId}`)); const totalSales = round2(dedupeSales(items)); const total = { key: '__total__', label: PICKING_BIN_TOTAL_LABEL, rowCount: items.length, skuCount: dedupeAll.size, stockQty: round2(summary.reduce((acc, s) => acc + s.stockQty, 0)), salesQty: totalSales, dailySales: round4(totalSales / days), }; const payload = { days, windowOptions: PICKING_BIN_WINDOW_OPTIONS, binTypes: [...PICKING_BIN_BIN_TYPES], binTypeOptions: [...PICKING_BIN_BIN_TYPES], latestBizDate, asOf, shipWindow: { shipFrom, shipTo, days }, takenAt, grades: PICKING_BIN_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })), totalLabel: PICKING_BIN_TOTAL_LABEL, warehouses: warehouses.map((b) => ({ wmsCoId: String(b.wmsCoId), warehouse: b.warehouse })), items, summary, total, }; cacheByKey.set(cacheKey, { latestBizDate, at: Date.now(), payload }); return res.json({ success: true, data: payload }); } catch (error) { console.error('[picking-bin-sales-analysis] 查询失败:', error.message); return res.status(500).json({ success: false, error: '拣选库位动销分析数据查询失败', detail: error.message, }); } }); /** * GET /api/picking-bin-sales-analysis/export * 导出库位明细(只含拣选位)。query: days、wmsCoId(逗号分隔)、grade(逗号分隔)、format(xlsx|csv) */ router.get('/export', async (req, res) => { try { const days = resolveDays(req.query?.days); const warehouses = resolveWarehouses(req.query?.wmsCoId); const wmsCoIds = warehouses.map((b) => String(b.wmsCoId)); const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx'; const grades = String(req.query?.grade || '') .split(',') .map((v) => v.trim()) .filter((v) => GRADE_MAP.has(v)); 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); // 导出按「仓位 × 商品编码」拆行:一个仓位有 N 个商品就输出 N 行 const rows = await fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }); const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse])); const filtered = grades.length > 0 ? rows.filter((r) => grades.includes(r.gradeKey)) : rows; const sheetRows = filtered.map((r) => [ whMap.get(r.wmsCoId) || r.wmsCoId, r.binName, r.binType, r.skuId, r.stockQty, r.skuCount, round2(r.salesQty), round4(r.allocatedSalesQty), round4(r.dailySales), GRADE_MAP.get(r.gradeKey)?.label || r.gradeKey, days, latestBizDate || '', ]); const fileName = `拣选库位动销分析-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`; const { totalRows } = sendWorkbookDownload(res, { fileName, format, sheets: [{ name: '库位动销明细', header: [ '仓库', '仓位', '类型', '商品编码', '库存数量', '仓位SKU数', `商品销量(近${days}天)`, `分摊销量(近${days}天)`, '商品日销', '动销等级', '统计天数', '统计基准日', ], rows: sheetRows, }], }); console.log(`[picking-bin-sales-analysis] 导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 仅拣选位, 窗口 ${days} 天`); return null; } catch (error) { console.error('[picking-bin-sales-analysis] 导出失败:', error.message); if (!res.headersSent) { return res.status(500).json({ success: false, error: '拣选库位动销分析导出失败', detail: error.message, }); } return null; } }); module.exports = router;