feat: MKH-BOM 补货预警支持待发订单驱动与配件不足标记
补货预警在原有「销量 + 库存」口径之外增加两条判定,均不改变已上线行为:
1. 待发订单驱动
- MKH 成品条码历史无销售量、但存在未发货订单时,同样进入补货预警。
- 建议补货量按「未发货件数 − 现有库存」计算,无订单时不参与该口径。
- 列表新增待发货订单列,展示件数、单数与最早下单时间。
2. 配件不足
- 对判定为缺货或预警的商品,按 BOM 计算可组装数:
可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小值。
- 可组装数为 0 时标记「配件不足,无法补货」并保留在清单中,
建议补货量显示「—」,同时给出短板配件及其库存。
- 配件库存只认「简尚-崔桥工厂半成品包装项目仓」的现有库存,
按业务确认不计采购在途。
- 配件库存查询失败时降级为空索引并标记 componentStockDegraded,
不写入缓存,避免把瞬时故障固化为错误数据。
导出与通知文案同步:导出新增「配件不足」列、补货量按同一口径输出;
缺货通知在配件不足时插入短板配件提示并把建议补货量显示为「—」。
验证:backend 相关单测全部通过(新增 9 例);语法检查通过;
前端 eslint 零错误且 vite build 通过;接口实测与页面渲染结果见 PR。
This commit is contained in:
parent
897bbd2afd
commit
f69d26796e
@ -4,6 +4,11 @@
|
||||
//
|
||||
// 业务口径(2026-09-17 业务确认):
|
||||
// ① 推送范围:缺货(可用天数 ≤ 0)+ 3 天内预警商品(默认,可在页面里改成仅缺货)
|
||||
// · 含「订单驱动」商品:近7日日销 = 0、但存在未发货订单的 MKH 成品
|
||||
// (口径与取数见 mkhBomService 文件头 ⑩;正文里以「待发货订单 N 件」代替日销展示)
|
||||
// · 含「配件不足」商品:配件现有库存可组装数 = 0(口径见 mkhBomService 文件头 ⑪)
|
||||
// —— 这类商品**补不出去**,正文里明确写「配件不足,无法补货」并列出短板配件,
|
||||
// 不给出建议补货量(否则业务会照着补不到的目标去采购)
|
||||
// ② 只提醒**新出现**的预警 —— 已经在「已推送」记录里的商品不重复推送,
|
||||
// 商品补货完成恢复到正常范围后会从记录里移除,下次再预警会重新提醒
|
||||
// ③ 收件人:单独配置(配置为空时回退环境变量 → 统一通知中心默认收件人)
|
||||
@ -123,12 +128,41 @@ function buildAlertSignature(alerts = []) {
|
||||
return crypto.createHash('sha1').update(parts.join('|'), 'utf8').digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* 短板配件文案(见 mkhBomService 文件头 ⑪):只列「可支配数 ≤ 0」的配件,
|
||||
* 即真正卡住组装的那些,最多 2 个,避免正文过长。
|
||||
*/
|
||||
function formatShortageNote(row) {
|
||||
const bottlenecks = Array.isArray(row?.bottleneckComponents) ? row.bottleneckComponents : [];
|
||||
const detail = bottlenecks
|
||||
.filter((item) => Number(item?.available) <= 0)
|
||||
.slice(0, 2)
|
||||
.map((item) => {
|
||||
const occupied = Number(item.occupied) || 0;
|
||||
return `${item.code}(库存 ${formatQty(item.stock)}`
|
||||
+ `${occupied > 0 ? ` − 加工单占用 ${formatQty(occupied)}` : ''})`;
|
||||
})
|
||||
.join('、');
|
||||
return detail ? `,短板配件:${detail}` : '';
|
||||
}
|
||||
|
||||
function describeAlert(row) {
|
||||
// 配件不足(见 mkhBomService 文件头 ⑪):可组装数 = 0 → 补不出来,
|
||||
// 建议补货量不再给数字(显示「—」),与页面口径保持一致。
|
||||
const replenishText = row.componentShortage ? '建议补货 —' : `建议补货 ${formatQty(row.replenishQty)}`;
|
||||
// 订单驱动(近7日日销=0 但有未发货订单,见 mkhBomService 文件头 ⑩):
|
||||
// 此时日销必然是 0,直接显示会让人误以为「没销量却要补货」,改展示待发货订单量
|
||||
const metric = row.orderDriven
|
||||
? `待发货订单 ${formatQty(row.pendingOrderQty)} 件 | 现有库存 ${formatQty(row.stock)} | ${replenishText}(近7日暂无日销,按订单补)`
|
||||
: `日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | ${replenishText}`;
|
||||
const lines = [
|
||||
`**${row.warningLevel === OUT_OF_STOCK ? '🔴 缺货' : '🟠 预警'}** — \`${row.skuId}\``,
|
||||
String(row.productName || '').trim() || '(无名称)',
|
||||
`日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | 建议补货 ${formatQty(row.replenishQty)}`,
|
||||
metric,
|
||||
];
|
||||
if (row.componentShortage) {
|
||||
lines.splice(2, 0, `⚠️ **配件不足,无法补货**(现有配件可组装 ${formatQty(row.assemblableQty)} 台${formatShortageNote(row)})`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@ -168,9 +202,21 @@ function buildMkhReplenishNotification({
|
||||
`---`,
|
||||
`当前共 **${totalCount}** 个 MKH 商品需要补货(缺货 ${totalOutOfStock} | ${snapshot.replenishDaysThreshold || 3}天内预警 ${totalWarningOnly})。`,
|
||||
`建议补货合计 ${formatQty(snapshot.replenishQtyTotal)} 件。`,
|
||||
Number(snapshot.orderDrivenCount) > 0
|
||||
? `其中 **${snapshot.orderDrivenCount}** 个近7日无日销、因有未发货订单(合计 ${formatQty(snapshot.pendingOrderQtyTotal)} 件)而列入预警。`
|
||||
: '',
|
||||
Number(snapshot.componentShortageCount) > 0
|
||||
? `其中 **${snapshot.componentShortageCount}** 个因**配件库存不足**(可组装数 = 0)暂时补不出来,建议补货量显示「—」,需先采购配件。`
|
||||
: '',
|
||||
snapshot.workOrderDegraded === true
|
||||
? `> ⚠️ 加工单数据源暂不可用,在途量按 0 处理,预警数量可能偏多。`
|
||||
: '',
|
||||
snapshot.pendingOrderDegraded === true
|
||||
? `> ⚠️ 待发货订单数据源暂不可用,待发货量按 0 处理,可能遗漏「无日销但有订单」的商品。`
|
||||
: '',
|
||||
snapshot.componentStockDegraded === true
|
||||
? `> ⚠️ 配件库存数据源暂不可用,本次**未做**「配件不足」判定,可能存在补不出来的商品。`
|
||||
: '',
|
||||
].filter(Boolean).join('\n\n')
|
||||
);
|
||||
|
||||
@ -211,11 +257,18 @@ async function checkMkhReplenishAlerts(options = {}) {
|
||||
outOfStockCount: snapshot.outOfStockCount,
|
||||
warningOnlyCount: snapshot.warningOnlyCount,
|
||||
replenishQtyTotal: snapshot.replenishQtyTotal,
|
||||
// 订单驱动(近7日无日销但有未发货订单)的商品数 / 待发货件数,口径见 mkhBomService 文件头 ⑩
|
||||
orderDrivenCount: snapshot.orderDrivenCount,
|
||||
pendingOrderQtyTotal: snapshot.pendingOrderQtyTotal,
|
||||
// 配件不足(可组装数 = 0)的商品数,口径见 mkhBomService 文件头 ⑪
|
||||
componentShortageCount: snapshot.componentShortageCount,
|
||||
freshCount: fresh.length,
|
||||
persistedCount: persisted.length,
|
||||
restoredCount: restored.length,
|
||||
monitorDate: snapshot.monitorDate,
|
||||
workOrderDegraded: snapshot.workOrderDegraded,
|
||||
pendingOrderDegraded: snapshot.pendingOrderDegraded,
|
||||
componentStockDegraded: snapshot.componentStockDegraded,
|
||||
};
|
||||
|
||||
if (!config.enabled) return { sent: false, reason: 'disabled', summary };
|
||||
|
||||
@ -37,29 +37,28 @@
|
||||
// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同
|
||||
// (无 SKU 条码),也不能直接当配件 BOM。
|
||||
//
|
||||
// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」)
|
||||
// ⑤ 配件仓位 —— **唯一数据源** 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」)
|
||||
// 2026-09-17 业务口径:仓位列**只认这张表**,其他来源一律不用(原来那套
|
||||
// 「库存表正数量仓(未分箱)→ 商品档案仓(暂无箱号)」的兜底链已删除 ——
|
||||
// 那不是仓位,标出来会让人以为料就在那儿)。
|
||||
// sku_id 可能带“-装箱序号”后缀(DNZ0019110N08V01-27),按基础配件编码匹配。
|
||||
// 取值是一条**四级兜底链**,宁可标注「未分箱 / 暂无箱号」也不误报具体货位:
|
||||
// 1) 真货位 bin_group / bin → 「仓名 · bin_group / bin」
|
||||
// 2) 箱号(净数量 > 0 的 pack_id) → 「仓名 · 箱号 pack_id」
|
||||
// 3) 尚未分箱 → rpt.jst_inventoryInfo 正库存仓库 → 「仓名(未分箱)」
|
||||
// 4) 连库存记录也没有 → jst_sku.other_3 商品档案仓库 → 「仓名(暂无箱号)」
|
||||
// 取不到任何一层时前端显示「未维护」。
|
||||
// · 列表展开(/detail)、导出配件明细共用 loadComponentLocations(),按编码缓存 10 分钟。
|
||||
// 取值只有两层:
|
||||
// 1) 真货位 bin_group / bin → 「仓名 · bin_group / bin」
|
||||
// 2) 箱号(净数量 > 0 的 pack_id) → 「仓名 · 箱号 pack_id」
|
||||
// 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。
|
||||
// · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(),
|
||||
// 只打一条查询,按编码缓存 10 分钟。
|
||||
// ⚠️ 关联列别踩(2026-09-17 实测):仓名必须用 `jst_warehouseInfo.Id = p.wh_id`;
|
||||
// 该表 `wms_co_id` **全表 NULL**,用 `w.wms_co_id = p.wms_co_id` 关联 85,152 行全部取不到仓名。
|
||||
// (库存表相反:`jst_inventoryInfo` 必须用 `w.wms_co_id = i.wms_co_id`,194,053 行仅 316 行无仓名。)
|
||||
// ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱**
|
||||
// (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、
|
||||
// wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**。
|
||||
// 且表内容与 MKH 配件**零交集**:正式 BOM 的 784 个配件编码在该表一个都查不到;
|
||||
// 表里存的是老型号半成品(DNZ0134xx / DNZ0199xx / DNZ0203xx,item_type=半成品)。
|
||||
// 结论:MKH 配件在第 1、2 层**必然落空**,实际显示值全部来自第 3 层
|
||||
// (jst_inventoryInfo 的实时库存仓库,如 DNZ039864N1410V01 →
|
||||
// 「简尚-崔桥工厂半成品包装项目仓(未分箱)」),需业务在聚水潭维护装箱货位后
|
||||
// 第 1、2 层才会生效。
|
||||
// 且表内容与 MKH 配件**零交集**:正式 BOM 的 715 个配件编码在该表一个都查不到
|
||||
// (精确 + `code-%` 前缀都是 0;箱表里是 DNZ0135xx/0199xx/0203xx 老型号半成品)。
|
||||
// → MKH 配件仓位列当前**必然全部显示「未维护」**,这是数据源现状,不是代码问题;
|
||||
// 需业务在聚水潭给配件做装箱/维护货位后才会自动出现(代码不用再动)。
|
||||
//
|
||||
// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。
|
||||
// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。
|
||||
//
|
||||
// ── ⑦ 补货预警(2026-09-17 业务确认口径)──────────────────────────
|
||||
// 规则:近 7 日日销 > 0 且 现有库存 < 3 天日销 → 预警补货
|
||||
@ -101,7 +100,59 @@
|
||||
// 每批结果都进 officialBomDetailCache,重复导出直接命中缓存。
|
||||
// ⚠️ products 形态**不返回 components** —— 4408 行商品若都挂上配件明细,
|
||||
// 响应会膨胀到几十 MB,而商品清单本身用不到明细。
|
||||
|
||||
//
|
||||
// ── ⑩ 待发货订单(2026-09-17 业务确认口径)────────────────────────
|
||||
// 规则:**近 7 日日销 = 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-` 打头的组合装)。
|
||||
// · 未发货判定:`send_date IS NULL AND status NOT IN ('Cancelled','Merged')`
|
||||
// (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单;
|
||||
// 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求)
|
||||
// · 待发货订单量 = Σ 该成品在未发货订单中的件数(同一单多次出现会累加)
|
||||
// · 预警级别(业务确认):现有库存 < 待发货订单量 → 缺货;否则 → 预警
|
||||
// · 建议补货量 = max(0, 待发货订单量 − 现有库存)
|
||||
// ⚠️ 该分支无法算「可用天数」(日销为 0,除数为零):
|
||||
// 缺货时记 stockDays = 0(保证排在最前),预警(库存已够发)时记 null。
|
||||
// ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)——
|
||||
// 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。
|
||||
// 实测(2026-09-17):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件,
|
||||
// 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。
|
||||
//
|
||||
// ── ⑪ 配件不足 / 可组装数(2026-09-17 业务确认口径)────────────────
|
||||
// 规则:**缺货 / 预警的 MKH 成品,若其配件现有库存「可组装数 = 0」,则提示
|
||||
// 「配件不足无法补货」**,建议补货量显示为「—」(不给出补不到的补货目标)。
|
||||
// · 可组装数 = min over 配件 ⌊ 可支配配件数 ÷ 单台用量 ⌋ (木桶短板决定)
|
||||
// 可支配配件数 = 现有库存 − 未完成加工单占用(不足 0 取 0)
|
||||
// ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件
|
||||
// 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集:
|
||||
// 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量)
|
||||
// · 配件现有库存 rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓
|
||||
// (wms_co_id = 15718196)—— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。
|
||||
// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**,
|
||||
// 跨仓正库存为 0,所以不跨仓汇总。
|
||||
// · 单台用量 rm_qty(jst_skuBomItem)实测取值为 1 / 2 / 4,必须参与计算,不能当 1。
|
||||
// ⚠️ 不能用 `jst_packInventoryInfo` 当配件库存 —— 它是「箱及仓位库存」,
|
||||
// 与 MKH 配件零交集(见 ⑤),仓位口径专用。
|
||||
// · 采购在途(purchase_qty)**不计入**可用量(业务确认:只认现有库存)。
|
||||
// 锁定列(order_lock / pick_lock / lock_qty / virtual_qty)实测几乎全为 0
|
||||
// (本页 80 个配件里仅 2 条非零且值为 1),故直接用 qty。
|
||||
// · 判定范围:**缺货 + 预警都判**(业务确认),不改变原有预警级别与排序,
|
||||
// 只额外打标记;「已排产」商品本来就不预警,不参与判定。
|
||||
// · 无 BOM 配件明细的商品 → 可组装数未知(组件缺失 ≠ 配件不足),不做判定。
|
||||
// 实测(2026-09-17):预警 26 个(缺货 9 + 预警 17)中 **10 个可组装数 = 0**
|
||||
// (缺货 6 + 预警 4);扣加工单占用前后仅 1 个商品变化(185 → 180 台),
|
||||
// 说明占用规模小但口径上必须扣。
|
||||
// ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标),
|
||||
// 接口透出 componentStockDegraded=true。
|
||||
//
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
const productDb = require('../config/productInformationDatabase');
|
||||
|
||||
@ -109,9 +160,8 @@ const DEFAULT_PAGE_SIZE = 50;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
const BOM_CACHE_TTL_MS = 2 * 60 * 1000;
|
||||
const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
// 配件仓位 + 配件库存(箱及仓位库 + 库存表)按编码统一缓存;单批编码数受 MSSQL
|
||||
// 单语句 2100 参数上限约束(每个编码最多占 2 个参数:精确匹配 + 带后缀前缀匹配),
|
||||
// 300 × 2 = 600 个参数,安全。
|
||||
// 配件仓位(只查「箱及仓位库存」)按编码缓存;单批编码数受 MSSQL 单语句 2100 参数
|
||||
// 上限约束(每个编码占 2 个参数:精确匹配 + 带后缀前缀匹配),300 × 2 = 600 个参数,安全。
|
||||
const COMPONENT_LOCATION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const COMPONENT_LOCATION_CHUNK_SIZE = 300;
|
||||
|
||||
@ -152,6 +202,28 @@ 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';
|
||||
// 不计入待发货需求的状态:Cancelled 已取消 / Merged 已合并进别的单
|
||||
const PENDING_ORDER_EXCLUDED_STATUS = Object.freeze(['Cancelled', 'Merged']);
|
||||
// 只捞含 MKH 的订单,避免全表扫描(实测 1.17M 行约 1.6s)
|
||||
const PENDING_ORDER_SKU_LIKE = '%MKH%';
|
||||
// 订单数据源异常时的降级缓存时长
|
||||
const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000;
|
||||
|
||||
// ── 配件不足 / 可组装数参数(见文件头 ⑪)────────────────────────
|
||||
// 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id
|
||||
const COMPONENT_STOCK_WMS_CO_ID = 15718196;
|
||||
const COMPONENT_STOCK_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓';
|
||||
// 单批编码数:每个编码占 1 个参数,300 远低于 MSSQL 单语句 2100 上限
|
||||
const COMPONENT_STOCK_CHUNK_SIZE = 300;
|
||||
const COMPONENT_STOCK_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
// 配件库存不可用时的降级缓存时长(短一点,尽快恢复判定)
|
||||
const COMPONENT_STOCK_DEGRADED_TTL_MS = 60 * 1000;
|
||||
// 文案 / Tooltip 里最多列出的短板配件数
|
||||
const COMPONENT_SHORTAGE_MAX_LIST = 3;
|
||||
|
||||
const toText = (value) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
const text = String(value).trim();
|
||||
@ -199,6 +271,10 @@ let mkhProductCache = null;
|
||||
let replenishIndexCache = null;
|
||||
// 加工单在途量索引(见文件头 ⑧)
|
||||
let workOrderIndexCache = null;
|
||||
// 待发货订单索引(见文件头 ⑩)
|
||||
let pendingOrderIndexCache = null;
|
||||
// 配件库存(按编码缓存,见文件头 ⑪)
|
||||
const componentStockCache = new Map();
|
||||
|
||||
const TRUTHY_FLAGS = new Set(['1', 'true', 'yes', 'on']);
|
||||
|
||||
@ -685,16 +761,21 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(
|
||||
/**
|
||||
* 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。
|
||||
*
|
||||
* 口径:近7日日销 > 0 且 现有库存 < 3 天日销 → 预警。
|
||||
* 无近7日日销的商品不参与预警,标记为「无销量」。
|
||||
* 口径(见文件头 ⑦ + ⑩):
|
||||
* ① 近7日日销 > 0 且 现有库存 < 3 天日销 → 预警
|
||||
* ② 近7日日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩)
|
||||
* 两者都不满足 → 无销量,不参与预警。
|
||||
* 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。
|
||||
*
|
||||
* @param {object} row
|
||||
* row.sevenAvgSale 近7日日均发货量
|
||||
* row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录)
|
||||
* row.monitorStock 监控快照库存(fallback)
|
||||
* row.safetyDays 源系统安全天数(仅展示)
|
||||
* row.suggestStock 源系统建议备货量(仅展示对照)
|
||||
* row.monitorDate 监控快照日期
|
||||
* row.sevenAvgSale 近7日日均发货量
|
||||
* row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录)
|
||||
* row.monitorStock 监控快照库存(fallback)
|
||||
* row.safetyDays 源系统安全天数(仅展示)
|
||||
* row.suggestStock 源系统建议备货量(仅展示对照)
|
||||
* row.monitorDate 监控快照日期
|
||||
* row.pendingWorkOrderQty 在途加工量(未完成且非作废)
|
||||
* row.pendingOrderQty 待发货订单量(未发货订单件数)
|
||||
*/
|
||||
function buildReplenishMetrics(row = {}) {
|
||||
const dailySale = Math.max(0, toNumber(row.sevenAvgSale) ?? 0);
|
||||
@ -708,6 +789,12 @@ function buildReplenishMetrics(row = {}) {
|
||||
const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0);
|
||||
const hasPendingWorkOrder = pendingWorkOrderQty > 0 || pendingWorkOrderCount > 0;
|
||||
|
||||
// 待发货订单(见文件头 ⑩):当前未发货订单里的件数
|
||||
const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0);
|
||||
const pendingOrderCount = Math.max(0, toNumber(row.pendingOrderCount) ?? 0);
|
||||
// 快照日销还是 0、但已经有订单 → 由订单驱动预警
|
||||
const orderDriven = dailySale <= 0 && pendingOrderQty > 0;
|
||||
|
||||
const base = {
|
||||
dailySale,
|
||||
stock,
|
||||
@ -716,6 +803,10 @@ function buildReplenishMetrics(row = {}) {
|
||||
hasPendingWorkOrder,
|
||||
pendingWorkOrderEta: toDateText(row.pendingWorkOrderEta),
|
||||
pendingWorkOrderNo: toText(row.pendingWorkOrderNo),
|
||||
pendingOrderQty,
|
||||
pendingOrderCount,
|
||||
pendingOrderEarliest: toDateText(row.pendingOrderEarliest),
|
||||
orderDriven,
|
||||
thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)),
|
||||
stockSource: hasRealtime ? 'realtime' : 'monitor',
|
||||
safetyDays: toNumber(row.safetyDays),
|
||||
@ -723,7 +814,8 @@ function buildReplenishMetrics(row = {}) {
|
||||
monitorDate: toDateText(row.monitorDate),
|
||||
};
|
||||
|
||||
if (dailySale <= 0) {
|
||||
// 既无近7日日销、也无未发货订单 → 不参与预警
|
||||
if (dailySale <= 0 && !orderDriven) {
|
||||
return {
|
||||
...base,
|
||||
thresholdQty: 0,
|
||||
@ -735,13 +827,11 @@ function buildReplenishMetrics(row = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
const stockDays = stock / dailySale;
|
||||
|
||||
// 已有未完成加工单 → 不再列入补货预警(业务口径,见文件头 ⑧)
|
||||
// 已有未完成加工单 → 不再列入补货预警(业务口径见文件头 ⑧;优先级高于订单驱动)
|
||||
if (hasPendingWorkOrder) {
|
||||
return {
|
||||
...base,
|
||||
stockDays: Number(stockDays.toFixed(2)),
|
||||
stockDays: dailySale > 0 ? Number((stock / dailySale).toFixed(2)) : null,
|
||||
replenishQty: 0,
|
||||
warningLevel: WARNING_LEVEL.SCHEDULED,
|
||||
warningOrder: 4,
|
||||
@ -749,6 +839,21 @@ function buildReplenishMetrics(row = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// 订单驱动(见文件头 ⑩):日销为 0 算不出可用天数,改按「能否覆盖待发货」判级
|
||||
if (orderDriven) {
|
||||
const notEnough = stock < pendingOrderQty;
|
||||
return {
|
||||
...base,
|
||||
// 缺货记 0(保证排在最前);库存已够发货时记 null(无日销,算不出天数)
|
||||
stockDays: notEnough ? 0 : null,
|
||||
replenishQty: Math.max(0, pendingOrderQty - stock),
|
||||
warningLevel: notEnough ? WARNING_LEVEL.OUT_OF_STOCK : WARNING_LEVEL.WARNING,
|
||||
warningOrder: notEnough ? 1 : 2,
|
||||
hasWarning: true,
|
||||
};
|
||||
}
|
||||
|
||||
const stockDays = stock / dailySale;
|
||||
const replenishQty = Math.max(0, Math.ceil(base.thresholdQty - stock));
|
||||
const level = stockDays <= 0
|
||||
? WARNING_LEVEL.OUT_OF_STOCK
|
||||
@ -765,6 +870,80 @@ function buildReplenishMetrics(row = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 未完成加工单对配件的占用(纯函数,见文件头 ⑪)。
|
||||
* 占用[配件编码] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量)
|
||||
* 加工单在途的成品已经把配件预定/领走,计算可组装数时必须先扣掉,否则会高估补货能力。
|
||||
* @param {Array<{skuId: string, pendingWorkOrderQty: number}>} workOrders 未完成加工单(成品级)
|
||||
* @param {Map<string, Array>} bomBySku 归一化成品编码 → 配件明细
|
||||
* @returns {Map<string, number>} 配件编码 → 被占用量
|
||||
*/
|
||||
function buildComponentOccupation(workOrders = [], bomBySku) {
|
||||
const occupation = new Map();
|
||||
if (!bomBySku || typeof bomBySku.get !== 'function') return occupation;
|
||||
|
||||
workOrders.forEach((workOrder) => {
|
||||
const pending = Math.max(0, toNumber(workOrder?.pendingWorkOrderQty) ?? 0);
|
||||
if (pending <= 0) return;
|
||||
const key = normalizeCode(workOrder.skuId);
|
||||
const components = key ? bomBySku.get(key) : null;
|
||||
if (!Array.isArray(components) || components.length === 0) return;
|
||||
components.forEach((component) => {
|
||||
const code = toText(component?.barcode) || toText(component?.code);
|
||||
if (!code) return;
|
||||
const perQty = Math.max(1, toNumber(component.qty) ?? 1);
|
||||
occupation.set(code, (occupation.get(code) || 0) + pending * perQty);
|
||||
});
|
||||
});
|
||||
|
||||
return occupation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配件可组装数 / 配件不足判定(纯函数,见文件头 ⑪)。
|
||||
* 可组装数 = min over 配件 ⌊ max(0, 库存 − 加工单占用) ÷ 单台用量 ⌋
|
||||
* 「木桶短板」决定能组装几台;可组装数 = 0 即配件不足、无法补货。
|
||||
* @param {Array} components 配件明细 [{ barcode, code, name, qty }]
|
||||
* @param {Map<string, number>} stockByCode 配件现有库存
|
||||
* @param {Map<string, number>} occupiedByCode 加工单占用
|
||||
* @returns {null | {assemblableQty:number, shortage:boolean, bottlenecks:Array}} 无配件明细时返回 null(未知 ≠ 不足)
|
||||
*/
|
||||
function buildComponentReadiness(components, stockByCode, occupiedByCode) {
|
||||
if (!Array.isArray(components) || components.length === 0) return null;
|
||||
|
||||
let assemblable = Infinity;
|
||||
const bottlenecks = [];
|
||||
components.forEach((component) => {
|
||||
const code = toText(component?.barcode) || toText(component?.code);
|
||||
if (!code) return;
|
||||
const perQty = Math.max(1, toNumber(component.qty) ?? 1);
|
||||
const stock = Math.max(0, toNumber(stockByCode?.get(code)) ?? 0);
|
||||
const occupied = Math.max(0, toNumber(occupiedByCode?.get(code)) ?? 0);
|
||||
const available = Math.max(0, stock - occupied);
|
||||
const can = Math.floor(available / perQty);
|
||||
if (can < assemblable) assemblable = can;
|
||||
bottlenecks.push({
|
||||
code,
|
||||
name: toText(component.name) || code,
|
||||
perQty,
|
||||
stock,
|
||||
occupied,
|
||||
available,
|
||||
can,
|
||||
});
|
||||
});
|
||||
|
||||
if (!Number.isFinite(assemblable)) return null;
|
||||
// 短板优先(可组装数最少),同分按编码稳定排序
|
||||
bottlenecks.sort((a, b) => a.can - b.can || a.code.localeCompare(b.code));
|
||||
|
||||
return {
|
||||
assemblableQty: assemblable,
|
||||
shortage: assemblable <= 0,
|
||||
bottlenecks: bottlenecks.slice(0, COMPONENT_SHORTAGE_MAX_LIST),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MKH 商品集合(指定仓库的模块化成品)—— 进程内缓存。
|
||||
* 返回 [{ skuId, productName, category, material }],按编码升序。
|
||||
@ -843,6 +1022,8 @@ async function loadPendingWorkOrderIndex() {
|
||||
const orderCount = Math.max(0, toNumber(row.orderCount) ?? 0);
|
||||
if (pendingQty <= 0 && orderCount <= 0) return;
|
||||
bySku.set(key, {
|
||||
// 原始商品编码:配件占用展开(见文件头 ⑪)需要拿它回查 BOM,故一并保留
|
||||
skuId: toText(row.skuId),
|
||||
pendingWorkOrderQty: pendingQty,
|
||||
pendingWorkOrderCount: orderCount,
|
||||
pendingWorkOrderEta: row.eta instanceof Date ? row.eta : null,
|
||||
@ -866,6 +1047,110 @@ async function loadPendingWorkOrderIndex() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 jst_Orders.skus 文本列 → [{ code, qty }](纯函数,便于回归测试)。
|
||||
* 格式:`<总件数>.<条码>[*数量][,<条码>[*数量]]…`
|
||||
* 例:`1.MKHDNZ0409260700426` → 1 个成品 1 件
|
||||
* `2.A*1,B*1` → A、B 各 1 件
|
||||
* `3.C*1,D*1,E*1` → 总件数前缀只作校验,不参与计算
|
||||
* · 省略 `*数量` 时按 1 件;解析不了的片段直接丢弃(可用率实测见文件头 ⑩)
|
||||
*/
|
||||
function parseOrderSkus(skus) {
|
||||
const text = toText(skus);
|
||||
if (!text) return [];
|
||||
const body = text.replace(/^\d+\./, '');
|
||||
const items = [];
|
||||
body.split(',').forEach((part) => {
|
||||
const segment = part.trim();
|
||||
if (!segment) return;
|
||||
const matched = segment.match(/^(.*?)(?:\*(\d+))?$/);
|
||||
if (!matched) return;
|
||||
const code = toText(matched[1]);
|
||||
if (!code) return;
|
||||
const parsedQty = matched[2] ? Number(matched[2]) : 1;
|
||||
items.push({ code, qty: Number.isFinite(parsedQty) && parsedQty > 0 ? parsedQty : 1 });
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 是否为 MKH **成品**编码(`MKH-` 打头的是组合装,见文件头 ⑩,不计入成品需求) */
|
||||
const isMkhProductCode = (code) => {
|
||||
const text = toText(code);
|
||||
return Boolean(text) && /^MKH/i.test(text) && !/^MKH-/i.test(text);
|
||||
};
|
||||
|
||||
/**
|
||||
* 待发货订单索引 —— 进程内缓存
|
||||
* { bySku: Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }>, degraded }。
|
||||
* 口径见文件头 ⑩:未发货 = send_date 为空且状态非取消/合并,商品取自主表 skus 文本列。
|
||||
* ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。
|
||||
*/
|
||||
async function loadPendingOrderIndex() {
|
||||
const now = Date.now();
|
||||
if (pendingOrderIndexCache?.value && pendingOrderIndexCache.expiresAt > now) {
|
||||
return pendingOrderIndexCache.value;
|
||||
}
|
||||
if (pendingOrderIndexCache?.pending) return pendingOrderIndexCache.pending;
|
||||
|
||||
const statusParams = {};
|
||||
const statusPlaceholders = PENDING_ORDER_EXCLUDED_STATUS.map((status, index) => {
|
||||
statusParams[`pendingOrderStatus${index}`] = status;
|
||||
return `@pendingOrderStatus${index}`;
|
||||
}).join(', ');
|
||||
|
||||
const pending = cloudDatabase.query(
|
||||
`SELECT o.o_id AS oId,
|
||||
o.order_date AS orderDate,
|
||||
o.skus AS skus
|
||||
FROM ${PENDING_ORDER_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) => {
|
||||
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;
|
||||
}
|
||||
bySku.set(key, entry);
|
||||
});
|
||||
});
|
||||
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 };
|
||||
try {
|
||||
const value = await pending;
|
||||
pendingOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
|
||||
return value;
|
||||
} catch (error) {
|
||||
pendingOrderIndexCache = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 补货预警索引 —— 进程内缓存 { bySku: Map<归一化编码, 指标>, monitorDate }。
|
||||
* 一次读入品牌仓监控快照与实时库存,指标计算全部在应用层完成(原因见文件头 ⑦)。
|
||||
@ -899,8 +1184,11 @@ async function loadReplenishIndex() {
|
||||
),
|
||||
// 在途加工单(见文件头 ⑧)
|
||||
loadPendingWorkOrderIndex(),
|
||||
]).then(([monitorRows, stockRows, workOrderIndex]) => {
|
||||
// 待发货订单(见文件头 ⑩)
|
||||
loadPendingOrderIndex(),
|
||||
]).then(([monitorRows, stockRows, workOrderIndex, pendingOrderIndex]) => {
|
||||
const workOrderBySku = workOrderIndex.bySku;
|
||||
const pendingOrderBySku = pendingOrderIndex.bySku;
|
||||
const latestStock = new Map();
|
||||
stockRows.forEach((row) => {
|
||||
const key = normalizeCode(row.skuId);
|
||||
@ -924,13 +1212,16 @@ async function loadReplenishIndex() {
|
||||
suggestStock: row.suggestStock,
|
||||
monitorDate: row.monitorDate,
|
||||
...(workOrderBySku.get(key) || null),
|
||||
...(pendingOrderBySku.get(key) || null),
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
bySku,
|
||||
workOrderBySku,
|
||||
pendingOrderBySku,
|
||||
workOrderDegraded: workOrderIndex.degraded === true,
|
||||
pendingOrderDegraded: pendingOrderIndex.degraded === true,
|
||||
monitorDate,
|
||||
};
|
||||
});
|
||||
@ -946,6 +1237,112 @@ async function loadReplenishIndex() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。
|
||||
* · 按基础配件编码精确匹配,SUM(qty) 汇总
|
||||
* · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存)
|
||||
* · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标),
|
||||
* 且失败的分片不写缓存,下次调用重试
|
||||
* @returns {Promise<{stockMap: Map<string, number>, degraded: boolean}>}
|
||||
*/
|
||||
async function loadComponentStock(codes = []) {
|
||||
const now = Date.now();
|
||||
const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean)));
|
||||
const stockMap = new Map();
|
||||
const missing = [];
|
||||
unique.forEach((code) => {
|
||||
const hit = componentStockCache.get(code);
|
||||
if (hit && hit.expiresAt > now) stockMap.set(code, hit.qty);
|
||||
else missing.push(code);
|
||||
});
|
||||
if (missing.length === 0) return { stockMap, degraded: false };
|
||||
|
||||
for (let i = 0; i < missing.length; i += COMPONENT_STOCK_CHUNK_SIZE) {
|
||||
const chunk = missing.slice(i, i + COMPONENT_STOCK_CHUNK_SIZE);
|
||||
try {
|
||||
const params = { componentStockWmsCoId: COMPONENT_STOCK_WMS_CO_ID };
|
||||
const placeholders = chunk.map((code, index) => {
|
||||
params[`cs${index}`] = code;
|
||||
return `@cs${index}`;
|
||||
});
|
||||
// eslint-disable-next-line no-await-in-loop -- 有意分片串行,避免一次打满连接池
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT i.sku_id AS code, SUM(i.qty) AS qty
|
||||
FROM dbo.jst_inventoryInfo i WITH (NOLOCK)
|
||||
WHERE i.wms_co_id = @componentStockWmsCoId
|
||||
AND i.sku_id IN (${placeholders.join(', ')})
|
||||
GROUP BY i.sku_id`,
|
||||
params,
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
const found = new Map();
|
||||
rows.forEach((row) => {
|
||||
const code = toText(row.code);
|
||||
if (code) found.set(code, toNumber(row.qty) ?? 0);
|
||||
});
|
||||
const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS;
|
||||
chunk.forEach((code) => {
|
||||
const qty = found.get(code) ?? 0;
|
||||
componentStockCache.set(code, { qty, expiresAt });
|
||||
stockMap.set(code, qty);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[MKH-BOM] 配件库存查询失败,本次不做配件不足判定:', error.message);
|
||||
return { stockMap, degraded: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { stockMap, degraded: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量计算商品的可组装数(见文件头 ⑪):列表 / 推送共用,防止口径漂移。
|
||||
* 依赖三份已缓存的索引,本身不再单独缓存:
|
||||
* · 成品 BOM 明细 —— loadOfficialBomForSkuBatches(按 SKU 缓存 15 分钟)
|
||||
* · 配件现有库存 —— loadComponentStock(按编码缓存 10 分钟)
|
||||
* · 加工单占用 —— loadPendingWorkOrderIndex(缓存 10 分钟)按 BOM 展开
|
||||
* @param {string[]} skuIds 成品编码(只传缺货 / 预警的商品,避免全量扫描)
|
||||
* @returns {Promise<{bySku: Map<string, object>, degraded: boolean}>} key = 归一化成品编码
|
||||
*/
|
||||
async function loadMkhComponentReadiness(skuIds = []) {
|
||||
const requested = Array.from(new Set(skuIds.map((skuId) => toText(skuId)).filter(Boolean)));
|
||||
if (requested.length === 0) return { bySku: new Map(), degraded: false };
|
||||
|
||||
const [alertBom, workOrderIndex] = await Promise.all([
|
||||
loadOfficialBomForSkuBatches(requested),
|
||||
loadPendingWorkOrderIndex(),
|
||||
]);
|
||||
|
||||
// 配件全集:预警商品自己的配件 + 加工单商品的配件(后者用于算占用)
|
||||
const workOrders = [...workOrderIndex.bySku.values()];
|
||||
const workOrderSkuIds = workOrders.map((item) => item.skuId).filter(Boolean);
|
||||
const workOrderBom = workOrderSkuIds.length > 0
|
||||
? await loadOfficialBomForSkuBatches(workOrderSkuIds)
|
||||
: { bySku: new Map() };
|
||||
|
||||
const occupation = buildComponentOccupation(workOrders, workOrderBom.bySku);
|
||||
|
||||
const codes = new Set(occupation.keys());
|
||||
alertBom.bySku.forEach((components) => {
|
||||
components.forEach((component) => {
|
||||
const code = toText(component?.barcode) || toText(component?.code);
|
||||
if (code) codes.add(code);
|
||||
});
|
||||
});
|
||||
|
||||
const { stockMap, degraded } = await loadComponentStock([...codes]);
|
||||
// 配件库存不可用 → 整批放弃判定(组件缺失 ≠ 配件不足,误标会误导业务)
|
||||
if (degraded) return { bySku: new Map(), degraded: true };
|
||||
|
||||
const bySku = new Map();
|
||||
alertBom.bySku.forEach((components, key) => {
|
||||
const readiness = buildComponentReadiness(components, stockMap, occupation);
|
||||
if (readiness) bySku.set(key, readiness);
|
||||
});
|
||||
|
||||
return { bySku, degraded: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* MKH 商品 × 库存/日销指标 × 在途加工单(内存合并)。
|
||||
* 列表接口与补货预警推送共用同一份合并结果,避免两处口径漂移。
|
||||
@ -960,14 +1357,36 @@ async function buildMergedReplenishRows() {
|
||||
const key = normalizeCode(product.skuId);
|
||||
const metrics = replenishIndex.bySku.get(key);
|
||||
if (metrics) return { ...product, ...metrics };
|
||||
// 监控快照里没有该商品时,仍要带上在途加工单信息(见文件头 ⑧)
|
||||
// 监控快照里没有该商品时,仍要带上在途加工单(⑧)与待发货订单(⑩)信息
|
||||
return {
|
||||
...product,
|
||||
...buildReplenishMetrics(replenishIndex.workOrderBySku.get(key) || {}),
|
||||
...buildReplenishMetrics({
|
||||
...(replenishIndex.workOrderBySku?.get(key) || null),
|
||||
...(replenishIndex.pendingOrderBySku?.get(key) || null),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return { rows, replenishIndex };
|
||||
// 配件可组装数 / 配件不足(见文件头 ⑪):只对「缺货 / 预警」的商品计算,
|
||||
// 「已排产」「无销量」等本来不需要补货的商品不参与,避免无谓查询。
|
||||
const alertSkuIds = rows.filter((row) => row.hasWarning).map((row) => row.skuId);
|
||||
const readiness = alertSkuIds.length > 0
|
||||
? await loadMkhComponentReadiness(alertSkuIds)
|
||||
: { bySku: new Map(), degraded: false };
|
||||
|
||||
rows.forEach((row) => {
|
||||
const hit = row.hasWarning ? readiness.bySku.get(normalizeCode(row.skuId)) : null;
|
||||
// 未判定(非预警 / 无 BOM 明细 / 配件库存降级)一律保持 false + null,前端据此显示「—」
|
||||
row.componentShortage = hit?.shortage === true;
|
||||
row.assemblableQty = hit ? hit.assemblableQty : null;
|
||||
row.bottleneckComponents = hit ? hit.bottlenecks : [];
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
replenishIndex,
|
||||
componentStockDegraded: readiness.degraded === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -982,7 +1401,7 @@ async function listReplenishAlertRows(options = {}) {
|
||||
? [WARNING_LEVEL.OUT_OF_STOCK]
|
||||
: [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING];
|
||||
|
||||
const { rows, replenishIndex } = await buildMergedReplenishRows();
|
||||
const { rows, replenishIndex, componentStockDegraded } = await buildMergedReplenishRows();
|
||||
const alerts = rows.filter((row) => levels.includes(row.warningLevel));
|
||||
|
||||
// 最紧急的排前面:缺货 → 可用天数最少 → 编码升序
|
||||
@ -1000,8 +1419,17 @@ async function listReplenishAlertRows(options = {}) {
|
||||
replenishQtyTotal: Number(
|
||||
alerts.reduce((sum, row) => sum + (toNumber(row.replenishQty) || 0), 0).toFixed(2)
|
||||
),
|
||||
// 订单驱动预警(日销=0 但有未发货订单)的条数,见文件头 ⑩
|
||||
orderDrivenCount: alerts.filter((row) => row.orderDriven).length,
|
||||
pendingOrderQtyTotal: Number(
|
||||
alerts.reduce((sum, row) => sum + (toNumber(row.pendingOrderQty) || 0), 0).toFixed(2)
|
||||
),
|
||||
monitorDate: replenishIndex.monitorDate,
|
||||
workOrderDegraded: replenishIndex.workOrderDegraded === true,
|
||||
pendingOrderDegraded: replenishIndex.pendingOrderDegraded === true,
|
||||
// 配件不足(见文件头 ⑪):可组装数 = 0、补不出货的商品数
|
||||
componentShortageCount: alerts.filter((row) => row.componentShortage).length,
|
||||
componentStockDegraded: componentStockDegraded === true,
|
||||
replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD,
|
||||
saleWindowDays: SALE_WINDOW_DAYS,
|
||||
scope: scope === 'outOfStock' ? 'outOfStock' : 'warning',
|
||||
@ -1051,113 +1479,40 @@ function buildPackLocationMap(rows, componentCodes) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildInventoryWarehouseMap(rows, componentCodes) {
|
||||
const positiveWarehouses = new Map(componentCodes.map((code) => [code, new Set()]));
|
||||
const defaultWarehouses = new Map();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const componentCode = componentCodes.find((code) => normalizeCode(code) === normalizeCode(row.skuId));
|
||||
if (!componentCode) return;
|
||||
|
||||
const defaultWarehouse = toText(row.defaultWarehouse);
|
||||
if (defaultWarehouse) defaultWarehouses.set(componentCode, defaultWarehouse);
|
||||
|
||||
const warehouseName = toText(row.warehouseName);
|
||||
if (warehouseName && (toNumber(row.availableQty) ?? 0) > 0) {
|
||||
positiveWarehouses.get(componentCode).add(warehouseName);
|
||||
}
|
||||
});
|
||||
|
||||
const result = new Map();
|
||||
componentCodes.forEach((code) => {
|
||||
const warehouses = [...positiveWarehouses.get(code)];
|
||||
if (warehouses.length > 0) {
|
||||
result.set(code, `${warehouses.join('、')}(未分箱)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultWarehouse = defaultWarehouses.get(code);
|
||||
if (defaultWarehouse) result.set(code, `${defaultWarehouse}(暂无箱号)`);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配件仓位 + 配件库存批量查询(列表展开 / 单品明细 / 导出配件明细共用)。
|
||||
* 数据源:rpt.jst_inventoryInfo(库存,及「未分箱 / 暂无箱号」兜底仓)、
|
||||
* rpt.jst_packInventoryInfo(箱及仓位库)、jst_sku.other_3(商品档案仓)
|
||||
* 配件仓位批量查询(列表展开 / 单品明细 / 导出配件明细共用)。
|
||||
* 数据源**只有一个**:rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」),
|
||||
* 仓名左连 rpt.jst_warehouseInfo(`Id = wh_id`,见文件头 ⑤ 的关联列警告)。
|
||||
* · 按基础配件编码匹配,兼容库内 sku_id 带「-装箱序号」后缀的形态
|
||||
* · 仓位走文件头 ⑤ 的四级兜底链:真货位 → 箱号 → 库存仓(未分箱)→ 档案仓(暂无箱号)
|
||||
* · 取值只有两层:真货位 bin_group / bin → 箱号 pack_id;
|
||||
* 该表查不到就是空,**不回退**库存表仓 / 商品档案仓(那不是仓位)
|
||||
* · 单个配件跨多个箱时最多列出 10 个位置,超出补「等 N 个位置」
|
||||
* · 结果按编码缓存(TTL 10 分钟);**两条查询都成功才写缓存**,
|
||||
* 任一降级都不写,避免把不完整结果固化 10 分钟
|
||||
* · 单元格超限由 COMPONENT_LOCATION_CHUNK_SIZE 分片,见常量处说明
|
||||
* @returns {Promise<{stockMap: Map<string, number>, locationMap: Map<string, string>}>}
|
||||
* · 结果按编码缓存(TTL 10 分钟);查询失败**不写缓存**,下次调用重试
|
||||
* @returns {Promise<Map<string, string>>} 配件编码 → 仓位文本(查不到的编码不含 key)
|
||||
*/
|
||||
async function loadComponentLocationData(codes = []) {
|
||||
async function loadComponentLocations(codes = []) {
|
||||
const now = Date.now();
|
||||
const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean)));
|
||||
const stockMap = new Map();
|
||||
const locationMap = new Map();
|
||||
const missing = [];
|
||||
unique.forEach((code) => {
|
||||
const hit = componentLocationCache.get(normalizeCode(code));
|
||||
const hit = componentLocationCache.get(code);
|
||||
if (hit && hit.expiresAt > now) {
|
||||
stockMap.set(code, hit.stock ?? 0);
|
||||
if (hit.location) locationMap.set(code, hit.location);
|
||||
} else {
|
||||
missing.push(code);
|
||||
}
|
||||
});
|
||||
if (missing.length === 0) return { stockMap, locationMap };
|
||||
if (missing.length === 0) return locationMap;
|
||||
|
||||
// 单元格超限由 COMPONENT_LOCATION_CHUNK_SIZE 分片,见常量处说明
|
||||
const chunks = [];
|
||||
for (let i = 0; i < missing.length; i += COMPONENT_LOCATION_CHUNK_SIZE) {
|
||||
chunks.push(missing.slice(i, i + COMPONENT_LOCATION_CHUNK_SIZE));
|
||||
}
|
||||
|
||||
// 组内串行(单个接口最多同时打两条查询,避免拖垮连接池)
|
||||
// 组间串行(本函数只打一条查询,避免一次塞满连接池)
|
||||
for (const chunk of chunks) {
|
||||
const packLocations = new Map(); // 第 1、2 层:真货位 / 箱号
|
||||
let fallbackLocations = new Map(); // 第 3、4 层:未分箱 / 暂无箱号
|
||||
let stockQuerySucceeded = false;
|
||||
let packQuerySucceeded = false;
|
||||
|
||||
// ① 配件库存 + 仓名 —— 一次查询同时供 stockMap 与兜底仓位使用
|
||||
try {
|
||||
const parameters = {};
|
||||
const placeholders = chunk.map((code, index) => {
|
||||
parameters[`c${index}`] = code;
|
||||
return `@c${index}`;
|
||||
}).join(', ');
|
||||
const stockRows = await cloudDatabase.query(
|
||||
`SELECT s.sku_id AS skuId,
|
||||
s.other_3 AS defaultWarehouse,
|
||||
w.name AS warehouseName,
|
||||
SUM(COALESCE(i.qty, 0)) AS qty,
|
||||
SUM(COALESCE(i.qty, 0)
|
||||
- COALESCE(i.order_lock, 0)
|
||||
- COALESCE(i.pick_lock, 0)) AS availableQty
|
||||
FROM dbo.jst_sku s WITH (NOLOCK)
|
||||
LEFT JOIN rpt.jst_inventoryInfo i WITH (NOLOCK) ON i.sku_id = s.sku_id
|
||||
LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = i.wms_co_id
|
||||
WHERE s.sku_id IN (${placeholders})
|
||||
GROUP BY s.sku_id, s.other_3, i.wms_co_id, w.name`,
|
||||
parameters,
|
||||
{ requestTimeout: 180000 }
|
||||
);
|
||||
stockRows.forEach((row) => {
|
||||
const code = chunk.find((candidate) => normalizeCode(candidate) === normalizeCode(row.skuId));
|
||||
if (!code) return;
|
||||
stockMap.set(code, (stockMap.get(code) || 0) + (toNumber(row.qty) ?? 0));
|
||||
});
|
||||
fallbackLocations = buildInventoryWarehouseMap(stockRows, chunk);
|
||||
stockQuerySucceeded = true;
|
||||
} catch (error) {
|
||||
console.warn('[MkhBom] 配件库存查询降级:', error.message);
|
||||
}
|
||||
|
||||
// ② 箱及仓位(第 1、2 层)—— 仓名必须用 jst_warehouseInfo.Id = p.wh_id,见文件头 ⑤
|
||||
try {
|
||||
const params = {};
|
||||
const conditions = chunk.map((code, index) => {
|
||||
@ -1165,14 +1520,14 @@ async function loadComponentLocationData(codes = []) {
|
||||
params[`lp${index}`] = `${code}-%`;
|
||||
return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`;
|
||||
});
|
||||
const locRows = await cloudDatabase.query(
|
||||
`SELECT p.sku_id AS skuId,
|
||||
p.pack_id AS packId,
|
||||
p.wh_id AS warehouseId,
|
||||
p.bin AS bin,
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT p.sku_id AS skuId,
|
||||
p.pack_id AS packId,
|
||||
p.wh_id AS warehouseId,
|
||||
p.bin AS bin,
|
||||
p.bin_group AS binGroup,
|
||||
w.name AS warehouseName,
|
||||
SUM(p.qty) AS qty
|
||||
w.name AS warehouseName,
|
||||
SUM(p.qty) AS qty
|
||||
FROM rpt.jst_packInventoryInfo p WITH (NOLOCK)
|
||||
LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.Id = p.wh_id
|
||||
WHERE ${conditions.join(' OR ')}
|
||||
@ -1181,36 +1536,19 @@ async function loadComponentLocationData(codes = []) {
|
||||
params,
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
buildPackLocationMap(locRows, chunk).forEach((location, code) => {
|
||||
packLocations.set(code, location);
|
||||
const byCode = buildPackLocationMap(rows, chunk);
|
||||
const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS;
|
||||
chunk.forEach((code) => {
|
||||
const location = byCode.get(code) || null;
|
||||
componentLocationCache.set(code, { location, expiresAt });
|
||||
if (location) locationMap.set(code, location);
|
||||
});
|
||||
packQuerySucceeded = true;
|
||||
} catch (error) {
|
||||
console.warn('[MkhBom] 配件箱号/仓位查询降级:', error.message);
|
||||
// 失败不写缓存,下次调用重试
|
||||
console.warn('[MkhBom] 配件仓位查询降级:', error.message);
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS;
|
||||
chunk.forEach((code) => {
|
||||
const location = packLocations.get(code) || fallbackLocations.get(code) || null;
|
||||
if (stockQuerySucceeded && packQuerySucceeded) {
|
||||
componentLocationCache.set(normalizeCode(code), {
|
||||
stock: stockMap.get(code) ?? 0,
|
||||
location,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
if (location) locationMap.set(code, location);
|
||||
});
|
||||
}
|
||||
|
||||
return { stockMap, locationMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容入口:只要仓位映射(列表展开 / 导出配件明细)。
|
||||
*/
|
||||
async function loadComponentLocations(codes = []) {
|
||||
const { locationMap } = await loadComponentLocationData(codes);
|
||||
return locationMap;
|
||||
}
|
||||
|
||||
@ -1235,7 +1573,7 @@ async function getMkhBomList(query = {}) {
|
||||
loadPgBom(),
|
||||
]);
|
||||
// 商品 × 库存/日销指标 × 在途加工单(内存合并,与补货预警推送共用)
|
||||
const { rows: merged, replenishIndex } = mergedResult;
|
||||
const { rows: merged, replenishIndex, componentStockDegraded } = mergedResult;
|
||||
|
||||
// 全量统计(不受关键词 / 范围筛选影响)
|
||||
const warningRows = merged.filter((row) => row.hasWarning);
|
||||
@ -1250,6 +1588,14 @@ async function getMkhBomList(query = {}) {
|
||||
pendingWorkOrderQtyTotal: Number(
|
||||
merged.reduce((sum, row) => sum + (toNumber(row.pendingWorkOrderQty) || 0), 0).toFixed(2)
|
||||
),
|
||||
// 待发货订单(见文件头 ⑩):有未发货订单的成品数 / 件数,以及其中「靠订单才预警」的数量
|
||||
pendingOrderSkuCount: merged.filter((row) => (toNumber(row.pendingOrderQty) || 0) > 0).length,
|
||||
pendingOrderQtyTotal: Number(
|
||||
merged.reduce((sum, row) => sum + (toNumber(row.pendingOrderQty) || 0), 0).toFixed(2)
|
||||
),
|
||||
orderDrivenCount: warningRows.filter((row) => row.orderDriven).length,
|
||||
// 配件不足(见文件头 ⑪):可组装数 = 0,即现有配件凑不出一台、补不出来的商品数
|
||||
componentShortageCount: warningRows.filter((row) => row.componentShortage).length,
|
||||
monitorDate: replenishIndex.monitorDate,
|
||||
};
|
||||
|
||||
@ -1296,6 +1642,11 @@ async function getMkhBomList(query = {}) {
|
||||
// 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理
|
||||
workOrderDegraded: replenishIndex.workOrderDegraded === true,
|
||||
workOrderWarehouse: WORK_ORDER_WAREHOUSE,
|
||||
// 待发货订单数据源不可用时为 true,此时待发货量按 0 处理(退化为旧口径)
|
||||
pendingOrderDegraded: replenishIndex.pendingOrderDegraded === true,
|
||||
// 配件库存数据源不可用时为 true,此时**不做**配件不足判定(见文件头 ⑪)
|
||||
componentStockDegraded: componentStockDegraded === true,
|
||||
componentStockWarehouse: COMPONENT_STOCK_WAREHOUSE,
|
||||
warningInPage: rows.filter((row) => row.hasWarning).length,
|
||||
};
|
||||
}
|
||||
@ -1341,9 +1692,8 @@ async function getMkhBomDetail(mkhSkuId) {
|
||||
const components = matched?.components || [];
|
||||
|
||||
const codes = components.map((item) => item.barcode).filter(Boolean);
|
||||
// 与列表展开 / 导出配件明细走同一个批量加载器 ——
|
||||
// 含仓名解析、文件头 ⑤ 的四级兜底链、库存与仓位的统一 10 分钟缓存
|
||||
const { stockMap, locationMap } = await loadComponentLocationData(codes);
|
||||
// 与列表展开 / 导出配件明细走同一个加载器:只查「箱及仓位库存」,见文件头 ⑤
|
||||
const locationMap = await loadComponentLocations(codes);
|
||||
|
||||
const items = components.map((component, index) => ({
|
||||
index: index + 1,
|
||||
@ -1353,7 +1703,6 @@ async function getMkhBomDetail(mkhSkuId) {
|
||||
role: component.role,
|
||||
qty: toNumber(component.qty) ?? 0,
|
||||
location: locationMap.get(component.barcode) || null,
|
||||
stock: stockMap.get(component.barcode) ?? 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
@ -1417,8 +1766,12 @@ function buildProductExportRows(matchedRows = []) {
|
||||
dailySale: toNumber(row.dailySale) ?? 0,
|
||||
stock: toNumber(row.stock) ?? 0,
|
||||
pendingWorkOrderQty: toNumber(row.pendingWorkOrderQty) ?? 0,
|
||||
pendingOrderQty: toNumber(row.pendingOrderQty) ?? 0,
|
||||
stockDays: toNumber(row.stockDays),
|
||||
replenishQty: toNumber(row.replenishQty) ?? 0,
|
||||
// 配件不足(见文件头 ⑪):可组装数 = 0 → 现有配件凑不出一台,标注出来避免业务照着补
|
||||
componentShortage: row.componentShortage === true ? '配件不足' : '',
|
||||
assemblableQty: toNumber(row.assemblableQty),
|
||||
bomConfigured: components.length > 0 ? '已配置' : '未配置',
|
||||
accessoryCount: components.length,
|
||||
accessoryQtySum: sumQty(components),
|
||||
@ -1535,13 +1888,24 @@ module.exports = {
|
||||
matchesPackSku,
|
||||
formatPackLocation,
|
||||
buildPackLocationMap,
|
||||
buildInventoryWarehouseMap,
|
||||
loadComponentLocations,
|
||||
loadComponentLocationData,
|
||||
COMPONENT_LOCATION_CHUNK_SIZE,
|
||||
COMPONENT_LOCATION_CACHE_TTL_MS,
|
||||
buildReplenishMetrics,
|
||||
loadPendingWorkOrderIndex,
|
||||
loadPendingOrderIndex,
|
||||
parseOrderSkus,
|
||||
isMkhProductCode,
|
||||
PENDING_ORDER_EXCLUDED_STATUS,
|
||||
// ⑪ 配件不足 / 可组装数
|
||||
buildComponentOccupation,
|
||||
buildComponentReadiness,
|
||||
loadComponentStock,
|
||||
loadMkhComponentReadiness,
|
||||
COMPONENT_STOCK_WMS_CO_ID,
|
||||
COMPONENT_STOCK_WAREHOUSE,
|
||||
COMPONENT_STOCK_CHUNK_SIZE,
|
||||
COMPONENT_SHORTAGE_MAX_LIST,
|
||||
parseFlag,
|
||||
resolveScope,
|
||||
filterRows,
|
||||
|
||||
@ -165,19 +165,24 @@ test('pack inventory location prefers bins and falls back to positive-stock box
|
||||
assert.equal(locations.get('PART-1'), '半成品仓 · 成品区 / A-01、仓库 1 · 箱号 BOX-2');
|
||||
});
|
||||
|
||||
test('inventory warehouse fallback distinguishes unboxed stock from a warehouse with no box record', () => {
|
||||
const locations = _private.buildInventoryWarehouseMap([
|
||||
{
|
||||
skuId: 'PART-1', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 12,
|
||||
},
|
||||
{
|
||||
skuId: 'PART-2', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 0,
|
||||
},
|
||||
], ['PART-1', 'PART-2', 'PART-3']);
|
||||
// ── 配件仓位:只认「箱及仓位库存」,不做库存表/商品档案仓回退(2026-09-17 业务口径)──
|
||||
test('component location is sourced only from the pack inventory table', () => {
|
||||
assert.equal(typeof _private.loadComponentLocations, 'function');
|
||||
// 兜底链已删除:这两个符号不应再存在(存在即有代码走回退)
|
||||
assert.equal(_private.loadComponentLocationData, undefined);
|
||||
assert.equal(_private.buildInventoryWarehouseMap, undefined);
|
||||
});
|
||||
|
||||
assert.equal(locations.get('PART-1'), '实际库存仓(未分箱)');
|
||||
assert.equal(locations.get('PART-2'), '默认半成品仓(暂无箱号)');
|
||||
assert.equal(locations.has('PART-3'), false);
|
||||
test('pack inventory without bin/bin_group falls back to the box id, never to a warehouse', () => {
|
||||
const locations = _private.buildPackLocationMap([
|
||||
{
|
||||
skuId: 'PART-1-1', warehouseId: 1, warehouseName: '半成品仓', binGroup: null, bin: null, packId: 'BOX-9', qty: 3,
|
||||
},
|
||||
], ['PART-1', 'PART-2']);
|
||||
|
||||
assert.equal(locations.get('PART-1'), '半成品仓 · 箱号 BOX-9');
|
||||
// 该表没有记录 → 无仓位,交给前端显示「未维护」,不回退任何仓库
|
||||
assert.equal(locations.has('PART-2'), false);
|
||||
});
|
||||
|
||||
// ── 补货预警:近 7 日日销 > 0 且 现有库存 < 3 天日销 ────────────────
|
||||
@ -342,6 +347,107 @@ test('pending work order filters keep the business-confirmed source and exclusio
|
||||
assert.equal(_private.WARNING_LEVEL.SCHEDULED, '已排产');
|
||||
});
|
||||
|
||||
// ── 待发货订单(见服务文件头 ⑩)──────────────────────────────────
|
||||
test('pending order: jst_Orders.skus text column is parsed into code/quantity pairs', () => {
|
||||
// 单商品、省略 *数量
|
||||
assert.deepEqual(_private.parseOrderSkus('1.MKHDNZ0409260700426'), [
|
||||
{ code: 'MKHDNZ0409260700426', qty: 1 },
|
||||
]);
|
||||
// 多商品、每个带 *数量(总件数前缀 2. 不参与计算)
|
||||
assert.deepEqual(_private.parseOrderSkus('2.A*1,B*1'), [
|
||||
{ code: 'A', qty: 1 },
|
||||
{ code: 'B', qty: 1 },
|
||||
]);
|
||||
// 同一单里成品与组合装混排:文本照常解析,是否成品由 isMkhProductCode 判定
|
||||
assert.deepEqual(
|
||||
_private.parseOrderSkus('3.GBGJ-ZWJ024903AN1407V01*1,GBGJ-ZWJ024903BN1407V01*1,MKHDNZ0409260600022*1'),
|
||||
[
|
||||
{ code: 'GBGJ-ZWJ024903AN1407V01', qty: 1 },
|
||||
{ code: 'GBGJ-ZWJ024903BN1407V01', qty: 1 },
|
||||
{ code: 'MKHDNZ0409260600022', qty: 1 },
|
||||
]
|
||||
);
|
||||
// 空值 / 空汇总(实测 23% 的普通订单是 `0.`,必须安全返回空数组)
|
||||
assert.deepEqual(_private.parseOrderSkus('0.'), []);
|
||||
assert.deepEqual(_private.parseOrderSkus(''), []);
|
||||
assert.deepEqual(_private.parseOrderSkus(null), []);
|
||||
assert.deepEqual(_private.parseOrderSkus('2.MKHDNZ0409260601388*2'), [
|
||||
{ code: 'MKHDNZ0409260601388', qty: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('pending order: only MKH finished products count, combination codes are excluded', () => {
|
||||
assert.equal(_private.isMkhProductCode('MKHDNZ0409260700426'), true);
|
||||
assert.equal(_private.isMkhProductCode('MKHSJ0425080100098'), true);
|
||||
// `MKH-` 打头的是组合装(拼接码),不是成品,不计入成品需求
|
||||
assert.equal(_private.isMkhProductCode('MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1'), false);
|
||||
assert.equal(_private.isMkhProductCode('GBGJ-ZWJ024903AN1407V01'), false);
|
||||
assert.equal(_private.isMkhProductCode(''), false);
|
||||
assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']);
|
||||
});
|
||||
|
||||
test('replenish: no sales but unshipped orders still trigger the warning', () => {
|
||||
// 库存不够发货 → 缺货,补货量 = 待发货件数 − 现有库存
|
||||
const notEnough = metrics({
|
||||
sevenAvgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2,
|
||||
});
|
||||
assert.equal(notEnough.hasWarning, true);
|
||||
assert.equal(notEnough.warningLevel, '缺货');
|
||||
assert.equal(notEnough.replenishQty, 3);
|
||||
assert.equal(notEnough.orderDriven, true);
|
||||
// 无日销基数 → 缺货时记 0,保证排在预警清单最前
|
||||
assert.equal(notEnough.stockDays, 0);
|
||||
assert.equal(notEnough.pendingOrderQty, 4);
|
||||
assert.equal(notEnough.pendingOrderCount, 2);
|
||||
|
||||
// 库存刚好够发 → 预警(不再缺货),补货量为 0
|
||||
const exactlyEnough = metrics({
|
||||
sevenAvgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4,
|
||||
});
|
||||
assert.equal(exactlyEnough.warningLevel, '预警');
|
||||
assert.equal(exactlyEnough.hasWarning, true);
|
||||
assert.equal(exactlyEnough.replenishQty, 0);
|
||||
// 库存已够发时算不出可用天数,记 null(页面显示「按订单」)
|
||||
assert.equal(exactlyEnough.stockDays, null);
|
||||
});
|
||||
|
||||
test('replenish: order-driven warning does not override the pending work order exemption', () => {
|
||||
const scheduled = metrics({
|
||||
sevenAvgSale: 0,
|
||||
realtimeStock: 0,
|
||||
pendingOrderQty: 5,
|
||||
pendingOrderCount: 1,
|
||||
pendingWorkOrderQty: 6,
|
||||
pendingWorkOrderCount: 1,
|
||||
});
|
||||
assert.equal(scheduled.warningLevel, '已排产');
|
||||
assert.equal(scheduled.hasWarning, false);
|
||||
assert.equal(scheduled.replenishQty, 0);
|
||||
// 待发货量仍要透出,页面上应能看到
|
||||
assert.equal(scheduled.pendingOrderQty, 5);
|
||||
assert.equal(scheduled.orderDriven, true);
|
||||
});
|
||||
|
||||
test('replenish: pending order fields default to empty without any unshipped order', () => {
|
||||
const none = metrics({ sevenAvgSale: 1, realtimeStock: 0 });
|
||||
assert.equal(none.pendingOrderQty, 0);
|
||||
assert.equal(none.pendingOrderCount, 0);
|
||||
assert.equal(none.pendingOrderEarliest, null);
|
||||
assert.equal(none.orderDriven, false);
|
||||
|
||||
// 无日销且无订单 → 仍是「无销量」,不参与预警
|
||||
const noSale = metrics({ sevenAvgSale: 0, realtimeStock: 0, pendingOrderQty: 0 });
|
||||
assert.equal(noSale.warningLevel, '无销量');
|
||||
assert.equal(noSale.orderDriven, false);
|
||||
|
||||
// 有日销的商品照旧走原口径,但待发货量也要带上(页面新增列)
|
||||
const withSale = metrics({ sevenAvgSale: 2, realtimeStock: 0, pendingOrderQty: 3 });
|
||||
assert.equal(withSale.warningLevel, '缺货');
|
||||
assert.equal(withSale.orderDriven, false);
|
||||
assert.equal(withSale.pendingOrderQty, 3);
|
||||
assert.equal(withSale.replenishQty, 6); // ceil(3 × 2 − 0),不受订单量影响
|
||||
});
|
||||
|
||||
// ── 导出(见服务文件头 ⑨)────────────────────────────────────────
|
||||
test('export: product rows follow the page table columns', () => {
|
||||
const rows = _private.buildProductExportRows([
|
||||
@ -381,8 +487,11 @@ test('export: product rows follow the page table columns', () => {
|
||||
dailySale: 2.97,
|
||||
stock: 0,
|
||||
pendingWorkOrderQty: 0,
|
||||
pendingOrderQty: 0,
|
||||
stockDays: 0,
|
||||
replenishQty: 9,
|
||||
componentShortage: '',
|
||||
assemblableQty: null,
|
||||
bomConfigured: '已配置',
|
||||
accessoryCount: 2,
|
||||
accessoryQtySum: 16,
|
||||
@ -390,6 +499,7 @@ test('export: product rows follow the page table columns', () => {
|
||||
// 无销量 → stockDays 为 null(前端导出时渲染成「无销量」),在途量缺省归 0
|
||||
assert.equal(rows[1].stockDays, null);
|
||||
assert.equal(rows[1].pendingWorkOrderQty, 0);
|
||||
assert.equal(rows[1].pendingOrderQty, 0);
|
||||
assert.equal(rows[1].bomConfigured, '未配置');
|
||||
assert.equal(rows[1].accessoryQtySum, 0);
|
||||
});
|
||||
@ -475,3 +585,117 @@ test('pack inventory location chunk size stays under the MSSQL parameter limit',
|
||||
// 每个编码占 2 个参数(精确匹配 @lcN + 带后缀前缀匹配 @lpN),MSSQL 单语句上限 2100
|
||||
assert.equal(_private.COMPONENT_LOCATION_CHUNK_SIZE * 2 <= 2000, true);
|
||||
});
|
||||
|
||||
// ── 配件不足 / 可组装数(见服务文件头 ⑪)──────────────────────────
|
||||
test('component readiness: assemblable qty is limited by the tightest component', () => {
|
||||
const components = [
|
||||
{ barcode: 'A', name: '配件A', qty: 1 },
|
||||
{ barcode: 'B', name: '配件B', qty: 2 },
|
||||
];
|
||||
// A: floor(10 / 1) = 10,B: floor(7 / 2) = 3 → 木桶短板是 B
|
||||
const ready = _private.buildComponentReadiness(components, new Map([['A', 10], ['B', 7]]), new Map());
|
||||
|
||||
assert.equal(ready.assemblableQty, 3);
|
||||
assert.equal(ready.shortage, false);
|
||||
assert.equal(ready.bottlenecks[0].code, 'B');
|
||||
assert.equal(ready.bottlenecks[0].can, 3);
|
||||
// Tooltip 要能显示「单台用量」,不能默认当 1
|
||||
assert.equal(ready.bottlenecks[0].perQty, 2);
|
||||
});
|
||||
|
||||
test('component readiness: zero assemblable qty marks 配件不足', () => {
|
||||
const components = [
|
||||
{ barcode: 'A', name: '配件A', qty: 1 },
|
||||
{ barcode: 'B', name: '配件B', qty: 1 },
|
||||
];
|
||||
// B 在该仓没有库存记录 → 视为 0(loadComponentStock 会缓存 0),一台都凑不出来
|
||||
const ready = _private.buildComponentReadiness(components, new Map([['A', 500]]), new Map());
|
||||
|
||||
assert.equal(ready.assemblableQty, 0);
|
||||
assert.equal(ready.shortage, true);
|
||||
assert.equal(ready.bottlenecks[0].code, 'B');
|
||||
assert.equal(ready.bottlenecks[0].stock, 0);
|
||||
assert.equal(ready.bottlenecks[0].available, 0);
|
||||
});
|
||||
|
||||
test('component readiness: pending work order occupation is deducted from stock', () => {
|
||||
const components = [{ barcode: 'A', name: '配件A', qty: 2 }];
|
||||
// 库存 10、单台用量 2 → 5 台;扣掉加工单占用 6 件 → floor((10 − 6) / 2) = 2
|
||||
const ready = _private.buildComponentReadiness(
|
||||
components,
|
||||
new Map([['A', 10]]),
|
||||
new Map([['A', 6]])
|
||||
);
|
||||
assert.equal(ready.assemblableQty, 2);
|
||||
assert.equal(ready.bottlenecks[0].occupied, 6);
|
||||
assert.equal(ready.bottlenecks[0].available, 4);
|
||||
|
||||
// 占用超过库存 → 可支配数归 0,不能算成负数
|
||||
const exhausted = _private.buildComponentReadiness(
|
||||
components,
|
||||
new Map([['A', 10]]),
|
||||
new Map([['A', 20]])
|
||||
);
|
||||
assert.equal(exhausted.assemblableQty, 0);
|
||||
assert.equal(exhausted.shortage, true);
|
||||
assert.equal(exhausted.bottlenecks[0].available, 0);
|
||||
});
|
||||
|
||||
test('component readiness: missing BOM detail means unknown, not 配件不足', () => {
|
||||
// 没有配件明细 → 判不了(组件缺失 ≠ 配件不足),返回 null 让调用方跳过标记
|
||||
assert.equal(_private.buildComponentReadiness([], new Map(), new Map()), null);
|
||||
assert.equal(_private.buildComponentReadiness(null, new Map(), new Map()), null);
|
||||
assert.equal(_private.buildComponentReadiness(undefined, new Map(), new Map()), null);
|
||||
});
|
||||
|
||||
test('component occupation: pending work orders expand into component quantities', () => {
|
||||
const bomBySku = new Map([['MKHA1', [
|
||||
{ barcode: 'A', qty: 2 },
|
||||
{ barcode: 'B', qty: 1 },
|
||||
]]]);
|
||||
const occupation = _private.buildComponentOccupation([
|
||||
{ skuId: 'MKHA1', pendingWorkOrderQty: 5 },
|
||||
// 加工单商品没有 BOM → 不产生占用(不抛错、不写脏数据)
|
||||
{ skuId: 'MKH-NO-BOM', pendingWorkOrderQty: 9 },
|
||||
// 在途 0 或非数字 → 忽略
|
||||
{ skuId: 'MKHA1', pendingWorkOrderQty: 0 },
|
||||
{ skuId: 'MKHA1', pendingWorkOrderQty: null },
|
||||
], bomBySku);
|
||||
|
||||
assert.equal(occupation.get('A'), 10);
|
||||
assert.equal(occupation.get('B'), 5);
|
||||
assert.equal(occupation.size, 2);
|
||||
});
|
||||
|
||||
test('component shortage keeps the business-confirmed source and scope', () => {
|
||||
// 配件库存只认半成品包装仓、只判现有库存(不含采购在途)
|
||||
assert.equal(_private.COMPONENT_STOCK_WMS_CO_ID, 15718196);
|
||||
assert.equal(_private.COMPONENT_STOCK_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓');
|
||||
// 每个编码占 1 个参数,MSSQL 单语句上限 2100
|
||||
assert.equal(_private.COMPONENT_STOCK_CHUNK_SIZE <= 2000, true);
|
||||
assert.equal(typeof _private.loadComponentStock, 'function');
|
||||
assert.equal(typeof _private.loadMkhComponentReadiness, 'function');
|
||||
});
|
||||
|
||||
test('export: 配件不足 rows are flagged and carry no replenish target', () => {
|
||||
const rows = _private.buildProductExportRows([
|
||||
{
|
||||
row: {
|
||||
skuId: 'MKHDNZ0409260600843',
|
||||
productName: 'K字单板基础',
|
||||
warningLevel: '缺货',
|
||||
dailySale: 0,
|
||||
stock: 0,
|
||||
pendingOrderQty: 1,
|
||||
replenishQty: 1,
|
||||
componentShortage: true,
|
||||
assemblableQty: 0,
|
||||
},
|
||||
components: [{ barcode: 'DNZ040958N1440V01', name: '白架通用前拉杆', qty: 1 }],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(rows[0].componentShortage, '配件不足');
|
||||
// 可组装数一并导出,便于业务核对短板
|
||||
assert.equal(rows[0].assemblableQty, 0);
|
||||
});
|
||||
|
||||
@ -137,3 +137,58 @@ test('notification flags degraded work-order source', () => {
|
||||
});
|
||||
assert.ok(payload.content.includes('加工单数据源暂不可用'));
|
||||
});
|
||||
|
||||
// ── 配件不足(见 mkhBomService 文件头 ⑪)──────────────────────────
|
||||
test('notification spells out 配件不足 and drops the replenish target', () => {
|
||||
const shortage = alert('MKHDNZ0409260600843', '缺货', {
|
||||
dailySale: 0,
|
||||
stock: 0,
|
||||
replenishQty: 1,
|
||||
componentShortage: true,
|
||||
assemblableQty: 0,
|
||||
bottleneckComponents: [
|
||||
{ code: 'DNZ040958N1440V01', name: '白架通用前拉杆', perQty: 1, stock: 0, occupied: 0, available: 0, can: 0 },
|
||||
{ code: 'DNZ041106B01V01', name: '通用小K', perQty: 1, stock: 23, occupied: 0, available: 23, can: 23 },
|
||||
],
|
||||
});
|
||||
|
||||
const payload = buildMkhReplenishNotification({
|
||||
alerts: [shortage],
|
||||
allAlerts: [shortage],
|
||||
snapshot: {
|
||||
outOfStockCount: 1,
|
||||
warningOnlyCount: 0,
|
||||
replenishQtyTotal: 1,
|
||||
componentShortageCount: 1,
|
||||
},
|
||||
recipientAlias: '苏适',
|
||||
dateKey: '20260917',
|
||||
});
|
||||
|
||||
assert.ok(payload.content.includes('配件不足,无法补货'));
|
||||
// 补不出来的商品不再给补货数字,避免业务照着一个补不到的目标去采购
|
||||
assert.ok(payload.content.includes('建议补货 —'));
|
||||
assert.equal(payload.content.includes('建议补货 1'), false);
|
||||
// 只列真正卡住的短板(可支配数 ≤ 0),库存充足的不列
|
||||
assert.ok(payload.content.includes('DNZ040958N1440V01'));
|
||||
assert.equal(payload.content.includes('DNZ041106B01V01'), false);
|
||||
// 汇总段说明本批里有几个配件不足
|
||||
assert.ok(payload.content.includes('因**配件库存不足**'));
|
||||
});
|
||||
|
||||
test('notification flags degraded component stock source', () => {
|
||||
const payload = buildMkhReplenishNotification({
|
||||
alerts: [alert('A')],
|
||||
allAlerts: [alert('A')],
|
||||
snapshot: {
|
||||
outOfStockCount: 1,
|
||||
warningOnlyCount: 0,
|
||||
replenishQtyTotal: 3,
|
||||
componentStockDegraded: true,
|
||||
},
|
||||
recipientAlias: '苏适',
|
||||
dateKey: '20260917',
|
||||
});
|
||||
assert.ok(payload.content.includes('配件库存数据源暂不可用'));
|
||||
assert.ok(payload.content.includes('未做'));
|
||||
});
|
||||
|
||||
@ -41,6 +41,23 @@ const WARNING_TONE = {
|
||||
无销量: 'muted',
|
||||
};
|
||||
|
||||
// 配件不足说明(口径见后端 mkhBomService 文件头 ⑪):
|
||||
// 可组装数 = min(⌊(配件库存 − 未完成加工单占用) ÷ 单台用量⌋),为 0 即凑不出一台、补不出来
|
||||
const shortageTooltip = (row) => {
|
||||
const bottlenecks = Array.isArray(row.bottleneckComponents) ? row.bottleneckComponents : [];
|
||||
const details = bottlenecks
|
||||
.filter((item) => Number(item.available) <= 0)
|
||||
.map((item) => `短板 ${item.code}${item.name ? `(${item.name})` : ''}`
|
||||
+ `:库存 ${number(item.stock)}`
|
||||
+ `${Number(item.occupied) > 0 ? `,未完成加工单占用 ${number(item.occupied)}` : ''}`
|
||||
+ `,单台用量 ${number(item.perQty)}`);
|
||||
return [
|
||||
`配件不足,无法补货:现有配件最多只能组装 ${number(row.assemblableQty)} 台`,
|
||||
...details,
|
||||
'口径:可组装数 = 各配件 ⌊(库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小;为 0 即补不出来',
|
||||
].filter(Boolean).join(';');
|
||||
};
|
||||
|
||||
// 展示范围切换
|
||||
const SCOPE_OPTIONS = [
|
||||
{ label: '仅预警', value: 'warning' },
|
||||
@ -57,20 +74,27 @@ const PRODUCT_EXPORT_COLUMNS = [
|
||||
{ title: '近7日日销', dataIndex: 'dailySale' },
|
||||
{ title: '现有库存', dataIndex: 'stock' },
|
||||
{ title: '在途加工量', dataIndex: 'pendingWorkOrderQty' },
|
||||
{ title: '待发货订单', dataIndex: 'pendingOrderQty' },
|
||||
{
|
||||
title: '可用天数',
|
||||
dataIndex: 'stockDays',
|
||||
// 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨
|
||||
exportValue: (value) => (value === null || value === undefined ? '无销量' : value),
|
||||
// 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨;
|
||||
// 订单驱动(日销=0 但有未发货订单)同样是 null,口径不同要分开说明
|
||||
exportValue: (value, row) => {
|
||||
if (value !== null && value !== undefined) return value;
|
||||
return row.orderDriven ? '无日销·按订单' : '无销量';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '建议补货量',
|
||||
dataIndex: 'replenishQty',
|
||||
// 与页面一致:非预警商品不显示数字,避免被当成「需要补 0 件」
|
||||
exportValue: (value, row) => (
|
||||
(row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : ''
|
||||
),
|
||||
// 与页面一致:非预警商品不显示数字;配件不足(可组装数 = 0)时显示「—」不给补货目标
|
||||
exportValue: (value, row) => {
|
||||
if (row.componentShortage) return '—';
|
||||
return (row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : '';
|
||||
},
|
||||
},
|
||||
{ title: '配件不足', dataIndex: 'componentShortage' },
|
||||
{ title: 'BOM 状态', dataIndex: 'bomConfigured' },
|
||||
{ title: '配件种类', dataIndex: 'accessoryCount' },
|
||||
{ title: '配件总数量', dataIndex: 'accessoryQtySum' },
|
||||
@ -182,7 +206,7 @@ const PrintPreview = ({ data, onClose }) => {
|
||||
<div className="mkh-print-foot">
|
||||
<span>
|
||||
MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充;
|
||||
配件仓位优先取包装箱与具体仓位,尚未分箱时显示库存仓库或商品档案仓库。
|
||||
配件仓位只取聚水潭「箱及仓位库存」,未维护货位时显示箱号,无装箱记录显示「未维护」。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -473,10 +497,18 @@ const MkhBomData = () => {
|
||||
{
|
||||
title: '预警',
|
||||
dataIndex: 'warningLevel',
|
||||
width: 84,
|
||||
width: 140,
|
||||
align: 'center',
|
||||
render: (value) => (
|
||||
<span className={`mkh-tag mkh-tag--${WARNING_TONE[value] || 'muted'}`}>{text(value)}</span>
|
||||
render: (value, row) => (
|
||||
<Space size={4}>
|
||||
<span className={`mkh-tag mkh-tag--${WARNING_TONE[value] || 'muted'}`}>{text(value)}</span>
|
||||
{/* 配件不足(后端文件头 ⑪):可组装数 = 0,补不出来 —— 只加标记,不改变预警级别 */}
|
||||
{row.componentShortage ? (
|
||||
<Tooltip title={shortageTooltip(row)}>
|
||||
<span className="mkh-tag mkh-tag--shortage">配件不足</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -519,24 +551,58 @@ const MkhBomData = () => {
|
||||
</Tooltip>
|
||||
) : <span className="mkh-muted">—</span>),
|
||||
},
|
||||
{
|
||||
title: '待发货订单',
|
||||
dataIndex: 'pendingOrderQty',
|
||||
width: 116,
|
||||
align: 'right',
|
||||
render: (value, row) => (Number(value) > 0 ? (
|
||||
<Tooltip
|
||||
title={[
|
||||
`聚水潭未发货订单 ${row.pendingOrderCount || 0} 单,合计 ${number(value)} 件`,
|
||||
row.pendingOrderEarliest ? `最早一单下单于 ${row.pendingOrderEarliest}` : '',
|
||||
'已取消 / 已合并单据不核算',
|
||||
row.orderDriven ? '该商品近 7 日暂无日销,按待发货订单量列入补货预警' : '',
|
||||
].filter(Boolean).join(';')}
|
||||
>
|
||||
<span className="mkh-order">{number(value)}</span>
|
||||
</Tooltip>
|
||||
) : <span className="mkh-muted">—</span>),
|
||||
},
|
||||
{
|
||||
title: '可用天数',
|
||||
dataIndex: 'stockDays',
|
||||
width: 106,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (value, row) => (value === null || value === undefined
|
||||
? <span className="mkh-muted">无销量</span>
|
||||
: <span className={row.hasWarning ? 'mkh-days--warn' : undefined}>{number(value, 2)}</span>),
|
||||
render: (value, row) => {
|
||||
if (value === null || value === undefined) {
|
||||
// 订单驱动(近7日无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同
|
||||
return row.orderDriven
|
||||
? <Tooltip title="近 7 日暂无日销,无日销基数可算天数,按待发货订单量预警"><span className="mkh-muted">按订单</span></Tooltip>
|
||||
: <span className="mkh-muted">无销量</span>;
|
||||
}
|
||||
return <span className={row.hasWarning ? 'mkh-days--warn' : undefined}>{number(value, 2)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '建议补货量',
|
||||
dataIndex: 'replenishQty',
|
||||
width: 116,
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: (value, row) => (row.hasWarning && Number(value) > 0
|
||||
? <span className="mkh-replenish">{number(value)}</span>
|
||||
: <span className="mkh-muted">—</span>),
|
||||
render: (value, row) => {
|
||||
// 配件不足(后端文件头 ⑪):凑不出一台,不给补货目标,避免业务照着补不到的量去采购
|
||||
if (row.componentShortage) {
|
||||
return (
|
||||
<Tooltip title={shortageTooltip(row)}>
|
||||
<span className="mkh-muted">—</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return row.hasWarning && Number(value) > 0
|
||||
? <span className="mkh-replenish">{number(value)}</span>
|
||||
: <span className="mkh-muted">—</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'BOM 状态',
|
||||
@ -656,15 +722,24 @@ const MkhBomData = () => {
|
||||
<Text type="secondary" className="mkh-note">
|
||||
数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条);
|
||||
配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充。
|
||||
无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位优先显示箱号或具体仓位,
|
||||
尚未分箱时显示实际库存仓库,没有库存记录时显示商品档案仓库。
|
||||
无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取聚水潭「箱及仓位库存」,
|
||||
有维护货位则显示货位、未维护货位则显示箱号,该表没有记录即显示「未维护」。
|
||||
<br />
|
||||
补货预警:近7日日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、
|
||||
该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近7日日销,低于 3 天即预警,缺货单独标识;
|
||||
建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无近7日日销的商品不参与预警。
|
||||
建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。
|
||||
<br />
|
||||
待发货订单:近7日暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警
|
||||
(新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」;
|
||||
预警级别按「现有库存 < 待发货件数 → 缺货,否则预警」,建议补货量 = 待发货件数 − 现有库存,
|
||||
可用天数因无日销基数而显示「按订单」。
|
||||
<br />
|
||||
在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算
|
||||
(作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。
|
||||
<br />
|
||||
配件不足:缺货 / 预警的成品若配件「可组装数 = 0」则无法补货,标记「配件不足」、建议补货量显示「—」。
|
||||
可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ BOM 单台用量⌋ 取最小值(木桶短板决定),
|
||||
配件库存取「{data.componentStockWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」的现有库存,不含采购在途。
|
||||
</Text>
|
||||
</div>
|
||||
</header>
|
||||
@ -713,18 +788,45 @@ const MkhBomData = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && data.pendingOrderDegraded ? (
|
||||
<Alert
|
||||
className="mkh-alert"
|
||||
type="warning"
|
||||
showIcon
|
||||
message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近7日无日销但有订单」的商品,请稍后点击「刷新数据」重试。"
|
||||
action={<Button size="small" onClick={load}>刷新数据</Button>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && data.componentStockDegraded ? (
|
||||
<Alert
|
||||
className="mkh-alert"
|
||||
type="warning"
|
||||
showIcon
|
||||
message="配件库存数据源暂时不可用,本次未做「配件不足」判定,可能存在配件凑不齐、补不出来的商品,请稍后点击「刷新数据」重试。"
|
||||
action={<Button size="small" onClick={load}>刷新数据</Button>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading && (data.warningCount || 0) > 0 ? (
|
||||
<Alert
|
||||
className="mkh-alert"
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`补货预警:${data.warningCount} 个 MKH 商品现有库存低于 ${data.replenishDaysThreshold || 3} 天日销`}
|
||||
message={`补货预警:${data.warningCount} 个 MKH 商品需要补货`}
|
||||
description={(
|
||||
<span>
|
||||
按近 {data.saleWindowDays || 7} 日日销核算(监控日 {data.monitorDate || '—'}):
|
||||
其中 <b>{data.outOfStockCount || 0}</b> 个已缺货,
|
||||
<b>{Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))}</b> 个可用天数不足 {data.replenishDaysThreshold || 3} 天。
|
||||
建议补货量 = {data.replenishDaysThreshold || 3} × 近7日日销 − 现有库存。点「只看预警」即为补货清单。
|
||||
{(data.orderDrivenCount || 0) > 0 ? (
|
||||
<>
|
||||
<br />
|
||||
其中 <b>{data.orderDrivenCount}</b> 个近 7 日暂无日销,因聚水潭存在未发货订单
|
||||
(待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件)而列入预警,建议补货量按待发货件数核算。
|
||||
</>
|
||||
) : null}
|
||||
{(data.scheduledCount || 0) > 0 ? (
|
||||
<>
|
||||
<br />
|
||||
@ -732,6 +834,14 @@ const MkhBomData = () => {
|
||||
(在途合计 {number(data.pendingWorkOrderQtyTotal || 0)} 件),已排除在预警之外。
|
||||
</>
|
||||
) : null}
|
||||
{(data.componentShortageCount || 0) > 0 ? (
|
||||
<>
|
||||
<br />
|
||||
其中 <b>{data.componentShortageCount}</b> 个商品<span className="mkh-tag mkh-tag--shortage">配件不足</span>
|
||||
:现有配件最多只能组装 0 台(已扣除未完成加工单占用的配件),<b>补不出来</b>,
|
||||
建议补货量显示「—」,需先采购配件。
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
action={(
|
||||
@ -799,7 +909,7 @@ const MkhBomData = () => {
|
||||
pageSizeOptions: [50, 100, 200],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
scroll={{ x: 1696, y: bodyHeight }}
|
||||
scroll={{ x: 1766, y: bodyHeight }}
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
@ -123,6 +123,19 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 待发货订单(聚水潭未发货订单件数,见文件头 ⑩) */
|
||||
.mkh-order {
|
||||
color: #ad4e00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 配件不足(可组装数 = 0,补不出来,见文件头 ⑪):与「缺货」红标签并排,用紫灰区分 */
|
||||
.mkh-tag--shortage {
|
||||
color: #722ed1;
|
||||
background: rgba(114, 46, 209, 0.14);
|
||||
border-color: rgba(114, 46, 209, 0.35);
|
||||
}
|
||||
|
||||
/* ============ A4 打印预览 ============ */
|
||||
.mkh-print-mask {
|
||||
position: fixed;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user