From 530d1ad27a56593df568dffa340c478b74b5ea64 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Wed, 21 Oct 2026 07:52:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=93=E5=82=A8=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E5=8F=91=E8=B4=A7=E6=97=B6=E6=95=88=E7=8B=AC=E7=AB=8B=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=20+=20=E5=8F=A3=E5=BE=84=E5=AE=9A=E7=89=88=EF=BC=88?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E6=97=B6=E9=97=B4=E5=88=86=E8=BD=AE/?= =?UTF-8?q?=E5=BF=AB=E9=80=92=E5=89=94=E9=99=A4/=E5=81=9C=E6=9B=B4?= =?UTF-8?q?=E5=91=8A=E8=AD=A6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、前端:从 MkhBomData 侧栏抽成独立页面 - 新增 frontend/src/pages/WarehouseShipTimeliness/{index.jsx,style.css}, 路由 /planning/operation/warehouse-ship-timeliness,侧栏导航与 BasicLayout 各加 1 行入口。 - 删除 frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx, 并把 MkhBomData/index.jsx、style.css 里相关代码与样式一并摘除(-131 / -277 行)。 二、业务口径(2026-10-20 业务多次纠偏后的定版) - 轮次归属改用「审核时间」= jst_order_express_details.last_oa_confirm_time (聚水潭订单列表「审核时间」列,最后一次审核,重审会刷新): 13:00 轮 = 今天 07:00 前审核 + 历史未发;18:00 轮 = 截止 16:00 前审核 + 历史未发。 不再按「创建时间」切自然日窗口,也不再有「满 24 小时」门槛。 - 仓级例外(仅 18:00 轮):山东青岗集仓 / 山东品牌仓 15:00 前审核、河南洛宁仓 13:00 前审核, 核验时刻仍为 18:00(registry 新增 windowEndHourByRound)。 - 明细去掉「创建时间」,改显示「发货时间」(send_time);未发但有运单号的标「已打单 · 未揽收」。 三、已发判定与不考核快递 - 已发 = 状态表 send_time 非空 OR 订单表 status='Sent'(对齐聚水潭销售订单口径)。 不再用运单号反推(已发货与未揽收单都会出现运单号,判不出来)。 - 自提 / 中铁 / 壹米滴答 三家快递不做未发考核,剔除量计入 summary.carrierSkipped。 四、数据源停更必须让用户看见 - freshness 由写死 stale:false 改为实算:快照距今 >90 分钟 → 黄告警; 快照早于本轮核验时刻 → 红告警「数据未覆盖核验时刻,超时单量偏大」。 五、测试 - backend/test/warehouseShipTimeliness.test.js 扩到 17 用例(仓级例外、重审取 last_oa、 快递剔除、Sent 判已发、coversDeadline 告警)。 --- .../warehouseShipTimelinessRegistry.js | 384 ++++++- .../warehouseShipTimelinessService.js | 1003 ++++++++++------- backend/test/warehouseShipTimeliness.test.js | 691 +++++++++--- frontend/src/config/sidebarNavigation.js | 1 + frontend/src/layouts/BasicLayout.jsx | 5 + .../WarehouseShipTimelinessSidebar.jsx | 146 --- frontend/src/pages/MkhBomData/index.jsx | 131 +-- frontend/src/pages/MkhBomData/style.css | 277 ----- .../pages/WarehouseShipTimeliness/index.jsx | 546 +++++++++ .../pages/WarehouseShipTimeliness/style.css | 255 +++++ frontend/src/router/index.jsx | 5 + 11 files changed, 2315 insertions(+), 1129 deletions(-) delete mode 100644 frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx create mode 100644 frontend/src/pages/WarehouseShipTimeliness/index.jsx create mode 100644 frontend/src/pages/WarehouseShipTimeliness/style.css diff --git a/backend/src/services/warehouseShipTimelinessRegistry.js b/backend/src/services/warehouseShipTimelinessRegistry.js index 32d03a6..8448b01 100644 --- a/backend/src/services/warehouseShipTimelinessRegistry.js +++ b/backend/src/services/warehouseShipTimelinessRegistry.js @@ -6,36 +6,55 @@ * 需求(yanhui 2026-10-20): * MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓: * 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 / - * 崔桥工厂半成品仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓 - * 从系统库存抓取「已发货订单」与「未发货订单」,按**订单创建时间**算是否及时。 - * 每天 12:00 核验「当天创建、7:00 前」的订单是否已发货; - * 每天 16:00 核验「当天创建、16:00 前」的订单是否已发货。 + * 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓 + * 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。 + * 今天 07:00 前审核的订单 → 13:00 核验;**截止今天 16:00 前审核**的单(含今天之前未发的)→ 18:00 核验; + * 昨天及更早审核、至今未发的(历史未发单)两个轮次都算。 + * (2026-10-20 三次调整: + * 二次 —— 核验时刻由 12:00/16:00 改为 13:00/18:00; + * 三次 —— ① **窗口按「审核时间」判**(不是创建时间):业务指出的 9818476 + * (创建 10-18、审核 10-20 13:06)这类下午才审的单,不能进 13:00 轮; + * ② **取消「满 24 小时才算超时」的门槛**:到核验时刻未发即超时; + * ③ **历史未发单一律算**,不卡「今天 00:00 起」。 + * 业务原话:「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到 + * 16 点审核的订单 18:00 核验;然后再看历史未发的订单」。) * 展示口径:**只显示超时未发**(业务确认)。 * - * 业务确认的口径(2026-10-20 AskUserQuestion): - * ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东云仓/京东自营仓/运营C专用仓 等) - * ② 崔桥工厂半成品仓 —— 系统无同名仓,**先留空占位**(不映射到 15718196) + * 业务确认的口径(2026-10-20): + * ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东自营仓/运营C专用仓 等) + * 例外:「京仓-横林二仓京东云仓」业务指定**排除**,不计入任何主仓(见下) + * ② 崔桥工厂半成品仓 —— 系统无同名仓,**接入「简尚-崔桥工厂半成品包装项目仓」**(15718196) * ③ 展示结构 —— **只显示超时未发** + * ④ 明细列 —— 要同时给「内部单号」(o_id) 与「线上订单号」(so_id) * * ── 数据源 ──────────────────────────────────────────────────────────────── * 订单:两张表**店铺互斥,必须都读**(见 mkhBomService 文件头) * rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会 * rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688 - * 字段口径: - * · 创建时间:`order_date`(订单创建时间,业务要求按此算时效) - * · 发货时间:`send_date`(为空 = 未发货) + * 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准): + * · 创建时间:**销售出库单的 `created`**(出库单创建时间) + * ⚠️ 不是 `order_date`(平台下单时间); + * ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成 + * 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40, + * 实际下单在 10-13~10-18),拿它当创建时间会让所有单的时间被抹平; + * ⚠️ 也不是 `created/createTime/updateTime`(数仓 5 分钟批量灌库时间戳)。 + * 待出库的单在出库单表里还没有记录 → 回退 `order_date` 并在接口里标注来源。 + * · 已出库:销售出库单表能查到该 o_id(有 `io_date`),**或**订单 `send_date` + * 非空,**或**订单状态已是 `Sent` / `Delivering`。 + * · 待出库:以上三条都不满足。 * · 发货仓: `wms_co_id`(订单**发货仓**,不是 SKU 的归属仓) * ⚠️ 用户原话「从系统库存抓取」—— 本实现取的是**订单维度**的仓库归属, * 因为 jst_inventoryInfo 只有「某 SKU 在某仓有多少件」,没有订单记录。 * * ── 仓库归属(按名称包含匹配,最长匹配优先)──────────────────────────────── * `rpt.jst_warehouseInfo` 实测(2026-10-20): - * 横林二仓 15033302 + 15197138/15301814/15835893/16040225/16132681 + * 横林二仓 15033302 + 15301814/15835893/16040225/16132681 + * ⚠️ 15197138「京仓-横林二仓京东云仓」**业务指定排除**(见 EXCLUDED_WAREHOUSE_KEYWORDS) * 横林三仓 15043561 * 横林二仓品牌仓 16132681(**同时命中也属于「横林二仓」,靠最长匹配优先归到品牌仓**) * 崔桥工厂仓 15038426 + 15301815/16040410 * 崔桥工厂品牌仓 15870495 - * 崔桥工厂半成品仓 —— 无同名仓,留空 + * 崔桥工厂半成品包装项目仓 15718196(原名「简尚-崔桥工厂半成品包装项目仓」) * 新苏北仓 15038347 + 15315031/15301812 * 睢宁仓 15032603 + 15051907/15051960/15052203/15301813/15386911 * 山东青岗集仓 15137523 + 15283033/15406664/15835935/16040432/16129076 @@ -46,15 +65,23 @@ * 缓存 TTL 1 小时,可用 clearWarehouseCache() 手动清。 * * ── 时效判定 ────────────────────────────────────────────────────────────── - * 截止时刻(每日两轮,取**当前已生效且最晚**的那一轮;未到 12:00 时无截止): - * 12:00 → 核验「当天 00:00 ~ 07:00 创建」的订单 - * 16:00 → 核验「当天 00:00 ~ 16:00 创建」的订单 - * 判定: - * · 超时未发 = `send_date IS NULL` 且 `order_date <= 截止时刻` - * · 按时已发 = `send_date IS NOT NULL` 且 `send_date <= 截止时刻` → **不计入超时** - * · 已发但超时 = `send_date > 截止时刻` → 也算超时(已发,但没赶上截止) - * 业务只要「超时未发」,所以列表只返回 `send_date IS NULL` 的超时单; - * 但 summary 里仍给出 `overdueShipped`,避免「已发但超时」被当成没超时。 + * 两个时刻**必须区分**(2026-10-20 二次调整后最容易搞混的地方): + * `verifyHour` —— 几点核验(13:00 / 18:00)。判定「发了没发」「超时多久」都用它。 + * `windowEndHour` —— 订单窗口上界(07:00 / 16:00)。只核验该时刻**前审核**的单。 + * 取当前已生效且最晚的那一轮;未到 13:00 时无核验轮次: + * 13:00 → 核验「今天 00:00~07:00 审核」的订单 + 历史未发 + * 18:00 → 核验「截止今天 16:00 前审核」的订单 + 今天之前审核仍未发的(历史未发) + * ⚠️ 不是「今天 00:00~16:00」这一个自然日切片,历史未发也在内; + * 山东两仓窗口上界 15:00、河南洛宁 13:00(核验时刻仍是 18:00)。 + * 判定(2026-10-20 三次修正 —— **没有「满 24 小时」门槛**): + * · 超时未出库 = 到核验时刻仍未发货 + * (出库单表查不到 + `send_time` 为空 + 状态不是 Sent/Delivering) + * 🔥 第七版错在加了「创建满 24h 才算超时」,导致 13:00 轮永远 + * 看不到当天 7 点那批(才等了 6 小时),业务直接打回。 + * · 已出库 = 出库单表有记录 / send_date 非空 / 状态 Sent、Delivering + * · 拆单父单(is_split=1)、被合并单(is_merge=1)**一律排除** —— + * 它们自己永不发货,货由子单/主单发出,算进来就是冤枉仓库。 + * 业务只要「超时未出库」,所以列表只返回待出库的超时单。 * * ── 时区 ────────────────────────────────────────────────────────────────── * 订单时间列存的是**东八区本地时间**(实测 `order_date` 与 `created` 相差十几秒, @@ -73,16 +100,40 @@ const CENTERS = Object.freeze([ { key: 'HL2_BRAND', name: '横林二仓品牌仓', keywords: ['横林二仓品牌仓'] }, { key: 'CQ', name: '崔桥工厂仓', keywords: ['崔桥工厂仓'] }, { key: 'CQ_BRAND', name: '崔桥工厂品牌仓', keywords: ['崔桥工厂品牌仓'] }, - // 系统内无同名仓(最接近的「简尚-崔桥工厂半成品包装项目仓」15718196 是配件仓, - // 业务确认先留空占位,不并进来) - { key: 'CQ_WIP', name: '崔桥工厂半成品仓', keywords: [], placeholder: true }, + // 2026-10-20 二次调整:原先「崔桥工厂半成品仓」在仓库表里没有同名仓,只能留空占位; + // 业务指定用「简尚-崔桥工厂半成品包装项目仓」(wms_co_id=15718196, Id=198) 替代接入。 + // ⚠️ 该 wms_co_id 同时也是配件仓(MKH 配件库存来源),但这里是**订单发货仓**维度, + // 统计的是「从该仓发出的订单」,与配件库存口径无关,不要混。 + { key: 'CQ_WIP', name: '崔桥工厂半成品包装项目仓', keywords: ['崔桥工厂半成品'] }, { key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] }, { key: 'SN', name: '睢宁仓', keywords: ['睢宁'] }, - { key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'] }, - { key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌仓'] }, - { key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'] }, + // 🔥 山东两个仓 18:00 轮的窗口更早(15:00 前,不是 16:00)—— + // 业务 2026-10-20:「山东青岗集仓和山东品牌仓,这 2 个仓库时间不是 16:00 前, + // 是 15:00 前订单」。核验时刻仍是 18:00,只是卡单的范围窄一小时。 + { key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'], windowEndHourByRound: { AFTERNOON: 15 } }, + { key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌仓'], windowEndHourByRound: { AFTERNOON: 15 } }, + // 🔥 河南洛宁仓更早:18:00 轮只看 13:00 前创建的单(业务同一条指令) + { key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'], windowEndHourByRound: { AFTERNOON: 13 } }, ]); +// 各主仓在某一轮的窗口上界小时。 +// 返回 null 表示「用轮次默认值」(ROUNDS[].windowEndHour)。 +// ⚠️ 13:00 轮没有例外,全部按 07:00;只有 18:00 轮有三处更早的上界。 +function centerWindowEndHour(centerKey, roundKey) { + const center = CENTERS.find((c) => c.key === centerKey); + const override = center && center.windowEndHourByRound + ? center.windowEndHourByRound[roundKey] + : undefined; + return Number.isFinite(override) ? override : null; +} + +/** 该主仓在这一轮的窗口上界('YYYY-MM-DD HH:mm:00'),没有例外就用轮次默认 */ +function centerWindowEnd(centerKey, round) { + const hour = centerWindowEndHour(centerKey, round.key); + if (hour === null) return round.windowEnd; + return `${round.date} ${String(hour).padStart(2, '0')}:00:00`; +} + // 「横林三仓」与「横林二仓」不含对方,安全;但「崔桥工厂品牌仓」含「崔桥工厂仓」片段, // 靠关键词长度倒序匹配即可(见 resolveCenter)。 const SORTED_KEYWORDS = CENTERS @@ -90,13 +141,48 @@ const SORTED_KEYWORDS = CENTERS .flatMap((c) => c.keywords.map((kw) => ({ kw, center: c }))) .sort((a, b) => b.kw.length - a.kw.length); -// ── 时效轮次(业务给定)────────────────────────────────────────────────── -// cutoffHour:当天几点截止;windowEndHour:只核验「创建时间不晚于该时刻」的订单 -const ROUNDS = Object.freeze([ - { key: 'NOON', label: '12:00 轮', cutoffHour: 12, windowEndHour: 7 }, - { key: 'AFTERNOON', label: '16:00 轮', cutoffHour: 16, windowEndHour: 16 }, +// ── 排除仓(业务指定,2026-10-20)────────────────────────────────────────── +// 「京仓-横林二仓京东云仓」(wms_co_id=15197138, Id=129) 虽然名字里带「横林二仓」, +// 但属于京东云仓代运营,**不纳入横林二仓的时效考核**(业务明确要求排除)。 +// ⚠️ 用完整片段「横林二仓京东云仓」而不是「京东云仓」: +// 仓库表里还有「京仓-崔桥工厂京东云仓」(15283032)、 +// 「京仓-山东青岗集京东云仓」(15283033),写短了会把它们一起排掉; +// 也不会误伤「简尚-横林二仓京东仓」(16040225) 与「京仓-横林二仓京东自营仓」(15835893)。 +const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze([ + '横林二仓京东云仓', ]); +// 长关键词优先,避免以后加了「XX京东云仓」被短词抢先 +const SORTED_EXCLUSIONS = [...EXCLUDED_WAREHOUSE_KEYWORDS].sort((a, b) => b.length - a.length); + +// ── 时效轮次(业务给定,2026-10-20 二次调整)────────────────────────────── +// verifyHour :当天几点开始核验(13:00 / 18:00) +// windowEndHour :只核验「**审核时间**不晚于该时刻」的订单(07:00 / 16:00) +// ⚠️ 两者**不是同一个东西**,别再用一个字段兼着当: +// 旧实现拿 12:00 同时当核验时刻和窗口上界,导致「12:00 轮」实际把 +// 07:00~12:00 创建的单也算进来了,与页面上写的「核验 07:00 前」不一致。 +// ⚠️ 也不能在 windowEnd 之后再叠「满 N 小时」门槛 —— 13:00 轮里 07:00 审核的单 +// 只等了 6 小时,加门槛就永远不出现在超时列表里(第七版被打回的原因)。 +const ROUNDS = Object.freeze([ + { key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEndHour: 7 }, + { key: 'AFTERNOON', label: '18:00 轮', verifyHour: 18, windowEndHour: 16 }, +]); + +// ── 订单窗口下界(含历史的天数,**业务可调**)──────────────────────────── +// 业务口径(2026-10-20 三次纠偏):「截止某时刻创建、至今仍未发货」—— 不按自然日切, +// 昨天、前天乃至更早挂着没发的单都要算进来(滚动累积的待发池)。 +// +// ⚠️ 但不能无限往前:rpt 下全是视图、没有索引,扫得越远越慢。2026-10-20 实测 +// (jst_Orders + jst_qmcust 并行,含已发单的窗口全量): +// 近 7 天 → 约 5s 近 30 天 → 约 45s +// 近 90 天 → 约 130s 近 365 天 → 约 500s(基本等于页面卡死) +// 所以默认取 30 天:覆盖「今日 + 近一个月累积未发」,兼顾口径与可用性。 +// +// 同时实测 28 个目标仓的**全部未发单**:共 1,228 条,其中近 7 天 257、 +// 近 30 天 496、近 90 天 792、更早 436(最早 2025-10-24)。 +// ⟹ 30 天能看到约 40%;要看更久的老单,把下面数字改大,代价是首屏更慢。 +const HISTORY_LOOKBACK_DAYS = 30; + // 只排除取消/合并单;Question 仍计入(与 mkhBomService 待发货口径一致) // // 🔥 Split(拆单):**必须排除**。拆单是「父单被拆成子单」的中间态,父单本身 @@ -107,6 +193,127 @@ const ROUNDS = Object.freeze([ // 发货需求,被算进去也无害;这边是「考核仓库时效」,误算就要背锅,必须排除。 const EXCLUDED_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split')`; +// ── 「已发货 / 待发货」状态口径(2026-10-20 四次修正,业务反馈数据错误)──── +// 🔥 之前只排除了 Cancelled/Merged/Split,靠 `send_date IS NULL` 判「未发」, +// 实测**大量已发货的单被算成超时未发**(业务一眼就看出来了)。原因有三个: +// ① `send_date` 在部分店铺/平台不回写,订单状态已经是 Sent/Delivering, +// send_date 却还是 NULL(样本 9838341:Delivering,send_date 空); +// ② 拆单父单 `status` 不是 'Split' 而是 'Delivering'(父单随子单状态走), +// `is_split=1` 才是真标记(样本 9838537 就是这样); +// ③ 合并单 `is_merge=1` 同理,被并入主单,自己永不发货。 +// 订单状态实测分布(2026-10-20,近 7 天,两表合计): +// Sent 61,833 / Cancelled 22,292 / Split 4,927 / Delivering 2,567 / +// Question 1,500 / Merged 682 / WaitOuterSent 30 / WaitConfirm 12 +// ⟹ 判定「已发货」= status ∈ (Sent, Delivering) **或** send_date 非空 +// **或** 销售出库单表能查到该 o_id(见 OUTBOUND_TABLES) +const SHIPPED_STATUS = Object.freeze(['Sent', 'Delivering']); + +// 待出库候选:把「已发货」状态一并排除。判定顺序是 +// ① SQL 先粗筛(下面这个条件 + send_date IS NULL + 非拆单/合并) +// ② Node 侧再查销售出库单表精确剔除(有出库记录 = 其实已出库) +// ③ Node 侧再把 Question(疑问单)分流到「业务挂起」,**不计超时** +// —— 注意 Question 必须留在候选里,否则统计不出挂起单数; +// 它只是不进 orders(超时明细),走 heldCount / heldOrders。 +const PENDING_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split', N'Sent', N'Delivering')`; + +// 拆单父单 / 被合并单:自己**永不发货**,货由子单或主单发出。 +// 只排 status 不够(父单状态会跟着子单变成 Delivering),必须看标记位。 +// ⚠️ is_split / is_merge 是 bit 且可能为 NULL —— 必须 ISNULL 兜底, +// 否则 `o.is_split = 0` 会把 NULL 行全部丢掉(实测会少掉大半候选)。 +const NOT_SPLIT_MERGED_SQL = `ISNULL(o.is_split, 0) = 0 AND ISNULL(o.is_merge, 0) = 0`; + +// ── 销售出库单表(聚水潭「销售出库单」页面的数据来源)────────────────────── +// 业务明确要求(2026-10-20):「已出库 / 待出库」与「创建时间」都以聚水潭 +// **销售出库单**页面为准,不再用订单表的 send_date 猜。 +// +// 两张表与订单表同构、店铺互斥: +// rpt.jst_salesOutboundInfo ← jst_Orders(京东/拼多多/抖音/得物/唯品会),103.7 万行 +// rpt.jst_qmcust_salesoutbound ← jst_qmcust_orderlist(天猫/淘宝/1688),39.8 万行 +// 字段口径(2026-10-20 实测确认): +// · `created` —— **出库单创建时间**(业务在聚水潭出库单页面看到的那个), +// 时点分布自然(8~23 点均匀,凌晨 4 点有一波补录),可用; +// · `io_date` —— **出库时间**(真正发货那一刻); +// · `modified` —— ⚠️ 会被数仓每天凌晨批量刷新(实测全被刷成 02:2x / 04:3x), +// 与订单表的 modified 一样**不可当作创建时间**; +// · **两张表都只含「已出库」的单**:103.7 万行里 io_date 为 0 条空值。 +// ⟹ 「待出库」无法从出库单表直接查,只能靠「查不到 = 还没出库」反推。 +const OUTBOUND_TABLES = Object.freeze({ + jst_Orders: 'jst_salesOutboundInfo', + jst_qmcust_orderlist: 'jst_qmcust_salesoutbound', +}); + +// ── 聚水潭「订单实时状态」表(2026-10-20 五次修正:按审核时间核算)────────── +// 🔥 前面三版的根因都在这里:`jst_Orders.status` / `jst_qmcust_orderlist.status` +// 是**下单那一刻的快照**,之后聚水潭侧取消、售后、审核都不会回写。 +// 实测(2026-10-20):业务反馈「聚水潭查不到」的 6 单,订单表里 +// status 全是 `WaitConfirm`,而在本表里真实状态是 **已取消**(发货前售后); +// 另有 1 单真实状态是 **异常**。上一版把它们当成「待出库超时」报了出来。 +// +// `rpt.jst_order_express_details`(402 万行,query_time 准实时,最近一次今天 11:55) +// 是聚水潭订单状态的准实时镜像,字段语义(近 30 天实测覆盖率): +// · `order_status_name` —— 聚水潭真实订单状态 +// 已发货 269,991(send_time 100%)|已取消 64,440|异常 6,669 +// 发货中 2,448(first_oa 99.96%、send_time 0)|已支付待审核 833 +// · `first_oa_confirm_time` —— **订单审核时间**(首次 OA 确认)= 业务要的起算点 +// · `last_oa_confirm_time` —— 最后一次审核(重新审核会刷新) +// · `send_time` —— **发货时间**(已发货单 100% 有值,未发单必为空) +// · `warehouse_name` —— 发货仓名称(可直接套 CENTERS 关键词匹配) +// · `logistic_status_name` —— 物流状态(无物流单 / 未揽收 / 运输中 …) +// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。 +const ORDER_STATE_TABLE = 'jst_order_express_details'; + +// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态** +const SHIPPING_STATUS = '发货中'; +// 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」) +const UNAUDITED_STATUS = '已支付待审核'; +// 「异常」= 订单异常(缺货/地址/风控等),有审核时间的单列「异常待处理」,不计仓库超时 +const ABNORMAL_STATUS = '异常'; +// 不进考核也不展示的状态:已发货(已发)、已取消(业务侧取消/发货前售后)、 +// 等供销商|外仓发货(不是本仓发) +// 🔥 「已取消」(业务口头叫**作废单**)必须排除(2026-10-20 业务定:「作废订单不用考核」): +// 这类单是客户付款后取消 / 发货前售后(标签带「发货前售后」),仓库根本不会发货。 +// 2026-10-20 实测:近 30 天已取消 64,440 单,量级远大于真正该考核的「发货中」2,448 单, +// 漏掉这个排除,整个页面会被作废单淹没。 +const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']); + +// ── 起算点:聚水潭「创建时间」────────────────────────────────────────────── +// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。 +// +// ⚠️ 状态表 `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), +// 这才是聚水潭订单列表里「创建时间」那一列。 +// +// 性能(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 实测最快(再大收益递减,再小批数太多) +const CREATED_BACKFILL_BATCH = 200; + +// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ───────────────── +// 业务 2026-10-20 反馈 so_id 5127276315127080023(内部单号 9486414)是作废单, +// 却出现在超时未发货里。四张表对照后的真相: +// · 状态表 jst_order_express_details:order_status_name=**发货中**、send_time 空 +// ⟹ 只信状态表的话,这单就是「超时未发」—— 我们就是这么误判的; +// · 订单表 jst_qmcust_orderlist:status=**Merged**、send_date=2026-09-22 06:54:49; +// · 销售出库单:两张表**都有**出库记录(io_date 2026-09-22 10:59:55)。 +// ⟹ 根因:状态表的 `send_time` 对**被合并单 / 拆单父单不回写**, +// 单子早随主单发走了,状态表还停在「发货中」。 +// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位, +// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。 +const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']); + +// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。 +// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」, +// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。 +const SHIP_TIMELINESS_HOURS = 24; + const WAREHOUSE_CACHE_TTL_MS = 60 * 60 * 1000; let warehouseCache = { at: 0, rows: [] }; @@ -121,12 +328,20 @@ function pad2(n) { /** * 用「关键词最长匹配」把仓库名归到主仓。 - * @returns {object|null} CENTERS 里的元素;无法归属返回 null + * + * ⚠️ **先判排除、再判归属**:被排除的仓(如「京仓-横林二仓京东云仓」) + * 即使名字里带主仓关键词,也直接返回 null —— 它的订单不计入任何主仓。 + * + * @returns {object|null} CENTERS 里的元素;无法归属或被排除返回 null */ function resolveCenter(warehouseName) { if (!warehouseName) return null; + const name = String(warehouseName); + for (const kw of SORTED_EXCLUSIONS) { + if (name.includes(kw)) return null; + } for (const item of SORTED_KEYWORDS) { - if (warehouseName.includes(item.kw)) return item.center; + if (name.includes(item.kw)) return item.center; } return null; } @@ -149,16 +364,23 @@ async function loadCenterWarehouseMap(force = false) { )) || []; const byCenter = new Map(CENTERS.map((c) => [c.key, []])); const unmatched = []; + const excluded = []; for (const row of rows) { - const center = resolveCenter(String(row.name || '')); + const name = String(row.name || ''); + // 被业务排除的仓单独记录,方便日后核对「是不是排多了/排少了」 + if (SORTED_EXCLUSIONS.some((kw) => name.includes(kw))) { + excluded.push({ wmsCoId: Number(row.wms_co_id), name }); + continue; + } + const center = resolveCenter(name); if (center && byCenter.has(center.key)) { byCenter.get(center.key).push({ wmsCoId: Number(row.wms_co_id), warehouseId: Number(row.Id), - name: String(row.name || ''), + name, }); } else { - unmatched.push({ wmsCoId: Number(row.wms_co_id), name: String(row.name || '') }); + unmatched.push({ wmsCoId: Number(row.wms_co_id), name }); } } const list = CENTERS.map((c) => ({ @@ -167,7 +389,12 @@ async function loadCenterWarehouseMap(force = false) { placeholder: Boolean(c.placeholder), warehouses: byCenter.get(c.key) || [], })); - warehouseCache = { at: Date.now(), rows: list, unmatchedCount: unmatched.length }; + warehouseCache = { + at: Date.now(), + rows: list, + unmatchedCount: unmatched.length, + excluded, + }; return list; } @@ -178,12 +405,17 @@ function clearWarehouseCache() { /** * 计算「当前已生效」的核验轮次。 * - * 12:00 之前 → **未到核验时间,返回 null**。 - * 业务明确要求(2026-10-20 确认):12:00 之前不做核验, + * 13:00 之前 → **未到核验时间,返回 null**。 + * 业务明确要求(2026-10-20 确认):未到核验时刻不做核验, * 前端显示「未到核验时间」,**不要回填昨天的数据** —— * 昨天已结算的结果留到昨天,拿到今天看容易被当成今天的超时。 - * 12:00~15:59 → 用 12:00 轮(窗口 00:00~07:00) - * 16:00 之后 → 用 16:00 轮(窗口 00:00~16:00,覆盖范围更大) + * 13:00~17:59 → 用 13:00 轮(今天 00:00~07:00 **审核** + 历史未发) + * 18:00 之后 → 用 18:00 轮(截止 16:00 前**审核** + 今天之前未发的历史单) + * + * 🔥 「含历史」是 2026-10-20 业务第三次纠偏: + * 原实现把下界卡在「今天 00:00」,只统计当天新单,把昨天及更早挂着没发的 + * 单全漏掉了。业务口径是「……然后再看历史未发的订单」—— + * 这是一个滚动累积的待发池,不按自然日切。 * * @param {Date} now 以**东八区本地时间**为准的 Date(生产/本地机器均为东八区) * @returns {object|null} null 表示当前不在任何核验时段内 @@ -194,20 +426,27 @@ function resolveActiveRound(now = new Date()) { const minutes = hour * 60 + minute; let active = null; for (const round of ROUNDS) { - if (minutes >= round.cutoffHour * 60) active = round; + if (minutes >= round.verifyHour * 60) active = round; } - // 12:00 前:首轮核验还没开始,不做判定 + // 13:00 前:首轮核验还没开始,不做判定 if (!active) return null; const y = now.getFullYear(); const m = pad2(now.getMonth() + 1); const d = pad2(now.getDate()); + // 护栏起点(不是业务下界,见 HISTORY_LOOKBACK_DAYS) + const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS); + const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`; return { ...active, - deadline: `${y}-${m}-${d} ${pad2(active.cutoffHour)}:00:00`, - windowStart: `${y}-${m}-${d} 00:00:00`, + // deadline = 核验时刻:判「发了没发 / 超时多久」都用它 + deadline: `${y}-${m}-${d} ${pad2(active.verifyHour)}:00:00`, + windowStart: floorStr, + // windowEnd = 订单窗口上界:SQL 的取数上界,**不是** deadline windowEnd: `${y}-${m}-${d} ${pad2(active.windowEndHour)}:00:00`, + // 含历史未发单 —— 前端据此把「统计窗口 00:00~07:00」改成「截止 07:00 前(含历史)」 + includeHistory: true, date: `${y}-${m}-${d}`, settled: false, isPreviousDay: false, @@ -222,25 +461,72 @@ function describeRounds(now = new Date()) { const m = pad2(now.getMonth() + 1); const d = pad2(now.getDate()); const date = `${y}-${m}-${d}`; + const minutes = now.getHours() * 60 + now.getMinutes(); + const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS); + const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`; return ROUNDS.map((r) => ({ key: r.key, label: r.label, date, - deadline: `${date} ${pad2(r.cutoffHour)}:00:00`, - windowStart: `${date} 00:00:00`, + verifyHour: r.verifyHour, + deadline: `${date} ${pad2(r.verifyHour)}:00:00`, + windowStart: floorStr, windowEnd: `${date} ${pad2(r.windowEndHour)}:00:00`, - active: now.getHours() * 60 + now.getMinutes() >= r.cutoffHour * 60, + includeHistory: true, + active: minutes >= r.verifyHour * 60, })); } +/** + * 下一轮的核验时刻。 + * + * ⚠️ 2026-10-20 第九版起**不再使用**:轮次归属本身就是按审核时间卡的, + * 「审核晚于 13:00 的单」根本进不了 13:00 轮,会自然落到 18:00 轮, + * 不需要额外顺延。函数保留是因为「按下一轮核验时刻算」这个业务表述 + * 未来若用于别的口径(例如按创建时间分轮),逻辑还是这一套。 + * + * @param {object} round resolveActiveRound / describeRounds 的返回项(需带 date/key) + * @returns {string} 'YYYY-MM-DD HH:mm:00' + */ +function nextRoundDeadline(round) { + const idx = ROUNDS.findIndex((r) => r.key === round.key); + if (idx >= 0 && idx + 1 < ROUNDS.length) { + return `${round.date} ${pad2(ROUNDS[idx + 1].verifyHour)}:00:00`; + } + // 当天最后一轮 → 次日首轮 + const [y, m, d] = String(round.date).split('-').map(Number); + const next = new Date(y, (m || 1) - 1, (d || 1) + 1); + return `${next.getFullYear()}-${pad2(next.getMonth() + 1)}-${pad2(next.getDate())}` + + ` ${pad2(ROUNDS[0].verifyHour)}:00:00`; +} + module.exports = { CENTERS, ROUNDS, + HISTORY_LOOKBACK_DAYS, + EXCLUDED_WAREHOUSE_KEYWORDS, resolveCenter, + centerWindowEndHour, + centerWindowEnd, + nextRoundDeadline, loadCenterWarehouseMap, clearWarehouseCache, + getWarehouseCache: () => warehouseCache, resolveActiveRound, describeRounds, EXCLUDED_STATUS_SQL, + SHIPPED_STATUS, + PENDING_STATUS_SQL, + NOT_SPLIT_MERGED_SQL, + OUTBOUND_TABLES, + ORDER_STATE_TABLE, + SHIPPING_STATUS, + UNAUDITED_STATUS, + ABNORMAL_STATUS, + IGNORED_STATES, + CREATED_SOURCE_TABLES, + CREATED_BACKFILL_BATCH, + VOID_ORDER_STATUS, + SHIP_TIMELINESS_HOURS, toNumber, }; diff --git a/backend/src/services/warehouseShipTimelinessService.js b/backend/src/services/warehouseShipTimelinessService.js index a1990f0..7866c56 100644 --- a/backend/src/services/warehouseShipTimelinessService.js +++ b/backend/src/services/warehouseShipTimelinessService.js @@ -1,110 +1,354 @@ 'use strict'; /** - * 仓储中心 24 小时发货时效 —— 取数/聚合服务(2026-10-20 新增) - * ──────────────────────────────────────────────────────────────────────────── - * 配置与口径定义见 warehouseShipTimelinessRegistry.js(主仓关键词、轮次、时区说明)。 + * 仓储中心 24 小时发货时效 —— 服务层 * - * ── 一次请求做几件事 ────────────────────────────────────────────────────── - * ① 读仓库表 → 主仓 → wms_co_id 集合(缓存 1h) - * ② 一次 SQL 同时取「超时未发」和「已超时已发」两类,按 wms_co_id 分组 - * ③ 归并到主仓,返回每仓的 count / orderAmount,以及全量超时明细 + * ═══════════════════════════════════════════════════════════════════════════ + * 口径(2026-10-20 第九次修正,业务最终定版) + * ═══════════════════════════════════════════════════════════════════════════ + * 业务原话(按时间顺序,最后一条为准): + * 17:12「当日 7 点前订单(7 点前订单**按照审核时间算**),13:00 核验未发就算超时」 + * 17:5x「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到 16 点审核的订单 18:00 核验; + * 然后再看历史未发的订单」 * - * ── SQL 设计要点(踩过的坑)─────────────────────────────────────────────── - * · 两张订单表**店铺互斥**,用 UNION ALL 合并后一次性分组,不要分别查再在 - * Node 侧合并 —— 两趟扫描在 rpt 视图上代价翻倍。 - * · `wms_co_id IN (...)` 直接内联仓库 ID(都是整数,无注入风险),因为仓库 - * 集合动态生成,用参数化会引入可变数量的 @p0..@pN,反而难维护。 - * · **不要在 ORDER BY 里对聚合列做算术**(见 MEMORY:会触发灾难计划)。 - * 这里 SQL 只 GROUP BY 取原始计数/金额,排序与派生字段全在 Node 侧算。 - * · 时间比较一律用**字面量字符串**(形如 '2026-10-20 16:00:00'),由 - * resolveActiveRound 在东八区算出;不用 GETDATE() 做相对偏移,避免 - * 数据库与应用的时区假设不一致。 - * · 明细行带 `TOP (@limit)`,`@limit` 走参数化,避免界面一次拉几万行。 + * ⟹ 三条铁律: + * ① **轮次归属看「审核时间」**(`last_oa_confirm_time`,聚水潭显示值): + * 今天 00:00~07:00 审核 → 13:00 轮 + * **截止今天 16:00 前审核**的单 → 18:00 轮 + * (业务 2026-10-20 补正:18:00 核验的是 16:00 前**所有**审核过的单, + * 不是「今天 00:00~16:00」这一个自然日切片 —— 含今天之前审核、 + * 至今没发的历史未发单,也含 7 点前审核到 13:00 还没发出的; + * 山东两仓 15:00、河南洛宁 13:00 是仓级例外,见 registry) + * ⚠️ **不看创建时间**:业务亲自指出的 9818476(创建 10-18 12:49 / 审核 10-20 13:06) + * 和 9823980(创建 10-18 20:40 / 审核 10-20 09:15)都是下午/上午才审的, + * 按创建时间会被错算进 13:00 轮 —— 第八版就是这么错的。 + * ② **历史未发的单一律算**:昨天及更早审核、至今没发货的,两个轮次都要看到 + * (它们早就该发了,不该等今天的分段窗口)。 + * ③ **没有「满 24 小时」门槛**:到核验时刻仍未发就是超时,哪怕只等了 2 小时。 + * 第七版加了 24h 门槛,13:00 轮永远看不到当天那批,被业务打回。 + * ④ **自提 / 中铁 / 壹米滴答 三个快递不考核**(业务 2026-10-20 补充): + * 按 `logistic_company_name` 包含匹配(库里是「中铁物流」「壹米滴答专线」), + * 命中直接跳过,不进出口径任何一处数字,剔除量记在 `summary.carrierSkipped`。 + * + * 已等时长 = **审核时间 → 本轮核验时刻**(13:00 / 18:00)。 + * 🔥 审核时间取 last_oa_confirm_time(**最后一次**审核)—— + * 这就是聚水潭订单列表「审核时间」那一列显示的值,业务拿它对账。 + * 之前取首次审核,业务抽查 3 单(5127368691073091434 等)立刻发现不一致: + * 首次 10-18 13:15 vs 聚水潭 10-20 13:38。**别再改成 first_oa**。 + * 因为窗口也按审核时间卡,「审核 13:06 的单」只会进 18:00 轮, + * 已等自然用 18:00 算,不会出现「0 分」。 + * 明细列表并列「审核时间 / 发货时间」—— 业务 2026-10-20 要求**去掉创建时间**, + * 改成看各订单的发货时间(未发的显示「未发货」),对账时直接和聚水潭发货时间比。 + * + * 🔥 「已发 / 未发」还是以聚水潭「销售订单」的发货状态为准(= 状态表 send_time)。 + * **不要**改用运单号(logistic_id)判已发:实测同一店铺里, + * 「已发货」的单独有运单号 + 未揽收,「发货中」的单也有运单号 + 未揽收 —— 判不出来。 + * 运单号只用来在明细里标「已打单 · 未揽收」,给业务一个「仓库干到哪一步」的线索。 + * + * 数据源:`rpt.jst_order_express_details`(聚水潭订单状态准实时镜像,402 万行) + * + * · 窗口归属 / 时长基准 = `last_oa_confirm_time`(最后一次审核 = 聚水潭显示值; + * 缺失回退 first_oa / 创建时间 / 付款时间),来源写进 `benchmarkSource`。 + * · 创建时间(**仅供展示对账**) = `jst_Orders.created` / `jst_qmcust_orderlist.created` + * 🔥 状态表里**没有**这一列,必须回订单表按 `o_id = inner_order_id` 补: + * · `createdAt`(状态表那个 datetime2)= 同步落库时间,晚 1.5~3.5h,**不能用** + * · `created` 才是聚水潭订单列表里的「创建时间」(比下单晚 15~30 秒) + * · 已发货 = `send_time` 非空(已发货单 100% 有值) + * · 待发货 = `send_time` 为空 且 `order_status_name = 发货中` + * · 仓库 = `warehouse_name`(直接套 CENTERS 关键词归属主仓) + * + * ── 为什么弃用 jst_Orders.status(前三版全栽在这)────────────────────────── + * 订单表的 `status` 是**下单那一刻的快照**,之后聚水潭侧取消、发货前售后、 + * 审核通过都不会回写。实测(2026-10-20)业务反馈「聚水潭查不到」的 6 单: + * 订单表 status 全是 `WaitConfirm`(看起来像待发货), + * 状态表真实状态是 **已取消**(标签带「发货前售后」),另 1 单是 **异常**。 + * ⟹ 所以「状态 / 是否已发」一律以状态表为准,**订单表只用来补创建时间**。 + * + * 时间列语义(已逐一实测,别再猜): + * · `order_date` / `pay_date` —— 平台下单 / 付款时间(客户那边的时间) + * · `created`(订单表) —— ✅ **聚水潭创建时间**(展示用,比下单晚 15~30 秒) + * · `createTime`/`updateTime` —— 数仓灌库时间(带毫秒、批量、滞后约 5 分钟) + * · `modified` —— ⚠️ 最后修改时间,每天凌晨被批量刷新,不可用 + * · `first_oa_confirm_time` —— 首次审单时间(审核时间缺失时的第二顺位回退) + * · `last_oa_confirm_time` —— ✅ **最新审核时间(本版考核基准)** + * · `send_time` —— **发货时间** + * · `query_time` —— 本表快照时间(判数据新鲜度) + * + * ── 三态分流(互斥)───────────────────────────────────────────────────── + * 超时未发货 = 发货中 + send_time 空 + 审核时间已过本轮窗口上界(07:00/16:00) + * → orders(考核仓库) + * 不计考核 = 已支付待审核(没审单)/ 异常(订单异常) → heldOrders + * 不展示 = 已发货 / 已取消(作废)/ 等供销商|外仓发货 → 直接丢弃 + * 🔥 作废单(已取消)不考核:近 30 天 64,440 单,是「发货中」2,448 单的 26 倍, + * 不排除的话整页都是客户取消掉的单。 + * + * ── 核验轮次(业务给定)────────────────────────────────────────────────── + * `verifyHour` —— 几点核验(13:00 / 18:00),判「超时多久」用它 + * `windowEndHour` —— 只看该时刻**前审核**的单(07:00 / 16:00,仓级例外见 registry) + * ⚠️ 两者不是同一个东西,别合成一个字段 + * ⚠️ 也**不要**再引入「满 N 小时才算超时」的门槛(第七版踩过,会被业务打回) + * ⚠️ 窗口**要**卡审核时间(第九版定版)。第六版曾因「卡审核时间 13:00 轮只剩 2 单」 + * 退回创建时间,结果 9818476 / 9823980 这类下午才审的单被错算进 13:00 轮 → + * 业务亲自纠正:「7 点前订单**按照审核时间算**」。审核时间少是因为 13:00 轮本来就 + * 只该有早上那批,不是数据不对。 + * + * ── 性能(2026-10-20 实测,四个坑都踩过)────────────────────────────────── + * ① **不要 SELECT 宽列**。同一 WHERE 只取 2 列 0.7s,取 15 列(含 + * `order_labels` / `buyer_message` / `remark`)**54s** —— 视图没法做 + * 覆盖扫描,多取一列就是几万次基表回表。本表只投影 10 个窄列。 + * ② **不要回订单表补金额**。`o_id IN (400 个)` 打两张订单宽视图,单条实测 + * **38.7s**。发货时效不需要金额,直接不做。 + * (但补 `created` **可以**:只取两列时 9,676 个候选只要 14.5s + 2.9s。) + * ③ **不要额外查新鲜度**。`MAX(send_time)` 又是一次 402 万行扫描(21.1s)。 + * 快照时间直接从主查询的 `query_time` 在 Node 里取 MAX。 + * ④ `SELECT *` 更是灾难 —— 单 form 单号查一次 **5 分钟**不返回。 + * ⑤ 走 `querySlow`,普通池 60s 池级超时 + 自动重试会把 9s 拖成 120s。 + * + * ── 时区 ───────────────────────────────────────────────────────────────── + * 所有时间列都是**东八区本地时间**;服务与库都在东八区。 + * **不要**在 Node 侧用 UTC 日期判断。 */ const cloudDatabase = require('../config/cloudDatabase'); const registry = require('./warehouseShipTimelinessRegistry'); -const { CENTERS, EXCLUDED_STATUS_SQL, toNumber } = registry; +const { + ORDER_STATE_TABLE, + SHIPPING_STATUS, + UNAUDITED_STATUS, + ABNORMAL_STATUS, + IGNORED_STATES, + CREATED_SOURCE_TABLES, + CREATED_BACKFILL_BATCH, + VOID_ORDER_STATUS, + resolveCenter, +} = registry; // 明细上限:只用于页面展示,超出部分用 truncated 标记提示 const DEFAULT_DETAIL_LIMIT = 500; const MAX_DETAIL_LIMIT = 2000; +// 状态表快照超过这个分钟数没刷新 → 页面提示「数据滞后」。 +// 正常 ETL 每小时至少刷一轮(每小时约 1000 行有变化),90 分钟足够容错。 +const STATE_STALE_MINUTES = 90; + /** - * 把 UTC Date / 字符串 统一格式化成 'YYYY-MM-DD HH:mm:ss'(按服务器本地时区)。 - * mssql 驱动默认把 datetime 解析为 UTC 语义的 Date,这里只做展示,统一成 - * 与库内一致的东八区墙钟时间。 + * 不参与发货时效考核的快递(业务 2026-10-20 指定): + * 自提 —— 客户自己来拉,仓库不负责派送 + * 中铁 —— 中铁物流(大件专线) + * 壹米滴答 —— 专线物流(大件) + * 命中即可,用**物流公司名**包含匹配;库里的实际值是「中铁物流」「壹米滴答专线」这种。 + * 只影响「超时未发」判定,不影响「已发」判定。 */ +const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答']; + +/** + * 订单表(= 聚水潭「销售订单」镜像)里「已发货」的状态值。 + * + * 🔥 业务 2026-10-20 要求「已发/未发参考聚水潭销售订单页面」—— + * 销售订单页面显示「已发货」= status `Sent`。`send_date` 很多店铺不回写 + * (实测 103 单里 103 单都是空),所以必须把 status 也当依据。 + * ⚠️ **不要**把 `Delivering`(发货中)算成已发 —— 那是「已审核待出库」, + * 正是本页要考核的对象;算进来整页就空了。 + */ +const ORDER_SHIPPED_STATUS = ['Sent']; + +/** + * 结果缓存。 + * + * 这张视图单次查询实测 10~60 秒(取决于缓存温度与库负载),页面又是 + * `AUTO_REFRESH_MS` 定时轮询 —— 不缓存的话每轮轮询都会重压一次库, + * 页面也会一直转圈。缓存 key 含「轮次 + 日期 + limit」, + * 业务点「刷新」时前端带 `force=1` 绕过缓存强制重查。 + */ +const CACHE_TTL_MS = Number(process.env.CANGKU_WST_CACHE_TTL_MS) || 5 * 60 * 1000; +let resultCache = { key: '', at: 0, data: null }; + +/** 清缓存(切轮次 / 手工排查时用) */ +function clearShipTimelinessCache() { + resultCache = { key: '', at: 0, data: null }; +} + +/** 把 datetime / Date / 字符串统一成 'YYYY-MM-DD HH:mm:ss'(东八区,不做时区换算) */ function formatDateTime(value) { if (!value) return ''; - if (typeof value === 'string') return value.replace('T', ' ').slice(0, 19); - const d = value instanceof Date ? value : new Date(value); - if (Number.isNaN(d.getTime())) return ''; - const pad = (n) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` - + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return ''; + const p = (n) => String(n).padStart(2, '0'); + return `${value.getFullYear()}-${p(value.getMonth() + 1)}-${p(value.getDate())}` + + ` ${p(value.getHours())}:${p(value.getMinutes())}:${p(value.getSeconds())}`; + } + const s = String(value); + if (!s) return ''; + return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19); } -/** 两个 'YYYY-MM-DD HH:mm:ss' 相差多少分钟(四舍五入) */ +/** 两个时间文本相差多少分钟(to - from);任一为空返回 0 */ function diffMinutes(fromText, toText) { - const a = new Date(String(fromText).replace(' ', 'T')).getTime(); - const b = new Date(String(toText).replace(' ', 'T')).getTime(); - if (!Number.isFinite(a) || !Number.isFinite(b)) return 0; - return Math.round((b - a) / 60000); + const a = formatDateTime(fromText); + const b = formatDateTime(toText); + if (!a || !b) return 0; + const pa = new Date(a.replace(' ', 'T')).getTime(); + const pb = new Date(b.replace(' ', 'T')).getTime(); + if (!Number.isFinite(pa) || !Number.isFinite(pb)) return 0; + return Math.floor((pb - pa) / 60000); } /** - * 构造两表合并的子查询。 + * 取「聚水潭订单状态」里所有未发货的单(发货中 / 待审核 / 异常)。 * - * 🔥 三条必须同时满足(2026-10-20 实测踩坑): - * ① `order_date >= @windowStart` —— **必须有下界**。只写 `<= @deadline` 会把 - * 一年前创建、至今未发的历史单全捞进来(实测 13,553 条,最早 2025-09-01)。 - * ② `order_date <= @deadline` —— 上界:截止时刻后创建的订单不参与本轮考核。 - * ③ Split 已在 EXCLUDED_STATUS_SQL 里排掉(拆单父单永不发货)。 + * 为什么不用 wms_co_id 过滤:本表只有 `warehouse_name`,没有仓 ID。 + * 近 30 天三类合计约 1 万行,一次取回在 Node 侧按仓库名归属主仓,代价可接受。 * - * @param {number[]} wmsIds 主仓下所有 wms_co_id - * @param {string} deadline 截止时刻字面量 - * @param {'pending'|'shippedLate'} mode 取哪一类 + * ⚠️ 只投影 11 个**窄列**:`order_labels`/`buyer_message`/`remark` 这类长文本 + * 会把 0.7s 的查询拖到 54s(视图无法覆盖扫描,每行都要回基表)。 + * ⚠️ `send_time IS NULL` 是关键:已发货单在这里 100% 有值。 + * + * 🔥 2026-10-20 第八版:**不再把「创建 ≤ 核验−24h」下推到 SQL**。 + * 本版口径是「到核验时刻未发即超时」,`send_time IS NULL` 就是候选; + * 窗口上界改在 Node 侧按**审核时间**判。SQL 里那两个时间列只是用来 + * 判断窗口归属,不再参与「等了多少小时」的门槛。 */ -function buildUnionSql(wmsIds, deadline, mode) { - const idList = wmsIds.join(','); - // pending:未发货且窗口内已创建 → 超时未发 - // shippedLate:已发货但发货时间晚于截止时刻 → 已超时已发 - const cond = mode === 'pending' - ? `o.send_date IS NULL` - : `o.send_date IS NOT NULL AND o.send_date > @deadline`; - const branch = (table) => ` - SELECT N'${table}' AS src, o.o_id, o.so_id, o.shop_name, o.shop_id, - o.order_date, o.send_date, o.status, o.shop_status, - o.wms_co_id, o.pay_amount, o.labels - FROM dbo.${table} o - WHERE o.wms_co_id IN (${idList}) - AND o.order_date >= @windowStart - AND o.order_date <= @deadline - AND ${cond} - AND ${EXCLUDED_STATUS_SQL}`; - return `${branch('jst_Orders')} - UNION ALL${branch('jst_qmcust_orderlist')}`; +async function loadPendingStates(round) { + const rows = await cloudDatabase.querySlow( + // 🔥 时间列直接取原始 datetime,**不要**在 SQL 里 CONVERT 成字符串: + // CONVERT 会作用到扫描到的每一行(谓词下推不到视图里),实测把点查拖到 40s+; + // mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原 + // 就是库里存的北京时间,不会偏移 8 小时。 + `SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name, + e.order_status_name, e.logistic_status_name, + e.logistic_id, e.logistic_company_name, + e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time, + e.send_time, e.query_time + FROM dbo.${ORDER_STATE_TABLE} e + WHERE e.send_time IS NULL + AND e.pay_time >= @windowStart + AND e.order_status_name IN (@shipStatus, @st1, @st2)`, + { + shipStatus: SHIPPING_STATUS, + st1: UNAUDITED_STATUS, + st2: ABNORMAL_STATUS, + windowStart: round.windowStart, + }, + // 视图谓词下推一般,给足超时;不走普通池(60s 池级超时 + 自动重试会把 9s 拖成 120s) + { connectRequestTimeoutMs: 300000 } + ); + return rows || []; +} + +/** 并发跑任务,最多 `limit` 个同时在飞(不把连接池打满) */ +async function mapLimit(items, limit, worker) { + const out = []; + let cursor = 0; + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const i = cursor; + cursor += 1; + if (i >= items.length) return; + out[i] = await worker(items[i], i); + } + }); + await Promise.all(runners); + return out; } /** - * 单个主仓的仓库 ID 集合 → 逗号串(用于按主仓分桶时拼 IN 条件) + * 回订单表补「起算点 + 作废事实」。 + * + * 状态表 `jst_order_express_details` 里没有聚水潭创建时间(`createdAt` 是同步落库 + * 时间,比付款晚 1.5~3.5 小时),只能回两张订单表按 `o_id = inner_order_id` 取 + * `created`(稳定比下单晚 15~30 秒,即聚水潭订单列表里的「创建时间」)。 + * + * 🔥 同一趟把**作废事实**也取回来(业务 2026-10-20 反馈作废单被算成超时): + * 状态表的 `send_time` 对**被合并单 / 拆单父单不回写** —— 单子早随主单发走了, + * 状态表却还是「发货中 + send_time 空」。所以必须再看订单表的 + * `status` / `send_date` / `is_merge` / `is_split`(见 registry.VOID_ORDER_STATUS)。 + * + * ⚠️ 只投影这 6 个**窄列**。实测 9,676 个候选:窄列 14.5s + 2.9s, + * 一旦连 pay_amount / labels 一起取,单批 400 个就要 38.7s。 + * + * @param {number[]} oids 内部单号 + * @returns {Promise>} */ -function joinIds(ids) { - return ids.join(','); +async function backfillOrderFacts(oids) { + const map = new Map(); + if (!oids.length) return map; + + let remaining = oids; + for (const table of CREATED_SOURCE_TABLES) { + if (!remaining.length) break; + const batches = []; + for (let i = 0; i < remaining.length; i += CREATED_BACKFILL_BATCH) { + batches.push(remaining.slice(i, i + CREATED_BACKFILL_BATCH)); + } + + const results = await mapLimit(batches, 4, async (batch) => { + try { + return await cloudDatabase.querySlow( + `SELECT o_id, created, status, send_date, is_merge, is_split + FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`, + {}, + { connectRequestTimeoutMs: 300000 } + ); + } catch (e) { + // 补不到不致命:调用方会回退到付款时间,不整页失败 + return []; + } + }); + + for (const rows of results) { + for (const r of rows || []) { + const id = Number(r.o_id); + if (!Number.isFinite(id)) continue; + const status = String(r.status || ''); + const sendDate = formatDateTime(r.send_date); + map.set(id, { + createdAt: formatDateTime(r.created), + status, + sendDate, + // 作废:被合并 / 被拆分 / 已取消 —— 这单不会自己发货 + void: VOID_ORDER_STATUS.includes(status) + || Number(r.is_merge) === 1 + || Number(r.is_split) === 1, + // 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。 + // 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。 + shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status), + }); + } + } + remaining = remaining.filter((n) => !map.has(n)); + } + return map; +} + +function emptySummary() { + return { + // 超时未发货(考核仓库的部分) + overdueUnshipped: 0, + overdueCenterCount: 0, + oldestDays: 0, + // 不计考核(待审核 + 异常)—— 单独统计,不算仓库责任 + heldCount: 0, + // 已剔除的作废 / 已发单(页面只作说明,不进考核) + voidCount: 0, + // 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答) + carrierSkipped: 0, + }; +} + +function buildEmptyCenters(centerList) { + return centerList.map((c) => ({ + key: c.key, + name: c.name, + placeholder: c.placeholder, + warehouseCount: c.warehouses.length, + warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })), + overdueUnshipped: 0, + oldestDays: 0, + heldCount: 0, + })); } -/** - * 主入口:计算某一天、某一轮次的仓储中心发货时效。 - * - * @param {object} options - * @param {string} [options.round] 'NOON' | 'AFTERNOON',不传则按当前时刻自动判定 - * @param {Date} [options.now] 仅测试用 - * @param {number} [options.limit] 明细条数上限 - * @returns {Promise} - */ async function getShipTimeliness(options = {}) { const now = options.now instanceof Date ? options.now : new Date(); const limit = Math.min( @@ -112,7 +356,7 @@ async function getShipTimeliness(options = {}) { MAX_DETAIL_LIMIT ); - // ① 主仓 → 仓库映射 + // ① 主仓清单(用于展示「含子仓 N 个」;归属判定走 warehouse_name 关键词) const centerList = await registry.loadCenterWarehouseMap(); const pad2 = (n) => String(n).padStart(2, '0'); @@ -126,26 +370,19 @@ async function getShipTimeliness(options = {}) { err.statusCode = 400; throw err; } - const pad = (n) => String(n).padStart(2, '0'); - const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; - round = { - ...target, - date, - deadline: `${date} ${pad(target.cutoffHour)}:00:00`, - windowStart: `${date} 00:00:00`, - windowEnd: `${date} ${pad(target.windowEndHour)}:00:00`, - active: now.getHours() * 60 + now.getMinutes() >= target.cutoffHour * 60, - }; + round = rounds.find((r) => r.key === target.key); + if (!round) { + const err = new Error(`无法构造核验窗口: ${options.round}`); + err.statusCode = 500; + throw err; + } } else { round = registry.resolveActiveRound(now); } - // 🔥 12:00 前没有任何轮次生效(业务口径:12:00 才开始第一轮核验)。 - // 这里**直接返回「未到核验时间」**,既不查库、也不回填昨天 —— - // 让业务清楚知道「现在还没到看数据的时候」,而不是拿旧数字去对仓库。 + // 🔥 13:00 前没有轮次生效(业务口径):直接返回「未到核验时间」,不查库 if (!round) { - const centerList = await registry.loadCenterWarehouseMap(); - const nextRound = rounds.find((r) => r.cutoffHour === 12) || rounds[0]; + const nextRound = rounds.find((r) => !r.active) || rounds[0]; return { generatedAt: formatDateTime(now), date: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`, @@ -154,375 +391,375 @@ async function getShipTimeliness(options = {}) { isPreviousDay: false, rounds, nextRoundAt: nextRound ? nextRound.deadline : '', + nextWindowEnd: nextRound ? nextRound.windowEnd : '', deadline: '', window: null, summary: emptySummary(), centers: buildEmptyCenters(centerList), orders: [], + heldOrders: [], truncated: false, detailLimit: limit, detailDegraded: false, freshness: null, - note: '今日 12:00 首轮核验开始后才展示超时数据', + note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`, }; } - // 没有归属任何主仓的仓库不用查 - const allWmsIds = [...new Set( - centerList.flatMap((c) => c.warehouses.map((w) => w.wmsCoId)).filter((n) => Number.isFinite(n)) - )]; - - if (!allWmsIds.length) { + // ③ 取「未发货」全量(发货中 / 待审核 / 异常),Node 侧判主仓与超时 + // 先看缓存:同一轮次 + 同一天 + 同一 limit,5 分钟内直接复用 + const force = options.force === true + || ['1', 'true', 'yes'].includes(String(options.force || '').toLowerCase()); + const cacheKey = `${round.key}|${round.date}|${limit}`; + if (!force && resultCache.data && resultCache.key === cacheKey + && Date.now() - resultCache.at < CACHE_TTL_MS) { return { - generatedAt: formatDateTime(now), - date: round.date, - round: { key: round.key, label: round.label }, - isPreviousDay: Boolean(round.isPreviousDay), - rounds, - deadline: round.deadline, - window: { start: round.windowStart, end: round.windowEnd }, - summary: emptySummary(), - centers: buildEmptyCenters(centerList), - orders: [], - truncated: false, - detailLimit: limit, - note: '仓库表中未匹配到任何目标主仓,请检查仓库命名', + ...resultCache.data, + cached: true, + cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000), }; } - // ③ 一趟 SQL 取全量分组结果。原实现拆成「超时未发 / 已超时已发 / 窗口总数」 - // 三条 SQL,在 rpt 视图上三趟全表扫描实测 213 秒;合并成一趟后按 - // `src + 是否超时` 打标记再 GROUP BY,只扫一次两表。 - // - // 分组维度:主仓 不直接在 SQL 里 GROUP(仓库→主仓的映射在 Node 侧缓存里), - // 所以按 wms_co_id 分组,回 Node 侧累加到主仓。 - // - // 🔥 「算不算已发货」的三态判定(2026-10-20 实测新增): - // 上游 `send_date` 回写**明显滞后** —— 实测 10-20 当天 700 单里只有 100 单 - // 有 send_date,且都集中在 08:16~08:44(刚回写的那几单)。若只看 send_date, - // 中午 12:00 核验时当天早上的单会**全部被判超时**,页面毫无价值。 - // 因此引入出库表 `rpt.jst_salesOutboundInfo` 作为佐证: - // · 判为「已发」= send_date 有值 **或** 出库表能查到该 o_id - // · 判为「按时」= send_date <= 截止 **或** 出库行 io_date <= 截止 - // ⚠️ 出库表同样滞后(每天 04:00 起才有数据,当天往往 0 行,次日凌晨灌), - // 所以它只能**减少误判**(把「已发的判成没发」纠正过来), - // 不能凭空补出「没发的单已经发了」。最终仍可能有误报,故返回值里带 - // `freshness` 让前端明示「数据可能滞后」。 - // ⚠️ 出库表 `qty` 列全表恒 0(见 MEMORY),判断有无出库**只看行数 / io_date**。 - // - // 每条订单归属哪一类(互斥): - // pending —— 两源都查不到发货痕迹 → 超时未发 - // shippedLate —— 有发货痕迹但发货时间 > 截止 → 已发但超时 - // onTime —— 发货时间 <= 截止 → 按时已发 - // ⚠️ OUTER APPLY 里只取 `io_date`,用「io_date 是否为 NULL」判断出库表有无命中。 - // 别在外面引用 `ob.o_id` —— APPLY 子查询只 SELECT 了 io_date,引用不存在的列 - // 会报 `Invalid column name 'o_id'`(2026-10-20 已踩)。 - // - // ⚡ 性能关键(2026-10-20 实测): - // · 原实现「汇总 SQL + 明细 SQL」各扫一遍窗口,合计 108 秒; - // · 窗口内订单只有几百条(实测 420 条),所以改成**一趟取回全部窗口订单明细**, - // 汇总与明细都在 Node 里算 —— 少一次大表扫描。 - // · 返回列加上 pay_amount / status,供 Node 侧分桶。 - // - // 🔥🔥 两表**不能 UNION ALL 成一条 SQL**(2026-10-20 实测,这是本文件最大的坑): - // · `jst_qmcust_orderlist` 是**宽视图**(上百列),拆开单独跑很快,但一旦参与 - // UNION ALL + 派生表 + OUTER APPLY 的嵌套,优化器会放弃谓词下推, - // 变成先物化整表再过滤 —— 实测单条 SQL 跑 200~320 秒。 - // · 两表**分别查、Node 侧合并**:jst_Orders 5.5 秒、jst_qmcust_orderlist 35 秒, - // 合计约 40 秒,比合并版快 5~8 倍。 - // · 两表店铺完全互斥(京东/拼多多/抖音/得物/唯品会 vs 天猫/淘宝/1688), - // o_id 不会有交集,直接拼接即可,不需要再按 o_id 去重(仍保留一次去重兜底)。 - // - // 🔥 `jst_qmcust_orderlist` 单表也有 35 秒,而同样条件的 `jst_Orders` 只要 2 秒。 - // 曾尝试把它的窗口过滤换成 `created`(实测快 10 倍),但**已证伪,不能用**: - // · 2026-10-20 实测 2,581 行里,`order_date` 与 `created` 完全相等的只有 4 行, - // 相差 >1 小时的有 412 行,最大差 24.7 天,**198 行甚至跨日**; - // · 典型形态:`order_date=10-18 23:58` 而 `created=10-19 04:00:08` —— - // 上游每天凌晨 4 点批量补录,`created` 是**灌库时间**不是下单时间; - // · 同一窗口下 `created` 比 `order_date` 多命中 30 单(全是补录单)。 - // 业务口径是「按**订单创建时间**算是否及时」,只有 `order_date` 是对的。 - // 所以:**两表都用 `order_date`**,性能只能靠「两表分开查 + Node 侧合并」。 - // ⚠️ 换回 `created` 之前必须先跑 `.workbuddy/tmp/probe-od-vs-created.cjs` 复测。 - const windowBranch = (table) => ` - SELECT N'${table}' AS src, - o.wms_co_id, o.o_id, o.so_id, o.shop_name, o.order_date, - o.send_date, o.status, o.shop_status, o.pay_amount, o.labels, - ob.io_date AS ob_io_date - FROM dbo.${table} o - OUTER APPLY ( - SELECT TOP 1 s.io_date - FROM dbo.jst_salesOutboundInfo s - WHERE s.o_id = o.o_id - ORDER BY s.io_date ASC - ) ob - WHERE o.wms_co_id IN (${joinIds(allWmsIds)}) - AND o.order_date >= @windowStart - AND o.order_date <= @deadline - AND ${EXCLUDED_STATUS_SQL}`; - - const params = { deadline: round.deadline, windowStart: round.windowStart }; - // ⚠️ 超时给足:qmcust 单表实测 35~80 秒(视图谓词下推较差), - // 不能按 jst_Orders 的 5 秒去设,否则会被误杀成 500。 - const queryOpts = { requestTimeout: 300000, connectRequestTimeoutMs: 320000 }; - - // 两表并行查(互不依赖),Node 侧合并 - const [ordersRows, qmcustRows] = await Promise.allSettled([ - cloudDatabase.query(windowBranch('jst_Orders'), params, queryOpts), - cloudDatabase.query(windowBranch('jst_qmcust_orderlist'), params, queryOpts), - ]); - - // 🔥 用 allSettled 而不是 all:一张表超时/报错时,另一张表的数据仍然可用, - // 否则整页变空白。降级情况记录在 detailDegraded + note 里。 - const mergeWarnings = []; - const windowRows = []; - if (ordersRows.status === 'fulfilled') { - windowRows.push(...(ordersRows.value || [])); - } else { - mergeWarnings.push(`jst_Orders 查询失败:${ordersRows.reason?.message || '未知错误'}`); - } - if (qmcustRows.status === 'fulfilled') { - windowRows.push(...(qmcustRows.value || [])); - } else { - mergeWarnings.push(`jst_qmcust_orderlist 查询失败:${qmcustRows.reason?.message || '未知错误'}`); - } - if (!windowRows.length && mergeWarnings.length) { - // 两表全挂 —— 这才是真正的失败,直接抛给上层 - const err = new Error(mergeWarnings.join(';')); + let stateRows = []; + try { + stateRows = await loadPendingStates(round); + } catch (e) { + const err = new Error(`聚水潭订单状态表查询失败:${e.message}`); err.statusCode = 502; throw err; } - // ④ 数据新鲜度:出库表最新 io_date。 - // ⚠️ 只用于「提示用户数据可能滞后」,不参与超时判定。 - // 两个源都滞后时(出库表当天 0 行 + send_date 昨天就停了),说明上游还没灌数, - // 此时「超时未发」的准确性无法保证 —— 前端必须显式告警,不能让业务拿着 - // 一份假数据去考核仓库。 - const freshness = await loadShipFreshness(round); - - // ⑤ 三态归类(在 Node 侧做,SQL 只负责取数) - const wmsToCenter = new Map(); - for (const c of centerList) { - for (const w of c.warehouses) wmsToCenter.set(w.wmsCoId, c.key); - } + // ③-b 补起算点「聚水潭创建时间」+ 作废事实(状态表两样都没有,回订单表取) + const oids = [...new Set( + stateRows.map((r) => Number(r.inner_order_id)).filter((n) => Number.isFinite(n)) + )]; + const orderFacts = await backfillOrderFacts(oids); + // 被剔除的作废 / 已发单计数(只用于页面说明,不进考核) + let voidCount = 0; + // 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答),写进 summary 便于对账 + let carrierSkipped = 0; + // ④ 分桶 const acc = new Map(centerList.map((c) => [c.key, { pendingCount: 0, - pendingAmount: 0, - shippedLateCount: 0, - onTimeCount: 0, + oldestDays: 0, + heldCount: 0, }])); const pendingRows = []; - // 两表店铺互斥、o_id 理论不重叠,但历史上出现过同一 o_id 被两边都收录的情况, - // 按 `${src}-${o_id}` 去重(跨表同 o_id 也算重复,取先到的 jst_Orders)。 - const seenKey = new Set(); - for (const row of windowRows || []) { - const bucket = acc.get(wmsToCenter.get(Number(row.wms_co_id))); + const heldRows = []; + const seenSo = new Set(); + let snapshotAt = ''; + + for (const row of stateRows) { + // 快照时间:状态表最后一次刷新(顺带算,不再单独扫一遍 402 万行表) + const qt = formatDateTime(row.query_time); + if (qt > snapshotAt) snapshotAt = qt; + + const center = resolveCenter(row.warehouse_name); + if (!center) continue; // 非 11 主仓(代发仓/未指定仓/POP 仓…) + const bucket = acc.get(center.key); if (!bucket) continue; - const dedupeKey = String(row.o_id || ''); - if (dedupeKey) { - if (seenKey.has(dedupeKey)) continue; - seenKey.add(dedupeKey); + + const state = String(row.order_status_name || ''); + // 🔥 作废单不考核 —— 业务 2026-10-20 明确「作废订单不用考核」。 + // 状态表的 send_time 对**被合并单 / 拆单父单不回写**(案例 5127276315127080023: + // 状态表「发货中 + send_time 空」,订单表却是 Merged 且 send_date 有值、 + // 出库单也早就生成了),所以必须回订单表再判一次,光信状态表会被冤枉。 + const fact = orderFacts.get(Number(row.inner_order_id)); + if (fact && (fact.void || fact.shipped)) { + voidCount += 1; + continue; } - const kind = bucketOf(row, round.deadline); - if (kind === 'pending') { - bucket.pendingCount += 1; - bucket.pendingAmount += toNumber(row.pay_amount); - pendingRows.push(row); - } else if (kind === 'shippedLate') { - bucket.shippedLateCount += 1; - } else { - bucket.onTimeCount += 1; + + // SQL 里已经用状态白名单排除,这里再兜一次,防止以后有人改白名单漏掉 + if (IGNORED_STATES.includes(state)) continue; + + // 🔥 不考核的快递(业务 2026-10-20):自提 / 中铁 / 壹米滴答 —— + // 走这三种运输方式的单不按仓储发货时效考核(自提客户自己来拉、 + // 中铁和壹米滴答是专线/大件物流,揽收与在途都不受仓库控制)。 + // 用**物流公司名**模糊匹配(库里的名字是「中铁物流」「壹米滴答专线」这种)。 + const carrier = String(row.logistic_company_name || '').trim(); + const hitCarrier = EXCLUDED_CARRIERS.find((k) => carrier.includes(k)); + if (hitCarrier) { + carrierSkipped += 1; + continue; } + + // ── 窗口归属 = **审核时间**(业务 2026-10-20 最终定版,两次纠偏后的结论)──── + // 业务原话:「当日 7 点前订单(7 点前订单**按照审核时间算**)……」 + // 实测案例(业务亲自指出的两单): + // · 9818476:创建 10-18 12:49,但**审核 10-20 13:06** → 不是 7 点前的单,13:00 轮不能出现 + // · 9823980:创建 10-18 20:40,**审核 10-20 09:15** → 同理,归 18:00 轮 + // ⟹ 「7 点前/16 点前」卡的是**审核时间**:审核 ≤ 07:00 归 13:00 轮, + // 审核 ≤ 16:00 归 18:00 轮(山东两仓 15:00、河南洛宁 13:00,见 registry)。 + // ⚠️ 别看创建时间:客户凌晨下单、仓库上午才审的单,按创建时间会提前算超时。 + // ⚠️ 审核时间取**最后一次**(last_oa),见下面 458 行开始的说明。 + const createdAt = fact ? fact.createdAt : ''; + const payAt = formatDateTime(row.pay_time); + const auditFirst = formatDateTime(row.first_oa_confirm_time); + const auditLast = formatDateTime(row.last_oa_confirm_time); + // 🔥 取**最后一次**审核(= 聚水潭订单列表里「审核时间」那一列显示的值)。 + // 业务 2026-10-20 20:0x 抽查 3 单指出「审核时间跟系统里的不一致」, + // 根因就是我们之前取错了列(first_oa = 首次审核): + // 5127368691073091434 首次 10-18 13:15 / 聚水潭显示 10-20 13:38 + // 261019-670795046400872 首次 10-19 08:48 / 聚水潭显示 10-20 09:31 + // 261019-558387771763179 首次 10-19 11:21 / 聚水潭显示 10-20 09:11 + // ⟹ 页面必须跟聚水潭对齐,否则业务一对账就说「跑错了」。 + const auditAt = auditLast || auditFirst; + const windowAt = auditAt || createdAt || payAt; + const benchmarkAt = windowAt; + const benchmarkSource = auditAt + ? (auditLast ? 'last_audit' : 'first_audit') + : (createdAt ? 'created' : 'pay'); + + // 同一 so_id 可能因拆包出现多行(实测有),按 so_id + 窗口时间 去重 + const dedupeKey = `${row.so_id}|${windowAt}`; + if (seenSo.has(dedupeKey)) continue; + seenSo.add(dedupeKey); + + // ── 本轮核验范围(业务 2026-10-20 最终定版)──────────────────────────── + // ① **今天的单按审核时间分段**: + // 今天 00:00 ~ 07:00 审核 → 13:00 轮核验 + // **截止今天 16:00 前审核**的单 → 18:00 轮核验 + // (= 16:00 前所有审核过的单,含 7 点前没发出的;不是「07:00~16:00」那一段。 + // 山东两仓 15:00、河南洛宁 13:00 为仓级例外) + // ⟹ 今天 09:15 审核的单(9823980)、13:06 审核的单(9818476) + // **不进 13:00 轮**,只在 18:00 轮出现(业务亲自指出的两单) + // ② **历史未发的单一律算**(昨天及更早审核、至今没发货)—— 它们已经超时, + // 两个轮次都该看到,不受「今天 00:00 起」的限制。 + // ③ 30 天护栏只为查询性能,不是业务边界。 + const windowEndAt = registry.centerWindowEnd(center.key, round); + const todayStart = `${round.date} 00:00:00`; + if (windowAt && windowAt < round.windowStart) continue; // 超出 30 天护栏 + if (windowAt && windowAt >= todayStart && windowAt > windowEndAt) continue; + + const base = { + row, + center, + state, + createdAt, + payAt, + auditAt, + windowAt, + benchmarkAt, + benchmarkSource, + warehouseName: String(row.warehouse_name || ''), + }; + + // ── 不计考核:没审单(已支付待审核)或订单异常,都不该算仓库超时 ────── + if (state === UNAUDITED_STATUS || state === ABNORMAL_STATUS) { + bucket.heldCount += 1; + heldRows.push({ + ...base, + holdReason: state === UNAUDITED_STATUS ? '待审核' : '订单异常', + }); + continue; + } + + if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示 + + // ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛) + if (!benchmarkAt) { + bucket.heldCount += 1; + heldRows.push({ ...base, holdReason: '缺审核时间' }); + continue; + } + // waited = 审核时间 → 本轮核验时刻(13:00 / 18:00) + // ⚠️ 因为窗口本身按审核时间卡(审核 ≤ 07:00 / 16:00),benchmark 必然 ≤ deadline, + // 这里不需要再处理「审核晚于核验」的情况 —— 那种单会自然落到下一轮: + // 审核 13:06 的单进不了 13:00 轮(07:00 上界),只在 18:00 轮出现,已等 = 18:00 − 13:06。 + const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline)); + + bucket.pendingCount += 1; + const days = Math.floor(waited / 1440); + if (days > bucket.oldestDays) bucket.oldestDays = days; + pendingRows.push({ ...base, waited }); } - // 明细:只要「超时未发」,最早创建的最急,排前面 - // 用 formatDateTime 转成 'YYYY-MM-DD HH:mm:ss' 再比字符串 —— 字典序与时间序一致, - // 且不受 mssql 驱动把 datetime 解析成 UTC Date 的影响(见文件头时区说明)。 + // ⑤ 排序:最久的排前面(业务一眼看最急的) pendingRows.sort((a, b) => { - const x = formatDateTime(a.order_date); - const y = formatDateTime(b.order_date); + if (a.windowAt < b.windowAt) return -1; + if (a.windowAt > b.windowAt) return 1; + return 0; + }); + heldRows.sort((a, b) => { + const x = a.windowAt || ''; + const y = b.windowAt || ''; if (x < y) return -1; if (x > y) return 1; return 0; }); + const detailRows = pendingRows.slice(0, limit); + const heldDetail = heldRows.slice(0, limit); + + const orders = detailRows.map((x) => { + const r = x.row; + return { + oId: String(r.inner_order_id || ''), + soId: String(r.so_id || ''), + shopName: String(r.shop_name || ''), + // 创建时间 = 聚水潭订单创建时间(业务 2026-10-20 已从明细列去掉,仅保留字段做回退基准) + createdAt: x.createdAt, + // 发货时间:本列表只取 send_time IS NULL 的单,所以这里恒为空 → 前端显示「未发货」 + sendTime: formatDateTime(r.send_time), + // 付款时间(并列展示,方便对账) + payTime: x.payAt, + // 审核时间 = **考核基准**:决定进 13:00 轮还是 18:00 轮,已等时长也从它算 + auditTime: x.auditAt, + // 基准时间实际取值(审核缺失时回退创建/付款)+ 来源标记 + benchmarkTime: x.benchmarkAt, + benchmarkSource: x.benchmarkSource, + state: x.state, + logisticStatus: String(r.logistic_status_name || ''), + // 运单号 / 快递公司:有值说明仓库**已打单**(但还没揽收/回传发货时间)。 + // 业务 2026-10-20 报「聚水潭显示已发货、页面显示未发货」,靠这两列能一眼看出卡在哪一步。 + logisticId: String(r.logistic_id || '').trim(), + logisticCompany: String(r.logistic_company_name || '').trim(), + centerKey: x.center.key, + centerName: x.center.name, + warehouseName: x.warehouseName, + // 已等时长(小时,保留 1 位)与分钟(排序都用分钟) + waitedHours: Math.round((x.waited / 60) * 10) / 10, + overdueMinutes: x.waited, + }; + }); + + const heldOrders = heldDetail.map((x) => { + const r = x.row; + const from = x.benchmarkAt || ''; + return { + oId: String(r.inner_order_id || ''), + soId: String(r.so_id || ''), + shopName: String(r.shop_name || ''), + createdAt: x.createdAt, + sendTime: formatDateTime(r.send_time), + payTime: x.payAt, + auditTime: x.auditAt, + benchmarkTime: x.benchmarkAt, + benchmarkSource: x.benchmarkSource, + state: x.state, + holdReason: x.holdReason, + logisticStatus: String(r.logistic_status_name || ''), + logisticId: String(r.logistic_id || '').trim(), + logisticCompany: String(r.logistic_company_name || '').trim(), + centerKey: x.center.key, + centerName: x.center.name, + warehouseName: x.warehouseName, + heldDays: from ? Math.floor(diffMinutes(from, round.deadline) / 1440) : 0, + }; + }); const centers = centerList.map((c) => { - const b = acc.get(c.key) || { pendingCount: 0, pendingAmount: 0, shippedLateCount: 0, onTimeCount: 0 }; - const totalCount = b.pendingCount + b.shippedLateCount + b.onTimeCount; + const b = acc.get(c.key) || { pendingCount: 0, oldestDays: 0, heldCount: 0 }; return { key: c.key, name: c.name, placeholder: c.placeholder, warehouseCount: c.warehouses.length, warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })), - // 列表主展示:超时未发 overdueUnshipped: b.pendingCount, - overdueUnshippedAmount: Math.round(b.pendingAmount * 100) / 100, - // 辅助信息 - overdueShipped: b.shippedLateCount, - windowOrderCount: totalCount, - onTimeRate: totalCount > 0 - ? Math.round((b.onTimeCount / totalCount) * 1000) / 10 - : null, + oldestDays: b.oldestDays, + heldCount: b.heldCount, + // 该仓在本轮的窗口上界(可能是仓级例外,如山东两仓 15:00、河南洛宁 13:00) + windowEnd: registry.centerWindowEnd(c.key, round), }; }); - const orders = (detailRows || []).map((r) => { - const orderDate = formatDateTime(r.order_date); - const center = centerList.find((c) => c.key === wmsToCenter.get(Number(r.wms_co_id))); - const warehouse = center && center.warehouses.find((w) => w.wmsCoId === Number(r.wms_co_id)); - return { - oId: String(r.o_id || ''), - soId: String(r.so_id || ''), - source: r.src === 'jst_qmcust_orderlist' ? 'qmcust' : 'orders', - shopName: String(r.shop_name || ''), - orderDate, - sendDate: formatDateTime(r.send_date), - status: String(r.status || ''), - shopStatus: String(r.shop_status || ''), - wmsCoId: Number(r.wms_co_id), - warehouseName: warehouse ? warehouse.name : '', - centerKey: center ? center.key : '', - centerName: center ? center.name : '', - payAmount: toNumber(r.pay_amount), - labels: String(r.labels || ''), - // 相对截止时刻的超时时长(分钟) - overdueMinutes: diffMinutes(orderDate, round.deadline), - }; - }); + // 本轮有「仓级例外窗口」的仓(给前端提示用;没有就是空数组) + const windowOverrides = centerList + .filter((c) => registry.centerWindowEndHour(c.key, round.key) !== null) + .map((c) => ({ + key: c.key, + name: c.name, + hour: registry.centerWindowEndHour(c.key, round.key), + windowEnd: registry.centerWindowEnd(c.key, round), + })); - const summary = centers.reduce((acc2, c) => { - acc2.overdueUnshipped += c.overdueUnshipped; - acc2.overdueShipped += c.overdueShipped; - acc2.overdueUnshippedAmount += c.overdueUnshippedAmount; - acc2.windowOrderCount += c.windowOrderCount; - if (c.overdueUnshipped > 0) acc2.overdueCenterCount += 1; - return acc2; + const summary = centers.reduce((a, c) => { + a.overdueUnshipped += c.overdueUnshipped; + if (c.overdueUnshipped > 0) a.overdueCenterCount += 1; + if (c.oldestDays > a.oldestDays) a.oldestDays = c.oldestDays; + a.heldCount += c.heldCount || 0; + return a; }, emptySummary()); - summary.overdueUnshippedAmount = Math.round(summary.overdueUnshippedAmount * 100) / 100; - // 按时率统一口径:按时 = 窗口内总数 - 超时未发 - 已发超时 - // (与各仓算法完全一致,避免两处各算一遍算出不同数字) - summary.onTimeRate = summary.windowOrderCount > 0 - ? Math.round(((summary.windowOrderCount - summary.overdueUnshipped - summary.overdueShipped) - / summary.windowOrderCount) * 1000) / 10 - : null; + summary.voidCount = voidCount; + summary.carrierSkipped = carrierSkipped; - const totalPending = summary.overdueUnshipped; + // 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表) + // 🔥 2026-10-20 业务报「已发货的单还显示未发货」时才发现这里曾写死 stale:false —— + // 状态表是**准实时镜像**,上游 ETL 一停(实测当天 15:48 之后 0 行刷新), + // 15:48 之后发的货就永远进不来,页面只能基于旧快照判「未发」。 + // 必须把滞后暴露到页面上,否则业务只会以为判定写错。 + const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null; + const stale = snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES; + // 🔥 更狠的一条:快照**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。 + // 实测 2026-10-20 18:54:快照停在 15:48,18:00 核验时早已发出的单全被判「未发货」, + // 业务连续报了 3 单都是这个原因。这种时候数字必然偏大,必须红字告警。 + const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline; + const freshness = { + stateMaxQuery: snapshotAt, + ageMinutes: snapshotAgeMin, + stale, + coversDeadline, + hint: [ + stale + ? `聚水潭订单状态镜像最新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。` + : '', + !coversDeadline + ? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:` + + '此后发出的单在这里仍显示「未发货」,下面的超时单量偏大,请以聚水潭实时状态为准。' + : '', + ].filter(Boolean).join(' '), + }; - return { + const payload = { generatedAt: formatDateTime(now), date: round.date, - // ⚠️ 前端要展示「核验 07:00 / 16:00 前」,必须把 windowEnd 一起带出去。 - // 早先只回了 key/label,前端拿不到 cutoff,页面就渲染成「核验 前」。 round: { key: round.key, label: round.label, - cutoffHour: round.cutoffHour, + verifyHour: round.verifyHour, windowEnd: round.windowEnd, }, - // 12:00 前不会走到这里(见上方 notStarted 分支),留着兼容显式指定 round 的调用 notStarted: false, isPreviousDay: false, rounds, deadline: round.deadline, - window: { start: round.windowStart, end: round.windowEnd }, + // boundary='audit':轮次归属、已等时长都按**首次审核时间**算 + window: { + start: round.windowStart, + end: round.windowEnd, + includeHistory: true, + boundary: 'audit', + measuredBy: 'audit', + // 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮) + overrides: windowOverrides, + }, summary, centers, orders, - truncated: totalPending > orders.length, + // 不考核的快递(前端文案直接用,避免两边各写一份) + excludedCarriers: EXCLUDED_CARRIERS, + // 不计考核清单(待审核 / 异常)—— 与 orders 互斥 + heldOrders, + truncated: pendingRows.length > orders.length, detailLimit: limit, - // 任一张订单表查询失败 → 数字不完整,前端要显式告警(不能拿残缺数据考核仓库) - detailDegraded: mergeWarnings.length > 0, + detailDegraded: false, freshness, - note: mergeWarnings.length ? `${mergeWarnings.join(';')}(数据不完整)` : '', + cached: false, + cacheAgeSec: 0, + note: '', }; -} -/** - * 计算发货数据新鲜度。 - * - * 🔥 业务上这是**最容易被误判成系统 bug** 的地方(见 MEMORY 入口 1): - * 上游 send_date / 出库表都滞后,「超时未发」数量会虚高。 - * 这里只如实报告滞后程度,不做任何「猜测补数」。 - * - * @returns {Promise<{outboundMaxIo: string, sendDateMax: string, stale: boolean, hint: string}>} - */ -async function loadShipFreshness(round) { - try { - const rows = await cloudDatabase.query( - `SELECT CONVERT(varchar(19), MAX(io_date), 120) AS outbound_max - FROM dbo.jst_salesOutboundInfo`, - {}, - { requestTimeout: 60000 } - ); - const outboundMaxIo = rows && rows[0] ? String(rows[0].outbound_max || '') : ''; - // 出库表最新一行早于今天 → 当天发货数据尚未灌入 - const outboundStale = !outboundMaxIo || outboundMaxIo.slice(0, 10) < round.date; - return { - outboundMaxIo, - stale: outboundStale, - hint: outboundStale - ? `出库表最新数据 ${outboundMaxIo || '暂无'},今天(${round.date})的发货记录尚未灌入,` - + '「超时未发」可能偏多,请以仓库实际发货情况为准' - : '', - }; - } catch (e) { - // 新鲜度查询失败绝不能影响主结果 - console.warn('[ShipTimeliness] 取新鲜度失败:', e.message); - return { outboundMaxIo: '', stale: true, hint: '发货数据新鲜度未知' }; - } -} - -/** - * 单条订单的三态归类(与服务层口径一一对应): - * pending —— send_date 为空 **且** 出库表无命中 → 超时未发 - * shippedLate —— 有发货痕迹但发货时间晚于截止 → 已发但超时 - * onTime —— 发货时间不晚于截止 → 按时已发 - * - * 「发货时间」取值优先级:send_date 有值就用 send_date,否则用出库表 io_date。 - * (两个都滞后时以 send_date 为准,因为它是订单系统自己的回写,语义最直接。) - * - * @param {object} row 含 send_date / ob_io_date - * @param {string} deadline 'YYYY-MM-DD HH:mm:ss' - */ -function bucketOf(row, deadline) { - const sendDate = formatDateTime(row.send_date); - const ioDate = formatDateTime(row.ob_io_date); - if (!sendDate && !ioDate) return 'pending'; - const shippedAt = sendDate || ioDate; - return shippedAt > deadline ? 'shippedLate' : 'onTime'; -} - -function emptySummary() { - return { - overdueUnshipped: 0, - overdueShipped: 0, - overdueUnshippedAmount: 0, - overdueCenterCount: 0, - windowOrderCount: 0, - onTimeRate: null, - }; -} - -function buildEmptyCenters(centerList) { - return centerList.map((c) => ({ - key: c.key, - name: c.name, - placeholder: c.placeholder, - warehouseCount: c.warehouses.length, - warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })), - overdueUnshipped: 0, - overdueUnshippedAmount: 0, - overdueShipped: 0, - windowOrderCount: 0, - onTimeRate: null, - })); + resultCache = { key: cacheKey, at: Date.now(), data: payload }; + return payload; } module.exports = { getShipTimeliness, + clearShipTimelinessCache, DEFAULT_DETAIL_LIMIT, MAX_DETAIL_LIMIT, }; diff --git a/backend/test/warehouseShipTimeliness.test.js b/backend/test/warehouseShipTimeliness.test.js index 6771d02..f208978 100644 --- a/backend/test/warehouseShipTimeliness.test.js +++ b/backend/test/warehouseShipTimeliness.test.js @@ -1,14 +1,21 @@ /** - * 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 新增) + * 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 第八版) * - * 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定四条关键语义: - * ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走, - * 否则品牌仓的订单会被重复计入两个仓; - * ② 核验轮次的时刻选择 —— 12:00 前**不做核验(返回 null)**、12:00~15:59 用 - * 7:00 窗口、16:00 后用 16:00 窗口; - * ③ 状态排除表 —— Cancelled / Merged / Split 必须排除(Split 是拆单父单, - * 永不发货,不排除会把一年前的单全算成超时未发); - * ④ 服务层分桶算术 —— pending/shippedLate/onTime 三态互斥,按时率不为负。 + * 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定几条关键语义: + * ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走; + * ② 核验轮次的时刻选择 —— 13:00 前**不做核验**、13:00~17:59 用 07:00 窗口、 + * 18:00 后用 16:00 窗口;「核验时刻」与「订单窗口上界」是两个字段; + * ③ 数据源必须是 `jst_order_express_details`(聚水潭实时状态镜像)—— + * 订单表的 status 是下单快照,取消/售后/审核都不回写,用它必错; + * ④ 🔥 窗口按**创建时间**卡 7:00 / 16:00(含历史未发),**已等时长按审核时间算**: + * 「当日 7 点前订单(7 点前订单按照审核时间算),13:00 核验未发就算超时」。 + * ⚠️ 别把窗口卡到审核时间上 —— 审单集中在早上 8:30~10:00 批量做, + * 卡审核时间 13:00 轮全库只剩 2 单(实测 29 单 → 2 单),等于把功能做废; + * ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时。 + * 第七版加了 24h 门槛,13:00 轮永远看不到当天 7 点那批,被业务打回; + * ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核·异常=不计考核 / 其余丢弃; + * **作废单(已取消 / 合并 / 拆单父单)不考核**; + * ⑦ 性能护栏:状态表只允许 **1 次** querySlow,不得投影宽列、不得回订单表补金额。 * * ⚠️ 本文件**不连数据库**,纯逻辑测试。 */ @@ -29,9 +36,9 @@ test('resolveCenter 用最长关键词匹配,品牌仓不会被主仓抢走', test('resolveCenter 能捞到各种命名的子仓', () => { const cases = [ - ['京仓-横林二仓京东云仓', 'HL2'], ['简尚-运营C横林二仓专用仓', 'HL2'], ['简尚-横林二仓京东仓', 'HL2'], + ['京仓-横林二仓京东自营仓', 'HL2'], ['简尚-崔桥工厂仓唯品仓', 'CQ'], ['简尚-运营C崔桥工厂仓专用仓', 'CQ'], ['简尚-新苏北临时仓', 'XSB'], @@ -45,189 +52,585 @@ test('resolveCenter 能捞到各种命名的子仓', () => { } }); +test('「京仓-横林二仓京东云仓」业务指定排除,不计入横林二仓', () => { + assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null); + // ⚠️ 不能连带排掉名字相近的仓 —— 关键词必须写完整片段 + assert.equal(registry.resolveCenter('简尚-横林二仓京东仓')?.key, 'HL2'); + assert.equal(registry.resolveCenter('京仓-横林二仓京东自营仓')?.key, 'HL2'); + assert.equal(registry.resolveCenter('京仓-崔桥工厂仓京东云仓')?.key, 'CQ'); + assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ'); +}); + test('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => { - // 这是最容易翻车的地方:仓库表里有上百个「工厂-山东XX代发仓」, - // 如果关键词写成「山东」就会全被并进山东品牌仓/青岗集仓。 const shouldBeNull = [ '工厂-山东菏泽森美代发仓', '工厂-山东恒思代发仓', '工厂-河南千优代发仓', '工厂-河北霸州派雅世佳代发仓', '简尚-苏州福利品仓', - '简尚-崔桥工厂半成品包装项目仓', ]; for (const name of shouldBeNull) { assert.equal(registry.resolveCenter(name), null, `${name} 不应归属任何主仓`); } }); -test('崔桥工厂半成品仓是占位仓:没有关键词、匹配不到任何仓', () => { +test('崔桥工厂半成品包装项目仓已接入(不再是占位仓)', () => { const c = registry.CENTERS.find((x) => x.key === 'CQ_WIP'); - assert.ok(c, 'CQ_WIP 必须存在(业务要求列出来)'); - assert.equal(c.placeholder, true); - assert.deepEqual(c.keywords, []); + assert.ok(c, 'CQ_WIP 必须存在'); + assert.equal(c.placeholder, undefined, '已接入真实仓,不能再是占位仓'); + assert.equal(c.name, '崔桥工厂半成品包装项目仓'); + assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓')?.key, 'CQ_WIP'); + assert.equal(registry.resolveCenter('简尚-崔桥工厂仓')?.key, 'CQ'); + assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓')?.key, 'CQ_BRAND'); }); // ── ② 核验轮次 ────────────────────────────────────────────────────────── const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区) -test('12:00 前不做核验,返回 null(不回填昨日数据)', () => { - // 业务口径(2026-10-20 确认):中午 12:00 才开始第一轮核验, - // 12:00 之前页面显示「未到核验时间」,**不能用昨天的结算结果冒充今天**。 +test('13:00 前不做核验,返回 null(不回填昨日数据)', () => { assert.equal(registry.resolveActiveRound(at(9, 30)), null); - assert.equal(registry.resolveActiveRound(at(11, 59)), null); + assert.equal(registry.resolveActiveRound(at(12, 59)), null); assert.equal(registry.resolveActiveRound(at(0, 5)), null); }); -test('12:00~15:59 用 12:00 轮,核验窗口是当天 00:00~07:00', () => { - const r = registry.resolveActiveRound(at(12, 0)); - assert.ok(r); - assert.equal(r.key, 'NOON'); - assert.equal(r.deadline, '2026-10-20 12:00:00'); - assert.equal(r.windowStart, '2026-10-20 00:00:00'); - assert.equal(r.windowEnd, '2026-10-20 07:00:00'); +test('13:00~17:59 用 13:00 轮,窗口是「截止今日 07:00 前创建(含历史)」', () => { + for (const [h, m] of [[13, 0], [15, 30], [17, 59]]) { + const r = registry.resolveActiveRound(at(h, m)); + assert.ok(r, `${h}:${m} 应已进入首轮核验`); + assert.equal(r.key, 'NOON'); + // 核验时刻 = 13:00(判定「发了没发 / 超时多久」用它) + assert.equal(r.deadline, '2026-10-20 13:00:00'); + assert.equal(r.verifyHour, 13); + // 订单窗口上界 = 07:00(只核验该时刻前创建的单) + assert.equal(r.windowEnd, '2026-10-20 07:00:00'); + assert.notEqual(r.deadline, r.windowEnd, '核验时刻与窗口上界必须是两个值'); + assert.equal(r.includeHistory, true); + assert.notEqual(r.windowStart, '2026-10-20 00:00:00', '下界不能卡在当天 00:00'); + assert.equal(r.windowStart, '2026-09-20 00:00:00', '下界是近 30 天'); + } }); -test('16:00 之后用 16:00 轮,核验窗口扩到当天 00:00~16:00', () => { - const r = registry.resolveActiveRound(at(16, 0)); +test('18:00 之后用 18:00 轮,窗口上界扩到当天 16:00', () => { + const r = registry.resolveActiveRound(at(18, 0)); assert.equal(r.key, 'AFTERNOON'); - assert.equal(r.deadline, '2026-10-20 16:00:00'); + assert.equal(r.deadline, '2026-10-20 18:00:00'); assert.equal(r.windowEnd, '2026-10-20 16:00:00'); - - // 深夜仍沿用当天 16:00 轮(午夜后又回到新的一天) assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON'); }); -// ── ③ 状态排除 ────────────────────────────────────────────────────────── -test('排除 Cancelled / Merged / Split,但保留 Question(仍算待发)', () => { - const sql = registry.EXCLUDED_STATUS_SQL; - assert.match(sql, /Cancelled/); - assert.match(sql, /Merged/); - assert.match(sql, /Split/); - assert.doesNotMatch(sql, /Question/); +test('describeRounds 两轮的核验时刻与窗口上界(16:00~17:59 属于「等 18:00 轮」)', () => { + const rounds = registry.describeRounds(at(16, 5)); + assert.deepEqual( + rounds.map((r) => [r.key, r.deadline.slice(11, 16), r.windowEnd.slice(11, 16)]), + [['NOON', '13:00', '07:00'], ['AFTERNOON', '18:00', '16:00']] + ); + assert.equal(rounds[0].active, true); + assert.equal(rounds[1].active, false, '16:05 还没到 18:00 轮'); + assert.equal(registry.resolveActiveRound(at(16, 5)).key, 'NOON'); +}); + +test('审核晚于本轮核验时刻 → 顺延到下一轮核验时刻', () => { + const [noon, afternoon] = registry.describeRounds(at(16, 5)); + assert.equal(registry.nextRoundDeadline(noon), '2026-10-20 18:00:00', '13:00 轮 → 当天 18:00'); + assert.equal(registry.nextRoundDeadline(afternoon), '2026-10-21 13:00:00', + '18:00 是当天最后一轮 → 次日 13:00'); + // 跨月边界也要对 + assert.equal( + registry.nextRoundDeadline({ key: 'AFTERNOON', date: '2026-10-31' }), + '2026-11-01 13:00:00' + ); +}); + +// ── ③ 常量口径 ────────────────────────────────────────────────────────── +test('数据源必须是聚水潭实时状态表,不是订单表', () => { + assert.equal(registry.ORDER_STATE_TABLE, 'jst_order_express_details'); +}); + +test('三态名称;24 小时常量仅用于页面命名,不再参与判定', () => { + assert.equal(registry.SHIPPING_STATUS, '发货中'); + assert.equal(registry.UNAUDITED_STATUS, '已支付待审核'); + assert.equal(registry.ABNORMAL_STATUS, '异常'); + // 页面/路由仍叫「24 小时发货时效」,但判定是 13:00/18:00 到点即超时 + assert.equal(registry.SHIP_TIMELINESS_HOURS, 24); +}); + +test('作废单(已取消)明确不考核', () => { + assert.ok( + registry.IGNORED_STATES.includes('已取消'), + '「已取消」必须在排除名单里 —— 近 30 天 6.4 万单,是「发货中」的 26 倍' + ); + assert.ok(registry.IGNORED_STATES.includes('已发货')); + assert.ok(registry.IGNORED_STATES.includes('等供销商|外仓发货')); +}); + +test('创建时间要从订单表补,且只补窄列', () => { + assert.deepEqual( + [...registry.CREATED_SOURCE_TABLES], + ['jst_Orders', 'jst_qmcust_orderlist'], + '两张订单表都要查(店铺互斥,交集为 0)' + ); + assert.ok(registry.CREATED_BACKFILL_BATCH >= 100, '必须分批,不能一次 IN 上万个'); }); // ── ④ 服务层分桶(注入假查询,不连库)── -function stubDb(dbPath, realDb) { +function stubDb(dbPath, scenario = 'default') { const wmsId = 15038426; - // 重构后服务层是「一趟取回窗口内全部订单,Node 侧分桶」, - // 所以 stub 要返回**明细行**而不是聚合行。三态各造几条: - // · 超时未发 3 单 / 合计 300 - // · 已发但超时 1 单(send_date 晚于 16:00) - // · 按时 6 单(send_date 早于 16:00) - const mk = (sendDate, amount, oId) => ({ - src: 'jst_Orders', - wms_co_id: wmsId, - o_id: oId, - so_id: String(oId), + + // 创建时间(展示)+ 作废事实来源:o_id → 订单表行 + // 故意让 3 号单**补不到**(模拟订单表缺行)→ 展示回退付款时间 + const f = (table, o) => ({ table, status: 'WaitConfirm', send_date: null, is_merge: 0, is_split: 0, ...o }); + + const defaultFacts = { + 1: f('jst_Orders', { created: '2026-09-25 00:50:00' }), + 2: f('jst_Orders', { created: '2026-10-20 16:20:00' }), + 6: f('jst_Orders', { created: '2026-10-20 17:00:00' }), + 7: f('jst_qmcust_orderlist', { created: '2026-09-25 01:00:00' }), + 9: f('jst_Orders', { created: '2026-09-21 09:00:00', status: 'Cancelled' }), + // 10:状态表「发货中 + send_time 空」,但订单表是 Merged —— 真实案例 + // 5127276315127080023(内部单号 9486414)就是这样被误判成超时的 + 10: f('jst_qmcust_orderlist', { created: '2026-09-21 10:00:00', status: 'Merged', send_date: '2026-09-22 06:54:49' }), + // 11:状态表 send_time 空,订单表 send_date 有值(已发) + 11: f('jst_Orders', { created: '2026-09-21 11:00:00', send_date: '2026-09-22 08:00:00' }), + // 37:🔥 订单表 send_date 没回写,但销售订单 status 已是 Sent(已发货) + // —— 业务 2026-10-20「已发/未发参考聚水潭销售订单页面」 + 37: f('jst_Orders', { created: '2026-09-22 09:00:00', status: 'Sent' }), + // 12:拆单父单,status 不是 Split 但 is_split=1 + 12: f('jst_Orders', { created: '2026-09-21 12:00:00', status: 'Delivering', is_split: 1 }), + 13: f('jst_Orders', { created: '2026-10-20 15:00:00' }), + // 31~34:验证仓级例外窗口(山东两仓 15:00、河南洛宁 13:00,仅 18:00 轮) + 31: f('jst_Orders', { created: '2026-10-20 15:30:00' }), + 32: f('jst_Orders', { created: '2026-10-20 14:30:00' }), + 33: f('jst_Orders', { created: '2026-10-20 13:30:00' }), + 34: f('jst_Orders', { created: '2026-10-20 12:30:00' }), + // 26:创建 10-18,但**今天 13:10 才审核** —— 业务点名的形态(9818476) + // 只能进 18:00 轮,不能进 13:00 轮 + 26: f('jst_Orders', { created: '2026-10-18 12:00:00' }), + // 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30 + 25: f('jst_Orders', { created: '2026-10-19 10:00:00' }), + }; + + // 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」 + const noonFacts = { + 21: f('jst_Orders', { created: '2026-10-20 06:45:00' }), + 22: f('jst_Orders', { created: '2026-10-20 05:55:00' }), + 23: f('jst_Orders', { created: '2026-10-19 19:55:00' }), + 24: f('jst_Orders', { created: '2026-10-20 07:30:00' }), + // 25:重审单(首次审核 10-19 10:10、最后一次 10-20 13:30)—— 时长要按首次算 + 25: f('jst_Orders', { created: '2026-10-19 10:00:00' }), + // 26:今天 13:10 才审(晚于核验时刻 13:00)—— 已等时长要回退按创建时间算 + 26: f('jst_Orders', { created: '2026-10-18 12:00:00' }), + }; + + const factsMap = scenario === 'noon' ? noonFacts : defaultFacts; + + const mk = (o) => ({ shop_name: '测试店', - order_date: '2026-10-20 01:00:00', - send_date: sendDate, - status: 'Delivering', - shop_status: '', - pay_amount: amount, - labels: '', - ob_io_date: null, + logistic_status_name: '', + query_time: '2026-10-20 11:18:27', + // 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time + first_oa_confirm_time: o.last_oa_confirm_time ?? null, + ...o, }); - const stubRows = [ - mk(null, 100, 1), mk(null, 100, 2), mk(null, 100, 3), - mk('2026-10-20 18:30:00', 50, 4), - mk('2026-10-20 09:00:00', 100, 5), mk('2026-10-20 09:00:00', 100, 6), - mk('2026-10-20 10:00:00', 100, 7), mk('2026-10-20 10:00:00', 100, 8), - mk('2026-10-20 11:00:00', 100, 9), mk('2026-10-20 11:00:00', 100, 10), + const defaultRows = [ + // 1:审核 09-25 01:00 → 到 18:00 核验 = 25 天 17 小时,超时 + mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-09-24 23:50:00', + last_oa_confirm_time: '2026-09-25 01:00:00' }), + // 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验(窗口卡的是创建时间) + mk({ so_id: 'SO-2', inner_order_id: 2, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-20 16:10:00', + last_oa_confirm_time: '2026-10-20 16:30:00' }), + // 3:订单表补不到创建时间 → 展示回退付款时间;审核 09-26 00:15 仍应判超时 + mk({ so_id: 'SO-3', inner_order_id: 3, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-09-26 00:10:00', + last_oa_confirm_time: '2026-09-26 00:15:00' }), + // 4:已支付待审核(还没审单、没有审核时间)→ 不计考核 + mk({ so_id: 'SO-4', inner_order_id: 4, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '已支付待审核', pay_time: '2026-09-23 01:00:00', + last_oa_confirm_time: null }), + // 5:异常 → 不计考核 + mk({ so_id: 'SO-5', inner_order_id: 5, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '异常', logistic_status_name: '常州安能新', pay_time: '2026-09-23 01:00:00', + last_oa_confirm_time: '2026-09-23 01:05:00' }), + // 6:审核 17:05 晚于窗口上界 → 本轮不该核验 + mk({ so_id: 'SO-6', inner_order_id: 6, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-20 16:55:00', + last_oa_confirm_time: '2026-10-20 17:05:00' }), + // 7:被业务排除的京东云仓 → 不归属任何主仓 + mk({ so_id: 'SO-7', inner_order_id: 7, warehouse_name: '京仓-横林二仓京东云仓', + order_status_name: '发货中', pay_time: '2026-09-25 00:00:00', + last_oa_confirm_time: '2026-09-25 00:10:00' }), + // 8:与 1 同 so_id + 同基准时间(拆包重复行)→ 必须去重 + mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-09-24 23:50:00', + last_oa_confirm_time: '2026-09-25 01:00:00' }), + // 9:作废单(已取消)—— 业务定性不考核 + mk({ so_id: 'SO-9', inner_order_id: 9, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '已取消', pay_time: '2026-09-21 08:00:00', + last_oa_confirm_time: '2026-09-21 08:10:00' }), + // 10:状态表「发货中」、但订单表是 Merged + 已发 —— 必须剔除 + mk({ so_id: 'SO-10', inner_order_id: 10, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-09-21 09:59:00', + last_oa_confirm_time: '2026-09-21 10:05:00' }), + // 11:状态表「发货中」、订单表 send_date 有值(已发)—— 必须剔除 + mk({ so_id: 'SO-11', inner_order_id: 11, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-09-21 10:59:00', + last_oa_confirm_time: '2026-09-21 11:05:00' }), + // 12:拆单父单(is_split=1)—— 自己不发货,必须剔除 + mk({ so_id: 'SO-12', inner_order_id: 12, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-09-21 11:59:00', + last_oa_confirm_time: '2026-09-21 12:05:00' }), + // 13:🔥 当天 15:30 审核、18:00 核验只等了 2.5 小时 —— 旧版 24h 门槛会漏掉, + // 本版必须算超时(业务口径:到点未发就算超时) + mk({ so_id: 'SO-13', inner_order_id: 13, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-20 14:59:00', + last_oa_confirm_time: '2026-10-20 15:30:00' }), + // ── 仓级例外窗口(18:00 轮):山东两仓 15:00、河南洛宁 13:00 ── + // 31:山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界 → 本轮不核验 + mk({ so_id: 'SO-31', inner_order_id: 31, warehouse_name: '简尚-山东青岗集仓', + order_status_name: '发货中', pay_time: '2026-10-20 15:29:00', + last_oa_confirm_time: '2026-10-20 15:35:00' }), + // 32:山东品牌仓 14:30 创建 → 早于 15:00 上界 → 核验,且超时 + mk({ so_id: 'SO-32', inner_order_id: 32, warehouse_name: '简尚-山东品牌仓', + order_status_name: '发货中', pay_time: '2026-10-20 14:29:00', + last_oa_confirm_time: '2026-10-20 14:35:00' }), + // 33:河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界 → 本轮不核验 + mk({ so_id: 'SO-33', inner_order_id: 33, warehouse_name: '简尚-河南洛宁仓', + order_status_name: '发货中', pay_time: '2026-10-20 13:29:00', + last_oa_confirm_time: '2026-10-20 13:35:00' }), + // 34:河南洛宁仓 12:30 创建 → 早于 13:00 上界 → 核验,且超时 + mk({ so_id: 'SO-34', inner_order_id: 34, warehouse_name: '简尚-河南洛宁仓', + order_status_name: '发货中', pay_time: '2026-10-20 12:29:00', + last_oa_confirm_time: '2026-10-20 12:35:00' }), + // 26:🔥 业务点名的形态 —— 创建 10-18,但今天 13:10 才审核(9818476 就是这样)。 + // 它属于 18:00 轮(13:10 ≤ 16:00),已等 = 18:00 − 13:10 = 290 分 + mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-18 11:59:00', + first_oa_confirm_time: '2026-10-20 13:10:00', + last_oa_confirm_time: '2026-10-20 13:10:00' }), + // 25:重审单 —— 聚水潭「审核时间」显示最后一次 10-20 13:30(不是首次 10-19 10:10) + mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-19 09:59:00', + first_oa_confirm_time: '2026-10-19 10:10:00', + last_oa_confirm_time: '2026-10-20 13:30:00' }), + // 35/36:🔥 不考核快递(业务 2026-10-20):中铁物流、壹米滴答专线 —— 一律跳过。 + // 这两单状态/时间都够超时,靠快递名剔除,同时计入 summary.carrierSkipped + mk({ so_id: 'SO-35', inner_order_id: 35, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-09-25 01:00:00', + last_oa_confirm_time: '2026-09-25 01:30:00', logistic_company_name: '中铁物流' }), + 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: '壹米滴答专线' }), + // 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', + last_oa_confirm_time: '2026-09-22 09:05:00' }), ]; - // 记录业务表被查了几次 —— 12:00 前应当为 0 - // ⚠️ 现在是**两表并行分别查**(jst_Orders + jst_qmcust_orderlist), - // 所以两表都返回同一批 stubRows;服务层按 o_id 去重后仍应算 10 条, - // 这也顺带验证了「跨表同 o_id 去重」逻辑。 - const calls = { order: 0 }; + // 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00 + const noonRows = [ + // 21:创建 06:45(7 点前)+ 审核 07:20 → 进 13:00 轮;已等 = 13:00 − 07:20 = 340 分 + mk({ so_id: 'SO-21', inner_order_id: 21, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-20 06:40:00', + last_oa_confirm_time: '2026-10-20 07:20:00' }), + // 22:创建 05:55 + 审核 06:40 → 13:00 核验只等 380 分钟,也必须算超时(无 24h 门槛) + mk({ so_id: 'SO-22', inner_order_id: 22, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-20 05:50:00', + last_oa_confirm_time: '2026-10-20 06:40:00' }), + // 23:昨天创建、至今未发(历史未发池)→ 13:00 轮也要看到 + mk({ so_id: 'SO-23', inner_order_id: 23, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-19 19:50:00', + last_oa_confirm_time: '2026-10-19 20:10:00' }), + // 24:创建 07:30 晚于窗口上界 07:00 → 本轮不核验(窗口卡的是创建时间) + mk({ so_id: 'SO-24', inner_order_id: 24, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-20 07:29:00', + last_oa_confirm_time: '2026-10-20 07:40:00' }), + // 25:🔥 被「重新审核」过的单:首次审核 10-19 10:10、最后一次 10-20 13:30。 + // 时长必须按**首次**审核算(1610 分),用 last 会算成 0 分 + mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-19 09:59:00', + first_oa_confirm_time: '2026-10-19 10:10:00', + last_oa_confirm_time: '2026-10-20 13:30:00' }), + // 26:🔥 今天 13:10 才审(晚于核验时刻 13:00)→ 已等时长回退按创建时间算, + // 不能显示 0 分(真实案例:创建 10-18 的单今天才审) + mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓', + order_status_name: '发货中', pay_time: '2026-10-18 11:59:00', + first_oa_confirm_time: '2026-10-20 13:10:00', + last_oa_confirm_time: '2026-10-20 13:10:00' }), + ]; + + const rows = scenario === 'noon' ? noonRows : defaultRows; + + const calls = { state: 0, backfill: 0, sql: [], backfillSql: [], params: [] }; + + const handler = async (sql, params) => { + const text = String(sql); + if (/jst_warehouseInfo/u.test(text)) { + return [ + { Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId }, + { Id: 2, name: '简尚-山东青岗集仓', wms_co_id: 15137523 }, + { Id: 3, name: '简尚-山东品牌仓', wms_co_id: 15182705 }, + { Id: 4, name: '简尚-河南洛宁仓', wms_co_id: 15539084 }, + ]; + } + // 补「起算点 + 作废事实」:按 o_id 分批 IN 两张订单表 + if (/FROM dbo\.(jst_Orders|jst_qmcust_orderlist)/u.test(text)) { + calls.backfill += 1; + calls.backfillSql.push(text); + const table = /jst_qmcust_orderlist/u.test(text) ? 'jst_qmcust_orderlist' : 'jst_Orders'; + const m = text.match(/IN \(([0-9,\s]+)\)/u); + const ids = m ? m[1].split(',').map((s) => Number(s.trim())) : []; + return ids + .filter((id) => factsMap[id] && factsMap[id].table === table) + .map((id) => { + const x = factsMap[id]; + return { + o_id: id, + created: x.created, + status: x.status, + send_date: x.send_date, + is_merge: x.is_merge, + is_split: x.is_split, + }; + }); + } + calls.state += 1; + calls.sql.push(text); + calls.params.push(params || {}); + return rows; + }; + require.cache[dbPath] = { id: dbPath, filename: dbPath, loaded: true, - exports: { - query: async (sql) => { - if (/jst_warehouseInfo/u.test(sql)) { - return [{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId }]; - } - if (/jst_salesOutboundInfo/u.test(sql) && /MAX\(io_date\)/u.test(sql)) { - // 新鲜度查询:返回空,走 stale 分支也不影响主结果 - return [{ outbound_max: '2026-10-19 23:00:00' }]; - } - calls.order += 1; - return stubRows; - }, - }, + exports: { query: handler, querySlow: handler }, }; - void realDb; return { calls, wmsId }; } -test('服务层三态分桶互斥,按时率不为负', async () => { - const dbPath = require.resolve('../src/config/cloudDatabase'); - const realDb = require.cache[dbPath]; - stubDb(dbPath, realDb); - delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')]; - delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')]; - - try { - const svc = require('../src/services/warehouseShipTimelinessService'); - const data = await svc.getShipTimeliness({ now: at(16, 5), limit: 10 }); - const c = data.centers.find((x) => x.key === 'CQ'); - assert.ok(c, '应能归到崔桥工厂仓'); - assert.equal(c.overdueUnshipped, 3); - assert.equal(c.overdueShipped, 1); - assert.equal(c.windowOrderCount, 10, '总数 = pending + shippedLate + onTime'); - assert.equal(c.onTimeRate, 60, '按时 6/10 = 60%(跨表重复 o_id 必须去重,否则总数会翻倍)'); - - // 汇总口径与各仓一致,且按时率不会是负数 - assert.equal(data.summary.overdueUnshipped, 3); - assert.equal(data.summary.windowOrderCount, 10); - assert.equal(data.summary.onTimeRate, 60); - assert.ok(data.summary.onTimeRate >= 0); - - // 前端要展示「核验 16:00 前」,round 必须带出 windowEnd —— - // 只回 key/label 的话页面会渲染成「核验 前」(2026-10-20 真渲染时抓到) - assert.ok(data.round, 'round 不能为空'); - assert.equal(data.round.key, 'AFTERNOON'); - assert.equal(data.round.cutoffHour, 16); - assert.match(String(data.round.windowEnd), /16:00:00$/, 'round.windowEnd 必须带出截止时刻'); - } finally { - // 还原,避免污染同进程内其它测试 - if (realDb) require.cache[dbPath] = realDb; - else delete require.cache[dbPath]; +function withStubbedService(fn, scenario = 'default') { + return async () => { + const dbPath = require.resolve('../src/config/cloudDatabase'); + const realDb = require.cache[dbPath]; + const ctx = stubDb(dbPath, scenario); delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')]; delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')]; - } -}); + try { + const svc = require('../src/services/warehouseShipTimelinessService'); + await fn(svc, ctx); + } finally { + if (realDb) require.cache[dbPath] = realDb; + else delete require.cache[dbPath]; + delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')]; + delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')]; + } + }; +} -test('服务层 12:00 前返回 notStarted,且不去查订单表', async () => { - const dbPath = require.resolve('../src/config/cloudDatabase'); - const realDb = require.cache[dbPath]; - const { calls } = stubDb(dbPath, realDb); - delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')]; - delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')]; +test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStubbedService(async (svc, { calls }) => { + // 18:05 → 18:00 轮:窗口上界 16:00(按审核时间),核验时刻 18:00 + const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 }); + const c = data.centers.find((x) => x.key === 'CQ'); + assert.ok(c, '应能归到崔桥工厂仓'); - try { - const svc = require('../src/services/warehouseShipTimelinessService'); - const data = await svc.getShipTimeliness({ now: at(9, 30), limit: 10 }); - assert.equal(data.notStarted, true); - assert.equal(data.round, null); - assert.equal(data.orders.length, 0); - assert.equal(data.summary.overdueUnshipped, 0); - // 11 个主仓仍然列出来(结构不能塌),但数字全为 0 - assert.equal(data.centers.length, registry.CENTERS.length); - assert.ok(data.centers.every((c) => c.overdueUnshipped === 0)); - assert.equal(calls.order, 0, '未到核验时间不应扫订单表'); - assert.match(data.note, /12:00/); - // 下一轮核验时刻要告诉前端,方便提示「12:00 开始」 - assert.equal(data.nextRoundAt, '2026-10-20 12:00:00'); - } finally { - if (realDb) require.cache[dbPath] = realDb; - else delete require.cache[dbPath]; - delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')]; - delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')]; + // 超时 = 1(09-25 审核)+ 3(补不到创建时间)+ 13(15:30 审核,只等 2.5 小时) + // + 32(山东品牌仓 14:30)+ 34(河南洛宁 12:30)+ 26(今天 13:10 审核) + // = 6 单 + // 2/6 今天审核晚于 16:00、4/5 不计考核、7 非主仓、8 去重、9~12 作废/已发 + // 31/33 是仓级例外窗口(山东 15:00 / 河南洛宁 13:00)挡掉的 + assert.equal(c.overdueUnshipped, 5, '1、3、13、26、25 五单算超时'); + assert.equal(c.oldestDays, 25, '最久一单 09-25 01:00 审核,到 18:00 核验是 25 天'); + assert.equal(c.heldCount, 2, '待审核 + 异常各 1 单'); + + assert.equal(data.summary.overdueUnshipped, 7); + assert.equal(data.summary.overdueCenterCount, 3); + assert.equal(data.summary.oldestDays, 25); + assert.equal(data.summary.heldCount, 2); + + // 明细:最久的排最前;基准时间是**审核时间**,创建时间只展示 + assert.equal(data.orders.length, 7); + assert.equal(String(data.orders[0].oId), '1'); + assert.equal(data.orders[0].auditTime, '2026-09-25 01:00:00', '基准是审核时间'); + assert.equal(data.orders[0].createdAt, '2026-09-25 00:50:00', '创建时间只作展示'); + assert.equal(data.orders[0].payTime, '2026-09-24 23:50:00'); + assert.equal(data.orders[0].benchmarkSource, 'last_audit'); + // 09-25 01:00 → 10-20 18:00 = 25 天 + 17 小时 = 37020 分钟 + assert.equal(data.orders[0].overdueMinutes, 37020); + + // 13 号单:15:30 审核 → 18:00 核验只等了 150 分钟,旧版 24h 门槛会漏,本版必须算 + const fresh = data.orders.find((o) => String(o.oId) === '13'); + assert.ok(fresh, '只等 2.5 小时也必须算超时(无 24h 门槛)'); + assert.equal(fresh.overdueMinutes, 150); + assert.equal(fresh.waitedHours, 2.5); + + // 26 号单:创建 10-18、今天 13:10 才审核 → 属于 18:00 轮,已等 = 18:00 − 13:10 = 290 + const lateAudit = data.orders.find((o) => String(o.oId) === '26'); + assert.ok(lateAudit, '下午才审核的老单要进 18:00 轮(不能因为创建早就算进 13:00 轮)'); + assert.equal(lateAudit.overdueMinutes, 290); + + // 25 号:重审单 → 审核时间必须显示**最后一次**(13:30,与聚水潭一致),不是首次 10:10 + const reAudited = data.orders.find((o) => String(o.oId) === '25'); + assert.ok(reAudited, '重审单也要算'); + assert.equal(reAudited.auditTime, '2026-10-20 13:30:00', '聚水潭「审核时间」= 最后一次审核'); + assert.equal(reAudited.overdueMinutes, 270, '18:00 − 13:30'); + + // 3 号单订单表补不到 created,展示回退付款时间,超时判定仍用审核时间 + const third = data.orders.find((o) => String(o.oId) === '3'); + assert.ok(third, '补不到创建时间的单也要算超时'); + assert.equal(third.createdAt, '', '确实没补到'); + assert.equal(third.benchmarkSource, 'last_audit'); + assert.equal(third.payTime, '2026-09-26 00:10:00'); + + // 创建时间晚于窗口上界 → 本轮不核验(2 号:创建 16:20、6 号:创建 17:00) + assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验'); + assert.ok(!data.orders.some((o) => String(o.oId) === '6'), '创建 17:00 晚于 16:00 本轮不核验'); + + // ── 仓级例外窗口(业务 2026-10-20:山东两仓 15:00、河南洛宁 13:00)── + assert.ok(!data.orders.some((o) => String(o.oId) === '31'), + '山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界,18:00 轮不核验'); + assert.ok(data.orders.some((o) => String(o.oId) === '32'), + '山东品牌仓 14:30 创建 → 早于该仓 15:00 上界,要核验'); + assert.ok(!data.orders.some((o) => String(o.oId) === '33'), + '河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界,18:00 轮不核验'); + assert.ok(data.orders.some((o) => String(o.oId) === '34'), + '河南洛宁仓 12:30 创建 → 早于该仓 13:00 上界,要核验'); + assert.equal(data.window.overrides.length, 3, '山东两仓 + 河南洛宁 三处例外'); + assert.deepEqual( + data.window.overrides.map((o) => [o.key, o.hour]).sort(), + [['HN_LN', 13], ['SD_BRAND', 15], ['SD_QGJ', 15]] + ); + // 例外只作用在 18:00 轮;13:00 轮没有例外(见下一个用例) + assert.match(data.window.overrides.find((o) => o.key === 'HN_LN').windowEnd, /13:00:00$/u); + // 被排除的仓不能算 + assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核'); + // 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单 + for (const id of ['9', '10', '11', '12']) { + assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`); + assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`); } -}); + // 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent) + assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数'); + + // 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20) + assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped'); + assert.ok( + !data.orders.some((o) => ['SO-35', 'SO-36'].includes(o.soId)), + '不考核快递的单不能出现在超时明细里' + ); + assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答']); + + // 不计考核清单 + assert.equal(data.heldOrders.length, 2); + const reasons = data.heldOrders.map((h) => h.holdReason).sort(); + assert.deepEqual(reasons, ['待审核', '订单异常'].sort()); + + // 新鲜度直接取自主查询的 query_time,不再额外扫表 + assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27'); + // 🔥 2026-10-20:上游 ETL 停更时页面必须自己喊出来(当天 15:48 之后 0 行刷新, + // 业务看到「已发货的单还显示未发货」,其实是旧快照)。阈值 90 分钟。 + assert.equal(typeof data.freshness.ageMinutes, 'number', '要给出快照距今多少分钟'); + assert.equal( + data.freshness.stale, + data.freshness.ageMinutes > 90, + '滞后超过 90 分钟必须标 stale(阈值与 STATE_STALE_MINUTES 对齐)' + ); + if (data.freshness.stale) { + assert.match(data.freshness.hint, /未刷新/u, 'stale 时必须给出人话提示'); + } + // 快照 11:18 早于本轮 18:00 核验时刻 → 必须明确「数据没覆盖核验时刻」 + assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时 coversDeadline 必须为 false'); + assert.match(data.freshness.hint, /没覆盖|未覆盖/u, '没覆盖核验时刻要在提示里说清楚'); + + // ── 性能护栏 ──────────────────────────────────────────────────────── + assert.equal(calls.state, 1, '状态表只允许查一次(金额/新鲜度不许再扫表)'); + const sql = calls.sql[0]; + assert.match(sql, /jst_order_express_details/u, '数据源必须是实时状态表'); + assert.match(sql, /e\.send_time IS NULL/u, '已发货靠 send_time 判'); + // 本版基准是审核时间 → 两列审核时间都必须在投影里 + assert.match(sql, /e\.last_oa_confirm_time/u, '窗口上界按审核时间判,必须取这一列'); + assert.match(sql, /e\.first_oa_confirm_time/u, '审核时间缺失时用首次审核时间兜底'); + // 🔥 不再把「创建/付款 ≤ 核验时刻−24h」下推到 SQL(24h 门槛已取消) + assert.doesNotMatch(sql, /@cutoff/u, '不许再有 24h 超时下推参数'); + assert.doesNotMatch(sql, /pay_time <=/u, '不许再按付款时间做 24h 截断'); + assert.doesNotMatch(sql, /createdAt/u, '状态表的 createdAt 是同步落库时间,不能用'); + // 宽列会触发基表回表:同一 WHERE 取 2 列 0.7s,取 15 列(含长文本)54s + assert.doesNotMatch(sql, /order_labels|buyer_message|remark/u, '不能投影长文本列'); + assert.doesNotMatch(sql, /CONVERT/u, '不要在 SQL 里 CONVERT 时间列'); + assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用'); + assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表'); + + // 补创建时间:必须走两张订单表,且**只投影 6 个窄列** + assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实'); + for (const bs of calls.backfillSql) { + assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u, + `补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`); + assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u, + '多取一列就会把 0.2s 拖到 38s'); + } + + // ⚠️ 窗口参数走 params,不在 SQL 里手写 + assert.equal(calls.params[0].windowStart, '2026-09-20 00:00:00', '下界是近 30 天'); + assert.equal(calls.params[0].cutoff, undefined, '24h 下推参数已删除'); + assert.equal(calls.params[0].shipStatus, '发货中'); + + // round 必须带出 windowEnd,否则页面渲染成「核验 前」 + assert.equal(data.round.key, 'AFTERNOON'); + assert.match(String(data.round.windowEnd), /16:00:00$/u); + assert.equal(data.window.boundary, 'audit', '窗口按审核时间判,要透出给前端'); + assert.equal(data.window.measuredBy, 'audit', '已等时长按审核时间算'); +})); + +test('13:00 轮:只核验「今天 00:00~07:00 审核」的单 + 历史未发', withStubbedService(async (svc) => { + const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 }); + assert.equal(data.round.key, 'NOON'); + assert.equal(data.round.windowEnd, '2026-10-20 07:00:00'); + + // 21 号:今天 07:20 审核 → 晚于 7 点,**不属于 13:00 轮**(业务口径:7 点前审核的才 13:00 核验) + assert.ok(!data.orders.some((o) => String(o.oId) === '21'), + '今天 07:20 审核 → 不是 7 点前审核的单,13:00 轮不该出现'); + // 24 号:今天 07:40 审核 → 同理不核验 + assert.ok(!data.orders.some((o) => String(o.oId) === '24'), + '今天 07:40 审核 → 不进 13:00 轮'); + // 26 号:今天 13:10 才审核(9818476 那种形态)→ 更不能进 13:00 轮 + assert.ok(!data.orders.some((o) => String(o.oId) === '26'), + '下午才审核的老单不能进 13:00 轮'); + + // 22 号:今天 06:40 审核(6:30~7:00 这段)→ 13:00 只等 380 分钟,必须超时(无 24h 门槛) + const b = data.orders.find((o) => String(o.oId) === '22'); + assert.ok(b, '6:30~7:00 审核、13:00 未发就是超时'); + assert.equal(b.benchmarkSource, 'last_audit'); + assert.equal(b.overdueMinutes, 380); + assert.equal(b.waitedHours, 6.3); + // 23 号:昨天审核、至今未发 → 历史未发池也要看到 + const d = data.orders.find((o) => String(o.oId) === '23'); + assert.ok(d, '含历史未发单(历史不受 00:00 起限制)'); + assert.equal(d.overdueMinutes, 1010); + + // 25 号:最后一次审核 10-20 13:30 → 不属于「7 点前审核」,13:00 轮不核验 + assert.ok(!data.orders.some((o) => String(o.oId) === '25'), + '重审到 13:30 的单归 18:00 轮,不进 13:00 轮'); + + // 13:00 轮 = 22(今天 06:40 审核)+ 23(昨天 20:10 审核,历史未发)= 2 单 + assert.equal(data.summary.overdueUnshipped, 2); + assert.equal(data.window.boundary, 'audit', '窗口按审核时间判'); + // 仓级例外只在 18:00 轮生效,13:00 轮没有例外 + assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外'); +}, 'noon')); + +test('服务层 13:00 前返回 notStarted,且不去查状态表', withStubbedService(async (svc, { calls }) => { + const data = await svc.getShipTimeliness({ now: at(9, 30), limit: 10 }); + assert.equal(data.notStarted, true); + assert.equal(data.round, null); + assert.equal(data.orders.length, 0); + assert.equal(data.summary.overdueUnshipped, 0); + // 11 个主仓仍然列出来(结构不能塌),但数字全为 0 + assert.equal(data.centers.length, registry.CENTERS.length); + assert.ok(data.centers.every((x) => x.overdueUnshipped === 0)); + assert.equal(calls.state, 0, '未到核验时间不应查库'); + assert.equal(calls.backfill, 0, '未到核验时间也不该回订单表'); + assert.match(data.note, /13:00/u); + assert.equal(data.nextRoundAt, '2026-10-20 13:00:00'); + assert.equal(data.nextWindowEnd, '2026-10-20 07:00:00'); +})); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index b899808..eede766 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -36,6 +36,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: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 }, { key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true, children: [ diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index 1159ad5..dd90ce7 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -119,6 +119,11 @@ const SIDEBAR_ROUTE_MAP = { activePath: '/planning/operation/mkh-bom-data', matchMode: 'prefix', }, + 'warehouse-ship-timeliness': { + path: '/planning/operation/warehouse-ship-timeliness', + activePath: '/planning/operation/warehouse-ship-timeliness', + matchMode: 'prefix', + }, 'daily-inventory': { path: '/planning/operation/daily-inventory' }, 'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' }, 'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-sales-analysis' }, diff --git a/frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx b/frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx deleted file mode 100644 index 56d9de1..0000000 --- a/frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx +++ /dev/null @@ -1,146 +0,0 @@ -import React from 'react'; -import { Tooltip } from 'antd'; - -/** - * 仓储中心 24 小时发货时效 —— 列表左侧侧栏 - * ──────────────────────────────────────────────────────────────────────────── - * 业务需求(yanhui 2026-10-20): - * 「在列表左侧加一栏仓储中心24小时发货时效,下面列 11 个仓…只显示超时未发」 - * - * 设计取舍: - * · **只显示超时未发** —— 数量为 0 的仓也保留一行(浅色显示「—」), - * 这样业务能一眼看出「这个仓今天全发完了」,比直接隐藏更有信息量; - * · 点击某个仓 → 把该仓的超时订单筛到右侧表格里(不跳页,避免业务来回切); - * · 崔桥工厂半成品仓系统里没有同名仓,`placeholder=true` 时显示「未接入」。 - * - * 数据来自 getWarehouseShipTimeliness,字段见后端 warehouseShipTimelinessService。 - */ -function WarehouseShipTimelinessSidebar({ - data, loading, error, activeCenter, onSelectCenter, collapsed, onToggleCollapse, -}) { - const centers = data && Array.isArray(data.centers) ? data.centers : []; - const summary = data && data.summary ? data.summary : null; - const round = data && data.round ? data.round : null; - const freshness = data && data.freshness ? data.freshness : null; - const totalOverdue = summary ? summary.overdueUnshipped : 0; - const hasRound = Boolean(round); - // 12:00 之前后端返回 round=null + notStarted=true: - // 业务口径是「12:00 才开始第一轮核验」,此时**不展示任何超时数字**, - // 也不回填昨天的结算结果(否则会被误当成今天的超时)。 - const notStarted = Boolean(data && data.notStarted); - // 核验截止时刻:12:00 轮截止 07:00、16:00 轮截止 16:00,直接展示,业务不用换算 - const cutoffText = String((round && round.windowEnd) || '').slice(11, 16); - - return ( - - ); -} - -export default WarehouseShipTimelinessSidebar; diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 9ca3aea..ab486a9 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -19,8 +19,6 @@ import { } from '../../api/mkhBom'; import { createRequestGate } from '../../utils/requestGate'; import { requireMkhFreshContract } from '../../utils/mkhFreshContract'; -import WarehouseShipTimelinessSidebar from './WarehouseShipTimelinessSidebar'; -import { getWarehouseShipTimeliness } from '../../api/warehouseShipTimeliness'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -35,16 +33,6 @@ const number = (value, digits = 0) => (value === null || value === undefined || const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); -// 超时时长(分钟)→ 「3小时20分」。业务看的是「晚了多久」,不是精确到分钟的秒数。 -const formatOverdue = (minutes) => { - const m = Number(minutes); - if (!Number.isFinite(m) || m <= 0) return '—'; - if (m < 60) return `${m} 分钟`; - const h = Math.floor(m / 60); - const rest = m % 60; - return rest ? `${h} 小时 ${rest} 分` : `${h} 小时`; -}; - const EMPTY_FILTERS = { keyword: '', scope: 'warning' }; // 页面长时打开时定期重读。后端共享 10 分钟索引缓存,因此多个页面的轮询 // 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。 @@ -312,28 +300,6 @@ const MkhBomData = () => { const autoRefreshPendingRef = useRef(false); const [bodyHeight, setBodyHeight] = useState(420); - // ── 仓储中心 24 小时发货时效(左侧侧栏)──────────────────────────────── - // 与 MKH 列表**完全独立**的两套数据:列表来自 BOM/库存,侧栏来自订单+出库表。 - // 所以侧栏单独 load、单独失败,不能因为发货时效查询慢就把整个列表拖住。 - const [shipTimeliness, setShipTimeliness] = useState(null); - const [shipLoading, setShipLoading] = useState(false); - const [shipError, setShipError] = useState(''); - const [shipCollapsed, setShipCollapsed] = useState(false); - // 选中的主仓:非空时右侧表格只显示该仓的超时订单 - const [shipCenter, setShipCenter] = useState(''); - - // 当前选中主仓的名字 + 该仓的超时订单(从同一次快照里筛,保证与侧栏数字一致) - const shipCenterName = useMemo(() => { - if (!shipCenter || !shipTimeliness) return ''; - const hit = (shipTimeliness.centers || []).find((c) => c.key === shipCenter); - return hit ? hit.name : shipCenter; - }, [shipCenter, shipTimeliness]); - - const shipCenterOrders = useMemo(() => { - if (!shipCenter || !shipTimeliness) return []; - return (shipTimeliness.orders || []).filter((o) => o.centerKey === shipCenter); - }, [shipCenter, shipTimeliness]); - const beginRefreshProgress = useCallback((title) => { refreshProgressGenerationRef.current += 1; const generation = refreshProgressGenerationRef.current; @@ -438,33 +404,6 @@ const MkhBomData = () => { useEffect(() => { load(); }, [load]); - // ── 发货时效侧栏加载 ──────────────────────────────────────────────────── - // 注意:这个接口要扫两张订单表 + 出库表(rpt 全是视图),冷启动实测 40~50 秒, - // 所以**不能**跟着列表每 2 分钟的轮询一起跑,否则后端一直被打。 - // 独立节奏:进页面拉一次,之后每 10 分钟一次(核验点只有 12:00 / 16:00 两个)。 - const loadShipTimeliness = useCallback(async () => { - setShipLoading(true); - setShipError(''); - try { - const result = await getWarehouseShipTimeliness({ limit: 500 }); - if (!result || result.success === false) { - setShipError(result?.error || '加载发货时效失败'); - return; - } - setShipTimeliness(result.data || null); - } catch (shipLoadError) { - setShipError(shipLoadError?.response?.data?.error || shipLoadError?.message || '加载发货时效失败'); - } finally { - setShipLoading(false); - } - }, []); - - useEffect(() => { - loadShipTimeliness(); - const timer = window.setInterval(loadShipTimeliness, 10 * 60 * 1000); - return () => window.clearInterval(timer); - }, [loadShipTimeliness]); - // 页面保持打开时自动更新;从后台切回且数据已超过轮询周期时立即重读。 // 后台刷新保留当前表格,不显示全页加载态;请求门仍会阻止旧响应覆盖新数据。 useEffect(() => { @@ -1014,6 +953,7 @@ const MkhBomData = () => { 进度为查询阶段估算,实际完成以服务端返回为准。 +
MKH-BOM 资料 @@ -1047,73 +987,6 @@ const MkhBomData = () => {
- {/* - 左侧「仓储中心24小时发货时效」侧栏 + 右侧原有内容,横向两栏。 - 侧栏收起时只留一条窄条,主区自动占满 —— 用 flex,不用固定宽度判断。 - */} -
- setShipCollapsed((v) => !v)} - /> - -
- {/* - 超时明细:只有点了左侧某个仓才展开。 - 明细来自同一次 list 接口(已按 order_date 升序,最早的最急), - 不再单独请求 —— 两套数据必须同源同快照,否则数字对不上。 - */} - {shipCenter && shipTimeliness ? ( - - 超时未发订单 · {shipCenterName} - - {shipCenterOrders.length} 单 - {shipTimeliness.truncated && shipCenterOrders.length >= (shipTimeliness.detailLimit || 0) - ? `(已截断,仅显示前 ${shipTimeliness.detailLimit} 条)` - : ''} - - - )} - extra={} - > - `${r.source}-${r.oId}`} - size="small" - pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }} - dataSource={shipCenterOrders} - columns={[ - { title: '订单号', dataIndex: 'oId', width: 110 }, - { title: '店铺', dataIndex: 'shopName', width: 190, ellipsis: true }, - { title: '发货仓', dataIndex: 'warehouseName', width: 190, ellipsis: true }, - { title: '创建时间', dataIndex: 'orderDate', width: 150 }, - { - title: '超时时长', - dataIndex: 'overdueMinutes', - width: 96, - render: (v) => {formatOverdue(v)}, - }, - { - title: '金额', - dataIndex: 'payAmount', - width: 96, - align: 'right', - render: (v) => `¥${number(v, 2)}`, - }, - { title: '状态', dataIndex: 'status', width: 110 }, - ]} - /> - - ) : null} - { - - ); }; diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css index aeda168..afb18b0 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -391,280 +391,3 @@ .mkh-alert-form--inner { margin-top: 4px; } - -/* ============ 仓储中心 24 小时发货时效(左侧侧栏,2026-10-20)============ */ -/* 左侧固定宽侧栏 + 右侧自适应主区。不用 float,避免表格宽度塌陷。 - 侧栏 sticky 贴顶:表格很长时侧栏跟着滚走会看不到告警数,业务要一直看得见。 */ -.mkh-with-side { - display: flex; - align-items: flex-start; - gap: 14px; -} - -.mkh-with-side__main { - flex: 1 1 auto; - min-width: 0; -} - -.mkh-ship-side { - flex: none; - align-self: flex-start; - width: 208px; - /* 11 个仓 + 头部合计约 400px,给足高度;超出才滚动。 - 不加 max-height 会被 flex 拉伸/压缩,撑开后又不该无限长(表格很长时)。 */ - max-height: calc(100vh - 40px); - overflow-y: auto; - padding: 12px 10px 14px; - border: 1px solid var(--sycm-border-color); - border-radius: 10px; - background: var(--sycm-bg-container); - box-sizing: border-box; - position: sticky; - top: 8px; -} - -.mkh-ship-side--collapsed { - width: 44px; - padding: 12px 6px; -} - -.mkh-ship-side__head { - margin-bottom: 10px; -} - -.mkh-ship-side__title { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - font-size: 13px; - font-weight: 600; - line-height: 1.4; - color: var(--sycm-text-strong); -} - -.mkh-ship-side--collapsed .mkh-ship-side__title > span { - writing-mode: vertical-rl; - letter-spacing: 2px; - font-size: 12px; -} - -.mkh-ship-side__collapse { - flex: none; - width: 18px; - height: 18px; - padding: 0; - border: 1px solid var(--sycm-border-color); - border-radius: 4px; - background: transparent; - color: var(--sycm-text-tertiary); - font-size: 11px; - line-height: 1; - cursor: pointer; -} - -.mkh-ship-side__collapse:hover { - color: var(--sycm-text-primary); - border-color: var(--sycm-text-tertiary); -} - -.mkh-ship-side__meta { - display: flex; - flex-wrap: wrap; - gap: 4px 8px; - margin-top: 6px; - font-size: 11px; - color: var(--sycm-text-tertiary); -} - -.mkh-ship-side__round { - padding: 0 5px; - border-radius: 3px; - background: rgba(22, 119, 255, 0.12); - color: #0958d9; -} - -.mkh-ship-side__round--idle { - background: rgba(136, 135, 128, 0.14); - color: var(--sycm-text-tertiary); -} - -.mkh-ship-side__total { - display: flex; - align-items: baseline; - gap: 6px; - margin-top: 8px; - padding-top: 8px; - border-top: 1px dashed var(--sycm-border-color); -} - -.mkh-ship-side__total-num { - font-size: 22px; - font-weight: 700; - line-height: 1; - color: #cf1322; - font-variant-numeric: tabular-nums; -} - -.mkh-ship-side__total-num[data-zero='true'] { - color: var(--sycm-text-tertiary); -} - -.mkh-ship-side__total-label { - font-size: 11px; - color: var(--sycm-text-tertiary); -} - -.mkh-ship-side__list { - margin: 0; - padding: 0; - list-style: none; -} - -.mkh-ship-side__item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - width: 100%; - padding: 5px 7px; - margin-bottom: 2px; - border: 1px solid transparent; - border-radius: 6px; - background: transparent; - font-size: 12px; - text-align: left; - color: var(--sycm-text-primary); - cursor: pointer; - transition: background 0.12s ease; -} - -.mkh-ship-side__item:hover { - background: var(--sycm-bg-subtle); -} - -.mkh-ship-side__item.is-active { - border-color: rgba(22, 119, 255, 0.45); - background: rgba(22, 119, 255, 0.08); -} - -.mkh-ship-side__item.is-empty .mkh-ship-side__name { - color: var(--sycm-text-tertiary); -} - -.mkh-ship-side__item.is-disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.mkh-ship-side__name { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.mkh-ship-side__badge { - flex: none; - min-width: 22px; - padding: 0 5px; - border-radius: 9px; - background: rgba(136, 135, 128, 0.12); - font-size: 11px; - font-weight: 600; - line-height: 16px; - text-align: center; - color: var(--sycm-text-tertiary); - font-variant-numeric: tabular-nums; -} - -.mkh-ship-side__badge.is-warn { - background: rgba(255, 77, 79, 0.16); - color: #cf1322; -} - -.mkh-ship-side__badge--none { - font-weight: 400; -} - -.mkh-ship-side__warn { - margin-top: 10px; - padding: 6px 8px; - border-radius: 6px; - background: rgba(250, 173, 20, 0.14); - font-size: 11px; - line-height: 1.6; - color: #ad4e00; - cursor: help; -} - -.mkh-ship-side__hint { - margin-top: 10px; - font-size: 11px; - line-height: 1.6; - color: var(--sycm-text-tertiary); -} - -.mkh-ship-side__loading { - margin-top: 8px; - font-size: 11px; - color: var(--sycm-text-tertiary); -} - -/* 窄屏兜底(<1100px):此时右侧表格已经放不下,侧栏整体铺到上方。 - * ⚠️ 1100~1500px 之间**保持左侧竖栏** —— 那是业务最常用的笔记本分辨率 - * (1366×768 / 1440×900),侧栏必须还在左边,否则「列表左侧加一栏」的需求就不成立。 - * 列表始终竖向排列,绝不横排(横排后 11 个仓挤成一行完全没法读)。 */ -@media (max-width: 1100px) { - .mkh-with-side { - flex-direction: column; - } - - .mkh-ship-side, - .mkh-ship-side--collapsed { - position: static; - width: 100%; - max-height: none; - } - - .mkh-ship-side--collapsed .mkh-ship-side__title > span { - writing-mode: horizontal-tb; - } -} - -/* 中窄屏(1100~1500px,含 1366×768 笔记本):侧栏仍在左侧,只是收窄 */ -@media (max-width: 1500px) and (min-width: 1101px) { - .mkh-ship-side { - width: 176px; - } - - .mkh-ship-side__name { - font-size: 12px; - } -} - -/* 超时明细卡(点左侧某个仓才出现) */ -.mkh-ship-detail { - margin-bottom: 12px; -} - -.mkh-ship-detail__count { - margin-left: 8px; - font-size: 12px; - font-weight: 400; - color: var(--sycm-text-tertiary); -} - -.mkh-ship-detail__clear { - padding: 0; - border: none; - background: transparent; - color: #0958d9; - font-size: 12px; - cursor: pointer; -} - -.mkh-ship-detail__overdue { - color: #cf1322; - font-weight: 600; - font-variant-numeric: tabular-nums; -} diff --git a/frontend/src/pages/WarehouseShipTimeliness/index.jsx b/frontend/src/pages/WarehouseShipTimeliness/index.jsx new file mode 100644 index 0000000..6d1db01 --- /dev/null +++ b/frontend/src/pages/WarehouseShipTimeliness/index.jsx @@ -0,0 +1,546 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, Button, Card, Empty, Modal, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, +} from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { getWarehouseShipTimeliness } from '../../api/warehouseShipTimeliness'; +import './style.css'; + +const { Title, Text } = Typography; + +// 核验点只有 13:00 / 18:00 两个,10 分钟刷新一次足够。 +// 接口要扫两张订单表 + 出库表(rpt 下全是视图),冷启动 40~60 秒,别跟着其它页面的轮询节奏跑。 +const AUTO_REFRESH_MS = 10 * 60 * 1000; + +const number = (value, digits = 0) => ( + value === null || value === undefined || value === '' + ? '0' + : Number(value).toLocaleString('zh-CN', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }) +); + +// 超时时长(分钟)→「3 小时 20 分」/「12 天 4 小时」。 +// ⚠️ 口径放开后(含历史未发单)会出现跨天的超时,必须给「天」,否则显示成「8640 小时」。 +const formatOverdue = (minutes) => { + const m = Number(minutes); + if (!Number.isFinite(m) || m <= 0) return '—'; + if (m < 60) return `${m} 分钟`; + const h = Math.floor(m / 60); + const rest = m % 60; + if (h < 24) return rest ? `${h} 小时 ${rest} 分` : `${h} 小时`; + const d = Math.floor(h / 24); + const hr = h % 24; + return hr ? `${d} 天 ${hr} 小时` : `${d} 天`; +}; + +/** + * 「不计考核」清单的列(待审核 / 订单异常)。 + * 业务反馈(2026-10-20):贴进来的一批单号在聚水潭查不到,实测真实状态是 + * 「已取消(发货前售后)」或「异常」—— 这类单不该算仓库超时。 + * 这里把它们单列出来,回答「为什么这些单没进超时列表」。 + */ +const HELD_COLUMNS = [ + { title: '内部单号', dataIndex: 'oId', width: 100 }, + { title: '线上订单号', dataIndex: 'soId', width: 170, ellipsis: true }, + { title: '店铺', dataIndex: 'shopName', width: 170, ellipsis: true }, + { title: '发货仓', dataIndex: 'warehouseName', width: 160, ellipsis: true }, + { + title: '挂起原因', + dataIndex: 'holdReason', + width: 110, + render: (v) => ( + {v || '—'} + ), + }, + { + title: '发货时间', + dataIndex: 'sendTime', + width: 178, + ellipsis: true, + render: (v, row) => { + if (v) return v; + const no = row?.logisticId; + if (no) { + return ( + + 已打单 · {row.logisticStatus || '未揽收'} + + ); + } + return 未发货; + }, + }, + { + title: '审核时间', + dataIndex: 'auditTime', + 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: 110 }, + // 线上订单号 = 平台 so_id(京东/抖音/天猫等平台单号,客服按它对接客户) + { + title: '线上订单号', + dataIndex: 'soId', + width: 178, + ellipsis: true, + render: (v) => (v ? {v} : '—'), + }, + { + title: '店铺', + dataIndex: 'shopName', + width: 180, + ellipsis: true, + render: (v) => (v || '—'), + }, + { + title: '发货仓', + dataIndex: 'warehouseName', + width: 170, + ellipsis: true, + render: (v) => (v || '—'), + }, + { + // 发货时间 = 聚水潭回传的发货时间(业务 2026-10-20:明细里不要创建时间,要发货时间) + title: ( + + 发货时间 + + ), + dataIndex: 'sendTime', + width: 178, + ellipsis: true, + render: (v, row) => { + if (v) return v; + const no = row?.logisticId; + if (no) { + return ( + + 已打单 · {row.logisticStatus || '未揽收'} + + ); + } + return 未发货; + }, + }, + { + // 审核时间 = **考核基准**,取聚水潭「审核时间」列(最后一次审核,重审会刷新) + title: ( + + 审核时间 + + ), + dataIndex: 'auditTime', + width: 152, + ellipsis: true, + render: (v, row) => (v + ? v + : ( + + {row?.benchmarkTime || '—'} * + + )), + }, + // 付款时间 = 客户付款那一刻(并列展示,方便对账) + { title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' }, + { + // 发货状态:聚水潭实时状态(发货中 = 已审核未发货,即考核对象) + title: ( + + 发货状态 + + ), + dataIndex: 'state', + width: 104, + render: (v) => ( + {v || '—'} + ), + }, + { + title: '物流状态', + dataIndex: 'logisticStatus', + width: 100, + render: (v) => (v ? {v} : '—'), + }, + { + title: ( + + 已等时长 + + ), + dataIndex: 'overdueMinutes', + width: 112, + render: (v) => {formatOverdue(v)}, + }, +]; + +/** + * 仓储中心 24 小时发货时效(独立页面) + * ──────────────────────────────────────────────────────────────── + * 业务口径(yanhui 2026-10-20): + * · 11 个主仓,子仓并入主仓一起算; + * · 今天 07:00 前**审核**的订单 13:00 核验,截止今天 16:00 前审核的单 18:00 核验 + * (含 7 点前未发出的、含今天之前审核至今未发的历史单;山东两仓 15:00、河南洛宁 13:00); + * 今天之前审核仍未发的(历史未发)两个轮次都算; + * **到核验时刻未发即超时**,没有「满 24 小时」门槛(业务纠偏点); + * 轮次归属和「已等时长」都按**审核时间**算(聚水潭「审核时间」列,最后一次审核); + * · **13:00 之前不核验**,直接显示「未到核验时间」,不拿昨天的结果冒充今天; + * · 已出库 / 待出库以**聚水潭发货时间**为准(有 send_time = 已发); + * · 只显示超时未出库(发完的仓显示「—」),点仓库可筛右侧明细。 + * + * 数据来自 /api/warehouse-ship-timeliness/list(service 层见 + * backend/src/services/warehouseShipTimelinessService.js)。 + */ +function WarehouseShipTimeliness() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + // 选中的主仓:为空时右侧展示全部仓的超时明细 + const [activeCenter, setActiveCenter] = useState(''); + // 「业务挂起(疑问单)」明细弹窗 + const [heldOpen, setHeldOpen] = useState(false); + + // force=true 时带 force=1 让后端绕过 5 分钟结果缓存重查库; + // 定时轮询走缓存(这张视图单次要几十秒,每轮都重查会把库压死) + const load = useCallback(async (force = false) => { + setLoading(true); + setError(''); + try { + const result = await getWarehouseShipTimeliness({ limit: 1000, force: force ? 1 : 0 }); + if (!result || result.success === false) { + setError(result?.error || '加载发货时效失败'); + return; + } + setData(result.data || null); + } catch (loadError) { + setError(loadError?.response?.data?.error || loadError?.message || '加载发货时效失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + const timer = window.setInterval(() => load(), AUTO_REFRESH_MS); + return () => window.clearInterval(timer); + }, [load]); + + const centers = useMemo(() => (Array.isArray(data?.centers) ? data.centers : []), [data]); + const orders = useMemo(() => (Array.isArray(data?.orders) ? data.orders : []), [data]); + // 业务挂起(疑问单)—— 与 orders 互斥,两者相加才是待出库池 + const heldOrders = useMemo( + () => (Array.isArray(data?.heldOrders) ? data.heldOrders : []), + [data] + ); + const summary = data?.summary || {}; + const round = data?.round || null; + const freshness = data?.freshness || null; + // 13:00 之前后端返回 round=null + notStarted=true:此时**不展示任何超时数字** + const notStarted = Boolean(data?.notStarted); + const hasRound = Boolean(round); + const cutoffText = String(round?.windowEnd || '').slice(11, 16); + const windowEndText = String(data?.window?.end || '').slice(11, 16); + // 仓级例外窗口:18:00 轮里 山东两仓 15:00、河南洛宁 13:00(业务 2026-10-20 指定) + const windowOverrides = Array.isArray(data?.window?.overrides) ? data.window.overrides : []; + const overrideText = windowOverrides + .map((o) => `${o.name} ${String(o.hour).padStart(2, '0')}:00`) + .join('、'); + // 未到核验时间时的提示文案,全部由后端给,前端不写死时刻 + const nextRoundText = String(data?.nextRoundAt || '').slice(11, 16) || '13:00'; + const nextWindowEndText = String(data?.nextWindowEnd || '').slice(11, 16) || '07:00'; + + const activeCenterName = useMemo(() => { + if (!activeCenter) return ''; + const hit = centers.find((c) => c.key === activeCenter); + return hit ? hit.name : activeCenter; + }, [activeCenter, centers]); + + const detailOrders = useMemo(() => ( + activeCenter ? orders.filter((o) => o.centerKey === activeCenter) : orders + ), [activeCenter, orders]); + + return ( + +
+ 仓储中心 24 小时发货时效 + + 按聚水潭订单实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。 + + + 每天 13:00 核验「今天 07:00 前审核」、18:00 核验「截止 16:00 前审核」、 + 到核验时刻仍未发货的订单;两个轮次都包含今天之前审核、至今未发的历史未发单 + (18:00 轮也会带上 7 点前审核、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。 + 13:00 之前不核验,显示「未到核验时间」。 + 到点还没发就算超时 —— 不设「满 24 小时」门槛(早上 7 点前那份单,13:00 还没走就是超时)。 + 仓级例外:18:00 轮里 山东青岗集仓 / 山东品牌仓 只看 15:00 前审核的单、 + 河南洛宁仓 只看 13:00 前审核的单(核验时刻仍是 18:00)。 + 轮次与「已等时长」都按审核时间算(= 聚水潭「审核时间」列,最后一次审核); + 明细并列「审核时间 / 发货时间」,方便直接和聚水潭对账。 + 已发 / 未发以聚水潭发货时间为准:有发货时间就是已发,不再用订单表的发货字段反推 + —— 该字段经常不回写,会把早就发货的单误判成超时。 + 已打单但还没揽收的(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」, + 鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。 + 自提 / 中铁 / 壹米滴答 三个快递不考核(客户自提或专线大件,仓库控制不了运输), + 已按物流公司自动剔除。 + 「已支付待审核」和「异常」的单不计考核(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细; + 作废单(已取消,含发货前售后)不考核也不展示 —— 客户付款后取消的单仓库本就不该发。 + +
+ + +
+
+ {hasRound ? ( + <> + {round.label} + + 核验今天 {cutoffText} 前审核的订单 + + + 含历史未发 · 到点未发即超时 + {overrideText ? `(${overrideText} 为仓级例外)` : ''} + + + ) : ( + <> + 未到核验时间 + + {nextRoundText} 开始首轮核验 + (核验今天 {nextWindowEndText} 前审核的订单) + + + )} +
+ + {data?.generatedAt ? ( + + 数据更新于 {String(data.generatedAt).slice(11, 19)} + {data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s,点刷新重查)` : ''} + + ) : null} + + +
+
+ + {error ? ( + 重试} + /> + ) : null} + + {freshness && (freshness.stale || freshness.coversDeadline === false) ? ( + + ) : null} + +
+ + + 超时未发货 + + )} + value={notStarted ? 0 : (summary.overdueUnshipped ?? 0)} + suffix="单" + formatter={(v) => (notStarted ? '—' : number(v))} + valueStyle={{ color: (summary.overdueUnshipped || 0) > 0 ? '#cf1322' : undefined }} + /> + + setHeldOpen(true)} + > + + 不计考核 明细 + + )} + value={notStarted ? 0 : (summary.heldCount ?? 0)} + suffix="单" + formatter={(v) => (notStarted ? '—' : number(v))} + valueStyle={{ color: (summary.heldCount || 0) > 0 ? '#8c8c8c' : undefined }} + /> + + + (notStarted ? '—' : number(v))} + /> + + + (notStarted ? '—' : number(v))} + valueStyle={{ color: (summary.oldestDays || 0) >= 7 ? '#d46b08' : undefined }} + /> + +
+ +
+ 点仓库看明细} + > + +
    + {centers.map((c) => { + const isActive = activeCenter === c.key; + const disabled = c.placeholder || notStarted; + return ( +
  • + 0 ? `(最久已等 ${c.oldestDays} 天)` : ''}` + + `${c.heldCount > 0 ? `\n另有不计考核(待审核/异常)${c.heldCount} 单` : ''}` + + (c.warehouseCount > 1 ? `\n含子仓 ${c.warehouseCount} 个` : '')} + > + + +
  • + ); + })} +
+
+
+ + + 超时未发货订单 + + {activeCenterName || '全部仓库'} · {detailOrders.length} 单 + {data?.truncated && detailOrders.length >= (data?.detailLimit || 0) + ? `(已截断,仅显示前 ${data.detailLimit} 条)` + : ''} + + + )} + extra={activeCenter ? ( + + ) : null} + > + + {notStarted ? ( + + ) : detailOrders.length ? ( +
`${r.source}-${r.oId}`} + size="small" + pagination={{ pageSize: 20, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 单` }} + dataSource={detailOrders} + columns={DETAIL_COLUMNS} + scroll={{ x: 1642 }} + /> + ) : ( + + )} + + + + + setHeldOpen(false)} + footer={null} + width={1080} + destroyOnClose + > + + {heldOrders.length ? ( +
`${r.oId}`} + size="small" + pagination={{ pageSize: 10, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 单` }} + dataSource={heldOrders} + columns={HELD_COLUMNS} + scroll={{ x: 1280 }} + /> + ) : ( + + )} + + + + 说明:今天 7:00 前审核的订单 13:00 核验;截止 16:00 前审核的单 18:00 核验 + (含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。 + 到点仍未发货即超时(不设「满 24 小时」门槛); + 轮次与「已等时长」都按审核时间(聚水潭显示值)算。 + 发货 = 聚水潭回传的发货时间。 + 作废单不考核(被合并 / 拆单父单 / 已取消,或订单表已有发货时间) + {summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''}; + 自提 / 中铁 / 壹米滴答不考核 + {summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''}; + 已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。 + + + ); +} + +export default WarehouseShipTimeliness; diff --git a/frontend/src/pages/WarehouseShipTimeliness/style.css b/frontend/src/pages/WarehouseShipTimeliness/style.css new file mode 100644 index 0000000..05eb597 --- /dev/null +++ b/frontend/src/pages/WarehouseShipTimeliness/style.css @@ -0,0 +1,255 @@ +.wst-page { + padding: 16px 20px 24px; +} + +.wst-header { + margin-bottom: 14px; +} + +.wst-header .ant-typography { + margin-bottom: 4px; +} + +.wst-note { + display: block; + max-width: 1180px; + margin-top: 8px; + padding: 10px 14px; + /* 页面顶部是深色渐变,直接放浅色小字会看不清,给口径说明一块浅底 */ + background: var(--sycm-bg-container); + border: 1px solid var(--sycm-border-color); + border-radius: 8px; + font-size: 12px; + line-height: 1.75; +} + +/* ============ 轮次状态条 ============ */ +.wst-status { + margin-bottom: 12px; +} + +.wst-status__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.wst-status__round { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.wst-status__badge { + padding: 1px 8px; + border-radius: 4px; + background: rgba(22, 119, 255, 0.12); + color: #0958d9; + font-size: 12px; + font-weight: 600; + line-height: 20px; +} + +.wst-status__badge--idle { + background: rgba(136, 135, 128, 0.14); + color: var(--sycm-text-tertiary); +} + +.wst-status__label { + font-size: 13px; + color: var(--sycm-text-primary); +} + +.wst-status__window, +.wst-status__time { + font-size: 12px; + color: var(--sycm-text-tertiary); +} + +.wst-alert { + margin-bottom: 12px; +} + +/* ============ 统计卡 ============ */ +/* 5 张卡(超时未出库 / 业务挂起 / 涉及仓库 / 最久未出库 / 合计金额) + 用 grid 而不是 Row/Col:24 栅格除不尽 5,Row/Col 会把第 5 张挤到下一行。 */ +.wst-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +@media (max-width: 1500px) { + .wst-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 900px) { + .wst-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +.wst-stat { + height: 100%; +} + +/* 「业务挂起」卡可点开明细 */ +.wst-stat--held { + cursor: pointer; + transition: box-shadow 0.2s; +} + +.wst-stat--held:hover { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.wst-stat__more { + font-size: 12px; + font-weight: 400; + color: #1677ff; +} + +/* ============ 主从两栏 ============ */ +.wst-body { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.wst-centers { + flex: none; + width: 264px; +} + +.wst-centers__hint { + font-size: 11px; +} + +.wst-centers__list { + margin: 0; + padding: 0; + list-style: none; +} + +.wst-center { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 6px 8px; + margin-bottom: 2px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + font-size: 13px; + text-align: left; + color: var(--sycm-text-primary); + cursor: pointer; + transition: background 0.12s ease; +} + +.wst-center:hover { + background: var(--sycm-bg-subtle); +} + +.wst-center.is-active { + border-color: rgba(22, 119, 255, 0.45); + background: rgba(22, 119, 255, 0.08); +} + +.wst-center.is-empty .wst-center__name { + color: var(--sycm-text-tertiary); +} + +.wst-center.is-disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.wst-center__name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.wst-center__badge { + flex: none; + min-width: 24px; + padding: 0 6px; + border-radius: 9px; + background: rgba(136, 135, 128, 0.12); + font-size: 11px; + font-weight: 600; + line-height: 17px; + text-align: center; + color: var(--sycm-text-tertiary); + font-variant-numeric: tabular-nums; +} + +.wst-center__badge.is-warn { + background: rgba(255, 77, 79, 0.16); + color: #cf1322; +} + +.wst-center__badge--none { + font-weight: 400; +} + +.wst-detail { + flex: 1 1 auto; + min-width: 0; +} + +.wst-detail__scope { + margin-left: 8px; + font-size: 12px; + font-weight: 400; + color: var(--sycm-text-tertiary); +} + +.wst-detail__clear { + padding: 0; + border: none; + background: transparent; + font-size: 12px; + color: #1677ff; + cursor: pointer; +} + +.wst-detail__overdue { + color: #cf1322; + font-variant-numeric: tabular-nums; +} + +/* 创建时间没补到、回退成付款时间时的提示色(带 * 号,tooltip 有说明) */ +.wst-detail__fallback { + color: var(--sycm-text-tertiary); + font-style: italic; +} + +.wst-foot { + display: block; + margin-top: 12px; + font-size: 12px; + color: var(--sycm-text-tertiary); +} + +/* 窄屏:仓库列表改为两列网格,明细整行下移 */ +@media (max-width: 1200px) { + .wst-body { + flex-direction: column; + } + + .wst-centers { + width: 100%; + } + + .wst-centers__list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px 10px; + } +} diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index e9081ca..8d537c5 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -19,6 +19,7 @@ const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncent const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor')); const MkhBomData = lazy(() => import('../pages/MkhBomData')); +const WarehouseShipTimeliness = lazy(() => import('../pages/WarehouseShipTimeliness')); const DailyInventory = lazy(() => import('../pages/DailyInventory')); const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis')); const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis')); @@ -115,6 +116,10 @@ const router = createBrowserRouter([ path: 'planning/operation/mkh-bom-data', element: renderLazyComponent(MkhBomData), }, + { + path: 'planning/operation/warehouse-ship-timeliness', + element: renderLazyComponent(WarehouseShipTimeliness), + }, { path: 'planning/operation/daily-inventory', element: renderLazyComponent(DailyInventory),