diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index c6fb7b2..ed4a607 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -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, }; } diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 1dd6578..21e6d1e 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -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', () => { diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 1c6b546..0fd2e30 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -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(';')} > {number(value)} @@ -577,12 +577,19 @@ const MkhBomData = () => { sorter: true, render: (value, row) => { if (value === null || value === undefined) { - // 订单驱动(近15天无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同 - return row.orderDriven - ? 按订单 - : 无销量; + // 真正算不出天数:无日销且无代发订单 → 无销量;订单驱动分支现已如实给出天数,不会进这里 + return 无销量; } - return {number(value, 2)}; + const text = {number(value, 2)}; + if (row.orderDriven) { + // 近 15 天无日销,可用天数按「库存 ÷ 代发订单件数」估算,≤ 3 天即预警 + return ( + + {text} + + ); + } + return text; }, }, { @@ -729,10 +736,10 @@ const MkhBomData = () => { 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近15天日销,低于 3 天即预警,缺货单独标识; 建议补货量 = 3 × 近15天日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。
- 待发货订单:近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 + 待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; - 预警级别按「现有库存 < 待发货件数 → 缺货,否则预警」,建议补货量 = 待发货件数 − 现有库存, - 可用天数因无日销基数而显示「按订单」。 + 可用天数 = 现有库存 ÷ 代发(待发货)订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警; + 建议补货量:缺货时按「代发件数 − 现有库存」、预警时按「3 × 代发件数 − 现有库存」补到 3 天缓冲。
在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算 (作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。 @@ -823,8 +830,8 @@ const MkhBomData = () => { {(data.orderDrivenCount || 0) > 0 ? ( <>
- 其中 {data.orderDrivenCount} 个近 15 天暂无日销,因聚水潭存在未发货订单 - (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件)而列入预警,建议补货量按待发货件数核算。 + 其中 {data.orderDrivenCount} 个近 15 天暂无日销,按代发(待发货)订单列入预警 + (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件),可用天数 = 库存 ÷ 代发件数、≤ 3 天即预警。 ) : null} {(data.scheduledCount || 0) > 0 ? (