From fe831c32fd3190d9dce5a1f7137c05be0e1d0f2d Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 10:01:13 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20MKH-BOM=20=E8=A1=A5=E8=B4=A7?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=E6=8E=92=E9=99=A4=E5=9C=A8=E9=80=94=E5=8A=A0?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E3=80=81=E7=BC=BA=E8=B4=A7=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E3=80=81=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补货预警:接入加工单(链接服务器 bi_processing_work_orders),有未完成加工单则不预警,新增在途加工量列 - 新增每日 09:00 缺货/预警通知推送(cron + 统一通知中心 + 幂等键) - 新增导出:商品清单 / 配件明细,XLSX / CSV,按当前筛选条件全量 - 配件仓位:新增 loadComponentLocations 批量加载器(导出/列表展开共用) --- backend/src/api/mkhBom.js | 64 ++ backend/src/index.js | 4 + backend/src/jobs/mkhReplenishAlertJob.js | 343 +++++++ backend/src/services/mkhBomService.js | 872 ++++++++++++++++-- .../src/services/mkhReplenishAlertSettings.js | 227 +++++ backend/test/mkhBomService.test.js | 296 ++++++ backend/test/mkhReplenishAlertJob.test.js | 139 +++ frontend/src/api/mkhBom.js | 31 + frontend/src/pages/MkhBomData/index.jsx | 595 +++++++++++- frontend/src/pages/MkhBomData/style.css | 129 +++ 10 files changed, 2610 insertions(+), 90 deletions(-) create mode 100644 backend/src/jobs/mkhReplenishAlertJob.js create mode 100644 backend/src/services/mkhReplenishAlertSettings.js create mode 100644 backend/test/mkhReplenishAlertJob.test.js diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index ff1978f..afe8956 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -4,7 +4,17 @@ const express = require('express'); const { getMkhBomList, getMkhBomDetail, + listMkhBomExportRows, } = 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 +47,58 @@ 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, '保存统一通知中心配置失败'); + } +}); + 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..dd1f2c1 --- /dev/null +++ b/backend/src/jobs/mkhReplenishAlertJob.js @@ -0,0 +1,343 @@ +'use strict'; + +// MKH 成品补货预警 · 定时推送 +// +// 业务口径(2026-09-17 业务确认): +// ① 推送范围:缺货(可用天数 ≤ 0)+ 3 天内预警商品(默认,可在页面里改成仅缺货) +// ② 只提醒**新出现**的预警 —— 已经在「已推送」记录里的商品不重复推送, +// 商品补货完成恢复到正常范围后会从记录里移除,下次再预警会重新提醒 +// ③ 收件人:单独配置(配置为空时回退环境变量 → 统一通知中心默认收件人) +// ④ 时间:每天早上 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); +} + +function describeAlert(row) { + const lines = [ + `**${row.warningLevel === OUT_OF_STOCK ? '🔴 缺货' : '🟠 预警'}** — \`${row.skuId}\``, + String(row.productName || '').trim() || '(无名称)', + `日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | 建议补货 ${formatQty(row.replenishQty)}`, + ]; + 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)} 件。`, + snapshot.workOrderDegraded === true + ? `> ⚠️ 加工单数据源暂不可用,在途量按 0 处理,预警数量可能偏多。` + : '', + ].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, + freshCount: fresh.length, + persistedCount: persisted.length, + restoredCount: restored.length, + monitorDate: snapshot.monitorDate, + workOrderDegraded: snapshot.workOrderDegraded, + }; + + 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 61d83f8..0c15965 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -37,11 +37,65 @@ // payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同 // (无 SKU 条码),也不能直接当配件 BOM。 // -// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo; -// sku_id 可能带“-包装序号”后缀,需按基础配件编码匹配。优先显示 bin_group / bin, -// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与 wh_id(仓库)。 +// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」) +// sku_id 可能带“-装箱序号”后缀(DNZ0019110N08V01-27),按基础配件编码匹配; +// 仓库取 jst_warehouseInfo.name(wh_id → Id);有真货位(bin_group / bin)就显示货位, +// 没有则退回「仓库 · 箱号 pack_id」,两者都取不到时前端显示「未维护」。 +// · 列表展开(/detail)与导出配件明细共用 loadComponentLocations(),按编码缓存 10 分钟。 +// ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱** +// (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、 +// wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**,`wms_co_id` 全 NULL。 +// 且表内容与 MKH 配件**零交集**:正式 BOM 的 784 个配件编码(DNZ0398xx / DNZ0409xx, +// jst_sku.item_type=成品)在该表一个都查不到;表里存的是老型号半成品 +// (DNZ0134xx / DNZ0199xx / DNZ0203xx,item_type=半成品,名称如 +// 「G4号;云霜灰色+珍珠白色抽面;100/60/71.2」)。 +// 结论:MKH 配件在**任何已接入数据源**里都没有货位/装箱数据 +// (jst_sku.bin / other_bin 全库 558,674 行全空;ERP 主库 JianSun.jst_packInventoryInfo +// 同源同量;产品资料库 external.warehouse_locations_v1 仅 1 行)。 +// → 仓位列只能显示「未维护」,需业务在聚水潭为配件维护装箱/货位后才会自动出现。 // // ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 +// +// ── ⑦ 补货预警(2026-09-17 业务确认口径)────────────────────────── +// 规则:近 7 日日销 > 0 且 现有库存 < 3 天日销 → 预警补货 +// · 近7日日销 rpt.pm_stockMonitor.sevenAvgSale +// (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照) +// · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存; +// 该仓对 MKH 成品只覆盖约 256 / 4408 条,缺失时回退监控快照 jsStock +// · 可用天数 = 现有库存 / 近7日日销 +// · 建议补货量 = ceil(3 × 近7日日销 − 现有库存),不足 0 取 0 +// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / sevenAvgSale 不同源 +// (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67, +// 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近7日日销」为准。 +// 实测(2026-09-16 快照):4408 个 MKH 成品中 114 个有日销、13 个可用天数 < 3 天。 +// +// ── ⑧ 在途加工单(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** —— 4408 行商品若都挂上配件明细, +// 响应会膨胀到几十 MB,而商品清单本身用不到明细。 const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -50,16 +104,48 @@ const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 200; const BOM_CACHE_TTL_MS = 2 * 60 * 1000; const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000; +// 配件仓位(箱及仓位库)按编码缓存;单批编码数受 MSSQL 单语句 2100 参数上限约束 +// (每个编码占 2 个参数:精确匹配 + 带后缀前缀匹配),300 × 2 = 600 个参数,安全。 +const COMPONENT_LOCATION_CACHE_TTL_MS = 10 * 60 * 1000; +const COMPONENT_LOCATION_CHUNK_SIZE = 300; let businessBomCache = null; let officialBomIndexCache = null; const officialBomDetailCache = new Map(); let productBomCache = null; +const componentLocationCache = new Map(); // MKH 商品编码 = 模块化成品 const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓'; const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND s.item_type = N'成品' AND s.other_3 = N'${DESIGNATED_WAREHOUSE}'`; +// ── 补货预警参数(见文件头 ⑦)──────────────────────────────────── +// 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得) +const BRAND_WMS_CO_ID = 15870495; +const SALE_WINDOW_DAYS = 7; +// 预警阈值:现有库存可用天数低于 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; + const toText = (value) => { if (value === null || value === undefined) return null; const text = String(value).trim(); @@ -72,24 +158,104 @@ 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; - 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', ALL: 'all' }); + +/** + * 解析展示范围: + * warning —— 仅需补货(近7日日销 > 0 且 可用天数 < 3 天) + * sold —— 有近7日日销的商品 + * 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.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; + 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 解析成统一结构。 @@ -510,6 +676,332 @@ function summarizeConfiguredBoms(officialBom, businessBom, productBom) { const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0); +/** + * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 + * + * 口径:近7日日销 > 0 且 现有库存 < 3 天日销 → 预警。 + * 无近7日日销的商品不参与预警,标记为「无销量」。 + * + * @param {object} row + * row.sevenAvgSale 近7日日均发货量 + * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) + * row.monitorStock 监控快照库存(fallback) + * row.safetyDays 源系统安全天数(仅展示) + * row.suggestStock 源系统建议备货量(仅展示对照) + * row.monitorDate 监控快照日期 + */ +function buildReplenishMetrics(row = {}) { + const dailySale = Math.max(0, toNumber(row.sevenAvgSale) ?? 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 base = { + dailySale, + stock, + pendingWorkOrderQty, + pendingWorkOrderCount, + hasPendingWorkOrder, + pendingWorkOrderEta: toDateText(row.pendingWorkOrderEta), + pendingWorkOrderNo: toText(row.pendingWorkOrderNo), + 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), + }; + + if (dailySale <= 0) { + return { + ...base, + thresholdQty: 0, + stockDays: null, + replenishQty: 0, + warningLevel: WARNING_LEVEL.NO_SALE, + warningOrder: 5, + hasWarning: false, + }; + } + + const stockDays = stock / dailySale; + + // 已有未完成加工单 → 不再列入补货预警(业务口径,见文件头 ⑧) + if (hasPendingWorkOrder) { + return { + ...base, + stockDays: Number(stockDays.toFixed(2)), + replenishQty: 0, + warningLevel: WARNING_LEVEL.SCHEDULED, + warningOrder: 4, + hasWarning: false, + }; + } + + const replenishQty = Math.max(0, Math.ceil(base.thresholdQty - stock)); + const level = stockDays <= 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, + }; +} + +/** + * 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, { + 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; + } +} + +/** + * 补货预警索引 —— 进程内缓存 { 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.sevenAvgSale AS sevenAvgSale, 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(), + ]).then(([monitorRows, stockRows, workOrderIndex]) => { + const workOrderBySku = workOrderIndex.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({ + sevenAvgSale: row.sevenAvgSale, + // 实时库存缺失(而非为 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), + })); + }); + + return { + bySku, + workOrderBySku, + workOrderDegraded: workOrderIndex.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; + } +} + +/** + * 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) || {}), + }; + }); + + return { rows, replenishIndex }; +} + +/** + * 补货预警清单(全量、不分页)—— 供定时推送任务使用。 + * 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 } = 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) + ), + monitorDate: replenishIndex.monitorDate, + workOrderDegraded: replenishIndex.workOrderDegraded === 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(); @@ -518,11 +1010,14 @@ function matchesPackSku(componentCode, packSkuCode) { } function formatPackLocation(row) { + const warehouseName = toText(row.warehouseName); const warehouseId = toText(row.warehouseId); const binGroup = toText(row.binGroup); const bin = toText(row.bin); const packId = toText(row.packId); - const warehouse = warehouseId ? `仓库 ${warehouseId}` : ''; + // 仓库优先显示名称(wh_id → jst_warehouseInfo.name),取不到时退回 id + const warehouse = warehouseName || (warehouseId ? `仓库 ${warehouseId}` : ''); + // 有真货位(bin_group / bin)才叫「仓位」;否则退回箱号 const position = [binGroup, bin].filter(Boolean).join(' / ') || (packId ? `箱号 ${packId}` : ''); return [warehouse, position].filter(Boolean).join(' · '); @@ -550,6 +1045,72 @@ function buildPackLocationMap(rows, componentCodes) { return result; } +/** + * 配件仓位批量查询(列表展开 / 单品明细 / 导出配件明细共用)。 + * 数据源:rpt.jst_packInventoryInfo(箱及仓位库)+ rpt.jst_warehouseInfo(仓名) + * · 按基础配件编码匹配,兼容库内 sku_id 带「-装箱序号」后缀的形态 + * · 单个配件跨多个箱时最多列出 10 个位置,超出补「等 N 个位置」 + * · 结果按编码缓存(TTL 10 分钟);查询失败按 null 降级且不写缓存,下次重试 + */ +async function loadComponentLocations(codes = []) { + const now = Date.now(); + const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); + const result = new Map(); + const missing = []; + unique.forEach((code) => { + const hit = componentLocationCache.get(code); + if (hit && hit.expiresAt > now) result.set(code, hit.location); + else missing.push(code); + }); + if (missing.length === 0) return result; + + 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 }); + result.set(code, location); + }); + } catch (error) { + console.warn('[MkhBom] 配件仓位查询降级:', error.message); + chunk.forEach((code) => { + if (!result.has(code)) result.set(code, null); + }); + } + } + return result; +} + /** * 列表:指定仓库的 MKH 模块化成品 + BOM 配件种类/数量。 * BOM 数据只做明细匹配,不向商品列表追加无仓库档案的编码。 @@ -558,53 +1119,52 @@ 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 } = 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) + ), + 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 ? '已配置' : '未配置', @@ -615,8 +1175,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, @@ -625,7 +1185,15 @@ 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, + warningInPage: rows.filter((row) => row.hasWarning).length, }; } @@ -692,27 +1260,9 @@ async function getMkhBomDetail(mkhSkuId) { } try { - const locationParams = {}; - const locationConditions = codes.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 - 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, codes).forEach((location, code) => { + // 与列表展开 / 导出配件明细走同一个批量加载器(含仓名解析与 10 分钟缓存) + const located = await loadComponentLocations(codes); + located.forEach((location, code) => { locationMap.set(code, location); }); } catch (error) { @@ -742,9 +1292,164 @@ 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, + stockDays: toNumber(row.stockDays), + replenishQty: toNumber(row.replenishQty) ?? 0, + 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, + }; +} + module.exports = { getMkhBomList, getMkhBomDetail, + listReplenishAlertRows, + listMkhBomExportRows, _private: { normalizeCode, groupOfficialBomRows, @@ -755,5 +1460,24 @@ module.exports = { matchesPackSku, formatPackLocation, buildPackLocationMap, + loadComponentLocations, + COMPONENT_LOCATION_CHUNK_SIZE, + buildReplenishMetrics, + loadPendingWorkOrderIndex, + 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/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 93e7010..190ca90 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -164,3 +164,299 @@ test('pack inventory location prefers bins and falls back to positive-stock box assert.equal(locations.get('PART-1'), '仓库 1 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); }); + +// ── 补货预警:近 7 日日销 > 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({ sevenAvgSale: 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({ sevenAvgSale: 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({ sevenAvgSale: 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({ sevenAvgSale: 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({ sevenAvgSale: 1, realtimeStock: 4, monitorStock: 9 }); + assert.equal(realtime.stock, 4); + assert.equal(realtime.stockSource, 'realtime'); + + // 实时库存「无记录」(null)才回退监控快照 + const fallback = metrics({ sevenAvgSale: 1, realtimeStock: null, monitorStock: 9 }); + assert.equal(fallback.stock, 9); + assert.equal(fallback.stockSource, 'monitor'); + + // 实时库存为 0 是有效值,不得被快照覆盖 + const zero = metrics({ sevenAvgSale: 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({ sevenAvgSale: 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({ + sevenAvgSale: 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({ sevenAvgSale: 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({ sevenAvgSale: 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({ + sevenAvgSale: 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({ + sevenAvgSale: 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('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, + stockDays: 0, + replenishQty: 9, + bomConfigured: '已配置', + accessoryCount: 2, + accessoryQtySum: 16, + }); + // 无销量 → stockDays 为 null(前端导出时渲染成「无销量」),在途量缺省归 0 + assert.equal(rows[1].stockDays, null); + assert.equal(rows[1].pendingWorkOrderQty, 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); +}); diff --git a/backend/test/mkhReplenishAlertJob.test.js b/backend/test/mkhReplenishAlertJob.test.js new file mode 100644 index 0000000..14faff6 --- /dev/null +++ b/backend/test/mkhReplenishAlertJob.test.js @@ -0,0 +1,139 @@ +'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: 7, + 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('加工单数据源暂不可用')); +}); diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js index 26cb884..fdd3341 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,33 @@ 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 +); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index c6f2b55..5f91c0f 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -1,10 +1,22 @@ 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, +} from '@ant-design/icons'; import { SycmPage } from '../../components/Common'; -import { getMkhBomList, getMkhBomDetail } from '../../api/mkhBom'; +import { + getMkhBomList, + getMkhBomDetail, + getMkhBomExportRows, + getMkhReplenishAlertConfig, + saveMkhReplenishAlertConfig, + saveMkhNotificationCenter, + checkMkhReplenishAlert, +} from '../../api/mkhBom'; +import { exportTableFile } from '../../utils/tableExport'; import './style.css'; const { Title, Text } = Typography; @@ -18,7 +30,93 @@ 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', +}; + +// 展示范围切换 +const SCOPE_OPTIONS = [ + { label: '仅预警', value: 'warning' }, + { label: '有日销', value: 'sold' }, + { label: '全部', value: 'all' }, +]; + +// ── 导出(列口径与后端 listMkhBomExportRows 的字段一一对应)────────── +// 商品清单:与页面表格列一致,去掉「操作」列 +const PRODUCT_EXPORT_COLUMNS = [ + { title: 'MKH商品编码', dataIndex: 'skuId' }, + { title: '商品名称', dataIndex: 'productName' }, + { title: '预警', dataIndex: 'warningLevel' }, + { title: '近7日日销', dataIndex: 'dailySale' }, + { title: '现有库存', dataIndex: 'stock' }, + { title: '在途加工量', dataIndex: 'pendingWorkOrderQty' }, + { + title: '可用天数', + dataIndex: 'stockDays', + // 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨 + exportValue: (value) => (value === null || value === undefined ? '无销量' : value), + }, + { + title: '建议补货量', + dataIndex: 'replenishQty', + // 与页面一致:非预警商品不显示数字,避免被当成「需要补 0 件」 + exportValue: (value, row) => ( + (row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : '' + ), + }, + { 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: '有日销', 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商品编码 | 配件明细 | 数量 | 配件仓位 const PrintPreview = ({ data, onClose }) => { @@ -99,7 +197,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 +207,19 @@ const MkhBomData = () => { const [printData, setPrintData] = useState(null); const [printLoading, setPrintLoading] = useState(false); + // 导出(当前筛选条件下的全部数据) + const [exporting, setExporting] = 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); @@ -170,6 +282,146 @@ const MkhBomData = () => { }; 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 +459,7 @@ const MkhBomData = () => { { title: 'MKH商品编码', dataIndex: 'skuId', - width: 240, + width: 230, fixed: 'left', sorter: true, render: (value) => {text(value)}, @@ -216,9 +468,76 @@ const MkhBomData = () => { title: '商品名称', dataIndex: 'productName', ellipsis: true, - sorter: true, render: (value) => {text(value)}, }, + { + title: '预警', + dataIndex: 'warningLevel', + width: 84, + align: 'center', + render: (value) => ( + {text(value)} + ), + }, + { + title: '近7日日销', + 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: 'stockDays', + width: 106, + align: 'right', + sorter: true, + render: (value, row) => (value === null || value === undefined + ? 无销量 + : {number(value, 2)}), + }, + { + title: '建议补货量', + dataIndex: 'replenishQty', + width: 116, + align: 'right', + render: (value, row) => (row.hasWarning && Number(value) > 0 + ? {number(value)} + : —), + }, { title: 'BOM 状态', dataIndex: 'bomConfigured', @@ -231,23 +550,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) => ( @@ -334,12 +651,18 @@ const MkhBomData = () => {
MKH-BOM 资料 - 模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位 + 模块化(MKH 开头)成品的配件明细与补货预警:近 7 日日销核算,库存低于 3 天日销即预警 数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条); - 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充; - 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位未维护时显示「未维护」。 + 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充。 +
+ 补货预警:近7日日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 + 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近7日日销,低于 3 天即预警,缺货单独标识; + 建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无近7日日销的商品不参与预警。 +
+ 在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算 + (作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。
@@ -354,13 +677,69 @@ const MkhBomData = () => { onChange={(e) => changeFilter('keyword')(e.target.value)} style={{ width: 260 }} /> + + handleExport(key) }} + > + + +
{error ? 重试} /> : null} + {!loading && !error && data.workOrderDegraded ? ( + 刷新数据} + /> + ) : null} + + {!loading && (data.warningCount || 0) > 0 ? ( + + 按近 {data.saleWindowDays || 7} 日日销核算(监控日 {data.monitorDate || '—'}): + 其中 {data.outOfStockCount || 0} 个已缺货, + {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 + 建议补货量 = {data.replenishDaysThreshold || 3} × 近7日日销 − 现有库存。点「只看预警」即为补货清单。 + {(data.scheduledCount || 0) > 0 ? ( + <> +
+ 另有 {data.scheduledCount} 个商品在「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」已有未完成加工单 + (在途合计 {number(data.pendingWorkOrderQtyTotal || 0)} 件),已排除在预警之外。 + + ) : null} + + )} + action={( + + )} + /> + ) : null} + {!loading && (data.total || 0) > 0 ? ( 0 ? 'info' : 'warning'} @@ -418,13 +797,197 @@ const MkhBomData = () => { pageSizeOptions: [50, 100, 200], showTotal: (total) => `共 ${total} 条`, }} - scroll={{ x: 1000, y: bodyHeight }} + scroll={{ x: 1696, 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..b591377 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -82,6 +82,47 @@ 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; +} + /* ============ A4 打印预览 ============ */ .mkh-print-mask { position: fixed; @@ -237,3 +278,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; +} -- 2.47.2 From f69d26796ed881d935fb7adfa3721fd190a0073d Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 12:18:32 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20MKH-BOM=20=E8=A1=A5=E8=B4=A7?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=E6=94=AF=E6=8C=81=E5=BE=85=E5=8F=91=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E9=A9=B1=E5=8A=A8=E4=B8=8E=E9=85=8D=E4=BB=B6=E4=B8=8D?= =?UTF-8?q?=E8=B6=B3=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补货预警在原有「销量 + 库存」口径之外增加两条判定,均不改变已上线行为: 1. 待发订单驱动 - MKH 成品条码历史无销售量、但存在未发货订单时,同样进入补货预警。 - 建议补货量按「未发货件数 − 现有库存」计算,无订单时不参与该口径。 - 列表新增待发货订单列,展示件数、单数与最早下单时间。 2. 配件不足 - 对判定为缺货或预警的商品,按 BOM 计算可组装数: 可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小值。 - 可组装数为 0 时标记「配件不足,无法补货」并保留在清单中, 建议补货量显示「—」,同时给出短板配件及其库存。 - 配件库存只认「简尚-崔桥工厂半成品包装项目仓」的现有库存, 按业务确认不计采购在途。 - 配件库存查询失败时降级为空索引并标记 componentStockDegraded, 不写入缓存,避免把瞬时故障固化为错误数据。 导出与通知文案同步:导出新增「配件不足」列、补货量按同一口径输出; 缺货通知在配件不足时插入短板配件提示并把建议补货量显示为「—」。 验证:backend 相关单测全部通过(新增 9 例);语法检查通过; 前端 eslint 零错误且 vite build 通过;接口实测与页面渲染结果见 PR。 --- backend/src/jobs/mkhReplenishAlertJob.js | 55 +- backend/src/services/mkhBomService.js | 688 +++++++++++++++++----- backend/test/mkhBomService.test.js | 248 +++++++- backend/test/mkhReplenishAlertJob.test.js | 55 ++ frontend/src/pages/MkhBomData/index.jsx | 154 ++++- frontend/src/pages/MkhBomData/style.css | 13 + 6 files changed, 1016 insertions(+), 197 deletions(-) diff --git a/backend/src/jobs/mkhReplenishAlertJob.js b/backend/src/jobs/mkhReplenishAlertJob.js index dd1f2c1..f0f2231 100644 --- a/backend/src/jobs/mkhReplenishAlertJob.js +++ b/backend/src/jobs/mkhReplenishAlertJob.js @@ -4,6 +4,11 @@ // // 业务口径(2026-09-17 业务确认): // ① 推送范围:缺货(可用天数 ≤ 0)+ 3 天内预警商品(默认,可在页面里改成仅缺货) +// · 含「订单驱动」商品:近7日日销 = 0、但存在未发货订单的 MKH 成品 +// (口径与取数见 mkhBomService 文件头 ⑩;正文里以「待发货订单 N 件」代替日销展示) +// · 含「配件不足」商品:配件现有库存可组装数 = 0(口径见 mkhBomService 文件头 ⑪) +// —— 这类商品**补不出去**,正文里明确写「配件不足,无法补货」并列出短板配件, +// 不给出建议补货量(否则业务会照着补不到的目标去采购) // ② 只提醒**新出现**的预警 —— 已经在「已推送」记录里的商品不重复推送, // 商品补货完成恢复到正常范围后会从记录里移除,下次再预警会重新提醒 // ③ 收件人:单独配置(配置为空时回退环境变量 → 统一通知中心默认收件人) @@ -123,12 +128,41 @@ function buildAlertSignature(alerts = []) { return crypto.createHash('sha1').update(parts.join('|'), 'utf8').digest('hex').slice(0, 16); } +/** + * 短板配件文案(见 mkhBomService 文件头 ⑪):只列「可支配数 ≤ 0」的配件, + * 即真正卡住组装的那些,最多 2 个,避免正文过长。 + */ +function formatShortageNote(row) { + const bottlenecks = Array.isArray(row?.bottleneckComponents) ? row.bottleneckComponents : []; + const detail = bottlenecks + .filter((item) => Number(item?.available) <= 0) + .slice(0, 2) + .map((item) => { + const occupied = Number(item.occupied) || 0; + return `${item.code}(库存 ${formatQty(item.stock)}` + + `${occupied > 0 ? ` − 加工单占用 ${formatQty(occupied)}` : ''})`; + }) + .join('、'); + return detail ? `,短板配件:${detail}` : ''; +} + function describeAlert(row) { + // 配件不足(见 mkhBomService 文件头 ⑪):可组装数 = 0 → 补不出来, + // 建议补货量不再给数字(显示「—」),与页面口径保持一致。 + const replenishText = row.componentShortage ? '建议补货 —' : `建议补货 ${formatQty(row.replenishQty)}`; + // 订单驱动(近7日日销=0 但有未发货订单,见 mkhBomService 文件头 ⑩): + // 此时日销必然是 0,直接显示会让人误以为「没销量却要补货」,改展示待发货订单量 + const metric = row.orderDriven + ? `待发货订单 ${formatQty(row.pendingOrderQty)} 件 | 现有库存 ${formatQty(row.stock)} | ${replenishText}(近7日暂无日销,按订单补)` + : `日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | ${replenishText}`; const lines = [ `**${row.warningLevel === OUT_OF_STOCK ? '🔴 缺货' : '🟠 预警'}** — \`${row.skuId}\``, String(row.productName || '').trim() || '(无名称)', - `日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | 建议补货 ${formatQty(row.replenishQty)}`, + metric, ]; + if (row.componentShortage) { + lines.splice(2, 0, `⚠️ **配件不足,无法补货**(现有配件可组装 ${formatQty(row.assemblableQty)} 台${formatShortageNote(row)})`); + } return lines.join('\n'); } @@ -168,9 +202,21 @@ function buildMkhReplenishNotification({ `---`, `当前共 **${totalCount}** 个 MKH 商品需要补货(缺货 ${totalOutOfStock} | ${snapshot.replenishDaysThreshold || 3}天内预警 ${totalWarningOnly})。`, `建议补货合计 ${formatQty(snapshot.replenishQtyTotal)} 件。`, + Number(snapshot.orderDrivenCount) > 0 + ? `其中 **${snapshot.orderDrivenCount}** 个近7日无日销、因有未发货订单(合计 ${formatQty(snapshot.pendingOrderQtyTotal)} 件)而列入预警。` + : '', + Number(snapshot.componentShortageCount) > 0 + ? `其中 **${snapshot.componentShortageCount}** 个因**配件库存不足**(可组装数 = 0)暂时补不出来,建议补货量显示「—」,需先采购配件。` + : '', snapshot.workOrderDegraded === true ? `> ⚠️ 加工单数据源暂不可用,在途量按 0 处理,预警数量可能偏多。` : '', + snapshot.pendingOrderDegraded === true + ? `> ⚠️ 待发货订单数据源暂不可用,待发货量按 0 处理,可能遗漏「无日销但有订单」的商品。` + : '', + snapshot.componentStockDegraded === true + ? `> ⚠️ 配件库存数据源暂不可用,本次**未做**「配件不足」判定,可能存在补不出来的商品。` + : '', ].filter(Boolean).join('\n\n') ); @@ -211,11 +257,18 @@ async function checkMkhReplenishAlerts(options = {}) { outOfStockCount: snapshot.outOfStockCount, warningOnlyCount: snapshot.warningOnlyCount, replenishQtyTotal: snapshot.replenishQtyTotal, + // 订单驱动(近7日无日销但有未发货订单)的商品数 / 待发货件数,口径见 mkhBomService 文件头 ⑩ + orderDrivenCount: snapshot.orderDrivenCount, + pendingOrderQtyTotal: snapshot.pendingOrderQtyTotal, + // 配件不足(可组装数 = 0)的商品数,口径见 mkhBomService 文件头 ⑪ + componentShortageCount: snapshot.componentShortageCount, freshCount: fresh.length, persistedCount: persisted.length, restoredCount: restored.length, monitorDate: snapshot.monitorDate, workOrderDegraded: snapshot.workOrderDegraded, + pendingOrderDegraded: snapshot.pendingOrderDegraded, + componentStockDegraded: snapshot.componentStockDegraded, }; if (!config.enabled) return { sent: false, reason: 'disabled', summary }; diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 32deaa2..7e8c0fc 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -37,29 +37,28 @@ // payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同 // (无 SKU 条码),也不能直接当配件 BOM。 // -// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」) +// ⑤ 配件仓位 —— **唯一数据源** 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」) +// 2026-09-17 业务口径:仓位列**只认这张表**,其他来源一律不用(原来那套 +// 「库存表正数量仓(未分箱)→ 商品档案仓(暂无箱号)」的兜底链已删除 —— +// 那不是仓位,标出来会让人以为料就在那儿)。 // sku_id 可能带“-装箱序号”后缀(DNZ0019110N08V01-27),按基础配件编码匹配。 -// 取值是一条**四级兜底链**,宁可标注「未分箱 / 暂无箱号」也不误报具体货位: -// 1) 真货位 bin_group / bin → 「仓名 · bin_group / bin」 -// 2) 箱号(净数量 > 0 的 pack_id) → 「仓名 · 箱号 pack_id」 -// 3) 尚未分箱 → rpt.jst_inventoryInfo 正库存仓库 → 「仓名(未分箱)」 -// 4) 连库存记录也没有 → jst_sku.other_3 商品档案仓库 → 「仓名(暂无箱号)」 -// 取不到任何一层时前端显示「未维护」。 -// · 列表展开(/detail)、导出配件明细共用 loadComponentLocations(),按编码缓存 10 分钟。 +// 取值只有两层: +// 1) 真货位 bin_group / bin → 「仓名 · bin_group / bin」 +// 2) 箱号(净数量 > 0 的 pack_id) → 「仓名 · 箱号 pack_id」 +// 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。 +// · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(), +// 只打一条查询,按编码缓存 10 分钟。 // ⚠️ 关联列别踩(2026-09-17 实测):仓名必须用 `jst_warehouseInfo.Id = p.wh_id`; // 该表 `wms_co_id` **全表 NULL**,用 `w.wms_co_id = p.wms_co_id` 关联 85,152 行全部取不到仓名。 -// (库存表相反:`jst_inventoryInfo` 必须用 `w.wms_co_id = i.wms_co_id`,194,053 行仅 316 行无仓名。) // ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱** // (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、 // wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**。 -// 且表内容与 MKH 配件**零交集**:正式 BOM 的 784 个配件编码在该表一个都查不到; -// 表里存的是老型号半成品(DNZ0134xx / DNZ0199xx / DNZ0203xx,item_type=半成品)。 -// 结论:MKH 配件在第 1、2 层**必然落空**,实际显示值全部来自第 3 层 -// (jst_inventoryInfo 的实时库存仓库,如 DNZ039864N1410V01 → -// 「简尚-崔桥工厂半成品包装项目仓(未分箱)」),需业务在聚水潭维护装箱货位后 -// 第 1、2 层才会生效。 +// 且表内容与 MKH 配件**零交集**:正式 BOM 的 715 个配件编码在该表一个都查不到 +// (精确 + `code-%` 前缀都是 0;箱表里是 DNZ0135xx/0199xx/0203xx 老型号半成品)。 +// → MKH 配件仓位列当前**必然全部显示「未维护」**,这是数据源现状,不是代码问题; +// 需业务在聚水潭给配件做装箱/维护货位后才会自动出现(代码不用再动)。 // -// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 +// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。 // // ── ⑦ 补货预警(2026-09-17 业务确认口径)────────────────────────── // 规则:近 7 日日销 > 0 且 现有库存 < 3 天日销 → 预警补货 @@ -101,7 +100,59 @@ // 每批结果都进 officialBomDetailCache,重复导出直接命中缓存。 // ⚠️ products 形态**不返回 components** —— 4408 行商品若都挂上配件明细, // 响应会膨胀到几十 MB,而商品清单本身用不到明细。 - +// +// ── ⑩ 待发货订单(2026-09-17 业务确认口径)──────────────────────── +// 规则:**近 7 日日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。 +// 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」 +// 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。 +// · 数据源:业务库 rpt.jst_Orders(订单**主表**,实时更新,实测最新到今天) +// ⚠️ 不要用 rpt.jst_OrderItem(订单明细表)—— 实测**已停更在 2026-07-16** +// (主表最新 o_id≈9409xxx,明细最大 o_id 仅 8439xxx;近 5 天订单有明细的为 0 条)。 +// 主表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]` +// (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。 +// 实测逐单对账(2026-07-01~07-16,13.5 万单):全表普通订单仅 76.9% 能对上 +// (多为 `skus='0.'` 空汇总、组合装展开),但**MKH 成品订单 100% 可用**, +// 故本口径只解析 MKH 成品码(`/^MKH/i` 且非 `MKH-` 打头的组合装)。 +// · 未发货判定:`send_date IS NULL AND status NOT IN ('Cancelled','Merged')` +// (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单; +// 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求) +// · 待发货订单量 = Σ 该成品在未发货订单中的件数(同一单多次出现会累加) +// · 预警级别(业务确认):现有库存 < 待发货订单量 → 缺货;否则 → 预警 +// · 建议补货量 = max(0, 待发货订单量 − 现有库存) +// ⚠️ 该分支无法算「可用天数」(日销为 0,除数为零): +// 缺货时记 stockDays = 0(保证排在最前),预警(库存已够发)时记 null。 +// ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)—— +// 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。 +// 实测(2026-09-17):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件, +// 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。 +// +// ── ⑪ 配件不足 / 可组装数(2026-09-17 业务确认口径)──────────────── +// 规则:**缺货 / 预警的 MKH 成品,若其配件现有库存「可组装数 = 0」,则提示 +// 「配件不足无法补货」**,建议补货量显示为「—」(不给出补不到的补货目标)。 +// · 可组装数 = min over 配件 ⌊ 可支配配件数 ÷ 单台用量 ⌋ (木桶短板决定) +// 可支配配件数 = 现有库存 − 未完成加工单占用(不足 0 取 0) +// ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件 +// 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集: +// 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量) +// · 配件现有库存 rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓 +// (wms_co_id = 15718196)—— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。 +// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**, +// 跨仓正库存为 0,所以不跨仓汇总。 +// · 单台用量 rm_qty(jst_skuBomItem)实测取值为 1 / 2 / 4,必须参与计算,不能当 1。 +// ⚠️ 不能用 `jst_packInventoryInfo` 当配件库存 —— 它是「箱及仓位库存」, +// 与 MKH 配件零交集(见 ⑤),仓位口径专用。 +// · 采购在途(purchase_qty)**不计入**可用量(业务确认:只认现有库存)。 +// 锁定列(order_lock / pick_lock / lock_qty / virtual_qty)实测几乎全为 0 +// (本页 80 个配件里仅 2 条非零且值为 1),故直接用 qty。 +// · 判定范围:**缺货 + 预警都判**(业务确认),不改变原有预警级别与排序, +// 只额外打标记;「已排产」商品本来就不预警,不参与判定。 +// · 无 BOM 配件明细的商品 → 可组装数未知(组件缺失 ≠ 配件不足),不做判定。 +// 实测(2026-09-17):预警 26 个(缺货 9 + 预警 17)中 **10 个可组装数 = 0** +// (缺货 6 + 预警 4);扣加工单占用前后仅 1 个商品变化(185 → 180 台), +// 说明占用规模小但口径上必须扣。 +// ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标), +// 接口透出 componentStockDegraded=true。 +// const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -109,9 +160,8 @@ const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 200; const BOM_CACHE_TTL_MS = 2 * 60 * 1000; const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000; -// 配件仓位 + 配件库存(箱及仓位库 + 库存表)按编码统一缓存;单批编码数受 MSSQL -// 单语句 2100 参数上限约束(每个编码最多占 2 个参数:精确匹配 + 带后缀前缀匹配), -// 300 × 2 = 600 个参数,安全。 +// 配件仓位(只查「箱及仓位库存」)按编码缓存;单批编码数受 MSSQL 单语句 2100 参数 +// 上限约束(每个编码占 2 个参数:精确匹配 + 带后缀前缀匹配),300 × 2 = 600 个参数,安全。 const COMPONENT_LOCATION_CACHE_TTL_MS = 10 * 60 * 1000; const COMPONENT_LOCATION_CHUNK_SIZE = 300; @@ -152,6 +202,28 @@ const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_ // 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量 const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000; +// ── 待发货订单参数(见文件头 ⑩)────────────────────────────────── +// 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里 +const PENDING_ORDER_TABLE = 'rpt.jst_Orders'; +// 不计入待发货需求的状态:Cancelled 已取消 / Merged 已合并进别的单 +const PENDING_ORDER_EXCLUDED_STATUS = Object.freeze(['Cancelled', 'Merged']); +// 只捞含 MKH 的订单,避免全表扫描(实测 1.17M 行约 1.6s) +const PENDING_ORDER_SKU_LIKE = '%MKH%'; +// 订单数据源异常时的降级缓存时长 +const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000; + +// ── 配件不足 / 可组装数参数(见文件头 ⑪)──────────────────────── +// 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id +const COMPONENT_STOCK_WMS_CO_ID = 15718196; +const COMPONENT_STOCK_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓'; +// 单批编码数:每个编码占 1 个参数,300 远低于 MSSQL 单语句 2100 上限 +const COMPONENT_STOCK_CHUNK_SIZE = 300; +const COMPONENT_STOCK_CACHE_TTL_MS = 10 * 60 * 1000; +// 配件库存不可用时的降级缓存时长(短一点,尽快恢复判定) +const COMPONENT_STOCK_DEGRADED_TTL_MS = 60 * 1000; +// 文案 / Tooltip 里最多列出的短板配件数 +const COMPONENT_SHORTAGE_MAX_LIST = 3; + const toText = (value) => { if (value === null || value === undefined) return null; const text = String(value).trim(); @@ -199,6 +271,10 @@ let mkhProductCache = null; let replenishIndexCache = null; // 加工单在途量索引(见文件头 ⑧) let workOrderIndexCache = null; +// 待发货订单索引(见文件头 ⑩) +let pendingOrderIndexCache = null; +// 配件库存(按编码缓存,见文件头 ⑪) +const componentStockCache = new Map(); const TRUTHY_FLAGS = new Set(['1', 'true', 'yes', 'on']); @@ -685,16 +761,21 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( /** * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 * - * 口径:近7日日销 > 0 且 现有库存 < 3 天日销 → 预警。 - * 无近7日日销的商品不参与预警,标记为「无销量」。 + * 口径(见文件头 ⑦ + ⑩): + * ① 近7日日销 > 0 且 现有库存 < 3 天日销 → 预警 + * ② 近7日日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩) + * 两者都不满足 → 无销量,不参与预警。 + * 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。 * * @param {object} row - * row.sevenAvgSale 近7日日均发货量 - * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) - * row.monitorStock 监控快照库存(fallback) - * row.safetyDays 源系统安全天数(仅展示) - * row.suggestStock 源系统建议备货量(仅展示对照) - * row.monitorDate 监控快照日期 + * row.sevenAvgSale 近7日日均发货量 + * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) + * row.monitorStock 监控快照库存(fallback) + * row.safetyDays 源系统安全天数(仅展示) + * row.suggestStock 源系统建议备货量(仅展示对照) + * row.monitorDate 监控快照日期 + * row.pendingWorkOrderQty 在途加工量(未完成且非作废) + * row.pendingOrderQty 待发货订单量(未发货订单件数) */ function buildReplenishMetrics(row = {}) { const dailySale = Math.max(0, toNumber(row.sevenAvgSale) ?? 0); @@ -708,6 +789,12 @@ function buildReplenishMetrics(row = {}) { const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0); const hasPendingWorkOrder = pendingWorkOrderQty > 0 || pendingWorkOrderCount > 0; + // 待发货订单(见文件头 ⑩):当前未发货订单里的件数 + const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0); + const pendingOrderCount = Math.max(0, toNumber(row.pendingOrderCount) ?? 0); + // 快照日销还是 0、但已经有订单 → 由订单驱动预警 + const orderDriven = dailySale <= 0 && pendingOrderQty > 0; + const base = { dailySale, stock, @@ -716,6 +803,10 @@ function buildReplenishMetrics(row = {}) { hasPendingWorkOrder, pendingWorkOrderEta: toDateText(row.pendingWorkOrderEta), pendingWorkOrderNo: toText(row.pendingWorkOrderNo), + pendingOrderQty, + pendingOrderCount, + pendingOrderEarliest: toDateText(row.pendingOrderEarliest), + orderDriven, thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)), stockSource: hasRealtime ? 'realtime' : 'monitor', safetyDays: toNumber(row.safetyDays), @@ -723,7 +814,8 @@ function buildReplenishMetrics(row = {}) { monitorDate: toDateText(row.monitorDate), }; - if (dailySale <= 0) { + // 既无近7日日销、也无未发货订单 → 不参与预警 + if (dailySale <= 0 && !orderDriven) { return { ...base, thresholdQty: 0, @@ -735,13 +827,11 @@ function buildReplenishMetrics(row = {}) { }; } - const stockDays = stock / dailySale; - - // 已有未完成加工单 → 不再列入补货预警(业务口径,见文件头 ⑧) + // 已有未完成加工单 → 不再列入补货预警(业务口径见文件头 ⑧;优先级高于订单驱动) if (hasPendingWorkOrder) { return { ...base, - stockDays: Number(stockDays.toFixed(2)), + stockDays: dailySale > 0 ? Number((stock / dailySale).toFixed(2)) : null, replenishQty: 0, warningLevel: WARNING_LEVEL.SCHEDULED, warningOrder: 4, @@ -749,6 +839,21 @@ function buildReplenishMetrics(row = {}) { }; } + // 订单驱动(见文件头 ⑩):日销为 0 算不出可用天数,改按「能否覆盖待发货」判级 + if (orderDriven) { + const notEnough = stock < pendingOrderQty; + return { + ...base, + // 缺货记 0(保证排在最前);库存已够发货时记 null(无日销,算不出天数) + stockDays: notEnough ? 0 : null, + replenishQty: Math.max(0, pendingOrderQty - stock), + warningLevel: notEnough ? WARNING_LEVEL.OUT_OF_STOCK : WARNING_LEVEL.WARNING, + warningOrder: notEnough ? 1 : 2, + hasWarning: true, + }; + } + + const stockDays = stock / dailySale; const replenishQty = Math.max(0, Math.ceil(base.thresholdQty - stock)); const level = stockDays <= 0 ? WARNING_LEVEL.OUT_OF_STOCK @@ -765,6 +870,80 @@ function buildReplenishMetrics(row = {}) { }; } +/** + * 未完成加工单对配件的占用(纯函数,见文件头 ⑪)。 + * 占用[配件编码] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量) + * 加工单在途的成品已经把配件预定/领走,计算可组装数时必须先扣掉,否则会高估补货能力。 + * @param {Array<{skuId: string, pendingWorkOrderQty: number}>} workOrders 未完成加工单(成品级) + * @param {Map} 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 }],按编码升序。 @@ -843,6 +1022,8 @@ async function loadPendingWorkOrderIndex() { const orderCount = Math.max(0, toNumber(row.orderCount) ?? 0); if (pendingQty <= 0 && orderCount <= 0) return; bySku.set(key, { + // 原始商品编码:配件占用展开(见文件头 ⑪)需要拿它回查 BOM,故一并保留 + skuId: toText(row.skuId), pendingWorkOrderQty: pendingQty, pendingWorkOrderCount: orderCount, pendingWorkOrderEta: row.eta instanceof Date ? row.eta : null, @@ -866,6 +1047,110 @@ async function loadPendingWorkOrderIndex() { } } +/** + * 解析 jst_Orders.skus 文本列 → [{ code, qty }](纯函数,便于回归测试)。 + * 格式:`<总件数>.<条码>[*数量][,<条码>[*数量]]…` + * 例:`1.MKHDNZ0409260700426` → 1 个成品 1 件 + * `2.A*1,B*1` → A、B 各 1 件 + * `3.C*1,D*1,E*1` → 总件数前缀只作校验,不参与计算 + * · 省略 `*数量` 时按 1 件;解析不了的片段直接丢弃(可用率实测见文件头 ⑩) + */ +function parseOrderSkus(skus) { + const text = toText(skus); + if (!text) return []; + const body = text.replace(/^\d+\./, ''); + const items = []; + body.split(',').forEach((part) => { + const segment = part.trim(); + if (!segment) return; + const matched = segment.match(/^(.*?)(?:\*(\d+))?$/); + if (!matched) return; + const code = toText(matched[1]); + if (!code) return; + const parsedQty = matched[2] ? Number(matched[2]) : 1; + items.push({ code, qty: Number.isFinite(parsedQty) && parsedQty > 0 ? parsedQty : 1 }); + }); + return items; +} + +/** 是否为 MKH **成品**编码(`MKH-` 打头的是组合装,见文件头 ⑩,不计入成品需求) */ +const isMkhProductCode = (code) => { + const text = toText(code); + return Boolean(text) && /^MKH/i.test(text) && !/^MKH-/i.test(text); +}; + +/** + * 待发货订单索引 —— 进程内缓存 + * { bySku: Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }>, degraded }。 + * 口径见文件头 ⑩:未发货 = send_date 为空且状态非取消/合并,商品取自主表 skus 文本列。 + * ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。 + */ +async function loadPendingOrderIndex() { + const now = Date.now(); + if (pendingOrderIndexCache?.value && pendingOrderIndexCache.expiresAt > now) { + return pendingOrderIndexCache.value; + } + if (pendingOrderIndexCache?.pending) return pendingOrderIndexCache.pending; + + const statusParams = {}; + const statusPlaceholders = PENDING_ORDER_EXCLUDED_STATUS.map((status, index) => { + statusParams[`pendingOrderStatus${index}`] = status; + return `@pendingOrderStatus${index}`; + }).join(', '); + + const pending = cloudDatabase.query( + `SELECT o.o_id AS oId, + o.order_date AS orderDate, + o.skus AS skus + FROM ${PENDING_ORDER_TABLE} o WITH (NOLOCK) + WHERE o.skus LIKE @pendingOrderSkuLike + AND o.send_date IS NULL + AND o.status NOT IN (${statusPlaceholders})`, + { pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams }, + { requestTimeout: 180000 } + ).then((rows) => { + const bySku = new Map(); + rows.forEach((row) => { + const orderDate = row.orderDate instanceof Date ? row.orderDate : null; + parseOrderSkus(row.skus).forEach(({ code, qty }) => { + if (!isMkhProductCode(code)) return; + const key = normalizeCode(code); + if (!key) return; + const entry = bySku.get(key) || { + pendingOrderQty: 0, + pendingOrderCount: 0, + pendingOrderEarliest: null, + }; + entry.pendingOrderQty += qty; + entry.pendingOrderCount += 1; + if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { + entry.pendingOrderEarliest = orderDate; + } + bySku.set(key, entry); + }); + }); + return { bySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; + }).catch((error) => { + console.warn('[MKH-BOM] 待发货订单读取失败,已按「无待发货订单」降级:', error.message); + return { + bySku: new Map(), + degraded: true, + error: error.message, + ttl: PENDING_ORDER_DEGRADED_TTL_MS, + }; + }); + + pendingOrderIndexCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + pendingOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + return value; + } catch (error) { + pendingOrderIndexCache = null; + throw error; + } +} + /** * 补货预警索引 —— 进程内缓存 { bySku: Map<归一化编码, 指标>, monitorDate }。 * 一次读入品牌仓监控快照与实时库存,指标计算全部在应用层完成(原因见文件头 ⑦)。 @@ -899,8 +1184,11 @@ async function loadReplenishIndex() { ), // 在途加工单(见文件头 ⑧) loadPendingWorkOrderIndex(), - ]).then(([monitorRows, stockRows, workOrderIndex]) => { + // 待发货订单(见文件头 ⑩) + loadPendingOrderIndex(), + ]).then(([monitorRows, stockRows, workOrderIndex, pendingOrderIndex]) => { const workOrderBySku = workOrderIndex.bySku; + const pendingOrderBySku = pendingOrderIndex.bySku; const latestStock = new Map(); stockRows.forEach((row) => { const key = normalizeCode(row.skuId); @@ -924,13 +1212,16 @@ async function loadReplenishIndex() { suggestStock: row.suggestStock, monitorDate: row.monitorDate, ...(workOrderBySku.get(key) || null), + ...(pendingOrderBySku.get(key) || null), })); }); return { bySku, workOrderBySku, + pendingOrderBySku, workOrderDegraded: workOrderIndex.degraded === true, + pendingOrderDegraded: pendingOrderIndex.degraded === true, monitorDate, }; }); @@ -946,6 +1237,112 @@ async function loadReplenishIndex() { } } +/** + * 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。 + * · 按基础配件编码精确匹配,SUM(qty) 汇总 + * · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存) + * · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标), + * 且失败的分片不写缓存,下次调用重试 + * @returns {Promise<{stockMap: Map, 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 商品 × 库存/日销指标 × 在途加工单(内存合并)。 * 列表接口与补货预警推送共用同一份合并结果,避免两处口径漂移。 @@ -960,14 +1357,36 @@ async function buildMergedReplenishRows() { const key = normalizeCode(product.skuId); const metrics = replenishIndex.bySku.get(key); if (metrics) return { ...product, ...metrics }; - // 监控快照里没有该商品时,仍要带上在途加工单信息(见文件头 ⑧) + // 监控快照里没有该商品时,仍要带上在途加工单(⑧)与待发货订单(⑩)信息 return { ...product, - ...buildReplenishMetrics(replenishIndex.workOrderBySku.get(key) || {}), + ...buildReplenishMetrics({ + ...(replenishIndex.workOrderBySku?.get(key) || null), + ...(replenishIndex.pendingOrderBySku?.get(key) || null), + }), }; }); - return { rows, replenishIndex }; + // 配件可组装数 / 配件不足(见文件头 ⑪):只对「缺货 / 预警」的商品计算, + // 「已排产」「无销量」等本来不需要补货的商品不参与,避免无谓查询。 + const alertSkuIds = rows.filter((row) => row.hasWarning).map((row) => row.skuId); + const readiness = alertSkuIds.length > 0 + ? await loadMkhComponentReadiness(alertSkuIds) + : { bySku: new Map(), degraded: false }; + + rows.forEach((row) => { + const hit = row.hasWarning ? readiness.bySku.get(normalizeCode(row.skuId)) : null; + // 未判定(非预警 / 无 BOM 明细 / 配件库存降级)一律保持 false + null,前端据此显示「—」 + row.componentShortage = hit?.shortage === true; + row.assemblableQty = hit ? hit.assemblableQty : null; + row.bottleneckComponents = hit ? hit.bottlenecks : []; + }); + + return { + rows, + replenishIndex, + componentStockDegraded: readiness.degraded === true, + }; } /** @@ -982,7 +1401,7 @@ async function listReplenishAlertRows(options = {}) { ? [WARNING_LEVEL.OUT_OF_STOCK] : [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING]; - const { rows, replenishIndex } = await buildMergedReplenishRows(); + const { rows, replenishIndex, componentStockDegraded } = await buildMergedReplenishRows(); const alerts = rows.filter((row) => levels.includes(row.warningLevel)); // 最紧急的排前面:缺货 → 可用天数最少 → 编码升序 @@ -1000,8 +1419,17 @@ async function listReplenishAlertRows(options = {}) { replenishQtyTotal: Number( alerts.reduce((sum, row) => sum + (toNumber(row.replenishQty) || 0), 0).toFixed(2) ), + // 订单驱动预警(日销=0 但有未发货订单)的条数,见文件头 ⑩ + orderDrivenCount: alerts.filter((row) => row.orderDriven).length, + pendingOrderQtyTotal: Number( + alerts.reduce((sum, row) => sum + (toNumber(row.pendingOrderQty) || 0), 0).toFixed(2) + ), monitorDate: replenishIndex.monitorDate, workOrderDegraded: replenishIndex.workOrderDegraded === true, + pendingOrderDegraded: replenishIndex.pendingOrderDegraded === true, + // 配件不足(见文件头 ⑪):可组装数 = 0、补不出货的商品数 + componentShortageCount: alerts.filter((row) => row.componentShortage).length, + componentStockDegraded: componentStockDegraded === true, replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, saleWindowDays: SALE_WINDOW_DAYS, scope: scope === 'outOfStock' ? 'outOfStock' : 'warning', @@ -1051,113 +1479,40 @@ function buildPackLocationMap(rows, componentCodes) { return result; } -function buildInventoryWarehouseMap(rows, componentCodes) { - const positiveWarehouses = new Map(componentCodes.map((code) => [code, new Set()])); - const defaultWarehouses = new Map(); - - rows.forEach((row) => { - const componentCode = componentCodes.find((code) => normalizeCode(code) === normalizeCode(row.skuId)); - if (!componentCode) return; - - const defaultWarehouse = toText(row.defaultWarehouse); - if (defaultWarehouse) defaultWarehouses.set(componentCode, defaultWarehouse); - - const warehouseName = toText(row.warehouseName); - if (warehouseName && (toNumber(row.availableQty) ?? 0) > 0) { - positiveWarehouses.get(componentCode).add(warehouseName); - } - }); - - const result = new Map(); - componentCodes.forEach((code) => { - const warehouses = [...positiveWarehouses.get(code)]; - if (warehouses.length > 0) { - result.set(code, `${warehouses.join('、')}(未分箱)`); - return; - } - - const defaultWarehouse = defaultWarehouses.get(code); - if (defaultWarehouse) result.set(code, `${defaultWarehouse}(暂无箱号)`); - }); - return result; -} - /** - * 配件仓位 + 配件库存批量查询(列表展开 / 单品明细 / 导出配件明细共用)。 - * 数据源:rpt.jst_inventoryInfo(库存,及「未分箱 / 暂无箱号」兜底仓)、 - * rpt.jst_packInventoryInfo(箱及仓位库)、jst_sku.other_3(商品档案仓) + * 配件仓位批量查询(列表展开 / 单品明细 / 导出配件明细共用)。 + * 数据源**只有一个**:rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」), + * 仓名左连 rpt.jst_warehouseInfo(`Id = wh_id`,见文件头 ⑤ 的关联列警告)。 * · 按基础配件编码匹配,兼容库内 sku_id 带「-装箱序号」后缀的形态 - * · 仓位走文件头 ⑤ 的四级兜底链:真货位 → 箱号 → 库存仓(未分箱)→ 档案仓(暂无箱号) + * · 取值只有两层:真货位 bin_group / bin → 箱号 pack_id; + * 该表查不到就是空,**不回退**库存表仓 / 商品档案仓(那不是仓位) * · 单个配件跨多个箱时最多列出 10 个位置,超出补「等 N 个位置」 - * · 结果按编码缓存(TTL 10 分钟);**两条查询都成功才写缓存**, - * 任一降级都不写,避免把不完整结果固化 10 分钟 - * · 单元格超限由 COMPONENT_LOCATION_CHUNK_SIZE 分片,见常量处说明 - * @returns {Promise<{stockMap: Map, locationMap: Map}>} + * · 结果按编码缓存(TTL 10 分钟);查询失败**不写缓存**,下次调用重试 + * @returns {Promise>} 配件编码 → 仓位文本(查不到的编码不含 key) */ -async function loadComponentLocationData(codes = []) { +async function loadComponentLocations(codes = []) { const now = Date.now(); const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); - const stockMap = new Map(); const locationMap = new Map(); const missing = []; unique.forEach((code) => { - const hit = componentLocationCache.get(normalizeCode(code)); + const hit = componentLocationCache.get(code); if (hit && hit.expiresAt > now) { - stockMap.set(code, hit.stock ?? 0); if (hit.location) locationMap.set(code, hit.location); } else { missing.push(code); } }); - if (missing.length === 0) return { stockMap, locationMap }; + if (missing.length === 0) return locationMap; + // 单元格超限由 COMPONENT_LOCATION_CHUNK_SIZE 分片,见常量处说明 const chunks = []; for (let i = 0; i < missing.length; i += COMPONENT_LOCATION_CHUNK_SIZE) { chunks.push(missing.slice(i, i + COMPONENT_LOCATION_CHUNK_SIZE)); } - // 组内串行(单个接口最多同时打两条查询,避免拖垮连接池) + // 组间串行(本函数只打一条查询,避免一次塞满连接池) for (const chunk of chunks) { - const packLocations = new Map(); // 第 1、2 层:真货位 / 箱号 - let fallbackLocations = new Map(); // 第 3、4 层:未分箱 / 暂无箱号 - let stockQuerySucceeded = false; - let packQuerySucceeded = false; - - // ① 配件库存 + 仓名 —— 一次查询同时供 stockMap 与兜底仓位使用 - try { - const parameters = {}; - const placeholders = chunk.map((code, index) => { - parameters[`c${index}`] = code; - return `@c${index}`; - }).join(', '); - const stockRows = await cloudDatabase.query( - `SELECT s.sku_id AS skuId, - s.other_3 AS defaultWarehouse, - w.name AS warehouseName, - SUM(COALESCE(i.qty, 0)) AS qty, - SUM(COALESCE(i.qty, 0) - - COALESCE(i.order_lock, 0) - - COALESCE(i.pick_lock, 0)) AS availableQty - FROM dbo.jst_sku s WITH (NOLOCK) - LEFT JOIN rpt.jst_inventoryInfo i WITH (NOLOCK) ON i.sku_id = s.sku_id - LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = i.wms_co_id - WHERE s.sku_id IN (${placeholders}) - GROUP BY s.sku_id, s.other_3, i.wms_co_id, w.name`, - parameters, - { requestTimeout: 180000 } - ); - stockRows.forEach((row) => { - const code = chunk.find((candidate) => normalizeCode(candidate) === normalizeCode(row.skuId)); - if (!code) return; - stockMap.set(code, (stockMap.get(code) || 0) + (toNumber(row.qty) ?? 0)); - }); - fallbackLocations = buildInventoryWarehouseMap(stockRows, chunk); - stockQuerySucceeded = true; - } catch (error) { - console.warn('[MkhBom] 配件库存查询降级:', error.message); - } - - // ② 箱及仓位(第 1、2 层)—— 仓名必须用 jst_warehouseInfo.Id = p.wh_id,见文件头 ⑤ try { const params = {}; const conditions = chunk.map((code, index) => { @@ -1165,14 +1520,14 @@ async function loadComponentLocationData(codes = []) { params[`lp${index}`] = `${code}-%`; return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`; }); - const locRows = await cloudDatabase.query( - `SELECT p.sku_id AS skuId, - p.pack_id AS packId, - p.wh_id AS warehouseId, - p.bin AS bin, + const rows = await cloudDatabase.query( + `SELECT p.sku_id AS skuId, + p.pack_id AS packId, + p.wh_id AS warehouseId, + p.bin AS bin, p.bin_group AS binGroup, - w.name AS warehouseName, - SUM(p.qty) AS qty + w.name AS warehouseName, + SUM(p.qty) AS qty FROM rpt.jst_packInventoryInfo p WITH (NOLOCK) LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.Id = p.wh_id WHERE ${conditions.join(' OR ')} @@ -1181,36 +1536,19 @@ async function loadComponentLocationData(codes = []) { params, { requestTimeout: 120000 } ); - buildPackLocationMap(locRows, chunk).forEach((location, code) => { - packLocations.set(code, location); + const byCode = buildPackLocationMap(rows, chunk); + const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS; + chunk.forEach((code) => { + const location = byCode.get(code) || null; + componentLocationCache.set(code, { location, expiresAt }); + if (location) locationMap.set(code, location); }); - packQuerySucceeded = true; } catch (error) { - console.warn('[MkhBom] 配件箱号/仓位查询降级:', error.message); + // 失败不写缓存,下次调用重试 + console.warn('[MkhBom] 配件仓位查询降级:', error.message); } - - const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS; - chunk.forEach((code) => { - const location = packLocations.get(code) || fallbackLocations.get(code) || null; - if (stockQuerySucceeded && packQuerySucceeded) { - componentLocationCache.set(normalizeCode(code), { - stock: stockMap.get(code) ?? 0, - location, - expiresAt, - }); - } - if (location) locationMap.set(code, location); - }); } - return { stockMap, locationMap }; -} - -/** - * 兼容入口:只要仓位映射(列表展开 / 导出配件明细)。 - */ -async function loadComponentLocations(codes = []) { - const { locationMap } = await loadComponentLocationData(codes); return locationMap; } @@ -1235,7 +1573,7 @@ async function getMkhBomList(query = {}) { loadPgBom(), ]); // 商品 × 库存/日销指标 × 在途加工单(内存合并,与补货预警推送共用) - const { rows: merged, replenishIndex } = mergedResult; + const { rows: merged, replenishIndex, componentStockDegraded } = mergedResult; // 全量统计(不受关键词 / 范围筛选影响) const warningRows = merged.filter((row) => row.hasWarning); @@ -1250,6 +1588,14 @@ async function getMkhBomList(query = {}) { pendingWorkOrderQtyTotal: Number( merged.reduce((sum, row) => sum + (toNumber(row.pendingWorkOrderQty) || 0), 0).toFixed(2) ), + // 待发货订单(见文件头 ⑩):有未发货订单的成品数 / 件数,以及其中「靠订单才预警」的数量 + pendingOrderSkuCount: merged.filter((row) => (toNumber(row.pendingOrderQty) || 0) > 0).length, + pendingOrderQtyTotal: Number( + merged.reduce((sum, row) => sum + (toNumber(row.pendingOrderQty) || 0), 0).toFixed(2) + ), + orderDrivenCount: warningRows.filter((row) => row.orderDriven).length, + // 配件不足(见文件头 ⑪):可组装数 = 0,即现有配件凑不出一台、补不出来的商品数 + componentShortageCount: warningRows.filter((row) => row.componentShortage).length, monitorDate: replenishIndex.monitorDate, }; @@ -1296,6 +1642,11 @@ async function getMkhBomList(query = {}) { // 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理 workOrderDegraded: replenishIndex.workOrderDegraded === true, workOrderWarehouse: WORK_ORDER_WAREHOUSE, + // 待发货订单数据源不可用时为 true,此时待发货量按 0 处理(退化为旧口径) + pendingOrderDegraded: replenishIndex.pendingOrderDegraded === true, + // 配件库存数据源不可用时为 true,此时**不做**配件不足判定(见文件头 ⑪) + componentStockDegraded: componentStockDegraded === true, + componentStockWarehouse: COMPONENT_STOCK_WAREHOUSE, warningInPage: rows.filter((row) => row.hasWarning).length, }; } @@ -1341,9 +1692,8 @@ async function getMkhBomDetail(mkhSkuId) { const components = matched?.components || []; const codes = components.map((item) => item.barcode).filter(Boolean); - // 与列表展开 / 导出配件明细走同一个批量加载器 —— - // 含仓名解析、文件头 ⑤ 的四级兜底链、库存与仓位的统一 10 分钟缓存 - const { stockMap, locationMap } = await loadComponentLocationData(codes); + // 与列表展开 / 导出配件明细走同一个加载器:只查「箱及仓位库存」,见文件头 ⑤ + const locationMap = await loadComponentLocations(codes); const items = components.map((component, index) => ({ index: index + 1, @@ -1353,7 +1703,6 @@ async function getMkhBomDetail(mkhSkuId) { role: component.role, qty: toNumber(component.qty) ?? 0, location: locationMap.get(component.barcode) || null, - stock: stockMap.get(component.barcode) ?? 0, })); return { @@ -1417,8 +1766,12 @@ function buildProductExportRows(matchedRows = []) { dailySale: toNumber(row.dailySale) ?? 0, stock: toNumber(row.stock) ?? 0, pendingWorkOrderQty: toNumber(row.pendingWorkOrderQty) ?? 0, + pendingOrderQty: toNumber(row.pendingOrderQty) ?? 0, stockDays: toNumber(row.stockDays), replenishQty: toNumber(row.replenishQty) ?? 0, + // 配件不足(见文件头 ⑪):可组装数 = 0 → 现有配件凑不出一台,标注出来避免业务照着补 + componentShortage: row.componentShortage === true ? '配件不足' : '', + assemblableQty: toNumber(row.assemblableQty), bomConfigured: components.length > 0 ? '已配置' : '未配置', accessoryCount: components.length, accessoryQtySum: sumQty(components), @@ -1535,13 +1888,24 @@ module.exports = { matchesPackSku, formatPackLocation, buildPackLocationMap, - buildInventoryWarehouseMap, loadComponentLocations, - loadComponentLocationData, COMPONENT_LOCATION_CHUNK_SIZE, COMPONENT_LOCATION_CACHE_TTL_MS, buildReplenishMetrics, loadPendingWorkOrderIndex, + loadPendingOrderIndex, + parseOrderSkus, + isMkhProductCode, + PENDING_ORDER_EXCLUDED_STATUS, + // ⑪ 配件不足 / 可组装数 + buildComponentOccupation, + buildComponentReadiness, + loadComponentStock, + loadMkhComponentReadiness, + COMPONENT_STOCK_WMS_CO_ID, + COMPONENT_STOCK_WAREHOUSE, + COMPONENT_STOCK_CHUNK_SIZE, + COMPONENT_SHORTAGE_MAX_LIST, parseFlag, resolveScope, filterRows, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 62e2c01..227523e 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -165,19 +165,24 @@ test('pack inventory location prefers bins and falls back to positive-stock box assert.equal(locations.get('PART-1'), '半成品仓 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); }); -test('inventory warehouse fallback distinguishes unboxed stock from a warehouse with no box record', () => { - const locations = _private.buildInventoryWarehouseMap([ - { - skuId: 'PART-1', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 12, - }, - { - skuId: 'PART-2', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 0, - }, - ], ['PART-1', 'PART-2', 'PART-3']); +// ── 配件仓位:只认「箱及仓位库存」,不做库存表/商品档案仓回退(2026-09-17 业务口径)── +test('component location is sourced only from the pack inventory table', () => { + assert.equal(typeof _private.loadComponentLocations, 'function'); + // 兜底链已删除:这两个符号不应再存在(存在即有代码走回退) + assert.equal(_private.loadComponentLocationData, undefined); + assert.equal(_private.buildInventoryWarehouseMap, undefined); +}); - assert.equal(locations.get('PART-1'), '实际库存仓(未分箱)'); - assert.equal(locations.get('PART-2'), '默认半成品仓(暂无箱号)'); - assert.equal(locations.has('PART-3'), false); +test('pack inventory without bin/bin_group falls back to the box id, never to a warehouse', () => { + const locations = _private.buildPackLocationMap([ + { + skuId: 'PART-1-1', warehouseId: 1, warehouseName: '半成品仓', binGroup: null, bin: null, packId: 'BOX-9', qty: 3, + }, + ], ['PART-1', 'PART-2']); + + assert.equal(locations.get('PART-1'), '半成品仓 · 箱号 BOX-9'); + // 该表没有记录 → 无仓位,交给前端显示「未维护」,不回退任何仓库 + assert.equal(locations.has('PART-2'), false); }); // ── 补货预警:近 7 日日销 > 0 且 现有库存 < 3 天日销 ──────────────── @@ -342,6 +347,107 @@ test('pending work order filters keep the business-confirmed source and exclusio assert.equal(_private.WARNING_LEVEL.SCHEDULED, '已排产'); }); +// ── 待发货订单(见服务文件头 ⑩)────────────────────────────────── +test('pending order: jst_Orders.skus text column is parsed into code/quantity pairs', () => { + // 单商品、省略 *数量 + assert.deepEqual(_private.parseOrderSkus('1.MKHDNZ0409260700426'), [ + { code: 'MKHDNZ0409260700426', qty: 1 }, + ]); + // 多商品、每个带 *数量(总件数前缀 2. 不参与计算) + assert.deepEqual(_private.parseOrderSkus('2.A*1,B*1'), [ + { code: 'A', qty: 1 }, + { code: 'B', qty: 1 }, + ]); + // 同一单里成品与组合装混排:文本照常解析,是否成品由 isMkhProductCode 判定 + assert.deepEqual( + _private.parseOrderSkus('3.GBGJ-ZWJ024903AN1407V01*1,GBGJ-ZWJ024903BN1407V01*1,MKHDNZ0409260600022*1'), + [ + { code: 'GBGJ-ZWJ024903AN1407V01', qty: 1 }, + { code: 'GBGJ-ZWJ024903BN1407V01', qty: 1 }, + { code: 'MKHDNZ0409260600022', qty: 1 }, + ] + ); + // 空值 / 空汇总(实测 23% 的普通订单是 `0.`,必须安全返回空数组) + assert.deepEqual(_private.parseOrderSkus('0.'), []); + assert.deepEqual(_private.parseOrderSkus(''), []); + assert.deepEqual(_private.parseOrderSkus(null), []); + assert.deepEqual(_private.parseOrderSkus('2.MKHDNZ0409260601388*2'), [ + { code: 'MKHDNZ0409260601388', qty: 2 }, + ]); +}); + +test('pending order: only MKH finished products count, combination codes are excluded', () => { + assert.equal(_private.isMkhProductCode('MKHDNZ0409260700426'), true); + assert.equal(_private.isMkhProductCode('MKHSJ0425080100098'), true); + // `MKH-` 打头的是组合装(拼接码),不是成品,不计入成品需求 + assert.equal(_private.isMkhProductCode('MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1'), false); + assert.equal(_private.isMkhProductCode('GBGJ-ZWJ024903AN1407V01'), false); + assert.equal(_private.isMkhProductCode(''), false); + assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']); +}); + +test('replenish: no sales but unshipped orders still trigger the warning', () => { + // 库存不够发货 → 缺货,补货量 = 待发货件数 − 现有库存 + const notEnough = metrics({ + sevenAvgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2, + }); + assert.equal(notEnough.hasWarning, true); + assert.equal(notEnough.warningLevel, '缺货'); + assert.equal(notEnough.replenishQty, 3); + assert.equal(notEnough.orderDriven, true); + // 无日销基数 → 缺货时记 0,保证排在预警清单最前 + assert.equal(notEnough.stockDays, 0); + assert.equal(notEnough.pendingOrderQty, 4); + assert.equal(notEnough.pendingOrderCount, 2); + + // 库存刚好够发 → 预警(不再缺货),补货量为 0 + const exactlyEnough = metrics({ + sevenAvgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4, + }); + assert.equal(exactlyEnough.warningLevel, '预警'); + assert.equal(exactlyEnough.hasWarning, true); + assert.equal(exactlyEnough.replenishQty, 0); + // 库存已够发时算不出可用天数,记 null(页面显示「按订单」) + assert.equal(exactlyEnough.stockDays, null); +}); + +test('replenish: order-driven warning does not override the pending work order exemption', () => { + const scheduled = metrics({ + sevenAvgSale: 0, + realtimeStock: 0, + pendingOrderQty: 5, + pendingOrderCount: 1, + pendingWorkOrderQty: 6, + pendingWorkOrderCount: 1, + }); + assert.equal(scheduled.warningLevel, '已排产'); + assert.equal(scheduled.hasWarning, false); + assert.equal(scheduled.replenishQty, 0); + // 待发货量仍要透出,页面上应能看到 + assert.equal(scheduled.pendingOrderQty, 5); + assert.equal(scheduled.orderDriven, true); +}); + +test('replenish: pending order fields default to empty without any unshipped order', () => { + const none = metrics({ sevenAvgSale: 1, realtimeStock: 0 }); + assert.equal(none.pendingOrderQty, 0); + assert.equal(none.pendingOrderCount, 0); + assert.equal(none.pendingOrderEarliest, null); + assert.equal(none.orderDriven, false); + + // 无日销且无订单 → 仍是「无销量」,不参与预警 + const noSale = metrics({ sevenAvgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); + assert.equal(noSale.warningLevel, '无销量'); + assert.equal(noSale.orderDriven, false); + + // 有日销的商品照旧走原口径,但待发货量也要带上(页面新增列) + const withSale = metrics({ sevenAvgSale: 2, realtimeStock: 0, pendingOrderQty: 3 }); + assert.equal(withSale.warningLevel, '缺货'); + assert.equal(withSale.orderDriven, false); + assert.equal(withSale.pendingOrderQty, 3); + assert.equal(withSale.replenishQty, 6); // ceil(3 × 2 − 0),不受订单量影响 +}); + // ── 导出(见服务文件头 ⑨)──────────────────────────────────────── test('export: product rows follow the page table columns', () => { const rows = _private.buildProductExportRows([ @@ -381,8 +487,11 @@ test('export: product rows follow the page table columns', () => { dailySale: 2.97, stock: 0, pendingWorkOrderQty: 0, + pendingOrderQty: 0, stockDays: 0, replenishQty: 9, + componentShortage: '', + assemblableQty: null, bomConfigured: '已配置', accessoryCount: 2, accessoryQtySum: 16, @@ -390,6 +499,7 @@ test('export: product rows follow the page table columns', () => { // 无销量 → stockDays 为 null(前端导出时渲染成「无销量」),在途量缺省归 0 assert.equal(rows[1].stockDays, null); assert.equal(rows[1].pendingWorkOrderQty, 0); + assert.equal(rows[1].pendingOrderQty, 0); assert.equal(rows[1].bomConfigured, '未配置'); assert.equal(rows[1].accessoryQtySum, 0); }); @@ -475,3 +585,117 @@ test('pack inventory location chunk size stays under the MSSQL parameter limit', // 每个编码占 2 个参数(精确匹配 @lcN + 带后缀前缀匹配 @lpN),MSSQL 单语句上限 2100 assert.equal(_private.COMPONENT_LOCATION_CHUNK_SIZE * 2 <= 2000, true); }); + +// ── 配件不足 / 可组装数(见服务文件头 ⑪)────────────────────────── +test('component readiness: assemblable qty is limited by the tightest component', () => { + const components = [ + { barcode: 'A', name: '配件A', qty: 1 }, + { barcode: 'B', name: '配件B', qty: 2 }, + ]; + // A: floor(10 / 1) = 10,B: floor(7 / 2) = 3 → 木桶短板是 B + const ready = _private.buildComponentReadiness(components, new Map([['A', 10], ['B', 7]]), new Map()); + + assert.equal(ready.assemblableQty, 3); + assert.equal(ready.shortage, false); + assert.equal(ready.bottlenecks[0].code, 'B'); + assert.equal(ready.bottlenecks[0].can, 3); + // Tooltip 要能显示「单台用量」,不能默认当 1 + assert.equal(ready.bottlenecks[0].perQty, 2); +}); + +test('component readiness: zero assemblable qty marks 配件不足', () => { + const components = [ + { barcode: 'A', name: '配件A', qty: 1 }, + { barcode: 'B', name: '配件B', qty: 1 }, + ]; + // B 在该仓没有库存记录 → 视为 0(loadComponentStock 会缓存 0),一台都凑不出来 + const ready = _private.buildComponentReadiness(components, new Map([['A', 500]]), new Map()); + + assert.equal(ready.assemblableQty, 0); + assert.equal(ready.shortage, true); + assert.equal(ready.bottlenecks[0].code, 'B'); + assert.equal(ready.bottlenecks[0].stock, 0); + assert.equal(ready.bottlenecks[0].available, 0); +}); + +test('component readiness: pending work order occupation is deducted from stock', () => { + const components = [{ barcode: 'A', name: '配件A', qty: 2 }]; + // 库存 10、单台用量 2 → 5 台;扣掉加工单占用 6 件 → floor((10 − 6) / 2) = 2 + const ready = _private.buildComponentReadiness( + components, + new Map([['A', 10]]), + new Map([['A', 6]]) + ); + assert.equal(ready.assemblableQty, 2); + assert.equal(ready.bottlenecks[0].occupied, 6); + assert.equal(ready.bottlenecks[0].available, 4); + + // 占用超过库存 → 可支配数归 0,不能算成负数 + const exhausted = _private.buildComponentReadiness( + components, + new Map([['A', 10]]), + new Map([['A', 20]]) + ); + assert.equal(exhausted.assemblableQty, 0); + assert.equal(exhausted.shortage, true); + assert.equal(exhausted.bottlenecks[0].available, 0); +}); + +test('component readiness: missing BOM detail means unknown, not 配件不足', () => { + // 没有配件明细 → 判不了(组件缺失 ≠ 配件不足),返回 null 让调用方跳过标记 + assert.equal(_private.buildComponentReadiness([], new Map(), new Map()), null); + assert.equal(_private.buildComponentReadiness(null, new Map(), new Map()), null); + assert.equal(_private.buildComponentReadiness(undefined, new Map(), new Map()), null); +}); + +test('component occupation: pending work orders expand into component quantities', () => { + const bomBySku = new Map([['MKHA1', [ + { barcode: 'A', qty: 2 }, + { barcode: 'B', qty: 1 }, + ]]]); + const occupation = _private.buildComponentOccupation([ + { skuId: 'MKHA1', pendingWorkOrderQty: 5 }, + // 加工单商品没有 BOM → 不产生占用(不抛错、不写脏数据) + { skuId: 'MKH-NO-BOM', pendingWorkOrderQty: 9 }, + // 在途 0 或非数字 → 忽略 + { skuId: 'MKHA1', pendingWorkOrderQty: 0 }, + { skuId: 'MKHA1', pendingWorkOrderQty: null }, + ], bomBySku); + + assert.equal(occupation.get('A'), 10); + assert.equal(occupation.get('B'), 5); + assert.equal(occupation.size, 2); +}); + +test('component shortage keeps the business-confirmed source and scope', () => { + // 配件库存只认半成品包装仓、只判现有库存(不含采购在途) + assert.equal(_private.COMPONENT_STOCK_WMS_CO_ID, 15718196); + assert.equal(_private.COMPONENT_STOCK_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓'); + // 每个编码占 1 个参数,MSSQL 单语句上限 2100 + assert.equal(_private.COMPONENT_STOCK_CHUNK_SIZE <= 2000, true); + assert.equal(typeof _private.loadComponentStock, 'function'); + assert.equal(typeof _private.loadMkhComponentReadiness, 'function'); +}); + +test('export: 配件不足 rows are flagged and carry no replenish target', () => { + const rows = _private.buildProductExportRows([ + { + row: { + skuId: 'MKHDNZ0409260600843', + productName: 'K字单板基础', + warningLevel: '缺货', + dailySale: 0, + stock: 0, + pendingOrderQty: 1, + replenishQty: 1, + componentShortage: true, + assemblableQty: 0, + }, + components: [{ barcode: 'DNZ040958N1440V01', name: '白架通用前拉杆', qty: 1 }], + }, + ]); + + assert.equal(rows[0].componentShortage, '配件不足'); + // 可组装数一并导出,便于业务核对短板 + assert.equal(rows[0].assemblableQty, 0); +}); diff --git a/backend/test/mkhReplenishAlertJob.test.js b/backend/test/mkhReplenishAlertJob.test.js index 14faff6..8e4bf57 100644 --- a/backend/test/mkhReplenishAlertJob.test.js +++ b/backend/test/mkhReplenishAlertJob.test.js @@ -137,3 +137,58 @@ test('notification flags degraded work-order source', () => { }); assert.ok(payload.content.includes('加工单数据源暂不可用')); }); + +// ── 配件不足(见 mkhBomService 文件头 ⑪)────────────────────────── +test('notification spells out 配件不足 and drops the replenish target', () => { + const shortage = alert('MKHDNZ0409260600843', '缺货', { + dailySale: 0, + stock: 0, + replenishQty: 1, + componentShortage: true, + assemblableQty: 0, + bottleneckComponents: [ + { code: 'DNZ040958N1440V01', name: '白架通用前拉杆', perQty: 1, stock: 0, occupied: 0, available: 0, can: 0 }, + { code: 'DNZ041106B01V01', name: '通用小K', perQty: 1, stock: 23, occupied: 0, available: 23, can: 23 }, + ], + }); + + const payload = buildMkhReplenishNotification({ + alerts: [shortage], + allAlerts: [shortage], + snapshot: { + outOfStockCount: 1, + warningOnlyCount: 0, + replenishQtyTotal: 1, + componentShortageCount: 1, + }, + recipientAlias: '苏适', + dateKey: '20260917', + }); + + assert.ok(payload.content.includes('配件不足,无法补货')); + // 补不出来的商品不再给补货数字,避免业务照着一个补不到的目标去采购 + assert.ok(payload.content.includes('建议补货 —')); + assert.equal(payload.content.includes('建议补货 1'), false); + // 只列真正卡住的短板(可支配数 ≤ 0),库存充足的不列 + assert.ok(payload.content.includes('DNZ040958N1440V01')); + assert.equal(payload.content.includes('DNZ041106B01V01'), false); + // 汇总段说明本批里有几个配件不足 + assert.ok(payload.content.includes('因**配件库存不足**')); +}); + +test('notification flags degraded component stock source', () => { + const payload = buildMkhReplenishNotification({ + alerts: [alert('A')], + allAlerts: [alert('A')], + snapshot: { + outOfStockCount: 1, + warningOnlyCount: 0, + replenishQtyTotal: 3, + componentStockDegraded: true, + }, + recipientAlias: '苏适', + dateKey: '20260917', + }); + assert.ok(payload.content.includes('配件库存数据源暂不可用')); + assert.ok(payload.content.includes('未做')); +}); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index cbb70dc..ac9454e 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -41,6 +41,23 @@ const WARNING_TONE = { 无销量: 'muted', }; +// 配件不足说明(口径见后端 mkhBomService 文件头 ⑪): +// 可组装数 = min(⌊(配件库存 − 未完成加工单占用) ÷ 单台用量⌋),为 0 即凑不出一台、补不出来 +const shortageTooltip = (row) => { + const bottlenecks = Array.isArray(row.bottleneckComponents) ? row.bottleneckComponents : []; + const details = bottlenecks + .filter((item) => Number(item.available) <= 0) + .map((item) => `短板 ${item.code}${item.name ? `(${item.name})` : ''}` + + `:库存 ${number(item.stock)}` + + `${Number(item.occupied) > 0 ? `,未完成加工单占用 ${number(item.occupied)}` : ''}` + + `,单台用量 ${number(item.perQty)}`); + return [ + `配件不足,无法补货:现有配件最多只能组装 ${number(row.assemblableQty)} 台`, + ...details, + '口径:可组装数 = 各配件 ⌊(库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小;为 0 即补不出来', + ].filter(Boolean).join(';'); +}; + // 展示范围切换 const SCOPE_OPTIONS = [ { label: '仅预警', value: 'warning' }, @@ -57,20 +74,27 @@ const PRODUCT_EXPORT_COLUMNS = [ { title: '近7日日销', dataIndex: 'dailySale' }, { title: '现有库存', dataIndex: 'stock' }, { title: '在途加工量', dataIndex: 'pendingWorkOrderQty' }, + { title: '待发货订单', dataIndex: 'pendingOrderQty' }, { title: '可用天数', dataIndex: 'stockDays', - // 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨 - exportValue: (value) => (value === null || value === undefined ? '无销量' : value), + // 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨; + // 订单驱动(日销=0 但有未发货订单)同样是 null,口径不同要分开说明 + exportValue: (value, row) => { + if (value !== null && value !== undefined) return value; + return row.orderDriven ? '无日销·按订单' : '无销量'; + }, }, { title: '建议补货量', dataIndex: 'replenishQty', - // 与页面一致:非预警商品不显示数字,避免被当成「需要补 0 件」 - exportValue: (value, row) => ( - (row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : '' - ), + // 与页面一致:非预警商品不显示数字;配件不足(可组装数 = 0)时显示「—」不给补货目标 + exportValue: (value, row) => { + if (row.componentShortage) return '—'; + return (row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : ''; + }, }, + { title: '配件不足', dataIndex: 'componentShortage' }, { title: 'BOM 状态', dataIndex: 'bomConfigured' }, { title: '配件种类', dataIndex: 'accessoryCount' }, { title: '配件总数量', dataIndex: 'accessoryQtySum' }, @@ -182,7 +206,7 @@ const PrintPreview = ({ data, onClose }) => {
MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充; - 配件仓位优先取包装箱与具体仓位,尚未分箱时显示库存仓库或商品档案仓库。 + 配件仓位只取聚水潭「箱及仓位库存」,未维护货位时显示箱号,无装箱记录显示「未维护」。
@@ -473,10 +497,18 @@ const MkhBomData = () => { { title: '预警', dataIndex: 'warningLevel', - width: 84, + width: 140, align: 'center', - render: (value) => ( - {text(value)} + render: (value, row) => ( + + {text(value)} + {/* 配件不足(后端文件头 ⑪):可组装数 = 0,补不出来 —— 只加标记,不改变预警级别 */} + {row.componentShortage ? ( + + 配件不足 + + ) : null} + ), }, { @@ -519,24 +551,58 @@ const MkhBomData = () => { ) : —), }, + { + title: '待发货订单', + dataIndex: 'pendingOrderQty', + width: 116, + align: 'right', + render: (value, row) => (Number(value) > 0 ? ( + + {number(value)} + + ) : —), + }, { title: '可用天数', dataIndex: 'stockDays', width: 106, align: 'right', sorter: true, - render: (value, row) => (value === null || value === undefined - ? 无销量 - : {number(value, 2)}), + render: (value, row) => { + if (value === null || value === undefined) { + // 订单驱动(近7日无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同 + return row.orderDriven + ? 按订单 + : 无销量; + } + return {number(value, 2)}; + }, }, { title: '建议补货量', dataIndex: 'replenishQty', - width: 116, + width: 130, align: 'right', - render: (value, row) => (row.hasWarning && Number(value) > 0 - ? {number(value)} - : —), + render: (value, row) => { + // 配件不足(后端文件头 ⑪):凑不出一台,不给补货目标,避免业务照着补不到的量去采购 + if (row.componentShortage) { + return ( + + — + + ); + } + return row.hasWarning && Number(value) > 0 + ? {number(value)} + : —; + }, }, { title: 'BOM 状态', @@ -656,15 +722,24 @@ const MkhBomData = () => { 数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条); 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充。 - 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位优先显示箱号或具体仓位, - 尚未分箱时显示实际库存仓库,没有库存记录时显示商品档案仓库。 + 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取聚水潭「箱及仓位库存」, + 有维护货位则显示货位、未维护货位则显示箱号,该表没有记录即显示「未维护」。
补货预警:近7日日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近7日日销,低于 3 天即预警,缺货单独标识; - 建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无近7日日销的商品不参与预警。 + 建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。 +
+ 待发货订单:近7日暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 + (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; + 预警级别按「现有库存 < 待发货件数 → 缺货,否则预警」,建议补货量 = 待发货件数 − 现有库存, + 可用天数因无日销基数而显示「按订单」。
在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算 (作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。 +
+ 配件不足:缺货 / 预警的成品若配件「可组装数 = 0」则无法补货,标记「配件不足」、建议补货量显示「—」。 + 可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ BOM 单台用量⌋ 取最小值(木桶短板决定), + 配件库存取「{data.componentStockWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」的现有库存,不含采购在途。
@@ -713,18 +788,45 @@ const MkhBomData = () => { /> ) : null} + {!loading && !error && data.pendingOrderDegraded ? ( + 刷新数据} + /> + ) : null} + + {!loading && !error && data.componentStockDegraded ? ( + 刷新数据} + /> + ) : null} + {!loading && (data.warningCount || 0) > 0 ? ( 按近 {data.saleWindowDays || 7} 日日销核算(监控日 {data.monitorDate || '—'}): 其中 {data.outOfStockCount || 0} 个已缺货, {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 建议补货量 = {data.replenishDaysThreshold || 3} × 近7日日销 − 现有库存。点「只看预警」即为补货清单。 + {(data.orderDrivenCount || 0) > 0 ? ( + <> +
+ 其中 {data.orderDrivenCount} 个近 7 日暂无日销,因聚水潭存在未发货订单 + (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件)而列入预警,建议补货量按待发货件数核算。 + + ) : null} {(data.scheduledCount || 0) > 0 ? ( <>
@@ -732,6 +834,14 @@ const MkhBomData = () => { (在途合计 {number(data.pendingWorkOrderQtyTotal || 0)} 件),已排除在预警之外。 ) : null} + {(data.componentShortageCount || 0) > 0 ? ( + <> +
+ 其中 {data.componentShortageCount} 个商品配件不足 + :现有配件最多只能组装 0 台(已扣除未完成加工单占用的配件),补不出来, + 建议补货量显示「—」,需先采购配件。 + + ) : null} )} action={( @@ -799,7 +909,7 @@ const MkhBomData = () => { pageSizeOptions: [50, 100, 200], showTotal: (total) => `共 ${total} 条`, }} - scroll={{ x: 1696, y: bodyHeight }} + scroll={{ x: 1766, y: bodyHeight }} /> diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css index b591377..790d760 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -123,6 +123,19 @@ font-weight: 600; } +/* 待发货订单(聚水潭未发货订单件数,见文件头 ⑩) */ +.mkh-order { + color: #ad4e00; + font-weight: 600; +} + +/* 配件不足(可组装数 = 0,补不出来,见文件头 ⑪):与「缺货」红标签并排,用紫灰区分 */ +.mkh-tag--shortage { + color: #722ed1; + background: rgba(114, 46, 209, 0.14); + border-color: rgba(114, 46, 209, 0.35); +} + /* ============ A4 打印预览 ============ */ .mkh-print-mask { position: fixed; -- 2.47.2 From 3e02c3e393017af0b9812afcbaf46114480bb285 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 14:06:50 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=E6=97=A5=E9=94=80=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E7=94=B1=E8=BF=91=207=20=E5=A4=A9=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E8=BF=91=2015=20=E5=A4=A9=EF=BC=88MKH=20=E8=A1=A5=E8=B4=A7?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=20+=20=E6=A8=A1=E5=9D=97=E5=8C=96=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务要求「近7日日销」改「近30天日销」。实测业务库与产品资料库均无 30 天日销, 已实现的最长周期是 pm_stockMonitor.fifteenAvgSale(15 天),经与业务确认改用 15 天。 为什么没有 30 天(2026-09-17 实测,避免重复排查): - rpt.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 没有销量 改动: - backend/src/services/mkhBomService.js · SALE_WINDOW_DAYS 7 → 15,新增 DAILY_SALE_COLUMN='fifteenAvgSale', 监控快照 SQL 按该常量插值取数(以后改口径只改常量) · buildReplenishMetrics 入参 sevenAvgSale → avgSale(语义与周期解耦) · 文件头 ⑦ 补「为什么是 15 天」的完整排查结论;⑦ / ⑩ / ⑪ 实测数字按新口径更新 - backend/src/services/modularStockMonitorService.js · 取数列与 API 字段名 sevenAvgSale/avgShip7d → fifteenAvgSale/avgShip15d (字段名同步改,避免名不副实) - frontend/src/pages/MkhBomData/index.jsx · 列头与口径说明「近7日」→「近15天」;动态文案兜底 7 → 15 - frontend/src/pages/ModularStockMonitor/index.jsx · 列头「近7日日均发货量」→「近15天日均发货量」,dataIndex 同步改 · 口径说明补充:可用天数仍沿用 jsUseDays(源系统口径),与日销并非同一时间窗 影响(同一份 09-16 快照,7 天 vs 15 天): 有销商品 114 → 170 周期拉长,更多商品进得了「有销」集合 补货预警 25 → 19 反而变少:15 天日销普遍低于 7 天(近期销量走低), 可用天数 = 库存 ÷ 日销 随之变大,跌破 3 天的变少 缺货 9 → 10 已排产 11 → 13 订单驱动 14 → 8 更多商品自己有日销,不再需要靠订单兜底 配件不足 10 → 12 「预警变少」是口径变化的正常结果,不是 bug。 验证: - 后端语法检查 151 个文件通过 - mkhBomService + mkhReplenishAlertJob 单测 52/52 通过 - 后端全量 334 tests / 333 pass(唯一失败是 resilientSecretStore 的 Windows 文件权限断言,与本改动无关的环境固有问题) - 接口实测:total 4223 / warningCount 19 / outOfStockCount 10 / saleWindowDays 15 / componentShortageCount 12 --- backend/src/services/mkhBomService.js | 74 +++++++++++++------ .../services/modularStockMonitorService.js | 12 +-- backend/test/mkhBomService.test.js | 40 +++++----- backend/test/mkhReplenishAlertJob.test.js | 2 +- frontend/src/pages/MkhBomData/index.jsx | 28 +++---- .../src/pages/ModularStockMonitor/index.jsx | 10 ++- 6 files changed, 99 insertions(+), 67 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 14bd9a4..c6fb7b2 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -64,19 +64,40 @@ // ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。 // // ── ⑦ 补货预警(2026-09-17 业务确认口径)────────────────────────── -// 规则:近 7 日日销 > 0 且 现有库存 < 3 天日销 → 预警补货 -// · 近7日日销 rpt.pm_stockMonitor.sevenAvgSale +// 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货 +// · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale // (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照) // · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存; // 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①), // 缺失时回退监控快照 jsStock -// · 可用天数 = 现有库存 / 近7日日销 -// · 建议补货量 = ceil(3 × 近7日日销 − 现有库存),不足 0 取 0 -// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / sevenAvgSale 不同源 +// · 可用天数 = 现有库存 / 近15天日销 +// · 建议补货量 = ceil(3 × 近15天日销 − 现有库存),不足 0 取 0 +// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / fifteenAvgSale 不同源 // (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67, -// 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近7日日销」为准。 -// 实测(2026-09-16 快照,当时成品口径尚未收紧为「正式 BOM 父项」): -// 4408 个 MKH 成品中 114 个有日销、13 个可用天数 < 3 天。 +// 除法 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 商品存在未完成的加工单,就不再列入补货预警** @@ -107,7 +128,7 @@ // 响应会膨胀到几十 MB,而商品清单本身用不到明细。 // // ── ⑩ 待发货订单(2026-09-17 业务确认口径)──────────────────────── -// 规则:**近 7 日日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。 +// 规则:**近 15 天日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。 // 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」 // 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。 // · 数据源:业务库 rpt.jst_Orders(订单**主表**,实时更新,实测最新到今天) @@ -128,8 +149,10 @@ // 缺货时记 stockDays = 0(保证排在最前),预警(库存已够发)时记 null。 // ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)—— // 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。 -// 实测(2026-09-17):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件, +// 实测(2026-09-17,7 天口径下):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件, // 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。 +// ⚠️ 改 15 天口径后实测:订单驱动从 14 个降到 **8 个** +// (日销基数 114 → 170,原本靠订单兜底的商品自己有日销了),与预期一致。 // // ── ⑪ 配件不足 / 可组装数(2026-09-17 业务确认口径)──────────────── // 规则:**缺货 / 预警的 MKH 成品,若其配件现有库存「可组装数 = 0」,则提示 @@ -152,8 +175,8 @@ // · 判定范围:**缺货 + 预警都判**(业务确认),不改变原有预警级别与排序, // 只额外打标记;「已排产」商品本来就不预警,不参与判定。 // · 无 BOM 配件明细的商品 → 可组装数未知(组件缺失 ≠ 配件不足),不做判定。 -// 实测(2026-09-17,并入最新 main 后复核):预警 25 个(缺货 9 + 预警 16)中 **10 个可组装数 = 0** -// (缺货 6 + 预警 4);扣加工单占用前后仅 1 个商品变化(185 → 180 台), +// 实测(2026-09-17,15 天口径):预警 19 个(缺货 10 + 预警 9)中 **12 个可组装数 = 0** +// (缺货 7 + 预警 5);扣加工单占用前后仅 1 个商品变化(185 → 180 台), // 说明占用规模小但口径上必须扣。 // ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标), // 接口透出 componentStockDegraded=true。 @@ -192,7 +215,13 @@ const MKH_WHERE = `s.sku_id LIKE N'MKH%' // ── 补货预警参数(见文件头 ⑦)──────────────────────────────────── // 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得) const BRAND_WMS_CO_ID = 15870495; -const SALE_WINDOW_DAYS = 7; +// 日销统计窗口(天)。见文件头 ⑦:业务最初要 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; @@ -299,8 +328,8 @@ const SCOPES = Object.freeze({ WARNING: 'warning', SOLD: 'sold', ALL: 'all' }); /** * 解析展示范围: - * warning —— 仅需补货(近7日日销 > 0 且 可用天数 < 3 天) - * sold —— 有近7日日销的商品 + * warning —— 仅需补货(近15天日销 > 0 且 可用天数 < 3 天) + * sold —— 有近15天日销的商品 * all —— 全部 MKH 成品 * 兼容旧参数 warningOnly=1(等价 scope=warning)。 */ @@ -776,13 +805,13 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 * * 口径(见文件头 ⑦ + ⑩): - * ① 近7日日销 > 0 且 现有库存 < 3 天日销 → 预警 - * ② 近7日日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩) + * ① 近15天日销 > 0 且 现有库存 < 3 天日销 → 预警 + * ② 近15天日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩) * 两者都不满足 → 无销量,不参与预警。 * 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。 * * @param {object} row - * row.sevenAvgSale 近7日日均发货量 + * row.avgSale 近15天日均发货量(取自 rpt.pm_stockMonitor.fifteenAvgSale) * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) * row.monitorStock 监控快照库存(fallback) * row.safetyDays 源系统安全天数(仅展示) @@ -792,7 +821,7 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( * row.pendingOrderQty 待发货订单量(未发货订单件数) */ function buildReplenishMetrics(row = {}) { - const dailySale = Math.max(0, toNumber(row.sevenAvgSale) ?? 0); + const dailySale = Math.max(0, toNumber(row.avgSale) ?? 0); const realtimeStock = toNumber(row.realtimeStock); const monitorStock = toNumber(row.monitorStock) ?? 0; const hasRealtime = realtimeStock !== null; @@ -828,7 +857,7 @@ function buildReplenishMetrics(row = {}) { monitorDate: toDateText(row.monitorDate), }; - // 既无近7日日销、也无未发货订单 → 不参与预警 + // 既无近15天日销、也无未发货订单 → 不参与预警 if (dailySale <= 0 && !orderDriven) { return { ...base, @@ -1179,7 +1208,7 @@ async function loadReplenishIndex() { const pending = Promise.all([ // 监控快照:同一天只有一行,按 date DESC 取首条即可 cloudDatabase.query( - `SELECT m.sku_id AS skuId, m.sevenAvgSale AS sevenAvgSale, m.jsStock AS monitorStock, + `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%' @@ -1218,7 +1247,8 @@ async function loadReplenishIndex() { seenMonitor.add(key); if (!monitorDate) monitorDate = toDateText(row.monitorDate); bySku.set(key, buildReplenishMetrics({ - sevenAvgSale: row.sevenAvgSale, + // 日销取数见文件头 ⑦:十五天口径(业务要 30 天但全库无此数据) + avgSale: row.fifteenAvgSale, // 实时库存缺失(而非为 0)时才回退监控快照 realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, monitorStock: row.monitorStock, diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 1d219cd..ea2dc2b 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -5,7 +5,7 @@ // 需求来源:《模块化库存监控.xlsx》 // 一级功能:模块化库存监控 二级功能:配件库存监控 // 表头:图片 | 预警 | SKU编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 | -// 仓位 | 近7日日均发货量 | 库存可用天数 | 所在仓库 +// 仓位 | 近15天日均发货量 | 库存可用天数 | 所在仓库 // 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的 SKU 编码 // // 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认): @@ -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 建议备货量 @@ -76,7 +76,7 @@ const SORTABLE_FIELDS = Object.freeze({ productName: 's.name', category: 's.category', realtimeStock: 'realtimeStock', - avgShip7d: 'm.sevenAvgSale', + avgShip15d: 'm.fifteenAvgSale', stockDays: 'm.jsUseDays', warningLevel: 'warningOrder', mkhStatus: 'mkhStatus', @@ -108,7 +108,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 @@ -352,7 +352,7 @@ const mapRow = (row = {}) => { outQtyToday: toNumber(row.outQtyToday) ?? 0, realtimeStock, location: toText(row.location), - avgShip7d: toNumber(row.avgShip7d), + avgShip15d: toNumber(row.avgShip15d), stockDays, safetyDays, suggestStock: toNumber(row.suggestStock), @@ -368,7 +368,7 @@ const buildProjection = () => ` 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, + m.fifteenAvgSale AS avgShip15d, m.jsUseDays AS stockDays, m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 5cfcfb8..1dd6578 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -190,17 +190,17 @@ test('pack inventory without bin/bin_group falls back to the box id, never to a assert.equal(locations.has('PART-2'), false); }); -// ── 补货预警:近 7 日日销 > 0 且 现有库存 < 3 天日销 ──────────────── +// ── 补货预警:近 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({ sevenAvgSale: 2.55, realtimeStock: 0, monitorStock: 0 }); + 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({ sevenAvgSale: 2, realtimeStock: 1, monitorStock: 1 }); + const warning = metrics({ avgSale: 2, realtimeStock: 1, monitorStock: 1 }); assert.equal(warning.stockDays, 0.5); assert.equal(warning.warningLevel, '预警'); assert.equal(warning.hasWarning, true); @@ -208,34 +208,34 @@ test('replenish: stock below 3 days of sales is flagged as stockout or warning', }); test('replenish: the threshold is exactly 3 days of sales', () => { - const atThreshold = metrics({ sevenAvgSale: 2, realtimeStock: 6, monitorStock: 6 }); + 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({ sevenAvgSale: 2, realtimeStock: 5.9, monitorStock: 5.9 }); + 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({ sevenAvgSale: 1, realtimeStock: 4, monitorStock: 9 }); + const realtime = metrics({ avgSale: 1, realtimeStock: 4, monitorStock: 9 }); assert.equal(realtime.stock, 4); assert.equal(realtime.stockSource, 'realtime'); // 实时库存「无记录」(null)才回退监控快照 - const fallback = metrics({ sevenAvgSale: 1, realtimeStock: null, monitorStock: 9 }); + const fallback = metrics({ avgSale: 1, realtimeStock: null, monitorStock: 9 }); assert.equal(fallback.stock, 9); assert.equal(fallback.stockSource, 'monitor'); // 实时库存为 0 是有效值,不得被快照覆盖 - const zero = metrics({ sevenAvgSale: 1, realtimeStock: 0, monitorStock: 9 }); + 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({ sevenAvgSale: 0, realtimeStock: 0, monitorStock: 0 }); + const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 }); assert.equal(noSale.hasWarning, false); assert.equal(noSale.warningLevel, '无销量'); assert.equal(noSale.stockDays, null); @@ -286,7 +286,7 @@ test('replenish sort puts the most urgent first and no-sale products last', () = test('replenish: a pending work order suppresses the replenish warning', () => { // 库存为 0、本该判「缺货」,但半成品包装项目仓已有未完成加工单 const scheduled = metrics({ - sevenAvgSale: 2, + avgSale: 2, realtimeStock: 0, monitorStock: 0, pendingWorkOrderQty: 20, @@ -307,13 +307,13 @@ test('replenish: a pending work order suppresses the replenish warning', () => { assert.equal(scheduled.stockDays, 0); // 同样库存但无加工单时仍然预警 —— 确认抑制效果确实来自加工单 - const bare = metrics({ sevenAvgSale: 2, realtimeStock: 0, monitorStock: 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({ sevenAvgSale: 1, realtimeStock: 0 }); + const none = metrics({ avgSale: 1, realtimeStock: 0 }); assert.equal(none.hasPendingWorkOrder, false); assert.equal(none.pendingWorkOrderQty, 0); assert.equal(none.pendingWorkOrderCount, 0); @@ -322,7 +322,7 @@ test('replenish: pending work order fields default to empty without any order', // 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理 const zeroQty = metrics({ - sevenAvgSale: 1, + avgSale: 1, realtimeStock: 0, pendingWorkOrderQty: 0, pendingWorkOrderCount: 2, @@ -334,7 +334,7 @@ test('replenish: pending work order fields default to empty without any order', test('replenish: products without sales stay "no sale" even with a pending work order', () => { const noSale = metrics({ - sevenAvgSale: 0, + avgSale: 0, realtimeStock: 0, pendingWorkOrderQty: 5, pendingWorkOrderCount: 1, @@ -394,7 +394,7 @@ test('pending order: only MKH finished products count, combination codes are exc test('replenish: no sales but unshipped orders still trigger the warning', () => { // 库存不够发货 → 缺货,补货量 = 待发货件数 − 现有库存 const notEnough = metrics({ - sevenAvgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2, + avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2, }); assert.equal(notEnough.hasWarning, true); assert.equal(notEnough.warningLevel, '缺货'); @@ -407,7 +407,7 @@ test('replenish: no sales but unshipped orders still trigger the warning', () => // 库存刚好够发 → 预警(不再缺货),补货量为 0 const exactlyEnough = metrics({ - sevenAvgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4, + avgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4, }); assert.equal(exactlyEnough.warningLevel, '预警'); assert.equal(exactlyEnough.hasWarning, true); @@ -418,7 +418,7 @@ test('replenish: no sales but unshipped orders still trigger the warning', () => test('replenish: order-driven warning does not override the pending work order exemption', () => { const scheduled = metrics({ - sevenAvgSale: 0, + avgSale: 0, realtimeStock: 0, pendingOrderQty: 5, pendingOrderCount: 1, @@ -434,19 +434,19 @@ test('replenish: order-driven warning does not override the pending work order e }); test('replenish: pending order fields default to empty without any unshipped order', () => { - const none = metrics({ sevenAvgSale: 1, realtimeStock: 0 }); + 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({ sevenAvgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); + const noSale = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); assert.equal(noSale.warningLevel, '无销量'); assert.equal(noSale.orderDriven, false); // 有日销的商品照旧走原口径,但待发货量也要带上(页面新增列) - const withSale = metrics({ sevenAvgSale: 2, realtimeStock: 0, pendingOrderQty: 3 }); + const withSale = metrics({ avgSale: 2, realtimeStock: 0, pendingOrderQty: 3 }); assert.equal(withSale.warningLevel, '缺货'); assert.equal(withSale.orderDriven, false); assert.equal(withSale.pendingOrderQty, 3); diff --git a/backend/test/mkhReplenishAlertJob.test.js b/backend/test/mkhReplenishAlertJob.test.js index 8e4bf57..7e90c92 100644 --- a/backend/test/mkhReplenishAlertJob.test.js +++ b/backend/test/mkhReplenishAlertJob.test.js @@ -83,7 +83,7 @@ test('notification carries recipient, idempotency key and shortfall details', () const all = [...fresh, alert('MKHDNZ0409260700486', '预警', { dailySale: 2, stock: 1, replenishQty: 5 })]; const snapshot = { monitorDate: '2026-09-16', - saleWindowDays: 7, + saleWindowDays: 15, replenishDaysThreshold: 3, outOfStockCount: 1, warningOnlyCount: 1, diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 47aa9c4..1c6b546 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -71,7 +71,7 @@ const PRODUCT_EXPORT_COLUMNS = [ { title: 'MKH商品编码', dataIndex: 'skuId' }, { title: '商品名称', dataIndex: 'productName' }, { title: '预警', dataIndex: 'warningLevel' }, - { title: '近7日日销', dataIndex: 'dailySale' }, + { title: '近15天日销', dataIndex: 'dailySale' }, { title: '现有库存', dataIndex: 'stock' }, { title: '在途加工量', dataIndex: 'pendingWorkOrderQty' }, { title: '待发货订单', dataIndex: 'pendingOrderQty' }, @@ -512,7 +512,7 @@ const MkhBomData = () => { ), }, { - title: '近7日日销', + title: '近15天日销', dataIndex: 'dailySale', width: 106, align: 'right', @@ -562,7 +562,7 @@ const MkhBomData = () => { `聚水潭未发货订单 ${row.pendingOrderCount || 0} 单,合计 ${number(value)} 件`, row.pendingOrderEarliest ? `最早一单下单于 ${row.pendingOrderEarliest}` : '', '已取消 / 已合并单据不核算', - row.orderDriven ? '该商品近 7 日暂无日销,按待发货订单量列入补货预警' : '', + row.orderDriven ? '该商品近 15 天暂无日销,按待发货订单量列入补货预警' : '', ].filter(Boolean).join(';')} > {number(value)} @@ -577,9 +577,9 @@ const MkhBomData = () => { sorter: true, render: (value, row) => { if (value === null || value === undefined) { - // 订单驱动(近7日无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同 + // 订单驱动(近15天无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同 return row.orderDriven - ? 按订单 + ? 按订单 : 无销量; } return {number(value, 2)}; @@ -717,7 +717,7 @@ const MkhBomData = () => {
MKH-BOM 资料 - 模块化(MKH 开头)成品的配件明细与补货预警:近 7 日日销核算,库存低于 3 天日销即预警 + 模块化(MKH 开头)成品的配件明细与补货预警:近 15 天日销核算,库存低于 3 天日销即预警 数据口径:MKH 商品编码取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”,并且必须是聚水潭正式 BOM 的父项; @@ -725,11 +725,11 @@ const MkhBomData = () => { 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo, 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
- 补货预警:近7日日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 - 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近7日日销,低于 3 天即预警,缺货单独标识; - 建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。 + 补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 + 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近15天日销,低于 3 天即预警,缺货单独标识; + 建议补货量 = 3 × 近15天日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。
- 待发货订单:近7日暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 + 待发货订单:近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; 预警级别按「现有库存 < 待发货件数 → 缺货,否则预警」,建议补货量 = 待发货件数 − 现有库存, 可用天数因无日销基数而显示「按订单」。 @@ -793,7 +793,7 @@ const MkhBomData = () => { className="mkh-alert" type="warning" showIcon - message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近7日无日销但有订单」的商品,请稍后点击「刷新数据」重试。" + message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近15天无日销但有订单」的商品,请稍后点击「刷新数据」重试。" action={} /> ) : null} @@ -816,14 +816,14 @@ const MkhBomData = () => { message={`补货预警:${data.warningCount} 个 MKH 商品需要补货`} description={( - 按近 {data.saleWindowDays || 7} 日日销核算(监控日 {data.monitorDate || '—'}): + 按近 {data.saleWindowDays || 15} 天日销核算(监控日 {data.monitorDate || '—'}): 其中 {data.outOfStockCount || 0} 个已缺货, {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 - 建议补货量 = {data.replenishDaysThreshold || 3} × 近7日日销 − 现有库存。点「只看预警」即为补货清单。 + 建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 − 现有库存。点「只看预警」即为补货清单。 {(data.orderDrivenCount || 0) > 0 ? ( <>
- 其中 {data.orderDrivenCount} 个近 7 日暂无日销,因聚水潭存在未发货订单 + 其中 {data.orderDrivenCount} 个近 15 天暂无日销,因聚水潭存在未发货订单 (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件)而列入预警,建议补货量按待发货件数核算。 ) : null} diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index f9d6cc6..d121390 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -233,8 +233,8 @@ const AccessoryStockPanel = () => { ), }, { - title: '近7日日均发货量', - dataIndex: 'avgShip7d', + title: '近15天日均发货量', + dataIndex: 'avgShip15d', width: 138, align: 'right', sorter: true, @@ -402,13 +402,15 @@ 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 全覆盖); 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」; 图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。 + 注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天); + 库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。 状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。
-- 2.47.2 From 8ba052649daf09cc4ce4ec8dcfe4e82d08d5694a Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 15:04:50 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=E8=A1=A5=E8=B4=A7=E9=A2=84?= =?UTF-8?q?=E8=AD=A6-=E6=97=A5=E9=94=80=E4=B8=BA0=E6=9C=89=E4=BB=A3?= =?UTF-8?q?=E5=8F=91=E8=AE=A2=E5=8D=95=E6=97=B6=E6=8C=89=E5=BA=93=E5=AD=98?= =?UTF-8?q?=C3=B7=E4=BB=A3=E5=8F=91=E4=BB=B6=E6=95=B0=E7=AE=97=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E5=A4=A9=E6=95=B0=E9=A2=84=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⑩订单驱动分支改为:可用天数 = 现有库存 ÷ 代发(待发货)订单件数 · 可用天数 = 0(无库存)→ 缺货 · 0 < 可用天数 < 3 天 → 预警(建议补到 3 天缓冲:3×代发件数−现有库存) · 可用天数 ≥ 3 天 → 库存充足,不预警(阈值语义与日销分支一致) - buildReplenishMetrics 订单驱动分支不再把 stockDays 记 null,统一如实记录 - 同步前端「可用天数」列/导出/数据口径说明文案(不再显示「按订单」) - 更新 mkhBomService.test.js 对应用例,单测 40/40 通过 --- backend/src/services/mkhBomService.js | 56 +++++++++++++++++----- backend/test/mkhBomService.test.js | 64 +++++++++++++++++-------- frontend/src/pages/MkhBomData/index.jsx | 33 ++++++++----- 3 files changed, 108 insertions(+), 45 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index c6fb7b2..ed4a607 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -143,16 +143,25 @@ // (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单; // 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求) // · 待发货订单量 = Σ 该成品在未发货订单中的件数(同一单多次出现会累加) -// · 预警级别(业务确认):现有库存 < 待发货订单量 → 缺货;否则 → 预警 -// · 建议补货量 = max(0, 待发货订单量 − 现有库存) -// ⚠️ 该分支无法算「可用天数」(日销为 0,除数为零): -// 缺货时记 stockDays = 0(保证排在最前),预警(库存已够发)时记 null。 +// · 预警判定(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」,则提示 @@ -806,7 +815,8 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( * * 口径(见文件头 ⑦ + ⑩): * ① 近15天日销 > 0 且 现有库存 < 3 天日销 → 预警 - * ② 近15天日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩) + * ② 近15天日销 = 0 且有代发(待发货)订单 → 订单驱动预警(见文件头 ⑩): + * 可用天数 = 现有库存 ÷ 代发订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警 * 两者都不满足 → 无销量,不参与预警。 * 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。 * @@ -882,17 +892,37 @@ function buildReplenishMetrics(row = {}) { }; } - // 订单驱动(见文件头 ⑩):日销为 0 算不出可用天数,改按「能否覆盖待发货」判级 + // 订单驱动(见文件头 ⑩):日销为 0 但有代发(待发货)订单、有库存 + // → 用「现有库存 ÷ 代发订单件数」估算可用天数,≤ 3 天即补货预警 + // ⚠️ 阈值语义与日销分支一致:可用天数 < 3 天为「预警」、= 0(无库存)为「缺货」、 + // ≥ 3 天视为库存充足不预警;代发订单件数恒 > 0(orderDriven 已保证 pendingOrderQty > 0) if (orderDriven) { - const notEnough = stock < pendingOrderQty; + 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, - // 缺货记 0(保证排在最前);库存已够发货时记 null(无日销,算不出天数) - stockDays: notEnough ? 0 : null, - replenishQty: Math.max(0, pendingOrderQty - stock), - warningLevel: notEnough ? WARNING_LEVEL.OUT_OF_STOCK : WARNING_LEVEL.WARNING, - warningOrder: notEnough ? 1 : 2, - hasWarning: true, + // 可用天数统一记录(保留 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, }; } diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 1dd6578..21e6d1e 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -391,29 +391,55 @@ test('pending order: only MKH finished products count, combination codes are exc assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']); }); -test('replenish: no sales but unshipped orders still trigger the warning', () => { - // 库存不够发货 → 缺货,补货量 = 待发货件数 − 现有库存 - const notEnough = metrics({ - avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2, +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(notEnough.hasWarning, true); - assert.equal(notEnough.warningLevel, '缺货'); - assert.equal(notEnough.replenishQty, 3); - assert.equal(notEnough.orderDriven, true); - // 无日销基数 → 缺货时记 0,保证排在预警清单最前 - assert.equal(notEnough.stockDays, 0); - assert.equal(notEnough.pendingOrderQty, 4); - assert.equal(notEnough.pendingOrderCount, 2); + 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 - const exactlyEnough = metrics({ + // 有货但只够 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(exactlyEnough.warningLevel, '预警'); - assert.equal(exactlyEnough.hasWarning, true); - assert.equal(exactlyEnough.replenishQty, 0); - // 库存已够发时算不出可用天数,记 null(页面显示「按订单」) - assert.equal(exactlyEnough.stockDays, null); + 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', () => { diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 1c6b546..0fd2e30 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -78,8 +78,8 @@ const PRODUCT_EXPORT_COLUMNS = [ { title: '可用天数', dataIndex: 'stockDays', - // 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨; - // 订单驱动(日销=0 但有未发货订单)同样是 null,口径不同要分开说明 + // 无销量且无代发订单的商品 stockDays 为 null,导出成文字而非空白; + // 订单驱动分支(日销=0 但有代发/待发货订单)现已如实给出可用天数(库存÷代发件数),不会进这里 exportValue: (value, row) => { if (value !== null && value !== undefined) return value; return row.orderDriven ? '无日销·按订单' : '无销量'; @@ -562,7 +562,7 @@ const MkhBomData = () => { `聚水潭未发货订单 ${row.pendingOrderCount || 0} 单,合计 ${number(value)} 件`, row.pendingOrderEarliest ? `最早一单下单于 ${row.pendingOrderEarliest}` : '', '已取消 / 已合并单据不核算', - row.orderDriven ? '该商品近 15 天暂无日销,按待发货订单量列入补货预警' : '', + row.orderDriven ? '该商品近 15 天暂无日销,按代发(待发货)订单量列入补货预警,可用天数 = 库存 ÷ 代发件数' : '', ].filter(Boolean).join(';')} > {number(value)} @@ -577,12 +577,19 @@ const MkhBomData = () => { sorter: true, render: (value, row) => { if (value === null || value === undefined) { - // 订单驱动(近15天无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同 - return row.orderDriven - ? 按订单 - : 无销量; + // 真正算不出天数:无日销且无代发订单 → 无销量;订单驱动分支现已如实给出天数,不会进这里 + return 无销量; } - return {number(value, 2)}; + const text = {number(value, 2)}; + if (row.orderDriven) { + // 近 15 天无日销,可用天数按「库存 ÷ 代发订单件数」估算,≤ 3 天即预警 + return ( + + {text} + + ); + } + return text; }, }, { @@ -729,10 +736,10 @@ const MkhBomData = () => { 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近15天日销,低于 3 天即预警,缺货单独标识; 建议补货量 = 3 × 近15天日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。
- 待发货订单:近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 + 待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; - 预警级别按「现有库存 < 待发货件数 → 缺货,否则预警」,建议补货量 = 待发货件数 − 现有库存, - 可用天数因无日销基数而显示「按订单」。 + 可用天数 = 现有库存 ÷ 代发(待发货)订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警; + 建议补货量:缺货时按「代发件数 − 现有库存」、预警时按「3 × 代发件数 − 现有库存」补到 3 天缓冲。
在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算 (作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。 @@ -823,8 +830,8 @@ const MkhBomData = () => { {(data.orderDrivenCount || 0) > 0 ? ( <>
- 其中 {data.orderDrivenCount} 个近 15 天暂无日销,因聚水潭存在未发货订单 - (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件)而列入预警,建议补货量按待发货件数核算。 + 其中 {data.orderDrivenCount} 个近 15 天暂无日销,按代发(待发货)订单列入预警 + (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件),可用天数 = 库存 ÷ 代发件数、≤ 3 天即预警。 ) : null} {(data.scheduledCount || 0) > 0 ? ( -- 2.47.2 From 75a87957231f71a780f2417224ade85d3f7a9378 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 15:44:30 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E9=85=8D=E4=BB=B6=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=EF=BC=9A=E8=A1=A8=E5=A4=B4"SKU=E7=BC=96?= =?UTF-8?q?=E7=A0=81"=E6=94=B9=E4=B8=BA"=E5=95=86=E5=93=81=E7=BC=96?= =?UTF-8?q?=E7=A0=81"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frontend/src/pages/ModularStockMonitor/index.jsx - 表格列头 "SKU编码" → "商品编码" - MKH 配置状态 tooltip 文案同步调整 - 搜索框 placeholder "SKU 编码 / 名称" → "商品编码 / 名称" - backend/src/services/modularStockMonitorService.js - 文件头注释同步更新(仅文案,无逻辑改动) 关于"从 BOM 维护匹配已配置 MKH 成品编码": 已探测 rpt.pm_bomBaseInfo 与目标仓 SKU 的编码体系完全脱钩 (sku_id/i_id/name 均 0 命中),若直接切换数据源会导致已配置数 从 661 归零。经用户确认,本次保留现有产品资料库判定口径, 仅做表头文案变更。 --- backend/src/services/modularStockMonitorService.js | 4 ++-- frontend/src/pages/ModularStockMonitor/index.jsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index ea2dc2b..51c6274 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -4,9 +4,9 @@ // // 需求来源:《模块化库存监控.xlsx》 // 一级功能:模块化库存监控 二级功能:配件库存监控 -// 表头:图片 | 预警 | SKU编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 | +// 表头:图片 | 预警 | 商品编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 | // 仓位 | 近15天日均发货量 | 库存可用天数 | 所在仓库 -// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的 SKU 编码 +// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的商品编码 // // 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认): // 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库 diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index d121390..fb8db99 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -157,7 +157,7 @@ const AccessoryStockPanel = () => { { title: , dataIndex: 'mkhStatus', width: 110, @@ -170,7 +170,7 @@ const AccessoryStockPanel = () => { }, }, { - title: 'SKU编码', + title: '商品编码', dataIndex: 'skuId', width: 176, fixed: 'left', @@ -291,7 +291,7 @@ const AccessoryStockPanel = () => { } - placeholder="SKU 编码 / 名称" + placeholder="商品编码 / 名称" value={filters.keyword} onChange={(e) => changeFilter('keyword')(e.target.value)} style={{ width: 220 }} -- 2.47.2 From e6ea1246f98e3f38ff7da422fc3712cefb30891a Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 16:01:20 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=E9=85=8D=E4=BB=B6=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=EF=BC=9A=E5=B7=B2=E9=85=8D=E7=BD=AEMKH?= =?UTF-8?q?=E6=94=B9=E4=B8=BA"=E8=A2=ABMKH=E6=A8=A1=E5=9D=97=E5=8C=96?= =?UTF-8?q?=E6=88=90=E5=93=81=E5=BC=95=E7=94=A8"=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/src/services/modularStockMonitorService.js - 「已配置MKH」判定口径切回 jst_comboProductItem 组合装引用: 该 SKU 出现在 jst_comboProductItem.src_sku_id 且 combine_sku_id 以 'MKH' 开头 即视为被 MKH 模块化成品(组合装/成品档案)引用为配件。 - 移除产品资料系统数据库覆盖清单逻辑(resolveProductCoverage / productCoveredCsv), 产品资料库仅保留作图片来源。 - 实测目标仓 794 SKU 中 29 个命中(原产品资料库口径 661 已废弃)。 - frontend/src/pages/ModularStockMonitor/index.jsx - 状态列 tooltip 文案同步为「是否被 MKH 模块化成品作为配件引用」。 注:本改动会令已配置MKH数量从 661 降至 29(数据使然,非 bug)。 --- .../services/modularStockMonitorService.js | 125 ++++-------------- .../src/pages/ModularStockMonitor/index.jsx | 2 +- 2 files changed, 30 insertions(+), 97 deletions(-) diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 51c6274..4dc5af0 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -50,27 +50,18 @@ 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 业务确认口径): +// 「已配置MKH」= 该 SKU 作为配件被某个 MKH 模块化成品引用 +// (业务库 jst_comboProductItem 中 src_sku_id = 该 SKU +// 且 combine_sku_id 以 'MKH' 开头,即 MKH 组合装 / 成品档案) +// 「未配置MKH」= 未被任何 MKH 模块化成品引用 +// 实测目标仓 794 个 SKU 中 29 个命中(32 个被任意组合装引用,其中 29 个属 MKH 体系)。 +// ⚠️ 之前的「产品资料系统数据库商品档案」口径(661/794)已废弃,改用组合装引用口径。 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', @@ -85,21 +76,19 @@ const SORTABLE_FIELDS = Object.freeze({ }); /** - * 「已配置MKH」判定表达式。 - * available = true —— 用产品资料库覆盖清单(@productCoveredCsv,逗号分隔的 SKU 编码) - * available = false —— 产品资料库不可用时回退到业务库 MKH BOM 判定,保证页面可用 + * 「已配置MKH」判定表达式(2026-09-17 口径): + * 该 SKU 作为配件被某个 MKH 模块化成品引用,即业务库 jst_comboProductItem 中 + * src_sku_id = 该 SKU 且 combine_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 dbo.jst_comboProductItem x + WHERE x.src_sku_id = s.sku_id + AND x.combine_sku_id LIKE N'MKH%' + )` ); -const buildBaseSql = (coverage = {}) => ` +const buildBaseSql = () => ` FROM dbo.jst_sku s OUTER APPLY ( SELECT TOP 1 i.qty @@ -127,9 +116,9 @@ OUTER APPLY ( 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 --- 模块化配置状态:见文件头部「口径变更说明」 +-- 模块化配置状态:见文件头部「已配置MKH」口径说明 OUTER APPLY ( - SELECT CAST(CASE WHEN ${buildConfiguredExistsSql(coverage)} THEN 1 ELSE 0 END AS BIT) AS configured + SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured ) cfg WHERE s.other_3 = @warehouseName`; @@ -180,7 +169,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 +177,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}%`; @@ -284,53 +268,11 @@ async function attachProductImages(rows = []) { } /** - * 解析目标仓 SKU 的「产品资料库覆盖清单」,用于「已配置MKH」判定。 - * 返回 { available, csv, set, total, coveredCount },结果按 TTL 缓存。 - * 产品资料库不可用时 available = false,SQL 侧自动回退到业务库 MKH BOM 判定。 + * 「已配置MKH」判定说明(2026-09-17): + * 直接由 buildBaseSql 的 cfg 子查询判定(jst_comboProductItem.src_sku_id + * 命中且 combine_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); @@ -390,12 +332,9 @@ 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'; @@ -474,22 +413,16 @@ async function buildSummary(whereSql, params) { } async function getModularStockFilters() { - const [monitorDate, coverage] = await Promise.all([ - resolveMonitorDate(), - resolveProductCoverage(), - ]); + const monitorDate = await resolveMonitorDate(); const params = { wmsCoId: WAREHOUSE_WMS_CO_ID, warehouseName: WAREHOUSE_NAME, monitorDate, }; - if (coverage.available) { - params.productCoveredCsv = coverage.csv || ''; - } const rows = await cloudDatabase.query( `SELECT DISTINCT s.category - ${buildBaseSql(coverage)} AND s.category IS NOT NULL AND LTRIM(RTRIM(s.category)) <> N'' + ${buildBaseSql()} AND s.category IS NOT NULL AND LTRIM(RTRIM(s.category)) <> N'' ORDER BY s.category`, params, { requestTimeout: 180000 } diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index fb8db99..daee125 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -157,7 +157,7 @@ const AccessoryStockPanel = () => { { title: , dataIndex: 'mkhStatus', width: 110, -- 2.47.2 From 0bb7c5905d7c37266a640cd8632fb5dfc19fff54 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 16:20:17 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(=E6=A8=A1=E5=9D=97=E5=8C=96=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E7=9B=91=E6=8E=A7):=20=E5=B7=B2=E9=85=8D=E7=BD=AEMKH?= =?UTF-8?q?=20=E6=94=B9=E4=BB=8E=E8=81=9A=E6=B0=B4=E6=BD=AD=20SKU=20?= =?UTF-8?q?=E7=BA=A7=20BOM=20=E7=BB=B4=E6=8A=A4(jst=5FskuBomItem)=E5=88=A4?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务反馈上一版「已配置MKH」数量不对(仅 29 个),原因是用了 jst_comboProductItem 组合装引用口径,漏掉了真正以 MKH 成品为父项的 BOM 配件。 修正: - 数据源由 jst_comboProductItem 改为 rpt.jst_skuBomItem(聚水潭 SKU 级 BOM 明细, 含配件商品编码 map_outer_sku_id)。 - 判定口径:目标仓商品编码出现在 jst_skuBomItem.map_outer_sku_id,且父项 sku_id 以 'MKH' 开头(即被某 MKH 模块化成品 BOM 引用为配件) → 已配置MKH。 - 实测目标仓 794 个 SKU 中 699 个命中(map_outer_sku_id 匹配、父项 MKH 过滤), 95 个未配置,合计 794,与接口返回 summary 一致。 - 同步前端状态列 tooltip 与页脚口径说明。 - 移除对「产品资料系统数据库商品档案」「jst_comboProductItem 组合装」两套旧口径的依赖。 注:pm_bomBaseInfo(聚水潭 BOM 维护) 的配件行无 SKU 编码列(仅中文品名/位号), 其组件 SKU 编码实际落在 jst_skuBomItem.map_outer_sku_id,故采用后者。 --- .../services/modularStockMonitorService.js | 39 +++++++++++-------- .../src/pages/ModularStockMonitor/index.jsx | 4 +- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 4dc5af0..4e2bc3e 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -50,13 +50,18 @@ const WARNING_LEVEL = Object.freeze({ NORMAL: '正常', }); -// 模块化配置状态(2026-09-17 业务确认口径): -// 「已配置MKH」= 该 SKU 作为配件被某个 MKH 模块化成品引用 -// (业务库 jst_comboProductItem 中 src_sku_id = 该 SKU -// 且 combine_sku_id 以 'MKH' 开头,即 MKH 组合装 / 成品档案) -// 「未配置MKH」= 未被任何 MKH 模块化成品引用 -// 实测目标仓 794 个 SKU 中 29 个命中(32 个被任意组合装引用,其中 29 个属 MKH 体系)。 -// ⚠️ 之前的「产品资料系统数据库商品档案」口径(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', @@ -76,15 +81,15 @@ const SORTABLE_FIELDS = Object.freeze({ }); /** - * 「已配置MKH」判定表达式(2026-09-17 口径): - * 该 SKU 作为配件被某个 MKH 模块化成品引用,即业务库 jst_comboProductItem 中 - * src_sku_id = 该 SKU 且 combine_sku_id 以 'MKH' 开头(MKH 组合装 / 成品档案)。 + * 「已配置MKH」判定表达式(2026-09-17 晚修正口径): + * 该 SKU 作为「配件商品编码」(map_outer_sku_id)出现在聚水潭 SKU 级 BOM 维护 + * rpt.jst_skuBomItem 中,且父项 sku_id 以 'MKH' 开头(即被某个 MKH 模块化成品引用)。 */ const buildConfiguredExistsSql = () => ( `EXISTS ( - SELECT 1 FROM dbo.jst_comboProductItem x - WHERE x.src_sku_id = s.sku_id - AND x.combine_sku_id LIKE N'MKH%' + SELECT 1 FROM rpt.jst_skuBomItem x + WHERE x.map_outer_sku_id = s.sku_id + AND x.sku_id LIKE N'MKH%' )` ); @@ -268,10 +273,10 @@ async function attachProductImages(rows = []) { } /** - * 「已配置MKH」判定说明(2026-09-17): - * 直接由 buildBaseSql 的 cfg 子查询判定(jst_comboProductItem.src_sku_id - * 命中且 combine_sku_id 以 'MKH' 开头),不再依赖产品资料系统数据库。 - * 产品资料库仅用于补齐商品图片(见 attachProductImages)。 + * 「已配置MKH」判定说明(2026-09-17 晚修正): + * 直接由 buildBaseSql 的 cfg 子查询判定,数据源为聚水潭 SKU 级 BOM 维护 + * rpt.jst_skuBomItem(map_outer_sku_id = 该 SKU 且父项 sku_id 以 'MKH' 开头), + * 不再依赖产品资料系统数据库。产品资料库仅用于补齐商品图片(见 attachProductImages)。 */ const mapRow = (row = {}) => { diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index daee125..5512c22 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -157,7 +157,7 @@ const AccessoryStockPanel = () => { { title: , dataIndex: 'mkhStatus', width: 110, @@ -411,7 +411,7 @@ const ModularStockMonitor = () => ( 图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。 注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天); 库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。 - 状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。 + 状态列:基于聚水潭 SKU 级 BOM 维护(rpt.jst_skuBomItem)判断该商品是否作为「配件商品编码」被某 MKH 成品 BOM 引用——已配置MKH = 出现在某 MKH 成品的 BOM 配件清单中;未配置MKH = 未出现。
-- 2.47.2 From bc8c46d5be8dc27ac291bbb89c8d90cb0523b672 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 16:41:25 +0800 Subject: [PATCH 08/10] =?UTF-8?q?=E9=85=8D=E4=BB=B6=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=EF=BC=9A=E6=9C=AA=E9=85=8D=E7=BD=AEMKH=20?= =?UTF-8?q?=E4=B8=8D=E8=BF=9B=E4=BB=BB=E4=BD=95=E7=BC=BA=E8=B4=A7/?= =?UTF-8?q?=E9=A2=84=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务确认(2026-09-17):未配置MKH 的配件不属于模块化体系,预警列一律显示「正常」, 既不报缺货(实时库存<=0)也不报预警(可用天数<=7)。 实现: - 新增 buildWarningOrderSql() 统一表达式(0缺货/1预警/2正常),未配置MKH(cfg.configured=0) 直接判 2。 - 列表映射 mapRow 改用 SQL 算好的 warningOrder 推导 warningLevel(删除重复 normalizeWarningLevel, 避免前后端各算一份导致排序与显示不一致)。 - 汇总计数 buildSummary.outOfStockCount / lowStockDaysCount 改用统一表达式,不再计入未配置MKH。 - 筛选 buildFilters:按预警等级筛选、onlyWarning=1(仅看缺货/预警)均排除未配置MKH。 实测(接口直跑,目标仓 794 SKU): - 未配置MKH 95 个全部判「正常」(其中 55 个库存=0、58 个可用天数<=7,原会预警)。 - summary.outOfStockCount=13 / lowStockDaysCount=3,与已配置MKH 页面分布一致。 - onlyWarning=1 返回 16 条,0 条未配置MKH。 --- .../services/modularStockMonitorService.js | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 4e2bc3e..5b3d0da 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -41,7 +41,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({ @@ -93,6 +94,22 @@ const buildConfiguredExistsSql = () => ( )` ); +/** + * 统一的「预警等级(数值)」表达式(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` +); + const buildBaseSql = () => ` FROM dbo.jst_sku s OUTER APPLY ( @@ -139,16 +156,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, @@ -209,20 +216,14 @@ const buildFilters = (query = {}, monitorDate = null) => { 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 }; @@ -283,7 +284,14 @@ 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), @@ -292,7 +300,7 @@ 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, @@ -326,11 +334,7 @@ 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 = {}) { @@ -395,9 +399,9 @@ 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, + 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}`, -- 2.47.2 From be52bdd0b50f3a85721b7413693063c300b0b476 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 18:14:57 +0800 Subject: [PATCH 09/10] =?UTF-8?q?feat(=E9=85=8D=E4=BB=B6=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E7=9B=91=E6=8E=A7/MKH-BOM):=20=E4=B8=A4=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8C=E5=90=8C=E6=AD=A5=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E3=80=8D=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:配件库存监控、MKH-BOM 资料两个页面各加一个同步按钮,任何时候点击都能 立即拉取最新数据,不必等凌晨 00:00 的定时刷新。 后端: - mkhBomService 新增 clearMkhBomCache():清空本页全部服务端缓存 (businessBom / officialBomIndex / officialBomDetail / productBom / mkhProduct / replenishIndex / workOrderIndex / pendingOrderIndex / componentStock / componentLocation),返回 syncedAt + 各缓存清除条目数。 - modularStockMonitorService 新增 syncModularStock():本页无进程内缓存 (每次请求直连副本库),同步动作 = 重新解析并返回最新监控日,前端据此重拉列表。 - 新增接口 POST /api/mkh-bom/sync、POST /api/modular-stock-monitor/sync。 前端: - api/mkhBom.js 新增 syncMkhBom;api/modularStockMonitor.js 新增 syncModularStock。 - 两个页面工具栏各加「同步最新数据」主按钮(SyncOutlined,带 loading 态), 点击 → 调后端 sync → 成功后重新拉列表(MKH-BOM 页同时清空展开明细缓存 detailCache,避免看到旧的配件明细)→ 提示「已同步最新数据」; 失败时展示后端返回的错误信息。 口径说明(重要): - 两页数据来自副本库 JSReportReplica,由上游聚水潭同步;应用层无法触发上游拉取。 本按钮的作用是「清服务端缓存 + 直连副本库重取最新」,保证看到的不是 2~15 分钟前的缓存结果,而不是重新跑一遍上游同步。 - 后端已内置的 dailyRefresh(cron 0 0 * * *,Asia/Shanghai)刷的是 summary_daily_* / summary_monthly_* 汇总表,不覆盖这两页数据源, 与本按钮既不重复也不冲突,可并存。 验证: - 后端 4 个改动文件 node --check 全部通过。 - 前端 4 个改动文件(含 2 个 jsx)用 @babel/parser 解析全部通过。 - 实测:syncModularStock 返回 monitorDate=2026-09-16; clearMkhBomCache 返回 syncedAt 与各缓存清除条目数。 - 单测 mkhBomService.test.js 40/40 通过,无回归。 - 前端 lint/build 未运行:frontend/node_modules 仍是之前被中断 npm ci 破坏的 坏目录(frontend/node_modules_broken_0917/,未纳入提交)。 合并前需在本机 npm ci 恢复后,打开两个页面确认按钮渲染与点击效果。 - 后端改动需重启服务后才生效。 --- backend/src/api/mkhBom.js | 11 +++++++ backend/src/api/modularStockMonitor.js | 10 +++++++ backend/src/services/mkhBomService.js | 29 +++++++++++++++++++ .../services/modularStockMonitorService.js | 12 ++++++++ frontend/src/api/mkhBom.js | 8 +++++ frontend/src/api/modularStockMonitor.js | 7 +++++ frontend/src/pages/MkhBomData/index.jsx | 23 ++++++++++++++- .../src/pages/ModularStockMonitor/index.jsx | 23 +++++++++++++-- 8 files changed, 120 insertions(+), 3 deletions(-) diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index afe8956..9614490 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -5,6 +5,7 @@ const { getMkhBomList, getMkhBomDetail, listMkhBomExportRows, + clearMkhBomCache, } = require('../services/mkhBomService'); const { getMkhReplenishAlertAdminConfig, @@ -101,4 +102,14 @@ router.put('/notification-center', async (req, res) => { } }); +// 手动同步:清空本页服务端缓存(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..6139f06 100644 --- a/backend/src/api/modularStockMonitor.js +++ b/backend/src/api/modularStockMonitor.js @@ -4,6 +4,7 @@ const express = require('express'); const { getModularStockList, getModularStockFilters, + syncModularStock, } = require('../services/modularStockMonitorService'); const router = express.Router(); @@ -36,4 +37,13 @@ router.get('/filters', async (_req, res) => { } }); +// 手动同步:重新取最新监控日,前端「同步」按钮调用后再拉一次列表即拿到最新快照 +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/services/mkhBomService.js b/backend/src/services/mkhBomService.js index ed4a607..d74260b 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -1947,8 +1947,37 @@ async function listMkhBomExportRows(query = {}) { }; } +// 手动同步:清空本页全部服务端缓存(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, diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 5b3d0da..b436771 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -445,6 +445,17 @@ async function getModularStockFilters() { }; } +// 手动同步:本页无进程内缓存(每次请求直连副本库),同步动作 = 重新解析监控日并返回, +// 前端据此刷新列表到最新快照。 +// 注意:副本库由聚水潭侧同步,应用层无法触发上游拉取,这里只保证取到最新监控日。 +async function syncModularStock() { + const monitorDate = await resolveMonitorDate(); + return { + syncedAt: new Date().toISOString(), + monitorDate, + }; +} + module.exports = { WAREHOUSE_NAME, WAREHOUSE_WMS_CO_ID, @@ -452,4 +463,5 @@ module.exports = { DEFAULT_SAFETY_DAYS, getModularStockList, getModularStockFilters, + syncModularStock, }; diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js index fdd3341..4d61416 100644 --- a/frontend/src/api/mkhBom.js +++ b/frontend/src/api/mkhBom.js @@ -45,3 +45,11 @@ 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..ec44585 100644 --- a/frontend/src/api/modularStockMonitor.js +++ b/frontend/src/api/modularStockMonitor.js @@ -14,3 +14,10 @@ export const getModularStockFilters = () => request.get( '/api/modular-stock-monitor/filters', { timeout: META_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 0fd2e30..33968da 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -4,7 +4,7 @@ import { Table, Tooltip, Typography, message, } from 'antd'; import { - BellOutlined, DownloadOutlined, PrinterOutlined, ReloadOutlined, SearchOutlined, + BellOutlined, DownloadOutlined, PrinterOutlined, ReloadOutlined, SearchOutlined, SyncOutlined, } from '@ant-design/icons'; import { SycmPage } from '../../components/Common'; import { @@ -15,6 +15,7 @@ import { saveMkhReplenishAlertConfig, saveMkhNotificationCenter, checkMkhReplenishAlert, + syncMkhBom, } from '../../api/mkhBom'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -233,6 +234,7 @@ const MkhBomData = () => { // 导出(当前筛选条件下的全部数据) const [exporting, setExporting] = useState(false); + const [syncing, setSyncing] = useState(false); // 补货预警推送设置 const [alertOpen, setAlertOpen] = useState(false); @@ -718,6 +720,22 @@ 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 (
@@ -772,6 +790,9 @@ const MkhBomData = () => { /> + + + { 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' }); @@ -282,6 +283,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 ( @@ -327,6 +343,9 @@ const AccessoryStockPanel = () => { + + + -- 2.47.2 From b7aca2343b6108a1faf3f1ca56dbda8ef863a39a Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Fri, 18 Sep 2026 17:44:01 +0800 Subject: [PATCH 10/10] =?UTF-8?q?feat(=E9=85=8D=E4=BB=B6=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E7=9B=91=E6=8E=A7/MKH-BOM):=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E9=87=87=E8=B4=AD=E5=9C=A8=E9=80=94=E3=80=8D=E6=95=B0=E9=87=8F?= =?UTF-8?q?=E5=88=97=E4=B8=8E=E5=9C=A8=E9=80=94=E6=98=8E=E7=BB=86=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=EF=BC=8C=E8=A1=A5=E8=B4=A7=E9=A2=84=E8=AD=A6=E6=94=B9?= =?UTF-8?q?=E6=8C=89=E7=BB=93=E4=BD=99=E5=BA=93=E5=AD=98=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配件库存监控: - 列表「实时库存」后新增「采购在途数量」列,口径 SUM(MAX(0, jst_purchaseItem.qty - inQty)) - 顶部「实时库存合计」后新增可点击「采购在途」卡片,弹窗展示在途明细 (商品编码/商品名称/采购数量/已入库/在途数量/计划到货日期/采购单号),支持关键词、排序、分页 - 新增后端接口 GET /api/modular-stock-monitor/purchase-in-transit - 筛选器品类改直查 jst_sku 主表,避免新增 OUTER APPLY 拖慢筛选 - 采购在途列仅取数展示,不参与 ORDER BY / 聚合(规避 MSSQL 对 OUTER APPLY 列的灾难性执行计划) MKH-BOM 补货预警: - 日销驱动与已排产分支改用「结余库存 = 现有库存 - 待发货件数」判定可用天数与预警等级 - 建议补货量按 ceil(3 天日销 + 待发货件数 - 现有库存) 计算 - 新增 scope=work-order 范围筛选;列表新增「结余库存」列 - 打印模板剥离公司名前缀,打印字号调大 测试:mkhBomService 单测新增结余库存缓冲/紧张用例与回归用例 --- backend/src/api/modularStockMonitor.js | 10 + backend/src/services/mkhBomService.js | 66 +++++-- .../services/modularStockMonitorService.js | 153 +++++++++++++-- backend/test/mkhBomService.test.js | 34 +++- frontend/src/api/modularStockMonitor.js | 6 + frontend/src/pages/MkhBomData/index.jsx | 65 ++++++- frontend/src/pages/MkhBomData/style.css | 11 +- .../src/pages/ModularStockMonitor/index.jsx | 178 +++++++++++++++++- .../src/pages/ModularStockMonitor/style.css | 10 + 9 files changed, 483 insertions(+), 50 deletions(-) diff --git a/backend/src/api/modularStockMonitor.js b/backend/src/api/modularStockMonitor.js index 6139f06..08deddf 100644 --- a/backend/src/api/modularStockMonitor.js +++ b/backend/src/api/modularStockMonitor.js @@ -4,6 +4,7 @@ const express = require('express'); const { getModularStockList, getModularStockFilters, + getPurchaseInTransitList, syncModularStock, } = require('../services/modularStockMonitorService'); @@ -37,6 +38,15 @@ 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 { diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index d74260b..6cb027b 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -70,8 +70,11 @@ // · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存; // 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①), // 缺失时回退监控快照 jsStock -// · 可用天数 = 现有库存 / 近15天日销 -// · 建议补货量 = ceil(3 × 近15天日销 − 现有库存),不足 0 取 0 +// · 结余库存 = 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天日销」为准。 @@ -190,6 +193,27 @@ // ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标), // 接口透出 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(已排产 / 无销量不变)。 +// const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -333,18 +357,23 @@ 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', ALL: 'all' }); +const SCOPES = Object.freeze({ + WARNING: 'warning', SOLD: 'sold', WORK_ORDER: 'work-order', ALL: 'all', +}); /** * 解析展示范围: - * warning —— 仅需补货(近15天日销 > 0 且 可用天数 < 3 天) - * sold —— 有近15天日销的商品 - * all —— 全部 MKH 成品 + * 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.ALL) return raw; + 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; } @@ -360,6 +389,7 @@ 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); }); } @@ -813,8 +843,10 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( /** * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 * - * 口径(见文件头 ⑦ + ⑩): - * ① 近15天日销 > 0 且 现有库存 < 3 天日销 → 预警 + * 口径(见文件头 ⑦ + ⑩ + ⑫): + * ① 近15天日销 > 0 且 结余库存 < 3 天日销 → 预警 + * 结余库存 = max(0, 现有库存 − 待发货订单),待发订单为 0 时即现有库存(见 ⑫) + * 结余库存 = 0 → 缺货(订单都发不出去) * ② 近15天日销 = 0 且有代发(待发货)订单 → 订单驱动预警(见文件头 ⑩): * 可用天数 = 现有库存 ÷ 代发订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警 * 两者都不满足 → 无销量,不参与预警。 @@ -845,12 +877,16 @@ function buildReplenishMetrics(row = {}) { // 待发货订单(见文件头 ⑩):当前未发货订单里的件数 const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0); const pendingOrderCount = Math.max(0, toNumber(row.pendingOrderCount) ?? 0); - // 快照日销还是 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, @@ -884,7 +920,7 @@ function buildReplenishMetrics(row = {}) { if (hasPendingWorkOrder) { return { ...base, - stockDays: dailySale > 0 ? Number((stock / dailySale).toFixed(2)) : null, + stockDays: dailySale > 0 ? Number((availableStock / dailySale).toFixed(2)) : null, replenishQty: 0, warningLevel: WARNING_LEVEL.SCHEDULED, warningOrder: 4, @@ -926,9 +962,11 @@ function buildReplenishMetrics(row = {}) { }; } - const stockDays = stock / dailySale; - const replenishQty = Math.max(0, Math.ceil(base.thresholdQty - stock)); - const level = stockDays <= 0 + // 日销驱动(见文件头 ⑦ + ⑫):一律用**结余库存**衡量,待发货订单先从库存里扣掉 + 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); diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index b436771..c99abae 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -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'); @@ -110,6 +113,24 @@ const buildWarningOrderSql = () => ( 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 ( @@ -132,12 +153,31 @@ 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 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 @@ -204,7 +244,7 @@ const buildFilters = (query = {}, monitorDate = null) => { 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); @@ -306,6 +346,7 @@ const mapRow = (row = {}) => { inQtyToday: toNumber(row.inQtyToday) ?? 0, outQtyToday: toNumber(row.outQtyToday) ?? 0, realtimeStock, + purchaseTransit: toNumber(row.purchaseTransit) ?? 0, location: toText(row.location), avgShip15d: toNumber(row.avgShip15d), stockDays, @@ -320,9 +361,10 @@ 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, + ${buildLocationSql()} AS location, m.fifteenAvgSale AS avgShip15d, m.jsUseDays AS stockDays, m.safetyDays AS safetyDays, @@ -391,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, }, }; } @@ -401,6 +444,7 @@ async function buildSummary(whereSql, params) { COUNT_BIG(*) AS skuCount, 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, + 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 @@ -408,13 +452,13 @@ async function buildSummary(whereSql, params) { 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, @@ -422,19 +466,17 @@ async function buildSummary(whereSql, params) { } async function getModularStockFilters() { - const monitorDate = await resolveMonitorDate(); - const params = { - wmsCoId: WAREHOUSE_WMS_CO_ID, - warehouseName: WAREHOUSE_NAME, - monitorDate, - }; - + // 2026-09-18 修正:品类下拉不需要 JOIN 库存/采购/监控等重表,只用 SKU 主表即可, + // 避免 buildBaseSql 里新增采购在途 OUTER APPLY 后把 filters 接口拖慢。 const rows = await cloudDatabase.query( `SELECT DISTINCT s.category - ${buildBaseSql()} 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 { @@ -445,6 +487,86 @@ 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, + }; +} + // 手动同步:本页无进程内缓存(每次请求直连副本库),同步动作 = 重新解析监控日并返回, // 前端据此刷新列表到最新快照。 // 注意:副本库由聚水潭侧同步,应用层无法触发上游拉取,这里只保证取到最新监控日。 @@ -463,5 +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 21e6d1e..876d9fc 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -471,12 +471,42 @@ test('replenish: pending order fields default to empty without any unshipped ord 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.replenishQty, 6); // ceil(3 × 2 − 0),不受订单量影响 + 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) }); // ── 导出(见服务文件头 ⑨)──────────────────────────────────────── diff --git a/frontend/src/api/modularStockMonitor.js b/frontend/src/api/modularStockMonitor.js index ec44585..ebafba9 100644 --- a/frontend/src/api/modularStockMonitor.js +++ b/frontend/src/api/modularStockMonitor.js @@ -15,6 +15,12 @@ export const getModularStockFilters = () => request.get( { 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', diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 33968da..f5d9f8d 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -63,6 +63,7 @@ const shortageTooltip = (row) => { const SCOPE_OPTIONS = [ { label: '仅预警', value: 'warning' }, { label: '有日销', value: 'sold' }, + { label: '加工在途', value: 'work-order' }, { label: '全部', value: 'all' }, ]; @@ -76,6 +77,7 @@ const PRODUCT_EXPORT_COLUMNS = [ { title: '现有库存', dataIndex: 'stock' }, { title: '在途加工量', dataIndex: 'pendingWorkOrderQty' }, { title: '待发货订单', dataIndex: 'pendingOrderQty' }, + { title: '结余库存', dataIndex: 'availableStock' }, { title: '可用天数', dataIndex: 'stockDays', @@ -119,7 +121,9 @@ const EXPORT_MENU_ITEMS = [ { key: 'components-csv', label: '配件明细 · CSV' }, ]; -const EXPORT_SCOPE_LABEL = { warning: '仅预警', sold: '有日销', all: '全部' }; +const EXPORT_SCOPE_LABEL = { + warning: '仅预警', sold: '有日销', 'work-order': '加工在途', all: '全部', +}; // 补货预警推送:范围选项与跳过原因文案 const ALERT_SCOPE_OPTIONS = [ @@ -144,6 +148,17 @@ const ALERT_RECIPIENT_SOURCE_TEXT = { }; // 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 || []; @@ -178,7 +193,7 @@ const PrintPreview = ({ data, onClose }) => { - + @@ -192,7 +207,7 @@ const PrintPreview = ({ data, onClose }) => { ) : null} - + ))} {items.length === 0 ? ( @@ -305,6 +320,12 @@ 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); }; @@ -571,6 +592,23 @@ const MkhBomData = () => { ) : —), }, + { + // 结余库存(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', @@ -591,7 +629,15 @@ const MkhBomData = () => { ); } - return text; + return ( + 0 + ? `可用天数 = 结余库存 ${number(row.availableStock)} ÷ 近15天日销 ${number(row.dailySale, 2)}(结余库存已扣待发货订单)` + : `可用天数 = 现有库存 ${number(row.stock)} ÷ 近15天日销 ${number(row.dailySale, 2)}` + }> + {text} + + ); }, }, { @@ -751,8 +797,10 @@ const MkhBomData = () => { 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 - 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近15天日销,低于 3 天即预警,缺货单独标识; - 建议补货量 = 3 × 近15天日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。 + 该仓无记录时回退监控快照库存;结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0), + 可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货; + 建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。 + 无日销且无订单的商品不参与预警。
待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; @@ -785,6 +833,7 @@ const MkhBomData = () => { options={[ { label: `仅预警 (${data.warningCount ?? 0})`, value: 'warning' }, { label: `有日销 (${data.soldCount ?? 0})`, value: 'sold' }, + { label: `加工在途 (${data.pendingWorkOrderSkuCount ?? 0})`, value: 'work-order' }, { label: `全部 (${data.mkhTotalCount ?? 0})`, value: 'all' }, ]} /> @@ -847,7 +896,7 @@ const MkhBomData = () => { 按近 {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.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。 {(data.orderDrivenCount || 0) > 0 ? ( <>
@@ -937,7 +986,7 @@ const MkhBomData = () => { pageSizeOptions: [50, 100, 200], showTotal: (total) => `共 ${total} 条`, }} - scroll={{ x: 1766, y: bodyHeight }} + scroll={{ x: 1866, y: bodyHeight }} /> diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css index 790d760..55300ae 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -235,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; } @@ -243,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 { diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index 0f7a78c..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, SyncOutlined } from '@ant-design/icons'; import { SycmPage } from '../../components/Common'; -import { getModularStockList, getModularStockFilters, syncModularStock } from '../../api/modularStockMonitor'; +import { getModularStockList, getModularStockFilters, getPurchaseInTransitList, syncModularStock } from '../../api/modularStockMonitor'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -45,6 +45,15 @@ const AccessoryStockPanel = () => { 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(() => { @@ -135,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: '图片', @@ -219,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, @@ -353,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 }} + /> + + ); }; @@ -426,7 +587,8 @@ const ModularStockMonitor = () => ( 数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、 库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖); - 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」; + 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准; + 仓位取聚水潭「箱及仓位库存」(jst_packInventoryInfo)的真货位,同商品多箱时显示库存最大的主货位,查不到才显示「未维护」; 图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。 注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天); 库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。 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; -- 2.47.2