Compare commits
3 Commits
codex/luna
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| db1185c19f | |||
| 4e4f8890c1 | |||
| 8c96877244 |
@ -307,8 +307,30 @@ const CREATED_BACKFILL_BATCH = 200;
|
||||
// 单子早随主单发走了,状态表还停在「发货中」。
|
||||
// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位,
|
||||
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
|
||||
// 🔥 2026-10-22 补 `Question` —— **但不能塞进这个数组**:
|
||||
// 这个数组在「状态分类之前」就判定,会把状态表「异常」的单(订单表常是 Question)
|
||||
// 从「异常待处理」清单里一起吞掉(实测 heldCount 472 → 10,少掉 462 单)。
|
||||
// ⟹ Question 单独在 `state === '发货中'` 之后判,见 VOID_QUESTION_STATUS。
|
||||
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
|
||||
|
||||
// 订单表 status=Question(聚水潭后台显示「已作废/问题单」)—— 业务 2026-10-22 确认:
|
||||
// so_id 3655421011854634(9823980) / 3658479011191285(9880810) 在后台都已作废,
|
||||
// 而状态表 order_status_name 仍停在「发货中」+ send_time 空、售后「暂无售后」
|
||||
// ⟹ 上游状态没同步,只信状态表判不出来。仅在「发货中」分支生效,不影响异常清单。
|
||||
const VOID_QUESTION_STATUS = 'Question';
|
||||
|
||||
// 订单表 `labels` 里出现这些关键词,等价于「客户发货前退款/取消」= 作废,不考核仓库。
|
||||
// 实测 9823980 的 labels = 「发货前售后,大单待确认,店铺-京东,晚发赔,禁止拆分」;
|
||||
// 已取消单(order_status_name='已取消')几乎全都带这个标签,可作交叉验证。
|
||||
// ⚠️ `labels` 是长文本列,回订单表补它会让 backfill 变慢(见 service 里的投影说明),
|
||||
// 所以只投影一个 `CASE WHEN ... THEN 1 ELSE 0 END` 布尔列,不把原文传回来。
|
||||
const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']);
|
||||
|
||||
// 状态表 `aftersale_status_name` 的「已作废」—— 售后维度最权威的作废标记。
|
||||
// 目前带这个值的单在订单表基本都是 Sent(已被其它判据剔除),这里是防御性兜底:
|
||||
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
|
||||
const AFTERSALE_VOID_STATUS = '已作废';
|
||||
|
||||
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||
@ -554,6 +576,9 @@ module.exports = {
|
||||
CREATED_SOURCE_TABLES,
|
||||
CREATED_BACKFILL_BATCH,
|
||||
VOID_ORDER_STATUS,
|
||||
VOID_QUESTION_STATUS,
|
||||
VOID_ORDER_LABEL_KEYWORDS,
|
||||
AFTERSALE_VOID_STATUS,
|
||||
SHIP_TIMELINESS_HOURS,
|
||||
toNumber,
|
||||
};
|
||||
|
||||
@ -129,9 +129,17 @@ const {
|
||||
CREATED_SOURCE_TABLES,
|
||||
CREATED_BACKFILL_BATCH,
|
||||
VOID_ORDER_STATUS,
|
||||
VOID_QUESTION_STATUS,
|
||||
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;
|
||||
@ -224,7 +232,7 @@ async function loadPendingStates(round) {
|
||||
// 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.logistic_status_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
|
||||
@ -293,7 +301,8 @@ async function backfillOrderFacts(oids) {
|
||||
const results = await mapLimit(batches, 4, async (batch) => {
|
||||
try {
|
||||
return await cloudDatabase.querySlow(
|
||||
`SELECT o_id, created, status, send_date, is_merge, is_split
|
||||
`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 }
|
||||
@ -314,10 +323,15 @@ async function backfillOrderFacts(oids) {
|
||||
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),
|
||||
@ -569,6 +583,19 @@ async function getShipTimeliness(options = {}) {
|
||||
|
||||
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;
|
||||
|
||||
@ -207,6 +207,13 @@ function stubDb(dbPath, scenario = 'default') {
|
||||
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' }),
|
||||
};
|
||||
|
||||
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
|
||||
@ -227,6 +234,8 @@ function stubDb(dbPath, scenario = 'default') {
|
||||
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,
|
||||
@ -326,6 +335,19 @@ function stubDb(dbPath, scenario = 'default') {
|
||||
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' }),
|
||||
];
|
||||
|
||||
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
|
||||
@ -392,6 +414,7 @@ function stubDb(dbPath, scenario = 'default') {
|
||||
send_date: x.send_date,
|
||||
is_merge: x.is_merge,
|
||||
is_split: x.is_split,
|
||||
presaleVoid: x.presaleVoid || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -506,12 +529,13 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
|
||||
// 被排除的仓不能算
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
|
||||
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
|
||||
for (const id of ['9', '10', '11', '12']) {
|
||||
// 38/39/40 = 2026-10-22 新补的三类作废形态(Question / 发货前售后标签 / 售后已作废)
|
||||
for (const id of ['9', '10', '11', '12', '38', '39', '40']) {
|
||||
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} 不该进挂起清单`);
|
||||
}
|
||||
// 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent)
|
||||
assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数');
|
||||
// 这 8 单要计进 voidCount(9/10/11/12 作废或已发 + 37 已发货 Sent + 38/39/40 作废三类)
|
||||
assert.equal(data.summary.voidCount, 8, '作废/已发单要计入剔除数');
|
||||
|
||||
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
|
||||
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
|
||||
@ -561,13 +585,16 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
|
||||
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
|
||||
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
|
||||
|
||||
// 补创建时间:必须走两张订单表,且**只投影 6 个窄列**
|
||||
// 补创建时间:必须走两张订单表;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+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
|
||||
`补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`);
|
||||
assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u,
|
||||
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 里手写
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user