fix: 缺货补货监控改为「崔桥主仓·仅当日待补」并修正指标

- 看板只展示当日待补 SKU(待发订单>拣货位库存,需存储→拣货补货),去除充足/无待发干扰行
- 库存收紧到崔桥工厂仓主仓(jst_packInventoryInfo 加 wh_id=1),数据源对齐「箱及仓位库存·主仓」
- 补齐「有打单拣货待发单但货位库存视图无记录」的 SKU(拣货位为0也应进待补明细)
- 指标卡改为四项:实时代发订单数 / 拣货库存数 / 存储库存数 / 缺货数(缺货=待补SKU数,脚注含存储不足子项)
- 各库底待补明细=need+short 行;范围筛「待补货/仅缺货」;移除多仓选择器,固定崔桥
- 待发订单仍取 op_OrderNotDispatched 打单拣货/发货中 每日快照(两步 querySlow)
- 副本实测:当日快照待补 45 SKU(need42/short3),待发132件/拣货40件/存储2381件/缺口5件
This commit is contained in:
lunanting 2026-10-23 08:35:11 +08:00
parent 5b0c44c9e2
commit e43f17fde4
3 changed files with 146 additions and 105 deletions

View File

@ -49,7 +49,7 @@ router.get('/export', async (req, res, next) => {
keyword: req.query.keyword,
status: req.query.status,
});
const fileName = `${payload.warehouse.shortName}缺货补货监控_待发订单`;
const fileName = `${payload.warehouse.shortName}待补明细`;
sendWorkbookDownload(res, {
fileName,

View File

@ -155,10 +155,40 @@ 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 });
}
/**
* 批量取 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;
}
/**
@ -258,40 +288,43 @@ function buildSkuRows({ invRows, orderMap }) {
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;
}
@ -311,7 +344,22 @@ async function loadDataset(warehouse, { refresh = false } = {}) {
loadPendingOrderMap(warehouse).catch(() => new Map()),
]);
const rows = buildSkuRows({ invRows, orderMap });
// 把「有打单拣货待发单、但货位库存视图里没有记录」的 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,
@ -334,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)
@ -352,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)
@ -366,15 +414,16 @@ 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,
@ -390,11 +439,12 @@ 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,
orderScope: PENDING_ORDER_SCOPE,
rows: sortRows(filterRows(dataset.rows, options)),
summary: buildSummary(dataset.rows, dataset.meta.orderSkuCount),
rows: sortRows(replenished),
summary: buildSummary(replenished),
};
}

View File

@ -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 {
@ -24,10 +24,7 @@ const STATUS_TAGS = {
const STATUS_OPTIONS = [
{ label: '待补货', value: 'replenish' },
{ label: '存储不足', value: 'short' },
{ label: '拣货位充足', value: 'enough' },
{ label: '无待发单', value: 'no_order' },
{ label: '全部', value: 'all' },
{ label: '仅缺货', value: 'short' },
];
const BinReplenishMonitor = () => {
@ -99,40 +96,40 @@ 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]);
@ -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>补到<b>拣货位</b>的 SKU。订单取自聚水潭「打单拣货 → 简尚-崔桥工厂仓」的发货中单据,
库存取自聚水潭「箱及仓位库存 → 简尚-崔桥工厂仓 主仓」。<b>已发货的不再计入</b>(货已发出、不再消耗拣货位)。
</p>
</div>
<div className="brp-hero__meta">
@ -254,7 +252,7 @@ const BinReplenishMonitor = () => {
{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>
@ -280,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>
@ -299,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>
@ -331,10 +317,11 @@ 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>崔桥工厂仓主仓(wh_id=1)</b>;拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;
其余货位(DB / Y* / Z* 等,占比极小)不计入。
判断规则:<b>待发订单量 &gt; 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
</span>
)}
@ -390,26 +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>:直接取聚水潭「<b>打单拣货 → 发货中</b>」快照 = rpt.op_OrderNotDispatched(该仓<b>最新快照日</b>、status=发货中),
SKU 级、组合装已展开为子件。这就是你在聚水潭「打单拣货 · 简尚崔桥工厂仓」看到的待发单据。
<b>已发货(Sent)、异常挂起、已付款待审核、待付款均不计入</b>——货已发出或短期不会拣,不该驱动补货。
口径仅覆盖<b>聚水潭线上店铺渠道</b>(运营中心 / 分销等),<b>不含全民(qmcust) B2B 客户单</b>——与旧「按 send_date 窗口统计当日订单」口径不同,属预期收窄。
<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>待发订单量 &gt; 拣货位库存</b>,超出的订单数即「需要从存储位补到拣货位」的量;
「建议补货量」= min(超出订单数, 存储位库存)。存储位也不够时标记「存储不足」,缺口需采购 / 紧急调拨。
<b>看板范围</b>:本看板<b>只显示当日待补</b> SKU —— 即「待发订单量 &gt; 拣货位库存」、需要从存储位补到拣货位的这部分
(含拣货位为 0、库存视图里查不到但有打单拣货待发单的 SKU)。其余「拣货位充足 / 无待发单」的 SKU 不进本看板。
</li>
<li>
<b>统计范围</b>:取该仓<b>最新一天的「打单拣货」快照</b>(ETL 约每日 07:00 刷新),即当前待拣/待发订单;
已发/异常/待审核一律排除。
<b>判断规则</b>:对每个待补 SKU,<b>超出订单数</b> = max(0, 待发订单量 − 拣货位库存);
「建议补货量」= min(超出订单数, 存储位库存);存储位也不够时标记「缺货」,缺口需采购 / 紧急调拨。
</li>
<li>
<b>时效</b>:仓位库存视图聚合需 30s~1min,待发快照表较快;服务端缓存 10 分钟(启动时已预热);
点「刷新数据」会强制回源,需要等约 1 分钟。
<b>统计范围</b>:取该仓<b>最新一天的「打单拣货」快照</b>(ETL 约每日 07:00 刷新);已发/异常/待审核一律排除。
</li>
<li>
<b>时效</b>:库存视图聚合约 1~2s,待发快照两步查询约 12s;服务端缓存 10 分钟(启动时已预热);
点「刷新数据」会强制回源,需要等约 15~30 秒。
</li>
</ul>
</div>