feat: 日销口径由近 7 天改为近 15 天(MKH 补货预警 + 模块化库存监控)

业务要求「近7日日销」改「近30天日销」。实测业务库与产品资料库均无 30 天日销,
已实现的最长周期是 pm_stockMonitor.fifteenAvgSale(15 天),经与业务确认改用 15 天。

为什么没有 30 天(2026-09-17 实测,避免重复排查):
- rpt.pm_stockMonitor 只有 threeAvgSale(3天)/sevenAvgSale(7天)/fifteenAvgSale(15天),
  且整表只保留 1 天快照(2026-09-16),攒不出 30 天历史
- rpt.dws_sn_sales_full_info_di(stat_date+skuid+goods_count,新鲜到 09-15)
  里 MKH 商品 0 个;目标仓 794 个 SKU 命中也是 0
- rpt.dws_order_sales_cost_detail_di 停在 2025-04(历史归档)
- rpt.jst_salesOutboundInfo 只有出库单头,无 SKU 明细;业务库无出库明细表
- rpt.op_BarcodeSalePrice30dDaily 名字带 30d,但只有 salePrice 没有销量

改动:
- backend/src/services/mkhBomService.js
  · SALE_WINDOW_DAYS 7 → 15,新增 DAILY_SALE_COLUMN='fifteenAvgSale',
    监控快照 SQL 按该常量插值取数(以后改口径只改常量)
  · buildReplenishMetrics 入参 sevenAvgSale → avgSale(语义与周期解耦)
  · 文件头 ⑦ 补「为什么是 15 天」的完整排查结论;⑦ / ⑩ / ⑪ 实测数字按新口径更新
- backend/src/services/modularStockMonitorService.js
  · 取数列与 API 字段名 sevenAvgSale/avgShip7d → fifteenAvgSale/avgShip15d
    (字段名同步改,避免名不副实)
- frontend/src/pages/MkhBomData/index.jsx
  · 列头与口径说明「近7日」→「近15天」;动态文案兜底 7 → 15
- frontend/src/pages/ModularStockMonitor/index.jsx
  · 列头「近7日日均发货量」→「近15天日均发货量」,dataIndex 同步改
  · 口径说明补充:可用天数仍沿用 jsUseDays(源系统口径),与日销并非同一时间窗

影响(同一份 09-16 快照,7 天 vs 15 天):
  有销商品  114 → 170   周期拉长,更多商品进得了「有销」集合
  补货预警   25 → 19    反而变少:15 天日销普遍低于 7 天(近期销量走低),
                         可用天数 = 库存 ÷ 日销 随之变大,跌破 3 天的变少
  缺货        9 → 10
  已排产     11 → 13
  订单驱动   14 → 8     更多商品自己有日销,不再需要靠订单兜底
  配件不足   10 → 12
「预警变少」是口径变化的正常结果,不是 bug。

验证:
- 后端语法检查 151 个文件通过
- mkhBomService + mkhReplenishAlertJob 单测 52/52 通过
- 后端全量 334 tests / 333 pass(唯一失败是 resilientSecretStore 的
  Windows 文件权限断言,与本改动无关的环境固有问题)
- 接口实测:total 4223 / warningCount 19 / outOfStockCount 10 /
  saleWindowDays 15 / componentShortageCount 12
This commit is contained in:
yanhui 2026-09-17 14:06:50 +08:00
parent 06952d8a04
commit 3e02c3e393
6 changed files with 99 additions and 67 deletions

View File

