From c6044d972cccbbc4e787211a2c0e68adf1f0c720 Mon Sep 17 00:00:00 2001 From: wangxuming Date: Wed, 22 Jul 2026 10:18:49 +0800 Subject: [PATCH] feat: add historical data management --- docs/history-data-management-plan.md | 211 ++++ findings.md | 45 + progress.md | 65 ++ scripts/test-postgres.sh | 2 +- .../database/database_integration_test.go | 50 +- server/internal/database/maintenance.go | 29 +- server/internal/httpapi/history.go | 941 +++++++++++++++ .../httpapi/history_integration_test.go | 139 +++ server/internal/httpapi/history_test.go | 78 ++ server/internal/httpapi/server.go | 6 + server/internal/model/models.go | 3 +- .../000013_history_indexes.down.sql | 5 + .../migrations/000013_history_indexes.up.sql | 14 + .../000014_history_anonymization.down.sql | 4 + .../000014_history_anonymization.up.sql | 6 + task_plan.md | 110 +- web/src/App.tsx | 2 + web/src/api.ts | 57 + web/src/pages/AdminPage.tsx | 1 + web/src/pages/HistoryPage.test.tsx | 77 ++ web/src/pages/HistoryPage.tsx | 367 ++++++ web/src/styles.css | 1038 +++++++++++++++++ web/src/types.ts | 151 +++ 23 files changed, 3389 insertions(+), 12 deletions(-) create mode 100644 docs/history-data-management-plan.md create mode 100644 server/internal/httpapi/history.go create mode 100644 server/internal/httpapi/history_integration_test.go create mode 100644 server/internal/httpapi/history_test.go create mode 100644 server/migrations/000013_history_indexes.down.sql create mode 100644 server/migrations/000013_history_indexes.up.sql create mode 100644 server/migrations/000014_history_anonymization.down.sql create mode 100644 server/migrations/000014_history_anonymization.up.sql create mode 100644 web/src/pages/HistoryPage.test.tsx create mode 100644 web/src/pages/HistoryPage.tsx diff --git a/docs/history-data-management-plan.md b/docs/history-data-management-plan.md new file mode 100644 index 0000000..8858f71 --- /dev/null +++ b/docs/history-data-management-plan.md @@ -0,0 +1,211 @@ +# 后台历史数据管理功能规划 + +## 1. 目标 + +在现有管理端增加“历史数据”模块,用于查询过去营业日的排队记录、叫号记录和运营统计,支持问题追溯、现场复盘和受控导出。 + +首期定位为“历史数据查询与分析”,不把历史记录当成可直接编辑的业务表,也不引入独立历史数据库或复杂数据中台。 + +## 2. 当前系统基础 + +- 管理端已有运营概览、项目管理、账号管理和大屏中心;管理端接口统一使用 `/api/admin/*`,要求管理员登录。 +- 数据库已有 `queue_sessions`、`queue_tickets`、`call_batches`、`call_batch_tickets` 和 `audit_entries`,可以用“项目 + 营业日”定位历史数据。 +- `queue_tickets` 已记录取号、叫号、到场、完成、过号、取消等时间和状态,并保存同行人数。 +- 个人关联数据已有定时清理机制:终态号码到期后清除手机号、姓氏和称谓;历史维护任务对超过 90 天的记录再次兜底清除未处理的个人字段,清理后仍保留号码和运营统计。 +- 当前 `ADMIN` 可跨项目查看管理数据,员工账号不能进入管理端;后续若增加项目级管理员,历史查询必须复用服务端项目授权校验。 + +## 3. 推荐的信息架构 + +在管理端侧边栏新增: + +`历史数据` + +页面内部采用三个视图,避免拆成过多导航: + +1. **排队记录**:按营业日、项目、号码和状态查询单号明细。 +2. **叫号记录**:按营业日、项目和操作人查询每次叫号及其号码成员。 +3. **运营统计**:按日/项目查看数量、等待时长、完成率和过号率。 + +“操作审计”可作为后续第四个视图,复用已有 `audit_entries`,不要和业务历史记录混在一张表里。 + +默认进入“排队记录”,默认日期为最近 7 天,默认只显示已结束营业日;当前运行中的队列继续由运营概览负责。 + +## 4. 首期功能范围 + +### 4.1 排队记录列表 + +筛选条件: + +- 项目:全部项目或单个项目; +- 营业日期:起止日期,默认最近 7 天,单次最多 31 天; +- 号码:支持完整号码或号码范围; +- 状态:全部、等待、已叫、已到场、已完成、过号、已取消; +- 可选:手机号精确查询,仅对仍在个人数据留存期内的记录生效。 + +列表字段: + +| 字段 | 说明 | +|---|---| +| 项目/营业日 | 明确数据归属,避免多项目混淆 | +| 号码 | 显示业务号码,不暴露内部 UUID | +| 同行人数 | 号码绑定人数 | +| 状态 | 使用现有状态颜色和中文文案 | +| 取号时间 | `joined_at` | +| 叫号时间 | `called_at`,未叫号显示“—” | +| 完成/终态时间 | 根据状态显示对应时间 | +| 实际等待时长 | `called_at - joined_at`,未叫号显示“—” | +| 联系信息 | 列表默认脱敏;已清理显示“已匿名化” | + +点击行打开详情抽屉,展示状态时间线、所属叫号批次、操作人和数据清理状态。历史详情只读,不提供直接修改状态、号码或同行人数的按钮。 + +### 4.2 叫号记录 + +列表字段: + +- 项目、营业日、叫号序号; +- 叫号方式:按号码/按人数; +- 请求数量、实际号码数、实际人数; +- 号码区间或号码列表; +- 操作人、叫号时间、完成时间、批次状态。 + +点击后展示批次成员及每个成员的最终状态。批次记录必须以 `call_batches` 和 `call_batch_tickets` 为准,不从当前队列快照反推。 + +### 4.3 运营统计 + +运营统计按经营复盘看板组织,首期提供四层信息: + +- 核心指标:取号号码/人数、完成率、平均/最长等待、过号率和取消率; +- 每日趋势:按营业日查看取号量、完成量和完成率变化; +- 项目对比:按取号量排序,联动完成率、过号率和平均等待,支持下钻到排队记录; +- 状态与时段节奏:展示号码状态、取号/叫号峰值时段和小时分布,并支持从项目对比下钻到排队记录。 + +基础业务指标包括: + +- 取号总数、取号总人数; +- 已叫号码数/人数、已完成号码数/人数; +- 过号率、取消率、完成率; +- 平均等待时长、最长等待时长、按小时取号/叫号量。 + +统计口径固定为: + +- 等待时长 = `called_at - joined_at`; +- 叫号吞吐按 `called_at` 计入小时; +- 完成率 = 已完成号码数 / 已取号号码数; +- 过号率、取消率均以已取号号码数为分母; +- 没有时间戳的数据不补估,统计中明确标记为缺失。 + +首期使用数据库聚合查询,不提前建设复杂报表引擎。若单表规模或查询 P95 超过目标,再增加按项目/营业日的汇总表。 + +### 4.4 导出 + +- 首期支持 CSV,编码采用 UTF-8 BOM,方便中文表格软件直接打开; +- 导出内容跟随当前筛选条件,不允许绕过项目范围; +- 默认导出脱敏手机号,完整手机号需要二次确认并记录审计; +- 单次最多导出 10,000 行,超出时提示缩小日期范围; +- 导出记录包含项目、营业日、筛选条件、操作人、时间和行数; +- 暂不做 Excel、定时邮件、外部链接和自动下载中心。 + +## 5. 数据生命周期与隐私 + +### 推荐口径 + +1. **0–30 天**:可查询业务明细;手机号/姓氏只在管理员权限范围内可见,列表默认脱敏。 +2. **个人信息清理后**:号码、同行人数、状态、时间和批次关系仍可用于历史分析;手机号和姓氏显示“已匿名化”,不可再按手机号查询。 +3. **90 天以后**:保留匿名业务明细和运营统计;手机号、姓氏等个人关联信息不可恢复,且不可再按手机号检索。 +4. **审计记录**:沿用现有 1 年留存和定时清理机制;历史模块的查看明文、导出、筛选敏感字段都要写入审计。 + +实现口径已确认:历史维护任务对超过 90 天的记录物理清除手机号、姓氏、称谓和检索摘要,并写入匿名化时间;业务号码、状态、时间、批次关系和统计字段继续保留,之后不可按手机号检索或恢复个人关联。 + +## 6. 权限与安全 + +- 所有历史接口只允许管理端账号访问,游客、员工、大屏令牌不能访问。 +- 服务端按项目授权过滤,不能信任前端传入的项目 ID;跨项目查询仅对全局管理员开放。 +- 列表默认手机号脱敏;查看完整手机号、按手机号查询和导出完整手机号均记录审计。 +- 过滤条件和分页参数必须有上限,避免全表扫描;手机号不进入 URL、普通日志或前端埋点。 +- 历史记录只读。若出现业务纠错需求,另建“纠正申请/调整记录”,保留原始值、调整值、原因、审批人和时间,不直接覆盖原始事实。 + +## 7. 后端接口建议 + +```text +GET /api/admin/history/sessions +GET /api/admin/history/tickets +GET /api/admin/history/tickets/{id} +GET /api/admin/history/batches +GET /api/admin/history/batches/{id} +GET /api/admin/history/summary +GET /api/admin/history/export.csv +``` + +公共查询参数建议统一为:`project_id`、`from`、`to`、`status`、`page`、`page_size`、`query`。 + +接口约束: + +- 日期按项目时区解释,数据库仍统一存储 UTC 时间; +- 列表接口返回 `items`、`page`、`page_size`、`total` 和 `filters`; +- 详情接口返回只读时间线和关联批次; +- 汇总接口同时返回指标值和数据更新时间; +- 导出接口复用同一套筛选和授权逻辑,不另写一套查询条件。 + +## 8. 数据库与性能建议 + +首期直接复用现有事实表,补充必要索引: + +- `queue_sessions(project_id, business_date DESC)`; +- `queue_tickets(queue_session_id, status, ticket_number)`; +- `queue_tickets(project_id, called_at)`; +- `call_batches(queue_session_id, called_at DESC)`; +- 如保留手机号查询,使用现有 `phone_hmac` 索引,不对密文做模糊搜索。 + +建议验收基线:最近 31 天、10 万条号码记录内,列表首屏 P95 ≤ 800ms;统计接口 P95 ≤ 1.5s;导出超过 10,000 行时先拒绝并提示缩小范围。达到规模后再评估日汇总表或异步导出任务。 + +## 9. 分阶段实施 + +### P0:可用的历史查询 + +- 新增历史数据导航和页面骨架; +- 营业日/项目/状态/号码筛选; +- 排队记录列表、详情抽屉; +- 叫号批次列表与详情; +- 项目隔离、脱敏和只读限制; +- 后端分页、索引和接口测试。 + +### P1:分析与受控导出 + +- 运营统计卡片、趋势图和按小时分布; +- CSV 导出与导出审计; +- 明文查看审计; +- 历史数据新鲜度和匿名化状态提示; +- 性能压测与大数据量空状态/错误状态。 + +### P2:治理能力 + +- 操作审计查询视图; +- 可配置留存策略和执行记录; +- 大范围导出的异步任务; +- 匿名化/纠正申请工作流; +- 冷数据归档和恢复演练。 + +## 10. 明确不做 + +- 不允许在历史列表直接“改状态”“改号码”“改同行人数”; +- 不把当前运营概览改造成全量历史报表; +- 不首期引入独立数据仓库、消息队列或复杂 BI 平台; +- 不首期支持任意字段模糊搜索、全库无限期明文查询和无审计导出; +- 不把个人手机号作为长期统计维度。 + +## 11. 验收标准 + +- 管理员可按项目、营业日、状态和号码分页查询历史记录; +- 查询结果不会混入当前运行队列之外的错误项目数据; +- 排队单详情能还原取号、叫号、到场、完成/过号/取消时间线; +- 叫号批次详情能展示实际号码数、实际人数和成员状态; +- 统计口径与列表数据一致,缺失时间不被伪造为 0; +- 已清理个人信息的记录不可通过手机号搜回,页面显示匿名化状态; +- 导出与明文查看均产生审计记录,且导出遵循同样的项目权限; +- 历史页面不提供改变事实数据的入口; +- 31 天范围内的列表和统计达到性能基线; +- 跨项目、未登录、员工账号和伪造项目参数的访问全部被拒绝并留痕。 + +## 12. 推荐决策 + +建议先按 P0 + P1 建设:先把“查得到、看得懂、导得出、可追责”做完整;暂不把人工修正、冷归档和可配置留存放进首期。按当前技术栈,预计 1 名前端、1 名后端和 1 名测试/产品协作,约 2–3 周可完成首版,实际取决于历史数据量和隐私口径确认。 diff --git a/findings.md b/findings.md index e798938..91166a7 100644 --- a/findings.md +++ b/findings.md @@ -1,5 +1,50 @@ # 景区排队叫号系统:调研发现与决策台账 +## Phase 42 运营统计模块标题区呼吸空间优化(2026-07-16) + +- 用户通过截图明确指出“每日趋势、项目对比、状态分布、高峰时段”几个模块的标题和副标题贴近边框,整体缺少呼吸空间。 +- 截图证据显示问题集中在卡片标题区的上下内边距和标题区到首个数据区的间隔,不是数据内容本身或模块顺序问题。 +- 本轮采用统一规则:桌面卡片增加 24px 上下内边距与 32px 水平内边距;标题区副标题使用更舒展的行高,标题区下方增加 24px 留白;移动端收敛为 20px 内边距并保留 20px 标题区间隔。 +- 不改变趋势、项目比较、状态列表、高峰列表的 DOM 数据结构和下钻交互,只调整 CSS 节奏与标题区/内容区边界。 + +## Phase 41 运营统计页视觉层级与排版精修(2026-07-16) + +- 本轮按 `design-taste-frontend` 的“保留式重设计”处理:不改变历史数据页路由、导航、数据契约、权限、导出和项目下钻,只调整视觉层级、排版和空值表达。 +- 设计读法为可信、轻量、低干扰的后台复盘工具;参数设为 `DESIGN_VARIANCE=4`、`MOTION_INTENSITY=2`、`VISUAL_DENSITY=5`,不引入营销页式动效、渐变或新图片。 +- `ui-ux-pro-max` 设计系统仍推荐数据密集型看板,但要求统一字体层级、8px 间距节奏、足够对比度、无横向溢出,以及少于 4 个时间点不用折线图。 +- 当前页面筛选区复用了记录页的 6 列网格,统计页隐藏字段后仍留下不均衡空白;五个 KPI 各自成卡,导致第一层指标像五个同级模块;趋势、项目和节奏卡片的边界同质,主次不够明显。 +- 精修方案:统计页筛选网格收敛为日期、项目、操作;KPI 合并成单一指标轨道;趋势与项目保持主内容宽卡,状态与运营节奏作为次级双栏;移动端统一单列并保持 8px 触控间距。 +- 页面 visible copy 需要避免使用破折号作为缺省值,统一为“暂无/未提供/日期至日期”等可读文案;小时分布文字改用已定义的深色文本 token,修复变量未定义导致的继承不确定性。 + +## Phase 40 运营统计看板布局与 UI/UX 收敛(2026-07-16) + +- 用户明确要求删除运营统计看板中的“需要关注”模块;本轮只移除前端展示与相关引导文案,不改变项目下钻、历史查询和实时运营概览职责。 +- `ui-ux-pro-max` 设计系统推荐数据密集型经营看板:以有限层级的网格承载多个 KPI、趋势和排序比较;配色沿用现有管理端 token,不新增依赖或视觉体系。 +- UI/UX 检索要求响应式页面避免横向溢出、保持连续标题层级、避免加载时布局跳动;图表检索建议时间序列使用折线图、离散项目使用排序比较,并保留可读数值。 +- 当前实现的主要布局问题是趋势与“需要关注”并列导致主内容被压缩,项目对比与高峰时段再次并列造成卡片碎片化,窄屏项目行的三列声明又承载四组内容。 +- 收敛后的结构固定为:核心指标 → 全宽每日趋势 → 全宽项目对比 → 状态分布与高峰/小时节奏;少于 4 个营业日时趋势区域改为日卡片摘要,避免单点折线误导。 + +## Phase 39 运营统计经营复盘看板优化(2026-07-16) + +- 用户已确认运营统计模块按“经营复盘看板”优化,不做实时值班监控;实时排队继续由现有“运营概览”负责。 +- 当前 `/api/admin/history/summary` 只返回四类汇总指标、状态分布字段和按小时聚合;缺少按营业日趋势、项目维度对比、峰值时段排名、异常项目/日期摘要。 +- 当前 `HistoryPage` 的“运营统计”只有 4 张汇总卡片、状态分布和按小时长条列表,信息密度偏低,无法快速回答“哪天/哪个项目变差、差在哪里”。 +- 既有日期范围最多 31 天、项目筛选和 Asia/Shanghai 小时聚合可以复用;新增接口字段应继续服从项目权限与同一日期筛选口径。 +- 小时统计实现已校正为分开按 `joined_at` 统计取号量、按 `called_at` 统计叫号量;两者可能落在不同本地小时,集成夹具已覆盖这一情况。 +- 推荐看板结构:顶部筛选与数据新鲜度;第一层核心 KPI;第二层每日趋势(取号量、完成率、平均等待);第三层项目排行/对比;第四层峰值时段和需要关注的异常摘要;支持点击项目或日期回到排队记录筛选。 +- 继续使用现有原生 CSS 和管理端 token,不新增图表依赖;趋势图用轻量 SVG/HTML 图形,避免引入包和造成项目风格分裂。 + +## Phase 37 后台历史数据管理功能规划(2026-07-16) + +- 现有管理端导航为运营概览、项目管理、账号管理和大屏中心,历史数据适合新增单一“历史数据”入口,内部采用排队记录、叫号记录、运营统计三个视图。 +- 现有 `queue_sessions` 以 `project_id + business_date` 定位营业日,`queue_tickets` 已保存号码、同行人数、状态和取号/叫号/到场/完成/过号时间,`call_batches` 与 `call_batch_tickets` 可还原叫号批次;首期不需要新建独立历史事实表。 +- 现有管理 API 仅允许 `ADMIN` 角色进入,管理员默认跨项目;历史接口仍需把项目授权作为服务端过滤条件,不能信任客户端项目参数。 +- 当前数据库维护任务会在终态数据到期后清除手机号、姓氏和称谓,并保留号码及统计所需字段;历史页面应把这类记录显示为“已匿名化”,不能再通过手机号检索。 +- 既有产品/设计文档要求原始排队业务事件 90 天后不可逆匿名化,但现有代码主要实现的是终态个人字段 30 天清理和审计 1 年过期删除,二者存在待统一的策略差异。 +- 推荐首期做只读历史查询、叫号批次追溯、统计聚合和 CSV 受控导出;不允许直接修改历史状态/号码/同行人数,不首期引入数据仓库、冷归档或复杂审批流。 +- 详细方案已写入 `docs/history-data-management-plan.md`,当前唯一必须确认的高影响决策是 90 天后原始业务事件究竟保留匿名明细还是只保留统计。 +- 用户已确认:90 天后保留匿名业务明细和长期统计,删除手机号、姓氏等个人关联信息,且不可恢复;该口径纳入后续清理任务、查询权限、导出字段和验收标准。 + ## Phase 36 员工端取号同行人数输入修复(2026-07-16) - 用户反馈员工端取号页的“同行人数”输入框存在默认值 `1` 被固定、无法顺畅改成其他人数的问题。 - 本轮只修复输入交互与相应回归测试,不改变项目配置的人数上下限、取号 API 契约或服务端校验。 diff --git a/progress.md b/progress.md index fc90563..d56a368 100644 --- a/progress.md +++ b/progress.md @@ -1,5 +1,70 @@ # Progress Log +## 2026-07-16 - Phase 42 运营统计模块标题区呼吸空间优化 + +- 已读取用户提供的看板截图,并按 Product Design Audit 规范完成证据定位:四个模块的标题、副标题与卡片边框距离过小,标题区与首个数据区缺少缓冲。 +- 已冻结本轮范围:只调整卡片内边距、标题区垂直节奏和内容分隔,不改数据、模块顺序、项目下钻或接口。 +- 已在看板样式末尾增加统一标题区规则:桌面卡片使用 24px 上下、32px 水平内边距;标题区增加顶部缓冲、标题/副标题间距和 24px 内容前留白;移动端收敛为 20px 卡片内边距。 +- 已同步增加项目行、峰值列表和状态列表的内容间距,保证四个模块的标题区和数据区具有一致呼吸空间。 +- 验收通过:历史页定向测试、全量 `make test`(15 个文件/53 项)、`make build`、类型检查、API `/healthz`、前端 `:5175` HTTP 200 和 `git diff --check` 均通过。 + +## 2026-07-16 - Phase 41 运营统计页视觉层级与排版精修 + +- 已读取并执行 `design-taste-frontend` 与 `ui-ux-pro-max`,完成保留式视觉审计、设计读法和参数设定。 +- 已确认本轮不改历史数据业务契约,只处理统计页及其共享历史筛选/空值文案的视觉与排版细节。 +- 已记录改版方向:视图感知筛选网格、统一 KPI 指标轨道、明确主次卡片层级、修复小时条文字 token 和缺省文案。 +- 已将 `HistoryFiltersBar` 按视图添加统计页专用网格,避免隐藏状态/关键词字段后留下空列;历史页标题、Tabs 和筛选控件的圆角、背景、焦点与 hover 层级保持统一。 +- 已将五个 KPI 从独立白卡合并为一张指标轨道,桌面使用内部分隔线,窄屏按两列折行并保持最后一项完整分隔。 +- 已为趋势、项目对比和运营节奏卡设置明确的主次边界;项目行增加轻量 hover 和固定下钻按钮宽度,小时分布文字改用 `--color-ink`。 +- 已完成页面可见缺省文案自检,移除破折号式空值表达,统一使用“暂无”“未提供”“日期至日期”等语义化文案。 +- 已补充历史页回归断言,覆盖统计筛选布局类名、短日期日卡片、关注模块不存在和项目下钻;业务数据、路由、导出、权限契约未改变。 +- 全量验收完成:`make test`(15 个文件/53 项)、`make check`、`make build`、API `/healthz`、前端 `:5175` HTTP 200 和 `git diff --check` 均通过。 + +## 2026-07-16 - Phase 40 运营统计看板布局与 UI/UX 收敛 + +- 已读取当前工作树、Phase 39 运营统计实现和既有测试,确认本轮以增量方式调整历史运营统计页,不清理其他在途改动。 +- 已使用 `ui-ux-pro-max` 生成数据密集型看板设计系统,并检索响应式、可访问性、层级和图表建议。 +- 已冻结改版口径:删除“需要关注”模块,改为 KPI、趋势、项目对比、状态/时段节奏四层结构;少于 4 天用日卡片,避免单点趋势图。 +- 已将趋势卡片改为全宽;项目对比改为独立宽卡;高峰时段与小时分布合并为运营节奏卡片;移动端项目行调整为四列,避免下钻按钮隐式换列。 +- 已删除“需要关注”卡片、异常引导语和对应前端样式;API 仍保留 `alerts` 字段以兼容已有后端响应,但当前页面不消费、不展示该字段。 +- 已为少于 4 个营业日的日期范围增加日卡片摘要;4 天及以上继续使用 SVG 趋势图,并用虚线区分完成量序列。 +- 已补充回归断言:关注模块不存在、短日期范围不渲染单点趋势图、项目下钻仍切换到排队记录并保留项目筛选。 +- 全量验收完成:`make test`(15 个文件/53 项)、`make check`、`make build`、API `/healthz`、前端 `:5175` HTTP 200 和 `git diff --check` 均通过。 + +## 2026-07-16 - Phase 39 运营统计经营复盘看板优化 + +- 已完成需求确认:本轮目标是历史经营复盘,不改变实时运营概览职责。 +- 已核对当前统计接口和页面:现有数据支持汇总、状态分布、按小时聚合,但缺少每日趋势、项目对比、峰值排名和异常摘要。 +- 已确定沿用现有管理端绿色/中性 token 与原生 CSS,不引入图表依赖;已完成统计聚合接口扩展与运营统计视图重构。 +- 已扩展 `/api/admin/history/summary`:新增 `daily` 每日趋势、`projects` 项目对比、`peak_hours` 峰值时段和 `alerts` 关注摘要,继续复用项目/日期筛选与权限校验。 +- 已将运营统计重构为经营复盘看板:核心 KPI、每日 SVG 趋势、项目排行/下钻、关注摘要、高峰时段、状态分布和小时节奏。 +- 已补充前端页面回归、后端关注阈值单元测试和真实 PostgreSQL 看板聚合集成断言;定向前后端测试与 `make test-db` 已通过。 +- 已校正小时统计口径:取号按 `joined_at`、叫号按 `called_at` 分桶,并验证两个事件落在不同本地小时的场景。 +- 全量验收完成:`make test`、`make check`、`make build`、`make test-db` 通过;API `:8080/healthz` 返回 `status: ok`,前端 `:5175` 返回 200。 + +## 2026-07-16 - Phase 38 后台历史数据管理功能实施 + +- 已实现 `/api/admin/history/tickets`、号码详情、叫号批次列表/详情、运营统计和 CSV 导出接口;统一支持项目、营业日、状态、号码/手机号筛选与分页。 +- 已复用现有 `queue_sessions`、`queue_tickets`、`call_batches` 和 `call_batch_tickets`,新增历史查询索引迁移 `000013`。 +- 已新增 `history_anonymized_at` 及 `000014` 迁移;数据库维护任务在终态记录超过 90 天时标记业务明细匿名化,保留号码、状态、时间和统计字段。 +- 已实现历史号码列表默认脱敏、详情审计查看联系方式、匿名化后不可按手机号检索,以及 CSV 10000 行上限和导出审计。 +- 管理端新增 `/admin/history` 与“历史数据”导航,完成排队记录、叫号记录、运营统计、筛选、详情抽屉和 CSV 下载页面。 +- 已新增前端 HistoryPage 回归测试、Go 历史筛选/脱敏单元测试和 PostgreSQL 历史接口集成测试。 +- 90 天匿名化维护任务会物理清除手机号、姓氏、称谓和检索摘要,并保留业务号码、状态、时间、批次关系与统计字段;历史页面默认不返回称谓,明文查看仍需审计。 +- 列表已补齐上一页/下一页操作,筛选提示改为实时筛选语义。 +- `make test`、`make check`、`make build`、`make test-db` 全部通过;前端 15 个测试文件、53 项测试通过,真实 PostgreSQL 验证包含匿名化字段清除。 + +## 2026-07-16 - Phase 37 后台历史数据管理功能规划 + +- 已读取当前项目计划、产品规划、数据库模型、初始迁移、后台 API、维护任务和管理端页面,确认历史功能可以复用现有事实表。 +- 已登记当前数据基础:按项目/营业日的队列场次、排队单状态时间线、叫号批次成员关系、管理员登录和基础审计。 +- 已形成 `docs/history-data-management-plan.md`,覆盖页面信息架构、查询字段、统计口径、导出、权限、隐私、接口、索引、分阶段范围和验收标准。 +- 已明确首期建议:P0 查询与追溯,P1 统计与 CSV 导出,P2 审计查询、可配置留存、异步导出和冷归档。 +- 已发现并记录一个待决策差异:文档要求原始业务事件 90 天匿名化,当前维护代码主要清理终态记录的个人字段;在实现前需确认最终留存/匿名化动作。 +- 用户已确认最终口径:90 天后保留匿名明细与统计,删除个人关联信息且不可恢复;Phase 37 规划阶段完成。 +- 真实 PostgreSQL 维护验证首次发现匿名化条件的参数类型推断错误,已记录并改为显式 `timestamptz` 转换,准备重跑验证。 +- 本轮未修改业务代码、数据库迁移或运行数据。 + ## 2026-07-16 - Phase 36 员工端取号同行人数输入修复 - 已恢复文件化计划上下文并确认工作树包含用户既有在途修改;本轮只对员工取号人数输入相关代码做增量修复。 diff --git a/scripts/test-postgres.sh b/scripts/test-postgres.sh index 1397202..b6b509d 100755 --- a/scripts/test-postgres.sh +++ b/scripts/test-postgres.sh @@ -14,7 +14,7 @@ trap cleanup EXIT INT TERM ( export TEST_DATABASE_URL="postgres://queue:queue@localhost:5432/$TEST_DB?sslmode=disable" cd "$ROOT_DIR/server" - go test ./internal/database -run TestPostgresMigrationAndMaintenanceIntegration -count=1 + go test ./internal/database ./internal/httpapi -run 'TestPostgresMigrationAndMaintenanceIntegration|TestHistoryEndpointsPostgresIntegration' -count=1 ) set -a diff --git a/server/internal/database/database_integration_test.go b/server/internal/database/database_integration_test.go index 177a5ac..f7596b8 100644 --- a/server/internal/database/database_integration_test.go +++ b/server/internal/database/database_integration_test.go @@ -45,8 +45,8 @@ func TestPostgresMigrationAndMaintenanceIntegration(t *testing.T) { if err := sqlDB.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&migrationCount); err != nil { t.Fatal(err) } - if migrationCount < 12 { - t.Fatalf("migration count = %d, want at least 12", migrationCount) + if migrationCount < 14 { + t.Fatalf("migration count = %d, want at least 14", migrationCount) } var experiencedPeopleStartColumn string if err := sqlDB.QueryRowContext(ctx, ` @@ -78,6 +78,16 @@ func TestPostgresMigrationAndMaintenanceIntegration(t *testing.T) { if nullable != "YES" { t.Fatalf("phone_hmac is_nullable = %q, want YES after purge migration", nullable) } + var historyAnonymizedNullable string + if err := sqlDB.QueryRowContext(ctx, ` + SELECT is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'queue_tickets' AND column_name = 'history_anonymized_at'`).Scan(&historyAnonymizedNullable); err != nil { + t.Fatal(err) + } + if historyAnonymizedNullable != "YES" { + t.Fatalf("history_anonymized_at is_nullable = %q, want YES", historyAnonymizedNullable) + } if _, err := sqlDB.ExecContext(ctx, `UPDATE users SET password_hash = $1, updated_at = now() WHERE username = $2`, "rotated-hash", "xqkwljtadmin"); err != nil { t.Fatalf("protected super admin password rotation should be allowed: %v", err) } @@ -90,7 +100,7 @@ func TestPostgresMigrationAndMaintenanceIntegration(t *testing.T) { if err != nil { t.Fatal(err) } - if stats.PersonalDataPurged != 1 || stats.IdempotencyDeleted != 1 || stats.SessionsDeleted != 1 || stats.AuditDeleted != 1 { + if stats.PersonalDataPurged != 1 || stats.HistoryAnonymized != 1 || stats.IdempotencyDeleted != 1 || stats.SessionsDeleted != 1 || stats.AuditDeleted != 1 { t.Fatalf("maintenance stats = %#v, want one row in each category", stats) } @@ -104,18 +114,32 @@ func TestPostgresMigrationAndMaintenanceIntegration(t *testing.T) { if phoneCiphertext != nil || phoneNonce != nil || phoneHMAC != nil || purgedAt == nil { t.Fatalf("purged ticket fields = %#v/%#v/%#v/%#v", phoneCiphertext, phoneNonce, phoneHMAC, purgedAt) } + + var historyPhoneCiphertext, historyPhoneNonce, historyPhoneHMAC, historyLastNameCiphertext, historyLastNameNonce, historyPurgedAt, historyAnonymizedAt any + var historyHonorific string + if err := sqlDB.QueryRowContext(ctx, ` + SELECT phone_ciphertext, phone_nonce, phone_hmac, last_name_ciphertext, last_name_nonce, + personal_data_purged_at, history_anonymized_at, honorific + FROM queue_tickets WHERE id = $1`, fixture.historyTicketID). + Scan(&historyPhoneCiphertext, &historyPhoneNonce, &historyPhoneHMAC, &historyLastNameCiphertext, &historyLastNameNonce, &historyPurgedAt, &historyAnonymizedAt, &historyHonorific); err != nil { + t.Fatal(err) + } + if historyPhoneCiphertext != nil || historyPhoneNonce != nil || historyPhoneHMAC != nil || historyLastNameCiphertext != nil || historyLastNameNonce != nil || historyPurgedAt == nil || historyAnonymizedAt == nil || historyHonorific != "游客" { + t.Fatalf("historical anonymization fields = %#v/%#v/%#v/%#v/%#v/%#v/%#v/%q", historyPhoneCiphertext, historyPhoneNonce, historyPhoneHMAC, historyLastNameCiphertext, historyLastNameNonce, historyPurgedAt, historyAnonymizedAt, historyHonorific) + } } type maintenanceFixture struct { - userID string - projectID string - ticketID string + userID string + projectID string + ticketID string + historyTicketID string } func newMaintenanceFixture(t *testing.T, ctx context.Context, db *sql.DB) maintenanceFixture { t.Helper() now := time.Now().UTC().Truncate(time.Microsecond) - userID, projectID, sessionID, ticketID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + userID, projectID, sessionID, ticketID, historyTicketID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() code := "MNT" + strings.ToUpper(strings.ReplaceAll(userID[:5], "-", "")) if _, err := db.ExecContext(ctx, ` INSERT INTO users (id, username, password_hash, role, active) @@ -145,6 +169,16 @@ func newMaintenanceFixture(t *testing.T, ctx context.Context, db *sql.DB) mainte $8, $8, $9, $10)`, ticketID, projectID, sessionID, strings.Repeat("a", 64), []byte("ciphertext"), make([]byte, 12), strings.Repeat("b", 64), now.Add(-72*time.Hour), now.Add(-time.Hour), userID); err != nil { t.Fatal(err) } + if _, err := db.ExecContext(ctx, ` + INSERT INTO queue_tickets ( + id, project_id, queue_session_id, ticket_number, display_number, + public_token_hash, phone_ciphertext, phone_nonce, phone_hmac, + last_name_ciphertext, last_name_nonce, honorific, status, joined_at, + completed_at, personal_data_purge_at, created_by + ) VALUES ($1, $2, $3, 2, '00002', $4, $5, $6, $7, $8, $9, '女士', 'COMPLETED', + $10, $10, $11, $12)`, historyTicketID, projectID, sessionID, strings.Repeat("e", 64), []byte("history-ciphertext"), make([]byte, 12), strings.Repeat("f", 64), []byte("history-last-name"), make([]byte, 12), now.Add(-100*24*time.Hour), now.Add(30*24*time.Hour), userID); err != nil { + t.Fatal(err) + } if _, err := db.ExecContext(ctx, ` INSERT INTO idempotency_keys (project_id, user_id, scope, key, request_hash, response_code, response_body, expires_at) VALUES ($1, $2, 'TEST', $3, $4, 200, '{}', $5)`, projectID, userID, "expired-"+userID, strings.Repeat("c", 64), now.Add(-time.Hour)); err != nil { @@ -160,5 +194,5 @@ func newMaintenanceFixture(t *testing.T, ctx context.Context, db *sql.DB) mainte VALUES ($1, $2, 'TEST', 'TEST', '{}', $3, 'integration-test', $4)`, projectID, userID, "request-"+userID, now.Add(-time.Hour)); err != nil { t.Fatal(err) } - return maintenanceFixture{userID: userID, projectID: projectID, ticketID: ticketID} + return maintenanceFixture{userID: userID, projectID: projectID, ticketID: ticketID, historyTicketID: historyTicketID} } diff --git a/server/internal/database/maintenance.go b/server/internal/database/maintenance.go index b502b8f..db3f5e4 100644 --- a/server/internal/database/maintenance.go +++ b/server/internal/database/maintenance.go @@ -15,6 +15,7 @@ const maintenanceLockID int64 = 733081337591911 // transaction while trying to clean an entire historical table. type CleanupStats struct { PersonalDataPurged int64 + HistoryAnonymized int64 IdempotencyDeleted int64 SessionsDeleted int64 AuditDeleted int64 @@ -83,6 +84,31 @@ func RunMaintenanceOnce(ctx context.Context, db *sql.DB, now time.Time, batchSiz if err != nil { return rollback(fmt.Errorf("purge personal data: %w", err)) } + stats.HistoryAnonymized, err = boundedUpdate(ctx, tx, ` + WITH candidates AS ( + SELECT id + FROM queue_tickets + WHERE history_anonymized_at IS NULL + AND joined_at <= ($1::timestamptz - interval '90 days') + ORDER BY joined_at, id + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + UPDATE queue_tickets AS ticket + SET phone_ciphertext = NULL, + phone_nonce = NULL, + phone_hmac = NULL, + last_name_ciphertext = NULL, + last_name_nonce = NULL, + honorific = '游客', + personal_data_purged_at = COALESCE(ticket.personal_data_purged_at, $1), + history_anonymized_at = $1, + updated_at = $1 + FROM candidates + WHERE ticket.id = candidates.id`, now, batchSize) + if err != nil { + return rollback(fmt.Errorf("mark historical data anonymized: %w", err)) + } stats.IdempotencyDeleted, err = boundedDelete(ctx, tx, ` WITH candidates AS ( SELECT id @@ -160,9 +186,10 @@ func StartMaintenance(ctx context.Context, db *sql.DB, logger *slog.Logger, inte logger.Error("database maintenance failed", "error", err) return } - if stats.PersonalDataPurged+stats.IdempotencyDeleted+stats.SessionsDeleted+stats.AuditDeleted > 0 { + if stats.PersonalDataPurged+stats.HistoryAnonymized+stats.IdempotencyDeleted+stats.SessionsDeleted+stats.AuditDeleted > 0 { logger.Info("database maintenance completed", "personal_data_purged", stats.PersonalDataPurged, + "history_anonymized", stats.HistoryAnonymized, "idempotency_deleted", stats.IdempotencyDeleted, "sessions_deleted", stats.SessionsDeleted, "audit_deleted", stats.AuditDeleted, diff --git a/server/internal/httpapi/history.go b/server/internal/httpapi/history.go new file mode 100644 index 0000000..c9bc1c2 --- /dev/null +++ b/server/internal/httpapi/history.go @@ -0,0 +1,941 @@ +package httpapi + +import ( + "context" + "encoding/csv" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "calllinesystem/server/internal/model" + "calllinesystem/server/internal/security" + + "gorm.io/gorm" +) + +const historyDateLayout = "2006-01-02" + +var historyTicketStatuses = map[string]bool{ + model.TicketWaiting: true, model.TicketCalled: true, model.TicketArrived: true, + model.TicketCompleted: true, model.TicketMissed: true, model.TicketCanceled: true, +} + +var historyBatchStatuses = map[string]bool{"CALLED": true, "COMPLETED": true} + +type historyFilter struct { + ProjectID string + From string + To string + Status string + Query string + Page int + PageSize int +} + +type historyTicketRow struct { + ID string `gorm:"column:id"` + ProjectID string `gorm:"column:project_id"` + ProjectName string `gorm:"column:project_name"` + ProjectCode string `gorm:"column:project_code"` + QueueSessionID string `gorm:"column:queue_session_id"` + BusinessDate time.Time `gorm:"column:business_date"` + TicketNumber int `gorm:"column:ticket_number"` + DisplayNumber string `gorm:"column:display_number"` + PartySize int `gorm:"column:party_size"` + Status string `gorm:"column:status"` + JoinedAt time.Time `gorm:"column:joined_at"` + CalledAt *time.Time `gorm:"column:called_at"` + ArrivedAt *time.Time `gorm:"column:arrived_at"` + CompletedAt *time.Time `gorm:"column:completed_at"` + MissedAt *time.Time `gorm:"column:missed_at"` + UpdatedAt *time.Time `gorm:"column:updated_at"` + PersonalDataPurgedAt *time.Time `gorm:"column:personal_data_purged_at"` + HistoryAnonymizedAt *time.Time `gorm:"column:history_anonymized_at"` + PhoneCiphertext []byte `gorm:"column:phone_ciphertext"` + PhoneNonce []byte `gorm:"column:phone_nonce"` + PhoneHMAC *string `gorm:"column:phone_hmac"` + LastNameCiphertext []byte `gorm:"column:last_name_ciphertext"` + LastNameNonce []byte `gorm:"column:last_name_nonce"` + Honorific string `gorm:"column:honorific"` + CreatedBy string `gorm:"column:created_by"` + CreatedByName string `gorm:"column:created_by_name"` + BatchID *string `gorm:"column:batch_id"` + BatchSequence *int `gorm:"column:batch_sequence"` + BatchPosition *int `gorm:"column:batch_position"` +} + +type historyBatchRow struct { + ID string `gorm:"column:id"` + ProjectID string `gorm:"column:project_id"` + ProjectName string `gorm:"column:project_name"` + ProjectCode string `gorm:"column:project_code"` + QueueSessionID string `gorm:"column:queue_session_id"` + BusinessDate time.Time `gorm:"column:business_date"` + BatchSequence int `gorm:"column:batch_sequence"` + Revision int64 `gorm:"column:revision"` + Status string `gorm:"column:status"` + CallMode string `gorm:"column:call_mode"` + RequestedCount int `gorm:"column:requested_count"` + TicketCount int `gorm:"column:ticket_count"` + PeopleCount int `gorm:"column:people_count"` + RequestedBy string `gorm:"column:requested_by"` + RequestedByName string `gorm:"column:requested_by_name"` + CalledAt time.Time `gorm:"column:called_at"` + CompletedAt *time.Time `gorm:"column:completed_at"` +} + +type historyBatchTicketLabel struct { + BatchID string `gorm:"column:batch_id"` + DisplayNumber string `gorm:"column:display_number"` + Position int `gorm:"column:position"` +} + +type historySummaryRow struct { + TotalTickets int64 `gorm:"column:total_tickets"` + TotalPeople int64 `gorm:"column:total_people"` + CalledTickets int64 `gorm:"column:called_tickets"` + CalledPeople int64 `gorm:"column:called_people"` + CompletedTickets int64 `gorm:"column:completed_tickets"` + CompletedPeople int64 `gorm:"column:completed_people"` + MissedTickets int64 `gorm:"column:missed_tickets"` + CanceledTickets int64 `gorm:"column:canceled_tickets"` + AverageWait *float64 `gorm:"column:average_wait"` + MaxWait *float64 `gorm:"column:max_wait"` +} + +type historyHourlyRow struct { + Hour int64 `gorm:"column:hour"` + IssuedTickets int64 `gorm:"column:issued_tickets"` + IssuedPeople int64 `gorm:"column:issued_people"` + CalledTickets int64 `gorm:"column:called_tickets"` + CalledPeople int64 `gorm:"column:called_people"` +} + +type historyDailyRow struct { + BusinessDate time.Time `gorm:"column:business_date"` + TotalTickets int64 `gorm:"column:total_tickets"` + TotalPeople int64 `gorm:"column:total_people"` + CalledTickets int64 `gorm:"column:called_tickets"` + CompletedTickets int64 `gorm:"column:completed_tickets"` + MissedTickets int64 `gorm:"column:missed_tickets"` + CanceledTickets int64 `gorm:"column:canceled_tickets"` + AverageWait *float64 `gorm:"column:average_wait"` + MaxWait *float64 `gorm:"column:max_wait"` +} + +type historyProjectRow struct { + ProjectID string `gorm:"column:project_id"` + ProjectName string `gorm:"column:project_name"` + ProjectCode string `gorm:"column:project_code"` + TotalTickets int64 `gorm:"column:total_tickets"` + TotalPeople int64 `gorm:"column:total_people"` + CalledTickets int64 `gorm:"column:called_tickets"` + CompletedTickets int64 `gorm:"column:completed_tickets"` + MissedTickets int64 `gorm:"column:missed_tickets"` + CanceledTickets int64 `gorm:"column:canceled_tickets"` + AverageWait *float64 `gorm:"column:average_wait"` + MaxWait *float64 `gorm:"column:max_wait"` +} + +func (s *Server) adminHistoryTickets(w http.ResponseWriter, r *http.Request) { + filter, err := s.parseHistoryFilter(r, historyTicketStatuses) + if err != nil { + writeError(w, err) + return + } + if err := s.auditHistoryPhoneSearch(r, filter); err != nil { + writeError(w, err) + return + } + + base := s.historyTicketQuery(r.Context()) + base = applyHistoryTicketFilter(base, filter, s) + var total int64 + if err := base.Select("COUNT(ticket.id)").Scan(&total).Error; err != nil { + writeError(w, err) + return + } + + var rows []historyTicketRow + query := s.historyTicketQuery(r.Context()) + query = applyHistoryTicketFilter(query, filter, s) + if err := query.Select(historyTicketSelect()).Order("session.business_date DESC, ticket.ticket_number DESC"). + Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Scan(&rows).Error; err != nil { + writeError(w, err) + return + } + items, err := s.historyTicketViews(rows, false) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, "page": filter.Page, "page_size": filter.PageSize, "total": total, + "from": filter.From, "to": filter.To, + }) +} + +func (s *Server) adminHistoryTicket(w http.ResponseWriter, r *http.Request) { + ticketID := r.PathValue("id") + if err := validateUUID(ticketID); err != nil { + writeError(w, err) + return + } + var row historyTicketRow + query := s.historyTicketQuery(r.Context()).Where("ticket.id = ?", ticketID) + if err := query.Select(historyTicketSelect()).First(&row).Error; err != nil { + writeError(w, mapNotFound(err, "TICKET_NOT_FOUND", "历史号码不存在")) + return + } + if err := s.authorizeProject(r.Context(), row.ProjectID); err != nil { + writeError(w, err) + return + } + reveal := parseBoolQuery(r.URL.Query().Get("reveal")) + if err := s.auditHistoryAccess(r, "HISTORY_TICKET_VIEW", "QUEUE_TICKET", &row.ID, &row.ProjectID, map[string]any{"reveal_personal": reveal}); err != nil { + writeError(w, err) + return + } + view, err := s.historyTicketView(row, reveal) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ticket": view}) +} + +func (s *Server) adminHistoryBatches(w http.ResponseWriter, r *http.Request) { + filter, err := s.parseHistoryFilter(r, historyBatchStatuses) + if err != nil { + writeError(w, err) + return + } + base := s.historyBatchQuery(r.Context()) + base = applyHistoryBatchFilter(base, filter) + var total int64 + if err := base.Select("COUNT(batch.id)").Scan(&total).Error; err != nil { + writeError(w, err) + return + } + var rows []historyBatchRow + query := s.historyBatchQuery(r.Context()) + query = applyHistoryBatchFilter(query, filter) + if err := query.Select(historyBatchSelect()).Order("session.business_date DESC, batch.batch_sequence DESC"). + Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Scan(&rows).Error; err != nil { + writeError(w, err) + return + } + labels, err := s.historyBatchLabels(r.Context(), rows) + if err != nil { + writeError(w, err) + return + } + items := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + items = append(items, historyBatchView(row, labels[row.ID])) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, "page": filter.Page, "page_size": filter.PageSize, "total": total, + "from": filter.From, "to": filter.To, + }) +} + +func (s *Server) adminHistoryBatch(w http.ResponseWriter, r *http.Request) { + batchID := r.PathValue("id") + if err := validateUUID(batchID); err != nil { + writeError(w, err) + return + } + var batch historyBatchRow + if err := s.historyBatchQuery(r.Context()).Where("batch.id = ?", batchID).Select(historyBatchSelect()).First(&batch).Error; err != nil { + writeError(w, mapNotFound(err, "BATCH_NOT_FOUND", "历史叫号记录不存在")) + return + } + if err := s.authorizeProject(r.Context(), batch.ProjectID); err != nil { + writeError(w, err) + return + } + var rows []historyTicketRow + query := s.historyTicketQuery(r.Context()). + Where("link.call_batch_id = ?", batchID). + Select(historyTicketSelect()). + Order("link.position ASC") + if err := query.Scan(&rows).Error; err != nil { + writeError(w, err) + return + } + items, err := s.historyTicketViews(rows, false) + if err != nil { + writeError(w, err) + return + } + if err := s.auditHistoryAccess(r, "HISTORY_BATCH_VIEW", "CALL_BATCH", &batch.ID, &batch.ProjectID, nil); err != nil { + writeError(w, err) + return + } + view := historyBatchView(batch, nil) + view["tickets"] = items + writeJSON(w, http.StatusOK, map[string]any{"batch": view}) +} + +func (s *Server) adminHistorySummary(w http.ResponseWriter, r *http.Request) { + filter, err := s.parseHistoryFilter(r, nil) + if err != nil { + writeError(w, err) + return + } + base := s.historyTicketQuery(r.Context()) + base = applyHistoryTicketFilter(base, filter, s) + var summary historySummaryRow + if err := base.Select(` + COUNT(ticket.id) AS total_tickets, + COALESCE(SUM(ticket.party_size), 0) AS total_people, + COUNT(ticket.id) FILTER (WHERE ticket.status IN ('CALLED', 'ARRIVED', 'COMPLETED', 'MISSED')) AS called_tickets, + COALESCE(SUM(ticket.party_size) FILTER (WHERE ticket.status IN ('CALLED', 'ARRIVED', 'COMPLETED', 'MISSED')), 0) AS called_people, + COUNT(ticket.id) FILTER (WHERE ticket.status = 'COMPLETED') AS completed_tickets, + COALESCE(SUM(ticket.party_size) FILTER (WHERE ticket.status = 'COMPLETED'), 0) AS completed_people, + COUNT(ticket.id) FILTER (WHERE ticket.status = 'MISSED') AS missed_tickets, + COUNT(ticket.id) FILTER (WHERE ticket.status = 'CANCELED') AS canceled_tickets, + AVG(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS average_wait, + MAX(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS max_wait`).Scan(&summary).Error; err != nil { + writeError(w, err) + return + } + hourly, err := s.historyHourlySummary(r.Context(), filter) + if err != nil { + writeError(w, err) + return + } + daily, err := s.historyDailySummary(r.Context(), filter) + if err != nil { + writeError(w, err) + return + } + projects, err := s.historyProjectSummary(r.Context(), filter) + if err != nil { + writeError(w, err) + return + } + completionRate, missedRate, canceledRate := 0.0, 0.0, 0.0 + if summary.TotalTickets > 0 { + completionRate = float64(summary.CompletedTickets) / float64(summary.TotalTickets) + missedRate = float64(summary.MissedTickets) / float64(summary.TotalTickets) + canceledRate = float64(summary.CanceledTickets) / float64(summary.TotalTickets) + } + writeJSON(w, http.StatusOK, map[string]any{ + "from": filter.From, "to": filter.To, + "summary": map[string]any{ + "total_tickets": summary.TotalTickets, "total_people": summary.TotalPeople, + "called_tickets": summary.CalledTickets, "called_people": summary.CalledPeople, + "completed_tickets": summary.CompletedTickets, "completed_people": summary.CompletedPeople, + "missed_tickets": summary.MissedTickets, "canceled_tickets": summary.CanceledTickets, + "completion_rate": completionRate, "missed_rate": missedRate, "canceled_rate": canceledRate, + "average_wait_seconds": nullableRoundedSeconds(summary.AverageWait), + "max_wait_seconds": nullableRoundedSeconds(summary.MaxWait), + }, + "hourly": historyHourlyViews(hourly), + "peak_hours": historyPeakHourViews(hourly), + "daily": historyDailyViews(daily), + "projects": historyProjectViews(projects), + "alerts": historySummaryAlerts(projects), + }) +} + +func (s *Server) adminHistoryExport(w http.ResponseWriter, r *http.Request) { + filter, err := s.parseHistoryFilter(r, historyTicketStatuses) + if err != nil { + writeError(w, err) + return + } + includePersonal := parseBoolQuery(r.URL.Query().Get("include_personal")) + if includePersonal && !parseBoolQuery(r.URL.Query().Get("confirm")) { + writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "EXPORT_CONFIRMATION_REQUIRED", Message: "导出个人信息前需要二次确认"}) + return + } + base := s.historyTicketQuery(r.Context()) + base = applyHistoryTicketFilter(base, filter, s) + var total int64 + if err := base.Select("COUNT(ticket.id)").Scan(&total).Error; err != nil { + writeError(w, err) + return + } + if total > 10000 { + writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "EXPORT_TOO_LARGE", Message: "导出结果超过 10000 行,请缩小日期范围或增加筛选条件", Details: map[string]any{"total": total, "max_rows": 10000}}) + return + } + var rows []historyTicketRow + query := s.historyTicketQuery(r.Context()) + query = applyHistoryTicketFilter(query, filter, s) + if err := query.Select(historyTicketSelect()).Order("session.business_date ASC, ticket.ticket_number ASC").Limit(10000).Scan(&rows).Error; err != nil { + writeError(w, err) + return + } + if err := s.auditHistoryAccess(r, "HISTORY_EXPORT", "HISTORY_QUERY", nil, historyProjectIDPointer(filter.ProjectID), map[string]any{ + "from": filter.From, "to": filter.To, "status": filter.Status, "query_kind": historyQueryKind(filter.Query), "rows": len(rows), "include_personal": includePersonal, + }); err != nil { + writeError(w, err) + return + } + + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=history-tickets-%s-%s.csv", filter.From, filter.To)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) + csvWriter := csv.NewWriter(w) + _ = csvWriter.Write([]string{"项目", "营业日", "号码", "同行人数", "状态", "取号时间", "叫号时间", "终态时间", "等待时长(秒)", "手机号", "姓氏", "称谓", "数据状态"}) + for _, row := range rows { + view, err := s.historyTicketView(row, includePersonal) + if err != nil { + return + } + _ = csvWriter.Write([]string{ + stringValue(view["project_name"]), stringValue(view["business_date"]), stringValue(view["ticket_number"]), + stringValue(view["party_size"]), stringValue(view["status"]), stringValue(view["joined_at"]), + stringValue(view["called_at"]), stringValue(view["terminal_at"]), stringValue(view["wait_seconds"]), + stringValue(view["phone"]), stringValue(view["last_name"]), stringValue(view["honorific"]), stringValue(view["personal_data_status"]), + }) + } + csvWriter.Flush() +} + +func (s *Server) parseHistoryFilter(r *http.Request, allowedStatuses map[string]bool) (historyFilter, error) { + query := r.URL.Query() + filter := historyFilter{From: query.Get("from"), To: query.Get("to"), Status: strings.ToUpper(strings.TrimSpace(query.Get("status"))), Query: strings.TrimSpace(query.Get("query")), Page: 1, PageSize: 20} + if filter.ProjectID = strings.TrimSpace(query.Get("project_id")); filter.ProjectID != "" { + if err := validateUUID(filter.ProjectID); err != nil { + return historyFilter{}, err + } + if err := s.authorizeProject(r.Context(), filter.ProjectID); err != nil { + return historyFilter{}, err + } + } + if filter.From == "" || filter.To == "" { + today := s.now().UTC().Truncate(24 * time.Hour) + filter.To = today.Format(historyDateLayout) + filter.From = today.AddDate(0, 0, -6).Format(historyDateLayout) + } + from, fromErr := time.Parse(historyDateLayout, filter.From) + to, toErr := time.Parse(historyDateLayout, filter.To) + if fromErr != nil || toErr != nil || to.Before(from) || to.Sub(from) > 30*24*time.Hour { + return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_HISTORY_DATE_RANGE", Message: "历史数据日期范围必须有效且不超过 31 天"} + } + if filter.Status != "" && allowedStatuses != nil && !allowedStatuses[filter.Status] { + return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_HISTORY_STATUS", Message: "历史数据状态筛选条件不正确"} + } + if len([]rune(filter.Query)) > 80 { + return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "HISTORY_QUERY_TOO_LONG", Message: "搜索条件不能超过 80 个字符"} + } + if raw := query.Get("page"); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value < 1 || value > 100000 { + return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_PAGE", Message: "页码不正确"} + } + filter.Page = value + } + if raw := query.Get("page_size"); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value < 1 || value > 100 { + return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_PAGE_SIZE", Message: "每页数量必须在 1 到 100 之间"} + } + filter.PageSize = value + } + return filter, nil +} + +func (s *Server) historyTicketQuery(ctx context.Context) *gorm.DB { + return s.db.WithContext(ctx).Table("queue_tickets AS ticket"). + Joins("JOIN queue_sessions AS session ON session.id = ticket.queue_session_id AND session.project_id = ticket.project_id"). + Joins("JOIN projects AS project ON project.id = ticket.project_id"). + Joins("LEFT JOIN users AS creator ON creator.id = ticket.created_by"). + Joins("LEFT JOIN call_batch_tickets AS link ON link.ticket_id = ticket.id AND link.project_id = ticket.project_id"). + Joins("LEFT JOIN call_batches AS batch ON batch.id = link.call_batch_id AND batch.project_id = link.project_id") +} + +func historyTicketSelect() string { + return `ticket.id, ticket.project_id, project.name AS project_name, project.code AS project_code, + ticket.queue_session_id, session.business_date, ticket.ticket_number, ticket.display_number, ticket.party_size, + ticket.status, ticket.joined_at, ticket.called_at, ticket.arrived_at, ticket.completed_at, ticket.missed_at, + ticket.updated_at, + ticket.personal_data_purged_at, ticket.phone_ciphertext, ticket.phone_nonce, ticket.phone_hmac, + ticket.history_anonymized_at, ticket.last_name_ciphertext, ticket.last_name_nonce, ticket.honorific, ticket.created_by, + creator.username AS created_by_name, batch.id AS batch_id, batch.batch_sequence, link.position AS batch_position` +} + +func applyHistoryTicketFilter(db *gorm.DB, filter historyFilter, server *Server) *gorm.DB { + db = db.Where("session.business_date BETWEEN ? AND ?", filter.From, filter.To) + if filter.ProjectID != "" { + db = db.Where("ticket.project_id = ?", filter.ProjectID) + } + if filter.Status != "" { + db = db.Where("ticket.status = ?", filter.Status) + } + if filter.Query != "" { + pattern := "%" + escapeHistoryLike(filter.Query) + "%" + phoneDigest := "" + if normalized, err := security.NormalizePhone(filter.Query); err == nil { + phoneDigest = server.cipher.Digest(normalized) + } + if phoneDigest != "" { + db = db.Where("(ticket.display_number ILIKE ? ESCAPE '\\' OR ticket.ticket_number::text ILIKE ? ESCAPE '\\' OR ticket.phone_hmac = ?)", pattern, pattern, phoneDigest) + } else { + db = db.Where("(ticket.display_number ILIKE ? ESCAPE '\\' OR ticket.ticket_number::text ILIKE ? ESCAPE '\\')", pattern, pattern) + } + } + return db +} + +func (s *Server) historyBatchQuery(ctx context.Context) *gorm.DB { + return s.db.WithContext(ctx).Table("call_batches AS batch"). + Joins("JOIN queue_sessions AS session ON session.id = batch.queue_session_id AND session.project_id = batch.project_id"). + Joins("JOIN projects AS project ON project.id = batch.project_id"). + Joins("LEFT JOIN users AS requester ON requester.id = batch.requested_by") +} + +func historyBatchSelect() string { + return `batch.id, batch.project_id, project.name AS project_name, project.code AS project_code, + batch.queue_session_id, session.business_date, batch.batch_sequence, batch.revision, batch.status, + batch.call_mode, batch.requested_count, batch.ticket_count, batch.people_count, batch.requested_by, + requester.username AS requested_by_name, batch.called_at, batch.completed_at` +} + +func applyHistoryBatchFilter(db *gorm.DB, filter historyFilter) *gorm.DB { + db = db.Where("session.business_date BETWEEN ? AND ?", filter.From, filter.To) + if filter.ProjectID != "" { + db = db.Where("batch.project_id = ?", filter.ProjectID) + } + if filter.Status != "" { + db = db.Where("batch.status = ?", filter.Status) + } + if filter.Query != "" { + pattern := "%" + escapeHistoryLike(filter.Query) + "%" + db = db.Where(`( + batch.batch_sequence::text ILIKE ? ESCAPE '\\' + OR EXISTS ( + SELECT 1 FROM call_batch_tickets AS search_link + JOIN queue_tickets AS search_ticket ON search_ticket.id = search_link.ticket_id AND search_ticket.project_id = search_link.project_id + WHERE search_link.call_batch_id = batch.id AND search_ticket.display_number ILIKE ? ESCAPE '\\' + )`, pattern, pattern) + } + return db +} + +func (s *Server) historyBatchLabels(ctx context.Context, rows []historyBatchRow) (map[string][]string, error) { + labels := make(map[string][]string) + if len(rows) == 0 { + return labels, nil + } + ids := make([]string, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ID) + } + var values []historyBatchTicketLabel + err := s.db.WithContext(ctx).Table("call_batch_tickets AS link"). + Select("link.call_batch_id AS batch_id, ticket.display_number, link.position"). + Joins("JOIN queue_tickets AS ticket ON ticket.id = link.ticket_id AND ticket.project_id = link.project_id"). + Where("link.call_batch_id IN ?", ids).Order("link.call_batch_id, link.position").Scan(&values).Error + if err != nil { + return nil, err + } + for _, value := range values { + labels[value.BatchID] = append(labels[value.BatchID], value.DisplayNumber) + } + return labels, nil +} + +func (s *Server) historyHourlySummary(ctx context.Context, filter historyFilter) ([]historyHourlyRow, error) { + issuedBase := s.historyTicketQuery(ctx) + issuedBase = applyHistoryTicketFilter(issuedBase, filter, s) + var issuedRows []historyHourlyRow + if err := issuedBase.Select(` + EXTRACT(HOUR FROM ticket.joined_at AT TIME ZONE 'Asia/Shanghai')::bigint AS hour, + COUNT(ticket.id) AS issued_tickets, + COALESCE(SUM(ticket.party_size), 0) AS issued_people`). + Group("EXTRACT(HOUR FROM ticket.joined_at AT TIME ZONE 'Asia/Shanghai')").Scan(&issuedRows).Error; err != nil { + return nil, err + } + + calledBase := s.historyTicketQuery(ctx) + calledBase = applyHistoryTicketFilter(calledBase, filter, s).Where("ticket.called_at IS NOT NULL") + var calledRows []historyHourlyRow + if err := calledBase.Select(` + EXTRACT(HOUR FROM ticket.called_at AT TIME ZONE 'Asia/Shanghai')::bigint AS hour, + COUNT(ticket.id) AS called_tickets, + COALESCE(SUM(ticket.party_size), 0) AS called_people`). + Group("EXTRACT(HOUR FROM ticket.called_at AT TIME ZONE 'Asia/Shanghai')").Scan(&calledRows).Error; err != nil { + return nil, err + } + + byHour := make(map[int64]historyHourlyRow, len(issuedRows)+len(calledRows)) + for _, row := range issuedRows { + byHour[row.Hour] = row + } + for _, row := range calledRows { + merged := byHour[row.Hour] + merged.Hour = row.Hour + merged.CalledTickets = row.CalledTickets + merged.CalledPeople = row.CalledPeople + byHour[row.Hour] = merged + } + rows := make([]historyHourlyRow, 0, len(byHour)) + for _, row := range byHour { + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Hour < rows[j].Hour }) + return rows, nil +} + +func (s *Server) historyDailySummary(ctx context.Context, filter historyFilter) ([]historyDailyRow, error) { + base := s.historyTicketQuery(ctx) + base = applyHistoryTicketFilter(base, filter, s) + var rows []historyDailyRow + err := base.Select(` + session.business_date AS business_date, + COUNT(ticket.id) AS total_tickets, + COALESCE(SUM(ticket.party_size), 0) AS total_people, + COUNT(ticket.id) FILTER (WHERE ticket.called_at IS NOT NULL) AS called_tickets, + COUNT(ticket.id) FILTER (WHERE ticket.status = 'COMPLETED') AS completed_tickets, + COUNT(ticket.id) FILTER (WHERE ticket.status = 'MISSED') AS missed_tickets, + COUNT(ticket.id) FILTER (WHERE ticket.status = 'CANCELED') AS canceled_tickets, + AVG(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS average_wait, + MAX(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS max_wait`). + Group("session.business_date").Order("session.business_date ASC").Scan(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + +func (s *Server) historyProjectSummary(ctx context.Context, filter historyFilter) ([]historyProjectRow, error) { + base := s.historyTicketQuery(ctx) + base = applyHistoryTicketFilter(base, filter, s) + var rows []historyProjectRow + err := base.Select(` + project.id AS project_id, project.name AS project_name, project.code AS project_code, + COUNT(ticket.id) AS total_tickets, + COALESCE(SUM(ticket.party_size), 0) AS total_people, + COUNT(ticket.id) FILTER (WHERE ticket.called_at IS NOT NULL) AS called_tickets, + COUNT(ticket.id) FILTER (WHERE ticket.status = 'COMPLETED') AS completed_tickets, + COUNT(ticket.id) FILTER (WHERE ticket.status = 'MISSED') AS missed_tickets, + COUNT(ticket.id) FILTER (WHERE ticket.status = 'CANCELED') AS canceled_tickets, + AVG(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS average_wait, + MAX(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS max_wait`). + Group("project.id, project.name, project.code").Order("total_tickets DESC, project.name ASC").Scan(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + +func historyHourlyViews(rows []historyHourlyRow) []map[string]any { + result := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + result = append(result, map[string]any{"hour": row.Hour, "issued_tickets": row.IssuedTickets, "issued_people": row.IssuedPeople, "called_tickets": row.CalledTickets, "called_people": row.CalledPeople}) + } + return result +} + +func historyPeakHourViews(rows []historyHourlyRow) []map[string]any { + peaks := append([]historyHourlyRow(nil), rows...) + sort.Slice(peaks, func(i, j int) bool { + if peaks[i].IssuedTickets != peaks[j].IssuedTickets { + return peaks[i].IssuedTickets > peaks[j].IssuedTickets + } + if peaks[i].CalledTickets != peaks[j].CalledTickets { + return peaks[i].CalledTickets > peaks[j].CalledTickets + } + return peaks[i].Hour < peaks[j].Hour + }) + if len(peaks) > 3 { + peaks = peaks[:3] + } + result := make([]map[string]any, 0, len(peaks)) + for index, row := range peaks { + if row.IssuedTickets == 0 { + continue + } + result = append(result, map[string]any{"rank": index + 1, "hour": row.Hour, "issued_tickets": row.IssuedTickets, "issued_people": row.IssuedPeople, "called_tickets": row.CalledTickets, "called_people": row.CalledPeople}) + } + return result +} + +func historyDailyViews(rows []historyDailyRow) []map[string]any { + result := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + result = append(result, map[string]any{ + "business_date": row.BusinessDate.Format(historyDateLayout), "total_tickets": row.TotalTickets, "total_people": row.TotalPeople, + "called_tickets": row.CalledTickets, "completed_tickets": row.CompletedTickets, "missed_tickets": row.MissedTickets, "canceled_tickets": row.CanceledTickets, + "completion_rate": historyRate(row.CompletedTickets, row.TotalTickets), "missed_rate": historyRate(row.MissedTickets, row.TotalTickets), "canceled_rate": historyRate(row.CanceledTickets, row.TotalTickets), + "average_wait_seconds": nullableRoundedSeconds(row.AverageWait), "max_wait_seconds": nullableRoundedSeconds(row.MaxWait), + }) + } + return result +} + +func historyProjectViews(rows []historyProjectRow) []map[string]any { + result := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + result = append(result, map[string]any{ + "project_id": row.ProjectID, "project_name": row.ProjectName, "project_code": row.ProjectCode, + "total_tickets": row.TotalTickets, "total_people": row.TotalPeople, "called_tickets": row.CalledTickets, "completed_tickets": row.CompletedTickets, + "missed_tickets": row.MissedTickets, "canceled_tickets": row.CanceledTickets, + "completion_rate": historyRate(row.CompletedTickets, row.TotalTickets), "missed_rate": historyRate(row.MissedTickets, row.TotalTickets), "canceled_rate": historyRate(row.CanceledTickets, row.TotalTickets), + "average_wait_seconds": nullableRoundedSeconds(row.AverageWait), "max_wait_seconds": nullableRoundedSeconds(row.MaxWait), + }) + } + return result +} + +func historySummaryAlerts(rows []historyProjectRow) []map[string]any { + alerts := make([]map[string]any, 0) + for _, row := range rows { + if row.TotalTickets < 5 { + continue + } + missedRate := historyRate(row.MissedTickets, row.TotalTickets) + canceledRate := historyRate(row.CanceledTickets, row.TotalTickets) + completionRate := historyRate(row.CompletedTickets, row.TotalTickets) + var alert map[string]any + switch { + case missedRate >= 0.20: + alert = map[string]any{"severity": 3, "type": "MISSED_RATE", "title": row.ProjectName + "过号率偏高", "detail": "过号率达到 " + formatHistoryRate(missedRate) + ",建议复盘叫号触达和现场提醒。"} + case row.AverageWait != nil && *row.AverageWait >= 900: + alert = map[string]any{"severity": 3, "type": "WAIT_TIME", "title": row.ProjectName + "等待时间偏长", "detail": "平均等待约 " + formatHistoryDuration(*row.AverageWait) + ",建议检查高峰时段的叫号吞吐。"} + case completionRate < 0.70: + alert = map[string]any{"severity": 2, "type": "COMPLETION_RATE", "title": row.ProjectName + "完成率偏低", "detail": "完成率为 " + formatHistoryRate(completionRate) + ",建议结合过号和取消明细进一步下钻。"} + case canceledRate >= 0.10: + alert = map[string]any{"severity": 2, "type": "CANCELED_RATE", "title": row.ProjectName + "取消率偏高", "detail": "取消率达到 " + formatHistoryRate(canceledRate) + ",建议确认营业安排或取号规则是否需要调整。"} + } + if alert != nil { + alert["project_id"] = row.ProjectID + alert["project_name"] = row.ProjectName + alert["total_tickets"] = row.TotalTickets + alerts = append(alerts, alert) + } + } + sort.Slice(alerts, func(i, j int) bool { + left, _ := alerts[i]["severity"].(int) + right, _ := alerts[j]["severity"].(int) + return left > right + }) + if len(alerts) > 6 { + alerts = alerts[:6] + } + return alerts +} + +func historyRate(numerator, denominator int64) float64 { + if denominator == 0 { + return 0 + } + return float64(numerator) / float64(denominator) +} + +func formatHistoryRate(value float64) string { + return strconv.FormatFloat(value*100, 'f', 1, 64) + "%" +} + +func formatHistoryDuration(seconds float64) string { + minutes := int64(seconds) / 60 + remaining := int64(seconds) % 60 + if minutes == 0 { + return strconv.FormatInt(remaining, 10) + " 秒" + } + return strconv.FormatInt(minutes, 10) + " 分钟" +} + +func (s *Server) historyTicketViews(rows []historyTicketRow, reveal bool) ([]map[string]any, error) { + views := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + view, err := s.historyTicketView(row, reveal) + if err != nil { + return nil, err + } + views = append(views, view) + } + return views, nil +} + +func (s *Server) historyTicketView(row historyTicketRow, reveal bool) (map[string]any, error) { + ticket := model.QueueTicket{ + ID: row.ID, ProjectID: row.ProjectID, DisplayNumber: row.DisplayNumber, PartySize: row.PartySize, + Status: row.Status, JoinedAt: row.JoinedAt, CalledAt: row.CalledAt, ArrivedAt: row.ArrivedAt, + CompletedAt: row.CompletedAt, MissedAt: row.MissedAt, PersonalDataPurgedAt: row.PersonalDataPurgedAt, + HistoryAnonymizedAt: row.HistoryAnonymizedAt, + UpdatedAt: func() time.Time { + if row.UpdatedAt != nil { + return *row.UpdatedAt + } + return row.JoinedAt + }(), + PhoneCiphertext: row.PhoneCiphertext, PhoneNonce: row.PhoneNonce, PhoneHMAC: row.PhoneHMAC, + LastNameCiphertext: row.LastNameCiphertext, LastNameNonce: row.LastNameNonce, Honorific: row.Honorific, + } + personal, err := s.historyPersonalView(ticket, reveal) + if err != nil { + return nil, err + } + view := map[string]any{ + "id": row.ID, "project_id": row.ProjectID, "project_name": row.ProjectName, "project_code": row.ProjectCode, + "queue_session_id": row.QueueSessionID, "business_date": row.BusinessDate.Format(historyDateLayout), + "ticket_number": row.DisplayNumber, "ticket_sequence": row.TicketNumber, "party_size": row.PartySize, "status": row.Status, + "joined_at": row.JoinedAt, "called_at": row.CalledAt, "arrived_at": row.ArrivedAt, + "completed_at": row.CompletedAt, "missed_at": row.MissedAt, "terminal_at": historyTerminalAt(ticket), + "wait_seconds": historyWaitSeconds(ticket), "created_by": row.CreatedByName, "personal_data_purged_at": row.PersonalDataPurgedAt, + "history_anonymized_at": row.HistoryAnonymizedAt, + "batch_id": row.BatchID, "batch_sequence": row.BatchSequence, "batch_position": row.BatchPosition, + } + for key, value := range personal { + view[key] = value + } + return view, nil +} + +func (s *Server) historyPersonalView(ticket model.QueueTicket, reveal bool) (map[string]any, error) { + view := map[string]any{"phone": nil, "phone_last4": nil, "phone_masked": nil, "last_name": nil, "honorific": nil, "personal_data_status": "ANONYMIZED"} + if ticket.HistoryAnonymizedAt != nil || ticket.PhoneHMAC == nil || len(ticket.PhoneCiphertext) == 0 || len(ticket.PhoneNonce) == 0 { + return view, nil + } + last4, err := s.ticketPhoneLast4(ticket) + if err != nil { + return nil, err + } + view["phone_last4"] = last4 + view["phone_masked"] = "****" + last4 + view["personal_data_status"] = "AVAILABLE" + if !reveal { + return view, nil + } + phone, lastName, err := s.decryptTicketPersonal(ticket) + if err != nil { + return nil, err + } + view["phone"] = phone + view["last_name"] = lastName + view["honorific"] = ticket.Honorific + return view, nil +} + +func historyBatchView(row historyBatchRow, ticketNumbers []string) map[string]any { + view := map[string]any{ + "id": row.ID, "project_id": row.ProjectID, "project_name": row.ProjectName, "project_code": row.ProjectCode, + "queue_session_id": row.QueueSessionID, "business_date": row.BusinessDate.Format(historyDateLayout), + "batch_sequence": row.BatchSequence, "status": row.Status, "call_mode": row.CallMode, + "requested_count": row.RequestedCount, "ticket_count": row.TicketCount, "people_count": row.PeopleCount, + "requested_by": row.RequestedByName, "called_at": row.CalledAt, "completed_at": row.CompletedAt, "revision": row.Revision, + } + if ticketNumbers != nil { + view["ticket_numbers"] = ticketNumbers + } + return view +} + +func (s *Server) auditHistoryPhoneSearch(r *http.Request, filter historyFilter) error { + if filter.Query == "" { + return nil + } + if _, err := security.NormalizePhone(filter.Query); err != nil { + return nil + } + return s.auditHistoryAccess(r, "HISTORY_PHONE_SEARCH", "HISTORY_QUERY", nil, historyProjectIDPointer(filter.ProjectID), map[string]any{ + "from": filter.From, "to": filter.To, "query_kind": "phone", + }) +} + +func (s *Server) auditHistoryAccess(r *http.Request, action, entityType string, entityID, projectID *string, details map[string]any) error { + user := currentPrincipal(r.Context()).User + return s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { + return s.addAudit(tx, r, projectID, &user.ID, action, entityType, entityID, details) + }) +} + +func historyTerminalAt(ticket model.QueueTicket) *time.Time { + switch ticket.Status { + case model.TicketCompleted: + return ticket.CompletedAt + case model.TicketMissed: + return ticket.MissedAt + case model.TicketCanceled: + return &ticket.UpdatedAt + default: + return nil + } +} + +func historyWaitSeconds(ticket model.QueueTicket) any { + if ticket.CalledAt == nil { + return nil + } + return maxInt64(0, int64(ticket.CalledAt.Sub(ticket.JoinedAt).Seconds())) +} + +func nullableRoundedSeconds(value *float64) any { + if value == nil { + return nil + } + return maxInt64(0, int64(*value+0.5)) +} + +func maxInt64(value, floor int64) int64 { + if value < floor { + return floor + } + return value +} + +func parseBoolQuery(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes": + return true + default: + return false + } +} + +func historyProjectIDPointer(value string) *string { + if value == "" { + return nil + } + return &value +} + +func historyQueryKind(value string) string { + if _, err := security.NormalizePhone(value); err == nil { + return "phone" + } + if value == "" { + return "none" + } + return "ticket_or_text" +} + +func escapeHistoryLike(value string) string { + return strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(value) +} + +func stringValue(value any) string { + if value == nil { + return "" + } + switch typed := value.(type) { + case string: + return typed + case *time.Time: + if typed == nil { + return "" + } + return typed.Format(time.RFC3339) + case time.Time: + return typed.Format(time.RFC3339) + case int64: + return strconv.FormatInt(typed, 10) + case int: + return strconv.Itoa(typed) + case float64: + return strconv.FormatFloat(typed, 'f', 0, 64) + default: + return fmt.Sprint(typed) + } +} diff --git a/server/internal/httpapi/history_integration_test.go b/server/internal/httpapi/history_integration_test.go new file mode 100644 index 0000000..957cbb3 --- /dev/null +++ b/server/internal/httpapi/history_integration_test.go @@ -0,0 +1,139 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "calllinesystem/server/internal/config" + "calllinesystem/server/internal/database" + "calllinesystem/server/internal/model" + + "github.com/google/uuid" +) + +func TestHistoryEndpointsPostgresIntegration(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + db, err := database.Open(ctx, dsn, logger) + if err != nil { + t.Fatal(err) + } + defer database.Close(db) + sqlDB, err := database.SQLDB(db) + if err != nil { + t.Fatal(err) + } + if err := database.Migrate(ctx, sqlDB, logger); err != nil { + t.Fatal(err) + } + keys := bytes.Repeat([]byte{0x41}, 32) + hmacKey := bytes.Repeat([]byte{0x42}, 32) + server, err := New(db, config.Config{EncryptionKey: keys, PhoneHMACKey: hmacKey}, logger) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC) + server.now = func() time.Time { return now } + userID, projectID, sessionID, ticketID, batchID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + user := model.User{ID: userID, Username: "history_admin_" + userID[:8], PasswordHash: "unused", Role: model.RoleAdmin, Active: true, CreatedAt: now, UpdatedAt: now} + project := model.Project{ID: projectID, Code: strings.ToUpper("HIST" + userID[:4]), Name: "历史测试项目", Status: model.ProjectEnded, Timezone: "Asia/Shanghai", TicketPrefix: "A", CallBatchSize: 5, CallMode: model.CallModeBoth, MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, MinPartySize: 1, MaxPartySize: 10, GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 60, ContinuousRatePerMinute: 2, ETABufferMinutes: 5, ETAIntervalSeconds: 60, VisitorNotice: "", DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&project).Error; err != nil { + t.Fatal(err) + } + session := model.QueueSession{ID: sessionID, ProjectID: projectID, BusinessDate: now, Status: "ENDED", NextTicketNumber: 2, Revision: 2, OpenedAt: now.Add(-4 * time.Hour), ClosedAt: func() *time.Time { value := now.Add(-time.Hour); return &value }(), CreatedAt: now, UpdatedAt: now} + if err := db.Create(&session).Error; err != nil { + t.Fatal(err) + } + phone := "+8613812345678" + phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+projectID)) + if err != nil { + t.Fatal(err) + } + phoneHMAC := server.cipher.Digest(phone) + joinedAt := now.Add(-3 * time.Hour) + calledAt := now.Add(-2 * time.Hour) + completedAt := now.Add(-time.Hour) + ticket := model.QueueTicket{ID: ticketID, ProjectID: projectID, QueueSessionID: sessionID, TicketNumber: 1, DisplayNumber: "00001", PartySize: 3, PublicTokenHash: uuid.NewString()[:8] + string(bytes.Repeat([]byte("a"), 56)), PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC, Honorific: "先生", Status: model.TicketCompleted, JoinedAt: joinedAt, CalledAt: &calledAt, CompletedAt: &completedAt, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: userID, CreatedAt: joinedAt, UpdatedAt: completedAt} + if err := db.Create(&ticket).Error; err != nil { + t.Fatal(err) + } + batch := model.CallBatch{ID: batchID, ProjectID: projectID, QueueSessionID: sessionID, BatchSequence: 1, Revision: 1, Status: "COMPLETED", CallMode: model.CallModeTicket, RequestedCount: 1, TicketCount: 1, PeopleCount: 3, RequestedBy: userID, CalledAt: calledAt, CompletedAt: &completedAt, CreatedAt: calledAt} + if err := db.Create(&batch).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.CallBatchTicket{CallBatchID: batchID, TicketID: ticketID, ProjectID: projectID, Position: 1, CreatedAt: calledAt}).Error; err != nil { + t.Fatal(err) + } + requestContext := context.WithValue(context.Background(), principalKey, principal{User: user}) + + request := httptest.NewRequest("GET", "/api/admin/history/tickets?from=2026-07-10&to=2026-07-16", nil).WithContext(requestContext) + recorder := httptest.NewRecorder() + server.adminHistoryTickets(recorder, request) + if recorder.Code != 200 { + t.Fatalf("history tickets status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var tickets map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &tickets); err != nil { + t.Fatal(err) + } + items := tickets["items"].([]any) + item := items[0].(map[string]any) + if item["ticket_number"] != "00001" || item["phone"] != nil || item["phone_masked"] != "****5678" || item["batch_sequence"] != float64(1) { + t.Fatalf("history ticket projection = %#v", item) + } + + detailRequest := httptest.NewRequest("GET", "/api/admin/history/tickets/"+ticketID+"?reveal=true", nil).WithContext(requestContext) + detailRequest.SetPathValue("id", ticketID) + detailRecorder := httptest.NewRecorder() + server.adminHistoryTicket(detailRecorder, detailRequest) + if detailRecorder.Code != 200 || !bytes.Contains(detailRecorder.Body.Bytes(), []byte(phone)) { + t.Fatalf("history ticket detail status = %d, body = %s", detailRecorder.Code, detailRecorder.Body.String()) + } + + batchRequest := httptest.NewRequest("GET", "/api/admin/history/batches/"+batchID, nil).WithContext(requestContext) + batchRequest.SetPathValue("id", batchID) + batchRecorder := httptest.NewRecorder() + server.adminHistoryBatch(batchRecorder, batchRequest) + if batchRecorder.Code != 200 || !bytes.Contains(batchRecorder.Body.Bytes(), []byte("00001")) { + t.Fatalf("history batch detail status = %d, body = %s", batchRecorder.Code, batchRecorder.Body.String()) + } + + summaryRequest := httptest.NewRequest("GET", "/api/admin/history/summary?from=2026-07-10&to=2026-07-16", nil).WithContext(requestContext) + summaryRecorder := httptest.NewRecorder() + server.adminHistorySummary(summaryRecorder, summaryRequest) + if summaryRecorder.Code != 200 || !bytes.Contains(summaryRecorder.Body.Bytes(), []byte(`"total_tickets":1`)) { + t.Fatalf("history summary status = %d, body = %s", summaryRecorder.Code, summaryRecorder.Body.String()) + } + var summary map[string]any + if err := json.Unmarshal(summaryRecorder.Body.Bytes(), &summary); err != nil { + t.Fatal(err) + } + if len(summary["daily"].([]any)) != 1 || len(summary["projects"].([]any)) != 1 || len(summary["peak_hours"].([]any)) != 1 { + t.Fatalf("history dashboard summary = %#v", summary) + } + hourly := summary["hourly"].([]any) + if len(hourly) != 2 { + t.Fatalf("history hourly summary = %#v, want separate issue and call hours", hourly) + } + firstHour := hourly[0].(map[string]any) + secondHour := hourly[1].(map[string]any) + if firstHour["hour"] != float64(17) || firstHour["issued_tickets"] != float64(1) || firstHour["called_tickets"] != float64(0) || secondHour["hour"] != float64(18) || secondHour["issued_tickets"] != float64(0) || secondHour["called_tickets"] != float64(1) { + t.Fatalf("history hourly issue/call separation = %#v / %#v", firstHour, secondHour) + } +} diff --git a/server/internal/httpapi/history_test.go b/server/internal/httpapi/history_test.go new file mode 100644 index 0000000..d668b91 --- /dev/null +++ b/server/internal/httpapi/history_test.go @@ -0,0 +1,78 @@ +package httpapi + +import ( + "net/http/httptest" + "testing" + "time" +) + +func TestParseHistoryFilterUsesRecentSevenDaysByDefault(t *testing.T) { + server := &Server{now: func() time.Time { return time.Date(2026, 7, 16, 8, 0, 0, 0, time.UTC) }} + filter, err := server.parseHistoryFilter(httptest.NewRequest("GET", "/api/admin/history/tickets", nil), historyTicketStatuses) + if err != nil { + t.Fatal(err) + } + if filter.From != "2026-07-10" || filter.To != "2026-07-16" || filter.Page != 1 || filter.PageSize != 20 { + t.Fatalf("default history filter = %#v", filter) + } +} + +func TestParseHistoryFilterRejectsUnsafeRangeAndPageSize(t *testing.T) { + server := &Server{now: time.Now} + for _, path := range []string{ + "/api/admin/history/tickets?from=2026-01-01&to=2026-02-01", + "/api/admin/history/tickets?from=2026-07-10&to=2026-07-16&page_size=101", + } { + if _, err := server.parseHistoryFilter(httptest.NewRequest("GET", path, nil), historyTicketStatuses); err == nil { + t.Fatalf("expected history filter to be rejected: %s", path) + } + } +} + +func TestHistoryTicketViewMasksPersonalDataAndHonorsAnonymization(t *testing.T) { + server, ticket := encryptedTicketFixture(t) + row := historyTicketRow{ + ID: ticket.ID, ProjectID: ticket.ProjectID, ProjectName: "云岭漂流", ProjectCode: "RIDE", + QueueSessionID: "session-a", BusinessDate: time.Date(2026, 7, 16, 0, 0, 0, 0, time.UTC), + TicketNumber: 42, DisplayNumber: ticket.DisplayNumber, PartySize: ticket.PartySize, Status: ticket.Status, + JoinedAt: ticket.JoinedAt, PhoneCiphertext: ticket.PhoneCiphertext, PhoneNonce: ticket.PhoneNonce, PhoneHMAC: ticket.PhoneHMAC, + LastNameCiphertext: ticket.LastNameCiphertext, LastNameNonce: ticket.LastNameNonce, Honorific: ticket.Honorific, + } + masked, err := server.historyTicketView(row, false) + if err != nil { + t.Fatal(err) + } + if masked["phone"] != nil || masked["last_name"] != nil || masked["phone_masked"] != "****5678" { + t.Fatalf("masked history view leaked personal data: %#v", masked) + } + revealed, err := server.historyTicketView(row, true) + if err != nil || revealed["phone"] != "+8613812345678" || revealed["last_name"] != "林" { + t.Fatalf("revealed history view = %#v, err = %v", revealed, err) + } + marked := time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC) + row.HistoryAnonymizedAt = &marked + anonymized, err := server.historyTicketView(row, true) + if err != nil { + t.Fatal(err) + } + if anonymized["personal_data_status"] != "ANONYMIZED" || anonymized["phone"] != nil || anonymized["phone_masked"] != nil { + t.Fatalf("anonymized history view = %#v", anonymized) + } + if anonymized["honorific"] != nil { + t.Fatalf("anonymized history view leaked honorific: %#v", anonymized) + } +} + +func TestHistorySummaryAlertsUseMinimumSampleAndOperationalThresholds(t *testing.T) { + longWait := 15.0 * 60 + alerts := historySummaryAlerts([]historyProjectRow{ + {ProjectID: "project-alert", ProjectName: "峡谷漂流", TotalTickets: 10, MissedTickets: 3, CompletedTickets: 6, AverageWait: &longWait}, + {ProjectID: "project-small", ProjectName: "小样本项目", TotalTickets: 4, MissedTickets: 4, CompletedTickets: 0, AverageWait: &longWait}, + }) + if len(alerts) != 1 { + t.Fatalf("alerts = %#v, want one alert after minimum sample filtering", alerts) + } + if alerts[0]["type"] != "MISSED_RATE" || alerts[0]["project_id"] != "project-alert" { + t.Fatalf("alert = %#v", alerts[0]) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 7c55377..ed5d2cf 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -97,6 +97,12 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/admin/projects", s.requireAdmin(http.HandlerFunc(s.createProject))) mux.Handle("PUT /api/admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.updateProject))) mux.Handle("PUT /api/admin/projects/{id}/settings", s.requireAdmin(http.HandlerFunc(s.updateProjectSettings))) + mux.Handle("GET /api/admin/history/tickets", s.requireAdmin(http.HandlerFunc(s.adminHistoryTickets))) + mux.Handle("GET /api/admin/history/tickets/{id}", s.requireAdmin(http.HandlerFunc(s.adminHistoryTicket))) + mux.Handle("GET /api/admin/history/batches", s.requireAdmin(http.HandlerFunc(s.adminHistoryBatches))) + mux.Handle("GET /api/admin/history/batches/{id}", s.requireAdmin(http.HandlerFunc(s.adminHistoryBatch))) + mux.Handle("GET /api/admin/history/summary", s.requireAdmin(http.HandlerFunc(s.adminHistorySummary))) + mux.Handle("GET /api/admin/history/export.csv", s.requireAdmin(http.HandlerFunc(s.adminHistoryExport))) return s.recoverPanic(s.requestID(s.accessLog(s.securityHeaders(mux)))) } diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 0887959..92b880e 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -130,7 +130,8 @@ type QueueTicket struct { MissedAt *time.Time PersonalDataPurgedAt *time.Time PersonalDataPurgeAt time.Time `gorm:"not null"` - CreatedBy string `gorm:"type:uuid;not null"` + HistoryAnonymizedAt *time.Time + CreatedBy string `gorm:"type:uuid;not null"` CreatedAt time.Time UpdatedAt time.Time } diff --git a/server/migrations/000013_history_indexes.down.sql b/server/migrations/000013_history_indexes.down.sql new file mode 100644 index 0000000..24cbb75 --- /dev/null +++ b/server/migrations/000013_history_indexes.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS call_batch_tickets_history_batch_position_idx; +DROP INDEX IF EXISTS call_batches_history_session_called_idx; +DROP INDEX IF EXISTS queue_tickets_history_project_called_idx; +DROP INDEX IF EXISTS queue_tickets_history_session_status_number_idx; +DROP INDEX IF EXISTS queue_sessions_history_project_date_idx; diff --git a/server/migrations/000013_history_indexes.up.sql b/server/migrations/000013_history_indexes.up.sql new file mode 100644 index 0000000..934b430 --- /dev/null +++ b/server/migrations/000013_history_indexes.up.sql @@ -0,0 +1,14 @@ +CREATE INDEX IF NOT EXISTS queue_sessions_history_project_date_idx + ON queue_sessions (project_id, business_date DESC, id); + +CREATE INDEX IF NOT EXISTS queue_tickets_history_session_status_number_idx + ON queue_tickets (queue_session_id, status, ticket_number); + +CREATE INDEX IF NOT EXISTS queue_tickets_history_project_called_idx + ON queue_tickets (project_id, called_at DESC); + +CREATE INDEX IF NOT EXISTS call_batches_history_session_called_idx + ON call_batches (queue_session_id, called_at DESC); + +CREATE INDEX IF NOT EXISTS call_batch_tickets_history_batch_position_idx + ON call_batch_tickets (call_batch_id, position); diff --git a/server/migrations/000014_history_anonymization.down.sql b/server/migrations/000014_history_anonymization.down.sql new file mode 100644 index 0000000..ee5879b --- /dev/null +++ b/server/migrations/000014_history_anonymization.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS queue_tickets_history_anonymized_idx; + +ALTER TABLE queue_tickets + DROP COLUMN IF EXISTS history_anonymized_at; diff --git a/server/migrations/000014_history_anonymization.up.sql b/server/migrations/000014_history_anonymization.up.sql new file mode 100644 index 0000000..a3d9dcd --- /dev/null +++ b/server/migrations/000014_history_anonymization.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE queue_tickets + ADD COLUMN history_anonymized_at timestamptz; + +CREATE INDEX queue_tickets_history_anonymized_idx + ON queue_tickets (history_anonymized_at, id) + WHERE history_anonymized_at IS NULL; diff --git a/task_plan.md b/task_plan.md index 8a08709..b1dcad1 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,10 +4,118 @@ 在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。 ## Current Phase -Phase 36(员工端取号同行人数输入修复) +Phase 42(运营统计模块标题区呼吸空间优化) ## Phases +### Phase 39: 运营统计经营复盘看板优化 +- [x] 扩展运营统计数据接口,支持趋势、项目对比、峰值时段和异常摘要 +- [x] 将运营统计页重构为指标层、趋势层、项目对比层和异常下钻层 +- [x] 补充筛选、空态、错误态、响应式布局和回归测试 +- [x] 完成全量测试、类型检查、生产构建与运行态验收 +- **Status:** complete + +#### Confirmed Decisions +| Decision | Result | Why it matters | +|---|---|---| +| 运营统计看板服务场景 | 经营复盘:按日期/项目查看总量、趋势、效率、异常并支持下钻 | 与实时运营概览分工,避免把历史分析和当前值班监控混在一起 | + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---|---| +| 在 `server` 工作目录执行时仍使用了包含 `server/` 前缀的格式化路径 | 1 | 改用 `internal/httpapi/...` 相对路径,后端定向测试通过 | +| 历史页面回归测试仍断言旧版“取号人数”卡片 | 1 | 更新测试夹具和断言,覆盖经营复盘、每日趋势和项目对比 | +| 看板新增“平均等待”在 KPI 与项目对比中各出现一次,旧测试使用唯一文本查询 | 1 | 改为断言匹配数量大于 0,测试通过 | +| 小时统计集成夹具按 UTC 写入,测试错误期望为 9/10 点 | 1 | 结合 Asia/Shanghai 业务时区修正为 17/18 点,并验证取号与叫号分桶分离 | +| 并行执行全量前端测试和静态检查时,员工页已有测试偶发超时 | 1 | 单独复跑员工页 8 项通过;随后串行执行 `make test`,15 个文件/53 项测试全部通过 | + +### Phase 40: 运营统计看板布局与 UI/UX 收敛 +- [x] 依据 UI/UX 规范复核看板层级、信息密度、趋势图可读性和响应式排版 +- [x] 删除“需要关注”前端模块及相关引导文案,保留项目下钻能力 +- [x] 将看板重排为 KPI、全宽趋势、全宽项目对比、状态与时段节奏四个层级 +- [x] 修复项目对比在窄屏下的网格错位、间距和可访问性回归 +- [x] 完成前端定向测试、类型检查、构建和工作态验证 +- **Status:** complete + +#### Confirmed Decisions +| Decision | Result | Why it matters | +|---|---|---| +| “需要关注”模块 | 删除前端展示,不再在经营复盘页渲染关注卡片或异常引导语 | 看板回到可验证的经营事实,避免把阈值推断当成首页主结论 | +| 看板信息层级 | KPI → 每日趋势 → 项目对比 → 状态/峰值/小时节奏 | 减少并列卡片,先看总体,再定位项目,最后看运营节奏 | +| 短日期范围趋势 | 少于 4 个营业日显示日卡片摘要,4 天及以上显示 SVG 折线图 | 避免单点/少点折线造成错误的趋势暗示,同时保留日级数据 | + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---|---| +| 当前管理端路由需要管理员会话,无法直接用未认证浏览器做看板截图 | 1 | 不读取或写入密码/运行数据,改用 UI/UX 规范、源码审计、组件回归和运行态健康检查验证 | + +### Phase 41: 运营统计页视觉层级与排版精修 +- [x] 完成保留式视觉审计,明确筛选区、指标层、内容卡片和响应式的改版边界 +- [x] 使用统一指标轨道、视图感知筛选网格和清晰的卡片层级,减少卡片碎片化 +- [x] 修复历史页空值文案、小时条文字色和移动端间距等细节问题 +- [x] 保留现有数据、路由、下钻、导出和权限契约,补充页面级 UI 回归断言 +- [x] 完成定向测试、类型检查、构建和运行态验证 +- **Status:** complete + +#### Confirmed Decisions +| Decision | Result | Why it matters | +|---|---|---| +| 视觉方向 | 保留现有景区绿色品牌和原生 CSS,不引入新组件库、图片或营销型动效 | 管理端优先可信、可读和稳定,不牺牲信息密度换装饰 | +| 指标容器 | 五个 KPI 合并为一张指标轨道,桌面用分隔线,移动端按两列自然折行 | 让指标成为一个整体,减少重复白卡和视觉噪音 | +| 筛选布局 | 统计页只展示日期、项目和操作;记录页保留状态和关键词字段 | 消除统计页空列与无效留白,保持各视图控件语义一致 | +| 动效 | 仅保留现有 hover/focus/press 反馈,不新增滚动或循环动画 | `MOTION_INTENSITY=2`,运营数据页需要稳定扫描和低干扰 | + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---|---| +| `design-taste-frontend` 明确不以后台仪表盘为专用范围 | 1 | 采用其保留式重设计、形状一致性、文案自检和交互状态规则;数据图表与响应式细节以 `ui-ux-pro-max` 为主 | + +### Phase 42: 运营统计模块标题区呼吸空间优化 +- [x] 依据用户截图定位每日趋势、项目对比、状态分布和高峰时段的标题区拥挤问题 +- [x] 统一增加卡片内边距、标题与副标题间距、标题区与数据区间距 +- [x] 保留项目行、峰值列表、状态列表和趋势数据结构,不改变业务交互 +- [x] 补充样式回归检查并完成前端测试、类型检查和构建验证 +- **Status:** complete + +#### Confirmed Decisions +| Decision | Result | Why it matters | +|---|---|---| +| 标题区处理 | 四个模块统一使用宽松的卡片内边距和独立标题区,不分别打补丁 | 保持同一页面的节奏一致,避免每张卡的标题位置不同 | +| 内容区分隔 | 标题区下方增加明确留白,数据列表/趋势从标题区之后开始 | 让标题、副标题和数据各自形成可扫描的视觉层级 | +| 响应式 | 桌面提升水平留白,移动端保留可用宽度并同步增加垂直间距 | 解决桌面拥挤的同时避免窄屏内容被压缩或横向溢出 | + +#### Evidence +| Source | Finding | +|---|---| +| 用户提供的运营统计截图 | 每日趋势、项目对比、状态分布和高峰时段的标题/副标题贴近卡片边线,标题区与首个数据区缺少缓冲空间 | + +### Phase 38: 后台历史数据管理功能实施 +- [x] 实现历史数据后端接口、查询过滤、统计聚合、详情与 CSV 导出 +- [x] 接入管理端历史数据入口与排队/叫号/统计三视图 +- [x] 补充脱敏、审计、索引和 90 天匿名化策略验证 +- [x] 运行 Go/前端测试、类型检查、构建与数据库烟测 +- **Status:** complete + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---|---| +| PostgreSQL 将匿名化维护条件 `$1 - interval '90 days'` 推断为 interval,导致 `timestamp with time zone <= interval` | 1 | 为参数增加 `::timestamptz` 显式转换,待重新运行真实数据库验证 | +| PostgreSQL 历史接口集成夹具使用大写项目编码,触发项目编码校验 | 1 | 将测试夹具项目编码改为小写格式,重新验证通过 | +| 直接调用详情 handler 的测试请求未设置 Go 1.22 `PathValue` | 1 | 在测试请求上显式设置 `id` 路径值,重新验证通过 | +| 根目录执行 `pnpm typecheck` 找不到 `package.json` | 1 | 按项目脚本约定改用 `make` 或在 `web` 子目录执行,验证通过 | + +### Phase 37: 后台历史数据管理功能规划 +- [x] 核对现有管理端导航、权限、历史事实表和个人信息清理机制 +- [x] 形成历史记录、叫号批次、运营统计、导出和治理边界 +- [x] 形成页面、接口、索引、权限、隐私和验收建议 +- [x] 确认“90 天后原始业务事件”保留匿名明细与统计、删除个人关联信息 +- **Status:** complete + +#### Risks / Open Decisions +| Decision | Current recommendation | Why it matters | +|---|---|---| +| 90 天后的历史明细 | 已确认:保留匿名明细与长期统计,删除个人关联信息且不可恢复 | 会决定清理任务、查询范围、导出字段和验收标准 | + ### Phase 36: 员工端取号同行人数输入修复 - [x] 复现并定位同行人数输入框被 `1` 固定的状态更新原因 - [x] 允许用户正常清空后输入合法人数,同时保留项目人数上下限校验 diff --git a/web/src/App.tsx b/web/src/App.tsx index 94e2946..5d0acac 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { LoadingState } from "./components/Feedback"; import { AdminPage } from "./pages/AdminPage"; import { DisplayPage } from "./pages/DisplayPage"; import { LoginPage } from "./pages/LoginPage"; +import { HistoryPage } from "./pages/HistoryPage"; import { StaffPage } from "./pages/StaffPage"; import { VisitorPage } from "./pages/VisitorPage"; import { VisitorLookupPage } from "./pages/VisitorLookupPage"; @@ -42,6 +43,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.ts b/web/src/api.ts index bb4b1d0..46351b4 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -13,6 +13,11 @@ import type { CallBatchDto, AdminUserDto, PublicProjectDto, + AdminHistoryBatchDto, + AdminHistoryPageDto, + AdminHistoryQuery, + AdminHistorySummaryDto, + AdminHistoryTicketDto, } from "./types"; const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? ""; @@ -71,6 +76,37 @@ async function request(path: string, init: RequestInit = {}): Promise { return body as T; } +async function requestBlob(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set("Accept", "text/csv, application/json"); + let response: Response; + try { + response = await fetch(`${API_BASE}${path}`, { ...init, headers, credentials: "include" }); + } catch (error) { + throw new ApiError("无法连接服务,请检查网络后重试。", 0, error); + } + if (!response.ok) { + const body = await response.json().catch(() => null); + const record = body && typeof body === "object" ? body as Record : null; + const nestedError = record?.error && typeof record.error === "object" ? record.error as Record : null; + const message = nestedError?.message ?? record?.message ?? "导出失败,请稍后重试。"; + throw new ApiError(typeof message === "string" ? message : "导出失败,请稍后重试。", response.status, body); + } + return response.blob(); +} + +function historyPath(path: string, params: AdminHistoryQuery): string { + const search = new URLSearchParams(); + search.set("from", params.from); + search.set("to", params.to); + if (params.project_id) search.set("project_id", params.project_id); + if (params.status) search.set("status", params.status); + if (params.query?.trim()) search.set("query", params.query.trim()); + if (params.page) search.set("page", String(params.page)); + if (params.page_size) search.set("page_size", String(params.page_size)); + return `${path}?${search.toString()}`; +} + export function newIdempotencyKey(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); return `web-${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -199,4 +235,25 @@ export const api = { updateAdminUser(userId: string, payload: { password?: string; role: string; active: boolean; project_ids: string[] }) { return request<{ user: AdminUserDto }>(`/api/admin/users/${encodeURIComponent(userId)}`, { method: "PUT", body: JSON.stringify(payload) }); }, + adminHistoryTickets(params: AdminHistoryQuery, signal?: AbortSignal) { + return request>(historyPath("/api/admin/history/tickets", params), { signal }); + }, + adminHistoryTicket(ticketId: string, reveal = false, signal?: AbortSignal) { + const query = reveal ? "?reveal=true" : ""; + return request<{ ticket: AdminHistoryTicketDto }>(`/api/admin/history/tickets/${encodeURIComponent(ticketId)}${query}`, { signal }); + }, + adminHistoryBatches(params: AdminHistoryQuery, signal?: AbortSignal) { + return request>(historyPath("/api/admin/history/batches", params), { signal }); + }, + adminHistoryBatch(batchId: string, signal?: AbortSignal) { + return request<{ batch: AdminHistoryBatchDto }>(`/api/admin/history/batches/${encodeURIComponent(batchId)}`, { signal }); + }, + adminHistorySummary(params: AdminHistoryQuery, signal?: AbortSignal) { + return request(historyPath("/api/admin/history/summary", params), { signal }); + }, + adminHistoryExport(params: AdminHistoryQuery, includePersonal = false) { + const path = historyPath("/api/admin/history/export.csv", params); + const query = includePersonal ? `${path}&include_personal=true&confirm=true` : path; + return requestBlob(query); + }, }; diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index e4ef933..21db315 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -39,6 +39,7 @@ function AdminNavigation() { diff --git a/web/src/pages/HistoryPage.test.tsx b/web/src/pages/HistoryPage.test.tsx new file mode 100644 index 0000000..d5794ec --- /dev/null +++ b/web/src/pages/HistoryPage.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../auth/AuthContext", () => ({ + useAuth: () => ({ + user: { id: "admin-1", username: "admin", display_name: "管理员", role: "ADMIN" }, + projects: [{ id: "project-1", name: "东门观光车", code: "EAST", status: "ENDED" }], + loading: false, + error: null, + login: vi.fn(), + logout: vi.fn(), + refresh: vi.fn(), + }), +})); + +vi.mock("../hooks/usePollingResource", () => ({ + usePollingResource: (_loader: unknown, options: { enabled?: boolean; resourceKey?: string }) => { + const key = String(options.resourceKey ?? ""); + if (!options.enabled) return { data: undefined, loading: false, refreshing: false, error: null, offline: false, lastClientSuccessAt: null, refresh: vi.fn() }; + if (key.includes('"batches"')) return { + data: { items: [], page: 1, page_size: 20, total: 0, from: "2026-07-10", to: "2026-07-16" }, loading: false, refreshing: false, error: null, offline: false, lastClientSuccessAt: new Date().toISOString(), refresh: vi.fn(), + }; + if (key.includes('"summary"')) return { + data: { + from: "2026-07-10", to: "2026-07-16", + summary: { total_tickets: 1, total_people: 3, called_tickets: 1, called_people: 3, completed_tickets: 1, completed_people: 3, missed_tickets: 0, canceled_tickets: 0, completion_rate: 1, missed_rate: 0, canceled_rate: 0, average_wait_seconds: 120, max_wait_seconds: 120 }, + hourly: [{ hour: 10, issued_tickets: 1, issued_people: 3, called_tickets: 1, called_people: 3 }], + peak_hours: [{ rank: 1, hour: 10, issued_tickets: 1, issued_people: 3, called_tickets: 1, called_people: 3 }], + daily: [{ business_date: "2026-07-16", total_tickets: 1, total_people: 3, called_tickets: 1, completed_tickets: 1, missed_tickets: 0, canceled_tickets: 0, completion_rate: 1, missed_rate: 0, canceled_rate: 0, average_wait_seconds: 120, max_wait_seconds: 120 }], + projects: [{ project_id: "project-1", project_name: "东门观光车", total_tickets: 1, total_people: 3, called_tickets: 1, completed_tickets: 1, missed_tickets: 0, canceled_tickets: 0, completion_rate: 1, missed_rate: 0, canceled_rate: 0, average_wait_seconds: 120, max_wait_seconds: 120 }], + }, loading: false, refreshing: false, error: null, offline: false, lastClientSuccessAt: new Date().toISOString(), refresh: vi.fn(), + }; + return { + data: { items: [{ id: "ticket-1", project_id: "project-1", project_name: "东门观光车", queue_session_id: "session-1", business_date: "2026-07-16", ticket_number: "00012", party_size: 3, status: "COMPLETED", joined_at: "2026-07-16T09:00:00Z", called_at: "2026-07-16T09:02:00Z", wait_seconds: 120, phone_masked: "****8000", personal_data_status: "AVAILABLE" }], page: 1, page_size: 20, total: 1, from: "2026-07-10", to: "2026-07-16" }, loading: false, refreshing: false, error: null, offline: false, lastClientSuccessAt: new Date().toISOString(), refresh: vi.fn(), + }; + }, +})); + +vi.mock("../api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: { adminHistoryExport: vi.fn().mockResolvedValue(new Blob(["号码"])), adminHistoryTicket: vi.fn(), adminHistoryBatch: vi.fn() } }; +}); + +import { HistoryPage } from "./HistoryPage"; + +afterEach(() => vi.restoreAllMocks()); + +describe("HistoryPage", () => { + it("默认展示排队历史并支持切换运营统计", () => { + render(); + + expect(screen.getByRole("heading", { name: "历史数据" })).toBeVisible(); + expect(screen.getByRole("tab", { name: "排队记录" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("row", { name: /东门观光车/ })).toBeVisible(); + expect(screen.getByText("00012")).toBeVisible(); + expect(screen.getByText("****8000")).toBeVisible(); + + fireEvent.click(screen.getByRole("tab", { name: "运营统计" })); + expect(screen.getByRole("tab", { name: "运营统计" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByLabelText("历史数据筛选条件")).toHaveClass("history-filters--summary"); + expect(screen.getByText("经营复盘")).toBeVisible(); + expect(screen.getByText("总人数 3 人")).toBeVisible(); + expect(screen.getByText("每日趋势")).toBeVisible(); + expect(screen.getByText("完成量")).toBeVisible(); + expect(screen.queryByRole("img", { name: "每日取号量与完成量趋势图" })).not.toBeInTheDocument(); + expect(screen.getByText("项目对比")).toBeVisible(); + expect(screen.getByText("高峰时段")).toBeVisible(); + expect(screen.getByText("小时分布")).toBeVisible(); + expect(screen.queryByText("需要关注")).not.toBeInTheDocument(); + expect(screen.getAllByText("平均等待").length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole("button", { name: "下钻" })); + expect(screen.getByRole("tab", { name: "排队记录" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("combobox", { name: "项目" })).toHaveValue("project-1"); + }); +}); diff --git a/web/src/pages/HistoryPage.tsx b/web/src/pages/HistoryPage.tsx new file mode 100644 index 0000000..d97524d --- /dev/null +++ b/web/src/pages/HistoryPage.tsx @@ -0,0 +1,367 @@ +import { useEffect, useMemo, useState } from "react"; +import { NavLink } from "react-router-dom"; +import { useAuth } from "../auth/AuthContext"; +import { ApiError, api } from "../api"; +import { EmptyState, FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback"; +import { StatusBadge } from "../components/StatusBadge"; +import { usePollingResource } from "../hooks/usePollingResource"; +import { formatDateTime, formatNumber, formatTicketNumberRange, ticketStatusMeta } from "../lib/format"; +import type { + AdminHistoryBatchDto, + AdminHistoryDailyDto, + AdminHistoryHourlyDto, + AdminHistoryPeakHourDto, + AdminHistoryQuery, + AdminHistorySummaryDto, + AdminHistoryTicketDto, + AdminProjectDto, + TicketStatus, +} from "../types"; +import { AppShell } from "../components/AppShell"; + +type HistoryTab = "tickets" | "batches" | "summary"; + +interface HistoryFilters { + from: string; + to: string; + projectId: string; + status: string; + query: string; +} + +function dateValue(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function defaultHistoryFilters(): HistoryFilters { + const to = new Date(); + const from = new Date(to); + from.setDate(from.getDate() - 6); + return { from: dateValue(from), to: dateValue(to), projectId: "", status: "", query: "" }; +} + +function toQuery(filters: HistoryFilters, page: number, pageSize = 20): AdminHistoryQuery { + return { + from: filters.from, + to: filters.to, + project_id: filters.projectId || undefined, + status: filters.status || undefined, + query: filters.query.trim() || undefined, + page, + page_size: pageSize, + }; +} + +function formatBusinessDate(value?: string): string { + if (!value) return "暂无日期"; + const date = new Date(`${value}T00:00:00`); + return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric" }).format(date); +} + +function formatDuration(seconds?: number | null): string { + if (seconds === null || seconds === undefined) return "暂无"; + const minutes = Math.floor(seconds / 60); + const remaining = seconds % 60; + return minutes ? `${minutes} 分 ${remaining} 秒` : `${remaining} 秒`; +} + +function rateLabel(value?: number): string { + return `${((value ?? 0) * 100).toFixed(1)}%`; +} + +function batchModeLabel(mode?: string): string { + return mode === "PEOPLE" ? "按人数" : mode === "TICKET" ? "按号码" : mode || "未标注"; +} + +function batchStatusLabel(status: string): string { + return status === "COMPLETED" ? "已完成" : status === "CALLED" ? "已叫号" : status; +} + +function HistoryFiltersBar({ + filters, + projects, + tab, + onChange, + onReset, + onExport, + exporting, +}: { + filters: HistoryFilters; + projects: AdminProjectDto[]; + tab: HistoryTab; + onChange: (patch: Partial) => void; + onReset: () => void; + onExport: () => void; + exporting: boolean; +}) { + const ticketStatuses = [ + ["WAITING", "等待中"], ["CALLED", "已叫号"], ["ARRIVED", "已到场"], + ["COMPLETED", "已完成"], ["MISSED", "已过号"], ["CANCELED", "已取消"], + ]; + const batchStatuses = [["CALLED", "已叫号"], ["COMPLETED", "已完成"]]; + const statuses = tab === "batches" ? batchStatuses : ticketStatuses; + return
+ + + + {tab !== "summary" ? : null} + {tab !== "summary" ? : null} +
{tab === "tickets" ? : null}
+
; +} + +function TicketHistoryTable({ data, onSelect, onPageChange }: { data: { items: AdminHistoryTicketDto[]; total: number; page: number; page_size: number }; onSelect: (ticketId: string) => void; onPageChange: (page: number) => void }) { + if (!data.items.length) return ; + return <> +
+ {data.items.map((ticket) => + + + + + + + + + + )} +
项目/营业日号码人数状态取号时间叫号时间等待时长联系方式操作
{ticket.project_name}{formatBusinessDate(ticket.business_date)}{ticket.ticket_number}{ticket.party_size} 人{formatDuration(ticket.wait_seconds)}{ticket.personal_data_status === "ANONYMIZED" ? "已匿名化" : ticket.phone_masked || "已脱敏"}
+ + ; +} + +function BatchHistoryTable({ data, onSelect, onPageChange }: { data: { items: AdminHistoryBatchDto[]; total: number; page: number; page_size: number }; onSelect: (batchId: string) => void; onPageChange: (page: number) => void }) { + if (!data.items.length) return ; + return <> +
+ {data.items.map((batch) => + + + + + + + + + + )} +
项目/营业日叫号序号方式实际号码实际人数操作人叫号时间状态操作
{batch.project_name}{formatBusinessDate(batch.business_date)}第 {batch.batch_sequence} 次{batchModeLabel(batch.call_mode)}{formatTicketNumberRange(batch.ticket_numbers ?? [])}{batch.ticket_count} 个号码{batch.people_count} 人{batch.requested_by || "未提供"}
+ + ; +} + +function HistoryPagination({ total, page, pageSize, onPageChange }: { total: number; page: number; pageSize: number; onPageChange: (page: number) => void }) { + const pages = Math.max(1, Math.ceil(total / pageSize)); + return
共 {formatNumber(total)} 条第 {page} / {pages} 页
; +} + +function HistoryTrendChart({ daily }: { daily: AdminHistoryDailyDto[] }) { + if (!daily.length) return ; + if (daily.length < 4) return
{daily.map((row) =>
{formatNumber(row.total_tickets)}取号
完成率
{rateLabel(row.completion_rate)}
完成量
{formatNumber(row.completed_tickets)}
)}
; + const width = 720; + const height = 240; + const padding = { top: 18, right: 16, bottom: 34, left: 42 }; + const maxValue = Math.max(1, ...daily.map((row) => Math.max(row.total_tickets, row.completed_tickets))); + const x = (index: number) => daily.length === 1 ? (width - padding.left - padding.right) / 2 + padding.left : padding.left + (index * (width - padding.left - padding.right)) / (daily.length - 1); + const y = (value: number) => height - padding.bottom - (value / maxValue) * (height - padding.top - padding.bottom); + const issuedPoints = daily.map((row, index) => `${x(index)},${y(row.total_tickets)}`).join(" "); + const completedPoints = daily.map((row, index) => `${x(index)},${y(row.completed_tickets)}`).join(" "); + return
+ + {[0, .5, 1].map((ratio) => )} + + + {daily.map((row, index) => + + + )} + +
{daily.map((row) => {formatBusinessDate(row.business_date)})}
+
取号量完成量
+
; +} + +function historyPeakHourFallback(hourly: AdminHistoryHourlyDto[]): AdminHistoryPeakHourDto[] { + return [...hourly].sort((left, right) => right.issued_tickets - left.issued_tickets || left.hour - right.hour).slice(0, 3).map((row, index) => ({ ...row, rank: index + 1 })); +} + +function SummaryView({ data, onDrillDown }: { data: AdminHistorySummaryDto; onDrillDown: (projectId: string) => void }) { + const summary = data.summary; + const daily = data.daily ?? []; + const projects = data.projects ?? []; + const hourly = data.hourly ?? []; + const peakHours = data.peak_hours?.length ? data.peak_hours : historyPeakHourFallback(hourly); + const maxProjectTickets = Math.max(1, ...projects.map((project) => project.total_tickets)); + const maxHourlyTickets = Math.max(1, ...hourly.map((row) => row.issued_tickets)); + const peakHour = peakHours[0]; + return
+
+
+ {formatBusinessDate(data.from)} 至 {formatBusinessDate(data.to)} +
+
+
取号号码{formatNumber(summary.total_tickets)}总人数 {formatNumber(summary.total_people)} 人
+
完成率{rateLabel(summary.completion_rate)}{formatNumber(summary.completed_tickets)} 个已完成
+
平均等待{formatDuration(summary.average_wait_seconds)}最长 {formatDuration(summary.max_wait_seconds)}
+
峰值时段{peakHour ? `${String(peakHour.hour).padStart(2, "0")}:00` : "暂无"}{peakHour ? `取号 ${formatNumber(peakHour.issued_tickets)} 个` : "暂无峰值数据"}
+
过号 / 取消{rateLabel(summary.missed_rate)} / {rateLabel(summary.canceled_rate)}项目对比中继续查看
+
+
+

每日趋势

按营业日查看取号量和完成量变化。

+
+

项目对比

按取号量排序,结合完成率和平均等待识别效率差异。

{projects.length ?
{projects.slice(0, 6).map((project, index) =>
{index + 1}
{project.project_name}{formatNumber(project.total_people)} 人 · 过号 {rateLabel(project.missed_rate)}
{formatNumber(project.total_tickets)} 个
{rateLabel(project.completion_rate)}完成率
{formatDuration(project.average_wait_seconds)}平均等待
)}
: }
+
+

状态分布

按当前筛选范围统计号码状态。

已叫号
{formatNumber(summary.called_tickets)} 个 · {formatNumber(summary.called_people)} 人
已完成
{formatNumber(summary.completed_tickets)} 个 · {rateLabel(summary.completion_rate)}
已过号
{formatNumber(summary.missed_tickets)} 个 · {rateLabel(summary.missed_rate)}
已取消
{formatNumber(summary.canceled_tickets)} 个 · {rateLabel(summary.canceled_rate)}
+
+

高峰时段

按项目本地时间统计取号和叫号量。

{peakHours.length ?
    {peakHours.map((row) =>
  1. {row.rank}
    {String(row.hour).padStart(2, "0")}:00取号 {formatNumber(row.issued_tickets)} · 叫号 {formatNumber(row.called_tickets)}
    {formatNumber(row.issued_people)} 人
  2. )}
: }
+

小时分布

用于观察取号和叫号的日内节奏。

{hourly.length ?
{hourly.map((row) =>
{String(row.hour).padStart(2, "0")}:00
取号 {formatNumber(row.issued_tickets)} · 叫号 {formatNumber(row.called_tickets)}
)}
: }
+
+
+
; +} + +function TicketDetail({ ticket, reveal, loading, error, onReveal, onClose }: { ticket?: AdminHistoryTicketDto; reveal: boolean; loading: boolean; error: ApiError | null; onReveal: () => void; onClose: () => void }) { + const status = ticket ? ticketStatusMeta(ticket.status) : null; + return
+ +
; +} + +function BatchDetail({ batch, loading, error, onClose }: { batch?: AdminHistoryBatchDto; loading: boolean; error: ApiError | null; onClose: () => void }) { + return
+ +
; +} + +export function HistoryPage() { + const { projects } = useAuth(); + const [tab, setTab] = useState("tickets"); + const [filters, setFilters] = useState(() => defaultHistoryFilters()); + const [page, setPage] = useState(1); + const [selectedTicketId, setSelectedTicketId] = useState(null); + const [selectedBatchId, setSelectedBatchId] = useState(null); + const [reveal, setReveal] = useState(false); + const [exporting, setExporting] = useState(false); + const [notice, setNotice] = useState<{ tone: "danger" | "success"; message: string } | null>(null); + const query = useMemo(() => toQuery(filters, page), [filters, page]); + const queryKey = JSON.stringify({ tab, query }); + const ticketResource = usePollingResource((signal) => api.adminHistoryTickets(query, signal), { enabled: tab === "tickets", intervalMs: 60_000, resourceKey: queryKey }); + const batchResource = usePollingResource((signal) => api.adminHistoryBatches(query, signal), { enabled: tab === "batches", intervalMs: 60_000, resourceKey: queryKey }); + const summaryResource = usePollingResource((signal) => api.adminHistorySummary({ ...query, status: undefined, query: undefined }, signal), { enabled: tab === "summary", intervalMs: 60_000, resourceKey: queryKey }); + const ticketDetailResource = usePollingResource((signal) => selectedTicketId ? api.adminHistoryTicket(selectedTicketId, reveal, signal) : Promise.reject(new ApiError("未选择历史号码")), { enabled: Boolean(selectedTicketId), intervalMs: 60_000, resourceKey: `${selectedTicketId || "none"}-${reveal ? "reveal" : "masked"}` }); + const batchDetailResource = usePollingResource((signal) => selectedBatchId ? api.adminHistoryBatch(selectedBatchId, signal) : Promise.reject(new ApiError("未选择历史叫号")), { enabled: Boolean(selectedBatchId), intervalMs: 60_000, resourceKey: selectedBatchId || "none" }); + const activeResource = tab === "tickets" ? ticketResource : tab === "batches" ? batchResource : summaryResource; + + useEffect(() => { + setPage(1); + }, [filters.from, filters.to, filters.projectId, filters.status, filters.query, tab]); + + const updateFilters = (patch: Partial) => { + setFilters((current) => ({ ...current, ...patch })); + setNotice(null); + }; + const resetFilters = () => { + setFilters(defaultHistoryFilters()); + setPage(1); + setNotice(null); + }; + const changeTab = (next: HistoryTab) => { + setTab(next); + setFilters((current) => ({ ...current, status: "", query: "" })); + setPage(1); + }; + const drillDownProject = (projectId: string) => { + setTab("tickets"); + setFilters((current) => ({ ...current, projectId, status: "", query: "" })); + setPage(1); + }; + const exportTickets = async () => { + setExporting(true); + setNotice(null); + try { + const blob = await api.adminHistoryExport(toQuery(filters, 1, 100)); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `历史排队记录-${filters.from}-${filters.to}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + setNotice({ tone: "success", message: "CSV 导出已开始。" }); + } catch (caught) { + setNotice({ tone: "danger", message: caught instanceof ApiError ? caught.message : "导出失败,请稍后重试。" }); + } finally { + setExporting(false); + } + }; + + return +
+ +
+

历史数据

查询过去营业日的排队记录、叫号批次和运营统计。历史事实只读,个人信息按留存策略处理。

+
+ void exportTickets()} exporting={exporting} /> + {notice ? {notice.message} : null} + {activeResource.loading && !activeResource.data ? : null} + {activeResource.error && !activeResource.data ? 重试}>{activeResource.error.message} : null} + {activeResource.data ? : null} + {tab === "tickets" && ticketResource.data ? { setSelectedTicketId(id); setReveal(false); }} onPageChange={setPage} /> : null} + {tab === "batches" && batchResource.data ? : null} + {tab === "summary" && summaryResource.data ? : null} +
+
+
+ {selectedTicketId ? setReveal(true)} onClose={() => setSelectedTicketId(null)} /> : null} + {selectedBatchId ? setSelectedBatchId(null)} /> : null} +
; +} diff --git a/web/src/styles.css b/web/src/styles.css index ddb672e..5e34bfc 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -755,6 +755,392 @@ body { } } +/* Historical data management */ +.history-panel { + display: grid; + gap: var(--space-4); +} + +.history-panel > .panel__header { + margin-bottom: 0; +} + +.history-tabs { + display: flex; + gap: var(--space-1); + border-bottom: 1px solid var(--color-border); +} + +.history-tab { + min-height: 42px; + border: 0; + border-bottom: 3px solid transparent; + padding: 0 var(--space-4); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + font: inherit; + font-weight: 700; +} + +.history-tab:hover, +.history-tab--active { + border-bottom-color: var(--color-primary); + color: var(--color-primary); +} + +.history-filters { + display: grid; + grid-template-columns: repeat(4, minmax(130px, 1fr)) minmax(200px, 1.5fr) auto; + gap: var(--space-3); + align-items: end; + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--color-neutral-soft); +} + +.history-filters .field { + gap: var(--space-1); +} + +.history-filters .field > span { + color: var(--color-text-secondary); + font-size: .8rem; + font-weight: 700; +} + +.history-filters__actions { + display: flex; + align-items: center; + gap: var(--space-2); + justify-content: flex-end; +} + +.history-table-scroll { + margin-top: var(--space-1); +} + +.history-table { + min-width: 1120px; +} + +.history-table td > strong, +.history-table td > small, +.history-table td > time, +.history-table td > span { + display: block; +} + +.history-table td > small, +.history-table td > time { + color: var(--color-text-secondary); + font-size: .8rem; +} + +.history-table .history-ticket-number { + color: var(--color-primary); + font-size: 1.05rem; + letter-spacing: .04em; +} + +.history-personal-cell { + color: var(--color-text-secondary); + font-size: .85rem; +} + +.history-pagination { + display: flex; + justify-content: space-between; + gap: var(--space-3); + align-items: center; + color: var(--color-text-secondary); + font-size: .85rem; +} + +.history-pagination__actions { + display: flex; + gap: var(--space-2); +} + +.history-summary { + display: grid; + gap: var(--space-5); +} + +.history-summary-cards { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-3); +} + +.history-summary-cards article { + display: grid; + gap: var(--space-1); + min-height: 112px; + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + background: var(--color-surface); +} + +.history-summary-cards span, +.history-summary-cards small { + color: var(--color-text-secondary); +} + +.history-summary-cards strong { + color: var(--color-primary); + font-size: clamp(1.5rem, 2.6vw, 2.2rem); + line-height: 1; +} + +.history-summary-grid { + display: grid; + grid-template-columns: minmax(240px, .8fr) minmax(0, 1.2fr); + gap: var(--space-4); +} + +.history-summary-grid .panel { + padding: var(--space-5); +} + +.history-summary-grid .panel__header { + margin-bottom: var(--space-3); +} + +.history-summary-grid h3, +.history-personal-detail h3, +.history-batch-members h3 { + margin: 0; + font-size: 1.05rem; +} + +.history-summary-grid .panel__header p { + color: var(--color-text-secondary); + font-size: .85rem; +} + +.history-stat-list, +.history-detail-list { + display: grid; + gap: 0; + margin: 0; +} + +.history-stat-list > div, +.history-detail-list > div { + display: flex; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) 0; + border-bottom: 1px solid var(--color-border); +} + +.history-stat-list > div:last-child, +.history-detail-list > div:last-child { + border-bottom: 0; +} + +.history-stat-list dt, +.history-detail-list dt { + color: var(--color-text-secondary); +} + +.history-stat-list dd, +.history-detail-list dd { + margin: 0; + text-align: right; + font-weight: 700; +} + +.history-hourly-list { + display: grid; + gap: var(--space-2); +} + +.history-hourly-row { + display: grid; + grid-template-columns: 56px minmax(0, 1fr); + gap: var(--space-3); + align-items: center; + color: var(--color-text-secondary); + font-size: .82rem; +} + +.history-hourly-bar { + position: relative; + min-height: 28px; + overflow: hidden; + border-radius: var(--radius-control); + background: var(--color-neutral-soft); +} + +.history-hourly-bar i { + display: block; + min-width: 4px; + height: 28px; + border-radius: inherit; + background: var(--color-primary-soft-strong, var(--color-primary)); + opacity: .7; +} + +.history-hourly-bar small { + position: absolute; + inset: 0; + display: flex; + align-items: center; + padding: 0 var(--space-2); + color: var(--color-text); + font-size: .76rem; +} + +.history-drawer-backdrop { + position: fixed; + z-index: 20; + inset: 0; + display: flex; + justify-content: flex-end; + background: rgb(23 48 38 / 32%); +} + +.history-drawer { + width: min(560px, 100%); + height: 100%; + overflow-y: auto; + background: var(--color-surface); + box-shadow: -12px 0 36px rgb(0 0 0 / 12%); +} + +.history-drawer__header { + display: flex; + justify-content: space-between; + gap: var(--space-4); + align-items: flex-start; + padding: var(--space-6); + border-bottom: 1px solid var(--color-border); +} + +.history-drawer__header h2, +.history-drawer__header p { + margin: 0; +} + +.history-drawer__header p { + margin-top: var(--space-1); + color: var(--color-text-secondary); +} + +.history-drawer__body { + display: grid; + gap: var(--space-5); + padding: var(--space-6); +} + +.history-detail-status { + display: grid; + gap: var(--space-2); + padding: var(--space-3); + border-radius: var(--radius-control); + background: var(--color-neutral-soft); +} + +.history-detail-status strong { + color: var(--color-text-secondary); + font-size: .85rem; + font-weight: 500; +} + +.history-personal-detail, +.history-batch-members { + display: grid; + gap: var(--space-3); + padding-top: var(--space-4); + border-top: 1px solid var(--color-border); +} + +.history-personal-detail p, +.history-personal-detail small { + margin: 0; + color: var(--color-text-secondary); +} + +.history-batch-members ol { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.history-batch-members li { + display: grid; + grid-template-columns: 1fr auto auto; + gap: var(--space-3); + align-items: center; + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-control); +} + +@media (max-width: 1100px) { + .history-filters { + grid-template-columns: repeat(3, minmax(130px, 1fr)); + } + + .history-filters__query { + grid-column: span 2; + } + + .history-filters__actions { + grid-column: 3; + } + + .history-summary-cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .history-panel { + padding: var(--space-4); + } + + .history-tabs { + overflow-x: auto; + } + + .history-tab { + flex: 0 0 auto; + padding: 0 var(--space-3); + } + + .history-filters, + .history-summary-cards, + .history-summary-grid { + grid-template-columns: 1fr; + } + + .history-filters__query, + .history-filters__actions { + grid-column: auto; + } + + .history-filters__actions { + justify-content: flex-start; + } + + .history-pagination { + flex-direction: column; + align-items: flex-start; + } + + .history-drawer__header, + .history-drawer__body { + padding: var(--space-4); + } +} + * { box-sizing: border-box; } @@ -6223,3 +6609,655 @@ body:has(.visitor-page--mobile) { text-align: left; } } + +/* Historical operations review dashboard */ +.history-dashboard { + display: grid; + gap: var(--space-4); +} + +.history-dashboard__intro { + display: flex; + justify-content: space-between; + gap: var(--space-4); + align-items: flex-start; +} + +.history-dashboard__intro > div { + display: flex; + gap: var(--space-3); + align-items: flex-start; +} + +.history-dashboard__intro h2, +.history-dashboard__intro p { + margin: 0; +} + +.history-dashboard__intro p { + margin-top: var(--space-1); + color: var(--color-text-secondary); + font-size: .85rem; +} + +.history-dashboard__range { + padding: 6px 10px; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-neutral-soft); + color: var(--color-text-secondary); + font-size: .8rem; + white-space: nowrap; +} + +.history-dashboard-kpis { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--space-3); +} + +.history-dashboard-kpis article { + display: grid; + gap: 6px; + min-height: 116px; + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + background: var(--color-surface); +} + +.history-dashboard-kpis span, +.history-dashboard-kpis small { + color: var(--color-text-secondary); + font-size: .8rem; +} + +.history-dashboard-kpis strong { + align-self: center; + color: var(--color-primary); + font-size: clamp(1.35rem, 2vw, 2rem); + line-height: 1.1; +} + +.history-dashboard-grid { + display: grid; + gap: var(--space-4); +} + +.history-dashboard-grid--top { + grid-template-columns: 1fr; +} + +.history-dashboard-grid--bottom { + grid-template-columns: minmax(260px, .8fr) minmax(0, 1.2fr); +} + +.history-dashboard-card { + min-width: 0; + padding: var(--space-5); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + background: var(--color-surface); +} + +.history-dashboard-card__header { + display: flex; + justify-content: space-between; + gap: var(--space-3); + align-items: flex-start; + margin-bottom: var(--space-4); +} + +.history-dashboard-card__header h3, +.history-dashboard-card__header p { + margin: 0; +} + +.history-dashboard-card__header p { + margin-top: 5px; + color: var(--color-text-secondary); + font-size: .8rem; +} + +.history-trend-chart svg { + display: block; + width: 100%; + height: 230px; + overflow: visible; +} + +.history-trend-gridline { + stroke: var(--color-border); + stroke-dasharray: 3 5; + stroke-width: 1; +} + +.history-trend-line { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 3; +} + +.history-trend-line--issued, +.history-trend-dot--issued { + stroke: var(--color-primary); + fill: var(--color-primary); +} + +.history-trend-line--completed, +.history-trend-dot--completed { + stroke: var(--color-accent-warm); + fill: var(--color-accent-warm); +} + +.history-trend-line--completed { + stroke-dasharray: 8 6; +} + +.history-trend-dot { + stroke: var(--color-surface); + stroke-width: 2; +} + +.history-trend-labels { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + gap: 4px; + color: var(--color-text-secondary); + font-size: .72rem; + text-align: center; +} + +.history-chart-legend { + display: flex; + gap: var(--space-4); + margin-top: var(--space-3); + color: var(--color-text-secondary); + font-size: .78rem; +} + +.history-chart-legend span { + display: inline-flex; + gap: 6px; + align-items: center; +} + +.history-chart-legend__dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.history-chart-legend__dot--issued { + background: var(--color-primary); +} + +.history-chart-legend__dot--completed { + background: var(--color-accent-warm); +} + +.history-project-list, +.history-peak-list { + display: grid; + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.history-project-row { + display: grid; + grid-template-columns: minmax(210px, 1.25fr) minmax(220px, 1.4fr) 100px 110px auto; + gap: var(--space-3); + align-items: center; + padding: var(--space-3) 0; + border-bottom: 1px solid var(--color-border); +} + +.history-project-row:last-child { + border-bottom: 0; +} + +.history-project-row__identity { + display: flex; + gap: var(--space-2); + align-items: center; + min-width: 0; +} + +.history-project-row__identity > span, +.history-peak-list__rank { + display: grid; + flex: 0 0 26px; + width: 26px; + height: 26px; + place-items: center; + border-radius: 50%; + background: var(--color-primary-soft); + color: var(--color-primary); + font-size: .78rem; + font-weight: 800; +} + +.history-project-row__identity div, +.history-project-row__metric, +.history-peak-list li > div { + display: grid; + gap: 3px; + min-width: 0; +} + +.history-project-row__identity strong, +.history-project-row__identity small, +.history-project-row__metric strong, +.history-project-row__metric small, +.history-peak-list strong, +.history-peak-list small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-project-row__identity small, +.history-project-row__metric small, +.history-peak-list small { + color: var(--color-text-secondary); + font-size: .75rem; +} + +.history-project-row__bar { + display: grid; + gap: 5px; +} + +.history-project-row__bar i { + display: block; + height: 8px; + border-radius: 999px; + background: var(--color-primary); +} + +.history-project-row__bar small { + color: var(--color-text-secondary); + font-size: .72rem; +} + +.history-project-row__metric strong { + color: var(--color-primary); +} + +.history-peak-list li { + display: grid; + grid-template-columns: 26px minmax(0, 1fr) auto; + gap: var(--space-3); + align-items: center; + padding: var(--space-3); + border-radius: var(--radius-control); + background: var(--color-page); +} + +.history-peak-list li:first-child { + background: var(--color-primary-soft); +} + +.history-peak-list li > b { + color: var(--color-primary); + font-size: .85rem; +} + +.history-hourly-list { + max-height: 260px; + overflow-y: auto; +} + +.history-trend-compact { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: var(--space-3); +} + +.history-trend-compact article { + display: grid; + gap: var(--space-2); + min-width: 0; + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--color-page); +} + +.history-trend-compact time { + color: var(--color-text-secondary); + font-size: .8rem; +} + +.history-trend-compact > article > strong { + color: var(--color-primary); + font-size: 1.8rem; + line-height: 1; +} + +.history-trend-compact > article > strong small { + margin-left: 6px; + color: var(--color-text-secondary); + font-size: .78rem; + font-weight: 600; +} + +.history-trend-compact dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); + margin: 0; +} + +.history-trend-compact dl div { + display: grid; + gap: 3px; +} + +.history-trend-compact dt { + color: var(--color-text-secondary); + font-size: .72rem; +} + +.history-trend-compact dd { + margin: 0; + font-size: .9rem; + font-weight: 700; +} + +.history-dashboard-card--rhythm { + min-width: 0; +} + +.history-dashboard-subsection + .history-dashboard-subsection { + margin-top: var(--space-5); + padding-top: var(--space-5); + border-top: 1px solid var(--color-border); +} + +@media (max-width: 1100px) { + .history-dashboard-kpis { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .history-dashboard-grid--top, + .history-dashboard-grid--bottom { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .history-dashboard__intro { + flex-direction: column; + } + + .history-dashboard__range { + align-self: flex-start; + } + + .history-dashboard-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .history-dashboard-kpis article:last-child { + grid-column: 1 / -1; + } + + .history-dashboard-card { + padding: var(--space-4); + } + + .history-project-row { + grid-template-columns: minmax(0, 1fr) auto auto auto; + } + + .history-project-row__bar { + grid-column: 1 / -1; + grid-row: 2; + } + + .history-project-row__metric { + grid-row: 1; + } + + .history-project-row > .button { + grid-row: 1; + grid-column: 4; + } + + .history-trend-chart svg { + height: 190px; + } +} + +/* Phase 41: visual hierarchy for the historical operations review */ +.history-panel > .panel__header { + align-items: flex-start; +} + +.history-panel > .panel__header p { + max-width: 66ch; + line-height: 1.55; +} + +.history-tabs { + gap: 4px; + padding: 4px 4px 0; +} + +.history-tab { + min-height: 40px; + border-radius: var(--radius-control) var(--radius-control) 0 0; + transition: background-color var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard); +} + +.history-tab:hover, +.history-tab--active { + background: var(--color-primary-soft); +} + +.history-filters { + grid-template-columns: repeat(4, minmax(140px, 1fr)) minmax(220px, 1.4fr) auto; + background: var(--color-surface); + box-shadow: 0 1px 2px rgba(16, 38, 29, .04); +} + +.history-filters--summary { + grid-template-columns: repeat(3, minmax(150px, 1fr)) auto; +} + +.history-filters .field > span { + font-size: .75rem; + letter-spacing: .02em; +} + +.history-filters__actions { + min-height: 48px; +} + +.history-dashboard__intro { + align-items: center; +} + +.history-dashboard__range { + border-radius: var(--radius-control); + background: var(--color-primary-soft); + color: var(--color-primary); + font-weight: 700; +} + +.history-dashboard-kpis { + gap: 0; + padding: 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + background: var(--color-surface); + box-shadow: 0 1px 2px rgba(16, 38, 29, .04); +} + +.history-dashboard-kpis article { + min-height: 108px; + border: 0; + border-left: 1px solid var(--color-border); + border-radius: 0; + padding: var(--space-4) var(--space-5); + background: transparent; + transition: background-color var(--motion-fast) var(--ease-standard); +} + +.history-dashboard-kpis article:first-child { + border-left: 0; +} + +.history-dashboard-kpis article:hover { + background: var(--color-page); +} + +.history-dashboard-kpis span, +.history-dashboard-kpis small { + font-size: .76rem; +} + +.history-dashboard-kpis strong { + font-variant-numeric: tabular-nums; +} + +.history-dashboard-card { + box-shadow: 0 1px 2px rgba(16, 38, 29, .04); +} + +.history-dashboard-card--trend { + border-top: 3px solid var(--color-primary); +} + +.history-dashboard-card--projects { + border-top: 2px solid var(--color-border-strong); +} + +.history-dashboard-card__header h3 { + font-size: 1.1rem; +} + +.history-dashboard-card__header p { + max-width: 66ch; + line-height: 1.5; +} + +.history-project-row { + transition: background-color var(--motion-fast) var(--ease-standard); +} + +.history-project-row:hover { + background: var(--color-page); +} + +.history-project-row > .button { + min-width: 56px; + white-space: nowrap; +} + +.history-hourly-bar small { + color: var(--color-ink); +} + +@media (max-width: 1100px) { + .history-filters--summary { + grid-template-columns: repeat(3, minmax(130px, 1fr)) auto; + } + + .history-filters--summary .history-filters__actions { + grid-column: auto; + } + + .history-dashboard-kpis article:nth-child(3n + 1) { + border-left: 0; + } +} + +@media (max-width: 720px) { + .history-filters--summary { + grid-template-columns: 1fr; + } + + .history-filters--summary .history-filters__actions { + grid-column: auto; + } + + .history-dashboard-kpis article:nth-child(2n + 1) { + border-left: 0; + } + + .history-dashboard-kpis article:last-child { + border-top: 1px solid var(--color-border); + border-left: 0; + } + + .history-dashboard-kpis article { + padding-inline: var(--space-3); + } +} + +/* Phase 42: give each dashboard module a calm title-to-content rhythm */ +.history-dashboard-card { + padding: var(--space-6) var(--space-8) var(--space-8); +} + +.history-dashboard-card__header { + margin-bottom: var(--space-6); + padding-top: var(--space-2); +} + +.history-dashboard-card__header h3 { + line-height: 1.25; +} + +.history-dashboard-card__header p { + margin-top: var(--space-2); + line-height: 1.65; +} + +.history-dashboard-card--trend .history-dashboard-card__header { + margin-bottom: var(--space-5); +} + +.history-dashboard-card--projects .history-dashboard-card__header { + margin-bottom: var(--space-6); +} + +.history-dashboard-subsection .history-dashboard-card__header { + margin-bottom: var(--space-5); +} + +.history-project-row { + padding-block: var(--space-4); +} + +.history-peak-list li { + padding: var(--space-4); +} + +.history-stat-list > div { + padding-block: var(--space-4); +} + +@media (max-width: 720px) { + .history-dashboard-card { + padding: var(--space-5); + } + + .history-dashboard-card__header { + margin-bottom: var(--space-5); + padding-top: 0; + } + + .history-dashboard-card__header p { + line-height: 1.55; + } + + .history-project-row { + padding-block: var(--space-4); + } +} diff --git a/web/src/types.ts b/web/src/types.ts index c488f2e..53a8f96 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -246,3 +246,154 @@ export interface AdminOverviewDto { projects: AdminProjectDto[]; active_tickets: AdminActiveTicketDto[]; } + +export interface AdminHistoryQuery { + project_id?: string; + from: string; + to: string; + status?: string; + query?: string; + page?: number; + page_size?: number; +} + +export interface AdminHistoryTicketDto { + id: string; + project_id: string; + project_name: string; + project_code?: string; + queue_session_id: string; + business_date: string; + ticket_number: string; + ticket_sequence?: number; + party_size: number; + status: TicketStatus; + joined_at?: string | null; + called_at?: string | null; + arrived_at?: string | null; + completed_at?: string | null; + missed_at?: string | null; + terminal_at?: string | null; + wait_seconds?: number | null; + created_by?: string | null; + phone?: string | null; + phone_last4?: string | null; + phone_masked?: string | null; + last_name?: string | null; + honorific?: string | null; + personal_data_status: "AVAILABLE" | "ANONYMIZED" | string; + personal_data_purged_at?: string | null; + history_anonymized_at?: string | null; + batch_id?: string | null; + batch_sequence?: number | null; + batch_position?: number | null; +} + +export interface AdminHistoryBatchDto { + id: string; + project_id: string; + project_name: string; + project_code?: string; + queue_session_id: string; + business_date: string; + batch_sequence: number; + revision?: number; + status: string; + call_mode?: "TICKET" | "PEOPLE" | string; + requested_count: number; + ticket_count: number; + people_count: number; + requested_by?: string | null; + called_at?: string | null; + completed_at?: string | null; + ticket_numbers?: string[]; + tickets?: AdminHistoryTicketDto[]; +} + +export interface AdminHistoryPageDto { + items: T[]; + page: number; + page_size: number; + total: number; + from: string; + to: string; +} + +export interface AdminHistoryHourlyDto { + hour: number; + issued_tickets: number; + issued_people: number; + called_tickets: number; + called_people: number; +} + +export interface AdminHistoryPeakHourDto extends AdminHistoryHourlyDto { + rank: number; +} + +export interface AdminHistoryDailyDto { + business_date: string; + total_tickets: number; + total_people: number; + called_tickets: number; + completed_tickets: number; + missed_tickets: number; + canceled_tickets: number; + completion_rate: number; + missed_rate: number; + canceled_rate: number; + average_wait_seconds?: number | null; + max_wait_seconds?: number | null; +} + +export interface AdminHistoryProjectDto { + project_id: string; + project_name: string; + project_code?: string; + total_tickets: number; + total_people: number; + called_tickets: number; + completed_tickets: number; + missed_tickets: number; + canceled_tickets: number; + completion_rate: number; + missed_rate: number; + canceled_rate: number; + average_wait_seconds?: number | null; + max_wait_seconds?: number | null; +} + +export interface AdminHistoryAlertDto { + severity: number; + type: string; + title: string; + detail: string; + project_id: string; + project_name: string; + total_tickets: number; +} + +export interface AdminHistorySummaryDto { + from: string; + to: string; + summary: { + total_tickets: number; + total_people: number; + called_tickets: number; + called_people: number; + completed_tickets: number; + completed_people: number; + missed_tickets: number; + canceled_tickets: number; + completion_rate: number; + missed_rate: number; + canceled_rate: number; + average_wait_seconds?: number | null; + max_wait_seconds?: number | null; + }; + hourly: AdminHistoryHourlyDto[]; + peak_hours?: AdminHistoryPeakHourDto[]; + daily?: AdminHistoryDailyDto[]; + projects?: AdminHistoryProjectDto[]; + alerts?: AdminHistoryAlertDto[]; +}