feat(logistics-arrival): 物流到货签收时长看板
- 看板:经营总览(平均到货时长 + 本月目标 + 目标达成率、异常单点击下钻)、 履约环节拆解(平均/中位/P90/目标/目标达成率,分栏可拖动)、发货→签收时效分布 - 经营对象问题排名:签收日期→平台→店铺→发货仓→物流→采购→供应商→商品→订单明细 逐级联动 + 独立翻页;经营对象带「目标 = 该对象上月平均 × 98%」与目标达成率 - 09 订单明细:看板内翻页、高度可拖动、下钻后自动滚动定位 - 排除关键词按字段分组(发货仓/物流公司/订单标签/买家留言/卖家备注), 选择器支持搜索、全部/已选项、全选/反选/清空、紧凑模式与按字段新增 - 每日订单明细快照:Prisma 模型与迁移、同步脚本、定时器安装脚本; 命中快照时整区间精确统计,超过规模上限回落均匀样本并如实标注 - 接入侧边菜单、标签页标题与权限映射(businessManagement),补充架构文档
This commit is contained in:
parent
ac572e2c4d
commit
83c10719ca
243
furniture-3d-system/docs/架构设计/物流到货签收时长看板-订单明细快照.md
Normal file
243
furniture-3d-system/docs/架构设计/物流到货签收时长看板-订单明细快照.md
Normal 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 进程需要重启才能拿到新模型。
|
||||
@ -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");
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
})
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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': '用户权限',
|
||||
|
||||
@ -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',
|
||||
})]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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>
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
@ -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>
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
@ -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}
|
||||
/>
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
@ -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}
|
||||
/>
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
1325
furniture-3d-system/src/lib/logistics-arrival-signing-duration.ts
Normal file
1325
furniture-3d-system/src/lib/logistics-arrival-signing-duration.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
@ -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)
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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()
|
||||
})
|
||||
@ -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()
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user