Compare commits
No commits in common. "main" and "yanhui-wst-notify-20261027" have entirely different histories.
main
...
yanhui-wst
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心发货时效 —— HTTP 路由(2026-10-20 新增)
|
||||
* 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* GET /api/warehouse-ship-timeliness/list
|
||||
* query:
|
||||
@ -11,10 +11,6 @@
|
||||
* GET /api/warehouse-ship-timeliness/rounds
|
||||
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
|
||||
*
|
||||
* GET /api/warehouse-ship-timeliness/export (2026-10-27 新增)
|
||||
* 「超时未发」明细导出:不分页、不截断,返回本轮全部行(与 /list 同一份缓存)
|
||||
* query: round / force
|
||||
*
|
||||
* ── 两轮核验完成后推钉钉群(2026-10-27 新增)────────────────────────────────
|
||||
* GET /api/warehouse-ship-timeliness/notify/settings 读配置(加签密钥只回掩码)
|
||||
* PUT /api/warehouse-ship-timeliness/notify/settings 保存配置(加签密钥留空 = 不变)
|
||||
@ -25,10 +21,7 @@
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getShipTimeliness,
|
||||
listShipTimelinessExportRows,
|
||||
} = require('../services/warehouseShipTimelinessService');
|
||||
const { getShipTimeliness } = require('../services/warehouseShipTimelinessService');
|
||||
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
|
||||
const {
|
||||
getShipTimelinessNotifyAdminConfig,
|
||||
@ -80,23 +73,6 @@ router.get('/rounds', async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 导出「超时未发」明细:**不分页、不截断**,返回本轮考核口径内的全部行。
|
||||
*
|
||||
* query: round(同 /list)、force=1(绕过缓存重查)
|
||||
*
|
||||
* 与 /list 共用 getShipTimeliness(同一份缓存),所以页面刚刷过时这里是秒回。
|
||||
* 前端拿到 rows 后按选中的仓在前端过滤 —— 口径与页面完全一致,不需要第二套 filter。
|
||||
*/
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const data = await listShipTimelinessExportRows(req.query || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出超时未发明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
// ── 两轮核验完成后推钉钉群 ──────────────────────────────────────────────────
|
||||
|
||||
// 读配置:加签密钥只回掩码 + 是否已配置,明文永不出后端
|
||||
|
||||
@ -681,7 +681,7 @@ async function startServer(options = {}) {
|
||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
||||
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
||||
// 仓储中心发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
||||
// 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
||||
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
|
||||
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
|
||||
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);
|
||||
|
||||
@ -245,26 +245,8 @@ function buildNotifyText(data = {}, options = {}) {
|
||||
const typeLine = buildTypeLine(data);
|
||||
if (typeLine) head.push(`类型:${typeLine}`);
|
||||
|
||||
// 🔥 2026-11-02 修:0 单有两种完全不同的含义,不能都写「全部正常」——
|
||||
// ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿;
|
||||
// ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。
|
||||
const freshness = data.freshness || {};
|
||||
if (totalPackages === 0) {
|
||||
if (freshness.emptyWindow && freshness.sourceStale) {
|
||||
const hours = Number(freshness.sourceAgeMinutes) > 0
|
||||
? `(已 ${Math.round((Number(freshness.sourceAgeMinutes) / 60) * 10) / 10} 小时未同步)`
|
||||
: '';
|
||||
head.push(`⚠️ 物流数据未同步:镜像表最后更新 ${freshness.sourceLastUpdate || '未知'}${hours},`
|
||||
+ `${batch.shipDate || '该日'} 的发货记录一条都没进库 —— 上面 0 单不代表当天没发货,请先到聚水潭核对。`);
|
||||
} else if (freshness.emptyWindow) {
|
||||
head.push(`${batch.shipDate || '该日'} 在物流镜像表里 0 条发货记录`
|
||||
+ `(表最后更新 ${freshness.sourceLastUpdate || '未知'},数据源本身正常)——`
|
||||
+ ' 若当天确有发货,说明上游漏同步了这一天,请到聚水潭核对。');
|
||||
} else if (shipped === 0) {
|
||||
head.push('本批发货 0 单,无异常可核。');
|
||||
} else {
|
||||
head.push('本批无转运异常,全部正常。');
|
||||
}
|
||||
head.push('本批无转运异常,全部正常。');
|
||||
}
|
||||
|
||||
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心发货时效 · 两轮核验完成后把超时未发单推到钉钉群
|
||||
* 仓储中心 24 小时发货时效 · 两轮核验完成后把超时未发单推到钉钉群
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 业务口径(2026-10-27 原话):
|
||||
* 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知;
|
||||
@ -9,19 +9,14 @@
|
||||
* ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS):
|
||||
* NOON 13:00 核验「今天 07:00 前审核」→ 默认 13:05 推
|
||||
* AFTERNOON 18:00 核验「截止 16:00 前审核」→ 默认 18:05 推
|
||||
* ⚠️ 2026-10-28 起数据源换成订单两表(jst_Orders / jst_qmcust_orderlist),
|
||||
* 起算点是**审核时间** `last_oa_confirm_time`(不是订单创建时间);
|
||||
* 取不到审核时间的单回退创建时间,正文里在该时间后加 `*` 提示。
|
||||
* 推送正文写「审核 HH:mm」(此前一版误写「创建」,已纠正)。
|
||||
* ⚠️ 默认「核验时刻 + 5 分钟」:订单表 `updateTime` 得晚于核验时刻,
|
||||
* ⚠️ 默认「核验时刻 + 5 分钟」:状态镜像 `query_time` 得晚于核验时刻,
|
||||
* 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。
|
||||
* 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。
|
||||
*
|
||||
* 消息内容:
|
||||
* 抬头(含「超时」字样,钉钉关键词安全设置要用)+ 汇总(超时单数 / 涉及仓数 / 最久已等)
|
||||
* + 不计考核(待审核·订单异常,不算仓库责任)+ 按仓超时单数 + 前 N 条明细 + 页面链接。
|
||||
* 明细一行 = 仓|内部单号|线上单号|快递|创建 HH:mm|已等 X 小时(业务对账用)。
|
||||
* (「快递」是业务 2026-10-27 要求加的,见 buildDetailLine 的说明)
|
||||
* 明细一行 = 仓|内部单号|线上单号|审核 HH:mm|已等 X 小时(业务对账用)。
|
||||
* 0 条也推 —— 群里看到「本轮 0 单超时」就知道任务在跑。
|
||||
*
|
||||
* 去重:状态存 system_config,按 `<北京日期>|<轮次键>` 去重(一天两轮互不影响)。
|
||||
@ -31,12 +26,8 @@
|
||||
*/
|
||||
|
||||
const cron = require('node-cron');
|
||||
const {
|
||||
getShipTimeliness,
|
||||
DEFAULT_DETAIL_LIMIT,
|
||||
EXCLUDED_CARRIERS,
|
||||
} = require('../services/warehouseShipTimelinessService');
|
||||
const { ROUNDS, beijingNow, describeRounds } = require('../services/warehouseShipTimelinessRegistry');
|
||||
const { getShipTimeliness, DEFAULT_DETAIL_LIMIT } = require('../services/warehouseShipTimelinessService');
|
||||
const { ROUNDS, describeRounds } = require('../services/warehouseShipTimelinessRegistry');
|
||||
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
|
||||
const {
|
||||
DEFAULT_MAX_DETAIL_ROWS,
|
||||
@ -122,18 +113,14 @@ function formatOverdue(minutes) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 起算时间(= 审核时间 `last_oa_confirm_time`)的展示:
|
||||
* 本轮的日期只给 HH:mm,跨天(历史未发单)必须带 MM-DD。
|
||||
* 审核时间的展示:本轮的日期就只给 HH:mm,跨天(历史未发单)必须带 MM-DD。
|
||||
* 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」,
|
||||
* 明细里会出现昨天 16:13 审核的单 —— 只写「审核 16:13」会被读成「今天 16:13」,
|
||||
* 看着像「审核时间晚于窗口,不该算超时」,业务会对不上账。
|
||||
*
|
||||
* ⚠️ 传进来的 `auditTime` 是服务层的 benchmarkAt:审核时间,取不到时回退订单创建时间
|
||||
* (此时 row.benchmarkSource === 'created',调用方会给时间加 `*`)。
|
||||
*/
|
||||
function auditWhen(auditTime, roundDate) {
|
||||
const text = cleanCell(auditTime);
|
||||
if (!text) return '';
|
||||
if (!text) return '未审核';
|
||||
const hit = text.match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
|
||||
if (!hit) {
|
||||
const clock = text.match(/(\d{2}:\d{2})/);
|
||||
@ -145,27 +132,17 @@ function auditWhen(auditTime, roundDate) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细一行:仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
|
||||
* 明细一行:仓|内部单号|线上单号|审核 时间|已等 X 小时
|
||||
* ⚠️ 第二段是**内部单号**(聚水潭 o_id,仓管在系统里按它查单),第三段是线上单号(客服对客户)。
|
||||
* 两者业务都要,别只留一个。
|
||||
* 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断
|
||||
* 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同),
|
||||
* 不用再回页面一单单点开确认。
|
||||
* 🔥 2026-10-28 第五段是「审核」时间(起算点),不是订单创建时间;
|
||||
* 审核时间缺失、回退订单创建时间的单,时间后面带 `*`。
|
||||
* ⚠️ 跨天要带日期,见 auditWhen 的说明。
|
||||
* ⚠️ 审核时间跨天要带日期,见 auditWhen 的说明。
|
||||
*/
|
||||
function buildDetailLine(row, index, roundDate) {
|
||||
const when = auditWhen(row?.auditTime, roundDate);
|
||||
const auditText = when
|
||||
? `审核 ${when}${row?.benchmarkSource === 'created' ? '*' : ''}`
|
||||
: '审核 —';
|
||||
const parts = [
|
||||
cleanCell(row?.warehouseName || row?.centerName, '未知仓'),
|
||||
cleanCell(row?.oId, '无单号'),
|
||||
cleanCell(row?.soId, '—'),
|
||||
cleanCell(row?.logisticCompany, '—'),
|
||||
auditText,
|
||||
`审核 ${auditWhen(row?.auditTime, roundDate)}`,
|
||||
`已等 ${formatOverdue(row?.overdueMinutes)}`,
|
||||
];
|
||||
return `${index}. ${parts.join('|')}`;
|
||||
@ -289,15 +266,11 @@ function buildNotifyText(data = {}, options = {}) {
|
||||
head.push('本轮没有超时未发货的订单,全部按时发出。');
|
||||
}
|
||||
if (heldCount > 0) {
|
||||
head.push(`另有 ${heldCount} 单不计考核(待审核 / 订单异常)`);
|
||||
head.push(`另有 ${heldCount} 单不计考核(已支付待审核 / 订单异常)`);
|
||||
}
|
||||
const carrierSkipped = Number(summary.carrierSkipped) || 0;
|
||||
if (carrierSkipped > 0) {
|
||||
// 快递清单由服务层给(`excludedCarriers`),别在这里写死 ——
|
||||
// 2026-10-27 加「武进安能」时就吃过一次:写死的文案忘了改,群里口径和页面不一致。
|
||||
const carriers = (Array.isArray(data.excludedCarriers) ? data.excludedCarriers : EXCLUDED_CARRIERS)
|
||||
.filter(Boolean);
|
||||
head.push(`已剔除 ${carrierSkipped} 单不考核快递${carriers.length ? `(${carriers.join(' / ')})` : ''}`);
|
||||
head.push(`已剔除 ${carrierSkipped} 单不考核快递(自提 / 中铁 / 壹米滴答)`);
|
||||
}
|
||||
// 数据没覆盖本轮核验时刻 → 数字必然偏大,必须写在消息里,不能让仓管拿错数去追单
|
||||
if (freshness.coversDeadline === false) {
|
||||
@ -417,15 +390,8 @@ function sendTimeForRound(config, roundKey) {
|
||||
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
|
||||
*/
|
||||
async function pushShipTimelinessNotify(options = {}) {
|
||||
// 🔥 两个「现在」不能混用(生产容器 /etc/timezone = Etc/UTC):
|
||||
// · rawNow —— 真实时刻。凡是走 Intl(Asia/Shanghai) 或 toISOString 的地方用它;
|
||||
// · bjNow —— 平移成「本地 getter 读出来正好是北京时间」的 Date。
|
||||
// registry.describeRounds / resolveRoundMeta / getShipTimeliness 内部都用
|
||||
// `getDate()/getHours()` 直接读,必须喂 bjNow —— 否则北京 00:00~08:00 会取到前一天
|
||||
// (2026-10-21 生产踩过:13:00 轮算出 3895 单,实际只有 6 单)。
|
||||
const rawNow = options.now === undefined ? new Date() : new Date(options.now);
|
||||
const bjNow = beijingNow(rawNow);
|
||||
const pushDate = chinaDateKey(rawNow);
|
||||
const now = options.now === undefined ? new Date() : new Date(options.now);
|
||||
const pushDate = chinaDateKey(now);
|
||||
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
||||
|
||||
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
|
||||
@ -445,7 +411,7 @@ async function pushShipTimelinessNotify(options = {}) {
|
||||
const explicitRound = String(options.round || '').toUpperCase();
|
||||
let roundKey = explicitRound;
|
||||
if (!roundKey) {
|
||||
const minutes = minutesOfDay(rawNow);
|
||||
const minutes = minutesOfDay(now);
|
||||
let active = null;
|
||||
for (const round of ROUNDS) {
|
||||
if (minutes >= round.verifyHour * 60) active = round;
|
||||
@ -454,12 +420,12 @@ async function pushShipTimelinessNotify(options = {}) {
|
||||
roundKey = active.key;
|
||||
}
|
||||
|
||||
const meta = resolveRoundMeta(roundKey, bjNow);
|
||||
const meta = resolveRoundMeta(roundKey, now);
|
||||
if (!meta) return { sent: false, reason: 'unknown_round', round: roundKey };
|
||||
|
||||
// 还没到本轮核验时刻就推 = 推的是半成品,直接跳过(等下一轮 tick)
|
||||
const deadlineMinutes = parseSendTime(String(meta.deadline || '').slice(11, 16));
|
||||
if (deadlineMinutes != null && minutesOfDay(rawNow) < deadlineMinutes) {
|
||||
if (deadlineMinutes != null && minutesOfDay(now) < deadlineMinutes) {
|
||||
return { sent: false, reason: 'before_deadline', round: roundKey, deadline: meta.deadline };
|
||||
}
|
||||
|
||||
@ -490,7 +456,7 @@ async function pushShipTimelinessNotify(options = {}) {
|
||||
const data = await getShipTimeliness({
|
||||
round: roundKey,
|
||||
limit: DEFAULT_DETAIL_LIMIT,
|
||||
now: bjNow,
|
||||
now,
|
||||
});
|
||||
if (data?.notStarted) {
|
||||
return { sent: false, reason: 'not_started', round: roundKey };
|
||||
@ -529,7 +495,7 @@ async function pushShipTimelinessNotify(options = {}) {
|
||||
const nextSent = {
|
||||
...sentMap,
|
||||
[slot]: {
|
||||
sentAt: rawNow.toISOString(),
|
||||
sentAt: new Date(now).toISOString(),
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
@ -541,7 +507,7 @@ async function pushShipTimelinessNotify(options = {}) {
|
||||
await saveShipTimelinessNotifyState({
|
||||
version: 1,
|
||||
sent: trimSentMap(nextSent),
|
||||
lastSentAt: rawNow.toISOString(),
|
||||
lastSentAt: new Date(now).toISOString(),
|
||||
lastStatus: 'sent',
|
||||
lastError: '',
|
||||
lastRound: roundKey,
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
* 仓储中心物流转运异常(2026-10-21 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 需求(yanhui 2026-10-21):
|
||||
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心发货时效」下面),
|
||||
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心 24 小时发货时效」下面),
|
||||
* 下属 12 个发货仓:
|
||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
|
||||
|
||||
@ -38,10 +38,6 @@ const EXPORT_DETAIL_LIMIT = 5000;
|
||||
|
||||
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
|
||||
const SNAPSHOT_STALE_MINUTES = 120;
|
||||
// 数据源(整张镜像表)多久没刷新算停更。与上面同值,但语义不同:
|
||||
// SNAPSHOT_STALE_MINUTES 判的是「本批窗口内行的 query_time」,
|
||||
// SOURCE_STALE_MINUTES 判的是「整张表最后被灌库的时刻」—— 窗口内 0 行时只能靠后者。
|
||||
const SOURCE_STALE_MINUTES = Number(process.env.CANGKU_WLE_SOURCE_STALE_MINUTES) || 120;
|
||||
|
||||
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
|
||||
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
|
||||
@ -152,36 +148,6 @@ async function loadShippedPackages(batch) {
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 整张镜像表最后被灌库的时刻(表级新鲜度)。
|
||||
*
|
||||
* 🔥 2026-11-02 新增,起因:11-02 09:00 那批推出「已发货 0 单 / 0 个包裹,异常 0 个包裹」,
|
||||
* 业务问「怎么可能 0 单」。查出来镜像表 **11-01 07:55 之后就没再灌过**,
|
||||
* 而 v1 的 freshness 只用「本批窗口内行的 query_time」算 —— 窗口一行都没有时
|
||||
* `snapshotAt` 是空串 → `stale=false`、`hint=''`,页面和钉钉于是把它当成
|
||||
* 「本批确实没有发货 / 全部正常」。这是最危险的一种假绿。
|
||||
*
|
||||
* 成本:全表 `MAX(query_time)` 在一个 402 万行的视图上实测 **13.5s**(谓词下推差),
|
||||
* 所以**只在窗口 0 行时才调**(正常批次一行都不用付这笔钱),结果随主结果进 5 分钟缓存。
|
||||
*
|
||||
* @returns {Promise<string>} 'YYYY-MM-DD HH:mm:ss',取不到返回空串
|
||||
*/
|
||||
async function loadSourceLastUpdate() {
|
||||
try {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT MAX(e.query_time) AS max_q
|
||||
FROM dbo.jst_order_express_details e WITH (NOLOCK)`,
|
||||
{},
|
||||
{ timeoutMs: 240000 }
|
||||
);
|
||||
return formatDateTime(rows?.[0]?.max_q);
|
||||
} catch (err) {
|
||||
// 探测失败不该让整页挂掉:退回「不知道」,hint 里会说明取不到表级时间
|
||||
console.warn(`[WLE] 数据源新鲜度探测失败:${err.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 售后拦截判定:一次 IN 多少单 + 查询超时。
|
||||
// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫,
|
||||
// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以:
|
||||
@ -349,17 +315,7 @@ async function getLogisticsExceptions(options = {}) {
|
||||
orders: [],
|
||||
detailLimit: limit,
|
||||
truncated: false,
|
||||
freshness: {
|
||||
snapshotAt: '',
|
||||
ageMinutes: null,
|
||||
stale: false,
|
||||
coversDeadline: null,
|
||||
emptyWindow: false,
|
||||
sourceLastUpdate: '',
|
||||
sourceAgeMinutes: null,
|
||||
sourceStale: false,
|
||||
hint: '',
|
||||
},
|
||||
freshness: { snapshotAt: '', ageMinutes: null, stale: false, coversDeadline: null, hint: '' },
|
||||
generatedAt,
|
||||
cached: false,
|
||||
cacheAgeSec: 0,
|
||||
@ -554,33 +510,11 @@ async function getLogisticsExceptions(options = {}) {
|
||||
}
|
||||
|
||||
// ── 新鲜度 ──────────────────────────────────────────────────────────
|
||||
// 🔥 emptyWindow = 本批窗口里**一行都没取到**(12 仓范围)。
|
||||
// 这时 snapshotAt 必然是空串,v1 的判据(coversDeadline=null、stale=false、hint='')
|
||||
// 会让「上游没同步」和「当天真的没发货」长得一模一样,页面/钉钉都显示成 0 单全绿。
|
||||
// 所以窗口为空时补查一次**表级**最后灌库时刻,把两种情形分开。
|
||||
const emptyWindow = rows.length === 0;
|
||||
const sourceLastUpdate = emptyWindow ? await loadSourceLastUpdate() : '';
|
||||
const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
|
||||
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES;
|
||||
const sourceAgeMinutes = sourceLastUpdate ? diffMinutes(sourceLastUpdate, generatedAt) : null;
|
||||
const sourceStale = sourceAgeMinutes !== null && sourceAgeMinutes > SOURCE_STALE_MINUTES;
|
||||
const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
|
||||
let hint = '';
|
||||
if (emptyWindow) {
|
||||
const lastText = sourceLastUpdate || '';
|
||||
const hours = sourceAgeMinutes === null ? 0 : Math.round((sourceAgeMinutes / 60) * 10) / 10;
|
||||
if (sourceStale) {
|
||||
hint = `${batch.shipDate} 的发货记录在物流镜像表里一条都没有 —— 该表最后更新 ${lastText}(已 ${hours} 小时未同步)。`
|
||||
+ ' 上面的「0 单 / 0 个包裹」是数据没进库,不代表当天没发货。'
|
||||
+ ' 请先到聚水潭核对当天发货量,等上游同步后再刷新本页。';
|
||||
} else if (lastText) {
|
||||
hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录(该表最后更新 ${lastText},数据源本身正常)。`
|
||||
+ ' 若当天确实有发货,说明上游漏同步了这一天,请到聚水潭核对;若当天本就停发,这个 0 才是真的。';
|
||||
} else {
|
||||
hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录,且取不到该表最后更新时间 ——`
|
||||
+ ' 分不清是「当天没发货」还是「数据没同步」,请到聚水潭核对后再看本页。';
|
||||
}
|
||||
} else if (coversDeadline === false) {
|
||||
if (coversDeadline === false) {
|
||||
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
|
||||
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
|
||||
} else if (stale) {
|
||||
@ -627,12 +561,6 @@ async function getLogisticsExceptions(options = {}) {
|
||||
ageMinutes,
|
||||
stale,
|
||||
coversDeadline,
|
||||
// 本批窗口 0 行:0 单含义不明,必须配合下面两个字段读
|
||||
emptyWindow,
|
||||
// 表级最后灌库时刻(只有 emptyWindow 时才探测,否则为空串)
|
||||
sourceLastUpdate,
|
||||
sourceAgeMinutes,
|
||||
sourceStale,
|
||||
hint,
|
||||
},
|
||||
generatedAt,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 「仓储中心发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取
|
||||
* 「仓储中心 24 小时发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 业务口径(2026-10-27):
|
||||
* 「13 点核验 7 点前的超时单,核验完成后通知;18:00 核验的订单,核验完成后也通知。」
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心发货时效(2026-10-20 新增)
|
||||
* 仓储中心 24 小时发货时效(2026-10-20 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 需求(yanhui 2026-10-20):
|
||||
* MKH-BOM 列表左侧加一栏「仓储中心发货时效」,下列 11 个仓:
|
||||
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
|
||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
||||
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
|
||||
@ -32,10 +32,7 @@
|
||||
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
|
||||
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
|
||||
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
|
||||
* 🔴 2026-10-28 现状:候选池取订单两表的 `send_date`,起算点取状态表的
|
||||
* `last_oa_confirm_time`(审核时间),**下面这段「创建时间 = 出库单 created」
|
||||
* 是老口径,仅作历史记录,不要再照着实现**。
|
||||
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)(已废弃)
|
||||
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)
|
||||
* ⚠️ 不是 `order_date`(平台下单时间);
|
||||
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
|
||||
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
|
||||
@ -265,48 +262,6 @@ const OUTBOUND_TABLES = Object.freeze({
|
||||
// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。
|
||||
const ORDER_STATE_TABLE = 'jst_order_express_details';
|
||||
|
||||
// ── 🔥 2026-10-28 数据源切换:主数据源改成「订单 + 订单明细」────────────────
|
||||
// 业务 yanhui 2026-10-28 明确指出:「核验抓取的销售订单表是错误的,应该抓
|
||||
// `jst_Orders`(订单)+ `jst_OrderItem`(订单明细),o_id → o_id;审核时间就是
|
||||
// jst_Orders 里的创建时间」。
|
||||
//
|
||||
// 为什么原来那张状态镜像表不能用(实测证据):
|
||||
// ① `send_time` 会**漏回写**:近 7 天「状态表说发货中+未发」的 4157 单里,
|
||||
// 订单表已有 `send_date` 的 **563 单**、status 已是 Sent 的 492 单 ——
|
||||
// 明明发了却还算超时;2026-10-28 06h/07h 那批大批量刷新后集中爆发。
|
||||
// ② 状态表对**被合并 / 拆单父单**不回写(已有 89 + 755 单),必须回订单表看 status。
|
||||
// ③ 状态表会**整批停更**:2026-10-27 11:54:59 之后 6.8 小时零写入,
|
||||
// 18:00 轮推出去 34 单里混着已发货的(业务当场发现)。
|
||||
// ④ 两张表发货时间**其实同源**:状态表 send_time 与订单表 send_date
|
||||
// 27387 单逐条比对**零偏差** ⟹ 订单表才是权威,状态表只是它的滞后副本。
|
||||
//
|
||||
// ⟹ 主查询直接打订单两表(店铺互斥必须都读),**不再依赖状态表**。
|
||||
// 仓库归属用订单表的 `wms_co_id`(不是状态表的仓名文本),更准。
|
||||
// 状态表只剩「售后已作废」一个判据,实测在「发货中+未发」里只影响 4 单,可放弃。
|
||||
const ORDER_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
||||
|
||||
// 订单表 `status` 的取值(聚水潭实时状态,不是下单快照 —— 这一点
|
||||
// `jst_Orders.status` 与代码历史上「status 不可信」的结论不同,见下面 CREATED 段说明):
|
||||
// Delivering 发货中 —— **唯一该考核仓库的状态**
|
||||
// WaitConfirm 待审核 —— 仓库还没接到单,单列「待审核」不计超时
|
||||
// Question 疑问/异常 —— 单列「异常待处理」不计超时
|
||||
// Sent 已发货 → 剔除
|
||||
// Cancelled 已取消(业务叫「作废」)→ 剔除
|
||||
// Split / Merged 拆单父单 / 被合并单 → 剔除(自己永不发货)
|
||||
// WaitOuterSent 等供销商|外仓发货 → 剔除(不是本仓发)
|
||||
// 近 30 天分布:Sent 132341 / Cancelled 66643 / Split 8251 / Question 2057 /
|
||||
// Merged 1854 / Delivering 1446 / WaitConfirm 190 / WaitOuterSent 45
|
||||
const ORDER_PENDING_STATUS = 'Delivering';
|
||||
const ORDER_UNAUDITED_STATUS = 'WaitConfirm';
|
||||
const ORDER_QUESTION_STATUS = 'Question';
|
||||
const ORDER_SHIPPED_STATUS = 'Sent';
|
||||
// 主查询只捞这三种(其余状态一律不进候选,SQL 侧就过滤掉,不传回 Node)
|
||||
const ORDER_CANDIDATE_STATUSES = Object.freeze([
|
||||
ORDER_PENDING_STATUS,
|
||||
ORDER_UNAUDITED_STATUS,
|
||||
ORDER_QUESTION_STATUS,
|
||||
]);
|
||||
|
||||
// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态**
|
||||
const SHIPPING_STATUS = '发货中';
|
||||
// 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」)
|
||||
@ -321,26 +276,24 @@ const ABNORMAL_STATUS = '异常';
|
||||
// 漏掉这个排除,整个页面会被作废单淹没。
|
||||
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
|
||||
|
||||
// ── 起算点:聚水潭「审核时间」`last_oa_confirm_time` ────────────────────────
|
||||
// 🔥 2026-10-28 **下午二次纠偏**(业务 yanhui 拿 9837824 打回来):
|
||||
// 起算点是**审核时间**,不是订单创建时间。证据:
|
||||
// 9837824 created 10-19 23:44:22 → last_oa_confirm_time 10-28 09:58:42,
|
||||
// 积压 8 天半;仓库 09:58 审核、10:09:55 快递员就上门取件。
|
||||
// 按 created 算「超时 205 小时」= 把客服审核积压算到仓库头上。
|
||||
// 量化(10-28 13:00 轮 599 单):376 单是当天 08:10~10:30 才批量审核的,
|
||||
// 按审核时间分轮后 13:00 轮只剩 247(再剔除漏回写的已发单 → 138)。
|
||||
// ── 起算点:聚水潭「创建时间」──────────────────────────────────────────────
|
||||
// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。
|
||||
//
|
||||
// ⚠️ 上午那版注释里「created 100% 早于 last_oa_confirm_time,正常差 7~40 分钟,
|
||||
// 业务要求按创建时间算」已被推翻 —— 那句话只统计了当期正常单,
|
||||
// 长尾积压单能差 8~11 天。**别再把起算点改回 created。**
|
||||
// ⚠️ 状态表 `jst_order_express_details` **没有**聚水潭创建时间,必须回订单表补:
|
||||
// · `createdAt`(本表那个 datetime2)—— ❌ **不是**订单创建时间。
|
||||
// 实测比付款晚 1.5~3.5 小时(261019-… pay 21:05:16 / createdAt 次日 00:23:51),
|
||||
// 是**同步落库时间**,用了会把起算点整体推后几小时。
|
||||
// · `pay_time` —— ❌ 付款时间,业务明确说「这一单的创建还是按照付款时间来的」,
|
||||
// 要换掉。
|
||||
// · ✅ `jst_Orders.created` / `jst_qmcust_orderlist.created` —— **聚水潭创建时间**,
|
||||
// 稳定比下单/付款晚 15~30 秒(抽样 00:07:23 → 00:07:41),
|
||||
// 这才是聚水潭订单列表里「创建时间」那一列。
|
||||
//
|
||||
// 审核时间只存在于状态镜像表 `jst_order_express_details`(订单两表没有这个字段),
|
||||
// 由 service 层按 `inner_order_id IN (...)` 批量取;取不到时回退 created 并标 `*`。
|
||||
// 用 last 而不是 first:业务认为改单重审后重新进作业流,从最后一次审核计时
|
||||
// (代价是重复审核会冲掉已累计超时,业务已知悉)。
|
||||
// 性能(2026-10-20 实测 9,676 个候选):
|
||||
// 只投影 `o_id, created` 两列、分批 200 → jst_Orders **14.5s** + qmcust **2.9s**。
|
||||
// 🔥 千万别顺手把 pay_amount / labels 一起取出来:同样 IN 400 个只要 **38.7s**。
|
||||
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
||||
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
|
||||
// ⚠️ 2026-10-28 起主链路已不用(created 直接从主查询取),保留给历史脚本/测试引用。
|
||||
const CREATED_BACKFILL_BATCH = 200;
|
||||
|
||||
// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ─────────────────
|
||||
@ -378,64 +331,6 @@ const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']);
|
||||
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
|
||||
const AFTERSALE_VOID_STATUS = '已作废';
|
||||
|
||||
/**
|
||||
* 状态镜像表 `jst_order_express_details.order_status_name` 的**终态**。
|
||||
*
|
||||
* 🔥 2026-10-29 血的教训(业务第三次报「作废单/已发单还在考核里」):
|
||||
* 订单两表**会各自停更**(当天 qmcust 停在 07:25、jst_Orders 停在 12:45),
|
||||
* 停更期间在聚水潭做的「取消 / 发货」动作,订单表里根本看不到 ——
|
||||
* 订单 status 仍是 `Delivering`、`send_date` 仍是空,于是被当成真超时。
|
||||
* 状态镜像表虽然也会停(当天 11:52),但**比订单表新鲜 4 小时**,
|
||||
* 而且 `order_status_name` 是聚水潭订单状态的直接映射。
|
||||
*
|
||||
* 实测(2026-10-29 13:00 轮 159 单):
|
||||
* · `order_status_name='已取消'` 11 单 —— 订单表的 `labels` 里**没有**「发货前售后」
|
||||
* 这个关键词(`presaleVoid` 判据完全失效),状态表的 `order_labels` 才有,且带
|
||||
* 「已打印被取消」。业务就是拿这 11 单说「作废的也在考核之内」。
|
||||
* · `order_status_name='已发货'` 0 单 —— 机制上必须有,否则停更期发的货永远判不出。
|
||||
*
|
||||
* ⚠️ **`aftersale_status_name='已作废'` 不是作废单**!那是「售后单被撤销」,
|
||||
* 订单照常发货、**仍然要考核**(近 2 天 1387 单都是这种)。别把两个混为一谈,
|
||||
* 能判「这单不用发了」的只有 `order_status_name='已取消'`。
|
||||
*/
|
||||
const STATE_ORDER_VOID_STATUS = '已取消';
|
||||
const STATE_ORDER_SHIPPED_STATUS = '已发货';
|
||||
|
||||
/**
|
||||
* 销售出库单 `status` 里**等于单据作废**的值。
|
||||
*
|
||||
* 🔥 2026-10-29 新增(业务点名 9992097 等 9 单:「在聚水潭的销售出库单里找一下这个作废单据」)。
|
||||
*
|
||||
* 聚水潭的作废会落在**出库单**上:`jst_salesOutboundInfo.status` / `jst_qmcust_salesoutbound.status`
|
||||
* 取值只有四种(近 2 天实测):`Confirmed` / `WaitConfirm` / `Delete` / `OuterConfirming`,
|
||||
* 其中 `Delete` 就是单据作废。
|
||||
*
|
||||
* 为什么必须加这一条判据(状态表 `order_status_name='已取消'` 不够):
|
||||
* · 出库单 `Delete` 的近 7 天样本里,**55 单订单表仍是 `Delivering`、36 单是 `Question`**,
|
||||
* 这些单状态镜像表未必抓得到(它一天只抓几次),只能靠出库单判;
|
||||
* · 出库单是仓库侧最贴身的单据,作废动作一定先落在这里。
|
||||
*
|
||||
* ⚠️ **不能只看「有 Delete 就算作废」**:一个订单可能有多张出库单(拆包 / 重开),
|
||||
* 删掉旧的那张、新的还在 `WaitConfirm` —— 这种单照常要发。
|
||||
* ⟹ 判据是「**该订单没有任何非 Delete 的出库单**」,见 service 里 ioAllDeleted 的说明。
|
||||
*
|
||||
* ⚠️ 也**不能**用「没有出库单」当判据:近 3 天未发单里 **82%(1467/1790)本来就没有出库单**
|
||||
* (订单还没推下游),拿它判作废会把绝大多数正常待发单全放跑。
|
||||
*/
|
||||
const IO_VOID_STATUS = 'Delete';
|
||||
|
||||
/**
|
||||
* 状态镜像表 `logistic_status_name` 里**等于已发**的值。
|
||||
*
|
||||
* `拒收` 也算已发 —— 快递拿走过了,客户拒收是后续环节,仓库的发货动作已完成。
|
||||
* ⚠️ **`无物流单` 不算**(39/159 单是这种:单子还没录快递单,可能自提/待定),
|
||||
* `未揽收` 也不算(只是打了单,快递还没来取)。
|
||||
* ⚠️ 别拿 `logistic_time` 当发货时间:它多数是「快递员将上门取件」的预告。
|
||||
*/
|
||||
const STATE_LOGISTIC_SHIPPED_STATUSES = Object.freeze([
|
||||
'已揽收', '运输中', '派送中', '已签收', '拒收',
|
||||
]);
|
||||
|
||||
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||
@ -544,29 +439,12 @@ async function loadCenterWarehouseMap(force = false) {
|
||||
warehouseCache = {
|
||||
at: Date.now(),
|
||||
rows: list,
|
||||
// 全量「wms_co_id → 仓名」映射(**含归不到主仓的仓**)。
|
||||
// 发货时效主查询不再 LEFT JOIN 仓表(实测 JOIN 会让执行计划从 28s 恶化到 61~105s),
|
||||
// 改为在主查询里取回 wms_co_id、在 Node 侧用这份缓存换名字。
|
||||
allNames: rows.map((r) => ({ wmsCoId: Number(r.wms_co_id), name: String(r.name || '') })),
|
||||
unmatched,
|
||||
unmatchedCount: unmatched.length,
|
||||
excluded,
|
||||
};
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 全量「wms_co_id → 仓名」映射(含归不到主仓的仓),供 service 侧做仓名还原 */
|
||||
async function loadWarehouseNameMap() {
|
||||
if (Date.now() - warehouseCache.at >= WAREHOUSE_CACHE_TTL_MS || !warehouseCache.rows.length) {
|
||||
await loadCenterWarehouseMap();
|
||||
}
|
||||
const map = new Map();
|
||||
for (const r of warehouseCache.allNames || []) {
|
||||
if (Number.isFinite(r.wmsCoId)) map.set(r.wmsCoId, r.name);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function clearWarehouseCache() {
|
||||
warehouseCache = { at: 0, rows: [] };
|
||||
}
|
||||
@ -679,7 +557,6 @@ module.exports = {
|
||||
centerWindowEnd,
|
||||
nextRoundDeadline,
|
||||
loadCenterWarehouseMap,
|
||||
loadWarehouseNameMap,
|
||||
clearWarehouseCache,
|
||||
getWarehouseCache: () => warehouseCache,
|
||||
resolveActiveRound,
|
||||
@ -692,13 +569,6 @@ module.exports = {
|
||||
NOT_SPLIT_MERGED_SQL,
|
||||
OUTBOUND_TABLES,
|
||||
ORDER_STATE_TABLE,
|
||||
// 2026-10-28 起主数据源 = 订单两表(状态表退居兜底)
|
||||
ORDER_SOURCE_TABLES,
|
||||
ORDER_PENDING_STATUS,
|
||||
ORDER_UNAUDITED_STATUS,
|
||||
ORDER_QUESTION_STATUS,
|
||||
ORDER_SHIPPED_STATUS,
|
||||
ORDER_CANDIDATE_STATUSES,
|
||||
SHIPPING_STATUS,
|
||||
UNAUDITED_STATUS,
|
||||
ABNORMAL_STATUS,
|
||||
@ -709,10 +579,6 @@ module.exports = {
|
||||
VOID_QUESTION_STATUS,
|
||||
VOID_ORDER_LABEL_KEYWORDS,
|
||||
AFTERSALE_VOID_STATUS,
|
||||
STATE_ORDER_VOID_STATUS,
|
||||
STATE_ORDER_SHIPPED_STATUS,
|
||||
STATE_LOGISTIC_SHIPPED_STATUSES,
|
||||
IO_VOID_STATUS,
|
||||
SHIP_TIMELINESS_HOURS,
|
||||
toNumber,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -154,10 +154,6 @@ function stubDb(rows, options = {}) {
|
||||
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
|
||||
return options.interceptRows || [];
|
||||
}
|
||||
// 窗口 0 行时的「表级最后灌库时刻」探测
|
||||
if (/MAX\(\s*e\.query_time\s*\)/iu.test(String(sql))) {
|
||||
return options.sourceMaxQueryTime === undefined ? [] : [{ max_q: options.sourceMaxQueryTime }];
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
require.cache[dbPath] = {
|
||||
@ -676,42 +672,3 @@ test('售后表查询失败时降级:不剔单、不炸页面', async () => {
|
||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||
}
|
||||
});
|
||||
|
||||
// ── ⑧ 窗口 0 行:0 单不能被当成「当天没发货 / 全部正常」(2026-11-02)──────
|
||||
// 起因:11-02 09:00 那批推出「已发货 0 单 / 0 个包裹,异常 0 个包裹」,
|
||||
// 实况是镜像表 11-01 07:55 之后就没再灌库,11-01 的 12 仓发货记录一条都没进库。
|
||||
test('窗口 0 行 + 数据源停更 → emptyWindow/sourceStale,hint 明说「数据没进库」', async () => {
|
||||
const nov2 = new Date(2026, 10, 2, 10, 0, 0); // 2026-11-02 10:00(本机时区)
|
||||
await withStub(async (svc, calls) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 });
|
||||
assert.equal(data.summary.shippedOrders, 0);
|
||||
assert.equal(data.freshness.emptyWindow, true);
|
||||
assert.equal(data.freshness.sourceLastUpdate, '2026-11-01 07:55:04');
|
||||
assert.ok(data.freshness.sourceAgeMinutes > 120, `应算出停更小时数,实际 ${data.freshness.sourceAgeMinutes}`);
|
||||
assert.equal(data.freshness.sourceStale, true);
|
||||
assert.match(data.freshness.hint, /一条都没有/);
|
||||
assert.match(data.freshness.hint, /不代表当天没发货/);
|
||||
// 只在空窗口才付那次全表 MAX(query_time)(实测 13.5s)
|
||||
assert.equal(calls.sql.filter((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s)).length, 1);
|
||||
}, [], { sourceMaxQueryTime: '2026-11-01 07:55:04' });
|
||||
});
|
||||
|
||||
test('窗口 0 行但数据源是新的 → 仍然提示「0 条记录」,不能静默全绿', async () => {
|
||||
const nov2 = new Date(2026, 10, 2, 10, 0, 0);
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 });
|
||||
assert.equal(data.freshness.emptyWindow, true);
|
||||
assert.equal(data.freshness.sourceStale, false, '表是新的,不该报停更');
|
||||
assert.match(data.freshness.hint, /0 条发货记录/, '真 0 单也要说清是「库里没有记录」');
|
||||
assert.match(data.freshness.hint, /数据源本身正常/);
|
||||
}, [], { sourceMaxQueryTime: '2026-11-02 09:58:00' });
|
||||
});
|
||||
|
||||
test('窗口有行时不做全表探测(省掉 13.5s 的表级 MAX)', async () => {
|
||||
await withStub(async (svc, calls) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||
assert.equal(data.freshness.emptyWindow, false);
|
||||
assert.equal(data.freshness.sourceLastUpdate, '');
|
||||
assert.equal(calls.sql.filter((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s)).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -495,50 +495,3 @@ test('发文本:超长正文自动截断后仍会发出去(不是直接抛
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── ⑤ 0 单不能用「全部正常」糊过去(2026-11-02)──────────────────────────
|
||||
// 实况:11-02 09:00 那批推的是「0 单 / 0 个包裹,异常 0 个包裹」,业务以为真没发货,
|
||||
// 其实是物流镜像表 11-01 07:55 后就没同步。群里绝不能出现「全部正常」这种假绿。
|
||||
test('数据源停更导致 0 单 → 正文首部醒目告警,且不出现「全部正常」', () => {
|
||||
const data = sampleData({
|
||||
rows: [],
|
||||
summary: {
|
||||
shippedOrders: 0, packageCount: 0, exceptionOrders: 0,
|
||||
exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {},
|
||||
},
|
||||
exclusions: {},
|
||||
freshness: {
|
||||
emptyWindow: true,
|
||||
sourceLastUpdate: '2026-11-01 07:55:04',
|
||||
sourceAgeMinutes: 1565,
|
||||
sourceStale: true,
|
||||
hint: '11-01 的发货记录在物流镜像表里一条都没有',
|
||||
},
|
||||
});
|
||||
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||
assert.match(built.text, /物流数据未同步/);
|
||||
assert.match(built.text, /镜像表最后更新 2026-11-01 07:55:04/);
|
||||
assert.match(built.text, /已 26\.1 小时未同步/);
|
||||
assert.match(built.text, /不代表当天没发货/);
|
||||
assert.ok(!built.text.includes('全部正常'), '数据没进库时不能说「全部正常」');
|
||||
// 抬头仍要带「异常」关键词,否则钉钉自定义关键词会拒收
|
||||
assert.match(built.text, /【仓储物流转运异常】/);
|
||||
});
|
||||
|
||||
test('数据源正常但当天 0 条记录 → 说明是「库里没有记录」,不说「全部正常」也不谎报停更', () => {
|
||||
const data = sampleData({
|
||||
rows: [],
|
||||
summary: {
|
||||
shippedOrders: 0, packageCount: 0, exceptionOrders: 0,
|
||||
exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {},
|
||||
},
|
||||
exclusions: {},
|
||||
freshness: { emptyWindow: true, sourceLastUpdate: '2026-11-02 09:58:00', sourceAgeMinutes: 2, sourceStale: false },
|
||||
});
|
||||
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||
assert.match(built.text, /在物流镜像表里 0 条发货记录/);
|
||||
assert.match(built.text, /数据源本身正常/);
|
||||
assert.match(built.text, /请到聚水潭核对/);
|
||||
assert.ok(!built.text.includes('物流数据未同步'), '表是新的,不能谎报停更');
|
||||
assert.ok(!built.text.includes('全部正常'), '0 条不能糊成「全部正常」');
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -48,7 +48,6 @@ function sampleData(overrides = {}) {
|
||||
shopName: '京东旗舰店',
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
centerName: '崔桥工厂仓',
|
||||
logisticCompany: '武进韵达',
|
||||
auditTime: '2026-10-27 06:32:11',
|
||||
overdueMinutes: 388,
|
||||
},
|
||||
@ -58,7 +57,6 @@ function sampleData(overrides = {}) {
|
||||
shopName: '天猫店',
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
centerName: '崔桥工厂仓',
|
||||
logisticCompany: '常州安能新',
|
||||
auditTime: '2026-10-26 09:05:00',
|
||||
overdueMinutes: 2875,
|
||||
},
|
||||
@ -68,8 +66,6 @@ function sampleData(overrides = {}) {
|
||||
shopName: '抖音店',
|
||||
warehouseName: '简尚-横林二仓',
|
||||
centerName: '横林二仓',
|
||||
// 快递名为空 → 明细里要显示「—」,不能出现 undefined
|
||||
logisticCompany: '',
|
||||
auditTime: '',
|
||||
overdueMinutes: 120,
|
||||
},
|
||||
@ -138,33 +134,25 @@ test('按仓块只列超时 > 0 的仓,并按单数降序', () => {
|
||||
assert.doesNotMatch(built.text, /睢宁仓 0 单/);
|
||||
});
|
||||
|
||||
test('明细一行 = 仓|内部单号|线上单号|快递|审核 时间|已等', () => {
|
||||
test('明细一行 = 仓|内部单号|线上单号|审核 时间|已等', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|武进韵达|审核 06:32|已等 6 小时 28 分/);
|
||||
// 无线上单号 → 「—」;无快递名 → 「—」;无创建时间 → 「无创建时间」;都不能出现 undefined
|
||||
assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|—|审核 —|已等 2 小时/);
|
||||
assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|审核 06:32|已等 6 小时 28 分/);
|
||||
// 无线上单号 → 「—」;无审核时间 → 「未审核」;都没有时不能出现 undefined
|
||||
assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|审核 未审核|已等 2 小时/);
|
||||
assert.doesNotMatch(built.text, /undefined|NaN/);
|
||||
});
|
||||
|
||||
test('快递列紧跟线上单号(业务 2026-10-27 要求:群里直接看是哪家快递)', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
const line = built.text.split('\n').find((l) => l.startsWith('2. 简尚-崔桥工厂仓'));
|
||||
assert.ok(line, '明细第 2 行不见了');
|
||||
const parts = line.replace(/^\d+\.\s*/, '').split('|');
|
||||
assert.deepEqual(parts, ['简尚-崔桥工厂仓', '9855200', 'SO20261027001', '常州安能新', '审核 10-26 09:05', '已等 1 天 23 小时']);
|
||||
});
|
||||
|
||||
test('历史未发单的审核时间必须带 MM-DD,否则会被读成「今天 16:13」', () => {
|
||||
// 13:00 轮窗口上界 07:00,但口径「含历史未发」→ 明细里会出现昨天的单
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|常州安能新|审核 10-26 09:05|已等 1 天 23 小时/);
|
||||
assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|审核 10-26 09:05|已等 1 天 23 小时/);
|
||||
// 同一天的就只给 HH:mm,别啰嗦
|
||||
assert.match(built.text, /审核 06:32/);
|
||||
assert.doesNotMatch(built.text, /审核 10-27 06:32/);
|
||||
// 直接测函数本身的跨天判定
|
||||
assert.equal(job.auditWhen('2026-10-27 06:32:11', '2026-10-27'), '06:32');
|
||||
assert.equal(job.auditWhen('2026-10-26 16:13:00', '2026-10-27'), '10-26 16:13');
|
||||
assert.equal(job.auditWhen('', '2026-10-27'), '');
|
||||
assert.equal(job.auditWhen('', '2026-10-27'), '未审核');
|
||||
});
|
||||
|
||||
test('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => {
|
||||
|
||||
@ -4,7 +4,7 @@ import request from './request';
|
||||
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
|
||||
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
// 仓储中心发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
||||
// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
||||
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
|
||||
// params.limit 明细条数上限
|
||||
export const getWarehouseShipTimeliness = (params = {}) => request.get(
|
||||
@ -22,18 +22,6 @@ export const getWarehouseShipTimelinessRounds = () => request.get(
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
// 「超时未发」明细导出:不分页、不截断,返回本轮考核口径内的全部行。
|
||||
// 与 /list 共用后端同一份缓存,所以页面刚刷过时这里是秒回;
|
||||
// 冷启动(缓存已过期)仍要全量扫视图,超时同样放大到 3 分钟。
|
||||
export const getWarehouseShipTimelinessExportRows = (params = {}) => request.get(
|
||||
'/api/warehouse-ship-timeliness/export',
|
||||
{
|
||||
params,
|
||||
timeout: TIMELINESS_TIMEOUT_MS,
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
}
|
||||
);
|
||||
|
||||
// ── 两轮核验完成后推文本到钉钉群(2026-10-27 新增)────────────────────────
|
||||
// 通道 = 群自定义机器人 webhook(不发 .xlsx,只发 text;加签密钥可选)
|
||||
// 读配置:加签密钥只回掩码(webhookSecretConfigured + webhookSecretPreview)
|
||||
|
||||
@ -37,7 +37,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
|
||||
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
|
||||
{ key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 },
|
||||
{
|
||||
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
|
||||
|
||||
@ -252,15 +252,8 @@ function WarehouseLogisticsException() {
|
||||
const [activeCenter, setActiveCenter] = useState('');
|
||||
// 异常类型筛选:为空 = 全部
|
||||
const [activeType, setActiveType] = useState('');
|
||||
// 核验日;默认 null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
|
||||
// 🔥 钉钉消息里的链接带 `?date=YYYY-MM-DD`(见 buildPageLink),页面必须认这个参数 ——
|
||||
// 否则群里点进来看到的是「今天这批」,跟消息里说的不是同一批,历史消息会误导人。
|
||||
const [verifyDate, setVerifyDate] = useState(() => {
|
||||
const raw = String(new URLSearchParams(window.location.search).get('date') || '').trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/u.test(raw)) return null;
|
||||
const parsed = dayjs(raw);
|
||||
return parsed.isValid() ? parsed : null;
|
||||
});
|
||||
// 核验日;null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
|
||||
const [verifyDate, setVerifyDate] = useState(null);
|
||||
// 导出中(导出比列表慢,按钮要转圈,避免重复点)
|
||||
const [exporting, setExporting] = useState(false);
|
||||
// 每天定点把异常文本推到钉钉群(「异常通知」)
|
||||
@ -571,16 +564,14 @@ function WarehouseLogisticsException() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{freshness && (freshness.emptyWindow || freshness.coversDeadline === false || freshness.stale) ? (
|
||||
{freshness && (freshness.coversDeadline === false || freshness.stale) ? (
|
||||
<Alert
|
||||
type={freshness.emptyWindow || freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||
type={freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||
showIcon
|
||||
className="wle-alert"
|
||||
message={freshness.emptyWindow
|
||||
? '本批 0 单:物流镜像表里没有当天的发货记录,这个 0 不可当结论'
|
||||
: (freshness.coversDeadline === false
|
||||
? '物流快照未覆盖本轮核验时刻,异常量偏大'
|
||||
: '物流数据可能滞后')}
|
||||
message={freshness.coversDeadline === false
|
||||
? '物流快照未覆盖本轮核验时刻,异常量偏大'
|
||||
: '物流数据可能滞后'}
|
||||
description={freshness.hint}
|
||||
/>
|
||||
) : null}
|
||||
@ -598,11 +589,7 @@ function WarehouseLogisticsException() {
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
/>
|
||||
<Text type="secondary" className="wle-stat__sub">
|
||||
{notStarted
|
||||
? '—'
|
||||
: (freshness?.emptyWindow
|
||||
? '数据未同步,见上方红条'
|
||||
: `共 ${number(summary.packageCount)} 个包裹`)}
|
||||
共 {notStarted ? '—' : number(summary.packageCount)} 个包裹
|
||||
</Text>
|
||||
</Card>
|
||||
<Card bordered={false} className="wle-stat">
|
||||
|
||||
@ -1,19 +1,17 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Collapse, Dropdown, Empty, Input, InputNumber, Modal, Space, Spin,
|
||||
Statistic, Switch, Table, Tag, TimePicker, Tooltip, Typography, message,
|
||||
Alert, Button, Card, Collapse, Empty, Input, InputNumber, Modal, Space, Spin, Statistic,
|
||||
Switch, Table, Tag, TimePicker, Tooltip, Typography, message,
|
||||
} from 'antd';
|
||||
import { BellOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { BellOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import {
|
||||
getShipTimelinessNotifySettings,
|
||||
getWarehouseShipTimeliness,
|
||||
getWarehouseShipTimelinessExportRows,
|
||||
runShipTimelinessNotify,
|
||||
saveShipTimelinessNotifySettings,
|
||||
} from '../../api/warehouseShipTimeliness';
|
||||
import { exportTableFile } from '../../utils/tableExport';
|
||||
import './style.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
@ -83,7 +81,7 @@ const NOTIFY_HELP_ITEMS = [
|
||||
业务口径:<b>13:00 核验 7 点前审核的超时单</b>、<b>18:00 核验 16 点前审核的单</b>,
|
||||
各自<b>核验完成后</b>推一次,所以是两个独立时刻,互不影响。
|
||||
<br />
|
||||
默认给到 <b>13:05 / 18:05</b>(核验点 + 5 分钟):订单表的 updateTime 要晚几分钟才覆盖到核验时刻,
|
||||
默认给到 <b>13:05 / 18:05</b>(核验点 + 5 分钟):聚水潭状态镜像要晚几分钟才覆盖到核验时刻,
|
||||
整点推容易报「数据没覆盖核验点、超时数偏大」。想改成整点也可以,消息里会带这句提醒。
|
||||
<br />
|
||||
同一轮次同一天只推一次(服务重启不会重复轰炸);要重发点「立即推送」。
|
||||
@ -165,46 +163,19 @@ const HELD_COLUMNS = [
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="聚水潭「审核时间」= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者(状态表会滞后,9978643 旧值比真实审核早一天)。两者都没有才回退订单创建时间,标 *">
|
||||
<span>审核时间</span>
|
||||
</Tooltip>
|
||||
),
|
||||
title: '审核时间',
|
||||
dataIndex: 'auditTime',
|
||||
width: 152,
|
||||
ellipsis: true,
|
||||
render: (v, row) => {
|
||||
if (!v) return <span className="wst-detail__fallback">无审核时间</span>;
|
||||
return row?.benchmarkSource === 'created' ? `${v} *` : v;
|
||||
},
|
||||
render: (v) => (v || <span className="wst-detail__fallback">未审核</span>),
|
||||
},
|
||||
{ title: '下单时间', dataIndex: 'createdAt', width: 152, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '已挂起', dataIndex: 'heldDays', width: 88, render: (v) => (v > 0 ? `${v} 天` : '—') },
|
||||
];
|
||||
|
||||
const DETAIL_COLUMNS = [
|
||||
// 内部单号 = 聚水潭 o_id(系统内部编号,仓管在系统里按它查单)
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
{ title: '内部单号', dataIndex: 'oId', width: 110 },
|
||||
// 线上订单号 = 平台 so_id(京东/抖音/天猫等平台单号,客服按它对接客户)
|
||||
{
|
||||
title: '线上订单号',
|
||||
@ -230,7 +201,7 @@ const DETAIL_COLUMNS = [
|
||||
{
|
||||
// 发货时间 = 聚水潭回传的发货时间(业务 2026-10-20:明细里不要创建时间,要发货时间)
|
||||
title: (
|
||||
<Tooltip title="订单表 jst_Orders.send_date(聚水潭回传的发货时间)。为空但有运单号的,说明仓库已打单、还没揽收/回传发货时间,会显示成「已打单」">
|
||||
<Tooltip title="聚水潭回传的发货时间(send_time)。为空但有运单号的,说明仓库已打单、还没揽收/回传发货时间,会显示成「已打单」">
|
||||
<span>发货时间</span>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -251,111 +222,47 @@ const DETAIL_COLUMNS = [
|
||||
},
|
||||
},
|
||||
{
|
||||
// 审核时间 = 本页的**考核起算点**(业务 2026-10-28 下午三次定版)
|
||||
// = max(状态表 last_oa_confirm_time, 订单表 modified),都没有才回退创建时间并打 `*`
|
||||
// 审核时间 = **考核基准**,取聚水潭「审核时间」列(最后一次审核,重审会刷新)
|
||||
title: (
|
||||
<Tooltip title="聚水潭「审核时间」= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者。状态表会滞后(9978643 状态表是 10-27 17:08,真实审核 10-28 14:52),所以取较晚的那个。今天 7:00 前审核的 13:00 核验;截止 16:00 前审核的(含历史未发单)18:00 核验。带 * 表示两者都没有,回退订单创建时间">
|
||||
<Tooltip title="= 聚水潭订单列表「审核时间」那一列(最后一次审核,重新审核会刷新)。今天 7:00 前审核的 13:00 核验;截止 16:00 前审核的(含今天之前未发的历史单)18:00 核验,已等时长也从它算">
|
||||
<span>审核时间</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'auditTime',
|
||||
width: 152,
|
||||
ellipsis: true,
|
||||
render: (v, row) => {
|
||||
if (!v) return <span className="wst-detail__fallback">—</span>;
|
||||
if (row?.benchmarkSource === 'created') {
|
||||
return (
|
||||
<Tooltip title="状态表审核时间和订单表 modified 都取不到,已回退订单创建时间参与分轮与计时(`*` 为标记)">
|
||||
<span>{v} *</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return v;
|
||||
},
|
||||
},
|
||||
// 下单时间 = 客户在平台下单那一刻(只做参考对账,不参与计时)
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="聚水潭订单创建时间(jst_Orders.created),即客户下单时刻。只做参考,不参与分轮与计时">
|
||||
<span>下单时间</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'createdAt',
|
||||
width: 152,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
render: (v, row) => (v
|
||||
? v
|
||||
: (
|
||||
<Tooltip title="没取到审核时间,已回退创建/付款时间参与分轮与计时">
|
||||
<span className="wst-detail__fallback">{row?.benchmarkTime || '—'} *</span>
|
||||
</Tooltip>
|
||||
)),
|
||||
},
|
||||
// 付款时间 = 客户付款那一刻(并列展示,方便对账)
|
||||
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
|
||||
{
|
||||
// 发货状态:**优先显示聚水潭终态**(状态镜像表),订单表状态只作对照
|
||||
// 🔥 2026-10-29:订单两表会各自停更,停更期间聚水潭里已经「已取消 / 已发货」的单,
|
||||
// 订单表里仍是「发货中」——业务看到的「作废的也在考核里」就是这么来的。
|
||||
// 状态镜像表比订单表新鲜,`order_status_name` 才是聚水潭的真状态。
|
||||
// 发货状态:聚水潭实时状态(发货中 = 已审核未发货,即考核对象)
|
||||
title: (
|
||||
<Tooltip title="优先显示聚水潭订单终态(状态镜像表,订单表停更时只有它靠得住)。⚠ = 与订单表 jst_Orders.status 不一致">
|
||||
<Tooltip title="聚水潭订单实时状态。发货中 = 已审核、仓库还没发货">
|
||||
<span>发货状态</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'stateOrderStatus',
|
||||
width: 128,
|
||||
render: (v, row) => {
|
||||
const lake = v || '';
|
||||
const ord = row?.state || '';
|
||||
const diff = Boolean(lake && ord && lake !== ord);
|
||||
const text = lake || ord || '—';
|
||||
const color = text === '已取消' ? 'red' : (text === '发货中' ? 'orange' : 'default');
|
||||
return (
|
||||
<Tooltip title={`聚水潭:${lake || '—'}|订单表:${ord || '—'}${diff ? '(不一致,订单表没同步到这个状态)' : ''}`}>
|
||||
<Tag bordered={false} color={color}>{text}{diff ? ' ⚠' : ''}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// 物流状态:优先用状态表的真实物流节点(未揽收 / 已揽收 / 运输中 / 已签收…),
|
||||
// 订单表没有物流轨迹,只能靠「有没有运单号」猜「已打单 · 未揽收」
|
||||
title: (
|
||||
<Tooltip title="聚水潭物流节点(状态镜像表)。已揽收及之后 = 货已经被快递拿走">
|
||||
<span>物流状态</span>
|
||||
</Tooltip>
|
||||
dataIndex: 'state',
|
||||
width: 104,
|
||||
render: (v) => (
|
||||
<Tag bordered={false} color={v === '发货中' ? 'orange' : 'default'}>{v || '—'}</Tag>
|
||||
),
|
||||
dataIndex: 'stateLogisticStatus',
|
||||
width: 108,
|
||||
render: (v, row) => {
|
||||
const text = v || row?.logisticStatus || '';
|
||||
if (!text) return '—';
|
||||
const shipped = ['已揽收', '运输中', '派送中', '已签收', '拒收'].includes(v);
|
||||
return <Tag bordered={false} color={shipped ? 'green' : 'default'}>{text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
// 销售出库单状态(2026-10-29 新增)
|
||||
// 🔥 业务点名「这些订单都是作废订单」后挖出来的:聚水潭的作废**落在出库单上**
|
||||
// (`status='Delete'`),订单表的 labels 判据在那批单上 0 命中。
|
||||
// 这一列让业务能直接对照聚水潭「销售出库单」页面,不用再问「系统里为什么没有」。
|
||||
title: (
|
||||
<Tooltip title="聚水潭「销售出库单」的单据状态。已作废=单据被删(不考核);待出库=已生成单据但没出库;空白=还没建出库单(正常,近八成未发单本来就没有)">
|
||||
<span>销售出库单</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'ioStatus',
|
||||
width: 112,
|
||||
render: (v) => {
|
||||
const map = {
|
||||
Delete: ['已作废', 'red'],
|
||||
WaitConfirm: ['待出库', 'blue'],
|
||||
Confirmed: ['已出库', 'green'],
|
||||
OuterConfirming: ['外部确认中', 'default'],
|
||||
};
|
||||
const hit = map[v];
|
||||
if (!hit) return '—';
|
||||
return <Tag bordered={false} color={hit[1]}>{hit[0]}</Tag>;
|
||||
},
|
||||
title: '物流状态',
|
||||
dataIndex: 'logisticStatus',
|
||||
width: 100,
|
||||
render: (v) => (v ? <Tag bordered={false}>{v}</Tag> : '—'),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="从审核时间(仓库接到单的时刻)到本轮核验时刻(13:00 / 18:00)等了多久">
|
||||
<Tooltip title="从审核时间到本轮核验时刻(13:00 / 18:00)等了多久">
|
||||
<span>已等时长</span>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -366,95 +273,17 @@ const DETAIL_COLUMNS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* 导出列(「超时未发」明细)。
|
||||
*
|
||||
* ⚠️ 必须**单独一份**,不能直接复用 DETAIL_COLUMNS:
|
||||
* ① 那里的 title 是 JSX(带 Tooltip),导出时会变成 "[object Object]" 当表头;
|
||||
* ② 页面上没有「快递公司 / 运单号」两列,而业务追单恰恰要这两列
|
||||
* (有运单号 = 已打单未揽收,能一眼看出卡在哪一步)。
|
||||
* 标题一律纯字符串,格式化统一走 exportValue(null 值别导出裸空/裸 0)。
|
||||
*/
|
||||
const EXPORT_COLUMNS = [
|
||||
{ title: '内部单号', dataIndex: 'oId' },
|
||||
{ title: '线上订单号', dataIndex: 'soId' },
|
||||
{ title: '店铺', dataIndex: 'shopName' },
|
||||
{ title: '仓储中心', dataIndex: 'centerName' },
|
||||
{ title: '发货仓', dataIndex: 'warehouseName' },
|
||||
{ title: '快递公司', dataIndex: 'logisticCompany', exportValue: (v) => v || '未指定' },
|
||||
{ title: '运单号', dataIndex: 'logisticId', exportValue: (v) => v || '未打单' },
|
||||
{ title: '物流状态', dataIndex: 'stateLogisticStatus', exportValue: (v, row) => v || row?.logisticStatus || '' },
|
||||
{ title: '发货状态', dataIndex: 'state' },
|
||||
// 聚水潭终态与订单表状态分开导出:两者不一致的单,就是「订单表没同步」的证据
|
||||
{ title: '聚水潭状态', dataIndex: 'stateOrderStatus', exportValue: (v) => v || '—' },
|
||||
// 销售出库单状态:聚水潭的作废落在这一层(Delete=单据已作废),
|
||||
// 业务对账时要拿这一列跟聚水潭「销售出库单」页面对
|
||||
{
|
||||
title: '销售出库单',
|
||||
dataIndex: 'ioStatus',
|
||||
exportValue: (v) => ({
|
||||
Delete: '已作废',
|
||||
WaitConfirm: '待出库',
|
||||
Confirmed: '已出库',
|
||||
OuterConfirming: '外部确认中',
|
||||
}[v] || '未建单'),
|
||||
},
|
||||
{ title: '状态表抓取时间', dataIndex: 'stateQueryTime', exportValue: (v) => v || '—' },
|
||||
// 导出也要带上「这单的来源表 + 该表是否停更」,否则 Excel 脱离页面就没有上下文,
|
||||
// 拿着 540 单「未发」去追仓库,实际人家早就发了。
|
||||
{ title: '数据源', dataIndex: 'srcLabel', exportValue: (v) => v || '—' },
|
||||
{
|
||||
title: '数据源是否停更',
|
||||
dataIndex: 'sourceStale',
|
||||
exportValue: (v) => (v ? '是(该单可能已发)' : '否'),
|
||||
},
|
||||
{ title: '发货时间', dataIndex: 'sendTime', exportValue: (v) => v || '未发货' },
|
||||
{
|
||||
title: '审核时间',
|
||||
dataIndex: 'auditTime',
|
||||
// 起算点 = 审核时间;没取到时回退订单创建时间,导出用 `*` 标出来,否则对账对不上
|
||||
exportValue: (v, row) => {
|
||||
const t = v || row?.benchmarkTime || '';
|
||||
if (!t) return '无审核时间';
|
||||
return row?.benchmarkSource === 'created' ? `${t} *` : t;
|
||||
},
|
||||
},
|
||||
{ title: '下单时间', dataIndex: 'createdAt', exportValue: (v) => v || '—' },
|
||||
{ title: '付款时间', dataIndex: 'payTime' },
|
||||
// 分钟数给一列原始值,业务在 Excel 里能直接排序/筛选;人读的那列再格式化
|
||||
{ title: '已等(分钟)', dataIndex: 'overdueMinutes' },
|
||||
{ title: '已等时长', dataIndex: 'overdueMinutes', exportValue: (v) => formatOverdue(v) },
|
||||
];
|
||||
|
||||
// key = 导出格式(tableExport 只支持这两种)
|
||||
const EXPORT_MENU_ITEMS = [
|
||||
{ key: 'xlsx', label: '导出 Excel(.xlsx)' },
|
||||
{ key: 'csv', label: '导出 CSV(.csv)' },
|
||||
];
|
||||
|
||||
// 文件名里不能出现 : \ / * ? " < > |,轮次用「13点轮」这种写法避开冒号
|
||||
const roundFileText = (roundInfo) => (
|
||||
Number.isFinite(Number(roundInfo?.verifyHour))
|
||||
? `${String(Number(roundInfo.verifyHour)).padStart(2, '0')}点轮`
|
||||
: '本轮'
|
||||
);
|
||||
|
||||
/**
|
||||
* 仓储中心发货时效(独立页面)
|
||||
* 仓储中心 24 小时发货时效(独立页面)
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 业务口径(yanhui 2026-10-20 定轮次;2026-10-28 定起算点):
|
||||
* 业务口径(yanhui 2026-10-20):
|
||||
* · 11 个主仓,子仓并入主仓一起算;
|
||||
* · 数据源 = 订单表 jst_Orders + jst_qmcust_orderlist(两张表店铺互斥,都要读);
|
||||
* · 今天 07:00 前**审核**的订单 13:00 核验,截止今天 16:00 前审核的单 18:00 核验
|
||||
* (含 7 点前审核未发出的、含今天之前审核至今未发的历史单;山东两仓 15:00、河南洛宁 13:00);
|
||||
* 今天之前仍未发的(历史未发)两个轮次都算;
|
||||
* (含 7 点前未发出的、含今天之前审核至今未发的历史单;山东两仓 15:00、河南洛宁 13:00);
|
||||
* 今天之前审核仍未发的(历史未发)两个轮次都算;
|
||||
* **到核验时刻未发即超时**,没有「满 24 小时」门槛(业务纠偏点);
|
||||
* 轮次归属和「已等时长」都按**审核时间**算 ——
|
||||
* 业务 2026-10-28 下午三次定版:审核时间 = max(状态表 last_oa_confirm_time, 订单表 modified)。
|
||||
* 只取状态表会漏 —— 状态表会滞后(9978643 状态表写的是 10-27 17:08,聚水潭真实审核 10-28 14:52,
|
||||
* 整整早一天),导致「今天下午才审核的单」被算进 13:00 轮,冤枉仓库;
|
||||
* 两者都没有才回退订单创建时间,页面上带 `*` 标记;
|
||||
* 轮次归属和「已等时长」都按**审核时间**算(聚水潭「审核时间」列,最后一次审核);
|
||||
* · **13:00 之前不核验**,直接显示「未到核验时间」,不拿昨天的结果冒充今天;
|
||||
* · 已出库 / 待出库以订单表 **send_date** 为准(SQL 直接 `send_date IS NULL` 才进候选);
|
||||
* · 已出库 / 待出库以**聚水潭发货时间**为准(有 send_time = 已发);
|
||||
* · 只显示超时未出库(发完的仓显示「—」),点仓库可筛右侧明细。
|
||||
*
|
||||
* 数据来自 /api/warehouse-ship-timeliness/list(service 层见
|
||||
@ -468,8 +297,6 @@ function WarehouseShipTimeliness() {
|
||||
const [activeCenter, setActiveCenter] = useState('');
|
||||
// 「业务挂起(疑问单)」明细弹窗
|
||||
const [heldOpen, setHeldOpen] = useState(false);
|
||||
// 「导出明细」按钮 loading(全量导出可能要走一次冷启动查询,必须给反馈)
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// ── 钉钉通知(两轮核验完成后各推一次)──────────────────────────────────
|
||||
const [notifyOpen, setNotifyOpen] = useState(false);
|
||||
@ -516,11 +343,6 @@ 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);
|
||||
@ -541,84 +363,10 @@ function WarehouseShipTimeliness() {
|
||||
return hit ? hit.name : activeCenter;
|
||||
}, [activeCenter, centers]);
|
||||
|
||||
// 选中仓时,该仓的超时单数以 centers 里的统计为准(明细可能被截断)
|
||||
const activeCenterRow = useMemo(
|
||||
() => (activeCenter ? centers.find((c) => c.key === activeCenter) || null : null),
|
||||
[activeCenter, centers]
|
||||
);
|
||||
|
||||
const detailOrders = useMemo(() => (
|
||||
activeCenter ? orders.filter((o) => o.centerKey === activeCenter) : orders
|
||||
), [activeCenter, orders]);
|
||||
|
||||
// 明细真实总数(后端截断前):页面的「共 N 单」必须用它,
|
||||
// 用 detailOrders.length 会把「已截断只显示前 1000 条」写成「一共就这么点」
|
||||
const detailTotal = Number.isFinite(Number(data?.detailTotal))
|
||||
? Number(data.detailTotal)
|
||||
: detailOrders.length;
|
||||
|
||||
const heldTotal = Number.isFinite(Number(data?.heldTotal))
|
||||
? Number(data.heldTotal)
|
||||
: heldOrders.length;
|
||||
|
||||
// 不考核快递清单跟随后端(加/减快递只改服务层一处,别在前端写死)
|
||||
const excludedCarrierText = (
|
||||
Array.isArray(data?.excludedCarriers) && data.excludedCarriers.length
|
||||
? data.excludedCarriers
|
||||
: ['自提', '中铁', '壹米滴答', '武进安能']
|
||||
).join(' / ');
|
||||
|
||||
// ── 导出「超时未发」明细 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 导出口径必须和页面明细一致,所以:
|
||||
* · 数据源走 `/export`(后端同一个 getShipTimeliness,只是不按 limit 截断);
|
||||
* · 选中仓时在前端按 centerKey 过滤,和后端算超时用的主仓归属是同一套。
|
||||
* 🔥 不能直接导出页面上的 `orders` —— 它被 limit=1000 截断过,导出会少行,
|
||||
* 而业务拿它去考核,少行比报错更危险。
|
||||
*/
|
||||
const handleExport = async (format) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const result = await getWarehouseShipTimelinessExportRows({
|
||||
round: round?.key || undefined,
|
||||
});
|
||||
if (!result || result.success === false) {
|
||||
message.error(result?.error || '导出失败');
|
||||
return;
|
||||
}
|
||||
const payload = result.data || {};
|
||||
if (payload.notStarted) {
|
||||
message.warning('本轮还没到核验时间,没有可导出的数据');
|
||||
return;
|
||||
}
|
||||
const allRows = Array.isArray(payload.rows) ? payload.rows : [];
|
||||
if (!allRows.length) {
|
||||
message.warning('本轮没有超时未发订单,无需导出');
|
||||
return;
|
||||
}
|
||||
const rows = activeCenter
|
||||
? allRows.filter((row) => row.centerKey === activeCenter)
|
||||
: allRows;
|
||||
if (!rows.length) {
|
||||
message.warning(`${activeCenterName || '该仓'}本轮没有超时未发订单`);
|
||||
return;
|
||||
}
|
||||
const fileName = [
|
||||
'仓储中心发货时效_超时未发',
|
||||
activeCenter ? activeCenterName : '全部仓库',
|
||||
roundFileText(payload.round || round),
|
||||
payload.date || data?.date || dayjs().format('YYYY-MM-DD'),
|
||||
].filter(Boolean).join('_');
|
||||
await exportTableFile(format, fileName, EXPORT_COLUMNS, rows, '超时未发');
|
||||
message.success(`已导出 ${rows.length} 单(${String(format).toUpperCase()})`);
|
||||
} catch (exportError) {
|
||||
message.error(exportError?.response?.data?.error || exportError?.message || '导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 钉钉通知:配置读写 / 预览 / 立即推送 ─────────────────────────────────
|
||||
|
||||
// 后端只回加签密钥掩码,所以表单里的 webhookSecret 永远是空的 —— 留空 = 沿用已存的
|
||||
@ -748,11 +496,9 @@ function WarehouseShipTimeliness() {
|
||||
return (
|
||||
<SycmPage className="wst-page">
|
||||
<header className="wst-header">
|
||||
<Title level={3}>仓储中心发货时效(到点未发即超时)</Title>
|
||||
<Title level={3}>仓储中心 24 小时发货时效</Title>
|
||||
<Text type="secondary">
|
||||
按聚水潭订单表实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。
|
||||
{/* 2026-10-28 业务定:名字里不再出现「24 小时」—— 规则本身没有 24h 门槛,
|
||||
原名会让 10-27 16:00 创建(只等了 21 小时)的单看着像「没到时限也被判超时」。*/}
|
||||
按聚水潭订单实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。
|
||||
</Text>
|
||||
<Text type="secondary" className="wst-note">
|
||||
每天 <b>13:00</b> 核验「<b>今天 07:00 前审核</b>」、<b>18:00</b> 核验「<b>截止 16:00 前审核</b>」、
|
||||
@ -762,17 +508,15 @@ function WarehouseShipTimeliness() {
|
||||
<b>到点还没发就算超时 —— 不设「满 24 小时」门槛</b>(早上 7 点前那份单,13:00 还没走就是超时)。
|
||||
<b>仓级例外</b>:18:00 轮里 <b>山东青岗集仓 / 山东品牌仓</b> 只看 <b>15:00 前审核</b>的单、
|
||||
<b>河南洛宁仓</b> 只看 <b>13:00 前审核</b>的单(核验时刻仍是 18:00)。
|
||||
<b>轮次与「已等时长」都按审核时间算</b>(= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者:
|
||||
审核才是仓库接到单的时刻,按下单时间算会把客服审核积压算到仓库头上;
|
||||
状态表的审核时间会滞后,所以要和订单 modified 取较晚的那个);
|
||||
明细并列「审核时间 / 下单时间 / 发货时间」,方便直接和聚水潭对账。
|
||||
<b>已发 / 未发以订单表 send_date 为准</b>(有值即已发,SQL 侧直接过滤不进候选);
|
||||
订单表 send_date 没回写、但状态表 send_time 有值的,也按已发剔除(不漏冤仓库)。
|
||||
<b>轮次与「已等时长」都按审核时间算</b>(= 聚水潭「审核时间」列,最后一次审核);
|
||||
明细并列「审核时间 / 发货时间」,方便直接和聚水潭对账。
|
||||
<b>已发 / 未发以聚水潭发货时间为准</b>:有发货时间就是已发,不再用订单表的发货字段反推
|
||||
—— 该字段经常不回写,会把早就发货的单误判成超时。
|
||||
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
|
||||
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
|
||||
<b>{excludedCarrierText} 不考核</b>(客户自提、专线大件或加盟快运网点,揽收与在途仓库控制不了),
|
||||
<b>自提 / 中铁 / 壹米滴答 三个快递不考核</b>(客户自提或专线大件,仓库控制不了运输),
|
||||
已按物流公司自动剔除。
|
||||
<b>「待审核」和「异常」的单不计考核</b>(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细;
|
||||
<b>「已支付待审核」和「异常」的单不计考核</b>(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细;
|
||||
<b>作废单(已取消,含发货前售后)不考核也不展示</b> —— 客户付款后取消的单仓库本就不该发。
|
||||
</Text>
|
||||
</header>
|
||||
@ -824,44 +568,14 @@ function WarehouseShipTimeliness() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{(freshness && (freshness.stale || freshness.coversDeadline === false))
|
||||
|| staleUnshipped > 0
|
||||
|| staleSources.length > 0 ? (
|
||||
{freshness && (freshness.stale || freshness.coversDeadline === false) ? (
|
||||
<Alert
|
||||
// 快照没覆盖到核验时刻 / 有单来自停更的表 → 数字必然偏大,用红色;只是滞后用黄色
|
||||
type={staleUnshipped > 0 || freshness?.coversDeadline === false ? 'error' : 'warning'}
|
||||
// 快照没覆盖到核验时刻 → 数字必然偏大,用红色;只是滞后用黄色
|
||||
type={freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||
showIcon
|
||||
className="wst-alert"
|
||||
message={
|
||||
staleUnshipped > 0
|
||||
? `${number(staleUnshipped)} 单所在订单表已停止同步,这些单很可能已经发货`
|
||||
: (freshness?.voidJudgeReliable === false
|
||||
// 🔥 2026-10-29:出库单/状态表停更 ⟹ 作废判据失效。
|
||||
// 业务当天报「洛宁仓 16 单都是作废单」,但我们三个来源全停在作废动作之前,
|
||||
// 判据再准也判不出来 —— 必须明说,别让业务以为是系统认定「没作废」。
|
||||
? '出库单数据源已停止同步,作废单可能判不出来'
|
||||
: (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}
|
||||
</>
|
||||
)}
|
||||
message={freshness.coversDeadline === false ? '数据未覆盖本轮核验时刻,超时单量偏大' : '发货数据可能滞后'}
|
||||
description={freshness.hint}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -878,13 +592,6 @@ 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}
|
||||
@ -893,7 +600,7 @@ function WarehouseShipTimeliness() {
|
||||
>
|
||||
<Statistic
|
||||
title={(
|
||||
<Tooltip title="待审核(订单状态 WaitConfirm,还没审单,仓库没接到)和订单异常(Question)的单。这类单不进仓库考核,点开看明细。">
|
||||
<Tooltip title="已支付待审核(还没审单,仓库没接到)和订单异常的单。这类单不进仓库考核,点开看明细。">
|
||||
<span>不计考核 <span className="wst-stat__more">明细</span></span>
|
||||
</Tooltip>
|
||||
)}
|
||||
@ -985,33 +692,16 @@ function WarehouseShipTimeliness() {
|
||||
<span>
|
||||
超时未发货订单
|
||||
<span className="wst-detail__scope">
|
||||
{activeCenterName || '全部仓库'} · {activeCenter
|
||||
? (activeCenterRow?.overdueUnshipped ?? detailOrders.length)
|
||||
: detailTotal} 单
|
||||
{!activeCenter && data?.truncated
|
||||
? `(页面只显示前 ${data.detailLimit} 条,导出可拿全部 ${detailTotal} 条)`
|
||||
{activeCenterName || '全部仓库'} · {detailOrders.length} 单
|
||||
{data?.truncated && detailOrders.length >= (data?.detailLimit || 0)
|
||||
? `(已截断,仅显示前 ${data.detailLimit} 条)`
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
extra={(
|
||||
<Space size={8}>
|
||||
{activeCenter ? (
|
||||
<button type="button" className="wst-detail__clear" onClick={() => setActiveCenter('')}>
|
||||
查看全部
|
||||
</button>
|
||||
) : null}
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
disabled={exporting || notStarted}
|
||||
menu={{ items: EXPORT_MENU_ITEMS, onClick: ({ key }) => handleExport(key) }}
|
||||
>
|
||||
<Button size="small" icon={<DownloadOutlined />} loading={exporting}>
|
||||
导出明细
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
)}
|
||||
extra={activeCenter ? (
|
||||
<button type="button" className="wst-detail__clear" onClick={() => setActiveCenter('')}>查看全部</button>
|
||||
) : null}
|
||||
>
|
||||
<Spin spinning={loading && !data}>
|
||||
{notStarted ? (
|
||||
@ -1036,7 +726,7 @@ function WarehouseShipTimeliness() {
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={`不计考核(待审核 / 异常)· ${heldTotal} 单`}
|
||||
title={`不计考核(待审核 / 异常)· ${heldOrders.length} 单`}
|
||||
open={heldOpen}
|
||||
onCancel={() => setHeldOpen(false)}
|
||||
footer={null}
|
||||
@ -1047,7 +737,7 @@ function WarehouseShipTimeliness() {
|
||||
type="info"
|
||||
showIcon
|
||||
className="wst-alert"
|
||||
message="「待审核」= 订单状态还没审单(WaitConfirm),仓库根本没接到;「订单异常」= 聚水潭标记 Question(缺货/地址/风控等)。两类都不该算仓库超时,故单列于此。"
|
||||
message="「已支付待审核」= 还没审单,仓库根本没接到;「订单异常」= 聚水潭标记异常(缺货/地址/风控等)。两类都不该算仓库超时,故单列于此。"
|
||||
/>
|
||||
{heldOrders.length ? (
|
||||
<Table
|
||||
@ -1241,25 +931,16 @@ function WarehouseShipTimeliness() {
|
||||
</Modal>
|
||||
|
||||
<Text type="secondary" className="wst-foot">
|
||||
说明:数据源 = 聚水潭<b>订单表 jst_Orders + jst_qmcust_orderlist</b>(两张表店铺互斥,按 o_id 去重)。
|
||||
<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
|
||||
说明:<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
|
||||
(含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。
|
||||
<b>到点仍未发货即超时</b>(不设「满 24 小时」门槛);
|
||||
轮次与「已等时长」都按<b>审核时间</b>算(= 状态表 last_oa_confirm_time 与订单表 modified 的较晚者,
|
||||
审核才是仓库接到单的时刻;状态表会滞后,故取较晚者)。两者都取不到才回退订单创建时间,列上带 <b>*</b>。
|
||||
发货 = 订单表 send_date(为空才算未发)。<b>已发货不考核</b>:状态表 send_time 有值、
|
||||
聚水潭状态「已发货」、或物流已揽收及之后,任一成立即判已发
|
||||
{summary.stateShippedSkipped > 0 ? ` —— 本轮已剔除 ${summary.stateShippedSkipped} 单` : ''}。
|
||||
状态只取「发货中」。
|
||||
<b>作废单不考核</b>:聚水潭状态「已取消」,或订单表 labels 含「发货前售后」
|
||||
{summary.voidCount > 0
|
||||
? ` —— 本轮已自动剔除 ${summary.voidCount} 单`
|
||||
+ `${summary.stateVoidSkipped > 0 ? `(其中 ${summary.stateVoidSkipped} 单靠聚水潭状态判出,订单表当时没同步)` : ''}`
|
||||
: ''};
|
||||
⚠️ 售后状态「已作废」<b>不算</b>作废单 —— 那是售后单被撤销、订单照常发货,仍要考核。
|
||||
<b>{excludedCarrierText}不考核</b>
|
||||
轮次与「已等时长」都按审核时间(聚水潭显示值)算。
|
||||
发货 = 聚水潭回传的发货时间。
|
||||
<b>作废单不考核</b>(被合并 / 拆单父单 / 已取消,或订单表已有发货时间)
|
||||
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
|
||||
<b>自提 / 中铁 / 壹米滴答不考核</b>
|
||||
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
|
||||
待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
||||
已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
||||
</Text>
|
||||
</SycmPage>
|
||||
);
|
||||
|
||||
@ -111,45 +111,6 @@
|
||||
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