'use strict'; /** * 缺货补货监控(订单驱动版,2026-10-22 重写) * ========================================================================== * 业务需求(用户确认): * 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够今天的订单拣, * 需要从「存储库位」把货补过去。 * * ── 货位口径(前缀规则,用户指定)────────────────────────────────────── * 拣货库位(拣货仓位):以 XA / XD / XE 开头 * 存储库位(存储仓位):以 XX / X9 / X5 / X4 开头 * 其余前缀(DB / Y* / Z* / YC 等,崔桥占比极小)归为「未分类」,不计入拣货/存储。 * * ── 数据源 ─────────────────────────────────────────────────────────── * 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储) * 订单:rpt.jst_Orders × jst_OrderItem(线上店铺) * + rpt.jst_qmcust_orderlist × jst_qmcust_orderlist_item(全民客户 B2B) * + 组合装(sku_type='combine')→ jst_comboProductItem 展开为子件 * ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1), * 按 wh_id 会把多仓混成一个桶。 * 订单日期:取该仓订单表 send_date 的最大日期(即「最近一个有订单的营业日」, * 副本 ETL 有延迟时不会为空,页面会明确标注统计的是哪一天)。 * * ── 判断逻辑(核心)────────────────────────────────────────────────── * 对每一个 SKU(在拣货/存储位上有库存): * 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计 * 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计 * 当日订单 orderQty = 该 SKU 在订单日期当天的订单量(含组合装展开) * 超出订单数 excess = max(0, orderQty − pickQty) * —— 拣货位不够今天的订单拣,超出的这部分要从存储位补到拣货位 * 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量) * 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调) * * 状态: * short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急 * need 需补货 (orderQty > pickQty 且 storageQty ≥ excess) * enough 拣货位充足(0 < orderQty ≤ pickQty) * no_order 今日无单(orderQty = 0) * (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调) * * ⚠️ 性能:库存视图聚合 + 当日订单 4 分支 union 各自都要 30s~1min, * 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。 */ const cloudDb = require('../config/cloudDatabase'); /** * 仓库清单:**按 wms_co_id 取数**。 * 前缀货位口径(XA/XD/XE 拣货、XX/X9/X5/X4 存储)是崔桥工厂仓的约定; * 其他仓若用不同前缀,页面会标注,默认聚焦崔桥工厂仓。 */ const WAREHOUSES = Object.freeze({ cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 }, shandong: { key: 'shandong', wmsCoId: 15137523, name: '简尚-山东青岗集仓', shortName: '山东青岗集仓', order: 20 }, henglin2: { key: 'henglin2', wmsCoId: 15033302, name: '简尚-横林二仓', shortName: '横林二仓', order: 30 }, subei: { key: 'subei', wmsCoId: 15038347, name: '简尚-新苏北仓', shortName: '新苏北仓', order: 40 }, henan: { key: 'henan', wmsCoId: 15539084, name: '简尚-河南洛宁仓', shortName: '河南洛宁仓', order: 50 }, shandongBrand: { key: 'shandongBrand', wmsCoId: 15182705, name: '简尚-山东品牌仓', shortName: '山东品牌仓', order: 60 }, cuiqiaoBcp: { key: 'cuiqiaoBcp', wmsCoId: 15718196, name: '简尚-崔桥工厂半成品包装项目仓', shortName: '崔桥半成品包装仓', order: 70 }, suining: { key: 'suining', wmsCoId: 15032603, name: '简尚-睢宁仓', shortName: '睢宁仓', order: 80 }, cuiqiaoBrand: { key: 'cuiqiaoBrand', wmsCoId: 15870495, name: '简尚-崔桥工厂品牌仓', shortName: '崔桥品牌仓', order: 90 }, henglin3: { key: 'henglin3', wmsCoId: 15043561, name: '简尚-横林三仓', shortName: '横林三仓', order: 100 }, }); const WAREHOUSE_LIST = Object.values(WAREHOUSES).sort((a, b) => a.order - b.order); const DEFAULT_WAREHOUSE_KEY = 'cuiqiao'; const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐 const MAX_PAGE_SIZE = 5000; // ── 货位前缀规则(用户指定)──────────────────────────────────────── const PICK_PREFIXES = Object.freeze(['XA', 'XD', 'XE']); const STORE_PREFIXES = Object.freeze(['XX', 'X9', 'X5', 'X4']); const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(','); const STORE_PREFIX_IN = STORE_PREFIXES.map((p) => `'${p}'`).join(','); const STATUS = Object.freeze({ SHORT: 'short', NEED: 'need', ENOUGH: 'enough', NO_ORDER: 'no_order', }); const STATUS_LABELS = Object.freeze({ short: '存储不足', need: '需补货', enough: '拣货位充足', no_order: '今日无单', }); // 排序权重:最紧急的排最前 const STATUS_RANK = Object.freeze({ short: 4, need: 3, no_order: 2, enough: 1 }); const cacheStore = new Map(); let cacheEpoch = 0; function toText(value, maxLength = 200) { return String(value ?? '').trim().slice(0, maxLength); } function toNumber(value, fallback = 0) { const num = Number(value); return Number.isFinite(num) ? num : fallback; } function round2(value) { return Math.round((toNumber(value) + Number.EPSILON) * 100) / 100; } function resolveWarehouseKey(value) { const key = toText(value, 32).toLowerCase(); return WAREHOUSES[key] ? key : DEFAULT_WAREHOUSE_KEY; } function resolveWarehouse(value) { return WAREHOUSES[resolveWarehouseKey(value)]; } function resolveCacheTtlMs() { const raw = process.env.BIN_REPLENISH_CACHE_TTL_MS; if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_CACHE_TTL_MS; const num = Number(raw); if (!Number.isFinite(num) || num < 0) return DEFAULT_CACHE_TTL_MS; return num; } /** 订单日期的窗口边界(仅用日期字符串,避免时区问题;副本 send_date 按中国营业日存储) */ function orderWindow(orderDate) { const d = new Date(`${orderDate}T00:00:00Z`); d.setUTCDate(d.getUTCDate() + 1); const next = d.toISOString().slice(0, 10); return { shipFrom: `${orderDate} 00:00:00`, shipTo: `${next} 00:00:00` }; } // ── 副本取数 ──────────────────────────────────────────────────────── /** * 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。 * 只统计用户指定的前缀宇宙(XA/XD/XE/XX/X9/X5/X4),其余货位不计入。 */ async function loadReplicaInventory(warehouse) { const sql = ` SELECT p.sku_id AS skuId, SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}) THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS pickQty, SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${STORE_PREFIX_IN}) THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS storageQty, MAX(s.name) AS productName, MAX(s.supplier_name) AS supplier FROM rpt.jst_packInventoryInfo p LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id WHERE p.wms_co_id = @wmsCoId AND ISNULL(p.bin, '') <> '' AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN}) GROUP BY p.sku_id`; return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 }); } /** 该仓最近的订单日期(线上 + 全民客户 取最大 send_date 日期) */ async function loadOrderDate(warehouse) { const rows = await cloudDb.query( `SELECT MAX(d) AS d FROM ( SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d FROM rpt.jst_Orders WHERE wms_co_id = @wmsCoId UNION ALL SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d FROM rpt.jst_qmcust_orderlist WHERE wms_co_id = @wmsCoId ) t`, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 120000 } ); return (rows && rows[0] && rows[0].d) || null; } /** 当日(订单日期)各 SKU 的订单量:4 个来源分支,组合装展开为子件。 */ async function loadTodayOrderMap(warehouse, orderDate) { if (!orderDate) return new Map(); const { shipFrom, shipTo } = orderWindow(orderDate); const sql = ` WITH ship AS ( -- 1) 线上店铺订单:普通商品行 SELECT oi.sku_id AS skuId, SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty FROM rpt.jst_OrderItem oi JOIN rpt.jst_Orders o ON o.o_id = oi.o_id WHERE o.wms_co_id = @wmsCoId AND o.send_date >= @shipFrom AND o.send_date < @shipTo AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled') AND ISNULL(oi.sku_type, 'normal') <> 'combine' GROUP BY oi.sku_id UNION ALL -- 2) 线上店铺订单:组合装行 → 展开为子件 SELECT ci.src_sku_id AS skuId, SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty FROM rpt.jst_OrderItem oi JOIN rpt.jst_Orders o ON o.o_id = oi.o_id JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id WHERE o.wms_co_id = @wmsCoId AND o.send_date >= @shipFrom AND o.send_date < @shipTo AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled') AND oi.sku_type = 'combine' GROUP BY ci.src_sku_id UNION ALL -- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除) SELECT oi.sku_id AS skuId, SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty FROM rpt.jst_qmcust_orderlist_item oi JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id WHERE o.wms_co_id = @wmsCoId AND o.send_date >= @shipFrom AND o.send_date < @shipTo AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question') AND ISNULL(oi.sku_type, 'normal') <> 'combine' GROUP BY oi.sku_id UNION ALL -- 4) 全民客户订单:组合装行 → 展开为子件 SELECT ci.src_sku_id AS skuId, SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty FROM rpt.jst_qmcust_orderlist_item oi JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id WHERE o.wms_co_id = @wmsCoId AND o.send_date >= @shipFrom AND o.send_date < @shipTo AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question') AND oi.sku_type = 'combine' GROUP BY ci.src_sku_id ) SELECT skuId, SUM(orderQty) AS orderQty FROM ship GROUP BY skuId`; const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query; const rows = await runQuery( sql, { wmsCoId: warehouse.wmsCoId, shipFrom, shipTo }, { requestTimeout: 300000 } ); const map = new Map(); (rows || []).forEach((row) => { const skuId = toText(row.skuId, 120); if (skuId) map.set(skuId, toNumber(row.orderQty)); }); return map; } // ── 组装 ──────────────────────────────────────────────────────────── function buildSkuRows({ invRows, orderMap, orderDate }) { const rows = []; (invRows || []).forEach((row) => { const skuId = toText(row.skuId, 120); if (!skuId) return; const pickQty = round2(row.pickQty); const storageQty = round2(row.storageQty); const orderQty = round2(toNumber(orderMap.get(skuId), 0)); const excess = Math.max(0, round2(orderQty - pickQty)); let status = STATUS.NO_ORDER; let suggestQty = 0; let shortfall = 0; let pickEmpty = false; if (orderQty > 0) { if (pickQty <= 0) pickEmpty = true; if (excess > 0) { const can = Math.min(excess, storageQty); suggestQty = round2(can); if (storageQty >= excess) { status = STATUS.NEED; } else { status = STATUS.SHORT; shortfall = round2(excess - storageQty); } } else { status = STATUS.ENOUGH; } } rows.push({ skuId, productName: toText(row.productName, 300) || '', supplier: toText(row.supplier, 120) || '', status, statusLabel: STATUS_LABELS[status], pickQty, storageQty, totalQty: round2(pickQty + storageQty), orderQty, excess, suggestQty, shortfall, pickEmpty, }); }); return rows; } function buildSummary(rows, orderSkuCount) { const summary = { skuTotal: rows.length, need: 0, short: 0, enough: 0, noOrder: 0, pickEmpty: 0, suggestQtyTotal: 0, orderSkuCount: orderSkuCount || 0, orderQtyTotal: 0, exceedTotal: 0, shortfallTotal: 0, pickTotal: 0, storageTotal: 0, }; rows.forEach((row) => { if (summary[row.status] !== undefined) summary[row.status] += 1; summary.suggestQtyTotal += row.suggestQty || 0; summary.orderQtyTotal += row.orderQty || 0; summary.exceedTotal += row.excess || 0; summary.shortfallTotal += row.shortfall || 0; summary.pickTotal += row.pickQty || 0; summary.storageTotal += row.storageQty || 0; if (row.pickEmpty) summary.pickEmpty += 1; }); summary.suggestQtyTotal = round2(summary.suggestQtyTotal); summary.orderQtyTotal = round2(summary.orderQtyTotal); summary.exceedTotal = round2(summary.exceedTotal); summary.shortfallTotal = round2(summary.shortfallTotal); summary.pickTotal = round2(summary.pickTotal); summary.storageTotal = round2(summary.storageTotal); return summary; } async function loadDataset(warehouse, { refresh = false } = {}) { const orderDate = await loadOrderDate(warehouse); // 把订单日期纳入缓存键:跨天后自动失效,无需等 TTL const cacheKey = `${warehouse.key}::${orderDate || 'none'}`; const cached = cacheStore.get(cacheKey); const ttl = resolveCacheTtlMs(); const now = Date.now(); if (!refresh && cached && ttl > 0 && now - cached.at < ttl) { return { ...cached, cacheHit: true }; } const epoch = cacheEpoch; const [invRows, orderMap] = await Promise.all([ loadReplicaInventory(warehouse), loadTodayOrderMap(warehouse, orderDate).catch(() => new Map()), ]); const rows = buildSkuRows({ invRows, orderMap, orderDate }); const dataset = { at: now, rows, warehouse, orderDate, meta: { rowCount: rows.length, orderSkuCount: orderMap.size, }, }; // 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据 if (epoch === cacheEpoch) cacheStore.set(cacheKey, dataset); return { ...dataset, cacheHit: false }; } function invalidateCache(warehouseKey) { cacheEpoch += 1; if (warehouseKey) cacheStore.delete(resolveWarehouseKey(warehouseKey)); else cacheStore.clear(); } function matchesStatus(row, status) { const key = toText(status, 20).toLowerCase(); if (!key || key === 'all') return true; if (key === 'action' || key === 'todo' || key === 'replenish') { return row.status === STATUS.NEED || row.status === STATUS.SHORT; } return row.status === key; } function sortRows(rows) { return rows.slice().sort((a, b) => ( (STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0) || (b.suggestQty || 0) - (a.suggestQty || 0) || (b.excess || 0) - (a.excess || 0) || String(a.skuId).localeCompare(String(b.skuId)) )); } function filterRows(rows, { keyword, status }) { const kw = toText(keyword, 120).toLowerCase(); return rows.filter((row) => { if (!matchesStatus(row, status)) return false; if (!kw) return true; return row.skuId.toLowerCase().includes(kw) || String(row.productName || '').toLowerCase().includes(kw) || String(row.supplier || '').toLowerCase().includes(kw); }); } async function getReplenishList(options = {}) { const warehouse = resolveWarehouse(options.warehouse); const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' }); const filtered = sortRows(filterRows(dataset.rows, options)); const summary = buildSummary(dataset.rows, dataset.meta.orderSkuCount); const page = Math.max(1, Number.parseInt(options.page, 10) || 1); const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(options.pageSize, 10) || 50)); const start = (page - 1) * pageSize; return { rows: filtered.slice(start, start + pageSize), total: filtered.length, page, pageSize, summary, warehouse, warehouses: Object.values(WAREHOUSES), orderDate: dataset.orderDate, cacheHit: dataset.cacheHit, updatedAt: new Date(dataset.at).toISOString(), cacheTtlMs: resolveCacheTtlMs(), }; } async function getReplenishExportRows(options = {}) { const warehouse = resolveWarehouse(options.warehouse); const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' }); return { warehouse, orderDate: dataset.orderDate, rows: sortRows(filterRows(dataset.rows, options)), summary: buildSummary(dataset.rows, dataset.meta.orderSkuCount), }; } /** 启动预热:库存视图 + 当日订单 union 各自 30s~1min,不能让首个用户请求等 */ async function warmBinReplenishCache() { try { const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY]; const t0 = Date.now(); await loadDataset(warehouse, { refresh: true }); console.log(`[BinReplenish] 预热完成 ${warehouse.name},耗时 ${Date.now() - t0}ms`); } catch (error) { console.warn('[BinReplenish] 预热失败:', error.message); } } module.exports = { DEFAULT_WAREHOUSE_KEY, PICK_PREFIXES, STORE_PREFIXES, STATUS, STATUS_LABELS, WAREHOUSES, clearPickBins: async () => ({ removed: 0 }), getReplenishExportRows, getReplenishList, loadDataset, resolveWarehouse, resolveWarehouseKey, warmBinReplenishCache, };