From f156b90dbba0ed396fbd5320a7b84b93c2ca4ea3 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 19 Sep 2026 12:25:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20MKH-BOM=20=E5=BE=85=E5=8F=91=E8=B4=A7?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=90=88=E5=B9=B6=E4=B8=A4=E5=BC=A0=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E8=A1=A8=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=A4=A9=E7=8C=AB?= =?UTF-8?q?/=E6=B7=98=E5=AE=9D=E8=AE=A2=E5=8D=95=E6=BC=8F=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 MKHDNZ0409260700059、MKHDNZ0409260601228 等成品明明有待发货订单, 补货预警却显示 0 单、判为「不预警」,业务反馈到系统。 根因 业务库的订单按渠道拆成**互不重叠的两张表**,而 loadPendingOrderIndex() 只读了 rpt.jst_Orders(京东/京喜/拼多多/抖音/得物/唯品会), 天猫 / 淘宝 / 1688 / 阿里巴巴渠道的待发货需求**全部被漏掉**。 实测(2026-09-19,近 30 天窗口) - 两表店铺名交集 0(jst_Orders 398 店 vs qmcust 78 店),so_id 交集 5 条, o_id 交集 **0 条** —— 合并无重复计件风险; - jst_Orders 里 `shop_name LIKE '%天猫%'` = **0 行** (不是命名差异:同一品牌在不同平台是不同店铺名,按品牌名反查会误判); - MKH 成品待发货:仅 jst_Orders = 36 编码 / 50 件 → 合并后 = **40 编码 / 56 件**,补回 4 个编码: MKHDNZ0409260700059 / MKHDNZ0409260601228 / MKHDNZ0409260601530 / MKHDNZ0409260700498。 修复 - PENDING_ORDER_TABLE → PENDING_ORDER_TABLES(两张表,新增 qmcust 源); - Promise.allSettled 并行读取,按 o_id 去重后合并(去重为防御性逻辑); - 部分源失败时**保留成功源的数据**并透出 degraded=true,用短 TTL 尽快重试, 不再像原先那样单源失败就整表降级为空索引(那会静默漏报全部需求); - 口径范围:**渠道全并**(2026-09-19 业务确认),含 1688 / 阿里巴巴 / 分销导单专用店铺。 测试 - 新增 2 个回归测试:两张表都读到 + 跨表同 o_id 只计一次 + 组合装不计入; 单源失败时 degraded=true 且成功源数据保留。 - 后端 337 项:336 通过,1 项失败为 Windows 平台固有差异 (resilientSecretStore 断言 0o600,Windows 实际 0o666),与本改动无关。 - 语法检查 151 文件通过。 --- backend/src/services/mkhBomService.js | 116 +++++++++++++++++--------- backend/test/mkhBomService.test.js | 79 ++++++++++++++++++ 2 files changed, 157 insertions(+), 38 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 6cb027b..0d97c89 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -134,14 +134,32 @@ // 规则:**近 15 天日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。 // 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」 // 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。 -// · 数据源:业务库 rpt.jst_Orders(订单**主表**,实时更新,实测最新到今天) -// ⚠️ 不要用 rpt.jst_OrderItem(订单明细表)—— 实测**已停更在 2026-07-16** -// (主表最新 o_id≈9409xxx,明细最大 o_id 仅 8439xxx;近 5 天订单有明细的为 0 条)。 -// 主表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]` -// (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。 -// 实测逐单对账(2026-07-01~07-16,13.5 万单):全表普通订单仅 76.9% 能对上 -// (多为 `skus='0.'` 空汇总、组合装展开),但**MKH 成品订单 100% 可用**, -// 故本口径只解析 MKH 成品码(`/^MKH/i` 且非 `MKH-` 打头的组合装)。 +// · 数据源:业务库**两张订单主表,必须都读**(2026-09-19 修正,见下 ⑩-a) +// · rpt.jst_Orders —— 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会 +// · rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单 +// 两张表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]` +// (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。 +// ⚠️ 不要用明细表(jst_OrderItem / jst_qmcust_orderlist_item):主表直读免关联, +// skus 列一次性带出全部商品;实测 MKH 成品订单 100% 可解析,故只解析 +// MKH 成品码(`/^MKH/i` 且非 `MKH-` 打头的组合装)。 +// +// ── ⑩-a 为什么是两张表(2026-09-19 修正,重要)──────────────────── +// 现象:`MKHDNZ0409260700059` / `MKHDNZ0409260601228` 明明有待发货订单, +// 系统却显示 0 单、不给预警,业务反馈到系统。 +// 根因:**业务库的订单按渠道拆成了互不重叠的两张表**,本服务原先只读了 +// `rpt.jst_Orders` —— 天猫 / 淘宝 / 1688 / 阿里巴巴渠道的待发货需求**全被漏掉**。 +// 实测(2026-09-19,近 30 天窗口): +// · 两表店铺名**交集 = 0**(jst_Orders 398 店 vs qmcust 78 店),so_id 交集仅 5 条, +// o_id 交集 **0 条**(两表的 o_id 完全独立,合并安全); +// · 同一品牌在不同平台是**不同店铺名**(例「欧意朗」在 jst_Orders 是拼多多/得物/唯品会, +// 在天猫/淘宝则是另一张表里的店)→ **按品牌名反查会误判「表里有」,必须按完整店铺名核对**; +// · `jst_Orders` 里 `shop_name LIKE '%天猫%'` = **0 行**(不是命名差异,是真的没有)。 +// 影响面(MKH 成品待发货):仅 jst_Orders = 36 编码 / 50 件; +// 合并后 = **40 编码 / 56 件**,补进来的新编码 4 个: +// MKHDNZ0409260700059 / MKHDNZ0409260601228 / MKHDNZ0409260601530 / MKHDNZ0409260700498; +// 实时单量:jst_Orders 未发货含 MKH 52 单 + qmcust 10 单。 +// 性能:4.91M 行的 qmcust 上该查询约 0.17s(LIKE '%MKH%' 全扫),可忽略。 +// 口径范围:**渠道全并**(2026-09-19 业务确认),含 1688 / 阿里巴巴 / 分销导单专用店铺。 // · 未发货判定:`send_date IS NULL AND status NOT IN ('Cancelled','Merged')` // (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单; // 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求) @@ -279,11 +297,16 @@ const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_ const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000; // ── 待发货订单参数(见文件头 ⑩)────────────────────────────────── -// 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里 -const PENDING_ORDER_TABLE = 'rpt.jst_Orders'; +// 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里。 +// ⚠️ **两张表必须都读**(2026-09-19 修正):业务库的订单按渠道拆成互不重叠的两张表, +// 只读 jst_Orders 会漏掉天猫 / 淘宝 / 1688 等渠道的全部待发货需求。 +const PENDING_ORDER_TABLES = Object.freeze([ + 'rpt.jst_Orders', // 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会 + 'rpt.jst_qmcust_orderlist', // 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单 +]); // 不计入待发货需求的状态:Cancelled 已取消 / Merged 已合并进别的单 const PENDING_ORDER_EXCLUDED_STATUS = Object.freeze(['Cancelled', 'Merged']); -// 只捞含 MKH 的订单,避免全表扫描(实测 1.17M 行约 1.6s) +// 只捞含 MKH 的订单,避免全表扫描(实测 1.17M 行约 1.6s / 4.91M 行约 0.17s) const PENDING_ORDER_SKU_LIKE = '%MKH%'; // 订单数据源异常时的降级缓存时长 const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000; @@ -1209,46 +1232,62 @@ async function loadPendingOrderIndex() { return `@pendingOrderStatus${index}`; }).join(', '); - const pending = cloudDatabase.query( + // 两张订单表**并行**读取后合并(见文件头 ⑩:渠道互不重叠,只读一张会漏需求) + const pending = Promise.allSettled(PENDING_ORDER_TABLES.map((table) => cloudDatabase.query( `SELECT o.o_id AS oId, o.order_date AS orderDate, o.skus AS skus - FROM ${PENDING_ORDER_TABLE} o WITH (NOLOCK) + FROM ${table} o WITH (NOLOCK) WHERE o.skus LIKE @pendingOrderSkuLike AND o.send_date IS NULL AND o.status NOT IN (${statusPlaceholders})`, { pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams }, { requestTimeout: 180000 } - ).then((rows) => { + ))).then((results) => { const bySku = new Map(); - rows.forEach((row) => { - 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) || { - pendingOrderQty: 0, - pendingOrderCount: 0, - pendingOrderEarliest: null, - }; - entry.pendingOrderQty += qty; - entry.pendingOrderCount += 1; - if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { - entry.pendingOrderEarliest = orderDate; + 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}`); + return; + } + result.value.forEach((row) => { + // 两表 o_id 实测零交集(2026-09-19 校验),去重只是防御: + // 万一上游把同一张单补录进另一张表,也不会重复计件。 + const orderId = row.oId === null || row.oId === undefined ? null : String(row.oId); + if (orderId !== null) { + if (seenOrderIds.has(orderId)) return; + seenOrderIds.add(orderId); } - bySku.set(key, entry); + 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) || { + pendingOrderQty: 0, + pendingOrderCount: 0, + pendingOrderEarliest: null, + }; + entry.pendingOrderQty += qty; + entry.pendingOrderCount += 1; + if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { + entry.pendingOrderEarliest = orderDate; + } + bySku.set(key, entry); + }); }); }); + + if (failed.length) { + // 部分源失败:仍用成功源的数据(比全空更接近真实),但标 degraded 让接口透出, + // 并用短 TTL 尽快重试补齐。 + console.warn('[MKH-BOM] 待发货订单部分数据源读取失败,需求口径可能不完整:', failed.join(' | ')); + return { bySku, degraded: true, error: failed.join(' | '), ttl: PENDING_ORDER_DEGRADED_TTL_MS }; + } return { bySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; - }).catch((error) => { - console.warn('[MKH-BOM] 待发货订单读取失败,已按「无待发货订单」降级:', error.message); - return { - bySku: new Map(), - degraded: true, - error: error.message, - ttl: PENDING_ORDER_DEGRADED_TTL_MS, - }; }); pendingOrderIndexCache = { pending, value: null, expiresAt: 0 }; @@ -2038,6 +2077,7 @@ module.exports = { parseOrderSkus, isMkhProductCode, PENDING_ORDER_EXCLUDED_STATUS, + PENDING_ORDER_TABLES, // ⑪ 配件不足 / 可组装数 buildComponentOccupation, buildComponentReadiness, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 876d9fc..c409030 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -389,6 +389,85 @@ 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` 漏报的根因) + assert.deepEqual( + [..._private.PENDING_ORDER_TABLES], + ['rpt.jst_Orders', 'rpt.jst_qmcust_orderlist'] + ); +}); + +test('pending order: 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; + const queried = []; + + cloudDatabase.query = async (sql) => { + 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' }, + ]; + } + 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' }, + // 组合装(MKH- 打头)不计入成品需求 + { + oId: 9004, + orderDate: new Date('2026-09-16T00:00:00Z'), + skus: '1.MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1', + }, + ]; + }; + + try { + svc.clearMkhBomCache(); + const index = await _private.loadPendingOrderIndex(); + + assert.deepEqual(queried.sort(), ['orders', 'qmcust'], '两张订单表都必须读到'); + assert.equal(index.degraded, false); + + const a = index.bySku.get('MKHDNZ0409260700059'); + assert.equal(a.pendingOrderQty, 3, '两表合并:1 + 2'); + assert.equal(a.pendingOrderCount, 2); + + const b = index.bySku.get('MKHDNZ0409260601228'); + assert.equal(b.pendingOrderQty, 1, '同 o_id 跨表重复只计一次'); + assert.equal(b.pendingOrderCount, 1); + + assert.equal(index.bySku.size, 2, '组合装不进入索引'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('pending order: 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' }]; + }; + + try { + svc.clearMkhBomCache(); + const index = await _private.loadPendingOrderIndex(); + + assert.equal(index.degraded, true, '部分源失败必须透出 degraded'); + assert.match(index.error, /qmcust unavailable/); + assert.equal(index.bySku.get('MKHDNZ0409260700059').pendingOrderQty, 1, '成功源的数据仍要保留'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } }); test('replenish: no sales but unshipped orders warn by available days (stock / order qty)', () => {