diff --git a/backend/src/api/modularStockMonitor.js b/backend/src/api/modularStockMonitor.js index 6139f06..08deddf 100644 --- a/backend/src/api/modularStockMonitor.js +++ b/backend/src/api/modularStockMonitor.js @@ -4,6 +4,7 @@ const express = require('express'); const { getModularStockList, getModularStockFilters, + getPurchaseInTransitList, syncModularStock, } = require('../services/modularStockMonitorService'); @@ -37,6 +38,15 @@ router.get('/filters', async (_req, res) => { } }); +// 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细 +router.get('/purchase-in-transit', async (req, res) => { + try { + res.json({ success: true, data: await getPurchaseInTransitList(req.query || {}) }); + } catch (error) { + sendError(res, error, '加载采购在途明细失败'); + } +}); + // 手动同步:重新取最新监控日,前端「同步」按钮调用后再拉一次列表即拿到最新快照 router.post('/sync', async (_req, res) => { try { diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index d74260b..6cb027b 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -70,8 +70,11 @@ // · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存; // 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①), // 缺失时回退监控快照 jsStock -// · 可用天数 = 现有库存 / 近15天日销 -// · 建议补货量 = ceil(3 × 近15天日销 − 现有库存),不足 0 取 0 +// · 结余库存 = max(0, 现有库存 − 待发货订单) +// ⚠️ 2026-09-18 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单** +// (见 ⑫)。待发货订单 = 0 时结余库存 ≡ 现有库存,与旧口径完全等价。 +// · 可用天数 = 结余库存 / 近15天日销 +// · 建议补货量 = ceil(3 × 近15天日销 + 待发货订单 − 现有库存),不足 0 取 0 // ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / fifteenAvgSale 不同源 // (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67, // 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近15天日销」为准。 @@ -190,6 +193,27 @@ // ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标), // 接口透出 componentStockDegraded=true。 // +// ── ⑫ 结余库存(2026-09-18 业务口径修正)────────────────────────── +// 背景:`MKHDNZ0409260601376` 现有库存 3、待发货订单 3,业务认为结余库存应为 0、 +// 可用天数 0、应判缺货;但旧代码里「待发货订单」**只在日销 = 0 时参与判定** +// (`orderDriven = dailySale <= 0 && pendingOrderQty > 0`),该品日销 0.27 > 0, +// 于是走日销分支 3 ≥ 0.81(3 天日销)→ 判「正常」,3 件待发订单被完全忽略。 +// 新口径:**待发货订单对所有商品都参与计算,不再区分日销是否为 0** +// · 结余库存 availableStock = max(0, 现有库存 − 待发货订单) +// · 可用天数 = 结余库存 ÷ 近15天日销(日销 > 0 时) +// · 缺货:结余库存 ≤ 0(订单都发不出去);预警:0 < 可用天数 < 3;否则正常 +// · 建议补货量 = ceil(3 × 近15天日销 + 待发货订单 − 现有库存) +// (先补到能把订单发掉,再补 3 天缓冲) +// ⚠️ ⑩ 的订单驱动分支(日销 = 0)**保持不变**:那里本来就是以代发订单件数为分母, +// 语义是「库存能覆盖几倍订单」,与日销无关,改动会误伤已有的订单驱动预警。 +// 实测(2026-09-18,4,223 个成品):既有日销又有待发订单的商品 38 个, +// 其中 **4 个等级变化、全部升级为缺货**: +// MKHDNZ0409260700503(日销1.67 库存1 待发2) 预警0.60天 → 缺货0天 +// MKHDNZ0409260601547(日销0.42 库存1 待发1) 预警2.38天 → 缺货0天 +// MKHDNZ0409260601527(日销0.13 库存1 待发1) 正常7.69天 → 缺货0天 +// MKHDNZ0409260601376(日销0.27 库存3 待发3) 正常11.11天 → 缺货0天 +// 全量分布:缺货 10→14、预警 6→4、正常 165→163(已排产 / 无销量不变)。 +// const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -333,18 +357,23 @@ const TRUTHY_FLAGS = new Set(['1', 'true', 'yes', 'on']); const parseFlag = (value) => TRUTHY_FLAGS.has(String(value ?? '').trim().toLowerCase()); // 列表展示范围(内存过滤) -const SCOPES = Object.freeze({ WARNING: 'warning', SOLD: 'sold', ALL: 'all' }); +const SCOPES = Object.freeze({ + WARNING: 'warning', SOLD: 'sold', WORK_ORDER: 'work-order', ALL: 'all', +}); /** * 解析展示范围: - * warning —— 仅需补货(近15天日销 > 0 且 可用天数 < 3 天) - * sold —— 有近15天日销的商品 - * all —— 全部 MKH 成品 + * warning —— 仅需补货(缺货 + 3 天内预警) + * sold —— 有近15天日销的商品 + * work-order —— 有未完成加工单(在途加工量 > 0)的商品 + * all —— 全部 MKH 成品 * 兼容旧参数 warningOnly=1(等价 scope=warning)。 */ function resolveScope(query = {}) { const raw = String(query.scope ?? '').trim().toLowerCase(); - if (raw === SCOPES.WARNING || raw === SCOPES.SOLD || raw === SCOPES.ALL) return raw; + if (raw === SCOPES.WARNING || raw === SCOPES.SOLD || raw === SCOPES.WORK_ORDER || raw === SCOPES.ALL) { + return raw; + } if (parseFlag(query.warningOnly)) return SCOPES.WARNING; return SCOPES.ALL; } @@ -360,6 +389,7 @@ function filterRows(rows, { keyword, scope }) { return rows.filter((row) => { if (scope === SCOPES.WARNING && !row.hasWarning) return false; if (scope === SCOPES.SOLD && !(toNumber(row.dailySale) > 0)) return false; + if (scope === SCOPES.WORK_ORDER && !row.hasPendingWorkOrder) return false; return matchesKeyword(row, keyword); }); } @@ -813,8 +843,10 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( /** * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 * - * 口径(见文件头 ⑦ + ⑩): - * ① 近15天日销 > 0 且 现有库存 < 3 天日销 → 预警 + * 口径(见文件头 ⑦ + ⑩ + ⑫): + * ① 近15天日销 > 0 且 结余库存 < 3 天日销 → 预警 + * 结余库存 = max(0, 现有库存 − 待发货订单),待发订单为 0 时即现有库存(见 ⑫) + * 结余库存 = 0 → 缺货(订单都发不出去) * ② 近15天日销 = 0 且有代发(待发货)订单 → 订单驱动预警(见文件头 ⑩): * 可用天数 = 现有库存 ÷ 代发订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警 * 两者都不满足 → 无销量,不参与预警。 @@ -845,12 +877,16 @@ function buildReplenishMetrics(row = {}) { // 待发货订单(见文件头 ⑩):当前未发货订单里的件数 const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0); const pendingOrderCount = Math.max(0, toNumber(row.pendingOrderCount) ?? 0); - // 快照日销还是 0、但已经有订单 → 由订单驱动预警 + // 快照日销还是 0、但已经有订单 → 由订单驱动预警(⑩ 专属分支,分母用代发订单件数) const orderDriven = dailySale <= 0 && pendingOrderQty > 0; + // 结余库存(见文件头 ⑫):已下单未发货的部分已被占用,先扣掉才是可自由支配的库存。 + // ⚠️ 不分日销是否为 0,所有口径统一按结余库存计算(2026-09-18 口径修正)。 + const availableStock = Math.max(0, stock - pendingOrderQty); const base = { dailySale, stock, + availableStock, pendingWorkOrderQty, pendingWorkOrderCount, hasPendingWorkOrder, @@ -884,7 +920,7 @@ function buildReplenishMetrics(row = {}) { if (hasPendingWorkOrder) { return { ...base, - stockDays: dailySale > 0 ? Number((stock / dailySale).toFixed(2)) : null, + stockDays: dailySale > 0 ? Number((availableStock / dailySale).toFixed(2)) : null, replenishQty: 0, warningLevel: WARNING_LEVEL.SCHEDULED, warningOrder: 4, @@ -926,9 +962,11 @@ function buildReplenishMetrics(row = {}) { }; } - const stockDays = stock / dailySale; - const replenishQty = Math.max(0, Math.ceil(base.thresholdQty - stock)); - const level = stockDays <= 0 + // 日销驱动(见文件头 ⑦ + ⑫):一律用**结余库存**衡量,待发货订单先从库存里扣掉 + const stockDays = availableStock / dailySale; + // 建议补货量 = 3 天日销缓冲 + 待发货订单缺口(先补到能把订单发掉,再补 3 天缓冲) + const replenishQty = Math.max(0, Math.ceil(base.thresholdQty + pendingOrderQty - stock)); + const level = availableStock <= 0 ? WARNING_LEVEL.OUT_OF_STOCK : (stockDays < REPLENISH_DAYS_THRESHOLD ? WARNING_LEVEL.WARNING : WARNING_LEVEL.NORMAL); diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index b436771..c99abae 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -28,8 +28,11 @@ // 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty // (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794) // -// ⚠️ 业务库确实无数据源的字段: -// - 仓位 location:rpt.jst_sku.bin / other_bin 全库 557,475 行填充数为 0 +// ✅ 仓位 location(2026-09-18 改源,旧的「无数据源」结论作废): +// - 旧源 rpt.jst_sku.bin / other_bin 在目标仓 794 行**恒为空**,所以页面长期显示「未维护」 +// - 新源 rpt.jst_packInventoryInfo.bin(真货位):该表 2026-09-18 10:48 被上游刷新后 +// 目标仓 794 个 SKU **命中 727 个**(如 ZT-8 / BCP01-01-1) +// - 详见 buildLocationSql() 注释 const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -110,6 +113,24 @@ const buildWarningOrderSql = () => ( END` ); +/** + * 统一的「仓位」表达式(2026-09-18 改源)。 + * 旧源 `jst_sku.bin / other_bin` 在目标仓 794 行**恒为空**(页面因此长期显示「未维护」); + * 真货位在 `rpt.jst_packInventoryInfo.bin` —— 该表 2026-09-18 10:48 被上游刷新后, + * 目标仓 794 个 SKU **命中 727 个**(实测),如 ZT-8 / BCP01-01-1。 + * · 只取 `qty > 0` 的箱,同 SKU 多箱时取库存最大的那个(列表页只展示主货位) + * · 取不到才回退商品档案的 bin / other_bin(目前仍全空,保留兜底不影响结果) + * 与 MKH-BOM 页 `mkhBomService.loadComponentLocations()` 同源,区别只在本页取单个主货位。 + * ⚠️ 列表映射、仓位筛选两处共用此表达式,改口径时只改这里。 + */ +const buildLocationSql = () => ( + `COALESCE( + ploc.bin, + NULLIF(LTRIM(RTRIM(s.bin)), N''), + NULLIF(LTRIM(RTRIM(s.other_bin)), N'') + )` +); + const buildBaseSql = () => ` FROM dbo.jst_sku s OUTER APPLY ( @@ -132,12 +153,31 @@ OUTER APPLY ( AND p.delivery_date >= @monitorDate AND p.delivery_date < DATEADD(DAY, 1, @monitorDate) ) pin +-- 采购在途:该 SKU 全部采购单中「已下单 − 已入库」的未到货部分合计 +-- jst_purchaseItem.qty = 采购数量,inQty = 已入库数量,plan_arrive_qty 全库恒为 0(不可用)、 +-- is_delivery 全库恒为 0(不可信),故在途口径 = SUM(MAX(0, qty − inQty))。 +-- ⚠️ 此列只参与「取数/展示」,绝不参与 ORDER BY / 聚合(参考本文件头 MSSQL 坑说明, +-- 对 OUTER APPLY 列做排序/聚合会触发灾难性执行计划)。 +OUTER APPLY ( + SELECT CAST(SUM(CASE WHEN p.qty > ISNULL(p.inQty, 0) THEN p.qty - ISNULL(p.inQty, 0) ELSE 0 END) AS DECIMAL(18, 2)) AS transit + FROM dbo.jst_purchaseItem p + WHERE p.sku_id = s.sku_id +) ptr OUTER APPLY ( SELECT TOP 1 NULLIF(LTRIM(RTRIM(c.pictureSkucode)), N'') AS pictureSkucode FROM dbo.fys_skuCostPriceUpdate c WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id)))) ORDER BY c.createTime DESC ) cost +-- 仓位:主货位(真货位),见 buildLocationSql() 说明 +OUTER APPLY ( + SELECT TOP 1 NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin + FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK) + WHERE pk.sku_id = s.sku_id + AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL + AND ISNULL(pk.qty, 0) > 0 + ORDER BY pk.qty DESC +) ploc -- 模块化配置状态:见文件头部「已配置MKH」口径说明 OUTER APPLY ( SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured @@ -204,7 +244,7 @@ const buildFilters = (query = {}, monitorDate = null) => { const location = toText(query.location); if (location) { params.location = location; - conditions.push(`COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) = @location`); + conditions.push(`${buildLocationSql()} = @location`); } const mkhStatus = toText(query.mkhStatus); @@ -306,6 +346,7 @@ const mapRow = (row = {}) => { inQtyToday: toNumber(row.inQtyToday) ?? 0, outQtyToday: toNumber(row.outQtyToday) ?? 0, realtimeStock, + purchaseTransit: toNumber(row.purchaseTransit) ?? 0, location: toText(row.location), avgShip15d: toNumber(row.avgShip15d), stockDays, @@ -320,9 +361,10 @@ const buildProjection = () => ` s.name AS productName, s.category AS category, COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock, + ISNULL(ptr.transit, 0) AS purchaseTransit, ISNULL(pin.inQty, 0) AS inQtyToday, ISNULL(m.nearSecondSale, 0) AS outQtyToday, - COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) AS location, + ${buildLocationSql()} AS location, m.fifteenAvgSale AS avgShip15d, m.jsUseDays AS stockDays, m.safetyDays AS safetyDays, @@ -391,6 +433,7 @@ async function getModularStockList(query = {}) { configuredCount: summaryRow?.configuredCount || 0, unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0), totalStock: summaryRow?.totalStock || 0, + totalPurchaseTransit: summaryRow?.totalPurchaseTransit || 0, }, }; } @@ -401,6 +444,7 @@ async function buildSummary(whereSql, params) { COUNT_BIG(*) AS skuCount, SUM(CASE WHEN ${buildWarningOrderSql()} = 0 THEN 1 ELSE 0 END) AS outOfStockCount, CAST(ISNULL(SUM(COALESCE(inv.qty, m.jsStock, 0)), 0) AS BIGINT) AS totalStock, + CAST(ISNULL(SUM(ISNULL(ptr.transit, 0)), 0) AS BIGINT) AS totalPurchaseTransit, SUM(CASE WHEN ${buildWarningOrderSql()} = 1 THEN 1 ELSE 0 END) AS lowStockDaysCount, SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount, MAX(m.date) AS monitorDate @@ -408,13 +452,13 @@ async function buildSummary(whereSql, params) { params, { requestTimeout: 180000 } ); - const row = rows[0] || {}; return [{ skuCount: Number(row.skuCount || 0), outOfStockCount: Number(row.outOfStockCount || 0), totalStock: Number(row.totalStock || 0), + totalPurchaseTransit: Number(row.totalPurchaseTransit || 0), lowStockDaysCount: Number(row.lowStockDaysCount || 0), configuredCount: Number(row.configuredCount || 0), monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null, @@ -422,19 +466,17 @@ async function buildSummary(whereSql, params) { } async function getModularStockFilters() { - const monitorDate = await resolveMonitorDate(); - const params = { - wmsCoId: WAREHOUSE_WMS_CO_ID, - warehouseName: WAREHOUSE_NAME, - monitorDate, - }; - + // 2026-09-18 修正:品类下拉不需要 JOIN 库存/采购/监控等重表,只用 SKU 主表即可, + // 避免 buildBaseSql 里新增采购在途 OUTER APPLY 后把 filters 接口拖慢。 const rows = await cloudDatabase.query( `SELECT DISTINCT s.category - ${buildBaseSql()} AND s.category IS NOT NULL AND LTRIM(RTRIM(s.category)) <> N'' + FROM dbo.jst_sku s + WHERE s.other_3 = @warehouseName + AND s.category IS NOT NULL + AND LTRIM(RTRIM(s.category)) <> N'' ORDER BY s.category`, - params, - { requestTimeout: 180000 } + { warehouseName: WAREHOUSE_NAME }, + { requestTimeout: 60000 } ); return { @@ -445,6 +487,86 @@ async function getModularStockFilters() { }; } +/** + * 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细。 + * 口径:在途数量 = qty − inQty(已下单但尚未入库的部分)。 + * 支持 keyword 搜索商品编码 / 名称,支持按 transitQty(默认)/ skuId / purchaseQty / deliveryDate 排序。 + */ +async function getPurchaseInTransitList(query = {}) { + const page = Math.max(1, Number.parseInt(query.page, 10) || 1); + const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE; + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize)); + + const keyword = toText(query.keyword); + const params = { warehouseName: WAREHOUSE_NAME }; + let whereSql = `s.other_3 = @warehouseName AND p.qty > ISNULL(p.inQty, 0)`; + if (keyword) { + params.keyword = `%${keyword}%`; + whereSql += ` AND (p.sku_id LIKE @keyword OR p.name LIKE @keyword)`; + } + + const sortField = String(query.sortField || '').trim(); + const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + const orderMap = { + skuId: 'p.sku_id', + deliveryDate: 'p.delivery_date', + transitQty: 'transitQty', + purchaseQty: 'p.qty', + }; + const orderCol = orderMap[sortField] || 'transitQty'; + const orderClause = sortField === 'skuId' + ? `${orderCol} ${sortOrder}` + : `${orderCol} ${sortOrder}, p.sku_id ASC`; + + const offset = (page - 1) * pageSize; + + const [rows, totalRow] = await Promise.all([ + cloudDatabase.query( + `SELECT + p.sku_id AS skuId, + p.name AS productName, + CAST(p.qty AS BIGINT) AS purchaseQty, + CAST(ISNULL(p.inQty, 0) AS BIGINT) AS inQty, + CAST(p.qty - ISNULL(p.inQty, 0) AS BIGINT) AS transitQty, + p.delivery_date AS deliveryDate, + CAST(p.po_id AS NVARCHAR(50)) AS poId, + CAST(p.poi_id AS NVARCHAR(50)) AS poiId + FROM rpt.jst_purchaseItem p + INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id + WHERE ${whereSql} + ORDER BY ${orderClause} + OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`, + params, + { requestTimeout: 180000 } + ), + cloudDatabase.query( + `SELECT COUNT_BIG(*) AS total + FROM rpt.jst_purchaseItem p + INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id + WHERE ${whereSql}`, + params, + { requestTimeout: 180000 } + ), + ]); + + return { + rows: rows.map((row) => ({ + skuId: toText(row.skuId), + productName: toText(row.productName), + purchaseQty: Number(row.purchaseQty || 0), + inQty: Number(row.inQty || 0), + transitQty: Number(row.transitQty || 0), + deliveryDate: row.deliveryDate ? new Date(row.deliveryDate).toISOString().slice(0, 10) : null, + poId: toText(row.poId), + poiId: toText(row.poiId), + })), + total: Number(totalRow?.[0]?.total || 0), + page, + pageSize, + warehouse: WAREHOUSE_NAME, + }; +} + // 手动同步:本页无进程内缓存(每次请求直连副本库),同步动作 = 重新解析监控日并返回, // 前端据此刷新列表到最新快照。 // 注意:副本库由聚水潭侧同步,应用层无法触发上游拉取,这里只保证取到最新监控日。 @@ -463,5 +585,6 @@ module.exports = { DEFAULT_SAFETY_DAYS, getModularStockList, getModularStockFilters, + getPurchaseInTransitList, syncModularStock, }; diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 21e6d1e..876d9fc 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -471,12 +471,42 @@ test('replenish: pending order fields default to empty without any unshipped ord assert.equal(noSale.warningLevel, '无销量'); assert.equal(noSale.orderDriven, false); - // 有日销的商品照旧走原口径,但待发货量也要带上(页面新增列) + // 有日销的商品:待发货订单先从库存里扣掉(结余库存口径,见服务文件头 ⑫) const withSale = metrics({ avgSale: 2, realtimeStock: 0, pendingOrderQty: 3 }); assert.equal(withSale.warningLevel, '缺货'); assert.equal(withSale.orderDriven, false); assert.equal(withSale.pendingOrderQty, 3); - assert.equal(withSale.replenishQty, 6); // ceil(3 × 2 − 0),不受订单量影响 + assert.equal(withSale.availableStock, 0); // max(0, 0 − 3) + assert.equal(withSale.stockDays, 0); + assert.equal(withSale.replenishQty, 9); // ceil(3 × 2 + 3 − 0):先补订单缺口 3,再补 3 天缓冲 6 + + // 结余库存 > 0 时按结余算可用天数:库存 10 − 待发 3 = 7,7 ÷ 2 = 3.5 天 → 正常 + const buffered = metrics({ avgSale: 2, realtimeStock: 10, pendingOrderQty: 3 }); + assert.equal(buffered.availableStock, 7); + assert.equal(buffered.stockDays, 3.5); + assert.equal(buffered.warningLevel, '正常'); + assert.equal(buffered.replenishQty, 0); // ceil(6 + 3 − 10) < 0 → 0 + + // 结余库存不足以支撑 3 天日销 → 预警(MKHDNZ0409260601376 同型:库存3 待发3 日销0.27) + const tight = metrics({ avgSale: 1, realtimeStock: 5, pendingOrderQty: 3 }); + assert.equal(tight.availableStock, 2); + assert.equal(tight.stockDays, 2); + assert.equal(tight.warningLevel, '预警'); + assert.equal(tight.replenishQty, 1); // ceil(3 + 3 − 5) +}); + +// 回归:MKHDNZ0409260601376(2026-09-18 业务反馈) +// 现有库存 3、待发货订单 3 → 结余库存 0、可用天数 0,应判「缺货」。 +// 旧代码只在 dailySale <= 0 时才让待发货订单参与判定,本例日销 0.27 > 0 导致漏报。 +test('replenish: pending orders are deducted even when daily sale is positive (⑫)', () => { + const row = metrics({ avgSale: 0.27, realtimeStock: 3, pendingOrderQty: 3, pendingOrderCount: 3 }); + assert.equal(row.orderDriven, false); + assert.equal(row.availableStock, 0); + assert.equal(row.stockDays, 0); + assert.equal(row.warningLevel, '缺货'); + assert.equal(row.hasWarning, true); + assert.equal(row.warningOrder, 1); + assert.equal(row.replenishQty, 1); // ceil(3 × 0.27 + 3 − 3) = ceil(0.81) }); // ── 导出(见服务文件头 ⑨)──────────────────────────────────────── diff --git a/frontend/src/api/modularStockMonitor.js b/frontend/src/api/modularStockMonitor.js index ec44585..ebafba9 100644 --- a/frontend/src/api/modularStockMonitor.js +++ b/frontend/src/api/modularStockMonitor.js @@ -15,6 +15,12 @@ export const getModularStockFilters = () => request.get( { timeout: META_TIMEOUT_MS } ); +// 采购在途明细列表(目标仓全部「采购数量 > 已入库数量」的采购单明细) +export const getPurchaseInTransitList = (params = {}) => request.get( + '/api/modular-stock-monitor/purchase-in-transit', + { params, timeout: LIST_TIMEOUT_MS } +); + // 手动同步:后端重新取最新监控日,前端随后再拉一次列表即拿到最新快照 export const syncModularStock = () => request.post( '/api/modular-stock-monitor/sync', diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 33968da..f5d9f8d 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -63,6 +63,7 @@ const shortageTooltip = (row) => { const SCOPE_OPTIONS = [ { label: '仅预警', value: 'warning' }, { label: '有日销', value: 'sold' }, + { label: '加工在途', value: 'work-order' }, { label: '全部', value: 'all' }, ]; @@ -76,6 +77,7 @@ const PRODUCT_EXPORT_COLUMNS = [ { title: '现有库存', dataIndex: 'stock' }, { title: '在途加工量', dataIndex: 'pendingWorkOrderQty' }, { title: '待发货订单', dataIndex: 'pendingOrderQty' }, + { title: '结余库存', dataIndex: 'availableStock' }, { title: '可用天数', dataIndex: 'stockDays', @@ -119,7 +121,9 @@ const EXPORT_MENU_ITEMS = [ { key: 'components-csv', label: '配件明细 · CSV' }, ]; -const EXPORT_SCOPE_LABEL = { warning: '仅预警', sold: '有日销', all: '全部' }; +const EXPORT_SCOPE_LABEL = { + warning: '仅预警', sold: '有日销', 'work-order': '加工在途', all: '全部', +}; // 补货预警推送:范围选项与跳过原因文案 const ALERT_SCOPE_OPTIONS = [ @@ -144,6 +148,17 @@ const ALERT_RECIPIENT_SOURCE_TEXT = { }; // A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 +// 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」), +// 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。 +const stripWarehousePrefix = (value) => String(value || '') + .split('、') + .map((part) => { + const idx = part.lastIndexOf('·'); + return (idx >= 0 ? part.slice(idx + 1) : part).trim(); + }) + .filter(Boolean) + .join('、'); + const PrintPreview = ({ data, onClose }) => { const now = useMemo(() => new Date().toLocaleString('zh-CN'), []); const items = data.items || []; @@ -178,7 +193,7 @@ const PrintPreview = ({ data, onClose }) => { - + @@ -192,7 +207,7 @@ const PrintPreview = ({ data, onClose }) => { ) : null} - + ))} {items.length === 0 ? ( @@ -305,6 +320,12 @@ const MkhBomData = () => { const changeFilter = (key) => (value) => { setFilters((old) => ({ ...old, [key]: value || '' })); setPage(1); + // 点击「仅预警 / 有日销 / 加工在途 / 全部」后自动滚动到下方表格明细 + if (key === 'scope') { + setTimeout(() => { + tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 0); + } }; const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); }; @@ -571,6 +592,23 @@ const MkhBomData = () => { ) : —), }, + { + // 结余库存(2026-09-18 新增口径):现有库存先扣掉待发货订单,才是可自由支配的库存, + // 补货预警 / 可用天数一律按它算(待发订单为 0 时 ≡ 现有库存) + title: '结余库存', + dataIndex: 'availableStock', + width: 100, + align: 'right', + render: (value, row) => ( + 0 + ? `结余库存 = 现有库存 ${number(row.stock)} − 待发货订单 ${number(row.pendingOrderQty)}` + : '无待发货订单,结余库存 = 现有库存'} + > + {number(value)} + + ), + }, { title: '可用天数', dataIndex: 'stockDays', @@ -591,7 +629,15 @@ const MkhBomData = () => { ); } - return text; + return ( + 0 + ? `可用天数 = 结余库存 ${number(row.availableStock)} ÷ 近15天日销 ${number(row.dailySale, 2)}(结余库存已扣待发货订单)` + : `可用天数 = 现有库存 ${number(row.stock)} ÷ 近15天日销 ${number(row.dailySale, 2)}` + }> + {text} + + ); }, }, { @@ -751,8 +797,10 @@ const MkhBomData = () => { 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 - 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近15天日销,低于 3 天即预警,缺货单独标识; - 建议补货量 = 3 × 近15天日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。 + 该仓无记录时回退监控快照库存;结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0), + 可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货; + 建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。 + 无日销且无订单的商品不参与预警。
待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; @@ -785,6 +833,7 @@ const MkhBomData = () => { options={[ { label: `仅预警 (${data.warningCount ?? 0})`, value: 'warning' }, { label: `有日销 (${data.soldCount ?? 0})`, value: 'sold' }, + { label: `加工在途 (${data.pendingWorkOrderSkuCount ?? 0})`, value: 'work-order' }, { label: `全部 (${data.mkhTotalCount ?? 0})`, value: 'all' }, ]} /> @@ -847,7 +896,7 @@ const MkhBomData = () => { 按近 {data.saleWindowDays || 15} 天日销核算(监控日 {data.monitorDate || '—'}): 其中 {data.outOfStockCount || 0} 个已缺货, {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 - 建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 − 现有库存。点「只看预警」即为补货清单。 + 建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。 {(data.orderDrivenCount || 0) > 0 ? ( <>
@@ -937,7 +986,7 @@ const MkhBomData = () => { pageSizeOptions: [50, 100, 200], showTotal: (total) => `共 ${total} 条`, }} - scroll={{ x: 1766, y: bodyHeight }} + scroll={{ x: 1866, y: bodyHeight }} /> diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css index 790d760..55300ae 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -235,7 +235,10 @@ .mkh-print-table .mkh-print-sku { font-family: 'SFMono-Regular', Consolas, Menlo, monospace; - font-size: 11px; + /* 2026-09-18 业务要求调大:原来是 11px,车间核对编码时太小 */ + font-size: 14px; + font-weight: 700; + letter-spacing: 0.3px; vertical-align: middle; } @@ -243,8 +246,10 @@ display: block; margin-top: 2px; font-family: 'SFMono-Regular', Consolas, Menlo, monospace; - font-size: 10px; - color: #666; + /* 2026-09-18 业务要求调大:原来是 10px / #666,扫码与人工核对都偏小偏淡 */ + font-size: 12px; + font-weight: 600; + color: #222; } .mkh-print-foot { diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index 0f7a78c..ec08b52 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -1,11 +1,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, Button, Card, Col, Dropdown, Image, Input, Row, Select, + Alert, Button, Card, Col, Dropdown, Image, Input, Modal, Row, Select, Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message, } from 'antd'; import { DownloadOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'; import { SycmPage } from '../../components/Common'; -import { getModularStockList, getModularStockFilters, syncModularStock } from '../../api/modularStockMonitor'; +import { getModularStockList, getModularStockFilters, getPurchaseInTransitList, syncModularStock } from '../../api/modularStockMonitor'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -45,6 +45,15 @@ const AccessoryStockPanel = () => { const tableWrapRef = useRef(null); const [bodyHeight, setBodyHeight] = useState(420); + // 采购在途明细弹窗状态 + const [transitVisible, setTransitVisible] = useState(false); + const [transitData, setTransitData] = useState({ rows: [], total: 0 }); + const [transitLoading, setTransitLoading] = useState(false); + const [transitPage, setTransitPage] = useState(1); + const [transitPageSize, setTransitPageSize] = useState(50); + const [transitKeyword, setTransitKeyword] = useState(''); + const [transitSorter, setTransitSorter] = useState({ field: 'transitQty', order: 'DESC' }); + // 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc: // 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行 useEffect(() => { @@ -135,6 +144,56 @@ const AccessoryStockPanel = () => { setPage(1); }; + // 点击顶部统计卡片 → 自动设置对应筛选条件并滚动到表格明细 + const applySummaryFilter = (type) => () => { + const next = { ...EMPTY_FILTERS }; + let warning = false; + if (type === 'outOfStock') { + next.warningLevel = '缺货'; + warning = true; + } else if (type === 'lowStockDays') { + next.warningLevel = '预警'; + warning = true; + } else if (type === 'configured') { + next.mkhStatus = '已配置MKH'; + } else if (type === 'unconfigured') { + next.mkhStatus = '未配置MKH'; + } + setFilters(next); + setOnlyWarning(warning); + setPage(1); + setTimeout(() => { + tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 0); + }; + + // 打开采购在途明细弹窗 + const openPurchaseTransit = () => { + setTransitVisible(true); + setTransitPage(1); + setTransitKeyword(''); + setTransitSorter({ field: 'transitQty', order: 'DESC' }); + loadPurchaseTransit(1, '', { field: 'transitQty', order: 'DESC' }); + }; + + const loadPurchaseTransit = useCallback(async (p = transitPage, kw = transitKeyword, sort = transitSorter) => { + setTransitLoading(true); + try { + const result = await getPurchaseInTransitList({ + page: p, + pageSize: transitPageSize, + keyword: kw, + sortField: sort.field, + sortOrder: sort.order, + }); + setTransitData(result?.data || { rows: [], total: 0 }); + } catch (err) { + message.error(err?.response?.data?.error || err?.message || '加载采购在途明细失败'); + } finally { + setTransitLoading(false); + } + }, [transitPage, transitPageSize, transitKeyword, transitSorter]); + const columns = useMemo(() => [ { title: '图片', @@ -219,10 +278,22 @@ const AccessoryStockPanel = () => { sorter: true, render: int, }, + { + title: , + dataIndex: 'purchaseTransit', + width: 120, + align: 'right', + render: (value) => (value === null || value === undefined || Number(value) === 0 + ? -- + : int(value)), + }, { title: , dataIndex: 'location', width: 88, @@ -353,11 +424,32 @@ const AccessoryStockPanel = () => { - - + + + + + + + + + + - - + + + + + + + + + + + + + + + { + + + 采购在途明细 + + 共 {transitData.total || 0} 条采购单 / 在途 {summary.totalPurchaseTransit || 0} 件 + + + )} + open={transitVisible} + onCancel={() => setTransitVisible(false)} + width={960} + footer={null} + destroyOnHidden + > + + { setTransitKeyword(value); setTransitPage(1); loadPurchaseTransit(1, value, transitSorter); }} + style={{ width: 260 }} + /> +
MKH商品编码MKH商品编码 配件明细 数量 配件仓位{text(item.skuName)}{item.skuId ? {item.skuId} : null} {number(item.qty)}{item.location ? text(item.location) : 未维护}{item.location ? text(stripWarehousePrefix(item.location)) : 未维护}
`${row.skuId}-${row.poiId || row.poId || Math.random()}`} + size="small" + loading={transitLoading} + columns={[ + { + title: '商品编码', dataIndex: 'skuId', width: 176, render: (v) => {text(v)}, + }, + { + title: '商品名称', dataIndex: 'productName', width: 240, ellipsis: true, render: (v) => {text(v)}, + }, + { title: '采购数量', dataIndex: 'purchaseQty', width: 96, align: 'right', render: int, sorter: true }, + { title: '已入库', dataIndex: 'inQty', width: 84, align: 'right', render: int, sorter: true }, + { + title: '在途数量', dataIndex: 'transitQty', width: 96, align: 'right', sorter: true, + render: (value) => {int(value)}, + }, + { + title: '计划到货日期', dataIndex: 'deliveryDate', width: 120, align: 'center', sorter: true, + render: (value) => text(value), + }, + { title: '采购单号', dataIndex: 'poId', width: 116, render: text }, + ]} + dataSource={transitData.rows || []} + pagination={{ + current: transitPage, + pageSize: transitPageSize, + total: transitData.total || 0, + showSizeChanger: true, + pageSizeOptions: [50, 100, 200], + showTotal: (total) => `共 ${total} 条`, + }} + onChange={(pagination, _filters, nextSorter) => { + setTransitPage(pagination.current || 1); + setTransitPageSize(pagination.pageSize || 50); + const nextSort = nextSorter?.field + ? { field: nextSorter.field, order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC' } + : { field: 'transitQty', order: 'DESC' }; + setTransitSorter(nextSort); + loadPurchaseTransit(pagination.current || 1, transitKeyword, nextSort); + }} + scroll={{ x: 860, y: 360 }} + /> + + ); }; @@ -426,7 +587,8 @@ const ModularStockMonitor = () => ( 数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、 库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖); - 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」; + 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准; + 仓位取聚水潭「箱及仓位库存」(jst_packInventoryInfo)的真货位,同商品多箱时显示库存最大的主货位,查不到才显示「未维护」; 图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。 注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天); 库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。 diff --git a/frontend/src/pages/ModularStockMonitor/style.css b/frontend/src/pages/ModularStockMonitor/style.css index 80ea127..6068a4b 100644 --- a/frontend/src/pages/ModularStockMonitor/style.css +++ b/frontend/src/pages/ModularStockMonitor/style.css @@ -87,6 +87,7 @@ .msm-summary-card.is-red::before { background: var(--sycm-color-danger); } .msm-summary-card.is-orange::before { background: var(--sycm-color-orange); } .msm-summary-card.is-green::before { background: var(--sycm-color-success); } +.msm-summary-card.is-blue::before { background: var(--sycm-color-primary); } .msm-summary-card .ant-card-body { display: flex; min-height: 98px; @@ -107,6 +108,15 @@ } .msm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); } +.msm-summary-card.is-clickable { + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.msm-summary-card.is-clickable:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + .msm-panel { margin-bottom: 0; overflow: hidden; } .msm-panel .ant-card-head { min-height: 48px;