diff --git a/backend/src/jobs/warehouseShipTimelinessNotifyJob.js b/backend/src/jobs/warehouseShipTimelinessNotifyJob.js index 232d138..4ff8925 100644 --- a/backend/src/jobs/warehouseShipTimelinessNotifyJob.js +++ b/backend/src/jobs/warehouseShipTimelinessNotifyJob.js @@ -7,16 +7,19 @@ * 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知; * 18:00 核验的订单,核验完成后也通知。」 * ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS): - * NOON 13:00 核验「今天 07:00 前审核」→ 默认 13:05 推 - * AFTERNOON 18:00 核验「截止 16:00 前审核」→ 默认 18:05 推 - * ⚠️ 默认「核验时刻 + 5 分钟」:状态镜像 `query_time` 得晚于核验时刻, + * NOON 13:00 核验「今天 07:00 前创建」→ 默认 13:05 推 + * AFTERNOON 18:00 核验「截止 16:00 前创建」→ 默认 18:05 推 + * ⚠️ 2026-10-28 起数据源换成订单两表、起算点换成 `jst_Orders.created` + * (业务:「审核时间 jst_Orders 是这个表里面的创建时间」), + * 所以推送正文写的是「创建 HH:mm」,不再叫「审核」。 + * ⚠️ 默认「核验时刻 + 5 分钟」:订单表 `updateTime` 得晚于核验时刻, * 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。 * 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。 * * 消息内容: * 抬头(含「超时」字样,钉钉关键词安全设置要用)+ 汇总(超时单数 / 涉及仓数 / 最久已等) * + 不计考核(待审核·订单异常,不算仓库责任)+ 按仓超时单数 + 前 N 条明细 + 页面链接。 - * 明细一行 = 仓|内部单号|线上单号|快递|审核 HH:mm|已等 X 小时(业务对账用)。 + * 明细一行 = 仓|内部单号|线上单号|快递|创建 HH:mm|已等 X 小时(业务对账用)。 * (「快递」是业务 2026-10-27 要求加的,见 buildDetailLine 的说明) * 0 条也推 —— 群里看到「本轮 0 单超时」就知道任务在跑。 * @@ -118,14 +121,18 @@ function formatOverdue(minutes) { } /** - * 审核时间的展示:本轮的日期就只给 HH:mm,跨天(历史未发单)必须带 MM-DD。 + * 起算时间(= 订单创建时间 `jst_Orders.created`)的展示: + * 本轮的日期只给 HH:mm,跨天(历史未发单)必须带 MM-DD。 * 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」, - * 明细里会出现昨天 16:13 审核的单 —— 只写「审核 16:13」会被读成「今天 16:13」, - * 看着像「审核时间晚于窗口,不该算超时」,业务会对不上账。 + * 明细里会出现昨天 16:13 创建的单 —— 只写「创建 16:13」会被读成「今天 16:13」, + * 看着像「创建时间晚于窗口,不该算超时」,业务会对不上账。 + * + * ⚠️ 2026-10-28 数据源切换后,本函数的入参 `auditTime` 实际是**订单创建时间** + * (服务层把 created 同时写进 auditTime / createdAt,业务口径两者等同)。 */ function auditWhen(auditTime, roundDate) { const text = cleanCell(auditTime); - if (!text) return '未审核'; + if (!text) return '无创建时间'; const hit = text.match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/); if (!hit) { const clock = text.match(/(\d{2}:\d{2})/); @@ -137,13 +144,14 @@ function auditWhen(auditTime, roundDate) { } /** - * 明细一行:仓|内部单号|线上单号|快递|审核 时间|已等 X 小时 + * 明细一行:仓|内部单号|线上单号|快递|创建 时间|已等 X 小时 * ⚠️ 第二段是**内部单号**(聚水潭 o_id,仓管在系统里按它查单),第三段是线上单号(客服对客户)。 * 两者业务都要,别只留一个。 * 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断 * 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同), * 不用再回页面一单单点开确认。 - * ⚠️ 审核时间跨天要带日期,见 auditWhen 的说明。 + * 🔥 2026-10-28 第五段从「审核」改「创建」:起算点已换成订单创建时间。 + * ⚠️ 跨天要带日期,见 auditWhen 的说明。 */ function buildDetailLine(row, index, roundDate) { const parts = [ @@ -151,7 +159,7 @@ function buildDetailLine(row, index, roundDate) { cleanCell(row?.oId, '无单号'), cleanCell(row?.soId, '—'), cleanCell(row?.logisticCompany, '—'), - `审核 ${auditWhen(row?.auditTime, roundDate)}`, + `创建 ${auditWhen(row?.auditTime, roundDate)}`, `已等 ${formatOverdue(row?.overdueMinutes)}`, ]; return `${index}. ${parts.join('|')}`; @@ -266,7 +274,7 @@ function buildNotifyText(data = {}, options = {}) { const windowEnd = String(window.end || '').slice(11, 16); const head = [ - `【仓储中心发货时效】${roundLabel}${windowEnd ? ` · 核验 ${windowEnd} 前审核` : ''}`, + `【仓储中心发货时效】${roundLabel}${windowEnd ? ` · 核验 ${windowEnd} 前创建` : ''}`, `超时未发货 ${overdue} 单,涉及 ${centerCount} 个仓` + (oldestDays > 0 ? `,最久已等 ${oldestDays} 天` : ''), ]; @@ -275,7 +283,7 @@ function buildNotifyText(data = {}, options = {}) { head.push('本轮没有超时未发货的订单,全部按时发出。'); } if (heldCount > 0) { - head.push(`另有 ${heldCount} 单不计考核(已支付待审核 / 订单异常)`); + head.push(`另有 ${heldCount} 单不计考核(待审核 / 订单异常)`); } const carrierSkipped = Number(summary.carrierSkipped) || 0; if (carrierSkipped > 0) { diff --git a/backend/src/services/warehouseShipTimelinessRegistry.js b/backend/src/services/warehouseShipTimelinessRegistry.js index 00e8ae3..8051d19 100644 --- a/backend/src/services/warehouseShipTimelinessRegistry.js +++ b/backend/src/services/warehouseShipTimelinessRegistry.js @@ -262,6 +262,48 @@ const OUTBOUND_TABLES = Object.freeze({ // ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。 const ORDER_STATE_TABLE = 'jst_order_express_details'; +// ── 🔥 2026-10-28 数据源切换:主数据源改成「订单 + 订单明细」──────────────── +// 业务 yanhui 2026-10-28 明确指出:「核验抓取的销售订单表是错误的,应该抓 +// `jst_Orders`(订单)+ `jst_OrderItem`(订单明细),o_id → o_id;审核时间就是 +// jst_Orders 里的创建时间」。 +// +// 为什么原来那张状态镜像表不能用(实测证据): +// ① `send_time` 会**漏回写**:近 7 天「状态表说发货中+未发」的 4157 单里, +// 订单表已有 `send_date` 的 **563 单**、status 已是 Sent 的 492 单 —— +// 明明发了却还算超时;2026-10-28 06h/07h 那批大批量刷新后集中爆发。 +// ② 状态表对**被合并 / 拆单父单**不回写(已有 89 + 755 单),必须回订单表看 status。 +// ③ 状态表会**整批停更**:2026-10-27 11:54:59 之后 6.8 小时零写入, +// 18:00 轮推出去 34 单里混着已发货的(业务当场发现)。 +// ④ 两张表发货时间**其实同源**:状态表 send_time 与订单表 send_date +// 27387 单逐条比对**零偏差** ⟹ 订单表才是权威,状态表只是它的滞后副本。 +// +// ⟹ 主查询直接打订单两表(店铺互斥必须都读),**不再依赖状态表**。 +// 仓库归属用订单表的 `wms_co_id`(不是状态表的仓名文本),更准。 +// 状态表只剩「售后已作废」一个判据,实测在「发货中+未发」里只影响 4 单,可放弃。 +const ORDER_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']); + +// 订单表 `status` 的取值(聚水潭实时状态,不是下单快照 —— 这一点 +// `jst_Orders.status` 与代码历史上「status 不可信」的结论不同,见下面 CREATED 段说明): +// Delivering 发货中 —— **唯一该考核仓库的状态** +// WaitConfirm 待审核 —— 仓库还没接到单,单列「待审核」不计超时 +// Question 疑问/异常 —— 单列「异常待处理」不计超时 +// Sent 已发货 → 剔除 +// Cancelled 已取消(业务叫「作废」)→ 剔除 +// Split / Merged 拆单父单 / 被合并单 → 剔除(自己永不发货) +// WaitOuterSent 等供销商|外仓发货 → 剔除(不是本仓发) +// 近 30 天分布:Sent 132341 / Cancelled 66643 / Split 8251 / Question 2057 / +// Merged 1854 / Delivering 1446 / WaitConfirm 190 / WaitOuterSent 45 +const ORDER_PENDING_STATUS = 'Delivering'; +const ORDER_UNAUDITED_STATUS = 'WaitConfirm'; +const ORDER_QUESTION_STATUS = 'Question'; +const ORDER_SHIPPED_STATUS = 'Sent'; +// 主查询只捞这三种(其余状态一律不进候选,SQL 侧就过滤掉,不传回 Node) +const ORDER_CANDIDATE_STATUSES = Object.freeze([ + ORDER_PENDING_STATUS, + ORDER_UNAUDITED_STATUS, + ORDER_QUESTION_STATUS, +]); + // 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态** const SHIPPING_STATUS = '发货中'; // 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」) @@ -277,23 +319,20 @@ const ABNORMAL_STATUS = '异常'; const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']); // ── 起算点:聚水潭「创建时间」────────────────────────────────────────────── -// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。 +// 🔥 2026-10-28 **业务再次确认**:「审核时间是 jst_Orders 这个表里面的创建时间」。 +// 这与 2026-10-20 的最初定版一致(本段下面就是那次的原话),中间一段 +// 因「9818476 创建 10-18、审核 10-20」被临时改成按审核时间,现已改回创建时间。 // -// ⚠️ 状态表 `jst_order_express_details` **没有**聚水潭创建时间,必须回订单表补: -// · `createdAt`(本表那个 datetime2)—— ❌ **不是**订单创建时间。 -// 实测比付款晚 1.5~3.5 小时(261019-… pay 21:05:16 / createdAt 次日 00:23:51), -// 是**同步落库时间**,用了会把起算点整体推后几小时。 -// · `pay_time` —— ❌ 付款时间,业务明确说「这一单的创建还是按照付款时间来的」, -// 要换掉。 -// · ✅ `jst_Orders.created` / `jst_qmcust_orderlist.created` —— **聚水潭创建时间**, -// 稳定比下单/付款晚 15~30 秒(抽样 00:07:23 → 00:07:41), -// 这才是聚水潭订单列表里「创建时间」那一列。 +// 实测差异(近 7 天 26086 单):`created` **100% 早于** `last_oa_confirm_time`, +// 正常单只差 7~40 分钟;夜间下单、次日上班才审的单差 6~9 小时(极端的差 2 天)。 +// 影响量化(2026-10-27 18:00 轮):按审核 147 单 / 平均已等 4.5h; +// 按创建 272 单 / 平均已等 8.5h —— 业务已知悉并要求按创建时间算。 // -// 性能(2026-10-20 实测 9,676 个候选): -// 只投影 `o_id, created` 两列、分批 200 → jst_Orders **14.5s** + qmcust **2.9s**。 -// 🔥 千万别顺手把 pay_amount / labels 一起取出来:同样 IN 400 个只要 **38.7s**。 +// 主数据源已经切成订单表(见上面 ORDER_SOURCE_TABLES),`created` 直接从主查询拿, +// **不再需要 backfill**。 const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']); // 分批大小:200 实测最快(再大收益递减,再小批数太多) +// ⚠️ 2026-10-28 起主链路已不用(created 直接从主查询取),保留给历史脚本/测试引用。 const CREATED_BACKFILL_BATCH = 200; // ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ───────────────── @@ -439,12 +478,29 @@ async function loadCenterWarehouseMap(force = false) { warehouseCache = { at: Date.now(), rows: list, + // 全量「wms_co_id → 仓名」映射(**含归不到主仓的仓**)。 + // 发货时效主查询不再 LEFT JOIN 仓表(实测 JOIN 会让执行计划从 28s 恶化到 61~105s), + // 改为在主查询里取回 wms_co_id、在 Node 侧用这份缓存换名字。 + allNames: rows.map((r) => ({ wmsCoId: Number(r.wms_co_id), name: String(r.name || '') })), + unmatched, unmatchedCount: unmatched.length, excluded, }; return list; } +/** 全量「wms_co_id → 仓名」映射(含归不到主仓的仓),供 service 侧做仓名还原 */ +async function loadWarehouseNameMap() { + if (Date.now() - warehouseCache.at >= WAREHOUSE_CACHE_TTL_MS || !warehouseCache.rows.length) { + await loadCenterWarehouseMap(); + } + const map = new Map(); + for (const r of warehouseCache.allNames || []) { + if (Number.isFinite(r.wmsCoId)) map.set(r.wmsCoId, r.name); + } + return map; +} + function clearWarehouseCache() { warehouseCache = { at: 0, rows: [] }; } @@ -557,6 +613,7 @@ module.exports = { centerWindowEnd, nextRoundDeadline, loadCenterWarehouseMap, + loadWarehouseNameMap, clearWarehouseCache, getWarehouseCache: () => warehouseCache, resolveActiveRound, @@ -569,6 +626,13 @@ module.exports = { NOT_SPLIT_MERGED_SQL, OUTBOUND_TABLES, ORDER_STATE_TABLE, + // 2026-10-28 起主数据源 = 订单两表(状态表退居兜底) + ORDER_SOURCE_TABLES, + ORDER_PENDING_STATUS, + ORDER_UNAUDITED_STATUS, + ORDER_QUESTION_STATUS, + ORDER_SHIPPED_STATUS, + ORDER_CANDIDATE_STATUSES, SHIPPING_STATUS, UNAUDITED_STATUS, ABNORMAL_STATUS, diff --git a/backend/src/services/warehouseShipTimelinessService.js b/backend/src/services/warehouseShipTimelinessService.js index 20043c6..117e8a3 100644 --- a/backend/src/services/warehouseShipTimelinessService.js +++ b/backend/src/services/warehouseShipTimelinessService.js @@ -4,150 +4,103 @@ * 仓储中心 24 小时发货时效 —— 服务层 * * ═══════════════════════════════════════════════════════════════════════════ - * 口径(2026-10-20 第九次修正,业务最终定版) + * 🔥 2026-10-28 数据源与起算点「双切换」(业务 yanhui 定版) * ═══════════════════════════════════════════════════════════════════════════ - * 业务原话(按时间顺序,最后一条为准): - * 17:12「当日 7 点前订单(7 点前订单**按照审核时间算**),13:00 核验未发就算超时」 - * 17:5x「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到 16 点审核的订单 18:00 核验; - * 然后再看历史未发的订单」 + * 业务原话: + * ① 「核验抓取的销售订单表是错误的,应该在数据库抓取:订单 jst_Orders、 + * 订单明细 jst_OrderItem,o_id → o_id」 + * ② 「审核时间是 jst_Orders 这个表里面的创建时间」 * - * ⟹ 三条铁律: - * ① **轮次归属看「审核时间」**(`last_oa_confirm_time`,聚水潭显示值): - * 今天 00:00~07:00 审核 → 13:00 轮 - * **截止今天 16:00 前审核**的单 → 18:00 轮 - * (业务 2026-10-20 补正:18:00 核验的是 16:00 前**所有**审核过的单, - * 不是「今天 00:00~16:00」这一个自然日切片 —— 含今天之前审核、 - * 至今没发的历史未发单,也含 7 点前审核到 13:00 还没发出的; - * 山东两仓 15:00、河南洛宁 13:00 是仓级例外,见 registry) - * ⚠️ **不看创建时间**:业务亲自指出的 9818476(创建 10-18 12:49 / 审核 10-20 13:06) - * 和 9823980(创建 10-18 20:40 / 审核 10-20 09:15)都是下午/上午才审的, - * 按创建时间会被错算进 13:00 轮 —— 第八版就是这么错的。 - * ② **历史未发的单一律算**:昨天及更早审核、至今没发货的,两个轮次都要看到 - * (它们早就该发了,不该等今天的分段窗口)。 - * ③ **没有「满 24 小时」门槛**:到核验时刻仍未发就是超时,哪怕只等了 2 小时。 - * 第七版加了 24h 门槛,13:00 轮永远看不到当天那批,被业务打回。 - * ④ **自提 / 中铁 / 壹米滴答 / 武进安能 四个快递不考核**(业务 2026-10-20 补充, - * 武进安能 2026-10-27 追加): - * 按 `logistic_company_name` 包含匹配(库里是「中铁物流」「壹米滴答专线」), - * 命中直接跳过,不进出口径任何一处数字,剔除量记在 `summary.carrierSkipped`。 - * ⚠️ 武进安能只剔这一个网点,别泛化成「安能」(常州安能新等仍考核)。 + * 于是本版做两件事: + * · 主数据源:rpt.jst_order_express_details(状态镜像)→ + * rpt.jst_Orders + rpt.jst_qmcust_orderlist(**店铺互斥,必须都读**) + * · 起算点:last_oa_confirm_time(审核时间)→ created(订单创建时间) * - * 已等时长 = **审核时间 → 本轮核验时刻**(13:00 / 18:00)。 - * 🔥 审核时间取 last_oa_confirm_time(**最后一次**审核)—— - * 这就是聚水潭订单列表「审核时间」那一列显示的值,业务拿它对账。 - * 之前取首次审核,业务抽查 3 单(5127368691073091434 等)立刻发现不一致: - * 首次 10-18 13:15 vs 聚水潭 10-20 13:38。**别再改成 first_oa**。 - * 因为窗口也按审核时间卡,「审核 13:06 的单」只会进 18:00 轮, - * 已等自然用 18:00 算,不会出现「0 分」。 - * 明细列表并列「审核时间 / 发货时间」—— 业务 2026-10-20 要求**去掉创建时间**, - * 改成看各订单的发货时间(未发的显示「未发货」),对账时直接和聚水潭发货时间比。 + * 为什么状态镜像表不能用(实测证据,2026-10-27/28): + * ① send_time **漏回写**:近 7 天「状态表说发货中+未发」的 4157 单里, + * 订单表已有 send_date 的 **563 单**、status 已是 Sent 的 492 单; + * ② 被合并 / 拆单父单**不回写**:另有 Merged 89 + Split 755 单被冤枉; + * ③ 会**整批停更**:2026-10-27 11:54:59 之后 6.8 小时零写入,18:00 轮 + * 推出去的 34 单里混着已发货的(业务当场发现); + * ④ 两表发货时间**同源**:send_time vs send_date 27387 单逐条比对**零偏差** + * ⟹ 订单表才是权威,状态表只是它的滞后副本。 * - * 🔥 「已发 / 未发」还是以聚水潭「销售订单」的发货状态为准(= 状态表 send_time)。 - * **不要**改用运单号(logistic_id)判已发:实测同一店铺里, - * 「已发货」的单独有运单号 + 未揽收,「发货中」的单也有运单号 + 未揽收 —— 判不出来。 - * 运单号只用来在明细里标「已打单 · 未揽收」,给业务一个「仓库干到哪一步」的线索。 + * 起算点换 created 的影响(业务已确认): + * created 100% 早于 last_oa_confirm_time(近 7 天 26086 单无一例外), + * 正常单差 7~40 分钟;夜间下单、次日上班才审的单差 6~9 小时。 + * 2026-10-27 18:00 轮实测:按审核 147 单 / 均等 4.5h → 按创建 272 单 / 均等 8.5h。 * - * 数据源:`rpt.jst_order_express_details`(聚水潭订单状态准实时镜像,402 万行) + * ⚠️ 2026-10-20 那次「按审核时间算」的纠偏已被本版覆盖,别再把基准时间 + * 改回 auditAt —— 那是上一轮的定版,业务 2026-10-28 明确要求按创建时间。 * - * 🔥 时区(2026-10-21 生产实测踩坑,别再裸用 `new Date()`): - * · 生产容器 `/etc/timezone = Etc/UTC`,而 MSSQL `GETDATE()` 返回**北京时间**, - * mssql 驱动 `useUTC:false` 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。 - * · 后果:**北京时间每天 00:00~08:00,服务端取到的「今天」是昨天**, - * 轮次日期 / 核验窗口整体错位(实测 08:04 跑 13:00 轮得 3895 单,本地仅 6 单)。 - * · 统一用 `registry.beijingNow()` 取「现在」(本机已是东八区时平移量为 0,行为不变)。 - * **不要**去改容器 TZ —— 会波及全部既有定时任务。 + * ── 三条铁律(不变)────────────────────────────────────────────────────── + * ① 轮次归属看起算点:今天 07:00 前创建 → 13:00 轮; + * 截止今天 16:00 前创建 → 18:00 轮(山东两仓 15:00、河南洛宁 13:00 为仓级例外)。 + * **历史未发单一律算**(昨天及更早创建、至今没发的,两个轮次都要看到)。 + * ② 没有「满 24 小时」门槛:到核验时刻仍未发就是超时。 + * ③ 自提 / 中铁 / 壹米滴答 / 武进安能 四个快递不考核(按快递名包含匹配, + * 武进安能只剔这一个网点,**别泛化成「安能」**,常州安能新等仍考核), + * 剔除量记 summary.carrierSkipped。 * - * · 窗口归属 / 时长基准 = `last_oa_confirm_time`(最后一次审核 = 聚水潭显示值; - * 缺失回退 first_oa / 创建时间 / 付款时间),来源写进 `benchmarkSource`。 - * · 创建时间(**仅供展示对账**) = `jst_Orders.created` / `jst_qmcust_orderlist.created` - * 🔥 状态表里**没有**这一列,必须回订单表按 `o_id = inner_order_id` 补: - * · `createdAt`(状态表那个 datetime2)= 同步落库时间,晚 1.5~3.5h,**不能用** - * · `created` 才是聚水潭订单列表里的「创建时间」(比下单晚 15~30 秒) - * · 已发货 = `send_time` 非空(已发货单 100% 有值) - * · 待发货 = `send_time` 为空 且 `order_status_name = 发货中` - * · 仓库 = `warehouse_name`(直接套 CENTERS 关键词归属主仓) + * 已等时长 = 创建时间 → 本轮核验时刻(13:00 / 18:00)。 * - * ── 为什么弃用 jst_Orders.status(前三版全栽在这)────────────────────────── - * 订单表的 `status` 是**下单那一刻的快照**,之后聚水潭侧取消、发货前售后、 - * 审核通过都不会回写。实测(2026-10-20)业务反馈「聚水潭查不到」的 6 单: - * 订单表 status 全是 `WaitConfirm`(看起来像待发货), - * 状态表真实状态是 **已取消**(标签带「发货前售后」),另 1 单是 **异常**。 - * ⟹ 所以「状态 / 是否已发」一律以状态表为准,**订单表只用来补创建时间**。 + * ── 订单表字段口径(已逐一实测)─────────────────────────────────────────── + * · created —— ✅ **本页起算点**(聚水潭订单创建时间) + * · order_date —— 平台下单时间(客户侧,比 created 早 15~30 秒) + * · pay_date —— 付款时间(并列展示) + * · send_date —— 发货时间(**未发单必为空**,已发 100% 有值) + * · status —— 聚水潭**实时状态**:Delivering 发货中 / WaitConfirm 待审核 / + * Question 异常 / Sent 已发货 / Cancelled 已取消 / + * Split 拆单父单 / Merged 被合并 / WaitOuterSent 等外仓 + * · wms_co_id —— ✅ **发货仓**(join jst_warehouseInfo 取名字,比状态表的仓名准) + * · logistics_company —— 快递公司(不考核快递就按它匹配) + * · l_id —— 运单号(有值 = 仓库已打单但还没回传发货时间) + * · labels —— 标签长文本(只在 SQL 里算成 presaleVoid 布尔,不外传) + * · updateTime / local_updated —— 数仓灌库时间(判数据新鲜度用) + * · modified —— ⚠️ 每天凌晨被批量刷新,**不可用** * - * 时间列语义(已逐一实测,别再猜): - * · `order_date` / `pay_date` —— 平台下单 / 付款时间(客户那边的时间) - * · `created`(订单表) —— ✅ **聚水潭创建时间**(展示用,比下单晚 15~30 秒) - * · `createTime`/`updateTime` —— 数仓灌库时间(带毫秒、批量、滞后约 5 分钟) - * · `modified` —— ⚠️ 最后修改时间,每天凌晨被批量刷新,不可用 - * · `first_oa_confirm_time` —— 首次审单时间(审核时间缺失时的第二顺位回退) - * · `last_oa_confirm_time` —— ✅ **最新审核时间(本版考核基准)** - * · `send_time` —— **发货时间** - * · `query_time` —— 本表快照时间(判数据新鲜度) + * ── 三态分流(互斥,全部由订单表 status 决定)───────────────────────────── + * 超时未发货 = status='Delivering' + send_date 空 + created 已过窗口上界 → orders + * 不计考核 = status='WaitConfirm'(待审核)/ 'Question'(异常) → heldOrders + * 不展示 = Sent / Cancelled / Split / Merged / WaitOuterSent → SQL 侧已排除 + * ⚠️ 作废(Cancelled,业务叫「作废单」)近 30 天 66643 单,是 Delivering 1446 单的 46 倍, + * 漏排除整页都是客户取消掉的单。 * - * ── 三态分流(互斥)───────────────────────────────────────────────────── - * 超时未发货 = 发货中 + send_time 空 + 审核时间已过本轮窗口上界(07:00/16:00) - * → orders(考核仓库) - * 不计考核 = 已支付待审核(没审单)/ 异常(订单异常) → heldOrders - * 不展示 = 已发货 / 已取消(作废)/ 等供销商|外仓发货 → 直接丢弃 - * 🔥 作废单(已取消)不考核:近 30 天 64,440 单,是「发货中」2,448 单的 26 倍, - * 不排除的话整页都是客户取消掉的单。 + * ── 性能(2026-10-28 实测,两个新坑)────────────────────────────────────── + * ① 🔥 **必须带 OPTION (RECOMPILE)**:同一条 qmcust 查询, + * 不加 → 33~50s(参数嗅探选错执行计划);加 → 0.4s。 + * ② 时间条件拼**字面量**(服务端自己算的 YYYY-MM-DD HH:mm:ss,经正则校验), + * 字面量 + RECOMPILE 两个措施一起上才稳。 + * ③ 只投影窄列,labels 在 SQL 里算成布尔;SELECT * 更是灾难。 + * ④ 走 querySlow,普通池 60s 池级超时 + 自动重试会把 9s 拖成 120s。 + * 实测总耗时:jst_Orders 1.8s + qmcust 0.4s + 仓表 join ≈ 3s(旧版 39~60s)。 * - * ── 核验轮次(业务给定)────────────────────────────────────────────────── - * `verifyHour` —— 几点核验(13:00 / 18:00),判「超时多久」用它 - * `windowEndHour` —— 只看该时刻**前审核**的单(07:00 / 16:00,仓级例外见 registry) - * ⚠️ 两者不是同一个东西,别合成一个字段 - * ⚠️ 也**不要**再引入「满 N 小时才算超时」的门槛(第七版踩过,会被业务打回) - * ⚠️ 窗口**要**卡审核时间(第九版定版)。第六版曾因「卡审核时间 13:00 轮只剩 2 单」 - * 退回创建时间,结果 9818476 / 9823980 这类下午才审的单被错算进 13:00 轮 → - * 业务亲自纠正:「7 点前订单**按照审核时间算**」。审核时间少是因为 13:00 轮本来就 - * 只该有早上那批,不是数据不对。 - * - * ── 性能(2026-10-20 实测,四个坑都踩过)────────────────────────────────── - * ① **不要 SELECT 宽列**。同一 WHERE 只取 2 列 0.7s,取 15 列(含 - * `order_labels` / `buyer_message` / `remark`)**54s** —— 视图没法做 - * 覆盖扫描,多取一列就是几万次基表回表。本表只投影 10 个窄列。 - * ② **不要回订单表补金额**。`o_id IN (400 个)` 打两张订单宽视图,单条实测 - * **38.7s**。发货时效不需要金额,直接不做。 - * (但补 `created` **可以**:只取两列时 9,676 个候选只要 14.5s + 2.9s。) - * ③ **不要额外查新鲜度**。`MAX(send_time)` 又是一次 402 万行扫描(21.1s)。 - * 快照时间直接从主查询的 `query_time` 在 Node 里取 MAX。 - * ④ `SELECT *` 更是灾难 —— 单 form 单号查一次 **5 分钟**不返回。 - * ⑤ 走 `querySlow`,普通池 60s 池级超时 + 自动重试会把 9s 拖成 120s。 - * - * ── 时区 ───────────────────────────────────────────────────────────────── - * 所有时间列都是**东八区本地时间**;服务与库都在东八区。 - * **不要**在 Node 侧用 UTC 日期判断。 + * ── 时区(2026-10-21 生产实测踩坑,别再裸用 new Date())────────────────── + * · 生产容器 /etc/timezone = Etc/UTC,而 MSSQL GETDATE() 返回**北京时间**, + * mssql 驱动 useUTC:false 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。 + * · 后果:**北京时间每天 00:00~08:00,服务端取到的「今天」是昨天**。 + * · 统一用 registry.beijingNow() 取「现在」。**不要**去改容器 TZ。 + * · 所有时间列都是**东八区本地时间**;**不要**在 Node 侧用 UTC 日期判断。 */ const cloudDatabase = require('../config/cloudDatabase'); const registry = require('./warehouseShipTimelinessRegistry'); const { - ORDER_STATE_TABLE, - SHIPPING_STATUS, - UNAUDITED_STATUS, - ABNORMAL_STATUS, - IGNORED_STATES, - CREATED_SOURCE_TABLES, - CREATED_BACKFILL_BATCH, - VOID_ORDER_STATUS, - VOID_QUESTION_STATUS, + ORDER_PENDING_STATUS, + ORDER_UNAUDITED_STATUS, + ORDER_QUESTION_STATUS, + ORDER_CANDIDATE_STATUSES, VOID_ORDER_LABEL_KEYWORDS, - AFTERSALE_VOID_STATUS, resolveCenter, } = registry; -// `labels` 里命中任一关键词即视为作废(SQL 侧用 LIKE 拼,避免把长文本传回 Node) -const VOID_LABEL_SQL = VOID_ORDER_LABEL_KEYWORDS - .map((k) => `labels LIKE N'%${k}%'`) - .join(' OR '); - // 明细上限:只用于页面展示,超出部分用 truncated 标记提示 const DEFAULT_DETAIL_LIMIT = 500; const MAX_DETAIL_LIMIT = 2000; -// 状态表快照超过这个分钟数没刷新 → 页面提示「数据滞后」。 -// 正常 ETL 每小时至少刷一轮(每小时约 1000 行有变化),90 分钟足够容错。 +// 数据源超过这个分钟数没更新 → 页面提示「数据滞后」。 +// 2026-10-28 起数据源是订单表(`updateTime` / `local_updated`),实测滞后约 2 小时。 const STATE_STALE_MINUTES = 90; /** @@ -169,22 +122,40 @@ const STATE_STALE_MINUTES = 90; const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答', '武进安能']; /** - * 订单表(= 聚水潭「销售订单」镜像)里「已发货」的状态值。 + * 订单表英文 status → 业务看的**中文状态**。 + * + * 前端「发货状态」列、钉钉推送正文都用这份映射,别在别处再写一遍。 + * 只有「发货中」进考核;待审核 / 异常进「不计考核」清单;其余在 SQL 侧就排除了。 + */ +const ORDER_STATE_LABELS = Object.freeze({ + Delivering: '发货中', + WaitConfirm: '待审核', + Question: '异常', + Sent: '已发货', + Cancelled: '已取消', + Split: '拆单父单', + Merged: '被合并', + WaitOuterSent: '等外仓发货', +}); + +/** + * 已发判定(`status = 'Sent'`)。 * * 🔥 业务 2026-10-20 要求「已发/未发参考聚水潭销售订单页面」—— - * 销售订单页面显示「已发货」= status `Sent`。`send_date` 很多店铺不回写 - * (实测 103 单里 103 单都是空),所以必须把 status 也当依据。 + * 销售订单页面显示「已发货」= status `Sent`。 * ⚠️ **不要**把 `Delivering`(发货中)算成已发 —— 那是「已审核待出库」, * 正是本页要考核的对象;算进来整页就空了。 + * + * 2026-10-28:主查询已经直接按 `status IN ('Delivering','WaitConfirm','Question')` + * + `send_date IS NULL` 过滤,已发单在 SQL 侧就被排除,这里不再单独判一次。 */ -const ORDER_SHIPPED_STATUS = ['Sent']; /** * 结果缓存。 * - * 这张视图单次查询实测 10~60 秒(取决于缓存温度与库负载),页面又是 - * `AUTO_REFRESH_MS` 定时轮询 —— 不缓存的话每轮轮询都会重压一次库, - * 页面也会一直转圈。缓存 key 含「轮次 + 日期 + limit」, + * 主查询实测 3~40 秒(取决于库负载),页面又是 `AUTO_REFRESH_MS` 定时轮询 —— + * 不缓存的话每轮轮询都会重压一次库,页面也会一直转圈。 + * 缓存 key = 「轮次 + 日期」(**不含 limit**,见 withDetailLimit 的说明), * 业务点「刷新」时前端带 `force=1` 绕过缓存强制重查。 */ const CACHE_TTL_MS = Number(process.env.CANGKU_WST_CACHE_TTL_MS) || 5 * 60 * 1000; @@ -249,137 +220,90 @@ function diffMinutes(fromText, toText) { } /** - * 取「聚水潭订单状态」里所有未发货的单(发货中 / 待审核 / 异常)。 + * 取「未发货订单」候选 —— 直接打**订单两表**(`jst_Orders` + `jst_qmcust_orderlist`)。 * - * 为什么不用 wms_co_id 过滤:本表只有 `warehouse_name`,没有仓 ID。 - * 近 30 天三类合计约 1 万行,一次取回在 Node 侧按仓库名归属主仓,代价可接受。 + * 🔥 2026-10-28 数据源切换(业务 yanhui:「销售订单表是错误的,应该抓 + * jst_Orders + jst_OrderItem,审核时间就是 jst_Orders 的创建时间」)。 + * 原来打的是状态镜像表 `jst_order_express_details`,它有三个致命问题: + * · `send_time` 漏回写 —— 近 7 天 563 单明明已发却仍算「未发」; + * · 被合并 / 拆单父单不回写 —— 89 + 755 单被冤枉; + * · 会整批停更 —— 2026-10-27 11:54 之后 6.8 小时零写入。 + * 而且两表发货时间实测 27387 单**逐条零偏差** ⟹ 订单表才是权威。 * - * ⚠️ 只投影 11 个**窄列**:`order_labels`/`buyer_message`/`remark` 这类长文本 - * 会把 0.7s 的查询拖到 54s(视图无法覆盖扫描,每行都要回基表)。 - * ⚠️ `send_time IS NULL` 是关键:已发货单在这里 100% 有值。 + * 过滤条件(SQL 侧一次到位,不把无关行传回 Node): + * · `send_date IS NULL` —— 未发货(未发单必为空,已发单 100% 有值) + * · `status IN (3 种)` —— Delivering 发货中 / WaitConfirm 待审核 / Question 异常; + * Sent/Cancelled/Split/Merged/WaitOuterSent 直接不进候选 + * · `created >= windowStart` —— 30 天护栏(`HISTORY_LOOKBACK_DAYS`) * - * 🔥 2026-10-20 第八版:**不再把「创建 ≤ 核验−24h」下推到 SQL**。 - * 本版口径是「到核验时刻未发即超时」,`send_time IS NULL` 就是候选; - * 窗口上界改在 Node 侧按**审核时间**判。SQL 里那两个时间列只是用来 - * 判断窗口归属,不再参与「等了多少小时」的门槛。 + * 仓库:订单表给的是 `wms_co_id`(不是仓名文本),主查询**只回这个 id**, + * 由 Node 侧用 `registry.loadWarehouseNameMap()` 的缓存换仓名,再走 + * `resolveCenter()` 关键词归属 —— 比状态表的仓名更准(同一仓在状态表里有 + * 「简尚-崔桥工厂仓 / 工厂-…」多种写法)。 + * 🔥 **不要在 SQL 里 LEFT JOIN `jst_warehouseInfo`**:实测 JOIN 让同一条查询 + * 从 28s 涨到 61~105s(qmcust 视图上 JOIN 直接把执行计划带偏), + * 换到 Node 侧查一次仓表缓存即可(仓表只有几百行,缓存 10 分钟)。 + * + * 🔥 别再按 `is_split / is_merge` 过滤:实测 `is_split=1` 恰恰是**拆单子单** + * (同一 so_id 下多行、各自要发货,近 30 天 1096 行),按老逻辑 `is_split=0` + * 会把它们全排掉、少算超时;父单的 status 是 `Split`,本来就不在候选状态里。 + * `is_merge=1` 且仍 Delivering 的只有 2 行,属于合单后真正要发的单,也不该排。 + * + * 🔥 性能(2026-10-28 实测,30 天护栏): + * · `jst_Orders` **1.8s** + * · `jst_qmcust_orderlist` **50.2s** ← 不加 RECOMPILE 时被参数嗅探拖死 + * · 同一条 SQL 加 `OPTION (RECOMPILE)` → **0.4s** + * ⟹ **必须带 RECOMPILE**,否则 qmcust 会走错执行计划(实测 33s vs 0.7s)。 + * 时间条件也直接拼字面量(值由服务端自己算的 `YYYY-MM-DD HH:mm:ss`,经校验), + * 字面量 + RECOMPILE 两个措施一起上才稳。 + * + * ⚠️ 只投影窄列:`labels` 长文本只在 SQL 里算成布尔 `presaleVoid`,不把原文传回。 */ -async function loadPendingStates(round) { +async function loadPendingOrders(round) { + // 时间字面量:只允许数字 / 短横 / 冒号 / 空格,杜绝拼接注入 + const startLit = String(round.windowStart || '').replace(/[^0-9\-: ]/g, '').slice(0, 19); + if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(startLit)) { + const err = new Error(`核验窗口起点非法: ${round.windowStart}`); + err.statusCode = 500; + throw err; + } + const statusList = ORDER_CANDIDATE_STATUSES.map((s) => `N'${s}'`).join(', '); + const presaleSql = `CASE WHEN labels LIKE N'%${ + VOID_ORDER_LABEL_KEYWORDS[0]}%' THEN 1 ELSE 0 END`; + const rows = await cloudDatabase.querySlow( - // 🔥 时间列直接取原始 datetime,**不要**在 SQL 里 CONVERT 成字符串: - // CONVERT 会作用到扫描到的每一行(谓词下推不到视图里),实测把点查拖到 40s+; - // mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原 - // 就是库里存的北京时间,不会偏移 8 小时。 - `SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name, - e.order_status_name, e.aftersale_status_name, e.logistic_status_name, - e.logistic_id, e.logistic_company_name, - e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time, - e.send_time, e.query_time - FROM dbo.${ORDER_STATE_TABLE} e - WHERE e.send_time IS NULL - AND e.pay_time >= @windowStart - AND e.order_status_name IN (@shipStatus, @st1, @st2)`, - { - shipStatus: SHIPPING_STATUS, - st1: UNAUDITED_STATUS, - st2: ABNORMAL_STATUS, - windowStart: round.windowStart, - }, + `SELECT x.o_id, x.so_id, x.shop_name, x.status, x.created, x.pay_date, + x.logistics_company, x.l_id, x.presaleVoid, x.src, + x.updated_at, x.wms_co_id + FROM ( + SELECT o.o_id, o.so_id, o.shop_name, o.status, o.created, o.pay_date, + o.logistics_company, o.l_id, o.wms_co_id, + ${presaleSql} AS presaleVoid, + o.updateTime AS updated_at, + N'jst_Orders' AS src + FROM rpt.jst_Orders o WITH (NOLOCK) + WHERE o.send_date IS NULL + AND o.created >= '${startLit}' + AND o.status IN (${statusList}) + UNION ALL + SELECT c.o_id, c.so_id, c.shop_name, c.status, c.created, c.pay_date, + c.logistics_company, c.l_id, c.wms_co_id, + ${presaleSql} AS presaleVoid, + c.local_updated AS updated_at, + N'jst_qmcust_orderlist' AS src + FROM rpt.jst_qmcust_orderlist c WITH (NOLOCK) + WHERE c.send_date IS NULL + AND c.created >= '${startLit}' + AND c.status IN (${statusList}) + ) x + OPTION (RECOMPILE)`, + {}, // 视图谓词下推一般,给足超时;不走普通池(60s 池级超时 + 自动重试会把 9s 拖成 120s) { connectRequestTimeoutMs: 300000 } ); return rows || []; } -/** 并发跑任务,最多 `limit` 个同时在飞(不把连接池打满) */ -async function mapLimit(items, limit, worker) { - const out = []; - let cursor = 0; - const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { - for (;;) { - const i = cursor; - cursor += 1; - if (i >= items.length) return; - out[i] = await worker(items[i], i); - } - }); - await Promise.all(runners); - return out; -} - -/** - * 回订单表补「起算点 + 作废事实」。 - * - * 状态表 `jst_order_express_details` 里没有聚水潭创建时间(`createdAt` 是同步落库 - * 时间,比付款晚 1.5~3.5 小时),只能回两张订单表按 `o_id = inner_order_id` 取 - * `created`(稳定比下单晚 15~30 秒,即聚水潭订单列表里的「创建时间」)。 - * - * 🔥 同一趟把**作废事实**也取回来(业务 2026-10-20 反馈作废单被算成超时): - * 状态表的 `send_time` 对**被合并单 / 拆单父单不回写** —— 单子早随主单发走了, - * 状态表却还是「发货中 + send_time 空」。所以必须再看订单表的 - * `status` / `send_date` / `is_merge` / `is_split`(见 registry.VOID_ORDER_STATUS)。 - * - * ⚠️ 只投影这 6 个**窄列**。实测 9,676 个候选:窄列 14.5s + 2.9s, - * 一旦连 pay_amount / labels 一起取,单批 400 个就要 38.7s。 - * - * @param {number[]} oids 内部单号 - * @returns {Promise>} - */ -async function backfillOrderFacts(oids) { - const map = new Map(); - if (!oids.length) return map; - - let remaining = oids; - for (const table of CREATED_SOURCE_TABLES) { - if (!remaining.length) break; - const batches = []; - for (let i = 0; i < remaining.length; i += CREATED_BACKFILL_BATCH) { - batches.push(remaining.slice(i, i + CREATED_BACKFILL_BATCH)); - } - - const results = await mapLimit(batches, 4, async (batch) => { - try { - return await cloudDatabase.querySlow( - `SELECT o_id, created, status, send_date, is_merge, is_split, - CASE WHEN ${VOID_LABEL_SQL} THEN 1 ELSE 0 END AS presaleVoid - FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`, - {}, - { connectRequestTimeoutMs: 300000 } - ); - } catch (e) { - // 补不到不致命:调用方会回退到付款时间,不整页失败 - return []; - } - }); - - for (const rows of results) { - for (const r of rows || []) { - const id = Number(r.o_id); - if (!Number.isFinite(id)) continue; - const status = String(r.status || ''); - const sendDate = formatDateTime(r.send_date); - map.set(id, { - createdAt: formatDateTime(r.created), - status, - sendDate, - // 作废(硬):被合并 / 被拆分 / 已取消 —— 这单不会自己发货,状态分类前就剔除 - void: VOID_ORDER_STATUS.includes(status) - || Number(r.is_merge) === 1 - || Number(r.is_split) === 1, - // 作废(软):问题单 Question / 标签含「发货前售后」。 - // ⚠️ 只能作用于「发货中」分支(见主循环),否则状态表「异常」的单 - // 在订单表常也是 Question,会被一起吞掉 —— 实测 heldCount 472 → 10。 - question: status === VOID_QUESTION_STATUS, - presaleVoid: Number(r.presaleVoid) === 1, - // 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。 - // 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。 - shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status), - }); - } - } - remaining = remaining.filter((n) => !map.has(n)); - } - return map; -} function emptySummary() { return { @@ -391,8 +315,10 @@ function emptySummary() { heldCount: 0, // 已剔除的作废 / 已发单(页面只作说明,不进考核) voidCount: 0, - // 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答) + // 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答 / 武进安能) carrierSkipped: 0, + // 不属于 11 主仓的单量(代发仓 / POP 仓 / 仓库表查不到) + unknownWarehouse: 0, }; } @@ -422,8 +348,11 @@ async function getShipTimeliness(options = {}) { MAX_DETAIL_LIMIT ); - // ① 主仓清单(用于展示「含子仓 N 个」;归属判定走 warehouse_name 关键词) + // ① 主仓清单(用于展示「含子仓 N 个」;归属判定走仓名关键词) const centerList = await registry.loadCenterWarehouseMap(); + // 全量「wms_co_id → 仓名」—— 主查询只回 wms_co_id,名字在这里换 + // (不在 SQL 里 LEFT JOIN:实测 JOIN 会把 28s 拖成 61~105s,视图下推被破坏) + const warehouseNames = await registry.loadWarehouseNameMap(); const pad2 = (n) => String(n).padStart(2, '0'); // ② 决定本轮截止时刻 @@ -492,22 +421,19 @@ async function getShipTimeliness(options = {}) { let stateRows = []; try { - stateRows = await loadPendingStates(round); + stateRows = await loadPendingOrders(round); } catch (e) { - const err = new Error(`聚水潭订单状态表查询失败:${e.message}`); + const err = new Error(`订单表(jst_Orders / jst_qmcust_orderlist)查询失败:${e.message}`); err.statusCode = 502; throw err; } - // ③-b 补起算点「聚水潭创建时间」+ 作废事实(状态表两样都没有,回订单表取) - const oids = [...new Set( - stateRows.map((r) => Number(r.inner_order_id)).filter((n) => Number.isFinite(n)) - )]; - const orderFacts = await backfillOrderFacts(oids); // 被剔除的作废 / 已发单计数(只用于页面说明,不进考核) let voidCount = 0; // 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答 / 武进安能),写进 summary 便于对账 let carrierSkipped = 0; + // 仓库表里查不到 / 不属于 11 主仓的单量(代发仓、POP 仓、wms_co_id 为空…) + let unknownWarehouse = 0; // ④ 分桶 const acc = new Map(centerList.map((c) => [c.key, { @@ -522,139 +448,105 @@ async function getShipTimeliness(options = {}) { let snapshotAt = ''; for (const row of stateRows) { - // 快照时间:状态表最后一次刷新(顺带算,不再单独扫一遍 402 万行表) - const qt = formatDateTime(row.query_time); - if (qt > snapshotAt) snapshotAt = qt; + // 新鲜度:订单表最后一次更新时间(顺带取 MAX,不额外扫表) + const ut = formatDateTime(row.updated_at); + if (ut > snapshotAt) snapshotAt = ut; - const center = resolveCenter(row.warehouse_name); - if (!center) continue; // 非 11 主仓(代发仓/未指定仓/POP 仓…) + // ── 仓库归属:主查询只回 wms_co_id,用仓表缓存换名字再走关键词归属 ────── + // ⚠️ 必须用仓名走关键词归属(子仓并入主仓、京东云仓排除都在 resolveCenter 里), + // 不能拿 wms_co_id 硬编码 —— 上游新增子仓后不用改代码。 + // 实测订单表 wms_co_id 映射出的仓名与状态表 warehouse_name **1821 单完全一致**, + // 所以换字段不影响归属结果。 + const wmsCoId = Number(row.wms_co_id); + const warehouseName = warehouseNames.get(wmsCoId) || ''; + const center = resolveCenter(warehouseName); + if (!center) { + unknownWarehouse += 1; // 代发仓 / 三方仓 / 未指定仓 / 配件仓(常州简尚家居)… + continue; + } const bucket = acc.get(center.key); if (!bucket) continue; - const state = String(row.order_status_name || ''); - // 🔥 作废单不考核 —— 业务 2026-10-20 明确「作废订单不用考核」。 - // 状态表的 send_time 对**被合并单 / 拆单父单不回写**(案例 5127276315127080023: - // 状态表「发货中 + send_time 空」,订单表却是 Merged 且 send_date 有值、 - // 出库单也早就生成了),所以必须回订单表再判一次,光信状态表会被冤枉。 - const fact = orderFacts.get(Number(row.inner_order_id)); - if (fact && (fact.void || fact.shipped)) { + // ── 起算点 = **订单创建时间**(业务 2026-10-28 定版)──────────────────── + // 业务原话:「审核时间是 jst_Orders 这个表里面的创建时间」。 + // 实测 created 100% 早于状态表的 last_oa_confirm_time,正常差 7~40 分钟, + // 夜间下单次日才审的单差 6~9 小时(详见 registry 起算点段的量化)。 + const createdAt = formatDateTime(row.created); + const payAt = formatDateTime(row.pay_date); + const statusRaw = String(row.status || ''); + + // 同一 so_id 可能因拆包出现多行,按 so_id + 创建时间 去重 + const dedupeKey = `${row.so_id}|${createdAt}`; + if (seenSo.has(dedupeKey)) continue; + seenSo.add(dedupeKey); + + // ── 本轮核验范围(窗口上界按**创建时间**卡)──────────────────────────── + // 13:00 轮:今天 07:00 前创建的单 + 历史未发 + // 18:00 轮:今天 16:00 前创建的单 + 历史未发(山东两仓 15:00、河南洛宁 13:00) + // ⚠️ 历史未发单一律算 —— 昨天、前天乃至更早挂着没发的都要进超时列表。 + const windowEndAt = registry.centerWindowEnd(center.key, round); + const todayStart = `${round.date} 00:00:00`; + if (createdAt && createdAt < round.windowStart) continue; // 超出 30 天护栏 + if (createdAt && createdAt >= todayStart && createdAt > windowEndAt) continue; + + const base = { + row, + center, + state: ORDER_STATE_LABELS[statusRaw] || statusRaw, + statusRaw, + src: String(row.src || ''), + createdAt, + payAt, + // 「审核时间」= 创建时间(业务口径),前端那一列显示的就是它 + auditAt: createdAt, + windowAt: createdAt, + benchmarkAt: createdAt, + benchmarkSource: 'created', + warehouseName, + logisticCompany: String(row.logistics_company || '').trim(), + logisticId: String(row.l_id || '').trim(), + }; + + // ── ① 不计考核:待审核(仓库还没接到单)/ 异常(疑问单)────────────────── + if (statusRaw === ORDER_UNAUDITED_STATUS || statusRaw === ORDER_QUESTION_STATUS) { + bucket.heldCount += 1; + heldRows.push({ + ...base, + holdReason: statusRaw === ORDER_UNAUDITED_STATUS ? '待审核' : '订单异常', + }); + continue; + } + + // SQL 白名单只放了 3 种状态,走到这里必然是「发货中」; + // 再兜一次是防止以后有人放宽候选白名单却没补判定分支 + if (statusRaw !== ORDER_PENDING_STATUS) { voidCount += 1; continue; } + + // ── ② 作废:labels 含「发货前售后」(客户发货前退款/取消)────────────── + // ⚠️ 状态表那条「售后已作废」判据已随数据源切换放弃 —— 实测它在 + // 「发货中+未发」里只命中 4 单(基本都是 Sent),不值得为它多扫一张 402 万行表。 + if (Number(row.presaleVoid) === 1) { voidCount += 1; continue; } - // SQL 里已经用状态白名单排除,这里再兜一次,防止以后有人改白名单漏掉 - if (IGNORED_STATES.includes(state)) continue; - - // 🔥 不考核的快递(业务 2026-10-20 定 3 个,2026-10-27 追加武进安能)—— - // 走这几种运输方式的单不按仓储发货时效考核(自提客户自己来拉、 - // 中铁 / 壹米滴答是专线大件、武进安能是加盟制快运网点, - // 揽收与在途都不受仓库控制)。 - // 用**物流公司名**模糊匹配(库里的名字是「中铁物流」「壹米滴答专线」这种)。 + // ── ③ 不考核的快递(自提 / 中铁 / 壹米滴答 / 武进安能)────────────────── + // 走这几种运输方式的单不按仓储发货时效考核:自提客户自己来拉、 + // 中铁 / 壹米滴答是专线大件、武进安能是加盟制快运网点,揽收与在途都不受仓库控制。 // ⚠️ 「武进安能」别泛化成「安能」:常州安能新等网点仍要考核。 - const carrier = String(row.logistic_company_name || '').trim(); - const hitCarrier = EXCLUDED_CARRIERS.find((k) => carrier.includes(k)); + const hitCarrier = EXCLUDED_CARRIERS.find((k) => base.logisticCompany.includes(k)); if (hitCarrier) { carrierSkipped += 1; continue; } - // ── 窗口归属 = **审核时间**(业务 2026-10-20 最终定版,两次纠偏后的结论)──── - // 业务原话:「当日 7 点前订单(7 点前订单**按照审核时间算**)……」 - // 实测案例(业务亲自指出的两单): - // · 9818476:创建 10-18 12:49,但**审核 10-20 13:06** → 不是 7 点前的单,13:00 轮不能出现 - // · 9823980:创建 10-18 20:40,**审核 10-20 09:15** → 同理,归 18:00 轮 - // ⟹ 「7 点前/16 点前」卡的是**审核时间**:审核 ≤ 07:00 归 13:00 轮, - // 审核 ≤ 16:00 归 18:00 轮(山东两仓 15:00、河南洛宁 13:00,见 registry)。 - // ⚠️ 别看创建时间:客户凌晨下单、仓库上午才审的单,按创建时间会提前算超时。 - // ⚠️ 审核时间取**最后一次**(last_oa),见下面 458 行开始的说明。 - const createdAt = fact ? fact.createdAt : ''; - const payAt = formatDateTime(row.pay_time); - const auditFirst = formatDateTime(row.first_oa_confirm_time); - const auditLast = formatDateTime(row.last_oa_confirm_time); - // 🔥 取**最后一次**审核(= 聚水潭订单列表里「审核时间」那一列显示的值)。 - // 业务 2026-10-20 20:0x 抽查 3 单指出「审核时间跟系统里的不一致」, - // 根因就是我们之前取错了列(first_oa = 首次审核): - // 5127368691073091434 首次 10-18 13:15 / 聚水潭显示 10-20 13:38 - // 261019-670795046400872 首次 10-19 08:48 / 聚水潭显示 10-20 09:31 - // 261019-558387771763179 首次 10-19 11:21 / 聚水潭显示 10-20 09:11 - // ⟹ 页面必须跟聚水潭对齐,否则业务一对账就说「跑错了」。 - const auditAt = auditLast || auditFirst; - const windowAt = auditAt || createdAt || payAt; - const benchmarkAt = windowAt; - const benchmarkSource = auditAt - ? (auditLast ? 'last_audit' : 'first_audit') - : (createdAt ? 'created' : 'pay'); - - // 同一 so_id 可能因拆包出现多行(实测有),按 so_id + 窗口时间 去重 - const dedupeKey = `${row.so_id}|${windowAt}`; - if (seenSo.has(dedupeKey)) continue; - seenSo.add(dedupeKey); - - // ── 本轮核验范围(业务 2026-10-20 最终定版)──────────────────────────── - // ① **今天的单按审核时间分段**: - // 今天 00:00 ~ 07:00 审核 → 13:00 轮核验 - // **截止今天 16:00 前审核**的单 → 18:00 轮核验 - // (= 16:00 前所有审核过的单,含 7 点前没发出的;不是「07:00~16:00」那一段。 - // 山东两仓 15:00、河南洛宁 13:00 为仓级例外) - // ⟹ 今天 09:15 审核的单(9823980)、13:06 审核的单(9818476) - // **不进 13:00 轮**,只在 18:00 轮出现(业务亲自指出的两单) - // ② **历史未发的单一律算**(昨天及更早审核、至今没发货)—— 它们已经超时, - // 两个轮次都该看到,不受「今天 00:00 起」的限制。 - // ③ 30 天护栏只为查询性能,不是业务边界。 - const windowEndAt = registry.centerWindowEnd(center.key, round); - const todayStart = `${round.date} 00:00:00`; - if (windowAt && windowAt < round.windowStart) continue; // 超出 30 天护栏 - if (windowAt && windowAt >= todayStart && windowAt > windowEndAt) continue; - - const base = { - row, - center, - state, - createdAt, - payAt, - auditAt, - windowAt, - benchmarkAt, - benchmarkSource, - warehouseName: String(row.warehouse_name || ''), - }; - - // ── 不计考核:没审单(已支付待审核)或订单异常,都不该算仓库超时 ────── - if (state === UNAUDITED_STATUS || state === ABNORMAL_STATUS) { + // ── ④ 超时判定:到核验时刻仍未发货即超时(没有「满 24 小时」门槛)──────── + if (!createdAt) { bucket.heldCount += 1; - heldRows.push({ - ...base, - holdReason: state === UNAUDITED_STATUS ? '待审核' : '订单异常', - }); + heldRows.push({ ...base, holdReason: '缺创建时间' }); continue; } - - if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示 - - // 🔥 2026-10-22:状态表说「发货中」,但订单表/售后维度已有作废证据 → 不考核。 - // ⚠️ 必须放在「发货中」之后:订单表 Question 的单里有一大批在状态表是「异常」, - // 若在状态分类前判,会把 462 单「异常待处理」从 heldOrders 里吞掉。 - // 三类证据(业务 2026-10-22 确认「聚水潭后台已作废却仍算超时」): - // · 状态表 aftersale_status_name =「已作废」(售后维度最权威) - // · 订单表 status = Question(聚水潭后台显示「已作废/问题单」)—— 9823980 / 9880810 - // · 订单表 labels 含「发货前售后」(客户发货前退款/取消)—— 9823980 也带 - if (String(row.aftersale_status_name || '').trim() === AFTERSALE_VOID_STATUS - || (fact && (fact.question || fact.presaleVoid))) { - voidCount += 1; - continue; - } - - // ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛) - if (!benchmarkAt) { - bucket.heldCount += 1; - heldRows.push({ ...base, holdReason: '缺审核时间' }); - continue; - } - // waited = 审核时间 → 本轮核验时刻(13:00 / 18:00) - // ⚠️ 因为窗口本身按审核时间卡(审核 ≤ 07:00 / 16:00),benchmark 必然 ≤ deadline, - // 这里不需要再处理「审核晚于核验」的情况 —— 那种单会自然落到下一轮: - // 审核 13:06 的单进不了 13:00 轮(07:00 上界),只在 18:00 轮出现,已等 = 18:00 − 13:06。 - const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline)); + // waited = 创建时间 → 本轮核验时刻(13:00 / 18:00) + const waited = Math.max(0, diffMinutes(createdAt, round.deadline)); bucket.pendingCount += 1; const days = Math.floor(waited / 1440); @@ -684,26 +576,25 @@ async function getShipTimeliness(options = {}) { const orders = detailRows.map((x) => { const r = x.row; return { - oId: String(r.inner_order_id || ''), + oId: String(r.o_id || ''), soId: String(r.so_id || ''), shopName: String(r.shop_name || ''), - // 创建时间 = 聚水潭订单创建时间(业务 2026-10-20 已从明细列去掉,仅保留字段做回退基准) + // 创建时间 = 订单表 `created`(**本页的考核起算点**,业务 2026-10-28 定版) createdAt: x.createdAt, - // 发货时间:本列表只取 send_time IS NULL 的单,所以这里恒为空 → 前端显示「未发货」 - sendTime: formatDateTime(r.send_time), + // 发货时间:候选本身就是 send_date IS NULL,所以恒为空 → 前端显示「未发货」 + sendTime: formatDateTime(r.send_date), // 付款时间(并列展示,方便对账) payTime: x.payAt, - // 审核时间 = **考核基准**:决定进 13:00 轮还是 18:00 轮,已等时长也从它算 + // 「审核时间」= 创建时间(业务口径:审核时间就是订单创建时间) auditTime: x.auditAt, - // 基准时间实际取值(审核缺失时回退创建/付款)+ 来源标记 benchmarkTime: x.benchmarkAt, benchmarkSource: x.benchmarkSource, state: x.state, - logisticStatus: String(r.logistic_status_name || ''), - // 运单号 / 快递公司:有值说明仓库**已打单**(但还没揽收/回传发货时间)。 - // 业务 2026-10-20 报「聚水潭显示已发货、页面显示未发货」,靠这两列能一眼看出卡在哪一步。 - logisticId: String(r.logistic_id || '').trim(), - logisticCompany: String(r.logistic_company_name || '').trim(), + // 订单表没有物流轨迹,只能从「有没有运单号」判断仓库是否已打单 + logisticStatus: x.logisticId ? '已打单 · 未揽收' : '', + // 运单号 / 快递公司:有运单号说明仓库**已打单**(但还没回传发货时间)。 + logisticId: x.logisticId, + logisticCompany: x.logisticCompany, centerKey: x.center.key, centerName: x.center.name, warehouseName: x.warehouseName, @@ -714,23 +605,22 @@ async function getShipTimeliness(options = {}) { }); const heldOrders = heldDetail.map((x) => { - const r = x.row; const from = x.benchmarkAt || ''; return { - oId: String(r.inner_order_id || ''), - soId: String(r.so_id || ''), - shopName: String(r.shop_name || ''), + oId: String(x.row.o_id || ''), + soId: String(x.row.so_id || ''), + shopName: String(x.row.shop_name || ''), createdAt: x.createdAt, - sendTime: formatDateTime(r.send_time), + sendTime: formatDateTime(x.row.send_date), payTime: x.payAt, auditTime: x.auditAt, benchmarkTime: x.benchmarkAt, benchmarkSource: x.benchmarkSource, state: x.state, holdReason: x.holdReason, - logisticStatus: String(r.logistic_status_name || ''), - logisticId: String(r.logistic_id || '').trim(), - logisticCompany: String(r.logistic_company_name || '').trim(), + logisticStatus: x.logisticId ? '已打单 · 未揽收' : '', + logisticId: x.logisticId, + logisticCompany: x.logisticCompany, centerKey: x.center.key, centerName: x.center.name, warehouseName: x.warehouseName, @@ -773,6 +663,7 @@ async function getShipTimeliness(options = {}) { }, emptySummary()); summary.voidCount = voidCount; summary.carrierSkipped = carrierSkipped; + summary.unknownWarehouse = unknownWarehouse; // 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表) // 🔥 2026-10-20 业务报「已发货的单还显示未发货」时才发现这里曾写死 stale:false —— @@ -781,18 +672,19 @@ async function getShipTimeliness(options = {}) { // 必须把滞后暴露到页面上,否则业务只会以为判定写错。 const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null; const stale = snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES; - // 🔥 更狠的一条:快照**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。 - // 实测 2026-10-20 18:54:快照停在 15:48,18:00 核验时早已发出的单全被判「未发货」, - // 业务连续报了 3 单都是这个原因。这种时候数字必然偏大,必须红字告警。 + // 🔥 更狠的一条:数据**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。 + // 2026-10-27 18:00 轮就是这种情况(状态镜像停在 11:54),推出去的 34 单里混着已发货的。 const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline; const freshness = { stateMaxQuery: snapshotAt, ageMinutes: snapshotAgeMin, stale, coversDeadline, + // 数据源已经换成订单表,文案跟着改(别再写「状态镜像」) + source: 'jst_Orders', hint: [ stale - ? `聚水潭订单状态镜像最新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。` + ? `订单表最后更新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。` : '', !coversDeadline ? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:` @@ -814,13 +706,14 @@ async function getShipTimeliness(options = {}) { isPreviousDay: false, rounds, deadline: round.deadline, - // boundary='audit':轮次归属、已等时长都按**首次审核时间**算 + // boundary='created':轮次归属、已等时长都按**订单创建时间**(jst_Orders.created)算 + // 业务 2026-10-28 定版;此前一段用的是状态表的 last_oa_confirm_time,已废弃。 window: { start: round.windowStart, end: round.windowEnd, includeHistory: true, - boundary: 'audit', - measuredBy: 'audit', + boundary: 'created', + measuredBy: 'created', // 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮) overrides: windowOverrides, }, diff --git a/backend/test/warehouseShipTimeliness.test.js b/backend/test/warehouseShipTimeliness.test.js index 1bafada..29da148 100644 --- a/backend/test/warehouseShipTimeliness.test.js +++ b/backend/test/warehouseShipTimeliness.test.js @@ -1,23 +1,27 @@ /** - * 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 第八版) + * 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-28 第九版:换数据源 + 换起算点) * * 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定几条关键语义: * ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走; * ② 核验轮次的时刻选择 —— 13:00 前**不做核验**、13:00~17:59 用 07:00 窗口、 * 18:00 后用 16:00 窗口;「核验时刻」与「订单窗口上界」是两个字段; - * ③ 数据源必须是 `jst_order_express_details`(聚水潭实时状态镜像)—— - * 订单表的 status 是下单快照,取消/售后/审核都不回写,用它必错; - * ④ 🔥 窗口按**创建时间**卡 7:00 / 16:00(含历史未发),**已等时长按审核时间算**: - * 「当日 7 点前订单(7 点前订单按照审核时间算),13:00 核验未发就算超时」。 - * ⚠️ 别把窗口卡到审核时间上 —— 审单集中在早上 8:30~10:00 批量做, - * 卡审核时间 13:00 轮全库只剩 2 单(实测 29 单 → 2 单),等于把功能做废; - * ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时。 - * 第七版加了 24h 门槛,13:00 轮永远看不到当天 7 点那批,被业务打回; - * ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核·异常=不计考核 / 其余丢弃; - * **作废单(已取消 / 合并 / 拆单父单)不考核**; - * ⑦ 性能护栏:状态表只允许 **1 次** querySlow,不得投影宽列、不得回订单表补金额。 + * ③ 🔥 数据源必须是**订单两表** `jst_Orders` + `jst_qmcust_orderlist`(业务 2026-10-28: + * 「销售订单表是错误的,应该在数据库抓 jst_Orders + jst_OrderItem」)。 + * 原来打的状态镜像表 `jst_order_express_details` 有三处硬伤(send_time 漏回写 / + * 合并拆单不回写 / 整批停更 6.8 小时),已废弃; + * 两张订单表**店铺互斥**(交集 0),必须 UNION ALL 都读,按 o_id 去重; + * ④ 🔥 起算点 = `jst_Orders.created`(**订单创建时间**)。 + * 业务原话:「审核时间 jst_Orders 是这个表里面的创建时间」。 + * 实测 created 100% 早于状态表的 last_oa_confirm_time:正常单差 7~40 分钟, + * 夜间下单次日才审的单差 6~9 小时 —— 所以换口径后单量与时长都会变大,这是**预期**; + * ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时; + * ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核(WaitConfirm)·异常(Question)=不计考核; + * 作废单(labels 含「发货前售后」)不考核; + * ⑦ 性能护栏:订单两表只允许 **1 次** querySlow,不得 JOIN 仓表、不得投影宽列; + * ⑧ **不按 is_split / is_merge 过滤** —— 实测 is_split=1 是拆单**子单**(要发货的), + * 按老逻辑 is_split=0 会把近 30 天 1096 行真单排掉。 * - * ⚠️ 本文件**不连数据库**,纯逻辑测试。 + * ⚠️ 本文件**不连数据库**,纯逻辑测试(注入假 cloudDatabase)。 */ const test = require('node:test'); const assert = require('node:assert/strict'); @@ -129,7 +133,7 @@ test('describeRounds 两轮的核验时刻与窗口上界(16:00~17:59 属于 assert.equal(registry.resolveActiveRound(at(16, 5)).key, 'NOON'); }); -test('审核晚于本轮核验时刻 → 顺延到下一轮核验时刻', () => { +test('创建晚于本轮核验时刻 → 顺延到下一轮核验时刻', () => { const [noon, afternoon] = registry.describeRounds(at(16, 5)); assert.equal(registry.nextRoundDeadline(noon), '2026-10-20 18:00:00', '13:00 轮 → 当天 18:00'); assert.equal(registry.nextRoundDeadline(afternoon), '2026-10-21 13:00:00', @@ -142,11 +146,27 @@ test('审核晚于本轮核验时刻 → 顺延到下一轮核验时刻', () => }); // ── ③ 常量口径 ────────────────────────────────────────────────────────── -test('数据源必须是聚水潭实时状态表,不是订单表', () => { +test('数据源必须是订单两表,不再是状态镜像表', () => { + assert.deepEqual( + [...registry.ORDER_SOURCE_TABLES], + ['jst_Orders', 'jst_qmcust_orderlist'], + '两张订单表店铺互斥(交集 0),必须都读' + ); + // 状态表的常量还留着(兜底 / 老代码引用),但服务层不许再用它取候选 assert.equal(registry.ORDER_STATE_TABLE, 'jst_order_express_details'); }); -test('三态名称;24 小时常量仅用于页面命名,不再参与判定', () => { +test('候选状态白名单:发货中 / 待审核 / 异常 三种', () => { + assert.equal(registry.ORDER_PENDING_STATUS, 'Delivering'); + assert.equal(registry.ORDER_UNAUDITED_STATUS, 'WaitConfirm'); + assert.equal(registry.ORDER_QUESTION_STATUS, 'Question'); + assert.equal(registry.ORDER_SHIPPED_STATUS, 'Sent'); + assert.deepEqual( + [...registry.ORDER_CANDIDATE_STATUSES], + ['Delivering', 'WaitConfirm', 'Question'], + 'Sent / Cancelled / Split / Merged 一律不进候选' + ); + // 中文状态只是给人看的 assert.equal(registry.SHIPPING_STATUS, '发货中'); assert.equal(registry.UNAUDITED_STATUS, '已支付待审核'); assert.equal(registry.ABNORMAL_STATUS, '异常'); @@ -154,282 +174,129 @@ test('三态名称;24 小时常量仅用于页面命名,不再参与判定', assert.equal(registry.SHIP_TIMELINESS_HOURS, 24); }); -test('作废单(已取消)明确不考核', () => { - assert.ok( - registry.IGNORED_STATES.includes('已取消'), - '「已取消」必须在排除名单里 —— 近 30 天 6.4 万单,是「发货中」的 26 倍' - ); - assert.ok(registry.IGNORED_STATES.includes('已发货')); - assert.ok(registry.IGNORED_STATES.includes('等供销商|外仓发货')); -}); - -test('创建时间要从订单表补,且只补窄列', () => { +test('起算点是 orders.created,且只从订单两表取', () => { assert.deepEqual( [...registry.CREATED_SOURCE_TABLES], ['jst_Orders', 'jst_qmcust_orderlist'], '两张订单表都要查(店铺互斥,交集为 0)' ); - assert.ok(registry.CREATED_BACKFILL_BATCH >= 100, '必须分批,不能一次 IN 上万个'); + assert.ok(registry.CREATED_BACKFILL_BATCH >= 100, '常量保留(分批 IN 的批量上限)'); }); // ── ④ 服务层分桶(注入假查询,不连库)── function stubDb(dbPath, scenario = 'default') { - const wmsId = 15038426; - - // 创建时间(展示)+ 作废事实来源:o_id → 订单表行 - // 故意让 3 号单**补不到**(模拟订单表缺行)→ 展示回退付款时间 - const f = (table, o) => ({ table, status: 'WaitConfirm', send_date: null, is_merge: 0, is_split: 0, ...o }); - - const defaultFacts = { - 1: f('jst_Orders', { created: '2026-09-25 00:50:00' }), - 2: f('jst_Orders', { created: '2026-10-20 16:20:00' }), - 6: f('jst_Orders', { created: '2026-10-20 17:00:00' }), - 7: f('jst_qmcust_orderlist', { created: '2026-09-25 01:00:00' }), - 9: f('jst_Orders', { created: '2026-09-21 09:00:00', status: 'Cancelled' }), - // 10:状态表「发货中 + send_time 空」,但订单表是 Merged —— 真实案例 - // 5127276315127080023(内部单号 9486414)就是这样被误判成超时的 - 10: f('jst_qmcust_orderlist', { created: '2026-09-21 10:00:00', status: 'Merged', send_date: '2026-09-22 06:54:49' }), - // 11:状态表 send_time 空,订单表 send_date 有值(已发) - 11: f('jst_Orders', { created: '2026-09-21 11:00:00', send_date: '2026-09-22 08:00:00' }), - // 37:🔥 订单表 send_date 没回写,但销售订单 status 已是 Sent(已发货) - // —— 业务 2026-10-20「已发/未发参考聚水潭销售订单页面」 - 37: f('jst_Orders', { created: '2026-09-22 09:00:00', status: 'Sent' }), - // 12:拆单父单,status 不是 Split 但 is_split=1 - 12: f('jst_Orders', { created: '2026-09-21 12:00:00', status: 'Delivering', is_split: 1 }), - 13: f('jst_Orders', { created: '2026-10-20 15:00:00' }), - // 31~34:验证仓级例外窗口(山东两仓 15:00、河南洛宁 13:00,仅 18:00 轮) - 31: f('jst_Orders', { created: '2026-10-20 15:30:00' }), - 32: f('jst_Orders', { created: '2026-10-20 14:30:00' }), - 33: f('jst_Orders', { created: '2026-10-20 13:30:00' }), - 34: f('jst_Orders', { created: '2026-10-20 12:30:00' }), - // 26:创建 10-18,但**今天 13:10 才审核** —— 业务点名的形态(9818476) - // 只能进 18:00 轮,不能进 13:00 轮 - 26: f('jst_Orders', { created: '2026-10-18 12:00:00' }), - // 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30 - 25: f('jst_Orders', { created: '2026-10-19 10:00:00' }), - // ── 2026-10-22 作废单三类形态 ── - // 38:订单表 Question(聚水潭「已作废/问题单」) - 38: f('jst_Orders', { created: '2026-10-20 09:10:00', status: 'Question' }), - // 39:labels 含「发货前售后」—— stub 直接给布尔结果(真实 SQL 用 CASE WHEN LIKE 转) - 39: f('jst_Orders', { created: '2026-10-20 10:10:00', presaleVoid: 1 }), - // 40:靠状态表 aftersale_status_name='已作废' 剔除,订单表本身没异常 - 40: f('jst_Orders', { created: '2026-10-20 11:10:00' }), + const WMS = { + CQ: 15038426, + SD_QGJ: 15137523, + SD_BRAND: 15182705, + HN_LN: 15539084, + JD_CLOUD: 99999901, }; - // 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」 - const noonFacts = { - 21: f('jst_Orders', { created: '2026-10-20 06:45:00' }), - 22: f('jst_Orders', { created: '2026-10-20 05:55:00' }), - 23: f('jst_Orders', { created: '2026-10-19 19:55:00' }), - 24: f('jst_Orders', { created: '2026-10-20 07:30:00' }), - // 25:重审单(首次审核 10-19 10:10、最后一次 10-20 13:30)—— 时长要按首次算 - 25: f('jst_Orders', { created: '2026-10-19 10:00:00' }), - // 26:今天 13:10 才审(晚于核验时刻 13:00)—— 已等时长要回退按创建时间算 - 26: f('jst_Orders', { created: '2026-10-18 12:00:00' }), - }; + const warehouseRows = [ + { Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: WMS.CQ }, + { Id: 2, name: '简尚-山东青岗集仓', wms_co_id: WMS.SD_QGJ }, + { Id: 3, name: '简尚-山东品牌仓', wms_co_id: WMS.SD_BRAND }, + { Id: 4, name: '简尚-河南洛宁仓', wms_co_id: WMS.HN_LN }, + // 业务指定排除的京东云仓:仓表里有它,但 resolveCenter 返回 null + { Id: 5, name: '京仓-横林二仓京东云仓', wms_co_id: WMS.JD_CLOUD }, + // 代发仓:归不到主仓 → 计入 unknownWarehouse + { Id: 6, name: '工厂-山东菏泽森美代发仓', wms_co_id: 88880001 }, + ]; - const factsMap = scenario === 'noon' ? noonFacts : defaultFacts; - - const mk = (o) => ({ + // 订单行 = 主查询的投影(SQL 已经按 send_date IS NULL + status IN 过滤过) + const o = (id, wmsCoId, created, extra = {}) => ({ + o_id: id, + so_id: `SO-${id}`, shop_name: '测试店', - logistic_status_name: '', - query_time: '2026-10-20 11:18:27', - // 状态表售后状态:默认「暂无售后」;2026-10-22 新增「已作废」判据要用到 - aftersale_status_name: '暂无售后', - // 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time - first_oa_confirm_time: o.last_oa_confirm_time ?? null, - ...o, + status: 'Delivering', + created, + pay_date: created, + logistics_company: '', + l_id: '', + presaleVoid: 0, + src: 'jst_Orders', + updated_at: '2026-10-20 18:02:00', + wms_co_id: wmsCoId, + ...extra, }); const defaultRows = [ - // 1:审核 09-25 01:00 → 到 18:00 核验 = 25 天 17 小时,超时 - mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-09-24 23:50:00', - last_oa_confirm_time: '2026-09-25 01:00:00' }), - // 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验(窗口卡的是创建时间) - mk({ so_id: 'SO-2', inner_order_id: 2, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-20 16:10:00', - last_oa_confirm_time: '2026-10-20 16:30:00' }), - // 3:订单表补不到创建时间 → 展示回退付款时间;审核 09-26 00:15 仍应判超时 - mk({ so_id: 'SO-3', inner_order_id: 3, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-09-26 00:10:00', - last_oa_confirm_time: '2026-09-26 00:15:00' }), - // 4:已支付待审核(还没审单、没有审核时间)→ 不计考核 - mk({ so_id: 'SO-4', inner_order_id: 4, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '已支付待审核', pay_time: '2026-09-23 01:00:00', - last_oa_confirm_time: null }), - // 5:异常 → 不计考核 - mk({ so_id: 'SO-5', inner_order_id: 5, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '异常', logistic_status_name: '常州安能新', pay_time: '2026-09-23 01:00:00', - last_oa_confirm_time: '2026-09-23 01:05:00' }), - // 6:审核 17:05 晚于窗口上界 → 本轮不该核验 - mk({ so_id: 'SO-6', inner_order_id: 6, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-20 16:55:00', - last_oa_confirm_time: '2026-10-20 17:05:00' }), - // 7:被业务排除的京东云仓 → 不归属任何主仓 - mk({ so_id: 'SO-7', inner_order_id: 7, warehouse_name: '京仓-横林二仓京东云仓', - order_status_name: '发货中', pay_time: '2026-09-25 00:00:00', - last_oa_confirm_time: '2026-09-25 00:10:00' }), - // 8:与 1 同 so_id + 同基准时间(拆包重复行)→ 必须去重 - mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-09-24 23:50:00', - last_oa_confirm_time: '2026-09-25 01:00:00' }), - // 9:作废单(已取消)—— 业务定性不考核 - mk({ so_id: 'SO-9', inner_order_id: 9, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '已取消', pay_time: '2026-09-21 08:00:00', - last_oa_confirm_time: '2026-09-21 08:10:00' }), - // 10:状态表「发货中」、但订单表是 Merged + 已发 —— 必须剔除 - mk({ so_id: 'SO-10', inner_order_id: 10, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-09-21 09:59:00', - last_oa_confirm_time: '2026-09-21 10:05:00' }), - // 11:状态表「发货中」、订单表 send_date 有值(已发)—— 必须剔除 - mk({ so_id: 'SO-11', inner_order_id: 11, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-09-21 10:59:00', - last_oa_confirm_time: '2026-09-21 11:05:00' }), - // 12:拆单父单(is_split=1)—— 自己不发货,必须剔除 - mk({ so_id: 'SO-12', inner_order_id: 12, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-09-21 11:59:00', - last_oa_confirm_time: '2026-09-21 12:05:00' }), - // 13:🔥 当天 15:30 审核、18:00 核验只等了 2.5 小时 —— 旧版 24h 门槛会漏掉, - // 本版必须算超时(业务口径:到点未发就算超时) - mk({ so_id: 'SO-13', inner_order_id: 13, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-20 14:59:00', - last_oa_confirm_time: '2026-10-20 15:30:00' }), + // 1:创建 09-25 00:50 → 到 18:00 核验 = 25 天 17 小时 = 37020 分,超时 + o(1, WMS.CQ, '2026-09-25 00:50:00', { pay_date: '2026-09-24 23:50:00' }), + // 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验 + o(2, WMS.CQ, '2026-10-20 16:20:00'), + // 3:没创建时间(真实 SQL 里 created >= 30 天护栏会挡掉,这里测 JS 兜底分支)→ 不计考核 + o(3, WMS.CQ, null, { pay_date: '2026-09-26 00:10:00' }), + // 4:WaitConfirm 待审核 → 不计考核 + o(4, WMS.CQ, '2026-09-23 01:00:00', { status: 'WaitConfirm' }), + // 5:Question 异常 → 不计考核 + o(5, WMS.CQ, '2026-09-23 01:05:00', { status: 'Question' }), + // 6:创建 17:00 晚于 16:00 → 本轮不核验 + o(6, WMS.CQ, '2026-10-20 17:00:00'), + // 7:京东云仓(业务排除)→ 不计入任何主仓 + o(7, WMS.JD_CLOUD, '2026-09-25 00:10:00'), + // 8:与 1 同 so_id + 同创建时间(拆包多行)→ 必须去重成 1 单 + o(1, WMS.CQ, '2026-09-25 00:50:00', { pay_date: '2026-09-24 23:50:00', so_id: 'SO-1' }), + // 13:创建 15:00 → 18:00 只等 3 小时,也必须算超时(无 24h 门槛) + o(13, WMS.CQ, '2026-10-20 15:00:00'), // ── 仓级例外窗口(18:00 轮):山东两仓 15:00、河南洛宁 13:00 ── - // 31:山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界 → 本轮不核验 - mk({ so_id: 'SO-31', inner_order_id: 31, warehouse_name: '简尚-山东青岗集仓', - order_status_name: '发货中', pay_time: '2026-10-20 15:29:00', - last_oa_confirm_time: '2026-10-20 15:35:00' }), - // 32:山东品牌仓 14:30 创建 → 早于 15:00 上界 → 核验,且超时 - mk({ so_id: 'SO-32', inner_order_id: 32, warehouse_name: '简尚-山东品牌仓', - order_status_name: '发货中', pay_time: '2026-10-20 14:29:00', - last_oa_confirm_time: '2026-10-20 14:35:00' }), - // 33:河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界 → 本轮不核验 - mk({ so_id: 'SO-33', inner_order_id: 33, warehouse_name: '简尚-河南洛宁仓', - order_status_name: '发货中', pay_time: '2026-10-20 13:29:00', - last_oa_confirm_time: '2026-10-20 13:35:00' }), - // 34:河南洛宁仓 12:30 创建 → 早于 13:00 上界 → 核验,且超时 - mk({ so_id: 'SO-34', inner_order_id: 34, warehouse_name: '简尚-河南洛宁仓', - order_status_name: '发货中', pay_time: '2026-10-20 12:29:00', - last_oa_confirm_time: '2026-10-20 12:35:00' }), - // 26:🔥 业务点名的形态 —— 创建 10-18,但今天 13:10 才审核(9818476 就是这样)。 - // 它属于 18:00 轮(13:10 ≤ 16:00),已等 = 18:00 − 13:10 = 290 分 - mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-18 11:59:00', - first_oa_confirm_time: '2026-10-20 13:10:00', - last_oa_confirm_time: '2026-10-20 13:10:00' }), - // 25:重审单 —— 聚水潭「审核时间」显示最后一次 10-20 13:30(不是首次 10-19 10:10) - mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-19 09:59:00', - first_oa_confirm_time: '2026-10-19 10:10:00', - last_oa_confirm_time: '2026-10-20 13:30:00' }), - // 35/36:🔥 不考核快递(业务 2026-10-20):中铁物流、壹米滴答专线 —— 一律跳过。 - // 这两单状态/时间都够超时,靠快递名剔除,同时计入 summary.carrierSkipped - mk({ so_id: 'SO-35', inner_order_id: 35, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-09-25 01:00:00', - last_oa_confirm_time: '2026-09-25 01:30:00', logistic_company_name: '中铁物流' }), - mk({ so_id: 'SO-36', inner_order_id: 36, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-09-25 02:00:00', - last_oa_confirm_time: '2026-09-25 02:30:00', logistic_company_name: '壹米滴答专线' }), - // 41/42:🔥 武进安能(业务 2026-10-27 追加不考核)—— 两种实际写法都要剔掉。 - // 42 是「武进安能大件」,靠包含匹配一起命中,别写成精确相等。 - mk({ so_id: 'SO-41', inner_order_id: 41, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-09-25 03:00:00', - last_oa_confirm_time: '2026-09-25 03:30:00', logistic_company_name: '武进安能' }), - mk({ so_id: 'SO-42', inner_order_id: 42, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-09-25 04:00:00', - last_oa_confirm_time: '2026-09-25 04:30:00', logistic_company_name: '武进安能大件' }), - // 37:销售订单 status=Sent(已发货),状态表还是「发货中 + send_time 空」→ 必须剔除 - mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-09-22 08:50:00', - last_oa_confirm_time: '2026-09-22 09:05:00' }), - // ── 2026-10-22 作废单三类形态(业务反馈 3 单在聚水潭后台已作废却仍算超时)── - // 38:订单表 status=Question(聚水潭后台的「已作废/问题单」)—— 真实案例 9823980 / 9880810 - mk({ so_id: 'SO-38', inner_order_id: 38, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-20 09:10:00', - last_oa_confirm_time: '2026-10-20 09:15:00' }), - // 39:订单表状态正常,但 labels 含「发货前售后」→ 视同作废 —— 真实案例 9823980 - mk({ so_id: 'SO-39', inner_order_id: 39, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-20 10:10:00', - last_oa_confirm_time: '2026-10-20 10:15:00' }), - // 40:状态表售后状态「已作废」→ 最权威的作废标记,直接剔除 - mk({ so_id: 'SO-40', inner_order_id: 40, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', aftersale_status_name: '已作废', - pay_time: '2026-10-20 11:10:00', last_oa_confirm_time: '2026-10-20 11:15:00' }), + o(31, WMS.SD_QGJ, '2026-10-20 15:30:00'), // 晚于 15:00 上界 → 不核验 + o(32, WMS.SD_BRAND, '2026-10-20 14:30:00'), // 早于 15:00 上界 → 核验(210 分) + o(33, WMS.HN_LN, '2026-10-20 13:30:00'), // 晚于 13:00 上界 → 不核验 + o(34, WMS.HN_LN, '2026-10-20 12:30:00'), // 早于 13:00 上界 → 核验(330 分) + // 26:昨天中午创建、至今未发 → 历史未发池,超时 3240 分 + o(26, WMS.CQ, '2026-10-18 12:00:00'), + // 25:前天一早就创建里挂着 → 超时 1920 分 + o(25, WMS.CQ, '2026-10-19 10:00:00'), + // 35/36/41/42:不考核快递 —— 状态/时间都够超时,靠快递名剔除 + o(35, WMS.CQ, '2026-09-25 01:00:00', { logistics_company: '中铁物流' }), + o(36, WMS.CQ, '2026-09-25 02:00:00', { logistics_company: '壹米滴答专线' }), + o(41, WMS.CQ, '2026-09-25 03:00:00', { logistics_company: '武进安能' }), + o(42, WMS.CQ, '2026-09-25 04:00:00', { logistics_company: '武进安能大件' }), + // 38:Question(聚水潭后台「已作废/问题单」)→ 不计考核 + o(38, WMS.CQ, '2026-10-20 09:10:00', { status: 'Question' }), + // 39:labels 含「发货前售后」(SQL 已折成布尔)→ 作废,进 voidCount + o(39, WMS.CQ, '2026-10-20 10:10:00', { presaleVoid: 1 }), + // 40:创建 11:10 → 410 分。⚠️ 状态表那条「售后已作废」判据已随数据源切换放弃 + // (实测只命中 4 单,不值得为它多扫 402 万行),所以这类单现在按正常单算超时 + o(40, WMS.CQ, '2026-10-20 11:10:00'), + // ── 以下由 SQL 过滤掉(status 不在白名单 / send_date 有值), + // 这里照样塞进最后端,验证「SQL 过滤 + JS 兜底」两道闸都不会漏 ── + o(9, WMS.CQ, '2026-09-21 08:00:00', { status: 'Cancelled' }), + o(10, WMS.CQ, '2026-09-21 09:59:00', { status: 'Merged', send_date: '2026-09-22 06:54:49' }), + o(11, WMS.CQ, '2026-09-21 10:59:00', { send_date: '2026-09-22 08:00:00' }), + o(37, WMS.CQ, '2026-09-22 08:50:00', { status: 'Sent' }), + // 拆单子单(is_split=true 也要算,别按 is_split 过滤) + o(43, WMS.CQ, '2026-10-20 13:00:00', { is_split: true }), ]; // 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00 const noonRows = [ - // 21:创建 06:45(7 点前)+ 审核 07:20 → 进 13:00 轮;已等 = 13:00 − 07:20 = 340 分 - mk({ so_id: 'SO-21', inner_order_id: 21, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-20 06:40:00', - last_oa_confirm_time: '2026-10-20 07:20:00' }), - // 22:创建 05:55 + 审核 06:40 → 13:00 核验只等 380 分钟,也必须算超时(无 24h 门槛) - mk({ so_id: 'SO-22', inner_order_id: 22, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-20 05:50:00', - last_oa_confirm_time: '2026-10-20 06:40:00' }), - // 23:昨天创建、至今未发(历史未发池)→ 13:00 轮也要看到 - mk({ so_id: 'SO-23', inner_order_id: 23, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-19 19:50:00', - last_oa_confirm_time: '2026-10-19 20:10:00' }), - // 24:创建 07:30 晚于窗口上界 07:00 → 本轮不核验(窗口卡的是创建时间) - mk({ so_id: 'SO-24', inner_order_id: 24, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-20 07:29:00', - last_oa_confirm_time: '2026-10-20 07:40:00' }), - // 25:🔥 被「重新审核」过的单:首次审核 10-19 10:10、最后一次 10-20 13:30。 - // 时长必须按**首次**审核算(1610 分),用 last 会算成 0 分 - mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-19 09:59:00', - first_oa_confirm_time: '2026-10-19 10:10:00', - last_oa_confirm_time: '2026-10-20 13:30:00' }), - // 26:🔥 今天 13:10 才审(晚于核验时刻 13:00)→ 已等时长回退按创建时间算, - // 不能显示 0 分(真实案例:创建 10-18 的单今天才审) - mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓', - order_status_name: '发货中', pay_time: '2026-10-18 11:59:00', - first_oa_confirm_time: '2026-10-20 13:10:00', - last_oa_confirm_time: '2026-10-20 13:10:00' }), + o(21, WMS.CQ, '2026-10-20 06:45:00', { updated_at: '2026-10-20 11:18:27' }), // 375 分 + o(22, WMS.CQ, '2026-10-20 05:55:00', { updated_at: '2026-10-20 11:18:27' }), // 425 分 + o(23, WMS.CQ, '2026-10-19 19:55:00', { updated_at: '2026-10-20 11:18:27' }), // 1025 分 + o(24, WMS.CQ, '2026-10-20 07:30:00', { updated_at: '2026-10-20 11:18:27' }), // 晚于 07:00 → 不核验 + o(25, WMS.CQ, '2026-10-19 10:00:00', { updated_at: '2026-10-20 11:18:27' }), // 1620 分 + o(26, WMS.CQ, '2026-10-18 12:00:00', { updated_at: '2026-10-20 11:18:27' }), // 2940 分 ]; const rows = scenario === 'noon' ? noonRows : defaultRows; - const calls = { state: 0, backfill: 0, sql: [], backfillSql: [], params: [] }; + const calls = { orders: 0, warehouse: 0, sql: [], params: [] }; const handler = async (sql, params) => { const text = String(sql); if (/jst_warehouseInfo/u.test(text)) { - return [ - { Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId }, - { Id: 2, name: '简尚-山东青岗集仓', wms_co_id: 15137523 }, - { Id: 3, name: '简尚-山东品牌仓', wms_co_id: 15182705 }, - { Id: 4, name: '简尚-河南洛宁仓', wms_co_id: 15539084 }, - ]; + calls.warehouse += 1; + return warehouseRows; } - // 补「起算点 + 作废事实」:按 o_id 分批 IN 两张订单表 - if (/FROM dbo\.(jst_Orders|jst_qmcust_orderlist)/u.test(text)) { - calls.backfill += 1; - calls.backfillSql.push(text); - const table = /jst_qmcust_orderlist/u.test(text) ? 'jst_qmcust_orderlist' : 'jst_Orders'; - const m = text.match(/IN \(([0-9,\s]+)\)/u); - const ids = m ? m[1].split(',').map((s) => Number(s.trim())) : []; - return ids - .filter((id) => factsMap[id] && factsMap[id].table === table) - .map((id) => { - const x = factsMap[id]; - return { - o_id: id, - created: x.created, - status: x.status, - send_date: x.send_date, - is_merge: x.is_merge, - is_split: x.is_split, - presaleVoid: x.presaleVoid || 0, - }; - }); - } - calls.state += 1; + calls.orders += 1; calls.sql.push(text); calls.params.push(params || {}); - return rows; + // 模拟 SQL 侧过滤:send_date IS NULL 且 status 在白名单里 + return rows.filter((r) => r.send_date == null + && ['Delivering', 'WaitConfirm', 'Question'].includes(r.status)); }; require.cache[dbPath] = { @@ -438,7 +305,7 @@ function stubDb(dbPath, scenario = 'default') { loaded: true, exports: { query: handler, querySlow: handler }, }; - return { calls, wmsId }; + return { calls, WMS }; } function withStubbedService(fn, scenario = 'default') { @@ -461,60 +328,56 @@ function withStubbedService(fn, scenario = 'default') { } test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStubbedService(async (svc, { calls }) => { - // 18:05 → 18:00 轮:窗口上界 16:00(按审核时间),核验时刻 18:00 + // 18:05 → 18:00 轮:窗口上界 16:00(按创建时间),核验时刻 18:00 const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 }); const c = data.centers.find((x) => x.key === 'CQ'); assert.ok(c, '应能归到崔桥工厂仓'); - // 超时 = 1(09-25 审核)+ 3(补不到创建时间)+ 13(15:30 审核,只等 2.5 小时) - // + 32(山东品牌仓 14:30)+ 34(河南洛宁 12:30)+ 26(今天 13:10 审核) - // = 6 单 - // 2/6 今天审核晚于 16:00、4/5 不计考核、7 非主仓、8 去重、9~12 作废/已发 - // 31/33 是仓级例外窗口(山东 15:00 / 河南洛宁 13:00)挡掉的 - assert.equal(c.overdueUnshipped, 5, '1、3、13、26、25 五单算超时'); - assert.equal(c.oldestDays, 25, '最久一单 09-25 01:00 审核,到 18:00 核验是 25 天'); - assert.equal(c.heldCount, 2, '待审核 + 异常各 1 单'); + // 崔桥工厂仓超时 = 1(09-25) + 13(15:00) + 25(10-19 10:00) + 26(10-18 12:00) + 40(11:10) + 43(13:00) + // 2/6 创建晚于 16:00、3 没创建时间、4/5/38 不计考核、7 非主仓、8 去重 + assert.equal(c.overdueUnshipped, 6, '1、13、25、26、40、43 六单算超时'); + assert.equal(c.oldestDays, 25, '最久一单 09-25 00:50 创建,到 18:00 核验是 25 天'); + assert.equal(c.heldCount, 4, '待审核 1 + 异常 2 + 缺创建时间 1'); - assert.equal(data.summary.overdueUnshipped, 7); + // 全局:崔桥 6 + 山东品牌 1 + 河南洛宁 1 = 8 + assert.equal(data.summary.overdueUnshipped, 8); assert.equal(data.summary.overdueCenterCount, 3); assert.equal(data.summary.oldestDays, 25); - assert.equal(data.summary.heldCount, 2); + assert.equal(data.summary.heldCount, 4); + // 京东云仓归不到主仓 → unknownWarehouse(代发仓不在本场景行里,故只有 1 单) + assert.equal(data.summary.unknownWarehouse, 1); - // 明细:最久的排最前;基准时间是**审核时间**,创建时间只展示 - assert.equal(data.orders.length, 7); + // 明细:最久的排最前;起算点就是创建时间 + assert.equal(data.orders.length, 8); assert.equal(String(data.orders[0].oId), '1'); - assert.equal(data.orders[0].auditTime, '2026-09-25 01:00:00', '基准是审核时间'); - assert.equal(data.orders[0].createdAt, '2026-09-25 00:50:00', '创建时间只作展示'); + assert.equal(data.orders[0].auditTime, '2026-09-25 00:50:00', '审核时间 = 订单创建时间'); + assert.equal(data.orders[0].createdAt, '2026-09-25 00:50:00'); assert.equal(data.orders[0].payTime, '2026-09-24 23:50:00'); - assert.equal(data.orders[0].benchmarkSource, 'last_audit'); - // 09-25 01:00 → 10-20 18:00 = 25 天 + 17 小时 = 37020 分钟 - assert.equal(data.orders[0].overdueMinutes, 37020); + assert.equal(data.orders[0].benchmarkSource, 'created', '起算点必须是 created'); + // 09-25 00:50 → 10-20 18:00 = 25 天 + 17 小时 10 分 = 37030 分钟 + assert.equal(data.orders[0].overdueMinutes, 37030); - // 13 号单:15:30 审核 → 18:00 核验只等了 150 分钟,旧版 24h 门槛会漏,本版必须算 + // 13 号单:15:00 创建 → 18:00 核验只等了 180 分钟,旧版 24h 门槛会漏,本版必须算 const fresh = data.orders.find((o) => String(o.oId) === '13'); - assert.ok(fresh, '只等 2.5 小时也必须算超时(无 24h 门槛)'); - assert.equal(fresh.overdueMinutes, 150); - assert.equal(fresh.waitedHours, 2.5); + assert.ok(fresh, '只等 3 小时也必须算超时(无 24h 门槛)'); + assert.equal(fresh.overdueMinutes, 180); + assert.equal(fresh.waitedHours, 3); - // 26 号单:创建 10-18、今天 13:10 才审核 → 属于 18:00 轮,已等 = 18:00 − 13:10 = 290 - const lateAudit = data.orders.find((o) => String(o.oId) === '26'); - assert.ok(lateAudit, '下午才审核的老单要进 18:00 轮(不能因为创建早就算进 13:00 轮)'); - assert.equal(lateAudit.overdueMinutes, 290); + // 26 号单:10-18 12:00 创建的老单 → 历史未发池,超时 3240 分 + const stale = data.orders.find((o) => String(o.oId) === '26'); + assert.ok(stale, '历史未发单必须进池'); + assert.equal(stale.overdueMinutes, 3240); - // 25 号:重审单 → 审核时间必须显示**最后一次**(13:30,与聚水潭一致),不是首次 10:10 - const reAudited = data.orders.find((o) => String(o.oId) === '25'); - assert.ok(reAudited, '重审单也要算'); - assert.equal(reAudited.auditTime, '2026-10-20 13:30:00', '聚水潭「审核时间」= 最后一次审核'); - assert.equal(reAudited.overdueMinutes, 270, '18:00 − 13:30'); + // 43 号:拆单子单(is_split=true)也必须算 —— 别按 is_split 过滤 + assert.ok(data.orders.some((o) => String(o.oId) === '43'), + 'is_split=1 是拆单子单(真单),必须算超时'); - // 3 号单订单表补不到 created,展示回退付款时间,超时判定仍用审核时间 - const third = data.orders.find((o) => String(o.oId) === '3'); - assert.ok(third, '补不到创建时间的单也要算超时'); - assert.equal(third.createdAt, '', '确实没补到'); - assert.equal(third.benchmarkSource, 'last_audit'); - assert.equal(third.payTime, '2026-09-26 00:10:00'); + // 3 号单没创建时间 → 归「不计考核」,原因写清楚 + const noCreated = data.heldOrders.find((o) => String(o.oId) === '3'); + assert.ok(noCreated, '缺创建时间的单不能被静默丢掉'); + assert.equal(noCreated.holdReason, '缺创建时间'); - // 创建时间晚于窗口上界 → 本轮不核验(2 号:创建 16:20、6 号:创建 17:00) + // 创建时间晚于窗口上界 → 本轮不核验(2 号:16:20、6 号:17:00) assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验'); assert.ok(!data.orders.some((o) => String(o.oId) === '6'), '创建 17:00 晚于 16:00 本轮不核验'); @@ -536,18 +399,18 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub assert.match(data.window.overrides.find((o) => o.key === 'HN_LN').windowEnd, /13:00:00$/u); // 被排除的仓不能算 assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核'); - // 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单 - // 38/39/40 = 2026-10-22 新补的三类作废形态(Question / 发货前售后标签 / 售后已作废) - for (const id of ['9', '10', '11', '12', '38', '39', '40']) { + assert.ok(!data.heldOrders.some((o) => String(o.oId) === '7'), '京东云仓也不进挂起清单'); + // 9(已取消) / 10(Merged) / 11(已发) / 37(Sent) 都不能进任何清单 + for (const id of ['9', '10', '11', '37']) { assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`); assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`); } - // 这 8 单要计进 voidCount(9/10/11/12 作废或已发 + 37 已发货 Sent + 38/39/40 作废三类) - assert.equal(data.summary.voidCount, 8, '作废/已发单要计入剔除数'); + // 作废(labels 发货前售后)只计 39 这一单;9/10/11/37 由 SQL 直接过滤,不在这张清单里 + assert.equal(data.summary.voidCount, 1, 'only 39 (labels 发货前售后)'); // 不考核快递:中铁物流、壹米滴答专线、武进安能必须整单跳过 // (业务 2026-10-20 定前两个,2026-10-27 追加武进安能) - assert.equal(data.summary.carrierSkipped, 4, '自提/中铁/壹米滴答/武进安能 要计入 carrierSkipped'); + assert.equal(data.summary.carrierSkipped, 4, '中铁/壹米滴答/武进安能 两种写法 要计入 carrierSkipped'); assert.ok( !data.orders.some((o) => ['SO-35', 'SO-36', 'SO-41', 'SO-42'].includes(o.soId)), '不考核快递的单不能出现在超时明细里' @@ -561,111 +424,108 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub ); assert.ok( svc._private.EXCLUDED_CARRIERS.some((k) => '武进安能大件'.includes(k)), - '武进安能大件要能被「武进安能」包含匹配命中' + '武进安能大件要能被「武进安能」包含命中' ); // 不计考核清单 - assert.equal(data.heldOrders.length, 2); const reasons = data.heldOrders.map((h) => h.holdReason).sort(); - assert.deepEqual(reasons, ['待审核', '订单异常'].sort()); - - // 新鲜度直接取自主查询的 query_time,不再额外扫表 - assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27'); - // 🔥 2026-10-20:上游 ETL 停更时页面必须自己喊出来(当天 15:48 之后 0 行刷新, - // 业务看到「已发货的单还显示未发货」,其实是旧快照)。阈值 90 分钟。 - assert.equal(typeof data.freshness.ageMinutes, 'number', '要给出快照距今多少分钟'); - assert.equal( - data.freshness.stale, - data.freshness.ageMinutes > 90, - '滞后超过 90 分钟必须标 stale(阈值与 STATE_STALE_MINUTES 对齐)' + assert.deepEqual( + reasons, + ['缺创建时间', '待审核', '订单异常', '订单异常'].sort(), + '4 单:缺创建时间 1 + 待审核 1 + 异常 2' ); - if (data.freshness.stale) { - assert.match(data.freshness.hint, /未刷新/u, 'stale 时必须给出人话提示'); - } - // 快照 11:18 早于本轮 18:00 核验时刻 → 必须明确「数据没覆盖核验时刻」 - assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时 coversDeadline 必须为 false'); - assert.match(data.freshness.hint, /没覆盖|未覆盖/u, '没覆盖核验时刻要在提示里说清楚'); - // ── 性能护栏 ──────────────────────────────────────────────────────── - assert.equal(calls.state, 1, '状态表只允许查一次(金额/新鲜度不许再扫表)'); + // 新鲜度:取自主查询的 MAX(updated_at) + assert.equal(data.freshness.stateMaxQuery, '2026-10-20 18:02:00'); + assert.equal(data.freshness.source, 'jst_Orders', '数据源已换成订单表'); + assert.equal(data.freshness.ageMinutes, 3, '18:05 − 18:02 = 3 分钟'); + assert.equal(data.freshness.stale, false, '3 分钟不算滞后'); + assert.equal(data.freshness.coversDeadline, true, '18:02 已覆盖 18:00 核验时刻'); + assert.equal(data.freshness.hint, '', '没异常就不要吓人'); + + // ── 性能 / 口径护栏 ────────────────────────────────────────────────── + assert.equal(calls.orders, 1, '订单两表只允许查一次(UNION ALL 合成一条 SQL)'); + assert.equal(calls.warehouse, 1, '仓名映射查一次缓存表'); const sql = calls.sql[0]; - assert.match(sql, /jst_order_express_details/u, '数据源必须是实时状态表'); - assert.match(sql, /e\.send_time IS NULL/u, '已发货靠 send_time 判'); - // 本版基准是审核时间 → 两列审核时间都必须在投影里 - assert.match(sql, /e\.last_oa_confirm_time/u, '窗口上界按审核时间判,必须取这一列'); - assert.match(sql, /e\.first_oa_confirm_time/u, '审核时间缺失时用首次审核时间兜底'); - // 🔥 不再把「创建/付款 ≤ 核验时刻−24h」下推到 SQL(24h 门槛已取消) - assert.doesNotMatch(sql, /@cutoff/u, '不许再有 24h 超时下推参数'); - assert.doesNotMatch(sql, /pay_time <=/u, '不许再按付款时间做 24h 截断'); - assert.doesNotMatch(sql, /createdAt/u, '状态表的 createdAt 是同步落库时间,不能用'); + assert.match(sql, /rpt\.jst_Orders\s+o/u, '主数据源必须含 jst_Orders'); + assert.match(sql, /rpt\.jst_qmcust_orderlist\s+c/u, '第二张订单表必须 UNION ALL 进来'); + assert.match(sql, /UNION ALL/u, '两张表是并集,不是 JOIN'); + assert.match(sql, /o\.send_date IS NULL/u, '已发货靠 send_date 判'); + assert.match(sql, /c\.send_date IS NULL/u, '第二张表也要判未发货'); + assert.match(sql, /o\.status IN \(N'Delivering', N'WaitConfirm', N'Question'\)/u, + '候选状态白名单必须下推到 SQL'); + assert.match(sql, /o\.created >= '/u, '窗口下界按创建时间卡(30 天护栏)'); + assert.match(sql, /OPTION \(RECOMPILE\)/u, + '🔥 必须带 RECOMPILE —— qmcust 不加会被参数嗅探拖到 33~50s'); + // 起算点列必须在投影里 + assert.match(sql, /\bo\.created\b/u, '起算点必须取订单创建时间'); + // 🔥 不许再 JOIN 仓表:实测 JOIN 让同一条查询从 28s 恶化到 61~105s + assert.doesNotMatch(sql, /JOIN\s+dbo\.jst_warehouseInfo/iu, '仓名在 Node 侧换,不许 JOIN 仓表'); + // 🔥 不许再用状态镜像表取候选 + assert.doesNotMatch(sql, /jst_order_express_details/u, '状态镜像表已废弃'); + // 🔥 不许按 is_split / is_merge 过滤(is_split=1 是子单,要考的) + assert.doesNotMatch(sql, /is_split|is_merge/u, '拆单子单也要考核,不许拿这两个字段过滤'); // 宽列会触发基表回表:同一 WHERE 取 2 列 0.7s,取 15 列(含长文本)54s - assert.doesNotMatch(sql, /order_labels|buyer_message|remark/u, '不能投影长文本列'); + assert.doesNotMatch(sql, /buyer_message|remark/u, '不能投影长文本列'); assert.doesNotMatch(sql, /CONVERT/u, '不要在 SQL 里 CONVERT 时间列'); - assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用'); assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表'); + // labels 只允许出现在 LIKE 里折成布尔,不许直接投影原文 + assert.match(sql, /CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END/u, + 'labels 只用来判「发货前售后」,不外传原文'); + assert.doesNotMatch(sql, /\blabels\s*,/u, 'labels 长文本不许直接投影'); + assert.doesNotMatch(sql, /SELECT \*/u, '不许 SELECT *'); - // 补创建时间:必须走两张订单表;6 个窄列 + 1 个「发货前售后」布尔(不把 labels 原文传回来) - assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实'); - for (const bs of calls.backfillSql) { - assert.match(bs, - /SELECT o_id, created, status, send_date, is_merge, is_split,\s+CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END AS presaleVoid\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u, - `补订单事实只允许 6 个窄列 + 1 个作废布尔:${bs.slice(0, 130)}`); - assert.doesNotMatch(bs, /pay_amount|buyer_message|remark|SELECT \*/u, - '多取一列就会把 0.2s 拖到 38s'); - // labels 只允许出现在 `LIKE` 里转成布尔,不许直接投影原文 - assert.doesNotMatch(bs, /\blabels\s*(,|AS\b)/u, 'labels 长文本不许直接投影,只允许 CASE WHEN ... LIKE 转布尔'); - } - - // ⚠️ 窗口参数走 params,不在 SQL 里手写 - assert.equal(calls.params[0].windowStart, '2026-09-20 00:00:00', '下界是近 30 天'); - assert.equal(calls.params[0].cutoff, undefined, '24h 下推参数已删除'); - assert.equal(calls.params[0].shipStatus, '发货中'); + // ⚠️ 时间条件走**字面量**(已用正则校验过格式),不再走参数 —— 参数嗅探会走错计划 + assert.match(sql, /created >= '2026-09-20 00:00:00'/u, '下界是近 30 天,写进字面量'); + assert.deepEqual(calls.params[0], {}, '这条查询不带参数'); // round 必须带出 windowEnd,否则页面渲染成「核验 前」 assert.equal(data.round.key, 'AFTERNOON'); assert.match(String(data.round.windowEnd), /16:00:00$/u); - assert.equal(data.window.boundary, 'audit', '窗口按审核时间判,要透出给前端'); - assert.equal(data.window.measuredBy, 'audit', '已等时长按审核时间算'); + assert.equal(data.window.boundary, 'created', '窗口按创建时间判,要透出给前端'); + assert.equal(data.window.measuredBy, 'created', '已等时长按创建时间算'); })); -test('13:00 轮:只核验「今天 00:00~07:00 审核」的单 + 历史未发', withStubbedService(async (svc) => { +test('13:00 轮:只核验「今天 07:00 前创建」的单 + 历史未发', withStubbedService(async (svc) => { const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 }); assert.equal(data.round.key, 'NOON'); assert.equal(data.round.windowEnd, '2026-10-20 07:00:00'); - // 21 号:今天 07:20 审核 → 晚于 7 点,**不属于 13:00 轮**(业务口径:7 点前审核的才 13:00 核验) - assert.ok(!data.orders.some((o) => String(o.oId) === '21'), - '今天 07:20 审核 → 不是 7 点前审核的单,13:00 轮不该出现'); - // 24 号:今天 07:40 审核 → 同理不核验 + // 24 号:今天 07:30 创建 → 晚于 7 点,不属于 13:00 轮 assert.ok(!data.orders.some((o) => String(o.oId) === '24'), - '今天 07:40 审核 → 不进 13:00 轮'); - // 26 号:今天 13:10 才审核(9818476 那种形态)→ 更不能进 13:00 轮 - assert.ok(!data.orders.some((o) => String(o.oId) === '26'), - '下午才审核的老单不能进 13:00 轮'); + '今天 07:30 创建 → 不进 13:00 轮'); - // 22 号:今天 06:40 审核(6:30~7:00 这段)→ 13:00 只等 380 分钟,必须超时(无 24h 门槛) + // 21 号:今天 06:45 创建 → 13:00 只等 375 分钟,必须超时(无 24h 门槛) + const a = data.orders.find((o) => String(o.oId) === '21'); + assert.ok(a, '7 点前创建、13:00 未发就是超时'); + assert.equal(a.benchmarkSource, 'created'); + assert.equal(a.overdueMinutes, 375); + assert.equal(a.waitedHours, 6.3); + + // 22 号:05:55 创建 → 425 分(旧口径按审核时间 06:40 算只有 380 分,换口径后更严) const b = data.orders.find((o) => String(o.oId) === '22'); - assert.ok(b, '6:30~7:00 审核、13:00 未发就是超时'); - assert.equal(b.benchmarkSource, 'last_audit'); - assert.equal(b.overdueMinutes, 380); - assert.equal(b.waitedHours, 6.3); - // 23 号:昨天审核、至今未发 → 历史未发池也要看到 + assert.equal(b.overdueMinutes, 425, '起算点提前到创建时间,已等时长跟着变长'); + + // 23 号:昨天创建、至今未发 → 历史未发池也要看到 const d = data.orders.find((o) => String(o.oId) === '23'); assert.ok(d, '含历史未发单(历史不受 00:00 起限制)'); - assert.equal(d.overdueMinutes, 1010); + assert.equal(d.overdueMinutes, 1025); - // 25 号:最后一次审核 10-20 13:30 → 不属于「7 点前审核」,13:00 轮不核验 - assert.ok(!data.orders.some((o) => String(o.oId) === '25'), - '重审到 13:30 的单归 18:00 轮,不进 13:00 轮'); - - // 13:00 轮 = 22(今天 06:40 审核)+ 23(昨天 20:10 审核,历史未发)= 2 单 - assert.equal(data.summary.overdueUnshipped, 2); - assert.equal(data.window.boundary, 'audit', '窗口按审核时间判'); + // 13:00 轮 = 21 + 22 + 23 + 25 + 26 = 5 单 + // (25/26 都是「今天之前创建、至今未发」,历史未发池两个轮次都算) + assert.equal(data.summary.overdueUnshipped, 5); + assert.equal(data.window.boundary, 'created', '窗口按创建时间判'); // 仓级例外只在 18:00 轮生效,13:00 轮没有例外 assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外'); + + // 快照只到 11:18(早于 13:00 核验时刻)→ 必须标滞后 + 明确「没覆盖核验时刻」 + assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27'); + assert.equal(data.freshness.stale, true, '107 分钟 > 90 分钟阈值,必须标 stale'); + assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时必须为 false'); + assert.match(data.freshness.hint, /未刷新|没覆盖|未覆盖/u, '要给出人话提示'); }, 'noon')); -test('服务层 13:00 前返回 notStarted,且不去查状态表', withStubbedService(async (svc, { calls }) => { +test('服务层 13:00 前返回 notStarted,且不去查库', withStubbedService(async (svc, { calls }) => { const data = await svc.getShipTimeliness({ now: at(9, 30), limit: 10 }); assert.equal(data.notStarted, true); assert.equal(data.round, null); @@ -674,8 +534,7 @@ test('服务层 13:00 前返回 notStarted,且不去查状态表', withStubbed // 11 个主仓仍然列出来(结构不能塌),但数字全为 0 assert.equal(data.centers.length, registry.CENTERS.length); assert.ok(data.centers.every((x) => x.overdueUnshipped === 0)); - assert.equal(calls.state, 0, '未到核验时间不应查库'); - assert.equal(calls.backfill, 0, '未到核验时间也不该回订单表'); + assert.equal(calls.orders, 0, '未到核验时间不应查订单表'); assert.match(data.note, /13:00/u); assert.equal(data.nextRoundAt, '2026-10-20 13:00:00'); assert.equal(data.nextWindowEnd, '2026-10-20 07:00:00'); @@ -713,21 +572,21 @@ test('resolveActiveRound 默认按北京时间取「今天」,不依赖容器 }); // ── ⑨ 导出明细(2026-10-27 新增「导出明细」按钮的后端口径)────────────────── -// 关键约束:导出必须是**全量**,且不能因为导出又重扫一遍状态表 —— +// 关键约束:导出必须是**全量**,且不能因为导出又重扫一遍订单表 —— // 所以缓存键不含 limit(存全量、出口截断)。这个用例专门锁这两点。 test('导出取数:不受 limit 截断,且与列表共用同一份缓存', withStubbedService(async (svc, { calls }) => { // 列表按 limit=3 取(页面就是这样截断的) const limited = await svc.getShipTimeliness({ now: at(18, 5), limit: 3 }); assert.equal(limited.orders.length, 3, 'limit=3 只给 3 行'); assert.equal(limited.truncated, true, '被截断必须打标记'); - assert.equal(limited.detailTotal, 7, '截断后仍要给出真实总数(页面「共 N 单」用它)'); - assert.equal(calls.state, 1, '第一次查询打了一次状态表'); + assert.equal(limited.detailTotal, 8, '截断后仍要给出真实总数(页面「共 N 单」用它)'); + assert.equal(calls.orders, 1, '第一次查询打了一次订单表'); - // 导出:同一轮次同一天 → 必须命中同一份缓存,且拿到全部 7 行 + // 导出:同一轮次同一天 → 必须命中同一份缓存,且拿到全部 8 行 const exported = await svc.listShipTimelinessExportRows({ round: 'AFTERNOON', now: at(18, 6) }); - assert.equal(calls.state, 1, '导出不能再扫一遍状态表(旧实现把 limit 拼进缓存键,每次导出都重查)'); - assert.equal(exported.total, 7, '导出必须是全部超时单,不能被列表的 limit 带跑'); - assert.equal(exported.rows.length, 7); + assert.equal(calls.orders, 1, '导出不能再扫一遍订单表(旧实现把 limit 拼进缓存键,每次导出都重查)'); + assert.equal(exported.total, 8, '导出必须是全部超时单,不能被列表的 limit 带跑'); + assert.equal(exported.rows.length, 8); // 导出列里业务要能直接看到快递 / 运单号(追单用),centerKey 供前端按仓过滤 for (const row of exported.rows) { for (const key of ['oId', 'soId', 'shopName', 'centerKey', 'centerName', diff --git a/backend/test/warehouseShipTimelinessNotify.test.js b/backend/test/warehouseShipTimelinessNotify.test.js index 77d417b..d914574 100644 --- a/backend/test/warehouseShipTimelinessNotify.test.js +++ b/backend/test/warehouseShipTimelinessNotify.test.js @@ -125,7 +125,7 @@ test('补推窗口:错过时刻 30 分钟内启动仍会补,超窗口不补' test('正文包含轮次、超时单数、涉及仓数、最久天数', () => { const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 }); assert.match(built.text, /【仓储中心发货时效】13:00 轮/); - assert.match(built.text, /核验 07:00 前审核/); + assert.match(built.text, /核验 07:00 前创建/); assert.match(built.text, /超时未发货 3 单,涉及 2 个仓,最久已等 4 天/); assert.equal(built.rowCount, 3); }); @@ -138,11 +138,11 @@ test('按仓块只列超时 > 0 的仓,并按单数降序', () => { assert.doesNotMatch(built.text, /睢宁仓 0 单/); }); -test('明细一行 = 仓|内部单号|线上单号|快递|审核 时间|已等', () => { +test('明细一行 = 仓|内部单号|线上单号|快递|创建 时间|已等', () => { const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 }); - assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|武进韵达|审核 06:32|已等 6 小时 28 分/); - // 无线上单号 → 「—」;无快递名 → 「—」;无审核时间 → 「未审核」;都不能出现 undefined - assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|—|审核 未审核|已等 2 小时/); + assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|武进韵达|创建 06:32|已等 6 小时 28 分/); + // 无线上单号 → 「—」;无快递名 → 「—」;无创建时间 → 「无创建时间」;都不能出现 undefined + assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|—|创建 无创建时间|已等 2 小时/); assert.doesNotMatch(built.text, /undefined|NaN/); }); @@ -151,20 +151,20 @@ test('快递列紧跟线上单号(业务 2026-10-27 要求:群里直接看 const line = built.text.split('\n').find((l) => l.startsWith('2. 简尚-崔桥工厂仓')); assert.ok(line, '明细第 2 行不见了'); const parts = line.replace(/^\d+\.\s*/, '').split('|'); - assert.deepEqual(parts, ['简尚-崔桥工厂仓', '9855200', 'SO20261027001', '常州安能新', '审核 10-26 09:05', '已等 1 天 23 小时']); + assert.deepEqual(parts, ['简尚-崔桥工厂仓', '9855200', 'SO20261027001', '常州安能新', '创建 10-26 09:05', '已等 1 天 23 小时']); }); -test('历史未发单的审核时间必须带 MM-DD,否则会被读成「今天 16:13」', () => { +test('历史未发单的创建时间必须带 MM-DD,否则会被读成「今天 16:13」', () => { // 13:00 轮窗口上界 07:00,但口径「含历史未发」→ 明细里会出现昨天的单 const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 }); - assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|常州安能新|审核 10-26 09:05|已等 1 天 23 小时/); + assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|常州安能新|创建 10-26 09:05|已等 1 天 23 小时/); // 同一天的就只给 HH:mm,别啰嗦 - assert.match(built.text, /审核 06:32/); - assert.doesNotMatch(built.text, /审核 10-27 06:32/); + assert.match(built.text, /创建 06:32/); + assert.doesNotMatch(built.text, /创建 10-27 06:32/); // 直接测函数本身的跨天判定 assert.equal(job.auditWhen('2026-10-27 06:32:11', '2026-10-27'), '06:32'); assert.equal(job.auditWhen('2026-10-26 16:13:00', '2026-10-27'), '10-26 16:13'); - assert.equal(job.auditWhen('', '2026-10-27'), '未审核'); + assert.equal(job.auditWhen('', '2026-10-27'), '无创建时间'); }); test('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => { diff --git a/frontend/src/pages/WarehouseShipTimeliness/index.jsx b/frontend/src/pages/WarehouseShipTimeliness/index.jsx index bdf273f..b63e7ae 100644 --- a/frontend/src/pages/WarehouseShipTimeliness/index.jsx +++ b/frontend/src/pages/WarehouseShipTimeliness/index.jsx @@ -80,10 +80,10 @@ const NOTIFY_HELP_ITEMS = [ label: '③ 为什么一天推两次、时间怎么定', children: (
- 业务口径:13:00 核验 7 点前审核的超时单、18:00 核验 16 点前审核的单, + 业务口径:13:00 核验 7 点前创建的超时单、18:00 核验 16 点前创建的单, 各自核验完成后推一次,所以是两个独立时刻,互不影响。
- 默认给到 13:05 / 18:05(核验点 + 5 分钟):聚水潭状态镜像要晚几分钟才覆盖到核验时刻, + 默认给到 13:05 / 18:05(核验点 + 5 分钟):订单表的 updateTime 要晚几分钟才覆盖到核验时刻, 整点推容易报「数据没覆盖核验点、超时数偏大」。想改成整点也可以,消息里会带这句提醒。
同一轮次同一天只推一次(服务重启不会重复轰炸);要重发点「立即推送」。 @@ -165,11 +165,11 @@ const HELD_COLUMNS = [ }, }, { - title: '审核时间', + title: '创建时间', dataIndex: 'auditTime', width: 152, ellipsis: true, - render: (v) => (v || 未审核), + render: (v) => (v || 无创建时间), }, { title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' }, { title: '已挂起', dataIndex: 'heldDays', width: 88, render: (v) => (v > 0 ? `${v} 天` : '—') }, @@ -203,7 +203,7 @@ const DETAIL_COLUMNS = [ { // 发货时间 = 聚水潭回传的发货时间(业务 2026-10-20:明细里不要创建时间,要发货时间) title: ( - + 发货时间 ), @@ -224,10 +224,10 @@ const DETAIL_COLUMNS = [ }, }, { - // 审核时间 = **考核基准**,取聚水潭「审核时间」列(最后一次审核,重审会刷新) + // 创建时间 = 本页的**考核起算点**(业务 2026-10-28 定版:审核时间就是订单创建时间) title: ( - - 审核时间 + + 创建时间 ), dataIndex: 'auditTime', @@ -236,7 +236,7 @@ const DETAIL_COLUMNS = [ render: (v, row) => (v ? v : ( - + {row?.benchmarkTime || '—'} * )), @@ -244,9 +244,9 @@ const DETAIL_COLUMNS = [ // 付款时间 = 客户付款那一刻(并列展示,方便对账) { title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' }, { - // 发货状态:聚水潭实时状态(发货中 = 已审核未发货,即考核对象) + // 发货状态:订单表 status(翻译成中文) title: ( - + 发货状态 ), @@ -264,7 +264,7 @@ const DETAIL_COLUMNS = [ }, { title: ( - + 已等时长 ), @@ -295,10 +295,10 @@ const EXPORT_COLUMNS = [ { title: '发货状态', dataIndex: 'state' }, { title: '发货时间', dataIndex: 'sendTime', exportValue: (v) => v || '未发货' }, { - title: '审核时间', + title: '创建时间', dataIndex: 'auditTime', - // 审核时间缺失时页面会回退创建/付款并打 `*`,导出也保持一致,否则对账对不上 - exportValue: (v, row) => v || (row?.benchmarkTime ? `${row.benchmarkTime} *` : '未审核'), + // 创建时间缺失时页面会回退付款时间并打 `*`,导出也保持一致,否则对账对不上 + exportValue: (v, row) => v || (row?.benchmarkTime ? `${row.benchmarkTime} *` : '无创建时间'), }, { title: '付款时间', dataIndex: 'payTime' }, // 分钟数给一列原始值,业务在 Excel 里能直接排序/筛选;人读的那列再格式化 @@ -322,15 +322,17 @@ const roundFileText = (roundInfo) => ( /** * 仓储中心 24 小时发货时效(独立页面) * ──────────────────────────────────────────────────────────────── - * 业务口径(yanhui 2026-10-20): + * 业务口径(yanhui 2026-10-20 定轮次;2026-10-28 定起算点): * · 11 个主仓,子仓并入主仓一起算; - * · 今天 07:00 前**审核**的订单 13:00 核验,截止今天 16:00 前审核的单 18:00 核验 - * (含 7 点前未发出的、含今天之前审核至今未发的历史单;山东两仓 15:00、河南洛宁 13:00); - * 今天之前审核仍未发的(历史未发)两个轮次都算; + * · 数据源 = 订单表 jst_Orders + jst_qmcust_orderlist(两张表店铺互斥,都要读); + * · 今天 07:00 前**创建**的订单 13:00 核验,截止今天 16:00 前创建的单 18:00 核验 + * (含 7 点前未发出的、含今天之前创建至今未发的历史单;山东两仓 15:00、河南洛宁 13:00); + * 今天之前创建仍未发的(历史未发)两个轮次都算; * **到核验时刻未发即超时**,没有「满 24 小时」门槛(业务纠偏点); - * 轮次归属和「已等时长」都按**审核时间**算(聚水潭「审核时间」列,最后一次审核); + * 轮次归属和「已等时长」都按**创建时间**(jst_Orders.created)算 —— + * 业务口径:聚水潭「审核时间」指的就是订单创建时间; * · **13:00 之前不核验**,直接显示「未到核验时间」,不拿昨天的结果冒充今天; - * · 已出库 / 待出库以**聚水潭发货时间**为准(有 send_time = 已发); + * · 已出库 / 待出库以订单表 **send_date** 为准(SQL 直接 `send_date IS NULL` 才进候选); * · 只显示超时未出库(发完的仓显示「—」),点仓库可筛右侧明细。 * * 数据来自 /api/warehouse-ship-timeliness/list(service 层见 @@ -621,25 +623,26 @@ function WarehouseShipTimeliness() {
仓储中心 24 小时发货时效 - 按聚水潭订单实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。 + 按聚水潭订单表实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。 - 每天 13:00 核验「今天 07:00 前审核」、18:00 核验「截止 16:00 前审核」、 - 到核验时刻仍未发货的订单;两个轮次都包含今天之前审核、至今未发的历史未发单 - (18:00 轮也会带上 7 点前审核、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。 + 每天 13:00 核验「今天 07:00 前创建」、18:00 核验「截止 16:00 前创建」、 + 到核验时刻仍未发货的订单;两个轮次都包含今天之前创建、至今未发的历史未发单 + (18:00 轮也会带上 7 点前创建、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。 13:00 之前不核验,显示「未到核验时间」。 到点还没发就算超时 —— 不设「满 24 小时」门槛(早上 7 点前那份单,13:00 还没走就是超时)。 - 仓级例外:18:00 轮里 山东青岗集仓 / 山东品牌仓 只看 15:00 前审核的单、 - 河南洛宁仓 只看 13:00 前审核的单(核验时刻仍是 18:00)。 - 轮次与「已等时长」都按审核时间算(= 聚水潭「审核时间」列,最后一次审核); - 明细并列「审核时间 / 发货时间」,方便直接和聚水潭对账。 - 已发 / 未发以聚水潭发货时间为准:有发货时间就是已发,不再用订单表的发货字段反推 - —— 该字段经常不回写,会把早就发货的单误判成超时。 + 仓级例外:18:00 轮里 山东青岗集仓 / 山东品牌仓 只看 15:00 前创建的单、 + 河南洛宁仓 只看 13:00 前创建的单(核验时刻仍是 18:00)。 + 轮次与「已等时长」都按创建时间算(= 聚水潭「审核时间」,业务口径即订单创建时间 jst_Orders.created); + 明细并列「创建时间 / 发货时间」,方便直接和聚水潭对账。 + 已发 / 未发直接取订单表 send_date:send_date 有值就是已发(SQL 侧直接过滤, + 根本不进候选),为空的才是未发;不再读状态镜像表的 send_time —— + 那张表会漏回写(近 7 天 563 单已发却仍算未发)甚至整批停更,是这次换源的根因。 已打单但还没揽收的(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」, 鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。 {excludedCarrierText} 不考核(客户自提、专线大件或加盟快运网点,揽收与在途仓库控制不了), 已按物流公司自动剔除。 - 「已支付待审核」和「异常」的单不计考核(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细; + 「待审核」和「异常」的单不计考核(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细; 作废单(已取消,含发货前售后)不考核也不展示 —— 客户付款后取消的单仓库本就不该发。
@@ -651,7 +654,7 @@ function WarehouseShipTimeliness() { <> {round.label} - 核验今天 {cutoffText} 前审核的订单 + 核验今天 {cutoffText} 前创建的订单 含历史未发 · 到点未发即超时 @@ -663,7 +666,7 @@ function WarehouseShipTimeliness() { 未到核验时间 {nextRoundText} 开始首轮核验 - (核验今天 {nextWindowEndText} 前审核的订单) + (核验今天 {nextWindowEndText} 前创建的订单) )} @@ -706,7 +709,7 @@ function WarehouseShipTimeliness() { + 超时未发货 )} @@ -723,7 +726,7 @@ function WarehouseShipTimeliness() { > + 不计考核 明细 )} @@ -772,7 +775,7 @@ function WarehouseShipTimeliness() { ? '系统仓库表中暂无同名仓,待上游建仓后自动接入' : notStarted ? `今日 ${nextRoundText} 首轮核验开始后才展示超时数据` - : `${c.name}:本轮核验 ${String(c.windowEnd || '').slice(11, 16) || windowEndText} 前审核的单,` + : `${c.name}:本轮核验 ${String(c.windowEnd || '').slice(11, 16) || windowEndText} 前创建的单,` + `超时未发货 ${c.overdueUnshipped} 单` + `${c.oldestDays > 0 ? `(最久已等 ${c.oldestDays} 天)` : ''}` + `${c.heldCount > 0 ? `\n另有不计考核(待审核/异常)${c.heldCount} 单` : ''}` @@ -877,7 +880,7 @@ function WarehouseShipTimeliness() { type="info" showIcon className="wst-alert" - message="「已支付待审核」= 还没审单,仓库根本没接到;「订单异常」= 聚水潭标记异常(缺货/地址/风控等)。两类都不该算仓库超时,故单列于此。" + message="「待审核」= 订单状态还没审单(WaitConfirm),仓库根本没接到;「订单异常」= 聚水潭标记 Question(缺货/地址/风控等)。两类都不该算仓库超时,故单列于此。" /> {heldOrders.length ? ( ({ ...old, noonSendTime: value.format('HH:mm') })); }} /> - 核验「今天 07:00 前审核」的超时单,默认核验点 + 5 分钟 + 核验「今天 07:00 前创建」的超时单,默认核验点 + 5 分钟
@@ -970,7 +973,7 @@ function WarehouseShipTimeliness() { setNotifyForm((old) => ({ ...old, afternoonSendTime: value.format('HH:mm') })); }} /> - 核验「截止 16:00 前审核」的超时单(含历史未发),默认核验点 + 5 分钟 + 核验「截止 16:00 前创建」的超时单(含历史未发),默认核验点 + 5 分钟
@@ -1071,16 +1074,17 @@ function WarehouseShipTimeliness() { - 说明:今天 7:00 前审核的订单 13:00 核验;截止 16:00 前审核的单 18:00 核验 - (含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。 + 说明:数据源 = 聚水潭订单表 jst_Orders + jst_qmcust_orderlist(两张表店铺互斥,按 o_id 去重)。 + 今天 7:00 前创建的订单 13:00 核验;截止 16:00 前创建的单 18:00 核验 + (含 7 点前创建、13:00 还没发出的,也含今天之前创建至今未发的历史单)。 到点仍未发货即超时(不设「满 24 小时」门槛); - 轮次与「已等时长」都按审核时间(聚水潭显示值)算。 - 发货 = 聚水潭回传的发货时间。 - 作废单不考核(被合并 / 拆单父单 / 已取消,或订单表已有发货时间) + 轮次与「已等时长」都按创建时间(jst_Orders.created)算 —— 业务口径的「审核时间」即订单创建时间。 + 发货 = 订单表 send_date(为空才算未发);状态只取「发货中」。 + 作废单不考核(labels 含「发货前售后」= 客户发货前取消) {summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''}; {excludedCarrierText}不考核 {summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''}; - 已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。 + 待审核、订单异常的单归入「不计考核」,避免冤枉仓库。 );