Compare commits
2 Commits
b0b618e7df
...
ada8d09c27
| Author | SHA1 | Date | |
|---|---|---|---|
| ada8d09c27 | |||
| a93691f5d8 |
@ -51,15 +51,18 @@
|
||||
// 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。
|
||||
// · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(),
|
||||
// 只打一条查询,按编码缓存 10 分钟。
|
||||
// ⚠️ 关联列别踩(2026-09-17 实测):仓名必须用 `jst_warehouseInfo.Id = p.wh_id`;
|
||||
// 该表 `wms_co_id` **全表 NULL**,用 `w.wms_co_id = p.wms_co_id` 关联 85,152 行全部取不到仓名。
|
||||
// ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱**
|
||||
// (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、
|
||||
// wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**。
|
||||
// 且表内容与 MKH 配件**零交集**:正式 BOM 的 715 个配件编码在该表一个都查不到
|
||||
// (精确 + `code-%` 前缀都是 0;箱表里是 DNZ0135xx/0199xx/0203xx 老型号半成品)。
|
||||
// → MKH 配件仓位列当前**必然全部显示「未维护」**,这是数据源现状,不是代码问题;
|
||||
// 需业务在聚水潭给配件做装箱/维护货位后才会自动出现(代码不用再动)。
|
||||
// ⚠️ 关联列(09-19 修正,PR #22):仓名用 `jst_warehouseInfo.wms_co_id = p.wms_co_id`
|
||||
// —— 09-19 实测该表 30,236 行 `wms_co_id` **0 NULL、14 个仓取值**。
|
||||
// ~~旧结论「该表 wms_co_id 全表 NULL,必须用 jst_warehouseInfo.Id = p.wh_id」~~
|
||||
// 只对 09-17 的表状态成立;误用 `w.Id = p.wh_id`(wh_id 仅 6 个取值、90% 为 1)
|
||||
// 会让 727 个配件的仓位仓名被统一显示成「常州简尚家居有限公司」。
|
||||
// 🔥 该表 **小时级增量刷新**,且 2026-09-18 被上游整表重建 ——
|
||||
// 09-17 的「全表 85,152 行、仅 3 个箱、`bin` / `bin_group` 全表为空、与 MKH 配件
|
||||
// **零交集**」这组结论**已全部失效**,别再照抄。
|
||||
// 09-19 实测:目标仓 794 个 SKU 命中 **727** 个(真货位如 ZT-8 / BCP01-01-1);
|
||||
// 2026-09-30 实测:本配件仓(15718196)箱表有库存(bin 非空)的 SKU **734** 个。
|
||||
// → 配件货位 / 箱库存数据是真实存在并持续刷新的;
|
||||
// 该结论同时修正 ⑪ 的配件库存来源(箱表可作兜底,见下)。
|
||||
//
|
||||
// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。
|
||||
//
|
||||
@ -303,13 +306,22 @@
|
||||
// ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件
|
||||
// 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集:
|
||||
// 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量)
|
||||
// · 配件现有库存 rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓
|
||||
// (wms_co_id = 15718196)—— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。
|
||||
// · 配件现有库存 **两级来源**(2026-09-30 修正,与配件监控页 `buildRealtimeStockSql` 同口径):
|
||||
// ① rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓(wms_co_id = 15718196)
|
||||
// —— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。
|
||||
// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**,
|
||||
// 跨仓正库存为 0,所以不跨仓汇总。
|
||||
// ② 同仓 rpt.jst_packInventoryInfo 的 **bin 非空** 行合计 —— **仅当 ① 为 0 / 缺行时启用**。
|
||||
// 🔥 **推翻旧结论**(旧版此处写「不能用 jst_packInventoryInfo 当配件库存 ——
|
||||
// 与 MKH 配件零交集」,那是 09-17 的表状态,09-18 上游重建后已失效,见 ⑤)。
|
||||
// 漏掉这一级会让「库存表滞后停在 0、箱表已有货」的配件被误判不足 ——
|
||||
// 2026-09-30 案例 MKHDNZ0409260600849 的配件 DNZ040947N1440V01:
|
||||
// inventoryInfo 停在 09-28 值 0,箱表当日 10:30 已记 bin=ZT-30 qty=200;
|
||||
// 配件监控页显示 200(判「正常」),MKH-BOM 却因 0 判「配件不足」。
|
||||
// 当日 30 行配件不足预警里 **7 行**属此类误判(可组装数 0 → 30~46)。
|
||||
// ⚠️ 必须过滤 `bin` 为空的行 —— 那是装箱记录不是库存(见 ⑤);
|
||||
// 且**只补 0、不覆盖正库存**(箱表可能只记部分装箱量,覆盖会压低有效库存)。
|
||||
// · 单台用量 rm_qty(jst_skuBomItem)实测取值为 1 / 2 / 4,必须参与计算,不能当 1。
|
||||
// ⚠️ 不能用 `jst_packInventoryInfo` 当配件库存 —— 它是「箱及仓位库存」,
|
||||
// 与 MKH 配件零交集(见 ⑤),仓位口径专用。
|
||||
// · 采购在途(purchase_qty)**不计入**可用量(业务确认:只认现有库存)。
|
||||
// 锁定列(order_lock / pick_lock / lock_qty / virtual_qty)实测几乎全为 0
|
||||
// (本页 80 个配件里仅 2 条非零且值为 1),故直接用 qty。
|
||||
@ -1845,6 +1857,10 @@ async function loadReplenishIndex() {
|
||||
/**
|
||||
* 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。
|
||||
* · 按基础配件编码精确匹配,SUM(qty) 汇总
|
||||
* · **两级来源**(与配件监控页 buildRealtimeStockSql 同口径):
|
||||
* ① jst_inventoryInfo 有正库存 → 以它为准
|
||||
* ② 为 0 / 缺行 → 用同仓 jst_packInventoryInfo 的 **bin 非空** 行合计兜底
|
||||
* (2026-09-30 修正:旧版只查 ①,导致「库存表滞后为 0、箱表已有货」的配件被误判不足)
|
||||
* · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存)
|
||||
* · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标),
|
||||
* 且失败的分片不写缓存,下次调用重试
|
||||
@ -1872,25 +1888,42 @@ async function loadComponentStock(codes = []) {
|
||||
params[`cs${index}`] = code;
|
||||
return `@cs${index}`;
|
||||
});
|
||||
const codeFilter = `IN (${placeholders.join(', ')})`;
|
||||
// 一条语句同时取两张表(UNION ALL + src 标记),避免多一次往返、也保证两个来源同一时点。
|
||||
// 库存表不筛 bin;箱表**必须**筛 bin 非空 —— bin 为空的是装箱记录,不是库存(见文件头 ⑤)。
|
||||
// eslint-disable-next-line no-await-in-loop -- 有意分片串行,避免一次打满连接池
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT i.sku_id AS code, SUM(i.qty) AS qty
|
||||
`SELECT N'inv' AS src, i.sku_id AS code, SUM(i.qty) AS qty
|
||||
FROM dbo.jst_inventoryInfo i WITH (NOLOCK)
|
||||
WHERE i.wms_co_id = @componentStockWmsCoId
|
||||
AND i.sku_id IN (${placeholders.join(', ')})
|
||||
GROUP BY i.sku_id`,
|
||||
WHERE i.wms_co_id = @componentStockWmsCoId AND i.sku_id ${codeFilter}
|
||||
GROUP BY i.sku_id
|
||||
UNION ALL
|
||||
SELECT N'pack' AS src, p.sku_id AS code, SUM(p.qty) AS qty
|
||||
FROM dbo.jst_packInventoryInfo p WITH (NOLOCK)
|
||||
WHERE p.wms_co_id = @componentStockWmsCoId AND p.sku_id ${codeFilter}
|
||||
AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL
|
||||
GROUP BY p.sku_id`,
|
||||
params,
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
const found = new Map();
|
||||
const inventoryQty = new Map();
|
||||
const packQty = new Map();
|
||||
rows.forEach((row) => {
|
||||
const code = toText(row.code);
|
||||
if (code) found.set(code, toNumber(row.qty) ?? 0);
|
||||
if (!code) return;
|
||||
const qty = toNumber(row.qty) ?? 0;
|
||||
if (toText(row.src) === 'pack') packQty.set(code, qty);
|
||||
else inventoryQty.set(code, qty);
|
||||
});
|
||||
const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS;
|
||||
const writable = epoch === cacheEpoch;
|
||||
chunk.forEach((code) => {
|
||||
const qty = found.get(code) ?? 0;
|
||||
// 只补 0、不覆盖正库存:库存表有正数就认它,否则看箱表(箱表可能只记部分装箱量)
|
||||
let qty = inventoryQty.get(code) ?? 0;
|
||||
if (qty <= 0) {
|
||||
const packStock = packQty.get(code) ?? 0;
|
||||
if (packStock > 0) qty = packStock;
|
||||
}
|
||||
// 本批结果仍要返回给调用方(stockMap),只是不再写缓存
|
||||
if (writable) componentStockCache.set(code, { qty, expiresAt });
|
||||
stockMap.set(code, qty);
|
||||
|
||||
@ -1286,6 +1286,117 @@ test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', asyn
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 配件库存的箱表兜底(2026-09-30 修正)
|
||||
//
|
||||
// 症状:配件监控页显示该配件有 200 件(货位 ZT-30、正常),MKH-BOM 却把成品
|
||||
// MKHDNZ0409260600849 判成「配件不足」、可组装数 0。
|
||||
// 成因:配件监控页是「库存表 → 同仓箱表 → 快照」三级兜底,而 MKH-BOM 的
|
||||
// loadComponentStock **只查 jst_inventoryInfo**。上游库存表滞后停在 0 时,
|
||||
// 箱表(小时级)已经有货 → 两个页面结论相反。
|
||||
// 口径:与配件监控页 buildRealtimeStockSql 对齐 —— 库存表有正数就认它,
|
||||
// 为 0 / 缺行才用同仓 jst_packInventoryInfo 的 bin 非空行合计兜底。
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('配件库存兜底:库存表为 0 / 缺行时用同仓箱表补,但正库存不被覆盖', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
|
||||
const ONLY_PACK = 'DNZTESTPACK01'; // 库存表 0、箱表 200 → 必须取 200(误判就是这里)
|
||||
const HAS_INV = 'DNZTESTPACK02'; // 库存表 5、箱表 300 → 必须取 5(箱表只记部分装箱量)
|
||||
const PACK_ZERO = 'DNZTESTPACK03'; // 库存表 0、箱表 0 → 0
|
||||
const NEITHER = 'DNZTESTPACK04'; // 两表都没有 → 0
|
||||
|
||||
const INV_ROWS = [ // 库存表:滞后停在旧值
|
||||
{ code: ONLY_PACK, qty: 0 },
|
||||
{ code: HAS_INV, qty: 5 },
|
||||
{ code: PACK_ZERO, qty: 0 },
|
||||
];
|
||||
const PACK_ROWS = [ // 同仓箱表(bin 非空):小时级,已反映真实到货
|
||||
{ code: ONLY_PACK, qty: 200 },
|
||||
{ code: HAS_INV, qty: 300 },
|
||||
{ code: PACK_ZERO, qty: 0 },
|
||||
];
|
||||
cloudDatabase.query = async (sql) => {
|
||||
if (!sql.includes('jst_inventoryInfo')) return [];
|
||||
// 按 SQL 形状分流:旧实现只查库存表(SQL 里没有箱表名)→ 拿不到箱表数据,
|
||||
// 兜底失效、ONLY_PACK 被判 0(对照组 = 修复前必然失败)。
|
||||
if (!sql.includes('jst_packInventoryInfo')) return INV_ROWS;
|
||||
return [
|
||||
...INV_ROWS.map((row) => ({ src: 'inv', ...row })),
|
||||
...PACK_ROWS.map((row) => ({ src: 'pack', ...row })),
|
||||
];
|
||||
};
|
||||
|
||||
try {
|
||||
svc.clearMkhBomCache();
|
||||
const { stockMap, degraded } = await svc._private.loadComponentStock([
|
||||
ONLY_PACK, HAS_INV, PACK_ZERO, NEITHER,
|
||||
]);
|
||||
assert.equal(degraded, false);
|
||||
assert.equal(stockMap.get(ONLY_PACK), 200,
|
||||
'库存表为 0 时必须用箱表兜底,否则配件被误判不足(本问题的根因)');
|
||||
assert.equal(stockMap.get(HAS_INV), 5,
|
||||
'库存表有正库存时必须以它为准,不能被箱表覆盖');
|
||||
assert.equal(stockMap.get(PACK_ZERO), 0, '箱表也是 0 时不能补出正数');
|
||||
assert.equal(stockMap.get(NEITHER), 0, '两表都查不到 = 该仓无库存,仍按 0');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('配件库存兜底:单条查询取两张表,且箱表必须过滤 bin 为空的行(防腐化)', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
|
||||
const seen = [];
|
||||
cloudDatabase.query = async (sql) => {
|
||||
if (!sql.includes('jst_inventoryInfo')) return [];
|
||||
seen.push(sql);
|
||||
return [];
|
||||
};
|
||||
|
||||
try {
|
||||
svc.clearMkhBomCache();
|
||||
await svc._private.loadComponentStock(['DNZTESTPACK05']);
|
||||
assert.equal(seen.length, 1, '一个分片只应打一条查询(避免多一次往返与快照时点差异)');
|
||||
const sql = seen[0];
|
||||
assert.match(sql, /jst_packInventoryInfo/, '必须查箱表,否则兜底不存在');
|
||||
assert.match(sql, /NULLIF\(LTRIM\(RTRIM\(p\.bin\)\), N''\)\s+IS NOT NULL/,
|
||||
'箱表必须过滤 bin 为空的行 —— 那是装箱记录,不是库存');
|
||||
assert.match(sql, /p\.wms_co_id = @componentStockWmsCoId/,
|
||||
'箱表必须限定配件归属仓,不能跨仓汇总');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('配件库存兜底:查询失败仍降级为「不做判定」,不得当成 0', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
|
||||
const CODE = 'DNZTESTPACK06';
|
||||
cloudDatabase.query = async (sql) => {
|
||||
if (!sql.includes('jst_inventoryInfo')) return [];
|
||||
throw new Error('模拟连接抖动');
|
||||
};
|
||||
|
||||
try {
|
||||
svc.clearMkhBomCache();
|
||||
const { stockMap, degraded } = await svc._private.loadComponentStock([CODE]);
|
||||
assert.equal(degraded, true, '查询失败必须降级,让调用方放弃配件不足判定(宁可少标不误标)');
|
||||
assert.equal(stockMap.get(CODE), undefined, '失败时不得把未知库存当作 0 返回');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user