Compare commits
6 Commits
yanhui-wst
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c0e48688c1 | |||
| cb7d06c20c | |||
| 6b6dfdd1da | |||
| 85c48ebe77 | |||
| 6c2686784d | |||
| 913827000a |
@ -118,6 +118,22 @@ const MAX_DETAIL_LIMIT = 2000;
|
||||
// 2026-10-28 起数据源是订单表(`updateTime` / `local_updated`),实测滞后约 2 小时。
|
||||
const STATE_STALE_MINUTES = 90;
|
||||
|
||||
/**
|
||||
* 单个**订单表数据源**的滞后阈值(分钟)。
|
||||
*
|
||||
* 🔥 为什么必须按源单独判(2026-10-28 业务第二次质疑「18:00 轮基本都发了」时定型):
|
||||
* 订单两表是**各自独立同步**的,同一天可以一个正常一个停更 ——
|
||||
* 18:35 实测:`jst_Orders` 最后更新 **14:40**(停 4 小时),
|
||||
* 而 `jst_qmcust_orderlist` 更新到 **17:30**(正常)。
|
||||
* 只取候选行的 `MAX(updated_at)` 会被"碰巧新"的行掩盖,必须直接问源表。
|
||||
*
|
||||
* 影响:该源停更 ⟹ 停更之后发出的单,`send_date` 仍是 NULL ⟹ 被判「未发货」。
|
||||
* 18:00 轮 546 单里 **540 单来自 jst_Orders**,全部是这种假超时。
|
||||
*
|
||||
* 所以停更源的单在页面上必须**明确标注**,绝不静默当成「确认未发」。
|
||||
*/
|
||||
const SOURCE_STALE_MINUTES = Number(process.env.CANGKU_WST_SOURCE_STALE_MINUTES) || 60;
|
||||
|
||||
/**
|
||||
* 不参与发货时效考核的快递(业务 2026-10-20 / 2026-10-27 指定):
|
||||
* 自提 —— 客户自己来拉,仓库不负责派送
|
||||
@ -407,6 +423,72 @@ async function loadOaConfirmInfo(stateRows) {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 两个订单表**各自**的最后更新时间,用于判断「这个源还活着吗」。
|
||||
*
|
||||
* 🔥 为什么不能只看候选行:候选池本身就是 `send_date IS NULL` 的单,
|
||||
* 某张表停更后它的候选单 `updateTime` 会集体冻在停更那一刻,看起来「都一样旧」;
|
||||
* 但若停更表里恰好有几单新改过,`MAX` 就假装新鲜了 —— 必须直接问源表。
|
||||
*
|
||||
* 口径:
|
||||
* · `jst_Orders` → `MAX(updateTime)`(5 分钟一批的增量同步时间)
|
||||
* · `jst_qmcust_orderlist` → `MAX(local_updated)`(该表没有 updateTime 列)
|
||||
* · 只扫 30 天窗口(`round.windowStart` 起),不然要全表扫描
|
||||
* · 任一源查询失败 → 该源记 `lastUpdate:null`、`stale:false`,
|
||||
* **不谎报停更**(查不到 ≠ 停更),但会在 `error` 里留痕
|
||||
*
|
||||
* @param {object} round 轮次(用 windowStart 做扫描下界)
|
||||
* @param {Date} now 当前时间(已按北京时间取)
|
||||
* @returns {Promise<Array<{key:string,label:string,lastUpdate:string,lagMinutes:number|null,stale:boolean,error?:string}>>}
|
||||
*/
|
||||
async function loadSourceFreshness(round, now) {
|
||||
const startLit = String(round.windowStart || '').slice(0, 19);
|
||||
const nowText = formatDateTime(now);
|
||||
const defs = [
|
||||
{
|
||||
key: 'jst_Orders',
|
||||
label: 'jst_Orders',
|
||||
sql: `SELECT MAX(updateTime) AS maxUpd FROM rpt.jst_Orders WITH (NOLOCK)
|
||||
WHERE updateTime >= '${startLit}'`,
|
||||
},
|
||||
{
|
||||
key: 'jst_qmcust_orderlist',
|
||||
label: 'qmcust',
|
||||
sql: `SELECT MAX(local_updated) AS maxUpd FROM rpt.jst_qmcust_orderlist WITH (NOLOCK)
|
||||
WHERE local_updated >= '${startLit}'`,
|
||||
},
|
||||
];
|
||||
|
||||
// 两个源并行查(各自 60s 上限,任一个慢不拖累另一个)
|
||||
const settled = await Promise.allSettled(defs.map((d) => cloudDatabase.querySlow(
|
||||
d.sql, {}, { connectRequestTimeoutMs: 120000 },
|
||||
)));
|
||||
|
||||
return defs.map((d, i) => {
|
||||
const s = settled[i];
|
||||
if (s.status !== 'fulfilled') {
|
||||
return {
|
||||
key: d.key,
|
||||
label: d.label,
|
||||
lastUpdate: '',
|
||||
lagMinutes: null,
|
||||
stale: false,
|
||||
error: String((s.reason && s.reason.message) || s.reason || '查询失败').slice(0, 160),
|
||||
};
|
||||
}
|
||||
const rows = s.value || [];
|
||||
const lastUpdate = formatDateTime(rows[0] && rows[0].maxUpd);
|
||||
const lagMinutes = lastUpdate ? Math.max(0, diffMinutes(lastUpdate, nowText)) : null;
|
||||
return {
|
||||
key: d.key,
|
||||
label: d.label,
|
||||
lastUpdate,
|
||||
lagMinutes,
|
||||
// 查不到更新时间时**不判停更**(宁可漏报,也不谎报)
|
||||
stale: lagMinutes != null && lagMinutes > SOURCE_STALE_MINUTES,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function emptySummary() {
|
||||
return {
|
||||
@ -422,6 +504,9 @@ function emptySummary() {
|
||||
carrierSkipped: 0,
|
||||
// 已剔除的「其实已发」单量:订单表 send_date 空、状态表 send_time 有值(漏回写)
|
||||
stateShippedSkipped: 0,
|
||||
// 🔥 超时单里「所在数据源已停更」的单量 —— 这些单的「未发」不可信,可能早已发出。
|
||||
// 2026-10-28 18:00 轮实测:546 单里 540 单来自停更的 jst_Orders。
|
||||
sourceStaleUnshipped: 0,
|
||||
// 不属于 11 主仓的单量(代发仓 / POP 仓 / 仓库表查不到)
|
||||
unknownWarehouse: 0,
|
||||
};
|
||||
@ -504,6 +589,7 @@ async function getShipTimeliness(options = {}) {
|
||||
heldTotal: 0,
|
||||
detailDegraded: false,
|
||||
freshness: null,
|
||||
dataSources: [],
|
||||
note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`,
|
||||
};
|
||||
}
|
||||
@ -544,6 +630,20 @@ async function getShipTimeliness(options = {}) {
|
||||
console.warn('[WST] 审核时间查询失败,全量回退订单创建时间:', e.message);
|
||||
}
|
||||
|
||||
// ③.6 数据源新鲜度:两个订单表**各自独立同步**,必须分源判断停更 ──────────
|
||||
// 🔥 2026-10-28 18:35 实测:`jst_Orders` 停更在 14:40(4 小时),
|
||||
// `jst_qmcust_orderlist` 正常到 17:30。那批「未发 546 单」里 540 单来自
|
||||
// 停更的表 —— 它们的 send_date 全空纯粹是没同步,业务说「基本都发了」是对的。
|
||||
// 只取候选行的 MAX(updated_at) 会被"碰巧新"的行掩盖,所以直接查源表。
|
||||
let sourceFreshness = [];
|
||||
try {
|
||||
sourceFreshness = await loadSourceFreshness(round, now);
|
||||
} catch (e) {
|
||||
// 查不到新鲜度不阻断页面,但要在日志里留痕(页面会退化成"不标注")
|
||||
console.warn('[WST] 数据源新鲜度查询失败:', e.message);
|
||||
}
|
||||
const sourceMap = new Map(sourceFreshness.map((s) => [s.key, s]));
|
||||
|
||||
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
|
||||
let voidCount = 0;
|
||||
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答 / 武进安能),写进 summary 便于对账
|
||||
@ -552,6 +652,10 @@ async function getShipTimeliness(options = {}) {
|
||||
let stateShippedSkipped = 0;
|
||||
// 仓库表里查不到 / 不属于 11 主仓的单量(代发仓、POP 仓、wms_co_id 为空…)
|
||||
let unknownWarehouse = 0;
|
||||
// 超时单里「所在数据源已停更」的数量 —— 这批单的「未发」不可信
|
||||
let sourceStaleUnshipped = 0;
|
||||
// 按来源表分开计数(提示里要说「jst_Orders 540 单、qmcust 6 单」,不能只说「这张表」)
|
||||
const sourceStaleBySrc = {};
|
||||
|
||||
// ④ 分桶
|
||||
const acc = new Map(centerList.map((c) => [c.key, {
|
||||
@ -653,6 +757,12 @@ async function getShipTimeliness(options = {}) {
|
||||
state: ORDER_STATE_LABELS[statusRaw] || statusRaw,
|
||||
statusRaw,
|
||||
src: String(row.src || ''),
|
||||
// 该单来自哪张订单表、这张表是否已经停更 ────────────────────────────
|
||||
// 🔥 停更 ⟹ 停更之后发出的单在这里仍是 send_date 空 ⟹ 被判「未发」,
|
||||
// 但真实情况很可能是已经发了。所以**不能静默当确认未发**,
|
||||
// 页面要标出来(2026-10-28 18:00 轮 546 单里 540 单是这种)。
|
||||
srcLabel: (sourceMap.get(String(row.src || '')) || {}).label || String(row.src || ''),
|
||||
sourceStale: Boolean((sourceMap.get(String(row.src || '')) || {}).stale),
|
||||
createdAt,
|
||||
oaConfirmAt,
|
||||
payAt,
|
||||
@ -709,6 +819,10 @@ async function getShipTimeliness(options = {}) {
|
||||
const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline));
|
||||
|
||||
bucket.pendingCount += 1;
|
||||
if (base.sourceStale) {
|
||||
sourceStaleUnshipped += 1;
|
||||
sourceStaleBySrc[base.srcLabel] = (sourceStaleBySrc[base.srcLabel] || 0) + 1;
|
||||
}
|
||||
const days = Math.floor(waited / 1440);
|
||||
if (days > bucket.oldestDays) bucket.oldestDays = days;
|
||||
pendingRows.push({ ...base, waited });
|
||||
@ -751,6 +865,10 @@ async function getShipTimeliness(options = {}) {
|
||||
benchmarkTime: x.benchmarkAt,
|
||||
benchmarkSource: x.benchmarkSource,
|
||||
state: x.state,
|
||||
// 该单所在订单表是否已停更 —— 前端据此给单打「数据未同步」标记
|
||||
src: x.src,
|
||||
srcLabel: x.srcLabel,
|
||||
sourceStale: x.sourceStale,
|
||||
// 订单表没有物流轨迹,只能从「有没有运单号」判断仓库是否已打单
|
||||
logisticStatus: x.logisticId ? '已打单 · 未揽收' : '',
|
||||
// 运单号 / 快递公司:有运单号说明仓库**已打单**(但还没回传发货时间)。
|
||||
@ -825,6 +943,7 @@ async function getShipTimeliness(options = {}) {
|
||||
summary.voidCount = voidCount;
|
||||
summary.carrierSkipped = carrierSkipped;
|
||||
summary.stateShippedSkipped = stateShippedSkipped;
|
||||
summary.sourceStaleUnshipped = sourceStaleUnshipped;
|
||||
summary.unknownWarehouse = unknownWarehouse;
|
||||
|
||||
// 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表)
|
||||
@ -833,7 +952,21 @@ async function getShipTimeliness(options = {}) {
|
||||
// 15:48 之后发的货就永远进不来,页面只能基于旧快照判「未发」。
|
||||
// 必须把滞后暴露到页面上,否则业务只会以为判定写错。
|
||||
const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null;
|
||||
const stale = snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES;
|
||||
// 停更判定要**两个口径取或**:
|
||||
// ① 候选行的 MAX(updated_at) 太旧(老口径,只能反映"候选池里最新的那条")
|
||||
// ② 任一订单表自己停更(新口径,直接问源表 —— 候选行会骗人,见 SOURCE_STALE_MINUTES 注释)
|
||||
const staleSources = sourceFreshness.filter((s) => s.stale);
|
||||
const stale = (snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES)
|
||||
|| staleSources.length > 0;
|
||||
// 停更源的一句话描述:「jst_Orders 停止同步于 14:40(已 4.3 小时)」
|
||||
const staleSrcText = staleSources
|
||||
.map((s) => `${s.label} 停止同步于 ${String(s.lastUpdate || '').slice(11, 16) || '—'}`
|
||||
+ `(已 ${Math.round((s.lagMinutes || 0) / 6) / 10} 小时)`)
|
||||
.join('、');
|
||||
// 停更源各贡献了多少超时单(540 来自 jst_Orders、6 来自 qmcust 这种要说清,别只写「这张表」)
|
||||
const staleBySrcText = Object.entries(sourceStaleBySrc)
|
||||
.map(([k, v]) => `${k} ${v} 单`)
|
||||
.join('、');
|
||||
// 🔥 更狠的一条:数据**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。
|
||||
// 2026-10-27 18:00 轮就是这种情况(状态镜像停在 11:54),推出去的 34 单里混着已发货的。
|
||||
const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline;
|
||||
@ -845,8 +978,11 @@ async function getShipTimeliness(options = {}) {
|
||||
// 数据源已经换成订单表,文案跟着改(别再写「状态镜像」)
|
||||
source: 'jst_Orders',
|
||||
hint: [
|
||||
stale
|
||||
? `订单表最后更新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。`
|
||||
// 有源停更就一定要说,哪怕本轮 0 单 —— 「没停更但没单」和「停更了所以看不到单」是两件事
|
||||
staleSrcText ? `数据源停更:${staleSrcText}。` : '',
|
||||
sourceStaleUnshipped > 0
|
||||
? `本轮 ${sourceStaleUnshipped} 单来自停更的数据源(${staleBySrcText}):`
|
||||
+ '停更之后发出的单在这里仍是「未发货」,这些单很可能已经发货,请先在聚水潭核对同步状态再判超时。'
|
||||
: '',
|
||||
!coversDeadline
|
||||
? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:`
|
||||
@ -895,6 +1031,8 @@ async function getShipTimeliness(options = {}) {
|
||||
detailLimit: detailRows.length,
|
||||
detailDegraded: false,
|
||||
freshness,
|
||||
// 两个订单表各自的同步新鲜度(页面据此出红条告警)
|
||||
dataSources: sourceFreshness,
|
||||
cached: false,
|
||||
cacheAgeSec: 0,
|
||||
note: '',
|
||||
@ -969,6 +1107,8 @@ async function listShipTimelinessExportRows(query = {}) {
|
||||
summary: payload.summary || emptySummary(),
|
||||
excludedCarriers: payload.excludedCarriers || EXCLUDED_CARRIERS,
|
||||
freshness: payload.freshness || null,
|
||||
// 导出也要能说明「这批单里有多少来自停更的源」,否则导出的 Excel 脱离页面就没了上下文
|
||||
dataSources: payload.dataSources || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -184,7 +184,7 @@ test('起算点是 orders.created,且只从订单两表取', () => {
|
||||
});
|
||||
|
||||
// ── ④ 服务层分桶(注入假查询,不连库)──
|
||||
function stubDb(dbPath, scenario = 'default', oaRows = []) {
|
||||
function stubDb(dbPath, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00') {
|
||||
const WMS = {
|
||||
CQ: 15038426,
|
||||
SD_QGJ: 15137523,
|
||||
@ -296,7 +296,7 @@ function stubDb(dbPath, scenario = 'default', oaRows = []) {
|
||||
|
||||
const rows = scenario === 'modified' ? modifiedRows : (scenario === 'noon' ? noonRows : defaultRows);
|
||||
|
||||
const calls = { orders: 0, oaConfirm: 0, warehouse: 0, sql: [], params: [] };
|
||||
const calls = { orders: 0, oaConfirm: 0, warehouse: 0, freshness: 0, sql: [], params: [] };
|
||||
|
||||
const handler = async (sql, params) => {
|
||||
const text = String(sql);
|
||||
@ -304,6 +304,15 @@ function stubDb(dbPath, scenario = 'default', oaRows = []) {
|
||||
calls.warehouse += 1;
|
||||
return warehouseRows;
|
||||
}
|
||||
// 数据源新鲜度探测:`SELECT MAX(updateTime) FROM jst_Orders` /
|
||||
// `SELECT MAX(local_updated) FROM jst_qmcust_orderlist`(2026-10-28 新增)。
|
||||
// 单独记账,**不算订单候选查询** —— 否则「订单两表只允许查一次」的老断言会误伤。
|
||||
if (/MAX\(updateTime\)/iu.test(text) || /MAX\(local_updated\)/iu.test(text)) {
|
||||
calls.freshness += 1;
|
||||
// 默认 18:00(用例 now=18:05 → 滞后 5 分钟 < 60,不触发停更告警);
|
||||
// 要测停更场景就把 freshnessUpd 传成当天更早的时刻
|
||||
return [{ maxUpd: freshnessUpd }];
|
||||
}
|
||||
// 审核时间查询(状态表):默认返回空 → 服务层全部回退订单创建时间,
|
||||
// 这样老断言(按创建时间算的分钟数)继续成立;要测审核时间口径就传 oaRows
|
||||
if (/jst_order_express_details/u.test(text)) {
|
||||
@ -327,11 +336,11 @@ function stubDb(dbPath, scenario = 'default', oaRows = []) {
|
||||
return { calls, WMS };
|
||||
}
|
||||
|
||||
function withStubbedService(fn, scenario = 'default', oaRows = []) {
|
||||
function withStubbedService(fn, scenario = 'default', oaRows = [], freshnessUpd = '2026-10-20 18:00:00') {
|
||||
return async () => {
|
||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||
const realDb = require.cache[dbPath];
|
||||
const ctx = stubDb(dbPath, scenario, oaRows);
|
||||
const ctx = stubDb(dbPath, scenario, oaRows, freshnessUpd);
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||
try {
|
||||
@ -665,3 +674,31 @@ test('状态表审核时间比 modified 更晚时,以状态表为准', withStu
|
||||
}, 'modified', [
|
||||
{ inner_order_id: 52, first_oa_confirm_time: new Date(2026, 9, 20, 6, 30, 0), last_oa_confirm_time: new Date(2026, 9, 20, 6, 30, 0), send_time: null },
|
||||
]));
|
||||
|
||||
test('数据源停更:来自停更表的超时单必须打标,并计入 sourceStaleUnshipped', async () => {
|
||||
// 🔥 2026-10-28 18:35 现场:`jst_Orders` 停更在 14:40,18:00 轮 546 单里 540 单来自它,
|
||||
// 业务当场质疑「基本都发了」。页面对这种单**不能静默当确认未发**。
|
||||
// 用例 now=18:05,源表停在 10:00 → 滞后 8 小时 > 60 分钟阈值
|
||||
await withStubbedService(async (svc) => {
|
||||
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
|
||||
assert.ok(data.summary.overdueUnshipped > 0, '前置:默认场景应有超时单');
|
||||
assert.equal(
|
||||
data.summary.sourceStaleUnshipped,
|
||||
data.summary.overdueUnshipped,
|
||||
'源停更时,全部超时单都要计入「数据未同步」',
|
||||
);
|
||||
assert.ok(data.dataSources.length >= 2, '两个订单表的新鲜度都要透出');
|
||||
assert.ok(data.dataSources.every((x) => x.stale), '两张表都停在 10:00,都算停更');
|
||||
assert.equal(data.orders[0].sourceStale, true, '明细行要带 sourceStale 标记');
|
||||
assert.ok(data.orders[0].srcLabel, '明细行要带来源表名,前端才解释得清');
|
||||
assert.match(String(data.freshness.hint), /停更/u, '页面提示要说明是哪张表停更');
|
||||
}, 'default', [], '2026-10-20 10:00:00');
|
||||
|
||||
// 反方向:源正常时不能误标,否则业务会以为每天都在停更
|
||||
await withStubbedService(async (svc) => {
|
||||
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
|
||||
assert.equal(data.summary.sourceStaleUnshipped, 0, '源正常时不能标停更');
|
||||
assert.ok(data.dataSources.every((x) => !x.stale), '滞后 5 分钟不达阈值');
|
||||
assert.equal(Boolean(data.orders[0].sourceStale), false, '明细行不带停更标记');
|
||||
}, 'default');
|
||||
});
|
||||
|
||||
@ -185,7 +185,26 @@ const HELD_COLUMNS = [
|
||||
|
||||
const DETAIL_COLUMNS = [
|
||||
// 内部单号 = 聚水潭 o_id(系统内部编号,仓管在系统里按它查单)
|
||||
{ title: '内部单号', dataIndex: 'oId', width: 110 },
|
||||
{
|
||||
title: '内部单号',
|
||||
dataIndex: 'oId',
|
||||
width: 158,
|
||||
// 🔥 来自停更订单表的单必须打标(2026-10-28):
|
||||
// 该表停更之后发出的单,send_date 仍是空 → 被判「未发」,实际很可能已经发了。
|
||||
// 18:00 轮实测 546 单里 540 单是这种,不标出来业务会以为判定写错。
|
||||
render: (v, row) => (
|
||||
<span className="wst-detail__oid">
|
||||
{v || '—'}
|
||||
{row?.sourceStale ? (
|
||||
<Tooltip
|
||||
title={`这单来自 ${row?.srcLabel || '订单表'},该表已停止同步:停更之后发出的单在这里仍是「未发货」,很可能已经发了。请先在聚水潭核对。`}
|
||||
>
|
||||
<Tag bordered={false} color="error" className="wst-detail__stale">未同步</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
// 线上订单号 = 平台 so_id(京东/抖音/天猫等平台单号,客服按它对接客户)
|
||||
{
|
||||
title: '线上订单号',
|
||||
@ -318,6 +337,14 @@ const EXPORT_COLUMNS = [
|
||||
{ title: '运单号', dataIndex: 'logisticId', exportValue: (v) => v || '未打单' },
|
||||
{ title: '物流状态', dataIndex: 'logisticStatus' },
|
||||
{ title: '发货状态', dataIndex: 'state' },
|
||||
// 导出也要带上「这单的来源表 + 该表是否停更」,否则 Excel 脱离页面就没有上下文,
|
||||
// 拿着 540 单「未发」去追仓库,实际人家早就发了。
|
||||
{ title: '数据源', dataIndex: 'srcLabel', exportValue: (v) => v || '—' },
|
||||
{
|
||||
title: '数据源是否停更',
|
||||
dataIndex: 'sourceStale',
|
||||
exportValue: (v) => (v ? '是(该单可能已发)' : '否'),
|
||||
},
|
||||
{ title: '发货时间', dataIndex: 'sendTime', exportValue: (v) => v || '未发货' },
|
||||
{
|
||||
title: '审核时间',
|
||||
@ -427,6 +454,11 @@ function WarehouseShipTimeliness() {
|
||||
const summary = data?.summary || {};
|
||||
const round = data?.round || null;
|
||||
const freshness = data?.freshness || null;
|
||||
// 🔥 超时单里「所在订单表已停更」的数量(2026-10-28 定型):
|
||||
// 停更⟹停更之后发出的单 send_date 仍为空⟹被判「未发」,但实际很可能已发。
|
||||
// 18:00 轮实测 546 单里 540 单属于这种,必须显著提示,否则业务会以为判定写错。
|
||||
const staleUnshipped = Number(summary.sourceStaleUnshipped || 0);
|
||||
const staleSources = Array.isArray(data?.dataSources) ? data.dataSources.filter((s) => s && s.stale) : [];
|
||||
// 13:00 之前后端返回 round=null + notStarted=true:此时**不展示任何超时数字**
|
||||
const notStarted = Boolean(data?.notStarted);
|
||||
const hasRound = Boolean(round);
|
||||
@ -730,14 +762,38 @@ function WarehouseShipTimeliness() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{freshness && (freshness.stale || freshness.coversDeadline === false) ? (
|
||||
{(freshness && (freshness.stale || freshness.coversDeadline === false))
|
||||
|| staleUnshipped > 0
|
||||
|| staleSources.length > 0 ? (
|
||||
<Alert
|
||||
// 快照没覆盖到核验时刻 → 数字必然偏大,用红色;只是滞后用黄色
|
||||
type={freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||
// 快照没覆盖到核验时刻 / 有单来自停更的表 → 数字必然偏大,用红色;只是滞后用黄色
|
||||
type={staleUnshipped > 0 || freshness?.coversDeadline === false ? 'error' : 'warning'}
|
||||
showIcon
|
||||
className="wst-alert"
|
||||
message={freshness.coversDeadline === false ? '数据未覆盖本轮核验时刻,超时单量偏大' : '发货数据可能滞后'}
|
||||
description={freshness.hint}
|
||||
message={
|
||||
staleUnshipped > 0
|
||||
? `${number(staleUnshipped)} 单所在订单表已停止同步,这些单很可能已经发货`
|
||||
: (staleSources.length > 0
|
||||
? '订单表数据源已停止同步'
|
||||
: (freshness?.coversDeadline === false
|
||||
? '数据未覆盖本轮核验时刻,超时单量偏大'
|
||||
: '发货数据可能滞后'))
|
||||
}
|
||||
description={(
|
||||
<>
|
||||
{freshness?.hint}
|
||||
{staleSources.length ? (
|
||||
<div className="wst-alert__sources">
|
||||
{staleSources.map((s) => (
|
||||
<span key={s.key} className="wst-alert__source">
|
||||
{s.label}:最后同步 {String(s.lastUpdate || '').slice(11, 19) || '—'}
|
||||
(已 {Math.round((s.lagMinutes || 0) / 6) / 10} 小时)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -754,6 +810,13 @@ function WarehouseShipTimeliness() {
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
valueStyle={{ color: (summary.overdueUnshipped || 0) > 0 ? '#cf1322' : undefined }}
|
||||
/>
|
||||
{staleUnshipped > 0 ? (
|
||||
<Tooltip title="这批单来自已停止同步的订单表:停更之后发出的单,在库里 send_date 还是空,会被判成「未发」。先在聚水潭核对,别直接拿去追仓库。">
|
||||
<div className="wst-stat__warn">
|
||||
其中 {number(staleUnshipped)} 单数据未同步
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Card>
|
||||
<Card
|
||||
bordered={false}
|
||||
|
||||
@ -111,6 +111,45 @@
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
/* 超时卡下面的「其中 N 单数据未同步」—— 订单表停更时的警示小字(2026-10-28) */
|
||||
.wst-stat__warn {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #cf1322;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* 停更告警里逐个源列出「最后同步时间 / 滞后多久」 */
|
||||
.wst-alert__sources {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
}
|
||||
|
||||
.wst-alert__source {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 明细里的「内部单号 + 未同步标记」 */
|
||||
.wst-detail__oid {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wst-detail__stale {
|
||||
margin-inline-end: 0;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
padding-inline: 5px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* ============ 主从两栏 ============ */
|
||||
.wst-body {
|
||||
display: flex;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user