@ -64,19 +64,40 @@
// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。
//
// ── ⑦ 补货预警(2026-09-17 业务确认口径)──────────────────────────
// 规则:近 7 日日销 > 0 且 现有库存 < 3 天日销 → 预警补货
// · 近7日日销 rpt.pm_stockMonitor.sevenAvgSale
// 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货
// · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale
// (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照)
// · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存;
// 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①),
// 缺失时回退监控快照 jsStock
// · 可用天数 = 现有库存 / 近7日日销
// · 建议补货量 = ceil(3 × 近7日日销 − 现有库存),不足 0 取 0
// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / sevenAvgSale 不同源
// · 可用天数 = 现有库存 / 近15天日销
// · 建议补货量 = ceil(3 × 近15天日销 − 现有库存),不足 0 取 0
// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / fifteenAvgSale 不同源
// (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67,
// 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近7日日销」为准。
// 实测(2026-09-16 快照,当时成品口径尚未收紧为「正式 BOM 父项」):
// 4408 个 MKH 成品中 114 个有日销、13 个可用天数 < 3 天。
// 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近15天日销」为准。
//
// ── 为什么是 15 天而不是业务最初要求的 30 天 ──
// 2026-09-17 业务要求「近7日日销」改「近30天日销」,但实测全库**没有 30 天日销**:
// · pm_stockMonitor 只有 threeAvgSale(3天) / sevenAvgSale(7天) / fifteenAvgSale(15天),
// 且整表**只保留 1 天快照**(2026-09-16),攒不出 30 天历史
// · rpt.dws_sn_sales_full_info_di(stat_date+skuid+goods_count,数据新鲜到 09-15)
// 里 MKH 商品 **0 个**;目标仓 794 个 SKU 命中也是 0
// · rpt.dws_order_sales_cost_detail_di 停在 2025-04(历史归档)
// · rpt.jst_salesOutboundInfo 只有出库**单头**,无 SKU 明细;业务库无出库明细表
// · rpt.op_BarcodeSalePrice30dDaily 名字带 30d,但只有 salePrice **没有销量**
// → 经与业务确认,改用十五天口径 fifteenAvgSale(已实现的最长周期)。
// ⚠️ 若日后上游补了 30 天字段,只需改 loadReplenishIndex 里的列名 + DAILY_SALE_DAYS。
//
// 实测(2026-09-16 品牌仓快照,当时成品口径尚未收紧为「正式 BOM 父项」):
// 4408 个 MKH 成品中,7 天有销 114 个、15 天有销 170 个
// 实测(2026-09-17 切换当天,同一份 09-16 快照,7 天 vs 15 天):
// 有销商品 114 → 170 ← 周期拉长,更多商品进得了「有销」集合
// 补货预警 25 → 19 ← 反而变少:15 天日销普遍低于 7 天(近期销量走低),
// 可用天数 = 库存 ÷ 日销 随之变大,跌破 3 天的变少
// 缺货 9 → 10
// 已排产 11 → 13
// 订单驱动 14 → 8 ← 更多商品自己有日销了,不再需要靠订单兜底
// ⚠️ 「预警变少」不是 bug,是口径变化的正常结果;若业务想要 30 天,需上游先补数。
//
// ── ⑧ 在途加工单(2026-09-17 业务确认口径)────────────────────────
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
@ -107,7 +128,7 @@
// 响应会膨胀到几十 MB,而商品清单本身用不到明细。
//
// ── ⑩ 待发货订单(2026-09-17 业务确认口径)────────────────────────
// 规则:**近 7 日日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。
// 规则:**近 15 天日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。
// 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」
// 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。
// · 数据源:业务库 rpt.jst_Orders(订单**主表**,实时更新,实测最新到今天)
@ -128,8 +149,10 @@
// 缺货时记 stockDays = 0(保证排在最前),预警(库存已够发)时记 null。
// ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)——
// 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。
// 实测(2026-09-17):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件,
// 实测(2026-09-17,7 天口径下):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件,
// 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。
// ⚠️ 改 15 天口径后实测:订单驱动从 14 个降到 **8 个**
// (日销基数 114 → 170,原本靠订单兜底的商品自己有日销了),与预期一致。
//
// ── ⑪ 配件不足 / 可组装数(2026-09-17 业务确认口径)────────────────
// 规则:**缺货 / 预警的 MKH 成品,若其配件现有库存「可组装数 = 0」,则提示
@ -152,8 +175,8 @@
// · 判定范围:**缺货 + 预警都判**(业务确认),不改变原有预警级别与排序,
// 只额外打标记;「已排产」商品本来就不预警,不参与判定。
// · 无 BOM 配件明细的商品 → 可组装数未知(组件缺失 ≠ 配件不足),不做判定。
// 实测(2026-09-17,并入最新 main 后复核):预警 25 个(缺货 9 + 预警 16)中 **10 个可组装数 = 0**
// (缺货 6 + 预警 4);扣加工单占用前后仅 1 个商品变化(185 → 180 台),
// 实测(2026-09-17,15 天口径):预警 19 个(缺货 10 + 预警 9)中 **12 个可组装数 = 0**
// (缺货 7 + 预警 5);扣加工单占用前后仅 1 个商品变化(185 → 180 台),
// 说明占用规模小但口径上必须扣。
// ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标),
// 接口透出 componentStockDegraded=true。
@ -192,7 +215,13 @@ const MKH_WHERE = `s.sku_id LIKE N'MKH%'
// ── 补货预警参数(见文件头 ⑦)────────────────────────────────────
// 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得)
const BRAND_WMS_CO_ID = 15870495;
const SALE_WINDOW_DAYS = 7;
// 日销统计窗口(天)。见文件头 ⑦:业务最初要 30 天,但全库无 30 天日销数据,
// 实测最长只有 fifteenAvgSale(15 天),经业务确认改用 15 天。
// ⚠️ SALE_WINDOW_DAYS 决定页面文案与口径展示,DAILY_SALE_COLUMN 决定实际取数列,
// 两者必须成对修改;下方 SQL 已按 DAILY_SALE_COLUMN 插值,改常量即可生效。
const SALE_WINDOW_DAYS = 15;
// 日销取数列(rpt.pm_stockMonitor)—— 必须与 SALE_WINDOW_DAYS 保持一致
const DAILY_SALE_COLUMN = 'fifteenAvgSale';
// 预警阈值:现有库存可用天数低于 3 天即需补货
const REPLENISH_DAYS_THRESHOLD = 3;
@ -299,8 +328,8 @@ const SCOPES = Object.freeze({ WARNING: 'warning', SOLD: 'sold', ALL: 'all' });
/**
* 解析展示范围:
* warning —— 仅需补货(近7日日销 > 0 且 可用天数 < 3 天)
* sold —— 有近7日日销的商品
* warning —— 仅需补货(近15天日销 > 0 且 可用天数 < 3 天)
* sold —— 有近15天日销的商品
* all —— 全部 MKH 成品
* 兼容旧参数 warningOnly=1(等价 scope=warning)。
*/
@ -776,13 +805,13 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(
* 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。
*
* 口径(见文件头 ⑦ + ⑩):
* ① 近7日日销 > 0 且 现有库存 < 3 天日销 → 预警
* ② 近7日日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩)
* ① 近15天日销 > 0 且 现有库存 < 3 天日销 → 预警
* ② 近15天日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩)
* 两者都不满足 → 无销量,不参与预警。
* 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。
*
* @param {object} row
* row.sevenAvgSale 近7日日均发货量
* row.avgSale 近15天日均发货量(取自 rpt.pm_stockMonitor.fifteenAvgSale)
* row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录)
* row.monitorStock 监控快照库存(fallback)
* row.safetyDays 源系统安全天数(仅展示)
@ -792,7 +821,7 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(
* row.pendingOrderQty 待发货订单量(未发货订单件数)
*/
function buildReplenishMetrics(row = {}) {
const dailySale = Math.max(0, toNumber(row.sevenAvgSale) ?? 0);
const dailySale = Math.max(0, toNumber(row.avgSale) ?? 0);
const realtimeStock = toNumber(row.realtimeStock);
const monitorStock = toNumber(row.monitorStock) ?? 0;
const hasRealtime = realtimeStock !== null;
@ -828,7 +857,7 @@ function buildReplenishMetrics(row = {}) {
monitorDate: toDateText(row.monitorDate),
};
// 既无近7日日销、也无未发货订单 → 不参与预警
// 既无近15天日销、也无未发货订单 → 不参与预警
if (dailySale <= 0 && !orderDriven) {
return {
...base,
@ -1179,7 +1208,7 @@ async function loadReplenishIndex() {
const pending = Promise.all([
// 监控快照:同一天只有一行,按 date DESC 取首条即可
cloudDatabase.query(
`SELECT m.sku_id AS skuId, m.sevenAvgSale AS sevenAvgSale, m.jsStock AS monitorStock,
`SELECT m.sku_id AS skuId, m.${DAILY_SALE_COLUMN} AS fifteenAvgSale, m.jsStock AS monitorStock,
m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, m.date AS monitorDate
FROM dbo.pm_stockMonitor m
WHERE m.warehouse = @brandWarehouse AND m.sku_id LIKE N'MKH%'
@ -1218,7 +1247,8 @@ async function loadReplenishIndex() {
seenMonitor.add(key);
if (!monitorDate) monitorDate = toDateText(row.monitorDate);
bySku.set(key, buildReplenishMetrics({
sevenAvgSale: row.sevenAvgSale,
// 日销取数见文件头 ⑦:十五天口径(业务要 30 天但全库无此数据)
avgSale: row.fifteenAvgSale,
// 实时库存缺失(而非为 0)时才回退监控快照
realtimeStock: latestStock.has(key) ? latestStock.get(key) : null,
monitorStock: row.monitorStock,

View File

@ -5,7 +5,7 @@
// 需求来源:《模块化库存监控.xlsx》
// 一级功能:模块化库存监控 二级功能:配件库存监控
// 表头:图片 | 预警 | SKU编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 |
// 仓位 | 近7日日均发货量 | 库存可用天数 | 所在仓库
// 仓位 | 近15天日均发货量 | 库存可用天数 | 所在仓库
// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的 SKU 编码
//
// 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认):
@ -14,7 +14,7 @@
// 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库
// 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得)
// 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU:
// sevenAvgSale 近7日日均发货量
// fifteenAvgSale 近15天日均发货量(2026-09-17 由 7 天改 15 天,见下)
// jsUseDays 库存可用天数
// safetyDays 安全天数(默认 15)
// suggestStock 建议备货量
@ -76,7 +76,7 @@ const SORTABLE_FIELDS = Object.freeze({
productName: 's.name',
category: 's.category',
realtimeStock: 'realtimeStock',
avgShip7d: 'm.sevenAvgSale',
avgShip15d: 'm.fifteenAvgSale',
stockDays: 'm.jsUseDays',
warningLevel: 'warningOrder',
mkhStatus: 'mkhStatus',
@ -108,7 +108,7 @@ OUTER APPLY (
ORDER BY i.updateTime DESC
) inv
OUTER APPLY (
SELECT TOP 1 m2.sevenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock,
SELECT TOP 1 m2.fifteenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock,
m2.date, m2.jsStock, m2.nearSecondSale
FROM dbo.pm_stockMonitor m2
WHERE m2.sku_id = s.sku_id AND m2.warehouse = @warehouseName
@ -352,7 +352,7 @@ const mapRow = (row = {}) => {
outQtyToday: toNumber(row.outQtyToday) ?? 0,
realtimeStock,
location: toText(row.location),
avgShip7d: toNumber(row.avgShip7d),
avgShip15d: toNumber(row.avgShip15d),
stockDays,
safetyDays,
suggestStock: toNumber(row.suggestStock),
@ -368,7 +368,7 @@ const buildProjection = () => `
ISNULL(pin.inQty, 0) AS inQtyToday,
ISNULL(m.nearSecondSale, 0) AS outQtyToday,
COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) AS location,
m.sevenAvgSale AS avgShip7d,
m.fifteenAvgSale AS avgShip15d,
m.jsUseDays AS stockDays,
m.safetyDays AS safetyDays,
m.suggestStock AS suggestStock,

