fix: 缺货补货监控改为只统计待发订单(排除已发/取消/拆单/合单),不限日期 #81
@ -17,7 +17,7 @@ const EXPORT_COLUMNS = [
|
||||
{ key: 'pickQty', title: '拣货位库存' },
|
||||
{ key: 'storageQty', title: '存储位库存' },
|
||||
{ key: 'totalQty', title: '现有库存合计' },
|
||||
{ key: 'orderQty', title: '当日订单' },
|
||||
{ key: 'orderQty', title: '待发订单' },
|
||||
{ key: 'excess', title: '超出订单数' },
|
||||
{ key: 'suggestQty', title: '建议补货量' },
|
||||
{ key: 'shortfall', title: '存储缺口' },
|
||||
@ -49,7 +49,7 @@ router.get('/export', async (req, res, next) => {
|
||||
keyword: req.query.keyword,
|
||||
status: req.query.status,
|
||||
});
|
||||
const fileName = `${payload.warehouse.shortName}缺货补货监控_${payload.orderDate || ''}`;
|
||||
const fileName = `${payload.warehouse.shortName}待补明细`;
|
||||
|
||||
sendWorkbookDownload(res, {
|
||||
fileName,
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
* 缺货补货监控(订单驱动版,2026-10-22 重写)
|
||||
* ==========================================================================
|
||||
* 业务需求(用户确认):
|
||||
* 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够今天的订单拣,
|
||||
* 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够待发订单拣,
|
||||
* 需要从「存储库位」把货补过去。
|
||||
*
|
||||
* ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
|
||||
@ -14,21 +14,26 @@
|
||||
*
|
||||
* ── 数据源 ───────────────────────────────────────────────────────────
|
||||
* 库存: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 展开为子件
|
||||
* 订单:rpt.op_OrderNotDispatched(聚水潭「打单拣货/未发货」每日快照,SKU 级、组合装已展开)
|
||||
* —— 直接对齐用户在聚水潭「打单拣货 → 简尚崔桥工厂仓」看到的单据。
|
||||
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
|
||||
* 按 wh_id 会把多仓混成一个桶。
|
||||
* 订单日期:取该仓订单表 send_date 的最大日期(即「最近一个有订单的营业日」,
|
||||
* 副本 ETL 有延迟时不会为空,页面会明确标注统计的是哪一天)。
|
||||
*
|
||||
* ── 待发口径(用户 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 在订单日期当天的订单量(含组合装展开)
|
||||
* 待发订单 orderQty = 该 SKU 的「打单拣货/发货中」快照待发量(组合装已展开)
|
||||
* 超出订单数 excess = max(0, orderQty − pickQty)
|
||||
* —— 拣货位不够今天的订单拣,超出的这部分要从存储位补到拣货位
|
||||
* —— 拣货位不够待发订单拣,超出的这部分要从存储位补到拣货位
|
||||
* 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量)
|
||||
* 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调)
|
||||
*
|
||||
@ -36,10 +41,10 @@
|
||||
* short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急
|
||||
* need 需补货 (orderQty > pickQty 且 storageQty ≥ excess)
|
||||
* enough 拣货位充足(0 < orderQty ≤ pickQty)
|
||||
* no_order 今日无单(orderQty = 0)
|
||||
* no_order 无待发单(orderQty = 0)
|
||||
* (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调)
|
||||
*
|
||||
* ⚠️ 性能:库存视图聚合 + 当日订单 4 分支 union 各自都要 30s~1min,
|
||||
* ⚠️ 性能:库存视图聚合 30s~1min;待发快照表(op_OrderNotDispatched)查询较快;
|
||||
* 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
|
||||
*/
|
||||
|
||||
@ -75,6 +80,14 @@ 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',
|
||||
@ -86,7 +99,7 @@ const STATUS_LABELS = Object.freeze({
|
||||
short: '存储不足',
|
||||
need: '需补货',
|
||||
enough: '拣货位充足',
|
||||
no_order: '今日无单',
|
||||
no_order: '无待发单',
|
||||
});
|
||||
|
||||
// 排序权重:最紧急的排最前
|
||||
@ -125,14 +138,6 @@ function resolveCacheTtlMs() {
|
||||
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 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
|
||||
@ -150,89 +155,79 @@ async function loadReplicaInventory(warehouse) {
|
||||
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`;
|
||||
return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 });
|
||||
// 单仓按货位前缀聚合约 1.7s;走 querySlow(独立连接 + 池级超时)避免 60s 池超时误杀。
|
||||
return cloudDb.querySlow(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 300000 });
|
||||
}
|
||||
|
||||
/** 该仓最近的订单日期(线上 + 全民客户 取最大 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 主数据(品名 / 供应商),用于补齐「有订单但无货位库存」的 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 的订单量: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`;
|
||||
|
||||
/**
|
||||
* 各 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(
|
||||
sql,
|
||||
{ wmsCoId: warehouse.wmsCoId, shipFrom, shipTo },
|
||||
{ requestTimeout: 300000 }
|
||||
`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) => {
|
||||
@ -243,7 +238,7 @@ async function loadTodayOrderMap(warehouse, orderDate) {
|
||||
}
|
||||
|
||||
// ── 组装 ────────────────────────────────────────────────────────────
|
||||
function buildSkuRows({ invRows, orderMap, orderDate }) {
|
||||
function buildSkuRows({ invRows, orderMap }) {
|
||||
const rows = [];
|
||||
(invRows || []).forEach((row) => {
|
||||
const skuId = toText(row.skuId, 120);
|
||||
@ -293,47 +288,49 @@ function buildSkuRows({ invRows, orderMap, orderDate }) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildSummary(rows, orderSkuCount) {
|
||||
/**
|
||||
* 汇总只统计「待补」SKU(need / short)——
|
||||
* 即需要从存储仓位补货到拣货仓位的部分。对应看板四张指标卡:
|
||||
* 实时代发订单数 = 待补 SKU 待发件数合计
|
||||
* 拣货库存数 = 待补 SKU 拣货位库存合计
|
||||
* 存储库存数 = 待补 SKU 存储位库存合计
|
||||
* 缺货数 = 存储也补不齐的 SKU 数(status=short)
|
||||
*/
|
||||
function buildSummary(rows) {
|
||||
const summary = {
|
||||
skuTotal: rows.length,
|
||||
skuTotal: 0,
|
||||
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,
|
||||
suggestQtyTotal: 0,
|
||||
exceedTotal: 0,
|
||||
shortfallTotal: 0,
|
||||
};
|
||||
const STATUS_SUM_KEY = { need: 'need', short: 'short', enough: 'enough', no_order: 'noOrder' };
|
||||
rows.forEach((row) => {
|
||||
const key = STATUS_SUM_KEY[row.status];
|
||||
if (key && summary[key] !== undefined) summary[key] += 1;
|
||||
summary.suggestQtyTotal += row.suggestQty || 0;
|
||||
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.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 += row.suggestQty || 0;
|
||||
summary.exceedTotal += row.excess || 0;
|
||||
summary.shortfallTotal += row.shortfall || 0;
|
||||
});
|
||||
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);
|
||||
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
|
||||
summary.exceedTotal = round2(summary.exceedTotal);
|
||||
summary.shortfallTotal = round2(summary.shortfallTotal);
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function loadDataset(warehouse, { refresh = false } = {}) {
|
||||
const orderDate = await loadOrderDate(warehouse);
|
||||
// 把订单日期纳入缓存键:跨天后自动失效,无需等 TTL
|
||||
const cacheKey = `${warehouse.key}::${orderDate || 'none'}`;
|
||||
// 缓存键只用仓库(内部仍取「最新快照日」,不固定某天);跨天靠 TTL 失效即可。
|
||||
const cacheKey = warehouse.key;
|
||||
const cached = cacheStore.get(cacheKey);
|
||||
const ttl = resolveCacheTtlMs();
|
||||
const now = Date.now();
|
||||
@ -344,16 +341,31 @@ async function loadDataset(warehouse, { refresh = false } = {}) {
|
||||
const epoch = cacheEpoch;
|
||||
const [invRows, orderMap] = await Promise.all([
|
||||
loadReplicaInventory(warehouse),
|
||||
loadTodayOrderMap(warehouse, orderDate).catch(() => new Map()),
|
||||
loadPendingOrderMap(warehouse).catch(() => new Map()),
|
||||
]);
|
||||
|
||||
const rows = buildSkuRows({ invRows, orderMap, orderDate });
|
||||
// 把「有打单拣货待发单、但货位库存视图里没有记录」的 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,
|
||||
orderDate,
|
||||
orderScope: PENDING_ORDER_SCOPE,
|
||||
meta: {
|
||||
rowCount: rows.length,
|
||||
orderSkuCount: orderMap.size,
|
||||
@ -370,15 +382,6 @@ function invalidateCache(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)
|
||||
@ -388,10 +391,19 @@ function sortRows(rows) {
|
||||
));
|
||||
}
|
||||
|
||||
function filterRows(rows, { keyword, status }) {
|
||||
/**
|
||||
* 只看「待补」数据:需要从存储仓位补货到拣货仓位的 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 (!matchesStatus(row, status)) return false;
|
||||
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)
|
||||
@ -402,21 +414,22 @@ function filterRows(rows, { keyword, status }) {
|
||||
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 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: filtered.slice(start, start + pageSize),
|
||||
total: filtered.length,
|
||||
rows: sorted.slice(start, start + pageSize),
|
||||
total: sorted.length,
|
||||
page,
|
||||
pageSize,
|
||||
summary,
|
||||
warehouse,
|
||||
warehouses: Object.values(WAREHOUSES),
|
||||
orderDate: dataset.orderDate,
|
||||
orderScope: PENDING_ORDER_SCOPE,
|
||||
cacheHit: dataset.cacheHit,
|
||||
updatedAt: new Date(dataset.at).toISOString(),
|
||||
cacheTtlMs: resolveCacheTtlMs(),
|
||||
@ -426,15 +439,16 @@ async function getReplenishList(options = {}) {
|
||||
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,
|
||||
orderDate: dataset.orderDate,
|
||||
rows: sortRows(filterRows(dataset.rows, options)),
|
||||
summary: buildSummary(dataset.rows, dataset.meta.orderSkuCount),
|
||||
orderScope: PENDING_ORDER_SCOPE,
|
||||
rows: sortRows(replenished),
|
||||
summary: buildSummary(replenished),
|
||||
};
|
||||
}
|
||||
|
||||
/** 启动预热:库存视图 + 当日订单 union 各自 30s~1min,不能让首个用户请求等 */
|
||||
/** 启动预热:库存视图 + 待发订单 union 各自 30s~1min,不能让首个用户请求等 */
|
||||
async function warmBinReplenishCache() {
|
||||
try {
|
||||
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];
|
||||
|
||||
@ -3,7 +3,7 @@ import request from './request';
|
||||
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
// 缺货补货监控(订单驱动版)
|
||||
// 判断口径:当日订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量
|
||||
// 判断口径:待发订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量
|
||||
export const getBinReplenishList = (params = {}) => request.get(
|
||||
'/api/bin-replenish/list',
|
||||
{ params, timeout: LONG_TIMEOUT_MS }
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Empty, Input, Segmented, Select, Spin, Table, Tag, Tooltip,
|
||||
Alert, Button, Empty, Input, Segmented, Spin, Table, Tag, Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
@ -19,15 +19,12 @@ const STATUS_TAGS = {
|
||||
short: { color: 'red', label: '存储不足' },
|
||||
need: { color: 'gold', label: '需补货' },
|
||||
enough: { color: 'green', label: '拣货位充足' },
|
||||
no_order: { color: 'default', label: '今日无单' },
|
||||
no_order: { color: 'default', label: '无待发单' },
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: '待补货', value: 'replenish' },
|
||||
{ label: '存储不足', value: 'short' },
|
||||
{ label: '拣货位充足', value: 'enough' },
|
||||
{ label: '今日无单', value: 'no_order' },
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '仅缺货', value: 'short' },
|
||||
];
|
||||
|
||||
const BinReplenishMonitor = () => {
|
||||
@ -41,7 +38,7 @@ const BinReplenishMonitor = () => {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [data, setData] = useState({
|
||||
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderDate: '', updatedAt: '', cacheHit: false,
|
||||
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderScope: '', updatedAt: '', cacheHit: false,
|
||||
});
|
||||
const keywordTimer = useRef(null);
|
||||
|
||||
@ -66,7 +63,7 @@ const BinReplenishMonitor = () => {
|
||||
summary: payload.summary || {},
|
||||
warehouse: payload.warehouse || null,
|
||||
warehouses: payload.warehouses || [],
|
||||
orderDate: payload.orderDate || '',
|
||||
orderScope: payload.orderScope || '',
|
||||
updatedAt: payload.updatedAt || '',
|
||||
cacheHit: Boolean(payload.cacheHit),
|
||||
});
|
||||
@ -99,47 +96,47 @@ const BinReplenishMonitor = () => {
|
||||
|
||||
const metrics = useMemo(() => ([
|
||||
{
|
||||
key: 'replenish',
|
||||
tone: 'red',
|
||||
label: '待补货 SKU',
|
||||
value: needCount,
|
||||
unit: '个',
|
||||
foot: `建议补货合计 ${num(summary.suggestQtyTotal)} 件`,
|
||||
tip: '当日订单量 > 拣货位库存:拣货位不够今天的订单拣,需要从存储位把货补过来',
|
||||
key: 'order',
|
||||
tone: 'blue',
|
||||
label: '实时代发订单数',
|
||||
value: summary.orderQtyTotal || 0,
|
||||
unit: '件',
|
||||
foot: `待补 ${num(summary.skuTotal)} 个 SKU`,
|
||||
tip: '待补 SKU 的待发订单件数合计(取聚水潭「打单拣货 → 发货中」每日快照,组合装已展开为子件;不含已发/异常/待审核)',
|
||||
},
|
||||
{
|
||||
key: 'pick',
|
||||
tone: 'green',
|
||||
label: '拣货库存数',
|
||||
value: summary.pickTotal || 0,
|
||||
unit: '件',
|
||||
foot: '待补 SKU 拣货位库存',
|
||||
tip: '待补 SKU 在 XA / XD / XE 拣货位上的库存合计(库存取自聚水潭「箱及仓位库存 · 简尚-崔桥工厂仓 主仓」)',
|
||||
},
|
||||
{
|
||||
key: 'store',
|
||||
tone: 'orange',
|
||||
label: '存储库存数',
|
||||
value: summary.storageTotal || 0,
|
||||
unit: '件',
|
||||
foot: '待补 SKU 存储位库存',
|
||||
tip: '待补 SKU 在 XX / X9 / X5 / X4 存储位上的库存合计(同上来源,主仓)',
|
||||
},
|
||||
{
|
||||
key: 'short',
|
||||
tone: 'grey',
|
||||
label: '其中存储不足',
|
||||
value: summary.short || 0,
|
||||
tone: 'red',
|
||||
label: '缺货数',
|
||||
value: summary.skuTotal || 0,
|
||||
unit: '个',
|
||||
foot: `存储缺口 ${num(summary.shortfallTotal)} 件`,
|
||||
tip: '拣货位缺货,且存储位也不够补(存储缺口 > 0):光靠库内调拨补不齐,需要采购 / 紧急调拨',
|
||||
},
|
||||
{
|
||||
key: 'exceed',
|
||||
tone: 'orange',
|
||||
label: '超出订单数合计',
|
||||
value: summary.exceedTotal || 0,
|
||||
unit: '件',
|
||||
foot: '今日拣货位缺口订单量',
|
||||
tip: '所有待补货 SKU 的「订单量 − 拣货位库存」之和:这是今天需要从存储位搬到拣货位的总量',
|
||||
},
|
||||
{
|
||||
key: 'order',
|
||||
tone: 'blue',
|
||||
label: '当日有单 SKU',
|
||||
value: summary.orderSkuCount || 0,
|
||||
unit: '个',
|
||||
foot: `当日订单量 ${num(summary.orderQtyTotal)} 件`,
|
||||
tip: '统计日当天下过单的商品种类数(含线上店铺 + 全民客户,组合装已展开为子件)',
|
||||
foot: `其中存储不足 ${num(summary.short)} 个,缺口 ${num(summary.shortfallTotal)} 件`,
|
||||
tip: '需要从存储位补到拣货位的 SKU 数(待发订单量 > 拣货位库存);其中存储位也补不齐的标为「缺货」,需采购 / 紧急调拨',
|
||||
},
|
||||
]), [summary, needCount]);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await exportBinReplenish({ warehouse, status, keyword });
|
||||
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_${data.orderDate || ''}.xlsx`);
|
||||
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_待发订单.xlsx`);
|
||||
} catch (error_) {
|
||||
const text = await readBlobErrorMessage(error_);
|
||||
message.error(text || '导出失败');
|
||||
@ -188,7 +185,7 @@ const BinReplenishMonitor = () => {
|
||||
render: (value) => <span className="brp-qty">{num(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '当日订单',
|
||||
title: '待发订单',
|
||||
dataIndex: 'orderQty',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
@ -238,8 +235,9 @@ const BinReplenishMonitor = () => {
|
||||
<div className="brp-hero__main">
|
||||
<h1 className="brp-hero__title">缺货补货监控</h1>
|
||||
<p className="brp-hero__desc">
|
||||
盯的是<b>拣货位</b>:今天下的订单,如果拣货位库存不够拣,超出的那部分就要从<b>存储位</b>补到拣货位。
|
||||
库存取自聚水潭「箱及仓位库存」,订单取自崔桥工厂仓当日订单。
|
||||
本看板只盯<b>崔桥工厂仓(主仓)</b>的<b>当日待补</b>数据:也就是那些待发订单超过拣货位库存、
|
||||
需要把货从<b>存储位</b>补到<b>拣货位</b>的 SKU。订单取自聚水潭「打单拣货 → 简尚-崔桥工厂仓」的发货中单据,
|
||||
库存取自聚水潭「箱及仓位库存 → 简尚-崔桥工厂仓 主仓」。<b>已发货的不再计入</b>(货已发出、不再消耗拣货位)。
|
||||
</p>
|
||||
</div>
|
||||
<div className="brp-hero__meta">
|
||||
@ -247,15 +245,14 @@ const BinReplenishMonitor = () => {
|
||||
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
|
||||
</div>
|
||||
<div>
|
||||
订单日期:<b>{data.orderDate || '--'}</b>
|
||||
<span className="brp-muted">(取该仓最近一个有订单的营业日)</span>
|
||||
统计口径:<b>{data.orderScope || '待发订单 = 聚水潭「打单拣货」发货中快照(不含已发/异常/待审核)'}</b>
|
||||
</div>
|
||||
<div>
|
||||
取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b>
|
||||
{data.cacheHit ? <span className="brp-muted">(缓存)</span> : null}
|
||||
</div>
|
||||
<div>
|
||||
共 <b>{num(summary.skuTotal)}</b> 个 SKU,其中今日无单 <b>{num(summary.noOrder)}</b> 个
|
||||
共 <b>{num(summary.skuTotal)}</b> 个待补 SKU,其中存储不足(缺货)<b>{num(summary.short)}</b> 个
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -281,15 +278,7 @@ const BinReplenishMonitor = () => {
|
||||
<div className="brp-toolbar">
|
||||
<span className="brp-toolbar__field">
|
||||
<span className="brp-toolbar__label">仓库</span>
|
||||
<Select
|
||||
value={warehouse}
|
||||
onChange={(value) => { setWarehouse(value); setPage(1); }}
|
||||
style={{ width: 210 }}
|
||||
options={(data.warehouses || []).map((item) => ({
|
||||
value: item.key,
|
||||
label: item.name,
|
||||
}))}
|
||||
/>
|
||||
<span className="brp-toolbar__static">简尚-崔桥工厂仓(主仓)</span>
|
||||
</span>
|
||||
<span className="brp-toolbar__field">
|
||||
<span className="brp-toolbar__label">范围</span>
|
||||
@ -300,11 +289,7 @@ const BinReplenishMonitor = () => {
|
||||
...item,
|
||||
label: item.value === 'replenish'
|
||||
? `${item.label} ${num(needCount)}`
|
||||
: item.value === 'short'
|
||||
? `${item.label} ${num(summary.short || 0)}`
|
||||
: item.value === 'no_order'
|
||||
? `${item.label} ${num(summary.noOrder || 0)}`
|
||||
: item.label,
|
||||
: `${item.label} ${num(summary.short || 0)}`,
|
||||
}))}
|
||||
/>
|
||||
</span>
|
||||
@ -332,11 +317,12 @@ const BinReplenishMonitor = () => {
|
||||
type="info"
|
||||
showIcon
|
||||
className="brp-alert"
|
||||
message="货位口径(崔桥工厂仓约定)"
|
||||
message="货位口径(崔桥工厂仓 主仓约定)"
|
||||
description={(
|
||||
<span>
|
||||
拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;其余货位(DB / Y* / Z* 等,占比极小)不计入。
|
||||
判断规则:<b>当日订单量 > 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
|
||||
库存只取 <b>崔桥工厂仓主仓(wh_id=1)</b>;拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;
|
||||
其余货位(DB / Y* / Z* 等,占比极小)不计入。
|
||||
判断规则:<b>待发订单量 > 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
@ -353,7 +339,7 @@ const BinReplenishMonitor = () => {
|
||||
|
||||
<Spin
|
||||
spinning={loading}
|
||||
tip="正在聚合仓位库存 + 当日订单,首次约 30~90 秒"
|
||||
tip="正在聚合仓位库存 + 待发订单,首次约 30~90 秒"
|
||||
wrapperClassName="brp-table-spin"
|
||||
>
|
||||
<Table
|
||||
@ -391,24 +377,30 @@ const BinReplenishMonitor = () => {
|
||||
<div className="brp-notes__title">数据来源与口径说明</div>
|
||||
<ul>
|
||||
<li>
|
||||
<b>库存</b>:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码(sku_id)聚合。
|
||||
「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
|
||||
<b>库存</b>:聚水潭「箱及仓位库存 → 简尚-崔桥工厂仓 主仓」(rpt.jst_packInventoryInfo,仅 <b>wh_id=1 主仓</b>),
|
||||
按商品编码(sku_id)聚合。「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;
|
||||
「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
|
||||
</li>
|
||||
<li>
|
||||
<b>订单</b>:崔桥工厂仓当日订单 = rpt.jst_Orders(线上店铺)+ rpt.jst_qmcust_orderlist(全民客户 B2B),
|
||||
均按 send_date 落在「订单日期」当天;组合装(sku_type=combine)已按 jst_comboProductItem 展开为子件。
|
||||
<b>订单</b>:直接取聚水潭「<b>打单拣货 → 简尚-崔桥工厂仓</b>」的发货中单据快照 = rpt.op_OrderNotDispatched
|
||||
(该仓<b>最新快照日</b>、status=发货中),SKU 级、组合装已展开为子件。
|
||||
<b>已发货 / 异常挂起 / 已付款待审核 / 待付款均不计入</b>——货已发出或短期不会拣,不该驱动补货。
|
||||
口径仅覆盖<b>聚水潭线上店铺渠道</b>(运营中心 / 分销等),<b>不含全民(qmcust) B2B 客户单</b>。
|
||||
</li>
|
||||
<li>
|
||||
<b>判断规则</b>:对每个 SKU,若 <b>当日订单量 > 拣货位库存</b>,超出的订单数即「需要从存储位补到拣货位」的量;
|
||||
「建议补货量」= min(超出订单数, 存储位库存)。存储位也不够时标记「存储不足」,缺口需采购 / 紧急调拨。
|
||||
<b>看板范围</b>:本看板<b>只显示当日待补</b> SKU —— 即「待发订单量 > 拣货位库存」、需要从存储位补到拣货位的这部分
|
||||
(含拣货位为 0、库存视图里查不到但有打单拣货待发单的 SKU)。其余「拣货位充足 / 无待发单」的 SKU 不进本看板。
|
||||
</li>
|
||||
<li>
|
||||
<b>订单日期</b>:取该仓订单表 send_date 的最大日期(即最近一个有订单的营业日),副本 ETL 有延迟时不会为空;
|
||||
页面顶部明确标注统计的是哪一天。
|
||||
<b>判断规则</b>:对每个待补 SKU,<b>超出订单数</b> = max(0, 待发订单量 − 拣货位库存);
|
||||
「建议补货量」= min(超出订单数, 存储位库存);存储位也不够时标记「缺货」,缺口需采购 / 紧急调拨。
|
||||
</li>
|
||||
<li>
|
||||
<b>时效</b>:仓位库存视图聚合 + 当日订单 4 分支 union 各需 30s~1min,服务端缓存 10 分钟(启动时已预热);
|
||||
点「刷新数据」会强制回源,需要等 1 分钟左右。
|
||||
<b>统计范围</b>:取该仓<b>最新一天的「打单拣货」快照</b>(ETL 约每日 07:00 刷新);已发/异常/待审核一律排除。
|
||||
</li>
|
||||
<li>
|
||||
<b>时效</b>:库存视图聚合约 1~2s,待发快照两步查询约 12s;服务端缓存 10 分钟(启动时已预热);
|
||||
点「刷新数据」会强制回源,需要等约 15~30 秒。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user