Compare commits
2 Commits
d650fd02f2
...
4946018251
| Author | SHA1 | Date | |
|---|---|---|---|
| 4946018251 | |||
| 3e64702f12 |
@ -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,
|
||||
|
||||
@ -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 → 缺货,补货量 = 代发件数 − 现有库存
|
||||
|
||||
@ -539,7 +539,23 @@ const MkhBomData = () => {
|
||||
dataIndex: 'dailySale',
|
||||
width: 106,
|
||||
align: 'right',
|
||||
render: (value) => (Number(value) > 0 ? number(value, 2) : <span className="mkh-muted">0</span>),
|
||||
render: (value, row) => {
|
||||
const text = Number(value) > 0 ? number(value, 2) : <span className="mkh-muted">0</span>;
|
||||
// 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。
|
||||
// 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。
|
||||
if (row.dailySaleSource !== 'orders') return text;
|
||||
return (
|
||||
<Tooltip
|
||||
title={[
|
||||
`上游近15天日销快照为 ${number(row.snapshotDailySale, 2)}(漏算)`,
|
||||
`此处按订单实算:近15天已发货 ${number(row.recentShippedQty)} 件 ÷ 15 天`,
|
||||
'已排除样品/测试专用店铺;上游修好后会自动以上游数字为准',
|
||||
].join(';')}
|
||||
>
|
||||
<span className="mkh-sale-fallback">{text}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '现有库存',
|
||||
|
||||
@ -129,6 +129,13 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 日销兜底(上游快照漏算时按订单表实算,见服务文件头 ⑦-a):
|
||||
虚线表示该值不是上游快照,悬停有说明,避免与源系统数字对照时误判为数据错误 */
|
||||
.mkh-sale-fallback {
|
||||
border-bottom: 1px dashed currentColor;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* 配件不足(可组装数 = 0,补不出来,见文件头 ⑪):与「缺货」红标签并排,用紫灰区分 */
|
||||
.mkh-tag--shortage {
|
||||
color: #722ed1;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user