View File

@ -190,17 +190,17 @@ test('pack inventory without bin/bin_group falls back to the box id, never to a
assert.equal(locations.has('PART-2'), false);
});
// ── 补货预警:近 7 日日销 > 0 且 现有库存 < 3 天日销 ────────────────
// ── 补货预警:近 15 天日销 > 0 且 现有库存 < 3 天日销 ───────────────
const metrics = (row) => _private.buildReplenishMetrics(row);
test('replenish: stock below 3 days of sales is flagged as stockout or warning', () => {
const stockout = metrics({ sevenAvgSale: 2.55, realtimeStock: 0, monitorStock: 0 });
const stockout = metrics({ avgSale: 2.55, realtimeStock: 0, monitorStock: 0 });
assert.equal(stockout.stockDays, 0);
assert.equal(stockout.warningLevel, '缺货');
assert.equal(stockout.hasWarning, true);
assert.equal(stockout.replenishQty, 8); // ceil(3 * 2.55 - 0) = ceil(7.65)
const warning = metrics({ sevenAvgSale: 2, realtimeStock: 1, monitorStock: 1 });
const warning = metrics({ avgSale: 2, realtimeStock: 1, monitorStock: 1 });
assert.equal(warning.stockDays, 0.5);
assert.equal(warning.warningLevel, '预警');
assert.equal(warning.hasWarning, true);
@ -208,34 +208,34 @@ test('replenish: stock below 3 days of sales is flagged as stockout or warning',
});
test('replenish: the threshold is exactly 3 days of sales', () => {
const atThreshold = metrics({ sevenAvgSale: 2, realtimeStock: 6, monitorStock: 6 });
const atThreshold = metrics({ avgSale: 2, realtimeStock: 6, monitorStock: 6 });
assert.equal(atThreshold.stockDays, 3);
assert.equal(atThreshold.warningLevel, '正常');
assert.equal(atThreshold.hasWarning, false);
assert.equal(atThreshold.replenishQty, 0);
const below = metrics({ sevenAvgSale: 2, realtimeStock: 5.9, monitorStock: 5.9 });
const below = metrics({ avgSale: 2, realtimeStock: 5.9, monitorStock: 5.9 });
assert.equal(below.hasWarning, true);
});
test('replenish: stock prefers the brand-warehouse realtime value, snapshot only as fallback', () => {
const realtime = metrics({ sevenAvgSale: 1, realtimeStock: 4, monitorStock: 9 });
const realtime = metrics({ avgSale: 1, realtimeStock: 4, monitorStock: 9 });
assert.equal(realtime.stock, 4);
assert.equal(realtime.stockSource, 'realtime');
// 实时库存「无记录」(null)才回退监控快照
const fallback = metrics({ sevenAvgSale: 1, realtimeStock: null, monitorStock: 9 });
const fallback = metrics({ avgSale: 1, realtimeStock: null, monitorStock: 9 });
assert.equal(fallback.stock, 9);
assert.equal(fallback.stockSource, 'monitor');
// 实时库存为 0 是有效值,不得被快照覆盖
const zero = metrics({ sevenAvgSale: 1, realtimeStock: 0, monitorStock: 9 });
const zero = metrics({ avgSale: 1, realtimeStock: 0, monitorStock: 9 });
assert.equal(zero.stock, 0);
assert.equal(zero.warningLevel, '缺货');
});
test('replenish: products without 7-day sales never warn', () => {
const noSale = metrics({ sevenAvgSale: 0, realtimeStock: 0, monitorStock: 0 });
const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 });
assert.equal(noSale.hasWarning, false);
assert.equal(noSale.warningLevel, '无销量');
assert.equal(noSale.stockDays, null);
@ -286,7 +286,7 @@ test('replenish sort puts the most urgent first and no-sale products last', () =
test('replenish: a pending work order suppresses the replenish warning', () => {
// 库存为 0、本该判「缺货」,但半成品包装项目仓已有未完成加工单
const scheduled = metrics({
sevenAvgSale: 2,
avgSale: 2,
realtimeStock: 0,
monitorStock: 0,
pendingWorkOrderQty: 20,
@ -307,13 +307,13 @@ test('replenish: a pending work order suppresses the replenish warning', () => {
assert.equal(scheduled.stockDays, 0);
// 同样库存但无加工单时仍然预警 —— 确认抑制效果确实来自加工单
const bare = metrics({ sevenAvgSale: 2, realtimeStock: 0, monitorStock: 0 });
const bare = metrics({ avgSale: 2, realtimeStock: 0, monitorStock: 0 });
assert.equal(bare.hasWarning, true);
assert.equal(bare.warningLevel, '缺货');
});
test('replenish: pending work order fields default to empty without any order', () => {
const none = metrics({ sevenAvgSale: 1, realtimeStock: 0 });
const none = metrics({ avgSale: 1, realtimeStock: 0 });
assert.equal(none.hasPendingWorkOrder, false);
assert.equal(none.pendingWorkOrderQty, 0);
assert.equal(none.pendingWorkOrderCount, 0);
@ -322,7 +322,7 @@ test('replenish: pending work order fields default to empty without any order',
// 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理
const zeroQty = metrics({
sevenAvgSale: 1,
avgSale: 1,
realtimeStock: 0,
pendingWorkOrderQty: 0,
pendingWorkOrderCount: 2,
@ -334,7 +334,7 @@ test('replenish: pending work order fields default to empty without any order',
test('replenish: products without sales stay "no sale" even with a pending work order', () => {
const noSale = metrics({
sevenAvgSale: 0,
avgSale: 0,
realtimeStock: 0,
pendingWorkOrderQty: 5,
pendingWorkOrderCount: 1,
@ -394,7 +394,7 @@ test('pending order: only MKH finished products count, combination codes are exc
test('replenish: no sales but unshipped orders still trigger the warning', () => {
// 库存不够发货 → 缺货,补货量 = 待发货件数 − 现有库存
const notEnough = metrics({
sevenAvgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2,
avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2,
});
assert.equal(notEnough.hasWarning, true);
assert.equal(notEnough.warningLevel, '缺货');
@ -407,7 +407,7 @@ test('replenish: no sales but unshipped orders still trigger the warning', () =>
// 库存刚好够发 → 预警(不再缺货),补货量为 0
const exactlyEnough = metrics({
sevenAvgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4,
avgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4,
});
assert.equal(exactlyEnough.warningLevel, '预警');
assert.equal(exactlyEnough.hasWarning, true);
@ -418,7 +418,7 @@ test('replenish: no sales but unshipped orders still trigger the warning', () =>
test('replenish: order-driven warning does not override the pending work order exemption', () => {
const scheduled = metrics({
sevenAvgSale: 0,
avgSale: 0,
realtimeStock: 0,
pendingOrderQty: 5,
pendingOrderCount: 1,
@ -434,19 +434,19 @@ test('replenish: order-driven warning does not override the pending work order e
});
test('replenish: pending order fields default to empty without any unshipped order', () => {
const none = metrics({ sevenAvgSale: 1, realtimeStock: 0 });
const none = metrics({ avgSale: 1, realtimeStock: 0 });
assert.equal(none.pendingOrderQty, 0);
assert.equal(none.pendingOrderCount, 0);
assert.equal(none.pendingOrderEarliest, null);
assert.equal(none.orderDriven, false);
// 无日销且无订单 → 仍是「无销量」,不参与预警
const noSale = metrics({ sevenAvgSale: 0, realtimeStock: 0, pendingOrderQty: 0 });
const noSale = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 });
assert.equal(noSale.warningLevel, '无销量');
assert.equal(noSale.orderDriven, false);
// 有日销的商品照旧走原口径,但待发货量也要带上(页面新增列)
const withSale = metrics({ sevenAvgSale: 2, realtimeStock: 0, pendingOrderQty: 3 });
const withSale = metrics({ avgSale: 2, realtimeStock: 0, pendingOrderQty: 3 });
assert.equal(withSale.warningLevel, '缺货');
assert.equal(withSale.orderDriven, false);
assert.equal(withSale.pendingOrderQty, 3);

View File

@ -83,7 +83,7 @@ test('notification carries recipient, idempotency key and shortfall details', ()
const all = [...fresh, alert('MKHDNZ0409260700486', '预警', { dailySale: 2, stock: 1, replenishQty: 5 })];
const snapshot = {
monitorDate: '2026-09-16',
saleWindowDays: 7,
saleWindowDays: 15,
replenishDaysThreshold: 3,
outOfStockCount: 1,
warningOnlyCount: 1,

View File

@ -71,7 +71,7 @@ const PRODUCT_EXPORT_COLUMNS = [
{ title: 'MKH商品编码', dataIndex: 'skuId' },
{ title: '商品名称', dataIndex: 'productName' },
{ title: '预警', dataIndex: 'warningLevel' },
{ title: '近7日日销', dataIndex: 'dailySale' },
{ title: '近15天日销', dataIndex: 'dailySale' },
{ title: '现有库存', dataIndex: 'stock' },
{ title: '在途加工量', dataIndex: 'pendingWorkOrderQty' },
{ title: '待发货订单', dataIndex: 'pendingOrderQty' },
@ -512,7 +512,7 @@ const MkhBomData = () => {
),
},
{
title: '近7日日销',
title: '近15天日销',
dataIndex: 'dailySale',
width: 106,
align: 'right',
@ -562,7 +562,7 @@ const MkhBomData = () => {
`聚水潭未发货订单 ${row.pendingOrderCount || 0} 单,合计 ${number(value)} 件`,
row.pendingOrderEarliest ? `最早一单下单于 ${row.pendingOrderEarliest}` : '',
'已取消 / 已合并单据不核算',
row.orderDriven ? '该商品近 7 日暂无日销,按待发货订单量列入补货预警' : '',
row.orderDriven ? '该商品近 15 天暂无日销,按待发货订单量列入补货预警' : '',
].filter(Boolean).join(';')}
>
<span className="mkh-order">{number(value)}</span>
@ -577,9 +577,9 @@ const MkhBomData = () => {
sorter: true,
render: (value, row) => {
if (value === null || value === undefined) {
// 订单驱动(近7日无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同
// 订单驱动(近15天无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同
return row.orderDriven
? <Tooltip title="近 7 日暂无日销,无日销基数可算天数,按待发货订单量预警"><span className="mkh-muted">按订单</span></Tooltip>
? <Tooltip title="近 15 天暂无日销,无日销基数可算天数,按待发货订单量预警"><span className="mkh-muted">按订单</span></Tooltip>
: <span className="mkh-muted">无销量</span>;
}
return <span className={row.hasWarning ? 'mkh-days--warn' : undefined}>{number(value, 2)}</span>;
@ -717,7 +717,7 @@ const MkhBomData = () => {
<div className="mkh-header__heading">
<Title level={3}>MKH-BOM 资料</Title>
<Text type="secondary">
模块化(MKH 开头)成品的配件明细与补货预警:近 7 日日销核算,库存低于 3 天日销即预警
模块化(MKH 开头)成品的配件明细与补货预警:近 15 天日销核算,库存低于 3 天日销即预警
</Text>
<Text type="secondary" className="mkh-note">
数据口径:MKH 商品编码取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”,并且必须是聚水潭正式 BOM 的父项;
@ -725,11 +725,11 @@ const MkhBomData = () => {
无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo,
优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
<br />
补货预警:近7日日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、
该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近7日日销,低于 3 天即预警,缺货单独标识;
建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。
补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、
该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近15天日销,低于 3 天即预警,缺货单独标识;
建议补货量 = 3 × 近15天日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。
<br />
待发货订单:近7日暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警
待发货订单:近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警
(新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」;
预警级别按「现有库存 &lt; 待发货件数 → 缺货,否则预警」,建议补货量 = 待发货件数 − 现有库存,
可用天数因无日销基数而显示「按订单」。
@ -793,7 +793,7 @@ const MkhBomData = () => {
className="mkh-alert"
type="warning"
showIcon
message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近7日无日销但有订单」的商品,请稍后点击「刷新数据」重试。"
message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近15天无日销但有订单」的商品,请稍后点击「刷新数据」重试。"
action={<Button size="small" onClick={load}>刷新数据</Button>}
/>
) : null}
@ -816,14 +816,14 @@ const MkhBomData = () => {
message={`补货预警:${data.warningCount} 个 MKH 商品需要补货`}
description={(
<span>
按近 {data.saleWindowDays || 7} 日日销核算(监控日 {data.monitorDate || '—'}):
按近 {data.saleWindowDays || 15} 天日销核算(监控日 {data.monitorDate || '—'}):
其中 <b>{data.outOfStockCount || 0}</b> 个已缺货,
<b>{Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))}</b> 个可用天数不足 {data.replenishDaysThreshold || 3} 天。
建议补货量 = {data.replenishDaysThreshold || 3} × 近7日日销 − 现有库存。点「只看预警」即为补货清单。
建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 − 现有库存。点「只看预警」即为补货清单。
{(data.orderDrivenCount || 0) > 0 ? (
<>
<br />
其中 <b>{data.orderDrivenCount}</b> 个近 7 日暂无日销,因聚水潭存在未发货订单
其中 <b>{data.orderDrivenCount}</b> 个近 15 天暂无日销,因聚水潭存在未发货订单
(待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件)而列入预警,建议补货量按待发货件数核算。
</>
) : null}

View File

@ -233,8 +233,8 @@ const AccessoryStockPanel = () => {
</Tooltip>),
},
{
title: '近7日日均发货量',
dataIndex: 'avgShip7d',
title: '近15天日均发货量',
dataIndex: 'avgShip15d',
width: 138,
align: 'right',
sorter: true,
@ -402,13 +402,15 @@ const ModularStockMonitor = () => (
<div className="msm-header__heading">
<Title level={3}>模块化库存监控</Title>
<Text type="secondary">
配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 7 日日均发货量与可用天数
配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 15 天日均发货量与可用天数
</Text>
<Text type="secondary" className="msm-note">
数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 7 日日均发货量、
数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、
库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖);
当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」;
图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。
注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天);
库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。
状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。
</Text>
</div>