feat(wst): 超时未发加「导出明细」+ 武进安能不考核 + 钉钉明细带快递;修复推送时区 #87

Merged
yanhui merged 1 commits from yanhui-wst-export-20261027 into main 2026-10-27 16:33:03 +08:00
Collaborator

目的与范围

业务 2026-10-27 四条要求 + 一个线上 bug,全部落在「仓储中心 24 小时发货时效」这一个模块:

# 要求 落点
1 超时未发加「导出明细」按钮 GET /export + 前端 Dropdown
2 快递是「武进安能」的剔除不做考核 EXCLUDED_CARRIERS
3 钉钉推送明细行在线上单号后加一列快递 buildDetailLine
4 (前三轮遗留)推送时区 bug pushShipTimelinessNotify

改动文件 7 个(3 后端源码 + 2 测试 + 2 前端),无新增依赖、无表结构变更、无配置项变更。

① 导出明细

  • 新增 GET /api/warehouse-ship-timeliness/export:与 /list 共用 getShipTimeliness,
    只是 fullDetail: true —— 不分页、不截断,返回本轮考核口径内的全部行。
  • 页面「超时未发货订单」卡片右上角加「导出明细」下拉(Excel / CSV),
    文件名 仓储中心发货时效_超时未发_<仓或全部仓库>_13点轮_2026-10-27.xlsx。
  • 选中某个仓时只导该仓:前端按 centerKey 过滤,与后端算超时用的主仓归属是同一套。
  • 导出列比页面多「快递公司 / 运单号」(业务追单要看有没有打单);单号一律 String()
    —— 聚水潭单号 19 位,数字形态在 Excel 里会变科学计数法。
  • 导出列单独一份 EXPORT_COLUMNS,不复用页面的 DETAIL_COLUMNS:
    后者 title 是 JSX(带 Tooltip),导出会变成 [object Object] 当表头。

② 缓存改为「存全量、出口截断」

getShipTimeliness 原来把 limit 拼进缓存键、并在建 payload 时就 slice。
后果:页面 limit=1000、导出要全量 → 两边永不命中,每点一次导出都重查一遍 402 万行视图。

改为:缓存键只留 round.key|round.date,payload 存全量,出口用 withDetailLimit(payload, limit) 切。
顺带修正 detailTotal / heldTotal(截断前的真实总数)—— 页面原来用 orders.length 显示
「共 N 单」,截断时会把「只显示前 1000 条」写成「一共就这么点」。

③ 武进安能不考核

EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答', '武进安能'],物流公司名包含匹配,
「武进安能大件」一起命中。

⚠️ 严格不泛化成「安能」。实测近 30 天 logistic_company_name 带「安能」的有 8 个写法:

写法 近30天单量 发货中未发
常州安能新 4207 45
武进安能 808 <19
安能物流 110 0
名屋居安能快运 / 华韵安能 22 / 22 5 / 1
武进安能大件 15 15
盛强安能快运 / 雅柏安能 5 / 3 0

业务确认只剔「武进安能」,其余(尤其单量最大的「常州安能新」)照旧考核。
与 warehouseLogisticsExceptionRegistry 同一口径,那里也明写着「武进安能必须精确,别泛化成安能」。

页面顶部 / 页脚 / 钉钉正文里的快递清单全部改成读服务层的 EXCLUDED_CARRIERS,
不再前端写死 —— 这次就是写死的文案忘了改,页面和群里口径会对不上。

④ 钉钉明细加「快递」

1. 简尚-崔桥工厂仓|9855123|261021-301318827670255|武进韵达|审核 06:32|已等 6 小时 28 分

群里看到超时单能直接判断是哪家快递、要不要走站点,不用回页面逐单点开。

⑤ 修复推送时区(线上 bug,重要)

生产容器 /etc/timezone = Etc/UTC。pushShipTimelinessNotify 原来把裸 new Date()
传给 getShipTimeliness,而 service / registry 内部用 getDate() / getHours() 直读:

北京 13:05 = UTC 05:05 → resolveActiveRound 读到「05:05,还没到 13:00」
→ notStarted: true → reason: 'not_started' → 两轮推送永远不触发

