From 05007d3ae2a360e33a8ee99e3d805e12dfd17194 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 30 Sep 2026 18:18:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(logistics-arrival):=20=E7=89=A9=E6=B5=81?= =?UTF-8?q?=E5=88=B0=E8=B4=A7=E7=AD=BE=E6=94=B6=E6=97=B6=E9=95=BF=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 看板:经营总览(平均到货时长 + 本月目标 + 目标达成率、异常单点击下钻)、 履约环节拆解(平均/中位/P90/目标/目标达成率,分栏可拖动)、发货→签收时效分布 - 经营对象问题排名:签收日期→平台→店铺→发货仓→物流→采购→供应商→商品→订单明细 逐级联动 + 独立翻页;经营对象带「目标 = 该对象上月平均 × 98%」与目标达成率 - 09 订单明细:看板内翻页、高度可拖动、下钻后自动滚动定位 - 排除关键词按字段分组(发货仓/物流公司/订单标签/买家留言/卖家备注), 选择器支持搜索、全部/已选项、全选/反选/清空、紧凑模式与按字段新增 - 每日订单明细快照:Prisma 模型与迁移、同步脚本、定时器安装脚本; 命中快照时整区间精确统计,超过规模上限回落均匀样本并如实标注 - 接入侧边菜单、标签页标题与权限映射(businessManagement),补充架构文档 --- .../物流到货签收时长看板-订单明细快照.md | 243 +++ .../migration.sql | 53 + furniture-3d-system/prisma/schema.prisma | 52 + ...arrival-signing-duration-snapshot-timer.sh | 66 + ...stics-arrival-signing-duration-snapshot.ts | 69 + .../dashboard/route.ts | 73 + .../drilldown/route.ts | 64 + .../export/route.ts | 95 ++ .../settings/route.ts | 34 + .../components/SupplyChainSidebar.test.tsx | 16 + .../components/supply-chain-tabs.tsx | 1 + .../config/supply-chain-menu.test.ts | 10 + .../supply-chain/config/supply-chain-menu.ts | 5 + .../BoardPagination.tsx | 59 + .../ExcludeKeywordPicker.test.tsx | 139 ++ .../ExcludeKeywordPicker.tsx | 392 +++++ ...csArrivalSigningDurationDashboard.test.tsx | 293 ++++ ...gisticsArrivalSigningDurationDashboard.tsx | 1064 +++++++++++++ ...gisticsArrivalSigningDurationView.test.tsx | 165 ++ .../LogisticsArrivalSigningDurationView.tsx | 371 +++++ .../ObjectAnalysisBoard.tsx | 459 ++++++ .../OrderDetailsBoard.test.tsx | 61 + .../OrderDetailsBoard.tsx | 140 ++ .../PayToSendAnalysis.test.tsx | 89 ++ .../PayToSendAnalysis.tsx | 76 + .../PayToSignAnalysis.test.tsx | 174 +++ .../PayToSignAnalysis.tsx | 77 + .../page.tsx | 57 + ...logistics-arrival-signing-duration.test.ts | 410 +++++ .../lib/logistics-arrival-signing-duration.ts | 1325 +++++++++++++++++ .../supply-chain-permission-settings.test.ts | 25 + .../lib/supply-chain-permission-settings.ts | 8 + ...al-signing-duration-snapshot.repository.ts | 1200 +++++++++++++++ ...ics-arrival-signing-duration.repository.ts | 974 ++++++++++++ ...al-signing-duration-export.service.test.ts | 36 + ...arrival-signing-duration-export.service.ts | 82 + ...-signing-duration-settings.service.test.ts | 49 + ...rival-signing-duration-settings.service.ts | 46 + ...-signing-duration-snapshot.service.test.ts | 136 ++ ...rival-signing-duration-snapshot.service.ts | 173 +++ ...istics-arrival-signing-duration.service.ts | 217 +++ 41 files changed, 9078 insertions(+) create mode 100644 furniture-3d-system/docs/架构设计/物流到货签收时长看板-订单明细快照.md create mode 100644 furniture-3d-system/prisma/migrations/20260928120000_add_logistics_arrival_signing_duration_snapshot/migration.sql create mode 100755 furniture-3d-system/scripts/install-logistics-arrival-signing-duration-snapshot-timer.sh create mode 100644 furniture-3d-system/scripts/sync-logistics-arrival-signing-duration-snapshot.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/management/logistics-arrival-signing-duration/dashboard/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/management/logistics-arrival-signing-duration/drilldown/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/management/logistics-arrival-signing-duration/export/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/management/logistics-arrival-signing-duration/settings/route.ts create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/BoardPagination.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/ExcludeKeywordPicker.test.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/ExcludeKeywordPicker.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationDashboard.test.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationDashboard.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationView.test.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationView.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/ObjectAnalysisBoard.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/OrderDetailsBoard.test.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/OrderDetailsBoard.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/PayToSendAnalysis.test.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/PayToSendAnalysis.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/PayToSignAnalysis.test.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/PayToSignAnalysis.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/page.tsx create mode 100644 furniture-3d-system/src/lib/logistics-arrival-signing-duration.test.ts create mode 100644 furniture-3d-system/src/lib/logistics-arrival-signing-duration.ts create mode 100644 furniture-3d-system/src/server/repositories/logistics-arrival-signing-duration-snapshot.repository.ts create mode 100644 furniture-3d-system/src/server/repositories/logistics-arrival-signing-duration.repository.ts create mode 100644 furniture-3d-system/src/server/services/logistics-arrival-signing-duration-export.service.test.ts create mode 100644 furniture-3d-system/src/server/services/logistics-arrival-signing-duration-export.service.ts create mode 100644 furniture-3d-system/src/server/services/logistics-arrival-signing-duration-settings.service.test.ts create mode 100644 furniture-3d-system/src/server/services/logistics-arrival-signing-duration-settings.service.ts create mode 100644 furniture-3d-system/src/server/services/logistics-arrival-signing-duration-snapshot.service.test.ts create mode 100644 furniture-3d-system/src/server/services/logistics-arrival-signing-duration-snapshot.service.ts create mode 100644 furniture-3d-system/src/server/services/logistics-arrival-signing-duration.service.ts diff --git a/furniture-3d-system/docs/架构设计/物流到货签收时长看板-订单明细快照.md b/furniture-3d-system/docs/架构设计/物流到货签收时长看板-订单明细快照.md new file mode 100644 index 00000000..d024bc0e --- /dev/null +++ b/furniture-3d-system/docs/架构设计/物流到货签收时长看板-订单明细快照.md @@ -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` 自己的 ` + +} diff --git a/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/ExcludeKeywordPicker.test.tsx b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/ExcludeKeywordPicker.test.tsx new file mode 100644 index 00000000..4265d77f --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/ExcludeKeywordPicker.test.tsx @@ -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() + + 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() + + 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() + + fireEvent.click(within(fieldSection('发货仓')).getByLabelText('仓储自提')) + fireEvent.click(screen.getByRole('button', { name: '确定' })) + expect(onConfirm).toHaveBeenCalledWith({ logisticCompanyName: ['仓储自提'] }) +}) + +it('清空后确定 = 不排除任何关键词;已选项页签只列勾选项', () => { + const onConfirm = jest.fn() + render() + + 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() + + fireEvent.change(screen.getByPlaceholderText('搜索关键词'), { target: { value: '安' } }) + expect(Array.from(document.querySelectorAll('.exclude-picker-option')).map((node) => node.textContent)).toEqual(['安能']) + expect(fieldSection('物流公司')).toBeTruthy() +}) + +it('非系统管理员看不到新增入口,只提示找管理员处理', () => { + render() + + 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( + , + ) + + 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( + , + ) + + const customInput = screen.getByPlaceholderText(/输入新的排除关键词/) + fireEvent.change(customInput, { target: { value: '自定义仓' } }) + fireEvent.keyDown(customInput, { key: 'Enter' }) + + expect(await screen.findByText('只有权限管理员可以维护排除关键词')).toBeInTheDocument() + expect(within(fieldSection('发货仓')).getByLabelText('自定义仓')).toBeChecked() +}) diff --git a/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/ExcludeKeywordPicker.tsx b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/ExcludeKeywordPicker.tsx new file mode 100644 index 00000000..cfaa64ab --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/ExcludeKeywordPicker.tsx @@ -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 +}) { + const [checked, setChecked] = useState(selected) + const [customKeywords, setCustomKeywords] = useState({}) + const [customInput, setCustomInput] = useState('') + const [customField, setCustomField] = useState(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(null) + const [activeField, setActiveField] = useState(LOGISTICS_ARRIVAL_SIGNING_DURATION_EXCLUDE_FIELDS[0].key) + const panelRef = useRef(null) + const listRef = useRef(null) + const searchRef = useRef(null) + const allRef = useRef(null) + const groupNodes = useRef(new Map()) + + 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(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( +
{ if (event.target === event.currentTarget) onCancel() }}> +
+
+

请选择排除关键词

+ + +
+ +
+ + + +
+ +
+ + + + 每个关键词只比对它所在字段;勾选 = 排除该关键词 +
+ +
+
+ {visibleGroups.length ? visibleGroups.map((group) => ( +
{ + if (node) groupNodes.current.set(group.key, node) + else groupNodes.current.delete(group.key) + }} + > +

+ {group.label} + 共 {group.total} 项 · 已勾选 {(checked[group.key] ?? []).length} 项 +

+
+ {group.keywords.map((keyword) => ( + + ))} +
+
+ )) :

{tab === 'selected' ? '还没有勾选任何关键词' : '没有匹配的关键词'}

} +
+ {visibleGroups.length > 1 ? ( + + ) : null} +
+ +
+ {canAddKeywords ? <> + + setCustomInput(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void addCustomKeywords() } }} + placeholder="输入新的排除关键词,回车或点添加(逗号分隔可一次多个,保存为该系统默认项)" + /> + + : ( + 新增候选关键词仅系统管理员可操作,需要新增请联系系统管理员 + )} + {addMessage ? {addMessage} : null} +
+ +
+ 共 {countExcludeKeywords(allCandidates)} 项 · 已勾选 {checkedCount} 项 + + +
+
+ + +
, + document.body, + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationDashboard.test.tsx b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationDashboard.test.tsx new file mode 100644 index 00000000..45c216c3 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationDashboard.test.tsx @@ -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(() => {})) as typeof fetch + try { + render() + 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() + 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((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() + 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() + 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() + 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() + 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() + 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 + } +}) diff --git a/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationDashboard.tsx b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationDashboard.tsx new file mode 100644 index 00000000..d315fc5e --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationDashboard.tsx @@ -0,0 +1,1064 @@ +'use client' + +import { AlertTriangle, Clock, Download, Maximize2, Minimize2, RotateCcw } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + buildPayToSignAnalysis, + buildPayToSendAnalysis, + logisticsArrivalSigningDurationRowMatchesCascade, + logisticsArrivalSigningDurationRowMatchesFilter, + LOGISTICS_ARRIVAL_SIGNING_DURATION_FULFILLMENT_TARGET_HOURS, + LOGISTICS_ARRIVAL_SIGNING_DURATION_CASCADE_LEVELS, + logisticsArrivalSigningDurationRowKey, + rankLogisticsArrivalSigningDurationRows, + rankLogisticsArrivalSigningDurationThresholdRows, + serializeExcludeKeywords, + withObjectAnalysisTargets, + type LogisticsArrivalSigningDurationDashboard as DashboardData, + type LogisticsArrivalSigningDurationCascadeEntry, + type LogisticsArrivalSigningDurationCascadeLevel, + type LogisticsArrivalSigningDurationDrilldownFilter, + type LogisticsArrivalSigningDurationFilters, + type LogisticsArrivalSigningDurationDrilldownKey, + type LogisticsArrivalSigningDurationRankRow, + type LogisticsArrivalSigningDurationRankings, + type LogisticsArrivalSigningDurationRow, + type LogisticsArrivalSigningDurationStageKey, + type LogisticsArrivalSigningDurationThresholdRankRow, + type PayToSendGroup, + type PayToSignGroup, +} from '@/lib/logistics-arrival-signing-duration' +import { PayToSignAnalysis } from './PayToSignAnalysis' +import { PayToSendAnalysis } from './PayToSendAnalysis' +import { OrderDetailsBoard } from './OrderDetailsBoard' +import { BoardPagination } from './BoardPagination' + +const STAGE_BAR_COLORS = [ + 'linear-gradient(90deg, #4e718c 0%, #7f9fb4 100%)', + 'linear-gradient(90deg, #a47a45 0%, #d0a66d 100%)', + 'linear-gradient(90deg, #79658e 0%, #a894bb 100%)', + 'linear-gradient(90deg, #438b88 0%, #78b4aa 100%)', + 'linear-gradient(90deg, #a85f4d 0%, #d38d76 100%)', +] + +const DISTRIBUTION_BAR_COLORS = [ + 'linear-gradient(90deg, #438b72 0%, #83b69b 100%)', + 'linear-gradient(90deg, #4e718c 0%, #83a9c3 100%)', + 'linear-gradient(90deg, #a47a45 0%, #d2a568 100%)', + 'linear-gradient(90deg, #a85f4d 0%, #d48a72 100%)', +] + +type RankingDimension = keyof LogisticsArrivalSigningDurationRankings +type BoardKey = 'overview' | 'stage' | 'distribution' | 'ranking' +type BoardSize = 'compact' | 'standard' | 'expanded' +type AnalysisDrilldown = { + label: string + rows: LogisticsArrivalSigningDurationRow[] + filter?: LogisticsArrivalSigningDurationDrilldownFilter +} + +const DIMENSION_OPTIONS: Array<{ key: RankingDimension; label: string }> = [ + { key: 'purchaser', label: '采购链路' }, + { key: 'supplier', label: '供应商' }, + { key: 'product', label: '商品' }, + { key: 'logisticCompany', label: '物流公司' }, + { key: 'warehouse', label: '发货仓' }, + { key: 'platform', label: '平台' }, + { key: 'shop', label: '店铺' }, +] + +const DIMENSION_LABELS = Object.fromEntries(DIMENSION_OPTIONS.map((item) => [item.key, item.label])) as Record +const SIZE_LABELS: Record = { compact: '紧凑', standard: '标准', expanded: '展开' } + +function formatHours(value: number | null) { + return value === null ? '-' : `${value.toFixed(1)} 小时` +} + +function formatAverageHours(value: number | null) { + return value === null ? '-' : value.toFixed(1) +} + +function formatRate(value: number | null) { + return value === null ? '-' : `${value.toFixed(1)}%` +} + +function formatCount(value: number) { + return value.toLocaleString('zh-CN') +} + +function queryString(filters: LogisticsArrivalSigningDurationFilters) { + const params = new URLSearchParams({ dateFrom: filters.dateFrom, dateTo: filters.dateTo }) + 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() +} + +function isRetryableNetworkError(cause: unknown) { + return cause instanceof TypeError && /fetch|network|load failed/i.test(cause.message) +} + +function dashboardErrorMessage(cause: unknown) { + if (isRetryableNetworkError(cause)) return '经营分析服务暂时不可用,请稍后刷新重试;如持续失败,请缩小签收日期范围。' + return cause instanceof Error ? cause.message : '经营分析加载失败,请稍后重试' +} + +function waitForRetry(delayMs: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('请求已取消', 'AbortError')) + return + } + const timer = window.setTimeout(() => { + signal.removeEventListener('abort', abort) + resolve() + }, delayMs) + const abort = () => { + window.clearTimeout(timer) + reject(new DOMException('请求已取消', 'AbortError')) + } + signal.addEventListener('abort', abort, { once: true }) + }) +} + +function csvCell(value: unknown) { + return `"${String(value ?? '').replaceAll('"', '""')}"` +} + +function downloadCsv(fileName: string, headers: string[], rows: Array>) { + const content = `\uFEFF${[headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n')}` + const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' })) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = fileName + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) +} + +function productLabel(row: LogisticsArrivalSigningDurationRow) { + return row.productName || row.barcode || '未填写' +} + +function dimensionLabel(row: LogisticsArrivalSigningDurationRow, dimension: RankingDimension) { + if (dimension === 'logisticCompany') return row.logisticCompanyName || '未填写' + if (dimension === 'warehouse') return row.warehouseName || '未填写' + if (dimension === 'platform') return row.platformName || '未填写' + if (dimension === 'shop') return row.shopName || '未填写' + if (dimension === 'purchaser') return row.purchaser || '未填写' + if (dimension === 'supplier') return row.supplier || '未填写' + return productLabel(row) +} + +function matchesSelection( + row: LogisticsArrivalSigningDurationRow, + selection: { purchaser: string | null; supplier: string | null; product: string | null }, +) { + return (!selection.purchaser || dimensionLabel(row, 'purchaser') === selection.purchaser) + && (!selection.supplier || dimensionLabel(row, 'supplier') === selection.supplier) + && (!selection.product || productLabel(row) === selection.product) +} + +function nextBoardSize(size: BoardSize): BoardSize { + return size === 'compact' ? 'standard' : size === 'standard' ? 'expanded' : 'compact' +} + +/** 环节目标达成判定:当前平均时长不高于目标(上月平均 × 98%)即达标。 */ +function stageTargetHit(averageHours: number | null, targetHours: number | null | undefined) { + return averageHours !== null && targetHours !== null && targetHours !== undefined && averageHours <= targetHours +} + +function stageTargetTitle( + target: { baselineMonth: string; baselineAverageHours: number | null; targetHours: number | null } | undefined, +) { + if (!target?.targetHours) return '上月同环节无有效时间数据,暂无目标' + return `本月目标 ${target.targetHours.toFixed(1)} 小时 = 上月(${target.baselineMonth})平均 ${target.baselineAverageHours?.toFixed(1) ?? '-'} × 98%` +} + +function formatStageAchievementRate(rate: number | null | undefined) { + return rate === null || rate === undefined ? '-' : `${rate.toFixed(1)}%` +} + +function stageAchievementTitle( + achievement: { averageHours: number | null; targetHours: number | null; rate: number | null } | undefined, +) { + if (!achievement || achievement.rate === null) return '暂无目标或无可计算订单,无法计算达成率' + return `目标达成率 ${achievement.rate.toFixed(1)}% = 100% − (平均 ${achievement.averageHours?.toFixed(1)} − 目标 ${achievement.targetHours?.toFixed(1)}) ÷ 目标 ${achievement.targetHours?.toFixed(1)} × 100%` +} + +function BoardTools({ + board, + size, + canDownload, + onResize, + onExport, +}: { + board: BoardKey + size: BoardSize + canDownload: boolean + onResize: () => void + onExport: () => void +}) { + return
+ {SIZE_LABELS[size]} + {canDownload ? : null} + + +
+} + +function RankList({ + title, + rows, + selectedLabel, + onSelect, +}: { + title: string + rows: LogisticsArrivalSigningDurationRankRow[] + selectedLabel?: string | null + onSelect?: (label: string) => void +}) { + return
+
{title}按 P90 从高到低
+
对象订单中位P90达成率
+ {rows.length ? rows.map((row) => ) :
暂无可排名数据
} +
+} + +type ThresholdDimension = 'warehouse' | 'logisticCompany' + +function ThresholdRankList({ + title, + stageKey, + rows, + selectedLabel, + canDownload, + onSelect, + onExport, +}: { + title: string + stageKey: 'sendToFetch' | 'fetchToSign' + rows: LogisticsArrivalSigningDurationThresholdRankRow[] + selectedLabel: string | null + canDownload: boolean + onSelect: (label: string) => void + onExport: () => void +}) { + const isPickup = stageKey === 'sendToFetch' + const rowsKey = `${rows.length}|${rows[0]?.label ?? ''}` + 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 shownRows = rows.slice((page - 1) * paging.pageSize, page * paging.pageSize) + return
+
+ {title} + {canDownload ? : null} +
+
+ + {!isPickup ? : null}{!isPickup ? : null} + {shownRows.length ? shownRows.map((row) => + + {!isPickup ? : null} + + + + + {!isPickup ? : null} + ) : } +
{title}单量平均时长24h{isPickup ? '揽收达成率' : '履约率'}48h{isPickup ? '揽收达成率' : '履约率'}{isPickup ? '超48h揽收达成率' : '72h履约率'}超72h履约率
{formatCount(row.orders)}{formatAverageHours(row.averageHours)}{formatRate(row.within24hRate)}{formatRate(row.within48hRate)}{formatRate(isPickup ? row.over48hRate : row.within72hRate)}{formatRate(row.over72hRate)}
暂无可排名数据
+
+ setPaging((current) => ({ ...current, page: next }))} + onPageSizeChange={(next) => setPaging((current) => ({ ...current, pageSize: next }))} + /> + +
+} + +export function LogisticsArrivalSigningDurationDashboard({ + filters, + canDownload = true, + orderRows, + orderTotal = 0, + orderTotalKnown = false, + orderPage = 1, + orderPageSize = 50, + orderTotalPages, + orderPageSizeOptions, + onOrderPageChange, + onOrderPageSizeChange, + exporting = false, + onExportDetails, +}: { + filters: LogisticsArrivalSigningDurationFilters + canDownload?: boolean + orderRows?: LogisticsArrivalSigningDurationRow[] + orderTotal?: number + orderTotalKnown?: boolean + orderPage?: number + orderPageSize?: number + orderTotalPages?: number + orderPageSizeOptions?: readonly number[] + onOrderPageChange?: (page: number) => void + onOrderPageSizeChange?: (pageSize: number) => void + exporting?: boolean + onExportDetails?: (target?: { + keys?: LogisticsArrivalSigningDurationDrilldownKey[] + drilldown?: LogisticsArrivalSigningDurationDrilldownFilter[] + }) => void +}) { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [loadedQuery, setLoadedQuery] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const [activeStageKey, setActiveStageKey] = useState('payToSign') + const [activeDimension, setActiveDimension] = useState('purchaser') + const [selection, setSelection] = useState({ purchaser: null as string | null, supplier: null as string | null, product: null as string | null }) + const [focusedValue, setFocusedValue] = useState(null) + const [payToSignDrilldown, setPayToSignDrilldown] = useState(null) + const [payToSendDrilldown, setPayToSendDrilldown] = useState(null) + const [drilldownPage, setDrilldownPage] = useState(1) + const [drilldownResult, setDrilldownResult] = useState<{ requestKey: string; rows: LogisticsArrivalSigningDurationRow[]; total: number; page: number; totalKnown?: boolean } | null>(null) + const [drilldownError, setDrilldownError] = useState<{ requestKey: string; message: string } | null>(null) + const [boardSizes, setBoardSizes] = useState>({ overview: 'standard', stage: 'standard', distribution: 'standard', ranking: 'standard' }) + const [sizesReady, setSizesReady] = useState(false) + // 履约环节拆解与时效分布的宽度占比(可拖中间分隔条调整,避免环节行被截断)。 + const [analysisSplit, setAnalysisSplit] = useState(0.62) + const analysisGridRef = useRef(null) + // 09 订单明细锚点:任何下钻(看板点选、异常卡片)后自动滚到明细,省掉人工下划。 + const detailBoardRef = useRef(null) + const lastDetailScrollRef = useRef('') + const splitDragRef = useRef<{ x: number; width: number; ratio: number } | null>(null) + const clampSplit = (value: number) => Math.min(0.82, Math.max(0.38, value)) + const handleSplitPointerDown = (event: React.PointerEvent) => { + if (event.button !== 0) return + const width = analysisGridRef.current?.getBoundingClientRect().width ?? 0 + if (!width) return + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + splitDragRef.current = { x: event.clientX, width, ratio: analysisSplit } + } + const handleSplitPointerMove = (event: React.PointerEvent) => { + const start = splitDragRef.current + if (!start) return + setAnalysisSplit(clampSplit(start.ratio + (event.clientX - start.x) / start.width)) + } + const handleSplitPointerEnd = (event: React.PointerEvent) => { + splitDragRef.current = null + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) + } + const handleSplitKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return + event.preventDefault() + setAnalysisSplit((current) => clampSplit(current + (event.key === 'ArrowLeft' ? -0.03 : 0.03))) + } + // 经营对象问题排名的联动链路:上级对象选中后,下级看板只展示对应数据。 + const [cascade, setCascade] = useState([]) + // 经营总览里的异常卡片点击后,只看对应异常订单(独立于对象联动链路)。 + const [anomalyDrilldown, setAnomalyDrilldown] = useState<{ value: 'missing' | 'invalid'; label: string } | null>(null) + const cascadeFilters = useMemo(() => cascade.map((entry) => entry.filter), [cascade]) + const cascadeQuery = useMemo( + () => (cascadeFilters.length ? `&cascade=${encodeURIComponent(JSON.stringify(cascadeFilters))}` : ''), + [cascadeFilters], + ) + // 页面筛选(日期/关键词)变化才显示整块加载态;联动链路变化只做原位刷新,避免看板闪一下。 + const baseQuery = useMemo(() => queryString(filters), [filters]) + const requestQuery = useMemo(() => `${baseQuery}${cascadeQuery}`, [baseQuery, cascadeQuery]) + + useEffect(() => { + const controller = new AbortController() + let attempts = 0 + // 只有联动链路引起的刷新才显示提示;日期/关键词变化由整块加载态接管。 + setRefreshing(requestQuery !== baseQuery) + const loadDashboard = async () => { + while (true) { + try { + const response = await fetch(`/api/supply-chain/management/logistics-arrival-signing-duration/dashboard?${requestQuery}`, { cache: 'default', signal: controller.signal }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '经营分析加载失败,请稍后重试') + return payload.data as DashboardData + } catch (cause) { + if (controller.signal.aborted) throw cause + if (attempts === 0 && isRetryableNetworkError(cause)) { + attempts += 1 + await waitForRetry(800, controller.signal) + continue + } + throw cause + } + } + } + loadDashboard().then((nextData) => { + setData(nextData) + setError(null) + setLoadedQuery(baseQuery) + setRefreshing(false) + }).catch((cause: unknown) => { + if (!controller.signal.aborted) { + setError(dashboardErrorMessage(cause)) + setLoadedQuery(baseQuery) + setRefreshing(false) + } + }) + return () => controller.abort() + }, [baseQuery, requestQuery]) + + useEffect(() => { + try { + const stored = window.localStorage.getItem('logistics-arrival-signing-duration.board-sizes') + if (stored) setBoardSizes({ overview: 'standard', stage: 'standard', distribution: 'standard', ranking: 'standard', ...JSON.parse(stored) }) + const storedSplit = window.localStorage.getItem('logistics-arrival-signing-duration.analysis-split') + if (storedSplit) { + const parsed = Number(storedSplit) + if (Number.isFinite(parsed)) setAnalysisSplit(clampSplit(parsed)) + } + } catch { + // Local layout preferences are optional. + } + setSizesReady(true) + }, []) + + useEffect(() => { + if (sizesReady) window.localStorage.setItem('logistics-arrival-signing-duration.board-sizes', JSON.stringify(boardSizes)) + }, [boardSizes, sizesReady]) + + useEffect(() => { + if (sizesReady) window.localStorage.setItem('logistics-arrival-signing-duration.analysis-split', String(analysisSplit)) + }, [analysisSplit, sizesReady]) + + const activeStage = data?.stages.find((stage) => stage.key === activeStageKey) ?? data?.stages[0] + const activeStageRankings = data?.stageRankings?.[activeStageKey] ?? data?.rankings + const drilldownRows = useMemo(() => data?.drilldownRows ?? [], [data]) + // 样本口径下按联动条件在前端收窄样本(整区间口径由服务端带 cascade 重算)。 + const cascadedRows = useMemo( + () => (cascadeFilters.length ? drilldownRows.filter((row) => logisticsArrivalSigningDurationRowMatchesCascade(row, cascadeFilters)) : drilldownRows), + [cascadeFilters, drilldownRows], + ) + // 命中快照时整块看板都是整区间口径(SQL 聚合),未命中时退回样本现算。 + const thresholdRankings = useMemo(() => data?.thresholdRankings ?? { + warehouse: rankLogisticsArrivalSigningDurationThresholdRows(cascadedRows, 'sendToFetch', (row) => row.warehouseName), + pickupCompany: rankLogisticsArrivalSigningDurationThresholdRows(cascadedRows, 'sendToFetch', (row) => row.logisticCompanyName), + deliveryCompany: rankLogisticsArrivalSigningDurationThresholdRows(cascadedRows, 'fetchToSign', (row) => row.logisticCompanyName), + }, [data, cascadedRows]) + const payToSignAnalysis = useMemo( + () => withObjectAnalysisTargets( + data?.payToSignAnalysis ?? buildPayToSignAnalysis(cascadedRows, filters.dateFrom, filters.dateTo), + data?.objectBaselines?.payToSign, + ), + [data, cascadedRows, filters.dateFrom, filters.dateTo], + ) + const payToSendAnalysis = useMemo( + () => withObjectAnalysisTargets( + data?.payToSendAnalysis ?? buildPayToSendAnalysis(cascadedRows, filters.dateFrom, filters.dateTo), + data?.objectBaselines?.payToSend, + ), + [data, cascadedRows, filters.dateFrom, filters.dateTo], + ) + const supplierRows = useMemo(() => { + if (!data) return [] + const rows = selection.purchaser ? drilldownRows.filter((row) => dimensionLabel(row, 'purchaser') === selection.purchaser) : drilldownRows + return rankLogisticsArrivalSigningDurationRows(rows, activeStageKey, (row) => dimensionLabel(row, 'supplier')) + }, [activeStageKey, data, drilldownRows, selection.purchaser]) + const productRows = useMemo(() => { + if (!data) return [] + const rows = drilldownRows.filter((row) => (!selection.purchaser || dimensionLabel(row, 'purchaser') === selection.purchaser) && (!selection.supplier || dimensionLabel(row, 'supplier') === selection.supplier)) + return rankLogisticsArrivalSigningDurationRows(rows, activeStageKey, productLabel) + }, [activeStageKey, data, drilldownRows, selection.purchaser, selection.supplier]) + const detailRows = useMemo(() => { + if (activeDimension === 'purchaser') return selection.purchaser ? drilldownRows.filter((row) => matchesSelection(row, selection)) : [] + if (!focusedValue) return [] + return drilldownRows.filter((row) => dimensionLabel(row, activeDimension) === focusedValue) + }, [activeDimension, drilldownRows, focusedValue, selection]) + + const selectedChainLabel = [selection.purchaser, selection.supplier, selection.product].filter(Boolean).join(' / ') + const [payToSignDrilldownFilter, payToSendDrilldownFilter] = [payToSignDrilldown?.filter, payToSendDrilldown?.filter] + const sampleDrilldownFilter = useMemo(() => { + if (activeDimension === 'purchaser') { + if (!selection.purchaser) return null + return { + kind: 'product', + purchaser: selection.purchaser, + ...(selection.supplier ? { supplier: selection.supplier } : {}), + ...(selection.product ? { barcode: selection.product } : {}), + } + } + if (!focusedValue) return null + if (activeDimension === 'supplier' || activeDimension === 'product') return null + return { kind: 'object', field: activeDimension, value: focusedValue } + }, [activeDimension, focusedValue, selection]) + const activeDrilldownFilter = activeStageKey === 'payToSign' + ? payToSignDrilldownFilter ?? null + : activeStageKey === 'payToSend' + ? payToSendDrilldownFilter ?? null + : sampleDrilldownFilter + const activeDrilldown = useMemo(() => activeStageKey === 'sendToSign' + ? null + : activeStageKey === 'payToSign' + ? payToSignDrilldown + : activeStageKey === 'payToSend' + ? payToSendDrilldown + : selectedChainLabel || focusedValue + ? { label: selectedChainLabel || `${DIMENSION_LABELS[activeDimension]} / ${focusedValue}`, rows: detailRows } + : null, [activeStageKey, payToSignDrilldown, payToSendDrilldown, selectedChainLabel, focusedValue, activeDimension, detailRows]) + const drilldownKeys = useMemo(() => activeDrilldown + ? Array.from(new Map((anomalyDrilldown + ? drilldownRows.filter((row) => logisticsArrivalSigningDurationRowMatchesFilter(row, { kind: 'anomaly', value: anomalyDrilldown.value })) + : cascadeFilters.length ? cascadedRows : activeDrilldown.rows).map((row) => [logisticsArrivalSigningDurationRowKey(row), { + innerOrderId: row.innerOrderId, soId: row.soId, logisticId: row.logisticId, signTime: row.signTime, + }])).values()) + : [], [activeDrilldown, anomalyDrilldown, cascadeFilters.length, cascadedRows, drilldownRows]) + // 整区间口径下按(可能多级的)维度条件下钻;样本口径按收窄后的样本行键下钻。 + // 命中快照时一律按条件下钻(能覆盖全量订单);只有走实时源时才退化成样本行键。 + const drilldownFilter = useMemo(() => (data?.source === 'snapshot' + ? (anomalyDrilldown + ? [{ kind: 'anomaly' as const, value: anomalyDrilldown.value }] + : cascadeFilters.length ? cascadeFilters : activeDrilldownFilter ? [activeDrilldownFilter] : null) + : null), [activeDrilldownFilter, anomalyDrilldown, cascadeFilters, data?.source]) + const drilldownRequestKey = JSON.stringify({ + filters: requestQuery, keys: drilldownFilter ? [] : drilldownKeys, filter: drilldownFilter, page: drilldownPage, pageSize: orderPageSize, + }) + const handlePayToSignDrilldown = useCallback((next: AnalysisDrilldown | null) => { + setPayToSignDrilldown(next) + setDrilldownPage(1) + }, []) + const handlePayToSendDrilldown = useCallback((next: AnalysisDrilldown | null) => { + setPayToSendDrilldown(next) + setDrilldownPage(1) + }, []) + + useEffect(() => { + if (!activeDrilldown && !anomalyDrilldown) return + if (!drilldownFilter && !drilldownKeys.length) return + const controller = new AbortController() + setDrilldownError(null) + fetch('/api/supply-chain/management/logistics-arrival-signing-duration/drilldown', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + dateFrom: filters.dateFrom, dateTo: filters.dateTo, keyword: filters.keyword, + excludeKeywords: filters.excludeKeywords, page: drilldownPage, + pageSize: orderPageSize, + ...(drilldownFilter ? { drilldown: drilldownFilter } : { drilldownKeys }), + }), + signal: controller.signal, + }).then(async (response) => { + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '下钻明细加载失败') + return payload.data as { rows: LogisticsArrivalSigningDurationRow[]; total: number; page: number; totalKnown?: boolean } + }).then((result) => { + setDrilldownResult({ ...result, requestKey: drilldownRequestKey }) + setDrilldownError(null) + }).catch((cause: unknown) => { + if (!controller.signal.aborted) setDrilldownError({ + requestKey: drilldownRequestKey, + message: cause instanceof Error ? cause.message : '下钻明细加载失败', + }) + }) + return () => controller.abort() + }, [drilldownKeys, drilldownFilter, drilldownPage, drilldownRequestKey, filters.dateFrom, filters.dateTo, filters.keyword, filters.excludeKeywords, orderPageSize, activeDrilldown, anomalyDrilldown]) + + // 异常卡片与对象联动都算"已下钻",09 明细按同一套逻辑展示。 + const drillingDown = Boolean(activeDrilldown || anomalyDrilldown) + const drilldownSignature = anomalyDrilldown + ? `anomaly:${anomalyDrilldown.value}` + : activeDrilldown ? `pick:${activeDrilldown.label}` : '' + // 任何下钻(看板点选、异常卡片)后自动滚到 09 订单明细,省掉人工下划。 + useEffect(() => { + if (!drilldownSignature) { + lastDetailScrollRef.current = '' + return + } + if (lastDetailScrollRef.current === drilldownSignature) return + lastDetailScrollRef.current = drilldownSignature + const target = detailBoardRef.current + if (!target || typeof target.scrollIntoView !== 'function') return + // 等一帧,确保明细板已按新条件渲染后再滚动定位。 + const raf = window.requestAnimationFrame(() => { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }) + return () => window.cancelAnimationFrame(raf) + }, [drilldownSignature]) + const renderPending = (message: string, isError = false) =>
+
{message}
+ {orderRows ?
+
经营对象问题排名
+ +
: null} + +
+ + if (loadedQuery !== baseQuery) return renderPending('经营分析加载中…') + if (error) return renderPending(error, true) + if (!data || !activeStage || !activeStageRankings) return renderPending('经营分析暂无数据') + + const maxDistribution = Math.max(...data.distribution.map((item) => item.orders), 1) + const maxStageP90 = Math.max(...data.stages.map((item) => item.p90Hours ?? 0), 1) + const payToSignStage = data.stages.find((stage) => stage.key === 'payToSign') + const currentDimensionRows = activeStageRankings[activeDimension] ?? [] + const resizeBoard = (board: BoardKey) => setBoardSizes((current) => ({ ...current, [board]: nextBoardSize(current[board]) })) + const resetSelection = () => { + setSelection({ purchaser: null, supplier: null, product: null }) + setFocusedValue(null) + setPayToSignDrilldown(null) + setPayToSendDrilldown(null) + setCascade([]) + setDrilldownPage(1) + } + /** 联动链路:保留更上级条件,写入本级并丢弃更下级条件。 */ + const pickCascade = ( + level: LogisticsArrivalSigningDurationCascadeLevel, + filter: LogisticsArrivalSigningDurationDrilldownFilter, + label: string, + ) => { + const index = LOGISTICS_ARRIVAL_SIGNING_DURATION_CASCADE_LEVELS.indexOf(level) + const current = cascade.find((entry) => entry.level === level) + // 再次点击同一对象 = 取消该级及以下,回到上一级。 + if (current && JSON.stringify(current.filter) === JSON.stringify(filter)) { + setCascade(cascade.filter((entry) => LOGISTICS_ARRIVAL_SIGNING_DURATION_CASCADE_LEVELS.indexOf(entry.level) < index)) + setFocusedValue(null) + setDrilldownPage(1) + return + } + const kept = cascade.filter((entry) => LOGISTICS_ARRIVAL_SIGNING_DURATION_CASCADE_LEVELS.indexOf(entry.level) < index) + setCascade([...kept, { level, filter, label }]) + setDrilldownPage(1) + } + const selectedResult = drilldownResult?.requestKey === drilldownRequestKey ? drilldownResult : null + const selectedError = drilldownError?.requestKey === drilldownRequestKey ? drilldownError.message : null + const detailBoard = orderRows || drillingDown ? entry.label).join(' / ') || activeDrilldown.label) : undefined} + onPageChange={drillingDown ? setDrilldownPage : onOrderPageChange} + onPageSizeChange={drillingDown ? undefined : onOrderPageSizeChange} + onExport={onExportDetails + ? () => onExportDetails(drillingDown + ? (drilldownFilter ? { drilldown: drilldownFilter } : { keys: drilldownKeys }) + : undefined) + : undefined} + /> : null + + const exportStage = () => downloadCsv(`履约环节拆解-${activeStage.label}.csv`, ['履约环节', '目标', '可计算订单', '平均时长', '中位时长', 'P90'], data.stages.map((stage) => [stage.label, stage.targetLabel, stage.eligibleOrders, formatAverageHours(stage.averageHours), formatHours(stage.medianHours), formatHours(stage.p90Hours)])) + const exportDistribution = () => downloadCsv('发货到签收时效分布.csv', ['时效区间', '订单数', '占比'], data.distribution.map((item) => [item.label, item.orders, `${item.percentage}%`])) + const exportOverview = () => downloadCsv('物流签收经营总览.csv', ['指标', '数值', '口径'], [ + ['签收订单', data.overview.orderCount, data.dataScope === 'exact' ? '当前筛选范围(整区间精确)' : '当前分析样本(上限 2,000 条)'], + ['24h揽收达成率', formatRate(data.overview.pickup24hRate), `付款后 24 小时内 / ${data.overview.pickupEligibleOrders} 条有效时间`], + ['48h揽收达成率', formatRate(data.overview.pickup48hRate), `付款后 48 小时内 / ${data.overview.pickupEligibleOrders} 条有效时间`], + ['付款→签收履约率', formatRate(data.overview.fulfillmentRate), `${LOGISTICS_ARRIVAL_SIGNING_DURATION_FULFILLMENT_TARGET_HOURS} 小时内 / ${data.overview.fulfillmentEligibleOrders} 条有效时间`], + ['缺少关键时间', data.overview.missingTimeOrders, '付款、发货或揽收时间不完整'], + ['时间异常', data.overview.invalidTimeOrders, '时间顺序无法计算'], + ]) + const payToSignDailyRows: Array> = [ + ['单量', payToSignAnalysis.total.orders, ...payToSignAnalysis.daily.map((day) => day.orders)], + ['平均时长', payToSignAnalysis.total.averageHours, ...payToSignAnalysis.daily.map((day) => day.averageHours)], + ['24h揽收及时率(%)', payToSignAnalysis.total.pickup24hRate, ...payToSignAnalysis.daily.map((day) => day.pickup24hRate)], + ['48h揽收及时率(%)', payToSignAnalysis.total.pickup48hRate, ...payToSignAnalysis.daily.map((day) => day.pickup48hRate)], + ['付款至签收履约率(%)', payToSignAnalysis.total.fulfillmentRate, ...payToSignAnalysis.daily.map((day) => day.fulfillmentRate)], + ] + const exportPayToSignDaily = () => downloadCsv( + '签收日期维度.csv', + ['指标', '总计', ...payToSignAnalysis.daily.map((day) => day.date)], + payToSignDailyRows, + ) + const exportPayToSignGroup = (title: string, groups: PayToSignGroup[]) => downloadCsv( + `${title}.csv`, + ['采购', '供应商', '对象/条码', '单量', '平均时长', '24h揽收及时率(%)', '48h揽收及时率(%)', '付款至签收履约率(%)'], + groups.map((group) => [ + group.purchaser ?? '', group.supplier ?? '', group.label, group.orders, + group.averageHours, group.pickup24hRate, group.pickup48hRate, group.fulfillmentRate, + ]), + ) + const payToSendDailyRows: Array> = [ + ['单量', payToSendAnalysis.total.orders, ...payToSendAnalysis.daily.map((day) => day.orders)], + ['18h发货及时率(%)', payToSendAnalysis.total.ship18hRate, ...payToSendAnalysis.daily.map((day) => day.ship18hRate)], + ['24h发货及时率(%)', payToSendAnalysis.total.ship24hRate, ...payToSendAnalysis.daily.map((day) => day.ship24hRate)], + ['48h发货及时率(%)', payToSendAnalysis.total.ship48hRate, ...payToSendAnalysis.daily.map((day) => day.ship48hRate)], + ['超48h发货及时率(%)', payToSendAnalysis.total.shipOver48hRate, ...payToSendAnalysis.daily.map((day) => day.shipOver48hRate)], + ] + const exportPayToSendDaily = () => downloadCsv( + '签收日期维度-发货及时率.csv', + ['指标', '总计', ...payToSendAnalysis.daily.map((day) => day.date)], + payToSendDailyRows, + ) + const exportPayToSendGroup = (title: string, groups: PayToSendGroup[]) => downloadCsv( + `${title}.csv`, + ['采购', '供应商', '对象/条码', '单量', '18h发货及时率(%)', '24h发货及时率(%)', '48h发货及时率(%)', '超48h发货及时率(%)'], + groups.map((group) => [ + group.purchaser ?? '', group.supplier ?? '', group.label, group.orders, + group.ship18hRate, group.ship24hRate, group.ship48hRate, group.shipOver48hRate, + ]), + ) + const thresholdExportRows = (dimension: ThresholdDimension) => { + const isPickup = activeStageKey === 'sendToFetch' + const title = dimension === 'warehouse' ? '发货仓' : '物流公司' + const rows = isPickup ? dimension === 'warehouse' ? thresholdRankings.warehouse : thresholdRankings.pickupCompany : thresholdRankings.deliveryCompany + const headers = isPickup + ? [title, '平均时长', '24h揽收达成率', '48h揽收达成率', '超48h揽收达成率'] + : [title, '单量', '平均时长', '24h履约率', '48h履约率', '72h履约率', '超72h履约率'] + const values = rows.map((row) => isPickup + ? [row.label, formatAverageHours(row.averageHours), formatRate(row.within24hRate), formatRate(row.within48hRate), formatRate(row.over48hRate)] + : [row.label, row.orders, formatAverageHours(row.averageHours), formatRate(row.within24hRate), formatRate(row.within48hRate), formatRate(row.within72hRate), formatRate(row.over72hRate)]) + return { title, headers, values } + } + const exportThresholdRanking = (dimension: ThresholdDimension) => { + const { title, headers, values } = thresholdExportRows(dimension) + downloadCsv(`${activeStage.label}-${title}.csv`, headers, values) + } + const exportRanking = () => { + if (activeStageKey === 'payToSign') { + const dates = payToSignAnalysis.daily.map((day) => day.date) + const sections = [ + ['采购维度', payToSignAnalysis.purchasers], + ['供应商维度', payToSignAnalysis.suppliers], + ['条码维度', payToSignAnalysis.products], + ['物流公司维度', payToSignAnalysis.logisticCompanies], + ['发货仓维度', payToSignAnalysis.warehouses], + ['平台维度', payToSignAnalysis.platforms], + ['店铺维度', payToSignAnalysis.shops], + ] as const + const groupRows = sections.flatMap(([title, groups]) => [ + [], + [title, '采购', '供应商', '对象/条码', '单量', '平均时长', '24h揽收及时率(%)', '48h揽收及时率(%)', '付款至签收履约率(%)', '目标', '目标达成率(%)'], + ...groups.map((group) => [ + '', group.purchaser ?? '', group.supplier ?? '', group.label, group.orders, + group.averageHours, group.pickup24hRate, group.pickup48hRate, group.fulfillmentRate, + group.targetHours ?? null, group.achievementRate ?? null, + ]), + ]) + downloadCsv('付款到签收经营分析.csv', ['签收日期维度', '总计', ...dates], [...payToSignDailyRows, ...groupRows]) + return + } + if (activeStageKey === 'payToSend') { + const dates = payToSendAnalysis.daily.map((day) => day.date) + const sections = [ + ['采购维度', payToSendAnalysis.purchasers], + ['供应商维度', payToSendAnalysis.suppliers], + ['条码维度', payToSendAnalysis.products], + ['物流公司维度', payToSendAnalysis.logisticCompanies], + ['发货仓维度', payToSendAnalysis.warehouses], + ['平台维度', payToSendAnalysis.platforms], + ['店铺维度', payToSendAnalysis.shops], + ] as const + const groupRows = sections.flatMap(([title, groups]) => [ + [], + [title, '采购', '供应商', '对象/条码', '单量', '18h发货及时率(%)', '24h发货及时率(%)', '48h发货及时率(%)', '超48h发货及时率(%)', '目标', '目标达成率(%)'], + ...groups.map((group) => [ + '', group.purchaser ?? '', group.supplier ?? '', group.label, group.orders, + group.ship18hRate, group.ship24hRate, group.ship48hRate, group.shipOver48hRate, + group.targetHours ?? null, group.achievementRate ?? null, + ]), + ]) + downloadCsv('付款到发货经营分析.csv', ['签收日期维度', '总计', ...dates], [...payToSendDailyRows, ...groupRows]) + return + } + if (activeStageKey === 'sendToFetch' || activeStageKey === 'fetchToSign') { + const dimensions: ThresholdDimension[] = activeStageKey === 'sendToFetch' ? ['warehouse', 'logisticCompany'] : ['logisticCompany'] + const sections = dimensions.map((dimension) => thresholdExportRows(dimension)) + downloadCsv(`经营对象问题排名-${activeStage.label}.csv`, sections[0].headers, [ + ...sections[0].values, + ...sections.slice(1).flatMap((section) => [[], section.headers, ...section.values]), + ]) + return + } + const rows = activeDimension === 'purchaser' + ? [ + ...activeStageRankings.purchaser.map((row) => ['采购', row.label, row.orders, formatHours(row.medianHours), formatHours(row.p90Hours), formatRate(row.fulfillmentRate)]), + ...supplierRows.map((row) => ['供应商', row.label, row.orders, formatHours(row.medianHours), formatHours(row.p90Hours), formatRate(row.fulfillmentRate)]), + ...productRows.map((row) => ['商品', row.label, row.orders, formatHours(row.medianHours), formatHours(row.p90Hours), formatRate(row.fulfillmentRate)]), + ] + : currentDimensionRows.map((row) => [DIMENSION_LABELS[activeDimension], row.label, row.orders, formatHours(row.medianHours), formatHours(row.p90Hours), formatRate(row.fulfillmentRate)]) + downloadCsv(`经营对象问题排名-${activeStage.label}.csv`, ['层级', '对象', '订单数', '中位时长', 'P90', `${activeStage.targetHours}h达成率`], rows) + } + + return
+ {data.warning ?
{data.warning}
: null} + +
+
经营总览当前筛选范围的关键履约指标
resizeBoard('overview')} onExport={exportOverview} />
+
+
+
签收订单{formatCount(data.overview.orderCount)}{data.dataScope === 'exact' || data.overviewExact ? '当前筛选范围 · 整区间精确' : '当前分析样本 · 上限 2,000 条'}
+
+ 付款→签收平均到货时长 + {formatAverageHours(payToSignStage?.averageHours ?? null)}小时 + 本月目标 {formatAverageHours(data.stageTargets?.payToSign?.targetHours ?? null)} · 目标达成率 {formatStageAchievementRate(data.stageAchievements?.payToSign?.rate)} +
+
24h揽收达成率{formatRate(data.overview.pickup24hRate)}付款后 24 小时内 / {formatCount(data.overview.pickupEligibleOrders)} 条有效时间
+
48h揽收达成率{formatRate(data.overview.pickup48hRate)}付款后 48 小时内 / {formatCount(data.overview.pickupEligibleOrders)} 条有效时间
+
付款→签收履约率{formatRate(data.overview.fulfillmentRate)}{LOGISTICS_ARRIVAL_SIGNING_DURATION_FULFILLMENT_TARGET_HOURS} 小时内 / {formatCount(data.overview.fulfillmentEligibleOrders)} 条有效时间
+
+
+ + +
+
+
+ +
+
+
履约环节拆解{activeStageKey === 'sendToSign' ? '当前环节' : '当前排名'}:{activeStage.label} · {activeStage.targetLabel}
resizeBoard('stage')} onExport={exportStage} />
+
+ {data.stages.map((stage, index) => )} +
+
+ +
+
+
发货→签收时效分布有效发货时间订单
resizeBoard('distribution')} onExport={exportDistribution} />
+
{data.distribution.map((item, index) =>
{item.label}{formatCount(item.orders)}
{item.percentage.toFixed(1)}%
)}
+
+
+ + {activeStageKey !== 'sendToSign' || orderRows ?
+ {activeStageKey !== 'sendToSign' ? <> +

经营对象问题排名

{activeStage.label} · {activeStageKey === 'payToSign' || activeStageKey === 'payToSend' ? '多维经营分析' : activeStageKey === 'sendToFetch' || activeStageKey === 'fetchToSign' ? '按超时率从高到低' : '按 P90 从高到低'}

{cascade.length + ? '已按联动链路筛选,点上级或"返回全部"可还原' + : activeStageKey === 'payToSign' ? '签收日期及经营对象 · 平均时长升序' : activeStageKey === 'payToSend' ? '签收日期及经营对象 · 超48h发货及时率从高到低' : `目标:${activeStage.targetLabel}。点击对象可继续下钻。`}{cascade.length ?
+ 联动链路 + {cascade.map((entry) => {entry.label})} + {refreshing ? 联动加载中… : null} + +
: null}
resizeBoard('ranking')} onExport={exportRanking} />
+ {activeStageKey === 'payToSign' + ? + : activeStageKey === 'payToSend' + ? + : activeStageKey === 'sendToFetch' || activeStageKey === 'fetchToSign' + ? <> +
+ {activeStageKey === 'sendToFetch' ? { setActiveDimension('warehouse'); setFocusedValue(label); pickCascade('warehouse', { kind: 'object', field: 'warehouse', value: label }, `发货仓 / ${label}`) }} onExport={() => exportThresholdRanking('warehouse')} /> : null} + { setActiveDimension('logisticCompany'); setFocusedValue(label); pickCascade('logisticCompany', { kind: 'object', field: 'logisticCompany', value: label }, `物流公司 / ${label}`) }} onExport={() => exportThresholdRanking('logisticCompany')} /> +
+ {focusedValue ?
当前下钻:{DIMENSION_LABELS[activeDimension]} / {focusedValue}
: null} + + : <> +
{DIMENSION_OPTIONS.map((item) => )}
+ {activeDimension === 'purchaser' ?
{ setSelection({ purchaser: label, supplier: null, product: null }); setDrilldownPage(1) }} /> { setSelection((current) => ({ ...current, supplier: label, product: null })); setDrilldownPage(1) }} /> { setSelection((current) => ({ ...current, product: label })); setDrilldownPage(1) }} />
:
{ setFocusedValue(label); setDrilldownPage(1) }} />
} + {selectedChainLabel || focusedValue ?
当前下钻:{selectedChainLabel || `${DIMENSION_LABELS[activeDimension]} / ${focusedValue}`}
:
选择采购后查看对应供应商,选择供应商后查看商品,选择商品后查看订单明细。
} + } + : null} +
{detailBoard}
+
: null} + +

