diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 0d97c89..94c7428 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -102,6 +102,36 @@ // 订单驱动 14 → 8 ← 更多商品自己有日销了,不再需要靠订单兜底 // ⚠️ 「预警变少」不是 bug,是口径变化的正常结果;若业务想要 30 天,需上游先补数。 // +// ── ⑦-a 日销兜底:订单表实算(2026-09-19 新增)──────────────────── +// 问题:上游快照 pm_stockMonitor.fifteenAvgSale 会**漏算**。实测(快照 09-18, +// 窗口内有发货的 MKH 成品共 178 个编码)**24 个被记成 0**,其中 16 个是 +// 「订单全部来自样品专用店铺」(上游同样不计样品单,属合理口径), +// 剩 **8 个是真实店铺**(天猫 / 京东 / 拼多多),这才是异常。 +// 业务实例:`MKHDNZ0409260601548` 近 15 天实发 2 件(09-09 / 09-19), +// 快照日销却是 0,库存又是 0 → 走「无销量」分支彻底隐身,**连缺货都不报**。 +// ⚠️ 该列是**保留两位小数的日均**,不是取整值:品牌仓 177 个非零值范围 +// 0.07 ~ 4.96,含小数的占 170 个,最小非零 0.07 = 1 件 ÷ 15 天。 +// 所以「发过货却记 0」**无法用四舍五入解释**,就是上游没算进去。 +// 兜底规则:**快照日销 ≤ 0 且近 SALE_WINDOW_DAYS 天有真实发货**时, +// 日销改用订单表实算值 = 已发货件数 ÷ SALE_WINDOW_DAYS, +// 随后照常走 ⑦/⑫ 的日销分支(结余库存、3 天阈值、建议补货量都不改)。 +// · **只在快照 ≤ 0 时兜底**:快照 > 0 一律以上游为准,不覆盖,避免两套口径打架 +// · **排除样品 / 测试专用店铺**(shop_name 含「样品」或「测试」),与上游口径对齐; +// 不排除的话那 16 个样品单编码会被误拉进预警 +// · 窗口 = 含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径 +// · 数据源与 ⑩ 的待发货订单**同一次查询、同一组表**:一次扫表同时拿到 +// 「待发货」与「近15天已发货」两份数据。实测合并后总耗时 ≈ 单查待发货 +// (均约 1.25s),而拆成两条独立查询要在现有开销之上**再加一次全表扫描**。 +// · 兜底生效的商品带 `dailySaleSource='orders'` 与 `recentShippedQty`, +// 前端据此提示「该值来自订单实算、非上游快照」,避免与源系统数字对不上时误判 +// · 订单源不可用时 shippedBySku 为空 → 兜底整体不生效,退化成旧口径(不会误报) +// 实测(2026-09-19,窗口随当天滚动):近 15 天有发货 187 个编码,其中 +// 165 个快照本就 > 0(兜底不生效),**22 个快照 = 0 → 兜底生效**, +// 含本问题的 MKHDNZ0409260601548,以及 09-18 才发货、快照尚未跟上的 +// MKHDNZ04102607000xx 一批。 +// ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」, +// 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 0)。 +// // ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** // (工厂已经在做了,不重复提示补货),改标为「已排产」。 @@ -296,20 +326,24 @@ const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_ // 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量 const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000; -// ── 待发货订单参数(见文件头 ⑩)────────────────────────────────── -// 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里。 +// ── 订单参数(见文件头 ⑩ 待发货 / ⑦-a 近15天已发货)────────────── +// 订单主表本地副本;含 `send_date`(发货日)、`shop_name`(店铺)与 `status`, +// 明细在 `skus` 文本列里。 // ⚠️ **两张表必须都读**(2026-09-19 修正):业务库的订单按渠道拆成互不重叠的两张表, -// 只读 jst_Orders 会漏掉天猫 / 淘宝 / 1688 等渠道的全部待发货需求。 -const PENDING_ORDER_TABLES = Object.freeze([ +// 只读 jst_Orders 会漏掉天猫 / 淘宝 / 1688 等渠道的全部需求。 +// ⚠️ 待发货(⑩)与近15天已发货(⑦-a)**共用这一组表、共用同一次查询**。 +const ORDER_TABLES = Object.freeze([ 'rpt.jst_Orders', // 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会 'rpt.jst_qmcust_orderlist', // 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单 ]); -// 不计入待发货需求的状态:Cancelled 已取消 / Merged 已合并进别的单 +// 不计入需求的订单状态:Cancelled 已取消 / Merged 已合并进别的单 const PENDING_ORDER_EXCLUDED_STATUS = Object.freeze(['Cancelled', 'Merged']); -// 只捞含 MKH 的订单,避免全表扫描(实测 1.17M 行约 1.6s / 4.91M 行约 0.17s) +// 只捞含 MKH 的订单,避免全表扫描(实测 1.18M 行 + 4.91M 行,合并查询约 1.25s) const PENDING_ORDER_SKU_LIKE = '%MKH%'; // 订单数据源异常时的降级缓存时长 const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000; +// 日销兜底(⑦-a):这些店铺的单上游不计入日销,兜底也必须排除,否则会误拉进预警 +const SALE_EXCLUDED_SHOP_KEYWORDS = Object.freeze(['样品', '测试']); // ── 配件不足 / 可组装数参数(见文件头 ⑪)──────────────────────── // 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id @@ -371,7 +405,7 @@ let replenishIndexCache = null; // 加工单在途量索引(见文件头 ⑧) let workOrderIndexCache = null; // 待发货订单索引(见文件头 ⑩) -let pendingOrderIndexCache = null; +let orderIndexCache = null; // 配件库存(按编码缓存,见文件头 ⑪) const componentStockCache = new Map(); @@ -866,7 +900,10 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( /** * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 * - * 口径(见文件头 ⑦ + ⑩ + ⑫): + * 口径(见文件头 ⑦ + ⑦-a + ⑩ + ⑫): + * ⓪ 日销取值:上游快照(⑦);**快照 ≤ 0 且近15天有真实发货时**改用订单实算值(⑦-a), + * 结果带 `dailySaleSource`('snapshot' / 'orders')与 `snapshotDailySale` 便于对照。 + * 下面 ① / ② 用的 `dailySale` 即此处的最终取值。 * ① 近15天日销 > 0 且 结余库存 < 3 天日销 → 预警 * 结余库存 = max(0, 现有库存 − 待发货订单),待发订单为 0 时即现有库存(见 ⑫) * 结余库存 = 0 → 缺货(订单都发不出去) @@ -876,7 +913,9 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( * 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。 * * @param {object} row - * row.avgSale 近15天日均发货量(取自 rpt.pm_stockMonitor.fifteenAvgSale) + * row.avgSale 上游近15天日均发货量(rpt.pm_stockMonitor.fifteenAvgSale) + * row.recentShippedQty 近15天已发货件数(订单表实算,⑦-a;已排除样品/测试店) + * → 快照 ≤ 0 且该值 > 0 时,日销改用 recentShippedQty ÷ 15 * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) * row.monitorStock 监控快照库存(fallback) * row.safetyDays 源系统安全天数(仅展示) @@ -886,7 +925,17 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( * row.pendingOrderQty 待发货订单量(未发货订单件数) */ function buildReplenishMetrics(row = {}) { - const dailySale = Math.max(0, toNumber(row.avgSale) ?? 0); + // 日销 = 上游快照(⑦) + 订单兜底(⑦-a): + // 上游 fifteenAvgSale 会漏算,**只在快照 ≤ 0 且近 SALE_WINDOW_DAYS 天有真实发货时** + // 才改用订单实算值(已发货件数 ÷ 天数);快照 > 0 一律以上游为准,不覆盖。 + const snapshotDailySale = Math.max(0, toNumber(row.avgSale) ?? 0); + const recentShippedQty = Math.max(0, toNumber(row.recentShippedQty) ?? 0); + const orderDailySale = recentShippedQty > 0 + ? Number((recentShippedQty / SALE_WINDOW_DAYS).toFixed(4)) + : 0; + const useOrderDailySale = snapshotDailySale <= 0 && orderDailySale > 0; + const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale; + const realtimeStock = toNumber(row.realtimeStock); const monitorStock = toNumber(row.monitorStock) ?? 0; const hasRealtime = realtimeStock !== null; @@ -908,6 +957,12 @@ function buildReplenishMetrics(row = {}) { const base = { dailySale, + // 日销来源(⑦-a):'snapshot' 上游快照 / 'orders' 订单表实算兜底 + dailySaleSource: useOrderDailySale ? 'orders' : 'snapshot', + // 上游快照原值(保留下来做对照,业务对不上源系统数字时能看出是兜底生效) + snapshotDailySale, + // 近 SALE_WINDOW_DAYS 天已发货件数(⑦-a,已排除样品/测试店) + recentShippedQty, stock, availableStock, pendingWorkOrderQty, @@ -1213,18 +1268,29 @@ const isMkhProductCode = (code) => { return Boolean(text) && /^MKH/i.test(text) && !/^MKH-/i.test(text); }; +/** 是否为「样品 / 测试专用店铺」—— 上游日销不计这些单,兜底也必须排除(见文件头 ⑦-a) */ +const isExcludedSaleShop = (shopName) => { + const text = toText(shopName); + if (!text) return false; + return SALE_EXCLUDED_SHOP_KEYWORDS.some((keyword) => text.includes(keyword)); +}; + /** - * 待发货订单索引 —— 进程内缓存 - * { bySku: Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }>, degraded }。 - * 口径见文件头 ⑩:未发货 = send_date 为空且状态非取消/合并,商品取自主表 skus 文本列。 + * 订单索引 —— 进程内缓存,**一次扫表同时得到两份数据**(合并的理由见文件头 ⑦-a): + * pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩) + * shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店) + * 口径: + * · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩) + * · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a) + * · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计 * ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。 */ -async function loadPendingOrderIndex() { +async function loadOrderIndex() { const now = Date.now(); - if (pendingOrderIndexCache?.value && pendingOrderIndexCache.expiresAt > now) { - return pendingOrderIndexCache.value; + if (orderIndexCache?.value && orderIndexCache.expiresAt > now) { + return orderIndexCache.value; } - if (pendingOrderIndexCache?.pending) return pendingOrderIndexCache.pending; + if (orderIndexCache?.pending) return orderIndexCache.pending; const statusParams = {}; const statusPlaceholders = PENDING_ORDER_EXCLUDED_STATUS.map((status, index) => { @@ -1232,25 +1298,37 @@ async function loadPendingOrderIndex() { return `@pendingOrderStatus${index}`; }).join(', '); - // 两张订单表**并行**读取后合并(见文件头 ⑩:渠道互不重叠,只读一张会漏需求) - const pending = Promise.allSettled(PENDING_ORDER_TABLES.map((table) => cloudDatabase.query( + // 两张订单表**并行**读取后合并(见 ⑩:渠道互不重叠,只读一张会漏需求) + // ⚠️ 一条查询同时覆盖「待发货」(send_date IS NULL)与「近 N 天已发货」(send_date 在窗口内): + // 两者共用同一次表扫描,拆成两条查询会在现有开销之上再加一次全表扫描(实测约 +1.25s)。 + // 窗口取含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径。 + const pending = Promise.allSettled(ORDER_TABLES.map((table) => cloudDatabase.query( `SELECT o.o_id AS oId, o.order_date AS orderDate, + o.send_date AS sendDate, + o.shop_name AS shopName, o.skus AS skus FROM ${table} o WITH (NOLOCK) WHERE o.skus LIKE @pendingOrderSkuLike - AND o.send_date IS NULL - AND o.status NOT IN (${statusPlaceholders})`, + AND o.status NOT IN (${statusPlaceholders}) + AND ( + o.send_date IS NULL + OR ( + o.send_date >= DATEADD(DAY, -${SALE_WINDOW_DAYS - 1}, CAST(GETDATE() AS date)) + AND o.send_date < DATEADD(DAY, 1, CAST(GETDATE() AS date)) + ) + )`, { pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams }, { requestTimeout: 180000 } ))).then((results) => { - const bySku = new Map(); + const pendingBySku = new Map(); + const shippedBySku = new Map(); const seenOrderIds = new Set(); const failed = []; results.forEach((result, index) => { if (result.status !== 'fulfilled') { - failed.push(`${PENDING_ORDER_TABLES[index]}: ${result.reason?.message || result.reason}`); + failed.push(`${ORDER_TABLES[index]}: ${result.reason?.message || result.reason}`); return; } result.value.forEach((row) => { @@ -1261,12 +1339,20 @@ async function loadPendingOrderIndex() { if (seenOrderIds.has(orderId)) return; seenOrderIds.add(orderId); } + // 已发货(⑦-a):样品/测试专用店铺的单上游不计入日销,这里也必须排除 + const shipped = row.sendDate !== null && row.sendDate !== undefined; + if (shipped && isExcludedSaleShop(row.shopName)) return; + const orderDate = row.orderDate instanceof Date ? row.orderDate : null; parseOrderSkus(row.skus).forEach(({ code, qty }) => { if (!isMkhProductCode(code)) return; const key = normalizeCode(code); if (!key) return; - const entry = bySku.get(key) || { + if (shipped) { + shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty); + return; + } + const entry = pendingBySku.get(key) || { pendingOrderQty: 0, pendingOrderCount: 0, pendingOrderEarliest: null, @@ -1276,7 +1362,7 @@ async function loadPendingOrderIndex() { if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { entry.pendingOrderEarliest = orderDate; } - bySku.set(key, entry); + pendingBySku.set(key, entry); }); }); }); @@ -1284,19 +1370,25 @@ async function loadPendingOrderIndex() { if (failed.length) { // 部分源失败:仍用成功源的数据(比全空更接近真实),但标 degraded 让接口透出, // 并用短 TTL 尽快重试补齐。 - console.warn('[MKH-BOM] 待发货订单部分数据源读取失败,需求口径可能不完整:', failed.join(' | ')); - return { bySku, degraded: true, error: failed.join(' | '), ttl: PENDING_ORDER_DEGRADED_TTL_MS }; + console.warn('[MKH-BOM] 订单数据源部分读取失败,需求与日销口径可能不完整:', failed.join(' | ')); + return { + pendingBySku, + shippedBySku, + degraded: true, + error: failed.join(' | '), + ttl: PENDING_ORDER_DEGRADED_TTL_MS, + }; } - return { bySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; + return { pendingBySku, shippedBySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; }); - pendingOrderIndexCache = { pending, value: null, expiresAt: 0 }; + orderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - pendingOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; return value; } catch (error) { - pendingOrderIndexCache = null; + orderIndexCache = null; throw error; } } @@ -1334,11 +1426,12 @@ async function loadReplenishIndex() { ), // 在途加工单(见文件头 ⑧) loadPendingWorkOrderIndex(), - // 待发货订单(见文件头 ⑩) - loadPendingOrderIndex(), - ]).then(([monitorRows, stockRows, workOrderIndex, pendingOrderIndex]) => { + // 订单索引:待发货(⑩)+ 近15天已发货(⑦-a),共用同一次查询 + loadOrderIndex(), + ]).then(([monitorRows, stockRows, workOrderIndex, orderIndex]) => { const workOrderBySku = workOrderIndex.bySku; - const pendingOrderBySku = pendingOrderIndex.bySku; + const pendingOrderBySku = orderIndex.pendingBySku; + const shippedBySku = orderIndex.shippedBySku; const latestStock = new Map(); stockRows.forEach((row) => { const key = normalizeCode(row.skuId); @@ -1356,6 +1449,8 @@ async function loadReplenishIndex() { bySku.set(key, buildReplenishMetrics({ // 日销取数见文件头 ⑦:十五天口径(业务要 30 天但全库无此数据) avgSale: row.fifteenAvgSale, + // 日销兜底见文件头 ⑦-a:快照漏算时用订单表实算的近15天发货量 + recentShippedQty: shippedBySku.get(key) || 0, // 实时库存缺失(而非为 0)时才回退监控快照 realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, monitorStock: row.monitorStock, @@ -1371,8 +1466,9 @@ async function loadReplenishIndex() { bySku, workOrderBySku, pendingOrderBySku, + shippedBySku, workOrderDegraded: workOrderIndex.degraded === true, - pendingOrderDegraded: pendingOrderIndex.degraded === true, + pendingOrderDegraded: orderIndex.degraded === true, monitorDate, }; }); @@ -1508,12 +1604,14 @@ async function buildMergedReplenishRows() { const key = normalizeCode(product.skuId); const metrics = replenishIndex.bySku.get(key); if (metrics) return { ...product, ...metrics }; - // 监控快照里没有该商品时,仍要带上在途加工单(⑧)与待发货订单(⑩)信息 + // 监控快照里没有该商品时,仍要带上在途加工单(⑧)、待发货订单(⑩)与 + // 近15天已发货(⑦-a)信息 —— 后者正是「快照里根本没这个编码」时唯一的日销来源 return { ...product, ...buildReplenishMetrics({ ...(replenishIndex.workOrderBySku?.get(key) || null), ...(replenishIndex.pendingOrderBySku?.get(key) || null), + recentShippedQty: replenishIndex.shippedBySku?.get(key) || 0, }), }; }); @@ -2035,7 +2133,7 @@ function clearMkhBomCache() { mkhProductCache = null; replenishIndexCache = null; workOrderIndexCache = null; - pendingOrderIndexCache = null; + orderIndexCache = null; const cleared = [ ['officialBomDetailCache', officialBomDetailCache.size], @@ -2073,11 +2171,13 @@ module.exports = { COMPONENT_LOCATION_CACHE_TTL_MS, buildReplenishMetrics, loadPendingWorkOrderIndex, - loadPendingOrderIndex, + loadOrderIndex, parseOrderSkus, isMkhProductCode, + isExcludedSaleShop, PENDING_ORDER_EXCLUDED_STATUS, - PENDING_ORDER_TABLES, + ORDER_TABLES, + SALE_EXCLUDED_SHOP_KEYWORDS, // ⑪ 配件不足 / 可组装数 buildComponentOccupation, buildComponentReadiness, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index c409030..3034c53 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -389,15 +389,22 @@ test('pending order: only MKH finished products count, combination codes are exc assert.equal(_private.isMkhProductCode('GBGJ-ZWJ024903AN1407V01'), false); assert.equal(_private.isMkhProductCode(''), false); assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']); - // 订单按渠道拆成互不重叠的两张表:只读 jst_Orders 会漏掉天猫/淘宝/1688 的待发货需求, - // 这两项是口径回归保护(2026-09-19,`MKHDNZ0409260700059` 漏报的根因) + // 订单按渠道拆成互不重叠的两张表:只读 jst_Orders 会漏掉天猫/淘宝/1688 的需求, + // 这两项是口径回归保护(2026-09-19,`MKHDNZ0409260700059` 漏报的根因)。 + // ⚠️ 待发货(⑩)与近15天已发货(⑦-a)共用这一组表。 assert.deepEqual( - [..._private.PENDING_ORDER_TABLES], + [..._private.ORDER_TABLES], ['rpt.jst_Orders', 'rpt.jst_qmcust_orderlist'] ); + // 日销兜底(⑦-a)排除样品/测试专用店铺:上游同样不计这些单,不排除会误拉进预警 + assert.deepEqual([..._private.SALE_EXCLUDED_SHOP_KEYWORDS], ['样品', '测试']); + assert.equal(_private.isExcludedSaleShop('样品专用店铺'), true); + assert.equal(_private.isExcludedSaleShop('某某测试专用店铺'), true); + assert.equal(_private.isExcludedSaleShop('天猫-HX旗舰店101'), false); + assert.equal(_private.isExcludedSaleShop(null), false); }); -test('pending order: reads both channel tables, merges and dedupes by o_id', async () => { +test('order index: reads both channel tables, merges and dedupes by o_id', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); const originalQuery = cloudDatabase.query; @@ -407,69 +414,162 @@ test('pending order: reads both channel tables, merges and dedupes by o_id', asy if (sql.includes('jst_qmcust_orderlist')) { queried.push('qmcust'); return [ - { oId: 9001, orderDate: new Date('2026-09-18T00:00:00Z'), skus: '1.MKHDNZ0409260700059' }, - { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260601228' }, + { oId: 9001, orderDate: new Date('2026-09-18T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059' }, + { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260601228' }, + // 已发货行(⑦-a):与待发货**同一次查询**返回,靠 send_date 是否有值区分 + { + oId: 9005, + sendDate: new Date('2026-09-09T00:00:00Z'), + shopName: '天猫-HX旗舰店101', + skus: '1.MKHDNZ0409260601548', + }, + // 样品专用店铺的已发货单必须排除(上游同样不计这些单) + { + oId: 9006, + sendDate: new Date('2026-09-10T00:00:00Z'), + shopName: '样品专用店铺', + skus: '2.MKHDNZ0409260609999', + }, ]; } queried.push('orders'); return [ // 与 qmcust 同 o_id → 必须只算一次(防御上游把同一单补录进另一张表) - { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260601228' }, - { oId: 9003, orderDate: new Date('2026-09-17T00:00:00Z'), skus: '1.MKHDNZ0409260700059*2' }, + { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260601228' }, + { oId: 9003, orderDate: new Date('2026-09-17T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059*2' }, // 组合装(MKH- 打头)不计入成品需求 { oId: 9004, orderDate: new Date('2026-09-16T00:00:00Z'), + sendDate: null, skus: '1.MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1', }, + // 已发货:真实店铺,同编码跨表累加 + { + oId: 9007, + sendDate: new Date('2026-09-11T00:00:00Z'), + shopName: '京东-HX旗舰店102', + skus: '1.MKHDNZ0409260601548*2', + }, + // 已发货:测试专用店铺 → 排除 + { + oId: 9008, + sendDate: new Date('2026-09-12T00:00:00Z'), + shopName: '某某测试专用店铺', + skus: '1.MKHDNZ0409260608888', + }, ]; }; try { svc.clearMkhBomCache(); - const index = await _private.loadPendingOrderIndex(); + const index = await _private.loadOrderIndex(); assert.deepEqual(queried.sort(), ['orders', 'qmcust'], '两张订单表都必须读到'); assert.equal(index.degraded, false); - const a = index.bySku.get('MKHDNZ0409260700059'); + const a = index.pendingBySku.get('MKHDNZ0409260700059'); assert.equal(a.pendingOrderQty, 3, '两表合并:1 + 2'); assert.equal(a.pendingOrderCount, 2); - const b = index.bySku.get('MKHDNZ0409260601228'); + const b = index.pendingBySku.get('MKHDNZ0409260601228'); assert.equal(b.pendingOrderQty, 1, '同 o_id 跨表重复只计一次'); assert.equal(b.pendingOrderCount, 1); - assert.equal(index.bySku.size, 2, '组合装不进入索引'); + assert.equal(index.pendingBySku.size, 2, '组合装不进入索引'); + + // 已发货(⑦-a):同一次查询拿到,真实店铺跨表累加、样品/测试店排除 + assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 3, '已发货件数 1 + 2 累加'); + assert.equal(index.shippedBySku.has('MKHDNZ0409260609999'), false, '样品专用店铺要排除'); + assert.equal(index.shippedBySku.has('MKHDNZ0409260608888'), false, '测试专用店铺要排除'); + assert.equal(index.shippedBySku.size, 1, '被排除的店铺不进入索引'); } finally { cloudDatabase.query = originalQuery; svc.clearMkhBomCache(); } }); -test('pending order: one failing channel degrades but still returns the other table', async () => { +test('order index: one failing channel degrades but still returns the other table', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); const originalQuery = cloudDatabase.query; cloudDatabase.query = async (sql) => { if (sql.includes('jst_qmcust_orderlist')) throw new Error('qmcust unavailable'); - return [{ oId: 9100, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260700059' }]; + return [ + { oId: 9100, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059' }, + { + oId: 9101, + sendDate: new Date('2026-09-15T00:00:00Z'), + shopName: '京东-HX旗舰店102', + skus: '1.MKHDNZ0409260601548', + }, + ]; }; try { svc.clearMkhBomCache(); - const index = await _private.loadPendingOrderIndex(); + const index = await _private.loadOrderIndex(); assert.equal(index.degraded, true, '部分源失败必须透出 degraded'); assert.match(index.error, /qmcust unavailable/); - assert.equal(index.bySku.get('MKHDNZ0409260700059').pendingOrderQty, 1, '成功源的数据仍要保留'); + assert.equal(index.pendingBySku.get('MKHDNZ0409260700059').pendingOrderQty, 1, '成功源的数据仍要保留'); + assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 1, '成功源的已发货数据同样保留'); } finally { cloudDatabase.query = originalQuery; svc.clearMkhBomCache(); } }); +test('replenish: daily sale falls back to order-derived value when snapshot is 0', () => { + // 上游快照漏算(⑦-a):`MKHDNZ0409260601548` 近15天实发 2 件、快照日销 0、库存 0, + // 旧口径走「无销量」分支彻底隐身,连缺货都不报。 + const fallback = metrics({ avgSale: 0, recentShippedQty: 2, realtimeStock: 0, pendingOrderQty: 0 }); + assert.equal(fallback.dailySaleSource, 'orders', '快照为 0 且有发货 → 用订单实算兜底'); + assert.equal(fallback.snapshotDailySale, 0); + assert.equal(fallback.recentShippedQty, 2); + assert.equal(fallback.dailySale, Number((2 / 15).toFixed(4)), '日销 = 2 件 ÷ 15 天'); + assert.equal(fallback.warningLevel, '缺货', '兜底后库存 0 必须报缺货'); + assert.equal(fallback.hasWarning, true); + // 建议补货量 = ceil(3 × 0.1333 + 0 − 0) = 1 + assert.equal(fallback.replenishQty, 1); + + // 快照 > 0 → 一律以上游为准,不被订单实算值覆盖 + const snapshotWins = metrics({ avgSale: 0.5, recentShippedQty: 30, realtimeStock: 0 }); + assert.equal(snapshotWins.dailySaleSource, 'snapshot'); + assert.equal(snapshotWins.dailySale, 0.5, '不覆盖上游快照'); + + // 快照 0 且无发货 → 仍是「无销量」,不参与预警(兜底不能凭空造需求) + const noFallback = metrics({ avgSale: 0, recentShippedQty: 0, realtimeStock: 0 }); + assert.equal(noFallback.dailySale, 0); + assert.equal(noFallback.dailySaleSource, 'snapshot'); + assert.equal(noFallback.warningLevel, '无销量'); + assert.equal(noFallback.hasWarning, false); + + // 兜底日销很小但库存充足 → 正常,不制造噪声 + const enough = metrics({ avgSale: 0, recentShippedQty: 1, realtimeStock: 50, pendingOrderQty: 0 }); + assert.equal(enough.dailySaleSource, 'orders'); + assert.equal(enough.warningLevel, '正常'); + assert.equal(enough.hasWarning, false); + + // 兜底后又快又慢:兜底日销 > 0 时走日销驱动分支(①),不再走订单驱动(②)—— + // 因为此时已经有真实需求历史,用「结余库存 ÷ 日销」衡量比「库存 ÷ 代发件数」更准 + const both = metrics({ + avgSale: 0, recentShippedQty: 2, realtimeStock: 0, pendingOrderQty: 5, pendingOrderCount: 1, + }); + assert.equal(both.dailySaleSource, 'orders'); + assert.equal(both.orderDriven, false, '有兜底日销后不再是订单驱动'); + assert.equal(both.warningLevel, '缺货'); + // 建议补货量 = ceil(3 × 0.1333 + 5 − 0) = ceil(5.3999) = 6(先补订单缺口再补 3 天缓冲) + assert.equal(both.replenishQty, 6); + + // 订单源整体不可用 → recentShippedQty 缺省 0 → 退化成旧口径(不会误报) + const degraded = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); + assert.equal(degraded.recentShippedQty, 0); + assert.equal(degraded.dailySaleSource, 'snapshot'); + assert.equal(degraded.warningLevel, '无销量'); +}); + test('replenish: no sales but unshipped orders warn by available days (stock / order qty)', () => { // 可用天数 = 现有库存 ÷ 代发订单件数;≤ 3 天即预警,= 0(无库存)为缺货。 // 完全没货:可用天数 0 → 缺货,补货量 = 代发件数 − 现有库存 diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index f5d9f8d..b74ac62 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -539,7 +539,23 @@ const MkhBomData = () => { dataIndex: 'dailySale', width: 106, align: 'right', - render: (value) => (Number(value) > 0 ? number(value, 2) : 0), + render: (value, row) => { + const text = Number(value) > 0 ? number(value, 2) : 0; + // 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。 + // 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。 + if (row.dailySaleSource !== 'orders') return text; + return ( + + {text} + + ); + }, }, { title: '现有库存', diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css index 55300ae..afb18b0 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -129,6 +129,13 @@ font-weight: 600; } +/* 日销兜底(上游快照漏算时按订单表实算,见服务文件头 ⑦-a): + 虚线表示该值不是上游快照,悬停有说明,避免与源系统数字对照时误判为数据错误 */ +.mkh-sale-fallback { + border-bottom: 1px dashed currentColor; + cursor: help; +} + /* 配件不足(可组装数 = 0,补不出来,见文件头 ⑪):与「缺货」红标签并排,用紫灰区分 */ .mkh-tag--shortage { color: #722ed1;