修法与 2026-10-21 那次同源问题一致,拆两个「现在」:

  • rawNow —— 真实时刻,给 chinaDateKey / minutesOfDay / toISOString;
  • bjNow = beijingNow(rawNow) —— 平移成「本地 getter 读出来正好是北京时间」,给
    resolveRoundMeta / getShipTimeliness。

本机是东八区,平移量为 0,所以本地表现不变;这也是它没被本地测试发现的原因。

同步与兼容性

  • 接口兼容:/list 返回体只新增 detailTotal / heldTotal 两个字段,
    原有字段语义不变(orders 仍按 limit 截断,truncated 仍表示是否截断)。
  • 口径变化(业务确认):武进安能的单不再计入超时。生产实测本轮
    carrierSkipped 由 2 → 4(自提/中铁/壹米滴答之外新增武进安能 2 单量级),
    超时单量相应减少;这是预期变化,不是回归。
  • 钉钉消息格式变化:明细行多一段。若有人按列数解析群消息需同步(目前无此消费方)。
  • 前端与后端需一起发布:新按钮依赖 /export。旧前端 + 新后端、新前端 + 旧后端都不会崩
    (旧后端没有 /export 时按钮报「导出失败」),但必须同批部署才有功能。
  • 无数据库变更、无新增环境变量、无 cron 变更。

验证

单元测试

node --test test/warehouseShipTimeliness.test.js test/warehouseShipTimelinessNotify.test.js
# 39 tests / 39 pass
npm --prefix backend run check
# 494 tests / 492 pass / 2 fail

2 个失败是环境固有、与本次无关(改动前后一致):
cloudDatabaseConfigCache 负缓存用例(要求业务库不可达)、
resilientSecretStore 0600 权限断言(Windows 返回 0o666)。

新增/更新的用例:

  • 导出取数不受 limit 截断,且与 /list 共用同一份缓存(断言 calls.state 不增加)
  • 导出纯函数:缺字段给空串、数字单号转字符串、null 时长给 0,不出现 undefined / NaN
  • 武进安能 / 武进安能大件被剔除;常州安能新不被剔除(反向断言,防止有人把关键词放大)
  • 钉钉明细行 6 段(含快递),快递为空时给「—」

前端

npx eslint src/pages/WarehouseShipTimeliness/index.jsx src/api/warehouseShipTimeliness.js   # 0 error
npm run build                                                                                # ✓ built in 21.17s

真实浏览器渲染(playwright,本地 5173 + 后端 4002)

/list 与 /export 走 mock(避免每跑一次验收都压生产库),17/17 通过:

  • 页面真渲染(body.innerText 1918 字)、无 pageerror、无 undefined / NaN 泄漏
  • 菜单两项 XLSX / CSV;点 XLSX 真落盘,行数 = 表头 + 4 行
  • XLSX 表头是纯文本(不含 [object Object]),含「武进韵达」「未打单」占位
  • 选中崔桥工厂仓再导 CSV:行数 = 表头 + 3 行,不含其它仓的单
  • 文件名:仓储中心发货时效_超时未发_全部仓库_13点轮_2026-10-27.xlsx /
    ..._崔桥工厂仓_....csv

真实接口(本地后端 → 生产只读副本)13/13 通过

/list?round=NOON&limit=5&force=1   → 200  86343ms   超时 13 单,真实明细 13 条,剔除 4 单
/export?round=NOON                 → 200      5ms   rows=13  ← 命中缓存
  • 导出 13 条 == /list 的 detailTotal 13
  • /export 包含 /list 的全部行(口径一致,缺 0 行)
  • 不考核快递的单未混入导出;字段齐全、无 undefined / NaN
  • 5ms vs 86343ms:这���是缓存键去掉 limit 带来的收益(旧实现这里要再等一次 86 秒)

风险与回退

风险 缓解
缓存改为存全量 → 单条缓存内存变大 行数是「未发单」量级(生产实测 13 行,历史峰值数千),远小于阈值;且 TTL 仍 5 分钟
导出可能返回几千行,前端一次性渲染 导出不渲染表格,只走 exportTableFile 落盘;页面表格仍按 limit 截断
武进安能剔除改变考核数字,有人觉得「超时变少了」 页面顶部/页脚/钉钉正文都写明剔除了哪几个快递,并给出 carrierSkipped 计数
时区修复若判断错方向会算错轮次 已有 beijingNow 单测锁「任何机器时区下本地 getter 读出北京时间」;本次只是把 job 侧也接上,未改 beijingNow 本身

