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] =?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 }) => {
| MKH商品编码 | +MKH商品编码 | 配件明细 | 数量 | 配件仓位 | @@ -192,7 +207,7 @@ const PrintPreview = ({ data, onClose }) => { ) : null}{text(item.skuName)}{item.skuId ? {item.skuId} : null} | {number(item.qty)} | -{item.location ? text(item.location) : 未维护} | +{item.location ? text(stripWarehousePrefix(item.location)) : 未维护} |
|---|