Merge pull request 'fix: MKH-BOM 预警与库存口径修复:日销以出库为准 + 库存增加箱表兜底' (#19)
This commit is contained in:
commit
bbabda5016
@ -118,6 +118,7 @@
|
||||
// · **只在快照 ≤ 0 时兜底**:快照 > 0 一律以上游为准,不覆盖,避免两套口径打架
|
||||
// · **排除样品 / 测试专用店铺**(shop_name 含「样品」或「测试」),与上游口径对齐;
|
||||
// 不排除的话那 16 个样品单编码会被误拉进预警
|
||||
// · **排除异常单**(2026-09-19 补充,见下):订单表里的「已发货」不等于真实销售
|
||||
// · 窗口 = 含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径
|
||||
// · 数据源与 ⑩ 的待发货订单**同一次查询、同一组表**:一次扫表同时拿到
|
||||
// 「待发货」与「近15天已发货」两份数据。实测合并后总耗时 ≈ 单查待发货
|
||||
@ -125,13 +126,92 @@
|
||||
// · 兜底生效的商品带 `dailySaleSource='orders'` 与 `recentShippedQty`,
|
||||
// 前端据此提示「该值来自订单实算、非上游快照」,避免与源系统数字对不上时误判
|
||||
// · 订单源不可用时 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 个编码,其中
|
||||
// 165 个快照本就 > 0(兜底不生效),**22 个快照 = 0 → 兜底生效**,
|
||||
// 含本问题的 MKHDNZ0409260601548,以及 09-18 才发货、快照尚未跟上的
|
||||
// MKHDNZ04102607000xx 一批。
|
||||
// 含本问题的 MKHDNZ0409260601548。这 22 个里:**12 个是真实单驱动**(实付 299~1338 元,
|
||||
// 兜底正确),**10 个是上述 0.01 元异常单驱动**(`MKHDNZ0410260700001/0002/0004/0005/
|
||||
// 0007/0008/0009/0010/0011/0012`)—— 加过滤后这 10 个日销归零,另 12 个不受影响。
|
||||
// ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」,
|
||||
// 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 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 业务确认口径)────────────────────────
|
||||
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
|
||||
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
|
||||
@ -344,6 +424,31 @@ const PENDING_ORDER_SKU_LIKE = '%MKH%';
|
||||
const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000;
|
||||
// 日销兜底(⑦-a):这些店铺的单上游不计入日销,兜底也必须排除,否则会误拉进预警
|
||||
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
|
||||
@ -936,10 +1041,30 @@ function buildReplenishMetrics(row = {}) {
|
||||
const useOrderDailySale = snapshotDailySale <= 0 && orderDailySale > 0;
|
||||
const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale;
|
||||
|
||||
// 现有库存来源优先级(见文件头 ⑦-d):
|
||||
// inventoryInfo 品牌仓有货 → 用它(旧口径不变)
|
||||
// 否则箱表(小时级)有货 → 用它(补「当日入库、日更库存表还没同步」的盲区)
|
||||
// 否则 inventoryInfo 有行(qty≤0) → 用 0(该仓确实没货,别再退回更旧的快照)
|
||||
// 否则回退监控快照
|
||||
const realtimeStock = toNumber(row.realtimeStock);
|
||||
const packStock = toNumber(row.packStock);
|
||||
const monitorStock = toNumber(row.monitorStock) ?? 0;
|
||||
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);
|
||||
@ -975,7 +1100,7 @@ function buildReplenishMetrics(row = {}) {
|
||||
pendingOrderEarliest: toDateText(row.pendingOrderEarliest),
|
||||
orderDriven,
|
||||
thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)),
|
||||
stockSource: hasRealtime ? 'realtime' : 'monitor',
|
||||
stockSource,
|
||||
safetyDays: toNumber(row.safetyDays),
|
||||
suggestStock: toNumber(row.suggestStock) ?? 0,
|
||||
monitorDate: toDateText(row.monitorDate),
|
||||
@ -1275,14 +1400,107 @@ const isExcludedSaleShop = (shopName) => {
|
||||
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):
|
||||
* pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩)
|
||||
* shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店)
|
||||
* 口径:
|
||||
* · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩)
|
||||
* · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a)
|
||||
* · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a),且
|
||||
* ①非样品/测试店铺、②非异常单(金额/标签)、③**有实际出库记录**(⑦-c)
|
||||
* · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计
|
||||
* · 三条过滤只作用于**已发货**分支:待发货(⑩)本来就是没出货的订单,不做出库校验
|
||||
* ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。
|
||||
*/
|
||||
async function loadOrderIndex() {
|
||||
@ -1307,6 +1525,9 @@ async function loadOrderIndex() {
|
||||
o.order_date AS orderDate,
|
||||
o.send_date AS sendDate,
|
||||
o.shop_name AS shopName,
|
||||
o.pay_amount AS payAmount,
|
||||
o.labels AS labels,
|
||||
o.status AS status,
|
||||
o.skus AS skus
|
||||
FROM ${table} o WITH (NOLOCK)
|
||||
WHERE o.skus LIKE @pendingOrderSkuLike
|
||||
@ -1320,11 +1541,14 @@ async function loadOrderIndex() {
|
||||
)`,
|
||||
{ pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams },
|
||||
{ requestTimeout: 180000 }
|
||||
))).then((results) => {
|
||||
))).then(async (results) => {
|
||||
const pendingBySku = new Map();
|
||||
const shippedBySku = new Map();
|
||||
const seenOrderIds = new Set();
|
||||
const failed = [];
|
||||
const shippedRows = [];
|
||||
const pendingRows = [];
|
||||
const outboundCheckIds = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
@ -1342,16 +1566,53 @@ async function loadOrderIndex() {
|
||||
// 已发货(⑦-a):样品/测试专用店铺的单上游不计入日销,这里也必须排除
|
||||
const shipped = row.sendDate !== null && row.sendDate !== undefined;
|
||||
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;
|
||||
parseOrderSkus(row.skus).forEach(({ code, qty }) => {
|
||||
if (!isMkhProductCode(code)) return;
|
||||
const key = normalizeCode(code);
|
||||
if (!key) return;
|
||||
if (shipped) {
|
||||
shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty);
|
||||
return;
|
||||
}
|
||||
const entry = pendingBySku.get(key) || {
|
||||
pendingOrderQty: 0,
|
||||
pendingOrderCount: 0,
|
||||
@ -1365,7 +1626,6 @@ async function loadOrderIndex() {
|
||||
pendingBySku.set(key, entry);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (failed.length) {
|
||||
// 部分源失败:仍用成功源的数据(比全空更接近真实),但标 degraded 让接口透出,
|
||||
@ -1424,11 +1684,22 @@ async function loadReplenishIndex() {
|
||||
REPLENISH_PARAMS,
|
||||
{ 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(),
|
||||
// 订单索引:待发货(⑩)+ 近15天已发货(⑦-a),共用同一次查询
|
||||
loadOrderIndex(),
|
||||
]).then(([monitorRows, stockRows, workOrderIndex, orderIndex]) => {
|
||||
]).then(([monitorRows, stockRows, packStockRows, workOrderIndex, orderIndex]) => {
|
||||
const workOrderBySku = workOrderIndex.bySku;
|
||||
const pendingOrderBySku = orderIndex.pendingBySku;
|
||||
const shippedBySku = orderIndex.shippedBySku;
|
||||
@ -1437,6 +1708,11 @@ async function loadReplenishIndex() {
|
||||
const key = normalizeCode(row.skuId);
|
||||
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 bySku = new Map();
|
||||
@ -1453,6 +1729,8 @@ async function loadReplenishIndex() {
|
||||
recentShippedQty: shippedBySku.get(key) || 0,
|
||||
// 实时库存缺失(而非为 0)时才回退监控快照
|
||||
realtimeStock: latestStock.has(key) ? latestStock.get(key) : null,
|
||||
// 箱/货位库存(⑦-d):inventoryInfo 日更,当日入库的货只有这张小时级表有
|
||||
packStock: packStock.has(key) ? packStock.get(key) : null,
|
||||
monitorStock: row.monitorStock,
|
||||
safetyDays: row.safetyDays,
|
||||
suggestStock: row.suggestStock,
|
||||
@ -2172,12 +2450,24 @@ module.exports = {
|
||||
buildReplenishMetrics,
|
||||
loadPendingWorkOrderIndex,
|
||||
loadOrderIndex,
|
||||
loadReplenishIndex,
|
||||
parseOrderSkus,
|
||||
isMkhProductCode,
|
||||
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,
|
||||
ORDER_TABLES,
|
||||
SALE_EXCLUDED_SHOP_KEYWORDS,
|
||||
// ⑪ 配件不足 / 可组装数
|
||||
buildComponentOccupation,
|
||||
buildComponentReadiness,
|
||||
|
||||
@ -234,6 +234,33 @@ test('replenish: stock prefers the brand-warehouse realtime value, snapshot only
|
||||
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', () => {
|
||||
const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 });
|
||||
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('天猫-HX旗舰店101'), 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 () => {
|
||||
@ -410,7 +510,14 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
|
||||
const originalQuery = cloudDatabase.query;
|
||||
const queried = [];
|
||||
|
||||
// 出库表命中集合(⑦-c):9005 / 9007 / 9011 有真实出库记录;
|
||||
// 9012(金额正常但无出库)、9009 / 9010 / 9015(异常单)刻意不在此列
|
||||
const outboundHits = ['9005', '9007', '9011'];
|
||||
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')) {
|
||||
queried.push('qmcust');
|
||||
return [
|
||||
@ -458,6 +565,76 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
|
||||
shopName: '某某测试专用店铺',
|
||||
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();
|
||||
const index = await _private.loadOrderIndex();
|
||||
|
||||
assert.deepEqual(queried.sort(), ['orders', 'qmcust'], '两张订单表都必须读到');
|
||||
assert.deepEqual(queried.sort(), ['orders', 'outbound', 'qmcust'], '两张订单表 + 出库表都必须读到');
|
||||
assert.equal(index.degraded, false);
|
||||
|
||||
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.has('MKHDNZ0409260609999'), 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 {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
|
||||
@ -147,6 +147,17 @@ const ALERT_RECIPIENT_SOURCE_TEXT = {
|
||||
none: '未找到可用的收件人',
|
||||
};
|
||||
|
||||
// 「现有库存」列的取值来源说明(后端 stockSource 字段):
|
||||
// realtime = 品牌仓实时库存表(jst_inventoryInfo,日更)
|
||||
// pack = 品牌仓箱/货位表(jst_packInventoryInfo,小时级)——
|
||||
// 实时库存表当天还没同步到这条货(当日入库)时用它兜底
|
||||
// monitor = 两个实时源都没有记录,退回监控快照库存
|
||||
const STOCK_SOURCE_TEXT = {
|
||||
realtime: '品牌仓实时库存',
|
||||
pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)',
|
||||
monitor: '品牌仓实时库存缺失,取监控快照库存',
|
||||
};
|
||||
|
||||
// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位
|
||||
// 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」),
|
||||
// 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。
|
||||
@ -564,9 +575,7 @@ const MkhBomData = () => {
|
||||
align: 'right',
|
||||
render: (value, row) => (
|
||||
<Tooltip
|
||||
title={row.stockSource === 'realtime'
|
||||
? '品牌仓实时库存'
|
||||
: '品牌仓实时库存缺失,取监控快照库存'}
|
||||
title={STOCK_SOURCE_TEXT[row.stockSource] || STOCK_SOURCE_TEXT.monitor}
|
||||
>
|
||||
<span>{number(value)}</span>
|
||||
</Tooltip>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user