回退:git revert <merge_commit> 即可,无数据迁移、无配置残留。
唯一需人工留意的是钉钉消息格式回到 5 段(无消费方,无需处理)。

合规

  • 未经 PR 直接推 main:无。分支 yanhui-wst-export-20261027,单个提交。
  • 未强推、未 reset --hard、未删他人分支。
  • 本 PR 为零审核,需仓库所有者授权后再合并,授权记录写入 .workbuddy/pr<N>-merge-authorization.md。
## 目的与范围 业务 2026-10-27 四条要求 + 一个线上 bug,全部落在「仓储中心 24 小时发货时效」这一个模块: | # | 要求 | 落点 | |---|---|---| | 1 | 超时未发加「导出明细」按钮 | `GET /export` + 前端 Dropdown | | 2 | 快递是「武进安能」的剔除不做考核 | `EXCLUDED_CARRIERS` | | 3 | 钉钉推送明细行在线上单号后加一列快递 | `buildDetailLine` | | 4 | (前三轮遗留)推送时区 bug | `pushShipTimelinessNotify` | 改动文件 7 个(3 后端源码 + 2 测试 + 2 前端),无新增依赖、无表结构变更、无配置项变更。 ### ① 导出明细 - 新增 `GET /api/warehouse-ship-timeliness/export`:与 `/list` 共用 `getShipTimeliness`, 只是 `fullDetail: true` —— **不分页、不截断**,返回本轮考核口径内的全部行。 - 页面「超时未发货订单」卡片右上角加「导出明细」下拉(Excel / CSV), 文件名 `仓储中心发货时效_超时未发_<仓或全部仓库>_13点轮_2026-10-27.xlsx`。 - 选中某个仓时只导该仓:前端按 `centerKey` 过滤,与后端算超时用的主仓归属是同一套。 - 导出列比页面多「快递公司 / 运单号」(业务追单要看有没有打单);单号一律 `String()` —— 聚水潭单号 19 位,数字形态在 Excel 里会变科学计数法。 - 导出列**单独一份** `EXPORT_COLUMNS`,不复用页面的 `DETAIL_COLUMNS`: 后者 `title` 是 JSX(带 Tooltip),导出会变成 `[object Object]` 当表头。 ### ② 缓存改为「存全量、出口截断」 `getShipTimeliness` 原来把 `limit` 拼进缓存键、并在建 payload 时就 slice。 后果:页面 `limit=1000`、导出要全量 → **两边永不命中**,每点一次导出都重查一遍 402 万行视图。 改为:缓存键只留 `round.key|round.date`,payload 存全量,出口用 `withDetailLimit(payload, limit)` 切。 顺带修正 `detailTotal` / `heldTotal`(截断前的真实总数)—— 页面原来用 `orders.length` 显示 「共 N 单」,截断时会把「只显示前 1000 条」写成「一共就这么点」。 ### ③ 武进安能不考核 `EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答', '武进安能']`,物流公司名包含匹配, 「武进安能大件」一起命中。 ⚠️ **严格不泛化成「安能」**。实测近 30 天 `logistic_company_name` 带「安能」的有 8 个写法: | 写法 | 近30天单量 | 发货中未发 | |---|---|---| | 常州安能新 | 4207 | 45 | | 武进安能 | 808 | <19 | | 安能物流 | 110 | 0 | | 名屋居安能快运 / 华韵安能 | 22 / 22 | 5 / 1 | | 武进安能大件 | 15 | 15 | | 盛强安能快运 / 雅柏安能 | 5 / 3 | 0 | 业务确认**只剔「武进安能」**,其余(尤其单量最大的「常州安能新」)照旧考核。 与 `warehouseLogisticsExceptionRegistry` 同一口径,那里也明写着「武进安能必须精确,别泛化成安能」。 页面顶部 / 页脚 / 钉钉正文里的快递清单全部改成读服务层的 `EXCLUDED_CARRIERS`, 不再前端写死 —— 这次就是写死的文案忘了改,页面和群里口径会对不上。 ### ④ 钉钉明细加「快递」 ``` 1. 简尚-崔桥工厂仓|9855123|261021-301318827670255|武进韵达|审核 06:32|已等 6 小时 28 分 ``` 群里看到超时单能直接判断是哪家快递、要不要走站点,不用回页面逐单点开。 ### ⑤ 修复推送时区(线上 bug,重要) 生产容器 `/etc/timezone = Etc/UTC`。`pushShipTimelinessNotify` 原来把**裸 `new Date()`** 传给 `getShipTimeliness`,而 service / registry 内部用 `getDate()` / `getHours()` 直读: > 北京 13:05 = UTC 05:05 → `resolveActiveRound` 读到「05:05,还没到 13:00」 > → `notStarted: true` → `reason: 'not_started'` → **两轮推送永远不触发** 修法与 2026-10-21 那次同源问题一致,拆两个「现在」: - `rawNow` —— 真实时刻,给 `chinaDateKey` / `minutesOfDay` / `toISOString`; - `bjNow = beijingNow(rawNow)` —— 平移成「本地 getter 读出来正好是北京时间」,给 `resolveRoundMeta` / `getShipTimeliness`。 本机是东八区,平移量为 0,所以**本地表现不变**;这也是它没被本地测试发现的原因。 ## 同步与兼容性 - **接口兼容**:`/list` 返回体只**新增** `detailTotal` / `heldTotal` 两个字段, 原有字段语义不变(`orders` 仍按 `limit` 截断,`truncated` 仍表示是否截断)。 - **口径变化(业务确认)**:武进安能的单不再计入超时。生产实测本轮 `carrierSkipped` 由 2 → 4(自提/中铁/壹米滴答之外新增武进安能 2 单量级), 超时单量相应减少;这是**预期变化**,不是回归。 - **钉钉消息格式变化**:明细行多一段。若有人按列数解析群消息需同步(目前无此消费方)。 - 前端与后端需一起发布:新按钮依赖 `/export`。旧前端 + 新后端、新前端 + 旧后端都不会崩 (旧后端没有 `/export` 时按钮报「导出失败」),但**必须同批部署**才有功能。 - 无数据库变更、无新增环境变量、无 cron 变更。 ## 验证 ### 单元测试 ``` node --test test/warehouseShipTimeliness.test.js test/warehouseShipTimelinessNotify.test.js # 39 tests / 39 pass npm --prefix backend run check # 494 tests / 492 pass / 2 fail ``` 2 个失败是**环境固有、与本次无关**(改动前后一致): `cloudDatabaseConfigCache` 负缓存用例(要求业务库不可达)、 `resilientSecretStore` 0600 权限断言(Windows 返回 0o666)。 新增/更新的用例: - 导出取数不受 limit 截断,且与 `/list` 共用同一份缓存(断言 `calls.state` 不增加) - 导出纯函数:缺字段给空串、数字单号转字符串、`null` 时长给 0,不出现 `undefined` / `NaN` - 武进安能 / 武进安能大件被剔除;**常州安能新不被剔除**(反向断言,防止有人把关键词放大) - 钉钉明细行 6 段(含快递),快递为空时给「—」 ### 前端 ``` npx eslint src/pages/WarehouseShipTimeliness/index.jsx src/api/warehouseShipTimeliness.js # 0 error npm run build # ✓ built in 21.17s ``` ### 真实浏览器渲染(playwright,本地 5173 + 后端 4002) `/list` 与 `/export` 走 mock(避免每跑一次验收都压生产库),**17/17 通过**: - 页面真渲染(body.innerText 1918 字)、无 pageerror、无 `undefined` / `NaN` 泄漏 - 菜单两项 XLSX / CSV;点 XLSX 真落盘,**行数 = 表头 + 4 行** - XLSX 表头是纯文本(不含 `[object Object]`),含「武进韵达」「未打单」占位 - 选中崔桥工厂仓再导 CSV:**行数 = 表头 + 3 行**,不含其它仓的单 - 文件名:`仓储中心发货时效_超时未发_全部仓库_13点轮_2026-10-27.xlsx` / `..._崔桥工厂仓_....csv` ### 真实接口(本地后端 → 生产只读副本)**13/13 通过** ``` /list?round=NOON&limit=5&force=1 → 200 86343ms 超时 13 单,真实明细 13 条,剔除 4 单 /export?round=NOON → 200 5ms rows=13 ← 命中缓存 ``` - 导出 13 条 == `/list` 的 `detailTotal` 13 - `/export` 包含 `/list` 的全部行(口径一致,缺 0 行) - 不考核快递的单未混入导出;字段齐全、无 `undefined` / `NaN` - **5ms vs 86343ms**:这���是缓存键去掉 `limit` 带来的收益(旧实现这里要再等一次 86 秒) ## 风险与回退 | 风险 | 缓解 | |---|---| | 缓存改为存全量 → 单条缓存内存变大 | 行数是「未发单」量级(生产实测 13 行,历史峰值数千),远小于阈值;且 TTL 仍 5 分钟 | | 导出可能返回几千行,前端一次性渲染 | 导出**不渲染表格**,只走 `exportTableFile` 落盘;页面表格仍按 `limit` 截断 | | 武进安能剔除改变考核数字,有人觉得「超时变少了」 | 页面顶部/页脚/钉钉正文都写明剔除了哪几个快递,并给出 `carrierSkipped` 计数 | | 时区修复若判断错方向会算错轮次 | 已有 `beijingNow` 单测锁「任何机器时区下本地 getter 读出北京时间」;本次只是把 job 侧也接上,未改 `beijingNow` 本身 | **回退**:`git revert <merge_commit>` 即可,无数据迁移、无配置残留。 唯一需人工留意的是钉钉消息格式回到 5 段(无消费方,无需处理)。 ## 合规 - 未经 PR 直接推 `main`:无。分支 `yanhui-wst-export-20261027`,单个提交。 - 未强推、未 `reset --hard`、未删他人分支。 - 本 PR 为**零审核**,需仓库所有者授权后再合并,授权记录写入 `.workbuddy/pr<N>-merge-authorization.md`。
yanhui added 1 commit 2026-10-27 16:29:56 +08:00
① 导出明细(业务要求:超时未发要能导出核对)
  · 新增 GET /api/warehouse-ship-timeliness/export:不分页、不截断,返回本轮全部行
  · 页面「超时未发货订单」卡片右上角加「导出明细」下拉(Excel / CSV)
  · 选中某个仓时只导该仓(前端按 centerKey 过滤,与后端主仓归属同一套)
  · 导出列比页面多「快递公司 / 运单号」(追单要看有没有打单),单号一律转字符串
    避免 Excel 显示成科学计数法

