Compare commits
7 Commits
codex/luna
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| db1185c19f | |||
| 4e4f8890c1 | |||
| 8c96877244 | |||
| a0b075dfdd | |||
| 88913260a3 | |||
| fe195114e4 | |||
| 1957330ebd |
@ -12,6 +12,10 @@
|
||||
* 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。
|
||||
* ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。
|
||||
* ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。
|
||||
* ⚠️ 明细**按仓聚集**:同一个仓的行相邻排在一起,组间按该仓异常包裹数降序,
|
||||
* 组内保持服务层时长降序(业务 10-22 要求,方便各仓在群里只看自己那段)。
|
||||
* ⚠️ 取前 N 条时**每仓保底 1 条**(剩余额度按各仓异常数比例分),
|
||||
* 否则异常最多的仓会把额度吃满、其它仓一条都看不到(10-22 真数据踩到过)。
|
||||
* ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置,
|
||||
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。
|
||||
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
|
||||
@ -95,6 +99,79 @@ function buildDetailLine(row, index) {
|
||||
return `${index}. ${parts.join('|')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按仓分桶:组间按异常包裹数降序(与上面「按仓(异常包裹)」块一致),数量相同按首次出现顺序。
|
||||
* 组内保持服务层原顺序(已按 pendingHours 降序,压得最久的排最前),别在这里再排。
|
||||
*/
|
||||
function bucketRowsByWarehouse(rows) {
|
||||
const buckets = new Map();
|
||||
const firstSeen = new Map();
|
||||
(Array.isArray(rows) ? rows : []).forEach((row, index) => {
|
||||
const key = cleanCell(row?.warehouseName, '未知仓');
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, []);
|
||||
firstSeen.set(key, index);
|
||||
}
|
||||
buckets.get(key).push(row);
|
||||
});
|
||||
|
||||
return Array.from(buckets.entries())
|
||||
.map(([key, items]) => ({ key, items, index: firstSeen.get(key) }))
|
||||
.sort((a, b) => b.items.length - a.items.length || a.index - b.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细按仓聚集:同一个仓的行排在一起(10-22 业务要求,便于各仓在群里只看自己那一段)。
|
||||
* - 组间:按该仓异常包裹数降序;组内:保持服务层原顺序(时长降序)。
|
||||
* - 仓名取 `warehouseName`,缺失归到「未知仓」一组,避免 undefined 行被拆成散行。
|
||||
*/
|
||||
function groupRowsByWarehouse(rows) {
|
||||
return bucketRowsByWarehouse(rows).flatMap((group) => group.items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按仓分配明细额度,返回**已按仓聚集**的行。
|
||||
* ⚠️ 不能直接 `rows.slice(0, N)`:某个仓异常特别多时会一口气吃满额度,
|
||||
* 真数据就出现过 40 条明细全是一个仓、其它仓一条都看不到(10-22 实测)。
|
||||
* 规则:
|
||||
* ① 仓数 ≥ 额度 → 每仓 1 条,砍掉的是异常最少的仓;
|
||||
* ② 否则每仓先保底 1 条,剩余额度按「各仓剩余条数」比例分(最大余数法,保证总数正好等于额度)。
|
||||
*/
|
||||
function pickDetailRows(rows, limit) {
|
||||
const groups = bucketRowsByWarehouse(rows);
|
||||
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
|
||||
const quota = Math.max(0, Math.min(Math.round(Number(limit) || 0), total));
|
||||
|
||||
if (quota === 0) return [];
|
||||
if (quota >= total) return groups.flatMap((group) => group.items);
|
||||
if (groups.length >= quota) return groups.slice(0, quota).map((group) => group.items[0]);
|
||||
|
||||
const alloc = groups.map((group) => ({ group, take: 1, left: group.items.length - 1 }));
|
||||
const remaining = quota - groups.length;
|
||||
const leftTotal = alloc.reduce((sum, item) => sum + item.left, 0);
|
||||
|
||||
if (remaining > 0 && leftTotal > 0) {
|
||||
alloc.forEach((item) => {
|
||||
item.exact = (item.left / leftTotal) * remaining;
|
||||
item.extra = Math.min(item.left, Math.floor(item.exact));
|
||||
});
|
||||
let assigned = alloc.reduce((sum, item) => sum + item.extra, 0);
|
||||
// 取整剩下的零星额度给「小数部分最大」的仓,避免白浪费额度
|
||||
[...alloc]
|
||||
.sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)))
|
||||
.forEach((item) => {
|
||||
if (assigned >= remaining || item.extra >= item.left) return;
|
||||
item.extra += 1;
|
||||
assigned += 1;
|
||||
});
|
||||
alloc.forEach((item) => {
|
||||
item.take += item.extra;
|
||||
});
|
||||
}
|
||||
|
||||
return alloc.flatMap((item) => item.group.items.slice(0, item.take));
|
||||
}
|
||||
|
||||
/** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 12 行(12 个发货仓够用)*/
|
||||
function buildCenterLines(data = {}) {
|
||||
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||||
@ -180,8 +257,9 @@ function buildNotifyText(data = {}, options = {}) {
|
||||
}
|
||||
|
||||
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
||||
const detailAll = rows.map((row, index) => buildDetailLine(row, index + 1));
|
||||
const wantCount = Math.min(maxDetailRows, detailAll.length);
|
||||
// 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序
|
||||
const detailAll = pickDetailRows(rows, maxDetailRows).map((row, index) => buildDetailLine(row, index + 1));
|
||||
const wantCount = detailAll.length;
|
||||
|
||||
// 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出)
|
||||
const buildTail = (listed) => {
|
||||
@ -418,6 +496,8 @@ module.exports = {
|
||||
buildTypeLine,
|
||||
chinaDateKey,
|
||||
chinaHourMinute,
|
||||
groupRowsByWarehouse,
|
||||
pickDetailRows,
|
||||
cleanCell,
|
||||
pushWarehouseLogisticsExceptionNotify,
|
||||
runWarehouseLogisticsExceptionNotifyJob,
|
||||
|
||||
@ -309,8 +309,10 @@ function buildSummary(rows, orderSkuCount) {
|
||||
pickTotal: 0,
|
||||
storageTotal: 0,
|
||||
};
|
||||
const STATUS_SUM_KEY = { need: 'need', short: 'short', enough: 'enough', no_order: 'noOrder' };
|
||||
rows.forEach((row) => {
|
||||
if (summary[row.status] !== undefined) summary[row.status] += 1;
|
||||
const key = STATUS_SUM_KEY[row.status];
|
||||
if (key && summary[key] !== undefined) summary[key] += 1;
|
||||
summary.suggestQtyTotal += row.suggestQty || 0;
|
||||
summary.orderQtyTotal += row.orderQty || 0;
|
||||
summary.exceedTotal += row.excess || 0;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -154,6 +154,95 @@ test('明细行:仓|快递单号|快递|异常类型(4 段,不带发
|
||||
);
|
||||
});
|
||||
|
||||
test('明细按仓聚集:同仓相邻、组间按异常数降序、组内保持原顺序(时长降序)', () => {
|
||||
const rows = [
|
||||
{ warehouseName: '睢宁仓', trackingNo: 'SN1', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
|
||||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ1', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||||
{ warehouseName: '睢宁仓', trackingNo: 'SN2', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
|
||||
{ warehouseName: '山东青岗集仓', trackingNo: 'SD1', carrier: '山东中通', exceptionLabel: '已揽收未中转' },
|
||||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ2', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ3', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||||
];
|
||||
|
||||
const grouped = job.groupRowsByWarehouse(rows);
|
||||
// 崔桥 3 条 > 睢宁 2 条 > 青岗集 1 条,组间按异常数降序
|
||||
assert.deepEqual(grouped.map((r) => r.trackingNo), ['CQ1', 'CQ2', 'CQ3', 'SN1', 'SN2', 'SD1']);
|
||||
// 不丢行、不改行
|
||||
assert.equal(grouped.length, rows.length);
|
||||
|
||||
const text = job.buildNotifyText(sampleData({ rows }), { maxDetailRows: 30 }).text;
|
||||
// 只取「明细前 N 条」块里的行(上面「按仓」块也是 `1. xxx` 格式,别混进来)
|
||||
const detailBlock = text.split('明细前')[1] || '';
|
||||
const detailLines = detailBlock.split('\n').filter((line) => /^\d+\. /.test(line));
|
||||
const warehouses = detailLines.map((line) => line.split('|')[0].replace(/^\d+\. /, ''));
|
||||
|
||||
// 同仓必须相邻:切成连续段后不应出现重复仓名
|
||||
const runs = warehouses.filter((name, i) => name !== warehouses[i - 1]);
|
||||
assert.equal(new Set(runs).size, runs.length, `同仓没有排在一起:${warehouses.join(' / ')}`);
|
||||
assert.deepEqual(warehouses, ['崔桥工厂仓', '崔桥工厂仓', '崔桥工厂仓', '睢宁仓', '睢宁仓', '山东青岗集仓']);
|
||||
// 编号仍然全局连续,不能每组从 1 开始(否则群里看到重复序号)
|
||||
assert.deepEqual(detailLines.map((line) => line.match(/^(\d+)\./)[1]), ['1', '2', '3', '4', '5', '6']);
|
||||
});
|
||||
|
||||
test('明细按仓聚集:仓名缺失的行归到「未知仓」一组,不会被拆散', () => {
|
||||
const rows = [
|
||||
{ warehouseName: '', trackingNo: 'A1' },
|
||||
{ warehouseName: '睢宁仓', trackingNo: 'B1' },
|
||||
{ trackingNo: 'A2' },
|
||||
];
|
||||
const grouped = job.groupRowsByWarehouse(rows);
|
||||
// 未知仓 2 条(含 undefined)> 睢宁 1 条
|
||||
assert.deepEqual(grouped.map((r) => r.trackingNo), ['A1', 'A2', 'B1']);
|
||||
});
|
||||
|
||||
test('明细额度按仓分配:大仓不能吃满,每仓至少 1 条', () => {
|
||||
// 真数据就是这样:青岗集一家占了大头(10-22 实测 140 条里前 40 条全是它)
|
||||
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
|
||||
warehouseName: name,
|
||||
trackingNo: `${name}-${i}`,
|
||||
}));
|
||||
const rows = [
|
||||
...make('青岗集仓', 100),
|
||||
...make('崔桥工厂仓', 20),
|
||||
...make('睢宁仓', 8),
|
||||
...make('新苏北仓', 6),
|
||||
...make('横林二仓', 6),
|
||||
];
|
||||
|
||||
const picked = job.pickDetailRows(rows, 40);
|
||||
assert.equal(picked.length, 40, '额度必须正好用满(140 条 > 40)');
|
||||
|
||||
const counts = picked.reduce((acc, row) => {
|
||||
acc[row.warehouseName] = (acc[row.warehouseName] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
// 每个仓都要露面,否则群里那个仓的人看不到自己的单
|
||||
for (const name of ['青岗集仓', '崔桥工厂仓', '睢宁仓', '新苏北仓', '横林二仓']) {
|
||||
assert.ok(counts[name] >= 1, `${name} 一条明细都没分到`);
|
||||
}
|
||||
// 大仓仍然拿得多,但不能吃满额度
|
||||
assert.ok(counts['青岗集仓'] < 40, '大仓不该独占全部额度');
|
||||
assert.ok(counts['青岗集仓'] > counts['崔桥工厂仓'], '异常多的仓应分到更多明细');
|
||||
|
||||
// 同仓仍然相邻
|
||||
const names = picked.map((row) => row.warehouseName);
|
||||
const runs = names.filter((name, i) => name !== names[i - 1]);
|
||||
assert.equal(runs.length, new Set(runs).size, `同仓被拆散:${names.join(',')}`);
|
||||
});
|
||||
|
||||
test('明细额度比仓数还少时:按异常数从多到少,每仓只取 1 条', () => {
|
||||
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
|
||||
warehouseName: name,
|
||||
trackingNo: `${name}-${i}`,
|
||||
}));
|
||||
const rows = [...make('大仓', 10), ...make('中仓', 5), ...make('小仓', 2)];
|
||||
const picked = job.pickDetailRows(rows, 2);
|
||||
assert.deepEqual(picked.map((row) => row.trackingNo), ['大仓-0', '中仓-0']);
|
||||
// 额度 0 / 超额度的边界
|
||||
assert.deepEqual(job.pickDetailRows(rows, 0), []);
|
||||
assert.equal(job.pickDetailRows(rows, 99).length, 17);
|
||||
});
|
||||
|
||||
test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => {
|
||||
const data = sampleData({
|
||||
rows: [],
|
||||
|
||||
@ -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