feat: 补货预警-日销为0有代发订单时按库存÷代发件数算可用天数预警

- ⑩订单驱动分支改为:可用天数 = 现有库存 ÷ 代发(待发货)订单件数
  · 可用天数 = 0(无库存)→ 缺货
  · 0 < 可用天数 < 3 天 → 预警(建议补到 3 天缓冲:3×代发件数−现有库存)
  · 可用天数 ≥ 3 天 → 库存充足,不预警(阈值语义与日销分支一致)
- buildReplenishMetrics 订单驱动分支不再把 stockDays 记 null,统一如实记录
- 同步前端「可用天数」列/导出/数据口径说明文案(不再显示「按订单」)
- 更新 mkhBomService.test.js 对应用例,单测 40/40 通过
This commit is contained in:
yanhui 2026-09-17 15:04:50 +08:00
parent 3e02c3e393
commit 8ba052649d
3 changed files with 108 additions and 45 deletions

View File

@ -143,16 +143,25 @@
// (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单;
// 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求)
// · 待发货订单量 = Σ 该成品在未发货订单中的件数(同一单多次出现会累加)
// · 预警级别(业务确认):现有库存 < 待发货订单量 → 缺货;否则 → 预警
// · 建议补货量 = max(0, 待发货订单量 − 现有库存)
// ⚠️ 该分支无法算「可用天数」(日销为 0,除数为零):
// 缺货时记 stockDays = 0(保证排在最前),预警(库存已够发)时记 null。
// · 预警判定(2026-09-17 业务补充口径):日销为 0,但仍用「可用天数」衡量风险 ——
// 可用天数 = 现有库存 ÷ 代发(待发货)订单件数
// · 可用天数 = 0(无库存) → 缺货
// · 0 < 可用天数 < 3 天 → 预警
// · 可用天数 ≥ 3 天 → 库存充足,不预警(阈值语义与日销分支一致)
// ⚠️ 代发订单件数恒 > 0(orderDriven 已保证 pendingOrderQty > 0),不会出现除零;
// 可用天数统一如实记录(保留 2 位小数),不再像以前那样缺货记 0 / 够发记 null。
// · 建议补货量:
// 缺货 → max(0, 代发订单件数 − 现有库存)(先补到能覆盖整笔订单)
// 预警(0<天数<3) → max(0, 3 × 代发订单件数 − 现有库存)(补到 3 天缓冲)
// 正常(天数≥3) → 0
// ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)——
// 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。
// 实测(2026-09-17,7 天口径下):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件,
// 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。
// ⚠️ 改 15 天口径后实测:订单驱动从 14 个降到 **8 个**
// (日销基数 114 → 170,原本靠订单兜底的商品自己有日销了),与预期一致。
// ⚠️ 同日又叠加「可用天数 ≤ 3 天」阈值(2026-09-17 补充):代发件数大、库存也能撑 3 天
// 以上的商品不再预警,订单驱动预警数会在此 8 个基础上进一步减少(具体数以接口为准)。
//
// ── ⑪ 配件不足 / 可组装数(2026-09-17 业务确认口径)────────────────
// 规则:**缺货 / 预警的 MKH 成品,若其配件现有库存「可组装数 = 0」,则提示
@ -806,7 +815,8 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(
*
* 口径(见文件头 ⑦ + ⑩):
* ① 近15天日销 > 0 且 现有库存 < 3 天日销 → 预警
* ② 近15天日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩)
* ② 近15天日销 = 0 且有代发(待发货)订单 → 订单驱动预警(见文件头 ⑩):
* 可用天数 = 现有库存 ÷ 代发订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警
* 两者都不满足 → 无销量,不参与预警。
* 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。
*
@ -882,17 +892,37 @@ function buildReplenishMetrics(row = {}) {
};
}
// 订单驱动(见文件头 ⑩):日销为 0 算不出可用天数,改按「能否覆盖待发货」判级
// 订单驱动(见文件头 ⑩):日销为 0 但有代发(待发货)订单、有库存
// → 用「现有库存 ÷ 代发订单件数」估算可用天数,≤ 3 天即补货预警
// ⚠️ 阈值语义与日销分支一致:可用天数 < 3 天为「预警」、= 0(无库存)为「缺货」、
// ≥ 3 天视为库存充足不预警;代发订单件数恒 > 0(orderDriven 已保证 pendingOrderQty > 0)
if (orderDriven) {
const notEnough = stock < pendingOrderQty;
const orderStockDays = stock / pendingOrderQty;
let level;
let replenishQty;
if (orderStockDays <= 0) {
// 完全没货(库存 ≤ 0):缺货,建议补到能覆盖整笔代发订单
level = WARNING_LEVEL.OUT_OF_STOCK;
replenishQty = Math.max(0, pendingOrderQty - stock);
} else if (orderStockDays < REPLENISH_DAYS_THRESHOLD) {
// 有货但可用天数不足 3 天:预警,建议补到 3 天缓冲(3 × 代发件数 − 现有库存)
level = WARNING_LEVEL.WARNING;
replenishQty = Math.max(0, Math.ceil(REPLENISH_DAYS_THRESHOLD * pendingOrderQty - stock));
} else {
// 可用天数 ≥ 3 天:库存充足,不预警
level = WARNING_LEVEL.NORMAL;
replenishQty = 0;
}
return {
...base,
// 缺货记 0(保证排在最前);库存已够发货时记 null(无日销,算不出天数)
stockDays: notEnough ? 0 : null,
replenishQty: Math.max(0, pendingOrderQty - stock),
warningLevel: notEnough ? WARNING_LEVEL.OUT_OF_STOCK : WARNING_LEVEL.WARNING,
warningOrder: notEnough ? 1 : 2,
hasWarning: true,
// 可用天数统一记录(保留 2 位小数,用于排序与展示)
stockDays: Number(orderStockDays.toFixed(2)),
replenishQty,
warningLevel: level,
// 排序权重:缺货 1 < 预警 2 < 正常 3(与日销分支一致)
warningOrder: level === WARNING_LEVEL.OUT_OF_STOCK ? 1
: (level === WARNING_LEVEL.WARNING ? 2 : 3),
hasWarning: level === WARNING_LEVEL.OUT_OF_STOCK || level === WARNING_LEVEL.WARNING,
};
}

