Compare commits

...

2 Commits

Author SHA1 Message Date
d650fd02f2 Merge pull request 'fix: MKH-BOM 待发货订单合并两张渠道表,修复天猫/淘宝订单漏报' (#17) 2026-09-19 13:06:11 +08:00
f156b90dbb fix: MKH-BOM 待发货订单合并两张渠道表,修复天猫/淘宝订单漏报
问题
  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 文件通过。
2026-09-19 12:25:43 +08:00
2 changed files with 157 additions and 38 deletions

View File

@ -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,

View File

@ -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)', () => {