Merge pull request 'feat(wst): 超时未发加「导出明细」+ 武进安能不考核 + 钉钉明细带快递;修复推送时区' (#87) from yanhui-wst-export-20261027 into main
This commit is contained in:
commit
913827000a
@ -11,6 +11,10 @@
|
||||
* 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 保存配置(加签密钥留空 = 不变)
|
||||
@ -21,7 +25,10 @@
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { getShipTimeliness } = require('../services/warehouseShipTimelinessService');
|
||||
const {
|
||||
getShipTimeliness,
|
||||
listShipTimelinessExportRows,
|
||||
} = require('../services/warehouseShipTimelinessService');
|
||||
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
|
||||
const {
|
||||
getShipTimelinessNotifyAdminConfig,
|
||||
@ -73,6 +80,23 @@ 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, '导出超时未发明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
// ── 两轮核验完成后推钉钉群 ──────────────────────────────────────────────────
|
||||
|
||||
// 读配置:加签密钥只回掩码 + 是否已配置,明文永不出后端
|
||||
|
||||
@ -16,7 +16,8 @@
|
||||
* 消息内容:
|
||||
* 抬头(含「超时」字样,钉钉关键词安全设置要用)+ 汇总(超时单数 / 涉及仓数 / 最久已等)
|
||||
* + 不计考核(待审核·订单异常,不算仓库责任)+ 按仓超时单数 + 前 N 条明细 + 页面链接。
|
||||
* 明细一行 = 仓|内部单号|线上单号|审核 HH:mm|已等 X 小时(业务对账用)。
|
||||
* 明细一行 = 仓|内部单号|线上单号|快递|审核 HH:mm|已等 X 小时(业务对账用)。
|
||||
* (「快递」是业务 2026-10-27 要求加的,见 buildDetailLine 的说明)
|
||||
* 0 条也推 —— 群里看到「本轮 0 单超时」就知道任务在跑。
|
||||
*
|
||||
* 去重:状态存 system_config,按 `<北京日期>|<轮次键>` 去重(一天两轮互不影响)。
|
||||
@ -26,8 +27,12 @@
|
||||
*/
|
||||
|
||||
const cron = require('node-cron');
|
||||
const { getShipTimeliness, DEFAULT_DETAIL_LIMIT } = require('../services/warehouseShipTimelinessService');
|
||||
const { ROUNDS, describeRounds } = require('../services/warehouseShipTimelinessRegistry');
|
||||
const {
|
||||
getShipTimeliness,
|
||||
DEFAULT_DETAIL_LIMIT,
|
||||
EXCLUDED_CARRIERS,
|
||||
} = require('../services/warehouseShipTimelinessService');
|
||||
const { ROUNDS, beijingNow, describeRounds } = require('../services/warehouseShipTimelinessRegistry');
|
||||
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
|
||||
const {
|
||||
DEFAULT_MAX_DETAIL_ROWS,
|
||||
@ -132,9 +137,12 @@ function auditWhen(auditTime, roundDate) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细一行:仓|内部单号|线上单号|审核 时间|已等 X 小时
|
||||
* 明细一行:仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
|
||||
* ⚠️ 第二段是**内部单号**(聚水潭 o_id,仓管在系统里按它查单),第三段是线上单号(客服对客户)。
|
||||
* 两者业务都要,别只留一个。
|
||||
* 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断
|
||||
* 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同),
|
||||
* 不用再回页面一单单点开确认。
|
||||
* ⚠️ 审核时间跨天要带日期,见 auditWhen 的说明。
|
||||
*/
|
||||
function buildDetailLine(row, index, roundDate) {
|
||||
@ -142,6 +150,7 @@ function buildDetailLine(row, index, roundDate) {
|
||||
cleanCell(row?.warehouseName || row?.centerName, '未知仓'),
|
||||
cleanCell(row?.oId, '无单号'),
|
||||
cleanCell(row?.soId, '—'),
|
||||
cleanCell(row?.logisticCompany, '—'),
|
||||
`审核 ${auditWhen(row?.auditTime, roundDate)}`,
|
||||
`已等 ${formatOverdue(row?.overdueMinutes)}`,
|
||||
];
|
||||
@ -270,7 +279,11 @@ function buildNotifyText(data = {}, options = {}) {
|
||||
}
|
||||
const carrierSkipped = Number(summary.carrierSkipped) || 0;
|
||||
if (carrierSkipped > 0) {
|
||||
head.push(`已剔除 ${carrierSkipped} 单不考核快递(自提 / 中铁 / 壹米滴答)`);
|
||||
// 快递清单由服务层给(`excludedCarriers`),别在这里写死 ——
|
||||
// 2026-10-27 加「武进安能」时就吃过一次:写死的文案忘了改,群里口径和页面不一致。
|
||||
const carriers = (Array.isArray(data.excludedCarriers) ? data.excludedCarriers : EXCLUDED_CARRIERS)
|
||||
.filter(Boolean);
|
||||
head.push(`已剔除 ${carrierSkipped} 单不考核快递${carriers.length ? `(${carriers.join(' / ')})` : ''}`);
|
||||
}
|
||||
// 数据没覆盖本轮核验时刻 → 数字必然偏大,必须写在消息里,不能让仓管拿错数去追单
|
||||
if (freshness.coversDeadline === false) {
|
||||
@ -390,8 +403,15 @@ function sendTimeForRound(config, roundKey) {
|
||||
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
|
||||
*/
|
||||
async function pushShipTimelinessNotify(options = {}) {
|
||||
const now = options.now === undefined ? new Date() : new Date(options.now);
|
||||
const pushDate = chinaDateKey(now);
|
||||
// 🔥 两个「现在」不能混用(生产容器 /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 config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
||||
|
||||
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
|
||||
@ -411,7 +431,7 @@ async function pushShipTimelinessNotify(options = {}) {
|
||||
const explicitRound = String(options.round || '').toUpperCase();
|
||||
let roundKey = explicitRound;
|
||||
if (!roundKey) {
|
||||
const minutes = minutesOfDay(now);
|
||||
const minutes = minutesOfDay(rawNow);
|
||||
let active = null;
|
||||
for (const round of ROUNDS) {
|
||||
if (minutes >= round.verifyHour * 60) active = round;
|
||||
@ -420,12 +440,12 @@ async function pushShipTimelinessNotify(options = {}) {
|
||||
roundKey = active.key;
|
||||
}
|
||||
|
||||
const meta = resolveRoundMeta(roundKey, now);
|
||||
const meta = resolveRoundMeta(roundKey, bjNow);
|
||||
if (!meta) return { sent: false, reason: 'unknown_round', round: roundKey };
|
||||
|
||||
// 还没到本轮核验时刻就推 = 推的是半成品,直接跳过(等下一轮 tick)
|
||||
const deadlineMinutes = parseSendTime(String(meta.deadline || '').slice(11, 16));
|
||||
if (deadlineMinutes != null && minutesOfDay(now) < deadlineMinutes) {
|
||||
if (deadlineMinutes != null && minutesOfDay(rawNow) < deadlineMinutes) {
|
||||
return { sent: false, reason: 'before_deadline', round: roundKey, deadline: meta.deadline };
|
||||
}
|
||||
|
||||
@ -456,7 +476,7 @@ async function pushShipTimelinessNotify(options = {}) {
|
||||
const data = await getShipTimeliness({
|
||||
round: roundKey,
|
||||
limit: DEFAULT_DETAIL_LIMIT,
|
||||
now,
|
||||
now: bjNow,
|
||||
});
|
||||
if (data?.notStarted) {
|
||||
return { sent: false, reason: 'not_started', round: roundKey };
|
||||
@ -495,7 +515,7 @@ async function pushShipTimelinessNotify(options = {}) {
|
||||
const nextSent = {
|
||||
...sentMap,
|
||||
[slot]: {
|
||||
sentAt: new Date(now).toISOString(),
|
||||
sentAt: rawNow.toISOString(),
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
@ -507,7 +527,7 @@ async function pushShipTimelinessNotify(options = {}) {
|
||||
await saveShipTimelinessNotifyState({
|
||||
version: 1,
|
||||
sent: trimSentMap(nextSent),
|
||||
lastSentAt: new Date(now).toISOString(),
|
||||
lastSentAt: rawNow.toISOString(),
|
||||
lastStatus: 'sent',
|
||||
lastError: '',
|
||||
lastRound: roundKey,
|
||||
|
||||
@ -26,9 +26,11 @@
|
||||
* (它们早就该发了,不该等今天的分段窗口)。
|
||||
* ③ **没有「满 24 小时」门槛**:到核验时刻仍未发就是超时,哪怕只等了 2 小时。
|
||||
* 第七版加了 24h 门槛,13:00 轮永远看不到当天那批,被业务打回。
|
||||
* ④ **自提 / 中铁 / 壹米滴答 三个快递不考核**(业务 2026-10-20 补充):
|
||||
* ④ **自提 / 中铁 / 壹米滴答 / 武进安能 四个快递不考核**(业务 2026-10-20 补充,
|
||||
* 武进安能 2026-10-27 追加):
|
||||
* 按 `logistic_company_name` 包含匹配(库里是「中铁物流」「壹米滴答专线」),
|
||||
* 命中直接跳过,不进出口径任何一处数字,剔除量记在 `summary.carrierSkipped`。
|
||||
* ⚠️ 武进安能只剔这一个网点,别泛化成「安能」(常州安能新等仍考核)。
|
||||
*
|
||||
* 已等时长 = **审核时间 → 本轮核验时刻**(13:00 / 18:00)。
|
||||
* 🔥 审核时间取 last_oa_confirm_time(**最后一次**审核)——
|
||||
@ -149,14 +151,22 @@ const MAX_DETAIL_LIMIT = 2000;
|
||||
const STATE_STALE_MINUTES = 90;
|
||||
|
||||
/**
|
||||
* 不参与发货时效考核的快递(业务 2026-10-20 指定):
|
||||
* 不参与发货时效考核的快递(业务 2026-10-20 / 2026-10-27 指定):
|
||||
* 自提 —— 客户自己来拉,仓库不负责派送
|
||||
* 中铁 —— 中铁物流(大件专线)
|
||||
* 壹米滴答 —— 专线物流(大件)
|
||||
* 武进安能 —— 加盟制快运网点,揽收与在途都不受仓库控制(业务 2026-10-27 追加)
|
||||
* 命中即可,用**物流公司名**包含匹配;库里的实际值是「中铁物流」「壹米滴答专线」这种。
|
||||
* 只影响「超时未发」判定,不影响「已发」判定。
|
||||
*
|
||||
* 🔥 「武进安能」**必须精确**,别泛化成「安能」(与 warehouseLogisticsExceptionRegistry
|
||||
* 同一口径,那里也写着这句)。实测近 30 天名字里带「安能」的有 8 个:
|
||||
* 常州安能新 4207 / 武进安能 808 / 安能物流 110 / 名屋居安能快运 22 /
|
||||
* 华韵安能 22 / 武进安能大件 15 / 盛强安能快运 5 / 雅柏安能 3
|
||||
* 业务 2026-10-27 只要求剔除「武进安能」(含「武进安能大件」,因为包含匹配会一起命中),
|
||||
* 其余安能网点(尤其「常州安能新」单量最大)**照旧考核**。
|
||||
*/
|
||||
const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答'];
|
||||
const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答', '武进安能'];
|
||||
|
||||
/**
|
||||
* 订单表(= 聚水潭「销售订单」镜像)里「已发货」的状态值。
|
||||
@ -185,6 +195,34 @@ function clearShipTimelinessCache() {
|
||||
resultCache = { key: '', at: 0, data: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细「出口截断」:缓存里存的是**全量**行,这里才按本次请求的 limit 切。
|
||||
*
|
||||
* 🔥 为什么缓存不按 limit 分键(2026-10-27 改):
|
||||
* 旧写法把 limit 拼进缓存键 —— 页面用 limit=1000、导出要全量 2000,
|
||||
* 两边永远互不命中,每点一次「导出」都要重查一遍视图(冷启动 40~60 秒)。
|
||||
* 但行数根本不影响 SQL 成本(`loadPendingStates` 一次就取回全量候选),
|
||||
* 只有最后那一次 slice 受影响。所以:**缓存存全量、出口截断**。
|
||||
* (WLE 的核验缓存早就这么改了,同一个教训。)
|
||||
*
|
||||
* `detailTotal` / `heldTotal` 是截断前的真实总数 —— 页面的「共 N 单」必须用它,
|
||||
* 不能用 `orders.length`(截断后就是 limit,等于把「已截断」写成「一共就这么多」)。
|
||||
*/
|
||||
function withDetailLimit(payload, limit) {
|
||||
const allOrders = Array.isArray(payload?.orders) ? payload.orders : [];
|
||||
const allHeld = Array.isArray(payload?.heldOrders) ? payload.heldOrders : [];
|
||||
const cap = Number.isFinite(limit) ? Math.max(0, limit) : allOrders.length;
|
||||
return {
|
||||
...payload,
|
||||
orders: allOrders.slice(0, cap),
|
||||
heldOrders: allHeld.slice(0, cap),
|
||||
truncated: allOrders.length > cap,
|
||||
detailLimit: Math.min(cap, allOrders.length),
|
||||
detailTotal: allOrders.length,
|
||||
heldTotal: allHeld.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** 把 datetime / Date / 字符串统一成 'YYYY-MM-DD HH:mm:ss'(东八区,不做时区换算) */
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '';
|
||||
@ -375,7 +413,11 @@ async function getShipTimeliness(options = {}) {
|
||||
// 🔥 必须按北京时间取「现在」:生产容器是 UTC,而库里时间是北京时间,
|
||||
// 裸 new Date() 会让北京时间 00:00~08:00 取到前一天(2026-10-21 实测)
|
||||
const now = options.now instanceof Date ? options.now : registry.beijingNow();
|
||||
const limit = Math.min(
|
||||
// 导出(fullDetail)要**全部**行,不受页面展示上限约束;
|
||||
// 页面 / 钉钉推送走普通 limit(钉钉只列前 N 条,页面截断提示)
|
||||
const fullDetail = options.fullDetail === true
|
||||
|| ['1', 'true', 'yes'].includes(String(options.fullDetail || '').toLowerCase());
|
||||
const limit = fullDetail ? Infinity : Math.min(
|
||||
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
|
||||
MAX_DETAIL_LIMIT
|
||||
);
|
||||
@ -423,7 +465,9 @@ async function getShipTimeliness(options = {}) {
|
||||
orders: [],
|
||||
heldOrders: [],
|
||||
truncated: false,
|
||||
detailLimit: limit,
|
||||
detailLimit: Number.isFinite(limit) ? limit : 0,
|
||||
detailTotal: 0,
|
||||
heldTotal: 0,
|
||||
detailDegraded: false,
|
||||
freshness: null,
|
||||
note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`,
|
||||
@ -431,14 +475,16 @@ async function getShipTimeliness(options = {}) {
|
||||
}
|
||||
|
||||
// ③ 取「未发货」全量(发货中 / 待审核 / 异常),Node 侧判主仓与超时
|
||||
// 先看缓存:同一轮次 + 同一天 + 同一 limit,5 分钟内直接复用
|
||||
// 先看缓存:同一轮次 + 同一天,5 分钟内直接复用(存全量,出口再按 limit 切)
|
||||
const force = options.force === true
|
||||
|| ['1', 'true', 'yes'].includes(String(options.force || '').toLowerCase());
|
||||
const cacheKey = `${round.key}|${round.date}|${limit}`;
|
||||
// 🔥 缓存键只含「轮次 + 日期」,**不含 limit**(原因见 withDetailLimit 的注释):
|
||||
// 页面、钉钉推送、导出三方共用同一份缓存,导出的不会再重查一遍视图。
|
||||
const cacheKey = `${round.key}|${round.date}`;
|
||||
if (!force && resultCache.data && resultCache.key === cacheKey
|
||||
&& Date.now() - resultCache.at < CACHE_TTL_MS) {
|
||||
return {
|
||||
...resultCache.data,
|
||||
...withDetailLimit(resultCache.data, limit),
|
||||
cached: true,
|
||||
cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000),
|
||||
};
|
||||
@ -460,7 +506,7 @@ async function getShipTimeliness(options = {}) {
|
||||
const orderFacts = await backfillOrderFacts(oids);
|
||||
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
|
||||
let voidCount = 0;
|
||||
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答),写进 summary 便于对账
|
||||
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答 / 武进安能),写进 summary 便于对账
|
||||
let carrierSkipped = 0;
|
||||
|
||||
// ④ 分桶
|
||||
@ -499,10 +545,12 @@ async function getShipTimeliness(options = {}) {
|
||||
// SQL 里已经用状态白名单排除,这里再兜一次,防止以后有人改白名单漏掉
|
||||
if (IGNORED_STATES.includes(state)) continue;
|
||||
|
||||
// 🔥 不考核的快递(业务 2026-10-20):自提 / 中铁 / 壹米滴答 ——
|
||||
// 走这三种运输方式的单不按仓储发货时效考核(自提客户自己来拉、
|
||||
// 中铁和壹米滴答是专线/大件物流,揽收与在途都不受仓库控制)。
|
||||
// 🔥 不考核的快递(业务 2026-10-20 定 3 个,2026-10-27 追加武进安能)——
|
||||
// 走这几种运输方式的单不按仓储发货时效考核(自提客户自己来拉、
|
||||
// 中铁 / 壹米滴答是专线大件、武进安能是加盟制快运网点,
|
||||
// 揽收与在途都不受仓库控制)。
|
||||
// 用**物流公司名**模糊匹配(库里的名字是「中铁物流」「壹米滴答专线」这种)。
|
||||
// ⚠️ 「武进安能」别泛化成「安能」:常州安能新等网点仍要考核。
|
||||
const carrier = String(row.logistic_company_name || '').trim();
|
||||
const hitCarrier = EXCLUDED_CARRIERS.find((k) => carrier.includes(k));
|
||||
if (hitCarrier) {
|
||||
@ -628,8 +676,10 @@ async function getShipTimeliness(options = {}) {
|
||||
return 0;
|
||||
});
|
||||
|
||||
const detailRows = pendingRows.slice(0, limit);
|
||||
const heldDetail = heldRows.slice(0, limit);
|
||||
// ⚠️ 这里**不再按 limit 切**:payload 存全量进缓存,出口由 withDetailLimit 截断。
|
||||
// 以前在这里 slice,缓存里就只有前 limit 行 —— 导出拿不到全部,且 limit 一变就重查。
|
||||
const detailRows = pendingRows;
|
||||
const heldDetail = heldRows;
|
||||
|
||||
const orders = detailRows.map((x) => {
|
||||
const r = x.row;
|
||||
@ -776,13 +826,14 @@ async function getShipTimeliness(options = {}) {
|
||||
},
|
||||
summary,
|
||||
centers,
|
||||
// ⚠️ 全量(未截断);出口由 withDetailLimit 按 limit 切,并回填 detailTotal / heldTotal
|
||||
orders,
|
||||
// 不考核的快递(前端文案直接用,避免两边各写一份)
|
||||
excludedCarriers: EXCLUDED_CARRIERS,
|
||||
// 不计考核清单(待审核 / 异常)—— 与 orders 互斥
|
||||
heldOrders,
|
||||
truncated: pendingRows.length > orders.length,
|
||||
detailLimit: limit,
|
||||
truncated: false,
|
||||
detailLimit: detailRows.length,
|
||||
detailDegraded: false,
|
||||
freshness,
|
||||
cached: false,
|
||||
@ -791,12 +842,88 @@ async function getShipTimeliness(options = {}) {
|
||||
};
|
||||
|
||||
resultCache = { key: cacheKey, at: Date.now(), data: payload };
|
||||
return payload;
|
||||
return withDetailLimit(payload, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出明细行(纯函数,便于单测)。
|
||||
*
|
||||
* 口径必须与页面明细**完全一致** —— 所以直接把 `getShipTimeliness().orders` 摊平,
|
||||
* 不重新查库、不重新过滤,否则导出的行和页面上看到的会对不上。
|
||||
*
|
||||
* 比页面多带两列「快递公司 / 运单号」:业务拿 Excel 去追单时要能直接看到
|
||||
* 是哪个快递、有没有打单(有运单号 = 已打单未揽收)。
|
||||
*
|
||||
* @param {Array} orders service 返回的 orders(已经是考核口径内的超时未发单)
|
||||
* @returns {Array<object>} 字段名与前端 EXPORT_COLUMNS 的 dataIndex 一一对应
|
||||
*/
|
||||
function buildShipTimelinessExportRows(orders = []) {
|
||||
const list = Array.isArray(orders) ? orders : [];
|
||||
// 一律转字符串:单号是数字时 Excel 会显示成科学计数法(5127…E+18),业务没法拿它去搜单
|
||||
const text = (v) => (v === null || v === undefined ? '' : String(v));
|
||||
return list.map((o) => ({
|
||||
oId: text(o?.oId),
|
||||
soId: text(o?.soId),
|
||||
shopName: text(o?.shopName),
|
||||
// centerKey 只用于前端「按选中仓过滤」,不进导出列
|
||||
centerKey: text(o?.centerKey),
|
||||
centerName: text(o?.centerName),
|
||||
warehouseName: text(o?.warehouseName),
|
||||
logisticCompany: text(o?.logisticCompany),
|
||||
logisticId: text(o?.logisticId),
|
||||
logisticStatus: text(o?.logisticStatus),
|
||||
state: text(o?.state),
|
||||
sendTime: text(o?.sendTime),
|
||||
auditTime: text(o?.auditTime),
|
||||
payTime: text(o?.payTime),
|
||||
benchmarkTime: text(o?.benchmarkTime),
|
||||
benchmarkSource: text(o?.benchmarkSource),
|
||||
overdueMinutes: Number(o?.overdueMinutes) || 0,
|
||||
waitedHours: Number(o?.waitedHours) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 「导出明细」的数据源:**不分页、不截断**。
|
||||
*
|
||||
* 与 `/list` 共用 `getShipTimeliness`(同一份缓存 → 页面刚刷过就是秒回),
|
||||
* 唯一区别是 `fullDetail: true` 让出口不按 limit 切。
|
||||
*/
|
||||
async function listShipTimelinessExportRows(query = {}) {
|
||||
const payload = await getShipTimeliness({
|
||||
round: query.round,
|
||||
force: query.force,
|
||||
// now 只是给单测注入固定时刻用的(HTTP 上传不进来也不会解析字符串);
|
||||
// 生产走 beijingNow(),见 getShipTimeliness 里的时区说明
|
||||
now: query.now,
|
||||
fullDetail: true,
|
||||
});
|
||||
const rows = buildShipTimelinessExportRows(payload.orders);
|
||||
return {
|
||||
rows,
|
||||
total: rows.length,
|
||||
notStarted: payload.notStarted === true,
|
||||
date: payload.date,
|
||||
round: payload.round,
|
||||
deadline: payload.deadline,
|
||||
generatedAt: payload.generatedAt,
|
||||
summary: payload.summary || emptySummary(),
|
||||
excludedCarriers: payload.excludedCarriers || EXCLUDED_CARRIERS,
|
||||
freshness: payload.freshness || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getShipTimeliness,
|
||||
listShipTimelinessExportRows,
|
||||
clearShipTimelinessCache,
|
||||
DEFAULT_DETAIL_LIMIT,
|
||||
MAX_DETAIL_LIMIT,
|
||||
// 钉钉推送正文要列「剔除了哪几个快递」,直接复用服务层的清单,别在 job 里再抄一份
|
||||
EXCLUDED_CARRIERS,
|
||||
_private: {
|
||||
EXCLUDED_CARRIERS,
|
||||
buildShipTimelinessExportRows,
|
||||
withDetailLimit,
|
||||
},
|
||||
};
|
||||
|
||||
@ -331,6 +331,14 @@ function stubDb(dbPath, scenario = 'default') {
|
||||
mk({ so_id: 'SO-36', inner_order_id: 36, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-25 02:00:00',
|
||||
last_oa_confirm_time: '2026-09-25 02:30:00', logistic_company_name: '壹米滴答专线' }),
|
||||
// 41/42:🔥 武进安能(业务 2026-10-27 追加不考核)—— 两种实际写法都要剔掉。
|
||||
// 42 是「武进安能大件」,靠包含匹配一起命中,别写成精确相等。
|
||||
mk({ so_id: 'SO-41', inner_order_id: 41, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-25 03:00:00',
|
||||
last_oa_confirm_time: '2026-09-25 03:30:00', logistic_company_name: '武进安能' }),
|
||||
mk({ so_id: 'SO-42', inner_order_id: 42, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-25 04:00:00',
|
||||
last_oa_confirm_time: '2026-09-25 04:30:00', logistic_company_name: '武进安能大件' }),
|
||||
// 37:销售订单 status=Sent(已发货),状态表还是「发货中 + send_time 空」→ 必须剔除
|
||||
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
|
||||
@ -537,13 +545,24 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
|
||||
// 这 8 单要计进 voidCount(9/10/11/12 作废或已发 + 37 已发货 Sent + 38/39/40 作废三类)
|
||||
assert.equal(data.summary.voidCount, 8, '作废/已发单要计入剔除数');
|
||||
|
||||
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
|
||||
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
|
||||
// 不考核快递:中铁物流、壹米滴答专线、武进安能必须整单跳过
|
||||
// (业务 2026-10-20 定前两个,2026-10-27 追加武进安能)
|
||||
assert.equal(data.summary.carrierSkipped, 4, '自提/中铁/壹米滴答/武进安能 要计入 carrierSkipped');
|
||||
assert.ok(
|
||||
!data.orders.some((o) => ['SO-35', 'SO-36'].includes(o.soId)),
|
||||
!data.orders.some((o) => ['SO-35', 'SO-36', 'SO-41', 'SO-42'].includes(o.soId)),
|
||||
'不考核快递的单不能出现在超时明细里'
|
||||
);
|
||||
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答']);
|
||||
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答', '武进安能']);
|
||||
// 🔥 「武进安能」只剔这一个网点,**不能**泛化成「安能」:
|
||||
// 库里带「安能」的还有 常州安能新 / 安能物流 / 华韵安能 / 雅柏安能…(常州安能新单量最大)
|
||||
assert.ok(
|
||||
!svc._private.EXCLUDED_CARRIERS.some((k) => '常州安能新'.includes(k)),
|
||||
'常州安能新必须照旧考核,别把关键词放大成「安能」'
|
||||
);
|
||||
assert.ok(
|
||||
svc._private.EXCLUDED_CARRIERS.some((k) => '武进安能大件'.includes(k)),
|
||||
'武进安能大件要能被「武进安能」包含匹配命中'
|
||||
);
|
||||
|
||||
// 不计考核清单
|
||||
assert.equal(data.heldOrders.length, 2);
|
||||
@ -692,3 +711,46 @@ test('resolveActiveRound 默认按北京时间取「今天」,不依赖容器
|
||||
assert.equal(noon.deadline, '2026-10-21 13:00:00');
|
||||
assert.equal(noon.windowEnd, '2026-10-21 07:00:00');
|
||||
});
|
||||
|
||||
// ── ⑨ 导出明细(2026-10-27 新增「导出明细」按钮的后端口径)──────────────────
|
||||
// 关键约束:导出必须是**全量**,且不能因为导出又重扫一遍状态表 ——
|
||||
// 所以缓存键不含 limit(存全量、出口截断)。这个用例专门锁这两点。
|
||||
test('导出取数:不受 limit 截断,且与列表共用同一份缓存', withStubbedService(async (svc, { calls }) => {
|
||||
// 列表按 limit=3 取(页面就是这样截断的)
|
||||
const limited = await svc.getShipTimeliness({ now: at(18, 5), limit: 3 });
|
||||
assert.equal(limited.orders.length, 3, 'limit=3 只给 3 行');
|
||||
assert.equal(limited.truncated, true, '被截断必须打标记');
|
||||
assert.equal(limited.detailTotal, 7, '截断后仍要给出真实总数(页面「共 N 单」用它)');
|
||||
assert.equal(calls.state, 1, '第一次查询打了一次状态表');
|
||||
|
||||
// 导出:同一轮次同一天 → 必须命中同一份缓存,且拿到全部 7 行
|
||||
const exported = await svc.listShipTimelinessExportRows({ round: 'AFTERNOON', now: at(18, 6) });
|
||||
assert.equal(calls.state, 1, '导出不能再扫一遍状态表(旧实现把 limit 拼进缓存键,每次导出都重查)');
|
||||
assert.equal(exported.total, 7, '导出必须是全部超时单,不能被列表的 limit 带跑');
|
||||
assert.equal(exported.rows.length, 7);
|
||||
// 导出列里业务要能直接看到快递 / 运单号(追单用),centerKey 供前端按仓过滤
|
||||
for (const row of exported.rows) {
|
||||
for (const key of ['oId', 'soId', 'shopName', 'centerKey', 'centerName',
|
||||
'warehouseName', 'logisticCompany', 'logisticId', 'auditTime', 'overdueMinutes']) {
|
||||
assert.ok(key in row, `导出缺字段 ${key}`);
|
||||
}
|
||||
}
|
||||
// 不考核快递的单不能出现在导出里(口径与页面一致)
|
||||
assert.ok(!exported.rows.some((r) => ['SO-35', 'SO-36', 'SO-41', 'SO-42'].includes(r.soId)),
|
||||
'不考核快递的单不能导出');
|
||||
assert.deepEqual(exported.excludedCarriers, ['自提', '中铁', '壹米滴答', '武进安能']);
|
||||
}));
|
||||
|
||||
test('导出行为纯函数:缺字段一律给空串,不导出 undefined', () => {
|
||||
const service = require('../src/services/warehouseShipTimelinessService');
|
||||
const rows = service._private.buildShipTimelinessExportRows([
|
||||
{ oId: 1, soId: 'SO-1', overdueMinutes: null },
|
||||
null,
|
||||
]);
|
||||
assert.equal(rows.length, 2, '空行也要占位,别让行号错位');
|
||||
assert.equal(rows[0].oId, '1', '数字单号要转成字符串,避免 Excel 科学计数法');
|
||||
assert.equal(rows[0].logisticCompany, '');
|
||||
assert.equal(rows[0].overdueMinutes, 0, 'null 时长按 0 处理,别给 NaN');
|
||||
assert.equal(rows[1].oId, '');
|
||||
assert.doesNotMatch(JSON.stringify(rows), /undefined|null|NaN/u);
|
||||
});
|
||||
|
||||
@ -48,6 +48,7 @@ function sampleData(overrides = {}) {
|
||||
shopName: '京东旗舰店',
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
centerName: '崔桥工厂仓',
|
||||
logisticCompany: '武进韵达',
|
||||
auditTime: '2026-10-27 06:32:11',
|
||||
overdueMinutes: 388,
|
||||
},
|
||||
@ -57,6 +58,7 @@ function sampleData(overrides = {}) {
|
||||
shopName: '天猫店',
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
centerName: '崔桥工厂仓',
|
||||
logisticCompany: '常州安能新',
|
||||
auditTime: '2026-10-26 09:05:00',
|
||||
overdueMinutes: 2875,
|
||||
},
|
||||
@ -66,6 +68,8 @@ function sampleData(overrides = {}) {
|
||||
shopName: '抖音店',
|
||||
warehouseName: '简尚-横林二仓',
|
||||
centerName: '横林二仓',
|
||||
// 快递名为空 → 明细里要显示「—」,不能出现 undefined
|
||||
logisticCompany: '',
|
||||
auditTime: '',
|
||||
overdueMinutes: 120,
|
||||
},
|
||||
@ -134,18 +138,26 @@ 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/);
|
||||
|
||||
@ -22,6 +22,18 @@ 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)
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Collapse, Empty, Input, InputNumber, Modal, Space, Spin, Statistic,
|
||||
Switch, Table, Tag, TimePicker, Tooltip, Typography, message,
|
||||
Alert, Button, Card, Collapse, Dropdown, Empty, Input, InputNumber, Modal, Space, Spin,
|
||||
Statistic, Switch, Table, Tag, TimePicker, Tooltip, Typography, message,
|
||||
} from 'antd';
|
||||
import { BellOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { BellOutlined, DownloadOutlined, 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;
|
||||
@ -272,6 +274,51 @@ 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: 'logisticStatus' },
|
||||
{ title: '发货状态', dataIndex: 'state' },
|
||||
{ title: '发货时间', dataIndex: 'sendTime', exportValue: (v) => v || '未发货' },
|
||||
{
|
||||
title: '审核时间',
|
||||
dataIndex: 'auditTime',
|
||||
// 审核时间缺失时页面会回退创建/付款并打 `*`,导出也保持一致,否则对账对不上
|
||||
exportValue: (v, row) => v || (row?.benchmarkTime ? `${row.benchmarkTime} *` : '未审核'),
|
||||
},
|
||||
{ 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 小时发货时效(独立页面)
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
@ -297,6 +344,8 @@ function WarehouseShipTimeliness() {
|
||||
const [activeCenter, setActiveCenter] = useState('');
|
||||
// 「业务挂起(疑问单)」明细弹窗
|
||||
const [heldOpen, setHeldOpen] = useState(false);
|
||||
// 「导出明细」按钮 loading(全量导出可能要走一次冷启动查询,必须给反馈)
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// ── 钉钉通知(两轮核验完成后各推一次)──────────────────────────────────
|
||||
const [notifyOpen, setNotifyOpen] = useState(false);
|
||||
@ -363,10 +412,84 @@ 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 永远是空的 —— 留空 = 沿用已存的
|
||||
@ -514,7 +637,7 @@ function WarehouseShipTimeliness() {
|
||||
—— 该字段经常不回写,会把早就发货的单误判成超时。
|
||||
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
|
||||
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
|
||||
<b>自提 / 中铁 / 壹米滴答 三个快递不考核</b>(客户自提或专线大件,仓库控制不了运输),
|
||||
<b>{excludedCarrierText} 不考核</b>(客户自提、专线大件或加盟快运网点,揽收与在途仓库控制不了),
|
||||
已按物流公司自动剔除。
|
||||
<b>「已支付待审核」和「异常」的单不计考核</b>(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细;
|
||||
<b>作废单(已取消,含发货前售后)不考核也不展示</b> —— 客户付款后取消的单仓库本就不该发。
|
||||
@ -692,16 +815,33 @@ function WarehouseShipTimeliness() {
|
||||
<span>
|
||||
超时未发货订单
|
||||
<span className="wst-detail__scope">
|
||||
{activeCenterName || '全部仓库'} · {detailOrders.length} 单
|
||||
{data?.truncated && detailOrders.length >= (data?.detailLimit || 0)
|
||||
? `(已截断,仅显示前 ${data.detailLimit} 条)`
|
||||
{activeCenterName || '全部仓库'} · {activeCenter
|
||||
? (activeCenterRow?.overdueUnshipped ?? detailOrders.length)
|
||||
: detailTotal} 单
|
||||
{!activeCenter && data?.truncated
|
||||
? `(页面只显示前 ${data.detailLimit} 条,导出可拿全部 ${detailTotal} 条)`
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
extra={activeCenter ? (
|
||||
<button type="button" className="wst-detail__clear" onClick={() => setActiveCenter('')}>查看全部</button>
|
||||
) : null}
|
||||
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>
|
||||
)}
|
||||
>
|
||||
<Spin spinning={loading && !data}>
|
||||
{notStarted ? (
|
||||
@ -726,7 +866,7 @@ function WarehouseShipTimeliness() {
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={`不计考核(待审核 / 异常)· ${heldOrders.length} 单`}
|
||||
title={`不计考核(待审核 / 异常)· ${heldTotal} 单`}
|
||||
open={heldOpen}
|
||||
onCancel={() => setHeldOpen(false)}
|
||||
footer={null}
|
||||
@ -938,7 +1078,7 @@ function WarehouseShipTimeliness() {
|
||||
发货 = 聚水潭回传的发货时间。
|
||||
<b>作废单不考核</b>(被合并 / 拆单父单 / 已取消,或订单表已有发货时间)
|
||||
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
|
||||
<b>自提 / 中铁 / 壹米滴答不考核</b>
|
||||
<b>{excludedCarrierText}不考核</b>
|
||||
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
|
||||
已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
||||
</Text>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user