forked from lunanting/cangku
feat(wst): 发货时效改用订单两表 + 起算点改为订单创建时间
业务 2026-10-28 定版:「核验抓取的销售订单表是错误的,应该在数据库抓 jst_Orders(订单)+ jst_OrderItem(明细),审核时间就是 jst_Orders 里的创建时间」。 ## 为什么换数据源(状态镜像表的硬伤,均有实测) 1. `send_time` 漏回写:近 7 天「状态表说发货中+未发」的 4157 单里, 订单表已有 `send_date` 的 563 单、status 已是 Sent 的 492 单 —— 发了还算超时。 2. 被合并 / 拆单父单不回写:89 + 755 单被冤枉。 3. 会整批停更:2026-10-27 11:54:59 之后 6.8 小时零写入,18:00 轮推出的 34 单里混着已发货的。 4. 两张表发货时间其实同源:27387 单逐条比对**零偏差** ⟹ 订单表才是权威。 ## 改了什么 后端 - 主查询改打 `rpt.jst_Orders` + `rpt.jst_qmcust_orderlist`(店铺互斥,UNION ALL, 按 o_id 去重),SQL 侧过滤 `send_date IS NULL` + `status IN (Delivering/WaitConfirm/Question)`。 - 起算点由状态表 `last_oa_confirm_time` 改为订单表 `created`; 实测 created 100% 早于审核时间,正常单差 7~40 分钟,夜间下单次日审的单差 6~9 小时 —— 换口径后单量与「已等时长」都会变大,业务已知悉。 - 仓库归属:主查询只回 `wms_co_id`,Node 侧用仓表缓存换仓名再走 `resolveCenter()` (实测 SQL 里 LEFT JOIN 仓表会把查询从 28s 拖到 61~105s)。 - `OPTION (RECOMPILE)` + 时间字面量:qmcust 不加 RECOMPILE 实测 33~50s,加上 0.4s。 - 删除 `backfillOrderFacts()` 与 `mapLimit()`(起算点/状态/仓名已由主查询一次取全)。 - 不再按 `is_split / is_merge` 过滤 —— 实测 `is_split=1` 恰是拆单**子单** (同一 so_id 多行、各自要发货,近 30 天 1096 行),老逻辑 `is_split=0` 会把它们全排掉。 - 钉钉明细第 5 段「审核 HH:mm」→「创建 HH:mm」,无值文案「未审核」→「无创建时间」。 前端 - 明细/挂起清单/导出列「审核时间」→「创建时间」,Tooltip 与说明文案同步改写; - 顶部说明补「数据源 = 订单两表」「已发未发取 send_date」; - 「已支付待审核」统一为「待审核」(新数据源里就是 WaitConfirm)。 测试 - 两个测试文件的桩改成「订单两表 UNION」的返回形状,断言改为 created 起算、候选状态白名单、SQL 必须带 RECOMPILE / 不含状态镜像表 / 不 JOIN 仓表。 ## 验证 - `npm --prefix backend run check`:491/493 通过。2 项失败是既有环境问题: `resilientSecretStore`(Windows 文件权限 0o666≠0o600)、 `cloudDatabaseConfigCache` 负缓存 TTL(依赖隧道状态),均与本改动无关,单独跑同样失败。 - 前端 eslint + build 通过。 - 真实库:NOON 1553 单 / AFTERNOON 1866 单(本地 2026-10-28), 数据质量:无仓名 0 / 时间异常 0 / 含 undefined|null|NaN 0; 单次查询耗时 35~38s(30 天护栏,冷缓存)。 - 浏览器真渲染(playwright,强制 round=NOON): 表头 `…发货时间 / 创建时间 / 付款时间…`、轮次提示「核验今天 07:00 前创建的订单」、 「导出明细」导出 CSV 表头含「创建时间」共 1551 行数据 + 1 行表头,无 pageerror。 ## 风险与回退 - 口径变化是可感知的:单量上升,夜间下单/次日审核的单「已等时长」增加 6~9 小时, 属业务确认后的预期结果。 - 状态表「售后已作废」判据随换源放弃(实测在「发货中+未发」里只命中 4 单), 这类单现在按正常单算超时;如业务反馈,可单独补查。 - 回退:单文件回滚本提交即可,无数据迁移、无配置变更。
This commit is contained in:
parent
f749bcc03c
commit
776a2cbde2
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<Map<number, {createdAt:string, status:string, sendDate:string, void:boolean, shipped:boolean}>>}
|
||||
*/
|
||||
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,
|
||||
},
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => {
|
||||
|
||||
@ -80,10 +80,10 @@ const NOTIFY_HELP_ITEMS = [
|
||||
label: '③ 为什么一天推两次、时间怎么定',
|
||||
children: (
|
||||
<div className="wst-notify__help">
|
||||
业务口径:<b>13:00 核验 7 点前审核的超时单</b>、<b>18:00 核验 16 点前审核的单</b>,
|
||||
业务口径:<b>13:00 核验 7 点前创建的超时单</b>、<b>18:00 核验 16 点前创建的单</b>,
|
||||
各自<b>核验完成后</b>推一次,所以是两个独立时刻,互不影响。
|
||||
<br />
|
||||
默认给到 <b>13:05 / 18:05</b>(核验点 + 5 分钟):聚水潭状态镜像要晚几分钟才覆盖到核验时刻,
|
||||
默认给到 <b>13:05 / 18:05</b>(核验点 + 5 分钟):订单表的 updateTime 要晚几分钟才覆盖到核验时刻,
|
||||
整点推容易报「数据没覆盖核验点、超时数偏大」。想改成整点也可以,消息里会带这句提醒。
|
||||
<br />
|
||||
同一轮次同一天只推一次(服务重启不会重复轰炸);要重发点「立即推送」。
|
||||
@ -165,11 +165,11 @@ const HELD_COLUMNS = [
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '审核时间',
|
||||
title: '创建时间',
|
||||
dataIndex: 'auditTime',
|
||||
width: 152,
|
||||
ellipsis: true,
|
||||
render: (v) => (v || <span className="wst-detail__fallback">未审核</span>),
|
||||
render: (v) => (v || <span className="wst-detail__fallback">无创建时间</span>),
|
||||
},
|
||||
{ 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: (
|
||||
<Tooltip title="聚水潭回传的发货时间(send_time)。为空但有运单号的,说明仓库已打单、还没揽收/回传发货时间,会显示成「已打单」">
|
||||
<Tooltip title="订单表 jst_Orders.send_date(聚水潭回传的发货时间)。为空但有运单号的,说明仓库已打单、还没揽收/回传发货时间,会显示成「已打单」">
|
||||
<span>发货时间</span>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -224,10 +224,10 @@ const DETAIL_COLUMNS = [
|
||||
},
|
||||
},
|
||||
{
|
||||
// 审核时间 = **考核基准**,取聚水潭「审核时间」列(最后一次审核,重审会刷新)
|
||||
// 创建时间 = 本页的**考核起算点**(业务 2026-10-28 定版:审核时间就是订单创建时间)
|
||||
title: (
|
||||
<Tooltip title="= 聚水潭订单列表「审核时间」那一列(最后一次审核,重新审核会刷新)。今天 7:00 前审核的 13:00 核验;截止 16:00 前审核的(含今天之前未发的历史单)18:00 核验,已等时长也从它算">
|
||||
<span>审核时间</span>
|
||||
<Tooltip title="取订单表 jst_Orders 的创建时间(业务口径:审核时间即订单创建时间)。今天 7:00 前创建的 13:00 核验;截止 16:00 前创建的(含今天之前未发的历史单)18:00 核验,已等时长也从它算">
|
||||
<span>创建时间</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'auditTime',
|
||||
@ -236,7 +236,7 @@ const DETAIL_COLUMNS = [
|
||||
render: (v, row) => (v
|
||||
? v
|
||||
: (
|
||||
<Tooltip title="没取到审核时间,已回退创建/付款时间参与分轮与计时">
|
||||
<Tooltip title="没取到创建时间,已回退付款时间参与分轮与计时">
|
||||
<span className="wst-detail__fallback">{row?.benchmarkTime || '—'} *</span>
|
||||
</Tooltip>
|
||||
)),
|
||||
@ -244,9 +244,9 @@ const DETAIL_COLUMNS = [
|
||||
// 付款时间 = 客户付款那一刻(并列展示,方便对账)
|
||||
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
|
||||
{
|
||||
// 发货状态:聚水潭实时状态(发货中 = 已审核未发货,即考核对象)
|
||||
// 发货状态:订单表 status(翻译成中文)
|
||||
title: (
|
||||
<Tooltip title="聚水潭订单实时状态。发货中 = 已审核、仓库还没发货">
|
||||
<Tooltip title="订单表 jst_Orders.status 的实时状态。发货中 = 仓库还没发货,即考核对象">
|
||||
<span>发货状态</span>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -264,7 +264,7 @@ const DETAIL_COLUMNS = [
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="从审核时间到本轮核验时刻(13:00 / 18:00)等了多久">
|
||||
<Tooltip title="从订单创建时间到本轮核验时刻(13:00 / 18:00)等了多久">
|
||||
<span>已等时长</span>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -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() {
|
||||
<header className="wst-header">
|
||||
<Title level={3}>仓储中心 24 小时发货时效</Title>
|
||||
<Text type="secondary">
|
||||
按聚水潭订单实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。
|
||||
按聚水潭订单表实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。
|
||||
</Text>
|
||||
<Text type="secondary" className="wst-note">
|
||||
每天 <b>13:00</b> 核验「<b>今天 07:00 前审核</b>」、<b>18:00</b> 核验「<b>截止 16:00 前审核</b>」、
|
||||
到核验时刻仍未发货的订单;两个轮次都<b>包含今天之前审核、至今未发的历史未发单</b>
|
||||
(18:00 轮也会带上 7 点前审核、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。
|
||||
每天 <b>13:00</b> 核验「<b>今天 07:00 前创建</b>」、<b>18:00</b> 核验「<b>截止 16:00 前创建</b>」、
|
||||
到核验时刻仍未发货的订单;两个轮次都<b>包含今天之前创建、至今未发的历史未发单</b>
|
||||
(18:00 轮也会带上 7 点前创建、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。
|
||||
13:00 之前不核验,显示「未到核验时间」。
|
||||
<b>到点还没发就算超时 —— 不设「满 24 小时」门槛</b>(早上 7 点前那份单,13:00 还没走就是超时)。
|
||||
<b>仓级例外</b>:18:00 轮里 <b>山东青岗集仓 / 山东品牌仓</b> 只看 <b>15:00 前审核</b>的单、
|
||||
<b>河南洛宁仓</b> 只看 <b>13:00 前审核</b>的单(核验时刻仍是 18:00)。
|
||||
<b>轮次与「已等时长」都按审核时间算</b>(= 聚水潭「审核时间」列,最后一次审核);
|
||||
明细并列「审核时间 / 发货时间」,方便直接和聚水潭对账。
|
||||
<b>已发 / 未发以聚水潭发货时间为准</b>:有发货时间就是已发,不再用订单表的发货字段反推
|
||||
—— 该字段经常不回写,会把早就发货的单误判成超时。
|
||||
<b>仓级例外</b>:18:00 轮里 <b>山东青岗集仓 / 山东品牌仓</b> 只看 <b>15:00 前创建</b>的单、
|
||||
<b>河南洛宁仓</b> 只看 <b>13:00 前创建</b>的单(核验时刻仍是 18:00)。
|
||||
<b>轮次与「已等时长」都按创建时间算</b>(= 聚水潭「审核时间」,业务口径即订单创建时间 jst_Orders.created);
|
||||
明细并列「创建时间 / 发货时间」,方便直接和聚水潭对账。
|
||||
<b>已发 / 未发直接取订单表 send_date</b>:send_date 有值就是已发(SQL 侧直接过滤,
|
||||
根本不进候选),为空的才是未发;不再读状态镜像表的 send_time ——
|
||||
那张表会漏回写(近 7 天 563 单已发却仍算未发)甚至整批停更,是这次换源的根因。
|
||||
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
|
||||
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
|
||||
<b>{excludedCarrierText} 不考核</b>(客户自提、专线大件或加盟快运网点,揽收与在途仓库控制不了),
|
||||
已按物流公司自动剔除。
|
||||
<b>「已支付待审核」和「异常」的单不计考核</b>(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细;
|
||||
<b>「待审核」和「异常」的单不计考核</b>(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细;
|
||||
<b>作废单(已取消,含发货前售后)不考核也不展示</b> —— 客户付款后取消的单仓库本就不该发。
|
||||
</Text>
|
||||
</header>
|
||||
@ -651,7 +654,7 @@ function WarehouseShipTimeliness() {
|
||||
<>
|
||||
<span className="wst-status__badge">{round.label}</span>
|
||||
<span className="wst-status__label">
|
||||
核验今天 {cutoffText} 前审核的订单
|
||||
核验今天 {cutoffText} 前创建的订单
|
||||
</span>
|
||||
<span className="wst-status__window">
|
||||
含历史未发 · 到点未发即超时
|
||||
@ -663,7 +666,7 @@ function WarehouseShipTimeliness() {
|
||||
<span className="wst-status__badge wst-status__badge--idle">未到核验时间</span>
|
||||
<span className="wst-status__label">
|
||||
{nextRoundText} 开始首轮核验
|
||||
(核验今天 {nextWindowEndText} 前审核的订单)
|
||||
(核验今天 {nextWindowEndText} 前创建的订单)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@ -706,7 +709,7 @@ function WarehouseShipTimeliness() {
|
||||
<Card bordered={false} className="wst-stat">
|
||||
<Statistic
|
||||
title={(
|
||||
<Tooltip title="到 13:00 / 18:00 核验时刻仍未发货的订单(按审核时间分轮),不设满 24 小时门槛">
|
||||
<Tooltip title="到 13:00 / 18:00 核验时刻仍未发货的订单(按订单创建时间分轮),不设满 24 小时门槛">
|
||||
<span>超时未发货</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
@ -723,7 +726,7 @@ function WarehouseShipTimeliness() {
|
||||
>
|
||||
<Statistic
|
||||
title={(
|
||||
<Tooltip title="已支付待审核(还没审单,仓库没接到)和订单异常的单。这类单不进仓库考核,点开看明细。">
|
||||
<Tooltip title="待审核(订单状态 WaitConfirm,还没审单,仓库没接到)和订单异常(Question)的单。这类单不进仓库考核,点开看明细。">
|
||||
<span>不计考核 <span className="wst-stat__more">明细</span></span>
|
||||
</Tooltip>
|
||||
)}
|
||||
@ -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 ? (
|
||||
<Table
|
||||
@ -954,7 +957,7 @@ function WarehouseShipTimeliness() {
|
||||
setNotifyForm((old) => ({ ...old, noonSendTime: value.format('HH:mm') }));
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary">核验「今天 07:00 前审核」的超时单,默认核验点 + 5 分钟</Text>
|
||||
<Text type="secondary">核验「今天 07:00 前创建」的超时单,默认核验点 + 5 分钟</Text>
|
||||
</div>
|
||||
|
||||
<div className="wst-notify__row">
|
||||
@ -970,7 +973,7 @@ function WarehouseShipTimeliness() {
|
||||
setNotifyForm((old) => ({ ...old, afternoonSendTime: value.format('HH:mm') }));
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary">核验「截止 16:00 前审核」的超时单(含历史未发),默认核验点 + 5 分钟</Text>
|
||||
<Text type="secondary">核验「截止 16:00 前创建」的超时单(含历史未发),默认核验点 + 5 分钟</Text>
|
||||
</div>
|
||||
|
||||
<div className="wst-notify__row">
|
||||
@ -1071,16 +1074,17 @@ function WarehouseShipTimeliness() {
|
||||
</Modal>
|
||||
|
||||
<Text type="secondary" className="wst-foot">
|
||||
说明:<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
|
||||
(含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。
|
||||
说明:数据源 = 聚水潭<b>订单表 jst_Orders + jst_qmcust_orderlist</b>(两张表店铺互斥,按 o_id 去重)。
|
||||
<b>今天 7:00 前创建</b>的订单 13:00 核验;<b>截止 16:00 前创建</b>的单 18:00 核验
|
||||
(含 7 点前创建、13:00 还没发出的,也含今天之前创建至今未发的历史单)。
|
||||
<b>到点仍未发货即超时</b>(不设「满 24 小时」门槛);
|
||||
轮次与「已等时长」都按审核时间(聚水潭显示值)算。
|
||||
发货 = 聚水潭回传的发货时间。
|
||||
<b>作废单不考核</b>(被合并 / 拆单父单 / 已取消,或订单表已有发货时间)
|
||||
轮次与「已等时长」都按创建时间(jst_Orders.created)算 —— 业务口径的「审核时间」即订单创建时间。
|
||||
发货 = 订单表 send_date(为空才算未发);状态只取「发货中」。
|
||||
<b>作废单不考核</b>(labels 含「发货前售后」= 客户发货前取消)
|
||||
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
|
||||
<b>{excludedCarrierText}不考核</b>
|
||||
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
|
||||
已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
||||
待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
||||
</Text>
|
||||
</SycmPage>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user