feat(logistics-arrival): 物流到货签收时长看板

- 看板:经营总览(平均到货时长 + 本月目标 + 目标达成率、异常单点击下钻)、
  履约环节拆解(平均/中位/P90/目标/目标达成率,分栏可拖动)、发货→签收时效分布
- 经营对象问题排名:签收日期→平台→店铺→发货仓→物流→采购→供应商→商品→订单明细
  逐级联动 + 独立翻页;经营对象带「目标 = 该对象上月平均 × 98%」与目标达成率
- 09 订单明细:看板内翻页、高度可拖动、下钻后自动滚动定位
- 排除关键词按字段分组(发货仓/物流公司/订单标签/买家留言/卖家备注),
  选择器支持搜索、全部/已选项、全选/反选/清空、紧凑模式与按字段新增
- 每日订单明细快照:Prisma 模型与迁移、同步脚本、定时器安装脚本;
  命中快照时整区间精确统计,超过规模上限回落均匀样本并如实标注
- 接入侧边菜单、标签页标题与权限映射(businessManagement),补充架构文档
This commit is contained in:
codex 2026-09-30 18:18:51 +08:00
parent a771a9a5dc
commit 05007d3ae2
41 changed files with 9078 additions and 0 deletions

View File

@ -0,0 +1,243 @@
# 物流到货签收时长看板 · 订单明细每日快照
## 为什么要做
看板的全部数据来自远端只读报表视图 `rpt.jst_order_express_details`(外加 `jst_qmcust_orderlist_item` 条码明细和 `SupplyChainProductArchive` 产品档案富化)。
该视图是远端对象,一次扫描通常以分钟计,页面每次刷新、翻页、下钻、导出都要重新扫一遍,看板因此很难"高效运转"。
做法:每天把看板口径的订单明细物化到本地 Postgres,看板优先读本地快照,只有快照覆盖不到时才回落实时数据源。
## 数据与表
### 条码/供应商/采购的匹配来源(两套互补的订单表)
报表库把订单明细拆成了两套**互不重叠**的表,只看一套会漏掉近一半订单的条码:
| 表 | 覆盖平台 | 规模(实测) |
| --- | --- | --- |
| `rpt.jst_qmcust_orderlist` / `rpt.jst_qmcust_orderlist_item` | 淘宝天猫、阿里巴巴(1688)、淘工厂、天猫供销 | 408 万 / 576 万行 |
| `rpt.jst_Orders` / `rpt.jst_OrderItem` | 京东商城、京东厂家直送、拼多多、头条放心购(抖音)、得物、唯品会、线下、微信视频号、小红书 | 124 万 / 175 万行 |
看板取条码时会**同时查两张明细表并用 `INNER_ORDER_ID`(即 `o_id`)合并**(`listItemRows`,两表按订单号各发一批查询后合并结果)。修复前只查 `jst_qmcust_orderlist_item`,导致京东/拼多多/抖音/得物/唯品会的订单条码全空;修复后新生成的快照条码匹配率为 **100%**(此前整体 52.3%)。
> 注:`rpt.jst_salesOutboundInfo`(销售出库单头)可以读取,但只有 `o_id / so_id / l_id / shop_name / io_id / qty` 等出库单头字段,没有 SKU 列,不能单独提供条码。
纯新增两张表(迁移 `prisma/migrations/20260928120000_add_logistics_arrival_signing_duration_snapshot`):
| 表 | 作用 |
| --- | --- |
| `LogisticsArrivalSigningDurationSnapshotRun` | 批次元数据:`snapshotDate`、`windowFrom`、`windowTo`、`status`(RUNNING/COMPLETED/FAILED)、`rowCount`、`message` |
| `LogisticsArrivalSigningDurationSnapshot` | 明细行:与看板 09 订单明细一一对应,含条码/供应商/采购/`productItems` 等快照时点已固化的富化结果 |
要点:
- 只有 `status = COMPLETED` 的批次对读路径可见,写入中途或失败的批次不会被读到。
- 行唯一键 `(snapshotDate, rowKey)`,`rowKey` 与下钻键一致(内部单号 + 线上订单号 + 物流单号 + 签收时间),重复执行用 `skipDuplicates` 幂等。
- `(snapshotDate, signTime)` 有索引,按签收日期区间取数走索引。
## 生成任务
```bash
# 默认:快照日期=当天(Asia/Shanghai),窗口=最近 62 天(覆盖本月与上月),保留最近 2 批
npm run sync:logistics-arrival-signing-duration-snapshot -- \
--apply --confirm=sync-logistics-arrival-signing-duration-snapshot \
--expected-database-identity="<本地库身份>" \
--expected-source-database-identity="<远端报表身份>"
```
可调参数:`--snapshot-date=YYYY-MM-DD`、`--window-days`、`--chunk-days`(默认 7,按天分批抓取以控制内存)、`--batch-size`(默认 2000)、`--retention`、`--force`(当天批次已完成时强制重建)。
编排逻辑在 `src/server/services/logistics-arrival-signing-duration-snapshot.service.ts`:
1. 当天批次已 `COMPLETED` 且未 `--force` → 直接跳过;
2. 标记 `RUNNING` 并清空当天旧行;
3. 按 `chunk-days` 分片读远端并写入本地;
4. 全部成功才标记 `COMPLETED`,随后按 `retention` 清理更早批次;任一分片失败则标记 `FAILED` 并保留错误信息,读路径会自动继续用上一批快照;
5. 收尾关闭远端连接池(一次性任务必须关闭,否则进程挂在事件循环上)。
生产定时(systemd timer,默认北京时间 05:30):
```bash
sudo bash scripts/install-logistics-arrival-signing-duration-snapshot-timer.sh
```
首次安装会生成 `$BASE/shared/logistics-arrival-signing-duration-snapshot.env` 模板,需要填两个身份校验值(缺省为空会拒绝写入)。可用环境变量覆盖:`LOGISTICS_SNAPSHOT_CALENDAR`、`LOGISTICS_SNAPSHOT_WINDOW_DAYS`、`LOGISTICS_SNAPSHOT_RETENTION`。
## 读路径规则
`src/server/repositories/logistics-arrival-signing-duration.repository.ts` 的每个读方法都先判断快照是否可用:
- 取最近一批 `COMPLETED` 快照;请求区间**完全落在** `[windowFrom, windowTo]` 内才读快照(否则回落实时视图,避免返回不完整结果);
- 命中快照时返回值带 `source: 'snapshot'` 与 `snapshotDate`,页面头部显示"快照数据 · 日期";未命中为 `source: 'live'`,显示原来的"数据更新 · 时间";
- 快照表还没迁移或还没有任何批次时,`findLatestCompletedRun()` 返回 null(并吞掉异常),整条链路自动等价于改造前行为。
因此在以下情况仍会走实时数据源(较慢,但结果正确):请求区间超出窗口、任务当天尚未跑完或失败、本地还没生成过快照。
## 指标口径(整区间精确 vs 样本)
命中快照时,**整块看板都是整区间精确值**,与 2,000 条样本彻底解耦:
| 看板块 | 精确口径实现 |
| --- | --- |
| 经营总览(6 个 KPI) | `aggregateDashboard()` 一次扫描聚合 |
| 履约环节拆解(5 环节平均/中位/P90) | 同上,`percentile_cont` 直接给出分位数 |
| 发货→签收时效分布 | 同上,4 个区间计数 |
| 付款→签收 / 付款→发货 多维经营分析(01–08) | `aggregatePayToSignAnalysis()` / `aggregatePayToSendAnalysis()`,按签收日期 + 采购/供应商/条码(`jsonb_array_elements` 展开 `productItems`)+ 物流公司/发货仓/平台/店铺分组 |
| 发货仓 / 物流公司 超时排名 | `aggregateThresholdRankings()` |
| 09 订单明细下钻 | `listDrilldownRows()`:按维度条件下钻,**列出全量匹配订单**并给出真实总数与页数 |
口径一致性由三点保证:筛选条件统一由 `snapshotFilterConditions()` 派生(Prisma 与原始 SQL 共用);比率/取整统一走 lib 的 `buildLogisticsArrivalSigningDurationOverview`、`buildPayToSignMetrics`、`buildPayToSendMetrics`;排序统一走 `comparePayToSignGroups` / `comparePayToSendGroups` / `compareLogisticsArrivalSigningDurationThresholdRows`。每个聚合块都用"把整区间明细喂给样本路径同样函数"的方式做过逐字段比对(MATCH)。
### 看板顺序与联动链路
### 履约环节拆解:目标值与看板宽度
### 经营总览:平均到货时长卡片与异常下钻
- 签收订单后面新增 **付款→签收平均到货时长** 卡片:主值是该环节平均时长,附行给出 **本月目标**(上月平均 × 98%)与 **目标达成率**,与履约环节拆解同一套口径。
- **缺少关键时间 / 时间异常** 两张卡片可点击:点击后用 `{ kind: 'anomaly', value: 'missing' | 'invalid' }` 条件下钻,09 订单明细列出对应订单;再点一次取消。判定口径与总览一致——缺少关键时间 = 四个时间任一为空;时间异常 = 四个时间齐全但至少一个环节时长算不出来(时间顺序异常)。
- **任何下钻(看板点选、异常卡片)都会自动把 09 订单明细滚到视口顶部**(`detailBoardRef` + `scrollIntoView`,按"下钻签名"去重,不会在翻页时反复滚动),省掉人工下划。
- 为保证"卡片数字 = 点击后的明细条数",**命中快照时经营总览始终按整区间精确统计**(`aggregateDashboard(..., includeStages=false)`,只算总览不算分位数);超出精确上限的大区间里,只有履约环节拆解/时效分布/经营分析/超时排名退回样本,页面脚注会写明。
### 经营总览视觉层次与看板工具条
- 经营总览从"7 张卡一张网格"改为**两组网格**:5 张履约指标卡一排(`--sc-kpi-cols`,标准 5 / 紧凑 3 / 展开 2),2 张数据质量卡单独成排(`--sc-kpi-alert-cols`,标准 2 / 展开 1)。7 张卡在常见宽度下不再是 5+2、第二排右侧大片留白。
- 卡片排版统一:标签 12px 用 `--sc-muted`,数值 24px 且 `font-variant-numeric: tabular-nums`,用 `margin-top: auto` 贴底、副标题保留 2 行最小高度(`min-height: 2.9em`)。同一行内标签换行不影响数值对齐,卡片等高、数字列对齐。
- 绿色强调卡(付款→签收平均到货时长、付款→签收履约率)改为"浅绿底 + 左侧 2px 竖条"(`inset 2px 0 0`),不再整卡大面积着色;点击态的竖条与边框跟着状态色走。
- 缺关键时间 / 时间异常改为横向"告警条":左侧图标 + 名称/说明,数值贴右,用低饱和暖色表达"异常"语义;点击下钻、`is-active` 选中态、`aria-pressed` 与 `aria-label`(含条数)都保留。
- 修复看板工具条(导出 / 尺寸切换)样式不生效的根因:`BoardTools` 是本文件内的**独立组件**,styled-jsx 按组件注入作用域类,主组件里的 `.sc-logistics-icon-button` / `.sc-logistics-board-tools` / `.sc-logistics-board-size-label` 规则永远匹配不到它的 DOM,四个看板右上角一直是"裸图标"。现在样式放进 `BoardTools` 自己的 `<style jsx>`(深色排名表头用 `:global(.sc-logistics-ranking-board-header)` 适配),并删除主样式块里的死规则。
- 每个环节多出一列 **目标**:`本月目标 = 上月同环节平均时长 × 98%`(`LOGISTICS_ARRIVAL_SIGNING_DURATION_TARGET_IMPROVEMENT_RATIO`)。基准月取所选区间结束日期所在月的上一个月(跨年正确),基准值由 `aggregateStageAverages()` 用同一套筛选条件跑上个月区间得到;当前平均不高于目标时目标值标绿表示达标,鼠标悬停可看到"目标 = 上月平均 × 98%"的算式。
- 目标后面还有 **目标达成率**:该环节时长不超过目标的订单占比。精确口径由 `aggregateStageTargetHits()` 按目标值统计命中订单数,样本口径由 `buildLogisticsArrivalSigningDurationStageAchievement()` 在样本上计算;无目标或无可计算订单时显示 `-`,鼠标悬停显示"命中 / 可计算"明细。环节行的平均/中位/P90/目标/达成率都只显示数值,单位(小时)只在表头语义里体现。
- 环节行按 `128px / 弹性条 / 74 / 74 / 86 / 92` 收紧列宽,列表支持横向滚动,避免 P90、目标被截断。
- 履约环节拆解与发货→签收时效分布之间加了**可拖动的分隔条**(`role="separator"`,支持左右方向键微调),比例默认 62% 并记在本地,可随时左右拖动调整两个看板的宽度。
经营对象问题排名各看板按"上级 → 下级"顺序排列,并逐级联动:
```
01 签收日期维度 → 02 平台维度 → 03 店铺维度 → 04 发货仓维度 → 05 物流维度
→ 06 采购维度 → 07 供应商维度 → 08 商品维度 → 09 订单明细
```
点击任意一级对象,都会保留更上级条件、丢弃更下级条件,并把这条链路作为筛选条件重新取数:
- 整区间口径:链路以 `cascade`(JSON 数组,`LogisticsArrivalSigningDurationDrilldownFilter[]`)随 dashboard 请求下发,服务端在 `aggregatePayToSignAnalysis` / `aggregatePayToSendAnalysis` / `aggregateThresholdRankings` 的 WHERE 上追加 `cascadeSqlWhere`,因此下级看板与 09 明细都只展示对应数据;
- 样本口径(实时源或超出精确上限):前端用 `logisticsArrivalSigningDurationRowMatchesCascade` 收窄样本后重算;
- 店铺维度分组键是"平台 + 店铺",因此平台级联动能精确到该平台下的店铺;
- 联动刷新是原位刷新(只有页面筛选变化才显示整块加载态),排名看板标题会显示当前链路与"联动加载中…"提示;
- 经营总览、履约环节拆解、时效分布属于页级口径,不随联动链路变化。
### 经营对象的目标与目标达成率
02–08 经营对象表(平台 / 店铺 / 发货仓 / 物流 / 采购 / 供应商 / 商品)在原有指标后面追加 **目标** 与 **目标达成率** 两列;01 签收日期维度的「指标 × 日期」矩阵与总计不追加(按日算"上月同日目标"没有业务含义)。
- **目标 = 该对象上月同环节平均时长 × 98%**,达成率 = `100% − (平均 − 目标) / 目标 × 100%`,与履约环节拆解同一套口径,只是精确到每个经营对象——所以每个对象的目标值不同(例如 08 月各采购平均不同,9 月目标 52.7 / 56.5 / 60.5 …),达成的判断是"相对自己上个月是否改善 2%"。
- 数值口径与展示一致:平均、目标都按 1 位小数取值后再算达成率,便于和表里看到的数字对账;没有上月基线的对象(新采购、新店铺、当月新条码)显示 `-`。
- 取数:`aggregateObjectBaselines()`(快照仓库)在上月区间一次扫描算出 7 个维度 × 付款→签收 / 付款→发货 两个环节的平均时长,随 dashboard 载荷以 `objectBaselines` 下发;前端用 `withObjectAnalysisTargets()` 按分组键(`JSON.stringify(keyParts)`,与样本路径同一套键)合并,样本口径与整区间口径共用。
- 这条聚合约 2–4 秒,因此按「快照日期 + 基准月 + 关键词/排除词」在服务端缓存 10 分钟(这些平均值只在每日快照重建时变化),并与看板其余查询并行执行;查询失败只让这两列显示 `-`,不会把整块看板拖回实时源。
- 「经营对象问题排名」导出的 CSV 同步带上这两列。
### 看板分页
### 批量关键词查询
筛选区的「关键词」支持批量输入(与排除关键词同一套拆分规则):
- 按中文逗号 `,`、英文逗号 `,`、换行拆分,自动去空、去重,最多 100 个、单个最长 120 字符(原始输入上限 4000 字符);
- **多个关键词之间是"或"**:命中任一字段即算匹配。整区间(快照)口径覆盖:内部单号、线上订单号、物流单号、物流公司、店铺、平台、发货仓、收货地址,以及快照时点固化的**条码、商品名称、供应商、采购**(`KEYWORD_FIELDS`)——所以能直接按采购/供应商/条码检索;
- 走实时源时(区间超出快照窗口)只覆盖前 8 个报表字段,条码/采购/供应商这三类需要本地快照支持,这是两套口径的已知差异;
- 解析规则由 `splitSearchKeywords()` 统一提供,服务端缓存键按关键词集合(排序后)计算,输入顺序不同不会重复缓存。
- 输入框与其它筛选控件**等高**(34px 单行高度,多行时框内滚动),"已识别 N 个关键词"显示在标签行右侧并在空间不足时省略,不再把这一格撑成两行高。
筛选区还提供 **重置**:一键清空"签收日期(起/止)"与"关键词"两个输入(排除关键词有自己的「清空排除 / 恢复默认」,不受影响),并提示"已清空签收日期与关键词,填好后点查询"。重置只清输入不立即取数;直接点查询时,空日期按默认区间(本月 1 号 ~ 昨日)、空关键词表示不过滤,因此在跨天且当日快照尚未生成时可能落到实时源(较慢但结果正确)。
**默认签收日期区间是「本月 1 号 ~ 昨日」**(由 `normalizeLogisticsArrivalSigningDurationFilters()` 生成):当日签收数据仍在滚动,且每日快照只覆盖到昨日,用"到昨日"作默认值可以让首屏直接命中快照(整区间精确 + 秒级),不再因为区间含当天而回落到实时源。每月 1 号时会自动退化为"昨日当天"(本月 1 号还没到昨日)。
筛选控件(签收日期、关键词输入框,以及排除关键词选择器的摘要)都是**受控并与服务端下发的筛选条件同步**:从菜单重新进入(无参数 URL)、重置后查询、浏览器前进/后退时,控件会刷成当前真正生效的条件。这一点很重要——否则会出现"数据已经是默认区间,输入框还停在上次条件"的错觉。
### 排除关键词选择器
筛选区的「排除关键词」是**按字段分组**的勾选式选择器(`ExcludeKeywordPicker.tsx`)。关键词只能配置在它要匹配的那个字段上,**不跨字段混用**——这是这一版最重要的口径修正。
**字段与匹配规则**
| 字段(配置分组) | 数据列 | 匹配方式 |
| --- | --- | --- |
| 发货仓 | `warehouseName` | 该字段值包含关键词(不区分大小写),命中即排除 |
| 物流公司 | `logisticCompanyName` | 同上 |
| 订单标签 | `orderLabels`(JSON 数组文本) | 同上 |
| 买家留言 | `buyerMessage` | 同上 |
| 卖家备注 | `remark` | 同上 |
配置与筛选都以「字段 → 关键词」存储(`LogisticsArrivalSigningDurationExcludeKeywords`)。SQL 侧对每个关键词只生成一条 `NOT (该字段 ILIKE '%词%')`;旧版"命中 5 个字段里任意一个就排除"的口径已废弃——那正是「物流」会连带命中订单标签里的 `商家指定物流`、`物流探查` 和买家留言里"物流单号"的根因。
**选择器交互**
- **按字段分组**:发货仓 / 物流公司 / 订单标签 / 买家留言 / 卖家备注,每组标题显示"共 N 项 · 已勾选 M 项",右侧索引条按字段跳转(字段没有候选词时不显示该组);
- **默认全选**:候选词 = 该字段的默认排除词 ∪ 当前已排除词(旧链接里带的自定义词不会丢),进入看板时全部处于"排除"状态;取消勾选 = 本次不排除该项,确定后立即以当前表单条件重新查询(`requestSubmit()` 在受控值落到 DOM 后再触发);一个都不勾时以 `excludeKeywordsMode=clear` 提交,避免回落到默认排除词;
- 选择器内含:搜索、`全部 / 已选项(n)` 页签、`全选 / 反选 / 清空`(作用于当前可见列表,全选带半选态)、`紧凑模式`(四列更密);
- **新增候选词仅系统管理员可操作**:底部先选字段、再输入关键词(回车或点「添加」),新词并入**该字段**的默认排除词并立即落库(`PUT …/settings`,服务端 `assertCanManage` 同样校验管理员身份),提示"已保存到「物流公司」默认排除关键词:…";非管理员只看到"需要新增请联系系统管理员";
- 页面原先独立的「默认排除关键词」设置卡片已删除,维护入口统一收敛到选择器;`清空排除`(不排除任何词)与 `恢复默认`(回到全部默认排除词)两个按钮保留,只改输入、点查询生效。
**编码与兼容**
- 筛选栏摘要按字段给数量(如"已排除 14 项:发货仓 3 · 物流公司 5 · 订单标签 2 · 买家留言 2 · 卖家备注 2"),URL 用 `excludeKeywords=字段:词,词;字段:词` 的紧凑写法;
- **老链接的平铺写法按当前配置归位**:`excludeKeywords=词1,词2` 会被逐个对应到配置里它所属的字段(大小写不敏感,`bbff` 会归一到配置里的 `BBFF`),配置里已经没有的词说明它没有字段、直接忽略——**不会再把一个词摊到 5 个字段上**(那会让选择器每组都列出全部关键词)。只有当配置为空、任何词都对应不上时才退回旧口径,避免老链接突然变成"不排除任何词"。
- 历史配置(数组)在 `normalizeExcludeKeywords()` 里仍按旧口径挂到全部 5 个字段(那是当年真实的匹配语义);但配置一旦以新结构保存,后续解析都会走"按字段归位"。
**当前初设配置**(2026-09-30 落库):发货仓=`自动发货仓 / 常州简尚家居有限公司 / 京东云仓`;物流公司=`京喜 / 物流 / 安能 / 自提 / 中铁`;订单标签=`配件 / 1688一件代发`;买家留言=`BBFF / SSDD`;卖家备注=`BBFF / SSDD`。2026-09-01~09-02 实测:不排除 26,296 行 → 按字段排除后 17,506 行(旧口径同样 14 个词是 16,552 行,多出的 954 行正是"标签里带物流、物流公司里没有"的那些)。
经营对象问题排名里的每个看板都有独立翻页,不再用"显示更多"或无限滚动,也不会因为对象太多被截断:
- 01 签收日期维度:按日期列翻页(默认 10 列/页,可切 10/20/50/100);
- 02–08 采购/供应商/条码/物流公司/发货仓/平台/店铺:按对象行翻页(默认 20 行/页);
- 发货仓 / 物流公司 超时排名:按对象行翻页(默认 20 行/页)。
- 09 订单明细:翻页控件就在看板内部(不再挂在页面底部);未下钻时按 URL 的 `page/pageSize` 走服务端分页(每页 25/50/100/200,命中快照时显示真实总数"共 N 条筛选结果"),下钻时用本地分页翻全量匹配明细。
分页条统一由 `BoardPagination.tsx` 渲染(含"共 N 条 · 第 x / y 页"、上/下一页与每页条数),行集合变化(例如切换采购后重新筛选供应商)时自动回到第 1 页。
店铺维度表在"店铺"列前额外展示"平台"列:点击平台即把该表筛成该平台的店铺(标题变成"店铺维度 · 天猫",再次点击取消),点击店铺名仍走 09 明细下钻。店铺分组的业务键是"平台 + 店铺",避免同名店铺跨平台互相覆盖;平台列在样本路径(JS 归组)与整区间路径(SQL `GROUPING SETS ((platform, shop))`)都做了同样的处理,并逐字段比对过一致性。
### 规模上限与样本回落
SQL 聚合成本与区间行数近似线性(约 5 万行/秒)。当**区间原始行数超过 `LOGISTICS_ARRIVAL_SIGNING_DURATION_EXACT_SCOPE_MAX_ROWS`(11 万条,约 11 天)**时,`getDashboard` 会退回区间内均匀抽取的 2,000 条样本(`listDashboardSampleRows`,5 层并行、按签收时间分层,不再是"取最近一段"),并在底部注释写明原因与"缩小签收日期范围即可得到精确值"。
返回值上的 `dataScope`(`exact` / `sample`)与 `dataScopeReason`(`live-source` / `range-too-large`)驱动卡片副标题、底部注释与 CSV 导出的口径说明。
实测(663k 行快照,含 13 个默认排除关键词):
| 区间 | 原始行数 | 口径 | 首屏耗时 |
| --- | --- | --- | --- |
| 1 天 | 约 1.3 万 | 整区间精确 | 0.55s |
| 2 天 | 约 2.6 万 | 整区间精确 | 1.7s |
| 5 天 | 约 6.5 万 | 整区间精确 | 3.1s |
| 本月 | 约 30 万 | 样本(超上限) | 3.8–5.0s |
服务端 60s 内重复访问直接命中缓存。要把大区间也做成亚秒精确,方向是在快照写入时就把 4 个时间戳、5 个环节时长落成列,并把多维指标的计数改为位掩码 `GROUP BY`(一次扫描产出全部桶计数),现有文档结论不变、只替换实现。
时间戳在 SQL 里用 `NULLIF(BTRIM(col),'')::timestamp` 直接转型:快照写入的就是远端 `CONVERT(varchar(19), …, 120)` 结果,格式固定;早期版本用正则逐行校验,整区间聚合约慢一个数量级。
## 运维排查
```sql
-- 批次状态
SELECT "snapshotDate", status, "windowFrom", "windowTo", "rowCount", message, "finishedAt"
FROM "LogisticsArrivalSigningDurationSnapshotRun" ORDER BY "snapshotDate" DESC;
-- 当天行数
SELECT count(*) FROM "LogisticsArrivalSigningDurationSnapshot" WHERE "snapshotDate" = CURRENT_DATE;
```
- 页面长时间显示"数据更新"而不是"快照数据" → 先看批次是不是 `FAILED`,或窗口是否覆盖了页面的签收日期区间。
- 批次 `RUNNING` 卡住(进程被杀)→ 该批次不会发布;直接重跑或加 `--force` 即可(重跑会先清空当天行)。
- 需要重建历史窗口 → `--window-days=` 调大 + `--force`。
## 部署步骤(生产)
1. 部署新版本代码;
2. 执行迁移 `prisma/migrations/20260928120000_add_logistics_arrival_signing_duration_snapshot`(需按生产迁移流程授权、先备份);
3. 跑一次手工同步生成第一批快照(`--apply`),确认批次为 `COMPLETED`、行数与窗口符合预期;
4. 安装 timer 并核对 `systemctl list-timers`;
5. 打开看板确认头部显示"快照数据 · 日期"。
注意:Prisma Client 重新生成过,运行中的 dev/prod 进程需要重启才能拿到新模型。

View File

@ -0,0 +1,53 @@
-- 物流到货签收时长看板的订单明细日快照(纯新增表,仅本地 Postgres 物化,不改动既有表)。
CREATE TABLE "LogisticsArrivalSigningDurationSnapshotRun" (
"id" TEXT NOT NULL,
"snapshotDate" DATE NOT NULL,
"status" TEXT NOT NULL DEFAULT 'RUNNING',
"windowFrom" TEXT NOT NULL DEFAULT '',
"windowTo" TEXT NOT NULL DEFAULT '',
"rowCount" INTEGER NOT NULL DEFAULT 0,
"message" TEXT NOT NULL DEFAULT '',
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
CONSTRAINT "LogisticsArrivalSigningDurationSnapshotRun_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "LogisticsArrivalSigningDurationSnapshotRun_snapshotDate_key" ON "LogisticsArrivalSigningDurationSnapshotRun"("snapshotDate");
CREATE INDEX "LogisticsArrivalSigningDurationSnapshotRun_status_date_idx" ON "LogisticsArrivalSigningDurationSnapshotRun"("status", "snapshotDate");
CREATE TABLE "LogisticsArrivalSigningDurationSnapshot" (
"id" TEXT NOT NULL,
"snapshotDate" DATE NOT NULL,
"rowKey" TEXT NOT NULL,
"innerOrderId" TEXT NOT NULL,
"soId" TEXT NOT NULL DEFAULT '',
"logisticId" TEXT NOT NULL DEFAULT '',
"logisticCompanyName" TEXT NOT NULL DEFAULT '',
"logisticStatusName" TEXT NOT NULL DEFAULT '',
"logisticReturnFlag" TEXT NOT NULL DEFAULT '',
"orderLabels" TEXT NOT NULL DEFAULT '',
"remark" TEXT NOT NULL DEFAULT '',
"buyerMessage" TEXT NOT NULL DEFAULT '',
"shopName" TEXT NOT NULL DEFAULT '',
"platformName" TEXT NOT NULL DEFAULT '',
"warehouseName" TEXT NOT NULL DEFAULT '',
"receiverAddress" TEXT NOT NULL DEFAULT '',
"payTime" TEXT,
"sendTime" TEXT,
"fetchTime" TEXT,
"signTime" TEXT,
"barcode" TEXT NOT NULL DEFAULT '',
"productName" TEXT NOT NULL DEFAULT '',
"supplier" TEXT NOT NULL DEFAULT '',
"purchaser" TEXT NOT NULL DEFAULT '',
"productItems" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LogisticsArrivalSigningDurationSnapshot_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "LogisticsArrivalSigningDurationSnapshot_date_row_key_key" ON "LogisticsArrivalSigningDurationSnapshot"("snapshotDate", "rowKey");
CREATE INDEX "LogisticsArrivalSigningDurationSnapshot_date_sign_time_idx" ON "LogisticsArrivalSigningDurationSnapshot"("snapshotDate", "signTime");

View File

@ -4601,3 +4601,55 @@ model BomCostingAgentSkuEstimate {
@@unique([skuId, engineVersion])
@@index([caseId])
}
/// 物流到货签收时长看板的订单明细快照批次。
/// 每日由 scripts/sync-logistics-arrival-signing-duration-snapshot.ts 生成一批;
/// 看板只读取 status = COMPLETED 的最新批次,写入中途的批次对读路径不可见。
model LogisticsArrivalSigningDurationSnapshotRun {
id String @id @default(cuid())
snapshotDate DateTime @unique @db.Date
status String @default("RUNNING")
windowFrom String @default("")
windowTo String @default("")
rowCount Int @default(0)
message String @default("")
startedAt DateTime @default(now())
finishedAt DateTime?
@@index([status, snapshotDate], map: "LogisticsArrivalSigningDurationSnapshotRun_status_date_idx")
}
/// 物流到货签收时长看板的订单明细快照行。
/// 字段与远端报表视图 rpt.jst_order_express_details 的看板口径一一对应,
/// 条码/供应商/采购在快照时点固化,避免看板每次刷新都回查远端明细与产品档案。
model LogisticsArrivalSigningDurationSnapshot {
id String @id @default(cuid())
snapshotDate DateTime @db.Date
rowKey String
innerOrderId String
soId String @default("")
logisticId String @default("")
logisticCompanyName String @default("")
logisticStatusName String @default("")
logisticReturnFlag String @default("")
orderLabels String @default("")
remark String @default("")
buyerMessage String @default("")
shopName String @default("")
platformName String @default("")
warehouseName String @default("")
receiverAddress String @default("")
payTime String?
sendTime String?
fetchTime String?
signTime String?
barcode String @default("")
productName String @default("")
supplier String @default("")
purchaser String @default("")
productItems Json?
createdAt DateTime @default(now())
@@unique([snapshotDate, rowKey], map: "LogisticsArrivalSigningDurationSnapshot_date_row_key_key")
@@index([snapshotDate, signTime], map: "LogisticsArrivalSigningDurationSnapshot_date_sign_time_idx")
}

View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}"
APP_ROOT="$BASE/current/furniture-3d-system"
SERVICE_NAME="${LOGISTICS_SNAPSHOT_SERVICE_NAME:-furniture-logistics-signing-snapshot}"
SYNC_ENV="$BASE/shared/logistics-arrival-signing-duration-snapshot.env"
CALENDAR="${LOGISTICS_SNAPSHOT_CALENDAR:-*-*-* 05:30:00 Asia/Shanghai}"
WINDOW_DAYS="${LOGISTICS_SNAPSHOT_WINDOW_DAYS:-62}"
RETENTION="${LOGISTICS_SNAPSHOT_RETENTION:-2}"
if [ "$(id -u)" -ne 0 ]; then
echo "This installer requires root." >&2
exit 1
fi
if [ ! -f "$APP_ROOT/scripts/sync-logistics-arrival-signing-duration-snapshot.ts" ]; then
echo "Missing deployed snapshot script: $APP_ROOT/scripts/sync-logistics-arrival-signing-duration-snapshot.ts" >&2
exit 1
fi
if [ ! -f "$SYNC_ENV" ]; then
umask 077
cat >"$SYNC_ENV" <<EOF
# 物流到货签收时长看板 · 每日订单明细快照
# 身份校验值必须填生产实际值,否则同步脚本拒绝写入。
# 取值方式:node_modules/.bin/tsx -e "import {loadEnvConfig} from '@next/env';loadEnvConfig(process.cwd());console.log(process.env.DATABASE_URL)"
LOGISTICS_SNAPSHOT_EXPECTED_DATABASE_IDENTITY=
LOGISTICS_SNAPSHOT_EXPECTED_SOURCE_DATABASE_IDENTITY=
EOF
echo "Created env template: $SYNC_ENV"
fi
chmod 600 "$SYNC_ENV"
cat >"/etc/systemd/system/$SERVICE_NAME.service" <<EOF
[Unit]
Description=Materialize logistics arrival signing order details snapshot
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=$APP_ROOT
Environment=NODE_ENV=production
EnvironmentFile=$BASE/shared/.env
EnvironmentFile=$SYNC_ENV
ExecStart=/bin/bash -lc 'exec ./node_modules/.bin/tsx scripts/sync-logistics-arrival-signing-duration-snapshot.ts --apply --confirm=sync-logistics-arrival-signing-duration-snapshot --window-days=$WINDOW_DAYS --retention=$RETENTION --expected-database-identity="\${LOGISTICS_SNAPSHOT_EXPECTED_DATABASE_IDENTITY:?Missing expected local identity}" --expected-source-database-identity="\${LOGISTICS_SNAPSHOT_EXPECTED_SOURCE_DATABASE_IDENTITY:?Missing expected source identity}"'
TimeoutStartSec=2h
Restart=on-failure
RestartSec=30min
EOF
cat >"/etc/systemd/system/$SERVICE_NAME.timer" <<EOF
[Unit]
Description=Logistics arrival signing order details snapshot at Beijing 05:30
[Timer]
OnCalendar=$CALENDAR
Persistent=true
RandomizedDelaySec=10min
Unit=$SERVICE_NAME.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME.timer"
systemctl list-timers --all "$SERVICE_NAME.timer" --no-pager

View File

@ -0,0 +1,69 @@
import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
const CONFIRM_TOKEN = 'sync-logistics-arrival-signing-duration-snapshot'
function arg(name: string) {
const prefix = `--${name}=`
return process.argv.slice(2).find((value) => value.startsWith(prefix))?.slice(prefix.length) ?? ''
}
function required(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`${name} is required.`)
return value
}
function identity(url: string) {
const parsed = new URL(url)
return [
parsed.protocol.replace(':', ''),
decodeURIComponent(parsed.username || ''),
parsed.hostname,
parsed.port || '',
parsed.pathname.replace(/^\//, ''),
parsed.searchParams.get('schema') ?? '',
].join('|')
}
function sourceIdentity() {
return [
'sqlserver',
required('JST_REPORT_DB_USER'),
required('JST_REPORT_DB_HOST'),
process.env.JST_REPORT_DB_PORT?.trim() || '1433',
required('JST_REPORT_DB_NAME'),
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
].join('|')
}
async function main() {
if (!process.argv.includes('--apply') || arg('confirm') !== CONFIRM_TOKEN) {
throw new Error(`写入订单明细快照需要 --apply 和 --confirm=${CONFIRM_TOKEN}`)
}
const databaseUrl = required('DATABASE_URL')
if (arg('expected-database-identity') !== identity(databaseUrl)) throw new Error('expected database identity mismatch')
if (arg('expected-source-database-identity') !== sourceIdentity()) throw new Error('expected source identity mismatch')
const { prisma } = await import('../src/lib/prisma')
const { logisticsArrivalSigningDurationSnapshotService } = await import(
'../src/server/services/logistics-arrival-signing-duration-snapshot.service'
)
try {
const result = await logisticsArrivalSigningDurationSnapshotService.sync({
snapshotDate: arg('snapshot-date') || undefined,
windowDays: arg('window-days') ? Number(arg('window-days')) : undefined,
retention: arg('retention') ? Number(arg('retention')) : undefined,
chunkDays: arg('chunk-days') ? Number(arg('chunk-days')) : undefined,
batchSize: arg('batch-size') ? Number(arg('batch-size')) : undefined,
force: process.argv.includes('--force'),
onProgress: (progress) => console.log(JSON.stringify(progress)),
})
console.log(JSON.stringify(result, null, 2))
} finally {
await prisma.$disconnect()
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})

View File

@ -0,0 +1,73 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import {
LOGISTICS_ARRIVAL_SIGNING_DURATION_NO_EXCLUDE_KEYWORDS,
normalizeLogisticsArrivalSigningDurationFilters,
parseExcludeKeywords,
} from '@/lib/logistics-arrival-signing-duration'
import type { LogisticsArrivalSigningDurationDrilldownFilter } from '@/lib/logistics-arrival-signing-duration'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { logisticsArrivalSigningDurationService } from '@/server/services/logistics-arrival-signing-duration.service'
import { logisticsArrivalSigningDurationSettingsService } from '@/server/services/logistics-arrival-signing-duration-settings.service'
function first(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] ?? '' : value ?? ''
}
const cascadeSchema = z.array(z.union([
z.object({ kind: z.literal('date'), date: z.string().trim().min(1).max(10) }),
z.object({
kind: z.literal('object'),
field: z.enum(['logisticCompany', 'warehouse', 'platform', 'shop']),
value: z.string().trim().max(200),
platform: z.string().trim().max(200).optional(),
}),
z.object({ kind: z.literal('anomaly'), value: z.enum(['missing', 'invalid']) }),
z.object({
kind: z.literal('product'),
purchaser: z.string().trim().max(200),
supplier: z.string().trim().max(200).optional(),
barcode: z.string().trim().max(200).optional(),
}),
])).max(10)
/** 级联筛选以 JSON 字符串放在查询串里(下钻链路越长,下面的看板展示的数据越窄)。 */
function parseCascade(raw: string) {
if (!raw) return undefined
try {
const parsed = cascadeSchema.safeParse(JSON.parse(raw))
return parsed.success ? parsed.data as LogisticsArrivalSigningDurationDrilldownFilter[] : undefined
} catch {
return undefined
}
}
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const params = request.nextUrl.searchParams
const hasExcludeKeywords = params.has('excludeKeywords')
const excludeKeywordsMode = params.get('excludeKeywordsMode') === 'clear' ? 'clear' : 'default'
// 默认配置既用于 fallback,也用于把老链接的平铺关键词归回各自字段。
const defaultExcludeKeywords = await logisticsArrivalSigningDurationSettingsService.getDefaultExcludeKeywords()
const excludeKeywords = excludeKeywordsMode === 'clear'
? LOGISTICS_ARRIVAL_SIGNING_DURATION_NO_EXCLUDE_KEYWORDS
: hasExcludeKeywords && params.get('excludeKeywords')?.trim()
? parseExcludeKeywords(params.get('excludeKeywords') ?? '', defaultExcludeKeywords)
: defaultExcludeKeywords
const filters = normalizeLogisticsArrivalSigningDurationFilters({
dateFrom: first(params.getAll('dateFrom')),
dateTo: first(params.getAll('dateTo')),
keyword: first(params.getAll('keyword')),
excludeKeywords,
excludeKeywordsMode,
page: 1,
pageSize: 50,
})
const cascade = parseCascade(first(params.getAll('cascade')))
const response = jsonSuccess(await logisticsArrivalSigningDurationService.getDashboard(filters, cascade), 200, ctx.requestId)
// The service owns the authoritative cache. Allow authenticated browser
// refreshes to reuse a very recent response without exposing it publicly.
response.headers.set('Cache-Control', 'private, max-age=30, stale-while-revalidate=120')
return response
}, { requireAuth: true })
}

