Compare commits
2 Commits
cc66e5100f
...
d650fd02f2
| Author | SHA1 | Date | |
|---|---|---|---|
| d650fd02f2 | |||
| f156b90dbb |
@ -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,
|
||||
|
||||
@ -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)', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user