统计范围遵循当前筛选条件:物流状态为“已签收”、拦截状态不为“已拦截”。{data.dataScope === 'exact' + ? '当前数据来自每日订单明细快照,总览、履约环节拆解、时效分布、经营分析与超时排名均为整区间精确统计,09 订单明细下钻同样为全量匹配订单。' + : data.dataScopeReason === 'range-too-large' + ? `当前区间订单量已超出整区间精确统计的上限(约 11 万条):经营总览与 09 订单明细仍为整区间精确值,履约环节拆解、时效分布、经营分析与超时排名改用区间内均匀抽取的 2,000 条分析样本;缩小签收日期范围可让这些看板也变成整区间精确。` + : '当前走实时数据源,总览与各分析看板均为区间内最多 2,000 条分析样本口径,并非全量订单;本地快照覆盖该区间时自动切换为整区间精确统计。'}

+ + +
+} diff --git a/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationView.test.tsx new file mode 100644 index 00000000..7d56f05f --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationView.test.tsx @@ -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( + , + ) + + // 摘要按字段给出数量,替代之前会截断的一长串关键词。 + expect(screen.getByText('已排除 2 项:发货仓 1 · 物流公司 1')).toBeInTheDocument() + const hidden = document.querySelector('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( + , + ) + + fireEvent.click(screen.getByTitle(/点击勾选要排除的关键词/)) + fireEvent.click(await screen.findByRole('button', { name: '清空' })) + fireEvent.click(screen.getByRole('button', { name: '确定' })) + + expect(document.querySelector('input[name="excludeKeywords"]')!.value).toBe('') + expect(document.querySelector('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( + , + ) + + 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 + } +}) diff --git a/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationView.tsx b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationView.tsx new file mode 100644 index 00000000..8322c66b --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/logistics-arrival-signing-duration/LogisticsArrivalSigningDurationView.tsx @@ -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(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(null) + const pendingScrollTop = useRef(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(null) + const [exporting, setExporting] = useState(false) + const [exportMessage, setExportMessage] = useState(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 ( +
+
+
+
+
+

经营管理 / 物流时效

+

物流到货签收时长看板

+

物流签收明细 · 物流状态:已签收 · 拦截状态:不等于已拦截

+
+
{initialData.totalKnown ? initialData.total.toLocaleString('zh-CN') : `${initialData.rows.length}`}{initialData.totalKnown ? '条明细' : '条已加载'}{initialData.source === 'snapshot' + ? 快照数据 · {initialData.snapshotDate ?? '-'} + : initialData.dataAsOf + ? {initialData.stale ? '缓存数据' : '数据更新'} · {formatLogisticsDateTime(initialData.dataAsOf)} + : null}
+
+ +
+ + 至 + +