Merge pull request 'fix: MKH-BOM 预警与库存口径修复:日销以出库为准 + 库存增加箱表兜底' (#19)

This commit is contained in:
yanhui 2026-09-22 23:14:37 +08:00
commit bbabda5016
3 changed files with 561 additions and 34 deletions

View File

@ -118,6 +118,7 @@
// · **只在快照 ≤ 0 时兜底**:快照 > 0 一律以上游为准,不覆盖,避免两套口径打架 // · **只在快照 ≤ 0 时兜底**:快照 > 0 一律以上游为准,不覆盖,避免两套口径打架
// · **排除样品 / 测试专用店铺**(shop_name 含「样品」或「测试」),与上游口径对齐; // · **排除样品 / 测试专用店铺**(shop_name 含「样品」或「测试」),与上游口径对齐;
// 不排除的话那 16 个样品单编码会被误拉进预警 // 不排除的话那 16 个样品单编码会被误拉进预警
// · **排除异常单**(2026-09-19 补充,见下):订单表里的「已发货」不等于真实销售
// · 窗口 = 含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径 // · 窗口 = 含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径
// · 数据源与 ⑩ 的待发货订单**同一次查询、同一组表**:一次扫表同时拿到 // · 数据源与 ⑩ 的待发货订单**同一次查询、同一组表**:一次扫表同时拿到
// 「待发货」与「近15天已发货」两份数据。实测合并后总耗时 ≈ 单查待发货 // 「待发货」与「近15天已发货」两份数据。实测合并后总耗时 ≈ 单查待发货
@ -125,13 +126,92 @@
// · 兜底生效的商品带 `dailySaleSource='orders'` 与 `recentShippedQty`, // · 兜底生效的商品带 `dailySaleSource='orders'` 与 `recentShippedQty`,
// 前端据此提示「该值来自订单实算、非上游快照」,避免与源系统数字对不上时误判 // 前端据此提示「该值来自订单实算、非上游快照」,避免与源系统数字对不上时误判
// · 订单源不可用时 shippedBySku 为空 → 兜底整体不生效,退化成旧口径(不会误报) // · 订单源不可用时 shippedBySku 为空 → 兜底整体不生效,退化成旧口径(不会误报)
//
// 🔥 **必须排除「异常单」(2026-09-19 业务反馈后修正)**
// 业务报「MKHDNZ0410260700005 没发过货,为啥有日销」→ 查下来是兜底把 **0.01 元特殊单**
// 当成了销售。该单画像(拼多多-亿家达书房家具官方旗舰店):
// · 实付 **0.01 元**、商品额 908.01、**免额 908(全额免)**、备注 `SSDD`、标签含「特殊单」
// · 该店在 **09-18 10:35–10:36 一分钟内**批量生成 18 张同样的 0.01 元 MKH 单
// (实付合计 0.18 元、免额合计 16,344 元),另有 18 张占位码 `1.X00005` 的 0.01 元单
// · **`rpt.jst_salesOutboundInfo` 里这些 o_id 查不到任何出库记录**
// (同店同时段其他单有 6,467 行)→ 系统标「已发货」,货其实没出
// · 后果:把 10 个 SKU 的日销从 0 抬到 0.6~1.8(共 162 件),凭空产出缺货预警
// 规则:已发货分支排除 **`pay_amount ≤ 0.01`** 或 **`labels` 含「特殊单」** 的单。
// ⚠️ **别用 `free_amount > 0` 判**:正常单普遍有折扣免额(实测窗口内 569/1086 行),
// 按它筛会把 356 / 458 / 1338 元的正常单一起误杀(第一版就踩了这个坑)。
// ⚠️ 只作用于**已发货**分支;待发货(⑩)口径本次未动。
//
// 实测(2026-09-19,窗口随当天滚动):近 15 天有发货 187 个编码,其中 // 实测(2026-09-19,窗口随当天滚动):近 15 天有发货 187 个编码,其中
// 165 个快照本就 > 0(兜底不生效),**22 个快照 = 0 → 兜底生效**, // 165 个快照本就 > 0(兜底不生效),**22 个快照 = 0 → 兜底生效**,
// 含本问题的 MKHDNZ0409260601548,以及 09-18 才发货、快照尚未跟上的 // 含本问题的 MKHDNZ0409260601548。这 22 个里:**12 个是真实单驱动**(实付 299~1338 元,
// MKHDNZ04102607000xx 一批。 // 兜底正确),**10 个是上述 0.01 元异常单驱动**(`MKHDNZ0410260700001/0002/0004/0005/
// 0007/0008/0009/0010/0011/0012`)—— 加过滤后这 10 个日销归零,另 12 个不受影响。
// ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」, // ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」,
// 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 0)。 // 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 0)。
// //
// ── ⑦-c 出库校验:**必须真出库才算销量**(2026-09-19 业务确认口径)────
// 业务原话:「这些异常 SKU,系统标记了『已发货』,但没有实际出库的,不需要出现缺货预警」。
// 即 ⑦-a 的金额/标签判据再往根上退一步——**以出库表为准**:
// 已发货分支要计入销量,必须同时满足
// ① 不是异常单(`pay_amount ≤ 0.01` 或 `labels` 含「特殊单」,⑦-a)
// ② 在出库表里查得到该 `o_id`(`jst_salesOutboundInfo` / `jst_qmcust_salesoutbound`)
// 实测(2026-09-19,窗口 15 天、1,086 张已发货 MKH 单):
// · 出库表命中 889 张 → 197 张无出库,其中 **40 张是拆分单、157 张是 Sent**
// · 157 张里 **131 张是 ≤0.01 元或「特殊单」**(即 ⑦-a 已排除的那批刷单),
// 剩下唯一 1 张正常金额的是 09-05 的 388 元单(标签「发货后取消 / 发货后售后 /
// 风控订单」)—— ② 这条判据正是为它这类「标了发货其实没出库」的单兜底
// · 与只按金额/标签判据相比,**当前窗口的兜底生效 SKU 集合完全一致(12 个)**,
// 属"同解但更根本",代价是每次冷缓存多约 0.5s 的出库查询
// 🔥 **两个必须豁免的情形**(不豁免就会大面积误杀真实单,实测数据如下):
// ① **拆分单**(`status='Split'`):父单被拆成子单后出库挂在**子单**上,
// 父单必然查不到出库 —— 窗口内 40 张 Split 单 **100% 无出库记录**,
// 若不豁免会误杀「京东-HX旗舰店104 09-19 实付 1194.14 元」这类真实单
// ② **今天发货的单**:当天出库数据尚未同步完(实测 09-19 全天出库表只有 1 行,
// 而 09-18 有 14,690 行)→ 09-19 的 7 张 Sent 无出库单里特殊单为 0,
// 说明无出库是**同步滞后**而非虚假发货。
// 注意豁免只到「昨天」为止:09-18 的 18 张无出库 Sent 单 **100% 是特殊单**,
// 出库表 09-18 数据完整,所以 09-18 不能被豁免(否则会放过刷单)
// ⚠️ 只判**有没有出库行**:`jst_salesOutboundInfo.qty` 全表 1,119,606 行恒为 0
// (`SUM(qty)` 永远得 0,近 15 天 180,433 行同样),别拿数量做判据。
// ⚠️ 出库数据取不到时**整体跳过出库校验**(退化成只按金额/标签判据)并标 degraded:
// 漏杀只是多报一条预警,错杀会让真实缺货彻底隐身。
//
// ── ⑦-d 库存源时效兜底:当日入库读箱表(2026-09-19 新增)──────────
// 业务原话:「MKHDNZ0409260700491 这个今日已经入库,现有库存是有……
// 点击同步最新数据这个按钮,数据没有更新,还是显示库存为 0」。
// 🔥 根因:**页面唯一的库存源 `jst_inventoryInfo` 是「有变化才写 updateTime」的同步表,
// 当天新入库的成品未必有对应行**(品牌仓尤其稀疏,一天只更新几十行)。
// 2026-09-19 实测:
// · 该 SKU 在 inventoryInfo 全表**只有主仓 14138566 的一行 qty=0**,
// 品牌仓**连行都没有**;而箱表品牌仓 bin='P-1' / qty=4 / createTime 当日 15:19
// · 横向对照:inv 当天最后更新 06:41(品牌仓最后 04:42,当天仅 3 行),
// 箱表当天最后更新 17:01(小时级)→ **当日入库的货只有箱表看得见**
// · ⚠️ 且 inv 当天 06:41 后**再没更新过**(09-17 / 09-18 都是全天逐小时更新)
// → 大概率上游同步任务当天异常,需业务侧跟进(见文末遗留)
// → 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。
// ⚠️ 「同步最新数据」按钮只清服务端缓存(clearMkhBomCache),**不会触发上游同步**,
// 所以点上多少次都还是 0 —— 数据源当天就没有这条库存。
// 兜底规则(**只补 0,不覆盖有效值**):
// ① inventoryInfo 品牌仓 qty > 0 → 用它(stockSource='realtime',与旧口径完全一致)
// ② 否则箱表品牌仓 `bin` 非空行 qty 合计 > 0 → 用它(stockSource='pack')
// ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0(stockSource='realtime')
// ④ 否则回退监控快照 jsStock → (stockSource='monitor')
// ⚠️ 为什么 inv 优先、pack 只补 0:两表都 > 0 且不等的 28 个成品里 **25 个是 inv 更大**
// (箱表只记装箱的货,散货不计)→ 若拿 pack 当主源会系统性低估库存、误报缺货。
// ⚠️ 必须过滤 `bin` 为空的行:那是**装箱记录不是库存**(品牌仓 bin 空行合计 21 件),
// 不过滤会虚增。
// 影响面(2026-09-19 实测):品牌仓 4,408 个成品里,走 ② 兜底的只有 **3 个**
// (MKHDNZ0409260601548=5 / MKHDNZ0409260700059=4 / MKHDNZ0409260700491=4,
// 同一箱 115870495000001201、货位 P-1,均为**当日新建**行);
// 预警列表随之 27 → 24 条(缺货 21 → 18),这 3 条都是「入库当天被误判缺货」。
// 性能:箱表全量取数 **87ms**(brand 仓 + bin 非空 + GROUP BY),无额外负担。
//
// ⚠️ 遗留(需业务 / 上游跟进):箱表兜底只解决「**当日入库看不见**」这一种情形。
// inventoryInfo 若长期停更,**存量库存的时效性仍依赖该表恢复**——届时页面库存
// 会停留在停更时点(本次即 09-19 06:41)的旧值,本项目无从感知。
// 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的
// MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。
//
// ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── // ── ⑧ 在途加工单(2026-09-17 业务确认口径)────────────────────────
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
// (工厂已经在做了,不重复提示补货),改标为「已排产」。 // (工厂已经在做了,不重复提示补货),改标为「已排产」。
@ -344,6 +424,31 @@ const PENDING_ORDER_SKU_LIKE = '%MKH%';
const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000; const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000;
// 日销兜底(⑦-a):这些店铺的单上游不计入日销,兜底也必须排除,否则会误拉进预警 // 日销兜底(⑦-a):这些店铺的单上游不计入日销,兜底也必须排除,否则会误拉进预警
const SALE_EXCLUDED_SHOP_KEYWORDS = Object.freeze(['样品', '测试']); const SALE_EXCLUDED_SHOP_KEYWORDS = Object.freeze(['样品', '测试']);
// 日销兜底(⑦-a):**异常单**同样不能计入「已发货」——
// 2026-09-19 实测:店铺在 1 分钟内批量生成 18 张「实付 0.01 元 / 商品额 908 元全额免 /
// 备注 SSDD / 标签含「特殊单」」的单(同批还有占位码 X00005 的单),
// 在 `rpt.jst_salesOutboundInfo` 里**查不到任何出库记录**(同店其他单有 6467 行)——
// 即系统标了「已发货」但货没真出。它们曾把 10 个 SKU 的日销从 0 抬到 0.6~1.8。
// 判定:实付 ≤ 0.01 元,或标签含「特殊单」。
// ⚠️ **不要用 `free_amount > 0` 判**:正常单普遍有折扣免额(实测窗口内 569/1086 行),
// 按它筛会把 356 / 458 / 1338 元的正常单一起误杀(第一版就踩了这个坑)。
const SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX = 0.01;
const SALE_EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze(['特殊单']);
// ── 出库校验参数(见文件头 ⑦-c)──────────────────────────────────
// 出库表:判定「标了已发货的单到底有没有真出货」。两张表按渠道分开,**必须都查**——
// 2026-09-19 实测近 15 天窗口内 1,086 张 MKH 已发货单:京东表命中 889、天猫表命中 198,
// jst_qmcust_orderlist 来源的 208 张单里 7 张只有京东表有记录、1 张只有天猫表有记录。
const OUTBOUND_TABLES = Object.freeze([
'rpt.jst_salesOutboundInfo', // 京东 / 拼多多 / 抖音 等
'rpt.jst_qmcust_salesoutbound', // 天猫 / 淘宝 / 1688 等
]);
// 单个查询里塞多少个 o_id:1000 个参数远低于 MSSQL 单语句 2100 上限,
// 实测 1086 个订单号分 2 块并行查询合计 ≈ 515ms(整窗口拉全量 DISTINCT 要 8s,别那么干)
const OUTBOUND_LOOKUP_CHUNK_SIZE = 1000;
// 「拆分单」状态:父单被拆成子单后,出库记录挂在**子单**上,父单必然查不到出库。
// 2026-09-19 实测窗口内 40 张 Split 单 **100% 无出库记录** → 不能拿「无出库」判它造假。
const SPLIT_ORDER_STATUSES = Object.freeze(['split']);
// ── 配件不足 / 可组装数参数(见文件头 ⑪)──────────────────────── // ── 配件不足 / 可组装数参数(见文件头 ⑪)────────────────────────
// 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id // 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id
@ -936,10 +1041,30 @@ function buildReplenishMetrics(row = {}) {
const useOrderDailySale = snapshotDailySale <= 0 && orderDailySale > 0; const useOrderDailySale = snapshotDailySale <= 0 && orderDailySale > 0;
const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale; const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale;
// 现有库存来源优先级(见文件头 ⑦-d):
// inventoryInfo 品牌仓有货 → 用它(旧口径不变)
// 否则箱表(小时级)有货 → 用它(补「当日入库、日更库存表还没同步」的盲区)
// 否则 inventoryInfo 有行(qty≤0) → 用 0(该仓确实没货,别再退回更旧的快照)
// 否则回退监控快照
const realtimeStock = toNumber(row.realtimeStock); const realtimeStock = toNumber(row.realtimeStock);
const packStock = toNumber(row.packStock);
const monitorStock = toNumber(row.monitorStock) ?? 0; const monitorStock = toNumber(row.monitorStock) ?? 0;
const hasRealtime = realtimeStock !== null; const hasRealtime = realtimeStock !== null;
const stock = hasRealtime ? realtimeStock : monitorStock; let stock;
let stockSource;
if (hasRealtime && realtimeStock > 0) {
stock = realtimeStock;
stockSource = 'realtime';
} else if (packStock !== null && packStock > 0) {
stock = packStock;
stockSource = 'pack';
} else if (hasRealtime) {
stock = realtimeStock;
stockSource = 'realtime';
} else {
stock = monitorStock;
stockSource = 'monitor';
}
// 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量 // 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量
const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0); const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0);
@ -975,7 +1100,7 @@ function buildReplenishMetrics(row = {}) {
pendingOrderEarliest: toDateText(row.pendingOrderEarliest), pendingOrderEarliest: toDateText(row.pendingOrderEarliest),
orderDriven, orderDriven,
thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)), thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)),
stockSource: hasRealtime ? 'realtime' : 'monitor', stockSource,
safetyDays: toNumber(row.safetyDays), safetyDays: toNumber(row.safetyDays),
suggestStock: toNumber(row.suggestStock) ?? 0, suggestStock: toNumber(row.suggestStock) ?? 0,
monitorDate: toDateText(row.monitorDate), monitorDate: toDateText(row.monitorDate),
@ -1275,14 +1400,107 @@ const isExcludedSaleShop = (shopName) => {
return SALE_EXCLUDED_SHOP_KEYWORDS.some((keyword) => text.includes(keyword)); return SALE_EXCLUDED_SHOP_KEYWORDS.some((keyword) => text.includes(keyword));
}; };
/**
* 是否为「异常单」—— 标了已发货但货没真出的单,不能计入兜底日销(见文件头 ⑦-a)。
* ① 标签含「特殊单」;
* ② 实付金额 ≤ 0.01 元(0.01 元全免单)。
* ⚠️ 金额未知(null / 非数字)时**不排除** —— 宁可用数据也别误杀,取不到就退化成旧口径。
*/
const isExcludedSaleOrder = (row = {}) => {
const labels = toText(row.labels);
if (labels && SALE_EXCLUDED_ORDER_LABEL_KEYWORDS.some((keyword) => labels.includes(keyword))) {
return true;
}
const paid = toNumber(row.payAmount);
return paid !== null && paid <= SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX;
};
/** 是否为「拆分单」——父单的出库记录挂在子单上,父单查不到出库属正常(见 ⑦-c) */
const isSplitOrder = (status) => {
const text = toText(status);
return Boolean(text) && SPLIT_ORDER_STATUSES.includes(text.toLowerCase());
};
/** 发货日是否为**今天** —— 当天出库尚未同步完成,不能用出库判据(见 ⑦-c) */
const isShippedToday = (sendDate) => {
const date = sendDate instanceof Date ? sendDate : new Date(sendDate);
if (Number.isNaN(date.getTime())) return false;
const today = new Date();
return date.getFullYear() === today.getFullYear()
&& date.getMonth() === today.getMonth()
&& date.getDate() === today.getDate();
};
/**
* 该已发货单能否用「出库记录」来判定真伪(见 ⑦-c)。
* 不能判定的两种情形,退回只按 ⑦-a 的金额/标签判据,**不做无出库排除**,避免误杀:
* · 拆分单:出库挂在子单上,父单必然查不到
* · 今天发货:当天出库数据尚未同步完(实测 09-19 全天出库表只有 1 行)
* 取不到订单号时同样豁免——宁可漏杀,不可错杀。
*/
const isOutboundCheckSkipped = (row = {}) => (
isSplitOrder(row.status)
|| isShippedToday(row.sendDate)
|| !toText(row.oId)
);
/**
* 批量查「这些订单号在出库表里有没有实际出库记录」(见 ⑦-c)。
* 只取 o_id 一列、分块并行查,失败时返回空集合 + 错误信息让调用方降级。
* ⚠️ 只判**有没有行**:`qty` 列全表 1,119,606 行恒为 0,`SUM(qty)` 永远得 0,别用它。
*/
async function loadOutboundOrderIds(orderIds) {
const ids = [...new Set((orderIds || []).map((id) => toText(id)).filter(Boolean))];
if (!ids.length) return { ids: new Set(), error: null };
const chunks = [];
for (let index = 0; index < ids.length; index += OUTBOUND_LOOKUP_CHUNK_SIZE) {
chunks.push(ids.slice(index, index + OUTBOUND_LOOKUP_CHUNK_SIZE));
}
const settled = await Promise.allSettled(chunks.map((chunk) => {
const params = {};
const placeholders = chunk
.map((value, index) => { params[`outboundOid${index}`] = value; return `@outboundOid${index}`; })
.join(', ');
const union = OUTBOUND_TABLES
.map((table) => `SELECT o_id AS oId FROM ${table} WITH (NOLOCK) WHERE o_id IN (${placeholders})`)
.join('\n UNION\n ');
return cloudDatabase.query(
`SELECT DISTINCT outbound_rows.oId AS oId
FROM (
${union}
) outbound_rows`,
params,
{ requestTimeout: 180000 }
);
}));
const hits = new Set();
const failed = [];
settled.forEach((result, index) => {
if (result.status !== 'fulfilled') {
failed.push(`出库表分块#${index + 1}: ${result.reason?.message || result.reason}`);
return;
}
result.value.forEach((row) => {
const text = toText(row.oId);
if (text) hits.add(text);
});
});
return { ids: hits, error: failed.length ? failed.join(' | ') : null };
}
/** /**
* 订单索引 —— 进程内缓存,**一次扫表同时得到两份数据**(合并的理由见文件头 ⑦-a): * 订单索引 —— 进程内缓存,**一次扫表同时得到两份数据**(合并的理由见文件头 ⑦-a):
* pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩) * pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩)
* shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店) * shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店)
* 口径: * 口径:
* · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩) * · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩)
* · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a) * · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a),且
* ①非样品/测试店铺、②非异常单(金额/标签)、③**有实际出库记录**(⑦-c)
* · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计 * · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计
* · 三条过滤只作用于**已发货**分支:待发货(⑩)本来就是没出货的订单,不做出库校验
* ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。 * ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。
*/ */
async function loadOrderIndex() { async function loadOrderIndex() {
@ -1307,6 +1525,9 @@ async function loadOrderIndex() {
o.order_date AS orderDate, o.order_date AS orderDate,
o.send_date AS sendDate, o.send_date AS sendDate,
o.shop_name AS shopName, o.shop_name AS shopName,
o.pay_amount AS payAmount,
o.labels AS labels,
o.status AS status,
o.skus AS skus o.skus AS skus
FROM ${table} o WITH (NOLOCK) FROM ${table} o WITH (NOLOCK)
WHERE o.skus LIKE @pendingOrderSkuLike WHERE o.skus LIKE @pendingOrderSkuLike
@ -1320,11 +1541,14 @@ async function loadOrderIndex() {
)`, )`,
{ pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams }, { pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams },
{ requestTimeout: 180000 } { requestTimeout: 180000 }
))).then((results) => { ))).then(async (results) => {
const pendingBySku = new Map(); const pendingBySku = new Map();
const shippedBySku = new Map(); const shippedBySku = new Map();
const seenOrderIds = new Set(); const seenOrderIds = new Set();
const failed = []; const failed = [];
const shippedRows = [];
const pendingRows = [];
const outboundCheckIds = [];
results.forEach((result, index) => { results.forEach((result, index) => {
if (result.status !== 'fulfilled') { if (result.status !== 'fulfilled') {
@ -1342,16 +1566,53 @@ async function loadOrderIndex() {
// 已发货(⑦-a):样品/测试专用店铺的单上游不计入日销,这里也必须排除 // 已发货(⑦-a):样品/测试专用店铺的单上游不计入日销,这里也必须排除
const shipped = row.sendDate !== null && row.sendDate !== undefined; const shipped = row.sendDate !== null && row.sendDate !== undefined;
if (shipped && isExcludedSaleShop(row.shopName)) return; if (shipped && isExcludedSaleShop(row.shopName)) return;
// 已发货(⑦-a):0.01 元特殊单「标了发货但没真出库」,同样不计入日销
if (shipped && isExcludedSaleOrder(row)) return;
if (shipped) {
shippedRows.push(row);
// ⑦-c:可用于出库校验的单先记下订单号,第二遍统一过滤
if (!isOutboundCheckSkipped(row)) outboundCheckIds.push(orderId);
return;
}
pendingRows.push(row);
});
});
// ⑦-c:已发货单再过一道「有没有真出库」。
// 出库数据取不到(或只取到一部分)时**整体不做出库排除**,退化成只按金额/标签判据——
// 宁可漏杀不可错杀:漏杀只是多一条预警,错杀会让真实缺货彻底隐身。
let outboundReady = false;
let outboundIds = new Set();
if (outboundCheckIds.length) {
const outbound = await loadOutboundOrderIds(outboundCheckIds);
if (outbound.error) {
console.warn('[MKH-BOM] 出库数据源读取失败,本次跳过出库校验:', outbound.error);
failed.push(outbound.error);
} else {
outboundReady = true;
outboundIds = outbound.ids;
}
}
shippedRows.forEach((row) => {
const orderId = row.oId === null || row.oId === undefined ? null : String(row.oId);
// ⑦-c:能判定、却没查到出库 → 系统标了「已发货」但货没真出,不计入销量
if (outboundReady && !isOutboundCheckSkipped(row) && !outboundIds.has(orderId)) return;
parseOrderSkus(row.skus).forEach(({ code, qty }) => {
if (!isMkhProductCode(code)) return;
const key = normalizeCode(code);
if (!key) return;
shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty);
});
});
pendingRows.forEach((row) => {
const orderDate = row.orderDate instanceof Date ? row.orderDate : null; const orderDate = row.orderDate instanceof Date ? row.orderDate : null;
parseOrderSkus(row.skus).forEach(({ code, qty }) => { parseOrderSkus(row.skus).forEach(({ code, qty }) => {
if (!isMkhProductCode(code)) return; if (!isMkhProductCode(code)) return;
const key = normalizeCode(code); const key = normalizeCode(code);
if (!key) return; if (!key) return;
if (shipped) {
shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty);
return;
}
const entry = pendingBySku.get(key) || { const entry = pendingBySku.get(key) || {
pendingOrderQty: 0, pendingOrderQty: 0,
pendingOrderCount: 0, pendingOrderCount: 0,
@ -1365,7 +1626,6 @@ async function loadOrderIndex() {
pendingBySku.set(key, entry); pendingBySku.set(key, entry);
}); });
}); });
});
if (failed.length) { if (failed.length) {
// 部分源失败:仍用成功源的数据(比全空更接近真实),但标 degraded 让接口透出, // 部分源失败:仍用成功源的数据(比全空更接近真实),但标 degraded 让接口透出,
@ -1424,11 +1684,22 @@ async function loadReplenishIndex() {
REPLENISH_PARAMS, REPLENISH_PARAMS,
{ requestTimeout: 180000 } { requestTimeout: 180000 }
), ),
// 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到,
// 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。
cloudDatabase.query(
`SELECT p.sku_id AS skuId, SUM(p.qty) AS qty
FROM dbo.jst_packInventoryInfo p
WHERE p.wms_co_id = @brandWmsCoId AND p.sku_id LIKE N'MKH%'
AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL
GROUP BY p.sku_id`,
REPLENISH_PARAMS,
{ requestTimeout: 180000 }
),
// 在途加工单(见文件头 ⑧) // 在途加工单(见文件头 ⑧)
loadPendingWorkOrderIndex(), loadPendingWorkOrderIndex(),
// 订单索引:待发货(⑩)+ 近15天已发货(⑦-a),共用同一次查询 // 订单索引:待发货(⑩)+ 近15天已发货(⑦-a),共用同一次查询
loadOrderIndex(), loadOrderIndex(),
]).then(([monitorRows, stockRows, workOrderIndex, orderIndex]) => { ]).then(([monitorRows, stockRows, packStockRows, workOrderIndex, orderIndex]) => {
const workOrderBySku = workOrderIndex.bySku; const workOrderBySku = workOrderIndex.bySku;
const pendingOrderBySku = orderIndex.pendingBySku; const pendingOrderBySku = orderIndex.pendingBySku;
const shippedBySku = orderIndex.shippedBySku; const shippedBySku = orderIndex.shippedBySku;
@ -1437,6 +1708,11 @@ async function loadReplenishIndex() {
const key = normalizeCode(row.skuId); const key = normalizeCode(row.skuId);
if (key && !latestStock.has(key)) latestStock.set(key, toNumber(row.qty)); if (key && !latestStock.has(key)) latestStock.set(key, toNumber(row.qty));
}); });
const packStock = new Map();
packStockRows.forEach((row) => {
const key = normalizeCode(row.skuId);
if (key) packStock.set(key, toNumber(row.qty));
});
const seenMonitor = new Set(); const seenMonitor = new Set();
const bySku = new Map(); const bySku = new Map();
@ -1453,6 +1729,8 @@ async function loadReplenishIndex() {
recentShippedQty: shippedBySku.get(key) || 0, recentShippedQty: shippedBySku.get(key) || 0,
// 实时库存缺失(而非为 0)时才回退监控快照 // 实时库存缺失(而非为 0)时才回退监控快照
realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, realtimeStock: latestStock.has(key) ? latestStock.get(key) : null,
// 箱/货位库存(⑦-d):inventoryInfo 日更,当日入库的货只有这张小时级表有
packStock: packStock.has(key) ? packStock.get(key) : null,
monitorStock: row.monitorStock, monitorStock: row.monitorStock,
safetyDays: row.safetyDays, safetyDays: row.safetyDays,
suggestStock: row.suggestStock, suggestStock: row.suggestStock,
@ -2172,12 +2450,24 @@ module.exports = {
buildReplenishMetrics, buildReplenishMetrics,
loadPendingWorkOrderIndex, loadPendingWorkOrderIndex,
loadOrderIndex, loadOrderIndex,
loadReplenishIndex,
parseOrderSkus, parseOrderSkus,
isMkhProductCode, isMkhProductCode,
isExcludedSaleShop, isExcludedSaleShop,
isExcludedSaleOrder,
SALE_EXCLUDED_SHOP_KEYWORDS,
SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX,
SALE_EXCLUDED_ORDER_LABEL_KEYWORDS,
// ⑦-c 出库校验
isSplitOrder,
isShippedToday,
isOutboundCheckSkipped,
loadOutboundOrderIds,
OUTBOUND_TABLES,
OUTBOUND_LOOKUP_CHUNK_SIZE,
SPLIT_ORDER_STATUSES,
PENDING_ORDER_EXCLUDED_STATUS, PENDING_ORDER_EXCLUDED_STATUS,
ORDER_TABLES, ORDER_TABLES,
SALE_EXCLUDED_SHOP_KEYWORDS,
// ⑪ 配件不足 / 可组装数 // ⑪ 配件不足 / 可组装数
buildComponentOccupation, buildComponentOccupation,
buildComponentReadiness, buildComponentReadiness,

View File

@ -234,6 +234,33 @@ test('replenish: stock prefers the brand-warehouse realtime value, snapshot only
assert.equal(zero.warningLevel, '缺货'); assert.equal(zero.warningLevel, '缺货');
}); });
test('replenish: box/pallet stock fills in when the daily inventory table is stale (⑦-d)', () => {
// 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货
const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 });
assert.equal(fillFromZero.stock, 4);
assert.equal(fillFromZero.stockSource, 'pack');
assert.notEqual(fillFromZero.warningLevel, '缺货');
const fillFromMissing = metrics({ avgSale: 1, realtimeStock: null, packStock: 5, monitorStock: 0 });
assert.equal(fillFromMissing.stock, 5);
assert.equal(fillFromMissing.stockSource, 'pack');
// 库存表有货时箱表不参与 —— 箱表只记装箱的货,拿它当主源会低估库存
const realtimeWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 100, monitorStock: 0 });
assert.equal(realtimeWins.stock, 4);
assert.equal(realtimeWins.stockSource, 'realtime');
// 两个实时源都没货 → 库存表说 0 就认 0,不再退回更旧的快照
const bothZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 0, monitorStock: 9 });
assert.equal(bothZero.stock, 0);
assert.equal(bothZero.stockSource, 'realtime');
// 库存表整个品牌仓无记录、箱表也没货 → 才回退监控快照
const snapshot = metrics({ avgSale: 1, realtimeStock: null, packStock: 0, monitorStock: 9 });
assert.equal(snapshot.stock, 9);
assert.equal(snapshot.stockSource, 'monitor');
});
test('replenish: products without 7-day sales never warn', () => { test('replenish: products without 7-day sales never warn', () => {
const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 }); const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 });
assert.equal(noSale.hasWarning, false); assert.equal(noSale.hasWarning, false);
@ -402,6 +429,79 @@ test('pending order: only MKH finished products count, combination codes are exc
assert.equal(_private.isExcludedSaleShop('某某测试专用店铺'), true); assert.equal(_private.isExcludedSaleShop('某某测试专用店铺'), true);
assert.equal(_private.isExcludedSaleShop('天猫-HX旗舰店101'), false); assert.equal(_private.isExcludedSaleShop('天猫-HX旗舰店101'), false);
assert.equal(_private.isExcludedSaleShop(null), false); assert.equal(_private.isExcludedSaleShop(null), false);
// 日销兜底(⑦-a)排除异常单:0.01 元特殊单「标了发货但没真出库」,不是销售
assert.equal(_private.SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX, 0.01);
assert.deepEqual([..._private.SALE_EXCLUDED_ORDER_LABEL_KEYWORDS], ['特殊单']);
assert.equal(_private.isExcludedSaleOrder({ payAmount: 0.01 }), true, '实付 0.01 元 → 异常单');
assert.equal(_private.isExcludedSaleOrder({ payAmount: 0 }), true, '实付 0 元 → 异常单');
assert.equal(_private.isExcludedSaleOrder({ payAmount: '0.01' }), true, '字符串金额同样识别');
assert.equal(_private.isExcludedSaleOrder({ payAmount: 908.01, labels: '模块化成品,特殊单,禁止拆分' }), true,
'标签含「特殊单」→ 异常单(即使实付正常)');
// ⚠️ 反例:正常单普遍有折扣免额,**不能**按 free_amount 判异常,否则会误杀
assert.equal(_private.isExcludedSaleOrder({ payAmount: 356, freeAmount: 50 }), false, '有免额的正常单不得排除');
assert.equal(_private.isExcludedSaleOrder({ payAmount: 1338.82, freeAmount: 236.26 }), false, '有免额的正常单不得排除');
// 金额取不到时不排除(宁可用数据也别误杀)
assert.equal(_private.isExcludedSaleOrder({}), false, '金额缺省 → 不排除');
assert.equal(_private.isExcludedSaleOrder({ payAmount: null }), false, '金额为 null → 不排除');
assert.equal(_private.isExcludedSaleOrder({ payAmount: '未知' }), false, '金额非数字 → 不排除');
});
test('replenish index: hourly box/pallet stock surfaces same-day inbound (⑦-d)', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const originalGetPool = cloudDatabase.getPool;
const queried = [];
// 复现 MKHDNZ0409260700491:当日 15:19 入库,日更的 inventoryInfo 还没有这条,
// 只有小时级的箱表有;监控快照 jsStock = 0。
cloudDatabase.query = async (sql) => {
if (sql.includes('jst_packInventoryInfo')) {
queried.push('pack');
return [{ skuId: 'MKHDNZ0409260700491', qty: 4 }];
}
if (sql.includes('jst_inventoryInfo')) {
queried.push('inventory');
return [];
}
if (sql.includes('pm_stockMonitor')) {
queried.push('monitor');
return [{
skuId: 'MKHDNZ0409260700491',
fifteenAvgSale: 0,
monitorStock: 0,
safetyDays: 3,
suggestStock: 0,
monitorDate: new Date('2026-09-18T00:00:00Z'),
}];
}
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) {
queried.push('outbound');
return [];
}
if (sql.includes('jst_qmcust_orderlist')) {
queried.push('qmcust');
return [];
}
queried.push('orders');
return [];
};
cloudDatabase.getPool = async () => ({
request: () => ({ input() {}, query: async () => ({ recordset: [] }) }),
});
try {
const index = await svc._private.loadReplenishIndex();
assert.ok(queried.includes('pack'), '箱表必须被读到');
assert.ok(queried.includes('inventory'), '实时库存表仍要读');
const row = index.bySku.get('MKHDNZ0409260700491');
assert.equal(row.stock, 4, '当日入库的货必须显示出来,不能显示 0');
assert.equal(row.stockSource, 'pack');
} finally {
cloudDatabase.query = originalQuery;
cloudDatabase.getPool = originalGetPool;
svc.clearMkhBomCache();
}
}); });
test('order index: 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 () => {
@ -410,7 +510,14 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
const originalQuery = cloudDatabase.query; const originalQuery = cloudDatabase.query;
const queried = []; const queried = [];
// 出库表命中集合(⑦-c):9005 / 9007 / 9011 有真实出库记录;
// 9012(金额正常但无出库)、9009 / 9010 / 9015(异常单)刻意不在此列
const outboundHits = ['9005', '9007', '9011'];
cloudDatabase.query = async (sql) => { cloudDatabase.query = async (sql) => {
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) {
queried.push('outbound');
return outboundHits.map((oId) => ({ oId }));
}
if (sql.includes('jst_qmcust_orderlist')) { if (sql.includes('jst_qmcust_orderlist')) {
queried.push('qmcust'); queried.push('qmcust');
return [ return [
@ -458,6 +565,76 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
shopName: '某某测试专用店铺', shopName: '某某测试专用店铺',
skus: '1.MKHDNZ0409260608888', skus: '1.MKHDNZ0409260608888',
}, },
// 异常单(⑦-a):0.01 元全免单,标了已发货但没真出库 → 排除
// (复现 MKHDNZ0410260700005:实付 0.01 / 免额 908 / 标签含「特殊单」)
{
oId: 9009,
sendDate: new Date('2026-09-18T00:00:00Z'),
shopName: '拼多多-亿家达书房家具官方旗舰店',
payAmount: 0.01,
freeAmount: 908,
labels: '大单待确认,模块化成品,特殊单,禁止拆分,线上发货,转x00005',
skus: '9.MKHDNZ0410260700005*9',
},
// 异常单(⑦-a):实付正常但标签含「特殊单」→ 同样排除
{
oId: 9010,
sendDate: new Date('2026-09-18T00:00:00Z'),
shopName: '拼多多-亿家达书房家具官方旗舰店',
payAmount: 500,
labels: '模块化成品,特殊单',
skus: '1.MKHDNZ0410260700099',
},
// 对照:有折扣免额的**正常单**必须照常计入(第一版按 free_amount 判会误杀它)
{
oId: 9011,
sendDate: new Date('2026-09-18T00:00:00Z'),
shopName: '拼多多-HX旗舰店103',
payAmount: 356,
freeAmount: 50,
labels: '店铺-拼多多,模块化成品',
skus: '1.MKHDNZ0410260700023',
},
// ⑦-c:金额正常、但出库表查不到 → 系统标了「已发货」货没真出 → 排除
// (业务原话:标了已发货但没有实际出库的不需要出现缺货预警)
{
oId: 9012,
sendDate: new Date('2026-09-10T00:00:00Z'),
shopName: '拼多多-HX旗舰店105',
payAmount: 388,
labels: '发货后取消,发货后售后,风控订单',
skus: '1.MKHDNZ0409260601888',
},
// ⑦-c 豁免①:拆分单的出库挂在子单上,父单查不到属正常 → 必须保留
// (实测窗口内 40 张 Split 单 100% 无出库记录)
{
oId: 9013,
status: 'Split',
sendDate: new Date('2026-09-15T00:00:00Z'),
shopName: '京东-HX旗舰店104',
payAmount: 1194.14,
labels: '以旧换新,模块化成品',
skus: '1.MKHDNZ0409260601777',
},
// ⑦-c 豁免②:今天发货的单,当天出库尚未同步完 → 必须保留
{
oId: 9014,
sendDate: new Date(),
shopName: '京东-HX旗舰店104',
payAmount: 500,
labels: '模块化成品',
skus: '1.MKHDNZ0409260601666',
},
// ⑦-c:拆分单/今天单虽然豁免**出库**校验,但金额/标签判据照样生效
{
oId: 9015,
status: 'Split',
sendDate: new Date('2026-09-15T00:00:00Z'),
shopName: '拼多多-亿家达书房家具官方旗舰店',
payAmount: 0.01,
labels: '特殊单,模块化成品',
skus: '1.MKHDNZ0410260700777',
},
]; ];
}; };
@ -465,7 +642,7 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
svc.clearMkhBomCache(); svc.clearMkhBomCache();
const index = await _private.loadOrderIndex(); const index = await _private.loadOrderIndex();
assert.deepEqual(queried.sort(), ['orders', 'qmcust'], '两张订单表都必须读到'); assert.deepEqual(queried.sort(), ['orders', 'outbound', 'qmcust'], '两张订单表 + 出库表都必须读到');
assert.equal(index.degraded, false); assert.equal(index.degraded, false);
const a = index.pendingBySku.get('MKHDNZ0409260700059'); const a = index.pendingBySku.get('MKHDNZ0409260700059');
@ -482,7 +659,58 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 3, '已发货件数 1 + 2 累加'); assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 3, '已发货件数 1 + 2 累加');
assert.equal(index.shippedBySku.has('MKHDNZ0409260609999'), false, '样品专用店铺要排除'); assert.equal(index.shippedBySku.has('MKHDNZ0409260609999'), false, '样品专用店铺要排除');
assert.equal(index.shippedBySku.has('MKHDNZ0409260608888'), false, '测试专用店铺要排除'); assert.equal(index.shippedBySku.has('MKHDNZ0409260608888'), false, '测试专用店铺要排除');
assert.equal(index.shippedBySku.size, 1, '被排除的店铺不进入索引'); // 异常单排除(⑦-a):0.01 元全免单、标签含「特殊单」都不计入已发货
assert.equal(index.shippedBySku.has('MKHDNZ0410260700005'), false, '0.01 元异常单不得计入日销');
assert.equal(index.shippedBySku.has('MKHDNZ0410260700099'), false, '标签含「特殊单」的不得计入日销');
// 有免额的正常单必须照常计入
assert.equal(index.shippedBySku.get('MKHDNZ0410260700023'), 1, '有免额的正常单不得被误排除');
// ⑦-c 出库校验:标了已发货但出库表查不到 → 不计入日销
assert.equal(index.shippedBySku.has('MKHDNZ0409260601888'), false, '无实际出库记录的不得计入日销');
// ⑦-c 豁免:拆分单 / 今天发货的单不能用出库判定,必须保留
assert.equal(index.shippedBySku.get('MKHDNZ0409260601777'), 1, '拆分单无出库记录属正常,不得误杀');
assert.equal(index.shippedBySku.get('MKHDNZ0409260601666'), 1, '今天发货的出库尚未同步,不得误杀');
// ⑦-c 豁免只免「出库校验」,金额/标签判据照样生效
assert.equal(index.shippedBySku.has('MKHDNZ0410260700777'), false, '拆分单的 0.01 元异常单仍要排除');
assert.equal(index.shippedBySku.size, 4, '被排除的店铺/异常单/无出库单不进入索引');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});
test('order index: outbound lookup failure skips the check instead of dropping sales', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
// 出库表整体不可用(例如视图未同步)——此时宁可漏杀不可错杀:
// 不做出库排除(否则真实销售会被整批砍掉),但要标 degraded 触发短 TTL 重试。
cloudDatabase.query = async (sql) => {
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) {
throw new Error('出库视图不可用');
}
if (sql.includes('jst_qmcust_orderlist')) return [];
return [
{
oId: 9101,
sendDate: new Date('2026-09-10T00:00:00Z'),
shopName: '京东-HX旗舰店104',
payAmount: 388,
labels: '模块化成品',
skus: '1.MKHDNZ0409260601777',
},
];
};
try {
svc.clearMkhBomCache();
const index = await _private.loadOrderIndex();
assert.equal(index.degraded, true, '出库源失败要标 degraded,让接口透出并尽快重试');
assert.equal(
index.shippedBySku.get('MKHDNZ0409260601777'),
1,
'出库数据取不到时不得据此砍掉销量(退化成只按金额/标签判据)'
);
} finally { } finally {
cloudDatabase.query = originalQuery; cloudDatabase.query = originalQuery;
svc.clearMkhBomCache(); svc.clearMkhBomCache();

View File

@ -147,6 +147,17 @@ const ALERT_RECIPIENT_SOURCE_TEXT = {
none: '未找到可用的收件人', none: '未找到可用的收件人',
}; };
// 「现有库存」列的取值来源说明(后端 stockSource 字段):
// realtime = 品牌仓实时库存表(jst_inventoryInfo,日更)
// pack = 品牌仓箱/货位表(jst_packInventoryInfo,小时级)——
// 实时库存表当天还没同步到这条货(当日入库)时用它兜底
// monitor = 两个实时源都没有记录,退回监控快照库存
const STOCK_SOURCE_TEXT = {
realtime: '品牌仓实时库存',
pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)',
monitor: '品牌仓实时库存缺失,取监控快照库存',
};
// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 // A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位
// 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」), // 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」),
// 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。 // 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。
@ -564,9 +575,7 @@ const MkhBomData = () => {
align: 'right', align: 'right',
render: (value, row) => ( render: (value, row) => (
<Tooltip <Tooltip
title={row.stockSource === 'realtime' title={STOCK_SOURCE_TEXT[row.stockSource] || STOCK_SOURCE_TEXT.monitor}
? '品牌仓实时库存'
: '品牌仓实时库存缺失,取监控快照库存'}
> >
<span>{number(value)}</span> <span>{number(value)}</span>
</Tooltip> </Tooltip>