fix(wle): 转仓代发(转x00005)不算转运异常 + 导出复用页面缓存 #84

Merged
yanhui merged 1 commits from yanhui-wle-transfer-cache-20261023 into main 2026-10-23 16:03:16 +08:00
5 changed files with 163 additions and 19 deletions

View File

@ -260,6 +260,11 @@ function buildNotifyText(data = {}, options = {}) {
if (interceptCount > 0) {
head.push(`另有 ${interceptCount} 个包裹属现场拦截(售后已签收),不计异常`);
}
// 转仓代发(转x00005):记账仓下单、目标仓实际发出,本仓永远停在「未揽收」,不算异常
const transferCount = Number(exclusions.transferPackages) || 0;
if (transferCount > 0) {
head.push(`另有 ${transferCount} 个包裹属转仓代发(转x00005),不计异常`);
}
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
// 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序

View File

@ -158,6 +158,21 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
{ keyword: '转空包', label: '信誉单' },
]);
// ── 转仓代发(业务 2026-10-23 指定:不用核对异常)──────────────────────────
// 聚水潭「转仓」操作会在 `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**。
@ -353,6 +368,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, '');
}
/**
* 解析「核验批次」。
*
@ -417,6 +445,8 @@ module.exports = {
matchExcludedCarrier,
matchVoidedOrder,
matchNonRealOrder,
matchTransferWarehouse,
TRANSFER_WAREHOUSE_LABEL_PATTERN,
resolveBatch,
beijingNow,
formatDate,

View File

@ -249,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)
@ -289,6 +308,9 @@ async function getLogisticsExceptions(options = {}) {
interceptPackages: 0,
interceptOrders: 0,
interceptReasons: {},
transferPackages: 0,
transferOrders: 0,
transferReasons: {},
},
orders: [],
detailLimit: limit,
@ -300,14 +322,16 @@ 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);
@ -340,6 +364,7 @@ async function getLogisticsExceptions(options = {}) {
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);
@ -394,10 +419,21 @@ async function getLogisticsExceptions(options = {}) {
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
// ④ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收,
// ④ 转仓代发(`转x00005` 这类标签):记账仓是原仓,货由目标仓发出,
// 原仓物流状态必然停在「未揽收 / 无物流单」→ 全是假异常(业务 2026-10-23 指定不核对)。
// ⚠️ 与 a/b/c 三类不同:货**确实发了**,所以保留发货分母,只从异常清单摘掉。
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;
}
// ⑤ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收,
// 物流环节其实结束了(派送时被拦下、原车拉回),不算「卡在网点发不出去」。
// ⚠️ 与上面 a/b/c 三类不同:这里**保留发货分母**(货确实发出去了),
// 只从异常清单里摘掉,异常率才不会被错误地放大。
// ⚠️ 同④:**保留发货分母**(货确实发出去了),只从异常清单里摘掉,
// 异常率才不会被错误地放大。
if (interceptedOrders.has(oId)) {
skippedByIntercept.packages += 1;
skippedByIntercept.orders.add(oId);
@ -510,10 +546,15 @@ async function getLogisticsExceptions(options = {}) {
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,
@ -536,7 +577,7 @@ async function getLogisticsExceptions(options = {}) {
}
if (oldestKey) resultCache.delete(oldestKey);
}
return payload;
return finalizeDetail(payload, limit, false, 0);
}
/**

View File

@ -326,20 +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 });
const list = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
assert.equal(list.cached, false, '页面首次应查库');
assert.equal(mainQueries(), 1);
// 导出用另一个 limit —— 不能把页面那份缓存顶掉
await svc.getLogisticsExceptionExportRows(opts);
assert.equal(mainQueries(), 2, '导出是首次查,应真查库');
// 导出 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(mainQueries(), 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);
});
});
@ -523,6 +542,39 @@ 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, '转仓单不计异常');
assert.equal(data.summary.packageCount, 2, '转仓单仍计入发货分母(货确实发了)');
}, mixed);
});
test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
await withStub(async (svc) => {
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });

View File

@ -815,6 +815,22 @@ function WarehouseLogisticsException() {
—— 货是被快递当场拦下原车拉回、由售后仓签收的,<b>仍计入发货分母、只是不再报异常</b>。
</>
) : null}
{exclusions?.transferOrders > 0 ? (
<>
{' '}另有 <b>{exclusions.transferOrders}</b> 单属<b>转仓代发</b>
{exclusions.transferReasons && Object.keys(exclusions.transferReasons).length ? (
<>(
{Object.entries(exclusions.transferReasons)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `标签${k} ${v} 单`)
.join(' / ')}
)
</>
) : null}
—— 记账仓下单、实际由目标仓发出,本仓物流状态必然停在「未揽收」,
<b>仍计入发货分母、只是不再报异常</b>。
</>
) : null}
<br />
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。