fix(wle): 转仓代发(转x00005)不算转运异常 + 导出复用页面缓存 #84
@ -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 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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) });
|
||||
|
||||
@ -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 点落一份快照。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user