forked from lunanting/cangku
- 看板只展示当日待补 SKU(待发订单>拣货位库存,需存储→拣货补货),去除充足/无待发干扰行 - 库存收紧到崔桥工厂仓主仓(jst_packInventoryInfo 加 wh_id=1),数据源对齐「箱及仓位库存·主仓」 - 补齐「有打单拣货待发单但货位库存视图无记录」的 SKU(拣货位为0也应进待补明细) - 指标卡改为四项:实时代发订单数 / 拣货库存数 / 存储库存数 / 缺货数(缺货=待补SKU数,脚注含存储不足子项) - 各库底待补明细=need+short 行;范围筛「待补货/仅缺货」;移除多仓选择器,固定崔桥 - 待发订单仍取 op_OrderNotDispatched 打单拣货/发货中 每日快照(两步 querySlow) - 副本实测:当日快照待补 45 SKU(need42/short3),待发132件/拣货40件/存储2381件/缺口5件
478 lines
20 KiB
JavaScript
478 lines
20 KiB
JavaScript
'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.op_OrderNotDispatched(聚水潭「打单拣货/未发货」每日快照,SKU 级、组合装已展开)
|
||
* —— 直接对齐用户在聚水潭「打单拣货 → 简尚崔桥工厂仓」看到的单据。
|
||
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
|
||
* 按 wh_id 会把多仓混成一个桶。
|
||
*
|
||
* ── 待发口径(用户 2026-10-22 三次明确:「取打单拣货里的数据」)──────────
|
||
* 补货需求只来自「聚水潭打单拣货 / 发货中」的单据,**已发(Sent)不参与**。
|
||
* 数据源 rpt.op_OrderNotDispatched 是**每日快照**(一天一批,local_updated≈07:00):
|
||
* 取该仓 **最新快照日** 且 status='发货中'(历史标签曾为「发货中(打单拣货)」)的行,
|
||
* 按 sku_id 汇总 qty —— 即当前待拣/待发的订单量(组合装已展开为子件,带 src_combine_sku_id)。
|
||
* **不含**:已发(Sent)/取消/删除/拆单/合单、异常(Question/异常)、已付款待审核、待付款。
|
||
* (异常挂起单不保证近期发货,不计入补货需求;如需要可再把「异常」并入。)
|
||
*
|
||
* ── 判断逻辑(核心)──────────────────────────────────────────────────
|
||
* 对每一个 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 强调)
|
||
*
|
||
* ⚠️ 性能:库存视图聚合 30s~1min;待发快照表(op_OrderNotDispatched)查询较快;
|
||
* 必须缓存(默认 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(',');
|
||
|
||
// ── 待发口径:聚水潭「打单拣货/发货中」快照(rpt.op_OrderNotDispatched)────────
|
||
// 该表是每日快照;取该仓「最新快照日」且 status='发货中' 的行。
|
||
// 历史标签在 2026-03 之前叫「发货中(打单拣货)」,两个都认,避免换名漏数。
|
||
// 不含:已发/取消/删除/拆单/合单、异常、已付款待审核、待付款。
|
||
const PENDING_STATUS_LIST = Object.freeze(['发货中', '发货中(打单拣货)']);
|
||
const PENDING_STATUS_IN = PENDING_STATUS_LIST.map((s) => `'${s}'`).join(',');
|
||
const PENDING_ORDER_SCOPE = '待发订单 = 聚水潭「打单拣货」发货中快照(不含已发/异常/待审核)';
|
||
|
||
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;
|
||
}
|
||
|
||
// ── 副本取数 ────────────────────────────────────────────────────────
|
||
/**
|
||
* 按 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 p.wh_id = 1
|
||
AND ISNULL(p.bin, '') <> ''
|
||
AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN})
|
||
GROUP BY p.sku_id`;
|
||
// 单仓按货位前缀聚合约 1.7s;走 querySlow(独立连接 + 池级超时)避免 60s 池超时误杀。
|
||
return cloudDb.querySlow(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 300000 });
|
||
}
|
||
|
||
/**
|
||
* 批量取 SKU 主数据(品名 / 供应商),用于补齐「有订单但无货位库存」的 SKU 名称。
|
||
* 这批 SKU 不在库存视图里,但确实有打单拣货待发单,必须进待补明细。
|
||
*/
|
||
async function loadSkuNames(skuIds) {
|
||
const uniq = [...new Set((skuIds || []).filter(Boolean).map((id) => toText(id, 120)))];
|
||
if (!uniq.length) return new Map();
|
||
const map = new Map();
|
||
const CHUNK = 400;
|
||
for (let i = 0; i < uniq.length; i += CHUNK) {
|
||
const chunk = uniq.slice(i, i + CHUNK);
|
||
const params = {};
|
||
const ph = chunk.map((_, idx) => `@s${idx}`).join(',');
|
||
chunk.forEach((id, idx) => { params[`s${idx}`] = id; });
|
||
const rows = await cloudDb.querySlow(
|
||
`SELECT sku_id AS skuId, name AS productName, supplier_name AS supplier
|
||
FROM rpt.jst_sku WHERE sku_id IN (${ph})`,
|
||
params,
|
||
{ requestTimeout: 120000 }
|
||
);
|
||
(rows || []).forEach((r) => {
|
||
const id = toText(r.skuId, 120);
|
||
if (id) map.set(id, { productName: toText(r.productName, 300), supplier: toText(r.supplier, 120) });
|
||
});
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/**
|
||
* 各 SKU 的「待发」订单量:直接取聚水潭打单拣货快照表 rpt.op_OrderNotDispatched。
|
||
* 该表 SKU 级、组合装已展开为子件(src_combine_sku_id),qty 即该子件待发件数。
|
||
* 取「该仓最新快照日」+ status IN ('发货中','发货中(打单拣货)'),按 sku_id 汇总。
|
||
*
|
||
* ⚠️ 性能红线:副本库 mssql 池默认 60s requestTimeout,CTE+CROSS JOIN 写法实测 60s
|
||
* 超时失败。改成**两步 querySlow**(先取最新快照日,再按日范围汇总),实测 ~12s,
|
||
* 走独立连接 + 池级 requestTimeout(5 分钟),必须用它。
|
||
*/
|
||
async function loadPendingOrderMap(warehouse) {
|
||
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
|
||
const slowOpts = { requestTimeout: 300000, connectRequestTimeoutMs: 300000 };
|
||
|
||
// step1:该仓最新快照日(local_updated≈每日 07:00 一批,MAX 落在最新那批)
|
||
const snapRows = await runQuery(
|
||
`SELECT CAST(MAX(local_updated) AS date) AS snapDate
|
||
FROM rpt.op_OrderNotDispatched
|
||
WHERE wms_co_id = @wmsCoId`,
|
||
{ wmsCoId: warehouse.wmsCoId },
|
||
slowOpts
|
||
);
|
||
const snapDate = snapRows && snapRows[0] && snapRows[0].snapDate;
|
||
if (!snapDate) return new Map();
|
||
|
||
// step2:最新快照日当天的「发货中」待发量,按 sku_id 汇总(组合装已展开)
|
||
const rows = await runQuery(
|
||
`SELECT o.sku_id AS skuId,
|
||
SUM(CAST(ISNULL(o.qty, 0) AS DECIMAL(18,2))) AS orderQty
|
||
FROM rpt.op_OrderNotDispatched o
|
||
WHERE o.wms_co_id = @wmsCoId
|
||
AND o.status IN (${PENDING_STATUS_IN})
|
||
AND o.local_updated >= @snapDate
|
||
AND o.local_updated < DATEADD(day, 1, @snapDate)
|
||
AND ISNULL(o.sku_id, '') <> ''
|
||
GROUP BY o.sku_id`,
|
||
{ wmsCoId: warehouse.wmsCoId, snapDate },
|
||
slowOpts
|
||
);
|
||
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 }) {
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 汇总只统计「待补」SKU(need / short)——
|
||
* 即需要从存储仓位补货到拣货仓位的部分。对应看板四张指标卡:
|
||
* 实时代发订单数 = 待补 SKU 待发件数合计
|
||
* 拣货库存数 = 待补 SKU 拣货位库存合计
|
||
* 存储库存数 = 待补 SKU 存储位库存合计
|
||
* 缺货数 = 存储也补不齐的 SKU 数(status=short)
|
||
*/
|
||
function buildSummary(rows) {
|
||
const summary = {
|
||
skuTotal: 0,
|
||
need: 0,
|
||
short: 0,
|
||
orderQtyTotal: 0,
|
||
pickTotal: 0,
|
||
storageTotal: 0,
|
||
suggestQtyTotal: 0,
|
||
exceedTotal: 0,
|
||
shortfallTotal: 0,
|
||
};
|
||
rows.forEach((row) => {
|
||
summary.skuTotal += 1;
|
||
if (row.status === STATUS.NEED) summary.need += 1;
|
||
else if (row.status === STATUS.SHORT) summary.short += 1;
|
||
summary.orderQtyTotal += row.orderQty || 0;
|
||
summary.pickTotal += row.pickQty || 0;
|
||
summary.storageTotal += row.storageQty || 0;
|
||
summary.suggestQtyTotal += row.suggestQty || 0;
|
||
summary.exceedTotal += row.excess || 0;
|
||
summary.shortfallTotal += row.shortfall || 0;
|
||
});
|
||
summary.orderQtyTotal = round2(summary.orderQtyTotal);
|
||
summary.pickTotal = round2(summary.pickTotal);
|
||
summary.storageTotal = round2(summary.storageTotal);
|
||
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
|
||
summary.exceedTotal = round2(summary.exceedTotal);
|
||
summary.shortfallTotal = round2(summary.shortfallTotal);
|
||
return summary;
|
||
}
|
||
|
||
async function loadDataset(warehouse, { refresh = false } = {}) {
|
||
// 缓存键只用仓库(内部仍取「最新快照日」,不固定某天);跨天靠 TTL 失效即可。
|
||
const cacheKey = warehouse.key;
|
||
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),
|
||
loadPendingOrderMap(warehouse).catch(() => new Map()),
|
||
]);
|
||
|
||
// 把「有打单拣货待发单、但货位库存视图里没有记录」的 SKU 也补进明细,
|
||
// 否则这批最该补货(拣货位为 0)的行会被漏掉。
|
||
const invKeys = new Set((invRows || []).map((r) => toText(r.skuId, 120)));
|
||
const orderOnly = [...orderMap.keys()]
|
||
.filter((k) => !invKeys.has(k))
|
||
.map((k) => ({ skuId: k, pickQty: 0, storageQty: 0, productName: null, supplier: null }));
|
||
const combined = (invRows || []).concat(orderOnly);
|
||
if (orderOnly.length) {
|
||
const nameMap = await loadSkuNames(orderOnly.map((r) => r.skuId));
|
||
orderOnly.forEach((r) => {
|
||
const n = nameMap.get(r.skuId);
|
||
if (n) { r.productName = n.productName; r.supplier = n.supplier; }
|
||
});
|
||
}
|
||
|
||
const rows = buildSkuRows({ invRows: combined, orderMap });
|
||
|
||
const dataset = {
|
||
at: now,
|
||
rows,
|
||
warehouse,
|
||
orderScope: PENDING_ORDER_SCOPE,
|
||
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 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))
|
||
));
|
||
}
|
||
|
||
/**
|
||
* 只看「待补」数据:需要从存储仓位补货到拣货仓位的 SKU(need / short)。
|
||
* status='short' 时仅保留存储不足(缺货)的 SKU。
|
||
*/
|
||
function filterReplenish(rows, { keyword, status }) {
|
||
const kw = toText(keyword, 120).toLowerCase();
|
||
const onlyShort = toText(status, 12).toLowerCase() === 'short';
|
||
return rows.filter((row) => {
|
||
if (onlyShort) {
|
||
if (row.status !== STATUS.SHORT) return false;
|
||
} else if (row.status !== STATUS.NEED && row.status !== STATUS.SHORT) {
|
||
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 replenished = filterReplenish(dataset.rows, options);
|
||
const sorted = sortRows(replenished);
|
||
const summary = buildSummary(replenished);
|
||
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: sorted.slice(start, start + pageSize),
|
||
total: sorted.length,
|
||
page,
|
||
pageSize,
|
||
summary,
|
||
warehouse,
|
||
warehouses: Object.values(WAREHOUSES),
|
||
orderScope: PENDING_ORDER_SCOPE,
|
||
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' });
|
||
const replenished = filterReplenish(dataset.rows, options);
|
||
return {
|
||
warehouse,
|
||
orderScope: PENDING_ORDER_SCOPE,
|
||
rows: sortRows(replenished),
|
||
summary: buildSummary(replenished),
|
||
};
|
||
}
|
||
|
||
/** 启动预热:库存视图 + 待发订单 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,
|
||
};
|