View File

@ -0,0 +1,64 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { logisticsArrivalSigningDurationService } from '@/server/services/logistics-arrival-signing-duration.service'
import { normalizeExcludeKeywords } from '@/lib/logistics-arrival-signing-duration'
const drilldownKeySchema = z.object({
innerOrderId: z.string().max(80),
soId: z.string().max(120),
logisticId: z.string().max(120),
signTime: z.string().max(40).nullable(),
})
const drilldownFilterSchema = z.union([
z.object({ kind: z.literal('date'), date: z.string().trim().min(1).max(10) }),
z.object({
kind: z.literal('object'),
field: z.enum(['logisticCompany', 'warehouse', 'platform', 'shop']),
value: z.string().trim().max(200),
}),
z.object({ kind: z.literal('anomaly'), value: z.enum(['missing', 'invalid']) }),
z.object({
kind: z.literal('product'),
purchaser: z.string().trim().max(200),
supplier: z.string().trim().max(200).optional(),
barcode: z.string().trim().max(200).optional(),
}),
])
/** 支持单条件与级联(多级)两种写法。 */
const drilldownSchema = z.union([drilldownFilterSchema, z.array(drilldownFilterSchema).max(10).min(1)])
const payloadSchema = z.object({
dateFrom: z.string().trim().max(10),
dateTo: z.string().trim().max(10),
keyword: z.string().trim().max(4000),
/** 排除词按字段分组下发:`字段:词,词;字段:词`,也兼容旧的平铺列表。 */
excludeKeywords: z.union([z.string().max(4000), z.array(z.string()).max(30)]),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
drilldownKeys: z.array(drilldownKeySchema).max(2000).optional(),
drilldown: drilldownSchema.optional(),
}).refine((value) => Boolean(value.drilldown) || Boolean(value.drilldownKeys?.length), {
message: '缺少下钻条件',
})
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = payloadSchema.safeParse(await request.json().catch(() => null))
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '下钻条件格式不正确', 400, ctx.requestId)
const { drilldownKeys, drilldown, excludeKeywords, ...filters } = parsed.data
const result = await logisticsArrivalSigningDurationService.listDrilldownPage({
...filters,
excludeKeywords: normalizeExcludeKeywords(excludeKeywords),
}, {
keys: drilldownKeys,
drilldown: drilldown ? (Array.isArray(drilldown) ? drilldown : [drilldown]) : undefined,
})
const response = jsonSuccess(result, 200, ctx.requestId)
response.headers.set('Cache-Control', 'private, no-store, max-age=0')
return response
}, { requireAuth: true })
}

View File

