Merge pull request 'fix: MKH-BOM 待发货订单合并两张渠道表,修复天猫/淘宝订单漏报' (#17)
This commit is contained in:
commit
d650fd02f2
@ -134,14 +134,32 @@
|
|||||||
// 规则:**近 15 天日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。
|
// 规则:**近 15 天日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。
|
||||||
// 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」
|
// 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」
|
||||||
// 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。
|
// 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。
|
||||||
// · 数据源:业务库 rpt.jst_Orders(订单**主表**,实时更新,实测最新到今天)
|
// · 数据源:业务库**两张订单主表,必须都读**(2026-09-19 修正,见下 ⑩-a)
|
||||||
// ⚠️ 不要用 rpt.jst_OrderItem(订单明细表)—— 实测**已停更在 2026-07-16**
|
// · rpt.jst_Orders —— 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会
|
||||||
// (主表最新 o_id≈9409xxx,明细最大 o_id 仅 8439xxx;近 5 天订单有明细的为 0 条)。
|
// · rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单
|
||||||
// 主表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]`
|
// 两张表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]`
|
||||||
// (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。
|
// (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。
|
||||||
// 实测逐单对账(2026-07-01~07-16,13.5 万单):全表普通订单仅 76.9% 能对上
|
// ⚠️ 不要用明细表(jst_OrderItem / jst_qmcust_orderlist_item):主表直读免关联,
|
||||||
// (多为 `skus='0.'` 空汇总、组合装展开),但**MKH 成品订单 100% 可用**,
|
// skus 列一次性带出全部商品;实测 MKH 成品订单 100% 可解析,故只解析
|
||||||
// 故本口径只解析 MKH 成品码(`/^MKH/i` 且非 `MKH-` 打头的组合装)。
|
// 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')`
|
// · 未发货判定:`send_date IS NULL AND status NOT IN ('Cancelled','Merged')`
|
||||||
// (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单;
|
// (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单;
|
||||||
// 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求)
|
// 已发货的 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;
|
const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000;
|
||||||
|
|
||||||
// ── 待发货订单参数(见文件头 ⑩)──────────────────────────────────
|
// ── 待发货订单参数(见文件头 ⑩)──────────────────────────────────
|
||||||
// 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里
|
// 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里。
|
||||||
const PENDING_ORDER_TABLE = 'rpt.jst_Orders';
|
// ⚠️ **两张表必须都读**(2026-09-19 修正):业务库的订单按渠道拆成互不重叠的两张表,
|
||||||
|
// 只读 jst_Orders 会漏掉天猫 / 淘宝 / 1688 等渠道的全部待发货需求。
|
||||||
|
const PENDING_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']);
|
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_SKU_LIKE = '%MKH%';
|
||||||
// 订单数据源异常时的降级缓存时长
|
// 订单数据源异常时的降级缓存时长
|
||||||
const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000;
|
const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000;
|
||||||
@ -1209,46 +1232,62 @@ async function loadPendingOrderIndex() {
|
|||||||
return `@pendingOrderStatus${index}`;
|
return `@pendingOrderStatus${index}`;
|
||||||
}).join(', ');
|
}).join(', ');
|
||||||
|
|
||||||
const pending = cloudDatabase.query(
|
// 两张订单表**并行**读取后合并(见文件头 ⑩:渠道互不重叠,只读一张会漏需求)
|
||||||
|
const pending = Promise.allSettled(PENDING_ORDER_TABLES.map((table) => cloudDatabase.query(
|
||||||
`SELECT o.o_id AS oId,
|
`SELECT o.o_id AS oId,
|
||||||
o.order_date AS orderDate,
|
o.order_date AS orderDate,
|
||||||
o.skus AS skus
|
o.skus AS skus
|
||||||
FROM ${PENDING_ORDER_TABLE} o WITH (NOLOCK)
|
FROM ${table} o WITH (NOLOCK)
|
||||||
WHERE o.skus LIKE @pendingOrderSkuLike
|
WHERE o.skus LIKE @pendingOrderSkuLike
|
||||||
AND o.send_date IS NULL
|
AND o.send_date IS NULL
|
||||||
AND o.status NOT IN (${statusPlaceholders})`,
|
AND o.status NOT IN (${statusPlaceholders})`,
|
||||||
{ pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams },
|
{ pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams },
|
||||||
{ requestTimeout: 180000 }
|
{ requestTimeout: 180000 }
|
||||||
).then((rows) => {
|
))).then((results) => {
|
||||||
const bySku = new Map();
|
const bySku = new Map();
|
||||||
rows.forEach((row) => {
|
const seenOrderIds = new Set();
|
||||||
const orderDate = row.orderDate instanceof Date ? row.orderDate : null;
|
const failed = [];
|
||||||
parseOrderSkus(row.skus).forEach(({ code, qty }) => {
|
|
||||||
if (!isMkhProductCode(code)) return;
|
results.forEach((result, index) => {
|
||||||
const key = normalizeCode(code);
|
if (result.status !== 'fulfilled') {
|
||||||
if (!key) return;
|
failed.push(`${PENDING_ORDER_TABLES[index]}: ${result.reason?.message || result.reason}`);
|
||||||
const entry = bySku.get(key) || {
|
return;
|
||||||
pendingOrderQty: 0,
|
}
|
||||||
pendingOrderCount: 0,
|
result.value.forEach((row) => {
|
||||||
pendingOrderEarliest: null,
|
// 两表 o_id 实测零交集(2026-09-19 校验),去重只是防御:
|
||||||
};
|
// 万一上游把同一张单补录进另一张表,也不会重复计件。
|
||||||
entry.pendingOrderQty += qty;
|
const orderId = row.oId === null || row.oId === undefined ? null : String(row.oId);
|
||||||
entry.pendingOrderCount += 1;
|
if (orderId !== null) {
|
||||||
if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) {
|
if (seenOrderIds.has(orderId)) return;
|
||||||
entry.pendingOrderEarliest = orderDate;
|
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 };
|
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 };
|
pendingOrderIndexCache = { pending, value: null, expiresAt: 0 };
|
||||||
@ -2038,6 +2077,7 @@ module.exports = {
|
|||||||
parseOrderSkus,
|
parseOrderSkus,
|
||||||
isMkhProductCode,
|
isMkhProductCode,
|
||||||
PENDING_ORDER_EXCLUDED_STATUS,
|
PENDING_ORDER_EXCLUDED_STATUS,
|
||||||
|
PENDING_ORDER_TABLES,
|
||||||
// ⑪ 配件不足 / 可组装数
|
// ⑪ 配件不足 / 可组装数
|
||||||
buildComponentOccupation,
|
buildComponentOccupation,
|
||||||
buildComponentReadiness,
|
buildComponentReadiness,
|
||||||
|
|||||||
@ -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('GBGJ-ZWJ024903AN1407V01'), false);
|
||||||
assert.equal(_private.isMkhProductCode(''), false);
|
assert.equal(_private.isMkhProductCode(''), false);
|
||||||
assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']);
|
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)', () => {
|
test('replenish: no sales but unshipped orders warn by available days (stock / order qty)', () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user