Compare commits
11 Commits
codex/luna
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a839d78d56 | |||
| dd58364593 | |||
| d1bf4047e2 | |||
| e02d1f6d66 | |||
| 38b902e35a | |||
| 538f8edcbf | |||
| c9e84f2151 | |||
| 9d80b4c73e | |||
| db1185c19f | |||
| 4e4f8890c1 | |||
| 8c96877244 |
@ -251,9 +251,15 @@ function buildNotifyText(data = {}, options = {}) {
|
||||
|
||||
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
||||
+ (Number(exclusions.voidedPackages) || 0)
|
||||
+ (Number(exclusions.nonRealPackages) || 0);
|
||||
+ (Number(exclusions.nonRealPackages) || 0)
|
||||
+ (Number(exclusions.transferPackages) || 0);
|
||||
if (excludedCount > 0) {
|
||||
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`);
|
||||
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单 / 转仓代发,不计考核)`);
|
||||
}
|
||||
// 现场拦截(售后已签收):货被快递当场拉回、售后仓已签收,不算异常;仍算发货分母
|
||||
const interceptCount = Number(exclusions.interceptPackages) || 0;
|
||||
if (interceptCount > 0) {
|
||||
head.push(`另有 ${interceptCount} 个包裹属现场拦截(售后已签收),不计异常`);
|
||||
}
|
||||
|
||||
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
||||
|
||||
@ -158,6 +158,59 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
|
||||
{ keyword: '转空包', label: '信誉单' },
|
||||
]);
|
||||
|
||||
// ── 转仓代发(业务 2026-10-23 指定不核对异常;2026-10-24 补充不计原仓发货量)──
|
||||
// 聚水潭「转仓」操作会在 `order_labels` 留一条 `转x00005` 形式的标签
|
||||
// (x + 目标仓编码)。这类单**记账仓是原仓**(如崔桥工厂仓),货却由目标仓
|
||||
// (x00005 = 曹县)实际发出 —— 承运账号也跟着变成曹县韵达小件。
|
||||
// 结果:原仓的物流状态永远停在「未揽收 / 无物流单」,报出来全是假异常。
|
||||
//
|
||||
// 实测近 30 天(12 仓):`转x00005` 438 单,报未揽收类 46 单(10.5%);
|
||||
// 已签收 371 / 无物流单 31 / 运输中 20 / 未揽收 15。
|
||||
// 快递分布:曹县韵达小件 133、邮政快递包裹POSTB 124、睢宁中通 41 ……
|
||||
// 目前只出现过 `转x00005` 一种编码,但标签是机器生成的(后续可能出现 x00006),
|
||||
// 所以用正则匹配「转x<3位以上数字>」,不写死单个编码。
|
||||
// ⚠️ 与「转空包」区分:转空包是发空包(信誉单,假发货);转仓代发是**真发货**。
|
||||
// 🔥 口径:**整单不计** —— 货不是从原仓走的,既不算原仓异常,也不进原仓发货分母
|
||||
// (记进分母会把分母做大、异常率被稀释)。这与「现场拦截」相反,后者货是本仓发的、留分母。
|
||||
const TRANSFER_WAREHOUSE_LABEL_PATTERN = /转\s*[xX]\s*\d{3,}/;
|
||||
|
||||
// ── 现场拦截(货已被售后仓签收)不算转运异常(业务 2026-10-23 指定)────────
|
||||
// 业务原话:「把现场拦截的给去掉,也就是发的货被售后签收了」,判据是
|
||||
// **售后单有「签收单号」且「实退数量」不为 0**。
|
||||
//
|
||||
// 这是什么形态:客户不要了 / 派送时被拦下,快递当场把货拉回(轨迹常写
|
||||
// 「异常签收(拦截退回)」),货退回后由售后仓签收入库。物流环节其实已经结束,
|
||||
// 不属于「卡在网点发不出去」,报成转运异常是假警报。
|
||||
//
|
||||
// 落到库里的字段(聚水潭售后单两张表,店铺互斥,必须都读):
|
||||
// · jst_returnAndRefundInfo + jst_returnAndRefundItem (绝大多数店铺)
|
||||
// · jst_qmcust_refundlist + jst_qmcust_refundlist_item (企业微信/千牛客服单)
|
||||
// 签收单号 = 主表 `l_id`(退回那一票的运单号,实测常与原发货单号相同)
|
||||
// 实退数量 = 明细表 `r_qty`(申请数量是 `qty`,**只能看 r_qty**:
|
||||
// 拒收未签收的 r_qty=0、仅退款/补发的 r_qty 恒为 0)
|
||||
//
|
||||
// 实测(2026-10-23 批次,36 个异常包裹)命中 3 单,全是「拒收退货 + r_qty=1 +
|
||||
// l_id 非空」,和业务描述完全一致;同期 9859198/9871523 虽然 l_id 非空但
|
||||
// r_qty=0(货还在路上),正确地没被剔。
|
||||
const INTERCEPT_AFTERSALE_SOURCES = Object.freeze([
|
||||
{
|
||||
key: 'returnAndRefund',
|
||||
header: 'dbo.jst_returnAndRefundInfo',
|
||||
item: 'dbo.jst_returnAndRefundItem',
|
||||
// 主表 as_id=bigint、明细 as_id=int → 显式收窄,避免隐式转换报错
|
||||
headerAsId: 'TRY_CONVERT(int, h.as_id)',
|
||||
label: '售后单',
|
||||
},
|
||||
{
|
||||
key: 'qmcustRefund',
|
||||
header: 'dbo.jst_qmcust_refundlist',
|
||||
item: 'dbo.jst_qmcust_refundlist_item',
|
||||
// 两张表 as_id 都是 nvarchar,直接等值(别传数字 IN,会把列转 int 撞脏数据)
|
||||
headerAsId: 'h.as_id',
|
||||
label: '客服售后单',
|
||||
},
|
||||
]);
|
||||
|
||||
// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走
|
||||
const SORTED_KEYWORDS = CENTERS
|
||||
.flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
|
||||
@ -316,6 +369,19 @@ function matchNonRealOrder(orderLabels) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转仓代发 → 本仓不用核对物流异常。
|
||||
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
|
||||
* @returns {string|null} 命中的标签原文(如 `转x00005`),便于页脚直接展示;不命中返回 null
|
||||
*/
|
||||
function matchTransferWarehouse(orderLabels) {
|
||||
const labels = String(orderLabels || '');
|
||||
if (!labels) return null;
|
||||
const hit = labels.match(TRANSFER_WAREHOUSE_LABEL_PATTERN);
|
||||
if (!hit) return null;
|
||||
return hit[0].replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「核验批次」。
|
||||
*
|
||||
@ -370,6 +436,7 @@ module.exports = {
|
||||
EXCLUDED_ORDER_STATUS_KEYWORDS,
|
||||
EXCLUDED_ORDER_LABEL_KEYWORDS,
|
||||
EXCLUDED_ORDER_TYPE_LABELS,
|
||||
INTERCEPT_AFTERSALE_SOURCES,
|
||||
EXCEPTION_TYPES,
|
||||
TYPE_MAP,
|
||||
TRANSIT_INFO_KEYWORDS,
|
||||
@ -379,6 +446,8 @@ module.exports = {
|
||||
matchExcludedCarrier,
|
||||
matchVoidedOrder,
|
||||
matchNonRealOrder,
|
||||
matchTransferWarehouse,
|
||||
TRANSFER_WAREHOUSE_LABEL_PATTERN,
|
||||
resolveBatch,
|
||||
beijingNow,
|
||||
formatDate,
|
||||
|
||||
@ -148,6 +148,72 @@ async function loadShippedPackages(batch) {
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
// 售后拦截判定:一次 IN 多少单 + 查询超时。
|
||||
// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫,
|
||||
// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以:
|
||||
// · 只查「本来就会报异常」的 oId(正常单不查,量级从 9800 降到几十);
|
||||
// · 两套店铺表**并发**查,总耗时取较慢的那条(~8.5s),而不是相加的 13s;
|
||||
// · 结果进 5 分钟结果缓存,页面自动刷新时基本命中缓存。
|
||||
const INTERCEPT_QUERY_BATCH = 500;
|
||||
const INTERCEPT_QUERY_TIMEOUT_MS = 240000;
|
||||
|
||||
/**
|
||||
* 找出「现场拦截、货已被售后仓签收」的订单(业务 2026-10-23 口径)。
|
||||
*
|
||||
* 判据 = 售后单**签收单号 `l_id` 非空** 且 明细**实退数量 `r_qty` 合计 > 0**。
|
||||
* 两张店铺售后表互斥,必须都查(见 registry.INTERCEPT_AFTERSALE_SOURCES)。
|
||||
*
|
||||
* @param {Array} rows 状态表原始行
|
||||
* @returns {Promise<Set<string>>} 内部单号字符串集合
|
||||
*/
|
||||
async function loadInterceptedOrders(rows) {
|
||||
const ids = [];
|
||||
const seen = new Set();
|
||||
for (const row of rows) {
|
||||
// 只关心本来会被判成异常的单:正常单(已签收/运输中)剔不剔都一样
|
||||
if (!classify(row)) continue;
|
||||
const id = Number(row.inner_order_id);
|
||||
if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
const intercepted = new Set();
|
||||
if (!ids.length) return intercepted;
|
||||
|
||||
const tasks = [];
|
||||
for (let i = 0; i < ids.length; i += INTERCEPT_QUERY_BATCH) {
|
||||
const oIds = ids.slice(i, i + INTERCEPT_QUERY_BATCH).join(',');
|
||||
for (const source of registry.INTERCEPT_AFTERSALE_SOURCES) {
|
||||
tasks.push((async () => {
|
||||
const hits = await cloudDatabase.query(
|
||||
`SELECT h.o_id
|
||||
FROM ${source.header} h WITH (NOLOCK)
|
||||
JOIN ${source.item} it WITH (NOLOCK) ON it.as_id = ${source.headerAsId}
|
||||
WHERE h.o_id IN (${oIds})
|
||||
AND ISNULL(h.l_id, '') <> ''
|
||||
GROUP BY h.o_id
|
||||
HAVING SUM(ISNULL(it.r_qty, 0)) > 0`,
|
||||
{},
|
||||
{ timeoutMs: INTERCEPT_QUERY_TIMEOUT_MS }
|
||||
);
|
||||
return hits || [];
|
||||
})().catch((err) => {
|
||||
// 售后表跟不上不该让整页挂掉:退化成「不剔除」,页脚会显示 0 单,口径说明仍在
|
||||
console.warn(`[WLE] 售后拦截判定失败(${source.key}):${err.message}`);
|
||||
return [];
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(tasks);
|
||||
for (const list of results) {
|
||||
for (const r of list) {
|
||||
if (r && r.o_id !== null && r.o_id !== undefined) intercepted.add(String(r.o_id));
|
||||
}
|
||||
}
|
||||
return intercepted;
|
||||
}
|
||||
|
||||
function emptySummary() {
|
||||
return {
|
||||
shippedOrders: 0,
|
||||
@ -183,6 +249,25 @@ function exclusionMeta() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存里存的是**全量明细**(不截断),limit 只在出口生效。
|
||||
*
|
||||
* 🔥 2026-10-23 修:缓存键以前是 `${核验日}|${limit}`,页面传 1000、导出传 5000
|
||||
* → 两份缓存互不命中,点一次导出就要把整轮重算一遍(生产实测 317s,而实际
|
||||
* 异常只有 33 行,limit 根本不影响扫库成本)。现在键只按核验日,出口再 slice。
|
||||
*/
|
||||
function finalizeDetail(data, limit, cached, cacheAgeSec) {
|
||||
const all = Array.isArray(data.orders) ? data.orders : [];
|
||||
return {
|
||||
...data,
|
||||
orders: all.slice(0, limit),
|
||||
detailLimit: limit,
|
||||
truncated: all.length > limit,
|
||||
cached,
|
||||
cacheAgeSec,
|
||||
};
|
||||
}
|
||||
|
||||
async function getLogisticsExceptions(options = {}) {
|
||||
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
|
||||
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
|
||||
@ -220,6 +305,12 @@ async function getLogisticsExceptions(options = {}) {
|
||||
nonRealPackages: 0,
|
||||
nonRealOrders: 0,
|
||||
nonRealReasons: {},
|
||||
interceptPackages: 0,
|
||||
interceptOrders: 0,
|
||||
interceptReasons: {},
|
||||
transferPackages: 0,
|
||||
transferOrders: 0,
|
||||
transferReasons: {},
|
||||
},
|
||||
orders: [],
|
||||
detailLimit: limit,
|
||||
@ -231,18 +322,24 @@ async function getLogisticsExceptions(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = `${batch.verifyDate}|${limit}`;
|
||||
// 键只到核验日 —— 页面(limit=1000)与导出(limit=5000)共用同一份全量结果
|
||||
const cacheKey = batch.verifyDate;
|
||||
const cachedEntry = resultCache.get(cacheKey);
|
||||
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
|
||||
return {
|
||||
...cachedEntry.data,
|
||||
cached: true,
|
||||
cacheAgeSec: Math.round((Date.now() - cachedEntry.at) / 1000),
|
||||
};
|
||||
return finalizeDetail(
|
||||
cachedEntry.data,
|
||||
limit,
|
||||
true,
|
||||
Math.round((Date.now() - cachedEntry.at) / 1000)
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await loadShippedPackages(batch);
|
||||
|
||||
// ── 现场拦截(售后已签收)—— 只对会报异常的单去售后表核对 ─────────────
|
||||
// 必须放在主循环之前:售后表要批量 IN 查,逐单查会把页面拖到几分钟。
|
||||
const interceptedOrders = await loadInterceptedOrders(rows);
|
||||
|
||||
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
|
||||
const centerStat = new Map();
|
||||
for (const center of registry.CENTERS) {
|
||||
@ -266,6 +363,8 @@ async function getLogisticsExceptions(options = {}) {
|
||||
const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} };
|
||||
const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} };
|
||||
const skippedByType = { packages: 0, orders: new Set(), reasons: {} };
|
||||
const skippedByIntercept = { packages: 0, orders: new Set(), reasons: {} };
|
||||
const skippedByTransfer = { packages: 0, orders: new Set(), reasons: {} };
|
||||
|
||||
for (const row of rows) {
|
||||
const queryTime = formatDateTime(row.query_time);
|
||||
@ -309,6 +408,18 @@ async function getLogisticsExceptions(options = {}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ④ 转仓代发(`转x00005` 这类标签):记账仓是原仓,货由目标仓实际发出。
|
||||
// 业务 2026-10-24 定版:**原仓的发货量也不该算** —— 货不是从这个仓走的,
|
||||
// 记在原仓只会把发货分母做大、异常率稀释。所以按「整单不计」处理
|
||||
// (既不算异常、也不进发货分母),与承运商/刷单那几类同级,不是「只摘异常」。
|
||||
const transferReason = registry.matchTransferWarehouse(row.order_labels);
|
||||
if (transferReason) {
|
||||
skippedByTransfer.packages += 1;
|
||||
skippedByTransfer.orders.add(oId);
|
||||
skippedByTransfer.reasons[transferReason] = (skippedByTransfer.reasons[transferReason] || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = centerStat.get(center.key);
|
||||
stat.shippedOrders.add(oId);
|
||||
stat.packages += 1;
|
||||
@ -318,11 +429,23 @@ async function getLogisticsExceptions(options = {}) {
|
||||
const type = classify(row);
|
||||
if (!type) continue;
|
||||
|
||||
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
|
||||
|
||||
// ⑤ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收,
|
||||
// 物流环节其实结束了(派送时被拦下、原车拉回),不算「卡在网点发不出去」。
|
||||
// ⚠️ 与上面几类不同:这里**保留发货分母**(货确实从这个仓发出去了),
|
||||
// 只从异常清单里摘掉,异常率才不会被错误地放大。
|
||||
if (interceptedOrders.has(oId)) {
|
||||
skippedByIntercept.packages += 1;
|
||||
skippedByIntercept.orders.add(oId);
|
||||
skippedByIntercept.reasons[typeMeta.label] = (skippedByIntercept.reasons[typeMeta.label] || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sendAt = formatDateTime(row.send_time);
|
||||
const logisticAt = formatDateTime(row.logistic_time);
|
||||
const pendingMinutes = diffMinutes(sendAt, batch.deadline);
|
||||
const staleMinutes = diffMinutes(logisticAt, batch.deadline);
|
||||
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
|
||||
|
||||
stat.exceptionOrders.add(oId);
|
||||
stat.byType[type] = (stat.byType[type] || 0) + 1;
|
||||
@ -420,10 +543,19 @@ async function getLogisticsExceptions(options = {}) {
|
||||
nonRealPackages: skippedByType.packages,
|
||||
nonRealOrders: skippedByType.orders.size,
|
||||
nonRealReasons: skippedByType.reasons,
|
||||
// 现场拦截(售后已签收)—— 只从异常里摘掉,仍计入发货分母
|
||||
interceptPackages: skippedByIntercept.packages,
|
||||
interceptOrders: skippedByIntercept.orders.size,
|
||||
interceptReasons: skippedByIntercept.reasons,
|
||||
// 转仓代发(转x00005)—— 整单不计:既不算异常,也不进原仓发货分母
|
||||
transferPackages: skippedByTransfer.packages,
|
||||
transferOrders: skippedByTransfer.orders.size,
|
||||
transferReasons: skippedByTransfer.reasons,
|
||||
},
|
||||
orders: exceptions.slice(0, limit),
|
||||
// 缓存里存全量,limit 由 finalizeDetail 在出口截断
|
||||
orders: exceptions,
|
||||
detailLimit: limit,
|
||||
truncated: exceptions.length > limit,
|
||||
truncated: false,
|
||||
freshness: {
|
||||
snapshotAt,
|
||||
ageMinutes,
|
||||
@ -446,7 +578,7 @@ async function getLogisticsExceptions(options = {}) {
|
||||
}
|
||||
if (oldestKey) resultCache.delete(oldestKey);
|
||||
}
|
||||
return payload;
|
||||
return finalizeDetail(payload, limit, false, 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;
|
||||
|
||||
@ -144,12 +144,16 @@ test('状态为空时按运单号区分:有号=未揽收,无号=无物流单
|
||||
// ── ④ 服务层(打桩数据库)──────────────────────────────────────────────
|
||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||
|
||||
function stubDb(rows) {
|
||||
function stubDb(rows, options = {}) {
|
||||
const calls = { times: 0, sql: [], params: [] };
|
||||
const handler = async (sql, params) => {
|
||||
calls.times += 1;
|
||||
calls.sql.push(String(sql));
|
||||
calls.params.push(params || {});
|
||||
// 售后拦截判定会额外查两张售后表 —— 按表名分流,别把物流行当成售后行
|
||||
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
|
||||
return options.interceptRows || [];
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
require.cache[dbPath] = {
|
||||
@ -206,9 +210,9 @@ const rows = [
|
||||
send_time: '2026-10-20 11:00:00' }),
|
||||
];
|
||||
|
||||
async function withStub(fn, stubRows = rows) {
|
||||
async function withStub(fn, stubRows = rows, options = {}) {
|
||||
const realDb = require.cache[dbPath];
|
||||
const calls = stubDb(stubRows);
|
||||
const calls = stubDb(stubRows, options);
|
||||
delete require.cache[svcPath];
|
||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||
try {
|
||||
@ -276,18 +280,20 @@ test('未到 09:00 的批次返回 notStarted,不查库也不给数字', async
|
||||
|
||||
test('结果缓存生效,force=1 绕过缓存重查', async () => {
|
||||
await withStub(async (svc, calls) => {
|
||||
// 只数主查询:售后拦截判定会额外查两张售后表,别把它们的次数算进来
|
||||
const mainQueries = () => calls.sql.filter((s) => /jst_order_express_details/u.test(s)).length;
|
||||
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||
const first = await svc.getLogisticsExceptions(opts);
|
||||
assert.equal(first.cached, false);
|
||||
assert.equal(calls.times, 1);
|
||||
assert.equal(mainQueries(), 1);
|
||||
|
||||
const second = await svc.getLogisticsExceptions(opts);
|
||||
assert.equal(second.cached, true);
|
||||
assert.equal(calls.times, 1, '第二次应命中缓存');
|
||||
assert.equal(mainQueries(), 1, '第二次应命中缓存');
|
||||
|
||||
const third = await svc.getLogisticsExceptions({ ...opts, force: 1 });
|
||||
assert.equal(third.cached, false);
|
||||
assert.equal(calls.times, 2, 'force=1 必须真查库');
|
||||
assert.equal(mainQueries(), 2, 'force=1 必须真查库');
|
||||
});
|
||||
});
|
||||
|
||||
@ -320,19 +326,39 @@ test('导出:放开明细上限、返回全量异常(不受列表 2000 上
|
||||
});
|
||||
});
|
||||
|
||||
test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => {
|
||||
test('缓存只按核验日:页面加载后导出直接命中,不再重算整轮', async () => {
|
||||
await withStub(async (svc, calls) => {
|
||||
const mainQueries = () => calls.sql.filter((s) => /jst_order_express_details/u.test(s)).length;
|
||||
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||
await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||
assert.equal(calls.times, 1);
|
||||
|
||||
// 导出用另一个 limit —— 不能把页面那份缓存顶掉
|
||||
await svc.getLogisticsExceptionExportRows(opts);
|
||||
assert.equal(calls.times, 2, '导出是首次查,应真查库');
|
||||
const list = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||
assert.equal(list.cached, false, '页面首次应查库');
|
||||
assert.equal(mainQueries(), 1);
|
||||
|
||||
// 导出 limit=5000 与页面 1000 不同,但扫库成本一样 → 必须复用同一份全量缓存
|
||||
// (2026-10-23 修:以前键里带 limit,这里会再扫一遍,生产实测多花 317s)
|
||||
const exp = await svc.getLogisticsExceptionExportRows(opts);
|
||||
assert.equal(mainQueries(), 1, '导出不该重新扫库');
|
||||
assert.equal(exp.detailLimit, 5000);
|
||||
assert.equal(exp.rows.length, list.summary.exceptionPackages, '导出仍要拿到全部明细');
|
||||
|
||||
const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||
assert.equal(listAgain.cached, true, '页面那份缓存应还在');
|
||||
assert.equal(calls.times, 2, '列表应命中缓存,不再查库');
|
||||
assert.equal(listAgain.cached, true, '页面那份缓存应仍在');
|
||||
assert.equal(mainQueries(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('缓存存全量:先按小 limit 取,后面大 limit 仍拿到完整明细', async () => {
|
||||
await withStub(async (svc) => {
|
||||
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||
const small = await svc.getLogisticsExceptions({ ...opts, limit: 2 });
|
||||
assert.equal(small.orders.length, 2);
|
||||
assert.equal(small.truncated, true);
|
||||
|
||||
const big = await svc.getLogisticsExceptions({ ...opts, limit: 5000, maxLimit: 5000 });
|
||||
assert.equal(big.cached, true, '应命中同一份缓存');
|
||||
assert.equal(big.orders.length, big.summary.exceptionPackages, '缓存不能只存被截断的那 2 条');
|
||||
assert.equal(big.truncated, false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -516,6 +542,45 @@ test('刷单的包裹不进分母也不算异常', async () => {
|
||||
}, mixed);
|
||||
});
|
||||
|
||||
test('转仓标签识别:转x00005(含大小写/空格)都认,转空包不认', () => {
|
||||
assert.equal(registry.matchTransferWarehouse('["京喜","转x00005"]'), '转x00005');
|
||||
assert.equal(registry.matchTransferWarehouse('["转X00005"]'), '转X00005');
|
||||
assert.equal(registry.matchTransferWarehouse('["转 x00005"]'), '转x00005');
|
||||
// 后续可能出现别的目标仓编码
|
||||
assert.equal(registry.matchTransferWarehouse('["转x00006"]'), '转x00006');
|
||||
// 反例:转空包是信誉单(发空包),不是转仓代发
|
||||
assert.equal(registry.matchTransferWarehouse('["转空包发货"]'), null);
|
||||
assert.equal(registry.matchTransferWarehouse('["京喜","线上发货"]'), null);
|
||||
assert.equal(registry.matchTransferWarehouse(''), null);
|
||||
assert.equal(registry.matchTransferWarehouse(null), null);
|
||||
});
|
||||
|
||||
test('转仓代发整单不计:不算异常,也不进原仓发货分母', async () => {
|
||||
const mixed = [
|
||||
// 30:转x00005 · 未揽收 → 崔桥下单、曹县代发,货不是从原仓走的,整单不计
|
||||
mk({ inner_order_id: 30, so_id: 'SO-30', logistic_status_name: '未揽收', logistic_time: null,
|
||||
logistic_company_name: '曹县韵达小件', order_labels: '["京喜","转x00005"]' }),
|
||||
// 31:同样「未揽收」但没转仓标签 → 必须留下
|
||||
mk({ inner_order_id: 31, so_id: 'SO-31', logistic_status_name: '未揽收', logistic_time: null,
|
||||
order_labels: '["京喜"]' }),
|
||||
];
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||
assert.deepEqual(data.orders.map((o) => o.oId), ['31'], '只留非转仓那单');
|
||||
assert.equal(data.exclusions.transferOrders, 1);
|
||||
assert.equal(data.exclusions.transferPackages, 1);
|
||||
assert.equal(data.exclusions.transferReasons['转x00005'], 1);
|
||||
assert.equal(data.summary.exceptionPackages, 1, '转仓单不计异常');
|
||||
// 🔥 业务 2026-10-24:货不从原仓走,原仓发货量也不该算
|
||||
assert.equal(data.summary.packageCount, 1, '转仓单不进原仓发货分母');
|
||||
assert.equal(data.summary.shippedOrders, 1, '转仓单不算原仓发货单');
|
||||
// 仓维度:崔桥仓发货单数也只剩 31 那单(30 是转仓、不计)
|
||||
const center = data.centers.find((c) => c.key === 'CQ');
|
||||
assert.equal(center.shippedOrders, 1, '崔桥仓发货单数不应包含转仓单');
|
||||
assert.equal(center.exceptionOrders, 1, '崔桥仓异常只算 31');
|
||||
}, mixed);
|
||||
});
|
||||
|
||||
test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
|
||||
@ -523,5 +588,87 @@ test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
|
||||
assert.ok(Array.isArray(data.exclusions.carriers));
|
||||
assert.ok(data.exclusions.carriers.includes('唯品顺丰'));
|
||||
assert.equal(data.exclusions.carrierOrders, 0);
|
||||
assert.equal(data.exclusions.interceptOrders, 0, '未开跑也要给出拦截剔除字段');
|
||||
});
|
||||
});
|
||||
|
||||
// ── ⑦ 现场拦截(售后已签收)不算转运异常(业务 2026-10-23)─────────────
|
||||
test('现场拦截判据:签收单号非空 + 实退数量>0 才剔,货还在路上的不剔', async () => {
|
||||
const mixed = [
|
||||
// 30:已揽收未中转 → 售后单有签收单号 + 实退 1 件 → 已签收,必须剔
|
||||
mk({ inner_order_id: 30, so_id: 'SO-30', logistic_status_name: '已揽收',
|
||||
logistic_time: '2026-10-20 02:00:00', send_time: '2026-10-20 01:00:00',
|
||||
last_info: '中通快递 已收取快件' }),
|
||||
// 31:同形态但售后 r_qty=0(货还在路上)→ **不能剔**,仍是真异常
|
||||
mk({ inner_order_id: 31, so_id: 'SO-31', logistic_status_name: '已揽收',
|
||||
logistic_time: '2026-10-20 02:00:00', send_time: '2026-10-20 01:00:00',
|
||||
last_info: '中通快递 已收取快件' }),
|
||||
// 32:未揽收 + 售后已签收 → 同样剔(不挑异常类型)
|
||||
mk({ inner_order_id: 32, so_id: 'SO-32', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
// 33:正常已签收 → 本来就不算异常,不受影响
|
||||
mk({ inner_order_id: 33, so_id: 'SO-33' }),
|
||||
];
|
||||
await withStub(async (svc, calls) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||
|
||||
// 30 / 32 被剔;31 仍是异常;33 本来正常
|
||||
assert.deepEqual(data.orders.map((o) => o.oId), ['31']);
|
||||
assert.equal(data.summary.exceptionOrders, 1);
|
||||
// 🔥 发货分母不能掉:货确实发出去了,只是物流环节已结束
|
||||
assert.equal(data.summary.shippedOrders, 4, '拦截单仍算发货分母,否则异常率被放大');
|
||||
assert.equal(data.summary.exceptionRate, 25, '1 / 4');
|
||||
|
||||
assert.equal(data.exclusions.interceptPackages, 2);
|
||||
assert.equal(data.exclusions.interceptOrders, 2);
|
||||
assert.equal(data.exclusions.interceptReasons['已揽收未中转'], 1);
|
||||
assert.equal(data.exclusions.interceptReasons['未揽收'], 1);
|
||||
|
||||
// 判据 SQL:两张店铺表都要查、都要 join 明细、都必须卡 l_id 与 r_qty
|
||||
const asSql = calls.sql.filter((s) => /jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(s));
|
||||
assert.equal(asSql.length, 2, `售后表要查两套(店铺互斥),实际 ${asSql.length}`);
|
||||
assert.ok(asSql.some((s) => /jst_returnAndRefundItem/u.test(s)), '必须关联明细表拿实退数量');
|
||||
assert.ok(asSql.some((s) => /jst_qmcust_refundlist_item/u.test(s)), '客服售后单也要关联明细表');
|
||||
for (const s of asSql) {
|
||||
assert.match(s, /l_id[\s\S]*<> ''/u, '必须有「签收单号非空」条件');
|
||||
assert.match(s, /SUM\(ISNULL\(it\.r_qty, 0\)\) > 0/u, '必须有「实退数量 > 0」条件');
|
||||
assert.doesNotMatch(s, /it\.qty[\s\S]*> 0/u, '不能拿申请数量 qty 当实退数量');
|
||||
}
|
||||
}, mixed, { interceptRows: [{ o_id: 30 }, { o_id: 32 }] });
|
||||
});
|
||||
|
||||
test('售后表查询失败时降级:不剔单、不炸页面', async () => {
|
||||
const mixed = [
|
||||
mk({ inner_order_id: 40, so_id: 'SO-40', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
];
|
||||
const realDb = require.cache[dbPath];
|
||||
const calls = { times: 0, sql: [] };
|
||||
require.cache[dbPath] = {
|
||||
id: dbPath,
|
||||
filename: dbPath,
|
||||
loaded: true,
|
||||
exports: {
|
||||
query: async (sql) => {
|
||||
calls.sql.push(String(sql));
|
||||
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
|
||||
throw new Error('售后表超时');
|
||||
}
|
||||
return mixed;
|
||||
},
|
||||
querySlow: async () => mixed,
|
||||
},
|
||||
};
|
||||
try {
|
||||
delete require.cache[svcPath];
|
||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||
const svc = require(svcPath);
|
||||
svc.clearLogisticsExceptionCache();
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||
assert.equal(data.summary.exceptionOrders, 1, '售后表挂了就该照常报异常,别把异常吞掉');
|
||||
assert.equal(data.exclusions.interceptOrders, 0);
|
||||
} finally {
|
||||
if (realDb) require.cache[dbPath] = realDb;
|
||||
else delete require.cache[dbPath];
|
||||
delete require.cache[svcPath];
|
||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||
}
|
||||
});
|
||||
|
||||
@ -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 里手写
|
||||
|
||||
@ -779,14 +779,16 @@ function WarehouseLogisticsException() {
|
||||
? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</>
|
||||
: null}
|
||||
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
|
||||
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流。
|
||||
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流;
|
||||
<b>现场拦截</b>(售后单有签收单号且实退数量 > 0,货已被售后仓签收)—— 物流环节已结束,不算异常;
|
||||
<b>转仓代发</b>(标签如「转x00005」,货由目标仓发出)—— 不算本仓异常,也不算本仓发货量。
|
||||
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|
||||
|| exclusions.nonRealOrders > 0) ? (
|
||||
|| exclusions.nonRealOrders > 0 || exclusions.transferOrders > 0) ? (
|
||||
<>
|
||||
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
|
||||
+ (exclusions.nonRealOrders || 0)}</b> 单
|
||||
+ (exclusions.nonRealOrders || 0) + (exclusions.transferOrders || 0)}</b> 单
|
||||
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
|
||||
刷单/信誉单 {exclusions.nonRealOrders || 0} 单),
|
||||
刷单/信誉单 {exclusions.nonRealOrders || 0} 单、转仓代发 {exclusions.transferOrders || 0} 单),
|
||||
{exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? (
|
||||
<>
|
||||
其中承运商剔除:<b>
|
||||
@ -800,6 +802,20 @@ function WarehouseLogisticsException() {
|
||||
剔除的单<b>既不算异常也不算发货分母</b>,所以异常率不会被稀释。
|
||||
</>
|
||||
) : null}
|
||||
{exclusions?.interceptOrders > 0 ? (
|
||||
<>
|
||||
{' '}另有 <b>{exclusions.interceptOrders}</b> 单属<b>现场拦截(售后已签收)</b>
|
||||
{exclusions.interceptReasons && Object.keys(exclusions.interceptReasons).length ? (
|
||||
<>(原本会算「
|
||||
{Object.entries(exclusions.interceptReasons)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${k} ${v} 单`)
|
||||
.join(' / ')}
|
||||
」)</>
|
||||
) : null}
|
||||
—— 货是被快递当场拦下原车拉回、由售后仓签收的,<b>仍计入发货分母、只是不再报异常</b>。
|
||||
</>
|
||||
) : null}
|
||||
<br />
|
||||
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
|
||||
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user