View File

@ -391,29 +391,55 @@ test('pending order: only MKH finished products count, combination codes are exc
assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']);
});
test('replenish: no sales but unshipped orders still trigger the warning', () => {
// 库存不够发货 → 缺货,补货量 = 待发货件数 − 现有库存
const notEnough = metrics({
avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2,
test('replenish: no sales but unshipped orders warn by available days (stock / order qty)', () => {
// 可用天数 = 现有库存 ÷ 代发订单件数;≤ 3 天即预警,= 0(无库存)为缺货。
// 完全没货:可用天数 0 → 缺货,补货量 = 代发件数 − 现有库存
const outOfStock = metrics({
avgSale: 0, realtimeStock: 0, monitorStock: 0, pendingOrderQty: 4, pendingOrderCount: 2,
});
assert.equal(notEnough.hasWarning, true);
assert.equal(notEnough.warningLevel, '缺货');
assert.equal(notEnough.replenishQty, 3);
assert.equal(notEnough.orderDriven, true);
// 无日销基数 → 缺货时记 0,保证排在预警清单最前
assert.equal(notEnough.stockDays, 0);
assert.equal(notEnough.pendingOrderQty, 4);
assert.equal(notEnough.pendingOrderCount, 2);
assert.equal(outOfStock.hasWarning, true);
assert.equal(outOfStock.warningLevel, '缺货');
assert.equal(outOfStock.orderDriven, true);
assert.equal(outOfStock.stockDays, 0);
assert.equal(outOfStock.replenishQty, 4);
assert.equal(outOfStock.pendingOrderQty, 4);
assert.equal(outOfStock.pendingOrderCount, 2);
// 库存刚好够发 → 预警(不再缺货),补货量为 0
const exactlyEnough = metrics({
// 有货但只够 0.25 天 → 预警(不再因「库存 < 订单件数」直接判缺货)
const short = metrics({
avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4,
});
assert.equal(short.warningLevel, '预警');
assert.equal(short.hasWarning, true);
assert.equal(short.stockDays, 0.25);
// 补到 3 天缓冲:ceil(3 × 4 − 1) = 11
assert.equal(short.replenishQty, 11);
// 可用天数正好 1 天 → 仍预警,补到 3 天缓冲:ceil(3 × 4 − 4) = 8
const oneDay = metrics({
avgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4,
});
assert.equal(exactlyEnough.warningLevel, '预警');
assert.equal(exactlyEnough.hasWarning, true);
assert.equal(exactlyEnough.replenishQty, 0);
// 库存已够发时算不出可用天数,记 null(页面显示「按订单」)
assert.equal(exactlyEnough.stockDays, null);
assert.equal(oneDay.warningLevel, '预警');
assert.equal(oneDay.stockDays, 1);
assert.equal(oneDay.replenishQty, 8);
// 可用天数正好 3 天(库存=12,件数=4)→ 阈值取「< 3」,故不预警
const atThreshold = metrics({
avgSale: 0, realtimeStock: 12, monitorStock: 12, pendingOrderQty: 4,
});
assert.equal(atThreshold.warningLevel, '正常');
assert.equal(atThreshold.hasWarning, false);
assert.equal(atThreshold.stockDays, 3);
assert.equal(atThreshold.replenishQty, 0);
// 可用天数超 3 天(库存=13,件数=4 → 3.25 天)→ 库存充足,不预警,但仍标记 orderDriven
const enough = metrics({
avgSale: 0, realtimeStock: 13, monitorStock: 13, pendingOrderQty: 4,
});
assert.equal(enough.warningLevel, '正常');
assert.equal(enough.hasWarning, false);
assert.equal(enough.orderDriven, true);
assert.equal(enough.stockDays, 3.25);
});
test('replenish: order-driven warning does not override the pending work order exemption', () => {

View File

@ -78,8 +78,8 @@ const PRODUCT_EXPORT_COLUMNS = [
{
title: '可用天数',
dataIndex: 'stockDays',
// 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨;
// 订单驱动(日销=0 但有未发货订单)同样是 null,口径不同要分开说明
// 无销量且无代发订单的商品 stockDays 为 null,导出成文字而非空白;
// 订单驱动分支(日销=0 但有代发/待发货订单)现已如实给出可用天数(库存÷代发件数),不会进这里
exportValue: (value, row) => {
if (value !== null && value !== undefined) return value;
return row.orderDriven ? '无日销·按订单' : '无销量';
@ -562,7 +562,7 @@ const MkhBomData = () => {
`聚水潭未发货订单 ${row.pendingOrderCount || 0} 单,合计 ${number(value)} 件`,
row.pendingOrderEarliest ? `最早一单下单于 ${row.pendingOrderEarliest}` : '',
'已取消 / 已合并单据不核算',
row.orderDriven ? '该商品近 15 天暂无日销,按待发货订单量列入补货预警' : '',
row.orderDriven ? '该商品近 15 天暂无日销,按代发(待发货)订单量列入补货预警,可用天数 = 库存 ÷ 代发件数' : '',
].filter(Boolean).join(';')}
>
<span className="mkh-order">{number(value)}</span>
@ -577,12 +577,19 @@ const MkhBomData = () => {
sorter: true,
render: (value, row) => {
if (value === null || value === undefined) {
// 订单驱动(近15天无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同
return row.orderDriven
? <Tooltip title="近 15 天暂无日销,无日销基数可算天数,按待发货订单量预警"><span className="mkh-muted">按订单</span></Tooltip>
: <span className="mkh-muted">无销量</span>;
// 真正算不出天数:无日销且无代发订单 → 无销量;订单驱动分支现已如实给出天数,不会进这里
return <span className="mkh-muted">无销量</span>;
}
return <span className={row.hasWarning ? 'mkh-days--warn' : undefined}>{number(value, 2)}</span>;
const text = <span className={row.hasWarning ? 'mkh-days--warn' : undefined}>{number(value, 2)}</span>;
if (row.orderDriven) {
// 近 15 天无日销,可用天数按「库存 ÷ 代发订单件数」估算,≤ 3 天即预警
return (
<Tooltip title="近 15 天暂无日销,可用天数 = 现有库存 ÷ 代发(待发货)订单件数(≤ 3 天即预警)">
{text}
</Tooltip>
);
}
return text;
},
},
{
@ -729,10 +736,10 @@ const MkhBomData = () => {
该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近15天日销,低于 3 天即预警,缺货单独标识;
建议补货量 = 3 × 近15天日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。
<br />
待发货订单:近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警
待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警
(新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」;
预警级别按「现有库存 &lt; 待发货件数 → 缺货,否则预警」,建议补货量 = 待发货件数 − 现有库存,
可用天数因无日销基数而显示「按订单」。
可用天数 = 现有库存 ÷ 代发(待发货)订单件数,&lt; 3 天预警、= 0(无库存)缺货、≥ 3 天不预警;
建议补货量:缺货时按「代发件数 − 现有库存」、预警时按「3 × 代发件数 − 现有库存」补到 3 天缓冲。
<br />
在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算
(作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。
@ -823,8 +830,8 @@ const MkhBomData = () => {
{(data.orderDrivenCount || 0) > 0 ? (
<>
<br />
其中 <b>{data.orderDrivenCount}</b> 个近 15 天暂无日销,因聚水潭存在未发货订单
(待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件)而列入预警,建议补货量按待发货件数核算。
其中 <b>{data.orderDrivenCount}</b> 个近 15 天暂无日销,按代发(待发货)订单列入预警
(待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件),可用天数 = 库存 ÷ 代发件数、≤ 3 天即预警。
</>
) : null}
{(data.scheduledCount || 0) > 0 ? (