@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import {
normalizeLogisticsArrivalSigningDurationFilters,
parseExcludeKeywords,
} from '@/lib/logistics-arrival-signing-duration'
import { handleRequest, jsonError } from '@/server/core/response'
import {
createLogisticsArrivalSigningDurationExport,
LOGISTICS_ARRIVAL_SIGNING_DURATION_EXPORT_CONTENT_TYPE,
} from '@/server/services/logistics-arrival-signing-duration-export.service'
import { logisticsArrivalSigningDurationSettingsService } from '@/server/services/logistics-arrival-signing-duration-settings.service'
const drilldownKeySchema = z.object({
innerOrderId: z.string().max(80),
soId: z.string().max(120),
logisticId: z.string().max(120),
signTime: z.string().max(40).nullable(),
})
const drilldownFilterSchema = z.union([
z.object({ kind: z.literal('date'), date: z.string().trim().min(1).max(10) }),
z.object({
kind: z.literal('object'),
field: z.enum(['logisticCompany', 'warehouse', 'platform', 'shop']),
value: z.string().trim().max(200),
}),
z.object({ kind: z.literal('anomaly'), value: z.enum(['missing', 'invalid']) }),
z.object({
kind: z.literal('product'),
purchaser: z.string().trim().max(200),
supplier: z.string().trim().max(200).optional(),
barcode: z.string().trim().max(200).optional(),
}),
])
const drilldownSchema = z.union([drilldownFilterSchema, z.array(drilldownFilterSchema).max(10).min(1)])
const payloadSchema = z.object({
dateFrom: z.string().trim().max(10).optional(),
dateTo: z.string().trim().max(10).optional(),
keyword: z.string().trim().max(4000).optional(),
/** 新格式为 `字段:词,词;字段:词`;旧的平铺列表仍兼容(等价于挂到全部 5 个字段)。 */
excludeKeywords: z.union([z.string(), z.array(z.string())]).optional(),
drilldownKeys: z.array(drilldownKeySchema).max(2000).optional(),
drilldown: drilldownSchema.optional(),
})
function contentDisposition(fileName: string) {
return `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = payloadSchema.safeParse(body)
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '导出筛选条件格式不正确', 400, ctx.requestId)
}
const hasExcludeKeywords = Object.prototype.hasOwnProperty.call(parsed.data, 'excludeKeywords')
const defaultExcludeKeywords = await logisticsArrivalSigningDurationSettingsService.getDefaultExcludeKeywords()
const excludeKeywords = hasExcludeKeywords
? (Array.isArray(parsed.data.excludeKeywords)
? parseExcludeKeywords(parsed.data.excludeKeywords.join(','), defaultExcludeKeywords)
: parseExcludeKeywords(parsed.data.excludeKeywords ?? '', defaultExcludeKeywords))
: defaultExcludeKeywords
const filters = normalizeLogisticsArrivalSigningDurationFilters({
...parsed.data,
excludeKeywords,
page: 1,
pageSize: 50,
})
const result = await createLogisticsArrivalSigningDurationExport(filters, {
keys: parsed.data.drilldownKeys,
drilldown: parsed.data.drilldown
? (Array.isArray(parsed.data.drilldown) ? parsed.data.drilldown : [parsed.data.drilldown])
: undefined,
})
return new NextResponse(new Blob([result.bytes as BlobPart], {
type: LOGISTICS_ARRIVAL_SIGNING_DURATION_EXPORT_CONTENT_TYPE,
}), {
status: 200,
headers: {
'Content-Type': LOGISTICS_ARRIVAL_SIGNING_DURATION_EXPORT_CONTENT_TYPE,
'Content-Length': String(result.bytes.byteLength),
'Content-Disposition': contentDisposition(result.fileName),
'Cache-Control': 'private, no-store, max-age=0',
'X-Exported-Row-Count': String(result.rowCount),
'X-Export-Truncated': String(result.truncated),
'X-Request-Id': ctx.requestId,
},
})
}, { requireAuth: true })
}

View File

@ -0,0 +1,34 @@
import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { logisticsArrivalSigningDurationSettingsService } from '@/server/services/logistics-arrival-signing-duration-settings.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => jsonSuccess({
excludeKeywords: await logisticsArrivalSigningDurationSettingsService.getDefaultExcludeKeywords(),
}, 200, ctx.requestId), { requireAuth: true })
}
export async function PUT(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null)
const value = body && typeof body === 'object' && !Array.isArray(body)
? (body as { excludeKeywords?: unknown }).excludeKeywords
: undefined
if (!Array.isArray(value) && typeof value !== 'string') {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排除关键词格式不正确', 400, ctx.requestId)
}
try {
return jsonSuccess(
await logisticsArrivalSigningDurationSettingsService.updateDefaultExcludeKeywords(value, ctx.user),
200,
ctx.requestId,
)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
}, { requireAuth: true })
}

View File

@ -60,4 +60,20 @@ describe('SupplyChainSidebar', () => {
expect(screen.queryByRole('link', { name: '产品全量档案' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: '供应商信息' })).toBeInTheDocument()
})
it('shows the logistics arrival signing duration dashboard under business management', () => {
usePathnameMock.mockReturnValue(
'/supply-chain/management/logistics-arrival-signing-duration',
)
render(<SupplyChainSidebar visibleTargetKeys={['businessManagement']} />)
expect(screen.getByRole('button', { name: '经营管理' })).toHaveAttribute(
'aria-expanded',
'true',
)
expect(
screen.getByRole('link', { name: '物流到货签收时长看板' }),
).toHaveAttribute('aria-current', 'page')
})
})

View File

@ -109,6 +109,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
'/supply-chain/purchase/daily-affairs': '日常事项',
'/supply-chain/purchase/price-change': '产品变价',
'/supply-chain/management/employee-hours': '员工工时统计',
'/supply-chain/management/logistics-arrival-signing-duration': '物流到货签收时长看板',
'/supply-chain/script-monitor': '脚本监控',
'/supply-chain/operation-logs': '操作日志',
'/supply-chain/user-permissions': '用户权限',

View File

@ -140,4 +140,14 @@ describe('supply chain menu', () => {
]),
)
})
it('places the logistics arrival signing duration dashboard under business management', () => {
expect(flattenSupplyChainMenu(filterSupplyChainMenu(['businessManagement']))).toEqual(
expect.arrayContaining([expect.objectContaining({
label: '物流到货签收时长看板',
module: '经营管理',
route: '/supply-chain/management/logistics-arrival-signing-duration',
})]),
)
})
})

View File

@ -103,6 +103,11 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
targetKey: 'businessManagement' as const,
})),
{ label: '员工工时统计', route: '/supply-chain/management/employee-hours', targetKey: 'employeeHours' },
{
label: '物流到货签收时长看板',
route: '/supply-chain/management/logistics-arrival-signing-duration',
targetKey: 'businessManagement',
},
],
},
],

View File

@ -0,0 +1,59 @@
'use client'
import { ChevronLeft, ChevronRight } from 'lucide-react'
export const BOARD_PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const
/**
* 看板内分页条:经营对象问题排名里每个看板(日期维度、各维度表、发货仓/物流公司排名)
* 都可能有很多条,统一用翻页而不是"显示更多"或无限滚动。
*/
export function BoardPagination({
total,
totalPages: knownTotalPages,
page,
pageSize,
unit,
pageSizeOptions = BOARD_PAGE_SIZE_OPTIONS,
onPageChange,
onPageSizeChange,
}: {
/** 真实总数;不确定时传 undefined,只显示页码。 */
total?: number
totalPages?: number
page: number
pageSize: number
unit: string
pageSizeOptions?: readonly number[]
onPageChange: (page: number) => void
onPageSizeChange?: (pageSize: number) => void
}) {
const totalPages = Math.max(1, knownTotalPages ?? (typeof total === 'number' ? Math.ceil(total / pageSize) : page))
const current = Math.min(Math.max(1, page), totalPages)
const summary = typeof total === 'number'
? `共 ${total.toLocaleString('zh-CN')} ${unit} · 第 ${current} / ${totalPages} 页`
: `第 ${current} / ${totalPages} 页,每页 ${pageSize} 条`
return <div className="sc-board-pagination">
<span className="sc-board-pagination-summary">{summary}</span>
<button type="button" disabled={current <= 1} onClick={() => onPageChange(current - 1)}><ChevronLeft size={14} aria-hidden="true" />上一页</button>
<button type="button" disabled={current >= totalPages} onClick={() => onPageChange(current + 1)}>下一页<ChevronRight size={14} aria-hidden="true" /></button>
{onPageSizeChange ? <select
aria-label="每页条数"
value={pageSize}
onChange={(event) => {
onPageSizeChange(Number(event.target.value))
onPageChange(1)
}}
>
{pageSizeOptions.map((size) => <option key={size} value={size}>{size} 条/页</option>)}
</select> : null}
<style jsx>{`
.sc-board-pagination { display: flex; align-items: center; justify-content: flex-end; gap: 6px; padding: 8px 2px 2px; color: var(--sc-subtle); font-size: 11px; }
.sc-board-pagination-summary { margin-right: auto; }
.sc-board-pagination button { display: inline-flex; align-items: center; gap: 3px; height: 26px; padding: 0 9px; border: 1px solid var(--sc-border); border-radius: 4px; background: var(--sc-surface); color: #161b18; cursor: pointer; font: inherit; font-size: 11px; }
.sc-board-pagination button:hover:not(:disabled) { border-color: #789889; background: #f2f8f4; }
.sc-board-pagination button:disabled { cursor: not-allowed; opacity: .45; }
.sc-board-pagination select { height: 26px; padding: 0 6px; border: 1px solid var(--sc-border); border-radius: 4px; background: var(--sc-surface); color: #161b18; font: inherit; font-size: 11px; }
`}</style>
</div>
}

View File

@ -0,0 +1,139 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { ExcludeKeywordPicker } from './ExcludeKeywordPicker'
import type { LogisticsArrivalSigningDurationExcludeKeywords } from '@/lib/logistics-arrival-signing-duration'
const candidates: LogisticsArrivalSigningDurationExcludeKeywords = {
warehouseName: ['自动发货仓', '京东云仓'],
logisticCompanyName: ['物流', '安能'],
orderLabels: ['1688一件代发'],
}
function fieldSection(label: string) {
const group = Array.from(document.querySelectorAll('.exclude-picker-group')).find((node) =>
node.querySelector('strong')?.textContent === label)
return group as HTMLElement
}
it('按字段分组展示候选词,进入时默认全选', () => {
render(<ExcludeKeywordPicker candidates={candidates} selected={candidates} canAddKeywords={false} onCancel={jest.fn()} onConfirm={jest.fn()} />)
expect(within(fieldSection('发货仓')).getByText('共 2 项 · 已勾选 2 项')).toBeInTheDocument()
expect(within(fieldSection('物流公司')).getByText('共 2 项 · 已勾选 2 项')).toBeInTheDocument()
expect(within(fieldSection('订单标签')).getByText('共 1 项 · 已勾选 1 项')).toBeInTheDocument()
// 买家留言 / 卖家备注没有候选词时不显示空分组
expect(screen.queryByText('买家留言')).toBeNull()
for (const keyword of ['自动发货仓', '京东云仓', '物流', '安能', '1688一件代发']) {
expect(screen.getByLabelText(keyword)).toBeChecked()
}
expect(screen.getByText('共 5 项 · 已勾选 5 项')).toBeInTheDocument()
})
it('取消勾选某一项后确定:只影响它所属字段,其余字段保持排除', () => {
const onConfirm = jest.fn()
render(<ExcludeKeywordPicker candidates={candidates} selected={candidates} canAddKeywords={false} onCancel={jest.fn()} onConfirm={onConfirm} />)
fireEvent.click(screen.getByLabelText('物流'))
expect(screen.getByLabelText('物流')).not.toBeChecked()
expect(screen.getByText('共 5 项 · 已勾选 4 项')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '确定' }))
expect(onConfirm).toHaveBeenCalledWith({
warehouseName: ['自动发货仓', '京东云仓'],
logisticCompanyName: ['安能'],
orderLabels: ['1688一件代发'],
})
})
it('同名关键词在不同字段互不影响(按"字段 + 关键词"勾选)', () => {
const same: LogisticsArrivalSigningDurationExcludeKeywords = {
warehouseName: ['仓储自提'],
logisticCompanyName: ['仓储自提'],
}
const onConfirm = jest.fn()
render(<ExcludeKeywordPicker candidates={same} selected={same} canAddKeywords={false} onCancel={jest.fn()} onConfirm={onConfirm} />)
fireEvent.click(within(fieldSection('发货仓')).getByLabelText('仓储自提'))
fireEvent.click(screen.getByRole('button', { name: '确定' }))
expect(onConfirm).toHaveBeenCalledWith({ logisticCompanyName: ['仓储自提'] })
})
it('清空后确定 = 不排除任何关键词;已选项页签只列勾选项', () => {
const onConfirm = jest.fn()
render(<ExcludeKeywordPicker candidates={candidates} selected={{ logisticCompanyName: ['物流'] }} canAddKeywords={false} onCancel={jest.fn()} onConfirm={onConfirm} />)
fireEvent.click(screen.getByRole('tab', { name: /已选项/ }))
expect(Array.from(document.querySelectorAll('.exclude-picker-option')).map((node) => node.textContent)).toEqual(['物流'])
fireEvent.click(screen.getByRole('tab', { name: '全部' }))
fireEvent.click(screen.getByRole('button', { name: '清空' }))
fireEvent.click(screen.getByRole('button', { name: '确定' }))
expect(onConfirm).toHaveBeenCalledWith({})
})
it('搜索按关键词过滤,字段分组保留', () => {
render(<ExcludeKeywordPicker candidates={candidates} selected={candidates} canAddKeywords={false} onCancel={jest.fn()} onConfirm={jest.fn()} />)
fireEvent.change(screen.getByPlaceholderText('搜索关键词'), { target: { value: '安' } })
expect(Array.from(document.querySelectorAll('.exclude-picker-option')).map((node) => node.textContent)).toEqual(['安能'])
expect(fieldSection('物流公司')).toBeTruthy()
})
it('非系统管理员看不到新增入口,只提示找管理员处理', () => {
render(<ExcludeKeywordPicker candidates={candidates} selected={candidates} canAddKeywords={false} onCancel={jest.fn()} onConfirm={jest.fn()} />)
expect(screen.queryByPlaceholderText(/输入新的排除关键词/)).toBeNull()
expect(screen.queryByRole('button', { name: /添加/ })).toBeNull()
expect(screen.getByText(/仅系统管理员可操作/)).toBeInTheDocument()
})
it('系统管理员可把新词加到指定字段:落库回调 + 该字段默认勾选', async () => {
const onConfirm = jest.fn()
const onAddKeywords = jest.fn(async () => {})
render(
<ExcludeKeywordPicker
candidates={candidates}
selected={candidates}
canAddKeywords
onCancel={jest.fn()}
onConfirm={onConfirm}
onAddKeywords={onAddKeywords}
/>,
)
fireEvent.change(screen.getByLabelText('新增关键词所属字段'), { target: { value: 'orderLabels' } })
const customInput = screen.getByPlaceholderText(/输入新的排除关键词/)
fireEvent.change(customInput, { target: { value: '自定义标签' } })
fireEvent.keyDown(customInput, { key: 'Enter' })
expect(onAddKeywords).toHaveBeenCalledWith('orderLabels', ['自定义标签'])
expect(await screen.findByText('已保存到「订单标签」默认排除关键词:自定义标签')).toBeInTheDocument()
expect(within(fieldSection('订单标签')).getByLabelText('自定义标签')).toBeChecked()
fireEvent.click(screen.getByRole('button', { name: '确定' }))
expect(onConfirm).toHaveBeenCalledWith({
warehouseName: ['自动发货仓', '京东云仓'],
logisticCompanyName: ['物流', '安能'],
orderLabels: ['1688一件代发', '自定义标签'],
})
})
it('新增落库失败时提示错误,不影响本次勾选', async () => {
const onAddKeywords = jest.fn(async () => { throw new Error('只有权限管理员可以维护排除关键词') })
render(
<ExcludeKeywordPicker
candidates={candidates}
selected={candidates}
canAddKeywords
onCancel={jest.fn()}
onConfirm={jest.fn()}
onAddKeywords={onAddKeywords}
/>,
)
const customInput = screen.getByPlaceholderText(/输入新的排除关键词/)
fireEvent.change(customInput, { target: { value: '自定义仓' } })
fireEvent.keyDown(customInput, { key: 'Enter' })
expect(await screen.findByText('只有权限管理员可以维护排除关键词')).toBeInTheDocument()
expect(within(fieldSection('发货仓')).getByLabelText('自定义仓')).toBeChecked()
})

View File

@ -0,0 +1,392 @@
'use client'
import { Plus, Search, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
countExcludeKeywords,
LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_FIELDS,
splitExcludeKeywords,
type LogisticsArrivalSigningDurationExcludeFieldKey,
type LogisticsArrivalSigningDurationExcludeKeywords,
} from '@/lib/logistics-arrival-signing-duration'
const FOCUSABLE_SELECTOR =
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'
type FieldKey = LogisticsArrivalSigningDurationExcludeFieldKey
/** 按字段合并两组排除词,去掉空字段。 */
function mergeByField(
base: LogisticsArrivalSigningDurationExcludeKeywords,
extra: LogisticsArrivalSigningDurationExcludeKeywords,
): LogisticsArrivalSigningDurationExcludeKeywords {
const result: LogisticsArrivalSigningDurationExcludeKeywords = {}
for (const field of LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_FIELDS) {
const keywords = Array.from(new Set([...(base[field.key] ?? []), ...(extra[field.key] ?? [])]))
if (keywords.length) result[field.key] = keywords
}
return result
}
/**
* 排除关键词选择器(按字段分组)。
*
* 排除词只在自己那个字段上生效:配在「物流公司」下面的"物流"不会再去命中订单标签/买家留言里的"物流"。
* 默认全选:进入看板时候选词全部处于"排除"状态;某人不想排除某一项时取消勾选即可,确定后按勾选结果重新查询。
* 右侧索引条按字段跳转;只有系统管理员能把新词加进某个字段的默认排除词。
*/
export function ExcludeKeywordPicker({
candidates,
selected,
canAddKeywords,
onCancel,
onConfirm,
onAddKeywords,
}: {
/** 每个字段的全部候选词(默认排除词 ∪ 当前已排除词)。 */
candidates: LogisticsArrivalSigningDurationExcludeKeywords
/** 当前勾选(= 正在排除)的词,同样按字段分组。 */
selected: LogisticsArrivalSigningDurationExcludeKeywords
/** 只有系统管理员能新增候选关键词,其他人找管理员处理。 */
canAddKeywords: boolean
onCancel: () => void
onConfirm: (keywords: LogisticsArrivalSigningDurationExcludeKeywords) => void
onAddKeywords?: (field: FieldKey, keywords: string[]) => Promise<void>
}) {
const [checked, setChecked] = useState<LogisticsArrivalSigningDurationExcludeKeywords>(selected)
const [customKeywords, setCustomKeywords] = useState<LogisticsArrivalSigningDurationExcludeKeywords>({})
const [customInput, setCustomInput] = useState('')
const [customField, setCustomField] = useState<FieldKey>(LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_FIELDS[0].key)
const [search, setSearch] = useState('')
const [tab, setTab] = useState<'all' | 'selected'>('all')
const [compact, setCompact] = useState(false)
const [adding, setAdding] = useState(false)
const [addMessage, setAddMessage] = useState<string | null>(null)
const [activeField, setActiveField] = useState<FieldKey>(LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_FIELDS[0].key)
const panelRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const allRef = useRef<HTMLInputElement>(null)
const groupNodes = useRef(new Map<FieldKey, HTMLDivElement>())
const allCandidates = useMemo(
() => mergeByField(mergeByField(candidates, selected), customKeywords),
[candidates, customKeywords, selected],
)
const checkedCount = countExcludeKeywords(checked)
const isChecked = (field: FieldKey, keyword: string) => (checked[field] ?? []).includes(keyword)
const visibleGroups = useMemo(() => {
const query = search.trim().toLowerCase()
return LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_FIELDS
.map((field) => ({
key: field.key,
label: field.label,
total: (allCandidates[field.key] ?? []).length,
keywords: (allCandidates[field.key] ?? []).filter((keyword) =>
(tab === 'all' || (checked[field.key] ?? []).includes(keyword))
&& (!query || keyword.toLowerCase().includes(query))),
}))
.filter((group) => group.keywords.length > 0)
}, [allCandidates, checked, search, tab])
const visibleEntries = visibleGroups.flatMap((group) =>
group.keywords.map((keyword) => ({ field: group.key, keyword })))
const allVisibleChecked = visibleEntries.length > 0
&& visibleEntries.every((entry) => isChecked(entry.field, entry.keyword))
useEffect(() => {
const restore = document.activeElement instanceof HTMLElement ? document.activeElement : null
const timer = window.setTimeout(() => searchRef.current?.focus(), 0)
return () => {
window.clearTimeout(timer)
restore?.focus()
}
}, [])
// 部分勾选时「全选」用半选态表示,避免看起来像"一个都没勾"。
useEffect(() => {
if (allRef.current) {
allRef.current.indeterminate = !allVisibleChecked
&& visibleEntries.some((entry) => isChecked(entry.field, entry.keyword))
}
// isChecked 每次渲染重建,依赖 checked 即可。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allVisibleChecked, visibleEntries, checked])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
return
}
if (event.key !== 'Tab') return
const nodes = Array.from(panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
.filter((node) => node.offsetParent !== null)
if (!nodes.length) return
const first = nodes[0]
const last = nodes[nodes.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onCancel])
const toggleKeyword = (field: FieldKey, keyword: string) => setChecked((current) => {
const list = current[field] ?? []
return { ...current, [field]: list.includes(keyword) ? list.filter((item) => item !== keyword) : [...list, keyword] }
})
const checkVisible = (next: boolean) => setChecked((current) => {
const result = { ...current }
for (const group of visibleGroups) {
const values = new Set(result[group.key] ?? [])
for (const keyword of group.keywords) {
if (next) values.add(keyword)
else values.delete(keyword)
}
result[group.key] = Array.from(values)
}
return result
})
const invertVisible = () => setChecked((current) => {
const result = { ...current }
for (const group of visibleGroups) {
const values = new Set(result[group.key] ?? [])
for (const keyword of group.keywords) {
if (values.has(keyword)) values.delete(keyword)
else values.add(keyword)
}
result[group.key] = Array.from(values)
}
return result
})
const addCustomKeywords = async () => {
const additions = splitExcludeKeywords(customInput)
if (!additions.length) return
const fieldLabel = LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_FIELDS
.find((field) => field.key === customField)?.label ?? customField
setCustomKeywords((current) => ({ ...current, [customField]: Array.from(new Set([...(current[customField] ?? []), ...additions])) }))
setChecked((current) => ({ ...current, [customField]: Array.from(new Set([...(current[customField] ?? []), ...additions])) }))
setCustomInput('')
setSearch('')
setTab('all')
if (!onAddKeywords) return
setAdding(true)
setAddMessage(null)
try {
await onAddKeywords(customField, additions)
setAddMessage(`已保存到「${fieldLabel}」默认排除关键词:${additions.join('、')}`)
} catch (cause) {
setAddMessage(cause instanceof Error ? cause.message : '保存失败,请稍后重试')
} finally {
setAdding(false)
}
}
const scrollToGroup = (field: FieldKey) => {
const container = listRef.current
const node = groupNodes.current.get(field)
if (!container || !node) return
container.scrollTop = node.offsetTop
setActiveField(field)
}
const onListScroll = () => {
const container = listRef.current
if (!container) return
let current: FieldKey = visibleGroups[0]?.key ?? LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_FIELDS[0].key
for (const group of visibleGroups) {
const node = groupNodes.current.get(group.key)
if (node && node.offsetTop <= container.scrollTop + 12) current = group.key
}
setActiveField(current)
}
return createPortal(
<div className="exclude-picker-overlay" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onCancel() }}>
<div
className={`exclude-picker${compact ? ' is-compact' : ''}`}
role="dialog"
aria-modal="true"
aria-labelledby="exclude-picker-title"
ref={panelRef}
>
<div className="exclude-picker-head">
<h2 id="exclude-picker-title">请选择排除关键词</h2>
<label className="exclude-picker-search">
<Search size={15} />
<span className="exclude-picker-visually-hidden">搜索排除关键词</span>
<input ref={searchRef} value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索关键词" />
</label>
<button type="button" className="exclude-picker-close" aria-label="关闭" title="关闭" onClick={onCancel}><X size={16} /></button>
</div>
<div className="exclude-picker-tabs" role="tablist" aria-label="排除关键词范围">
<button type="button" role="tab" aria-selected={tab === 'all'} className={`exclude-picker-tab${tab === 'all' ? ' is-active' : ''}`} onClick={() => setTab('all')}>全部</button>
<button type="button" role="tab" aria-selected={tab === 'selected'} className={`exclude-picker-tab${tab === 'selected' ? ' is-active' : ''}`} onClick={() => setTab('selected')}>已选项({checkedCount})</button>
<label className="exclude-picker-compact">
<span>紧凑模式</span>
<input type="checkbox" checked={compact} onChange={(event) => setCompact(event.target.checked)} />
</label>
</div>
<div className="exclude-picker-toolbar">
<label className="exclude-picker-tool">
<input ref={allRef} type="checkbox" checked={allVisibleChecked} onChange={(event) => checkVisible(event.target.checked)} />
<span>全选</span>
</label>
<button type="button" className="exclude-picker-tool" onClick={invertVisible}>反选</button>
<button type="button" className="exclude-picker-tool" onClick={() => checkVisible(false)}>清空</button>
<span className="exclude-picker-toolbar-note">每个关键词只比对它所在字段;勾选 = 排除该关键词</span>
</div>
<div className="exclude-picker-body">
<div className="exclude-picker-list" ref={listRef} onScroll={onListScroll}>
{visibleGroups.length ? visibleGroups.map((group) => (
<div
className="exclude-picker-group"
key={group.key}
ref={(node) => {
if (node) groupNodes.current.set(group.key, node)
else groupNodes.current.delete(group.key)
}}
>
<p className="exclude-picker-group-title">
<strong>{group.label}</strong>
<small>共 {group.total} 项 · 已勾选 {(checked[group.key] ?? []).length} 项</small>
</p>
<div className="exclude-picker-grid">
{group.keywords.map((keyword) => (
<label className="exclude-picker-option" key={keyword} title={`${group.label}:${keyword}`}>
<input type="checkbox" checked={isChecked(group.key, keyword)} onChange={() => toggleKeyword(group.key, keyword)} />
<span>{keyword}</span>
</label>
))}
</div>
</div>
)) : <p className="exclude-picker-empty">{tab === 'selected' ? '还没有勾选任何关键词' : '没有匹配的关键词'}</p>}
</div>
{visibleGroups.length > 1 ? (
<div className="exclude-picker-rail" aria-hidden="true">
{visibleGroups.map((group) => (
<button
type="button"
key={group.key}
tabIndex={-1}
className={activeField === group.key ? 'is-active' : ''}
onClick={() => scrollToGroup(group.key)}
>
{group.label}
</button>
))}
</div>
) : null}
</div>
<div className="exclude-picker-custom">
{canAddKeywords ? <>
<select value={customField} onChange={(event) => setCustomField(event.target.value as FieldKey)} aria-label="新增关键词所属字段">
{LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_FIELDS.map((field) => (
<option key={field.key} value={field.key}>{field.label}</option>
))}
</select>
<input
value={customInput}
onChange={(event) => setCustomInput(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void addCustomKeywords() } }}
placeholder="输入新的排除关键词,回车或点添加(逗号分隔可一次多个,保存为该系统默认项)"
/>
<button type="button" className="exclude-picker-button" disabled={adding} onClick={() => void addCustomKeywords()}>
<Plus size={14} />{adding ? '保存中' : '添加'}
</button>
</> : (
<span className="exclude-picker-custom-hint">新增候选关键词仅系统管理员可操作,需要新增请联系系统管理员</span>
)}
{addMessage ? <span className="exclude-picker-custom-message" role="status">{addMessage}</span> : null}
</div>
<div className="exclude-picker-foot">
<span className="exclude-picker-count">共 {countExcludeKeywords(allCandidates)} 项 · 已勾选 {checkedCount} 项</span>
<button type="button" className="exclude-picker-button" onClick={onCancel}>取消</button>
<button type="button" className="exclude-picker-button is-primary" onClick={() => onConfirm(mergeByField({}, checked))}>确定</button>
</div>
</div>
<style jsx>{`
.exclude-picker-overlay { position: fixed; inset: 0; z-index: 80; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(17, 19, 21, .38); }
.exclude-picker { display: flex; flex-direction: column; width: min(920px, 100%); max-height: min(780px, 100%); overflow: hidden; border: 1px solid var(--sc-border); border-radius: 10px; background: var(--sc-surface); color: var(--sc-ink); box-shadow: 0 18px 48px rgba(17, 19, 21, .22); }
.exclude-picker-head { display: flex; align-items: center; gap: 14px; padding: 16px 18px 12px; }
.exclude-picker-head h2 { margin: 0; font-size: 15px; font-weight: 650; }
.exclude-picker-search { display: flex; flex: 1; align-items: center; gap: 7px; max-width: 420px; height: 34px; margin-left: auto; padding: 0 10px; border: 1px solid var(--sc-border); border-radius: 6px; background: var(--sc-surface); color: var(--sc-subtle); }
.exclude-picker-search input { flex: 1; min-width: 0; height: 30px; border: 0; outline: 0; background: transparent; color: var(--sc-ink); font: inherit; }
.exclude-picker-close { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--sc-muted); cursor: pointer; }
.exclude-picker-close:hover { border-color: var(--sc-border); background: var(--sc-hover); color: var(--sc-ink); }
.exclude-picker-tabs { display: flex; align-items: center; gap: 18px; padding: 0 18px; border-bottom: 1px solid var(--sc-border); }
.exclude-picker-tab { padding: 8px 2px 9px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--sc-subtle); font: inherit; font-size: 13px; cursor: pointer; }
.exclude-picker-tab.is-active { border-bottom-color: #2f7d55; color: var(--sc-ink); font-weight: 600; }
.exclude-picker-compact { display: inline-flex; align-items: center; gap: 7px; margin-left: auto; color: var(--sc-subtle); font-size: 12px; cursor: pointer; }
.exclude-picker-toolbar { display: flex; align-items: center; gap: 16px; padding: 10px 18px; border-bottom: 1px solid var(--sc-border); color: var(--sc-muted); }
.exclude-picker-tool { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: var(--sc-muted); font: inherit; font-size: 12px; cursor: pointer; }
.exclude-picker-tool:hover { color: #1f6b41; }
.exclude-picker-toolbar-note { margin-left: auto; color: var(--sc-subtle); font-size: 11px; }
.exclude-picker-body { display: flex; flex: 1; min-height: 0; }
.exclude-picker-list { position: relative; flex: 1; min-height: 0; overflow: auto; padding: 12px 18px 16px; }
.exclude-picker-group-title { display: flex; align-items: baseline; gap: 8px; margin: 0 0 8px; color: var(--sc-ink); font-size: 12px; }
.exclude-picker-group-title strong { font-weight: 650; }
.exclude-picker-group-title small { color: var(--sc-subtle); font-size: 11px; }
.exclude-picker-group-title::after { content: ''; flex: 1; height: 1px; background: var(--sc-border); align-self: center; }
.exclude-picker-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px 18px; margin-bottom: 14px; }
.exclude-picker-option { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 6px 8px; border-radius: 6px; font-size: 12px; cursor: pointer; }
.exclude-picker-option:hover { background: var(--sc-hover); }
.exclude-picker-option input { flex: none; width: 15px; height: 15px; accent-color: #2f7d55; cursor: pointer; }
.exclude-picker-option span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.exclude-picker.is-compact .exclude-picker-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0 14px; margin-bottom: 8px; }
.exclude-picker.is-compact .exclude-picker-option { padding: 2px 8px; }
.exclude-picker.is-compact .exclude-picker-group-title { margin-bottom: 5px; }
.exclude-picker-rail { display: flex; flex: none; flex-direction: column; gap: 2px; overflow: auto; padding: 12px 8px; border-left: 1px solid var(--sc-border); }
.exclude-picker-rail button { padding: 3px 7px; border: 0; border-radius: 4px; background: transparent; color: var(--sc-subtle); font-size: 11px; white-space: nowrap; cursor: pointer; }
.exclude-picker-rail button:hover, .exclude-picker-rail button.is-active { background: #eaf3ed; color: #1f6b41; font-weight: 600; }
.exclude-picker-empty { margin: 0; padding: 48px 0; color: var(--sc-subtle); font-size: 12px; text-align: center; }
.exclude-picker-custom { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 18px; border-top: 1px solid var(--sc-border); }
.exclude-picker-custom select { flex: none; height: 32px; padding: 0 8px; border: 1px solid var(--sc-border); border-radius: 5px; background: var(--sc-surface); color: var(--sc-ink); font: inherit; }
.exclude-picker-custom input { flex: 1; min-width: 0; height: 32px; padding: 0 9px; border: 1px solid var(--sc-border); border-radius: 5px; background: var(--sc-surface); color: var(--sc-ink); font: inherit; }
.exclude-picker-custom-hint { color: var(--sc-subtle); font-size: 12px; }
.exclude-picker-custom-message { flex-basis: 100%; color: var(--sc-subtle); font-size: 11px; }
.exclude-picker-foot { display: flex; align-items: center; gap: 10px; padding: 12px 18px; border-top: 1px solid var(--sc-border); }
.exclude-picker-count { margin-right: auto; color: var(--sc-subtle); font-size: 12px; }
.exclude-picker-button { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 18px; border: 1px solid var(--sc-border); border-radius: 6px; background: var(--sc-surface); color: var(--sc-ink); font: inherit; cursor: pointer; }
.exclude-picker-button:hover { border-color: #8ba79a; }
.exclude-picker-button:disabled { cursor: wait; opacity: .6; }
.exclude-picker-button.is-primary { border-color: #27272a; background: #27272a; color: #fff; }
.exclude-picker-button.is-primary:hover { border-color: #3f4145; background: #3f4145; }
.exclude-picker-visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
/* 小屏时全局规则会把 checkbox/button 撑成 44x44(移动端触摸目标)。
这里把触摸目标留给整行,复选框本身保持原尺寸,避免选项变成大色块。 */
@media (max-width: 1023px) {
.exclude-picker-option, .exclude-picker-tool, .exclude-picker-compact { min-height: 44px; }
.exclude-picker-option input, .exclude-picker-tool input, .exclude-picker-compact input { min-width: 0; min-height: 0; width: 16px; height: 16px; }
.exclude-picker-close, .exclude-picker-rail button { min-width: 0; min-height: 0; }
.exclude-picker-tab { min-width: 0; }
}
@media (max-width: 760px) {
.exclude-picker-overlay { padding: 12px; }
.exclude-picker-head { flex-wrap: wrap; }
.exclude-picker-search { max-width: none; }
.exclude-picker-grid, .exclude-picker.is-compact .exclude-picker-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.exclude-picker-rail { display: none; }
.exclude-picker-toolbar-note { display: none; }
}
`}</style>
</div>,
document.body,
)
}

View File

@ -0,0 +1,293 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { buildLogisticsArrivalSigningDurationDashboard } from '@/lib/logistics-arrival-signing-duration'
import type { LogisticsArrivalSigningDurationFilters, LogisticsArrivalSigningDurationRow } from '@/lib/logistics-arrival-signing-duration'
import { LogisticsArrivalSigningDurationDashboard } from './LogisticsArrivalSigningDurationDashboard'
const filters: LogisticsArrivalSigningDurationFilters = {
dateFrom: '2026-09-01', dateTo: '2026-09-26', keyword: '',
excludeKeywords: [], page: 1, pageSize: 50,
}
const row: LogisticsArrivalSigningDurationRow = {
innerOrderId: '内部-1', soId: '线上-1', logisticId: '物流-1',
logisticCompanyName: '快运', logisticStatusName: '已签收', logisticReturnFlag: '',
orderLabels: '', remark: '', buyerMessage: '', shopName: '店铺',
platformName: '平台', warehouseName: '发货仓', receiverAddress: '',
payTime: null, sendTime: null, fetchTime: null, signTime: '2026-09-02 00:00:00',
barcode: 'B1', supplier: '供应商', purchaser: '采购',
}
it('经营分析加载中时订单明细仍位于经营对象问题排名内', () => {
const originalFetch = global.fetch
global.fetch = jest.fn(() => new Promise<Response>(() => {})) as typeof fetch
try {
render(<LogisticsArrivalSigningDurationDashboard filters={filters} orderRows={[row]} orderTotal={1} orderTotalKnown orderPage={1} orderPageSize={50} />)
const ranking = screen.getByText('经营对象问题排名').closest('section')!
expect(within(ranking).getByRole('heading', { name: '订单明细' })).toBeInTheDocument()
expect(within(ranking).getByText('内部-1')).toBeInTheDocument()
} finally {
global.fetch = originalFetch
}
})
it('两个履约环节只展示指定维度和字段,发货仓看板导出与界面一致', async () => {
const originalFetch = global.fetch
const originalCreateObjectURL = URL.createObjectURL
const originalRevokeObjectURL = URL.revokeObjectURL
const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
let exportedBlob: Blob | undefined
URL.createObjectURL = jest.fn((blob: Blob) => { exportedBlob = blob; return 'blob:test' })
URL.revokeObjectURL = jest.fn()
const readyRows = [{
...row, sendTime: '2026-09-01 00:00:00', fetchTime: '2026-09-02 00:00:00',
signTime: '2026-09-03 00:00:00',
}, {
...row, innerOrderId: '内部-2', soId: '线上-2', logisticId: '物流-2',
logisticCompanyName: '慢运', warehouseName: '西仓', purchaser: '另一采购',
sendTime: '2026-09-01 00:00:00', fetchTime: '2026-09-02 00:00:00',
signTime: '2026-09-04 00:00:00',
}]
const dashboard = buildLogisticsArrivalSigningDurationDashboard(readyRows)
global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/drilldown')) {
const body = JSON.parse(String(init?.body)) as { drilldownKeys: Array<{ innerOrderId: string }> }
const rows = readyRows.filter((item) => body.drilldownKeys.some((key) => key.innerOrderId === item.innerOrderId))
return { ok: true, json: async () => ({ success: true, data: { rows, total: rows.length, page: 1 } }) } as Response
}
return { ok: true, json: async () => ({ success: true, data: dashboard }) } as Response
}) as typeof fetch
try {
render(<LogisticsArrivalSigningDurationDashboard filters={filters} orderRows={readyRows} />)
fireEvent.click(await screen.findByRole('button', { name: /发货 → 揽收 24h达成/ }))
const ranking = screen.getByText('经营对象问题排名').closest('section')!
const warehouseTable = within(ranking).getByLabelText('发货仓排名表格')
const companyTable = within(ranking).getByLabelText('物流公司排名表格')
expect(within(warehouseTable).getAllByRole('columnheader').map((item) => item.textContent)).toEqual([
'发货仓', '平均时长', '24h揽收达成率', '48h揽收达成率', '超48h揽收达成率',
])
expect(within(companyTable).getAllByRole('columnheader')).toHaveLength(5)
expect(within(ranking).queryByRole('tablist')).not.toBeInTheDocument()
fireEvent.click(within(ranking).getByRole('button', { name: '导出发货仓看板' }))
expect(exportedBlob).toBeDefined()
const csv = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error)
reader.readAsText(exportedBlob!)
})
expect(csv).toContain('"发货仓","平均时长","24h揽收达成率","48h揽收达成率","超48h揽收达成率"')
expect(csv).toContain('"发货仓","24.0","100.0%","100.0%","0.0%"')
fireEvent.click(within(warehouseTable).getByRole('button', { name: '发货仓' }))
const details = within(ranking).getByRole('heading', { name: '订单明细' }).closest('section')!
await waitFor(() => expect(within(details).getByText('内部-1')).toBeInTheDocument())
expect(within(details).queryByText('内部-2')).not.toBeInTheDocument()
expect(within(details).getByText(/当前下钻:发货仓 \/ 发货仓/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /揽收 → 签收 72h履约/ }))
expect(within(ranking).queryByLabelText('发货仓排名表格')).not.toBeInTheDocument()
const deliveryTable = within(ranking).getByLabelText('物流公司排名表格')
expect(within(deliveryTable).getAllByRole('columnheader').map((item) => item.textContent)).toEqual([
'物流公司', '单量', '平均时长', '24h履约率', '48h履约率', '72h履约率', '超72h履约率',
])
fireEvent.click(within(deliveryTable).getByRole('button', { name: '快运' }))
await waitFor(() => expect(within(ranking).getByText('当前下钻:物流公司 / 快运')).toBeInTheDocument())
await waitFor(() => expect(within(details).getByText('内部-1')).toBeInTheDocument())
expect(within(details).queryByText('内部-2')).not.toBeInTheDocument()
expect(screen.getAllByRole('heading', { name: '订单明细' })).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: /发货 → 签收 72h履约/ }))
expect(screen.queryByText('经营对象问题排名')).not.toBeInTheDocument()
expect(screen.queryByRole('tablist', { name: '分析维度' })).not.toBeInTheDocument()
const detailsOnlyBoard = screen.getByRole('heading', { name: '订单明细' }).closest('section')!.parentElement!
expect(within(detailsOnlyBoard).queryByRole('button', { name: '导出当前看板' })).not.toBeInTheDocument()
expect(screen.getByText('当前环节:发货 → 签收 · 72h履约')).toBeInTheDocument()
expect(screen.getByText('发货→签收时效分布')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '订单明细' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /付款 → 发货 24h达成/ }))
expect(screen.getByRole('heading', { name: '付款 → 发货 · 多维经营分析' })).toBeInTheDocument()
expect(screen.getByText('经营对象问题排名')).toBeInTheDocument()
} finally {
global.fetch = originalFetch
URL.createObjectURL = originalCreateObjectURL
URL.revokeObjectURL = originalRevokeObjectURL
click.mockRestore()
}
})
it('付款到发货经营对象排名按发货及时率展示并下钻到 09 明细', async () => {
const originalFetch = global.fetch
const readyRows = [{
...row, payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 10:00:00',
fetchTime: '2026-09-02 00:00:00', signTime: '2026-09-03 00:00:00',
}, {
...row, innerOrderId: '内部-2', soId: '线上-2', logisticId: '物流-2',
purchaser: '另一采购', supplier: '另一供应商', barcode: 'B2',
payTime: '2026-09-01 00:00:00', sendTime: '2026-09-04 00:00:00',
fetchTime: '2026-09-05 00:00:00', signTime: '2026-09-06 00:00:00',
}]
const dashboard = buildLogisticsArrivalSigningDurationDashboard(readyRows)
global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/drilldown')) {
const body = JSON.parse(String(init?.body)) as { drilldownKeys: Array<{ innerOrderId: string }> }
const matching = readyRows.filter((item) => body.drilldownKeys.some((key) => key.innerOrderId === item.innerOrderId))
return { ok: true, json: async () => ({ success: true, data: { rows: matching, total: matching.length, page: 1 } }) } as Response
}
return { ok: true, json: async () => ({ success: true, data: dashboard }) } as Response
}) as typeof fetch
try {
render(<LogisticsArrivalSigningDurationDashboard filters={filters} orderRows={readyRows} />)
fireEvent.click(await screen.findByRole('button', { name: /付款 → 发货 24h达成/ }))
const ranking = screen.getByRole('heading', { name: '付款 → 发货 · 多维经营分析' }).closest('section')!
expect(within(ranking).queryByRole('tablist')).not.toBeInTheDocument()
const headers = within(ranking).getAllByRole('columnheader').map((item) => item.textContent)
expect(headers).toContain('单量')
expect(headers).toContain('18h发货及时率')
expect(headers).toContain('24h发货及时率')
expect(headers).toContain('48h发货及时率')
expect(headers).toContain('超48h发货及时率')
expect(headers).not.toContain('平均时长')
expect(within(ranking).getAllByText(/超48h发货及时率从高到低/).length).toBeGreaterThan(0)
fireEvent.click(within(ranking).getByRole('button', { name: '另一采购' }))
const details = within(ranking).getByRole('heading', { name: '订单明细' }).closest('section')!
await waitFor(() => expect(within(details).getByText('内部-2')).toBeInTheDocument())
expect(within(details).queryByText('内部-1')).not.toBeInTheDocument()
expect(within(details).getByText(/当前下钻:另一采购/)).toBeInTheDocument()
expect(screen.getAllByRole('heading', { name: '订单明细' })).toHaveLength(1)
} finally {
global.fetch = originalFetch
}
})
it('发货仓排名看板分页展示,避免仓库很多时被截断', async () => {
const originalFetch = global.fetch
const readyRows = Array.from({ length: 25 }, (_, index) => ({
...row, innerOrderId: `内部-${index + 1}`, soId: `线上-${index + 1}`, logisticId: `物流-${index + 1}`,
warehouseName: `仓库${String(index + 1).padStart(2, '0')}`,
payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 00:00:00',
fetchTime: '2026-09-01 12:00:00', signTime: '2026-09-03 00:00:00',
}))
const dashboard = buildLogisticsArrivalSigningDurationDashboard(readyRows)
global.fetch = jest.fn(async () => ({ ok: true, json: async () => ({ success: true, data: dashboard }) }) as Response) as typeof fetch
try {
render(<LogisticsArrivalSigningDurationDashboard filters={filters} orderRows={readyRows} />)
fireEvent.click(await screen.findByRole('button', { name: /发货 → 揽收 24h达成/ }))
const ranking = screen.getByText('经营对象问题排名').closest('section')!
const warehouseTable = within(ranking).getByLabelText('发货仓排名表格')
expect(within(warehouseTable).getByRole('button', { name: '仓库01' })).toBeInTheDocument()
expect(within(warehouseTable).queryByRole('button', { name: '仓库25' })).not.toBeInTheDocument()
const pager = within(ranking).getByText('共 25 个发货仓 · 第 1 / 2 页')
fireEvent.click(within(pager.parentElement!).getByRole('button', { name: '下一页' }))
expect(within(warehouseTable).getByRole('button', { name: '仓库25' })).toBeInTheDocument()
expect(within(warehouseTable).queryByRole('button', { name: '仓库01' })).not.toBeInTheDocument()
} finally {
global.fetch = originalFetch
}
})
it('付款到签收采购链路只在 09 展示明细,导出跟随所选商品', async () => {
const originalFetch = global.fetch
const onExportDetails = jest.fn()
const rows = [{
...row, payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 02:00:00',
fetchTime: '2026-09-01 04:00:00', signTime: '2026-09-02 00:00:00',
}, {
...row, innerOrderId: '内部-2', soId: '线上-2', logisticId: '物流-2',
purchaser: '另一采购', supplier: '另一供应商', barcode: 'B2',
payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 02:00:00',
fetchTime: '2026-09-01 04:00:00', signTime: '2026-09-03 00:00:00',
}]
const dashboard = buildLogisticsArrivalSigningDurationDashboard(rows)
global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/drilldown')) {
const body = JSON.parse(String(init?.body)) as { drilldownKeys: Array<{ innerOrderId: string }> }
const matching = rows.filter((item) => body.drilldownKeys.some((key) => key.innerOrderId === item.innerOrderId))
return { ok: true, json: async () => ({ success: true, data: { rows: matching, total: matching.length, page: 1 } }) } as Response
}
return { ok: true, json: async () => ({ success: true, data: dashboard }) } as Response
}) as typeof fetch
try {
render(<LogisticsArrivalSigningDurationDashboard filters={filters} orderRows={rows} onExportDetails={onExportDetails} />)
const ranking = (await screen.findByRole('heading', { name: '付款 → 签收 · 多维经营分析' })).closest('section')!
fireEvent.click(within(ranking).getByRole('button', { name: '采购' }))
const details = within(ranking).getByRole('heading', { name: '订单明细' }).closest('section')!
await waitFor(() => expect(within(details).getByText('内部-1')).toBeInTheDocument())
expect(within(details).queryByText('内部-2')).not.toBeInTheDocument()
fireEvent.click(within(ranking).getByRole('button', { name: '供应商' }))
fireEvent.click(within(ranking).getByRole('button', { name: 'B1' }))
await waitFor(() => expect(within(details).getByText('当前下钻:采购 / 供应商 / B1 · 1 条样本明细 · 第 1 页,每页 50 条')).toBeInTheDocument())
expect(screen.getAllByRole('heading', { name: '订单明细' })).toHaveLength(1)
fireEvent.click(within(details).getByRole('button', { name: '导出订单明细' }))
expect(onExportDetails).toHaveBeenCalledWith({
keys: [expect.objectContaining({ innerOrderId: '内部-1', logisticId: '物流-1' })],
})
fireEvent.click(within(ranking).getByRole('button', { name: '发货仓' }))
await waitFor(() => expect(within(details).getByText('内部-2')).toBeInTheDocument())
fireEvent.click(within(ranking).getByRole('button', { name: '09-02' }))
await waitFor(() => expect(within(details).queryByText('内部-2')).not.toBeInTheDocument())
fireEvent.click(within(ranking).getByRole('button', { name: '重置' }))
await waitFor(() => expect(within(details).getByText('内部-2')).toBeInTheDocument())
expect(within(details).queryByText(/当前下钻:/)).not.toBeInTheDocument()
} finally {
global.fetch = originalFetch
}
})
it('经营总览含平均到货时长卡片,异常卡片可点击按异常条件下钻', async () => {
const originalFetch = global.fetch
const missingRow = { ...row, payTime: '2026-09-01 00:00:00', sendTime: null, fetchTime: null, signTime: '2026-09-02 00:00:00' }
const rows = [missingRow]
const baseDashboard = buildLogisticsArrivalSigningDurationDashboard(rows)
const dashboard = {
...baseDashboard,
overview: { ...baseDashboard.overview, orderCount: 1, missingTimeOrders: 1, invalidTimeOrders: 0 },
stageTargets: { payToSign: { baselineMonth: '2026-08', baselineAverageHours: 50, targetHours: 49 } },
stageAchievements: { payToSign: { averageHours: 48, targetHours: 49, rate: 102 } },
source: 'snapshot' as const,
snapshotDate: '2026-09-28',
}
let drilldownBody: { drilldown?: unknown } | null = null
global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/drilldown')) {
drilldownBody = JSON.parse(String(init?.body))
return { ok: true, json: async () => ({ success: true, data: { rows, total: 1, page: 1, totalKnown: true } }) } as Response
}
return { ok: true, json: async () => ({ success: true, data: dashboard }) } as Response
}) as typeof fetch
try {
render(<LogisticsArrivalSigningDurationDashboard filters={filters} orderRows={rows} />)
const kpiBoard = (await screen.findByText('付款→签收平均到货时长')).closest('section')!
expect(within(kpiBoard).getByText(/本月目标 49\.0 · 目标达成率 102\.0%/)).toBeInTheDocument()
fireEvent.click(within(kpiBoard).getByRole('button', { name: /缺少关键时间/ }))
await waitFor(() => expect(drilldownBody?.drilldown).toEqual([{ kind: 'anomaly', value: 'missing' }]))
expect(screen.getByText(/当前下钻:缺少关键时间/)).toBeInTheDocument()
} finally {
global.fetch = originalFetch
}
})
it('下钻后自动滚动到 09 订单明细,无需人工下划', async () => {
const originalFetch = global.fetch
const scrollSpy = jest.fn()
const originalScrollIntoView = Element.prototype.scrollIntoView
Element.prototype.scrollIntoView = scrollSpy
const dashboard = buildLogisticsArrivalSigningDurationDashboard([row])
global.fetch = jest.fn(async () => ({ ok: true, json: async () => ({ success: true, data: dashboard }) }) as Response) as typeof fetch
try {
render(<LogisticsArrivalSigningDurationDashboard filters={filters} orderRows={[row]} />)
const ranking = (await screen.findByRole('heading', { name: '付款 → 签收 · 多维经营分析' })).closest('section')!
fireEvent.click(within(ranking).getByRole('button', { name: '采购' }))
await waitFor(() => expect(scrollSpy).toHaveBeenCalled())
const anchor = screen.getByRole('heading', { name: '订单明细' }).closest('.sc-logistics-detail-anchor')
expect(anchor).not.toBeNull()
expect(scrollSpy.mock.contexts[0]).toBe(anchor)
} finally {
global.fetch = originalFetch
Element.prototype.scrollIntoView = originalScrollIntoView
}
})

View File

@ -0,0 +1,165 @@
import { fireEvent, render, screen } from '@testing-library/react'
import {
buildLogisticsArrivalSigningDurationDashboard,
type LogisticsArrivalSigningDurationExcludeKeywords,
type LogisticsArrivalSigningDurationFilters,
type LogisticsArrivalSigningDurationPage,
type LogisticsArrivalSigningDurationRow,
} from '@/lib/logistics-arrival-signing-duration'
import { LogisticsArrivalSigningDurationView } from './LogisticsArrivalSigningDurationView'
jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), refresh: jest.fn() }) }))
const row: LogisticsArrivalSigningDurationRow = {
innerOrderId: '内部-1', soId: '线上-1', logisticId: '物流-1',
logisticCompanyName: '快运', logisticStatusName: '已签收', logisticReturnFlag: '',
orderLabels: '标签', remark: '备注', buyerMessage: '留言', shopName: '店铺',
platformName: '平台', warehouseName: '发货仓', receiverAddress: '浙江省杭州市',
payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 04:00:00',
fetchTime: '2026-09-01 12:00:00', signTime: '2026-09-02 00:00:00',
barcode: 'B1', supplier: '供应商', purchaser: '采购',
}
const defaultExcludeKeywords: LogisticsArrivalSigningDurationExcludeKeywords = {
warehouseName: ['自动发货仓'],
logisticCompanyName: ['物流'],
}
const filters: LogisticsArrivalSigningDurationFilters = {
dateFrom: '2026-09-01',
dateTo: '2026-09-28',
keyword: '',
keywords: [],
excludeKeywords: defaultExcludeKeywords,
excludeKeywordsMode: 'default',
page: 1,
pageSize: 50,
}
const initialData: LogisticsArrivalSigningDurationPage = {
rows: [row],
total: 1,
totalKnown: true,
hasNext: false,
page: 1,
pageSize: 50,
totalPages: 1,
filters,
source: 'snapshot',
snapshotDate: '2026-09-28',
}
it('排除关键词默认全选,取消勾选后确定会按新条件重新查询', async () => {
const originalFetch = global.fetch
const dashboard = buildLogisticsArrivalSigningDurationDashboard([row])
global.fetch = jest.fn(async () => ({ ok: true, json: async () => ({ success: true, data: dashboard }) }) as Response) as typeof fetch
// jsdom 没实现 requestSubmit(会打印 not implemented),这里替换掉只验证调用。
const requestSubmit = jest.spyOn(HTMLFormElement.prototype, 'requestSubmit').mockImplementation(() => {})
try {
render(
<LogisticsArrivalSigningDurationView
initialData={initialData}
defaultExcludeKeywords={defaultExcludeKeywords}
canManageExcludeKeywords={false}
canDownload
/>,
)
// 摘要按字段给出数量,替代之前会截断的一长串关键词。
expect(screen.getByText('已排除 2 项:发货仓 1 · 物流公司 1')).toBeInTheDocument()
const hidden = document.querySelector<HTMLInputElement>('input[name="excludeKeywords"]')!
expect(hidden.value).toBe('warehouseName:自动发货仓;logisticCompanyName:物流')
fireEvent.click(screen.getByTitle(/点击勾选要排除的关键词/))
expect(await screen.findByRole('dialog', { name: '请选择排除关键词' })).toBeInTheDocument()
// 非系统管理员:没有新增入口,也没有独立的默认排除关键词设置卡片。
expect(screen.queryByPlaceholderText(/输入新的排除关键词/)).toBeNull()
expect(screen.queryByText('默认排除关键词')).toBeNull()
// 进入选择器时默认全选:候选词与已排除项一致,按字段分组展示。
expect(Array.from(document.querySelectorAll('.exclude-picker-group strong')).map((node) => node.textContent))
.toEqual(['发货仓', '物流公司'])
expect(screen.getByLabelText('自动发货仓')).toBeChecked()
expect(screen.getByLabelText('物流')).toBeChecked()
expect(screen.getByText('共 2 项 · 已勾选 2 项')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('物流'))
fireEvent.click(screen.getByRole('button', { name: '确定' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(hidden.value).toBe('warehouseName:自动发货仓')
expect(screen.getByText('已排除 1 项:发货仓 1')).toBeInTheDocument()
expect(requestSubmit).toHaveBeenCalledTimes(1)
} finally {
requestSubmit.mockRestore()
global.fetch = originalFetch
}
})
it('清空排除关键词时不排除任何一项,并显式告知服务端不要回落到默认排除项', async () => {
const originalFetch = global.fetch
const dashboard = buildLogisticsArrivalSigningDurationDashboard([row])
global.fetch = jest.fn(async () => ({ ok: true, json: async () => ({ success: true, data: dashboard }) }) as Response) as typeof fetch
const requestSubmit = jest.spyOn(HTMLFormElement.prototype, 'requestSubmit').mockImplementation(() => {})
try {
render(
<LogisticsArrivalSigningDurationView
initialData={initialData}
defaultExcludeKeywords={defaultExcludeKeywords}
canManageExcludeKeywords={false}
canDownload
/>,
)
fireEvent.click(screen.getByTitle(/点击勾选要排除的关键词/))
fireEvent.click(await screen.findByRole('button', { name: '清空' }))
fireEvent.click(screen.getByRole('button', { name: '确定' }))
expect(document.querySelector<HTMLInputElement>('input[name="excludeKeywords"]')!.value).toBe('')
expect(document.querySelector<HTMLInputElement>('input[name="excludeKeywordsMode"]')!.value).toBe('clear')
expect(screen.getByText('未排除任何关键词')).toBeInTheDocument()
} finally {
requestSubmit.mockRestore()
global.fetch = originalFetch
}
})
it('系统管理员在选择器里新增关键词会写入默认排除关键词,非管理员没有该入口', async () => {
const originalFetch = global.fetch
const dashboard = buildLogisticsArrivalSigningDurationDashboard([row])
const settingsBodies: unknown[] = []
global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/settings')) {
settingsBodies.push(JSON.parse(String(init?.body)))
return {
ok: true,
json: async () => ({
success: true,
data: { excludeKeywords: { warehouseName: ['自动发货仓', '新词'], logisticCompanyName: ['物流'] } },
}),
} as Response
}
return { ok: true, json: async () => ({ success: true, data: dashboard }) } as Response
}) as typeof fetch
const requestSubmit = jest.spyOn(HTMLFormElement.prototype, 'requestSubmit').mockImplementation(() => {})
try {
render(
<LogisticsArrivalSigningDurationView
initialData={initialData}
defaultExcludeKeywords={defaultExcludeKeywords}
canManageExcludeKeywords
canDownload
/>,
)
fireEvent.click(screen.getByTitle(/点击勾选要排除的关键词/))
fireEvent.change(await screen.findByLabelText('新增关键词所属字段'), { target: { value: 'warehouseName' } })
const customInput = await screen.findByPlaceholderText(/输入新的排除关键词/)
fireEvent.change(customInput, { target: { value: '新词' } })
fireEvent.keyDown(customInput, { key: 'Enter' })
expect(await screen.findByText('已保存到「发货仓」默认排除关键词:新词')).toBeInTheDocument()
expect(settingsBodies).toEqual([{ excludeKeywords: { warehouseName: ['自动发货仓', '新词'], logisticCompanyName: ['物流'] } }])
expect(screen.getByLabelText('新词')).toBeChecked()
} finally {
requestSubmit.mockRestore()
global.fetch = originalFetch
}
})

View File

@ -0,0 +1,371 @@
'use client'
import { ChevronDown, Eraser, ListFilter, RotateCcw, Search, SlidersHorizontal, X } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
formatLogisticsDateTime,
countExcludeKeywords,
LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_FIELDS,
LOGISTICS_ARRIVAL_SIGNING_DURATION_MAX_EXCLUDE_KEYWORDS,
LOGISTICS_ARRIVAL_SIGNING_DURATION_PAGE_SIZE_OPTIONS,
mergeExcludeKeywords,
parseExcludeKeywords,
serializeExcludeKeywords,
splitSearchKeywords,
type LogisticsArrivalSigningDurationDrilldownFilter,
type LogisticsArrivalSigningDurationDrilldownKey,
type LogisticsArrivalSigningDurationExcludeFieldKey,
type LogisticsArrivalSigningDurationExcludeKeywords,
type LogisticsArrivalSigningDurationPage,
} from '@/lib/logistics-arrival-signing-duration'
import { LogisticsArrivalSigningDurationDashboard } from './LogisticsArrivalSigningDurationDashboard'
import { ExcludeKeywordPicker } from './ExcludeKeywordPicker'
function href(page: number, filters: LogisticsArrivalSigningDurationPage['filters']) {
const params = new URLSearchParams({
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
page: String(page),
pageSize: String(filters.pageSize),
})
if (filters.keyword) params.set('keyword', filters.keyword)
const excludeKeywords = serializeExcludeKeywords(filters.excludeKeywords)
if (excludeKeywords) params.set('excludeKeywords', excludeKeywords)
if (filters.excludeKeywordsMode === 'clear') params.set('excludeKeywordsMode', 'clear')
return `?${params.toString()}`
}
async function readJsonResponse<T>(response: Response) {
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || '请求失败,请稍后重试')
}
return payload.data as T
}
export function LogisticsArrivalSigningDurationView({
initialData,
defaultExcludeKeywords,
canManageExcludeKeywords,
canDownload,
}: {
initialData: LogisticsArrivalSigningDurationPage
defaultExcludeKeywords: LogisticsArrivalSigningDurationExcludeKeywords
canManageExcludeKeywords: boolean
canDownload: boolean
}) {
const { filters } = initialData
const router = useRouter()
// 翻页是软导航(不整页刷新),并且记住翻页前的滚动位置:
// 否则浏览器会重建滚动容器,用户会被弹回看板最顶端。
const scrollRef = useRef<HTMLDivElement>(null)
const pendingScrollTop = useRef<number | null>(null)
const navigateKeepingScroll = (url: string) => {
pendingScrollTop.current = scrollRef.current?.scrollTop ?? null
router.push(url, { scroll: false })
}
useEffect(() => {
const container = scrollRef.current
if (!container || pendingScrollTop.current === null) return
container.scrollTop = pendingScrollTop.current
pendingScrollTop.current = null
}, [initialData.page, initialData.pageSize])
const [excludeInput, setExcludeInput] = useState(serializeExcludeKeywords(filters.excludeKeywords))
const [keywordInput, setKeywordInput] = useState(filters.keyword)
const [dateFromInput, setDateFromInput] = useState(filters.dateFrom)
const [dateToInput, setDateToInput] = useState(filters.dateTo)
const [resetHint, setResetHint] = useState(false)
const keywordCount = splitSearchKeywords(keywordInput).length
/** 重置筛选输入:清空签收日期与关键词(排除关键词有自己的清空/恢复默认),填好后点查询生效。 */
const resetFilterInputs = () => {
setDateFromInput('')
setDateToInput('')
setKeywordInput('')
setResetHint(true)
}
const [excludeKeywordsMode, setExcludeKeywordsMode] = useState<'default' | 'clear'>(filters.excludeKeywordsMode ?? 'default')
// 系统管理员维护的默认排除关键词(服务端 system config),也是选择器的候选池。
const [defaultKeywords, setDefaultKeywords] = useState(defaultExcludeKeywords)
const [excludePickerOpen, setExcludePickerOpen] = useState(false)
const [pendingFilterSubmit, setPendingFilterSubmit] = useState(false)
const filterFormRef = useRef<HTMLFormElement>(null)
const [exporting, setExporting] = useState(false)
const [exportMessage, setExportMessage] = useState<string | null>(null)
// 选择器按字段分组:候选词 = 该字段的默认排除词 ∪ 当前已排除的词,旧链接里带的自定义词不会丢。
const excludePreview = useMemo(() => parseExcludeKeywords(excludeInput, defaultKeywords), [excludeInput, defaultKeywords])
const excludeCandidates = useMemo(
() => mergeExcludeKeywords(defaultKeywords, excludePreview),
[defaultKeywords, excludePreview],
)
/** 筛选栏摘要:按字段显示各项数量,替代之前会截断的一长串关键词。 */
const excludeSummary = useMemo(() => {
const parts = LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_FIELDS
.map((field) => [field.label, excludePreview[field.key]?.length ?? 0] as const)
.filter(([, count]) => count > 0)
.map(([label, count]) => `${label} ${count}`)
return parts.join(' · ')
}, [excludePreview])
// 确定后立刻按新条件查询:等受控值落到 DOM 再提交,避免提交到旧值。
useEffect(() => {
if (!pendingFilterSubmit) return
setPendingFilterSubmit(false)
const form = filterFormRef.current
if (!form) return
// requestSubmit 会走原生提交校验与 submit 事件;老浏览器退回 form.submit()。
if (typeof form.requestSubmit === 'function') form.requestSubmit()
else form.submit()
}, [pendingFilterSubmit])
const applyExcludeKeywords = (keywords: LogisticsArrivalSigningDurationExcludeKeywords) => {
setExcludePickerOpen(false)
const serialized = serializeExcludeKeywords(keywords)
setExcludeInput(serialized)
// 一个都不勾 = 不排除任何关键词,必须显式告诉服务端,否则会回落到默认排除项。
setExcludeKeywordsMode(serialized ? 'default' : 'clear')
setPendingFilterSubmit(true)
}
// 服务端下发的筛选条件变化时(点菜单重进、重置后查询、浏览器前进/后退)同步输入框,
// 否则会出现"数据已是默认区间、输入框还停在上次条件"的错觉。
const filtersKey = [
filters.dateFrom,
filters.dateTo,
filters.keyword,
serializeExcludeKeywords(filters.excludeKeywords),
filters.excludeKeywordsMode ?? 'default',
filters.pageSize,
].join('|')
const [syncedFiltersKey, setSyncedFiltersKey] = useState(filtersKey)
if (syncedFiltersKey !== filtersKey) {
setSyncedFiltersKey(filtersKey)
setDateFromInput(filters.dateFrom)
setDateToInput(filters.dateTo)
setKeywordInput(filters.keyword)
setExcludeInput(serializeExcludeKeywords(filters.excludeKeywords))
setExcludeKeywordsMode(filters.excludeKeywordsMode ?? 'default')
setResetHint(false)
}
/** 仅系统管理员:把新关键词并入默认排除关键词并落库(服务端同样校验管理员身份)。 */
const addDefaultExcludeKeywords = async (field: LogisticsArrivalSigningDurationExcludeFieldKey, additions: string[]) => {
const merged: LogisticsArrivalSigningDurationExcludeKeywords = {
...defaultKeywords,
[field]: Array.from(new Set([...(defaultKeywords[field] ?? []), ...additions])),
}
if (countExcludeKeywords(merged) > LOGISTICS_ARRIVAL_SIGNING_DURATION_MAX_EXCLUDE_KEYWORDS) {
throw new Error(`默认排除关键词最多 ${LOGISTICS_ARRIVAL_SIGNING_DURATION_MAX_EXCLUDE_KEYWORDS} 个,请先删减后再添加`)
}
const response = await fetch('/api/supply-chain/management/logistics-arrival-signing-duration/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ excludeKeywords: merged }),
})
const data = await readJsonResponse<{ excludeKeywords: LogisticsArrivalSigningDurationExcludeKeywords }>(response)
setDefaultKeywords(data.excludeKeywords)
}
const exportRows = async (target?: {
keys?: LogisticsArrivalSigningDurationDrilldownKey[]
drilldown?: LogisticsArrivalSigningDurationDrilldownFilter[]
}) => {
setExporting(true)
setExportMessage(null)
try {
const response = await fetch('/api/supply-chain/management/logistics-arrival-signing-duration/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
keyword: filters.keyword,
excludeKeywords: filters.excludeKeywords,
...(target?.drilldown ? { drilldown: target.drilldown } : {}),
...(target?.keys ? { drilldownKeys: target.keys } : {}),
}),
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.error?.message || '导出失败,请稍后重试')
}
const blob = await response.blob()
const disposition = response.headers.get('Content-Disposition') || ''
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
const fileName = encoded ? decodeURIComponent(encoded) : '物流签收明细.xlsx'
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
setExportMessage(response.headers.get('X-Export-Truncated') === 'true'
? '导出完成,数据量超过上限,仅导出前 50000 条'
: target ? '下钻明细导出完成' : '导出完成')
} catch (cause) {
setExportMessage(cause instanceof Error ? cause.message : '导出失败,请稍后重试')
} finally {
setExporting(false)
}
}
return (
<main className="sc-logistics-page">
<div className="sc-logistics-scroll-viewport" ref={scrollRef}>
<div className="sc-logistics-scroll-content">
<header className="sc-logistics-header">
<div>
<p className="sc-logistics-eyebrow">经营管理 / 物流时效</p>
<h1>物流到货签收时长看板</h1>
<p>物流签收明细 · 物流状态:已签收 · 拦截状态:不等于已拦截</p>
</div>
<div className="sc-logistics-total"><strong>{initialData.totalKnown ? initialData.total.toLocaleString('zh-CN') : `${initialData.rows.length}`}</strong><span>{initialData.totalKnown ? '条明细' : '条已加载'}</span>{initialData.source === 'snapshot'
? <small>快照数据 · {initialData.snapshotDate ?? '-'}</small>
: initialData.dataAsOf
? <small className={initialData.stale ? 'is-stale' : ''}>{initialData.stale ? '缓存数据' : '数据更新'} · {formatLogisticsDateTime(initialData.dataAsOf)}</small>
: null}</div>
</header>
<form className="sc-logistics-filters" method="get" ref={filterFormRef}>
<label><span>签收日期</span><input type="date" name="dateFrom" value={dateFromInput} onChange={(event) => { setDateFromInput(event.target.value); setResetHint(false) }} /></label>
<span className="sc-logistics-range-separator">至</span>
<label><span className="sc-logistics-visually-hidden">签收日期结束</span><input type="date" name="dateTo" value={dateToInput} onChange={(event) => { setDateToInput(event.target.value); setResetHint(false) }} /></label>
<label className="sc-logistics-search sc-logistics-search-batch">
{/* 高度与其他筛选控件保持一致:单行高度、多行时框内滚动;已识别数量放在标签行,避免把这一格撑高。 */}
<span className="sc-logistics-search-label">
关键词(支持批量)
{keywordCount > 1 ? <em>已识别 {keywordCount} 个,命中任意一个即显示</em> : null}
</span>
<div title="订单号 / 物流单号 / 店铺 / 条码 / 采购 / 供应商;支持批量:逗号或换行分隔,命中任意一个即显示">
<Search size={15} />
<textarea
name="keyword"
rows={1}
value={keywordInput}
onChange={(event) => { setKeywordInput(event.target.value); setResetHint(false) }}
placeholder="订单号 / 物流单号 / 店铺 / 条码 / 采购 / 供应商,支持批量(逗号或换行分隔)"
/>
</div>
</label>
<div className="sc-logistics-exclude">
<span>排除关键词</span>
<button
type="button"
className="sc-logistics-exclude-trigger"
title="点击勾选要排除的关键词(默认全选,取消勾选即不排除该项)"
onClick={() => setExcludePickerOpen(true)}
>
<ListFilter size={15} />
<span className="sc-logistics-exclude-summary">
{excludeSummary ? `已排除 ${countExcludeKeywords(excludePreview)} 项:${excludeSummary}` : '未排除任何关键词'}
</span>
<ChevronDown size={15} />
</button>
<input type="hidden" name="excludeKeywords" value={excludeInput} />
</div>
<input type="hidden" name="excludeKeywordsMode" value={excludeKeywordsMode} />
<input type="hidden" name="page" value="1" />
<input type="hidden" name="pageSize" value={filters.pageSize} />
<div className="sc-logistics-filter-actions">
<button type="button" className="sc-logistics-light-button" title="清空签收日期与关键词输入,填好后点查询生效" onClick={resetFilterInputs}><Eraser size={15} />重置</button>
<button type="button" className="sc-logistics-light-button" onClick={() => { setExcludeInput(''); setExcludeKeywordsMode('clear') }}><X size={15} />清空排除</button>
<button type="button" className="sc-logistics-light-button" onClick={() => { setExcludeInput(serializeExcludeKeywords(defaultKeywords)); setExcludeKeywordsMode('default') }}><RotateCcw size={15} />恢复默认</button>
<button type="submit"><SlidersHorizontal size={15} />查询</button>
</div>
{resetHint ? <span className="sc-logistics-inline-message sc-logistics-reset-hint" role="status">已清空签收日期与关键词,填好后点查询</span> : null}
</form>
{excludePickerOpen ? (
<ExcludeKeywordPicker
candidates={excludeCandidates}
selected={excludePreview}
canAddKeywords={canManageExcludeKeywords}
onCancel={() => setExcludePickerOpen(false)}
onConfirm={applyExcludeKeywords}
onAddKeywords={canManageExcludeKeywords ? addDefaultExcludeKeywords : undefined}
/>
) : null}
{exportMessage ? <div className="sc-logistics-inline-message sc-logistics-export-message" role="status">{exportMessage}</div> : null}
{initialData.warning ? <div className="sc-logistics-warning" role="status">{initialData.warning}</div> : null}
<LogisticsArrivalSigningDurationDashboard
filters={filters}
canDownload={canDownload}
orderRows={initialData.rows}
orderTotal={initialData.total}
orderTotalKnown={initialData.totalKnown}
orderPage={initialData.page}
orderPageSize={initialData.pageSize}
orderTotalPages={initialData.totalPages}
orderPageSizeOptions={LOGISTICS_ARRIVAL_SIGNING_DURATION_PAGE_SIZE_OPTIONS}
onOrderPageChange={(page) => navigateKeepingScroll(href(page, filters))}
onOrderPageSizeChange={(pageSize) => navigateKeepingScroll(href(1, { ...filters, pageSize }))}
exporting={exporting}
onExportDetails={canDownload ? (target) => void exportRows(target) : undefined}
/>
<div className="sc-logistics-meta">
<span>数据源:rpt.jst_order_express_details</span>
<span>条码关联:物流单号、内部单号 → 出库商品编码</span>
<span>供应商 / 采购:SupplyChainProductArchive</span>
</div>
</div>
</div>
<style jsx>{`
.sc-logistics-page { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; color: var(--sc-ink); background: var(--sc-bg); }
.sc-logistics-scroll-viewport { width: 100%; height: 100%; min-height: 0; overflow: auto; scrollbar-color: #8f969b var(--sc-surface-strong); scrollbar-width: auto; }
.sc-logistics-scroll-viewport::-webkit-scrollbar { width: 12px; height: 12px; }
.sc-logistics-scroll-viewport::-webkit-scrollbar-track { background: var(--sc-surface-strong); }
.sc-logistics-scroll-viewport::-webkit-scrollbar-thumb { border: 3px solid var(--sc-surface-strong); border-radius: 8px; background: #8f969b; }
.sc-logistics-scroll-content { box-sizing: border-box; width: 100%; min-width: 100%; min-height: 100%; padding: 24px; }
.sc-logistics-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; margin-bottom: 18px; }
.sc-logistics-eyebrow { margin: 0 0 6px; color: var(--sc-subtle); font-size: 12px; }
h1 { margin: 0; font-size: 22px; line-height: 1.25; letter-spacing: 0; }
.sc-logistics-header p:last-child { margin: 7px 0 0; color: var(--sc-subtle); font-size: 13px; }
.sc-logistics-total { display: flex; align-items: baseline; gap: 6px; color: var(--sc-subtle); white-space: nowrap; }
.sc-logistics-total strong { color: var(--sc-ink); font-size: 23px; }
.sc-logistics-total span { font-size: 12px; }
.sc-logistics-total small { display: block; color: var(--sc-subtle); font-size: 10px; }
.sc-logistics-total small.is-stale { color: #8a5a11; }
.sc-logistics-filters { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 10px; padding: 14px; background: var(--sc-surface); border: 1px solid var(--sc-border); border-radius: 8px; }
label { display: flex; flex-direction: column; gap: 6px; color: var(--sc-subtle); font-size: 12px; }
label input, select { height: 34px; padding: 0 9px; border: 1px solid var(--sc-border); border-radius: 5px; background: var(--sc-surface); color: var(--sc-ink); font: inherit; }
.sc-logistics-search { min-width: 210px; flex: 1; }
.sc-logistics-search div { display: flex; align-items: center; gap: 7px; height: 34px; padding: 0 9px; border: 1px solid var(--sc-border); border-radius: 5px; background: var(--sc-surface); }
.sc-logistics-search input { flex: 1; min-width: 0; height: 30px; border: 0; padding: 0; outline: 0; }
.sc-logistics-search-batch div { align-items: center; }
.sc-logistics-search-batch textarea { flex: 1; min-width: 0; height: 30px; max-height: 30px; padding: 0; overflow-y: auto; border: 0; outline: 0; resize: none; background: transparent; color: var(--sc-ink); font: inherit; line-height: 30px; }
.sc-logistics-search-label { display: flex; align-items: baseline; gap: 8px; min-width: 0; white-space: nowrap; }
.sc-logistics-search-label em { flex: 1; min-width: 0; overflow: hidden; color: var(--sc-subtle); font-size: 11px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.sc-logistics-exclude { display: flex; flex-direction: column; gap: 6px; min-width: 235px; flex: 1.2; }
.sc-logistics-exclude > span { color: var(--sc-subtle); font-size: 12px; }
.sc-logistics-filters .sc-logistics-exclude-trigger { display: flex; align-items: center; gap: 8px; width: 100%; height: 34px; padding: 0 9px; border: 1px solid var(--sc-border); border-radius: 5px; background: var(--sc-surface); color: var(--sc-ink); font: inherit; cursor: pointer; }
.sc-logistics-filters .sc-logistics-exclude-trigger:hover { border-color: #8ba79a; }
.sc-logistics-filters .sc-logistics-exclude-trigger:focus-visible { outline: 2px solid #3f8563; outline-offset: 1px; }
.sc-logistics-exclude-trigger > svg { flex: none; color: var(--sc-subtle); }
.sc-logistics-exclude-summary { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-align: left; text-overflow: ellipsis; }
.sc-logistics-range-separator { padding-bottom: 9px; color: var(--sc-subtle); }
.sc-logistics-filter-actions { display: flex; align-items: center; gap: 8px; }
.sc-logistics-filters button { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px; border: 0; border-radius: 5px; background: #27272a; color: #fff; cursor: pointer; font: inherit; }
.sc-logistics-filters button:disabled { cursor: wait; opacity: .55; }
.sc-logistics-light-button { border: 1px solid var(--sc-border) !important; background: var(--sc-surface) !important; color: var(--sc-ink) !important; }
.sc-logistics-inline-message { display: inline-block; margin-top: 9px; color: var(--sc-subtle); font-size: 12px; }
.sc-logistics-export-message { margin-bottom: 2px; }
.sc-logistics-reset-hint { width: 100%; margin-top: 2px; }
.sc-logistics-meta { display: flex; flex-wrap: wrap; gap: 16px; padding: 13px 2px; color: var(--sc-subtle); font-size: 12px; }
.sc-logistics-warning { margin: 0 0 12px; padding: 10px 12px; border: 1px solid #e5b85c; border-radius: 6px; background: #fff8e6; color: #7a5311; font-size: 13px; }
.sc-logistics-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding-top: 12px; color: var(--sc-subtle); font-size: 12px; }
.sc-logistics-footer select { height: 30px; }
.sc-logistics-footer a { padding: 7px 10px; border: 1px solid var(--sc-border); border-radius: 5px; color: var(--sc-ink); text-decoration: none; }
.sc-logistics-footer a.disabled { pointer-events: none; color: var(--sc-subtle); opacity: .5; }
.sc-logistics-visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
@media (max-width: 900px) { .sc-logistics-scroll-content { min-width: 1120px; padding: 16px; } .sc-logistics-header { align-items: flex-start; flex-direction: column; } .sc-logistics-filters { align-items: stretch; } .sc-logistics-range-separator { display: none; } label, .sc-logistics-search, .sc-logistics-exclude { min-width: 100%; } .sc-logistics-filter-actions { flex-wrap: wrap; } .sc-logistics-filter-actions button { flex: 1; justify-content: center; } }
`}</style>
</main>
)
}

View File

@ -0,0 +1,459 @@
'use client'
import { Download, GripHorizontal, RotateCcw } from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react'
import {
LOGISTICS_ARRIVAL_SIGNING_DURATION_CASCADE_LEVELS,
payToSignProductItems,
type LogisticsArrivalSigningDurationCascadeEntry,
type LogisticsArrivalSigningDurationCascadeLevel,
type LogisticsArrivalSigningDurationRow,
type LogisticsArrivalSigningDurationDrilldownFilter,
type ObjectAnalysis,
type ObjectAnalysisGroup,
} from '@/lib/logistics-arrival-signing-duration'
import { BoardPagination } from './BoardPagination'
export type ObjectAnalysisSize = 'compact' | 'standard' | 'expanded'
export type ObjectAnalysisMetric<M> = {
label: string
value: (metrics: M) => string
}
export function formatAnalysisCount(value: number) {
return value.toLocaleString('zh-CN')
}
export function formatAnalysisHours(value: number | null) {
return value === null ? '-' : value.toFixed(1)
}
export function formatAnalysisPercent(value: number | null) {
return value === null ? '-' : `${value.toFixed(1)}%`
}
type SimpleDimension = 'logisticCompanies' | 'warehouses' | 'platforms' | 'shops'
type Focus = { dimension: SimpleDimension | 'date'; value: string } | null
type Selection = { purchaser: string | null; supplier: string | null; barcode: string | null }
type DrilldownSelection = {
label: string
rows: LogisticsArrivalSigningDurationRow[]
/** 整区间口径下钻用:按维度条件在快照里过滤全量订单。 */
filter?: LogisticsArrivalSigningDurationDrilldownFilter
} | null
const SIMPLE_DIMENSION_FIELD: Record<SimpleDimension, 'logisticCompany' | 'warehouse' | 'platform' | 'shop'> = {
logisticCompanies: 'logisticCompany',
warehouses: 'warehouse',
platforms: 'platform',
shops: 'shop',
}
const EMPTY_SELECTION: Selection = { purchaser: null, supplier: null, barcode: null }
/**
* 看板顺序按用户要求的联动链路排列:
* 签收日期维度 → 平台维度 → 店铺维度 → 发货仓维度 → 物流维度 → 采购维度 → 供应商维度 → 商品维度 → 订单明细。
*/
const SIMPLE_SECTIONS: Array<{ key: SimpleDimension; title: string; column: string }> = [
{ key: 'platforms', title: '平台维度', column: '平台' },
{ key: 'shops', title: '店铺维度', column: '店铺' },
{ key: 'warehouses', title: '发货仓维度', column: '发货仓' },
{ key: 'logisticCompanies', title: '物流维度', column: '物流公司' },
]
function matchingItems(row: LogisticsArrivalSigningDurationRow) {
return payToSignProductItems(row).map((item) => ({
barcode: item.barcode.trim() || '未匹配条码',
supplier: item.supplier.trim() || '未填写',
purchaser: item.purchaser.trim() || '未填写',
}))
}
function matchesSelection(row: LogisticsArrivalSigningDurationRow, selection: Selection) {
return matchingItems(row).some((item) =>
(!selection.purchaser || item.purchaser === selection.purchaser)
&& (!selection.supplier || item.supplier === selection.supplier)
&& (!selection.barcode || item.barcode === selection.barcode),
)
}
function matchesFocus(row: LogisticsArrivalSigningDurationRow, focus: NonNullable<Focus>) {
if (focus.dimension === 'date') return row.signTime?.slice(0, 10) === focus.value
const field = {
logisticCompanies: row.logisticCompanyName,
warehouses: row.warehouseName,
platforms: row.platformName,
shops: row.shopName,
}[focus.dimension]
return (field.trim() || '未填写') === focus.value
}
const MIN_TABLE_HEIGHT = 144
const MAX_TABLE_HEIGHT = 680
const TABLE_HEIGHT: Record<ObjectAnalysisSize, number> = { compact: 176, standard: 232, expanded: 304 }
function ResizableTableViewport({ children, label, defaultHeight }: { children: ReactNode; label: string; defaultHeight: number }) {
const [height, setHeight] = useState(defaultHeight)
const startRef = useRef<{ y: number; height: number } | null>(null)
const viewportId = useId()
const clampHeight = (value: number) => Math.max(MIN_TABLE_HEIGHT, Math.min(MAX_TABLE_HEIGHT, value))
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
startRef.current = { y: event.clientY, height }
}
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!startRef.current) return
setHeight(clampHeight(startRef.current.height + event.clientY - startRef.current.y))
}
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
startRef.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
}
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
event.preventDefault()
setHeight((current) => clampHeight(current + (event.key === 'ArrowUp' ? -24 : 24)))
}
return <div className="sc-analysis-resizable">
<div id={viewportId} className="sc-analysis-table-viewport" style={{ height }}>{children}</div>
<div role="separator" aria-label={`调整${label}高度`} aria-controls={viewportId} aria-orientation="horizontal" aria-valuemin={MIN_TABLE_HEIGHT} aria-valuemax={MAX_TABLE_HEIGHT} aria-valuenow={height} tabIndex={0} title={`上下拖动调整${label}高度`} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerEnd} onPointerCancel={handlePointerEnd} onKeyDown={handleKeyDown} className="sc-analysis-resize-handle"><GripHorizontal size={15} aria-hidden="true" /></div>
<style jsx>{`
.sc-analysis-resizable { min-width: 0; }
.sc-analysis-table-viewport { width: 100%; min-width: 0; overflow: scroll; overscroll-behavior: contain; scrollbar-gutter: stable; scrollbar-color: #aab5b0 #f2f4f3; scrollbar-width: thin; }
.sc-analysis-table-viewport::-webkit-scrollbar { width: 10px; height: 10px; }
.sc-analysis-table-viewport::-webkit-scrollbar-track { background: #f2f4f3; }
.sc-analysis-table-viewport::-webkit-scrollbar-thumb { border: 2px solid #f2f4f3; border-radius: 5px; background: #aab5b0; }
.sc-analysis-resize-handle { display: flex; height: 16px; align-items: center; justify-content: center; border-top: 1px solid #e5e7eb; background: #f7f8f8; color: #64716d; cursor: row-resize; touch-action: none; user-select: none; }
.sc-analysis-resize-handle:hover, .sc-analysis-resize-handle:focus-visible { background: #e9eef1; color: #111; outline: 2px solid #738895; outline-offset: -2px; }
`}</style>
</div>
}
function GroupTable<M>({
title,
index,
columns,
rows,
metrics,
sortLabel,
selectedKey,
onSelect,
size,
onExport,
}: {
title: string
index: string
columns: Array<{
label: string
value: (row: ObjectAnalysisGroup<M>) => string
/** 非末列的可点击行为(例如店铺表的平台列用于联动筛选)。 */
onSelect?: (row: ObjectAnalysisGroup<M>) => void
active?: (row: ObjectAnalysisGroup<M>) => boolean
}>
rows: Array<ObjectAnalysisGroup<M>>
metrics: Array<ObjectAnalysisMetric<M>>
sortLabel: string
selectedKey?: string | null
onSelect: (row: ObjectAnalysisGroup<M>) => void
size: ObjectAnalysisSize
onExport?: () => void
}) {
// 行集合变化(切换采购/供应商等)时把页码退回第 1 页。
const rowsKey = `${rows.length}|${rows[0]?.key ?? ''}`
const [paging, setPaging] = useState({ page: 1, pageSize: 20, rowsKey })
if (paging.rowsKey !== rowsKey) setPaging({ page: 1, pageSize: paging.pageSize, rowsKey })
const totalPages = Math.max(1, Math.ceil(rows.length / paging.pageSize))
const page = Math.min(Math.max(1, paging.page), totalPages)
const shown = rows.slice((page - 1) * paging.pageSize, page * paging.pageSize)
return <section className={`sc-analysis-section sc-size-${size}`}>
<div className="sc-analysis-section-heading"><span>{index}</span><h4>{title}</h4><small>{formatAnalysisCount(rows.length)} 个对象 · {sortLabel}</small>{onExport ? <button type="button" className="sc-analysis-export" title={`导出${title}`} aria-label={`导出${title}`} onClick={onExport}><Download size={14} /></button> : null}</div>
<ResizableTableViewport key={size} label={title} defaultHeight={TABLE_HEIGHT[size]}>
<table className="sc-analysis-table">
<thead><tr>{columns.map((column) => <th key={column.label} className="sc-analysis-dimension-cell">{column.label}</th>)}{metrics.map((metric) => <th key={metric.label}>{metric.label}</th>)}</tr></thead>
<tbody>{shown.map((row) => <tr key={row.key} className={selectedKey === row.key ? 'is-selected' : ''}>
{columns.map((column, index) => {
const isPrimary = index === columns.length - 1
const handler = isPrimary ? onSelect : column.onSelect
const pressed = isPrimary ? selectedKey === row.key : Boolean(column.active?.(row))
return <td key={column.label} className="sc-analysis-dimension-cell">{handler
? <button type="button" className="sc-analysis-row-action" aria-pressed={pressed} onClick={() => handler(row)} title={column.value(row)}>{column.value(row)}</button>
: column.value(row)}</td>
})}
{metrics.map((metric) => <td key={metric.label}>{metric.value(row)}</td>)}
</tr>)}</tbody>
</table>
{!shown.length ? <div className="sc-analysis-empty">当前条件下暂无数据</div> : null}
</ResizableTableViewport>
<BoardPagination
total={rows.length}
page={page}
pageSize={paging.pageSize}
unit="个对象"
onPageChange={(next) => setPaging((current) => ({ ...current, page: next }))}
onPageSizeChange={(next) => setPaging((current) => ({ ...current, pageSize: next }))}
/>
<style jsx>{`
.sc-analysis-section { min-width: 0; padding: 13px 14px 12px; border-top: 1px solid var(--sc-border); background: #fff; }
.sc-analysis-section.sc-size-compact { padding: 9px 10px; }
.sc-analysis-section.sc-size-expanded { padding: 18px 20px; }
.sc-analysis-section-heading { display: flex; min-height: 27px; align-items: center; gap: 8px; margin-bottom: 8px; }
.sc-analysis-section-heading span { color: #111; font-size: 11px; font-weight: 400; }
.sc-analysis-section-heading h4 { min-width: 0; margin: 0; overflow: hidden; color: #111; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.sc-analysis-section-heading small { min-width: 0; overflow: hidden; color: #111; font-size: 11px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
.sc-analysis-export { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 26px; height: 26px; margin-left: auto; border: 1px solid var(--sc-border); border-radius: 4px; background: #fff; color: #111; cursor: pointer; }
.sc-analysis-table { width: 100%; min-width: 810px; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.sc-analysis-table th, .sc-analysis-table td { padding: 7px 9px; border-bottom: 1px solid #e9eceb; text-align: right; white-space: nowrap; }
.sc-size-compact .sc-analysis-table th, .sc-size-compact .sc-analysis-table td { padding: 6px 8px; }
.sc-size-expanded .sc-analysis-table th, .sc-size-expanded .sc-analysis-table td { padding: 13px 15px; }
.sc-analysis-table th { position: sticky; top: 0; z-index: 1; background: #f3f5f6; color: #111; font-weight: 600; }
.sc-analysis-table th:first-child, .sc-analysis-table td:first-child { text-align: left; }
.sc-analysis-table .sc-analysis-dimension-cell { text-align: left; }
.sc-analysis-table td { color: #111; font-weight: 400; }
.sc-analysis-table tr:hover td, .sc-analysis-table tr.is-selected td { background: #f0f3fb; }
.sc-analysis-row-action { max-width: 220px; padding: 0; overflow: hidden; border: 0; background: transparent; color: #111; text-overflow: ellipsis; white-space: nowrap; text-align: left; cursor: pointer; font: inherit; font-weight: 400; }
.sc-analysis-row-action:hover { text-decoration: underline; }
.sc-analysis-empty { padding: 22px 10px; color: #111; text-align: center; font-size: 12px; }
`}</style>
</section>
}
export function ObjectAnalysisBoard<M>({
analysis,
rows,
metrics,
groupMetrics = metrics,
size = 'standard',
dailyTitle = '签收日期维度',
sortLabel,
onExportDaily,
onExportGroup,
onDrilldownChange,
cascade,
onCascadeChange,
}: {
analysis: ObjectAnalysis<M>
rows: LogisticsArrivalSigningDurationRow[]
/** 01 签收日期维度的指标行(指标 × 日期矩阵)。 */
metrics: Array<ObjectAnalysisMetric<M>>
/** 02–08 经营对象表的指标列;默认与 metrics 相同,可额外带上目标与目标达成率。 */
groupMetrics?: Array<ObjectAnalysisMetric<M>>
size?: ObjectAnalysisSize
dailyTitle?: string
sortLabel: string
onExportDaily?: () => void
onExportGroup?: (title: string, rows: Array<ObjectAnalysisGroup<M>>) => void
onDrilldownChange?: (selection: DrilldownSelection) => void
/** 联动链路:每次选中上级对象都会带着"它以及更上一级"的条件重新取数。 */
cascade?: LogisticsArrivalSigningDurationCascadeEntry[]
onCascadeChange?: (cascade: LogisticsArrivalSigningDurationCascadeEntry[]) => void
}) {
const [selection, setSelection] = useState<Selection>(EMPTY_SELECTION)
const [focus, setFocus] = useState<Focus>(null)
const selectedSupplierKey = selection.supplier
? JSON.stringify([selection.purchaser, selection.supplier])
: null
const selectedProductKey = selection.barcode
? JSON.stringify([selection.purchaser, selection.supplier, selection.barcode])
: null
const suppliers = selection.purchaser
? analysis.suppliers.filter((row) => row.purchaser === selection.purchaser)
: analysis.suppliers
const products = analysis.products.filter((row) =>
(!selection.purchaser || row.purchaser === selection.purchaser)
&& (!selection.supplier || row.supplier === selection.supplier),
)
const detailRows = useMemo(() => {
if (focus) return rows.filter((row) => matchesFocus(row, focus))
if (selection.purchaser) return rows.filter((row) => matchesSelection(row, selection))
return []
}, [focus, rows, selection])
const focusLabel = focus?.dimension === 'date' ? `签收日期 / ${focus.value}` : focus?.value
const selectionLabel = [selection.purchaser, selection.supplier, selection.barcode].filter(Boolean).join(' / ')
const clearSelection = () => {
setSelection(EMPTY_SELECTION)
setFocus(null)
setShopFilter((current) => ({ ...current, platform: null }))
onCascadeChange?.([])
}
// 签收日期维度按列翻页:区间较长时日期列会很多,一次只展示一页。
const datesKey = `${analysis.daily.length}|${analysis.daily[0]?.date ?? ''}|${analysis.daily[analysis.daily.length - 1]?.date ?? ''}`
const [datePaging, setDatePaging] = useState({ page: 1, pageSize: 10, datesKey })
if (datePaging.datesKey !== datesKey) setDatePaging({ page: 1, pageSize: datePaging.pageSize, datesKey })
const datePages = Math.max(1, Math.ceil(analysis.daily.length / datePaging.pageSize))
const datePage = Math.min(Math.max(1, datePaging.page), datePages)
const visibleDaily = analysis.daily.slice((datePage - 1) * datePaging.pageSize, datePage * datePaging.pageSize)
// 店铺维度:点击平台列只看该平台店铺,再次点击取消;数据刷新后重置。
const shopsKey = `${analysis.shops.length}|${analysis.shops[0]?.key ?? ''}`
const [shopFilter, setShopFilter] = useState({ platform: null as string | null, shopsKey })
if (shopFilter.shopsKey !== shopsKey) setShopFilter({ platform: null, shopsKey })
const shopPlatform = shopFilter.platform
const setShopPlatform = (update: (current: string | null) => string | null) =>
setShopFilter((current) => ({ ...current, platform: update(current.platform) }))
// 平台维度(07)里点任意平台,同样联动把店铺维度筛成该平台的店铺。
const platformFocus = focus?.dimension === 'platforms' ? focus.value : null
const activeShopPlatform = shopPlatform ?? platformFocus
const focusedShopKey = focus?.dimension === 'shops'
? analysis.shops.find((row) => row.label === focus.value)?.key ?? null
: null
const cascadePath = cascade ?? []
const cascadeKey = JSON.stringify(cascadePath.map((entry) => [entry.level, entry.label]))
const [syncedCascadeKey, setSyncedCascadeKey] = useState(cascadeKey)
if (syncedCascadeKey !== cascadeKey) {
// 上级重置(清空链路)时,本看板的本地高亮同步清掉。
setSyncedCascadeKey(cascadeKey)
if (!cascadePath.length) {
setSelection(EMPTY_SELECTION)
setFocus(null)
setShopFilter((current) => ({ ...current, platform: null }))
}
}
/** 选中某级对象:保留更上级条件、丢弃更下级条件后再请求数据。 */
const pickCascade = (
level: LogisticsArrivalSigningDurationCascadeLevel,
filter: LogisticsArrivalSigningDurationDrilldownFilter,
label: string,
) => {
const index = LOGISTICS_ARRIVAL_SIGNING_DURATION_CASCADE_LEVELS.indexOf(level)
const current = cascadePath.find((entry) => entry.level === level)
// 再次点击同一对象 = 取消该级及以下。
if (current && JSON.stringify(current.filter) === JSON.stringify(filter)) {
onCascadeChange?.(cascadePath.filter((entry) =>
LOGISTICS_ARRIVAL_SIGNING_DURATION_CASCADE_LEVELS.indexOf(entry.level) < index))
return
}
const kept = cascadePath.filter((entry) =>
LOGISTICS_ARRIVAL_SIGNING_DURATION_CASCADE_LEVELS.indexOf(entry.level) < index)
onCascadeChange?.([...kept, { level, filter, label }])
}
useEffect(() => {
if (!focus && !selection.purchaser) {
onDrilldownChange?.(null)
return
}
const filter: LogisticsArrivalSigningDurationDrilldownFilter = focus
? focus.dimension === 'date'
? { kind: 'date', date: focus.value }
: { kind: 'object', field: SIMPLE_DIMENSION_FIELD[focus.dimension], value: focus.value }
: {
kind: 'product',
purchaser: selection.purchaser!,
...(selection.supplier ? { supplier: selection.supplier } : {}),
...(selection.barcode ? { barcode: selection.barcode } : {}),
}
onDrilldownChange?.({ label: selectionLabel || focusLabel || '', rows: detailRows, filter })
}, [detailRows, focus, focusLabel, onDrilldownChange, selection.barcode, selection.purchaser, selection.supplier, selectionLabel])
return <div className={`sc-analysis sc-size-${size}`}>
<section className="sc-analysis-daily">
<div className="sc-analysis-daily-heading"><span>01</span><h4>{dailyTitle}</h4><small>按{dailyTitle.replace(/维度$/, '')}统计当前分析样本</small>{onExportDaily ? <button type="button" className="sc-analysis-export" title={`导出${dailyTitle}`} aria-label={`导出${dailyTitle}`} onClick={onExportDaily}><Download size={14} /></button> : null}</div>
<ResizableTableViewport key={size} label={dailyTitle} defaultHeight={TABLE_HEIGHT[size]}>
<table><thead><tr><th>指标</th><th>总计</th>{visibleDaily.map((day) => <th key={day.date}><button type="button" className={focus?.dimension === 'date' && focus.value === day.date ? 'is-selected' : ''} onClick={() => { setSelection(EMPTY_SELECTION); setFocus({ dimension: 'date', value: day.date }); pickCascade('date', { kind: 'date', date: day.date }, `签收日期 / ${day.date}`) }}>{day.date.slice(5)}</button></th>)}</tr></thead>
<tbody>{metrics.map((metric) => <tr key={metric.label}><th>{metric.label}</th><td>{metric.value(analysis.total)}</td>{visibleDaily.map((day) => <td key={day.date}>{metric.value(day)}</td>)}</tr>)}</tbody>
</table>
</ResizableTableViewport>
<BoardPagination
total={analysis.daily.length}
page={datePage}
pageSize={datePaging.pageSize}
unit="个签收日期"
onPageChange={(next) => setDatePaging((current) => ({ ...current, page: next }))}
onPageSizeChange={(next) => setDatePaging((current) => ({ ...current, pageSize: next }))}
/>
</section>
{[0, 2].map((start) => <div className="sc-analysis-pair" key={start}>{SIMPLE_SECTIONS.slice(start, start + 2).map((section, index) => {
const isShopSection = section.key === 'shops'
const sectionRows = isShopSection
? (activeShopPlatform ? analysis.shops.filter((row) => row.platform === activeShopPlatform) : analysis.shops)
: analysis[section.key]
return <div className="sc-analysis-pane" key={section.key}><GroupTable
size={size}
index={String(start + index + 2).padStart(2, '0')}
title={isShopSection && activeShopPlatform ? `${section.title} · ${activeShopPlatform}` : section.title}
columns={isShopSection
? [
{
label: '平台',
value: (row) => row.platform || '未填写',
onSelect: (row) => {
const platform = row.platform || '未填写'
setShopPlatform((current) => (current === platform ? null : platform))
pickCascade('platform', { kind: 'object', field: 'platform', value: platform }, platform)
},
active: (row) => activeShopPlatform === row.platform,
},
{ label: section.column, value: (row) => row.label },
]
: [{ label: section.column, value: (row) => row.label }]}
rows={sectionRows}
metrics={groupMetrics}
sortLabel={isShopSection && activeShopPlatform ? '已按平台筛选' : sortLabel}
selectedKey={focus?.dimension === section.key
? (isShopSection ? focusedShopKey : JSON.stringify([focus.value]))
: null}
onSelect={(row) => {
setSelection(EMPTY_SELECTION)
setFocus({ dimension: section.key, value: row.label })
if (section.key === 'shops') {
pickCascade('shop', {
kind: 'object',
field: 'shop',
value: row.label,
...(row.platform ? { platform: row.platform } : {}),
}, row.label)
} else if (section.key === 'platforms') {
pickCascade('platform', { kind: 'object', field: 'platform', value: row.label }, row.label)
} else if (section.key === 'warehouses') {
pickCascade('warehouse', { kind: 'object', field: 'warehouse', value: row.label }, row.label)
} else {
pickCascade('logisticCompany', { kind: 'object', field: 'logisticCompany', value: row.label }, row.label)
}
}}
onExport={onExportGroup ? () => onExportGroup(section.title, sectionRows) : undefined}
/></div>
})}</div>)}
<div className="sc-analysis-pair" data-testid="purchaser-supplier-panels">
<div className="sc-analysis-pane"><GroupTable size={size} index="06" title="采购维度" columns={[{ label: '采购', value: (row) => row.label }]} rows={analysis.purchasers} metrics={groupMetrics} sortLabel={sortLabel} selectedKey={selection.purchaser ? JSON.stringify([selection.purchaser]) : null} onSelect={(row) => { setFocus(null); setSelection({ purchaser: row.purchaser ?? null, supplier: null, barcode: null }); pickCascade('purchaser', { kind: 'product', purchaser: row.label }, row.label) }} onExport={onExportGroup ? () => onExportGroup('采购维度', analysis.purchasers) : undefined} /></div>
<div className="sc-analysis-pane"><GroupTable size={size} index="07" title={selection.purchaser ? `供应商维度 · ${selection.purchaser}` : '供应商维度'} columns={[{ label: '采购', value: (row) => row.purchaser ?? '-' }, { label: '供应商', value: (row) => row.label }]} rows={suppliers} metrics={groupMetrics} sortLabel={sortLabel} selectedKey={selectedSupplierKey} onSelect={(row) => { setFocus(null); setSelection({ purchaser: row.purchaser ?? null, supplier: row.supplier ?? null, barcode: null }); pickCascade('supplier', { kind: 'product', purchaser: row.purchaser ?? row.label, supplier: row.label }, row.label) }} onExport={onExportGroup ? () => onExportGroup('供应商维度', suppliers) : undefined} /></div>
</div>
<GroupTable size={size} index="08" title={selection.supplier ? `商品维度 · ${selection.supplier}` : '商品维度'} columns={[{ label: '采购', value: (row) => row.purchaser ?? '-' }, { label: '供应商', value: (row) => row.supplier ?? '-' }, { label: '条码', value: (row) => row.label }]} rows={products} metrics={groupMetrics} sortLabel={sortLabel} selectedKey={selectedProductKey} onSelect={(row) => { setFocus(null); setSelection({ purchaser: row.purchaser ?? null, supplier: row.supplier ?? null, barcode: row.barcode ?? null }); pickCascade('barcode', { kind: 'product', purchaser: row.purchaser ?? row.label, ...(row.supplier ? { supplier: row.supplier } : {}), barcode: row.barcode ?? row.label }, row.label) }} onExport={onExportGroup ? () => onExportGroup('商品维度', products) : undefined} />
{focus || selection.purchaser ? <div className="sc-analysis-selection"><span>当前下钻:{selectionLabel || focusLabel} · {formatAnalysisCount(detailRows.length)} 条样本</span><button type="button" onClick={clearSelection} title="重置下钻"><RotateCcw size={14} />重置</button></div> : null}
<style jsx>{`
.sc-analysis { min-width: 0; background: #fff; color: #111; }
.sc-analysis-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sc-analysis-pane { min-width: 0; }
.sc-analysis-pane + .sc-analysis-pane { border-left: 1px solid var(--sc-border); }
.sc-analysis-daily { min-width: 0; padding: 13px 14px 12px; }
.sc-size-compact .sc-analysis-daily { padding: 9px 10px; }
.sc-size-expanded .sc-analysis-daily { padding: 18px 20px; }
.sc-analysis-daily-heading { display: flex; min-height: 27px; align-items: center; gap: 8px; margin-bottom: 8px; }
.sc-analysis-daily-heading span { color: #111; font-size: 11px; font-weight: 400; }
.sc-analysis-daily-heading h4 { margin: 0; color: #111; font-size: 13px; font-weight: 650; }
.sc-analysis-daily-heading small { color: #111; font-size: 11px; font-weight: 400; }
.sc-analysis-export { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 26px; height: 26px; margin-left: auto; border: 1px solid var(--sc-border); border-radius: 4px; background: #fff; color: #111; cursor: pointer; }
.sc-analysis-daily table { min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.sc-analysis-daily th, .sc-analysis-daily td { min-width: 104px; padding: 7px 9px; border-bottom: 1px solid #e9eceb; text-align: right; white-space: nowrap; }
.sc-analysis-daily th { background: #f3f5f6; color: #111; font-weight: 600; }
.sc-analysis-daily thead th { position: sticky; top: 0; z-index: 3; }
.sc-analysis-daily thead th:first-child, .sc-analysis-daily tbody th { position: sticky; left: 0; min-width: 150px; text-align: left; }
.sc-analysis-daily thead th:first-child { z-index: 5; }
.sc-analysis-daily tbody th { z-index: 2; }
.sc-analysis-daily thead th:nth-child(2), .sc-analysis-daily tbody td:first-child { position: sticky; left: 150px; background: #f3f5f6; color: #111; }
.sc-analysis-daily thead th:nth-child(2) { z-index: 4; }
.sc-analysis-daily tbody td:first-child { z-index: 1; font-weight: 400; }
.sc-analysis-daily th button { padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-weight: 650; }
.sc-analysis-daily th button.is-selected { color: #111; text-decoration: underline; }
.sc-analysis-daily td { color: #111; font-weight: 400; }
.sc-analysis-selection { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-top: 1px solid var(--sc-border); background: #f2f8f4; color: #111; font-size: 11px; }
.sc-analysis-selection button { display: inline-flex; align-items: center; gap: 5px; border: 0; background: transparent; color: #111; cursor: pointer; font: inherit; }
@media (max-width: 900px) { .sc-analysis-pair { grid-template-columns: minmax(0, 1fr); } .sc-analysis-pane + .sc-analysis-pane { border-left: 0; } }
`}</style>
</div>
}

View File

@ -0,0 +1,61 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import type { LogisticsArrivalSigningDurationRow } from '@/lib/logistics-arrival-signing-duration'
import { OrderDetailsBoard } from './OrderDetailsBoard'
const row: LogisticsArrivalSigningDurationRow = {
innerOrderId: '内部-1', soId: '线上-1', logisticId: '物流-1',
logisticCompanyName: '快运', logisticStatusName: '已签收', logisticReturnFlag: '',
orderLabels: '标签', remark: '备注', buyerMessage: '留言', shopName: '店铺',
platformName: '平台', warehouseName: '发货仓', receiverAddress: '浙江省杭州市',
payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 04:00:00',
fetchTime: '2026-09-01 12:00:00', signTime: '2026-09-02 00:00:00',
barcode: 'B1', supplier: '供应商', purchaser: '采购',
}
it('订单明细保持原有字段和顺序,并提供独立导出及高度调整', () => {
const onExport = jest.fn()
render(<OrderDetailsBoard rows={[row]} total={135} totalKnown page={2} pageSize={50} canDownload exporting={false} onExport={onExport} />)
expect(screen.getAllByRole('columnheader').map((header) => header.textContent)).toEqual([
'内部单号', '线上订单号', '物流单号', '物流公司', '物流状态', '拦截状态',
'订单标签', '卖家备注', '买家留言', '店铺', '平台', '发货仓',
'收货省市区', '付款时间', '发货时间', '揽收时间', '签收时间', '条码', '供应商', '采购',
])
expect(screen.getByText('内部-1')).toBeInTheDocument()
expect(screen.getByText('135 条筛选结果 · 第 2 页,每页 50 条')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '导出订单明细' }))
expect(onExport).toHaveBeenCalledTimes(1)
const separator = screen.getByRole('separator', { name: '调整订单明细高度' })
const viewport = document.getElementById(separator.getAttribute('aria-controls')!)
expect(viewport).toHaveStyle({ height: '360px' })
fireEvent.keyDown(separator, { key: 'ArrowDown' })
expect(viewport).toHaveStyle({ height: '384px' })
})
it('翻页控件放在订单明细看板内部,可按真实总数翻页并切换每页条数', () => {
const onPageChange = jest.fn()
const onPageSizeChange = jest.fn()
render(<OrderDetailsBoard
rows={[row]}
total={135}
totalKnown
page={3}
pageSize={50}
totalPages={3}
pageSizeOptions={[25, 50, 100, 200]}
canDownload
exporting={false}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>)
const board = screen.getByRole('heading', { name: '订单明细' }).closest('section')!
expect(within(board).getByText('共 135 条筛选结果 · 第 3 / 3 页')).toBeInTheDocument()
fireEvent.click(within(board).getByRole('button', { name: '上一页' }))
expect(onPageChange).toHaveBeenCalledWith(2)
expect(within(board).getByRole('button', { name: '下一页' })).toBeDisabled()
fireEvent.change(within(board).getByLabelText('每页条数'), { target: { value: '200' } })
expect(onPageSizeChange).toHaveBeenCalledWith(200)
expect(within(board).getByRole('option', { name: '200 条/页' })).toBeInTheDocument()
})

View File

@ -0,0 +1,140 @@
'use client'
import { Download, GripHorizontal } from 'lucide-react'
import { useId, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'
import {
formatLogisticsDateTime,
type LogisticsArrivalSigningDurationRow,
} from '@/lib/logistics-arrival-signing-duration'
import { BoardPagination } from './BoardPagination'
function cell(row: LogisticsArrivalSigningDurationRow, key: string) {
const value = row[key as keyof LogisticsArrivalSigningDurationRow]
if (key.endsWith('Time')) return formatLogisticsDateTime(value as string | null)
if (Array.isArray(value)) return '-'
return value || '-'
}
const ORDER_TABLE_MIN_HEIGHT = 240
const ORDER_TABLE_MAX_HEIGHT = 760
const ORDER_TABLE_DEFAULT_HEIGHT = 360
export function OrderDetailsBoard({
rows,
total,
totalKnown,
exact = false,
page,
pageSize,
canDownload,
exporting,
loading = false,
error,
drilldownLabel,
onPageChange,
onPageSizeChange,
pageSizeOptions,
totalPages,
onExport,
}: {
rows: LogisticsArrivalSigningDurationRow[]
total: number
totalKnown: boolean
/** 整区间口径下钻:总数是全量匹配订单而不是样本行。 */
exact?: boolean
page: number
pageSize: number
canDownload: boolean
exporting: boolean
loading?: boolean
error?: string | null
drilldownLabel?: string
onPageChange?: (page: number) => void
onPageSizeChange?: (pageSize: number) => void
pageSizeOptions?: readonly number[]
totalPages?: number
onExport?: () => void
}) {
const [height, setHeight] = useState(ORDER_TABLE_DEFAULT_HEIGHT)
const startRef = useRef<{ y: number; height: number } | null>(null)
const viewportId = useId()
const clampHeight = (value: number) => Math.max(ORDER_TABLE_MIN_HEIGHT, Math.min(ORDER_TABLE_MAX_HEIGHT, value))
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
startRef.current = { y: event.clientY, height }
}
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!startRef.current) return
setHeight(clampHeight(startRef.current.height + event.clientY - startRef.current.y))
}
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
startRef.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
}
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
event.preventDefault()
setHeight((current) => clampHeight(current + (event.key === 'ArrowUp' ? -24 : 24)))
}
return <section className="sc-order-details-board">
<div className="sc-order-details-board-header">
<span className="sc-order-details-board-index">09</span>
<h4>订单明细</h4>
<small>{drilldownLabel ? `当前下钻:${drilldownLabel} · ` : ''}{totalKnown ? `${total.toLocaleString('zh-CN')} 条${drilldownLabel ? (exact ? '匹配明细' : '样本明细') : '筛选结果'}` : `${rows.length} 条已加载`} · 第 {page} 页,每页 {pageSize} 条</small>
{canDownload && onExport ? <button type="button" className="sc-order-details-board-export" title="导出订单明细" aria-label="导出订单明细" onClick={onExport} disabled={exporting || loading || Boolean(error)}><Download size={15} />{exporting ? '导出中' : null}</button> : null}
</div>
<div id={viewportId} className="sc-order-details-board-scroll" style={{ height }}>
<table>
<thead><tr>
<th>内部单号</th><th>线上订单号</th><th>物流单号</th><th>物流公司</th><th>物流状态</th><th>拦截状态</th>
<th>订单标签</th><th>卖家备注</th><th>买家留言</th><th>店铺</th><th>平台</th><th>发货仓</th>
<th>收货省市区</th><th>付款时间</th><th>发货时间</th><th>揽收时间</th><th>签收时间</th><th>条码</th><th>供应商</th><th>采购</th>
</tr></thead>
<tbody>{rows.map((row) => <tr key={`${row.innerOrderId}-${row.logisticId}`}>
{[
'innerOrderId','soId','logisticId','logisticCompanyName','logisticStatusName','logisticReturnFlag','orderLabels','remark','buyerMessage','shopName','platformName','warehouseName','receiverAddress','payTime','sendTime','fetchTime','signTime','barcode','supplier','purchaser',
].map((key) => <td key={key} title={String(cell(row, key))}>{cell(row, key)}</td>)}
</tr>)}</tbody>
</table>
{loading ? <div className="sc-order-details-board-empty" role="status">下钻明细加载中…</div> : error ? <div className="sc-order-details-board-empty sc-order-details-board-error" role="alert">{error}</div> : rows.length === 0 ? <div className="sc-order-details-board-empty">当前条件下暂无订单明细</div> : null}
</div>
{onPageChange ? <BoardPagination
total={totalKnown ? total : undefined}
totalPages={totalPages}
page={page}
pageSize={pageSize}
unit={drilldownLabel ? (exact ? '条匹配明细' : '条样本明细') : '条筛选结果'}
pageSizeOptions={pageSizeOptions}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/> : null}
<div role="separator" aria-label="调整订单明细高度" aria-controls={viewportId} aria-orientation="horizontal" aria-valuemin={ORDER_TABLE_MIN_HEIGHT} aria-valuemax={ORDER_TABLE_MAX_HEIGHT} aria-valuenow={height} tabIndex={0} title="上下拖动调整订单明细高度" onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerEnd} onPointerCancel={handlePointerEnd} onKeyDown={handleKeyDown} className="sc-order-details-board-resize"><GripHorizontal size={15} aria-hidden="true" /></div>
<style jsx>{`
.sc-order-details-board { min-width: 0; border-top: 1px solid var(--sc-border); background: var(--sc-surface); }
.sc-order-details-board-header { display: flex; min-height: 51px; align-items: center; gap: 8px; padding: 11px 14px; background: var(--sc-surface); }
.sc-order-details-board-index { color: #111; font-size: 11px; font-weight: 400; }
.sc-order-details-board-header h4 { margin: 0; color: #111; font-size: 13px; font-weight: 650; white-space: nowrap; }
.sc-order-details-board-header small { min-width: 0; overflow: hidden; color: #111; font-size: 11px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
.sc-order-details-board-export { display: inline-flex; flex: none; align-items: center; justify-content: center; gap: 5px; width: 27px; height: 27px; margin-left: auto; padding: 0; border: 1px solid var(--sc-border); border-radius: 4px; background: var(--sc-surface); color: #111; cursor: pointer; font: inherit; font-size: 11px; }
.sc-order-details-board-export:disabled { width: auto; padding: 0 6px; }
.sc-order-details-board-export:disabled { cursor: wait; opacity: .55; }
.sc-order-details-board-scroll { width: 100%; min-width: 0; overflow: scroll; overscroll-behavior: contain; scrollbar-color: #aab5b0 #f2f4f3; scrollbar-gutter: stable; scrollbar-width: thin; }
.sc-order-details-board-scroll::-webkit-scrollbar { width: 12px; height: 12px; }
.sc-order-details-board-scroll::-webkit-scrollbar-track { background: #f2f4f3; }
.sc-order-details-board-scroll::-webkit-scrollbar-thumb { border: 3px solid #f2f4f3; border-radius: 7px; background: #aab5b0; }
.sc-order-details-board-scroll table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.sc-order-details-board-scroll th, .sc-order-details-board-scroll td { max-width: 260px; padding: 9px 11px; border-bottom: 1px solid var(--sc-border); text-align: left; vertical-align: top; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sc-order-details-board-scroll th { position: sticky; top: 0; z-index: 2; background: var(--sc-surface-strong); color: #111; font-weight: 600; }
.sc-order-details-board-scroll td { color: #111; font-weight: 400; }
.sc-order-details-board-scroll tr:last-child td { border-bottom: 0; }
.sc-order-details-board-resize { display: flex; height: 16px; align-items: center; justify-content: center; border-top: 1px solid var(--sc-border); background: #f7f8f8; color: #64716d; cursor: row-resize; touch-action: none; user-select: none; }
.sc-order-details-board-resize:hover, .sc-order-details-board-resize:focus-visible { background: #e9eef1; color: #111; outline: 2px solid #738895; outline-offset: -2px; }
.sc-order-details-board-empty { padding: 80px 20px; color: var(--sc-subtle); text-align: center; font-size: 13px; }
.sc-order-details-board-error { color: #a44133; }
`}</style>
</section>
}

View File

@ -0,0 +1,89 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import {
buildPayToSendAnalysis,
type LogisticsArrivalSigningDurationRow,
} from '@/lib/logistics-arrival-signing-duration'
import { PayToSendAnalysis } from './PayToSendAnalysis'
function order(
id: string,
purchaser: string,
supplier: string,
barcode: string,
sendTime: string,
): LogisticsArrivalSigningDurationRow {
return {
innerOrderId: `SO-${id}`,
soId: `SO-${id}`,
logisticId: `L-${id}`,
logisticCompanyName: '快运',
logisticStatusName: '已签收',
logisticReturnFlag: '',
orderLabels: '',
remark: '',
buyerMessage: '',
shopName: '店铺',
platformName: '平台',
warehouseName: '发货仓',
receiverAddress: '',
payTime: '2026-09-01 00:00:00',
sendTime,
fetchTime: '2026-09-04 12:00:00',
signTime: '2026-09-05 00:00:00',
barcode,
productName: `商品${id}`,
purchaser,
supplier,
productItems: [{ barcode, productName: `商品${id}`, purchaser, supplier }],
}
}
const rows = [
order('1', '采购甲', '供应商一', 'B1', '2026-09-01 10:00:00'),
order('2', '采购甲', '供应商一', 'B1', '2026-09-01 20:00:00'),
order('3', '采购乙', '供应商三', 'B3', '2026-09-03 00:00:00'),
order('4', '采购乙', '供应商四', 'B4', '2026-09-04 00:00:00'),
]
it('付款到发货按单量、18h、24h、48h 与超48h发货及时率展示经营对象', () => {
render(<PayToSendAnalysis analysis={buildPayToSendAnalysis(rows, '2026-09-01', '2026-09-05')} rows={rows} />)
const purchaserTable = screen.getByRole('button', { name: '采购甲' }).closest('table')!
expect(within(purchaserTable).getAllByRole('columnheader').map((header) => header.textContent)).toEqual([
'采购', '单量', '18h发货及时率', '24h发货及时率', '48h发货及时率', '超48h发货及时率', '目标', '目标达成率',
])
expect(within(purchaserTable).queryByRole('columnheader', { name: '平均时长' })).not.toBeInTheDocument()
// 没有上月基线时目标列显示 -,但列本身始终在(与履约环节拆解一致)。
const cells = within(purchaserTable).getByRole('button', { name: '采购甲' }).closest('tr')!.querySelectorAll('td')
expect(cells[cells.length - 1].textContent).toBe('-')
expect(cells[cells.length - 2].textContent).toBe('-')
expect(within(purchaserTable).getByRole('button', { name: '采购甲' }).closest('tr')!.textContent).toContain('50.0%')
const dailyTable = screen.getByText('签收日期维度').closest('section')!
for (const label of ['单量', '18h发货及时率', '24h发货及时率', '48h发货及时率', '超48h发货及时率']) {
expect(within(dailyTable).getByText(label)).toBeInTheDocument()
}
expect(within(dailyTable).getByText('超48h发货及时率').closest('tr')!.textContent).toContain('25.0%')
})
it('付款到发货可按采购下钻,并支持导出单个经营维度', () => {
const onExportGroup = jest.fn()
const onDrilldownChange = jest.fn()
render(<PayToSendAnalysis analysis={buildPayToSendAnalysis(rows, '2026-09-01', '2026-09-05')} rows={rows} onExportGroup={onExportGroup} onDrilldownChange={onDrilldownChange} />)
fireEvent.click(screen.getByRole('button', { name: '导出采购维度' }))
expect(onExportGroup).toHaveBeenCalledWith('采购维度', expect.arrayContaining([
expect.objectContaining({ purchaser: '采购甲', shipOver48hRate: 0 }),
expect.objectContaining({ purchaser: '采购乙', shipOver48hRate: 50 }),
]))
fireEvent.click(screen.getByRole('button', { name: '采购乙' }))
const drilldown = onDrilldownChange.mock.calls[onDrilldownChange.mock.calls.length - 1][0]
expect(drilldown.label).toBe('采购乙')
expect(drilldown.rows.map((row: LogisticsArrivalSigningDurationRow) => row.innerOrderId)).toEqual(['SO-3', 'SO-4'])
expect(screen.getByRole('heading', { name: '供应商维度 · 采购乙' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'B1' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重置' }))
expect(onDrilldownChange).toHaveBeenLastCalledWith(null)
})

View File

@ -0,0 +1,76 @@
'use client'
import type {
LogisticsArrivalSigningDurationCascadeEntry,
LogisticsArrivalSigningDurationDrilldownFilter,
LogisticsArrivalSigningDurationRow,
ObjectAnalysisTargetMetrics,
PayToSendAnalysis as PayToSendAnalysisData,
PayToSendGroup,
PayToSendMetrics,
} from '@/lib/logistics-arrival-signing-duration'
import {
ObjectAnalysisBoard,
formatAnalysisCount,
formatAnalysisHours,
formatAnalysisPercent,
type ObjectAnalysisMetric,
type ObjectAnalysisSize,
} from './ObjectAnalysisBoard'
const PAY_TO_SEND_METRICS: Array<ObjectAnalysisMetric<PayToSendMetrics>> = [
{ label: '单量', value: (metrics) => formatAnalysisCount(metrics.orders) },
{ label: '18h发货及时率', value: (metrics) => formatAnalysisPercent(metrics.ship18hRate) },
{ label: '24h发货及时率', value: (metrics) => formatAnalysisPercent(metrics.ship24hRate) },
{ label: '48h发货及时率', value: (metrics) => formatAnalysisPercent(metrics.ship48hRate) },
{ label: '超48h发货及时率', value: (metrics) => formatAnalysisPercent(metrics.shipOver48hRate) },
]
/**
* 经营对象表追加「目标 / 目标达成率」:目标 = 该对象上月同环节平均时长 × 98%,
* 目标达成率 = 100% − (平均 − 目标) / 目标 × 100%,与履约环节拆解同一套口径。
*/
const PAY_TO_SEND_GROUP_METRICS: Array<ObjectAnalysisMetric<PayToSendMetrics & ObjectAnalysisTargetMetrics>> = [
...PAY_TO_SEND_METRICS as Array<ObjectAnalysisMetric<PayToSendMetrics & ObjectAnalysisTargetMetrics>>,
{ label: '目标', value: (metrics) => formatAnalysisHours(metrics.targetHours ?? null) },
{ label: '目标达成率', value: (metrics) => formatAnalysisPercent(metrics.achievementRate ?? null) },
]
export function PayToSendAnalysis({
analysis,
rows,
size = 'standard',
onExportDaily,
onExportGroup,
onDrilldownChange,
cascade,
onCascadeChange,
}: {
analysis: PayToSendAnalysisData
rows: LogisticsArrivalSigningDurationRow[]
size?: ObjectAnalysisSize
onExportDaily?: () => void
onExportGroup?: (title: string, rows: PayToSendGroup[]) => void
onDrilldownChange?: (selection: {
label: string
rows: LogisticsArrivalSigningDurationRow[]
filter?: LogisticsArrivalSigningDurationDrilldownFilter
} | null) => void
cascade?: LogisticsArrivalSigningDurationCascadeEntry[]
onCascadeChange?: (cascade: LogisticsArrivalSigningDurationCascadeEntry[]) => void
}) {
return <ObjectAnalysisBoard
analysis={analysis}
rows={rows}
metrics={PAY_TO_SEND_METRICS}
groupMetrics={PAY_TO_SEND_GROUP_METRICS}
size={size}
dailyTitle="签收日期维度"
sortLabel="超48h发货及时率从高到低"
onExportDaily={onExportDaily}
onExportGroup={onExportGroup}
onDrilldownChange={onDrilldownChange}
cascade={cascade}
onCascadeChange={onCascadeChange}
/>
}

View File

@ -0,0 +1,174 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import {
buildPayToSignAnalysis,
type LogisticsArrivalSigningDurationRow,
} from '@/lib/logistics-arrival-signing-duration'
import { PayToSignAnalysis } from './PayToSignAnalysis'
function order(
id: string,
purchaser: string,
supplier: string,
barcode: string,
): LogisticsArrivalSigningDurationRow {
return {
innerOrderId: `SO-${id}`,
soId: `SO-${id}`,
logisticId: `L-${id}`,
logisticCompanyName: '快运',
logisticStatusName: '已签收',
logisticReturnFlag: '',
orderLabels: '',
remark: '',
buyerMessage: '',
shopName: '店铺',
platformName: '平台',
warehouseName: '发货仓',
receiverAddress: '',
payTime: '2026-09-01 00:00:00',
sendTime: '2026-09-01 04:00:00',
fetchTime: '2026-09-01 12:00:00',
signTime: '2026-09-02 00:00:00',
barcode,
productName: `商品${id}`,
purchaser,
supplier,
productItems: [{ barcode, productName: `商品${id}`, purchaser, supplier }],
}
}
it('付款到签收可按采购、供应商、条码逐级筛选并查看订单', () => {
const rows = [
order('1', '采购甲', '供应商一', 'B1'),
order('2', '采购甲', '供应商二', 'B2'),
order('3', '采购乙', '供应商三', 'B3'),
]
const onExportGroup = jest.fn()
const onExportDaily = jest.fn()
const onDrilldownChange = jest.fn()
render(<PayToSignAnalysis analysis={buildPayToSignAnalysis(rows, '2026-09-01', '2026-09-03')} rows={rows} onExportGroup={onExportGroup} onExportDaily={onExportDaily} onDrilldownChange={onDrilldownChange} />)
expect(screen.getByRole('button', { name: '09-02' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'B3' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '导出签收日期维度' }))
expect(onExportDaily).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: '采购甲' }))
expect(screen.queryByRole('button', { name: 'B3' })).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '供应商维度 · 采购甲' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '导出供应商维度 · 采购甲' }))
expect(onExportGroup).toHaveBeenCalledWith('供应商维度', expect.arrayContaining([
expect.objectContaining({ supplier: '供应商一' }),
expect.objectContaining({ supplier: '供应商二' }),
]))
fireEvent.click(screen.getByRole('button', { name: '供应商一' }))
expect(screen.queryByRole('button', { name: 'B2' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'B1' }))
const drilldown = onDrilldownChange.mock.calls[onDrilldownChange.mock.calls.length - 1][0]
expect(drilldown.label).toBe('采购甲 / 供应商一 / B1')
expect(drilldown.rows.map((item: LogisticsArrivalSigningDurationRow) => item.innerOrderId)).toEqual(['SO-1'])
fireEvent.click(screen.getByRole('button', { name: '重置' }))
expect(screen.getByRole('button', { name: 'B3' })).toBeInTheDocument()
expect(onDrilldownChange).toHaveBeenLastCalledWith(null)
})
it('经营维度并排展示,平均时长仅显示数值,每张表可调整高度', () => {
const rows = [order('1', '采购甲', '供应商一', 'B1')]
render(<PayToSignAnalysis analysis={buildPayToSignAnalysis(rows, '2026-09-01', '2026-09-03')} rows={rows} />)
const pairedPanels = screen.getByTestId('purchaser-supplier-panels')
expect(within(pairedPanels).getByRole('heading', { name: '采购维度' })).toBeInTheDocument()
expect(within(pairedPanels).getByRole('heading', { name: '供应商维度' })).toBeInTheDocument()
expect(screen.getAllByText('24.0').length).toBeGreaterThan(0)
expect(screen.queryByText(/24\.0 小时/)).not.toBeInTheDocument()
const separator = screen.getByRole('separator', { name: '调整采购维度高度' })
const viewport = document.getElementById(separator.getAttribute('aria-controls')!)
expect(viewport).toHaveStyle({ height: '232px' })
fireEvent.keyDown(separator, { key: 'ArrowDown' })
expect(viewport).toHaveStyle({ height: '256px' })
expect(separator).toHaveAttribute('aria-valuenow', '256')
fireEvent.keyDown(separator, { key: 'ArrowUp' })
expect(viewport).toHaveStyle({ height: '232px' })
separator.setPointerCapture = jest.fn()
separator.hasPointerCapture = jest.fn(() => true)
separator.releasePointerCapture = jest.fn()
const down = new MouseEvent('pointerdown', { bubbles: true, button: 0, clientY: 100 })
Object.defineProperty(down, 'pointerId', { value: 1 })
fireEvent(separator, down)
const move = new MouseEvent('pointermove', { bubbles: true, clientY: 140 })
Object.defineProperty(move, 'pointerId', { value: 1 })
fireEvent(separator, move)
expect(viewport).toHaveStyle({ height: '272px' })
const up = new MouseEvent('pointerup', { bubbles: true })
Object.defineProperty(up, 'pointerId', { value: 1 })
fireEvent(separator, up)
expect(separator.releasePointerCapture).toHaveBeenCalledWith(1)
})
it('经营维度表按页展示对象,可翻页与调整每页条数', () => {
const rows = Array.from({ length: 25 }, (_, index) =>
order(String(index + 1), `采购${String(index + 1).padStart(2, '0')}`, '供应商一', 'B1'))
render(<PayToSignAnalysis analysis={buildPayToSignAnalysis(rows, '2026-09-01', '2026-09-02')} rows={rows} />)
const purchaserSection = screen.getByRole('heading', { name: '采购维度' }).closest('section')!
expect(within(purchaserSection).getByText('共 25 个对象 · 第 1 / 2 页')).toBeInTheDocument()
expect(within(purchaserSection).getByRole('button', { name: '采购01' })).toBeInTheDocument()
expect(within(purchaserSection).queryByRole('button', { name: '采购25' })).not.toBeInTheDocument()
expect(within(purchaserSection).getByRole('button', { name: '上一页' })).toBeDisabled()
fireEvent.click(within(purchaserSection).getByRole('button', { name: '下一页' }))
expect(within(purchaserSection).getByText('共 25 个对象 · 第 2 / 2 页')).toBeInTheDocument()
expect(within(purchaserSection).getByRole('button', { name: '采购25' })).toBeInTheDocument()
expect(within(purchaserSection).queryByRole('button', { name: '采购01' })).not.toBeInTheDocument()
expect(within(purchaserSection).getByRole('button', { name: '下一页' })).toBeDisabled()
fireEvent.change(within(purchaserSection).getByLabelText('每页条数'), { target: { value: '100' } })
expect(within(purchaserSection).getByText('共 25 个对象 · 第 1 / 1 页')).toBeInTheDocument()
expect(within(purchaserSection).getByRole('button', { name: '采购01' })).toBeInTheDocument()
expect(within(purchaserSection).getByRole('button', { name: '采购25' })).toBeInTheDocument()
})
it('签收日期维度按列翻页,避免长区间日期列铺满一屏', () => {
const rows = [order('1', '采购甲', '供应商一', 'B1')]
render(<PayToSignAnalysis analysis={buildPayToSignAnalysis(rows, '2026-09-01', '2026-09-25')} rows={rows} />)
const dailySection = screen.getByText('签收日期维度').closest('section')!
expect(within(dailySection).getByText('共 25 个签收日期 · 第 1 / 3 页')).toBeInTheDocument()
expect(within(dailySection).getByRole('button', { name: '09-01' })).toBeInTheDocument()
const dateButtons = within(dailySection).getAllByRole('button').filter((button) => /^\d\d-\d\d$/.test(button.textContent ?? ''))
expect(dateButtons).toHaveLength(10)
expect(within(dailySection).queryByRole('button', { name: '09-25' })).not.toBeInTheDocument()
fireEvent.click(within(dailySection).getByRole('button', { name: '下一页' }))
expect(within(dailySection).getByText('共 25 个签收日期 · 第 2 / 3 页')).toBeInTheDocument()
expect(within(dailySection).getByRole('button', { name: '09-11' })).toBeInTheDocument()
expect(within(dailySection).queryByRole('button', { name: '09-01' })).not.toBeInTheDocument()
})
it('店铺维度在店铺列前展示平台列,点击平台联动筛选该平台店铺', () => {
const rows = [
{ ...order('1', '采购甲', '供应商一', 'B1'), platformName: '天猫', shopName: '天猫-A店' },
{ ...order('2', '采购甲', '供应商一', 'B1'), platformName: '天猫', shopName: '天猫-B店' },
{ ...order('3', '采购甲', '供应商一', 'B1'), platformName: '拼多多', shopName: '拼多多-C店' },
]
render(<PayToSignAnalysis analysis={buildPayToSignAnalysis(rows, '2026-09-01', '2026-09-03')} rows={rows} />)
const shopSection = screen.getByRole('heading', { name: '店铺维度' }).closest('section')!
expect(within(shopSection).getAllByRole('columnheader').map((header) => header.textContent)).toEqual([
'平台', '店铺', '单量', '平均时长', '24h揽收及时率', '48h揽收及时率', '付款至签收履约率', '目标', '目标达成率',
])
expect(within(shopSection).getByRole('button', { name: '拼多多-C店' })).toBeInTheDocument()
fireEvent.click(within(shopSection).getAllByRole('button', { name: '天猫' })[0])
expect(within(shopSection).getByRole('heading', { name: '店铺维度 · 天猫' })).toBeInTheDocument()
expect(within(shopSection).queryByRole('button', { name: '拼多多-C店' })).not.toBeInTheDocument()
expect(within(shopSection).getByRole('button', { name: '天猫-A店' })).toBeInTheDocument()
fireEvent.click(within(shopSection).getAllByRole('button', { name: '天猫' })[0])
expect(within(shopSection).getByRole('heading', { name: '店铺维度' })).toBeInTheDocument()
expect(within(shopSection).getByRole('button', { name: '拼多多-C店' })).toBeInTheDocument()
})

View File

@ -0,0 +1,77 @@
'use client'
import type {
LogisticsArrivalSigningDurationCascadeEntry,
LogisticsArrivalSigningDurationDrilldownFilter,
LogisticsArrivalSigningDurationRow,
ObjectAnalysisTargetMetrics,
PayToSignAnalysis as PayToSignAnalysisData,
PayToSignGroup,
PayToSignMetrics,
} from '@/lib/logistics-arrival-signing-duration'
import {
ObjectAnalysisBoard,
formatAnalysisCount,
formatAnalysisHours,
formatAnalysisPercent,
type ObjectAnalysisMetric,
type ObjectAnalysisSize,
} from './ObjectAnalysisBoard'
const PAY_TO_SIGN_METRICS: Array<ObjectAnalysisMetric<PayToSignMetrics>> = [
{ label: '单量', value: (metrics) => formatAnalysisCount(metrics.orders) },
{ label: '平均时长', value: (metrics) => formatAnalysisHours(metrics.averageHours) },
{ label: '24h揽收及时率', value: (metrics) => formatAnalysisPercent(metrics.pickup24hRate) },
{ label: '48h揽收及时率', value: (metrics) => formatAnalysisPercent(metrics.pickup48hRate) },
{ label: '付款至签收履约率', value: (metrics) => formatAnalysisPercent(metrics.fulfillmentRate) },
]
/**
* 经营对象表在同样的指标后追加「目标 / 目标达成率」:
* 目标 = 该对象上月同环节平均时长 × 98%,目标达成率 = 100% − (平均 − 目标) / 目标 × 100%,
* 与履约环节拆解同一套口径,只是精确到经营对象。
*/
const PAY_TO_SIGN_GROUP_METRICS: Array<ObjectAnalysisMetric<PayToSignMetrics & ObjectAnalysisTargetMetrics>> = [
...PAY_TO_SIGN_METRICS as Array<ObjectAnalysisMetric<PayToSignMetrics & ObjectAnalysisTargetMetrics>>,
{ label: '目标', value: (metrics) => formatAnalysisHours(metrics.targetHours ?? null) },
{ label: '目标达成率', value: (metrics) => formatAnalysisPercent(metrics.achievementRate ?? null) },
]
export function PayToSignAnalysis({
analysis,
rows,
size = 'standard',
onExportDaily,
onExportGroup,
onDrilldownChange,
cascade,
onCascadeChange,
}: {
analysis: PayToSignAnalysisData
rows: LogisticsArrivalSigningDurationRow[]
size?: ObjectAnalysisSize
onExportDaily?: () => void
onExportGroup?: (title: string, rows: PayToSignGroup[]) => void
onDrilldownChange?: (selection: {
label: string
rows: LogisticsArrivalSigningDurationRow[]
filter?: LogisticsArrivalSigningDurationDrilldownFilter
} | null) => void
cascade?: LogisticsArrivalSigningDurationCascadeEntry[]
onCascadeChange?: (cascade: LogisticsArrivalSigningDurationCascadeEntry[]) => void
}) {
return <ObjectAnalysisBoard
analysis={analysis}
rows={rows}
metrics={PAY_TO_SIGN_METRICS}
groupMetrics={PAY_TO_SIGN_GROUP_METRICS}
size={size}
dailyTitle="签收日期维度"
sortLabel="平均时长升序"
onExportDaily={onExportDaily}
onExportGroup={onExportGroup}
onDrilldownChange={onDrilldownChange}
cascade={cascade}
onCascadeChange={onCascadeChange}
/>
}

View File

@ -0,0 +1,57 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { logisticsArrivalSigningDurationSettingsService } from '@/server/services/logistics-arrival-signing-duration-settings.service'
import { logisticsArrivalSigningDurationService } from '@/server/services/logistics-arrival-signing-duration.service'
import { canManageSupplyChainPermissions } from '@/lib/supply-chain-permission-settings'
import { parseExcludeKeywords } from '@/lib/logistics-arrival-signing-duration'
import { LogisticsArrivalSigningDurationView } from './LogisticsArrivalSigningDurationView'
export const metadata = { title: '物流到货签收时长看板 · 供应链中心' }
function first(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] ?? '' : value ?? ''
}
export const dynamic = 'force-dynamic'
export default async function LogisticsArrivalSigningDurationPage({
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>
}) {
const { authUser } = await requireSession('/login')
const access = await supplyChainPermissionSettingsService.getUserAccess(
authUser,
'businessManagement',
)
if (!access.view) redirect('/supply-chain')
const params = await searchParams
const defaultExcludeKeywords = await logisticsArrivalSigningDurationSettingsService.getDefaultExcludeKeywords()
const hasExcludeKeywords = Boolean(params && Object.prototype.hasOwnProperty.call(params, 'excludeKeywords'))
const excludeKeywordsMode = first(params?.excludeKeywordsMode) === 'clear' ? 'clear' : 'default'
const submittedExcludeKeywords = first(params?.excludeKeywords).trim()
const initialData = await logisticsArrivalSigningDurationService.listPage({
dateFrom: first(params?.dateFrom),
dateTo: first(params?.dateTo),
page: Number.parseInt(first(params?.page), 10),
pageSize: Number.parseInt(first(params?.pageSize), 10),
keyword: first(params?.keyword),
excludeKeywords: excludeKeywordsMode === 'clear'
? {}
: hasExcludeKeywords && submittedExcludeKeywords
? parseExcludeKeywords(submittedExcludeKeywords, defaultExcludeKeywords)
: defaultExcludeKeywords,
excludeKeywordsMode,
})
return (
<LogisticsArrivalSigningDurationView
initialData={initialData}
defaultExcludeKeywords={defaultExcludeKeywords}
canManageExcludeKeywords={canManageSupplyChainPermissions(authUser)}
canDownload={access.view}
/>
)
}

View File

@ -0,0 +1,410 @@
import {
buildLogisticsArrivalSigningDurationOverview,
buildLogisticsArrivalSigningDurationStageAchievement,
buildLogisticsArrivalSigningDurationStageTarget,
buildLogisticsArrivalSigningDurationObjectTarget,
buildPayToSignAnalysis,
buildLogisticsArrivalSigningDurationDashboard,
formatLogisticsDateTime,
normalizeLogisticsArrivalSigningDurationFilters,
normalizeExcludeKeywords,
parseExcludeKeywords,
serializeExcludeKeywords,
countExcludeKeywords,
excludeKeywordEntries,
rankLogisticsArrivalSigningDurationThresholdRows,
splitExcludeKeywords,
splitSearchKeywords,
logisticsArrivalSigningDurationKeywords,
logisticsArrivalSigningDurationRowMatchesFilter,
logisticsArrivalSigningDurationBaselineMonth,
logisticsArrivalSigningDurationMonthRange,
logisticsArrivalSigningDurationSnapshotCovers,
withObjectAnalysisTargets,
} from './logistics-arrival-signing-duration'
describe('物流到货签收时长明细', () => {
it('经营总览由计数统一换算比率,无有效时间时返回空值而不是 0', () => {
expect(buildLogisticsArrivalSigningDurationOverview({
orderCount: 100,
fulfillmentEligibleOrders: 80,
fulfillmentOnTimeOrders: 60,
pickupEligibleOrders: 40,
pickup24hOnTimeOrders: 30,
pickup48hOnTimeOrders: 38,
missingTimeOrders: 20,
invalidTimeOrders: 3,
averageHours: 50.84,
medianHours: 49.06,
p90Hours: 75.95,
})).toEqual({
orderCount: 100,
averageHours: 50.8,
medianHours: 49.1,
p90Hours: 76,
fulfillmentRate: 75,
fulfillmentEligibleOrders: 80,
fulfillmentOnTimeOrders: 60,
pickupEligibleOrders: 40,
pickup24hOnTimeOrders: 30,
pickup48hOnTimeOrders: 38,
pickup24hRate: 75,
pickup48hRate: 95,
missingTimeOrders: 20,
invalidTimeOrders: 3,
})
expect(buildLogisticsArrivalSigningDurationOverview({
orderCount: 5,
fulfillmentEligibleOrders: 0,
fulfillmentOnTimeOrders: 0,
pickupEligibleOrders: 0,
pickup24hOnTimeOrders: 0,
pickup48hOnTimeOrders: 0,
missingTimeOrders: 5,
invalidTimeOrders: 0,
averageHours: null,
medianHours: null,
p90Hours: null,
})).toMatchObject({ fulfillmentRate: null, pickup24hRate: null, pickup48hRate: null, orderCount: 5 })
})
it('订单明细快照仅在覆盖完整请求区间时才被采用', () => {
const window = { windowFrom: '2026-08-01', windowTo: '2026-09-28' }
expect(logisticsArrivalSigningDurationSnapshotCovers(window, { dateFrom: '2026-09-01', dateTo: '2026-09-28' })).toBe(true)
expect(logisticsArrivalSigningDurationSnapshotCovers(window, { dateFrom: '2026-08-01', dateTo: '2026-08-01' })).toBe(true)
expect(logisticsArrivalSigningDurationSnapshotCovers(window, { dateFrom: '2026-07-15', dateTo: '2026-09-28' })).toBe(false)
expect(logisticsArrivalSigningDurationSnapshotCovers(window, { dateFrom: '2026-09-01', dateTo: '2026-09-29' })).toBe(false)
expect(logisticsArrivalSigningDurationSnapshotCovers({ windowFrom: '', windowTo: '' }, { dateFrom: '2026-09-01', dateTo: '2026-09-28' })).toBe(false)
})
it('按中国日期生成默认月份范围并规范页码', () => {
expect(normalizeLogisticsArrivalSigningDurationFilters({ page: 0, pageSize: 100 }, new Date('2026-09-24T01:00:00Z'))).toMatchObject({
dateFrom: '2026-09-01',
dateTo: '2026-09-23',
page: 1,
pageSize: 100,
})
})
it('默认区间是本月1号到昨日,月初自动回退到昨日当天', () => {
expect(normalizeLogisticsArrivalSigningDurationFilters({}, new Date('2026-09-29T02:00:00Z'))).toMatchObject({
dateFrom: '2026-09-01',
dateTo: '2026-09-28',
})
// 每月 1 号:本月 1 号还没到"昨日",默认只取昨日一天
expect(normalizeLogisticsArrivalSigningDurationFilters({}, new Date('2026-10-01T02:00:00Z'))).toMatchObject({
dateFrom: '2026-09-30',
dateTo: '2026-09-30',
})
// 显式传值不受默认区间影响
expect(normalizeLogisticsArrivalSigningDurationFilters(
{ dateFrom: '2026-09-28', dateTo: '2026-09-28' },
new Date('2026-09-29T02:00:00Z'),
)).toMatchObject({ dateFrom: '2026-09-28', dateTo: '2026-09-28' })
})
it('支持中英文逗号排除关键词并去重', () => {
expect(splitExcludeKeywords('发货后取消, 发货后售后,发货后取消\n')).toEqual([
'发货后取消',
'发货后售后',
])
})
it('排除词按字段规范化:新格式保留字段归属,旧的平铺数组落到全部字段', () => {
// 新格式(字段 → 关键词)
expect(normalizeExcludeKeywords({
warehouseName: ['自动发货仓, 京东云仓', '自动发货仓'],
orderLabels: '配件',
unknownField: ['忽略我'],
})).toEqual({
warehouseName: ['自动发货仓', '京东云仓'],
orderLabels: ['配件'],
})
// 旧格式(平铺数组)→ 等价于原来"命中任意字段即排除",所以挂到全部 5 个字段
const legacy = normalizeExcludeKeywords(['标签A, 标签B', '标签A', 'x'.repeat(120)])
expect(Object.keys(legacy)).toEqual([
'warehouseName', 'logisticCompanyName', 'orderLabels', 'buyerMessage', 'remark',
])
expect(legacy.warehouseName).toEqual(['标签A', '标签B', 'x'.repeat(100)])
expect(legacy.remark).toEqual(legacy.warehouseName)
})
it('排除词序列化与反序列化可往返,老链接的平铺写法仍可用', () => {
const value = {
warehouseName: ['自动发货仓', '京东云仓'],
orderLabels: ['配件', '1688一件代发'],
}
expect(serializeExcludeKeywords(value)).toBe('warehouseName:自动发货仓,京东云仓;orderLabels:配件,1688一件代发')
expect(parseExcludeKeywords(serializeExcludeKeywords(value))).toEqual(value)
expect(parseExcludeKeywords('')).toEqual({})
expect(countExcludeKeywords(value)).toBe(4)
expect(excludeKeywordEntries(value).map((entry) => `${entry.label}:${entry.keyword}`)).toEqual([
'发货仓:自动发货仓', '发货仓:京东云仓', '订单标签:配件', '订单标签:1688一件代发',
])
})
it('老链接的平铺关键词按当前配置归回各自字段,配置里没有的词忽略', () => {
const defaults = {
warehouseName: ['自动发货仓', '京东云仓'],
logisticCompanyName: ['京喜', '物流'],
orderLabels: ['配件'],
buyerMessage: ['BBFF'],
remark: ['SSDD'],
}
// 老链接把词平铺在一起:小写的 bbff 归一到配置里的 BBFF,配置里已不存在的"常州简尚家具有限公司"忽略
expect(parseExcludeKeywords('自动发货仓,京喜,物流,配件,bbff,BBFF,SSDD,常州简尚家具有限公司', defaults)).toEqual({
warehouseName: ['自动发货仓'],
logisticCompanyName: ['京喜', '物流'],
orderLabels: ['配件'],
buyerMessage: ['BBFF'],
remark: ['SSDD'],
})
// 只挑一部分词时,只保留这部分(用户当时的选择不丢)
expect(parseExcludeKeywords('京喜,配件', defaults)).toEqual({
logisticCompanyName: ['京喜'],
orderLabels: ['配件'],
})
// 配置为空时退回旧口径,避免老链接变成"不排除任何词"
expect(parseExcludeKeywords('自动发货仓', {}).warehouseName).toEqual(['自动发货仓'])
})
it('保持 SQL Server 墙上时间,不发生时区跨日', () => {
expect(formatLogisticsDateTime('2026-09-01 16:09:01')).toBe('2026-09-01 16:09')
})
it('按付款到签收 72 小时目标计算履约率,并输出时效分布和问题排名', () => {
const rows = [
{
innerOrderId: '1', soId: 'SO-1', logisticId: 'L-1', logisticCompanyName: '快运', logisticStatusName: '已签收', logisticReturnFlag: '', orderLabels: '', remark: '', buyerMessage: '', shopName: '旗舰店', platformName: '平台A', warehouseName: '华东仓', receiverAddress: '', payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 12:00:00', fetchTime: '2026-09-01 14:00:00', signTime: '2026-09-03 00:00:00', barcode: 'SKU-1', productName: '商品一', supplier: '供应商一', purchaser: '采购一',
},
{
innerOrderId: '2', soId: 'SO-2', logisticId: 'L-2', logisticCompanyName: '慢运', logisticStatusName: '已签收', logisticReturnFlag: '', orderLabels: '', remark: '', buyerMessage: '', shopName: '旗舰店', platformName: '平台A', warehouseName: '华东仓', receiverAddress: '', payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 12:00:00', fetchTime: '2026-09-02 12:00:00', signTime: '2026-09-05 12:00:00', barcode: 'SKU-2', productName: '商品二', supplier: '供应商二', purchaser: '采购二',
},
]
const dashboard = buildLogisticsArrivalSigningDurationDashboard(rows)
expect(dashboard.overview).toMatchObject({
orderCount: 2,
fulfillmentEligibleOrders: 2,
fulfillmentOnTimeOrders: 1,
fulfillmentRate: 50,
pickupEligibleOrders: 2,
pickup24hRate: 50,
pickup48hRate: 100,
medianHours: 66,
})
expect(dashboard.distribution.map((item) => item.orders)).toEqual([0, 1, 0, 1])
expect(dashboard.rankings.logisticCompany[0]).toMatchObject({ label: '慢运', orders: 1, p90Hours: 96 })
expect(dashboard.stages.find((stage) => stage.key === 'payToSend')).toMatchObject({ targetHours: 24, targetLabel: '24h达成' })
expect(dashboard.stageRankings.payToSend.logisticCompany.find((row) => row.label === '快运')).toMatchObject({ p90Hours: 12, fulfillmentRate: 100, targetHours: 24 })
expect(dashboard.drilldownRows).toHaveLength(2)
})
it('按签收日汇总并保持采购、供应商、条码的真实归属', () => {
const base = {
soId: '', logisticCompanyName: '快运', logisticStatusName: '已签收',
logisticReturnFlag: '', orderLabels: '', remark: '', buyerMessage: '',
shopName: '店铺一', platformName: '平台一', warehouseName: '华东仓',
receiverAddress: '', sendTime: null, barcode: '', productName: '',
supplier: '', purchaser: '',
}
const rows = [
{
...base, innerOrderId: '1', logisticId: 'L1', payTime: '2026-09-01 00:00:00',
fetchTime: '2026-09-01 12:00:00', signTime: '2026-09-02 00:00:00',
productItems: [
{ barcode: 'B1', productName: '商品一', supplier: '供应商一', purchaser: '采购甲' },
{ barcode: 'B2', productName: '商品二', supplier: '供应商二', purchaser: '采购甲' },
],
},
{
...base, innerOrderId: '2', logisticId: 'L2', payTime: '2026-09-01 00:00:00',
fetchTime: '2026-09-03 00:00:00', signTime: '2026-09-05 00:00:00',
productItems: [{ barcode: 'B1', productName: '商品一', supplier: '供应商一', purchaser: '采购甲' }],
},
]
const analysis = buildPayToSignAnalysis(rows, '2026-09-02', '2026-09-06')
expect(analysis.total).toEqual({
orders: 2, averageHours: 60, pickup24hRate: 50, pickup48hRate: 100, fulfillmentRate: 50,
})
expect(analysis.daily.map((day) => day.orders)).toEqual([1, 0, 0, 1, 0])
expect(analysis.daily[0]).toMatchObject({
averageHours: 24, pickup24hRate: 100, pickup48hRate: 100, fulfillmentRate: 100,
})
expect(analysis.purchasers).toHaveLength(1)
expect(analysis.purchasers[0]).toMatchObject({ label: '采购甲', orders: 2 })
expect(analysis.suppliers.map((group) => [group.supplier, group.orders])).toEqual([
['供应商二', 1], ['供应商一', 2],
])
expect(analysis.products.find((group) => group.barcode === 'B1')).toMatchObject({
purchaser: '采购甲', supplier: '供应商一', orders: 2,
})
})
it('按有效环节时间计算多档履约率,边界时间只落入正确区间', () => {
const base = {
soId: '', logisticId: '', logisticCompanyName: '快运', logisticStatusName: '已签收',
logisticReturnFlag: '', orderLabels: '', remark: '', buyerMessage: '',
shopName: '', platformName: '', warehouseName: '华东仓', receiverAddress: '',
payTime: null, sendTime: '2026-09-01 00:00:00', fetchTime: null,
signTime: null, barcode: '', supplier: '', purchaser: '',
}
const afterHours = (hours: number) => new Date(Date.UTC(2026, 8, 1, hours)).toISOString()
const rows = [24, 48, 72, 73].map((hours, index) => ({
...base, innerOrderId: String(index), fetchTime: afterHours(hours), signTime: afterHours(hours * 2),
}))
rows.push({ ...base, innerOrderId: 'missing', fetchTime: null, signTime: null })
rows.push({ ...base, innerOrderId: 'invalid', fetchTime: '2026-08-31 00:00:00', signTime: '2026-08-30 00:00:00' })
const pickup = rankLogisticsArrivalSigningDurationThresholdRows(rows, 'sendToFetch', (row) => row.warehouseName)[0]
expect(pickup).toMatchObject({
label: '华东仓', orders: 6, eligibleOrders: 4, averageHours: 54.3,
within24hRate: 25, within48hRate: 50, over48hRate: 50,
})
const delivery = rankLogisticsArrivalSigningDurationThresholdRows(rows, 'fetchToSign', (row) => row.logisticCompanyName)[0]
expect(delivery).toMatchObject({
label: '快运', orders: 6, eligibleOrders: 4, averageHours: 54.3,
within24hRate: 25, within48hRate: 50, within72hRate: 75, over72hRate: 25,
})
})
})
describe('批量关键词查询', () => {
it('按中英文逗号/换行拆分,去重并限量', () => {
expect(splitSearchKeywords('a, b,c\nd')).toEqual(['a', 'b', 'c', 'd'])
expect(splitSearchKeywords('9150629,9159525,9150629')).toEqual(['9150629', '9159525'])
expect(splitSearchKeywords(' ')).toEqual([])
expect(splitSearchKeywords(Array.from({ length: 150 }, (_, index) => `k${index}`).join(','))).toHaveLength(100)
})
it('规范化过滤器时同时给出关键词列表,兼容只传 keyword 的调用方', () => {
const filters = normalizeLogisticsArrivalSigningDurationFilters({ keyword: ' 单号A, 单号B ' })
expect(filters.keywords).toEqual(['单号A', '单号B'])
expect(normalizeLogisticsArrivalSigningDurationFilters({ keyword: '单号A' }).keywords).toEqual(['单号A'])
expect(logisticsArrivalSigningDurationKeywords({ keyword: '' })).toEqual([])
expect(logisticsArrivalSigningDurationKeywords({ keyword: '', keywords: ['已解析'] })).toEqual(['已解析'])
})
})
describe('环节目标(上月平均 × 98%)', () => {
it('按上月平均下调 2% 得到目标值,无基准数据时返回空', () => {
expect(buildLogisticsArrivalSigningDurationStageTarget('2026-08', 60.5)).toEqual({
baselineMonth: '2026-08',
baselineAverageHours: 60.5,
targetHours: 59.3,
})
expect(buildLogisticsArrivalSigningDurationStageTarget('2026-08', 2.4971).targetHours).toBe(2.4)
expect(buildLogisticsArrivalSigningDurationStageTarget('2026-08', null)).toMatchObject({ targetHours: null })
})
it('基准月取所选区间结束月的上一个月,跨年正确', () => {
expect(logisticsArrivalSigningDurationBaselineMonth('2026-09-28')).toBe('2026-08')
expect(logisticsArrivalSigningDurationBaselineMonth('2026-01-15')).toBe('2025-12')
expect(logisticsArrivalSigningDurationBaselineMonth('')).toBe('')
})
it('给出基准月的首末日(含闰年二月)', () => {
expect(logisticsArrivalSigningDurationMonthRange('2026-08')).toEqual({ dateFrom: '2026-08-01', dateTo: '2026-08-31' })
expect(logisticsArrivalSigningDurationMonthRange('2024-02')).toEqual({ dateFrom: '2024-02-01', dateTo: '2024-02-29' })
expect(logisticsArrivalSigningDurationMonthRange('bad')).toBeNull()
})
})
describe('环节目标达成率', () => {
it('达成率 = 100% − (平均 − 目标) / 目标 × 100%', () => {
expect(buildLogisticsArrivalSigningDurationStageAchievement(61.4, 59.3)).toEqual({
averageHours: 61.4,
targetHours: 59.3,
rate: 96.5,
})
expect(buildLogisticsArrivalSigningDurationStageAchievement(2.4, 2.4)).toMatchObject({ rate: 100 })
expect(buildLogisticsArrivalSigningDurationStageAchievement(10.9, 10)).toMatchObject({ rate: 91 })
expect(buildLogisticsArrivalSigningDurationStageAchievement(null, 59.3)).toMatchObject({ rate: null })
expect(buildLogisticsArrivalSigningDurationStageAchievement(61.4, null)).toMatchObject({ rate: null })
expect(buildLogisticsArrivalSigningDurationStageAchievement(61.4, 0)).toMatchObject({ rate: null })
})
it('看板在传入目标时按目标补出各环节达成率', () => {
const row = {
innerOrderId: '1', soId: '', logisticId: '', logisticCompanyName: '快运', logisticStatusName: '已签收',
logisticReturnFlag: '', orderLabels: '', remark: '', buyerMessage: '', shopName: '', platformName: '',
warehouseName: '', receiverAddress: '', payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 05:00:00',
fetchTime: '2026-09-01 06:00:00', signTime: '2026-09-02 00:00:00', barcode: '', supplier: '', purchaser: '',
}
const dashboard = buildLogisticsArrivalSigningDurationDashboard([row], false, {
payToSend: buildLogisticsArrivalSigningDurationStageTarget('2026-08', 6),
})
// 付款→发货 实际 5 小时、目标 5.9 小时 → 100% − (5−5.9)/5.9×100% ≈ 115.3%
expect(dashboard.stageTargets?.payToSend?.targetHours).toBe(5.9)
expect(dashboard.stageAchievements?.payToSend).toMatchObject({ averageHours: 5, targetHours: 5.9, rate: 115.3 })
expect(dashboard.stageAchievements?.payToSign).toMatchObject({ rate: null })
})
})
describe('经营对象目标(该对象上月平均 × 98%)', () => {
const row = (purchaser: string, signHours: number) => ({
innerOrderId: purchaser, soId: '', logisticId: '', logisticCompanyName: '快运', logisticStatusName: '已签收',
logisticReturnFlag: '', orderLabels: '', remark: '', buyerMessage: '', shopName: '店铺一', platformName: '平台一',
warehouseName: '华东仓', receiverAddress: '', barcode: '', productName: '', supplier: `${purchaser}供应商`,
payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 06:00:00', fetchTime: '2026-09-01 08:00:00',
signTime: `2026-09-0${1 + signHours / 24} 00:00:00`,
productItems: [{ barcode: `B-${purchaser}`, productName: '商品', supplier: `${purchaser}供应商`, purchaser }],
})
// 采购甲 48 小时、采购乙 72 小时;基线分别是上月 50 / 80 小时。
const rows = [row('采购甲', 48), row('采购乙', 72)]
it('每个经营对象各自拿到目标与目标达成率,公式与履约环节拆解一致', () => {
const annotated = withObjectAnalysisTargets(buildPayToSignAnalysis(rows, '2026-09-01', '2026-09-05'), {
purchasers: {
[JSON.stringify(['采购甲'])]: 50,
[JSON.stringify(['采购乙'])]: 80,
},
})
// 甲:目标 = 50 × 98% = 49,达成率 = 100 − (48 − 49) / 49 × 100% = 102.0%
expect(annotated.purchasers.find((group) => group.label === '采购甲')).toMatchObject({
averageHours: 48,
targetHours: 49,
achievementRate: 102,
})
// 乙:目标 = 80 × 98% = 78.4,达成率 = 100 − (72 − 78.4) / 78.4 × 100% = 108.2%
expect(annotated.purchasers.find((group) => group.label === '采购乙')).toMatchObject({
averageHours: 72,
targetHours: 78.4,
achievementRate: 108.2,
})
// 该维度没有基线的对象只留空,不会串到别的维度。
expect(annotated.suppliers.every((group) => group.targetHours === null && group.achievementRate === null)).toBe(true)
})
it('没有上月基线时保持原样(目标列显示 -)', () => {
const annotated = withObjectAnalysisTargets(buildPayToSignAnalysis(rows, '2026-09-01', '2026-09-05'), undefined)
expect(annotated.purchasers[0].targetHours).toBeUndefined()
expect(buildLogisticsArrivalSigningDurationObjectTarget(50)).toBe(49)
expect(buildLogisticsArrivalSigningDurationObjectTarget(undefined)).toBeNull()
})
})
describe('异常订单筛选', () => {
const base = {
innerOrderId: '1', soId: '', logisticId: '', logisticCompanyName: '', logisticStatusName: '已签收',
logisticReturnFlag: '', orderLabels: '', remark: '', buyerMessage: '', shopName: '', platformName: '',
warehouseName: '', receiverAddress: '', barcode: '', supplier: '', purchaser: '',
payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 02:00:00', fetchTime: '2026-09-01 03:00:00',
signTime: '2026-09-02 00:00:00',
}
it('缺少关键时间 = 任一关键时间为空', () => {
const missing = { ...base, fetchTime: null }
expect(logisticsArrivalSigningDurationRowMatchesFilter(missing, { kind: 'anomaly', value: 'missing' })).toBe(true)
expect(logisticsArrivalSigningDurationRowMatchesFilter(base, { kind: 'anomaly', value: 'missing' })).toBe(false)
})
it('时间异常 = 四个时间齐全但顺序算不出时长', () => {
const invalid = { ...base, fetchTime: '2026-08-31 23:00:00' } // 揽收早于付款
expect(logisticsArrivalSigningDurationRowMatchesFilter(invalid, { kind: 'anomaly', value: 'invalid' })).toBe(true)
expect(logisticsArrivalSigningDurationRowMatchesFilter(base, { kind: 'anomaly', value: 'invalid' })).toBe(false)
expect(logisticsArrivalSigningDurationRowMatchesFilter({ ...base, fetchTime: null }, { kind: 'anomaly', value: 'invalid' })).toBe(false)
})
})

File diff suppressed because it is too large Load Diff

View File

@ -256,4 +256,29 @@ describe('supply chain permission settings', () => {
'workspace:user@valid-user_1:view': true,
})
})
it('maps the logistics arrival signing duration dashboard and its APIs to business management', () => {
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/management/logistics-arrival-signing-duration'),
).toBe('businessManagement')
expect(
getSupplyChainApiPermissionRequirement(
'/api/supply-chain/management/logistics-arrival-signing-duration',
'GET',
),
).toEqual({ targetKey: 'businessManagement', action: 'view' })
// 导出按 view 放行(能否导出由页面自身控制),写入类接口按 edit 校验。
expect(
getSupplyChainApiPermissionRequirement(
'/api/supply-chain/management/logistics-arrival-signing-duration/export',
'POST',
),
).toEqual({ targetKey: 'businessManagement', action: 'view' })
expect(
getSupplyChainApiPermissionRequirement(
'/api/supply-chain/management/logistics-arrival-signing-duration/settings',
'PUT',
),
).toEqual({ targetKey: 'businessManagement', action: 'edit' })
})
})

View File

@ -330,6 +330,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string) {
if (pathname === '/supply-chain/management/custom-table') return 'businessManagement' as const
if (isManagementBlankPagePath(pathname)) return 'businessManagement' as const
if (pathname === '/supply-chain/management/employee-hours') return 'employeeHours' as const
if (pathname === '/supply-chain/management/logistics-arrival-signing-duration') return 'businessManagement' as const
if (pathname === '/supply-chain/database/product-archive') return 'productArchive' as const
if (pathname === '/supply-chain/database/category-config') return 'categoryConfig' as const
if (pathname === '/supply-chain/database/warehouse-config') return 'warehouseConfig' as const
@ -354,6 +355,13 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
if (pathname === '/api/supply-chain/permission-settings') return null
const action = method.toUpperCase() === 'GET' ? 'view' : 'edit'
if (pathname.startsWith('/api/supply-chain/management/logistics-arrival-signing-duration')) {
if (pathname.endsWith('/export')) {
// 该看板允许所有能看的人导出当前筛选明细。
return { targetKey: 'businessManagement' as const, action: 'view' as const }
}
return { targetKey: 'businessManagement' as const, action }
}
if (pathname.startsWith('/api/supply-chain/management/employee-hours')) {
return { targetKey: 'employeeHours' as const, action: 'view' as const }
}

View File

@ -0,0 +1,974 @@
import sql from 'mssql'
import { getSupplyChainArchiveReadPool } from '@/server/repositories/supply-chain-archive-read-pool'
import {
logisticsArrivalSigningDurationSnapshotRepository as snapshotRepository,
type LogisticsArrivalSigningDurationSnapshotRun,
} from '@/server/repositories/logistics-arrival-signing-duration-snapshot.repository'
import type {
LogisticsArrivalSigningDurationFilters,
LogisticsArrivalSigningDurationDashboard,
LogisticsArrivalSigningDurationDrilldownKey,
LogisticsArrivalSigningDurationRow,
} from '@/lib/logistics-arrival-signing-duration'
import {
buildLogisticsArrivalSigningDurationDashboard,
buildLogisticsArrivalSigningDurationStageAchievement,
buildLogisticsArrivalSigningDurationStageTarget,
compareLogisticsArrivalSigningDurationThresholdRows,
excludeKeywordEntries,
excludeKeywordsCacheKey,
logisticsArrivalSigningDurationBaselineMonth,
logisticsArrivalSigningDurationKeywords,
logisticsArrivalSigningDurationMonthRange,
logisticsArrivalSigningDurationRowKey,
logisticsArrivalSigningDurationSnapshotCovers,
} from '@/lib/logistics-arrival-signing-duration'
import { LOGISTICS_ARRIVAL_SIGNING_DURATION_STAGES } from '@/lib/logistics-arrival-signing-duration'
import type {
LogisticsArrivalSigningDurationDrilldownFilter,
LogisticsArrivalSigningDurationExcludeFieldKey,
LogisticsArrivalSigningDurationObjectBaselines,
LogisticsArrivalSigningDurationStageAchievement,
LogisticsArrivalSigningDurationStageKey,
LogisticsArrivalSigningDurationStageTarget,
LogisticsArrivalSigningDurationThresholdRankings,
} from '@/lib/logistics-arrival-signing-duration'
type SqlExpressRow = {
inner_order_id: number | string | null
so_id: string | null
logistic_id: string | null
logistic_company_name: string | null
logistic_status_name: string | null
logistic_return_flag: string | null
order_labels: string | null
remark: string | null
buyer_message: string | null
shop_name: string | null
platform_name: string | null
warehouse_name: string | null
receiver_address: string | null
pay_time: string | null
send_time: string | null
fetch_time: string | null
sign_time: string | null
}
type SqlItemRow = {
o_id: number | string | null
outer_so_id: string | null
raw_so_id: string | null
sku_id: string | null
i_id: string | null
l_id: string | null
pay_date: string | null
}
type ArchiveRecord = { barcode: string; productName: string; supplier: string; purchaser: string }
type LocalCacheEntry<T> = { value: T; cachedAt: number }
type SqlPoolGlobal = typeof globalThis & {
logisticsArrivalSigningDurationPool?: sql.ConnectionPool
}
const EXPRESS_OBJECT = 'jst_order_express_details'
const ITEM_OBJECT = 'jst_qmcust_orderlist_item'
// 报表库把订单拆成两套互补的表:jst_qmcust_* 只覆盖淘宝天猫/1688/淘工厂/天猫供销,
// 京东/拼多多/抖音/得物/唯品会等平台在 jst_Orders + jst_OrderItem 里。只看一套会漏掉一半订单的条码。
const ITEM_OBJECT_OTHER = 'jst_OrderItem'
const ORDER_OBJECT_OTHER = 'jst_Orders'
export const LOGISTICS_ARRIVAL_SIGNING_DURATION_EXPORT_MAX_ROWS = 50_000
export const LOGISTICS_ARRIVAL_SIGNING_DURATION_DASHBOARD_MAX_ROWS = 2_000
// The report objects are remote views. Fifteen seconds caused healthy but
// slow scans to be reported as failures before SQL Server could return rows.
// Service-level caching below keeps repeat refreshes fast once a scan succeeds.
const PAGE_REQUEST_TIMEOUT_MS = 45_000
const EXPORT_REQUEST_TIMEOUT_MS = 120_000
const ENRICHMENT_CACHE_TTL_MS = 10 * 60_000
const MAX_ENRICHMENT_CACHE_ENTRIES = 20_000
// 条码明细来自远端视图,一次查询的往返成本远高于单条数据量;SQL Server 单请求
// 参数上限为 2100,这里取 1000 兼顾单次往返成本与安全余量。每日快照要富化十万级
// 订单,批次过小会把整批同步时间拖到数十分钟。
const ITEM_LOOKUP_BATCH_SIZE = 1_000
// The report view is remote and expensive to scan. These small local caches
// keep the follow-up item/archive lookups out of the critical refresh path.
const itemRowsCache = new Map<string, LocalCacheEntry<SqlItemRow[]>>()
const archiveCache = new Map<string, LocalCacheEntry<ArchiveRecord | null>>()
function requiredEnv(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`Missing required environment variable: ${name}`)
return value
}
function boolEnv(name: string, fallback: boolean) {
const value = process.env[name]?.trim().toLowerCase()
return value ? value === 'true' || value === '1' : fallback
}
function identifier(value: string, name: string) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new Error(`${name} is not a valid SQL identifier`)
return value
}
function sourceObject(name: string) {
const schema = identifier(process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt', 'JST_REPORT_DB_SCHEMA')
return `[${schema}].[${identifier(name, 'source object')}]`
}
function text(value: unknown) {
return String(value ?? '').trim()
}
function unique(values: string[]) {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
}
function whereParts(filters: LogisticsArrivalSigningDurationFilters, request: sql.Request) {
const conditions = [
`e.[logistic_status_name] = N'已签收'`,
// A row is eligible unless it has explicitly been marked as intercepted.
// Treat NULL as an empty status so missing values are not filtered out.
`(e.[logistic_return_flag] <> N'已拦截' OR e.[logistic_return_flag] IS NULL)`,
`e.[sign_time] >= CONVERT(datetime2, @dateFrom, 23)`,
`e.[sign_time] < DATEADD(day, 1, CONVERT(datetime2, @dateTo, 23))`,
]
request.input('dateFrom', sql.VarChar(10), filters.dateFrom)
request.input('dateTo', sql.VarChar(10), filters.dateTo)
// 批量查询:多个关键词之间是"或",每个关键词命中任一字段即算匹配。
logisticsArrivalSigningDurationKeywords(filters).forEach((keyword, index) => {
const name = `keyword${index}`
request.input(name, sql.NVarChar(120), `%${keyword}%`)
conditions.push(`(
CONVERT(nvarchar(80), e.[inner_order_id]) LIKE @${name}
OR e.[so_id] LIKE @${name}
OR e.[logistic_id] LIKE @${name}
OR e.[logistic_company_name] LIKE @${name}
OR e.[shop_name] LIKE @${name}
OR e.[platform_name] LIKE @${name}
OR e.[warehouse_name] LIKE @${name}
OR e.[receiver_address] LIKE @${name}
)`)
})
// 排除词按字段配置:每个词只比对它自己那个字段(不再用"命中任意字段就排除")。
const excludeColumn: Record<LogisticsArrivalSigningDurationExcludeFieldKey, string> = {
orderLabels: 'order_labels',
remark: 'remark',
buyerMessage: 'buyer_message',
warehouseName: 'warehouse_name',
logisticCompanyName: 'logistic_company_name',
}
excludeKeywordEntries(filters.excludeKeywords).forEach((entry, index) => {
const name = `excludeKeyword${index}`
request.input(name, sql.NVarChar(200), `%${entry.keyword}%`)
conditions.push(`NOT (ISNULL(e.[${excludeColumn[entry.field]}], N'') LIKE @${name})`)
})
return conditions.join('\n AND ')
}
function addRequestParams(request: sql.Request, filters: LogisticsArrivalSigningDurationFilters) {
request.input('offset', sql.Int, (filters.page - 1) * filters.pageSize)
// Fetch one extra row so pagination does not depend on a whole-table count.
request.input('take', sql.Int, filters.pageSize + 1)
return request
}
const EXPRESS_SELECT = `
e.[inner_order_id], e.[so_id], e.[logistic_id], e.[logistic_company_name],
e.[logistic_status_name], e.[logistic_return_flag], e.[order_labels],
e.[remark], e.[buyer_message], e.[shop_name], e.[platform_name],
e.[warehouse_name], e.[receiver_address],
CONVERT(varchar(19), e.[pay_time], 120) AS [pay_time],
CONVERT(varchar(19), e.[send_time], 120) AS [send_time],
CONVERT(varchar(19), e.[fetch_time], 120) AS [fetch_time],
CONVERT(varchar(19), e.[sign_time], 120) AS [sign_time]`
// The dashboard only needs dimensions and milestone timestamps. Avoid loading
// multi-kilobyte remarks/messages or sorting the remote view for its sample.
// The detail page and export continue to use EXPRESS_SELECT with stable order.
const DASHBOARD_SELECT = `
e.[inner_order_id], e.[so_id], e.[logistic_id], e.[logistic_company_name],
e.[logistic_status_name], e.[logistic_return_flag],
e.[shop_name], e.[platform_name], e.[warehouse_name],
CONVERT(varchar(19), e.[pay_time], 120) AS [pay_time],
CONVERT(varchar(19), e.[send_time], 120) AS [send_time],
CONVERT(varchar(19), e.[fetch_time], 120) AS [fetch_time],
CONVERT(varchar(19), e.[sign_time], 120) AS [sign_time]`
function createSqlPool() {
const globalForPool = globalThis as SqlPoolGlobal
if (globalForPool.logisticsArrivalSigningDurationPool) return globalForPool.logisticsArrivalSigningDurationPool
const pool = new sql.ConnectionPool({
server: requiredEnv('JST_REPORT_DB_HOST'),
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
database: requiredEnv('JST_REPORT_DB_NAME'),
user: requiredEnv('JST_REPORT_DB_USER'),
password: requiredEnv('JST_REPORT_DB_PASSWORD'),
options: {
encrypt: boolEnv('JST_REPORT_DB_ENCRYPT', true),
trustServerCertificate: boolEnv('JST_REPORT_DB_TRUST_SERVER_CERTIFICATE', true),
readOnlyIntent: true,
appName: 'furniture-logistics-arrival-signing-readonly',
},
pool: { min: 1, max: 6, idleTimeoutMillis: 60_000 },
connectionTimeout: 20_000,
requestTimeout: PAGE_REQUEST_TIMEOUT_MS,
})
globalForPool.logisticsArrivalSigningDurationPool = pool
return pool
}
/**
* 关闭远端报表连接池。远端池是长连接,常驻进程的看板读路径不应关闭它;
* 只有一次性同步任务(每日快照)在收尾时调用,否则进程会一直挂在事件循环上。
*/
export async function closeLogisticsArrivalSigningDurationSourcePool() {
const globalForPool = globalThis as SqlPoolGlobal
const pool = globalForPool.logisticsArrivalSigningDurationPool
if (!pool) return
globalForPool.logisticsArrivalSigningDurationPool = undefined
try {
await pool.close()
} catch (error) {
console.warn('[logistics-arrival-signing-duration] source pool close skipped', error)
}
}
async function listSqlRows(filters: LogisticsArrivalSigningDurationFilters) {
const pool = await createSqlPool().connect()
const request = addRequestParams(pool.request(), filters)
request.timeout = PAGE_REQUEST_TIMEOUT_MS
const where = whereParts(filters, request)
const result = await request.query<SqlExpressRow>(`
SELECT ${EXPRESS_SELECT}
FROM ${sourceObject(EXPRESS_OBJECT)} AS e
WHERE ${where}
-- The report object is a remote view. Sorting it by sign_time forces a
-- large remote sort before returning the first page. Detail users do not
-- rely on row order, so use a no-op order for fast OFFSET pagination.
ORDER BY (SELECT NULL)
OFFSET @offset ROWS FETCH NEXT @take ROWS ONLY
`)
return result.recordset
}
async function listSqlRowsForExport(
filters: LogisticsArrivalSigningDurationFilters,
limit = LOGISTICS_ARRIVAL_SIGNING_DURATION_EXPORT_MAX_ROWS,
) {
const pool = await createSqlPool().connect()
const request = pool.request()
request.timeout = EXPORT_REQUEST_TIMEOUT_MS
request.input('take', sql.Int, limit + 1)
const where = whereParts(filters, request)
const result = await request.query<SqlExpressRow>(`
SELECT TOP (@take) ${EXPRESS_SELECT}
FROM ${sourceObject(EXPRESS_OBJECT)} AS e
WHERE ${where}
ORDER BY e.[sign_time] DESC, e.[id] DESC
`)
return { rows: result.recordset.slice(0, limit), truncated: result.recordset.length > limit }
}
async function listSqlRowsForDashboard(filters: LogisticsArrivalSigningDurationFilters) {
const pool = await createSqlPool().connect()
const request = pool.request()
request.timeout = PAGE_REQUEST_TIMEOUT_MS
request.input('take', sql.Int, LOGISTICS_ARRIVAL_SIGNING_DURATION_DASHBOARD_MAX_ROWS + 1)
const where = whereParts(filters, request)
const result = await request.query<SqlExpressRow>(`
SELECT TOP (@take) ${DASHBOARD_SELECT}
FROM ${sourceObject(EXPRESS_OBJECT)} AS e
WHERE ${where}
`)
return { rows: result.recordset.slice(0, LOGISTICS_ARRIVAL_SIGNING_DURATION_DASHBOARD_MAX_ROWS), truncated: result.recordset.length > LOGISTICS_ARRIVAL_SIGNING_DURATION_DASHBOARD_MAX_ROWS }
}
async function listSqlRowsForDrilldown(
filters: LogisticsArrivalSigningDurationFilters,
keys: LogisticsArrivalSigningDurationDrilldownKey[],
) {
if (!keys.length) return []
const pool = await createSqlPool().connect()
const request = pool.request()
request.timeout = EXPORT_REQUEST_TIMEOUT_MS
const where = whereParts(filters, request)
const orderIds = unique(keys.map((key) => key.innerOrderId).filter((value) => /^\d{1,18}$/.test(value)))
const logisticIds = unique(keys.filter((key) => !/^\d{1,18}$/.test(key.innerOrderId)).map((key) => key.logisticId))
const soIds = unique(keys.filter((key) => !/^\d{1,18}$/.test(key.innerOrderId) && !key.logisticId).map((key) => key.soId))
const conditions: string[] = []
if (orderIds.length) {
const params = orderIds.map((value, index) => {
const name = `drilldownOrderId${index}`
request.input(name, sql.BigInt, value)
return `@${name}`
})
conditions.push(`e.[inner_order_id] IN (${params.join(', ')})`)
}
if (logisticIds.length) {
const params = logisticIds.map((value, index) => {
const name = `drilldownLogisticId${index}`
request.input(name, sql.NVarChar(120), value)
return `@${name}`
})
conditions.push(`e.[logistic_id] IN (${params.join(', ')})`)
}
if (soIds.length) {
const params = soIds.map((value, index) => {
const name = `drilldownSoId${index}`
request.input(name, sql.NVarChar(120), value)
return `@${name}`
})
conditions.push(`e.[so_id] IN (${params.join(', ')})`)
}
if (!conditions.length) return []
const result = await request.query<SqlExpressRow>(`
SELECT ${EXPRESS_SELECT}
FROM ${sourceObject(EXPRESS_OBJECT)} AS e
WHERE ${where} AND (${conditions.join(' OR ')})
`)
const matching = new Map<string, SqlExpressRow>()
const wanted = new Set(keys.map(logisticsArrivalSigningDurationRowKey))
for (const row of result.recordset) {
const key = logisticsArrivalSigningDurationRowKey({
innerOrderId: text(row.inner_order_id), soId: text(row.so_id),
logisticId: text(row.logistic_id), signTime: row.sign_time,
})
if (wanted.has(key)) matching.set(key, row)
}
return keys.map((key) => matching.get(logisticsArrivalSigningDurationRowKey(key))).filter((row): row is SqlExpressRow => Boolean(row))
}
async function listItemRows(rows: SqlExpressRow[]) {
const orderIds = unique(rows.map((row) => text(row.inner_order_id)))
if (!orderIds.length) return []
const now = Date.now()
const cachedRows = new Map<string, SqlItemRow[]>()
const missingOrderIds = orderIds.filter((orderId) => {
const entry = itemRowsCache.get(orderId)
if (!entry) return true
if (now - entry.cachedAt > ENRICHMENT_CACHE_TTL_MS) return true
cachedRows.set(orderId, entry.value)
return false
})
if (!missingOrderIds.length) return Array.from(cachedRows.values()).flat()
const pool = await createSqlPool().connect()
const batches = Array.from({ length: Math.ceil(missingOrderIds.length / ITEM_LOOKUP_BATCH_SIZE) }, (_, index) =>
missingOrderIds.slice(index * ITEM_LOOKUP_BATCH_SIZE, index * ITEM_LOOKUP_BATCH_SIZE + ITEM_LOOKUP_BATCH_SIZE),
)
try {
const batchResults = await Promise.all(batches.map(async (batch) => {
const request = pool.request()
// 条码明细要查两套订单表,批量 IN 在远端视图上可能较慢,用导出级超时。
request.timeout = EXPORT_REQUEST_TIMEOUT_MS
const orderParams = batch.map((_, index) => `@orderId${index}`)
batch.forEach((value, index) => request.input(`orderId${index}`, sql.BigInt, value))
const batchResult = await request.query<SqlItemRow>(`
SELECT oi.[o_id], oi.[outer_so_id], oi.[raw_so_id], oi.[sku_id], oi.[i_id],
o.[l_id], CONVERT(varchar(19), o.[pay_date], 120) AS [pay_date]
FROM ${sourceObject(ITEM_OBJECT)} AS oi
LEFT JOIN ${sourceObject('jst_qmcust_orderlist')} AS o ON o.[o_id] = oi.[o_id]
WHERE oi.[o_id] IN (${orderParams.join(', ')})
`)
// 第二套订单表(京东/拼多多/抖音/得物/唯品会等)用同一批订单号查询并合并,
// 两套表的 o_id 互不重叠,合并后即覆盖全部平台。
const otherRequest = pool.request()
otherRequest.timeout = EXPORT_REQUEST_TIMEOUT_MS
batch.forEach((value, index) => otherRequest.input(`orderId${index}`, sql.BigInt, value))
const otherResult = await otherRequest.query<SqlItemRow>(`
SELECT oi.[o_id], CONVERT(nvarchar(120), o.[so_id]) AS [outer_so_id], oi.[raw_so_id], oi.[sku_id], oi.[i_id],
o.[l_id], CONVERT(varchar(19), o.[pay_date], 120) AS [pay_date]
FROM ${sourceObject(ITEM_OBJECT_OTHER)} AS oi
LEFT JOIN ${sourceObject(ORDER_OBJECT_OTHER)} AS o ON o.[o_id] = oi.[o_id]
WHERE oi.[o_id] IN (${orderParams.join(', ')})
`)
return [...batchResult.recordset, ...otherResult.recordset]
}))
const fetchedRows = batchResults.flat()
const fetchedByOrderId = new Map<string, SqlItemRow[]>()
for (const item of fetchedRows) {
const orderId = text(item.o_id)
const current = fetchedByOrderId.get(orderId) ?? []
current.push(item)
fetchedByOrderId.set(orderId, current)
}
for (const orderId of missingOrderIds) {
const matching = fetchedByOrderId.get(orderId) ?? []
cachedRows.set(orderId, matching)
itemRowsCache.set(orderId, { value: matching, cachedAt: now })
}
while (itemRowsCache.size > MAX_ENRICHMENT_CACHE_ENTRIES) {
const oldestKey = itemRowsCache.keys().next().value
if (!oldestKey) break
itemRowsCache.delete(oldestKey)
}
return Array.from(cachedRows.values()).flat()
} catch (error) {
// A cached barcode mapping is still useful when the optional enrichment
// source is temporarily unavailable; the caller can render blank fields
// only for orders that have never been enriched.
if (cachedRows.size) return Array.from(cachedRows.values()).flat()
throw error
}
}
function itemKeyCandidates(row: SqlExpressRow) {
return [text(row.inner_order_id), text(row.so_id)].filter(Boolean)
}
function buildBarcodeMap(rows: SqlExpressRow[], items: SqlItemRow[]) {
const map = new Map<string, string[]>()
for (const item of items) {
const barcode = text(item.sku_id) || text(item.i_id)
if (!barcode) continue
for (const key of [text(item.o_id), text(item.outer_so_id), text(item.raw_so_id)].filter(Boolean)) {
const current = map.get(key) ?? []
if (!current.includes(barcode)) current.push(barcode)
map.set(key, current)
}
if (item.l_id) {
const current = map.get(text(item.l_id)) ?? []
if (!current.includes(barcode)) current.push(barcode)
map.set(text(item.l_id), current)
if (item.pay_date) {
const dateKey = `${text(item.l_id)}|${text(item.pay_date)}`
const dateCurrent = map.get(dateKey) ?? []
if (!dateCurrent.includes(barcode)) dateCurrent.push(barcode)
map.set(dateKey, dateCurrent)
}
}
}
return new Map(rows.map((row) => [
row,
unique([
...itemKeyCandidates(row).flatMap((key) => map.get(key) ?? []),
...(map.get(`${text(row.logistic_id)}|${text(row.pay_time)}`) ?? []),
...((map.get(text(row.logistic_id)) ?? [])),
]).join(', '),
]))
}
async function listArchiveRecords(barcodes: string[]) {
const normalized = unique(barcodes.map((value) => value.toUpperCase()))
if (!normalized.length) return new Map<string, ArchiveRecord>()
const now = Date.now()
const resultMap = new Map<string, ArchiveRecord>()
const missing = normalized.filter((barcode) => {
const entry = archiveCache.get(barcode)
if (!entry || now - entry.cachedAt > ENRICHMENT_CACHE_TTL_MS) return true
if (entry.value) resultMap.set(barcode, entry.value)
return false
})
if (!missing.length) return resultMap
try {
const pool = getSupplyChainArchiveReadPool()
const result = await pool.query<ArchiveRecord>(`
SELECT
"barcode",
"productName",
"supplierName" AS supplier,
"purchaseOwner" AS purchaser
FROM public."SupplyChainProductArchive"
WHERE UPPER(BTRIM("barcode")) = ANY($1::text[])
`, [missing])
const fetched = new Map(result.rows.map((row) => [row.barcode.trim().toUpperCase(), {
barcode: row.barcode,
productName: row.productName ?? '',
supplier: row.supplier ?? '',
purchaser: row.purchaser ?? '',
}]))
for (const barcode of missing) {
const value = fetched.get(barcode) ?? null
archiveCache.set(barcode, { value, cachedAt: now })
if (value) resultMap.set(barcode, value)
}
while (archiveCache.size > MAX_ENRICHMENT_CACHE_ENTRIES) {
const oldestKey = archiveCache.keys().next().value
if (!oldestKey) break
archiveCache.delete(oldestKey)
}
return resultMap
} catch (error) {
if (resultMap.size) return resultMap
throw error
}
}
function formatRow(row: SqlExpressRow, barcode: string, archive: Map<string, ArchiveRecord>): LogisticsArrivalSigningDurationRow {
const productItems = unique(barcode.split(',').map((item) => item.toUpperCase()))
.map((item) => ({
barcode: item,
productName: archive.get(item)?.productName ?? '',
supplier: archive.get(item)?.supplier ?? '',
purchaser: archive.get(item)?.purchaser ?? '',
}))
return {
innerOrderId: text(row.inner_order_id),
soId: text(row.so_id),
logisticId: text(row.logistic_id),
logisticCompanyName: text(row.logistic_company_name),
logisticStatusName: text(row.logistic_status_name),
logisticReturnFlag: text(row.logistic_return_flag),
orderLabels: text(row.order_labels),
remark: text(row.remark),
buyerMessage: text(row.buyer_message),
shopName: text(row.shop_name),
platformName: text(row.platform_name),
warehouseName: text(row.warehouse_name),
receiverAddress: text(row.receiver_address),
payTime: row.pay_time,
sendTime: row.send_time,
fetchTime: row.fetch_time,
signTime: row.sign_time,
barcode,
productName: unique(productItems.map((item) => item.productName)).join(', '),
supplier: unique(productItems.map((item) => item.supplier)).join(', '),
purchaser: unique(productItems.map((item) => item.purchaser)).join(', '),
productItems,
}
}
async function enrichRows(sqlRows: SqlExpressRow[]) {
let items: SqlItemRow[] = []
try {
items = await listItemRows(sqlRows)
} catch (error) {
console.warn('[logistics-arrival-signing-duration] item lookup skipped', error)
}
const barcodeMap = buildBarcodeMap(sqlRows, items)
let archive = new Map<string, ArchiveRecord>()
try {
archive = await listArchiveRecords(Array.from(barcodeMap.values()).flatMap((value) => value.split(', ')))
} catch (error) {
console.warn('[logistics-arrival-signing-duration] archive lookup skipped', error)
}
return sqlRows.map((row) => formatRow(row, barcodeMap.get(row) ?? '', archive))
}
/**
* 命中每日快照的条件:存在已完成的快照批次,且请求区间完全落在该批次的覆盖窗口内。
* 区间越界时回落实时数据源,避免返回不完整的快照结果。
*/
async function resolveSnapshot(filters: LogisticsArrivalSigningDurationFilters) {
const run = await snapshotRepository.findLatestCompletedRun()
return run && logisticsArrivalSigningDurationSnapshotCovers(run, filters) ? run : null
}
/**
* 快照读取兜底:快照表被删、批次被清、Prisma 客户端未重新生成等情况都不应让看板报错,
* 一律退回远端实时数据源(慢但正确),并保留一条可排查的日志。
*/
async function readSnapshotOrFallback<T>(
label: string,
loader: () => Promise<T>,
): Promise<T | null> {
try {
return await loader()
} catch (error) {
console.warn(`[logistics-arrival-signing-duration] snapshot ${label} failed; falling back to live source`, error)
return null
}
}
function snapshotSource(run: LogisticsArrivalSigningDurationSnapshotRun) {
return {
source: 'snapshot' as const,
snapshotDate: run.snapshotDate,
dataAsOf: run.finishedAt ?? `${run.snapshotDate}T00:00:00.000Z`,
}
}
const LIVE_SOURCE = { source: 'live' as const, snapshotDate: null }
/**
* 整区间精确口径的规模上限:SQL 聚合的成本与区间行数近似线性(约 5 万行/秒),
* 超过这个量级继续整块精确聚合会让首屏等待进入十秒级。超出时退回区间内均匀样本,
* 并在看板上如实标注原因与"缩小日期范围可得精确值"的提示。
*/
export const LOGISTICS_ARRIVAL_SIGNING_DURATION_EXACT_SCOPE_MAX_ROWS = 110_000
function percentOrNull(numerator: number, denominator: number) {
return denominator ? Math.round((numerator / denominator) * 1000) / 10 : null
}
/**
* 本月目标 = 上月同环节平均时长 × 98%。
* 口径跟随页面筛选(日期区间换成上个月),不跟随看板内的联动链路。
*/
async function loadStageTargets(
filters: LogisticsArrivalSigningDurationFilters,
snapshotDate: string,
): Promise<Partial<Record<LogisticsArrivalSigningDurationStageKey, LogisticsArrivalSigningDurationStageTarget>>> {
const baselineMonth = logisticsArrivalSigningDurationBaselineMonth(filters.dateTo)
const baselineRange = logisticsArrivalSigningDurationMonthRange(baselineMonth)
const baselineAverages = baselineRange
? await snapshotRepository.aggregateStageAverages(filters, snapshotDate, baselineRange.dateFrom, baselineRange.dateTo)
: {}
return Object.fromEntries(
LOGISTICS_ARRIVAL_SIGNING_DURATION_STAGES.map((stage) => [
stage.key,
buildLogisticsArrivalSigningDurationStageTarget(baselineMonth, baselineAverages[stage.key] ?? null),
]),
) as Partial<Record<LogisticsArrivalSigningDurationStageKey, LogisticsArrivalSigningDurationStageTarget>>
}
/**
* 各经营对象的上月平均时长基线(目标 = 该对象上月平均 × 98%)。
* 与环节目标同一口径:跟随页面筛选,但日期区间换成上个月,且不跟随看板内的联动链路。
* 这一段是"锦上添花"的指标,查询失败时只让目标列显示 `-`,不把整块看板拖回实时源。
*/
/**
* 上月基线按「快照日期 + 基准月 + 关键词/排除词」缓存 10 分钟:
* 这些平均值只在每日快照重建时才会变,没必要跟着 60 秒的看板缓存反复重算(一次约 2–4 秒)。
*/
const OBJECT_BASELINE_CACHE_TTL_MS = 10 * 60_000
type ObjectBaselineCacheEntry = {
value: Partial<Record<'payToSign' | 'payToSend', LogisticsArrivalSigningDurationObjectBaselines>>
cachedAt: number
}
type ObjectBaselineCacheGlobal = typeof globalThis & {
logisticsArrivalSigningDurationObjectBaselineCache?: Map<string, ObjectBaselineCacheEntry>
}
const objectBaselineCache = new Map<string, ObjectBaselineCacheEntry>()
const objectBaselineCacheGlobal = globalThis as ObjectBaselineCacheGlobal
const sharedObjectBaselineCache =
objectBaselineCacheGlobal.logisticsArrivalSigningDurationObjectBaselineCache ?? objectBaselineCache
objectBaselineCacheGlobal.logisticsArrivalSigningDurationObjectBaselineCache = sharedObjectBaselineCache
function objectBaselineCacheKey(
filters: LogisticsArrivalSigningDurationFilters,
snapshotDate: string,
range: { dateFrom: string; dateTo: string },
) {
return JSON.stringify({
snapshotDate,
from: range.dateFrom,
to: range.dateTo,
keywords: [...logisticsArrivalSigningDurationKeywords(filters)].sort(),
excludeKeywords: excludeKeywordsCacheKey(filters.excludeKeywords),
})
}
async function loadObjectBaselines(
filters: LogisticsArrivalSigningDurationFilters,
snapshotDate: string,
): Promise<Partial<Record<'payToSign' | 'payToSend', LogisticsArrivalSigningDurationObjectBaselines>> | undefined> {
const baselineRange = logisticsArrivalSigningDurationMonthRange(
logisticsArrivalSigningDurationBaselineMonth(filters.dateTo),
)
if (!baselineRange) return undefined
const key = objectBaselineCacheKey(filters, snapshotDate, baselineRange)
const cached = sharedObjectBaselineCache.get(key)
if (cached && Date.now() - cached.cachedAt < OBJECT_BASELINE_CACHE_TTL_MS) return cached.value
try {
const value = await snapshotRepository.aggregateObjectBaselines(
filters,
snapshotDate,
baselineRange.dateFrom,
baselineRange.dateTo,
)
if (sharedObjectBaselineCache.size > 100) sharedObjectBaselineCache.clear()
sharedObjectBaselineCache.set(key, { value, cachedAt: Date.now() })
return value
} catch (error) {
console.warn('[logistics-arrival-signing-duration] 经营对象上月基线不可用,目标列将显示 -', error)
return undefined
}
}
/** 各环节目标达成率:达成率 = 100% − (平均 − 目标) / 目标 × 100%(用展示用的取整后数值计算,便于对账)。 */
function buildStageAchievements(
stageAverages: Partial<Record<LogisticsArrivalSigningDurationStageKey, number | null>>,
targets: Partial<Record<LogisticsArrivalSigningDurationStageKey, LogisticsArrivalSigningDurationStageTarget>>,
) {
return Object.fromEntries(
LOGISTICS_ARRIVAL_SIGNING_DURATION_STAGES.map((stage) => {
const average = stageAverages[stage.key] ?? null
return [stage.key, buildLogisticsArrivalSigningDurationStageAchievement(
average === null ? null : Math.round(average * 10) / 10,
targets[stage.key]?.targetHours ?? null,
)]
}),
) as Partial<Record<LogisticsArrivalSigningDurationStageKey, LogisticsArrivalSigningDurationStageAchievement>>
}
/** 把 SQL 聚合出的发货仓/物流公司计数换算成看板排行行,并使用与样本路径同一比较器排序。 */
function toThresholdRankRows(
rows: Array<Record<string, unknown>>,
stage: 'sendToFetch' | 'fetchToSign',
dimension: 'warehouse' | 'logisticCompany',
) {
const rankRows = rows
.filter((row) => row.stage === stage && row.dimension === dimension)
.map((row) => {
const eligibleOrders = Number(row.eligible ?? 0)
return {
label: String(row.label ?? ''),
orders: Number(row.orders ?? 0),
eligibleOrders,
averageHours: row.average === null || row.average === undefined
? null
: Math.round(Number(row.average) * 10) / 10,
within24hRate: percentOrNull(Number(row.within24 ?? 0), eligibleOrders),
within48hRate: percentOrNull(Number(row.within48 ?? 0), eligibleOrders),
within72hRate: percentOrNull(Number(row.within72 ?? 0), eligibleOrders),
over48hRate: percentOrNull(Number(row.over48 ?? 0), eligibleOrders),
over72hRate: percentOrNull(Number(row.over72 ?? 0), eligibleOrders),
}
})
return rankRows.sort((a, b) => compareLogisticsArrivalSigningDurationThresholdRows(stage, a, b))
}
/**
* 供每日快照同步使用:按区间读取远端报表并完成条码/供应商/采购富化。
* 读路径不经过快照表,因此同步任务可以安全地重建当天批次。
*/
export async function loadLogisticsArrivalSigningDurationRowsForSnapshots(
filters: LogisticsArrivalSigningDurationFilters,
limit = LOGISTICS_ARRIVAL_SIGNING_DURATION_DASHBOARD_MAX_ROWS,
) {
const result = await listSqlRowsForExport(filters, limit)
return { rows: await enrichRows(result.rows), truncated: result.truncated }
}
export class LogisticsArrivalSigningDurationRepository {
async listPage(filters: LogisticsArrivalSigningDurationFilters) {
const snapshot = await resolveSnapshot(filters)
if (snapshot) {
const result = await readSnapshotOrFallback('page', async () => {
// 本地快照可以给出真实总数,09 明细的翻页因此显示"共 N 条 · 第 x / y 页"。
const [rows, total] = await Promise.all([
snapshotRepository.listPageRows(filters, snapshot.snapshotDate),
snapshotRepository.countRows(filters, snapshot.snapshotDate),
])
return {
total,
totalKnown: true,
hasNext: rows.length > filters.pageSize,
rows: rows.slice(0, filters.pageSize),
...snapshotSource(snapshot),
}
})
if (result) return result
}
const sqlRows = await listSqlRows(filters)
const hasNext = sqlRows.length > filters.pageSize
const pageRows = sqlRows.slice(0, filters.pageSize)
const rows = await enrichRows(pageRows)
// COUNT_BIG over this report view can scan for minutes. The extra fetched
// row provides reliable next-page navigation without making first render
// wait for a whole-table aggregate.
return { total: 0, totalKnown: false, hasNext, rows, ...LIVE_SOURCE }
}
async listExport(filters: LogisticsArrivalSigningDurationFilters) {
const snapshot = await resolveSnapshot(filters)
if (snapshot) {
const result = await readSnapshotOrFallback('export', () =>
snapshotRepository.listRangeRows(filters, snapshot.snapshotDate, LOGISTICS_ARRIVAL_SIGNING_DURATION_EXPORT_MAX_ROWS))
if (result) return result
}
const result = await listSqlRowsForExport(filters)
return { rows: await enrichRows(result.rows), truncated: result.truncated }
}
async listDrilldownPage(
filters: LogisticsArrivalSigningDurationFilters,
target: {
keys?: LogisticsArrivalSigningDurationDrilldownKey[]
drilldown?: LogisticsArrivalSigningDurationDrilldownFilter[]
},
) {
const snapshot = await resolveSnapshot(filters)
if (snapshot && target.drilldown) {
const result = await readSnapshotOrFallback('drilldown predicate', () =>
snapshotRepository.listDrilldownRows(
filters,
snapshot.snapshotDate,
target.drilldown!,
filters.page,
filters.pageSize,
))
// 整区间口径下总数真实可知,09 明细可以显示真实页数。
if (result) return { ...result, totalKnown: true }
}
const keys = target.keys ?? []
if (snapshot) {
const result = await readSnapshotOrFallback('drilldown page', async () => {
const rows = await snapshotRepository.listRowsByKeys(filters, snapshot.snapshotDate, keys)
const page = Math.min(filters.page, Math.max(1, Math.ceil(rows.length / filters.pageSize)))
return {
rows: rows.slice((page - 1) * filters.pageSize, page * filters.pageSize),
total: rows.length,
page,
pageSize: filters.pageSize,
totalKnown: true,
}
})
if (result) return result
}
const rows = await listSqlRowsForDrilldown(filters, keys)
const page = Math.min(filters.page, Math.max(1, Math.ceil(rows.length / filters.pageSize)))
return {
rows: await enrichRows(rows.slice((page - 1) * filters.pageSize, page * filters.pageSize)),
total: rows.length,
page,
pageSize: filters.pageSize,
totalKnown: true,
}
}
async listDrilldownExport(
filters: LogisticsArrivalSigningDurationFilters,
target: {
keys?: LogisticsArrivalSigningDurationDrilldownKey[]
drilldown?: LogisticsArrivalSigningDurationDrilldownFilter[]
},
) {
const snapshot = await resolveSnapshot(filters)
if (snapshot && target.drilldown) {
const result = await readSnapshotOrFallback('drilldown export predicate', async () => {
const page = await snapshotRepository.listDrilldownRows(
filters,
snapshot.snapshotDate,
target.drilldown!,
1,
LOGISTICS_ARRIVAL_SIGNING_DURATION_EXPORT_MAX_ROWS,
)
return { rows: page.rows, truncated: page.total > page.rows.length }
})
if (result) return result
}
const keys = target.keys ?? []
if (snapshot) {
const result = await readSnapshotOrFallback('drilldown export', async () => ({
rows: await snapshotRepository.listRowsByKeys(filters, snapshot.snapshotDate, keys),
truncated: false,
}))
if (result) return result
}
const rows = await listSqlRowsForDrilldown(filters, keys)
return { rows: await enrichRows(rows), truncated: false }
}
async getDashboard(
filters: LogisticsArrivalSigningDurationFilters,
cascade?: LogisticsArrivalSigningDurationDrilldownFilter[],
): Promise<LogisticsArrivalSigningDurationDashboard> {
const snapshot = await resolveSnapshot(filters)
if (snapshot) {
// 先确认区间规模:超出精确口径上限时按样本口径渲染,保证首屏速度。
const exactEligible = await readSnapshotOrFallback('row count', async () =>
(await snapshotRepository.countRangeRows(snapshot.snapshotDate, filters.dateFrom, filters.dateTo))
<= LOGISTICS_ARRIVAL_SIGNING_DURATION_EXACT_SCOPE_MAX_ROWS)
if (exactEligible === false) {
// 三件事互不依赖,并发下发:样本、环节目标、整区间经营总览。
// 经营总览是看板门面(含异常单量、点击下钻的对账口径),即使走样本口径也按整区间精确统计。
const [sample, sampleTargets, exactOverview, objectBaselines] = await Promise.all([
snapshotRepository.listDashboardSampleRows(
filters,
snapshot.snapshotDate,
LOGISTICS_ARRIVAL_SIGNING_DURATION_DASHBOARD_MAX_ROWS,
),
loadStageTargets(filters, snapshot.snapshotDate),
snapshotRepository.aggregateDashboard(filters, snapshot.snapshotDate, false),
loadObjectBaselines(filters, snapshot.snapshotDate),
])
return {
...buildLogisticsArrivalSigningDurationDashboard(
sample.rows,
sample.truncated || sample.rows.length < sample.total,
sampleTargets,
),
...(objectBaselines ? { objectBaselines } : {}),
overview: exactOverview.overview,
overviewExact: true as const,
dataScope: 'sample' as const,
dataScopeReason: 'range-too-large' as const,
sampleSize: exactOverview.overview.orderCount,
...snapshotSource(snapshot),
}
}
// 命中快照:整块看板都由本地 SQL 聚合给出(总览/环节/分布/经营分析/超时排名),
// 与远端实时源的 2,000 条样本口径彻底解耦。
const result = await readSnapshotOrFallback('dashboard', async () => {
const [aggregate, payToSignAnalysis, payToSendAnalysis, thresholdRows] = await Promise.all([
snapshotRepository.aggregateDashboard(filters, snapshot.snapshotDate),
snapshotRepository.aggregatePayToSignAnalysis(filters, snapshot.snapshotDate, filters.dateFrom, filters.dateTo, cascade),
snapshotRepository.aggregatePayToSendAnalysis(filters, snapshot.snapshotDate, filters.dateFrom, filters.dateTo, cascade),
snapshotRepository.aggregateThresholdRankings(filters, snapshot.snapshotDate, cascade),
])
const base = buildLogisticsArrivalSigningDurationDashboard([], false)
const distributionTotal = aggregate.distribution.reduce((sum, value) => sum + value, 0)
const thresholdRankings: LogisticsArrivalSigningDurationThresholdRankings = {
warehouse: toThresholdRankRows(thresholdRows, 'sendToFetch', 'warehouse'),
pickupCompany: toThresholdRankRows(thresholdRows, 'sendToFetch', 'logisticCompany'),
deliveryCompany: toThresholdRankRows(thresholdRows, 'fetchToSign', 'logisticCompany'),
}
// 本月目标(上月平均 × 98%)与目标达成率:目标先算,达成率再按目标统计命中订单。
const [stageTargets, objectBaselines] = await Promise.all([
loadStageTargets(filters, snapshot.snapshotDate),
loadObjectBaselines(filters, snapshot.snapshotDate),
])
const stageAchievements = buildStageAchievements(
Object.fromEntries(aggregate.stages.map((stage) => [stage.key, stage.averageHours])),
stageTargets,
)
return {
...base,
sampleSize: aggregate.overview.orderCount,
truncated: false,
dataScope: 'exact' as const,
overview: aggregate.overview,
distribution: base.distribution.map((item, index) => ({
label: item.label,
orders: aggregate.distribution[index] ?? 0,
percentage: distributionTotal
? Math.round(((aggregate.distribution[index] ?? 0) / distributionTotal) * 1000) / 10
: 0,
})),
stages: LOGISTICS_ARRIVAL_SIGNING_DURATION_STAGES.map((stage) => {
const measured = aggregate.stages.find((item) => item.key === stage.key)
return {
key: stage.key,
label: stage.label,
targetHours: stage.targetHours,
targetLabel: stage.targetLabel,
eligibleOrders: measured?.eligibleOrders ?? 0,
averageHours: measured?.averageHours === null || measured?.averageHours === undefined
? null
: Math.round(measured.averageHours * 10) / 10,
medianHours: measured?.medianHours === null || measured?.medianHours === undefined
? null
: Math.round(measured.medianHours * 10) / 10,
p90Hours: measured?.p90Hours === null || measured?.p90Hours === undefined
? null
: Math.round(measured.p90Hours * 10) / 10,
}
}),
payToSignAnalysis,
payToSendAnalysis,
thresholdRankings,
stageTargets,
stageAchievements,
...(objectBaselines ? { objectBaselines } : {}),
...snapshotSource(snapshot),
}
})
if (result) return result
}
const result = await listSqlRowsForDashboard(filters)
const rows = await enrichRows(result.rows)
return { ...buildLogisticsArrivalSigningDurationDashboard(rows, result.truncated), ...LIVE_SOURCE }
}
}
export const logisticsArrivalSigningDurationRepository = new LogisticsArrivalSigningDurationRepository()

View File

@ -0,0 +1,36 @@
import { createLogisticsArrivalSigningDurationExport } from './logistics-arrival-signing-duration-export.service'
import { logisticsArrivalSigningDurationRepository } from '@/server/repositories/logistics-arrival-signing-duration.repository'
import type { LogisticsArrivalSigningDurationFilters, LogisticsArrivalSigningDurationRow } from '@/lib/logistics-arrival-signing-duration'
jest.mock('@/server/repositories/logistics-arrival-signing-duration.repository', () => ({
logisticsArrivalSigningDurationRepository: {
listExport: jest.fn(),
listDrilldownExport: jest.fn(),
},
}))
const filters: LogisticsArrivalSigningDurationFilters = {
dateFrom: '2026-09-01', dateTo: '2026-09-26', keyword: '',
excludeKeywords: [], page: 1, pageSize: 50,
}
it('下钻导出只读取选中的样本订单,保留 09 的完整字段列', async () => {
const row: LogisticsArrivalSigningDurationRow = {
innerOrderId: '9073200', soId: 'SO-1', logisticId: 'L-1',
logisticCompanyName: '快运', logisticStatusName: '已签收', logisticReturnFlag: '',
orderLabels: '标签', remark: '备注', buyerMessage: '留言', shopName: '店铺',
platformName: '平台', warehouseName: '华东仓', receiverAddress: '浙江省杭州市',
payTime: '2026-09-01 00:00:00', sendTime: '2026-09-01 02:00:00',
fetchTime: '2026-09-01 04:00:00', signTime: '2026-09-02 00:00:00',
barcode: 'B1', supplier: '供应商', purchaser: '采购',
}
const keys = [{ innerOrderId: row.innerOrderId, soId: row.soId, logisticId: row.logisticId, signTime: row.signTime }]
jest.mocked(logisticsArrivalSigningDurationRepository.listDrilldownExport).mockResolvedValue({ rows: [row], truncated: false })
const result = await createLogisticsArrivalSigningDurationExport(filters, { keys })
expect(logisticsArrivalSigningDurationRepository.listDrilldownExport).toHaveBeenCalledWith(filters, { keys })
expect(logisticsArrivalSigningDurationRepository.listExport).not.toHaveBeenCalled()
expect(result.rowCount).toBe(1)
expect(result.bytes.byteLength).toBeGreaterThan(0)
})

View File

@ -0,0 +1,82 @@
import ExcelJS from 'exceljs'
import {
LOGISTICS_ARRIVAL_SIGNING_DURATION_COLUMNS,
type LogisticsArrivalSigningDurationFilters,
type LogisticsArrivalSigningDurationDrilldownFilter,
type LogisticsArrivalSigningDurationDrilldownKey,
type LogisticsArrivalSigningDurationRow,
} from '@/lib/logistics-arrival-signing-duration'
import { logisticsArrivalSigningDurationRepository } from '@/server/repositories/logistics-arrival-signing-duration.repository'
export const LOGISTICS_ARRIVAL_SIGNING_DURATION_EXPORT_CONTENT_TYPE =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
function stamp(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(now)
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]))
return `${values.year}${values.month}${values.day}-${values.hour}${values.minute}`
}
function cellValue(row: LogisticsArrivalSigningDurationRow, key: string) {
const value = row[key as keyof LogisticsArrivalSigningDurationRow]
return value ?? ''
}
export async function createLogisticsArrivalSigningDurationExport(
filters: LogisticsArrivalSigningDurationFilters,
drilldownTarget?: {
keys?: LogisticsArrivalSigningDurationDrilldownKey[]
drilldown?: LogisticsArrivalSigningDurationDrilldownFilter[]
},
) {
const result = drilldownTarget && (drilldownTarget.keys?.length || drilldownTarget.drilldown)
? await logisticsArrivalSigningDurationRepository.listDrilldownExport(filters, drilldownTarget)
: await logisticsArrivalSigningDurationRepository.listExport(filters)
const workbook = new ExcelJS.Workbook()
workbook.creator = '物流到货签收时长看板'
workbook.created = new Date()
const sheet = workbook.addWorksheet('物流签收明细')
sheet.columns = LOGISTICS_ARRIVAL_SIGNING_DURATION_COLUMNS.map(([key, label]) => ({
header: label,
key,
width: key.endsWith('Time') ? 20 : key === 'receiverAddress' ? 24 : 18,
}))
result.rows.forEach((row) => {
sheet.addRow(Object.fromEntries(
LOGISTICS_ARRIVAL_SIGNING_DURATION_COLUMNS.map(([key]) => [key, cellValue(row, key)]),
))
})
sheet.views = [{ state: 'frozen', ySplit: 1 }]
sheet.autoFilter = { from: 'A1', to: `${columnName(LOGISTICS_ARRIVAL_SIGNING_DURATION_COLUMNS.length)}1` }
const header = sheet.getRow(1)
header.font = { bold: true, color: { argb: 'FFFFFFFF' } }
header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF3F3F46' } }
header.alignment = { vertical: 'middle', horizontal: 'center' }
header.height = 24
return {
bytes: (await workbook.xlsx.writeBuffer()) as unknown as Uint8Array,
fileName: `物流签收明细-${stamp()}.xlsx`,
rowCount: result.rows.length,
truncated: result.truncated,
}
}
function columnName(number: number) {
let value = number
let name = ''
while (value > 0) {
const remainder = (value - 1) % 26
name = String.fromCharCode(65 + remainder) + name
value = Math.floor((value - 1) / 26)
}
return name
}

View File

@ -0,0 +1,49 @@
const mockGet = jest.fn()
const mockSet = jest.fn()
jest.mock('@/server/services/system-config.service', () => ({
systemConfigService: {
get: (...args: unknown[]) => mockGet(...args),
set: (...args: unknown[]) => mockSet(...args),
},
}))
import { logisticsArrivalSigningDurationSettingsService } from './logistics-arrival-signing-duration-settings.service'
describe('物流到货签收时长默认排除关键词配置', () => {
beforeEach(() => {
mockGet.mockReset()
mockSet.mockReset()
})
it('读取并规范管理员保存的关键词(按字段分组)', async () => {
mockGet.mockResolvedValue({ warehouseName: ['自动发货仓, 京东云仓'], orderLabels: '配件', 其他字段: ['忽略我'] })
await expect(logisticsArrivalSigningDurationSettingsService.getDefaultExcludeKeywords())
.resolves.toEqual({ warehouseName: ['自动发货仓', '京东云仓'], orderLabels: ['配件'] })
})
it('老配置是平铺数组时,按"任意字段命中"的旧口径挂到全部字段', async () => {
mockGet.mockResolvedValue(['标签A,标签B'])
const value = await logisticsArrivalSigningDurationSettingsService.getDefaultExcludeKeywords()
expect(Object.keys(value)).toHaveLength(5)
expect(value.warehouseName).toEqual(['标签A', '标签B'])
expect(value.logisticCompanyName).toEqual(['标签A', '标签B'])
})
it('仅权限管理员可以保存配置', async () => {
await expect(
logisticsArrivalSigningDurationSettingsService.updateDefaultExcludeKeywords(
{ warehouseName: ['标签A'] },
{ id: 'user-1', role: 'USER' } as never,
),
).rejects.toThrow('只有权限管理员可以维护排除关键词')
await expect(
logisticsArrivalSigningDurationSettingsService.updateDefaultExcludeKeywords(
{ warehouseName: ['标签A, 标签B'] },
{ id: 'admin-1', role: 'ADMIN' } as never,
),
).resolves.toEqual({ excludeKeywords: { warehouseName: ['标签A', '标签B'] } })
expect(mockSet).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,46 @@
import { systemConfigService } from '@/server/services/system-config.service'
import {
LOGISTICS_ARRIVAL_SIGNING_DURATION_NO_EXCLUDE_KEYWORDS,
LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_KEYWORDS_CONFIG_KEY,
normalizeExcludeKeywords,
type LogisticsArrivalSigningDurationExcludeKeywords,
} from '@/lib/logistics-arrival-signing-duration'
import { canManageSupplyChainPermissions } from '@/lib/supply-chain-permission-settings'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
function assertCanManage(user: AuthUser) {
if (canManageSupplyChainPermissions(user)) return
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有权限管理员可以维护排除关键词')
}
export class LogisticsArrivalSigningDurationSettingsService {
async getDefaultExcludeKeywords() {
try {
const value = await systemConfigService.get<unknown>(
LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_KEYWORDS_CONFIG_KEY,
)
return normalizeExcludeKeywords(value)
} catch (error) {
console.warn('[logistics-arrival-signing-duration] default keywords unavailable', error)
return LOGISTICS_ARRIVAL_SIGNING_DURATION_NO_EXCLUDE_KEYWORDS
}
}
async updateDefaultExcludeKeywords(
value: unknown,
user: AuthUser,
): Promise<{ excludeKeywords: LogisticsArrivalSigningDurationExcludeKeywords }> {
assertCanManage(user)
const excludeKeywords = normalizeExcludeKeywords(value)
await systemConfigService.set(
LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_KEYWORDS_CONFIG_KEY,
excludeKeywords,
'物流到货签收时长看板默认排除关键词',
)
return { excludeKeywords }
}
}
export const logisticsArrivalSigningDurationSettingsService =
new LogisticsArrivalSigningDurationSettingsService()

View File

@ -0,0 +1,136 @@
import type { LogisticsArrivalSigningDurationFilters, LogisticsArrivalSigningDurationRow } from '@/lib/logistics-arrival-signing-duration'
import {
LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_COMPLETED,
LogisticsArrivalSigningDurationSnapshotService,
} from './logistics-arrival-signing-duration-snapshot.service'
// 远端报表仓储会加载 mssql/tedious,单测只验证编排逻辑,默认依赖不参与。
jest.mock('@/server/repositories/logistics-arrival-signing-duration.repository', () => ({
loadLogisticsArrivalSigningDurationRowsForSnapshots: jest.fn(),
}))
function row(id: string, signTime: string): LogisticsArrivalSigningDurationRow {
return {
innerOrderId: id,
soId: `SO-${id}`,
logisticId: `L-${id}`,
logisticCompanyName: '快运',
logisticStatusName: '已签收',
logisticReturnFlag: '',
orderLabels: '',
remark: '',
buyerMessage: '',
shopName: '店铺',
platformName: '平台',
warehouseName: '发货仓',
receiverAddress: '',
payTime: signTime,
sendTime: signTime,
fetchTime: signTime,
signTime,
barcode: 'B1',
productName: '商品',
supplier: '供应商',
purchaser: '采购',
}
}
function buildService(options: {
existingRun?: { status: string; rowCount: number } | null
rowsForRange: (filters: LogisticsArrivalSigningDurationFilters) => LogisticsArrivalSigningDurationRow[]
failChunk?: string
}) {
const calls = {
startRun: jest.fn(),
completeRun: jest.fn(),
failRun: jest.fn(async () => undefined),
clearRows: jest.fn(async () => 0),
insertRows: jest.fn(async (_date: string, rows: LogisticsArrivalSigningDurationRow[]) => rows.length),
pruneSnapshots: jest.fn(async () => ['2026-09-20']),
close: jest.fn(async () => undefined),
ranges: [] as Array<{ from: string; to: string }>,
}
const service = new LogisticsArrivalSigningDurationSnapshotService({
source: {
listRowsForRange: jest.fn(async (filters: LogisticsArrivalSigningDurationFilters) => {
calls.ranges.push({ from: filters.dateFrom, to: filters.dateTo })
if (options.failChunk === filters.dateFrom) throw new Error('远端数据源不可用')
return { rows: options.rowsForRange(filters), truncated: false }
}),
close: calls.close,
},
snapshot: {
findRun: jest.fn(async () => (options.existingRun ?? null) as { status: string; rowCount: number } | null),
startRun: calls.startRun,
completeRun: calls.completeRun,
failRun: calls.failRun,
clearRows: calls.clearRows,
insertRows: calls.insertRows,
pruneSnapshots: calls.pruneSnapshots,
} as never,
now: () => new Date('2026-09-28T05:30:00.000Z'),
})
return { service, calls }
}
it('按窗口分片抓取远端明细,写入当天快照并在完成后清理旧批次', async () => {
const { service, calls } = buildService({
rowsForRange: (filters) => [row(`${filters.dateFrom}-1`, `${filters.dateFrom} 10:00:00`)],
})
const progress: string[] = []
const result = await service.sync({
windowDays: 10,
chunkDays: 3,
batchSize: 100,
retention: 2,
onProgress: (item) => progress.push(`${item.from}..${item.to}`),
})
expect(calls.ranges).toEqual([
{ from: '2026-09-19', to: '2026-09-21' },
{ from: '2026-09-22', to: '2026-09-24' },
{ from: '2026-09-25', to: '2026-09-27' },
{ from: '2026-09-28', to: '2026-09-28' },
])
expect(progress).toEqual(['2026-09-19..2026-09-21', '2026-09-22..2026-09-24', '2026-09-25..2026-09-27', '2026-09-28..2026-09-28'])
expect(calls.startRun).toHaveBeenCalledWith({ snapshotDate: '2026-09-28', windowFrom: '2026-09-19', windowTo: '2026-09-28' })
expect(calls.completeRun).toHaveBeenCalledWith({ snapshotDate: '2026-09-28', rowCount: 4, windowFrom: '2026-09-19', windowTo: '2026-09-28' })
expect(result).toMatchObject({
status: 'completed',
snapshotDate: '2026-09-28',
windowFrom: '2026-09-19',
rowsRead: 4,
rowsWritten: 4,
prunedSnapshots: ['2026-09-20'],
})
})
it('当天批次已完成时跳过重复生成,force 才会重建', async () => {
const skipped = buildService({
existingRun: { status: LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_COMPLETED, rowCount: 1234 },
rowsForRange: () => [],
})
const skippedResult = await skipped.service.sync({ windowDays: 5 })
expect(skippedResult).toMatchObject({ status: 'skipped', rowCount: 1234 })
expect(skipped.calls.startRun).not.toHaveBeenCalled()
const forced = buildService({
existingRun: { status: LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_COMPLETED, rowCount: 1234 },
rowsForRange: (filters) => [row(filters.dateFrom, `${filters.dateFrom} 10:00:00`)],
})
const forcedResult = await forced.service.sync({ windowDays: 2, chunkDays: 1, force: true })
expect(forcedResult).toMatchObject({ status: 'completed', rowsWritten: 2 })
expect(forced.calls.clearRows).toHaveBeenCalledWith('2026-09-28')
})
it('任一分片失败时把批次标记为失败且不发布', async () => {
const { service, calls } = buildService({
rowsForRange: (filters) => [row(filters.dateFrom, `${filters.dateFrom} 10:00:00`)],
failChunk: '2026-09-22',
})
await expect(service.sync({ windowDays: 10, chunkDays: 3 })).rejects.toThrow('远端数据源不可用')
expect(calls.completeRun).not.toHaveBeenCalled()
expect(calls.pruneSnapshots).not.toHaveBeenCalled()
expect(calls.failRun).toHaveBeenCalledWith('2026-09-28', '远端数据源不可用')
expect(calls.close).toHaveBeenCalled()
})

View File

@ -0,0 +1,173 @@
import { shanghaiToday, shiftSalesDate } from '@/lib/product-archive-sales-period'
import {
normalizeLogisticsArrivalSigningDurationFilters,
type LogisticsArrivalSigningDurationRow,
} from '@/lib/logistics-arrival-signing-duration'
import {
LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_COMPLETED,
logisticsArrivalSigningDurationSnapshotRepository,
} from '@/server/repositories/logistics-arrival-signing-duration-snapshot.repository'
import type { LogisticsArrivalSigningDurationSnapshotRepository } from '@/server/repositories/logistics-arrival-signing-duration-snapshot.repository'
import {
closeLogisticsArrivalSigningDurationSourcePool,
loadLogisticsArrivalSigningDurationRowsForSnapshots,
} from '@/server/repositories/logistics-arrival-signing-duration.repository'
/** 每日订单明细快照:把远端报表的看板明细物化到本地 Postgres,看板优先读取最新一批快照。 */
export {
LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_COMPLETED,
LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_FAILED,
LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_RUNNING,
} from '@/server/repositories/logistics-arrival-signing-duration-snapshot.repository'
export const LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_CONFIRM_TOKEN =
'sync-logistics-arrival-signing-duration-snapshot'
/** 快照覆盖的签收日期窗口(含当天),默认覆盖本月与上月。 */
export const LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_DEFAULT_WINDOW_DAYS = 62
/** 保留的最近快照批次数;今日批次失败时仍可回退到昨日批次。 */
export const LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_DEFAULT_RETENTION = 2
export const LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_DEFAULT_CHUNK_DAYS = 7
export const LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_DEFAULT_BATCH_SIZE = 2_000
export const LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_CHUNK_MAX_ROWS = 200_000
export type LogisticsArrivalSigningDurationSnapshotProgress = {
stage: 'chunk'
snapshotDate: string
from: string
to: string
rowsRead: number
rowsWritten: number
}
export type LogisticsArrivalSigningDurationSnapshotSyncResult =
| { status: 'skipped'; snapshotDate: string; windowFrom: string; windowTo: string; rowCount: number }
| {
status: 'completed'
snapshotDate: string
windowFrom: string
windowTo: string
rowsRead: number
rowsWritten: number
clearedRows: number
prunedSnapshots: string[]
}
type Dependencies = {
source: {
listRowsForRange: (
filters: ReturnType<typeof normalizeLogisticsArrivalSigningDurationFilters>,
limit: number,
) => Promise<{ rows: LogisticsArrivalSigningDurationRow[]; truncated: boolean }>
/** 一次性任务收尾:释放远端报表连接池,避免进程挂在事件循环上。 */
close: () => Promise<void>
}
snapshot: Pick<
LogisticsArrivalSigningDurationSnapshotRepository,
'findRun' | 'startRun' | 'completeRun' | 'failRun' | 'clearRows' | 'insertRows' | 'pruneSnapshots'
>
now: () => Date
}
function batchRows<T>(rows: T[], size: number) {
const batches: T[][] = []
for (let index = 0; index < rows.length; index += size) batches.push(rows.slice(index, index + size))
return batches
}
function positiveInteger(value: number | undefined, fallback: number, name: string, max: number) {
const resolved = value ?? fallback
if (!Number.isInteger(resolved) || resolved < 1 || resolved > max) {
throw new Error(`${name} must be an integer between 1 and ${max}`)
}
return resolved
}
export class LogisticsArrivalSigningDurationSnapshotService {
constructor(private readonly dependencies: Dependencies = {
source: {
listRowsForRange: loadLogisticsArrivalSigningDurationRowsForSnapshots,
close: closeLogisticsArrivalSigningDurationSourcePool,
},
snapshot: logisticsArrivalSigningDurationSnapshotRepository,
now: () => new Date(),
}) {}
async sync(input: {
snapshotDate?: string
windowDays?: number
retention?: number
chunkDays?: number
batchSize?: number
force?: boolean
onProgress?: (progress: LogisticsArrivalSigningDurationSnapshotProgress) => void
} = {}): Promise<LogisticsArrivalSigningDurationSnapshotSyncResult> {
try {
return await this.runSync(input)
} finally {
await this.dependencies.source.close().catch(() => undefined)
}
}
private async runSync(input: {
snapshotDate?: string
windowDays?: number
retention?: number
chunkDays?: number
batchSize?: number
force?: boolean
onProgress?: (progress: LogisticsArrivalSigningDurationSnapshotProgress) => void
}): Promise<LogisticsArrivalSigningDurationSnapshotSyncResult> {
const snapshotDate = input.snapshotDate ?? shanghaiToday(this.dependencies.now())
if (!/^\d{4}-\d{2}-\d{2}$/.test(snapshotDate)) throw new Error('snapshotDate must be YYYY-MM-DD')
const windowDays = positiveInteger(input.windowDays, LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_DEFAULT_WINDOW_DAYS, 'windowDays', 3_660)
const retention = positiveInteger(input.retention, LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_DEFAULT_RETENTION, 'retention', 60)
const chunkDays = positiveInteger(input.chunkDays, LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_DEFAULT_CHUNK_DAYS, 'chunkDays', 62)
const batchSize = positiveInteger(input.batchSize, LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_DEFAULT_BATCH_SIZE, 'batchSize', 10_000)
const windowFrom = shiftSalesDate(snapshotDate, 1 - windowDays)
const windowTo = snapshotDate
const existing = await this.dependencies.snapshot.findRun(snapshotDate)
if (existing?.status === LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_COMPLETED && !input.force) {
return { status: 'skipped', snapshotDate, windowFrom, windowTo, rowCount: existing.rowCount }
}
await this.dependencies.snapshot.startRun({ snapshotDate, windowFrom, windowTo })
try {
const clearedRows = await this.dependencies.snapshot.clearRows(snapshotDate)
let rowsRead = 0
let rowsWritten = 0
for (let from = windowFrom; from <= windowTo;) {
const candidateTo = shiftSalesDate(from, chunkDays - 1)
const to = candidateTo < windowTo ? candidateTo : windowTo
const filters = normalizeLogisticsArrivalSigningDurationFilters({ dateFrom: from, dateTo: to, page: 1, pageSize: 50 })
const result = await this.dependencies.source.listRowsForRange(
filters,
LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_CHUNK_MAX_ROWS,
)
if (result.truncated) {
throw new Error(`Snapshot chunk ${from}..${to} exceeded ${LOGISTICS_ARRIVAL_SIGNING_DURATION_SNAPSHOT_CHUNK_MAX_ROWS} rows; reduce chunkDays`)
}
rowsRead += result.rows.length
for (const batch of batchRows(result.rows, batchSize)) {
rowsWritten += await this.dependencies.snapshot.insertRows(snapshotDate, batch)
}
input.onProgress?.({ stage: 'chunk', snapshotDate, from, to, rowsRead, rowsWritten })
from = shiftSalesDate(to, 1)
}
await this.dependencies.snapshot.completeRun({ snapshotDate, rowCount: rowsWritten, windowFrom, windowTo })
const prunedSnapshots = await this.dependencies.snapshot.pruneSnapshots(retention)
return { status: 'completed', snapshotDate, windowFrom, windowTo, rowsRead, rowsWritten, clearedRows, prunedSnapshots }
} catch (error) {
// 失败标记本身出错时不能覆盖原始失败原因。
try {
await this.dependencies.snapshot.failRun(snapshotDate, error instanceof Error ? error.message : String(error))
} catch {
// ignore
}
throw error
}
}
}
export const logisticsArrivalSigningDurationSnapshotService = new LogisticsArrivalSigningDurationSnapshotService()

View File

@ -0,0 +1,217 @@
import {
normalizeLogisticsArrivalSigningDurationFilters,
buildLogisticsArrivalSigningDurationDashboard,
excludeKeywordsCacheKey,
logisticsArrivalSigningDurationKeywords,
type LogisticsArrivalSigningDurationFilters,
type LogisticsArrivalSigningDurationDashboard,
type LogisticsArrivalSigningDurationDrilldownFilter,
type LogisticsArrivalSigningDurationDrilldownKey,
type LogisticsArrivalSigningDurationPage,
} from '@/lib/logistics-arrival-signing-duration'
import { logisticsArrivalSigningDurationRepository } from '@/server/repositories/logistics-arrival-signing-duration.repository'
const FRESH_CACHE_TTL_MS = 60_000
const STALE_CACHE_TTL_MS = 15 * 60_000
type CacheEntry<T> = {
value: T
cachedAt: number
}
const pageCache = new Map<string, CacheEntry<LogisticsArrivalSigningDurationPage>>()
const dashboardCache = new Map<string, CacheEntry<LogisticsArrivalSigningDurationDashboard>>()
const pageInFlight = new Map<string, Promise<LogisticsArrivalSigningDurationPage>>()
const dashboardInFlight = new Map<string, Promise<LogisticsArrivalSigningDurationDashboard>>()
const MAX_CACHE_ENTRIES = 100
type LogisticsCacheGlobal = typeof globalThis & {
logisticsArrivalSigningDurationPageCache?: Map<string, CacheEntry<LogisticsArrivalSigningDurationPage>>
logisticsArrivalSigningDurationDashboardCache?: Map<string, CacheEntry<LogisticsArrivalSigningDurationDashboard>>
logisticsArrivalSigningDurationPageInFlight?: Map<string, Promise<LogisticsArrivalSigningDurationPage>>
logisticsArrivalSigningDurationDashboardInFlight?: Map<string, Promise<LogisticsArrivalSigningDurationDashboard>>
}
const globalForCache = globalThis as LogisticsCacheGlobal
const sharedPageCache = globalForCache.logisticsArrivalSigningDurationPageCache ?? pageCache
const sharedDashboardCache = globalForCache.logisticsArrivalSigningDurationDashboardCache ?? dashboardCache
const sharedPageInFlight = globalForCache.logisticsArrivalSigningDurationPageInFlight ?? pageInFlight
const sharedDashboardInFlight = globalForCache.logisticsArrivalSigningDurationDashboardInFlight ?? dashboardInFlight
globalForCache.logisticsArrivalSigningDurationPageCache = sharedPageCache
globalForCache.logisticsArrivalSigningDurationDashboardCache = sharedDashboardCache
globalForCache.logisticsArrivalSigningDurationPageInFlight = sharedPageInFlight
globalForCache.logisticsArrivalSigningDurationDashboardInFlight = sharedDashboardInFlight
function cacheKey(filters: LogisticsArrivalSigningDurationFilters, includePaging = true) {
return JSON.stringify({
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
// 批量关键词按集合参与缓存键,顺序不同视为同一条件。
keywords: [...logisticsArrivalSigningDurationKeywords(filters)].sort(),
excludeKeywords: excludeKeywordsCacheKey(filters.excludeKeywords),
...(includePaging ? { page: filters.page, pageSize: filters.pageSize } : {}),
})
}
function cacheWarning(label: string, cachedAt: number) {
const timestamp = new Date(cachedAt).toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
hour12: false,
})
return `${label}暂时无法响应,当前展示最近一次成功数据(更新时间:${timestamp})。数据源恢复后将自动刷新。`
}
function staleValue<T extends { warning?: string; dataAsOf?: string; stale?: boolean }>(
value: T,
cachedAt: number,
label: string,
) {
return {
...value,
dataAsOf: value.dataAsOf ?? new Date(cachedAt).toISOString(),
stale: true,
warning: cacheWarning(label, cachedAt),
}
}
function isFresh<T>(entry: CacheEntry<T> | undefined, now: number) {
return Boolean(entry && now - entry.cachedAt < FRESH_CACHE_TTL_MS)
}
function setCacheEntry<T>(cache: Map<string, CacheEntry<T>>, key: string, value: T, cachedAt: number) {
cache.set(key, { value, cachedAt })
if (cache.size <= MAX_CACHE_ENTRIES) return
const oldestKey = cache.keys().next().value
if (oldestKey) cache.delete(oldestKey)
}
async function loadWithCache<T extends { warning?: string; dataAsOf?: string; stale?: boolean }>(
key: string,
cache: Map<string, CacheEntry<T>>,
inFlight: Map<string, Promise<T>>,
loader: () => Promise<T>,
label: string,
) {
const now = Date.now()
const current = cache.get(key)
if (isFresh(current, now)) return current!.value
const running = inFlight.get(key)
if (running) {
if (current && now - current.cachedAt <= STALE_CACHE_TTL_MS) {
return staleValue(current.value, current.cachedAt, label)
}
return running
}
const request = loader().then((value) => {
const cachedAt = Date.now()
const next = { ...value, dataAsOf: value.dataAsOf ?? new Date(cachedAt).toISOString(), stale: false }
setCacheEntry(cache, key, next, cachedAt)
return next
}).catch((error) => {
const stale = cache.get(key)
if (stale && Date.now() - stale.cachedAt <= STALE_CACHE_TTL_MS) {
return staleValue(stale.value, stale.cachedAt, label)
}
throw error
}).finally(() => {
inFlight.delete(key)
})
inFlight.set(key, request)
// Once a successful result exists, never make the user wait for the next
// remote-view scan. Return the last result immediately and refresh it in the
// background; the next request will observe the fresh value.
if (current && now - current.cachedAt <= STALE_CACHE_TTL_MS) {
void request.catch((error) => {
console.warn(`[logistics-arrival-signing-duration] ${label} background refresh failed`, error)
})
return staleValue(current.value, current.cachedAt, label)
}
return request
}
export class LogisticsArrivalSigningDurationService {
async listDrilldownPage(
input: Partial<LogisticsArrivalSigningDurationFilters>,
target: {
keys?: LogisticsArrivalSigningDurationDrilldownKey[]
drilldown?: LogisticsArrivalSigningDurationDrilldownFilter[]
},
) {
const filters = normalizeLogisticsArrivalSigningDurationFilters(input)
return logisticsArrivalSigningDurationRepository.listDrilldownPage(filters, target)
}
async listPage(input: Partial<LogisticsArrivalSigningDurationFilters>): Promise<LogisticsArrivalSigningDurationPage> {
const filters = normalizeLogisticsArrivalSigningDurationFilters(input)
const key = cacheKey(filters)
try {
return await loadWithCache<LogisticsArrivalSigningDurationPage>(
key,
sharedPageCache,
sharedPageInFlight,
async () => {
const result = await logisticsArrivalSigningDurationRepository.listPage(filters)
const totalPages = result.totalKnown
? Math.max(1, Math.ceil(result.total / filters.pageSize))
: Math.max(1, filters.page + (result.hasNext ? 1 : 0))
const page = result.totalKnown ? Math.min(filters.page, totalPages) : filters.page
return {
...result,
page,
pageSize: filters.pageSize,
totalPages,
filters: { ...filters, page },
}
},
'物流数据源',
)
} catch (error) {
console.error('[logistics-arrival-signing-duration] page load failed', error)
return {
rows: [],
total: 0,
totalKnown: false,
hasNext: false,
page: 1,
pageSize: filters.pageSize,
totalPages: 1,
filters: { ...filters, page: 1 },
warning: '物流数据源暂时无法响应,请稍后重试或缩小签收日期范围。',
}
}
}
async getDashboard(
input: Partial<LogisticsArrivalSigningDurationFilters>,
cascade?: LogisticsArrivalSigningDurationDrilldownFilter[],
): Promise<LogisticsArrivalSigningDurationDashboard> {
const filters = normalizeLogisticsArrivalSigningDurationFilters({
...input,
page: 1,
pageSize: 50,
})
// 级联条件参与缓存键,不同下钻路径各自缓存。
const key = `${cacheKey(filters, false)}|cascade:${JSON.stringify(cascade ?? [])}`
try {
return await loadWithCache<LogisticsArrivalSigningDurationDashboard>(
key,
sharedDashboardCache,
sharedDashboardInFlight,
() => logisticsArrivalSigningDurationRepository.getDashboard(filters, cascade),
'经营分析数据源',
)
} catch (error) {
console.error('[logistics-arrival-signing-duration] dashboard load failed', error)
return {
...buildLogisticsArrivalSigningDurationDashboard([]),
warning: '经营分析数据源暂时无法响应,当前仅展示空看板;请刷新或缩小签收日期范围后重试。',
}
}
}
}
export const logisticsArrivalSigningDurationService = new LogisticsArrivalSigningDurationService()