cangku/backend/src/services/binReplenishService.js
lunanting c374cbc713 feat: 缺货补货监控改为订单驱动(崔桥当日订单>拣货位库存即需从存储位补货)
- 货位口径按前缀:XA/XD/XE=拣货位,XX/X9/X5/X4=存储位
- 判断逻辑改为当日订单量>拣货位库存的超出部分=需从存储位补到拣货位的量(上限存储位库存)
- 订单取自 rpt.jst_Orders/jst_qmcust_orderlist(含组合装展开),按最近订单日
- 状态:需补货/存储不足/拣货位充足/今日无单;移除旧的货位清单导入(前缀规则取代)
- 前端列与说明同步更新
2026-10-22 14:01:35 +08:00

462 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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,
};