From f749bcc03c135a58c8be3de320fe14a8aa1e19a0 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Tue, 27 Oct 2026 16:29:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(wst):=20=E8=B6=85=E6=97=B6=E6=9C=AA?= =?UTF-8?q?=E5=8F=91=E5=8A=A0=E3=80=8C=E5=AF=BC=E5=87=BA=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E3=80=8D+=20=E6=AD=A6=E8=BF=9B=E5=AE=89=E8=83=BD=E4=B8=8D?= =?UTF-8?q?=E8=80=83=E6=A0=B8=20+=20=E9=92=89=E9=92=89=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E5=B8=A6=E5=BF=AB=E9=80=92=EF=BC=9B=E4=BF=AE=E5=A4=8D=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E6=97=B6=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 导出明细(业务要求:超时未发要能导出核对) · 新增 GET /api/warehouse-ship-timeliness/export:不分页、不截断,返回本轮全部行 · 页面「超时未发货订单」卡片右上角加「导出明细」下拉(Excel / CSV) · 选中某个仓时只导该仓(前端按 centerKey 过滤,与后端主仓归属同一套) · 导出列比页面多「快递公司 / 运单号」(追单要看有没有打单),单号一律转字符串 避免 Excel 显示成科学计数法 ② 缓存改成「存全量、出口截断」 · 缓存键去掉 limit(原为企业级教训:WLE 核验缓存早就这么改了) · 页面 limit=1000、导出要全量,旧实现两边永不命中 → 每点一次导出重查一遍视图 · 实测:首次查询 86.3s,导出命中缓存 5ms · 顺带修 detailTotal / heldTotal:明细被截断时页面仍显示真实总数, 不再把「只显示前 1000 条」写成「一共就这么点」 ③ 不考核快递追加「武进安能」(业务 2026-10-27) · 包含匹配,同时覆盖「武进安能大件」 · ⚠️ 严格不泛化成「安能」:实测库里带「安能」的有 8 个写法, 常州安能新 4207 单 / 安能物流 110 单等仍照旧考核(与 warehouseLogisticsExceptionRegistry 同一口径,那里也写着这句) ④ 钉钉推送明细行在「线上单号」后加「快递」 · 一行 = 仓|内部单号|线上单号|快递|审核 时间|已等 X 小时 · 群里直接能判断是哪家快递、要不要走站点,不用回页面逐单点开 · 「已剔除 N 单不考核快递」的快递清单改成读服务层,别再各写一份 ⑤ 修复推送任务时区(生产容器 /etc/timezone = Etc/UTC) · 症状:pushShipTimelinessNotify 把裸 new Date() 传给 getShipTimeliness, 而 service / registry 内部用 getDate()/getHours() 直读 → 北京 13:05 = UTC 05:05,被判「未到 13:00 核验时间」→ 两轮推送永不触发 · 改法:rawNow(真实时刻,给 Intl / toISOString)+ bjNow(平移后, 给本地 getter 与 service 调用)双轨,与 2026-10-21 那次同源问题的修法一致 --- backend/src/api/warehouseShipTimeliness.js | 26 ++- .../jobs/warehouseShipTimelinessNotifyJob.js | 46 +++-- .../warehouseShipTimelinessService.js | 161 +++++++++++++++-- backend/test/warehouseShipTimeliness.test.js | 70 +++++++- .../warehouseShipTimelinessNotify.test.js | 22 ++- frontend/src/api/warehouseShipTimeliness.js | 12 ++ .../pages/WarehouseShipTimeliness/index.jsx | 164 ++++++++++++++++-- 7 files changed, 449 insertions(+), 52 deletions(-) diff --git a/backend/src/api/warehouseShipTimeliness.js b/backend/src/api/warehouseShipTimeliness.js index d8d82f6..11f1fe4 100644 --- a/backend/src/api/warehouseShipTimeliness.js +++ b/backend/src/api/warehouseShipTimeliness.js @@ -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, '导出超时未发明细失败'); + } +}); + // ── 两轮核验完成后推钉钉群 ────────────────────────────────────────────────── // 读配置:加签密钥只回掩码 + 是否已配置,明文永不出后端 diff --git a/backend/src/jobs/warehouseShipTimelinessNotifyJob.js b/backend/src/jobs/warehouseShipTimelinessNotifyJob.js index d7397ad..232d138 100644 --- a/backend/src/jobs/warehouseShipTimelinessNotifyJob.js +++ b/backend/src/jobs/warehouseShipTimelinessNotifyJob.js @@ -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, diff --git a/backend/src/services/warehouseShipTimelinessService.js b/backend/src/services/warehouseShipTimelinessService.js index 789d6a5..20043c6 100644 --- a/backend/src/services/warehouseShipTimelinessService.js +++ b/backend/src/services/warehouseShipTimelinessService.js @@ -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} 字段名与前端 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, + }, }; diff --git a/backend/test/warehouseShipTimeliness.test.js b/backend/test/warehouseShipTimeliness.test.js index 2782e78..1bafada 100644 --- a/backend/test/warehouseShipTimeliness.test.js +++ b/backend/test/warehouseShipTimeliness.test.js @@ -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); +}); diff --git a/backend/test/warehouseShipTimelinessNotify.test.js b/backend/test/warehouseShipTimelinessNotify.test.js index 0d96699..77d417b 100644 --- a/backend/test/warehouseShipTimelinessNotify.test.js +++ b/backend/test/warehouseShipTimelinessNotify.test.js @@ -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/); diff --git a/frontend/src/api/warehouseShipTimeliness.js b/frontend/src/api/warehouseShipTimeliness.js index 89a35d4..fd59fae 100644 --- a/frontend/src/api/warehouseShipTimeliness.js +++ b/frontend/src/api/warehouseShipTimeliness.js @@ -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) diff --git a/frontend/src/pages/WarehouseShipTimeliness/index.jsx b/frontend/src/pages/WarehouseShipTimeliness/index.jsx index 3b72b17..bdf273f 100644 --- a/frontend/src/pages/WarehouseShipTimeliness/index.jsx +++ b/frontend/src/pages/WarehouseShipTimeliness/index.jsx @@ -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() { —— 该字段经常不回写,会把早就发货的单误判成超时。 已打单但还没揽收的(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」, 鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。 - 自提 / 中铁 / 壹米滴答 三个快递不考核(客户自提或专线大件,仓库控制不了运输), + {excludedCarrierText} 不考核(客户自提、专线大件或加盟快运网点,揽收与在途仓库控制不了), 已按物流公司自动剔除。 「已支付待审核」和「异常」的单不计考核(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细; 作废单(已取消,含发货前售后)不考核也不展示 —— 客户付款后取消的单仓库本就不该发。 @@ -692,16 +815,33 @@ function WarehouseShipTimeliness() { 超时未发货订单 - {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} 条)` : ''} )} - extra={activeCenter ? ( - - ) : null} + extra={( + + {activeCenter ? ( + + ) : null} + handleExport(key) }} + > + + + + )} > {notStarted ? ( @@ -726,7 +866,7 @@ function WarehouseShipTimeliness() { setHeldOpen(false)} footer={null} @@ -938,7 +1078,7 @@ function WarehouseShipTimeliness() { 发货 = 聚水潭回传的发货时间。 作废单不考核(被合并 / 拆单父单 / 已取消,或订单表已有发货时间) {summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''}; - 自提 / 中铁 / 壹米滴答不考核 + {excludedCarrierText}不考核 {summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''}; 已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。 -- 2.47.2