diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index ff1978f..9614490 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -4,7 +4,18 @@ const express = require('express'); const { getMkhBomList, getMkhBomDetail, + listMkhBomExportRows, + clearMkhBomCache, } = require('../services/mkhBomService'); +const { + getMkhReplenishAlertAdminConfig, + resetMkhReplenishAlertState, + saveMkhReplenishAlertConfig, +} = require('../services/mkhReplenishAlertSettings'); +const { checkMkhReplenishAlerts } = require('../jobs/mkhReplenishAlertJob'); +const { + saveYydNotificationCenterConfig, +} = require('../services/yydNotificationCenterSettings'); const router = express.Router(); @@ -37,4 +48,68 @@ router.get('/detail', async (req, res) => { } }); +// 导出:当前筛选条件下的全部数据(不分页) +// mode=products(默认)→ 商品清单;mode=components → 配件明细摊平 +router.get('/export', async (req, res) => { + try { + res.json({ success: true, data: await listMkhBomExportRows(req.query || {}) }); + } catch (error) { + sendError(res, error, '导出 MKH-BOM 资料失败'); + } +}); + +// 补货预警推送设置(接收人 / 开关 / 推送范围) +router.get('/alert-config', async (req, res) => { + try { + res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() }); + } catch (error) { + sendError(res, error, '加载补货预警推送设置失败'); + } +}); + +// 保存补货预警推送设置 +router.put('/alert-config', async (req, res) => { + try { + const data = await saveMkhReplenishAlertConfig(req.body || {}); + res.json({ success: true, data }); + } catch (error) { + sendError(res, error, '保存补货预警推送设置失败'); + } +}); + +// 立即检查并推送(dryRun: 只预览不发送;force: 忽略「只推新增」推全量;reset: 先清空已推送记录) +router.post('/alert-check', async (req, res) => { + try { + const body = req.body || {}; + if (body.reset === true) await resetMkhReplenishAlertState(); + const result = await checkMkhReplenishAlerts({ + dryRun: body.dryRun === true, + force: body.force === true, + }); + res.json({ success: true, data: result }); + } catch (error) { + sendError(res, error, '执行补货预警推送失败'); + } +}); + +// 保存统一通知中心凭据(补货预警的推送通道) +router.put('/notification-center', async (req, res) => { + try { + await saveYydNotificationCenterConfig(req.body || {}); + res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() }); + } catch (error) { + sendError(res, error, '保存统一通知中心配置失败'); + } +}); + +// 手动同步:清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), +// 前端「同步」按钮调用后再拉一次列表即可直连副本库取最新快照 +router.post('/sync', async (_req, res) => { + try { + res.json({ success: true, data: clearMkhBomCache() }); + } catch (error) { + sendError(res, error, '同步 MKH-BOM 数据失败'); + } +}); + module.exports = router; diff --git a/backend/src/api/modularStockMonitor.js b/backend/src/api/modularStockMonitor.js index ce34dd1..08deddf 100644 --- a/backend/src/api/modularStockMonitor.js +++ b/backend/src/api/modularStockMonitor.js @@ -4,6 +4,8 @@ const express = require('express'); const { getModularStockList, getModularStockFilters, + getPurchaseInTransitList, + syncModularStock, } = require('../services/modularStockMonitorService'); const router = express.Router(); @@ -36,4 +38,22 @@ router.get('/filters', async (_req, res) => { } }); +// 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细 +router.get('/purchase-in-transit', async (req, res) => { + try { + res.json({ success: true, data: await getPurchaseInTransitList(req.query || {}) }); + } catch (error) { + sendError(res, error, '加载采购在途明细失败'); + } +}); + +// 手动同步:重新取最新监控日,前端「同步」按钮调用后再拉一次列表即拿到最新快照 +router.post('/sync', async (_req, res) => { + try { + res.json({ success: true, data: await syncModularStock() }); + } catch (error) { + sendError(res, error, '同步配件库存监控数据失败'); + } +}); + module.exports = router; diff --git a/backend/src/index.js b/backend/src/index.js index 96a2fe2..88daa64 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -50,6 +50,9 @@ const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob'); const { startAlibaba1688ImageSearchExpiryScheduler, } = require('./jobs/alibaba1688ImageSearchExpiryJob'); +const { + startMkhReplenishAlertScheduler, +} = require('./jobs/mkhReplenishAlertJob'); const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); const { ensureLocalSchema } = require('./services/localSchema'); const { @@ -724,6 +727,7 @@ async function startServer(options = {}) { startOpsBIncentiveSnapshotSchedulers(); startGoodsFirstSeenScheduler(); startAlibaba1688ImageSearchExpiryScheduler(); + startMkhReplenishAlertScheduler(); // Keep the first selection-pool page request from paying the remote schema // inspection round-trip over the planning database tunnel. diff --git a/backend/src/jobs/mkhReplenishAlertJob.js b/backend/src/jobs/mkhReplenishAlertJob.js new file mode 100644 index 0000000..f0f2231 --- /dev/null +++ b/backend/src/jobs/mkhReplenishAlertJob.js @@ -0,0 +1,396 @@ +'use strict'; + +// MKH 成品补货预警 · 定时推送 +// +// 业务口径(2026-09-17 业务确认): +// ① 推送范围:缺货(可用天数 ≤ 0)+ 3 天内预警商品(默认,可在页面里改成仅缺货) +// · 含「订单驱动」商品:近7日日销 = 0、但存在未发货订单的 MKH 成品 +// (口径与取数见 mkhBomService 文件头 ⑩;正文里以「待发货订单 N 件」代替日销展示) +// · 含「配件不足」商品:配件现有库存可组装数 = 0(口径见 mkhBomService 文件头 ⑪) +// —— 这类商品**补不出去**,正文里明确写「配件不足,无法补货」并列出短板配件, +// 不给出建议补货量(否则业务会照着补不到的目标去采购) +// ② 只提醒**新出现**的预警 —— 已经在「已推送」记录里的商品不重复推送, +// 商品补货完成恢复到正常范围后会从记录里移除,下次再预警会重新提醒 +// ③ 收件人:单独配置(配置为空时回退环境变量 → 统一通知中心默认收件人) +// ④ 时间:每天早上 09:00(Asia/Shanghai) +// +// 被未完成加工单豁免的「已排产」商品**不进入**推送清单(口径见 mkhBomService 文件头 ⑧)。 + +const crypto = require('crypto'); +const cron = require('node-cron'); +const { + listReplenishAlertRows, +} = require('../services/mkhBomService'); +const { + getMkhReplenishAlertRuntimeConfig, + loadMkhReplenishAlertState, + resolveMkhReplenishAlertRecipient, + saveMkhReplenishAlertState, +} = require('../services/mkhReplenishAlertSettings'); +const { + isYydNotificationCenterConfigured, + sendYydNotification, +} = require('../services/yydNotificationCenterClient'); +const { + getYydNotificationCenterRuntimeConfig, +} = require('../services/yydNotificationCenterSettings'); + +const DEFAULT_CRON = '0 9 * * *'; +const TIMEZONE = 'Asia/Shanghai'; +const OUT_OF_STOCK = '缺货'; +const WARNING = '预警'; +// 正文最多列出多少个商品(超出只报数量,避免超出通知中心 4800 字上限) +const MAX_LISTED_ITEMS = 20; +const STARTUP_DELAY_MS = 45 * 1000; + +function formatQty(value) { + if (value === null || value === undefined || value === '') return '—'; + const num = Number(value); + if (!Number.isFinite(num)) return '—'; + return Number.isInteger(num) ? String(num) : String(Number(num.toFixed(2))); +} + +function formatChinaTime(value) { + const date = value instanceof Date ? value : new Date(value); + if (!Number.isFinite(date.getTime())) return '—'; + return new Intl.DateTimeFormat('zh-CN', { + timeZone: TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); +} + +/** 北京时间 YYYYMMDD —— 幂等键按「天 + 本批商品」去重 */ +function chinaDateKey(date = new Date()) { + const text = new Intl.DateTimeFormat('en-CA', { + timeZone: TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date instanceof Date ? date : new Date(date)); + return text.replace(/\D/g, ''); +} + +/** + * 对比「当前预警清单」与「已推送记录」: + * fresh 本次新出现的(需要推送) + * persisted 之前推过、目前仍在预警的(不重复推) + * restored 之前推过、现在已经不预警的(补货完成,从记录里剔除) + */ +function diffAlertState(alerts = [], state = {}) { + const notified = state?.notified && typeof state.notified === 'object' ? state.notified : {}; + const fresh = []; + const persisted = []; + alerts.forEach((row) => { + const skuId = String(row?.skuId || ''); + if (!skuId) return; + if (notified[skuId]) persisted.push(row); + else fresh.push(row); + }); + const alertIds = new Set(alerts.map((row) => String(row?.skuId || '')).filter(Boolean)); + const restored = Object.keys(notified).filter((skuId) => !alertIds.has(skuId)); + return { fresh, persisted, restored }; +} + +/** + * 生成下一版「已推送」记录:只保留当前仍在预警的商品。 + * touch=false 时不刷新 lastNotifiedAt(用于「没有新预警、本轮未真正推送」的场景)。 + */ +function buildNotifiedSnapshot(alerts = [], state = {}, nowIso, options = {}) { + const touch = options.touch !== false; + const prev = state?.notified && typeof state.notified === 'object' ? state.notified : {}; + const next = {}; + alerts.forEach((row) => { + const skuId = String(row?.skuId || ''); + if (!skuId) return; + const old = prev[skuId]; + const firstNotifiedAt = old?.firstNotifiedAt || nowIso; + next[skuId] = { + level: row.warningLevel || null, + firstNotifiedAt, + lastNotifiedAt: touch ? nowIso : (old?.lastNotifiedAt || firstNotifiedAt), + dailySale: Number(row.dailySale) || 0, + stock: Number(row.stock) || 0, + }; + }); + return next; +} + +/** 本批商品的指纹 —— 同一天内同一批商品只推一次(服务重启后重复触发也能被通知中心去重) */ +function buildAlertSignature(alerts = []) { + const parts = alerts + .map((row) => `${String(row?.skuId || '')}:${row?.warningLevel || ''}`) + .sort(); + 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() || '(无名称)', + metric, + ]; + if (row.componentShortage) { + lines.splice(2, 0, `⚠️ **配件不足,无法补货**(现有配件可组装 ${formatQty(row.assemblableQty)} 台${formatShortageNote(row)})`); + } + return lines.join('\n'); +} + +function buildMkhReplenishNotification({ + alerts = [], + allAlerts = alerts, + snapshot = {}, + recipientAlias, + dateKey, +}) { + const listed = alerts.slice(0, MAX_LISTED_ITEMS); + const outOfStockCount = alerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length; + + const totalCount = allAlerts.length || alerts.length; + const totalOutOfStock = Number.isFinite(Number(snapshot.outOfStockCount)) + ? Number(snapshot.outOfStockCount) + : allAlerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length; + const totalWarningOnly = Number.isFinite(Number(snapshot.warningOnlyCount)) + ? Number(snapshot.warningOnlyCount) + : Math.max(0, totalCount - totalOutOfStock); + + const titleParts = [`MKH 成品补货预警 · 新增 ${alerts.length} 个`]; + if (outOfStockCount > 0) titleParts.push(`含 ${outOfStockCount} 个已缺货`); + + const blocks = [ + `**仓库:** 简尚-崔桥工厂品牌仓(MKH 模块化成品)`, + `**监控日:** ${snapshot.monitorDate || '—'} | 日销口径:近 ${snapshot.saleWindowDays || 7} 日 | 补货阈值:可用天数低于 ${snapshot.replenishDaysThreshold || 3} 天`, + `**本次新增:** ${alerts.length} 个${listed.length < alerts.length ? `(仅列出前 ${listed.length} 个)` : ''}`, + ]; + + listed.forEach((row, index) => { + blocks.push(`**${index + 1}.** ${describeAlert(row)}`); + }); + + blocks.push( + [ + `---`, + `当前共 **${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') + ); + + const actionUrl = String( + process.env.MKH_REPLENISH_ALERT_ACTION_URL + || 'https://pddg.jiansuntuzhi.cn/planning/operation/mkh-bom-data?scope=warning' + ).trim(); + + return { + idempotencyKey: `mkh_replenish_alert:${recipientAlias}:${dateKey}:${buildAlertSignature(alerts)}:v1`, + recipientAlias, + title: titleParts.join(' · ').slice(0, 100), + content: blocks.join('\n\n').slice(0, 4800), + actionTitle: '查看补货清单', + actionUrl, + }; +} + +/** + * 执行一次检查。可选参数: + * force 忽略「只推新增」,把当前全部预警商品推一次(手动触发 / 测试用) + * dryRun 只计算不发送,用于预览将推送的内容 + */ +async function checkMkhReplenishAlerts(options = {}) { + const now = options.now === undefined ? new Date() : new Date(options.now); + const nowIso = now.toISOString(); + const dryRun = options.dryRun === true; + + const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true }); + const snapshot = await listReplenishAlertRows({ scope: config.scope }); + const alerts = snapshot.rows || []; + const state = await loadMkhReplenishAlertState(); + const { fresh, persisted, restored } = diffAlertState(alerts, state); + + const summary = { + scope: snapshot.scope, + totalCount: snapshot.totalCount, + 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 }; + + // 预警清空(都补完了)→ 清空已推送记录,下次再预警重新提醒 + if (!alerts.length) { + if (!dryRun) await saveMkhReplenishAlertState({ notified: {}, lastRunAt: nowIso }); + return { sent: false, reason: 'no_alert', summary }; + } + + const targets = options.force === true ? alerts : fresh; + + if (!targets.length) { + if (!dryRun) { + await saveMkhReplenishAlertState({ + notified: buildNotifiedSnapshot(alerts, state, nowIso, { touch: false }), + lastRunAt: nowIso, + }); + } + return { sent: false, reason: 'no_new_alert', summary }; + } + + const recipient = await resolveMkhReplenishAlertRecipient({ config }); + if (!recipient.alias) return { sent: false, reason: 'recipient_not_configured', summary }; + + const payload = buildMkhReplenishNotification({ + alerts: targets, + allAlerts: alerts, + snapshot, + recipientAlias: recipient.alias, + dateKey: chinaDateKey(now), + }); + + // 预览不受「通知中心是否配置」影响,方便先核对口径 + if (dryRun) { + return { + sent: false, + reason: 'dry_run', + summary, + recipient: recipient.alias, + recipientSource: recipient.source, + payload, + }; + } + + let centerConfig = null; + try { + centerConfig = await getYydNotificationCenterRuntimeConfig(); + } catch (_error) { + centerConfig = null; + } + if (!isYydNotificationCenterConfigured(centerConfig)) { + return { + sent: false, + reason: 'notification_center_not_configured', + summary, + recipient: recipient.alias, + payload, + }; + } + + const response = await sendYydNotification(payload); + await saveMkhReplenishAlertState({ + notified: buildNotifiedSnapshot(alerts, state, nowIso), + lastRunAt: nowIso, + }); + + return { + sent: true, + recipient: recipient.alias, + recipientSource: recipient.source, + newCount: targets.length, + deliveryId: response?.id || response?.data?.id || null, + summary, + }; +} + +async function runMkhReplenishAlertJob(source = 'scheduled', options = {}) { + try { + const result = await checkMkhReplenishAlerts(options); + if (result.sent) { + console.log(`[MkhReplenishAlert] ${source} 已推送 ${result.newCount} 个新增预警 → ${result.recipient}`); + } else { + console.log(`[MkhReplenishAlert] ${source} 跳过推送: ${result.reason}`); + } + return result; + } catch (error) { + console.error(`[MkhReplenishAlert] ${source} 执行失败:`, error.message || error); + return { sent: false, reason: 'error', error: error.message || String(error) }; + } +} + +function startMkhReplenishAlertScheduler() { + const disabled = String(process.env.DISABLE_MKH_REPLENISH_ALERT || '').trim().toLowerCase() === 'true'; + if (disabled) { + console.log('[Scheduler] MKH 补货预警推送已禁用'); + return; + } + const expression = String(process.env.MKH_REPLENISH_ALERT_CRON || DEFAULT_CRON).trim(); + cron.schedule(expression, () => { + runMkhReplenishAlertJob('daily_schedule').catch(() => {}); + }, { timezone: TIMEZONE }); + + const startupTimer = setTimeout(() => { + runMkhReplenishAlertJob('startup').catch(() => {}); + }, STARTUP_DELAY_MS); + if (typeof startupTimer.unref === 'function') startupTimer.unref(); + + console.log(`[Scheduler] MKH 补货预警推送已注册: ${expression} (${TIMEZONE})`); +} + +module.exports = { + DEFAULT_CRON, + MAX_LISTED_ITEMS, + TIMEZONE, + buildAlertSignature, + buildMkhReplenishNotification, + buildNotifiedSnapshot, + checkMkhReplenishAlerts, + chinaDateKey, + diffAlertState, + formatQty, + runMkhReplenishAlertJob, + startMkhReplenishAlertScheduler, +}; diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index b6694c8..6cb027b 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -40,13 +40,180 @@ // payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同 // (无 SKU 条码),也不能直接当配件 BOM。 // -// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo; -// sku_id 可能带“-包装序号”后缀,需按基础配件编码匹配。优先显示 bin_group / bin, -// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与 wh_id(仓库)。 -// 该列只使用箱及仓位库存表,不用库存仓库或商品档案仓库冒充仓位。 +// ⑤ 配件仓位 —— **唯一数据源** 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」) +// 2026-09-17 业务口径:仓位列**只认这张表**,其他来源一律不用(原来那套 +// 「库存表正数量仓(未分箱)→ 商品档案仓(暂无箱号)」的兜底链已删除 —— +// 那不是仓位,标出来会让人以为料就在那儿)。 +// sku_id 可能带“-装箱序号”后缀(DNZ0019110N08V01-27),按基础配件编码匹配。 +// 取值只有两层: +// 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 行全部取不到仓名。 +// ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱** +// (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、 +// wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**。 +// 且表内容与 MKH 配件**零交集**:正式 BOM 的 715 个配件编码在该表一个都查不到 +// (精确 + `code-%` 前缀都是 0;箱表里是 DNZ0135xx/0199xx/0203xx 老型号半成品)。 +// → MKH 配件仓位列当前**必然全部显示「未维护」**,这是数据源现状,不是代码问题; +// 需业务在聚水潭给配件做装箱/维护货位后才会自动出现(代码不用再动)。 +// +// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。 +// +// ── ⑦ 补货预警(2026-09-17 业务确认口径)────────────────────────── +// 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货 +// · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale +// (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照) +// · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存; +// 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①), +// 缺失时回退监控快照 jsStock +// · 结余库存 = max(0, 现有库存 − 待发货订单) +// ⚠️ 2026-09-18 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单** +// (见 ⑫)。待发货订单 = 0 时结余库存 ≡ 现有库存,与旧口径完全等价。 +// · 可用天数 = 结余库存 / 近15天日销 +// · 建议补货量 = ceil(3 × 近15天日销 + 待发货订单 − 现有库存),不足 0 取 0 +// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / fifteenAvgSale 不同源 +// (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67, +// 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近15天日销」为准。 +// +// ── 为什么是 15 天而不是业务最初要求的 30 天 ── +// 2026-09-17 业务要求「近7日日销」改「近30天日销」,但实测全库**没有 30 天日销**: +// · pm_stockMonitor 只有 threeAvgSale(3天) / sevenAvgSale(7天) / fifteenAvgSale(15天), +// 且整表**只保留 1 天快照**(2026-09-16),攒不出 30 天历史 +// · rpt.dws_sn_sales_full_info_di(stat_date+skuid+goods_count,数据新鲜到 09-15) +// 里 MKH 商品 **0 个**;目标仓 794 个 SKU 命中也是 0 +// · rpt.dws_order_sales_cost_detail_di 停在 2025-04(历史归档) +// · rpt.jst_salesOutboundInfo 只有出库**单头**,无 SKU 明细;业务库无出库明细表 +// · rpt.op_BarcodeSalePrice30dDaily 名字带 30d,但只有 salePrice **没有销量** +// → 经与业务确认,改用十五天口径 fifteenAvgSale(已实现的最长周期)。 +// ⚠️ 若日后上游补了 30 天字段,只需改 loadReplenishIndex 里的列名 + DAILY_SALE_DAYS。 +// +// 实测(2026-09-16 品牌仓快照,当时成品口径尚未收紧为「正式 BOM 父项」): +// 4408 个 MKH 成品中,7 天有销 114 个、15 天有销 170 个 +// 实测(2026-09-17 切换当天,同一份 09-16 快照,7 天 vs 15 天): +// 有销商品 114 → 170 ← 周期拉长,更多商品进得了「有销」集合 +// 补货预警 25 → 19 ← 反而变少:15 天日销普遍低于 7 天(近期销量走低), +// 可用天数 = 库存 ÷ 日销 随之变大,跌破 3 天的变少 +// 缺货 9 → 10 +// 已排产 11 → 13 +// 订单驱动 14 → 8 ← 更多商品自己有日销了,不再需要靠订单兜底 +// ⚠️ 「预警变少」不是 bug,是口径变化的正常结果;若业务想要 30 天,需上游先补数。 +// +// ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── +// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** +// (工厂已经在做了,不重复提示补货),改标为「已排产」。 +// · 数据源:业务库同实例的链接服务器 +// LS_JS_DMP → JSDataMiddlePlatform.dbo.bi_processing_work_orders +// (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表) +// · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓' +// AND 状态 <> '作废' ← 作废单据一律不核算 +// AND 收货状态 <> '全部入库' ← 未完成(全部入库 ⟺ 状态=完成) +// · 在途加工量 = Σ(数量 − 已入库数量),按 商品编码 归集 +// ⚠️ 为什么走 getPool 而不是 cloudDatabase.query: +// query() 会经 rewriteReportReplicaSql() 把 `dbo.xxx` 重写成 `rpt.xxx`, +// 跨库四段名 `LS_JS_DMP.JSDataMiddlePlatform.dbo.t` 会被改坏成 +// `LS_JS_DMP.JSDataMiddlePlatform.rpt.t`。getPool() 不经过该重写。 +// 实测(2026-09-16):全表 679 行,其中 完成 498(全部入库)/ 作废 176 / +// 已确认 3 / 待审核 2;属半成品包装仓且未完成且非作废的只有 5 单 40 件。 +// ⚠️ 作废单在半成品包装仓有 79 单 740 件,若不过滤会虚增约 18 倍在途量。 +// +// ── ⑨ 导出(2026-09-17)────────────────────────────────────────── +// 导出与列表**共用同一套筛选/排序口径**(filterRows + sortRows),区别只有两点: +// · 不分页 —— 返回当前筛选条件下的全部行,而不是当前页 +// · 两种形态 —— products(商品清单,列同页面表格)/ components(配件明细摊平) +// 官方 BOM 明细按 SKU 分批读取(单批上限 MAX_PAGE_SIZE=200,见 loadOfficialBomForSkuBatches), +// 只对筛选结果取数,因此「仅预警」这类小范围导出只需 1 批查询; +// 每批结果都进 officialBomDetailCache,重复导出直接命中缓存。 +// ⚠️ products 形态**不返回 components** —— 全量商品(4000+ 行)若都挂上配件明细, +// 响应会膨胀到几十 MB,而商品清单本身用不到明细。 +// +// ── ⑩ 待发货订单(2026-09-17 业务确认口径)──────────────────────── +// 规则:**近 15 天日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。 +// 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」 +// 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。 +// · 数据源:业务库 rpt.jst_Orders(订单**主表**,实时更新,实测最新到今天) +// ⚠️ 不要用 rpt.jst_OrderItem(订单明细表)—— 实测**已停更在 2026-07-16** +// (主表最新 o_id≈9409xxx,明细最大 o_id 仅 8439xxx;近 5 天订单有明细的为 0 条)。 +// 主表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]` +// (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。 +// 实测逐单对账(2026-07-01~07-16,13.5 万单):全表普通订单仅 76.9% 能对上 +// (多为 `skus='0.'` 空汇总、组合装展开),但**MKH 成品订单 100% 可用**, +// 故本口径只解析 MKH 成品码(`/^MKH/i` 且非 `MKH-` 打头的组合装)。 +// · 未发货判定:`send_date IS NULL AND status NOT IN ('Cancelled','Merged')` +// (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单; +// 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求) +// · 待发货订单量 = Σ 该成品在未发货订单中的件数(同一单多次出现会累加) +// · 预警判定(2026-09-17 业务补充口径):日销为 0,但仍用「可用天数」衡量风险 —— +// 可用天数 = 现有库存 ÷ 代发(待发货)订单件数 +// · 可用天数 = 0(无库存) → 缺货 +// · 0 < 可用天数 < 3 天 → 预警 +// · 可用天数 ≥ 3 天 → 库存充足,不预警(阈值语义与日销分支一致) +// ⚠️ 代发订单件数恒 > 0(orderDriven 已保证 pendingOrderQty > 0),不会出现除零; +// 可用天数统一如实记录(保留 2 位小数),不再像以前那样缺货记 0 / 够发记 null。 +// · 建议补货量: +// 缺货 → max(0, 代发订单件数 − 现有库存)(先补到能覆盖整笔订单) +// 预警(0<天数<3) → max(0, 3 × 代发订单件数 − 现有库存)(补到 3 天缓冲) +// 正常(天数≥3) → 0 +// ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)—— +// 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。 +// 实测(2026-09-17,7 天口径下):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件, +// 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。 +// ⚠️ 改 15 天口径后实测:订单驱动从 14 个降到 **8 个** +// (日销基数 114 → 170,原本靠订单兜底的商品自己有日销了),与预期一致。 +// ⚠️ 同日又叠加「可用天数 ≤ 3 天」阈值(2026-09-17 补充):代发件数大、库存也能撑 3 天 +// 以上的商品不再预警,订单驱动预警数会在此 8 个基础上进一步减少(具体数以接口为准)。 +// +// ── ⑪ 配件不足 / 可组装数(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,15 天口径):预警 19 个(缺货 10 + 预警 9)中 **12 个可组装数 = 0** +// (缺货 7 + 预警 5);扣加工单占用前后仅 1 个商品变化(185 → 180 台), +// 说明占用规模小但口径上必须扣。 +// ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标), +// 接口透出 componentStockDegraded=true。 +// +// ── ⑫ 结余库存(2026-09-18 业务口径修正)────────────────────────── +// 背景:`MKHDNZ0409260601376` 现有库存 3、待发货订单 3,业务认为结余库存应为 0、 +// 可用天数 0、应判缺货;但旧代码里「待发货订单」**只在日销 = 0 时参与判定** +// (`orderDriven = dailySale <= 0 && pendingOrderQty > 0`),该品日销 0.27 > 0, +// 于是走日销分支 3 ≥ 0.81(3 天日销)→ 判「正常」,3 件待发订单被完全忽略。 +// 新口径:**待发货订单对所有商品都参与计算,不再区分日销是否为 0** +// · 结余库存 availableStock = max(0, 现有库存 − 待发货订单) +// · 可用天数 = 结余库存 ÷ 近15天日销(日销 > 0 时) +// · 缺货:结余库存 ≤ 0(订单都发不出去);预警:0 < 可用天数 < 3;否则正常 +// · 建议补货量 = ceil(3 × 近15天日销 + 待发货订单 − 现有库存) +// (先补到能把订单发掉,再补 3 天缓冲) +// ⚠️ ⑩ 的订单驱动分支(日销 = 0)**保持不变**:那里本来就是以代发订单件数为分母, +// 语义是「库存能覆盖几倍订单」,与日销无关,改动会误伤已有的订单驱动预警。 +// 实测(2026-09-18,4,223 个成品):既有日销又有待发订单的商品 38 个, +// 其中 **4 个等级变化、全部升级为缺货**: +// MKHDNZ0409260700503(日销1.67 库存1 待发2) 预警0.60天 → 缺货0天 +// MKHDNZ0409260601547(日销0.42 库存1 待发1) 预警2.38天 → 缺货0天 +// MKHDNZ0409260601527(日销0.13 库存1 待发1) 正常7.69天 → 缺货0天 +// MKHDNZ0409260601376(日销0.27 库存3 待发3) 正常11.11天 → 缺货0天 +// 全量分布:缺货 10→14、预警 6→4、正常 165→163(已排产 / 无销量不变)。 // -// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 - const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -54,7 +221,10 @@ 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; -const COMPONENT_LOCATION_CACHE_TTL_MS = 2 * 60 * 1000; +// 配件仓位(只查「箱及仓位库存」)按编码缓存;单批编码数受 MSSQL 单语句 2100 参数 +// 上限约束(每个编码占 2 个参数:精确匹配 + 带后缀前缀匹配),300 × 2 = 600 个参数,安全。 +const COMPONENT_LOCATION_CACHE_TTL_MS = 10 * 60 * 1000; +const COMPONENT_LOCATION_CHUNK_SIZE = 300; let businessBomCache = null; let officialBomIndexCache = null; @@ -75,6 +245,61 @@ const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND NULLIF(LTRIM(RTRIM(parent_bom.map_outer_sku_id)), N'') IS NOT NULL )`; +// ── 补货预警参数(见文件头 ⑦)──────────────────────────────────── +// 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得) +const BRAND_WMS_CO_ID = 15870495; +// 日销统计窗口(天)。见文件头 ⑦:业务最初要 30 天,但全库无 30 天日销数据, +// 实测最长只有 fifteenAvgSale(15 天),经业务确认改用 15 天。 +// ⚠️ SALE_WINDOW_DAYS 决定页面文案与口径展示,DAILY_SALE_COLUMN 决定实际取数列, +// 两者必须成对修改;下方 SQL 已按 DAILY_SALE_COLUMN 插值,改常量即可生效。 +const SALE_WINDOW_DAYS = 15; +// 日销取数列(rpt.pm_stockMonitor)—— 必须与 SALE_WINDOW_DAYS 保持一致 +const DAILY_SALE_COLUMN = 'fifteenAvgSale'; +// 预警阈值:现有库存可用天数低于 3 天即需补货 +const REPLENISH_DAYS_THRESHOLD = 3; + +const WARNING_LEVEL = Object.freeze({ + OUT_OF_STOCK: '缺货', + WARNING: '预警', + SCHEDULED: '已排产', + NORMAL: '正常', + NO_SALE: '无销量', +}); + +// ── 在途加工单参数(见文件头 ⑧)────────────────────────────────── +// 加工仓储方:加工单的加工地,即半成品包装项目仓(产出入品牌仓) +const WORK_ORDER_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓'; +// 作废状态:作废单据不参与在途量核算 +const WORK_ORDER_VOID_STATUS = '作废'; +// 收货状态:该值表示已全部入库,等价于「已完成」 +const WORK_ORDER_RECEIVED_STATUS = '全部入库'; +// 加工单表位于同一实例的链接服务器上,四段名必须走 getPool 查询(原因见文件头 ⑧) +const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_orders'; +// 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量 +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(); @@ -87,24 +312,114 @@ const toNumber = (value) => { return Number.isFinite(parsed) ? parsed : null; }; +// 日期转 YYYY-MM-DD(按本地时区),用于监控日展示 +const toDateText = (value) => { + if (value === null || value === undefined || value === '') return null; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +}; + // 编码归一化:去横线/空格 + 大写,用于跨库匹配 const normalizeCode = (value) => { const text = toText(value); return text ? text.toUpperCase().replace(/[-\s]/g, '') : null; }; -const buildFilters = (query = {}) => { - const conditions = []; - const params = {}; +// ── 补货预警:进程内索引(见文件头 ⑦)──────────────────────────── +// ⚠️ 为什么不在 SQL 里关联: +// 实测(2026-09-17)在 ORDER BY / CASE 中对 OUTER APPLY 的列做 DECIMAL 算术, +// 会让 SQL Server 选到灾难性执行计划 —— +// · 按「可用天数」排序:128.8 秒 +// · 统计预警条数(CASE 内引用 apply 列):97.1 秒 +// · 同结构但只 COUNT:0.098 秒 +// 而「一次全量取数 + 应用层计算」只要约 0.2 秒。 +// 故 MKH 商品(4,408 条)与监控快照(4,411 行)一次性读入进程内索引, +// 筛选 / 排序 / 分页 / 统计全部在内存完成,SQL 侧只负责取数。 +const REPLENISH_PARAMS = Object.freeze({ + brandWarehouse: DESIGNATED_WAREHOUSE, + brandWmsCoId: BRAND_WMS_CO_ID, +}); +// 商品集合是主数据、监控快照为日更,10 分钟缓存足够 +const MKH_CACHE_TTL_MS = 10 * 60 * 1000; +let mkhProductCache = null; +let replenishIndexCache = null; +// 加工单在途量索引(见文件头 ⑧) +let workOrderIndexCache = null; +// 待发货订单索引(见文件头 ⑩) +let pendingOrderIndexCache = null; +// 配件库存(按编码缓存,见文件头 ⑪) +const componentStockCache = new Map(); - const keyword = toText(query.keyword); - if (keyword) { - params.keyword = `%${keyword}%`; - conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)'); +const TRUTHY_FLAGS = new Set(['1', 'true', 'yes', 'on']); + +const parseFlag = (value) => TRUTHY_FLAGS.has(String(value ?? '').trim().toLowerCase()); + +// 列表展示范围(内存过滤) +const SCOPES = Object.freeze({ + WARNING: 'warning', SOLD: 'sold', WORK_ORDER: 'work-order', ALL: 'all', +}); + +/** + * 解析展示范围: + * warning —— 仅需补货(缺货 + 3 天内预警) + * sold —— 有近15天日销的商品 + * work-order —— 有未完成加工单(在途加工量 > 0)的商品 + * all —— 全部 MKH 成品 + * 兼容旧参数 warningOnly=1(等价 scope=warning)。 + */ +function resolveScope(query = {}) { + const raw = String(query.scope ?? '').trim().toLowerCase(); + if (raw === SCOPES.WARNING || raw === SCOPES.SOLD || raw === SCOPES.WORK_ORDER || raw === SCOPES.ALL) { + return raw; + } + if (parseFlag(query.warningOnly)) return SCOPES.WARNING; + return SCOPES.ALL; +} + +const matchesKeyword = (row, keyword) => { + if (!keyword) return true; + const needle = keyword.toLowerCase(); + return String(row.skuId || '').toLowerCase().includes(needle) + || String(row.productName || '').toLowerCase().includes(needle); +}; + +function filterRows(rows, { keyword, scope }) { + return rows.filter((row) => { + if (scope === SCOPES.WARNING && !row.hasWarning) return false; + if (scope === SCOPES.SOLD && !(toNumber(row.dailySale) > 0)) return false; + if (scope === SCOPES.WORK_ORDER && !row.hasPendingWorkOrder) return false; + return matchesKeyword(row, keyword); + }); +} + +/** + * 排序:stockDays(可用天数)/ skuId。 + * ⚠️ 无销量的商品 stockDays 为 null,无论升降序都恒定排在最后, + * 否则它们会占据「最紧急」的首屏位置。 + */ +function sortRows(rows, field, order) { + const direction = order === 'DESC' ? -1 : 1; + const bySku = (a, b) => String(a.skuId || '').localeCompare(String(b.skuId || '')); + const list = [...rows]; + + if (field === 'stockDays') { + list.sort((a, b) => { + const av = toNumber(a.stockDays); + const bv = toNumber(b.stockDays); + if (av === null && bv === null) return bySku(a, b); + if (av === null) return 1; + if (bv === null) return -1; + if (av !== bv) return (av - bv) * direction; + return bySku(a, b); + }); + return list; } - return { conditions, params }; -}; + list.sort((a, b) => bySku(a, b) * direction); + return list; +} /** * 把 componentDetails 原始 JSON 解析成统一结构。 @@ -525,6 +840,714 @@ function summarizeConfiguredBoms(officialBom, businessBom, productBom) { const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0); +/** + * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 + * + * 口径(见文件头 ⑦ + ⑩ + ⑫): + * ① 近15天日销 > 0 且 结余库存 < 3 天日销 → 预警 + * 结余库存 = max(0, 现有库存 − 待发货订单),待发订单为 0 时即现有库存(见 ⑫) + * 结余库存 = 0 → 缺货(订单都发不出去) + * ② 近15天日销 = 0 且有代发(待发货)订单 → 订单驱动预警(见文件头 ⑩): + * 可用天数 = 现有库存 ÷ 代发订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警 + * 两者都不满足 → 无销量,不参与预警。 + * 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。 + * + * @param {object} row + * row.avgSale 近15天日均发货量(取自 rpt.pm_stockMonitor.fifteenAvgSale) + * 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.avgSale) ?? 0); + const realtimeStock = toNumber(row.realtimeStock); + const monitorStock = toNumber(row.monitorStock) ?? 0; + const hasRealtime = realtimeStock !== null; + const stock = hasRealtime ? realtimeStock : monitorStock; + + // 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量 + const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0); + 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; + // 结余库存(见文件头 ⑫):已下单未发货的部分已被占用,先扣掉才是可自由支配的库存。 + // ⚠️ 不分日销是否为 0,所有口径统一按结余库存计算(2026-09-18 口径修正)。 + const availableStock = Math.max(0, stock - pendingOrderQty); + + const base = { + dailySale, + stock, + availableStock, + pendingWorkOrderQty, + pendingWorkOrderCount, + 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), + suggestStock: toNumber(row.suggestStock) ?? 0, + monitorDate: toDateText(row.monitorDate), + }; + + // 既无近15天日销、也无未发货订单 → 不参与预警 + if (dailySale <= 0 && !orderDriven) { + return { + ...base, + thresholdQty: 0, + stockDays: null, + replenishQty: 0, + warningLevel: WARNING_LEVEL.NO_SALE, + warningOrder: 5, + hasWarning: false, + }; + } + + // 已有未完成加工单 → 不再列入补货预警(业务口径见文件头 ⑧;优先级高于订单驱动) + if (hasPendingWorkOrder) { + return { + ...base, + stockDays: dailySale > 0 ? Number((availableStock / dailySale).toFixed(2)) : null, + replenishQty: 0, + warningLevel: WARNING_LEVEL.SCHEDULED, + warningOrder: 4, + hasWarning: false, + }; + } + + // 订单驱动(见文件头 ⑩):日销为 0 但有代发(待发货)订单、有库存 + // → 用「现有库存 ÷ 代发订单件数」估算可用天数,≤ 3 天即补货预警 + // ⚠️ 阈值语义与日销分支一致:可用天数 < 3 天为「预警」、= 0(无库存)为「缺货」、 + // ≥ 3 天视为库存充足不预警;代发订单件数恒 > 0(orderDriven 已保证 pendingOrderQty > 0) + if (orderDriven) { + const orderStockDays = stock / pendingOrderQty; + let level; + let replenishQty; + if (orderStockDays <= 0) { + // 完全没货(库存 ≤ 0):缺货,建议补到能覆盖整笔代发订单 + level = WARNING_LEVEL.OUT_OF_STOCK; + replenishQty = Math.max(0, pendingOrderQty - stock); + } else if (orderStockDays < REPLENISH_DAYS_THRESHOLD) { + // 有货但可用天数不足 3 天:预警,建议补到 3 天缓冲(3 × 代发件数 − 现有库存) + level = WARNING_LEVEL.WARNING; + replenishQty = Math.max(0, Math.ceil(REPLENISH_DAYS_THRESHOLD * pendingOrderQty - stock)); + } else { + // 可用天数 ≥ 3 天:库存充足,不预警 + level = WARNING_LEVEL.NORMAL; + replenishQty = 0; + } + return { + ...base, + // 可用天数统一记录(保留 2 位小数,用于排序与展示) + stockDays: Number(orderStockDays.toFixed(2)), + replenishQty, + warningLevel: level, + // 排序权重:缺货 1 < 预警 2 < 正常 3(与日销分支一致) + warningOrder: level === WARNING_LEVEL.OUT_OF_STOCK ? 1 + : (level === WARNING_LEVEL.WARNING ? 2 : 3), + hasWarning: level === WARNING_LEVEL.OUT_OF_STOCK || level === WARNING_LEVEL.WARNING, + }; + } + + // 日销驱动(见文件头 ⑦ + ⑫):一律用**结余库存**衡量,待发货订单先从库存里扣掉 + const stockDays = availableStock / dailySale; + // 建议补货量 = 3 天日销缓冲 + 待发货订单缺口(先补到能把订单发掉,再补 3 天缓冲) + const replenishQty = Math.max(0, Math.ceil(base.thresholdQty + pendingOrderQty - stock)); + const level = availableStock <= 0 + ? WARNING_LEVEL.OUT_OF_STOCK + : (stockDays < REPLENISH_DAYS_THRESHOLD ? WARNING_LEVEL.WARNING : WARNING_LEVEL.NORMAL); + + return { + ...base, + stockDays: Number(stockDays.toFixed(2)), + replenishQty, + warningLevel: level, + // 排序权重:缺货 1 < 预警 2 < 正常 3 < 已排产 4 < 无销量 5 + warningOrder: level === WARNING_LEVEL.OUT_OF_STOCK ? 1 : (level === WARNING_LEVEL.WARNING ? 2 : 3), + hasWarning: level === WARNING_LEVEL.OUT_OF_STOCK || level === WARNING_LEVEL.WARNING, + }; +} + +/** + * 未完成加工单对配件的占用(纯函数,见文件头 ⑪)。 + * 占用[配件编码] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量) + * 加工单在途的成品已经把配件预定/领走,计算可组装数时必须先扣掉,否则会高估补货能力。 + * @param {Array<{skuId: string, pendingWorkOrderQty: number}>} workOrders 未完成加工单(成品级) + * @param {Map} bomBySku 归一化成品编码 → 配件明细 + * @returns {Map} 配件编码 → 被占用量 + */ +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} stockByCode 配件现有库存 + * @param {Map} 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 }],按编码升序。 + */ +async function loadMkhProductIndex() { + const now = Date.now(); + if (mkhProductCache?.value && mkhProductCache.expiresAt > now) { + return mkhProductCache.value; + } + if (mkhProductCache?.pending) return mkhProductCache.pending; + + const pending = cloudDatabase.query( + `SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category, s.other_10 AS material + FROM dbo.jst_sku s + WHERE ${MKH_WHERE} + ORDER BY s.sku_id ASC`, + {}, + { requestTimeout: 180000 } + ).then((rows) => rows.map((row) => ({ + skuId: toText(row.skuId), + productName: toText(row.productName), + category: toText(row.category), + material: toText(row.material), + })).filter((row) => row.skuId)); + + mkhProductCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + return value; + } catch (error) { + mkhProductCache = null; + throw error; + } +} + +/** + * 未完成加工单(在途量)索引 —— 进程内缓存 { bySku: Map<归一化编码, 在途指标>, degraded }。 + * 只保留「半成品包装项目仓加工、未完成、非作废」的加工单,口径见文件头 ⑧。 + * ⚠️ 该数据源是链接服务器,取不到时**降级为空索引**(补货预警照常跑), + * 并缩短缓存避免长时间带着错误在途量。degraded=true 会被透出到接口。 + */ +async function loadPendingWorkOrderIndex() { + const now = Date.now(); + if (workOrderIndexCache?.value && workOrderIndexCache.expiresAt > now) { + return workOrderIndexCache.value; + } + if (workOrderIndexCache?.pending) return workOrderIndexCache.pending; + + const pending = (async () => { + // ⚠️ 必须走 getPool:cloudDatabase.query() 会把 `dbo.xxx` 重写成 `rpt.xxx`, + // 跨库四段名会被改坏(见文件头 ⑧)。 + const pool = await cloudDatabase.getPool(); + const request = pool.request(); + request.input('workOrderWarehouse', WORK_ORDER_WAREHOUSE); + request.input('voidStatus', WORK_ORDER_VOID_STATUS); + request.input('receivedStatus', WORK_ORDER_RECEIVED_STATUS); + + const result = await request.query(` + SELECT [商品编码] AS skuId, + SUM(CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))) AS pendingQty, + COUNT(DISTINCT [加工单号]) AS orderCount, + MIN(TRY_CONVERT(DATETIME, [预计加工完成时间], 111)) AS eta, + MIN(CAST([加工单号] AS NVARCHAR(40))) AS firstOrderNo + FROM ${WORK_ORDER_TABLE} WITH (NOLOCK) + WHERE [加工仓储方] = @workOrderWarehouse + AND [状态] <> @voidStatus + AND [收货状态] <> @receivedStatus + GROUP BY [商品编码]`); + + const bySku = new Map(); + (result.recordset || []).forEach((row) => { + const key = normalizeCode(row.skuId); + if (!key) return; + const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0); + 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, + pendingWorkOrderNo: toText(row.firstOrderNo), + }); + }); + 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: WORK_ORDER_DEGRADED_TTL_MS }; + }); + + workOrderIndexCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + return value; + } catch (error) { + workOrderIndexCache = null; + throw error; + } +} + +/** + * 解析 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 }。 + * 一次读入品牌仓监控快照与实时库存,指标计算全部在应用层完成(原因见文件头 ⑦)。 + */ +async function loadReplenishIndex() { + const now = Date.now(); + if (replenishIndexCache?.value && replenishIndexCache.expiresAt > now) { + return replenishIndexCache.value; + } + if (replenishIndexCache?.pending) return replenishIndexCache.pending; + + const pending = Promise.all([ + // 监控快照:同一天只有一行,按 date DESC 取首条即可 + cloudDatabase.query( + `SELECT m.sku_id AS skuId, m.${DAILY_SALE_COLUMN} AS fifteenAvgSale, m.jsStock AS monitorStock, + m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, m.date AS monitorDate + FROM dbo.pm_stockMonitor m + WHERE m.warehouse = @brandWarehouse AND m.sku_id LIKE N'MKH%' + ORDER BY m.sku_id ASC, m.date DESC`, + REPLENISH_PARAMS, + { requestTimeout: 180000 } + ), + // 实时库存:同一 SKU 可能多行,按 updateTime DESC 取首条 + cloudDatabase.query( + `SELECT i.sku_id AS skuId, i.qty AS qty + FROM dbo.jst_inventoryInfo i + WHERE i.wms_co_id = @brandWmsCoId AND i.sku_id LIKE N'MKH%' + ORDER BY i.sku_id ASC, i.updateTime DESC`, + REPLENISH_PARAMS, + { requestTimeout: 180000 } + ), + // 在途加工单(见文件头 ⑧) + loadPendingWorkOrderIndex(), + // 待发货订单(见文件头 ⑩) + 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); + if (key && !latestStock.has(key)) latestStock.set(key, toNumber(row.qty)); + }); + + const seenMonitor = new Set(); + const bySku = new Map(); + let monitorDate = null; + monitorRows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (!key || seenMonitor.has(key)) return; + seenMonitor.add(key); + if (!monitorDate) monitorDate = toDateText(row.monitorDate); + bySku.set(key, buildReplenishMetrics({ + // 日销取数见文件头 ⑦:十五天口径(业务要 30 天但全库无此数据) + avgSale: row.fifteenAvgSale, + // 实时库存缺失(而非为 0)时才回退监控快照 + realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, + monitorStock: row.monitorStock, + safetyDays: row.safetyDays, + 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, + }; + }); + + replenishIndexCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + return value; + } catch (error) { + replenishIndexCache = null; + throw error; + } +} + +/** + * 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。 + * · 按基础配件编码精确匹配,SUM(qty) 汇总 + * · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存) + * · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标), + * 且失败的分片不写缓存,下次调用重试 + * @returns {Promise<{stockMap: Map, 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, 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 商品 × 库存/日销指标 × 在途加工单(内存合并)。 + * 列表接口与补货预警推送共用同一份合并结果,避免两处口径漂移。 + */ +async function buildMergedReplenishRows() { + const [products, replenishIndex] = await Promise.all([ + loadMkhProductIndex(), + loadReplenishIndex(), + ]); + + const rows = products.map((product) => { + const key = normalizeCode(product.skuId); + const metrics = replenishIndex.bySku.get(key); + if (metrics) return { ...product, ...metrics }; + // 监控快照里没有该商品时,仍要带上在途加工单(⑧)与待发货订单(⑩)信息 + return { + ...product, + ...buildReplenishMetrics({ + ...(replenishIndex.workOrderBySku?.get(key) || null), + ...(replenishIndex.pendingOrderBySku?.get(key) || null), + }), + }; + }); + + // 配件可组装数 / 配件不足(见文件头 ⑪):只对「缺货 / 预警」的商品计算, + // 「已排产」「无销量」等本来不需要补货的商品不参与,避免无谓查询。 + 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, + }; +} + +/** + * 补货预警清单(全量、不分页)—— 供定时推送任务使用。 + * scope = 'warning'(默认)→ 缺货 + 3 天内预警 + * scope = 'outOfStock' → 仅缺货 + * ⚠️ 返回的是「需要补货」的商品,不包含已被未完成加工单豁免的「已排产」商品。 + */ +async function listReplenishAlertRows(options = {}) { + const scope = toText(options.scope); + const levels = scope === 'outOfStock' + ? [WARNING_LEVEL.OUT_OF_STOCK] + : [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING]; + + const { rows, replenishIndex, componentStockDegraded } = await buildMergedReplenishRows(); + const alerts = rows.filter((row) => levels.includes(row.warningLevel)); + + // 最紧急的排前面:缺货 → 可用天数最少 → 编码升序 + alerts.sort((a, b) => ( + (a.warningOrder || 9) - (b.warningOrder || 9) + || ((toNumber(a.stockDays) ?? 0) - (toNumber(b.stockDays) ?? 0)) + || String(a.skuId).localeCompare(String(b.skuId)) + )); + + return { + rows: alerts, + totalCount: alerts.length, + outOfStockCount: alerts.filter((row) => row.warningLevel === WARNING_LEVEL.OUT_OF_STOCK).length, + warningOnlyCount: alerts.filter((row) => row.warningLevel === WARNING_LEVEL.WARNING).length, + 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', + }; +} + function matchesPackSku(componentCode, packSkuCode) { const component = toText(componentCode)?.toUpperCase(); const packSku = toText(packSkuCode)?.toUpperCase(); @@ -535,10 +1558,12 @@ function matchesPackSku(componentCode, packSkuCode) { function formatPackLocation(row) { const warehouseName = toText(row.warehouseName); const warehouseId = toText(row.warehouseId); + // 仓库优先显示名称(wh_id → jst_warehouseInfo.Id → name),取不到时退回 id const warehouse = warehouseName || (warehouseId ? `仓库 ${warehouseId}` : ''); const binGroup = toText(row.binGroup); const bin = toText(row.bin); const packId = toText(row.packId); + // 有真货位(bin_group / bin)才叫「仓位」;否则退回箱号 const position = [binGroup, bin].filter(Boolean).join(' / ') || (packId ? `箱号 ${packId}` : ''); return [warehouse, position].filter(Boolean).join(' · '); @@ -566,6 +1591,79 @@ function buildPackLocationMap(rows, componentCodes) { return result; } +/** + * 配件仓位批量查询(列表展开 / 单品明细 / 导出配件明细共用)。 + * 数据源**只有一个**:rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」), + * 仓名左连 rpt.jst_warehouseInfo(`Id = wh_id`,见文件头 ⑤ 的关联列警告)。 + * · 按基础配件编码匹配,兼容库内 sku_id 带「-装箱序号」后缀的形态 + * · 取值只有两层:真货位 bin_group / bin → 箱号 pack_id; + * 该表查不到就是空,**不回退**库存表仓 / 商品档案仓(那不是仓位) + * · 单个配件跨多个箱时最多列出 10 个位置,超出补「等 N 个位置」 + * · 结果按编码缓存(TTL 10 分钟);查询失败**不写缓存**,下次调用重试 + * @returns {Promise>} 配件编码 → 仓位文本(查不到的编码不含 key) + */ +async function loadComponentLocations(codes = []) { + const now = Date.now(); + const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); + const locationMap = new Map(); + const missing = []; + unique.forEach((code) => { + const hit = componentLocationCache.get(code); + if (hit && hit.expiresAt > now) { + if (hit.location) locationMap.set(code, hit.location); + } else { + missing.push(code); + } + }); + 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) { + try { + const params = {}; + const conditions = chunk.map((code, index) => { + params[`lc${index}`] = code; + params[`lp${index}`] = `${code}-%`; + return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`; + }); + 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 + 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 ')} + GROUP BY p.sku_id, p.pack_id, p.wh_id, p.bin, p.bin_group, w.name + HAVING SUM(p.qty) > 0`, + params, + { requestTimeout: 120000 } + ); + 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); + }); + } catch (error) { + // 失败不写缓存,下次调用重试 + console.warn('[MkhBom] 配件仓位查询降级:', error.message); + } + } + + return locationMap; +} + /** * 列表:指定仓库的 MKH 模块化成品 + BOM 配件种类/数量。 * BOM 数据只做明细匹配,不向商品列表追加无仓库档案的编码。 @@ -574,53 +1672,60 @@ async function getMkhBomList(query = {}) { const page = Math.max(1, Number.parseInt(query.page, 10) || 1); const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE; const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize)); - const { conditions, params } = buildFilters(query); - - const whereSql = conditions.length - ? `FROM dbo.jst_sku s WHERE ${MKH_WHERE} AND ${conditions.join(' AND ')}` - : `FROM dbo.jst_sku s WHERE ${MKH_WHERE}`; - - const sortField = String(query.sortField || 'skuId'); + const keyword = toText(query.keyword); + const scope = resolveScope(query); + // 默认按可用天数升序 —— 最紧急的排最前 + const sortField = String(query.sortField || 'stockDays'); const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; - const orderSql = sortField === 'skuId' ? `s.sku_id ${sortOrder}` : `s.sku_id ASC`; - const offset = (page - 1) * pageSize; - const [businessRows, totalRows, officialBomIndex, businessBom, productBom] = await Promise.all([ - cloudDatabase.query( - `SELECT - s.sku_id AS skuId, - s.name AS productName, - s.category AS category, - s.other_10 AS material - ${whereSql} - ORDER BY ${orderSql} - OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`, - params, - { requestTimeout: 180000 } - ), - cloudDatabase.query( - `SELECT COUNT_BIG(*) AS total ${whereSql}`, - params, - { requestTimeout: 180000 } - ), + const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([ + buildMergedReplenishRows(), loadOfficialBomIndex(), loadBusinessBom(), loadPgBom(), ]); - const officialBom = await loadOfficialBomForSkus(businessRows.map((row) => row.skuId)); + // 商品 × 库存/日销指标 × 在途加工单(内存合并,与补货预警推送共用) + const { rows: merged, replenishIndex, componentStockDegraded } = mergedResult; + + // 全量统计(不受关键词 / 范围筛选影响) + const warningRows = merged.filter((row) => row.hasWarning); + const stats = { + mkhTotalCount: merged.length, + soldCount: merged.filter((row) => toNumber(row.dailySale) > 0).length, + warningCount: warningRows.length, + outOfStockCount: warningRows.filter((row) => row.warningLevel === WARNING_LEVEL.OUT_OF_STOCK).length, + // 已排产:有未完成加工单、按业务口径不再预警的商品数(见文件头 ⑧) + scheduledCount: merged.filter((row) => row.warningLevel === WARNING_LEVEL.SCHEDULED).length, + pendingWorkOrderSkuCount: merged.filter((row) => row.hasPendingWorkOrder).length, + 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, + }; + + // 筛选 → 排序 → 分页(全部在内存完成,见文件头 ⑦) + const filtered = filterRows(merged, { keyword, scope }); + const sorted = sortRows(filtered, sortField, sortOrder); + const offset = (page - 1) * pageSize; + const pageRows = sorted.slice(offset, offset + pageSize); + + // 只对当前页补 BOM 明细,避免全量扫描 + const officialBom = await loadOfficialBomForSkus(pageRows.map((row) => row.skuId)); const bomSummary = summarizeConfiguredBoms(officialBomIndex, businessBom, productBom); - const [totalRow] = totalRows; - const mapped = businessRows.map((row) => { - const skuId = toText(row.skuId); - const matched = matchBomSources(officialBom, businessBom, productBom, skuId); + const rows = pageRows.map((row) => { + const matched = matchBomSources(officialBom, businessBom, productBom, row.skuId); const components = matched?.components || []; - return { - skuId, - productName: toText(row.productName), - category: toText(row.category), - material: toText(row.material), + ...row, accessoryCount: components.length, accessoryQtySum: sumQty(components), bomConfigured: components.length > 0 ? '已配置' : '未配置', @@ -631,8 +1736,8 @@ async function getMkhBomList(query = {}) { }); return { - rows: mapped, - total: Number(totalRow?.total || 0), + rows, + total: sorted.length, bomRowCount: bomSummary.total, bomOfficialRowCount: bomSummary.official, bomBusinessRowCount: bomSummary.business, @@ -641,7 +1746,20 @@ async function getMkhBomList(query = {}) { bomReady: bomSummary.total > 0, page, pageSize, - configuredInPage: mapped.filter((row) => row.accessoryCount > 0).length, + scope, + configuredInPage: rows.filter((row) => row.accessoryCount > 0).length, + ...stats, + replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, + saleWindowDays: SALE_WINDOW_DAYS, + // 加工单数据源(链接服务器)不可用时为 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, }; } @@ -686,82 +1804,8 @@ async function getMkhBomDetail(mkhSkuId) { const components = matched?.components || []; const codes = components.map((item) => item.barcode).filter(Boolean); - const stockMap = new Map(); - const locationMap = new Map(); - const now = Date.now(); - const uncachedCodes = codes.filter((code) => { - const cached = componentLocationCache.get(normalizeCode(code)); - if (!cached || cached.expiresAt <= now) return true; - stockMap.set(code, cached.stock); - if (cached.location) locationMap.set(code, cached.location); - return false; - }); - - if (uncachedCodes.length > 0) { - const placeholders = uncachedCodes.map((_, index) => `@c${index}`).join(', '); - const codeParams = Object.fromEntries(uncachedCodes.map((code, index) => [`c${index}`, code])); - let stockQuerySucceeded = false; - let packQuerySucceeded = false; - - try { - const stockRows = await cloudDatabase.query( - `SELECT i.sku_id AS skuId, SUM(i.qty) AS qty - FROM rpt.jst_inventoryInfo i WITH (NOLOCK) - WHERE i.sku_id IN (${placeholders}) - GROUP BY i.sku_id`, - codeParams, - { requestTimeout: 180000 } - ); - stockRows.forEach((row) => { - const code = uncachedCodes.find((candidate) => normalizeCode(candidate) === normalizeCode(row.skuId)); - if (!code) return; - stockMap.set(code, (stockMap.get(code) || 0) + (toNumber(row.qty) ?? 0)); - }); - stockQuerySucceeded = true; - } catch (error) { - console.warn('[MkhBom] 配件库存查询降级:', error.message); - } - - try { - const locationParams = {}; - const locationConditions = uncachedCodes.map((code, index) => { - locationParams[`lc${index}`] = code; - locationParams[`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, - p.bin_group AS binGroup, - SUM(p.qty) AS qty - FROM rpt.jst_packInventoryInfo p WITH (NOLOCK) - WHERE ${locationConditions.join(' OR ')} - GROUP BY p.sku_id, p.pack_id, p.wh_id, p.bin, p.bin_group - HAVING SUM(p.qty) > 0`, - locationParams, - { requestTimeout: 120000 } - ); - buildPackLocationMap(locRows, uncachedCodes).forEach((location, code) => { - locationMap.set(code, location); - }); - packQuerySucceeded = true; - } catch (error) { - console.warn('[MkhBom] 配件箱号/仓位查询降级:', error.message); - } - - if (stockQuerySucceeded && packQuerySucceeded) { - const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS; - uncachedCodes.forEach((code) => { - componentLocationCache.set(normalizeCode(code), { - stock: stockMap.get(code) ?? 0, - location: locationMap.get(code) || null, - expiresAt, - }); - }); - } - } + // 与列表展开 / 导出配件明细走同一个加载器:只查「箱及仓位库存」,见文件头 ⑤ + const locationMap = await loadComponentLocations(codes); const items = components.map((component, index) => ({ index: index + 1, @@ -771,7 +1815,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 { @@ -785,9 +1828,197 @@ async function getMkhBomDetail(mkhSkuId) { }; } +// ── ⑨ 导出 ──────────────────────────────────────────────────────── +const EXPORT_MODES = Object.freeze({ + PRODUCTS: 'products', + COMPONENTS: 'components', +}); + +/** + * 分批读取正式 BOM 明细。 + * loadOfficialBomForSkus() 单次只处理 MAX_PAGE_SIZE 个 SKU,导出全量时必须自行分批; + * 组内并发、组间串行,避免一次打满 mssql 连接池。 + * 每批结果都会写入 officialBomDetailCache,同一天重复导出直接命中缓存、不再查库。 + */ +async function loadOfficialBomForSkuBatches(skuIds = [], concurrency = 4) { + const unique = Array.from(new Map( + skuIds + .map((skuId) => [normalizeCode(skuId), toText(skuId)]) + .filter(([key, value]) => key && value) + ).values()); + const bySku = new Map(); + if (!unique.length) return { bySku, bySpuPrefix: [], entries: [] }; + + const batches = []; + for (let i = 0; i < unique.length; i += MAX_PAGE_SIZE) { + batches.push(unique.slice(i, i + MAX_PAGE_SIZE)); + } + + for (let i = 0; i < batches.length; i += concurrency) { + const group = batches.slice(i, i + concurrency); + // eslint-disable-next-line no-await-in-loop -- 有意分组串行:组内并发 4 路,避免打满连接池 + const parts = await Promise.all(group.map((batch) => loadOfficialBomForSkus(batch))); + parts.forEach((part) => { + part.bySku.forEach((components, key) => bySku.set(key, components)); + }); + } + + return { bySku, bySpuPrefix: [], entries: [] }; +} + +/** + * 商品清单导出行(纯函数,便于回归测试)。 + * 列口径与页面表格保持一致,只是去掉「操作」列、把 null 统一成可读值。 + */ +function buildProductExportRows(matchedRows = []) { + return matchedRows.map(({ row, components = [] }) => ({ + skuId: toText(row.skuId), + productName: toText(row.productName), + warningLevel: toText(row.warningLevel), + 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), + })); +} + +/** + * 配件明细导出行(纯函数)—— 按打印模板摊平:一行一个配件。 + * ⚠️ 同一商品的 MKH 编码逐行重复、不做纵向合并:合并单元格会让 Excel 筛选/透视失效, + * 业务拿这份数据多半是继续加工,扁平结构更合用。 + */ +function buildComponentExportRows(matchedRows = []) { + const rows = []; + matchedRows.forEach(({ row, components = [] }) => { + components.forEach((component, index) => { + rows.push({ + skuId: toText(row.skuId), + productName: toText(row.productName), + index: index + 1, + componentBarcode: toText(component.barcode || component.code), + componentName: toText(component.name), + qty: toNumber(component.qty) ?? 0, + location: toText(component.location), + }); + }); + }); + return rows; +} + +/** + * 导出:当前筛选条件下的全部行(不分页),见文件头 ⑨。 + * mode = products(默认)→ 商品清单;mode = components → 配件明细摊平 + */ +async function listMkhBomExportRows(query = {}) { + const requestedMode = String(query.mode || '').trim().toLowerCase(); + const mode = requestedMode === EXPORT_MODES.COMPONENTS + ? EXPORT_MODES.COMPONENTS + : EXPORT_MODES.PRODUCTS; + const keyword = toText(query.keyword); + const scope = resolveScope(query); + const sortField = String(query.sortField || 'stockDays'); + const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + // 与列表接口完全相同的筛选 / 排序口径:先全量合并,再内存筛选排序(见文件头 ⑦) + const { rows: merged, replenishIndex } = await buildMergedReplenishRows(); + const sorted = sortRows(filterRows(merged, { keyword, scope }), sortField, sortOrder); + + const [officialBom, businessBom, productBom] = await Promise.all([ + loadOfficialBomForSkuBatches(sorted.map((row) => row.skuId)), + loadBusinessBom(), + loadPgBom(), + ]); + + const matchedRows = sorted.map((row) => { + const matched = matchBomSources(officialBom, businessBom, productBom, row.skuId); + return { + row, + components: matched?.components || [], + matchType: matched?.matchType || null, + dataSource: matched?.dataSource || null, + }; + }); + + // ⚠️ 配件明细导出必须单独补仓位:BOM 明细本身不带仓位(见文件头 ⑤), + // 需要拿配件编码去「箱及仓位库」批量取,再按编码合并回明细行。 + // 页面展开走 /detail(同样会补),但导出走的是这条路径,漏掉会整列出空。 + if (mode === EXPORT_MODES.COMPONENTS) { + const componentCodes = matchedRows + .flatMap((item) => item.components.map((component) => component.barcode || component.code)); + const locationMap = await loadComponentLocations(componentCodes); + matchedRows.forEach((item) => { + item.components = item.components.map((component) => ({ + ...component, + location: locationMap.get(toText(component.barcode || component.code)) + || component.location + || null, + })); + }); + } + + const rows = mode === EXPORT_MODES.COMPONENTS + ? buildComponentExportRows(matchedRows) + : buildProductExportRows(matchedRows); + + return { + mode, + rows, + total: rows.length, + skuCount: sorted.length, + bomConfiguredCount: matchedRows.filter((item) => item.components.length > 0).length, + scope, + keyword, + sortField, + sortOrder, + monitorDate: replenishIndex.monitorDate, + replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, + saleWindowDays: SALE_WINDOW_DAYS, + workOrderDegraded: replenishIndex.workOrderDegraded === true, + }; +} + +// 手动同步:清空本页全部服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 配件仓位)。 +// 页面「同步」按钮调用——清掉后下一次查询直连副本库,拿到的是上游最新快照, +// 而不是 2~15 分钟前的缓存结果。 +// 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。 +function clearMkhBomCache() { + businessBomCache = null; + officialBomIndexCache = null; + productBomCache = null; + mkhProductCache = null; + replenishIndexCache = null; + workOrderIndexCache = null; + pendingOrderIndexCache = null; + + const cleared = [ + ['officialBomDetailCache', officialBomDetailCache.size], + ['componentLocationCache', componentLocationCache.size], + ['componentStockCache', componentStockCache.size], + ]; + officialBomDetailCache.clear(); + componentLocationCache.clear(); + componentStockCache.clear(); + + return { + syncedAt: new Date().toISOString(), + cleared: cleared.map(([name, entries]) => ({ name, entries })), + }; +} + module.exports = { getMkhBomList, + clearMkhBomCache, getMkhBomDetail, + listReplenishAlertRows, + listMkhBomExportRows, _private: { normalizeCode, groupOfficialBomRows, @@ -798,5 +2029,38 @@ module.exports = { matchesPackSku, formatPackLocation, buildPackLocationMap, + loadComponentLocations, + 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, + sortRows, + matchesKeyword, + REPLENISH_DAYS_THRESHOLD, + SALE_WINDOW_DAYS, + SCOPES, + WARNING_LEVEL, + WORK_ORDER_WAREHOUSE, + WORK_ORDER_VOID_STATUS, + WORK_ORDER_RECEIVED_STATUS, + EXPORT_MODES, + buildProductExportRows, + buildComponentExportRows, }, }; diff --git a/backend/src/services/mkhReplenishAlertSettings.js b/backend/src/services/mkhReplenishAlertSettings.js new file mode 100644 index 0000000..ff91799 --- /dev/null +++ b/backend/src/services/mkhReplenishAlertSettings.js @@ -0,0 +1,227 @@ +'use strict'; + +// MKH 成品补货预警推送 —— 配置 + 推送状态(本地库 system_config) +// +// ── 配置键 mkh_replenish_alert_config ───────────────────────────── +// { enabled, recipientAlias, scope } +// · recipientAlias 为空时回退顺序:环境变量 → 统一通知中心默认收件人 +// · scope: 'warning'(缺货 + 3 天内预警,默认)| 'outOfStock'(仅缺货) +// +// ── 状态键 mkh_replenish_alert_state ────────────────────────────── +// { version, notified: { [skuId]: { level, firstNotifiedAt, lastNotifiedAt, ... } }, lastRunAt } +// · 用于实现「只提醒新出现的预警」:已在 notified 里的商品不再重复推送; +// 商品恢复到正常范围后从 notified 移除,下次再预警会重新提醒。 + +const database = require('../config/database'); +const { + getYydNotificationCenterRuntimeConfig, + toAdminConfig: toNotificationCenterAdminConfig, +} = require('./yydNotificationCenterSettings'); + +const CONFIG_KEY = 'mkh_replenish_alert_config'; +const STATE_KEY = 'mkh_replenish_alert_state'; +const CONFIG_CACHE_TTL_MS = 30 * 1000; + +const SCOPES = Object.freeze({ WARNING: 'warning', OUT_OF_STOCK: 'outOfStock' }); + +const SCOPE_OPTIONS = Object.freeze([ + { value: SCOPES.WARNING, label: '缺货 + 3天内预警' }, + { value: SCOPES.OUT_OF_STOCK, label: '仅缺货' }, +]); + +let cachedConfig = null; +let cachedAt = 0; + +function normalizeText(value, maxLength = 200) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function safeJsonParse(value, fallback = {}) { + if (value && typeof value === 'object') return value; + try { + const parsed = JSON.parse(String(value || '')); + return parsed && typeof parsed === 'object' ? parsed : fallback; + } catch (_error) { + return fallback; + } +} + +function normalizeScope(value) { + const text = normalizeText(value, 40).toLowerCase(); + if (!text) return SCOPES.WARNING; + if (text === 'outofstock' || text === 'out_of_stock' || text === '缺货') return SCOPES.OUT_OF_STOCK; + if (text === 'warning' || text === 'warn' || text === '预警' || text === 'all') return SCOPES.WARNING; + return SCOPES.WARNING; +} + +function normalizeFlag(value, fallback = true) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const text = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(text)) return true; + if (['0', 'false', 'no', 'off'].includes(text)) return false; + return fallback; +} + +async function readSystemConfigValue(key) { + const rows = await database.local.query( + 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', + [key] + ); + return rows?.[0]?.configValue; +} + +async function upsertSystemConfigValue(key, value, configType = 'json') { + const payload = typeof value === 'string' ? value : JSON.stringify(value); + const rows = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [key] + ); + if (rows?.[0]) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [payload, configType, key] + ); + } else { + await database.local.insert('system_config', { + config_key: key, + config_value: payload, + config_type: configType, + }); + } + return payload; +} + +// ── 配置 ──────────────────────────────────────────────────────── +async function getMkhReplenishAlertRuntimeConfig({ forceRefresh = false } = {}) { + if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CONFIG_CACHE_TTL_MS) { + return { ...cachedConfig }; + } + let stored = {}; + try { + stored = safeJsonParse(await readSystemConfigValue(CONFIG_KEY), {}); + } catch (error) { + console.warn('读取补货预警推送配置失败,使用默认值:', error.message); + } + cachedConfig = { + enabled: normalizeFlag(stored.enabled, true), + recipientAlias: normalizeText(stored.recipientAlias || stored.recipient_alias, 50), + scope: normalizeScope(stored.scope), + recipientAliasFallback: normalizeText(process.env.MKH_REPLENISH_ALERT_RECIPIENT_ALIAS, 50), + }; + cachedAt = Date.now(); + return { ...cachedConfig }; +} + +/** + * 解析实际收件人 alias:配置填写值 → 环境变量 → 统一通知中心默认收件人。 + */ +async function resolveMkhReplenishAlertRecipient(options = {}) { + const config = options.config || await getMkhReplenishAlertRuntimeConfig(); + if (config.recipientAlias) return { alias: config.recipientAlias, source: 'config' }; + if (config.recipientAliasFallback) return { alias: config.recipientAliasFallback, source: 'environment' }; + let center = {}; + try { + center = await getYydNotificationCenterRuntimeConfig({ forceRefresh: options.forceRefresh === true }); + } catch (_error) { + center = {}; + } + const alias = normalizeText(center?.recipientAlias, 50); + return { alias, source: alias ? 'notification_center' : 'none' }; +} + +async function getMkhReplenishAlertAdminConfig() { + const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true }); + const recipient = await resolveMkhReplenishAlertRecipient({ config }); + let center = {}; + try { + center = await getYydNotificationCenterRuntimeConfig(); + } catch (_error) { + center = {}; + } + const centerAdmin = toNotificationCenterAdminConfig(center); + const state = await loadMkhReplenishAlertState(); + return { + enabled: config.enabled, + scope: config.scope, + recipientAlias: config.recipientAlias, + effectiveRecipientAlias: recipient.alias || '', + recipientSource: recipient.source, + notificationCenterConfigured: centerAdmin.configured, + notificationCenterBaseUrl: centerAdmin.baseUrl, + notificationCenter: { + configured: centerAdmin.configured, + baseUrl: centerAdmin.baseUrl, + projectCode: centerAdmin.projectCode, + projectSecretConfigured: centerAdmin.projectSecretConfigured, + projectSecretPreview: centerAdmin.projectSecretPreview, + recipientAlias: centerAdmin.recipientAlias, + }, + notifiedCount: Object.keys(state.notified || {}).length, + lastRunAt: state.lastRunAt || null, + scopeOptions: SCOPE_OPTIONS.map((item) => ({ ...item })), + }; +} + +async function saveMkhReplenishAlertConfig(input = {}) { + const current = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true }); + const rawAlias = input.recipientAlias ?? input.recipient_alias; + const next = { + enabled: normalizeFlag(input.enabled, current.enabled), + recipientAlias: normalizeText(rawAlias === undefined ? current.recipientAlias : rawAlias, 50), + scope: normalizeScope(input.scope || current.scope), + }; + await upsertSystemConfigValue(CONFIG_KEY, next); + cachedConfig = { ...next, recipientAliasFallback: current.recipientAliasFallback }; + cachedAt = Date.now(); + return getMkhReplenishAlertAdminConfig(); +} + +// ── 推送状态 ──────────────────────────────────────────────────── +async function loadMkhReplenishAlertState() { + let parsed = {}; + try { + parsed = safeJsonParse(await readSystemConfigValue(STATE_KEY), {}); + } catch (error) { + console.warn('读取补货预警推送状态失败,按空状态处理:', error.message); + } + const notified = parsed.notified && typeof parsed.notified === 'object' && !Array.isArray(parsed.notified) + ? parsed.notified + : {}; + return { + version: 1, + notified, + lastRunAt: normalizeText(parsed.lastRunAt, 40) || null, + }; +} + +async function saveMkhReplenishAlertState(state = {}) { + const value = { + version: 1, + notified: state.notified && typeof state.notified === 'object' ? state.notified : {}, + lastRunAt: normalizeText(state.lastRunAt, 40) || null, + }; + await upsertSystemConfigValue(STATE_KEY, value); + return value; +} + +/** 清空「已推送」记录,让下一次检查重新按新增口径判断(便于测试)。 */ +async function resetMkhReplenishAlertState() { + return saveMkhReplenishAlertState({ notified: {}, lastRunAt: null }); +} + +module.exports = { + CONFIG_KEY, + STATE_KEY, + SCOPES, + SCOPE_OPTIONS, + getMkhReplenishAlertAdminConfig, + getMkhReplenishAlertRuntimeConfig, + loadMkhReplenishAlertState, + normalizeFlag, + normalizeScope, + resetMkhReplenishAlertState, + resolveMkhReplenishAlertRecipient, + saveMkhReplenishAlertConfig, + saveMkhReplenishAlertState, +}; diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 1d219cd..c99abae 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -4,9 +4,9 @@ // // 需求来源:《模块化库存监控.xlsx》 // 一级功能:模块化库存监控 二级功能:配件库存监控 -// 表头:图片 | 预警 | SKU编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 | -// 仓位 | 近7日日均发货量 | 库存可用天数 | 所在仓库 -// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的 SKU 编码 +// 表头:图片 | 预警 | 商品编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 | +// 仓位 | 近15天日均发货量 | 库存可用天数 | 所在仓库 +// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的商品编码 // // 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认): // 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库 @@ -14,7 +14,7 @@ // 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库 // 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得) // 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU: -// sevenAvgSale 近7日日均发货量 +// fifteenAvgSale 近15天日均发货量(2026-09-17 由 7 天改 15 天,见下) // jsUseDays 库存可用天数 // safetyDays 安全天数(默认 15) // suggestStock 建议备货量 @@ -28,8 +28,11 @@ // 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty // (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794) // -// ⚠️ 业务库确实无数据源的字段: -// - 仓位 location:rpt.jst_sku.bin / other_bin 全库 557,475 行填充数为 0 +// ✅ 仓位 location(2026-09-18 改源,旧的「无数据源」结论作废): +// - 旧源 rpt.jst_sku.bin / other_bin 在目标仓 794 行**恒为空**,所以页面长期显示「未维护」 +// - 新源 rpt.jst_packInventoryInfo.bin(真货位):该表 2026-09-18 10:48 被上游刷新后 +// 目标仓 794 个 SKU **命中 727 个**(如 ZT-8 / BCP01-01-1) +// - 详见 buildLocationSql() 注释 const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -41,7 +44,8 @@ const WAREHOUSE_WMS_CO_ID = 15718196; const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 200; const DEFAULT_SAFETY_DAYS = 15; -// 预警阈值:库存可用天数 <= 7 天即预警 +// 预警阈值:库存可用天数 <= 7 天即预警(缺货 = 实时库存 <= 0) +// ⚠️ 2026-09-17 业务确认:未配置MKH 的配件不进任何预警(见 buildWarningOrderSql) const LOW_STOCK_DAYS_THRESHOLD = 7; const WARNING_LEVEL = Object.freeze({ @@ -50,33 +54,29 @@ const WARNING_LEVEL = Object.freeze({ NORMAL: '正常', }); -// 模块化配置状态: -// 「已配置MKH」= 该 SKU 已在「产品资料系统数据库」中建立商品档案 -// (external.product_catalog_wide_base_v1 按 barcode 匹配) -// 「未配置MKH」= 产品资料系统数据库中查不到该 SKU 档案 -// -// 口径变更说明(2026-09-15,按业务确认): -// 原先用业务库 jst_comboProductItem.src_sku_id 判定「是否被 MKH 组合 BOM 引用」, -// 实测目标仓 794 个 SKU 中只有 32 个命中——因为目标仓(半成品包装仓)与 -// MKH 组合 BOM 的配件(159 个 SKU / 14 个款号)是两批基本不重叠的编码集合, -// 按款号(SPU)放宽也只有 3 个款号重叠。 -// 业务确认改用「产品资料系统数据库是否有商品档案」作为判定口径, -// 实测目标仓 661/794 命中。 +// 模块化配置状态(2026-09-17 业务确认口径,2026-09-17 晚修正): +// 「已配置MKH」= 该 SKU 作为「配件商品编码」出现在聚水潭 SKU 级 BOM 维护中, +// 被某个 MKH 模块化成品引用: +// 业务库 rpt.jst_skuBomItem 中 map_outer_sku_id = 该 SKU +// 且父项 sku_id 以 'MKH' 开头(即 MKH 成品档案,如 MKHSJZ0033260600001)。 +// 「未配置MKH」= 未出现在任何 MKH 成品的 BOM 配件清单中。 +// 实测目标仓 794 个 SKU 中 **699** 个命中(按 map_outer_sku_id 匹配、父项 MKH 过滤)。 +// ⚠️ 历史口径均已废弃: +// · 早期「产品资料系统数据库商品档案」口径(661/794)—— 覆盖面过宽、与 BOM 无关; +// · 上一版「jst_comboProductItem 组合装引用」口径(29/794)—— 仅命中 MKH- 组合装, +// 漏掉了真正以 MKH 成品为父项的 BOM 配件,数量明显偏低。 +// ✅ 当前采用 rpt.jst_skuBomItem(聚水潭 SKU BOM 明细,含配件商品编码 map_outer_sku_id)。 const MKH_STATUS = Object.freeze({ CONFIGURED: '已配置MKH', UNCONFIGURED: '未配置MKH', }); -// 产品资料覆盖度缓存:避免每次列表请求都回查产品资料系统数据库 -const PRODUCT_COVERAGE_TTL_MS = 10 * 60 * 1000; -let productCoverageCache = null; - const SORTABLE_FIELDS = Object.freeze({ skuId: 's.sku_id', productName: 's.name', category: 's.category', realtimeStock: 'realtimeStock', - avgShip7d: 'm.sevenAvgSale', + avgShip15d: 'm.fifteenAvgSale', stockDays: 'm.jsUseDays', warningLevel: 'warningOrder', mkhStatus: 'mkhStatus', @@ -85,21 +85,53 @@ const SORTABLE_FIELDS = Object.freeze({ }); /** - * 「已配置MKH」判定表达式。 - * available = true —— 用产品资料库覆盖清单(@productCoveredCsv,逗号分隔的 SKU 编码) - * available = false —— 产品资料库不可用时回退到业务库 MKH BOM 判定,保证页面可用 + * 「已配置MKH」判定表达式(2026-09-17 晚修正口径): + * 该 SKU 作为「配件商品编码」(map_outer_sku_id)出现在聚水潭 SKU 级 BOM 维护 + * rpt.jst_skuBomItem 中,且父项 sku_id 以 'MKH' 开头(即被某个 MKH 模块化成品引用)。 */ -const buildConfiguredExistsSql = (coverage = {}) => ( - coverage.available - ? `EXISTS ( - SELECT 1 FROM STRING_SPLIT(@productCoveredCsv, N',') pc WHERE pc.value = s.sku_id - )` - : `EXISTS ( - SELECT 1 FROM JSDataMiddlePlatform.dbo.jst_comboProductItem x WHERE x.src_sku_id = s.sku_id - )` +const buildConfiguredExistsSql = () => ( + `EXISTS ( + SELECT 1 FROM rpt.jst_skuBomItem x + WHERE x.map_outer_sku_id = s.sku_id + AND x.sku_id LIKE N'MKH%' + )` ); -const buildBaseSql = (coverage = {}) => ` +/** + * 统一的「预警等级(数值)」表达式(2026-09-17 业务确认:未配置MKH 不进任何预警)。 + * 0 = 缺货 / 1 = 预警 / 2 = 正常 + * 未配置MKH(cfg.configured = 0)一律判为「正常」,不报缺货也不报预警。 + * 注意 cfg / inv / m 三个别名都来自 buildBaseSql 的 OUTER APPLY,WHERE / SELECT 中均可引用。 + * 列表映射、汇总计数、筛选、排序四处共用此表达式,避免口径不一致。 + */ +const buildWarningOrderSql = () => ( + `CASE + WHEN cfg.configured = 0 THEN 2 + WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0 + WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 + ELSE 2 + END` +); + +/** + * 统一的「仓位」表达式(2026-09-18 改源)。 + * 旧源 `jst_sku.bin / other_bin` 在目标仓 794 行**恒为空**(页面因此长期显示「未维护」); + * 真货位在 `rpt.jst_packInventoryInfo.bin` —— 该表 2026-09-18 10:48 被上游刷新后, + * 目标仓 794 个 SKU **命中 727 个**(实测),如 ZT-8 / BCP01-01-1。 + * · 只取 `qty > 0` 的箱,同 SKU 多箱时取库存最大的那个(列表页只展示主货位) + * · 取不到才回退商品档案的 bin / other_bin(目前仍全空,保留兜底不影响结果) + * 与 MKH-BOM 页 `mkhBomService.loadComponentLocations()` 同源,区别只在本页取单个主货位。 + * ⚠️ 列表映射、仓位筛选两处共用此表达式,改口径时只改这里。 + */ +const buildLocationSql = () => ( + `COALESCE( + ploc.bin, + NULLIF(LTRIM(RTRIM(s.bin)), N''), + NULLIF(LTRIM(RTRIM(s.other_bin)), N'') + )` +); + +const buildBaseSql = () => ` FROM dbo.jst_sku s OUTER APPLY ( SELECT TOP 1 i.qty @@ -108,7 +140,7 @@ OUTER APPLY ( ORDER BY i.updateTime DESC ) inv OUTER APPLY ( - SELECT TOP 1 m2.sevenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock, + SELECT TOP 1 m2.fifteenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock, m2.date, m2.jsStock, m2.nearSecondSale FROM dbo.pm_stockMonitor m2 WHERE m2.sku_id = s.sku_id AND m2.warehouse = @warehouseName @@ -121,15 +153,34 @@ OUTER APPLY ( AND p.delivery_date >= @monitorDate AND p.delivery_date < DATEADD(DAY, 1, @monitorDate) ) pin +-- 采购在途:该 SKU 全部采购单中「已下单 − 已入库」的未到货部分合计 +-- jst_purchaseItem.qty = 采购数量,inQty = 已入库数量,plan_arrive_qty 全库恒为 0(不可用)、 +-- is_delivery 全库恒为 0(不可信),故在途口径 = SUM(MAX(0, qty − inQty))。 +-- ⚠️ 此列只参与「取数/展示」,绝不参与 ORDER BY / 聚合(参考本文件头 MSSQL 坑说明, +-- 对 OUTER APPLY 列做排序/聚合会触发灾难性执行计划)。 +OUTER APPLY ( + SELECT CAST(SUM(CASE WHEN p.qty > ISNULL(p.inQty, 0) THEN p.qty - ISNULL(p.inQty, 0) ELSE 0 END) AS DECIMAL(18, 2)) AS transit + FROM dbo.jst_purchaseItem p + WHERE p.sku_id = s.sku_id +) ptr OUTER APPLY ( SELECT TOP 1 NULLIF(LTRIM(RTRIM(c.pictureSkucode)), N'') AS pictureSkucode FROM dbo.fys_skuCostPriceUpdate c WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id)))) ORDER BY c.createTime DESC ) cost --- 模块化配置状态:见文件头部「口径变更说明」 +-- 仓位:主货位(真货位),见 buildLocationSql() 说明 OUTER APPLY ( - SELECT CAST(CASE WHEN ${buildConfiguredExistsSql(coverage)} THEN 1 ELSE 0 END AS BIT) AS configured + SELECT TOP 1 NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin + FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK) + WHERE pk.sku_id = s.sku_id + AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL + AND ISNULL(pk.qty, 0) > 0 + ORDER BY pk.qty DESC +) ploc +-- 模块化配置状态:见文件头部「已配置MKH」口径说明 +OUTER APPLY ( + SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured ) cfg WHERE s.other_3 = @warehouseName`; @@ -145,16 +196,6 @@ const toText = (value) => { return text === '' ? null : text; }; -const normalizeWarningLevel = (realtimeStock, stockDays) => { - const stock = Number(realtimeStock || 0); - if (stock <= 0) return WARNING_LEVEL.OUT_OF_STOCK; - - const days = toNumber(stockDays); - if (days !== null && days <= LOW_STOCK_DAYS_THRESHOLD) return WARNING_LEVEL.WARNING; - - return WARNING_LEVEL.NORMAL; -}; - const WARNING_ORDER = { [WARNING_LEVEL.OUT_OF_STOCK]: 0, [WARNING_LEVEL.WARNING]: 1, @@ -180,7 +221,7 @@ async function resolveMonitorDate() { return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); } -const buildFilters = (query = {}, monitorDate = null, coverage = {}) => { +const buildFilters = (query = {}, monitorDate = null) => { const conditions = []; const params = { wmsCoId: WAREHOUSE_WMS_CO_ID, @@ -188,11 +229,6 @@ const buildFilters = (query = {}, monitorDate = null, coverage = {}) => { monitorDate, }; - // 「已配置MKH」判定依赖产品资料库覆盖清单(逗号分隔的 SKU 编码) - if (coverage.available) { - params.productCoveredCsv = coverage.csv || ''; - } - const keyword = toText(query.keyword); if (keyword) { params.keyword = `%${keyword}%`; @@ -208,7 +244,7 @@ const buildFilters = (query = {}, monitorDate = null, coverage = {}) => { const location = toText(query.location); if (location) { params.location = location; - conditions.push(`COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) = @location`); + conditions.push(`${buildLocationSql()} = @location`); } const mkhStatus = toText(query.mkhStatus); @@ -220,20 +256,14 @@ const buildFilters = (query = {}, monitorDate = null, coverage = {}) => { const warningLevel = toText(query.warningLevel); if (warningLevel && Object.prototype.hasOwnProperty.call(WARNING_ORDER, warningLevel)) { params.warningOrder = WARNING_ORDER[warningLevel]; - conditions.push(` - CASE - WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0 - WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 - ELSE 2 - END = @warningOrder`); + // 用统一表达式,未配置MKH 不参与预警判定(筛「缺货 / 预警」时不会漏出未配置MKH) + conditions.push(`${buildWarningOrderSql()} = @warningOrder`); } const onlyWarning = String(query.onlyWarning || '').toLowerCase(); if (['1', 'true', 'yes'].includes(onlyWarning)) { - conditions.push(`( - COALESCE(inv.qty, m.jsStock, 0) <= 0 - OR (m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD}) - )`); + // 仅看缺货 / 预警:统一表达式 <= 1(未配置MKH 一律为 2,自动排除) + conditions.push(`${buildWarningOrderSql()} <= 1`); } return { conditions, params }; @@ -284,59 +314,24 @@ async function attachProductImages(rows = []) { } /** - * 解析目标仓 SKU 的「产品资料库覆盖清单」,用于「已配置MKH」判定。 - * 返回 { available, csv, set, total, coveredCount },结果按 TTL 缓存。 - * 产品资料库不可用时 available = false,SQL 侧自动回退到业务库 MKH BOM 判定。 + * 「已配置MKH」判定说明(2026-09-17 晚修正): + * 直接由 buildBaseSql 的 cfg 子查询判定,数据源为聚水潭 SKU 级 BOM 维护 + * rpt.jst_skuBomItem(map_outer_sku_id = 该 SKU 且父项 sku_id 以 'MKH' 开头), + * 不再依赖产品资料系统数据库。产品资料库仅用于补齐商品图片(见 attachProductImages)。 */ -async function resolveProductCoverage() { - const now = Date.now(); - if ( - productCoverageCache - && productCoverageCache.warehouse === WAREHOUSE_NAME - && now - productCoverageCache.at < PRODUCT_COVERAGE_TTL_MS - ) { - return productCoverageCache; - } - - const skuRows = await cloudDatabase.query( - `SELECT s.sku_id AS skuId FROM dbo.jst_sku s WHERE s.other_3 = @warehouseName`, - { warehouseName: WAREHOUSE_NAME }, - { requestTimeout: 180000 } - ); - const codes = skuRows.map((row) => toText(row.skuId)).filter(Boolean); - - let covered = []; - let available = true; - try { - const rows = await productDb.query( - `SELECT DISTINCT barcode - FROM external.product_catalog_wide_base_v1 - WHERE barcode = ANY($1)`, - [codes] - ); - covered = rows.map((row) => toText(row.barcode)).filter(Boolean); - } catch (error) { - available = false; - console.warn('[ModularStock] 产品资料库覆盖清单查询失败,回退业务库 MKH BOM 判定:', error.message); - } - - productCoverageCache = { - warehouse: WAREHOUSE_NAME, - available, - csv: covered.join(','), - set: new Set(covered), - total: codes.length, - coveredCount: covered.length, - at: now, - }; - return productCoverageCache; -} const mapRow = (row = {}) => { const realtimeStock = Number(row.realtimeStock || 0); const stockDays = toNumber(row.stockDays); const safetyDays = toNumber(row.safetyDays) ?? DEFAULT_SAFETY_DAYS; - const warningLevel = normalizeWarningLevel(realtimeStock, stockDays); + // 预警等级以 SQL 端 buildWarningOrderSql() 的判定为准(已含「未配置MKH 不进预警」规则), + // 前端显示与排序口径保持一致,避免前后端各算一份。 + const warningOrder = Number(row.warningOrder ?? WARNING_ORDER[WARNING_LEVEL.NORMAL]); + const warningLevel = warningOrder === WARNING_ORDER[WARNING_LEVEL.OUT_OF_STOCK] + ? WARNING_LEVEL.OUT_OF_STOCK + : warningOrder === WARNING_ORDER[WARNING_LEVEL.WARNING] + ? WARNING_LEVEL.WARNING + : WARNING_LEVEL.NORMAL; return { skuId: toText(row.skuId), @@ -345,14 +340,15 @@ const mapRow = (row = {}) => { // 图片优先取产品资料系统数据库(业务库 pic/pic_big 目标仓全为空) imageUrl: toText(row.productImageUrl) || toText(row.imageUrl), warningLevel, - warningOrder: WARNING_ORDER[warningLevel], + warningOrder, mkhStatus: toText(row.mkhStatus) || MKH_STATUS.UNCONFIGURED, // 业务库无出入库流水表:入库取采购到货(监控日),出库取 pm_stockMonitor 近一日发货量 inQtyToday: toNumber(row.inQtyToday) ?? 0, outQtyToday: toNumber(row.outQtyToday) ?? 0, realtimeStock, + purchaseTransit: toNumber(row.purchaseTransit) ?? 0, location: toText(row.location), - avgShip7d: toNumber(row.avgShip7d), + avgShip15d: toNumber(row.avgShip15d), stockDays, safetyDays, suggestStock: toNumber(row.suggestStock), @@ -365,10 +361,11 @@ const buildProjection = () => ` s.name AS productName, s.category AS category, COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock, + ISNULL(ptr.transit, 0) AS purchaseTransit, ISNULL(pin.inQty, 0) AS inQtyToday, ISNULL(m.nearSecondSale, 0) AS outQtyToday, - COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) AS location, - m.sevenAvgSale AS avgShip7d, + ${buildLocationSql()} AS location, + m.fifteenAvgSale AS avgShip15d, m.jsUseDays AS stockDays, m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, @@ -379,23 +376,16 @@ const buildProjection = () => ` cost.pictureSkucode ) AS imageUrl, CASE WHEN cfg.configured = 1 THEN N'已配置MKH' ELSE N'未配置MKH' END AS mkhStatus, - CASE - WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0 - WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 - ELSE 2 - END AS warningOrder, + ${buildWarningOrderSql()} AS warningOrder, m.date AS monitorDate`; async function getModularStockList(query = {}) { const page = Math.max(1, Number.parseInt(query.page, 10) || 1); const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE; const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize)); - const [monitorDate, coverage] = await Promise.all([ - resolveMonitorDate(), - resolveProductCoverage(), - ]); - const { conditions, params } = buildFilters(query, monitorDate, coverage); - const baseSql = buildBaseSql(coverage); + const monitorDate = await resolveMonitorDate(); + const { conditions, params } = buildFilters(query, monitorDate); + const baseSql = buildBaseSql(); const whereSql = conditions.length ? `${baseSql} AND ${conditions.join(' AND ')}` : baseSql; const sortField = SORTABLE_FIELDS[query.sortField] ? query.sortField : 'realtimeStock'; @@ -443,6 +433,7 @@ async function getModularStockList(query = {}) { configuredCount: summaryRow?.configuredCount || 0, unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0), totalStock: summaryRow?.totalStock || 0, + totalPurchaseTransit: summaryRow?.totalPurchaseTransit || 0, }, }; } @@ -451,22 +442,23 @@ async function buildSummary(whereSql, params) { const rows = await cloudDatabase.query( `SELECT COUNT_BIG(*) AS skuCount, - SUM(CASE WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 1 ELSE 0 END) AS outOfStockCount, + SUM(CASE WHEN ${buildWarningOrderSql()} = 0 THEN 1 ELSE 0 END) AS outOfStockCount, CAST(ISNULL(SUM(COALESCE(inv.qty, m.jsStock, 0)), 0) AS BIGINT) AS totalStock, - SUM(CASE WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 ELSE 0 END) AS lowStockDaysCount, + CAST(ISNULL(SUM(ISNULL(ptr.transit, 0)), 0) AS BIGINT) AS totalPurchaseTransit, + SUM(CASE WHEN ${buildWarningOrderSql()} = 1 THEN 1 ELSE 0 END) AS lowStockDaysCount, SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount, MAX(m.date) AS monitorDate ${whereSql}`, params, { requestTimeout: 180000 } ); - const row = rows[0] || {}; return [{ skuCount: Number(row.skuCount || 0), outOfStockCount: Number(row.outOfStockCount || 0), totalStock: Number(row.totalStock || 0), + totalPurchaseTransit: Number(row.totalPurchaseTransit || 0), lowStockDaysCount: Number(row.lowStockDaysCount || 0), configuredCount: Number(row.configuredCount || 0), monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null, @@ -474,25 +466,17 @@ async function buildSummary(whereSql, params) { } async function getModularStockFilters() { - const [monitorDate, coverage] = await Promise.all([ - resolveMonitorDate(), - resolveProductCoverage(), - ]); - const params = { - wmsCoId: WAREHOUSE_WMS_CO_ID, - warehouseName: WAREHOUSE_NAME, - monitorDate, - }; - if (coverage.available) { - params.productCoveredCsv = coverage.csv || ''; - } - + // 2026-09-18 修正:品类下拉不需要 JOIN 库存/采购/监控等重表,只用 SKU 主表即可, + // 避免 buildBaseSql 里新增采购在途 OUTER APPLY 后把 filters 接口拖慢。 const rows = await cloudDatabase.query( `SELECT DISTINCT s.category - ${buildBaseSql(coverage)} AND s.category IS NOT NULL AND LTRIM(RTRIM(s.category)) <> N'' + FROM dbo.jst_sku s + WHERE s.other_3 = @warehouseName + AND s.category IS NOT NULL + AND LTRIM(RTRIM(s.category)) <> N'' ORDER BY s.category`, - params, - { requestTimeout: 180000 } + { warehouseName: WAREHOUSE_NAME }, + { requestTimeout: 60000 } ); return { @@ -503,6 +487,97 @@ async function getModularStockFilters() { }; } +/** + * 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细。 + * 口径:在途数量 = qty − inQty(已下单但尚未入库的部分)。 + * 支持 keyword 搜索商品编码 / 名称,支持按 transitQty(默认)/ skuId / purchaseQty / deliveryDate 排序。 + */ +async function getPurchaseInTransitList(query = {}) { + const page = Math.max(1, Number.parseInt(query.page, 10) || 1); + const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE; + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize)); + + const keyword = toText(query.keyword); + const params = { warehouseName: WAREHOUSE_NAME }; + let whereSql = `s.other_3 = @warehouseName AND p.qty > ISNULL(p.inQty, 0)`; + if (keyword) { + params.keyword = `%${keyword}%`; + whereSql += ` AND (p.sku_id LIKE @keyword OR p.name LIKE @keyword)`; + } + + const sortField = String(query.sortField || '').trim(); + const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + const orderMap = { + skuId: 'p.sku_id', + deliveryDate: 'p.delivery_date', + transitQty: 'transitQty', + purchaseQty: 'p.qty', + }; + const orderCol = orderMap[sortField] || 'transitQty'; + const orderClause = sortField === 'skuId' + ? `${orderCol} ${sortOrder}` + : `${orderCol} ${sortOrder}, p.sku_id ASC`; + + const offset = (page - 1) * pageSize; + + const [rows, totalRow] = await Promise.all([ + cloudDatabase.query( + `SELECT + p.sku_id AS skuId, + p.name AS productName, + CAST(p.qty AS BIGINT) AS purchaseQty, + CAST(ISNULL(p.inQty, 0) AS BIGINT) AS inQty, + CAST(p.qty - ISNULL(p.inQty, 0) AS BIGINT) AS transitQty, + p.delivery_date AS deliveryDate, + CAST(p.po_id AS NVARCHAR(50)) AS poId, + CAST(p.poi_id AS NVARCHAR(50)) AS poiId + FROM rpt.jst_purchaseItem p + INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id + WHERE ${whereSql} + ORDER BY ${orderClause} + OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`, + params, + { requestTimeout: 180000 } + ), + cloudDatabase.query( + `SELECT COUNT_BIG(*) AS total + FROM rpt.jst_purchaseItem p + INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id + WHERE ${whereSql}`, + params, + { requestTimeout: 180000 } + ), + ]); + + return { + rows: rows.map((row) => ({ + skuId: toText(row.skuId), + productName: toText(row.productName), + purchaseQty: Number(row.purchaseQty || 0), + inQty: Number(row.inQty || 0), + transitQty: Number(row.transitQty || 0), + deliveryDate: row.deliveryDate ? new Date(row.deliveryDate).toISOString().slice(0, 10) : null, + poId: toText(row.poId), + poiId: toText(row.poiId), + })), + total: Number(totalRow?.[0]?.total || 0), + page, + pageSize, + warehouse: WAREHOUSE_NAME, + }; +} + +// 手动同步:本页无进程内缓存(每次请求直连副本库),同步动作 = 重新解析监控日并返回, +// 前端据此刷新列表到最新快照。 +// 注意:副本库由聚水潭侧同步,应用层无法触发上游拉取,这里只保证取到最新监控日。 +async function syncModularStock() { + const monitorDate = await resolveMonitorDate(); + return { + syncedAt: new Date().toISOString(), + monitorDate, + }; +} + module.exports = { WAREHOUSE_NAME, WAREHOUSE_WMS_CO_ID, @@ -510,4 +585,6 @@ module.exports = { DEFAULT_SAFETY_DAYS, getModularStockList, getModularStockFilters, + getPurchaseInTransitList, + syncModularStock, }; diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 346e9bb..876d9fc 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -169,3 +169,594 @@ test('components without pack inventory rows do not receive a fallback warehouse const locations = _private.buildPackLocationMap([], ['PART-1']); assert.equal(locations.has('PART-1'), false); }); + +// ── 配件仓位:只认「箱及仓位库存」,不做库存表/商品档案仓回退(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); +}); + +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); +}); + +// ── 补货预警:近 15 天日销 > 0 且 现有库存 < 3 天日销 ─────────────── +const metrics = (row) => _private.buildReplenishMetrics(row); + +test('replenish: stock below 3 days of sales is flagged as stockout or warning', () => { + const stockout = metrics({ avgSale: 2.55, realtimeStock: 0, monitorStock: 0 }); + assert.equal(stockout.stockDays, 0); + assert.equal(stockout.warningLevel, '缺货'); + assert.equal(stockout.hasWarning, true); + assert.equal(stockout.replenishQty, 8); // ceil(3 * 2.55 - 0) = ceil(7.65) + + const warning = metrics({ avgSale: 2, realtimeStock: 1, monitorStock: 1 }); + assert.equal(warning.stockDays, 0.5); + assert.equal(warning.warningLevel, '预警'); + assert.equal(warning.hasWarning, true); + assert.equal(warning.replenishQty, 5); // ceil(6 - 1) +}); + +test('replenish: the threshold is exactly 3 days of sales', () => { + const atThreshold = metrics({ avgSale: 2, realtimeStock: 6, monitorStock: 6 }); + assert.equal(atThreshold.stockDays, 3); + assert.equal(atThreshold.warningLevel, '正常'); + assert.equal(atThreshold.hasWarning, false); + assert.equal(atThreshold.replenishQty, 0); + + const below = metrics({ avgSale: 2, realtimeStock: 5.9, monitorStock: 5.9 }); + assert.equal(below.hasWarning, true); +}); + +test('replenish: stock prefers the brand-warehouse realtime value, snapshot only as fallback', () => { + const realtime = metrics({ avgSale: 1, realtimeStock: 4, monitorStock: 9 }); + assert.equal(realtime.stock, 4); + assert.equal(realtime.stockSource, 'realtime'); + + // 实时库存「无记录」(null)才回退监控快照 + const fallback = metrics({ avgSale: 1, realtimeStock: null, monitorStock: 9 }); + assert.equal(fallback.stock, 9); + assert.equal(fallback.stockSource, 'monitor'); + + // 实时库存为 0 是有效值,不得被快照覆盖 + const zero = metrics({ avgSale: 1, realtimeStock: 0, monitorStock: 9 }); + assert.equal(zero.stock, 0); + assert.equal(zero.warningLevel, '缺货'); +}); + +test('replenish: products without 7-day sales never warn', () => { + const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 }); + assert.equal(noSale.hasWarning, false); + assert.equal(noSale.warningLevel, '无销量'); + assert.equal(noSale.stockDays, null); + assert.equal(noSale.replenishQty, 0); + assert.equal(noSale.thresholdQty, 0); +}); + +test('replenish scope resolves from scope or the legacy warningOnly flag, then filters rows', () => { + const rows = [ + { skuId: 'A', hasWarning: true, dailySale: 2 }, + { skuId: 'B', hasWarning: false, dailySale: 2 }, + { skuId: 'C', hasWarning: false, dailySale: 0 }, + ]; + + assert.equal(_private.resolveScope({}), 'all'); + assert.equal(_private.resolveScope({ warningOnly: '1' }), 'warning'); + assert.equal(_private.resolveScope({ scope: 'sold' }), 'sold'); + assert.equal(_private.resolveScope({ scope: 'bogus', warningOnly: 'true' }), 'warning'); + + assert.deepEqual(_private.filterRows(rows, { scope: 'warning' }).map((r) => r.skuId), ['A']); + assert.deepEqual(_private.filterRows(rows, { scope: 'sold' }).map((r) => r.skuId), ['A', 'B']); + assert.deepEqual(_private.filterRows(rows, { keyword: 'b' }).map((r) => r.skuId), ['B']); +}); + +test('replenish sort puts the most urgent first and no-sale products last', () => { + const rows = [ + { skuId: 'NO-SALE', stockDays: null }, + { skuId: 'B', stockDays: 2.8 }, + { skuId: 'A', stockDays: 0 }, + { skuId: 'C', stockDays: 12 }, + ]; + + assert.deepEqual( + _private.sortRows(rows, 'stockDays', 'ASC').map((r) => r.skuId), + ['A', 'B', 'C', 'NO-SALE'] + ); + assert.deepEqual( + _private.sortRows(rows, 'stockDays', 'DESC').map((r) => r.skuId), + ['C', 'B', 'A', 'NO-SALE'] + ); + assert.deepEqual( + _private.sortRows(rows, 'skuId', 'ASC').map((r) => r.skuId), + ['A', 'B', 'C', 'NO-SALE'] + ); +}); + +// ── 在途加工单:半成品包装仓有未完成加工单就不再预警(2026-09-17 口径)── +test('replenish: a pending work order suppresses the replenish warning', () => { + // 库存为 0、本该判「缺货」,但半成品包装项目仓已有未完成加工单 + const scheduled = metrics({ + avgSale: 2, + realtimeStock: 0, + monitorStock: 0, + pendingWorkOrderQty: 20, + pendingWorkOrderCount: 1, + // 用本地时间构造,避免测试机时区不同导致日期串变化 + pendingWorkOrderEta: new Date(2026, 9, 1), + pendingWorkOrderNo: '893495', + }); + assert.equal(scheduled.hasWarning, false); + assert.equal(scheduled.warningLevel, '已排产'); + assert.equal(scheduled.replenishQty, 0); + assert.equal(scheduled.hasPendingWorkOrder, true); + assert.equal(scheduled.pendingWorkOrderQty, 20); + assert.equal(scheduled.pendingWorkOrderCount, 1); + assert.equal(scheduled.pendingWorkOrderEta, '2026-10-01'); + assert.equal(scheduled.pendingWorkOrderNo, '893495'); + // 可用天数仍如实反映现有库存,便于业务复核 + assert.equal(scheduled.stockDays, 0); + + // 同样库存但无加工单时仍然预警 —— 确认抑制效果确实来自加工单 + const bare = metrics({ avgSale: 2, realtimeStock: 0, monitorStock: 0 }); + assert.equal(bare.hasWarning, true); + assert.equal(bare.warningLevel, '缺货'); +}); + +test('replenish: pending work order fields default to empty without any order', () => { + const none = metrics({ avgSale: 1, realtimeStock: 0 }); + assert.equal(none.hasPendingWorkOrder, false); + assert.equal(none.pendingWorkOrderQty, 0); + assert.equal(none.pendingWorkOrderCount, 0); + assert.equal(none.pendingWorkOrderEta, null); + assert.equal(none.pendingWorkOrderNo, null); + + // 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理 + const zeroQty = metrics({ + avgSale: 1, + realtimeStock: 0, + pendingWorkOrderQty: 0, + pendingWorkOrderCount: 2, + }); + assert.equal(zeroQty.hasPendingWorkOrder, true); + assert.equal(zeroQty.warningLevel, '已排产'); + assert.equal(zeroQty.hasWarning, false); +}); + +test('replenish: products without sales stay "no sale" even with a pending work order', () => { + const noSale = metrics({ + avgSale: 0, + realtimeStock: 0, + pendingWorkOrderQty: 5, + pendingWorkOrderCount: 1, + }); + assert.equal(noSale.warningLevel, '无销量'); + assert.equal(noSale.hasWarning, false); + // 在途量仍要透出,页面上应能看到 + assert.equal(noSale.pendingWorkOrderQty, 5); +}); + +test('pending work order filters keep the business-confirmed source and exclusions', () => { + assert.equal(_private.WORK_ORDER_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓'); + assert.equal(_private.WORK_ORDER_VOID_STATUS, '作废'); + assert.equal(_private.WORK_ORDER_RECEIVED_STATUS, '全部入库'); + 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 warn by available days (stock / order qty)', () => { + // 可用天数 = 现有库存 ÷ 代发订单件数;≤ 3 天即预警,= 0(无库存)为缺货。 + // 完全没货:可用天数 0 → 缺货,补货量 = 代发件数 − 现有库存 + const outOfStock = metrics({ + avgSale: 0, realtimeStock: 0, monitorStock: 0, pendingOrderQty: 4, pendingOrderCount: 2, + }); + assert.equal(outOfStock.hasWarning, true); + assert.equal(outOfStock.warningLevel, '缺货'); + assert.equal(outOfStock.orderDriven, true); + assert.equal(outOfStock.stockDays, 0); + assert.equal(outOfStock.replenishQty, 4); + assert.equal(outOfStock.pendingOrderQty, 4); + assert.equal(outOfStock.pendingOrderCount, 2); + + // 有货但只够 0.25 天 → 预警(不再因「库存 < 订单件数」直接判缺货) + const short = metrics({ + avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, + }); + assert.equal(short.warningLevel, '预警'); + assert.equal(short.hasWarning, true); + assert.equal(short.stockDays, 0.25); + // 补到 3 天缓冲:ceil(3 × 4 − 1) = 11 + assert.equal(short.replenishQty, 11); + + // 可用天数正好 1 天 → 仍预警,补到 3 天缓冲:ceil(3 × 4 − 4) = 8 + const oneDay = metrics({ + avgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4, + }); + assert.equal(oneDay.warningLevel, '预警'); + assert.equal(oneDay.stockDays, 1); + assert.equal(oneDay.replenishQty, 8); + + // 可用天数正好 3 天(库存=12,件数=4)→ 阈值取「< 3」,故不预警 + const atThreshold = metrics({ + avgSale: 0, realtimeStock: 12, monitorStock: 12, pendingOrderQty: 4, + }); + assert.equal(atThreshold.warningLevel, '正常'); + assert.equal(atThreshold.hasWarning, false); + assert.equal(atThreshold.stockDays, 3); + assert.equal(atThreshold.replenishQty, 0); + + // 可用天数超 3 天(库存=13,件数=4 → 3.25 天)→ 库存充足,不预警,但仍标记 orderDriven + const enough = metrics({ + avgSale: 0, realtimeStock: 13, monitorStock: 13, pendingOrderQty: 4, + }); + assert.equal(enough.warningLevel, '正常'); + assert.equal(enough.hasWarning, false); + assert.equal(enough.orderDriven, true); + assert.equal(enough.stockDays, 3.25); +}); + +test('replenish: order-driven warning does not override the pending work order exemption', () => { + const scheduled = metrics({ + avgSale: 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({ avgSale: 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({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); + assert.equal(noSale.warningLevel, '无销量'); + assert.equal(noSale.orderDriven, false); + + // 有日销的商品:待发货订单先从库存里扣掉(结余库存口径,见服务文件头 ⑫) + const withSale = metrics({ avgSale: 2, realtimeStock: 0, pendingOrderQty: 3 }); + assert.equal(withSale.warningLevel, '缺货'); + assert.equal(withSale.orderDriven, false); + assert.equal(withSale.pendingOrderQty, 3); + assert.equal(withSale.availableStock, 0); // max(0, 0 − 3) + assert.equal(withSale.stockDays, 0); + assert.equal(withSale.replenishQty, 9); // ceil(3 × 2 + 3 − 0):先补订单缺口 3,再补 3 天缓冲 6 + + // 结余库存 > 0 时按结余算可用天数:库存 10 − 待发 3 = 7,7 ÷ 2 = 3.5 天 → 正常 + const buffered = metrics({ avgSale: 2, realtimeStock: 10, pendingOrderQty: 3 }); + assert.equal(buffered.availableStock, 7); + assert.equal(buffered.stockDays, 3.5); + assert.equal(buffered.warningLevel, '正常'); + assert.equal(buffered.replenishQty, 0); // ceil(6 + 3 − 10) < 0 → 0 + + // 结余库存不足以支撑 3 天日销 → 预警(MKHDNZ0409260601376 同型:库存3 待发3 日销0.27) + const tight = metrics({ avgSale: 1, realtimeStock: 5, pendingOrderQty: 3 }); + assert.equal(tight.availableStock, 2); + assert.equal(tight.stockDays, 2); + assert.equal(tight.warningLevel, '预警'); + assert.equal(tight.replenishQty, 1); // ceil(3 + 3 − 5) +}); + +// 回归:MKHDNZ0409260601376(2026-09-18 业务反馈) +// 现有库存 3、待发货订单 3 → 结余库存 0、可用天数 0,应判「缺货」。 +// 旧代码只在 dailySale <= 0 时才让待发货订单参与判定,本例日销 0.27 > 0 导致漏报。 +test('replenish: pending orders are deducted even when daily sale is positive (⑫)', () => { + const row = metrics({ avgSale: 0.27, realtimeStock: 3, pendingOrderQty: 3, pendingOrderCount: 3 }); + assert.equal(row.orderDriven, false); + assert.equal(row.availableStock, 0); + assert.equal(row.stockDays, 0); + assert.equal(row.warningLevel, '缺货'); + assert.equal(row.hasWarning, true); + assert.equal(row.warningOrder, 1); + assert.equal(row.replenishQty, 1); // ceil(3 × 0.27 + 3 − 3) = ceil(0.81) +}); + +// ── 导出(见服务文件头 ⑨)──────────────────────────────────────── +test('export: product rows follow the page table columns', () => { + const rows = _private.buildProductExportRows([ + { + row: { + skuId: 'MKHDNZ0409260700428', + productName: 'Z字翻转|25刨花板斜切', + warningLevel: '缺货', + dailySale: 2.97, + stock: 0, + pendingWorkOrderQty: 0, + stockDays: 0, + replenishQty: 9, + }, + components: [{ barcode: 'A1', name: '铁搁板销', qty: 4 }, { barcode: 'A2', name: '自攻螺丝', qty: 12 }], + }, + { + row: { + skuId: 'MKHDNZ0409260600117', + productName: '无销量商品', + warningLevel: '无销量', + dailySale: 0, + stock: 10, + pendingWorkOrderQty: null, + stockDays: null, + replenishQty: 0, + }, + components: [], + }, + ]); + + assert.equal(rows.length, 2); + assert.deepEqual(rows[0], { + skuId: 'MKHDNZ0409260700428', + productName: 'Z字翻转|25刨花板斜切', + warningLevel: '缺货', + dailySale: 2.97, + stock: 0, + pendingWorkOrderQty: 0, + pendingOrderQty: 0, + stockDays: 0, + replenishQty: 9, + componentShortage: '', + assemblableQty: null, + bomConfigured: '已配置', + accessoryCount: 2, + accessoryQtySum: 16, + }); + // 无销量 → 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); +}); + +test('export: component rows are flattened one row per component', () => { + const rows = _private.buildComponentExportRows([ + { + row: { skuId: 'MKHDNZ0409260700428', productName: 'Z字翻转' }, + components: [ + { barcode: 'DNZ039864N1410V01', name: '台面', qty: 1, location: 'A-01 成品区' }, + { barcode: 'GBSFY-01', name: '说明书', qty: 2, location: null }, + ], + }, + { row: { skuId: 'MKHDNZ0409260600117', productName: '无配件商品' }, components: [] }, + ]); + + // 无配件的商品不产生任何导出行(不像页面那样占位) + assert.equal(rows.length, 2); + assert.deepEqual(rows[0], { + skuId: 'MKHDNZ0409260700428', + productName: 'Z字翻转', + index: 1, + componentBarcode: 'DNZ039864N1410V01', + componentName: '台面', + qty: 1, + location: 'A-01 成品区', + }); + // 同一商品的编码逐行重复(不做纵向合并,保证 Excel 可筛选) + assert.equal(rows[1].skuId, 'MKHDNZ0409260700428'); + assert.equal(rows[1].index, 2); + assert.equal(rows[1].location, null); + assert.equal(rows[1].qty, 2); +}); + +test('export: component rows fall back to code when barcode is missing', () => { + const rows = _private.buildComponentExportRows([ + { row: { skuId: 'MKH-X' }, components: [{ code: 'ONLY-CODE', name: '仅编码配件', qty: 3 }] }, + ]); + assert.equal(rows[0].componentBarcode, 'ONLY-CODE'); +}); + +test('export: mode constants cover both shapes', () => { + assert.deepEqual(_private.EXPORT_MODES, { PRODUCTS: 'products', COMPONENTS: 'components' }); +}); + +test('pack inventory location prefers the warehouse name and keeps the box fallback', () => { + const locations = _private.buildPackLocationMap([ + { + skuId: 'PART-1-1', + warehouseId: 1, + warehouseName: '常州简尚家居有限公司', + binGroup: null, + bin: '', + packId: 'BOX-1', + qty: 3, + }, + { + skuId: 'PART-1-2', + warehouseId: 1, + warehouseName: '常州简尚家居有限公司', + binGroup: '成品区', + bin: 'A-01', + packId: 'BOX-2', + qty: 1, + }, + ], ['PART-1']); + + assert.equal( + locations.get('PART-1'), + '常州简尚家居有限公司 · 箱号 BOX-1、常州简尚家居有限公司 · 成品区 / A-01' + ); +}); + +test('pack inventory location falls back to the warehouse id when the name is missing', () => { + const locations = _private.buildPackLocationMap([ + { skuId: 'PART-1-1', warehouseId: 4, warehouseName: null, bin: '', packId: 'BOX-9', qty: 1 }, + ], ['PART-1']); + + assert.equal(locations.get('PART-1'), '仓库 4 · 箱号 BOX-9'); +}); + +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); +}); diff --git a/backend/test/mkhReplenishAlertJob.test.js b/backend/test/mkhReplenishAlertJob.test.js new file mode 100644 index 0000000..7e90c92 --- /dev/null +++ b/backend/test/mkhReplenishAlertJob.test.js @@ -0,0 +1,194 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildAlertSignature, + buildMkhReplenishNotification, + buildNotifiedSnapshot, + chinaDateKey, + diffAlertState, + formatQty, +} = require('../src/jobs/mkhReplenishAlertJob'); + +const alert = (skuId, level = '缺货', extra = {}) => ({ + skuId, + warningLevel: level, + productName: `商品 ${skuId}`, + dailySale: 1, + stock: 0, + replenishQty: 3, + ...extra, +}); + +test('diffAlertState splits fresh / persisted / restored', () => { + const state = { notified: { A: { level: '缺货' }, B: { level: '预警' }, Z: { level: '缺货' } } }; + const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B'), alert('C')], state); + + assert.deepEqual(fresh.map((r) => r.skuId), ['C']); + assert.deepEqual(persisted.map((r) => r.skuId), ['A', 'B']); + // Z 已补货恢复,应从已推送记录里剔除 + assert.deepEqual(restored, ['Z']); +}); + +test('diffAlertState treats empty state as all fresh', () => { + const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B')], {}); + assert.equal(fresh.length, 2); + assert.equal(persisted.length, 0); + assert.deepEqual(restored, []); +}); + +test('buildNotifiedSnapshot keeps firstNotifiedAt and drops restored skus', () => { + const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-01T01:00:00.000Z' } } }; + const next = buildNotifiedSnapshot([alert('A'), alert('B')], state, '2026-09-17T01:00:00.000Z'); + + assert.deepEqual(Object.keys(next).sort(), ['A', 'B']); + assert.equal(next.A.firstNotifiedAt, '2026-09-01T01:00:00.000Z'); + assert.equal(next.A.lastNotifiedAt, '2026-09-17T01:00:00.000Z'); + assert.equal(next.B.firstNotifiedAt, '2026-09-17T01:00:00.000Z'); +}); + +test('buildNotifiedSnapshot with touch=false does not refresh lastNotifiedAt', () => { + const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-05T01:00:00.000Z' } } }; + const next = buildNotifiedSnapshot([alert('A')], state, '2026-09-17T01:00:00.000Z', { touch: false }); + assert.equal(next.A.lastNotifiedAt, '2026-09-05T01:00:00.000Z'); +}); + +test('buildAlertSignature is order independent but content sensitive', () => { + const a = alert('A'); + const b = alert('B', '预警'); + assert.equal(buildAlertSignature([a, b]), buildAlertSignature([b, a])); + assert.notEqual(buildAlertSignature([a, b]), buildAlertSignature([a])); + // 等级变化也算变化(缺货 → 预警 应产生新指纹) + assert.notEqual(buildAlertSignature([alert('A', '缺货')]), buildAlertSignature([alert('A', '预警')])); +}); + +test('chinaDateKey renders Beijing date regardless of UTC offset', () => { + // 2026-09-16T17:30:00Z === 2026-09-17 01:30 (北京) → 跨日必须算 09-17 + assert.equal(chinaDateKey(new Date('2026-09-16T17:30:00Z')), '20260917'); + assert.equal(chinaDateKey(new Date('2026-09-16T01:00:00Z')), '20260916'); +}); + +test('formatQty trims trailing zeros and tolerates bad input', () => { + assert.equal(formatQty(3), '3'); + assert.equal(formatQty(2.9700000001), '2.97'); + assert.equal(formatQty(0.28), '0.28'); + assert.equal(formatQty(null), '—'); + assert.equal(formatQty('abc'), '—'); +}); + +test('notification carries recipient, idempotency key and shortfall details', () => { + const fresh = [alert('MKHDNZ0409260700428', '缺货', { dailySale: 2.97, replenishQty: 9 })]; + const all = [...fresh, alert('MKHDNZ0409260700486', '预警', { dailySale: 2, stock: 1, replenishQty: 5 })]; + const snapshot = { + monitorDate: '2026-09-16', + saleWindowDays: 15, + replenishDaysThreshold: 3, + outOfStockCount: 1, + warningOnlyCount: 1, + replenishQtyTotal: 14, + }; + + const payload = buildMkhReplenishNotification({ + alerts: fresh, + allAlerts: all, + snapshot, + recipientAlias: '苏适', + dateKey: '20260917', + }); + + assert.equal(payload.recipientAlias, '苏适'); + assert.equal(payload.idempotencyKey, `mkh_replenish_alert:苏适:20260917:${buildAlertSignature(fresh)}:v1`); + assert.ok(payload.title.includes('新增 1 个')); + assert.ok(payload.title.includes('含 1 个已缺货')); + assert.ok(payload.content.includes('MKHDNZ0409260700428')); + assert.ok(payload.content.includes('现有库存 0')); + assert.ok(payload.content.includes('建议补货 9')); + // 汇总用全量口径,不是本批新增 + assert.ok(payload.content.includes('当前共 **2** 个')); + assert.equal(payload.actionTitle, '查看补货清单'); + assert.ok(payload.content.length <= 4800); +}); + +test('notification truncates long lists but keeps the totals', () => { + const many = Array.from({ length: 40 }, (_, i) => alert(`MKH${String(i).padStart(4, '0')}`)); + const payload = buildMkhReplenishNotification({ + alerts: many, + allAlerts: many, + snapshot: { outOfStockCount: 40, warningOnlyCount: 0, replenishQtyTotal: 120 }, + recipientAlias: '苏适', + dateKey: '20260917', + }); + + assert.ok(payload.content.includes('仅列出前 20 个')); + assert.ok(payload.content.includes('当前共 **40** 个')); + assert.equal(payload.content.includes('MKH0039'), false); + assert.ok(payload.content.length <= 4800); +}); + +test('notification flags degraded work-order source', () => { + const payload = buildMkhReplenishNotification({ + alerts: [alert('A')], + allAlerts: [alert('A')], + snapshot: { outOfStockCount: 1, warningOnlyCount: 0, replenishQtyTotal: 3, workOrderDegraded: true }, + recipientAlias: '苏适', + dateKey: '20260917', + }); + 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('未做')); +}); diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js index 26cb884..4d61416 100644 --- a/frontend/src/api/mkhBom.js +++ b/frontend/src/api/mkhBom.js @@ -2,6 +2,7 @@ import request from './request'; const LIST_TIMEOUT_MS = 3 * 60 * 1000; const DETAIL_TIMEOUT_MS = 3 * 60 * 1000; +const EXPORT_TIMEOUT_MS = 5 * 60 * 1000; // MKH-BOM 资料列表(模块化组合商品 + 配件汇总) export const getMkhBomList = (params = {}) => request.get( @@ -14,3 +15,41 @@ export const getMkhBomDetail = (skuId) => request.get( '/api/mkh-bom/detail', { params: { skuId }, timeout: DETAIL_TIMEOUT_MS } ); + +// 导出:当前筛选条件下的全部数据(不分页) +// mode=products → 商品清单;mode=components → 配件明细摊平 +// ⚠️ 「全部」范围下官方 BOM 明细要分批拉取,耗时明显高于列表接口,故单独放大超时 +export const getMkhBomExportRows = (params = {}) => request.get( + '/api/mkh-bom/export', + { params, timeout: EXPORT_TIMEOUT_MS } +); + +// 补货预警推送设置(接收人 / 开关 / 推送范围) +export const getMkhReplenishAlertConfig = () => request.get('/api/mkh-bom/alert-config'); + +// 保存补货预警推送设置 +export const saveMkhReplenishAlertConfig = (payload = {}) => request.put( + '/api/mkh-bom/alert-config', + payload +); + +// 立即检查并推送(dryRun 只预览 / force 忽略「只推新增」/ reset 先清空已推送记录) +export const checkMkhReplenishAlert = (payload = {}) => request.post( + '/api/mkh-bom/alert-check', + payload, + { timeout: LIST_TIMEOUT_MS } +); + +// 保存统一通知中心凭据(补货预警的推送通道) +export const saveMkhNotificationCenter = (payload = {}) => request.put( + '/api/mkh-bom/notification-center', + payload +); + +// 手动同步:清空服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), +// 随后重新拉列表即直连副本库取最新快照,可随时点击 +export const syncMkhBom = () => request.post( + '/api/mkh-bom/sync', + null, + { timeout: LIST_TIMEOUT_MS } +); diff --git a/frontend/src/api/modularStockMonitor.js b/frontend/src/api/modularStockMonitor.js index 19257e7..ebafba9 100644 --- a/frontend/src/api/modularStockMonitor.js +++ b/frontend/src/api/modularStockMonitor.js @@ -14,3 +14,16 @@ export const getModularStockFilters = () => request.get( '/api/modular-stock-monitor/filters', { timeout: META_TIMEOUT_MS } ); + +// 采购在途明细列表(目标仓全部「采购数量 > 已入库数量」的采购单明细) +export const getPurchaseInTransitList = (params = {}) => request.get( + '/api/modular-stock-monitor/purchase-in-transit', + { params, timeout: LIST_TIMEOUT_MS } +); + +// 手动同步:后端重新取最新监控日,前端随后再拉一次列表即拿到最新快照 +export const syncModularStock = () => request.post( + '/api/modular-stock-monitor/sync', + null, + { timeout: META_TIMEOUT_MS } +); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 85e265f..f5d9f8d 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -1,10 +1,23 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, Button, Card, Input, Space, Spin, Table, Tooltip, Typography, message, + Alert, Button, Card, Collapse, Dropdown, Input, Modal, Segmented, Select, Space, Spin, Switch, + Table, Tooltip, Typography, message, } from 'antd'; -import { PrinterOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { + BellOutlined, DownloadOutlined, PrinterOutlined, ReloadOutlined, SearchOutlined, SyncOutlined, +} from '@ant-design/icons'; import { SycmPage } from '../../components/Common'; -import { getMkhBomList, getMkhBomDetail } from '../../api/mkhBom'; +import { + getMkhBomList, + getMkhBomDetail, + getMkhBomExportRows, + getMkhReplenishAlertConfig, + saveMkhReplenishAlertConfig, + saveMkhNotificationCenter, + checkMkhReplenishAlert, + syncMkhBom, +} from '../../api/mkhBom'; +import { exportTableFile } from '../../utils/tableExport'; import './style.css'; const { Title, Text } = Typography; @@ -18,9 +31,134 @@ const number = (value, digits = 0) => (value === null || value === undefined || const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); -const EMPTY_FILTERS = { keyword: '' }; +const EMPTY_FILTERS = { keyword: '', scope: 'warning' }; + +// 预警等级 → 标签配色 +const WARNING_TONE = { + 缺货: 'danger', + 预警: 'warn', + 已排产: 'scheduled', + 正常: 'ok', + 无销量: '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' }, + { label: '有日销', value: 'sold' }, + { label: '加工在途', value: 'work-order' }, + { label: '全部', value: 'all' }, +]; + +// ── 导出(列口径与后端 listMkhBomExportRows 的字段一一对应)────────── +// 商品清单:与页面表格列一致,去掉「操作」列 +const PRODUCT_EXPORT_COLUMNS = [ + { title: 'MKH商品编码', dataIndex: 'skuId' }, + { title: '商品名称', dataIndex: 'productName' }, + { title: '预警', dataIndex: 'warningLevel' }, + { title: '近15天日销', dataIndex: 'dailySale' }, + { title: '现有库存', dataIndex: 'stock' }, + { title: '在途加工量', dataIndex: 'pendingWorkOrderQty' }, + { title: '待发货订单', dataIndex: 'pendingOrderQty' }, + { title: '结余库存', dataIndex: 'availableStock' }, + { + title: '可用天数', + dataIndex: 'stockDays', + // 无销量且无代发订单的商品 stockDays 为 null,导出成文字而非空白; + // 订单驱动分支(日销=0 但有代发/待发货订单)现已如实给出可用天数(库存÷代发件数),不会进这里 + exportValue: (value, row) => { + if (value !== null && value !== undefined) return value; + return row.orderDriven ? '无日销·按订单' : '无销量'; + }, + }, + { + title: '建议补货量', + dataIndex: 'replenishQty', + // 与页面一致:非预警商品不显示数字;配件不足(可组装数 = 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' }, +]; + +// 配件明细:与打印模板一致,额外带部件条码便于下游核对 +const COMPONENT_EXPORT_COLUMNS = [ + { title: 'MKH商品编码', dataIndex: 'skuId' }, + { title: '部件条码', dataIndex: 'componentBarcode' }, + { title: '配件名称', dataIndex: 'componentName' }, + { title: '配件数量', dataIndex: 'qty' }, + { title: '仓位', dataIndex: 'location' }, +]; + +// 导出下拉菜单:key = `-` +const EXPORT_MENU_ITEMS = [ + { key: 'products-xlsx', label: '商品清单 · XLSX' }, + { key: 'products-csv', label: '商品清单 · CSV' }, + { type: 'divider' }, + { key: 'components-xlsx', label: '配件明细 · XLSX' }, + { key: 'components-csv', label: '配件明细 · CSV' }, +]; + +const EXPORT_SCOPE_LABEL = { + warning: '仅预警', sold: '有日销', 'work-order': '加工在途', all: '全部', +}; + +// 补货预警推送:范围选项与跳过原因文案 +const ALERT_SCOPE_OPTIONS = [ + { label: '缺货 + 3天内预警', value: 'warning' }, + { label: '仅缺货', value: 'outOfStock' }, +]; + +const ALERT_REASON_TEXT = { + no_new_alert: '没有新出现的预警,未发送(已推送过的不重复提醒)', + no_alert: '当前没有需要补货的 MKH 商品,未发送', + disabled: '推送开关已关闭,未发送', + recipient_not_configured: '未配置收件人,未发送', + notification_center_not_configured: '统一通知中心尚未配置,未发送', + error: '执行出错,未发送', +}; + +const ALERT_RECIPIENT_SOURCE_TEXT = { + config: '来自本页配置', + environment: '来自环境变量', + notification_center: '来自统一通知中心默认收件人', + none: '未找到可用的收件人', +}; // A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 +// 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」), +// 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。 +const stripWarehousePrefix = (value) => String(value || '') + .split('、') + .map((part) => { + const idx = part.lastIndexOf('·'); + return (idx >= 0 ? part.slice(idx + 1) : part).trim(); + }) + .filter(Boolean) + .join('、'); + const PrintPreview = ({ data, onClose }) => { const now = useMemo(() => new Date().toLocaleString('zh-CN'), []); const items = data.items || []; @@ -55,7 +193,7 @@ const PrintPreview = ({ data, onClose }) => { - + @@ -69,7 +207,7 @@ const PrintPreview = ({ data, onClose }) => { ) : null} - + ))} {items.length === 0 ? ( @@ -84,7 +222,7 @@ const PrintPreview = ({ data, onClose }) => {
MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充; - 配件仓位只取箱及仓位库存表;没有箱号或仓位记录时显示「未维护」。 + 配件仓位只取聚水潭「箱及仓位库存」,未维护货位时显示箱号,无装箱记录显示「未维护」。
@@ -99,7 +237,8 @@ const MkhBomData = () => { const [error, setError] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); - const [sorter, setSorter] = useState({ field: 'skuId', order: 'ASC' }); + // 默认按可用天数升序 —— 补货预警场景下最紧急的排最前 + const [sorter, setSorter] = useState({ field: 'stockDays', order: 'ASC' }); const [expandedKeys, setExpandedKeys] = useState({}); const [detailCache, setDetailCache] = useState({}); @@ -108,6 +247,20 @@ const MkhBomData = () => { const [printData, setPrintData] = useState(null); const [printLoading, setPrintLoading] = useState(false); + // 导出(当前筛选条件下的全部数据) + const [exporting, setExporting] = useState(false); + const [syncing, setSyncing] = useState(false); + + // 补货预警推送设置 + const [alertOpen, setAlertOpen] = useState(false); + const [alertConfig, setAlertConfig] = useState(null); + const [alertLoading, setAlertLoading] = useState(false); + const [alertBusy, setAlertBusy] = useState(false); + const [alertForm, setAlertForm] = useState({ enabled: true, recipientAlias: '', scope: 'warning' }); + const [alertResult, setAlertResult] = useState(null); + const [centerForm, setCenterForm] = useState({ baseUrl: '', projectCode: '', projectSecret: '' }); + const [centerSaving, setCenterSaving] = useState(false); + const tableWrapRef = useRef(null); const [bodyHeight, setBodyHeight] = useState(420); @@ -167,9 +320,155 @@ const MkhBomData = () => { const changeFilter = (key) => (value) => { setFilters((old) => ({ ...old, [key]: value || '' })); setPage(1); + // 点击「仅预警 / 有日销 / 加工在途 / 全部」后自动滚动到下方表格明细 + if (key === 'scope') { + setTimeout(() => { + tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 0); + } }; const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); }; + // ── 导出 ──────────────────────────────────────────────────── + // 与列表共用同一套筛选/排序口径,但走不分页接口(导出当前筛选条件下的「全部」行)。 + const handleExport = async (menuKey) => { + const [mode, format] = String(menuKey || '').split('-'); + if (!mode || !format) return; + + setExporting(true); + try { + const result = await getMkhBomExportRows({ + mode, + keyword: filters.keyword, + scope: filters.scope, + sortField: sorter.field, + sortOrder: sorter.order, + }); + if (!result || result.success === false) { + message.error(result?.error || '导出失败'); + return; + } + + const payload = result.data || {}; + const rows = payload.rows || []; + if (!rows.length) { + message.warning('当前筛选条件下没有可导出的数据'); + return; + } + + const isComponents = mode === 'components'; + const scopeLabel = EXPORT_SCOPE_LABEL[payload.scope] || '全部'; + const stamp = payload.monitorDate || new Date().toISOString().slice(0, 10); + + await exportTableFile( + format, + `${isComponents ? 'MKH配件明细' : 'MKH商品清单'}_${scopeLabel}_${stamp}`, + isComponents ? COMPONENT_EXPORT_COLUMNS : PRODUCT_EXPORT_COLUMNS, + rows, + isComponents ? '配件明细' : '商品清单' + ); + message.success(`已导出 ${rows.length} 行(${format.toUpperCase()})`); + } catch (exportError) { + message.error(exportError?.response?.data?.error || exportError?.message || '导出失败'); + } finally { + setExporting(false); + } + }; + + // ── 补货预警推送 ──────────────────────────────────────────── + const applyAlertConfig = (config = {}) => { + setAlertConfig(config); + setAlertForm({ + enabled: config.enabled !== false, + recipientAlias: config.recipientAlias || '', + scope: config.scope || 'warning', + }); + setCenterForm({ + baseUrl: config.notificationCenter?.baseUrl || '', + projectCode: config.notificationCenter?.projectCode || '', + projectSecret: '', + }); + }; + + const saveCenterConfig = async () => { + setCenterSaving(true); + try { + const result = await saveMkhNotificationCenter(centerForm); + if (!result || result.success === false) { + message.error(result?.error || '保存推送通道失败'); + return; + } + applyAlertConfig(result.data || {}); + message.success('推送通道配置已保存'); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '保存推送通道失败'); + } finally { + setCenterSaving(false); + } + }; + + const openAlertSettings = async () => { + setAlertOpen(true); + setAlertResult(null); + setAlertLoading(true); + try { + const result = await getMkhReplenishAlertConfig(); + if (!result || result.success === false) { + message.error(result?.error || '加载推送设置失败'); + return; + } + applyAlertConfig(result.data || {}); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '加载推送设置失败'); + } finally { + setAlertLoading(false); + } + }; + + const saveAlertSettings = async () => { + setAlertBusy(true); + try { + const result = await saveMkhReplenishAlertConfig(alertForm); + if (!result || result.success === false) { + message.error(result?.error || '保存推送设置失败'); + return; + } + applyAlertConfig(result.data || {}); + message.success('推送设置已保存'); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '保存推送设置失败'); + } finally { + setAlertBusy(false); + } + }; + + const runAlertCheck = async (payload, doneText) => { + setAlertBusy(true); + setAlertResult(null); + try { + const result = await checkMkhReplenishAlert(payload); + if (!result || result.success === false) { + message.error(result?.error || '执行推送失败'); + return; + } + const outcome = result.data || {}; + setAlertResult(outcome); + if (outcome.sent) { + message.success(`${doneText || '已推送'}:新增 ${outcome.newCount} 个预警 → ${outcome.recipient}`); + } else if (outcome.reason === 'dry_run') { + message.info('已生成预览内容,未实际发送'); + } else { + message.warning(ALERT_REASON_TEXT[outcome.reason] || `未发送:${outcome.reason || '无新预警'}`); + } + const refreshed = await getMkhReplenishAlertConfig().catch(() => null); + if (refreshed?.success) applyAlertConfig(refreshed.data || {}); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '执行推送失败'); + } finally { + setAlertBusy(false); + } + }; + const loadDetail = useCallback(async (skuId) => { if (detailCache[skuId] || detailLoading[skuId]) return; setDetailLoading((old) => ({ ...old, [skuId]: true })); @@ -207,7 +506,7 @@ const MkhBomData = () => { { title: 'MKH商品编码', dataIndex: 'skuId', - width: 240, + width: 230, fixed: 'left', sorter: true, render: (value) => {text(value)}, @@ -216,9 +515,150 @@ const MkhBomData = () => { title: '商品名称', dataIndex: 'productName', ellipsis: true, - sorter: true, render: (value) => {text(value)}, }, + { + title: '预警', + dataIndex: 'warningLevel', + width: 140, + align: 'center', + render: (value, row) => ( + + {text(value)} + {/* 配件不足(后端文件头 ⑪):可组装数 = 0,补不出来 —— 只加标记,不改变预警级别 */} + {row.componentShortage ? ( + + 配件不足 + + ) : null} + + ), + }, + { + title: '近15天日销', + dataIndex: 'dailySale', + width: 106, + align: 'right', + render: (value) => (Number(value) > 0 ? number(value, 2) : 0), + }, + { + title: '现有库存', + dataIndex: 'stock', + width: 100, + align: 'right', + render: (value, row) => ( + + {number(value)} + + ), + }, + { + title: '在途加工量', + dataIndex: 'pendingWorkOrderQty', + width: 116, + align: 'right', + render: (value, row) => (row.hasPendingWorkOrder ? ( + + {number(value)} + + ) : —), + }, + { + title: '待发货订单', + dataIndex: 'pendingOrderQty', + width: 116, + align: 'right', + render: (value, row) => (Number(value) > 0 ? ( + + {number(value)} + + ) : —), + }, + { + // 结余库存(2026-09-18 新增口径):现有库存先扣掉待发货订单,才是可自由支配的库存, + // 补货预警 / 可用天数一律按它算(待发订单为 0 时 ≡ 现有库存) + title: '结余库存', + dataIndex: 'availableStock', + width: 100, + align: 'right', + render: (value, row) => ( + 0 + ? `结余库存 = 现有库存 ${number(row.stock)} − 待发货订单 ${number(row.pendingOrderQty)}` + : '无待发货订单,结余库存 = 现有库存'} + > + {number(value)} + + ), + }, + { + title: '可用天数', + dataIndex: 'stockDays', + width: 106, + align: 'right', + sorter: true, + render: (value, row) => { + if (value === null || value === undefined) { + // 真正算不出天数:无日销且无代发订单 → 无销量;订单驱动分支现已如实给出天数,不会进这里 + return 无销量; + } + const text = {number(value, 2)}; + if (row.orderDriven) { + // 近 15 天无日销,可用天数按「库存 ÷ 代发订单件数」估算,≤ 3 天即预警 + return ( + + {text} + + ); + } + return ( + 0 + ? `可用天数 = 结余库存 ${number(row.availableStock)} ÷ 近15天日销 ${number(row.dailySale, 2)}(结余库存已扣待发货订单)` + : `可用天数 = 现有库存 ${number(row.stock)} ÷ 近15天日销 ${number(row.dailySale, 2)}` + }> + {text} + + ); + }, + }, + { + title: '建议补货量', + dataIndex: 'replenishQty', + width: 130, + align: 'right', + render: (value, row) => { + // 配件不足(后端文件头 ⑪):凑不出一台,不给补货目标,避免业务照着补不到的量去采购 + if (row.componentShortage) { + return ( + + — + + ); + } + return row.hasWarning && Number(value) > 0 + ? {number(value)} + : —; + }, + }, { title: 'BOM 状态', dataIndex: 'bomConfigured', @@ -231,23 +671,21 @@ const MkhBomData = () => { { title: '配件种类', dataIndex: 'accessoryCount', - width: 110, + width: 96, align: 'right', - sorter: true, render: (value) => number(value), }, { title: '配件总数量', dataIndex: 'accessoryQtySum', - width: 120, + width: 110, align: 'right', - sorter: true, render: (value) => number(value), }, { title: '操作', key: 'action', - width: 190, + width: 200, fixed: 'right', render: (_value, row) => ( @@ -328,19 +766,53 @@ const MkhBomData = () => { ); }; + // 手动同步:清掉服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), + // 再拉一次列表即可直连副本库取最新快照;可随时点击,不必等凌晨的定时刷新 + const handleSync = async () => { + setSyncing(true); + try { + await syncMkhBom(); + setDetailCache({}); + await load(); + message.success('已同步最新数据'); + } catch (syncError) { + message.error(syncError?.response?.data?.error || syncError?.message || '同步失败'); + } finally { + setSyncing(false); + } + }; + return (
MKH-BOM 资料 - 模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位 + 模块化(MKH 开头)成品的配件明细与补货预警:近 15 天日销核算,库存低于 3 天日销即预警 数据口径:MKH 商品编码取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”,并且必须是聚水潭正式 BOM 的父项; 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充; 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo, 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。 +
+ 补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 + 该仓无记录时回退监控快照库存;结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0), + 可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货; + 建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。 + 无日销且无订单的商品不参与预警。 +
+ 待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 + (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; + 可用天数 = 现有库存 ÷ 代发(待发货)订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警; + 建议补货量:缺货时按「代发件数 − 现有库存」、预警时按「3 × 代发件数 − 现有库存」补到 3 天缓冲。 +
+ 在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算 + (作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。 +
+ 配件不足:缺货 / 预警的成品若配件「可组装数 = 0」则无法补货,标记「配件不足」、建议补货量显示「—」。 + 可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ BOM 单台用量⌋ 取最小值(木桶短板决定), + 配件库存取「{data.componentStockWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」的现有库存,不含采购在途。
@@ -355,13 +827,108 @@ const MkhBomData = () => { onChange={(e) => changeFilter('keyword')(e.target.value)} style={{ width: 260 }} /> + + + + + handleExport(key) }} + > + + +
{error ? 重试} /> : null} + {!loading && !error && data.workOrderDegraded ? ( + 刷新数据} + /> + ) : null} + + {!loading && !error && data.pendingOrderDegraded ? ( + 刷新数据} + /> + ) : null} + + {!loading && !error && data.componentStockDegraded ? ( + 刷新数据} + /> + ) : null} + + {!loading && (data.warningCount || 0) > 0 ? ( + + 按近 {data.saleWindowDays || 15} 天日销核算(监控日 {data.monitorDate || '—'}): + 其中 {data.outOfStockCount || 0} 个已缺货, + {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 + 建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。 + {(data.orderDrivenCount || 0) > 0 ? ( + <> +
+ 其中 {data.orderDrivenCount} 个近 15 天暂无日销,按代发(待发货)订单列入预警 + (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件),可用天数 = 库存 ÷ 代发件数、≤ 3 天即预警。 + + ) : null} + {(data.scheduledCount || 0) > 0 ? ( + <> +
+ 另有 {data.scheduledCount} 个商品在「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」已有未完成加工单 + (在途合计 {number(data.pendingWorkOrderQtyTotal || 0)} 件),已排除在预警之外。 + + ) : null} + {(data.componentShortageCount || 0) > 0 ? ( + <> +
+ 其中 {data.componentShortageCount} 个商品配件不足 + :现有配件最多只能组装 0 台(已扣除未完成加工单占用的配件),补不出来, + 建议补货量显示「—」,需先采购配件。 + + ) : null} + + )} + action={( + + )} + /> + ) : null} + {!loading && (data.total || 0) > 0 ? ( 0 ? 'info' : 'warning'} @@ -419,13 +986,197 @@ const MkhBomData = () => { pageSizeOptions: [50, 100, 200], showTotal: (total) => `共 ${total} 条`, }} - scroll={{ x: 1000, y: bodyHeight }} + scroll={{ x: 1866, y: bodyHeight }} /> {printData ? setPrintData(null)} /> : null} + + setAlertOpen(false)} + width={680} + destroyOnHidden + styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }} + footer={( + + + + + + + )} + > + + + +
+
+ 开启推送 + setAlertForm((old) => ({ ...old, enabled: checked }))} + /> + 关闭后自动任务不再发送,本页仍可手动推送 +
+ +
+ 收件人 + setAlertForm((old) => ({ ...old, recipientAlias: e.target.value }))} + style={{ width: 280 }} + /> +
+ +
+ 推送范围 + setCenterForm((old) => ({ ...old, baseUrl: e.target.value }))} + placeholder="https://sc.jiansun.vip/notification-center" + style={{ width: 380 }} + /> +
+
+ 项目代码 + setCenterForm((old) => ({ ...old, projectCode: e.target.value }))} + style={{ width: 280 }} + /> +
+
+ 项目密钥 + setCenterForm((old) => ({ ...old, projectSecret: e.target.value }))} + placeholder={alertConfig?.notificationCenter?.projectSecretConfigured + ? `已配置(${alertConfig.notificationCenter.projectSecretPreview}),留空则不修改` + : '未配置'} + style={{ width: 280 }} + /> +
+
+ +
+
+ ), + }]} + /> + + {alertResult ? ( + +
+ 当前预警 {alertResult.summary?.totalCount ?? 0} 个 + (缺货 {alertResult.summary?.outOfStockCount ?? 0} | + {alertResult.summary?.replenishDaysThreshold || 3}天内 {alertResult.summary?.warningOnlyCount ?? 0}) + ,本次新增 {alertResult.summary?.freshCount ?? 0} 个,历史已推 {alertResult.summary?.persistedCount ?? 0} 个。 +
+ {alertResult.payload ? ( +
+
{alertResult.payload.title}
+
{alertResult.payload.content}
+ 收件人:{alertResult.payload.recipientAlias} +
+ ) : null} + + )} + /> + ) : null} + +
+ 「立即检查并推送」按线上口径执行(只推新出现的);「重推全部预警」会清空已推送记录后重新推送当前全部预警,用于首次验证。 +
+
+
); }; diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css index e25d910..55300ae 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -82,6 +82,60 @@ background: rgba(22, 119, 255, 0.12); } +/* ============ 补货预警 ============ */ +.mkh-alert { + margin-bottom: 12px; +} + +/* 缺货 */ +.mkh-tag--danger { + color: #a8071a; + background: rgba(255, 77, 79, 0.14); +} + +/* 预警 */ +.mkh-tag--warn { + color: #ad4e00; + background: rgba(250, 173, 20, 0.2); +} + +/* 可用天数低于阈值 */ +.mkh-days--warn { + color: #cf1322; + font-weight: 600; +} + +/* 建议补货量 */ +.mkh-replenish { + color: #0958d9; + font-weight: 600; +} + +/* 已排产:半成品包装仓已有未完成加工单,按业务口径不再预警 */ +.mkh-tag--scheduled { + color: #0958d9; + background: rgba(22, 119, 255, 0.14); +} + +/* 在途加工量(未完成加工单待入库数量) */ +.mkh-inbound { + color: #0958d9; + 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; @@ -181,7 +235,10 @@ .mkh-print-table .mkh-print-sku { font-family: 'SFMono-Regular', Consolas, Menlo, monospace; - font-size: 11px; + /* 2026-09-18 业务要求调大:原来是 11px,车间核对编码时太小 */ + font-size: 14px; + font-weight: 700; + letter-spacing: 0.3px; vertical-align: middle; } @@ -189,8 +246,10 @@ display: block; margin-top: 2px; font-family: 'SFMono-Regular', Consolas, Menlo, monospace; - font-size: 10px; - color: #666; + /* 2026-09-18 业务要求调大:原来是 10px / #666,扫码与人工核对都偏小偏淡 */ + font-size: 12px; + font-weight: 600; + color: #222; } .mkh-print-foot { @@ -237,3 +296,91 @@ margin: 12mm; } } + +/* ── 补货预警推送设置弹窗 ──────────────────────────── */ +.mkh-alert-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.mkh-alert-row { + display: flex; + align-items: center; + gap: 10px; +} + +.mkh-alert-label { + flex: none; + width: 68px; + color: var(--sycm-text-secondary); +} + +.mkh-alert-meta { + margin-top: 14px; + padding: 10px 12px; + border: 1px solid var(--sycm-border-color); + border-radius: 8px; + background: var(--sycm-bg-subtle); + font-size: 12px; + line-height: 2; + color: var(--sycm-text-secondary); +} + +.mkh-alert-result { + margin-top: 14px; +} + +.mkh-alert-result-body { + font-size: 12px; + line-height: 1.8; +} + +.mkh-alert-preview { + margin-top: 8px; + padding: 10px 12px; + border: 1px solid var(--sycm-border-color); + border-radius: 8px; + background: var(--sycm-bg-container); +} + +.mkh-alert-preview-title { + margin-bottom: 6px; + font-weight: 600; + color: var(--sycm-text-strong); +} + +.mkh-alert-preview pre { + margin: 0 0 6px; + font-family: inherit; + font-size: 12px; + line-height: 1.8; + white-space: pre-wrap; + word-break: break-word; + color: var(--sycm-text-primary); +} + +.mkh-alert-hint { + margin-top: 10px; + font-size: 12px; + line-height: 1.7; + color: var(--sycm-text-tertiary); +} + +/* 推送通道(统一通知中心)折叠区 */ +.mkh-alert-center { + margin-top: 6px; +} + +.mkh-alert-center .ant-collapse-header { + padding: 8px 0 !important; +} + +.mkh-alert-center .ant-collapse-content-box { + padding: 0 0 4px !important; +} + +.mkh-alert-form--inner { + margin-top: 4px; +} diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index f9d6cc6..ec08b52 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -1,11 +1,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, Button, Card, Col, Dropdown, Image, Input, Row, Select, + Alert, Button, Card, Col, Dropdown, Image, Input, Modal, Row, Select, Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message, } from 'antd'; -import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { DownloadOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'; import { SycmPage } from '../../components/Common'; -import { getModularStockList, getModularStockFilters } from '../../api/modularStockMonitor'; +import { getModularStockList, getModularStockFilters, getPurchaseInTransitList, syncModularStock } from '../../api/modularStockMonitor'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -38,12 +38,22 @@ const AccessoryStockPanel = () => { const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, monitorDate: null }); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [syncing, setSyncing] = useState(false); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); const [sorter, setSorter] = useState({ field: 'realtimeStock', order: 'DESC' }); const tableWrapRef = useRef(null); const [bodyHeight, setBodyHeight] = useState(420); + // 采购在途明细弹窗状态 + const [transitVisible, setTransitVisible] = useState(false); + const [transitData, setTransitData] = useState({ rows: [], total: 0 }); + const [transitLoading, setTransitLoading] = useState(false); + const [transitPage, setTransitPage] = useState(1); + const [transitPageSize, setTransitPageSize] = useState(50); + const [transitKeyword, setTransitKeyword] = useState(''); + const [transitSorter, setTransitSorter] = useState({ field: 'transitQty', order: 'DESC' }); + // 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc: // 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行 useEffect(() => { @@ -134,6 +144,56 @@ const AccessoryStockPanel = () => { setPage(1); }; + // 点击顶部统计卡片 → 自动设置对应筛选条件并滚动到表格明细 + const applySummaryFilter = (type) => () => { + const next = { ...EMPTY_FILTERS }; + let warning = false; + if (type === 'outOfStock') { + next.warningLevel = '缺货'; + warning = true; + } else if (type === 'lowStockDays') { + next.warningLevel = '预警'; + warning = true; + } else if (type === 'configured') { + next.mkhStatus = '已配置MKH'; + } else if (type === 'unconfigured') { + next.mkhStatus = '未配置MKH'; + } + setFilters(next); + setOnlyWarning(warning); + setPage(1); + setTimeout(() => { + tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 0); + }; + + // 打开采购在途明细弹窗 + const openPurchaseTransit = () => { + setTransitVisible(true); + setTransitPage(1); + setTransitKeyword(''); + setTransitSorter({ field: 'transitQty', order: 'DESC' }); + loadPurchaseTransit(1, '', { field: 'transitQty', order: 'DESC' }); + }; + + const loadPurchaseTransit = useCallback(async (p = transitPage, kw = transitKeyword, sort = transitSorter) => { + setTransitLoading(true); + try { + const result = await getPurchaseInTransitList({ + page: p, + pageSize: transitPageSize, + keyword: kw, + sortField: sort.field, + sortOrder: sort.order, + }); + setTransitData(result?.data || { rows: [], total: 0 }); + } catch (err) { + message.error(err?.response?.data?.error || err?.message || '加载采购在途明细失败'); + } finally { + setTransitLoading(false); + } + }, [transitPage, transitPageSize, transitKeyword, transitSorter]); + const columns = useMemo(() => [ { title: '图片', @@ -157,7 +217,7 @@ const AccessoryStockPanel = () => { { title: , dataIndex: 'mkhStatus', width: 110, @@ -170,7 +230,7 @@ const AccessoryStockPanel = () => { }, }, { - title: 'SKU编码', + title: '商品编码', dataIndex: 'skuId', width: 176, fixed: 'left', @@ -218,10 +278,22 @@ const AccessoryStockPanel = () => { sorter: true, render: int, }, + { + title: , + dataIndex: 'purchaseTransit', + width: 120, + align: 'right', + render: (value) => (value === null || value === undefined || Number(value) === 0 + ? -- + : int(value)), + }, { title: , dataIndex: 'location', width: 88, @@ -233,8 +305,8 @@ const AccessoryStockPanel = () => { ), }, { - title: '近7日日均发货量', - dataIndex: 'avgShip7d', + title: '近15天日均发货量', + dataIndex: 'avgShip15d', width: 138, align: 'right', sorter: true, @@ -282,6 +354,21 @@ const AccessoryStockPanel = () => { } }; + // 手动同步:后端重新取最新监控日(清服务端缓存)后再拉一次列表, + // 拿到的是副本库当前最新快照;可随时点击,不受 24 点定时任务限制 + const handleSync = async () => { + setSyncing(true); + try { + await syncModularStock(); + await load(); + message.success('已同步最新数据'); + } catch (syncError) { + message.error(syncError?.response?.data?.error || syncError?.message || '同步失败'); + } finally { + setSyncing(false); + } + }; + const summary = data.summary || {}; return ( @@ -291,7 +378,7 @@ const AccessoryStockPanel = () => { } - placeholder="SKU 编码 / 名称" + placeholder="商品编码 / 名称" value={filters.keyword} onChange={(e) => changeFilter('keyword')(e.target.value)} style={{ width: 220 }} @@ -327,6 +414,9 @@ const AccessoryStockPanel = () => { + + + @@ -334,11 +424,32 @@ const AccessoryStockPanel = () => {
- - + + + + + + + + + + - - + + + + + + + + + + + + + + + { + + + 采购在途明细 + + 共 {transitData.total || 0} 条采购单 / 在途 {summary.totalPurchaseTransit || 0} 件 + + + )} + open={transitVisible} + onCancel={() => setTransitVisible(false)} + width={960} + footer={null} + destroyOnHidden + > + + { setTransitKeyword(value); setTransitPage(1); loadPurchaseTransit(1, value, transitSorter); }} + style={{ width: 260 }} + /> +
MKH商品编码MKH商品编码 配件明细 数量 配件仓位{text(item.skuName)}{item.skuId ? {item.skuId} : null} {number(item.qty)}{item.location ? text(item.location) : 未维护}{item.location ? text(stripWarehousePrefix(item.location)) : 未维护}
`${row.skuId}-${row.poiId || row.poId || Math.random()}`} + size="small" + loading={transitLoading} + columns={[ + { + title: '商品编码', dataIndex: 'skuId', width: 176, render: (v) => {text(v)}, + }, + { + title: '商品名称', dataIndex: 'productName', width: 240, ellipsis: true, render: (v) => {text(v)}, + }, + { title: '采购数量', dataIndex: 'purchaseQty', width: 96, align: 'right', render: int, sorter: true }, + { title: '已入库', dataIndex: 'inQty', width: 84, align: 'right', render: int, sorter: true }, + { + title: '在途数量', dataIndex: 'transitQty', width: 96, align: 'right', sorter: true, + render: (value) => {int(value)}, + }, + { + title: '计划到货日期', dataIndex: 'deliveryDate', width: 120, align: 'center', sorter: true, + render: (value) => text(value), + }, + { title: '采购单号', dataIndex: 'poId', width: 116, render: text }, + ]} + dataSource={transitData.rows || []} + pagination={{ + current: transitPage, + pageSize: transitPageSize, + total: transitData.total || 0, + showSizeChanger: true, + pageSizeOptions: [50, 100, 200], + showTotal: (total) => `共 ${total} 条`, + }} + onChange={(pagination, _filters, nextSorter) => { + setTransitPage(pagination.current || 1); + setTransitPageSize(pagination.pageSize || 50); + const nextSort = nextSorter?.field + ? { field: nextSorter.field, order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC' } + : { field: 'transitQty', order: 'DESC' }; + setTransitSorter(nextSort); + loadPurchaseTransit(pagination.current || 1, transitKeyword, nextSort); + }} + scroll={{ x: 860, y: 360 }} + /> + + ); }; @@ -402,14 +582,17 @@ const ModularStockMonitor = () => (
模块化库存监控 - 配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 7 日日均发货量与可用天数 + 配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 15 天日均发货量与可用天数 - 数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 7 日日均发货量、 + 数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、 库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖); - 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」; + 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准; + 仓位取聚水潭「箱及仓位库存」(jst_packInventoryInfo)的真货位,同商品多箱时显示库存最大的主货位,查不到才显示「未维护」; 图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。 - 状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。 + 注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天); + 库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。 + 状态列:基于聚水潭 SKU 级 BOM 维护(rpt.jst_skuBomItem)判断该商品是否作为「配件商品编码」被某 MKH 成品 BOM 引用——已配置MKH = 出现在某 MKH 成品的 BOM 配件清单中;未配置MKH = 未出现。
diff --git a/frontend/src/pages/ModularStockMonitor/style.css b/frontend/src/pages/ModularStockMonitor/style.css index 80ea127..6068a4b 100644 --- a/frontend/src/pages/ModularStockMonitor/style.css +++ b/frontend/src/pages/ModularStockMonitor/style.css @@ -87,6 +87,7 @@ .msm-summary-card.is-red::before { background: var(--sycm-color-danger); } .msm-summary-card.is-orange::before { background: var(--sycm-color-orange); } .msm-summary-card.is-green::before { background: var(--sycm-color-success); } +.msm-summary-card.is-blue::before { background: var(--sycm-color-primary); } .msm-summary-card .ant-card-body { display: flex; min-height: 98px; @@ -107,6 +108,15 @@ } .msm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); } +.msm-summary-card.is-clickable { + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.msm-summary-card.is-clickable:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + .msm-panel { margin-bottom: 0; overflow: hidden; } .msm-panel .ant-card-head { min-height: 48px;