diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 001fa31..0287648 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -240,24 +240,27 @@ // ⚠️ 配件库存**不适用本口径**(配件仍只算 15718196)——新仓一个配件都没有, // 横林三仓/沭阳仓虽有 MKH 配件但归属别的项目,并入会让「配件不足」漏报。 // -// ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── +// ── ⑧ 在途加工单(2026-09-17 业务确认口径;2026-11-11 换数据源)────────── // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** // (工厂已经在做了,不重复提示补货),改标为「已排产」。 -// · 数据源:业务库同实例的链接服务器 -// LS_JS_DMP → JSDataMiddlePlatform.dbo.bi_processing_work_orders -// (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表) -// · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓' -// AND 状态 <> '作废' ← 作废单据一律不核算 -// AND max(数量 − 已入库数量, 0) > 0 ← 只认真实剩余量 -// · 在途加工量 = Σ max(数量 − 已入库数量, 0),按 商品编码 归集。 -// 不再依赖「收货状态」文本:上游状态字段延迟时,已入库数量已追平的单据必须立即退出在途。 -// ⚠️ 为什么走 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-11-11 起):聚水潭原生加工单三表,主键 `po_id` 关联 +// rpt.jst_manufactureInfo 加工单单头:status / wms_co_id(加工仓) / receipt_wms_co_id(收货仓) / po_date +// rpt.jst_manufactureItem 加工单**成品**明细:sku_id / qty(计划数) / io_qty(已入库) / delivery_date +// rpt.jst_manufactureRaw 加工单**原料**明细:qty(暂未参与在途核算,配件占用走 BOM 单台用量) +// · 过滤:i.wms_co_id = 15718196(简尚-崔桥工厂半成品包装项目仓,产出入品牌仓 15870495) +// AND i.status <> 'Cancelled' ← 作废单不核算(实测 178 单 7,515 件,不过滤会虚增几十倍) +// AND it.qty > ISNULL(it.io_qty,0) ← 只认真实剩余量 +// · 在途加工量 = Σ(qty − io_qty),按成品编码 sku_id 归集;ETA 取 delivery_date 最小值。 +// 状态值域实测(2026-11-11):Finished 1,149 单 / Cancelled 178 / **Confirmed 18**; +// Finished 的 1,173 行明细**零剩余**(io_qty 已追平),Confirmed 的 18 行 io_qty 全 0。 +// → 无需依赖状态文本判「完成」,剩余量判据自身就够;状态只用来剔作废。 +// ⚠️ **换源原因**:旧源是链接服务器 `LS_JS_DMP`(192.168.1.209) 的快照表 +// `JSDataMiddlePlatform.dbo.bi_processing_work_orders`,自 2026-11-03 10:49 起**停更 8 天** +// (1,370 行 / MAX(Id)=1370 不再增长,而此前每天都有 5~34 行),且**不会报错** —— +// `workOrderDegraded` 只反映「查询失败」,数据陈旧永远是 false, +// 于是页面长期显示 9 个 11-03 的僵尸单(业务在聚水潭看到的是 18 个新单)。 +// 换成本实例内的原生三表后随主库小时级刷新,不再依赖那台内网机器的同步任务。 +// 实测(2026-11-11):18 单 / 18 个成品 / 在途 175 件,ETA 全可解析(11-24 ~ 11-25)。 // // ── ⑨ 导出(2026-09-17)────────────────────────────────────────── // 导出与列表**共用同一套筛选/排序口径**(filterRows + sortRows),区别只有两点: @@ -465,15 +468,25 @@ const WARNING_LEVEL = Object.freeze({ }); // ── 在途加工单参数(见文件头 ⑧)────────────────────────────────── -// 加工仓储方:加工单的加工地,即半成品包装项目仓(产出入品牌仓) +// 加工仓储方:加工单的加工地,即半成品包装项目仓(产出入品牌仓)。仅用于文案展示。 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'; -// 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量 +// 加工仓 wms_co_id(半成品包装项目仓):SQL 侧按它过滤。 +// ⚠️ 用 ID 而不是中文名 —— 名称可能被上游改写,ID 稳定;实测该仓加工单收货仓恒为品牌仓 15870495。 +const WORK_ORDER_WMS_CO_ID = 15718196; +// 作废状态:作废单据不参与在途量核算(聚水潭加工单 status 值域 Confirmed / Finished / Cancelled) +const WORK_ORDER_VOID_STATUS = 'Cancelled'; +// 完成状态:已全部入库 +const WORK_ORDER_RECEIVED_STATUS = 'Finished'; +// 聚水潭原生加工单三表(rpt schema 视图,主键 po_id): +// Info = 单头(status / wms_co_id / po_date) +// Item = 成品明细(sku_id / qty / io_qty 已入库 / delivery_date 预计到货) +// Raw = 原料明细(暂未参与在途核算,配件占用走 BOM 单台用量) +// ⚠️ 2026-11-11 换源:旧的链接服务器 LS_JS_DMP 快照表自 11-03 起停更 8 天且**不会报错** +// (`workOrderDegraded` 只反映查询失败),导致页面 9 个僵尸单 + 新单全丢。 +// 这三张表在业务库本实例内,可走 cloudDatabase.query()(表名已带 rpt.,不会被重写坏)。 +const WORK_ORDER_INFO_TABLE = 'rpt.jst_manufactureInfo'; +const WORK_ORDER_ITEM_TABLE = 'rpt.jst_manufactureItem'; +// 加工单不可用时的降级缓存时长:短一点,避免长时间带着错误在途量 const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000; // ── 订单参数(见文件头 ⑩ 待发货 / ⑦-a 近15天已发货)────────────── @@ -1392,8 +1405,10 @@ async function loadMkhProductIndex() { /** * 未完成加工单(在途量)索引 —— 进程内缓存 { bySku: Map<归一化编码, 在途指标>, degraded }。 * 只保留「半成品包装项目仓加工、未完成、非作废」的加工单,口径见文件头 ⑧。 - * ⚠️ 该数据源是链接服务器,取不到时**降级为空索引**(补货预警照常跑), - * 并缩短缓存避免长时间带着错误在途量。degraded=true 会被透出到接口。 + * 数据源:聚水潭原生加工单三表 —— jst_manufactureInfo(单头)+ jst_manufactureItem(成品明细), + * PO 关联键 po_id;剩余量 = qty − io_qty(已入库数量)。 + * ⚠️ 取不到时**降级为空索引**(补货预警照常跑),并缩短缓存避免长时间带着错误在途量。 + * degraded=true 会被透出到接口。 */ async function loadPendingWorkOrderIndex() { const now = Date.now(); @@ -1403,43 +1418,26 @@ async function loadPendingWorkOrderIndex() { 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); - const result = await request.query(` - SELECT [商品编码] AS skuId, - SUM(CASE - WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) - THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4)) - ELSE 0 - END) AS pendingQty, - COUNT(DISTINCT CASE - WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) - THEN [加工单号] - END) AS orderCount, - MIN(CASE - WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) - THEN TRY_CONVERT(DATETIME, [预计加工完成时间], 111) - END) AS eta, - MIN(CASE - WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) - THEN CAST([加工单号] AS NVARCHAR(40)) - END) AS firstOrderNo - FROM ${WORK_ORDER_TABLE} WITH (NOLOCK) - WHERE [加工仓储方] = @workOrderWarehouse - AND [状态] <> @voidStatus - GROUP BY [商品编码] - HAVING SUM(CASE - WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) - THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4)) - ELSE 0 - END) > 0`); + // 表名已带 rpt. 前缀,`query()` 的 dbo→rpt 重写不会碰它,无需 getPool。 + // Finished 单的 io_qty 已追平(实测 1,173 行零剩余),靠 `qty > io_qty` 自然退出,不依赖状态文本。 + const rows = await cloudDatabase.query( + `SELECT it.sku_id AS skuId, + SUM(it.qty - ISNULL(it.io_qty, 0)) AS pendingQty, + COUNT(DISTINCT i.po_id) AS orderCount, + MIN(TRY_CONVERT(DATETIME, it.delivery_date, 111)) AS eta, + MIN(CAST(i.po_id AS NVARCHAR(40))) AS firstOrderNo + FROM ${WORK_ORDER_ITEM_TABLE} it WITH (NOLOCK) + JOIN ${WORK_ORDER_INFO_TABLE} i WITH (NOLOCK) ON i.po_id = it.po_id + WHERE i.wms_co_id = @workOrderWmsCoId + AND i.status <> @voidStatus + AND it.qty > ISNULL(it.io_qty, 0) + GROUP BY it.sku_id`, + { workOrderWmsCoId: WORK_ORDER_WMS_CO_ID, voidStatus: WORK_ORDER_VOID_STATUS }, + { requestTimeout: 180000 } + ); const bySku = new Map(); - (result.recordset || []).forEach((row) => { + (rows || []).forEach((row) => { const key = normalizeCode(row.skuId); if (!key) return; const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0); @@ -2349,7 +2347,7 @@ async function getMkhBomList(query = {}) { operationalRefreshedAt: operationalRefreshedAt ? new Date(operationalRefreshedAt).toISOString() : null, - // 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理 + // 加工单数据源不可用(查询失败)时为 true,此时在途量按 0 处理 workOrderDegraded: replenishIndex.workOrderDegraded === true, workOrderWarehouse: WORK_ORDER_WAREHOUSE, // 待发货订单数据源不可用时为 true,此时待发货量按 0 处理(退化为旧口径) @@ -2733,8 +2731,11 @@ module.exports = { SCOPES, WARNING_LEVEL, WORK_ORDER_WAREHOUSE, + WORK_ORDER_WMS_CO_ID, WORK_ORDER_VOID_STATUS, WORK_ORDER_RECEIVED_STATUS, + WORK_ORDER_INFO_TABLE, + WORK_ORDER_ITEM_TABLE, EXPORT_MODES, buildProductExportRows, buildComponentExportRows, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 9388a20..26fa87b 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -395,8 +395,12 @@ test('replenish: products without sales stay "no sale" even with a pending work 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, '全部入库'); + // 2026-11-11 换源:聚水潭原生加工单三表(po_id 关联),状态值域为英文 + assert.equal(_private.WORK_ORDER_WMS_CO_ID, 15718196); + assert.equal(_private.WORK_ORDER_VOID_STATUS, 'Cancelled'); + assert.equal(_private.WORK_ORDER_RECEIVED_STATUS, 'Finished'); + assert.equal(_private.WORK_ORDER_INFO_TABLE, 'rpt.jst_manufactureInfo'); + assert.equal(_private.WORK_ORDER_ITEM_TABLE, 'rpt.jst_manufactureItem'); assert.equal(_private.WARNING_LEVEL.SCHEDULED, '已排产'); });