feat(配件库存监控/MKH-BOM): 新增「采购在途」数量列与在途明细弹窗,补货预警改按结余库存判定

配件库存监控:
- 列表「实时库存」后新增「采购在途数量」列,口径 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 单测新增结余库存缓冲/紧张用例与回归用例
This commit is contained in:
yanhui 2026-09-18 17:44:01 +08:00
parent be52bdd0b5
commit b7aca2343b
9 changed files with 483 additions and 50 deletions

View File

@ -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 {

View File

@ -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);

View File

@ -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,
};

View File

@ -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)
});
// ── 导出(见服务文件头 ⑨)────────────────────────────────────────

View File

@ -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',

View File

@ -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 }) => {
<table className="mkh-print-table">
<thead>
<tr>
<th style={{ width: '170px' }}>MKH商品编码</th>
<th style={{ width: '190px' }}>MKH商品编码</th>
<th>配件明细</th>
<th style={{ width: '64px' }}>数量</th>
<th style={{ width: '110px' }}>配件仓位</th>
@ -192,7 +207,7 @@ const PrintPreview = ({ data, onClose }) => {
) : null}
<td>{text(item.skuName)}{item.skuId ? <span className="mkh-print-code">{item.skuId}</span> : null}</td>
<td className="mkh-center">{number(item.qty)}</td>
<td className="mkh-center">{item.location ? text(item.location) : <span className="mkh-muted">未维护</span>}</td>
<td className="mkh-center">{item.location ? text(stripWarehousePrefix(item.location)) : <span className="mkh-muted">未维护</span>}</td>
</tr>
))}
{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 = () => {
</Tooltip>
) : <span className="mkh-muted">—</span>),
},
{
// 结余库存(2026-09-18 新增口径):现有库存先扣掉待发货订单,才是可自由支配的库存,
// 补货预警 / 可用天数一律按它算(待发订单为 0 时 ≡ 现有库存)
title: '结余库存',
dataIndex: 'availableStock',
width: 100,
align: 'right',
render: (value, row) => (
<Tooltip
title={Number(row.pendingOrderQty) > 0
? `结余库存 = 现有库存 ${number(row.stock)} − 待发货订单 ${number(row.pendingOrderQty)}`
: '无待发货订单,结余库存 = 现有库存'}
>
<span className={Number(value) <= 0 ? 'mkh-days--warn' : undefined}>{number(value)}</span>
</Tooltip>
),
},
{
title: '可用天数',
dataIndex: 'stockDays',
@ -591,7 +629,15 @@ const MkhBomData = () => {
</Tooltip>
);
}
return text;
return (
<Tooltip title={
Number(row.pendingOrderQty) > 0
? `可用天数 = 结余库存 ${number(row.availableStock)} ÷ 近15天日销 ${number(row.dailySale, 2)}(结余库存已扣待发货订单)`
: `可用天数 = 现有库存 ${number(row.stock)} ÷ 近15天日销 ${number(row.dailySale, 2)}`
}>
{text}
</Tooltip>
);
},
},
{
@ -751,8 +797,10 @@ const MkhBomData = () => {
优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
<br />
补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、
该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近15天日销,低于 3 天即预警,缺货单独标识;
建议补货量 = 3 × 近15天日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。
该仓无记录时回退监控快照库存;<b>结余库存 = 现有库存 − 待发货订单</b>(已下单未发货的部分已被占用,不足 0 取 0),
可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货;
建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。
无日销且无订单的商品不参与预警。
<br />
待发货订单(代发):近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 || '—'}):
其中 <b>{data.outOfStockCount || 0}</b> 个已缺货,
<b>{Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))}</b> 个可用天数不足 {data.replenishDaysThreshold || 3} 天。
建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 − 现有库存。点「只看预警」即为补货清单。
建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。
{(data.orderDrivenCount || 0) > 0 ? (
<>
<br />
@ -937,7 +986,7 @@ const MkhBomData = () => {
pageSizeOptions: [50, 100, 200],
showTotal: (total) => `共 ${total} 条`,
}}
scroll={{ x: 1766, y: bodyHeight }}
scroll={{ x: 1866, y: bodyHeight }}
/>
</div>
</Spin>

View File

@ -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 {

View File

@ -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: <TipTitle
title="采购在途数量"
tip="该商品(配件)全部采购单中『已下单 − 已入库』的未到货数量合计,取自 jst_purchaseItem.qty − inQty"
/>,
dataIndex: 'purchaseTransit',
width: 120,
align: 'right',
render: (value) => (value === null || value === undefined || Number(value) === 0
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: <TipTitle
title="仓位"
tip="业务库 jst_sku.bin / other_bin 全库 55.7 万行均为空,上游未回传库位数据,因此该列暂无值"
tip="取自聚水潭「箱及仓位库存」jst_packInventoryInfo 的真货位(bin),同商品多箱时显示库存最大的主货位;该表查不到才显示「未维护」"
/>,
dataIndex: 'location',
width: 88,
@ -353,11 +424,32 @@ const AccessoryStockPanel = () => {
<Row gutter={[12, 12]} className="msm-summary-row">
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="SKU 总数" value={summary.skuCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-red"><Statistic title="缺货 SKU" value={summary.outOfStockCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-orange"><Statistic title="可用天数不足" value={summary.lowStockDaysCount || 0} suffix="个 SKU" /></Card></Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-red is-clickable" onClick={applySummaryFilter('outOfStock')}>
<Statistic title="缺货 SKU" value={summary.outOfStockCount || 0} />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-orange is-clickable" onClick={applySummaryFilter('lowStockDays')}>
<Statistic title="可用天数不足" value={summary.lowStockDaysCount || 0} suffix="个 SKU" />
</Card>
</Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-green"><Statistic title="实时库存合计" value={summary.totalStock || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="已配置MKH" value={summary.configuredCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="未配置MKH" value={summary.unconfiguredCount || 0} /></Card></Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-blue is-clickable" onClick={openPurchaseTransit}>
<Statistic title="采购在途" value={summary.totalPurchaseTransit || 0} suffix="件" />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-clickable" onClick={applySummaryFilter('configured')}>
<Statistic title="已配置MKH" value={summary.configuredCount || 0} />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-clickable" onClick={applySummaryFilter('unconfigured')}>
<Statistic title="未配置MKH" value={summary.unconfiguredCount || 0} />
</Card>
</Col>
</Row>
<Card
@ -411,6 +503,75 @@ const AccessoryStockPanel = () => {
</div>
</Spin>
</Card>
<Modal
title={(
<Space>
<span>采购在途明细</span>
<Text type="secondary" style={{ fontSize: 13 }}>
共 {transitData.total || 0} 条采购单 / 在途 {summary.totalPurchaseTransit || 0} 件
</Text>
</Space>
)}
open={transitVisible}
onCancel={() => setTransitVisible(false)}
width={960}
footer={null}
destroyOnHidden
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Input.Search
allowClear
placeholder="商品编码 / 名称"
defaultValue={transitKeyword}
onSearch={(value) => { setTransitKeyword(value); setTransitPage(1); loadPurchaseTransit(1, value, transitSorter); }}
style={{ width: 260 }}
/>
<Table
rowKey={(row) => `${row.skuId}-${row.poiId || row.poId || Math.random()}`}
size="small"
loading={transitLoading}
columns={[
{
title: '商品编码', dataIndex: 'skuId', width: 176, render: (v) => <span className="msm-sku">{text(v)}</span>,
},
{
title: '商品名称', dataIndex: 'productName', width: 240, ellipsis: true, render: (v) => <Tooltip title={v}>{text(v)}</Tooltip>,
},
{ 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) => <strong style={{ color: '#1677ff' }}>{int(value)}</strong>,
},
{
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 }}
/>
</Space>
</Modal>
</>
);
};
@ -426,7 +587,8 @@ const ModularStockMonitor = () => (
<Text type="secondary" className="msm-note">
数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、
库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖);
当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」;
当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;
仓位取聚水潭「箱及仓位库存」(jst_packInventoryInfo)的真货位,同商品多箱时显示库存最大的主货位,查不到才显示「未维护」;
图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。
注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天);
库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。

View File

@ -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;