需求(yanhui 2026-10-20):在 MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」, 下列 11 个主仓(横林二仓、横林三仓、横林二仓品牌仓、崔桥工厂仓、崔桥工厂品牌仓、 崔桥工厂半成品仓、新苏北仓、睢宁仓、山东青岗集仓、山东品牌仓、河南洛宁仓), 从系统库存抓取已发货/未发货订单,按订单创建时间判定发货是否及时: · 每天 12:00 核验「当天创建时间 7:00 之前」的订单是否已完成发货 · 每天 16:00 核验「当天创建时间 16:00 之前」的订单是否已完成发货 已确认的三条设计决策: 1. 子仓归属 → 连子仓一起算(京东云仓/自营仓/运营C专用仓/唯品仓等并入主仓) 2. 崔桥工厂半成品仓 → 系统无同名仓,先留空占位(不映射到配件仓 15718196) 3. 展示结构 → 只显示超时未发 实现要点 - 仓库归属用「关键词最长匹配」(不写死 wms_co_id),新增子仓只要名字含主仓关键词 就自动归入,缓存 1h。实测 28 个子仓全部正确归入 11 个主仓,132 个代发仓零误并。 - 12:00 之前**不做核验**:resolveActiveRound 返回 null,服务层直接返回 notStarted, 既不查订单表(接口 165ms)也不回填昨天的数据。前端显示「未到核验时间」+ 「12:00 开始首轮核验(核验当日 07:00 前创建的订单)」,11 个仓结构保留、数字显示「—」。 - 订单取数:两张表店铺互斥(jst_Orders = 京东/拼多多/抖音/得物/唯品会, jst_qmcust_orderlist = 天猫/淘宝/1688),并行分开查 + Node 侧按 o_id 去重合并, 用 Promise.allSettled 保证单表失败时另一表数据仍可用(detailDegraded 标记)。 - 三态判定 pending / shippedLate / onTime:send_date 与出库表 io_date 取先到的发货痕迹, 避免上游 send_date 回写滞后导致「已发的被判成没发」。 - 状态过滤:排除 Cancelled / Merged / **Split**(拆单父单永不发货,不排除会捞出一年前 1.3 万条历史单),Question 仍计入待发。 - freshness 字段如实报告出库表最新 io_date,前端「发货数据可能滞后」告警, 不拿一份可能滞后的数据去考核仓库。 排除的两次错误优化(均在代码注释里留了实测数据与复测脚本路径) 1. 用 created 替代 order_date 做窗口过滤(快 10 倍)—— 已证伪:2581 行里完全相等的只有 4 行, 差 >1h 的 412 行,198 行跨日;典型形态 order_date=10-18 23:58 而 created=10-19 04:00:08 (上游凌晨 4 点批量补录,created 是灌库时间不是下单时间)。业务口径是「订单创建时间」, 只有 order_date 正确。 2. 「UNION ALL 一条 SQL 慢 5~8 倍」—— 被后续实测推翻(46.3s vs 并行 48.2s,基本持平)。 验证 - 新增 backend/test/warehouseShipTimeliness.test.js,10 条用例全绿,覆盖: 最长匹配不误并、子仓命名、山东/河南高频前缀不误并、占位仓、12:00 前返回 null、 12:00 轮窗口 00:00~07:00、16:00 轮窗口 00:00~16:00、状态排除表、三态分桶互斥、 跨表重复 o_id 去重、round.windowEnd 必须带出(否则前端渲染成「核验 前」)。 - check-syntax 185 文件通过;全量 node --test 415 项 / 413 过 / 2 fail, 两条失败均为已知环境因素(负缓存带 TTL 因 .env 有 CLOUD_DB_* 短路; resilientSecretStore 的 Windows 权限断言,基线恒失败)。 - 真渲染验收(组件级沙盒 + 真实接口数据):两态均 0 控制台错误; 已核验态 358 单超时未发、8 个仓有数字、点仓切换选中/再点取消均正常; 多分辨率验证 1920/1600 侧栏 208px、1440/1366 收窄 176px、列表始终竖向。
234 lines
11 KiB
JavaScript
234 lines
11 KiB
JavaScript
/**
|
||
* 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 新增)
|
||
*
|
||
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定四条关键语义:
|
||
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走,
|
||
* 否则品牌仓的订单会被重复计入两个仓;
|
||
* ② 核验轮次的时刻选择 —— 12:00 前**不做核验(返回 null)**、12:00~15:59 用
|
||
* 7:00 窗口、16:00 后用 16:00 窗口;
|
||
* ③ 状态排除表 —— Cancelled / Merged / Split 必须排除(Split 是拆单父单,
|
||
* 永不发货,不排除会把一年前的单全算成超时未发);
|
||
* ④ 服务层分桶算术 —— pending/shippedLate/onTime 三态互斥,按时率不为负。
|
||
*
|
||
* ⚠️ 本文件**不连数据库**,纯逻辑测试。
|
||
*/
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const registry = require('../src/services/warehouseShipTimelinessRegistry');
|
||
|
||
// ── ① 主仓归属:最长匹配 ────────────────────────────────────────────────
|
||
test('resolveCenter 用最长关键词匹配,品牌仓不会被主仓抢走', () => {
|
||
assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND');
|
||
assert.equal(registry.resolveCenter('简尚-横林二仓').key, 'HL2');
|
||
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND');
|
||
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓').key, 'CQ');
|
||
assert.equal(registry.resolveCenter('简尚-山东品牌仓').key, 'SD_BRAND');
|
||
assert.equal(registry.resolveCenter('简尚-山东青岗集仓').key, 'SD_QGJ');
|
||
});
|
||
|
||
test('resolveCenter 能捞到各种命名的子仓', () => {
|
||
const cases = [
|
||
['京仓-横林二仓京东云仓', 'HL2'],
|
||
['简尚-运营C横林二仓专用仓', 'HL2'],
|
||
['简尚-横林二仓京东仓', 'HL2'],
|
||
['简尚-崔桥工厂仓唯品仓', 'CQ'],
|
||
['简尚-运营C崔桥工厂仓专用仓', 'CQ'],
|
||
['简尚-新苏北临时仓', 'XSB'],
|
||
['简尚-分销部睢宁专用仓', 'SN'],
|
||
['京仓-山东青岗集京东仓储', 'SD_QGJ'],
|
||
['简尚-山东青岗集仓马来海外仓', 'SD_QGJ'],
|
||
['简尚-河南洛宁仓', 'HN_LN'],
|
||
];
|
||
for (const [name, expected] of cases) {
|
||
assert.equal(registry.resolveCenter(name)?.key, expected, name);
|
||
}
|
||
});
|
||
|
||
test('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => {
|
||
// 这是最容易翻车的地方:仓库表里有上百个「工厂-山东XX代发仓」,
|
||
// 如果关键词写成「山东」就会全被并进山东品牌仓/青岗集仓。
|
||
const shouldBeNull = [
|
||
'工厂-山东菏泽森美代发仓',
|
||
'工厂-山东恒思代发仓',
|
||
'工厂-河南千优代发仓',
|
||
'工厂-河北霸州派雅世佳代发仓',
|
||
'简尚-苏州福利品仓',
|
||
'简尚-崔桥工厂半成品包装项目仓',
|
||
];
|
||
for (const name of shouldBeNull) {
|
||
assert.equal(registry.resolveCenter(name), null, `${name} 不应归属任何主仓`);
|
||
}
|
||
});
|
||
|
||
test('崔桥工厂半成品仓是占位仓:没有关键词、匹配不到任何仓', () => {
|
||
const c = registry.CENTERS.find((x) => x.key === 'CQ_WIP');
|
||
assert.ok(c, 'CQ_WIP 必须存在(业务要求列出来)');
|
||
assert.equal(c.placeholder, true);
|
||
assert.deepEqual(c.keywords, []);
|
||
});
|
||
|
||
// ── ② 核验轮次 ──────────────────────────────────────────────────────────
|
||
const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区)
|
||
|
||
test('12:00 前不做核验,返回 null(不回填昨日数据)', () => {
|
||
// 业务口径(2026-10-20 确认):中午 12:00 才开始第一轮核验,
|
||
// 12:00 之前页面显示「未到核验时间」,**不能用昨天的结算结果冒充今天**。
|
||
assert.equal(registry.resolveActiveRound(at(9, 30)), null);
|
||
assert.equal(registry.resolveActiveRound(at(11, 59)), null);
|
||
assert.equal(registry.resolveActiveRound(at(0, 5)), null);
|
||
});
|
||
|
||
test('12:00~15:59 用 12:00 轮,核验窗口是当天 00:00~07:00', () => {
|
||
const r = registry.resolveActiveRound(at(12, 0));
|
||
assert.ok(r);
|
||
assert.equal(r.key, 'NOON');
|
||
assert.equal(r.deadline, '2026-10-20 12:00:00');
|
||
assert.equal(r.windowStart, '2026-10-20 00:00:00');
|
||
assert.equal(r.windowEnd, '2026-10-20 07:00:00');
|
||
});
|
||
|
||
test('16:00 之后用 16:00 轮,核验窗口扩到当天 00:00~16:00', () => {
|
||
const r = registry.resolveActiveRound(at(16, 0));
|
||
assert.equal(r.key, 'AFTERNOON');
|
||
assert.equal(r.deadline, '2026-10-20 16:00:00');
|
||
assert.equal(r.windowEnd, '2026-10-20 16:00:00');
|
||
|
||
// 深夜仍沿用当天 16:00 轮(午夜后又回到新的一天)
|
||
assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON');
|
||
});
|
||
|
||
// ── ③ 状态排除 ──────────────────────────────────────────────────────────
|
||
test('排除 Cancelled / Merged / Split,但保留 Question(仍算待发)', () => {
|
||
const sql = registry.EXCLUDED_STATUS_SQL;
|
||
assert.match(sql, /Cancelled/);
|
||
assert.match(sql, /Merged/);
|
||
assert.match(sql, /Split/);
|
||
assert.doesNotMatch(sql, /Question/);
|
||
});
|
||
|
||
// ── ④ 服务层分桶(注入假查询,不连库)──
|
||
function stubDb(dbPath, realDb) {
|
||
const wmsId = 15038426;
|
||
// 重构后服务层是「一趟取回窗口内全部订单,Node 侧分桶」,
|
||
// 所以 stub 要返回**明细行**而不是聚合行。三态各造几条:
|
||
// · 超时未发 3 单 / 合计 300
|
||
// · 已发但超时 1 单(send_date 晚于 16:00)
|
||
// · 按时 6 单(send_date 早于 16:00)
|
||
const mk = (sendDate, amount, oId) => ({
|
||
src: 'jst_Orders',
|
||
wms_co_id: wmsId,
|
||
o_id: oId,
|
||
so_id: String(oId),
|
||
shop_name: '测试店',
|
||
order_date: '2026-10-20 01:00:00',
|
||
send_date: sendDate,
|
||
status: 'Delivering',
|
||
shop_status: '',
|
||
pay_amount: amount,
|
||
labels: '',
|
||
ob_io_date: null,
|
||
});
|
||
|
||
const stubRows = [
|
||
mk(null, 100, 1), mk(null, 100, 2), mk(null, 100, 3),
|
||
mk('2026-10-20 18:30:00', 50, 4),
|
||
mk('2026-10-20 09:00:00', 100, 5), mk('2026-10-20 09:00:00', 100, 6),
|
||
mk('2026-10-20 10:00:00', 100, 7), mk('2026-10-20 10:00:00', 100, 8),
|
||
mk('2026-10-20 11:00:00', 100, 9), mk('2026-10-20 11:00:00', 100, 10),
|
||
];
|
||
|
||
// 记录业务表被查了几次 —— 12:00 前应当为 0
|
||
// ⚠️ 现在是**两表并行分别查**(jst_Orders + jst_qmcust_orderlist),
|
||
// 所以两表都返回同一批 stubRows;服务层按 o_id 去重后仍应算 10 条,
|
||
// 这也顺带验证了「跨表同 o_id 去重」逻辑。
|
||
const calls = { order: 0 };
|
||
require.cache[dbPath] = {
|
||
id: dbPath,
|
||
filename: dbPath,
|
||
loaded: true,
|
||
exports: {
|
||
query: async (sql) => {
|
||
if (/jst_warehouseInfo/u.test(sql)) {
|
||
return [{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId }];
|
||
}
|
||
if (/jst_salesOutboundInfo/u.test(sql) && /MAX\(io_date\)/u.test(sql)) {
|
||
// 新鲜度查询:返回空,走 stale 分支也不影响主结果
|
||
return [{ outbound_max: '2026-10-19 23:00:00' }];
|
||
}
|
||
calls.order += 1;
|
||
return stubRows;
|
||
},
|
||
},
|
||
};
|
||
void realDb;
|
||
return { calls, wmsId };
|
||
}
|
||
|
||
test('服务层三态分桶互斥,按时率不为负', async () => {
|
||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||
const realDb = require.cache[dbPath];
|
||
stubDb(dbPath, realDb);
|
||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||
|
||
try {
|
||
const svc = require('../src/services/warehouseShipTimelinessService');
|
||
const data = await svc.getShipTimeliness({ now: at(16, 5), limit: 10 });
|
||
const c = data.centers.find((x) => x.key === 'CQ');
|
||
assert.ok(c, '应能归到崔桥工厂仓');
|
||
assert.equal(c.overdueUnshipped, 3);
|
||
assert.equal(c.overdueShipped, 1);
|
||
assert.equal(c.windowOrderCount, 10, '总数 = pending + shippedLate + onTime');
|
||
assert.equal(c.onTimeRate, 60, '按时 6/10 = 60%(跨表重复 o_id 必须去重,否则总数会翻倍)');
|
||
|
||
// 汇总口径与各仓一致,且按时率不会是负数
|
||
assert.equal(data.summary.overdueUnshipped, 3);
|
||
assert.equal(data.summary.windowOrderCount, 10);
|
||
assert.equal(data.summary.onTimeRate, 60);
|
||
assert.ok(data.summary.onTimeRate >= 0);
|
||
|
||
// 前端要展示「核验 16:00 前」,round 必须带出 windowEnd ——
|
||
// 只回 key/label 的话页面会渲染成「核验 前」(2026-10-20 真渲染时抓到)
|
||
assert.ok(data.round, 'round 不能为空');
|
||
assert.equal(data.round.key, 'AFTERNOON');
|
||
assert.equal(data.round.cutoffHour, 16);
|
||
assert.match(String(data.round.windowEnd), /16:00:00$/, 'round.windowEnd 必须带出截止时刻');
|
||
} finally {
|
||
// 还原,避免污染同进程内其它测试
|
||
if (realDb) require.cache[dbPath] = realDb;
|
||
else delete require.cache[dbPath];
|
||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||
}
|
||
});
|
||
|
||
test('服务层 12:00 前返回 notStarted,且不去查订单表', async () => {
|
||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||
const realDb = require.cache[dbPath];
|
||
const { calls } = stubDb(dbPath, realDb);
|
||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||
|
||
try {
|
||
const svc = require('../src/services/warehouseShipTimelinessService');
|
||
const data = await svc.getShipTimeliness({ now: at(9, 30), limit: 10 });
|
||
assert.equal(data.notStarted, true);
|
||
assert.equal(data.round, null);
|
||
assert.equal(data.orders.length, 0);
|
||
assert.equal(data.summary.overdueUnshipped, 0);
|
||
// 11 个主仓仍然列出来(结构不能塌),但数字全为 0
|
||
assert.equal(data.centers.length, registry.CENTERS.length);
|
||
assert.ok(data.centers.every((c) => c.overdueUnshipped === 0));
|
||
assert.equal(calls.order, 0, '未到核验时间不应扫订单表');
|
||
assert.match(data.note, /12:00/);
|
||
// 下一轮核验时刻要告诉前端,方便提示「12:00 开始」
|
||
assert.equal(data.nextRoundAt, '2026-10-20 12:00:00');
|
||
} finally {
|
||
if (realDb) require.cache[dbPath] = realDb;
|
||
else delete require.cache[dbPath];
|
||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||
}
|
||
});
|