fix: 缺货补货监控改为只统计待发订单(排除已发/取消/拆单/合单),不限日期
All checks were successful
[Do] try bracketed context
Do auto-pass for待发订单 PR

This commit is contained in:
lunanting 2026-10-22 15:07:29 +08:00
parent a0b075dfdd
commit d74b4b649a
4 changed files with 71 additions and 85 deletions

View File

@ -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,

View File

@ -4,7 +4,7 @@
* 缺货补货监控(订单驱动版,2026-10-22 重写)
* ==========================================================================
* 业务需求(用户确认):
* 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够今天的订单拣,
* 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够待发订单拣,
* 需要从「存储库位」把货补过去。
*
* ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
@ -19,16 +19,22 @@
* + 组合装(sku_type='combine')→ jst_comboProductItem 展开为子件
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
* 按 wh_id 会把多仓混成一个桶。
* 订单日期:取该仓订单表 send_date 的最大日期(即「最近一个有订单的营业日」,
* 副本 ETL 有延迟时不会为空,页面会明确标注统计的是哪一天)。
*
* ── 待发口径(用户 2026-10-22 二次明确)──────────────────────────────
* 补货需求只来自「还没发的订单(待发)」,**已发货(Sent)的订单不参与计算** ——
* 已发订单的货已经拣走发出,不再消耗拣货位库存,不该驱动补货。
* 判定方式:排除掉「已发 / 取消 / 删除 / 拆单 / 合单」这几类非待发状态,
* 其余(WaitConfirm 待确认 / Question 异常挂起 / Delivering 发货中 / 任何新增待发状态)
* 一律按「待发」计入。如此新增的待发状态也会自动落入统计,无需改代码。
* 待发订单不限日期:含历史未发积压,最贴近「需要补货去拣的货」。
*
* ── 判断逻辑(核心)──────────────────────────────────────────────────
* 对每一个 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 +42,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,
* ⚠️ 性能:库存视图聚合 + 待发订单 4 分支 union 各自都要 30s~1min,
* 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
*/
@ -75,6 +81,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(',');
// ── 待发口径:排除掉「非待发」状态,其余一律算待发 ──────────────────────
// Sent=已发(唯一 100% 带 send_date 的状态)、Cancelled=已取消、Delete=删除、
// Split=拆单父单(子单另计)、Merged=合单(合后单另计)。
// 白名单式排除比「只认某几个待发状态」更稳:新增的待发状态会自动落入统计。
const PENDING_EXCLUDE_STATUS = Object.freeze(['Sent', 'Cancelled', 'Delete', 'Split', 'Merged']);
const PENDING_EXCLUDE_IN = PENDING_EXCLUDE_STATUS.map((s) => `'${s}'`).join(',');
const PENDING_ORDER_SCOPE = '待发订单(不含已发/取消/拆单/合单),不限日期';
const STATUS = Object.freeze({
SHORT: 'short',
NEED: 'need',
@ -86,7 +100,7 @@ const STATUS_LABELS = Object.freeze({
short: '存储不足',
need: '需补货',
enough: '拣货位充足',
no_order: '今日无单',
no_order: '无待发单',
});
// 排序权重:最紧急的排最前
@ -125,14 +139,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 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
@ -156,36 +162,20 @@ async function loadReplicaInventory(warehouse) {
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);
/**
* 各 SKU 的「待发」订单量:4 个来源分支,组合装展开为子件。
* 不再按 send_date 窗口(那是「已发」),改为排除非待发状态、不限日期。
*/
async function loadPendingOrderMap(warehouse) {
const sql = `
WITH ship AS (
-- 1) 线上店铺订单:普通商品行
-- 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(o.status, '') NOT IN (${PENDING_EXCLUDE_IN})
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
GROUP BY oi.sku_id
UNION ALL
@ -196,19 +186,17 @@ async function loadTodayOrderMap(warehouse, orderDate) {
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 ISNULL(o.status, '') NOT IN (${PENDING_EXCLUDE_IN})
AND oi.sku_type = 'combine'
GROUP BY ci.src_sku_id
UNION ALL
-- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除)
-- 3) 全民客户(B2B 分销)订单:普通商品行(排除 已发/取消/删除/拆单/合单)
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(o.status, '') NOT IN (${PENDING_EXCLUDE_IN})
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
GROUP BY oi.sku_id
UNION ALL
@ -219,8 +207,7 @@ async function loadTodayOrderMap(warehouse, orderDate) {
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 ISNULL(o.status, '') NOT IN (${PENDING_EXCLUDE_IN})
AND oi.sku_type = 'combine'
GROUP BY ci.src_sku_id
)
@ -231,7 +218,7 @@ async function loadTodayOrderMap(warehouse, orderDate) {
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
const rows = await runQuery(
sql,
{ wmsCoId: warehouse.wmsCoId, shipFrom, shipTo },
{ wmsCoId: warehouse.wmsCoId },
{ requestTimeout: 300000 }
);
const map = new Map();
@ -243,7 +230,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);
@ -331,9 +318,8 @@ function buildSummary(rows, orderSkuCount) {
}
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 +330,16 @@ 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 });
const rows = buildSkuRows({ invRows, orderMap });
const dataset = {
at: now,
rows,
warehouse,
orderDate,
orderScope: PENDING_ORDER_SCOPE,
meta: {
rowCount: rows.length,
orderSkuCount: orderMap.size,
@ -416,7 +402,7 @@ async function getReplenishList(options = {}) {
summary,
warehouse,
warehouses: Object.values(WAREHOUSES),
orderDate: dataset.orderDate,
orderScope: PENDING_ORDER_SCOPE,
cacheHit: dataset.cacheHit,
updatedAt: new Date(dataset.at).toISOString(),
cacheTtlMs: resolveCacheTtlMs(),
@ -428,13 +414,13 @@ async function getReplenishExportRows(options = {}) {
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
return {
warehouse,
orderDate: dataset.orderDate,
orderScope: PENDING_ORDER_SCOPE,
rows: sortRows(filterRows(dataset.rows, options)),
summary: buildSummary(dataset.rows, dataset.meta.orderSkuCount),
};
}
/** 启动预热:库存视图 + 当日订单 union 各自 30s~1min,不能让首个用户请求等 */
/** 启动预热:库存视图 + 待发订单 union 各自 30s~1min,不能让首个用户请求等 */
async function warmBinReplenishCache() {
try {
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];

View File

@ -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 }

View File

@ -19,14 +19,14 @@ 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: 'no_order' },
{ label: '全部', value: 'all' },
];
@ -41,7 +41,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 +66,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),
});
@ -105,7 +105,7 @@ const BinReplenishMonitor = () => {
value: needCount,
unit: '个',
foot: `建议补货合计 ${num(summary.suggestQtyTotal)} 件`,
tip: '当日订单量 > 拣货位库存:拣货位不够今天的订单拣,需要从存储位把货补过来',
tip: '待发订单量 > 拣货位库存:拣货位不够待发订单拣,需要从存储位把货补过来',
},
{
key: 'short',
@ -122,24 +122,24 @@ const BinReplenishMonitor = () => {
label: '超出订单数合计',
value: summary.exceedTotal || 0,
unit: '件',
foot: '今日拣货位缺口订单量',
tip: '所有待补货 SKU 的「订单量 − 拣货位库存」之和:这是今天需要从存储位搬到拣货位的总量',
foot: '待发拣货位缺口订单量',
tip: '所有待补货 SKU 的「待发订单量 − 拣货位库存」之和:这是需要从存储位搬到拣货位的总量',
},
{
key: 'order',
tone: 'blue',
label: '当日有单 SKU',
label: '有待发单 SKU',
value: summary.orderSkuCount || 0,
unit: '个',
foot: `当日订单量 ${num(summary.orderQtyTotal)} 件`,
tip: '统计日当天下过单的商品种类数(含线上店铺 + 全民客户,组合装已展开为子件)',
foot: `待发订单量 ${num(summary.orderQtyTotal)} 件`,
tip: '有待发订单的商品种类数(含线上店铺 + 全民客户,组合装已展开为子件;不含已发/取消/拆单/合单)',
},
]), [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 +188,7 @@ const BinReplenishMonitor = () => {
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
{
title: '当日订单',
title: '待发订单',
dataIndex: 'orderQty',
width: 100,
align: 'right',
@ -238,8 +238,8 @@ 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>(已发货的不再计入,因为货已经发出、不再消耗拣货位)。
</p>
</div>
<div className="brp-hero__meta">
@ -247,15 +247,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.noOrder)}</b> 个
</div>
</div>
</div>
@ -336,7 +335,7 @@ const BinReplenishMonitor = () => {
description={(
<span>
拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;其余货位(DB / Y* / Z* 等,占比极小)不计入。
判断规则:<b>当日订单量 &gt; 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
判断规则:<b>待发订单量 &gt; 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
</span>
)}
/>
@ -353,7 +352,7 @@ const BinReplenishMonitor = () => {
<Spin
spinning={loading}
tip="正在聚合仓位库存 + 当日订单,首次约 30~90 秒"
tip="正在聚合仓位库存 + 待发订单,首次约 30~90 秒"
wrapperClassName="brp-table-spin"
>
<Table
@ -395,19 +394,20 @@ const BinReplenishMonitor = () => {
「拣货位库存」= 货位号以 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.jst_Orders(线上店铺)+ rpt.jst_qmcust_orderlist(全民客户 B2B),
排除 已发(Sent) / 取消(Cancelled) / 删除(Delete) / 拆单(Split) / 合单(Merged);组合装(sku_type=combine)已按 jst_comboProductItem 展开为子件。
<b>已发货的订单不计入</b>——货已发出、不再消耗拣货位,不该驱动补货。
</li>
<li>
<b>判断规则</b>:对每个 SKU,若 <b>当日订单量 &gt; 拣货位库存</b>,超出的订单数即「需要从存储位补到拣货位」的量;
<b>判断规则</b>:对每个 SKU,若 <b>待发订单量 &gt; 拣货位库存</b>,超出的订单数即「需要从存储位补到拣货位」的量;
「建议补货量」= min(超出订单数, 存储位库存)。存储位也不够时标记「存储不足」,缺口需采购 / 紧急调拨。
</li>
<li>
<b>订单日期</b>:取该仓订单表 send_date 的最大日期(即最近一个有订单的营业日),副本 ETL 有延迟时不会为空;
页面顶部明确标注统计的是哪一天。
<b>统计范围</b>:待发订单<b>不限日期</b>,含历史未发积压。这样最贴近「需要补货去拣的货」;
已发/取消/拆单/合单一律排除,任何新增的待发状态也会自动落入统计。
</li>
<li>
<b>时效</b>:仓位库存视图聚合 + 当日订单 4 分支 union 各需 30s~1min,服务端缓存 10 分钟(启动时已预热);
<b>时效</b>:仓位库存视图聚合 + 待发订单 4 分支 union 各需 30s~1min,服务端缓存 10 分钟(启动时已预热);
点「刷新数据」会强制回源,需要等 1 分钟左右。
</li>
</ul>