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; +}