Merge pull request 'fix(wle): 现场拦截(售后已签收)不再算仓储物流转运异常' (#83)
This commit is contained in:
commit
38b902e35a
@ -255,6 +255,11 @@ function buildNotifyText(data = {}, options = {}) {
|
||||
if (excludedCount > 0) {
|
||||
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`);
|
||||
}
|
||||
// 现场拦截(售后已签收):货被快递当场拉回、售后仓已签收,不算异常;仍算发货分母
|
||||
const interceptCount = Number(exclusions.interceptPackages) || 0;
|
||||
if (interceptCount > 0) {
|
||||
head.push(`另有 ${interceptCount} 个包裹属现场拦截(售后已签收),不计异常`);
|
||||
}
|
||||
|
||||
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
||||
// 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序
|
||||
|
||||
@ -158,6 +158,43 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
|
||||
{ keyword: '转空包', label: '信誉单' },
|
||||
]);
|
||||
|
||||
// ── 现场拦截(货已被售后仓签收)不算转运异常(业务 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 })))
|
||||
@ -370,6 +407,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,
|
||||
|
||||
@ -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,
|
||||
@ -220,6 +286,9 @@ async function getLogisticsExceptions(options = {}) {
|
||||
nonRealPackages: 0,
|
||||
nonRealOrders: 0,
|
||||
nonRealReasons: {},
|
||||
interceptPackages: 0,
|
||||
interceptOrders: 0,
|
||||
interceptReasons: {},
|
||||
},
|
||||
orders: [],
|
||||
detailLimit: limit,
|
||||
@ -243,6 +312,10 @@ async function getLogisticsExceptions(options = {}) {
|
||||
|
||||
const rows = await loadShippedPackages(batch);
|
||||
|
||||
// ── 现场拦截(售后已签收)—— 只对会报异常的单去售后表核对 ─────────────
|
||||
// 必须放在主循环之前:售后表要批量 IN 查,逐单查会把页面拖到几分钟。
|
||||
const interceptedOrders = await loadInterceptedOrders(rows);
|
||||
|
||||
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
|
||||
const centerStat = new Map();
|
||||
for (const center of registry.CENTERS) {
|
||||
@ -266,6 +339,7 @@ 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: {} };
|
||||
|
||||
for (const row of rows) {
|
||||
const queryTime = formatDateTime(row.query_time);
|
||||
@ -318,11 +392,23 @@ async function getLogisticsExceptions(options = {}) {
|
||||
const type = classify(row);
|
||||
if (!type) continue;
|
||||
|
||||
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
|
||||
|
||||
// ④ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收,
|
||||
// 物流环节其实结束了(派送时被拦下、原车拉回),不算「卡在网点发不出去」。
|
||||
// ⚠️ 与上面 a/b/c 三类不同:这里**保留发货分母**(货确实发出去了),
|
||||
// 只从异常清单里摘掉,异常率才不会被错误地放大。
|
||||
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,6 +506,10 @@ async function getLogisticsExceptions(options = {}) {
|
||||
nonRealPackages: skippedByType.packages,
|
||||
nonRealOrders: skippedByType.orders.size,
|
||||
nonRealReasons: skippedByType.reasons,
|
||||
// 现场拦截(售后已签收)—— 只从异常里摘掉,仍计入发货分母
|
||||
interceptPackages: skippedByIntercept.packages,
|
||||
interceptOrders: skippedByIntercept.orders.size,
|
||||
interceptReasons: skippedByIntercept.reasons,
|
||||
},
|
||||
orders: exceptions.slice(0, limit),
|
||||
detailLimit: limit,
|
||||
|
||||
@ -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 必须真查库');
|
||||
});
|
||||
});
|
||||
|
||||
@ -322,17 +328,18 @@ test('导出:放开明细上限、返回全量异常(不受列表 2000 上
|
||||
|
||||
test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', 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);
|
||||
assert.equal(mainQueries(), 1);
|
||||
|
||||
// 导出用另一个 limit —— 不能把页面那份缓存顶掉
|
||||
await svc.getLogisticsExceptionExportRows(opts);
|
||||
assert.equal(calls.times, 2, '导出是首次查,应真查库');
|
||||
assert.equal(mainQueries(), 2, '导出是首次查,应真查库');
|
||||
|
||||
const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||
assert.equal(listAgain.cached, true, '页面那份缓存应还在');
|
||||
assert.equal(calls.times, 2, '列表应命中缓存,不再查库');
|
||||
assert.equal(mainQueries(), 2, '列表应命中缓存,不再查库');
|
||||
});
|
||||
});
|
||||
|
||||
@ -523,5 +530,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')];
|
||||
}
|
||||
});
|
||||
|
||||
@ -779,7 +779,8 @@ function WarehouseLogisticsException() {
|
||||
? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</>
|
||||
: null}
|
||||
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
|
||||
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流。
|
||||
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流;
|
||||
<b>现场拦截</b>(售后单有签收单号且实退数量 > 0,货已被售后仓签收)—— 物流环节已结束,不算异常。
|
||||
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|
||||
|| exclusions.nonRealOrders > 0) ? (
|
||||
<>
|
||||
@ -800,6 +801,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