② 缓存改成「存全量、出口截断」
  · 缓存键去掉 limit(原为企业级教训:WLE 核验缓存早就这么改了)
  · 页面 limit=1000、导出要全量,旧实现两边永不命中 → 每点一次导出重查一遍视图
  · 实测:首次查询 86.3s,导出命中缓存 5ms
  · 顺带修 detailTotal / heldTotal:明细被截断时页面仍显示真实总数,
    不再把「只显示前 1000 条」写成「一共就这么点」

③ 不考核快递追加「武进安能」(业务 2026-10-27)
  · 包含匹配,同时覆盖「武进安能大件」
  · ⚠️ 严格不泛化成「安能」:实测库里带「安能」的有 8 个写法,
    常州安能新 4207 单 / 安能物流 110 单等仍照旧考核(与
    warehouseLogisticsExceptionRegistry 同一口径,那里也写着这句)

④ 钉钉推送明细行在「线上单号」后加「快递」
  · 一行 = 仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
  · 群里直接能判断是哪家快递、要不要走站点,不用回页面逐单点开
  · 「已剔除 N 单不考核快递」的快递清单改成读服务层,别再各写一份

⑤ 修复推送任务时区(生产容器 /etc/timezone = Etc/UTC)
  · 症状:pushShipTimelinessNotify 把裸 new Date() 传给 getShipTimeliness,
    而 service / registry 内部用 getDate()/getHours() 直读 →
    北京 13:05 = UTC 05:05,被判「未到 13:00 核验时间」→ 两轮推送永不触发
  · 改法:rawNow(真实时刻,给 Intl / toISOString)+ bjNow(平移后,
    给本地 getter 与 service 调用)双轨,与 2026-10-21 那次同源问题的修法一致
yanhui merged commit 913827000a into main 2026-10-27 16:33:03 +08:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: lunanting/cangku#87
No description provided.