diff --git a/README.md b/README.md index 97f2f4f..39b0c8a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 景区排队叫号系统 -这是一个面向景区多项目运营的轻量排队叫号系统。当前实现目标是首个可运行纵向切片:员工独立 H5 的取号和批量叫号,游客私密状态页,管理端的运营概览、项目设置与大屏中心,实体公示大屏,设备模拟器和审计基础。 +这是一个面向景区多项目运营的轻量排队叫号系统。一个排队号码可绑定多名同行游客;项目可配置按号码叫号、按人数叫号或同时支持两种方式。系统包含员工独立 H5、游客取号与私密状态页、管理端运营概览/项目设置/大屏中心、实体公示大屏、设备模拟器和审计基础。 产品与交互基线见: @@ -43,9 +43,9 @@ make web `make seed` 会注入一套可直接演示的基础数据: -- `DEMO` 云栖观光车:运行中,含已完成批次、当前叫号、已到场、过号、取消和等待队列。 -- `RAFT` 峡谷漂流:暂停中,含候场队列,用于验证暂停态。 -- `CABLE` 云顶索道:未开放,用于验证空队列和未开放态。 +- `DEMO` 云栖观光车:运行中,同时支持按号码与按人数叫号,包含 1–10 人/号的多人数测试数据。 +- `RAFT` 峡谷漂流:暂停中,仅按人数叫号,包含 1–6 人/号的候场队列。 +- `CABLE` 云顶索道:未开放,仅按号码叫号,用于验证空队列和未开放态。 命令会在终端输出公示屏与游客页的本地路径。它可重复执行:每次只重置上述演示项目,不会删除其他项目。本地演示员工账号和固定演示令牌禁止用于生产。 @@ -66,15 +66,15 @@ docker compose up -d postgres - `/staff/login`:员工 H5 独立登录,只接受员工账号。 - `/admin/login`:管理端独立登录,只接受管理员账号。 - 两端使用独立会话 Cookie,可在同一浏览器同时登录,不会互相覆盖。 -- `/staff`:员工 H5 叫号页,聚焦当前批次与叫下一批。 -- `/staff/tickets`:员工 H5 取号页与等待队列核对。 -- 员工端每次“叫下一批”会自动结束上一批,再按 FIFO 叫出固定数量的连续号码。 -- `/visitor`:游客运营测试手机号查询入口;查询成功后跳转到号码状态页;正式上线前需替换为验证码或外部身份接口。 +- `/staff`:员工 H5 叫号页;同时支持两种方式的项目会并列显示“按号码叫号”和“按人数叫号”操作栏,队列逐号显示绑定人数。 +- `/staff/tickets`:员工 H5 取号页;同行人数必填且创建后不可修改,取值范围由项目配置。 +- 按号码叫号取队首连续 N 个号码;按人数叫号取合计人数不超过目标值的最长连续队首,不拆号、不跳号。队首单号人数大于目标时拒绝操作。 +- `/visitor`:游客自助取号与手机号查询入口,通过页面顶部的“取号 / 查号” Tab 切换;手机号查询仍是运营测试入口,正式上线前需替换为验证码或外部身份接口。 - `/visitor/phone`:手机号查询后的游客号码状态页,不再显示手机号输入框。 - `/visitor/:token`:游客私密状态页。 - `/display/:token`:单项目只读公示屏。 - `/admin`:管理端运营概览。 -- `/admin/projects`:管理端项目规则与预计等待时间设置。 +- `/admin/projects`:配置项目支持的叫号方式、单号人数范围、两种方式各自的默认值与防误触单次上限,以及按单人间隔计算的预计等待时间。 - `/admin/display`:管理端大屏中心,可全屏展示多项目的公开运行状态。 ## 质量检查 @@ -87,7 +87,7 @@ make build make smoke-real ``` -`make smoke-real` 会创建一个临时 PostgreSQL 数据库,启动独立 Go API,写入受控测试手机号并完成员工全号、游客尾四位、管理端全号、公屏零手机号的真实接口验收;结束后自动关闭测试 API 并删除临时数据库。它不使用前端 mock,也不会改动日常开发数据库。 +`make smoke-real` 会创建一个临时 PostgreSQL 数据库并启动独立 Go API,验证多人取号、人数模式不超过目标的连续 FIFO 选择、号码模式、号码数/人数双统计,以及员工全号、游客尾四位、管理端全号、公屏零手机号的隐私边界;结束后自动关闭测试 API 并删除临时数据库。它不使用前端 mock,也不会改动日常开发数据库。 核心叫号写操作必须由服务端事务、行锁、幂等键和队列修订号共同保护;前端按钮禁用不能替代这些约束。 diff --git a/design-qa.md b/design-qa.md index 5109d8c..75dc4a1 100644 --- a/design-qa.md +++ b/design-qa.md @@ -48,4 +48,100 @@ The implementation now follows the selected image's major composition: slim bran - Visual comparison blocker: the in-app browser rejected the local preview URL under its URL security policy, so matching-viewport implementation screenshots could not be captured after the rebuild. - The earlier admin comparison above remains passed; the new three-surface comparison must not inherit that result. -final result: blocked +historical result: blocked + +--- + +## Phase 32 员工端叫号指标与队列展示 + +- Source visual truth: `/var/folders/pq/fhy1w3z94077dxsjlb5d7p5m0000gn/T/codex-clipboard-4597599f-64bb-464e-be56-f742b8f32e94.png` +- Implementation full screenshot: `/Users/inmanw/.codex/visualizations/2026/07/15/019f65e4-386f-7d50-b2e1-40a0b166571d/staff-phase32-mobile-full.png` +- Implementation focused screenshot: `/Users/inmanw/.codex/visualizations/2026/07/15/019f65e4-386f-7d50-b2e1-40a0b166571d/staff-phase32-call-focused.jpg` +- Viewport/state: 390×844 authenticated employee call page, real local queue data, default collapsed queue; additional checks at 720px and 1280px widths + +### Full-view comparison + +The implementation preserves the reference's white employee header, official logo, evergreen palette, single rounded call card, strong title, full-width quick-call action, and paired quantity/action rows. The latest written brief intentionally supersedes two details visible in the supplied source: the internal horizontal rules are removed, and the number/people call buttons now use the same green primary treatment. + +The lower call workspace now presents six balanced indicators in the requested order, followed by a naturally flowing queue. The first ten entries fit the page hierarchy without an internal scrollbar, and the fixed employee navigation remains clear of the content. + +### Focused-region comparison + +- Call module: title, quick action, two quantity inputs, and two call modes remain in the same order as the source; computed title and mobile mode horizontal borders are both 0px. +- Color: the number-call and people-call buttons both compute to `rgb(11, 107, 58)` with white text. +- Metrics: the visible labels are exactly 最新叫到、本次叫号人数、下一个号、下个号人数、剩余未叫号、剩余未叫人数; the two superseded labels are absent. +- Queue layout: the three mobile tracks are 98.664px each, with content centers at approximately 66px, 195px, and 323px, producing a symmetric left/center/right scan line. +- Natural expansion: the list computes to `max-height:none` and `overflow-y:visible`; client height equals scroll height in both collapsed and expanded states. +- Responsive behavior: 390px, 720px, and 1280px views have no horizontal overflow. The desktop-only vertical mode separator remains; no horizontal mode separator appears at mobile or tablet widths. +- Accessibility: the expansion control exposes `aria-controls` and toggles `aria-expanded`; all call inputs and buttons retain their accessible names and keyboard focus treatment. + +### Comparison history + +1. Captured and opened the supplied reference before implementation. +2. Implemented the written deltas on the existing design system without replacing brand assets or changing call APIs. +3. Compared the reference, focused call-module crop, and full 390px implementation in the same review input. No P1/P2 mismatch remained; visible differences in dividers and button color are the requested changes. +4. Verified the default 10-item state, expanded 200-item real queue state, collapsed state, sticky scroll state, tablet breakpoint, desktop breakpoint, and console output. + +### Findings + +- P0: none. +- P1: none. +- P2: none. +- P3: none. + +### Primary interactions tested + +- “查看更多” changes the queue from 10 to 200 rendered items and grows the page from 1684px to 15104px without creating an internal scroll area. +- “收起” restores 10 rendered items and the original page height. +- At mobile scroll position 840.5px, the sticky call module starts exactly below the 83px header with zero overlap. +- Automated verification: 14 test files, 52 tests passed; TypeScript and production build passed. +- Browser console: zero errors and zero warnings. + +historical result: passed + +--- + +## Phase 34 员工端批量按钮与指标分隔 + +- Source visual truth: `/var/folders/pq/fhy1w3z94077dxsjlb5d7p5m0000gn/T/codex-clipboard-4597599f-64bb-464e-be56-f742b8f32e94.png` plus the latest written deltas for two light batch buttons and a restored metric divider +- Implementation viewport screenshot: `/Users/inmanw/.codex/visualizations/2026/07/15/019f65e4-386f-7d50-b2e1-40a0b166571d/staff-phase34-mobile-viewport.png` +- Implementation call-module crop: `/Users/inmanw/.codex/visualizations/2026/07/15/019f65e4-386f-7d50-b2e1-40a0b166571d/staff-phase34-call-focused.jpg` +- Before/after metric crops: `/Users/inmanw/.codex/visualizations/2026/07/15/019f65e4-386f-7d50-b2e1-40a0b166571d/staff-phase32-metrics-focused.jpg` and `/Users/inmanw/.codex/visualizations/2026/07/15/019f65e4-386f-7d50-b2e1-40a0b166571d/staff-phase34-metrics-focused.jpg` +- Viewport/state: 390×844 authenticated employee call page with real local queue data; breakpoint checks at 720px and 721px + +### Full-view comparison + +The employee page keeps the reference's official logo, white header, evergreen hierarchy, rounded call card, full-width primary quick-call action, and paired quantity/action rows. The two batch actions now use the requested light treatment, so the dark green remains reserved for “快速叫下一个号.” The lower metric grid retains its compact two-column rhythm and now shows an uninterrupted center divider on every row. + +### Focused-region comparison + +- Fonts/typography: existing Chinese sans-serif family, weights, line heights, and centered button labels remain unchanged; the two new labels fit without wrapping at 390px. +- Spacing/layout: the call card remains 366px wide inside the 390px shell. Input/action pairs keep equal row heights and gaps. The “下一个号 / 下个号人数” cards meet at the same x-coordinate with zero gap. +- Colors/tokens: “批量叫号” and “批量叫人” both compute to white background, `rgb(11, 107, 58)` border, and matching green text by reusing `button--secondary`. +- Divider: the fourth metric has a 1px left border in `rgb(213, 222, 216)` at 390px and 720px. At 721px the grid returns to three columns and the fourth item correctly starts a new row without a left border. +- Image quality/assets: the supplied official logo and existing raster treatment are untouched; no generated, placeholder, SVG, or CSS-drawn asset was introduced. +- Copy/content: the visible batch actions are exactly “批量叫号” and “批量叫人”; the previous button names are absent. Precise mode/input accessible labels remain available. +- Accessibility/behavior: both batch buttons remain enabled in the running state, retain visible focus treatment, and call the same ticket/people modes and counts as before. + +### Comparison history + +1. The Phase 32 implementation used dark green for both batch buttons, and the mobile fourth metric inherited the desktop `nth-child(3n+1)` border reset, leaving the middle-row divider missing. +2. Both batch buttons were switched to the shared light secondary style and renamed. The mobile rule now explicitly restores left borders on even metric items. +3. The original reference, current call-module crop, and before/after metric crops were reviewed in the same comparison input. The post-fix capture shows both requested corrections with no P0/P1/P2 mismatch. + +### Findings + +- P0: none. +- P1: none. +- P2: none. +- P3: none. + +### Primary interactions tested + +- Both renamed batch buttons are present, enabled, and mapped to the unchanged call handlers. +- The people-count over-limit validation still blocks submission and reports the existing error. +- 390px and 720px show the restored divider; 721px preserves the intended desktop three-column border logic. +- Automated verification: 14 test files, 52 tests passed; TypeScript and production build passed. +- Browser console: zero errors and zero warnings. + +final result: passed diff --git a/docs/scenic-queue-design-paradigm.md b/docs/scenic-queue-design-paradigm.md index baac9a7..a77b8f3 100644 --- a/docs/scenic-queue-design-paradigm.md +++ b/docs/scenic-queue-design-paradigm.md @@ -1,7 +1,7 @@ # 景区排队叫号系统 UI/UX 设计范式 -> 文档版本:v1.0 -> 日期:2026-07-10 +> 文档版本:v1.1 +> 日期:2026-07-10;人数与双叫号模式修订:2026-07-15 > 状态:已确认开发基线;景区 VI、设备尺寸和现场阅读距离作为后续校准输入,不阻塞开发 > 关联规划:[景区项目排队叫号系统:案例调研与完整规划](./scenic-queue-system-plan.md) @@ -27,11 +27,11 @@ 已冻结的产品与实现约束: -- 一人一号,不建立同行组或 `party_size`;手机号必填,姓氏/称谓选填,称谓默认“游客”,不采集法定性别。 +- 一个号码保存创建后不可修改的 `party_size`;手机号和同行人数必填,姓氏/称谓选填,称谓默认“游客”,不采集法定性别。 - 同一手机号可有多个活动号码;取号时先显示脱敏重复提示,员工确认后可继续并记录审计。 -- 每项目配置固定 `N`,员工只执行“叫下一批”;系统严格 FIFO 呼叫队首连续 `N` 个有效等待号码,不足时呼叫全部剩余号码。 +- 每项目配置按号码、按人数或两种叫号方式;号码模式取队首 N 张,人数模式取不超过目标的最长连续队首,不拆号、不跳号。 - 过号后原号失效;仍需排队时创建新号进入队尾,并保留新旧号码关联。 -- 预计等待时间首期同时提供固定批次和连续放行模板,每个项目二选一。 +- 预计等待时间按本号前方实际人数与项目单人间隔计算,不包含本号自身人数。 - 中心服务是唯一写入权威,断网时停止数字化取号/叫号并切换人工预案。 - 员工和管理员首期使用内置账号密码;前端 React,后端 Go + GORM,数据库 PostgreSQL。 - 首期硬件只提供统一接口、成功/失败模拟器和测试日志,不接真实设备。 @@ -68,8 +68,8 @@ 本文约束以下界面: -- 员工 H5:员工账号登录后只保留“叫号、取号”。叫号页展示当前批次、队首预览和固定主操作;每次叫下一批自动结束上一批;取号页核对等待队列; -- 游客 H5:[MVP] 个人排队状态、预计等待区间、提醒、过号和服务异常;[P1] 游客自助取号; +- 员工 H5:员工账号登录后只保留“叫号、取号”。叫号页按项目显示号码栏、人数栏或两栏,并在每个队列号码旁显示人数;每次叫号自动结束上一批; +- 游客 H5:[MVP] 游客自助取号、个人排队状态、本号人数、前方人数、预计等待、提醒和服务异常; - 大屏 Web:实体公示屏展示当前叫号、最近批次、队列概况、服务公告、数据新鲜度和播报状态; - 管理 Web:管理员账号登录后的运营概览、项目与预计时间设置及大屏中心。大屏中心呈现全项目公开运行状态并支持全屏监控;设备、账号权限、日志和审计按后续范围扩展。 @@ -103,13 +103,13 @@ |---|---|---| | Visitor Status Ticket | Qmatic Mobile Ticket 和 Waitwhile 状态页都把当前状态、票号、预计等待时间、队列进度和下一步作为个人页主层级 | 游客端采用单一主状态卡;叫到后由“等待信息”切换为“入口行动” | | Operational Call Card | Qmatic Serve 以当前服务对象/下一位和主叫号操作为中心;Waitwhile 采用状态列 + 详情抽屉 + 固定操作 | 员工 H5 把叫号、取号和核验拆为三个场景;叫号页使用当前批次、下批候选和固定底部主操作,不做缩小版后台表格 | -| Batch Action Bar | Waitwhile 公开支持多选后批量 Serve、Alert、No Show 等 | 借鉴批量汇总,但本项目由服务端按固定 `N` 选择队首连续号码,并增加原子批次和模拟硬件结果语义 | +| Batch Action Bar | Waitwhile 公开支持多选后批量 Serve、Alert、No Show 等 | 借鉴批量汇总,但本项目由服务端按号码数或人数目标选择队首连续前缀,并增加原子批次和模拟硬件结果语义 | | Call Takeover | Qmatic/Waitwhile 公示屏均让最新叫号成为主区域,并保留最近历史 | 大屏最新批次持续占主视觉,最近 3–5 批作为防漏读历史,不用瞬时 toast | | Live Ops Strip | Waitwhile Analytics 与 Qmatic Operations/BI 区分实时运营和历史分析 | 管理端实时页只放可驱动当下动作的队列/设备异常,历史趋势另页分析 | | Offline Trust Banner | Wavetec 的低连接指导强调显式离线、待同步/失败和设备健康 | 中心服务是唯一写入权威;所有端显示数据新鲜度,离线时停写且禁止伪实时 | | Hardware Receipt Tray | Qmatic/Wavetec 可核验多硬件闭环,但未公开完整局部失败 UI | 本项目自定义“业务结果 + 各通道 ACK + 单通道重试”模式 | -公开资料边界同样重要:Waitwhile 可核验批量操作,但不能证明固定 `N` 队首选号、事务原子性、并发冲突或硬件 ACK;Qmatic 可核验单票叫号、转移、过号和多硬件,但未公开多人原子批量叫号;Wavetec 可核验硬件/低连接能力,未公开本项目所需的完整部分失败界面。因此这些能力必须按本文自有模式设计和 POC,不能用厂商营销图替代验收。 +公开资料边界同样重要:Waitwhile 可核验批量操作,但不能证明本项目的连续 FIFO 人数选择、事务原子性、并发冲突或硬件 ACK;Qmatic 可核验单票叫号、转移、过号和多硬件,但未公开多人原子批量叫号;Wavetec 可核验硬件/低连接能力,未公开本项目所需的完整部分失败界面。因此这些能力必须按本文自有模式设计和 POC,不能用厂商营销图替代验收。 设计类本机 skill 的采用边界: @@ -493,8 +493,8 @@ MVP 只设计以上四种状态。[P1] 确有现场需求时,再拆分“仅 ### 12.1 核心原则 - `CallBatch` 是一个原子业务对象,不是前端循环调用单号按钮; -- 一人一号,每张有效等待票计为一人;同时显示项目固定 `N` 与本批实际号码数量; -- MVP 严格 FIFO、不跳号、不手选;队列不足 `N` 时叫出全部剩余号码; +- 每张有效等待票同时显示号码与 `party_size`;批次汇总分别显示实际号码数和实际人数; +- MVP 严格 FIFO、不跳号、不手选、不拆号;人数模式允许因下一个号码放不下而低于目标值; - 批次内所有票号同时出现,禁止逐号揭示、stagger、翻牌或滚动数字; - UI 不使用乐观成功;按钮立即响应,但只能在服务端事务成功后写“已生效”。 @@ -503,10 +503,10 @@ MVP 只设计以上四种状态。[P1] 确有现场需求时,再拆分“仅 稳定结构: 1. 顶部:项目状态、资源/入口、数据更新时间; -2. 中部:下一批候选号码,显示票号、等待时长和特殊标记; -3. 批量条:项目固定 `N`、本批实际号码数和队列剩余数,但不只靠颜色; +2. 中部:下一批候选号码,显示票号、同行人数、等待时长和特殊标记; +3. 操作区:按项目显示号码栏、人数栏或两栏,各栏展示默认值和防误触上限; 4. 批次预览:系统自动选出的队首连续号码;员工不能在日常流程修改成员; -5. 底部固定操作区:唯一主按钮“叫下一批(5 个号码)”; +5. 提交结果明确显示“X 个号码 · Y 人”,不把目标值冒充实际人数; 6. 提交后:业务结果和各设备回执在原位置持续展示。 ### 12.3 确认策略 @@ -519,7 +519,7 @@ MVP 只设计以上四种状态。[P1] 确有现场需求时,再拆分“仅 风险批次 [P1]: -- 临时修改 `N`、越过 FIFO、手工选人、撤回/重排时弹确认; +- 越过 FIFO、手工选人、撤回/重排时弹确认;正常输入由各模式的防误触上限保护; - 弹层必须说明影响对象、人数、原因字段和审计后果; - 危险确认按钮使用具体动词,如“确认跳过并叫号”,不用“确定”。 @@ -543,19 +543,19 @@ MVP 结果卡只显示“第 18 批已生效:5 个号码”和模拟适配器 游客状态页按以下顺序组织: 1. **项目与当前状态**:明确项目名称,以及排队中、请留意、已叫到、已过号等; -2. **我的号码与下一步**:票号、入口/通道、行动指令; +2. **我的号码与下一步**:票号、本号人数、入口/通道、行动指令; 3. **预计时间**:区间、可信度、更新时间; -4. **队列进展**:前方约多少个号码、当前叫号; +4. **队列进展**:前方号码数、前方实际人数、当前叫号; 5. **帮助与规则**:迟到、取消、暂停、隐私和联系方式。 页面不先展示全局统计,不把“景区介绍”放在个人状态之前。 -### 13.2 游客自助取号 [P1] +### 13.2 游客自助取号 [MVP] -MVP 由员工代取号;游客端先交付私密状态页。P1 启用自助取号时,表单使用单列、渐进披露: +游客自助取号表单使用单列、渐进披露,并与员工取号共用服务端规则: -- 先选项目/资源,再填写一人一号所需字段; -- 手机号必填;姓氏和称谓选填,称谓未选择时默认“游客”;不展示或采集法定性别字段; +- 先选项目/资源,再填写同行人数与联系人字段; +- 手机号和同行人数必填;人数预填项目最小值并受项目范围约束;姓氏和称谓选填,不采集法定性别; - 每个字段旁说明用途,隐私说明在提交前可见; - 不用 placeholder 代替 label; - 表单错误在提交后集中摘要,并在字段旁给出相同的具体修正方式; @@ -570,7 +570,7 @@ MVP 由员工代取号;游客端先交付私密状态页。P1 启用自助取 - 大号票号:“A123”; - 主预测:“预计 25–35 分钟叫到”; - 可信度/异常:“参考性中 · 14:32 更新”; -- 队列位置:“前方约 8 个号码”; +- 队列位置:“前方 8 个号码 · 共 21 人”; - 下一步:“可暂时离开,建议在叫号前 X 分钟返回”(X 为项目配置,不在界面写死)。 预计等待时间: @@ -618,7 +618,7 @@ MVP 由员工代取号;游客端先交付私密状态页。P1 启用自助取 - 支持紧凑列表,但每行仍保证 48px 触控目标; - 票号、等待时长、特殊标记位置稳定; - 当前操作对象用边框、背景、勾选和文字同时标识; -- 日常叫号由系统按当前项目固定 `N` 自动选择队首连续号码,员工只核对“本批几个号码”;手工多选仅在后续授权异常处理中出现; +- 日常叫号由系统按员工选择的号码/人数模式自动选择队首连续前缀;员工同时核对每号人数及本批实际号码数/人数,手工多选仅在后续授权异常处理中出现; - 列表发生实时变化时,保留员工当前选择并显示冲突条,不静默重排; - 高密度不等于小字,正文以 17–18px 为现场起点。 @@ -765,7 +765,7 @@ MVP 由员工代取号;游客端先交付私密状态页。P1 启用自助取 ### 16.3 项目与预计时间设置 -每个项目使用一张简单表单:项目名称、票号前缀、营业状态、固定批量人数 `N`、宽限时间,以及“连续放行/固定批次”二选一的预计时间模板和对应参数。两种模板都属于首期能力。 +每个项目使用一张简单表单:项目名称、票号前缀、营业状态、单号最少/最多人数、支持的叫号方式、号码/人数两种方式各自的默认值和防误触上限、宽限时间,以及单人预计间隔时间。单次上限只防误输入,不作为项目总容量上限。 - 保存后直接生效,并记录修改人、时间和前后值; - 页面显示上一版值,可由有权限的管理员恢复; @@ -1081,7 +1081,7 @@ flowchart LR - 同一手机号可以关联多个活动号码;重复提示只用于防误操作,员工确认后继续并留审计; - 大屏、广播只使用每日可读票号;该票号不能单独查询个人状态; - 姓氏/称谓不公开,首期不提供项目级公开例外; -- 未成年人仍使用独立的一人一号排队单,可复用监护人手机号;不在大屏建立儿童身份特征。 +- 同行人数可包含未成年人,不额外采集组内未成年人的手机号或身份特征;大屏仍只展示票号和聚合人数。 ### 22.3 建议术语 @@ -1141,8 +1141,9 @@ flowchart LR ### 24.3 批量叫号 -- [ ] 主按钮显示本批实际号码数量,批次预览同时显示项目固定 `N`; -- [ ] 批次预览显示队首连续成员、固定 `N` 和规则来源; +- [ ] 操作区只显示项目支持的模式;`BOTH` 项目必须同时显示号码栏与人数栏; +- [ ] 批次预览逐号显示 `party_size`,结果同时显示实际号码数和实际人数; +- [ ] 人数模式不超过目标、不拆号、不跳号,队首单号超过目标时给出明确错误; - [ ] 并发冲突不静默覆盖; - [ ] 所有批次成员原子出现,无逐号动效; - [ ] MVP 业务成功与模拟适配器成功/失败相互独立; diff --git a/docs/scenic-queue-system-plan.md b/docs/scenic-queue-system-plan.md index dcf401a..b9d7cc2 100644 --- a/docs/scenic-queue-system-plan.md +++ b/docs/scenic-queue-system-plan.md @@ -1,7 +1,7 @@ # 景区项目排队叫号系统:案例调研与完整规划 -> 文档状态:v1.0 已确认开发基线 -> 调研日期:2026-07-10 +> 文档状态:v1.1 已确认开发基线 +> 调研日期:2026-07-10;人数与双叫号模式修订:2026-07-15 > 当前阶段:关键产品与技术决策已确认,已获授权进入开发。 > 适用前提:绿地项目;一个景区内存在多个游玩项目,每个项目可能有多个通道、设备或服务批次。 @@ -13,30 +13,30 @@ 核心方案是: -1. 取号采用“一人一号”:每位游客对应一张 `QueueTicket`,不建立同行组或 `party_size` 规则。手机号必填,姓氏和称谓选填;称谓未填写时默认为“游客”,系统不采集法定性别。每次取号生成不可猜测的排队单 ID、可读票号和私密查询令牌。 +1. 每张 `QueueTicket` 对应一个排队号码,并保存创建后不可修改的同行人数 `party_size`。手机号和同行人数必填,姓氏和称谓选填;人数必须落在项目配置的连续范围内。每次取号生成不可猜测的排队单 ID、可读票号和私密查询令牌。 2. 同一手机号在同一项目可同时持有多个活动号码,以支持家人共用联系人手机号。发现重复时员工端必须展示脱敏的活动号码提示,经员工明确确认后方可继续,并记录审计;数据库不得设置“项目 + 手机号”的活动唯一约束。 -3. “批量叫号”建模为独立 `CallBatch`。每个项目配置固定人数 `N`,`CALL_NEXT` 严格按 FIFO 原子呼叫队首连续 `N` 个有效等待号码;不足 `N` 时呼叫全部剩余号码,不手选、不跳号。 -4. 预计等待时间只配置“单个号码预计间隔时间”,每个号码按该间隔累加计算;界面显示易懂的预计时间和更新时间,不建设通用预测平台。 +3. “批量叫号”建模为独立 `CallBatch`。项目可支持按号码、按人数或两种方式;两种方式均严格 FIFO、连续、不手选、不跳号。按人数时选择合计人数不超过目标的最长队首前缀,不拆分号码。 +4. 预计等待时间只配置“单人预计间隔时间”,按本号前方所有号码的实际人数累加,不包含本号自身同行人数;界面显示易懂的预计时间和更新时间,不建设通用预测平台。 5. PostgreSQL 是唯一事实源,中心服务是唯一写入权威。景区完全断外网或客户端无法确认中心权威时,停止数字化取号与叫号,只展示最后快照并切换纸质号码、人工广播等现场预案。 6. 前端采用 React,后端采用 Go + GORM,数据库采用 PostgreSQL;保持模块化单体和单仓库,不拆分微服务。 7. 首期硬件范围只包含统一设备接口、成功/失败模拟器和测试日志,不接真实打印机、广播、LED 或扫码设备;真实硬件取得型号、协议、驱动、网络拓扑及回执能力后另行立项。 -8. 推荐先以一个项目做现场试点,跑通取号—固定 `N` 叫号—游客状态页/公示屏—到场—完成—预计时间校准的完整闭环,再扩展全景区。 +8. 推荐先以一个项目做现场试点,跑通多人取号—号码/人数叫号—游客状态页/公示屏—到场—完成—预计时间校准的完整闭环,再扩展全景区。 9. 一个系统支持多个项目:所有队列、规则、票号、权限、设备和统计都带 `project_id`,但共用一套代码、数据库和部署,不为每个项目复制系统。 ## 2. 已知需求、目标与边界 ### 2.1 已确认需求 -- 员工端 H5:员工使用账号登录后只进行叫号和取号;每次叫下一批自动结束上一批,并按 FIFO 叫出固定数量的连续号码。 +- 员工端 H5:员工使用账号登录后进行取号和叫号;项目支持两种方式时同时展示号码栏与人数栏,每次叫号自动结束上一批并严格按 FIFO 选择连续号码。 - 游客端 H5:查看自身排队情况、预计等待时间和当前进度。 - 大屏 Web:实体公示屏公开展示最新叫号与队列概况;其公开投影继续使用绑定的只读地址。 - 管理端 Web:统一承载运营概览、项目规则和大屏中心;大屏中心只展示公开票号与运行状态,并可进入管理员全屏监控。账号、权限、日志和真实硬件配置仍按后续范围扩展。 -- 一人一号;手机号必填,姓氏和称谓选填,称谓默认“游客”,不采集法定性别。 +- 一个号码可绑定多名同行游客;手机号和同行人数必填,姓氏和称谓选填,称谓默认“游客”,不采集法定性别。 - 员工端、游客状态页和内部登录卡采用 390px 手机设计宽度;更窄设备缩到 100%,页面高度保持自适应。管理端和公示屏不套用此宽度。 - 同一手机号允许创建多个活动号码;重复时先脱敏提示、再由员工确认并审计。 -- 每项目配置固定批量人数 `N`,严格 FIFO 呼叫队首连续号码,队列不足时呼叫全部剩余号码。 +- 每项目配置支持的叫号方式、单号人数范围,以及号码/人数两种方式各自的默认值与防误触单次上限。 - 过号后原号码保持 `MISSED` 并失效;仍需排队时创建新号码进入队尾,原号码保留关联审计。 -- 预计等待时间统一按项目配置的单个号码间隔累加计算。 +- 预计等待时间统一按项目配置的单人间隔和本号前方实际人数累加计算。 - 员工端和大屏端预留稳定硬件接口;首期只交付接口、模拟器和测试日志。 - 中心服务是唯一写入权威,完全断网时停止数字化写入并执行人工预案。 - 员工与管理员首期使用系统内置账号密码登录,预留后续 OIDC/SSO 接口。 @@ -60,7 +60,7 @@ - 不使用机器学习、复杂滚动权重或预测平台;先用项目级简单参数计算预计等待时间。 - 不承诺“精确到某一分钟”的等待时间。 - 不把浏览器直连硬件作为唯一生产方案。 -- MVP 不开放游客远程自助取号;首期只做员工代取,游客自助取号属于 P1 独立范围。 +- 游客自助取号已按 2026-07-15 需求开放为当前实现:只允许选择正在运行的项目,使用手机号、选填姓氏/称谓和私密状态令牌;短信/微信通知仍不在范围内。 - 不拆微服务,不引入通用工作流引擎、规则 DSL、事件溯源或独立消息中间件。 - 不单独部署运营数据看板;它是管理端的一部分。 - 不做跨项目自动调度、跨项目队列迁移或多租户计费;MVP 只做一个景区内的多项目隔离。 @@ -112,7 +112,7 @@ | Qtrac | 官网有 customer or group 措辞,但不能证明 group 等于多张独立票;Auto Call 仍是逐个 | 浏览器大屏、语音、票据机/扫码可选;偏云端 | 有 API Library/Integration Hub 概述,无完整公开端点 | 采购前必须演示批量原子语义和断网行为 | | QLess | 有群组群发消息,未核验到批量改变多张票的叫号状态 | 通用 kiosk、monitor、短信/语音;偏云端 | 宣称 API 覆盖广,缺少公开端点/Webhook 细节 | 群发通知不能被当作批量叫号 | -值得关注的景区类厂商案例是 [Waitwhile 的 SUMMIT One Vanderbilt](https://waitwhile.com/case-studies/summit-one-vanderbilt/):游客扫码或由员工平板登记,填写同行人数,系统按设施吞吐召集下一组。案例数据属于厂商自报,其“设施吞吐 + 运营批次”思路可用于对照,但本项目已经确认采用“一人一号”,不采用该案例的同行组模型。 +值得关注的景区类厂商案例是 [Waitwhile 的 SUMMIT One Vanderbilt](https://waitwhile.com/case-studies/summit-one-vanderbilt/):游客扫码或由员工平板登记,填写同行人数,系统按设施吞吐召集下一组。案例数据属于厂商自报;其“单号人数 + 运营批次”思路可用于对照,本项目仍使用更简单的连续 FIFO 规则。 参考资料:[Waitwhile 多选与批量操作](https://help.waitwhile.com/en/articles/8061037-navigating-the-visits-page)、[Waitwhile 离线说明](https://help.waitwhile.com/en/articles/11603596-does-waitwhile-work-without-internet)、[Qmatic Serve View](https://docs.qmatic.io/en/staff-user-guides/user-guide--serve-view.html)、[Qmatic Queue Agent](https://docs.qmatic.io/en/service-and-branch-configuration/branch-configuration/about-branches.html)、[Qmatic Data Connect 限制](https://data-connect.docs.qmatic.io/developerguide.html)、[Wavetec 本地分支与云同步案例](https://www.wavetec.com/es/case-studies/wasl-properties-customer-experience-transformation/)、[Qtrac](https://qtrac.com/virtual-queuing/)、[QLess](https://www.qless.com/products/)。 @@ -141,8 +141,8 @@ | 队列场次 `QueueSession` | 某项目在某营业日/时段的一条运行队列 | 营业日、开闭时间、状态、单调修订号、预计时间设置 | | 服务资源 `ServiceResource` | 通道、入口、船、车辆、窗口或操作位 | 类型、容量、在线状态、所属项目 | | 游客 `Visitor` | 受保护的最小访客资料,不以手机号唯一 | 内部 ID、加密手机号、手机号 HMAC 索引、选填姓氏、选填称谓、称谓默认“游客”、留存时间 | -| 排队单 `QueueTicket` | 一位游客一次加入某队列的业务记录;一人一号 | 内部 ID、公开票号、私密查询令牌、顺序键、状态、来源、版本、可选原过号票关联 | -| 叫号批次 `CallBatch` | 一次原子批量放行 | 批次序号、资源、固定目标人数 `N`、成员票 ID、操作人、项目设置快照、状态 | +| 排队单 `QueueTicket` | 一个排队号码及其同行游客人数 | 内部 ID、公开票号、不可变 `party_size`、私密查询令牌、顺序键、状态、来源、版本、可选原过号票关联 | +| 叫号批次 `CallBatch` | 一次原子批量放行 | 批次序号、叫号方式、请求数量、实际号码数、实际人数、成员票 ID、操作人、项目设置快照、状态 | | 叫号尝试 `CallAttempt` | 初叫、重叫及其渠道结果 | 次数、时间、到期时间、语音/屏幕/通知回执 | | 预计时间设置 `WaitEstimateConfig` | 每个项目当前使用的简单参数 | 模板、平均速度或批容量/间隔、缓冲分钟、上次修改人、上一版值 | | 预计时间记录 `WaitEstimateRecord` | 只记录关键业务节点的预计值 | 排队单、取号/叫号节点、预计区间、计算时间、项目设置摘要 | @@ -201,15 +201,20 @@ MVP 只保留这四种状态。暂停、恢复和结束均记录操作人、原 - [P1] 优先队列、人工插队和配额规则不进入 MVP。 - 当日结束不批量物理删除;按状态关场并保留审计。 - [P1] 手工修改位置不进入 MVP。 -- 一人一号使每张有效等待票都计为一人;不提供人数修改、同行组合并或拆分。 -- MVP 固定严格 FIFO:每次读取项目配置的 `N`,呼叫队首连续 `N` 张有效等待票;不足 `N` 时呼叫全部剩余票,不跳号、不手选。 +- 每张排队票保存创建时确认的 `party_size`;创建后不提供修改、拆分或合并,需纠正时取消原号并重新取号。 +- MVP 固定严格 FIFO:按号码时取队首连续 N 张;按人数时取合计人数不超过目标的最长连续队首。两种方式都不跳号、不手选。 - 闭园前由管理员将项目切为 `PAUSED` 停止新取号/叫号,处理完现场后切为 `FINISHED`;复杂自动清队规则后移。 ## 6. 批量叫号:完整业务定义 -### 6.1 MVP 只有一种模式 +### 6.1 项目可配置两种叫号方式 -`CALL_NEXT`:读取当前项目配置的固定批量人数 `N`,从队首按严格 FIFO 选择连续 `N` 张有效等待票;队列不足 `N` 时选择全部剩余票。员工界面只有“叫下一批”,不输入票数、不手选、不跳号。 +`CALL_NEXT` 接收明确的 `mode` 与 `count`: + +- `TICKET`:从队首选择最多 `count` 张连续有效等待票;队列不足时选择全部剩余票。 +- `PEOPLE`:从队首选择 `party_size` 合计不超过 `count` 的最长连续前缀。不得拆号或跳过大号;若队首单号人数已超过目标,则拒绝并提示所需最小人数。 + +项目可配置只支持 `TICKET`、只支持 `PEOPLE` 或 `BOTH`。`BOTH` 时员工端同时展示两栏,不设置隐含默认模式。两种模式分别配置默认输入值和单次防误触上限;防误触上限不等同于项目容量上限。 [P1] 手选、跳号、按班次和预叫只有在试点证明必要后再单独设计。 @@ -239,20 +244,21 @@ stateDiagram-v2 1. 校验员工权限、资源归属和请求格式;若幂等键已有成功结果,直接返回原批次。 2. 先锁定 `QueueSession`/队列修订行(或用 `UPDATE ... WHERE revision = expected` 做 CAS),再校验项目状态和客户端看到的修订号;修订不符立即返回冲突。 3. 在同一串行化边界内,从队首按原始顺序读取等待票。 -4. 读取项目固定批量人数 `N`,选择队首连续 `N` 张有效等待票;不足时选择全部剩余票,并生成不可变成员快照。 +4. 校验项目是否支持请求模式及其防误触上限,按号码数或人数目标选择连续 FIFO 前缀,并生成包含模式、请求值、实际号码数、实际人数和成员的不可变快照。 5. 创建 `CallBatch`、成员记录和首次 `CallAttempt`,把成员从 `WAITING` 更新为 `CALLED`。 6. 增加队列修订号,同时写入审计和同库的“待发送事件”记录,然后提交。 7. 提交成功后再发布实时事件、通知和硬件命令。 多个员工同时叫号时,只能有一个事务在预期修订号上成功;后到请求在获得锁后会发现修订已变化,收到新的队列快照并要求重试,绝不继续使用旧候选集。 -API 语义固定为 `callNext(project_id)`;服务端读取该项目的固定批量人数 `N`。队列人数不足时叫出全部剩余号码,界面在提交前显示“将叫 X 个号码”。每个项目同一时间只保留一个尚未处理完的活动批次。 +API 语义为 `callNext(project_id, mode, count, expected_revision)`。服务端不信任客户端候选集,始终在锁内重新选择队首。响应同时返回实际号码数与实际人数;每个项目新一批叫号会自动完成上一活动批次。 -### 6.4 固定 N 与连续号码 +### 6.4 连续 FIFO 与“不超过目标” -- 每张有效等待票代表一名游客,所以批次“票数”与“人数”相等。 +- 每张有效等待票代表一个号码,`party_size` 表示该号绑定人数;批次号码数与人数必须分别统计。 - “连续”指队列顺序连续:只跳过已取消、已过号等非 `WAITING` 记录,不因票号数值存在自然缺口而回填或重排。 -- 队列剩余人数小于 `N` 时,本批呼叫全部剩余号码,不把这视为异常或欠载规则。 +- 人数模式只接受不超过目标的最长连续前缀;目标值可能未被填满,这是严格 FIFO 和不拆号的正常结果。 +- 号码模式仅限制本次号码数量,不另设这些号码的合计人数硬上限。 - 大屏仅在公开票号数值确实连续时压缩为号段;否则完整列出本批票号,业务成员始终以 ID 列表为准。 - [P1] 手选、跳号、轮椅位/舱位等多维容量约束在有明确项目需求时另行扩展。 @@ -270,13 +276,13 @@ API 语义固定为 `callNext(project_id)`;服务端读取该项目的固定 游客默认看到: - 当前状态与公开票号; -- 前方约多少个号码; +- 前方号码数与前方实际人数; - 预计等待区间,例如 25–35 分钟; - 估算状态(正常、暂停、暂不可估); - 最近更新时间; - 免责声明:现场运营、天气、设备和安全检查可能导致变化。 -一人一号后“前方号码数”就是前方人数;仍不显示“准确 29 分钟”,因为它会被游客理解为承诺。 +“前方人数”只统计本号前面的等待票 `party_size` 之和,不包含本号自身同行人数。仍不显示“准确 29 分钟”,因为它会被游客理解为承诺。 ### 7.2 MVP 简单计算模板 @@ -284,9 +290,9 @@ API 语义固定为 `callNext(project_id)`;服务端读取该项目的固定 | 模板 | 适用项目 | 简单计算 | |---|---|---| -| 单号间隔 | 所有项目 | (前方有效号码数 + 1)× 单个号码预计间隔时间 | +| 单人间隔 | 所有项目 | 前方实际人数 × 单人预计间隔时间 | -两个模板均为首期能力,并由每个项目二选一。固定批次只按 FIFO 顺序和固定 `N` 计算,不建设装箱、排班或约束求解引擎;班次、车辆、多资源联合调度放到确有项目需要时再扩展。 +首期只保留单人间隔模板,不建设装箱、排班或约束求解引擎;班次、车辆、多资源联合调度放到确有项目需要时再扩展。 预计值统一四舍五入到 5 分钟并显示为区间。项目暂停、权威队列数据陈旧或参数缺失时直接写“暂无法估算”,不叠加复杂修正系数;设备模拟失败不改变预计时间结果。 @@ -294,9 +300,7 @@ API 语义固定为 `callNext(project_id)`;服务端读取该项目的固定 管理端每个项目只保留以下字段: -- 计算模板:连续放行或固定批次; -- 单个号码预计间隔时间; -- 展示区间的固定缓冲分钟数; +- 单人预计间隔时间; - 暂停时是否隐藏预计时间; - 最后修改人、修改时间和上一版值。 @@ -317,20 +321,20 @@ MVP 保存修改日志并允许恢复上一版,不做草稿审批流、定时 | 模块 | MVP 能力 | |---|---| | 登录与工作台 | 必须账号登录;从已授权项目中选择当前项目,查看项目状态、网络和设备摘要 | -| 取号 | 一人一号;手机号必填,姓氏/称谓选填,称谓默认“游客”,不采集法定性别;重复活动号码脱敏提示,员工确认后可继续并留审计;生成票号/二维码 | -| 队列 | 等待/已叫/过号/到场列表,在账号项目授权范围内直接对应展示票号、完整手机号、姓氏/称谓、状态和取号时间;支持按手机号/票号搜索 | -| 叫号控制台 | 主按钮“叫下一批”;按项目固定 `N` 自动选择队首连续号码,提交前显示号码数量和成员预览;叫号后可到场、完成 | +| 取号 | 手机号和同行人数必填,姓氏/称谓选填;人数按项目范围校验且创建后不可修改;重复活动号码确认后可继续并留审计 | +| 队列 | 等待/已叫/过号/到场列表逐号展示 `party_size`,并在账号授权范围内展示票号、完整手机号、姓氏/称谓、状态和取号时间 | +| 叫号控制台 | 按项目展示号码栏、人数栏或两栏;提交后显示实际号码数、实际人数与成员预览,叫号后可到场、完成 | | 更多操作 | 重叫、过号、取消、过号后重新取新号、暂停/恢复;默认折叠,不占主流程;授权手选/重排放 P1 | | 设备 | 首期显示接口未启用/模拟成功/模拟失败、测试日志和降级指引 | -关键体验:危险操作二次确认;批次预览展示固定 `N`、实际号码数量和成员票号;按钮响应后显示服务端批次号,不用乐观假成功掩盖并发冲突。 +关键体验:防误触上限在服务端强制;每个候选号码旁显示同行人数;按钮响应后展示服务端实际号码数与人数,不用乐观假成功掩盖并发冲突。 ### 8.2 游客端 H5 MVP 推荐使用取号后生成的随机查询链接/二维码进入个人状态页: -- 票号、项目、取号时间; -- 当前状态、前方号码数、预计等待时间区间、更新时间; +- 票号、项目、本号人数、取号时间; +- 当前状态、前方号码数、前方人数、预计等待时间区间、更新时间; - 被叫后明显的到场窗口、位置导航和核验码; - 暂停、停运、闭园等运营公告; - 取消排队(若业务允许)与隐私说明。 @@ -351,9 +355,9 @@ MVP 推荐使用取号后生成的随机查询链接/二维码进入个人状态 | 模块 | 能力 | |---|---| | 运营看板 | 管理端首页;项目筛选、等待号码数/人数、正在叫号、近期吞吐、设备模拟异常和活动号码明细;活动号码在管理员权限内对应展示项目、票号、完整手机号、姓氏/称谓与状态 | -| 景区与项目 | 项目、营业时间、票号前缀、固定 `N`、批次间隔、项目开关 | -| 叫号设置 | 固定批量人数 `N`、宽限时间、允许重叫次数;高级重排策略不进入 MVP | -| 预计等待设置 | 每项目只填写单个号码预计间隔时间;显示上一版值和修改日志 | +| 景区与项目 | 项目、营业时间、票号前缀、单号人数范围、项目开关 | +| 叫号设置 | 支持模式、号码/人数默认值及各自防误触上限、宽限时间;高级重排策略不进入 MVP | +| 预计等待设置 | 每项目填写单人预计间隔时间;显示上一版值和修改日志 | | 账号与权限 | 账号、角色、项目范围、禁用、会话、重置、MFA/SSO 预留 | | 日志与报表 | 队列流转、员工操作、配置、登录、通知、设备日志与导出 | | 设备接口 | MVP 只配置“未启用/模拟器”、测试成功/失败和记录;注册、心跳、回执与驱动管理属于后续真实硬件专项 | @@ -477,9 +481,9 @@ MVP 先定义设备适配接口和模拟器。只有确认的真实硬件无法 以下是边界,不是最终 URL: -- `POST /staff/tickets`:员工取号;含幂等键。 +- `POST /staff/tickets`:员工取号;含不可变 `party_size` 与幂等键。 - `GET /staff/queues/{id}/snapshot`:员工队列快照和修订号。 -- `POST /staff/queues/{id}/call-batches`:执行“叫下一批”,固定 `N` 读取项目设置。 +- `POST /staff/queues/{id}/call-batches`:提交 `mode`、`count`、队列修订号与幂等键,服务端在锁内选择连续 FIFO 前缀。 - `POST /staff/call-batches/{id}/recall`:重叫。 - `POST /staff/tickets/{id}/arrive|serve|complete|miss|cancel`:明确状态动作。 - `POST /staff/tickets/{id}/replacement`:为已过号票创建新票号并排到队尾,记录新旧票关联;不修改原票状态和位置。 @@ -512,7 +516,7 @@ MVP 先定义设备适配接口和模拟器。只有确认的真实硬件无法 - 系统不采集法定性别;称谓只用于人工核对,不推断或记录性别。 - 同一手机号可对应多个同时活动的号码。重复检测只触发脱敏提示和员工确认,不阻止创建;确认结果必须审计。 - 系统身份使用内部 ID;公开身份使用票号;个人查询使用高熵令牌或验证码。 -- 未成年人仍按“一人一号”创建独立排队单,可复用监护人手机号,不额外采集儿童手机号或法定性别。 +- 同行人数可包含未成年人,不为组内每个人额外采集手机号、姓名或法定性别;联系人手机号可被多个活动号码复用。 ### 13.3 存储与展示 @@ -555,7 +559,7 @@ MVP 先定义设备适配接口和模拟器。只有确认的真实硬件无法 ### 14.2 核心指标 -- 当前/峰值等待号码数和人数(一人一号,两者数值一致); +- 当前/峰值等待号码数和人数(分别统计,不再假定两者相等); - 今日平均/最长实际等待时长; - 每小时吞吐、批次人数、容量利用率; - 过号率、取消率、过号后重新取号率、人工跳过率; @@ -587,8 +591,8 @@ MVP 先定义设备适配接口和模拟器。只有确认的真实硬件无法 - 状态机单元/性质测试:所有允许和禁止的转移、逆向操作、营业日边界。 - 并发测试:多员工同时叫下一批、客户端超时重试、重复提交、队列暂停竞态。 -- 固定 `N` 选号测试:严格 FIFO、队首连续 `N` 个有效等待号码、不跳号,队列不足时叫出全部剩余号码。 -- 预计时间测试:按单个号码间隔累加、暂停、参数缺失和五分钟取整。 +- 双模式选号测试:号码模式取队首 N 张;人数模式取不超过目标的最长连续前缀,并覆盖队首单号超目标、不拆号、不跳号和欠载结果。 +- 预计时间测试:只累加本号前方实际人数,覆盖前方 0 人、暂停、参数缺失和五分钟取整。 - 实时测试:掉线补快照、事件缺口、乱序/重复事件、大屏旧声音不重播。 - 设备接口测试:MVP 模拟成功/失败;[后续真实硬件专项] 再测网关超时、重复命令、坏载荷、离线、过期和部分设备失败。 - 端到端测试:四端共享同一排队单/批次事实。 @@ -597,17 +601,18 @@ MVP 先定义设备适配接口和模拟器。只有确认的真实硬件无法 ### 16.2 必过场景 -1. 两名员工同时叫“下一批 20 人”,成员无重复、顺序严格 FIFO 且两端得到明确结果。 +1. 两名员工基于同一修订号并发叫号,只有一方成功,成员无重复、顺序严格 FIFO 且两端得到明确结果。 2. 服务端已成功、员工手机超时后重试,不生成第二批。 3. 模拟语音适配器失败但公示屏成功,业务批次保持有效;真实设备重试语义留到后续专项验收。 4. 大屏断线十分钟后恢复,只显示最新状态,不重播十分钟前语音。 5. 项目暂停后停止新叫号,游客显示暂停而不是继续倒计时。 -6. 队列只剩 7 人而项目固定 `N=20` 时,原子叫出剩余 7 个号码,不等待凑满、不跳号。 -7. 过号后创建的新号码位于队尾,且可以追溯原号码、新号码、原因、操作人和所有叫号尝试。 -8. 已登录员工只能获得其授权项目的完整手机号对应数据,管理员可获得管理范围内的活动号码明细;游客只获得本人私密状态页中的手机号尾四位,大屏和设备令牌无法获得任何手机号或管理数据。 -9. 修改项目预计时间参数后立即生效、记录前后值,并可由授权管理员恢复上一版。 -10. 营业日结束、跨午夜、时区、时钟漂移和夏令时测试不破坏票号与统计。 -11. A 项目员工、SSE 订阅、公示屏令牌和设备绑定均无法读取或操作 B 项目数据;客户端伪造 `project_id` 也被服务端拒绝并留痕。 +6. 队首人数依次为 3、4、2,按人数目标 8 时只叫前两号共 7 人;不得越过第二号再选择第三号,也不得拆号。 +7. 队首单号 5 人而目标为 4 时拒绝叫号;提高到 5 后才允许叫出该号。 +8. 过号后创建的新号码位于队尾并继承原号同行人数,且可追溯原号码、新号码、原因、操作人和所有叫号尝试。 +9. 已登录员工只能获得其授权项目的完整手机号对应数据,游客只获得本人私密状态页中的手机号尾四位,大屏和设备令牌无法获得任何手机号或管理数据。 +10. 修改项目人数范围、叫号方式或预计时间参数后立即生效并记录前后值;既有号码的 `party_size` 保持不变。 +11. 营业日结束、跨午夜、时区、时钟漂移和夏令时测试不破坏票号与人数统计。 +12. A 项目员工、SSE 订阅、公示屏令牌和设备绑定均无法读取或操作 B 项目数据;客户端伪造 `project_id` 也被服务端拒绝并留痕。 ## 17. 部署、弱网与灾备分支 @@ -635,19 +640,19 @@ MVP 以方案 A 和纸质号码、人工广播等应急预案为准。断网时 ### 阶段 0:开发基线与脚手架(约 1 周) -- 将 v1.0 已确认决策落实为领域模型、API 契约、数据库迁移和验收场景。 +- 将 v1.1 已确认决策落实为领域模型、API 契约、数据库迁移和验收场景。 - 创建 React 前端、Go + GORM 后端和 PostgreSQL 的单仓库脚手架,接通本地开发与自动化测试。 - 跟班观察普通日和高峰日,补充实际容量、网络与设备资料;这些现场输入用于校准,不改变已确认基线。 ### 阶段 1:可运行垂直切片(约 2 周) - 先跑一个试点项目,但数据库和权限从第一天包含 `project_id`。 -- 员工一人一号取号、固定 `N` 批量叫号、游客状态页、公示屏更新和管理基础配置。 +- 员工多人取号、号码/人数双模式叫号、游客状态页、公示屏更新和管理基础配置。 - PostgreSQL 事务、幂等、审计和设备模拟器从第一条链路就具备。 ### 阶段 2:MVP 完整闭环(3–4 周) -- 最小必要身份字段、重复手机号确认审计、到场/过号/新号排队尾,以及单个号码间隔的预计时间规则。 +- 最小必要身份字段、重复手机号确认审计、到场/过号/新号排队尾,以及按前方人数计算的预计时间规则。 - 多项目选择、项目权限、管理端运营看板、日志报表、实时降级、隐私与安全加固。 - 设备适配接口、成功/失败模拟器和测试日志;不加入真实设备适配器或本地网关。 @@ -667,10 +672,10 @@ MVP 以方案 A 和纸质号码、人工广播等应急预案为准。断网时 ### MVP -- 员工代取号,一人一号;手机号必填,姓氏/称谓选填,称谓默认“游客”,不采集法定性别。 +- 员工代取与游客自助取号均要求手机号和同行人数;单号人数按项目范围校验且创建后不可修改。 - 同一手机号允许多个活动号码;员工确认重复提示后继续创建并留审计。 - 一个系统支持多个项目;员工按授权选择项目,各项目独立队列、票号、参数、设备和统计。 -- 固定 `N` 批量叫下一批、严格 FIFO 连续号码、人数不足叫出剩余号码、重叫、到场、过号、过号后新号排队尾、取消、完成。 +- 项目可按号码、按人数或同时支持两种叫号;严格 FIFO、连续、不拆号、不跳号,并支持重叫、到场、过号、过号后新号排队尾、取消和完成。 - 游客私密状态页;大屏公开投影;实时事件与轮询降级。 - 简单的预计等待时间区间、暂停/停运降级。 - 管理端运营看板,以及项目/设置/账号/权限/日志/设备管理。 @@ -682,7 +687,7 @@ MVP 以方案 A 和纸质号码、人工广播等应急预案为准。断网时 ### P1 -- 游客现场扫码自助取号、短信/微信通知、票务/闸机凭证核验。 +- 短信/微信通知、票务/闸机凭证核验。 - 优先队列、多个资源/班次、高级报表和预计时间统计、多语言,以及 AAA 级/专项辅助设备等无障碍增强。 - 更多硬件驱动、设备远程诊断、离线部署增强和独立设备网关。 - 员工手选、跳号、预叫、批次撤回和多维容量约束。 @@ -699,24 +704,24 @@ MVP 以方案 A 和纸质号码、人工广播等应急预案为准。断网时 - 四类访问视图:员工 H5、游客私密状态 H5、实体只读公示大屏,以及包含运营概览、项目管理和大屏中心的 PC 管理端。 - 一个系统支持多个景区项目,所有业务通过 `project_id` 强隔离,但不为每个项目重复部署。 -- 一人一号;手机号必填,姓氏/称谓选填,称谓默认“游客”,不采集法定性别。 +- 一个号码可绑定多名同行游客;手机号与同行人数必填,姓氏/称谓选填,称谓默认“游客”,不采集法定性别。 - 同一手机号允许多个活动号码;重复时脱敏提示、员工确认并审计。 -- 每项目配置固定 `N`,严格 FIFO 呼叫队首连续号码;不足 `N` 时叫出全部剩余号码,不手选、不跳号。 +- 每项目配置单号人数范围、支持的叫号方式,以及两种方式各自的默认值和防误触单次上限;不增加独立项目总容量上限。 - 过号后原号失效;重新排队必须创建新号进入队尾,并保留新旧票关联审计。 -- 预计等待时间只保留单个号码预计间隔时间,每个号码按该规则累加计算。 +- 预计等待时间只保留单人预计间隔时间,并按本号前方实际人数计算。 - 中心服务是唯一写入权威;断网停写并切换纸质号码、人工广播等预案。 - 员工和管理员使用内置账号密码、RBAC 与项目权限,预留 OIDC/SSO。 - 前端 React,后端 Go + GORM,数据库 PostgreSQL;模块化单体、单仓库、版本化 SQL 迁移。 - 首期硬件只做统一接口、成功/失败模拟器和测试日志。 - 留存采用已确认的 30 天个人关联、90 天原始业务事件、至少 6 个月安全日志、1 年后台审计和 3 年影响评估相关记录。 -- MVP 由员工代取,游客自助取号放 P1;大屏只显示票号,不公开身份字段。 +- 员工代取和游客自助取号共用统一排队事实源;公开创建接口只返回票号、手机号尾号和私密状态令牌,大屏仍只显示票号。 - 界面显示预计等待时间区间和更新时间,不承诺精确分钟,也不展示 ETA 缩写。 ### 20.2 开发期间需要补齐的现场输入 下列输入用于参数校准、现场验收和后续集成立项,不是开始开发的阻塞项,也不得被解释为上述基线仍未确认: -- 试点项目的峰值容量、营业时间、默认 `N`、宽限时间、批次间隔和连续放行速度; +- 试点项目的峰值容量、营业时间、单号人数范围、两种叫号默认值/防误触上限、宽限时间和单人间隔; - 景区 VI、员工设备、大屏尺寸/阅读距离与网络质量; - 纸质号码、人工广播、现场负责人和恢复联网后的应急切换流程; - 未来真实硬件的型号、协议、驱动、网络拓扑、回执能力和安全边界; @@ -727,7 +732,7 @@ MVP 以方案 A 和纸质号码、人工广播等应急预案为准。断网时 | 风险 | 应对与验证 | |---|---| | 批量叫号重复/跳号 | 服务端原子事务、幂等、队列修订号、并发压测和审计回放 | -| 固定 `N` 与并发队列变更 | 锁定队列修订行后读取队首连续号码;覆盖人数不足、取消穿插和并发叫号测试 | +| 双模式与并发队列变更 | 锁定队列修订行后读取队首连续号码;覆盖人数欠载、队首超目标、取消穿插和并发叫号测试 | | 预计时间被理解成承诺 | 区间、更新时间、停运降级;管理看板观察近期平均误差 | | 大屏泄露个人信息 | 独立公开读模型、默认仅票号、自动化隐私测试 | | 浏览器/硬件不兼容 | 首期用适配器契约和模拟器隔离;后续真实硬件专项再评估本地网关与指定设备认证清单 | @@ -737,12 +742,12 @@ MVP 以方案 A 和纸质号码、人工广播等应急预案为准。断网时 ## 21. 开发启动状态 -用户已经确认本文件的关键产品与技术决策,并明确授权开始执行。开发不再等待同行组、身份字段、认证方式、断网写入、真实硬件或留存期限等旧决策分支。 +用户已经确认本文件 v1.1 的关键产品与技术决策,并明确授权执行。人数与双模式规则以本次修订为准,替代旧版“一人一号/固定 N”描述。 开发启动后按以下门槛推进: - 首个里程碑先完成单试点项目垂直切片,再扩展完整管理看板与多项目体验; -- 状态机、固定 `N` 叫号、过号新号排队尾、项目隔离和留存任务必须进入自动化验收; +- 状态机、号码/人数双模式叫号、过号新号排队尾、项目隔离和留存任务必须进入自动化验收; - 首批项目容量、峰值、网络、VI 和设备资料在现场 UAT 前核实并用于校准; - 隐私告知、展示、导出规则与已确认留存配置在正式上线前由责任人复核; - 中心服务断网停写、RPO/RTO、纸质号码与人工广播预案在试点切换前演练; diff --git a/findings.md b/findings.md index f22834a..e798938 100644 --- a/findings.md +++ b/findings.md @@ -1,5 +1,115 @@ # 景区排队叫号系统:调研发现与决策台账 +## Phase 36 员工端取号同行人数输入修复(2026-07-16) +- 用户反馈员工端取号页的“同行人数”输入框存在默认值 `1` 被固定、无法顺畅改成其他人数的问题。 +- 本轮只修复输入交互与相应回归测试,不改变项目配置的人数上下限、取号 API 契约或服务端校验。 +- 根因已定位在受控输入的 `onChange`:`Number(event.target.value) || minPartySize` 会把清空产生的空字符串立即转换并回填为最小人数 `1`,导致用户无法按“删除旧值 → 输入新值”的正常方式编辑。 +- 修复需要让前端表单草稿的 `party_size` 临时接受空字符串;只有提交时才收窄为整数并校验项目上下限,发给 API 的字段仍保持 `number`。 +- 表单草稿现使用 `number | ""`,项目切换时仍会把空值或越界值恢复到新项目最小人数;成功取号后同样恢复到项目最小人数,既修复编辑体验又保留原重置行为。 +- 定向 8 项员工页测试与 TypeScript 双配置检查通过;新增断言覆盖默认 `1` 被清空后保持为空、再输入 `3` 并正常取号的完整路径。 +- 前端全量 14 个测试文件、52 项测试及 Vite 生产构建通过;真实员工取号页已打开,首个快照处于正常的会话权限确认加载态,待页面完成渲染后继续输入验证。 +- 真实页面首次交互中,`fill("")` 后读取输入值仍为 `1`;这与新回归测试结果矛盾,当前更可能是运行中的开发服务没有加载最新模块,或页面数据初始化存在测试未覆盖的二次回填,必须继续定位后再验收。 +- `:5173` 的 Vite 转换源码已明确包含新的空字符串分支,排除旧进程/旧模块;失败后的 DOM 显示输入仍聚焦且值为 `1`,下一步改用真实键盘的全选删除路径,判断是自动化 `fill("")` 行为差异还是 React 状态仍被重置。 +- 真实键盘路径验证通过:对默认 `1` 执行全选 + Backspace 后 DOM 值稳定为 `""`,继续键入 `3` 后值为 `"3"`;用户实际编辑路径已修复,未提交真实取号数据。 +- 首次 `fill("")` 结果是数字输入框与浏览器自动化直接填充方法的差异,不代表 React 状态仍被回填;以实际键盘操作和组件回归测试作为交互验收依据。 +- 最终 DOM 显示同行人数为 `3`,项目提示仍为 1–10 人;浏览器控制台无 error/warn,修复后的取号页已保留供直接查看。 + +## Phase 35 员工端队列增量展开(2026-07-16) +- 前端 Vite 已监听 `:5173`,Go API 已监听 `:8080`,`/healthz` 返回 `status: ok`;用户要求的“启动项目”当前已满足,无需重复启动占用端口。 +- 当前队列默认切片 10 项,但点击“查看更多”会把接口已返回的全部等待号码一次性渲染;真实队列可达 200 项,会让页面高度瞬间大幅增长。 +- 最新口径按页面空间优化处理:继续复用现有队列快照,不修改 API;每次点击只把可见上限增加 10,属于按需渲染而非新增服务端分页。 +- 保留“收起”可让用户随时回到 10 项;切换项目或员工场景时也应重置为 10 项,避免把上一上下文的展开量带入新页面。 +- 增量控制不改变 FIFO 顺序、叫号 revision、轮询刷新、队列总指标或列表行布局。 +- 真实员工队列浏览器实测为 10 → 20 → 30 → 10 项;对应页面高度为 1606 → 2372 → 3079 → 1606px,证明每次只追加一页且列表继续随页面自然延展。 +- 第一次追加后“查看更多”和“收起”同时存在,第二次仍可继续追加;收起后只保留“查看更多”,不存在一次性渲染全部队列的回退。 +- 浏览器控制台无 error/warn;员工端定向 8 项、前端全量 14 个文件 52 项测试、TypeScript 检查和 Vite 生产构建全部通过。 + +## Phase 34 员工端批量按钮与指标分隔(2026-07-15) +- 最新反馈只调整员工端叫号页,不能影响正在进行的 Phase 33 管理端项目表单结构化工作,也不改叫号 API、模式参数、数量上限或禁用条件。 +- 当前“按号码叫号”和“按人数叫号”按钮均使用深绿色 `button--primary`;本轮将复用现有浅色按钮体系,避免新增独立颜色 token。 +- 可见按钮文案按用户指定改为“批量叫号”和“批量叫人”;模式容器与输入框仍保留精确业务语义,确保屏幕阅读器能区分号码数量与目标人数。 +- 移动端指标为两列布局。全局三列规则会对第 4 项应用 `nth-child(3n+1)` 并清除左边框,现有移动端规则只清除奇数项边框、没有重新给偶数项加回边框,因此“下一个号”和“下个号人数”之间缺线。 +- 修复应限定在 `max-width: 720px`:偶数项恢复左边框、奇数项继续无左边框;桌面三列首项规则保持不变。 +- 390×844 真实页面中两个批量按钮均计算为白色背景、绿色边框与绿色文字;旧按钮文案计数为 0,快速叫号仍是白字主操作。 +- “下个号人数”卡片左边框计算宽度为 1px、颜色为 `rgb(213, 222, 216)`,与“下一个号”卡片边界零间隙衔接;页面 `scrollWidth=clientWidth=390`。 +- 普通 390×844 视口截图显示叫号卡宽 366px、页面壳宽 390px,两个浅色按钮和指标中线均正常;失真的 `fullPage` 截图属于接管旧标签后的截图合成问题,不是页面布局问题。 +- 720px 边界仍为两列,第四项左边框为 1px;721px 恢复三列,第四项作为新行首项左边框为 0,证明修复未破坏桌面边框逻辑。 +- 原始参考图、当前叫号模块、修复前指标和修复后指标已放入同一次视觉对照;最新文字需求全部体现,未发现 P0-P3 遗留问题,`design-qa.md` 最终状态为 passed。 + +## Phase 33 项目表单结构化(2026-07-15) +- 用户要求的字段已存在于共享 `ProjectForm`,新建和维护也已复用同一套草稿与提交逻辑;本轮无需修改 API 或数据模型。 +- 现状把 13 个字段平铺在一个 `.settings-grid` 中,基础属性、叫号约束和展示规则没有语义层级,不利于快速扫读和维护。 +- 信息架构按用户口径固定为三组:基础信息(名称、编码、格式、状态);叫号规则(支持方式、单号最少/最多人数、按号码默认/单次上限、按人数默认/单次上限);其他规则(已体验起始数、每人预计间隔、官方提示)。 +- “单次山限”按上下文解读为“单次上限”;页面保留现有“按号码/按人数”精确业务文案,避免“批量叫号/批量叫人”在员工端产生歧义。 +- 这是保留式后台重构,沿用现有品牌绿、圆角、原生表单控件与 CSS 变量,不新增组件库或装饰动效。 +- 产品手册中的历史“项目维护”截图显示为旧版极窄布局,与当前已统一的宽屏管理端代码不一致;它仅用于确认历史问题,本轮视觉验收必须以当前运行页面为准。 +- 1440×1000 真实页面中,表单宽 1376px,三个分区均采用“左侧分组说明 + 右侧字段”结构,页面 `scrollWidth` 等于视口宽度,无水平溢出。 +- 首次桌面实测发现,成对字段中带辅助说明的“单次上限”会因 Grid 默认拉伸,将旁边默认值输入框下移约 14px;已为分组内字段增加顶部对齐约束。 +- 修正后桌面实测的单号人数、批量叫号、批量叫人三组成对输入框 Y 坐标分别完全一致,基线错位已消除。 +- 390×844 实测中,表单宽 358px,三个主分区均收敛为单列;叫号规则每行收敛为“标题说明在上、两个成对字段在下”,每列 139px,页面无水平溢出。 +- 真实项目列表已加载 3 个“维护”入口;首个维护路由为 `/admin/projects/16f75858-8a19-4f79-bd39-eeda4a86f1ad`,可用于复验共享表单在已有数据回填场景下的结构与响应式表现。 +- 真实维护页已正确回填项目名称、编码、格式、状态及全部叫号/其他规则字段;DOM 中保留三处分区和四个叫号规则分组,保存入口正常出现。 +- 维护页在 390×844 下 `scrollWidth=clientWidth=390`;1440×1000 下三处分区均为约 `285px + 1017px` 两栏,三组成对数字输入框的 Y 坐标分别完全一致,页面 `scrollWidth=clientWidth=1440`。 +- 新建页与真实维护页的浏览器控制台均无 error/warn;临时响应式视口已恢复,验收标签页已关闭。 +- 最终前端全量验证通过:14 个测试文件、52 项测试全部成功,应用与 Node TypeScript 检查通过,Vite 生产构建成功。 +- 本轮目标文件和三份项目台账的 `git diff --check` 通过;工作树中仍保留用户已有的其他未提交改动,本轮未清理、回退或暂存。 + +## Phase 32 员工端叫号指标与队列展示(2026-07-15) +- 参考图只用于确认员工端统一叫号卡的结构与品牌语言;本轮继续沿用仓库现有 Logo、绿色主色、圆角和响应式体系,不新增视觉资产。 +- 用户明确要求移除的是叫号模块内部横线:标题下方分隔线和窄屏两种叫号模式之间的横线都应删除;宽屏模式之间现有竖向分隔不属于本次删除范围。 +- 按号码叫号按钮当前使用次要描边样式,按人数叫号使用绿色主按钮;统一颜色应让两者都采用同一个绿色主按钮样式,输入框与禁用逻辑保持不变。 +- 指标目标为六项:最新叫到、本次叫号人数、下一个号、下个号人数、剩余未叫号、剩余未叫人数;不再展示最末号数和最末号预计时长。 +- 下个号人数可直接取等待队列首项 `party_size`;剩余未叫号和剩余未叫人数分别使用已有 `waiting_ticket_count`、`waiting_people_count`,不需要修改 API。 +- 队列默认仅渲染前 10 项;超过 10 项时显示可访问的“查看更多/收起”按钮。展开只改变前端可见切片,不改变队列顺序或轮询数据。 +- “不要容器、页面自适应延展”按去除队列 `ol` 的固定高度、内部滚动、边框和圆角处理;展开后由页面本身滚动,叫号吸顶与底部导航行为保持现状。 +- 队列每行改为三个等宽轨道:号数左对齐、人数居中、已等待分钟右对齐,保证移动端和宽屏都具有均匀对称的扫描节奏。 +- 仓库无 `.project-docs`,无需触发额外项目文档维护流程;当前工作树仍包含大量既有改动,本轮只增量修改员工页、员工页测试、样式和既有台账。 +- 390×844 真实页面测量:两个叫号按钮背景均为 `rgb(11, 107, 58)`;标题下边框和第二模式横向边框均为 0;旧末号指标数量为 0。 +- 390px 队列首屏恰好 10 项,三列计算宽度均为 98.664px,内容中心点约为 66/195/323px,左右间距对称;整页 `scrollWidth=clientWidth=390`。 +- 队列列表计算样式为 `max-height:none`、`overflow-y:visible`、0 边框/0 圆角,收起时 `clientHeight=scrollHeight=701px`,不存在内部滚动容器。 +- 真实“查看更多”展开后渲染接口返回的 200 项,页面高度由 1684px 增至 15104px,列表自身 `clientHeight=scrollHeight=14122px`;收起后恢复 10 项和 1684px 页面高度。 +- 移动端滚动到 `scrollY=840.5` 时页头底边和叫号吸顶层顶边同为 83px,重叠为 0;控制台无 error/warn。 +- 1280px 桌面与 720px 平板均无横向溢出、默认 10 项、列表无内部滚动且三列等宽;桌面保留模式间竖向分隔,720px 以下不出现任何模式分隔横线。 +- 参考图、390px 聚焦裁图与完整页面已在同一次对照输入中复核;按最新文字需求产生的按钮色和分隔线差异为有意变更,未发现 P0-P3 视觉问题,根级 `design-qa.md` 最终状态为 passed。 + +## Phase 31 员工端叫号模块与通知(2026-07-15) +- 本轮只调整员工端现有业务工作台,保留当前品牌、路由、叫号 API 与高密度操作语言。 +- 目标文件 `StaffPage.tsx`、`StaffPage.test.tsx` 与 `styles.css` 已有未提交改动,必须基于当前工作树增量修改,不能回退其他在途功能。 +- 当前代码已有叫号反馈状态、按号码/人数模式和 `staff-call-notice` 样式线索,需读取当前实现与工作树差异后判断哪些需求已部分落地。 +- 验收口径:通知可访问地手动关闭、每条新通知从创建起 5 秒自动移除;两种叫号入口保留业务能力但共享一个主标题和一个吸顶容器。 +- `notice` 当前是单条可空状态,叫号前会清空,成功或失败后写入;尚无超时 effect、关闭回调或关闭按钮。 +- 通知当前渲染在叫号模块之前,并通过 `.staff-call-notice` 包裹;这适合保留为瞬时状态消息,但应避免它挤占或替代固定叫号模块的位置。 +- `staff-call-actions` 内已有 `staff-call-controls`,其下按能力分别渲染两个 `staff-call-mode`,每个模式都带重复小标题;合并需求可在不改变 API 参数和输入框可访问名称的前提下重组 DOM。 +- 现有员工页测试覆盖叫号成功、设备失败、人数上限和主操作,需新增假定时器测试,并调整对统一标题与删除模式标题的断言。 +- 当前样式明确写着“叫号操作在文档流中”,`staff-call-actions` 没有 sticky/fixed;只有通知 `.staff-call-notice` 使用 `position: sticky`,与“整个叫号模块始终置顶”相反。 +- 适合的结构是新增一个统一吸顶包装层,把叫号通知和 `staff-call-actions` 作为同一层级整体置顶;通知消失后叫号卡自然回到顶部,不需要监听滚动或计算高度。 +- 大标题应由 `staff-call-actions` 内唯一的 `h2` 提供,并通过 `aria-labelledby` 命名整个操作区;两个 `staff-call-mode` 可保留为业务布局容器,但删除其可见小标题和内层卡片感。 +- 现有按钮与输入的可访问名称已经区分“按号码”和“按人数”,删除小标题不会损失操作辨识度。 +- 员工端页头本身 `position: sticky; z-index: 20`,最终覆盖层高度为桌面 68px、窄屏 64px;叫号吸顶层应使用 `z-index: 19`,并在对应断点采用 `top: 68px/64px`,避免遮挡页头。 +- `.staff-motion-scope` 为 `display: contents`,叫号布局父级没有阻断 sticky 的 overflow;吸顶包装层可纯 CSS 实现,不需要滚动事件。 +- 当前未提交差异包含本轮之前正在开发的同行人数、按人数叫号、游客取号和大屏调整;实现必须只触碰员工叫号相关片段,不能清理或格式化整个样式文件。 +- 基线全量测试中员工页相关用例通过,但已有在途游客取号/查号改动导致 `VisitorLookupPage.test.tsx` 两项断言失败;这些失败在本轮代码修改前已存在,与员工叫号需求无关。 +- 浏览器验收前确认本地 Vite `:5173` 与 Go API `:8080` 均已运行,API `/healthz` 返回 200,可直接使用真实页面和数据验证。 +- 真实员工账号登录后,DOM 只呈现一个 `h2`“按号数/人数叫号”;按号码与按人数仍作为可访问的操作分组,按钮和默认数量均正常返回。 +- 390x844 真实视口首轮测量显示统一叫号卡高 262px、吸顶包装层高 282px,页面总高 1306px,具备实际滚动空间;布局未横向溢出。 +- 首轮测量同时发现窄屏页头真实底边为 83px,而 CSS sticky 偏移仍是 64px;滚动后会有 19px 落到页头下方,需以真实页头高度修正而不是只依赖 `min-height` 声明。 +- 页头高度差异来自后置品牌 Logo 规则:窄屏 Logo 框实际为 104x66px,加上下各 8px padding 与边框后页头为 83px;`min-height: 64px` 不代表最终盒高。 +- 1280px 桌面视口同样存在偏移:Logo 框 112x64px、页头真实高 85px,而 sticky top 为 68px。最终偏移应按断点设置为桌面 85px、`<=720px` 83px。 +- 修正后 390x844 滚动至页面底部(`scrollY=462`)时,页头底边与 sticky 顶边均为 83px,重叠量为 0;统一叫号卡仍完整可见,下面队列内容正常从其后滚动。 +- 移动端视觉截图确认:唯一大标题、快速叫号、按号码数量和按人数三项操作组成一个白色大卡;两个旧小标题和两张内层小卡已消失,底部导航未被覆盖。 +- 真实叫号后通知正确显示“叫号已生效:00015”“1 个号码,共 5 人”和可访问按钮“删除叫号通知”;跨步骤复查时通知已自动消失,证明真实页面的 5 秒清理链路已运行。 +- 手动路径复验:新通知“叫号已生效:00016”出现后,删除按钮从 1 个变为 0 个,通知立即移除。 +- 自动路径精确复验:通知“叫号已生效:00017”在约 4.65 秒时仍存在,超过 5 秒后按钮计数变为 0,符合从通知创建起 5 秒自动消失的口径。 +- 1280x800 桌面滚动至最大 `scrollY=410.5` 后,页头底边和 sticky 顶边分别为 85px,重叠量为 0;页面无水平溢出,统一叫号卡宽 1176px 并保持完整可操作。 +- 桌面截图确认按号数和按人数操作共享同一卡片与标题,右侧人数操作通过一条轻量分隔线区分,不再形成第二张小卡。 +- 断点边界复测发现 720px 页头为 87px(Logo 已进入窄屏尺寸但仍使用桌面 10px padding),721px 页头为 85px;需保留三档偏移:默认 85px、621-720px 为 87px、<=620px 为 83px。 +- 三档修正后 620/621/720/721px 四个边界的 header bottom 与 sticky top 分别精确相等(83/87/87/85px),四个视口重叠量均为 0。 +- 浏览器验收结束前控制台 error/warn 均为空,临时响应式视口已恢复。 +- 最终前端全量测试为 14 个文件、51 项全部通过;修改前曾失败的两项在最终工作树中也已恢复通过,无遗留测试失败。 +- 目标文件与三份项目台账的 `git diff --check` 通过;仓库仍包含用户原有的大量未提交跨端/后端改动,本轮未清理、回退或暂存这些内容。 +- 最终源码复核确认:5 秒 effect 有 clearTimeout 清理;“删除”按钮为显式 `type=button` 且有可访问名称;叫号 API 参数、幂等键、禁用条件和两种按钮文案均未改变。 +- 新增可见文案仅为“按号数/人数叫号”和“删除”,语义明确、无重复小标题;布局未引入滚动监听、第三方依赖或额外动画。 + ## Phase 28 创建项目与项目维护表单统一(2026-07-15) - 创建页 `ProjectProfileForm` 当前只展示项目名称、项目编码和票号格式;项目维护页 `ProjectSettingsForm` 还展示项目状态、每次叫号数量、单号预计间隔和游客官方提示,两个页面的可见字段与布局不一致。 diff --git a/progress.md b/progress.md index d655d52..fc90563 100644 --- a/progress.md +++ b/progress.md @@ -1,5 +1,105 @@ # Progress Log +## 2026-07-16 - Phase 36 员工端取号同行人数输入修复 + +- 已恢复文件化计划上下文并确认工作树包含用户既有在途修改;本轮只对员工取号人数输入相关代码做增量修复。 +- 已登记复现、状态更新修正、边界校验、自动化与真实页面验收项。 +- 已定位问题为人数输入清空时被 `Number("") || minPartySize` 同步回填 `1`,现有测试直接从 1 改成 3,未覆盖先清空再输入的真实操作。 +- 已新增“默认 1 → 清空 → 输入 3”的回归步骤;旧实现按预期失败并收到值 `1`,缺陷已由自动化稳定复现。 +- 已将本地表单人数类型调整为 `number | ""`,清空时保留空值,提交时再收窄并执行整数与项目上下限校验;API 请求仍严格使用数字。 +- 员工页定向 8 项测试全部通过,TypeScript 应用与 Node 配置检查通过。 +- 前端全量 14 个文件、52 项测试全部通过,包含类型检查的 Vite 生产构建成功。 +- 已打开真实员工取号页准备交互验收;页面首个快照处于正常的访问权限确认加载态。 +- 真实页面首次清空验证仍读到 `1`;已暂停验收并登记差异,下一步核对当前 Vite 实例与浏览器加载源码,不重复同一失败操作。 +- 已确认运行中 Vite 直接返回的新模块含空字符串处理分支,页面不是旧构建;失败后输入仍为聚焦状态,准备改用全选 + 删除的真实键盘路径复测。 +- 真实键盘实测通过:默认 `1` 全选删除后保持空值,再输入 `3` 正常显示;全程未点击创建按钮、未写入取号数据。 +- 最终 DOM 与控制台复核通过:同行人数稳定显示 `3`,原项目范围提示保留,控制台无 error/warn;Phase 36 全部验收项完成。 + +## 2026-07-16 - Phase 35 员工端队列增量展开 + +- 已读取文件化计划规范并恢复上一阶段上下文;本轮只调整员工队列的前端可见数量控制。 +- 已确认项目无需重复启动:Vite `:5173` 和 API `:8080` 均在监听,API 健康检查通过。 +- 已冻结交互:默认 10 项;每次“查看更多”追加最多 10 项;展开后保留“收起”;项目或场景切换恢复 10 项。 +- 已将布尔式“全部展开”状态改为可见数量上限:每次点击增加 10,最后不足 10 时只补齐剩余项。 +- 已保留独立“收起”按钮,并将两个队列控制放入居中的紧凑操作组;列表仍由页面自然延展。 +- 已把测试队列扩展到 25 项,新增 10 → 20 → 25 → 10 的完整增量与收起断言。 +- 员工页定向 8 项测试、TypeScript 双配置检查、前端全量 14 个文件 52 项测试和生产构建全部通过。 +- 已使用本地演示员工登录真实页面,连续点击验证队列按 10 → 20 → 30 项增量展示;“收起”恢复到 10 项。 +- 页面高度随可见项从 1606px 增至 2372px、3079px,收起后恢复 1606px;列表没有内部滚动容器或一次展开全部的行为。 +- 真实页面控制台无 error/warn,员工端预览已保留;Phase 35 全部验收项完成。 + +## 2026-07-15 - Phase 34 员工端批量按钮与指标分隔 + +- 已读取截图到代码、设计上下文、设计 QA 与文件化计划规范;目标与用户结果明确,无需补问。 +- 已运行 Product Design 用户上下文预检,结果仍为无已保存上下文;继续使用当前项目、既有参考图和真实页面作为视觉依据。 +- 已检查脏工作树与当前计划,确认 Phase 33 管理端表单工作仍在进行;本轮只增量修改员工页、员工页测试、样式和验收记录。 +- 已定位按钮文案/颜色的精确 DOM,以及第 4 个指标在移动端缺少左边框的 CSS 级联原因。 +- 已将两个批量操作改为现有浅色描边按钮,并按要求更新为“批量叫号 / 批量叫人”;快速叫下一个号继续保留深绿色主操作层级。 +- 已在 720px 以下明确恢复偶数指标的左边框,使“下一个号 / 下个号人数”以及每行两项都保持完整中线。 +- 已同步更新员工端按钮文案、浅色样式和人数上限交互断言,等待执行验证。 +- 员工页定向 8 项测试与 TypeScript 双配置检查通过;前端全量 14 个文件、52 项测试和生产构建也全部通过。 +- 已在 390×844 真实页面确认两个浅色按钮、两项新文案、指标中线、无横向溢出和零控制台告警。 +- 接管旧预览标签后的全页截图出现一次合成压缩;实际 DOM 尺寸与普通视口截图均正常,已改用普通 390×844 截图作为视觉证据。 +- 已完成 720/721px 边界复测:移动两列中线存在,桌面三列新行首项无多余左边框,两端均无横向溢出。 +- 已将原始参考、当前叫号模块和指标修复前后裁图放入同一次视觉对照;无 P0-P3 遗留项。 +- 已追加 Phase 34 `design-qa.md` passed 记录并保留历史验收内容;本阶段全部验收项完成。 + +## 2026-07-15 - Phase 32 员工端叫号指标与队列展示 + +- 已读取并执行截图到代码、设计上下文、设计 QA 与文件化计划规范;目标和用户结果明确,无需补问。 +- 已打开用户参考图,确认本轮是现有员工端的局部视觉与信息架构调整,不需要生成新资产。 +- 已运行用户上下文预检;首次使用错误脚本路径失败,定位技能目录内实际路径后成功,结果为无已保存上下文。 +- 已检查工作树和员工端现有实现:按号码按钮颜色不一致、旧末号指标仍存在、队列为固定高度内部滚动容器。 +- 已冻结实现口径:两类叫号按钮统一绿色;六项指标使用既有队列字段;队列前 10 项摘要加展开/收起,并随页面自然增长。 +- 已确认仓库无 `.project-docs`,且不回退、清理或格式化用户的其他在途改动。 +- 已完成展示实现:删除叫号标题下和窄屏模式间的横线,按号码按钮改为与按人数一致的绿色主操作。 +- 已将末号数/末号预计时长替换为下个号人数、剩余未叫号、剩余未叫人数三项独立指标。 +- 已将队列改为三等分列,移除固定高度、内部滚动与外框,默认渲染 10 项并提供可访问的查看更多/收起控制。 +- 员工页定向 8 项测试与 TypeScript 双配置检查通过。 +- 前端全量 14 个测试文件、52 项测试全部通过;生产构建与构建内类型检查通过。 +- 已在 390×844 真实员工页面确认六项新指标、两个同色主按钮、叫号卡内部无横线、队列默认 10 项和三列对称布局。 +- 已实测“查看更多”把 200 项队列展开到页面流,整页高度增长且列表无内部滚动;“收起”恢复 10 项。 +- 已复测移动端吸顶零重叠、无横向溢出、控制台零告警,并通过 720px 平板和 1280px 桌面响应式测量。 +- 桌面预览刷新曾出现一次页面重新挂载超时;按浏览器故障指引保留连接并新建标签后验收恢复,未影响应用或测试结果。 +- 已将参考图、聚焦裁图和完整页面放入同一视觉对照,完成字体、间距、色彩、布局、响应式、交互和可访问性检查;无 P0-P3 遗留项。 +- 已更新根级 `design-qa.md` 为本轮 passed 结果,并保留旧任务的历史 blocked 记录。 +- Phase 32 全部验收项完成。 + +## 2026-07-15 - Phase 31 员工端叫号模块与通知 + +- 已读取前端与文件化计划技能,明确本轮是现有员工工作台的局部保留式调整。 +- 已确认仓库存在大量在途修改,目标前端文件也已被修改;后续仅做增量补丁。 +- 已登记通知 5 秒消失、手动关闭、模块合并、统一标题和滚动置顶的验收标准。 +- 发现根目录已有长期台账后,已恢复原内容并改为只追加本轮记录;业务代码未受影响。 +- 已完成 `StaffPage` 状态与测试首轮审计:确认通知尚无生命周期控制,两个叫号模式仍各自带小标题,业务调用可原样保留。 +- 已完成目标 DOM/CSS 审计:确认应新增统一 sticky 包装层,并将两种操作在一个有唯一主标题的卡片中扁平合并。 +- 已核对页头层级和响应式高度,确定吸顶模块使用 68px/64px 顶部偏移及低于页头的层级,无需 JavaScript 滚动监听。 +- 修改前基线执行误跑全量测试:员工页用例通过,另有 2 个既有游客查询页测试失败;已登记并改用精确 Vitest 文件命令。 +- 精确基线 `StaffPage.test.tsx` 共 6 项全部通过,现可进入增量实现阶段。 +- 已实现叫号通知 5 秒定时清理与“删除”按钮,手动删除会通过 effect 清理未完成定时器。 +- 已将通知和叫号操作放入统一 `.staff-call-sticky` 吸顶层,并以页头下方 68px/64px 偏移固定。 +- 已合并两种叫号操作为一个卡片,增加唯一标题“按号数/人数叫号”,删除两个内部小标题和内层卡片视觉。 +- 已新增结构、手动删除和 5 秒自动消失回归断言,等待执行验证。 +- 员工页 7 项定向测试全部通过;TypeScript 双配置检查和 Vite 生产构建通过。 +- 本地前后端服务健康,已进入真实浏览器滚动与通知验收。 +- 已使用真实员工账号进入叫号页,确认统一标题、两种操作和队列数据均正常渲染;准备进行 390px 视口滚动测量。 +- 390px 首轮视觉测量发现吸顶偏移与真实页头高度不一致(64px 对 83px);已暂停验收,先修复遮挡风险后再复测。 +- 已定位偏移根因是后置 Logo 尺寸覆盖使窄屏页头增高到 83px,不是 sticky 失效。 +- 桌面页头实测为 85px,确定用 85px/83px 两档真实偏移修复吸顶遮挡。 +- 已修正 sticky 偏移并完成 390x844 滚动复测:页头与叫号层零重叠,模块在最大滚动位置仍固定且完整可操作。 +- 已在真实队列触发通知并观察到 5 秒自动消失;手动删除将用同一次调用内的即时操作补验。 +- 真实浏览器已分别通过手动立即删除与 5 秒前后计时复验,两条通知清理路径均符合需求。 +- 已完成 1280x800 桌面最大滚动复测:吸顶零重叠、无横向溢出、两种叫号入口均可见。 +- 断点边界检查发现 621-720px 的页头比手机端多 4px,正在补充分段偏移并复测 620/621/720/721px。 +- 三档偏移已完成,620/621/720/721px 边界复测全部零重叠。 +- 浏览器最终控制台无 error/warn,响应式视口已复原并完成验收清理。 +- 最终员工页 7 项测试、TypeScript 检查和 Vite 生产构建再次全部通过。 +- 最终前端全量 14 个测试文件、51 项测试全部通过。 +- 目标差异格式检查通过;已再次确认其余大量脏工作树内容保持原状。 +- 已完成最终源码与文案预检,未发现计时器泄漏、业务调用回归、可访问性或响应式问题。 +- Phase 31 全部验收项完成。 +- 最终 `git diff --check` 再次通过;交付文件为员工页、员工页测试、全局样式及既有三份项目台账。 + ## Session: 2026-07-12(生产后端与数据库基础交接) - 用户确认:独立 PostgreSQL、Kubernetes 业务服务、全景区一次上线、峰值 3000 人同时在线;RPO ≤ 5 分钟、RTO ≤ 30 分钟作为运维目标。 @@ -479,3 +579,13 @@ - 新增无手机号输入框的号码状态页,复用游客状态卡片和自动刷新;同手机号多个活动号码仍可在号码页选择。 - 增加“查询其他手机号”返回入口,保持旧的 `/visitor/:token` 私密状态页兼容。 - 验证通过:前端 13 个测试文件共 43 项测试、TypeScript 检查、Vite 生产构建和 `git diff --check`。 +# Session: 2026-07-15(项目新建/维护表单结构化) + +- 确认新建和维护已共用 `ProjectForm`,本轮只重组表单信息架构,不改动 API 和数据字段。 +- 表单已拆分为基础信息、叫号规则、其他规则三个可访问区域。 +- 叫号规则已按支持方式、单号人数、批量叫号、批量叫人四行组织,默认数量与单次上限成对展示。 +- 已更新项目页回归断言;前端 14 个测试文件共 52 项测试全部通过,TypeScript 类型检查通过。 +- 已完成 Vite 生产构建;目标文件和三份项目台账的差异格式检查通过。 +- 新建页与真实项目维护页均完成 1440×1000 和 390×844 响应式验收:三处分区、四个规则分组、既有数据回填、成对输入对齐及无横向溢出均通过。 +- 浏览器控制台无 error/warn,临时视口已恢复,Phase 33 全部验收项完成。 +- 台账收尾后再次执行目标差异格式检查,结果仍为通过。 diff --git a/scripts/smoke.py b/scripts/smoke.py index c95cd98..021330f 100755 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -153,13 +153,36 @@ def main() -> None: "smoke requires a freshly rebuilt, dedicated database with no waiting " "tickets; rebuild and seed the smoke database before running it" ) + project_config = snapshot["project"] + assert project_config["call_mode"] == "BOTH", "smoke project must expose both call modes" + party_size = min( + int(project_config["max_party_size"]), + max(int(project_config["min_party_size"]), 3), + ) phone = "139" + str(int(time.time() * 1000))[-8:] last_name = "烟测" honorific = "游客" + missing_party = client.request( + "POST", + f"/api/staff/projects/{project_id}/tickets", + {"phone": phone}, + key=str(uuid.uuid4()), + expected=422, + ) + assert error_code(missing_party) == "INVALID_PARTY_SIZE" + oversized_party = client.request( + "POST", + f"/api/staff/projects/{project_id}/tickets", + {"phone": phone, "party_size": int(project_config["max_party_size"]) + 1}, + key=str(uuid.uuid4()), + expected=422, + ) + assert error_code(oversized_party) == "INVALID_PARTY_SIZE" ticket_body = { "phone": phone, "last_name": last_name, "honorific": honorific, + "party_size": party_size, "allow_duplicate": False, } first_key = str(uuid.uuid4()) @@ -173,6 +196,7 @@ def main() -> None: first_ticket = first["ticket"] assert first_ticket["ticket_number"] assert first_ticket["public_token"] + assert first_ticket["party_size"] == party_size assert_personal_continuity( first_ticket, phone=phone, @@ -217,6 +241,9 @@ def main() -> None: "GET", f"/api/public/status/{first_ticket['public_token']}" ) assert public["ticket_number"] == first_ticket["ticket_number"] + assert public["party_size"] == party_size + assert public["people_ahead"] == 0 + assert public["estimated_wait"]["estimate_minutes"] == 0 assert_public_phone_projection(public, phone, "public status") phone_lookup = client.request( @@ -308,7 +335,19 @@ def main() -> None: assert waiting_ids == {ticket["id"] for ticket in created_tickets}, ( "dedicated smoke queue contains unexpected tickets" ) + expected_waiting_people = sum(ticket["party_size"] for ticket in created_tickets) + assert snapshot["metrics"]["waiting_ticket_count"] == len(created_tickets) + assert snapshot["metrics"]["waiting_people_count"] == expected_waiting_people + display_before_call = client.request( + "GET", f"/api/display/{args.display_token}/snapshot" + ) + assert display_before_call["waiting_ticket_count"] == len(created_tickets) + assert display_before_call["waiting_people_count"] == expected_waiting_people + assert display_before_call["experienced_people"] == max( + int(project.get("experienced_people_start") or 0), expected_waiting_people + ) for ticket in waiting_before_call: + assert ticket["party_size"] == party_size assert_personal_continuity( ticket, phone=phone, @@ -319,7 +358,7 @@ def main() -> None: stale_call = client.request( "POST", f"/api/staff/projects/{project_id}/call-next", - {"expected_revision": max(0, int(snapshot["revision"]) - 1), "count": batch_size}, + {"expected_revision": max(0, int(snapshot["revision"]) - 1), "mode": "PEOPLE", "count": batch_size * party_size + party_size - 1}, key=str(uuid.uuid4()), expected=409, ) @@ -329,7 +368,8 @@ def main() -> None: ticket["id"] for ticket in waiting_before_call[:batch_size] ] call_key = str(uuid.uuid4()) - call_body = {"expected_revision": int(snapshot["revision"]), "count": batch_size} + people_target = batch_size * party_size + party_size - 1 + call_body = {"expected_revision": int(snapshot["revision"]), "mode": "PEOPLE", "count": people_target} called = client.request( "POST", f"/api/staff/projects/{project_id}/call-next", @@ -340,6 +380,12 @@ def main() -> None: assert [ticket["id"] for ticket in called_tickets] == expected_called_ids assert len(called_tickets) == min(batch_size, len(waiting_before_call)) assert called_tickets, "call-next must call at least one waiting ticket" + assert called["batch"]["call_mode"] == "PEOPLE" + assert called["batch"]["requested_count"] == people_target + assert called["batch"]["ticket_count"] == batch_size + assert called["batch"]["people_count"] == batch_size * party_size + assert called["batch"]["people_count"] <= people_target + assert called["batch"]["people_count"] + waiting_before_call[batch_size]["party_size"] > people_target for ticket in called_tickets: assert_personal_continuity( ticket, @@ -375,6 +421,8 @@ def main() -> None: assert [ticket["id"] for ticket in current_batch["tickets"]] == ( expected_called_ids ) + assert current_batch["call_mode"] == "PEOPLE" + assert current_batch["people_count"] == batch_size * party_size for ticket in current_batch["tickets"]: assert_personal_continuity( ticket, @@ -387,16 +435,21 @@ def main() -> None: second_called = client.request( "POST", f"/api/staff/projects/{project_id}/call-next", - {"expected_revision": int(called["revision"]), "count": 1}, + {"expected_revision": int(called["revision"]), "mode": "TICKET", "count": 1}, key=str(uuid.uuid4()), ) assert second_called["batch"]["id"] != called["batch"]["id"] assert len(second_called["batch"]["tickets"]) == 1 + assert second_called["batch"]["call_mode"] == "TICKET" + assert second_called["batch"]["ticket_count"] == 1 + assert second_called["batch"]["people_count"] == party_size final_staff_snapshot = client.request( "GET", f"/api/staff/projects/{project_id}/queue" ) assert final_staff_snapshot["current_batch"]["id"] == second_called["batch"]["id"] assert final_staff_snapshot["metrics"]["waiting_count"] == 0 + assert final_staff_snapshot["metrics"]["waiting_ticket_count"] == 0 + assert final_staff_snapshot["metrics"]["waiting_people_count"] == 0 completed_public = client.request( "GET", f"/api/public/status/{first_ticket['public_token']}" ) @@ -405,6 +458,9 @@ def main() -> None: display = client.request( "GET", f"/api/display/{args.display_token}/snapshot" ) + assert display["experienced_people"] == max( + int(project_config.get("experienced_people_start") or 0), expected_waiting_people + ) serialized_display = json.dumps(display, ensure_ascii=False) display_keys = mapping_keys(display) assert "honorific" not in display_keys @@ -426,6 +482,11 @@ def main() -> None: overview = client.request("GET", "/api/admin/overview") project = next(item for item in overview["projects"] if item["id"] == project_id) assert project["waiting_count"] == 0 + assert project["waiting_ticket_count"] == 0 + assert project["waiting_people_count"] == 0 + assert project["experienced_people"] == max( + int(project_config.get("experienced_people_start") or 0), expected_waiting_people + ) current_admin_ticket = ticket_with_number( overview.get("active_tickets"), second_called["batch"]["tickets"][0]["ticket_number"], @@ -440,6 +501,36 @@ def main() -> None: source="admin active_tickets after automatic rotation", ) + public_projects = client.request("GET", "/api/public/projects") + assert any(item.get("id") == project_id for item in public_projects.get("projects", [])) + public_phone = "137" + str(int(time.time() * 1000))[-8:] + public_party_size = min(int(project_config["max_party_size"]), party_size + 1) + public_body = {"phone": public_phone, "last_name": "游客", "honorific": "游客", "party_size": public_party_size} + public_key = str(uuid.uuid4()) + public_create = client.request( + "POST", + f"/api/public/projects/{project_id}/tickets", + public_body, + key=public_key, + expected=201, + ) + public_ticket = public_create["ticket"] + assert public_ticket["ticket_number"] + assert public_ticket["party_size"] == public_party_size + assert public_ticket["phone_last4"] == public_phone[-4:] + public_keys = mapping_keys(public_ticket) + assert "phone" not in public_keys + assert "last_name" not in public_keys + assert "honorific" not in public_keys + public_replay = client.request( + "POST", + f"/api/public/projects/{project_id}/tickets", + public_body, + key=public_key, + expected=201, + ) + assert public_replay["ticket"]["id"] == public_ticket["id"] + print( json.dumps( { diff --git a/server/README.md b/server/README.md index a96c187..ef2fa5e 100644 --- a/server/README.md +++ b/server/README.md @@ -31,10 +31,26 @@ seed command against production. `POST /api/staff/projects/{id}/tickets` and `POST /api/staff/projects/{id}/call-next` require an `Idempotency-Key` header. +Ticket creation requires an immutable integer `party_size` inside the project's +configured `min_party_size` / `max_party_size` range. A call-next request uses +`{ "expected_revision": 12, "mode": "TICKET|PEOPLE", "count": 5 }`. +`TICKET` selects up to `count` consecutive FIFO tickets. `PEOPLE` selects the +longest consecutive FIFO prefix whose total party size does not exceed `count`; +it never splits or skips a ticket and rejects when the first ticket alone is +larger than the requested target. Each mode has a separate project-level +anti-mistouch maximum. + If a phone already has active tickets, ticket creation returns `DUPLICATE_PHONE`; repeat with the same request body except `allow_duplicate: true` and a new idempotency key after the employee confirms. +`GET /api/public/projects` returns projects currently open for visitor +self-service, including each project's allowed party-size range. +`POST /api/public/projects/{id}/tickets` uses the same ticket +validation, queue locking and idempotency rules as the staff ticket flow, but +returns only the public ticket projection and a private status token. Public +ticket creation is rate-limited and audited as `PUBLIC_TICKET_CREATED`. + `POST /api/public/status/search` is a temporary non-production operational-test endpoint. It accepts `{ "phone": "..." }` and returns all current active tickets associated with that phone. It is disabled when `APP_ENV=production`; @@ -50,6 +66,7 @@ go vet ./... From the repository root, `make test-db` creates a disposable PostgreSQL database and runs migration/maintenance integration tests. -Unit tests cover ETA, phone normalization, authenticated encryption, token -hashing and password hashing. PostgreSQL integration tests run through +Unit tests cover FIFO ticket/people selection, people-ahead ETA, phone +normalization, authenticated encryption, token hashing and password hashing. +PostgreSQL integration tests run through `make test-db` against a disposable database. diff --git a/server/cmd/seed/main.go b/server/cmd/seed/main.go index eb48f0d..0583a8c 100644 --- a/server/cmd/seed/main.go +++ b/server/cmd/seed/main.go @@ -20,11 +20,12 @@ import ( ) type projectSeed struct { - Code, Name, Status, Prefix, ETAMode, DeviceMode, DisplayToken string - BatchSize, Grace, BatchInterval, Buffer int - Rate float64 - Tickets []ticketSeed - SessionStatus string + Code, Name, Status, Prefix, ETAMode, DeviceMode, DisplayToken, CallMode string + BatchSize, MaxTicketCall, DefaultPeopleCall, MaxPeopleCall int + MinPartySize, MaxPartySize, Grace, BatchInterval, Buffer int + Rate float64 + Tickets []ticketSeed + SessionStatus string } type ticketSeed struct { @@ -86,7 +87,8 @@ func main() { if index == 2 && user.Role == model.RoleStaff { continue } - if err := tx.Create(&model.UserProject{UserID: user.ID, ProjectID: project.ID, CreatedAt: now}).Error; err != nil { + assignment := model.UserProject{UserID: user.ID, ProjectID: project.ID, CreatedAt: now} + if err := tx.Where("user_id = ? AND project_id = ?", user.ID, project.ID).FirstOrCreate(&assignment).Error; err != nil { return err } } @@ -112,15 +114,15 @@ func main() { func demoProjects() []projectSeed { projects := []projectSeed{ - {Code: envOr("PROJECT_CODE", "DEMO"), Name: envOr("PROJECT_NAME", "云栖观光车"), Status: model.ProjectRunning, Prefix: "A", ETAMode: model.ETAFixedBatch, DeviceMode: "SUCCESS", DisplayToken: envOr("DEMO_DISPLAY_TOKEN", "demo-display-yunqi"), BatchSize: 5, Grace: 5, BatchInterval: 480, Buffer: 3, Rate: 2, SessionStatus: "RUNNING", Tickets: []ticketSeed{ + {Code: envOr("PROJECT_CODE", "DEMO"), Name: envOr("PROJECT_NAME", "云栖观光车"), Status: model.ProjectRunning, Prefix: "A", ETAMode: model.ETAFixedBatch, DeviceMode: "SUCCESS", DisplayToken: envOr("DEMO_DISPLAY_TOKEN", "demo-display-yunqi"), CallMode: model.CallModeBoth, BatchSize: 5, MaxTicketCall: 20, DefaultPeopleCall: 10, MaxPeopleCall: 50, MinPartySize: 1, MaxPartySize: 10, Grace: 5, BatchInterval: 480, Buffer: 3, Rate: 2, SessionStatus: "RUNNING", Tickets: []ticketSeed{ {1, "13800001001", "张", "先生", model.TicketCompleted, 95, 1}, {2, "13800001002", "李", "女士", model.TicketCompleted, 93, 1}, {3, "13800001003", "王", "先生", model.TicketCompleted, 91, 1}, {4, "13800001004", "赵", "女士", model.TicketCompleted, 89, 1}, {5, "13800001005", "陈", "先生", model.TicketCompleted, 87, 1}, {6, "13800001006", "刘", "女士", model.TicketMissed, 72, 2}, {7, "13800001007", "杨", "先生", model.TicketCalled, 18, 2}, {8, "13800001008", "黄", "女士", model.TicketCalled, 17, 2}, {9, "13800001009", "周", "先生", model.TicketCalled, 16, 2}, {10, "13800001010", "吴", "女士", model.TicketArrived, 15, 2}, {11, "13800001011", "徐", "先生", model.TicketWaiting, 14, 0}, {12, "13800001012", "孙", "女士", model.TicketWaiting, 13, 0}, {13, "13800001013", "胡", "先生", model.TicketWaiting, 12, 0}, {14, "13800001014", "朱", "女士", model.TicketWaiting, 11, 0}, {15, "13800001015", "高", "先生", model.TicketWaiting, 10, 0}, {16, "13800001016", "林", "女士", model.TicketWaiting, 9, 0}, {17, "13800001017", "何", "先生", model.TicketWaiting, 8, 0}, {18, "13800001018", "郭", "女士", model.TicketWaiting, 7, 0}, {19, "13800001019", "马", "先生", model.TicketWaiting, 6, 0}, {20, "13800001020", "罗", "女士", model.TicketWaiting, 5, 0}, {21, "13800001021", "梁", "游客", model.TicketCanceled, 4, 0}, }}, - {Code: "RAFT", Name: "峡谷漂流", Status: model.ProjectPaused, Prefix: "B", ETAMode: model.ETAContinuous, DeviceMode: "FAILURE", DisplayToken: envOr("RAFT_DISPLAY_TOKEN", "demo-display-raft"), BatchSize: 4, Grace: 8, BatchInterval: 600, Buffer: 5, Rate: 1.5, SessionStatus: "PAUSED", Tickets: []ticketSeed{ + {Code: "RAFT", Name: "峡谷漂流", Status: model.ProjectPaused, Prefix: "B", ETAMode: model.ETAContinuous, DeviceMode: "FAILURE", DisplayToken: envOr("RAFT_DISPLAY_TOKEN", "demo-display-raft"), CallMode: model.CallModePeople, BatchSize: 4, MaxTicketCall: 20, DefaultPeopleCall: 8, MaxPeopleCall: 24, MinPartySize: 1, MaxPartySize: 6, Grace: 8, BatchInterval: 600, Buffer: 5, Rate: 1.5, SessionStatus: "PAUSED", Tickets: []ticketSeed{ {1, "13900002001", "郑", "女士", model.TicketWaiting, 35, 0}, {2, "13900002002", "谢", "先生", model.TicketWaiting, 30, 0}, {3, "13900002003", "宋", "女士", model.TicketWaiting, 25, 0}, {4, "13900002004", "唐", "先生", model.TicketWaiting, 20, 0}, {5, "13900002005", "许", "女士", model.TicketWaiting, 15, 0}, {6, "13900002006", "韩", "先生", model.TicketWaiting, 10, 0}, }}, - {Code: "CABLE", Name: "云顶索道", Status: model.ProjectNotOpen, Prefix: "C", ETAMode: model.ETAFixedBatch, DeviceMode: "DISABLED", DisplayToken: envOr("CABLE_DISPLAY_TOKEN", "demo-display-cable"), BatchSize: 8, Grace: 5, BatchInterval: 720, Buffer: 5, Rate: 2, SessionStatus: ""}, + {Code: "CABLE", Name: "云顶索道", Status: model.ProjectNotOpen, Prefix: "C", ETAMode: model.ETAFixedBatch, DeviceMode: "DISABLED", DisplayToken: envOr("CABLE_DISPLAY_TOKEN", "demo-display-cable"), CallMode: model.CallModeTicket, BatchSize: 8, MaxTicketCall: 16, DefaultPeopleCall: 8, MaxPeopleCall: 32, MinPartySize: 1, MaxPartySize: 4, Grace: 5, BatchInterval: 720, Buffer: 5, Rate: 2, SessionStatus: ""}, } if strings.EqualFold(envOr("SEED_MODE", "demo"), "smoke") { projects[0].Tickets = nil @@ -176,7 +178,7 @@ func resetProject(tx *gorm.DB, spec projectSeed, now time.Time) (model.Project, "DELETE FROM idempotency_keys WHERE project_id = ?", "DELETE FROM audit_entries WHERE project_id = ?", "DELETE FROM device_simulations WHERE project_id = ?", "DELETE FROM call_batch_tickets WHERE project_id = ?", "DELETE FROM call_batches WHERE project_id = ?", "DELETE FROM queue_tickets WHERE project_id = ?", - "DELETE FROM queue_sessions WHERE project_id = ?", "DELETE FROM user_projects WHERE project_id = ?", + "DELETE FROM queue_sessions WHERE project_id = ?", } { if err := tx.Exec(statement, project.ID).Error; err != nil { return project, err @@ -185,6 +187,9 @@ func resetProject(tx *gorm.DB, spec projectSeed, now time.Time) (model.Project, hash := security.HashToken(spec.DisplayToken) project.Code, project.Name, project.Status, project.Timezone, project.TicketPrefix = spec.Code, spec.Name, spec.Status, "Asia/Shanghai", spec.Prefix project.CallBatchSize, project.GracePeriodMinutes, project.ETAMode = spec.BatchSize, spec.Grace, spec.ETAMode + project.CallMode, project.MaxCallTicketCount = spec.CallMode, spec.MaxTicketCall + project.DefaultCallPeopleCount, project.MaxCallPeopleCount = spec.DefaultPeopleCall, spec.MaxPeopleCall + project.MinPartySize, project.MaxPartySize = spec.MinPartySize, spec.MaxPartySize project.AverageBatchIntervalSeconds, project.ContinuousRatePerMinute, project.ETABufferMinutes = spec.BatchInterval, spec.Rate, spec.Buffer if spec.ETAMode == model.ETAContinuous { project.ETAIntervalSeconds = max(1, int(60/spec.Rate)) @@ -192,7 +197,13 @@ func resetProject(tx *gorm.DB, spec projectSeed, now time.Time) (model.Project, project.ETAIntervalSeconds = max(1, spec.BatchInterval/spec.BatchSize) } project.VisitorNotice = model.DefaultVisitorNotice - project.DisplayTokenHash, project.DeviceSimulationMode, project.UpdatedAt = &hash, spec.DeviceMode, now + // Existing display bindings are operational configuration and survive a + // queue-data rebuild. Newly created demo projects still receive the stable + // seed token so local and smoke environments remain reproducible. + if err == gorm.ErrRecordNotFound || project.DisplayTokenHash == nil { + project.DisplayTokenHash = &hash + } + project.DeviceSimulationMode, project.UpdatedAt = spec.DeviceMode, now if err == gorm.ErrRecordNotFound { err = tx.Create(&project).Error } else { @@ -214,6 +225,7 @@ func seedQueue(tx *gorm.DB, cipher *security.Cipher, project model.Project, crea } byBatch := map[int][]model.QueueTicket{} visitors := map[string]string{} + totalPeople := 0 for _, item := range spec.Tickets { joined := now.Add(-time.Duration(item.MinutesAgo) * time.Minute) token := fmt.Sprintf("demo-visitor-%s-%03d", strings.ToLower(project.Code), item.Number) @@ -230,7 +242,9 @@ func seedQueue(tx *gorm.DB, cipher *security.Cipher, project model.Project, crea return nil, err } phoneDigest := cipher.Digest(item.Phone) - ticket := model.QueueTicket{ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, TicketNumber: item.Number, DisplayNumber: displayNumber, PublicTokenHash: security.HashToken(token), PhoneCiphertext: phoneCipher, PhoneNonce: phoneNonce, PhoneHMAC: &phoneDigest, LastNameCiphertext: lastCipher, LastNameNonce: lastNonce, Honorific: item.Honorific, Status: item.Status, JoinedAt: joined, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: creator, CreatedAt: joined, UpdatedAt: now} + partySize := spec.MinPartySize + (item.Number-1)%(spec.MaxPartySize-spec.MinPartySize+1) + totalPeople += partySize + ticket := model.QueueTicket{ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, TicketNumber: item.Number, DisplayNumber: displayNumber, PartySize: partySize, PublicTokenHash: security.HashToken(token), PhoneCiphertext: phoneCipher, PhoneNonce: phoneNonce, PhoneHMAC: &phoneDigest, LastNameCiphertext: lastCipher, LastNameNonce: lastNonce, Honorific: item.Honorific, Status: item.Status, JoinedAt: joined, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: creator, CreatedAt: joined, UpdatedAt: now} called := joined.Add(8 * time.Minute) arrived := called.Add(3 * time.Minute) completed := called.Add(8 * time.Minute) @@ -271,7 +285,15 @@ func seedQueue(tx *gorm.DB, cipher *security.Cipher, project model.Project, crea done := calledAt.Add(12 * time.Minute) completedAt = &done } - batch := model.CallBatch{ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, BatchSequence: sequence, Revision: int64(sequence), Status: status, RequestedBy: creator, CalledAt: calledAt, CompletedAt: completedAt, CreatedAt: calledAt} + peopleCount := 0 + for _, ticket := range tickets { + peopleCount += ticket.PartySize + } + callMode, requestedCount := model.CallModeTicket, len(tickets) + if project.CallMode == model.CallModePeople { + callMode, requestedCount = model.CallModePeople, peopleCount + } + batch := model.CallBatch{ID: uuid.NewString(), ProjectID: project.ID, QueueSessionID: session.ID, BatchSequence: sequence, Revision: int64(sequence), Status: status, CallMode: callMode, RequestedCount: requestedCount, TicketCount: len(tickets), PeopleCount: peopleCount, RequestedBy: creator, CalledAt: calledAt, CompletedAt: completedAt, CreatedAt: calledAt} if err := tx.Create(&batch).Error; err != nil { return nil, err } @@ -291,7 +313,7 @@ func seedQueue(tx *gorm.DB, cipher *security.Cipher, project model.Project, crea return nil, err } } - details, _ := json.Marshal(map[string]any{"source": "demo_seed", "ticket_count": len(spec.Tickets)}) + details, _ := json.Marshal(map[string]any{"source": "demo_seed", "ticket_count": len(spec.Tickets), "people_count": totalPeople}) projectID, actorID := project.ID, creator audit := model.AuditEntry{ID: uuid.NewString(), ProjectID: &projectID, ActorUserID: &actorID, Action: "DEMO_DATA_SEEDED", EntityType: "PROJECT", EntityID: &projectID, Details: details, RequestID: "seed", UserAgent: "cmd/seed", RetainUntil: now.AddDate(1, 0, 0), CreatedAt: now} if err := tx.Create(&audit).Error; err != nil { diff --git a/server/internal/database/database_integration_test.go b/server/internal/database/database_integration_test.go index 32c367d..177a5ac 100644 --- a/server/internal/database/database_integration_test.go +++ b/server/internal/database/database_integration_test.go @@ -45,8 +45,28 @@ func TestPostgresMigrationAndMaintenanceIntegration(t *testing.T) { if err := sqlDB.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&migrationCount); err != nil { t.Fatal(err) } - if migrationCount < 8 { - t.Fatalf("migration count = %d, want at least 8", migrationCount) + if migrationCount < 12 { + t.Fatalf("migration count = %d, want at least 12", migrationCount) + } + var experiencedPeopleStartColumn string + if err := sqlDB.QueryRowContext(ctx, ` + SELECT is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'projects' AND column_name = 'experienced_people_start'`).Scan(&experiencedPeopleStartColumn); err != nil { + t.Fatal(err) + } + if experiencedPeopleStartColumn != "NO" { + t.Fatalf("experienced_people_start is_nullable = %q, want NO", experiencedPeopleStartColumn) + } + var partySizeNullable 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 = 'party_size'`).Scan(&partySizeNullable); err != nil { + t.Fatal(err) + } + if partySizeNullable != "NO" { + t.Fatalf("party_size is_nullable = %q, want NO", partySizeNullable) } var nullable string if err := sqlDB.QueryRowContext(ctx, ` diff --git a/server/internal/domain/call_selection.go b/server/internal/domain/call_selection.go new file mode 100644 index 0000000..208c2dd --- /dev/null +++ b/server/internal/domain/call_selection.go @@ -0,0 +1,47 @@ +package domain + +import ( + "errors" + + "calllinesystem/server/internal/model" +) + +var ErrFirstTicketExceedsPeopleTarget = errors.New("first ticket party size exceeds people target") + +// SelectTicketsForCall preserves the incoming FIFO order. Ticket mode selects +// up to target tickets. People mode selects the longest leading sequence whose +// total party size does not exceed target; tickets are never split or skipped. +func SelectTicketsForCall(tickets []model.QueueTicket, mode string, target int) ([]model.QueueTicket, int, error) { + if target < 1 || len(tickets) == 0 { + return nil, 0, nil + } + if mode == model.CallModeTicket { + count := min(target, len(tickets)) + selected := tickets[:count] + return selected, totalPartySize(selected), nil + } + if mode != model.CallModePeople { + return nil, 0, errors.New("unsupported call mode") + } + total := 0 + count := 0 + for _, ticket := range tickets { + if total+ticket.PartySize > target { + break + } + total += ticket.PartySize + count++ + } + if count == 0 { + return nil, 0, ErrFirstTicketExceedsPeopleTarget + } + return tickets[:count], total, nil +} + +func totalPartySize(tickets []model.QueueTicket) int { + total := 0 + for _, ticket := range tickets { + total += ticket.PartySize + } + return total +} diff --git a/server/internal/domain/domain_test.go b/server/internal/domain/domain_test.go index 0cc4522..d4e039f 100644 --- a/server/internal/domain/domain_test.go +++ b/server/internal/domain/domain_test.go @@ -7,9 +7,9 @@ import ( "calllinesystem/server/internal/model" ) -func TestETAUsesIntervalForEveryNumber(t *testing.T) { +func TestETAUsesIntervalForEveryPersonAhead(t *testing.T) { result, err := CalculateETA(ETAInput{ - PeopleAhead: 9, IntervalPerNumber: 90 * time.Second, Running: true, + PeopleAhead: 9, IntervalPerPerson: 90 * time.Second, Running: true, }) if err != nil || !result.Available { t.Fatalf("got %#v, %v", result, err) @@ -19,6 +19,32 @@ func TestETAUsesIntervalForEveryNumber(t *testing.T) { } } +func TestETAExcludesCurrentParty(t *testing.T) { + result, err := CalculateETA(ETAInput{PeopleAhead: 0, IntervalPerPerson: 90 * time.Second, Running: true}) + if err != nil || !result.Available || result.EstimateMinutes != 0 { + t.Fatalf("got %#v, %v", result, err) + } +} + +func TestSelectTicketsForCall(t *testing.T) { + tickets := []model.QueueTicket{ + {ID: "one", PartySize: 3}, + {ID: "two", PartySize: 4}, + {ID: "three", PartySize: 2}, + } + byTicket, people, err := SelectTicketsForCall(tickets, model.CallModeTicket, 2) + if err != nil || len(byTicket) != 2 || people != 7 { + t.Fatalf("ticket selection = %#v, %d, %v", byTicket, people, err) + } + byPeople, people, err := SelectTicketsForCall(tickets, model.CallModePeople, 5) + if err != nil || len(byPeople) != 1 || people != 3 || byPeople[0].ID != "one" { + t.Fatalf("people selection = %#v, %d, %v", byPeople, people, err) + } + if _, _, err := SelectTicketsForCall(tickets, model.CallModePeople, 2); err != ErrFirstTicketExceedsPeopleTarget { + t.Fatalf("expected first-ticket overflow, got %v", err) + } +} + func TestETAUnavailableWhenPaused(t *testing.T) { result, err := CalculateETA(ETAInput{Running: false}) if err != nil || result.Available || result.Reason != "queue_not_running" { @@ -26,6 +52,26 @@ func TestETAUnavailableWhenPaused(t *testing.T) { } } +func TestDisplayExperiencedPeopleUsesConfiguredStartUntilActualCountExceedsIt(t *testing.T) { + tests := []struct { + start int + actual int64 + want int64 + }{ + {start: 0, actual: 0, want: 0}, + {start: 20, actual: 0, want: 20}, + {start: 20, actual: 19, want: 20}, + {start: 20, actual: 20, want: 20}, + {start: 20, actual: 21, want: 21}, + {start: -1, actual: -3, want: 0}, + } + for _, test := range tests { + if got := DisplayExperiencedPeople(test.start, test.actual); got != test.want { + t.Fatalf("DisplayExperiencedPeople(%d, %d) = %d, want %d", test.start, test.actual, got, test.want) + } + } +} + func TestTicketRules(t *testing.T) { if !CanTransitionTicket(model.TicketCalled, model.TicketArrived) || !CanTransitionTicket(model.TicketArrived, model.TicketCompleted) { t.Fatal("expected normal transitions") diff --git a/server/internal/domain/eta.go b/server/internal/domain/eta.go index 3a74cc7..27fbbfa 100644 --- a/server/internal/domain/eta.go +++ b/server/internal/domain/eta.go @@ -8,15 +8,15 @@ import ( type ETAInput struct { PeopleAhead int - IntervalPerNumber time.Duration + IntervalPerPerson time.Duration Running bool } type ETAResult struct { Available bool `json:"available"` - EstimateMinutes int `json:"estimate_minutes,omitempty"` - MinMinutes int `json:"min_minutes,omitempty"` - MaxMinutes int `json:"max_minutes,omitempty"` + EstimateMinutes int `json:"estimate_minutes"` + MinMinutes int `json:"min_minutes"` + MaxMinutes int `json:"max_minutes"` Reason string `json:"reason,omitempty"` } @@ -27,10 +27,10 @@ func CalculateETA(input ETAInput) (ETAResult, error) { if input.PeopleAhead < 0 { return ETAResult{}, errors.New("people ahead cannot be negative") } - if input.IntervalPerNumber <= 0 { + if input.IntervalPerPerson <= 0 { return ETAResult{Available: false, Reason: "missing_interval_configuration"}, nil } - rawMinutes := float64(input.PeopleAhead+1) * input.IntervalPerNumber.Minutes() + rawMinutes := float64(input.PeopleAhead) * input.IntervalPerPerson.Minutes() estimate := roundUpFive(rawMinutes) return ETAResult{ Available: true, diff --git a/server/internal/domain/experienced_people.go b/server/internal/domain/experienced_people.go new file mode 100644 index 0000000..9e5af98 --- /dev/null +++ b/server/internal/domain/experienced_people.go @@ -0,0 +1,16 @@ +package domain + +// DisplayExperiencedPeople applies the project's configured display floor to +// the actual number of tickets issued for the current business day. +func DisplayExperiencedPeople(start int, actual int64) int64 { + if start < 0 { + start = 0 + } + if actual < 0 { + actual = 0 + } + if displayedStart := int64(start); displayedStart > actual { + return displayedStart + } + return actual +} diff --git a/server/internal/httpapi/admin.go b/server/internal/httpapi/admin.go index e8fdf6e..1ef8cf0 100644 --- a/server/internal/httpapi/admin.go +++ b/server/internal/httpapi/admin.go @@ -70,10 +70,14 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { project := model.Project{ ID: uuid.NewString(), Name: input.Name, Code: input.Code, Timezone: input.Timezone, TicketPrefix: input.TicketPrefix, Status: model.ProjectNotOpen, CallBatchSize: 1, GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch, + CallMode: model.CallModeBoth, MaxCallTicketCount: 100, + DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, + MinPartySize: 1, MaxPartySize: 10, AverageBatchIntervalSeconds: 300, ContinuousRatePerMinute: 1, ETABufferMinutes: 0, - ETAIntervalSeconds: 60, - VisitorNotice: model.DefaultVisitorNotice, - DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now, + ETAIntervalSeconds: 60, + ExperiencedPeopleStart: 0, + VisitorNotice: model.DefaultVisitorNotice, + DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now, } err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { if err := tx.Create(&project).Error; err != nil { @@ -153,6 +157,9 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request) { } views := make([]map[string]any, 0, len(users)) for _, user := range users { + if user.Username == model.PublicVisitorUsername { + continue + } views = append(views, adminUserView(user, projects[user.ID])) } writeJSON(w, http.StatusOK, map[string]any{"users": views}) @@ -256,6 +263,9 @@ func (s *Server) updateAdminUser(w http.ResponseWriter, r *http.Request) { if user.Username == model.SuperAdminUsername { return &apiError{Status: 403, Code: "PROTECTED_SUPER_ADMIN", Message: "超级管理员账号不能编辑"} } + if user.Username == model.PublicVisitorUsername { + return &apiError{Status: 403, Code: "PROTECTED_SYSTEM_USER", Message: "系统游客账号不能编辑"} + } nextRole := user.Role if input.Role != "" { nextRole = input.Role @@ -318,16 +328,20 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) { type totalRow struct { Projects int64 ActiveSessions int64 - Waiting int64 - Called int64 + WaitingTickets int64 + WaitingPeople int64 + CalledTickets int64 + CalledPeople int64 } var totals totalRow if err := s.db.WithContext(r.Context()).Raw(` SELECT (SELECT count(*) FROM projects) AS projects, (SELECT count(*) FROM queue_sessions WHERE status IN ('RUNNING','PAUSED')) AS active_sessions, - (SELECT count(*) FROM queue_tickets WHERE status = 'WAITING') AS waiting, - (SELECT count(*) FROM queue_tickets WHERE status IN ('CALLED','ARRIVED')) AS called + (SELECT count(*) FROM queue_tickets WHERE status = 'WAITING') AS waiting_tickets, + (SELECT COALESCE(sum(party_size), 0) FROM queue_tickets WHERE status = 'WAITING') AS waiting_people, + (SELECT count(*) FROM queue_tickets WHERE status IN ('CALLED','ARRIVED')) AS called_tickets, + (SELECT COALESCE(sum(party_size), 0) FROM queue_tickets WHERE status IN ('CALLED','ARRIVED')) AS called_people `).Scan(&totals).Error; err != nil { writeError(w, err) return @@ -363,15 +377,16 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) { activeTicketViews = append(activeTicketViews, view) } projectProjections := make([]map[string]any, 0, len(projects)) - var runningProjects, anomalyProjects, offlineDevices, projectedWaiting int64 + var runningProjects, anomalyProjects, offlineDevices, projectedWaitingTickets, projectedWaitingPeople int64 for _, project := range projects { - projection, anomaly, offline, waiting, err := s.adminProjectProjection(r.Context(), project) + projection, anomaly, offline, waitingTickets, waitingPeople, err := s.adminProjectProjection(r.Context(), project) if err != nil { writeError(w, err) return } projectProjections = append(projectProjections, projection) - projectedWaiting += waiting + projectedWaitingTickets += waitingTickets + projectedWaitingPeople += waitingPeople if project.Status == model.ProjectRunning { runningProjects++ } @@ -384,12 +399,15 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{ "summary": map[string]int64{ - "running_projects": runningProjects, "waiting_count": projectedWaiting, + "running_projects": runningProjects, "waiting_count": projectedWaitingTickets, + "waiting_ticket_count": projectedWaitingTickets, "waiting_people_count": projectedWaitingPeople, "anomaly_projects": anomalyProjects, "offline_devices": offlineDevices, }, "totals": map[string]int64{ "projects": totals.Projects, "active_sessions": totals.ActiveSessions, - "waiting": totals.Waiting, "called": totals.Called, + "waiting": totals.WaitingTickets, "called": totals.CalledTickets, + "waiting_ticket_count": totals.WaitingTickets, "waiting_people_count": totals.WaitingPeople, + "called_ticket_count": totals.CalledTickets, "called_people_count": totals.CalledPeople, }, "projects": projectProjections, "active_tickets": activeTicketViews, "recent_device_simulations": simulations, "server_time": s.now(), @@ -404,11 +422,11 @@ func (s *Server) adminActiveTicketView(ticket model.QueueTicket, projectName str return map[string]any{ "id": ticket.ID, "project_id": ticket.ProjectID, "project_name": projectName, "ticket_number": ticket.DisplayNumber, "phone": phone, "last_name": lastName, - "honorific": ticket.Honorific, "status": ticket.Status, "created_at": ticket.CreatedAt, + "honorific": ticket.Honorific, "party_size": ticket.PartySize, "status": ticket.Status, "created_at": ticket.CreatedAt, }, nil } -func (s *Server) adminProjectProjection(ctx context.Context, project model.Project) (map[string]any, bool, bool, int64, error) { +func (s *Server) adminProjectProjection(ctx context.Context, project model.Project) (map[string]any, bool, bool, int64, int64, error) { view := projectView(project) lastUpdated := project.UpdatedAt deviceStatus := map[string]any{ @@ -432,7 +450,7 @@ func (s *Server) adminProjectProjection(ctx context.Context, project model.Proje anomaly = true } } else if !errors.Is(simulationErr, gorm.ErrRecordNotFound) { - return nil, false, false, 0, simulationErr + return nil, false, false, 0, 0, simulationErr } if project.DeviceSimulationMode == "DISABLED" { deviceStatus = map[string]any{"mode": "DISABLED", "status": "DISABLED", "label": "设备模拟器已停用"} @@ -442,48 +460,91 @@ func (s *Server) adminProjectProjection(ctx context.Context, project model.Proje sessionErr := s.db.WithContext(ctx).Where("project_id = ? AND status IN ?", project.ID, []string{"RUNNING", "PAUSED"}). Order("business_date DESC").First(&session).Error if errors.Is(sessionErr, gorm.ErrRecordNotFound) { + experiencedPeople, err := s.displayedExperiencedPeople(ctx, project, nil) + if err != nil { + return nil, false, false, 0, 0, err + } view["waiting_count"] = int64(0) + view["waiting_ticket_count"] = int64(0) + view["waiting_people_count"] = int64(0) + view["issued_ticket_count"] = int64(0) + view["latest_ticket_number"] = nil + view["experienced_people"] = experiencedPeople view["current_batch"] = nil view["estimated_wait"] = domain.ETAResult{Available: false, Reason: "queue_not_running"} view["last_updated_at"] = lastUpdated view["device_status"] = deviceStatus - return view, anomaly, offline, 0, nil + return view, anomaly, offline, 0, 0, nil } if sessionErr != nil { - return nil, false, false, 0, sessionErr + return nil, false, false, 0, 0, sessionErr } if session.UpdatedAt.After(lastUpdated) { lastUpdated = session.UpdatedAt } - var waitingCount int64 - if err := s.db.WithContext(ctx).Model(&model.QueueTicket{}). - Where("project_id = ? AND queue_session_id = ? AND status = ?", project.ID, session.ID, model.TicketWaiting). - Count(&waitingCount).Error; err != nil { - return nil, false, false, 0, err + waiting, err := queueTotals(s.db.WithContext(ctx), project.ID, session.ID, model.TicketWaiting) + if err != nil { + return nil, false, false, 0, 0, err } currentBatch, err := s.currentDisplayBatch(ctx, project.ID, session.ID) if err != nil { - return nil, false, false, 0, err + return nil, false, false, 0, 0, err + } + var lastWaiting model.QueueTicket + lastWaitingErr := s.db.WithContext(ctx).Select("id", "party_size"). + Where("project_id = ? AND queue_session_id = ? AND status = ?", project.ID, session.ID, model.TicketWaiting). + Order("ticket_number DESC").First(&lastWaiting).Error + if lastWaitingErr != nil && !errors.Is(lastWaitingErr, gorm.ErrRecordNotFound) { + return nil, false, false, 0, 0, lastWaitingErr + } + peopleAhead := int(waiting.PeopleCount) + if lastWaitingErr == nil { + peopleAhead = max(0, peopleAhead-lastWaiting.PartySize) } estimatedWait, err := domain.CalculateETA(domain.ETAInput{ - PeopleAhead: max(0, int(waitingCount)-1), IntervalPerNumber: time.Duration(project.ETAIntervalSeconds) * time.Second, - Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && waitingCount > 0, + PeopleAhead: peopleAhead, IntervalPerPerson: time.Duration(project.ETAIntervalSeconds) * time.Second, + Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && waiting.TicketCount > 0, }) if err != nil { - return nil, false, false, 0, err + return nil, false, false, 0, 0, err } - view["waiting_count"] = waitingCount + experiencedPeople, err := s.displayedExperiencedPeople(ctx, project, &session) + if err != nil { + return nil, false, false, 0, 0, err + } + issuedTicketCount := max(0, session.NextTicketNumber-1) + var latestTicketNumber any + if issuedTicketCount > 0 { + latestTicketNumber, err = domain.DisplayNumber("", issuedTicketCount) + if err != nil { + return nil, false, false, 0, 0, err + } + } + view["waiting_count"] = waiting.TicketCount + view["waiting_ticket_count"] = waiting.TicketCount + view["waiting_people_count"] = waiting.PeopleCount + view["issued_ticket_count"] = issuedTicketCount + view["latest_ticket_number"] = latestTicketNumber + view["experienced_people"] = experiencedPeople view["current_batch"] = currentBatch view["estimated_wait"] = estimatedWait view["last_updated_at"] = lastUpdated view["device_status"] = deviceStatus - return view, anomaly, offline, waitingCount, nil + return view, anomaly, offline, waiting.TicketCount, waiting.PeopleCount, nil } type updateProjectSettingsRequest struct { Status *string `json:"status"` CallBatchSize *int `json:"call_batch_size"` + DefaultCallTicketCount *int `json:"default_call_ticket_count"` + MaxCallTicketCount *int `json:"max_call_ticket_count"` + DefaultCallPeopleCount *int `json:"default_call_people_count"` + MaxCallPeopleCount *int `json:"max_call_people_count"` + CallMode *string `json:"call_mode"` + MinPartySize *int `json:"min_party_size"` + MaxPartySize *int `json:"max_party_size"` GracePeriodMinutes *int `json:"grace_period_minutes"` + ExperiencedPeopleStart *int `json:"experienced_people_start"` ETAMode *string `json:"eta_mode"` AverageBatchIntervalSeconds *int `json:"average_batch_interval_seconds"` ContinuousRatePerMinute *float64 `json:"continuous_rate_per_minute"` @@ -516,6 +577,9 @@ func (s *Server) updateProjectSettings(w http.ResponseWriter, r *http.Request) { return mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在") } before := projectView(project) + if err := validateProjectSettingsCombination(project, updates); err != nil { + return err + } updates["updated_at"] = s.now() if err := tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(updates).Error; err != nil { return err @@ -542,11 +606,54 @@ func validateProjectSettings(input updateProjectSettingsRequest) (map[string]any } updates["status"] = value } - if input.CallBatchSize != nil { - if *input.CallBatchSize < 1 || *input.CallBatchSize > 100 { - return nil, invalidSetting("call_batch_size") + ticketDefault := input.DefaultCallTicketCount + if ticketDefault == nil { + ticketDefault = input.CallBatchSize + } else if input.CallBatchSize != nil && *input.CallBatchSize != *ticketDefault { + return nil, invalidSetting("default_call_ticket_count") + } + if ticketDefault != nil { + if *ticketDefault < 1 || *ticketDefault > 10000 { + return nil, invalidSetting("default_call_ticket_count") } - updates["call_batch_size"] = *input.CallBatchSize + updates["call_batch_size"] = *ticketDefault + } + if input.MaxCallTicketCount != nil { + if *input.MaxCallTicketCount < 1 || *input.MaxCallTicketCount > 10000 { + return nil, invalidSetting("max_call_ticket_count") + } + updates["max_call_ticket_count"] = *input.MaxCallTicketCount + } + if input.DefaultCallPeopleCount != nil { + if *input.DefaultCallPeopleCount < 1 || *input.DefaultCallPeopleCount > 10000 { + return nil, invalidSetting("default_call_people_count") + } + updates["default_call_people_count"] = *input.DefaultCallPeopleCount + } + if input.MaxCallPeopleCount != nil { + if *input.MaxCallPeopleCount < 1 || *input.MaxCallPeopleCount > 10000 { + return nil, invalidSetting("max_call_people_count") + } + updates["max_call_people_count"] = *input.MaxCallPeopleCount + } + if input.CallMode != nil { + value := strings.ToUpper(strings.TrimSpace(*input.CallMode)) + if value != model.CallModeTicket && value != model.CallModePeople && value != model.CallModeBoth { + return nil, invalidSetting("call_mode") + } + updates["call_mode"] = value + } + if input.MinPartySize != nil { + if *input.MinPartySize < 1 || *input.MinPartySize > 10000 { + return nil, invalidSetting("min_party_size") + } + updates["min_party_size"] = *input.MinPartySize + } + if input.MaxPartySize != nil { + if *input.MaxPartySize < 1 || *input.MaxPartySize > 10000 { + return nil, invalidSetting("max_party_size") + } + updates["max_party_size"] = *input.MaxPartySize } if input.GracePeriodMinutes != nil { if *input.GracePeriodMinutes < 0 || *input.GracePeriodMinutes > 120 { @@ -554,6 +661,12 @@ func validateProjectSettings(input updateProjectSettingsRequest) (map[string]any } updates["grace_period_minutes"] = *input.GracePeriodMinutes } + if input.ExperiencedPeopleStart != nil { + if *input.ExperiencedPeopleStart < 0 || *input.ExperiencedPeopleStart > 1000000000 { + return nil, invalidSetting("experienced_people_start") + } + updates["experienced_people_start"] = *input.ExperiencedPeopleStart + } if input.ETAMode != nil { value := strings.ToUpper(strings.TrimSpace(*input.ETAMode)) if value != model.ETAFixedBatch && value != model.ETAContinuous { @@ -605,6 +718,43 @@ func validateProjectSettings(input updateProjectSettingsRequest) (map[string]any return updates, nil } +func validateProjectSettingsCombination(project model.Project, updates map[string]any) error { + ticketDefault := project.CallBatchSize + maxTicketCount := project.MaxCallTicketCount + peopleDefault := project.DefaultCallPeopleCount + maxPeopleCount := project.MaxCallPeopleCount + minPartySize := project.MinPartySize + maxPartySize := project.MaxPartySize + if value, ok := updates["call_batch_size"].(int); ok { + ticketDefault = value + } + if value, ok := updates["max_call_ticket_count"].(int); ok { + maxTicketCount = value + } + if value, ok := updates["default_call_people_count"].(int); ok { + peopleDefault = value + } + if value, ok := updates["max_call_people_count"].(int); ok { + maxPeopleCount = value + } + if value, ok := updates["min_party_size"].(int); ok { + minPartySize = value + } + if value, ok := updates["max_party_size"].(int); ok { + maxPartySize = value + } + if ticketDefault > maxTicketCount { + return invalidSetting("default_call_ticket_count") + } + if peopleDefault > maxPeopleCount { + return invalidSetting("default_call_people_count") + } + if minPartySize > maxPartySize { + return invalidSetting("min_party_size") + } + return nil +} + func invalidSetting(field string) error { return &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_SETTING", Message: "项目设置值不正确", Details: map[string]string{"field": field}} } diff --git a/server/internal/httpapi/admin_test.go b/server/internal/httpapi/admin_test.go index 1404843..4eb2bb2 100644 --- a/server/internal/httpapi/admin_test.go +++ b/server/internal/httpapi/admin_test.go @@ -94,3 +94,74 @@ func TestValidateProjectSettingsETAInterval(t *testing.T) { t.Fatal("expected interval validation") } } + +func TestValidateProjectSettingsExperiencedPeopleStart(t *testing.T) { + start := 120 + updates, err := validateProjectSettings(updateProjectSettingsRequest{ExperiencedPeopleStart: &start}) + if err != nil || updates["experienced_people_start"] != 120 { + t.Fatalf("unexpected experienced people start result: %#v, %v", updates, err) + } + negative := -1 + if _, err := validateProjectSettings(updateProjectSettingsRequest{ExperiencedPeopleStart: &negative}); err == nil { + t.Fatal("expected negative experienced people start to be rejected") + } + tooLarge := 1000000001 + if _, err := validateProjectSettings(updateProjectSettingsRequest{ExperiencedPeopleStart: &tooLarge}); err == nil { + t.Fatal("expected oversized experienced people start to be rejected") + } +} + +func TestValidateProjectSettingsPeopleAndCallModes(t *testing.T) { + mode := model.CallModeBoth + minParty, maxParty := 1, 8 + ticketDefault, ticketLimit := 5, 20 + peopleDefault, peopleLimit := 12, 40 + updates, err := validateProjectSettings(updateProjectSettingsRequest{ + CallMode: &mode, MinPartySize: &minParty, MaxPartySize: &maxParty, + DefaultCallTicketCount: &ticketDefault, MaxCallTicketCount: &ticketLimit, + DefaultCallPeopleCount: &peopleDefault, MaxCallPeopleCount: &peopleLimit, + }) + if err != nil { + t.Fatal(err) + } + project := model.Project{CallBatchSize: 1, MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, MinPartySize: 1, MaxPartySize: 10} + if err := validateProjectSettingsCombination(project, updates); err != nil { + t.Fatal(err) + } + if updates["call_mode"] != model.CallModeBoth || updates["max_party_size"] != 8 || updates["call_batch_size"] != 5 { + t.Fatalf("unexpected updates: %#v", updates) + } +} + +func TestValidateProjectSettingsAllowsTicketDefaultAboveLegacyLimit(t *testing.T) { + value := 250 + updates, err := validateProjectSettings(updateProjectSettingsRequest{DefaultCallTicketCount: &value}) + if err != nil { + t.Fatal(err) + } + if updates["call_batch_size"] != 250 { + t.Fatalf("updates = %#v", updates) + } +} + +func TestValidateProjectSettingsRejectsInvalidCombinedRanges(t *testing.T) { + project := model.Project{CallBatchSize: 5, MaxCallTicketCount: 10, DefaultCallPeopleCount: 5, MaxCallPeopleCount: 10, MinPartySize: 1, MaxPartySize: 6} + if err := validateProjectSettingsCombination(project, map[string]any{"max_call_ticket_count": 4}); err == nil { + t.Fatal("expected ticket default above limit to fail") + } + if err := validateProjectSettingsCombination(project, map[string]any{"min_party_size": 7}); err == nil { + t.Fatal("expected invalid party range to fail") + } +} + +func TestProjectAllowsConfiguredCallModes(t *testing.T) { + if !projectAllowsCallMode(model.CallModeBoth, model.CallModeTicket) || !projectAllowsCallMode(model.CallModeBoth, model.CallModePeople) { + t.Fatal("BOTH must allow ticket and people calls") + } + if !projectAllowsCallMode(model.CallModeTicket, model.CallModeTicket) || projectAllowsCallMode(model.CallModeTicket, model.CallModePeople) { + t.Fatal("TICKET must allow only ticket calls") + } + if !projectAllowsCallMode(model.CallModePeople, model.CallModePeople) || projectAllowsCallMode(model.CallModePeople, model.CallModeTicket) { + t.Fatal("PEOPLE must allow only people calls") + } +} diff --git a/server/internal/httpapi/experienced_people.go b/server/internal/httpapi/experienced_people.go new file mode 100644 index 0000000..5c82c03 --- /dev/null +++ b/server/internal/httpapi/experienced_people.go @@ -0,0 +1,34 @@ +package httpapi + +import ( + "context" + "fmt" + "time" + + "calllinesystem/server/internal/domain" + "calllinesystem/server/internal/model" +) + +// displayedExperiencedPeople returns the current project's public daily +// metric. A queue session is created lazily on the first ticket, so a nil +// session intentionally still returns the configured starting display value. +func (s *Server) displayedExperiencedPeople(ctx context.Context, project model.Project, session *model.QueueSession) (int64, error) { + var actual int64 + if session != nil { + location, err := time.LoadLocation(project.Timezone) + if err != nil { + return 0, fmt.Errorf("invalid project timezone: %w", err) + } + businessDate := s.now().In(location).Format("2006-01-02") + if session.BusinessDate.Format("2006-01-02") != businessDate { + return domain.DisplayExperiencedPeople(project.ExperiencedPeopleStart, 0), nil + } + if err := s.db.WithContext(ctx).Model(&model.QueueTicket{}). + Select("COALESCE(sum(party_size), 0)"). + Where("project_id = ? AND queue_session_id = ?", project.ID, session.ID). + Scan(&actual).Error; err != nil { + return 0, err + } + } + return domain.DisplayExperiencedPeople(project.ExperiencedPeopleStart, actual), nil +} diff --git a/server/internal/httpapi/public.go b/server/internal/httpapi/public.go index bb82d49..6dffabb 100644 --- a/server/internal/httpapi/public.go +++ b/server/internal/httpapi/public.go @@ -35,6 +35,45 @@ func (s *Server) publicStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, view) } +func (s *Server) publicProjects(w http.ResponseWriter, r *http.Request) { + var projects []model.Project + if err := s.db.WithContext(r.Context()). + Where("status = ?", model.ProjectRunning). + Order("name ASC").Find(&projects).Error; err != nil { + writeError(w, err) + return + } + views := make([]map[string]any, 0, len(projects)) + for _, project := range projects { + views = append(views, map[string]any{ + "id": project.ID, "name": project.Name, "status": project.Status, + "visitor_notice": project.VisitorNotice, + "min_party_size": project.MinPartySize, "max_party_size": project.MaxPartySize, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"projects": views}) +} + +func (s *Server) publicCreateTicket(w http.ResponseWriter, r *http.Request) { + projectID := r.PathValue("id") + if err := validateUUID(projectID); err != nil { + writeError(w, err) + return + } + if s.publicTicketLimiter != nil { + if allowed, retry := s.publicTicketLimiter.allow("ip:" + publicQueryClientKey(r)); !allowed { + writePublicTicketRateLimit(w, retry) + return + } + } + var actor model.User + if err := s.db.WithContext(r.Context()).Where("username = ?", model.PublicVisitorUsername).First(&actor).Error; err != nil { + writeError(w, err) + return + } + s.createTicketForActor(w, r, actor.ID, true) +} + type publicPhoneQueryRequest struct { Phone string `json:"phone"` } @@ -114,6 +153,15 @@ func writePublicQueryRateLimit(w http.ResponseWriter, retry time.Duration) { writeError(w, &apiError{Status: http.StatusTooManyRequests, Code: "PUBLIC_QUERY_RATE_LIMITED", Message: "查询次数过多,请稍后再试"}) } +func writePublicTicketRateLimit(w http.ResponseWriter, retry time.Duration) { + seconds := int((retry + time.Second - 1) / time.Second) + if seconds < 1 { + seconds = 1 + } + w.Header().Set("Retry-After", fmt.Sprintf("%d", seconds)) + writeError(w, &apiError{Status: http.StatusTooManyRequests, Code: "PUBLIC_TICKET_RATE_LIMITED", Message: "取号次数过多,请稍后再试"}) +} + func (s *Server) publicStatusView(ctx context.Context, ticket model.QueueTicket) (map[string]any, error) { var project model.Project var session model.QueueSession @@ -123,19 +171,26 @@ func (s *Server) publicStatusView(ctx context.Context, ticket model.QueueTicket) if err := s.db.WithContext(ctx).First(&session, "id = ? AND project_id = ?", ticket.QueueSessionID, ticket.ProjectID).Error; err != nil { return nil, err } + experiencedPeople, err := s.displayedExperiencedPeople(ctx, project, &session) + if err != nil { + return nil, err + } phoneSuffix, err := s.ticketPhoneLast4(ticket) if err != nil { return nil, err } + ticketsAhead := 0 peopleAhead := 0 if ticket.Status == model.TicketWaiting { - var count int64 + var totals ticketPeopleTotals if err := s.db.WithContext(ctx).Model(&model.QueueTicket{}). + Select("count(*) AS ticket_count, COALESCE(sum(party_size), 0) AS people_count"). Where("project_id = ? AND queue_session_id = ? AND status = ? AND ticket_number < ?", - ticket.ProjectID, ticket.QueueSessionID, model.TicketWaiting, ticket.TicketNumber).Count(&count).Error; err != nil { + ticket.ProjectID, ticket.QueueSessionID, model.TicketWaiting, ticket.TicketNumber).Scan(&totals).Error; err != nil { return nil, err } - peopleAhead = int(count) + ticketsAhead = int(totals.TicketCount) + peopleAhead = int(totals.PeopleCount) } var latestCalledTicket struct { DisplayNumber string `gorm:"column:display_number"` @@ -150,7 +205,7 @@ func (s *Server) publicStatusView(ctx context.Context, ticket model.QueueTicket) latestCalledNumber = latestCalledTicket.DisplayNumber } eta, err := domain.CalculateETA(domain.ETAInput{ - PeopleAhead: peopleAhead, IntervalPerNumber: time.Duration(project.ETAIntervalSeconds) * time.Second, + PeopleAhead: peopleAhead, IntervalPerPerson: time.Duration(project.ETAIntervalSeconds) * time.Second, Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && ticket.Status == model.TicketWaiting, }) if err != nil { @@ -161,25 +216,26 @@ func (s *Server) publicStatusView(ctx context.Context, ticket model.QueueTicket) } return map[string]any{ "ticket_number": ticket.DisplayNumber, "display_number": ticket.DisplayNumber, - "project_name": project.Name, "status": ticket.Status, "phone_last4": phoneSuffix, "estimated_wait": eta, - "visitor_notice": project.VisitorNotice, - "last_updated_at": s.now(), "called_at": ticket.CalledAt, + "project_name": project.Name, "status": ticket.Status, "party_size": ticket.PartySize, "phone_last4": phoneSuffix, "estimated_wait": eta, + "visitor_notice": project.VisitorNotice, + "experienced_people": experiencedPeople, + "last_updated_at": s.now(), "called_at": ticket.CalledAt, "ticket": map[string]any{ - "display_number": ticket.DisplayNumber, "status": ticket.Status, "joined_at": ticket.JoinedAt, + "display_number": ticket.DisplayNumber, "status": ticket.Status, "party_size": ticket.PartySize, "joined_at": ticket.JoinedAt, "called_at": ticket.CalledAt, "arrived_at": ticket.ArrivedAt, "completed_at": ticket.CompletedAt, "missed_at": ticket.MissedAt, }, - "project": map[string]any{"id": project.ID, "name": project.Name, "status": project.Status}, - "people_ahead": peopleAhead, "queue_position": queuePosition(ticket.Status, peopleAhead), + "project": map[string]any{"id": project.ID, "name": project.Name, "status": project.Status}, + "tickets_ahead": ticketsAhead, "people_ahead": peopleAhead, "queue_position": queuePosition(ticket.Status, ticketsAhead), "latest_called_number": latestCalledNumber, "eta": eta, "revision": session.Revision, "server_time": s.now(), }, nil } -func queuePosition(status string, peopleAhead int) any { +func queuePosition(status string, ticketsAhead int) any { if status != model.TicketWaiting { return nil } - return peopleAhead + 1 + return ticketsAhead + 1 } // displayTicketDTO is the complete public ticket shape for a display. Keeping @@ -189,14 +245,19 @@ type displayTicketDTO struct { TicketNumber string `json:"ticket_number"` DisplayNumber string `json:"display_number"` Status string `json:"status"` + PartySize int `json:"party_size"` } type displayBatchDTO struct { - BatchNumber int `json:"batch_number"` - Sequence int `json:"sequence"` - Status string `json:"status"` - CalledAt time.Time `json:"called_at"` - Tickets []displayTicketDTO `json:"tickets"` + BatchNumber int `json:"batch_number"` + Sequence int `json:"sequence"` + Status string `json:"status"` + CallMode string `json:"call_mode"` + RequestedCount int `json:"requested_count"` + TicketCount int `json:"ticket_count"` + PeopleCount int `json:"people_count"` + CalledAt time.Time `json:"called_at"` + Tickets []displayTicketDTO `json:"tickets"` } type displayProjectDTO struct { @@ -206,16 +267,19 @@ type displayProjectDTO struct { } type displaySnapshotDTO struct { - ProjectName string `json:"project_name"` - Status string `json:"status"` - Project displayProjectDTO `json:"project"` - Revision int64 `json:"revision"` - WaitingCount int64 `json:"waiting_count"` - CurrentBatch *displayBatchDTO `json:"current_batch"` - RecentBatches []displayBatchDTO `json:"recent_batches"` - EstimatedWait domain.ETAResult `json:"estimated_wait"` - ServerTime time.Time `json:"server_time"` - LastUpdatedAt time.Time `json:"last_updated_at"` + ProjectName string `json:"project_name"` + Status string `json:"status"` + Project displayProjectDTO `json:"project"` + Revision int64 `json:"revision"` + WaitingCount int64 `json:"waiting_count"` + WaitingTicketCount int64 `json:"waiting_ticket_count"` + WaitingPeopleCount int64 `json:"waiting_people_count"` + ExperiencedPeople int64 `json:"experienced_people"` + CurrentBatch *displayBatchDTO `json:"current_batch"` + RecentBatches []displayBatchDTO `json:"recent_batches"` + EstimatedWait domain.ETAResult `json:"estimated_wait"` + ServerTime time.Time `json:"server_time"` + LastUpdatedAt time.Time `json:"last_updated_at"` } func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) { @@ -234,10 +298,15 @@ func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) { err = s.db.WithContext(r.Context()).Where("project_id = ? AND status IN ?", project.ID, []string{"RUNNING", "PAUSED"}). Order("business_date DESC").First(&session).Error if errors.Is(err, gorm.ErrRecordNotFound) { + experiencedPeople, metricErr := s.displayedExperiencedPeople(r.Context(), project, nil) + if metricErr != nil { + writeError(w, metricErr) + return + } writeJSON(w, http.StatusOK, displaySnapshotDTO{ ProjectName: project.Name, Status: project.Status, - Project: displayProjectDTO{ID: project.ID, Name: project.Name, Status: project.Status}, - RecentBatches: []displayBatchDTO{}, EstimatedWait: domain.ETAResult{Available: false, Reason: "queue_not_running"}, + Project: displayProjectDTO{ID: project.ID, Name: project.Name, Status: project.Status}, + ExperiencedPeople: experiencedPeople, RecentBatches: []displayBatchDTO{}, EstimatedWait: domain.ETAResult{Available: false, Reason: "queue_not_running"}, ServerTime: s.now(), LastUpdatedAt: project.UpdatedAt, }) return @@ -246,10 +315,8 @@ func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - var waitingCount int64 - if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}). - Where("project_id = ? AND queue_session_id = ? AND status = ?", project.ID, session.ID, model.TicketWaiting). - Count(&waitingCount).Error; err != nil { + waiting, err := queueTotals(s.db.WithContext(r.Context()), project.ID, session.ID, model.TicketWaiting) + if err != nil { writeError(w, err) return } @@ -263,15 +330,31 @@ func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - peopleAhead := max(0, int(waitingCount)-1) + var lastWaiting model.QueueTicket + lastWaitingErr := s.db.WithContext(r.Context()).Select("id", "party_size"). + Where("project_id = ? AND queue_session_id = ? AND status = ?", project.ID, session.ID, model.TicketWaiting). + Order("ticket_number DESC").First(&lastWaiting).Error + if lastWaitingErr != nil && !errors.Is(lastWaitingErr, gorm.ErrRecordNotFound) { + writeError(w, lastWaitingErr) + return + } + peopleAhead := int(waiting.PeopleCount) + if lastWaitingErr == nil { + peopleAhead = max(0, peopleAhead-lastWaiting.PartySize) + } estimatedWait, err := domain.CalculateETA(domain.ETAInput{ - PeopleAhead: peopleAhead, IntervalPerNumber: time.Duration(project.ETAIntervalSeconds) * time.Second, - Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && waitingCount > 0, + PeopleAhead: peopleAhead, IntervalPerPerson: time.Duration(project.ETAIntervalSeconds) * time.Second, + Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && waiting.TicketCount > 0, }) if err != nil { writeError(w, err) return } + experiencedPeople, err := s.displayedExperiencedPeople(r.Context(), project, &session) + if err != nil { + writeError(w, err) + return + } lastUpdated := project.UpdatedAt if session.UpdatedAt.After(lastUpdated) { lastUpdated = session.UpdatedAt @@ -279,7 +362,9 @@ func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, displaySnapshotDTO{ ProjectName: project.Name, Status: project.Status, Project: displayProjectDTO{ID: project.ID, Name: project.Name, Status: project.Status}, - Revision: session.Revision, WaitingCount: waitingCount, CurrentBatch: batch, RecentBatches: recentBatches, + Revision: session.Revision, WaitingCount: waiting.TicketCount, + WaitingTicketCount: waiting.TicketCount, WaitingPeopleCount: waiting.PeopleCount, + ExperiencedPeople: experiencedPeople, CurrentBatch: batch, RecentBatches: recentBatches, EstimatedWait: estimatedWait, ServerTime: s.now(), LastUpdatedAt: lastUpdated, }) } @@ -321,7 +406,7 @@ func (s *Server) recentDisplayBatches(r *http.Request, projectID, sessionID stri func displayBatchTickets(db *gorm.DB, batchID, projectID string, calledOnly bool) ([]displayTicketDTO, error) { query := db.Table("queue_tickets"). - Select("queue_tickets.display_number, queue_tickets.status"). + Select("queue_tickets.display_number, queue_tickets.status, queue_tickets.party_size"). Joins("JOIN call_batch_tickets cbt ON cbt.ticket_id = queue_tickets.id AND cbt.project_id = queue_tickets.project_id"). Where("cbt.call_batch_id = ? AND cbt.project_id = ?", batchID, projectID). Order("cbt.position ASC") @@ -331,6 +416,7 @@ func displayBatchTickets(db *gorm.DB, batchID, projectID string, calledOnly bool type row struct { DisplayNumber string Status string + PartySize int } var rows []row if err := query.Scan(&rows).Error; err != nil { @@ -339,7 +425,7 @@ func displayBatchTickets(db *gorm.DB, batchID, projectID string, calledOnly bool tickets := make([]displayTicketDTO, 0, len(rows)) for _, item := range rows { tickets = append(tickets, displayTicketDTO{ - TicketNumber: item.DisplayNumber, DisplayNumber: item.DisplayNumber, Status: item.Status, + TicketNumber: item.DisplayNumber, DisplayNumber: item.DisplayNumber, Status: item.Status, PartySize: item.PartySize, }) } return tickets, nil @@ -348,6 +434,8 @@ func displayBatchTickets(db *gorm.DB, batchID, projectID string, calledOnly bool func newDisplayBatchDTO(batch model.CallBatch, tickets []displayTicketDTO) displayBatchDTO { return displayBatchDTO{ BatchNumber: batch.BatchSequence, Sequence: batch.BatchSequence, Status: batch.Status, + CallMode: batch.CallMode, RequestedCount: batch.RequestedCount, + TicketCount: batch.TicketCount, PeopleCount: batch.PeopleCount, CalledAt: batch.CalledAt, Tickets: tickets, } } diff --git a/server/internal/httpapi/queue_counts.go b/server/internal/httpapi/queue_counts.go new file mode 100644 index 0000000..22b8d35 --- /dev/null +++ b/server/internal/httpapi/queue_counts.go @@ -0,0 +1,29 @@ +package httpapi + +import ( + "calllinesystem/server/internal/model" + + "gorm.io/gorm" +) + +type ticketPeopleTotals struct { + TicketCount int64 `gorm:"column:ticket_count"` + PeopleCount int64 `gorm:"column:people_count"` +} + +func queueTotals(db *gorm.DB, projectID, sessionID, status string) (ticketPeopleTotals, error) { + var totals ticketPeopleTotals + err := db.Model(&model.QueueTicket{}). + Select("count(*) AS ticket_count, COALESCE(sum(party_size), 0) AS people_count"). + Where("project_id = ? AND queue_session_id = ? AND status = ?", projectID, sessionID, status). + Scan(&totals).Error + return totals, err +} + +func ticketsPartySize(tickets []model.QueueTicket) int { + total := 0 + for _, ticket := range tickets { + total += ticket.PartySize + } + return total +} diff --git a/server/internal/httpapi/reissue.go b/server/internal/httpapi/reissue.go index 467cb98..18afd93 100644 --- a/server/internal/httpapi/reissue.go +++ b/server/internal/httpapi/reissue.go @@ -128,7 +128,7 @@ func (s *Server) reissueTicket(w http.ResponseWriter, r *http.Request) { now := s.now() newTicket := model.QueueTicket{ ID: uuid.NewString(), ProjectID: initial.ProjectID, QueueSessionID: session.ID, - ReissuedFromTicketID: &original.ID, TicketNumber: session.NextTicketNumber, DisplayNumber: displayNumber, + ReissuedFromTicketID: &original.ID, TicketNumber: session.NextTicketNumber, DisplayNumber: displayNumber, PartySize: original.PartySize, PublicTokenHash: security.HashToken(publicToken), PhoneCiphertext: append([]byte(nil), original.PhoneCiphertext...), PhoneNonce: append([]byte(nil), original.PhoneNonce...), PhoneHMAC: cloneStringPointer(original.PhoneHMAC), LastNameCiphertext: append([]byte(nil), original.LastNameCiphertext...), @@ -147,7 +147,7 @@ func (s *Server) reissueTicket(w http.ResponseWriter, r *http.Request) { if err := s.addAudit(tx, r, &initial.ProjectID, &user.ID, "TICKET_REISSUED", "QUEUE_TICKET", &newTicket.ID, map[string]any{ "reissued_from_ticket_id": original.ID, "old_display_number": original.DisplayNumber, - "new_display_number": newTicket.DisplayNumber, "revision": revision, + "new_display_number": newTicket.DisplayNumber, "party_size": newTicket.PartySize, "revision": revision, }); err != nil { return err } diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 129c212..7c55377 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -27,15 +27,16 @@ const ( ) type Server struct { - db *gorm.DB - config config.Config - cipher *security.Cipher - logger *slog.Logger - hub eventPublisher - loginLimiter *loginLimiter - publicQueryLimiter *queryLimiter - dummyPassword string - now func() time.Time + db *gorm.DB + config config.Config + cipher *security.Cipher + logger *slog.Logger + hub eventPublisher + loginLimiter *loginLimiter + publicQueryLimiter *queryLimiter + publicTicketLimiter *queryLimiter + dummyPassword string + now func() time.Time } func New(db *gorm.DB, cfg config.Config, logger *slog.Logger) (*Server, error) { @@ -56,15 +57,16 @@ func NewWithEventPublisher(db *gorm.DB, cfg config.Config, logger *slog.Logger, } now := func() time.Time { return time.Now().UTC() } return &Server{ - db: db, - config: cfg, - cipher: fieldCipher, - logger: logger, - hub: publisher, - loginLimiter: newLoginLimiter(now), - publicQueryLimiter: newQueryLimiter(now, 120, time.Minute), - dummyPassword: dummy, - now: now, + db: db, + config: cfg, + cipher: fieldCipher, + logger: logger, + hub: publisher, + loginLimiter: newLoginLimiter(now), + publicQueryLimiter: newQueryLimiter(now, 120, time.Minute), + publicTicketLimiter: newQueryLimiter(now, 20, time.Minute), + dummyPassword: dummy, + now: now, }, nil } @@ -83,6 +85,8 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/staff/projects/{id}/tickets", s.requireStaff(http.HandlerFunc(s.createTicket))) mux.Handle("POST /api/staff/projects/{id}/call-next", s.requireStaff(http.HandlerFunc(s.callNext))) mux.HandleFunc("GET /api/public/status/{token}", s.publicStatus) + mux.HandleFunc("GET /api/public/projects", s.publicProjects) + mux.HandleFunc("POST /api/public/projects/{id}/tickets", s.publicCreateTicket) mux.HandleFunc("POST /api/public/status/search", s.publicStatusByPhone) mux.HandleFunc("GET /api/display/{token}/snapshot", s.displaySnapshot) mux.Handle("GET /api/events", s.requireStaff(http.HandlerFunc(s.events))) diff --git a/server/internal/httpapi/staff.go b/server/internal/httpapi/staff.go index 787f97c..c0172ca 100644 --- a/server/internal/httpapi/staff.go +++ b/server/internal/httpapi/staff.go @@ -39,11 +39,17 @@ func projectViews(projects []model.Project) []map[string]any { func projectView(project model.Project) map[string]any { return map[string]any{ "id": project.ID, "code": project.Code, "name": project.Name, "status": project.Status, - "timezone": project.Timezone, "ticket_prefix": project.TicketPrefix, "call_batch_size": project.CallBatchSize, "batch_size": project.CallBatchSize, - "grace_period_minutes": project.GracePeriodMinutes, - "visitor_notice": project.VisitorNotice, + "timezone": project.Timezone, "ticket_prefix": project.TicketPrefix, + "call_mode": project.CallMode, + "default_call_ticket_count": project.CallBatchSize, "call_batch_size": project.CallBatchSize, "batch_size": project.CallBatchSize, + "max_call_ticket_count": project.MaxCallTicketCount, + "default_call_people_count": project.DefaultCallPeopleCount, "max_call_people_count": project.MaxCallPeopleCount, + "min_party_size": project.MinPartySize, "max_party_size": project.MaxPartySize, + "grace_period_minutes": project.GracePeriodMinutes, + "experienced_people_start": project.ExperiencedPeopleStart, + "visitor_notice": project.VisitorNotice, "eta": map[string]any{ - "interval_per_number_seconds": project.ETAIntervalSeconds, + "interval_per_person_seconds": project.ETAIntervalSeconds, }, } } @@ -69,8 +75,12 @@ func (s *Server) queueSnapshot(w http.ResponseWriter, r *http.Request) { if errors.Is(err, gorm.ErrRecordNotFound) { writeJSON(w, http.StatusOK, map[string]any{ "project": projectView(project), "session": nil, "revision": 0, - "counts": map[string]int64{}, "waiting": []any{}, "current_batch": nil, "recent_batches": []any{}, - "metrics": map[string]any{"waiting_count": 0, "called_count": 0, "estimated_wait": nil, "last_ticket_number": nil, "next_ticket_number": nil}, + "counts": map[string]int64{}, "people_counts": map[string]int64{}, "waiting": []any{}, "current_batch": nil, "recent_batches": []any{}, + "metrics": map[string]any{ + "waiting_count": 0, "waiting_ticket_count": 0, "waiting_people_count": 0, + "called_count": 0, "called_ticket_count": 0, "called_people_count": 0, + "estimated_wait": nil, "last_ticket_number": nil, "next_ticket_number": nil, + }, "server_time": s.now(), "last_success_at": s.now(), }) return @@ -81,19 +91,22 @@ func (s *Server) queueSnapshot(w http.ResponseWriter, r *http.Request) { } type statusCount struct { - Status string - Count int64 + Status string + TicketCount int64 `gorm:"column:ticket_count"` + PeopleCount int64 `gorm:"column:people_count"` } var grouped []statusCount if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}). - Select("status, count(*) AS count").Where("project_id = ? AND queue_session_id = ?", projectID, session.ID). + Select("status, count(*) AS ticket_count, COALESCE(sum(party_size), 0) AS people_count").Where("project_id = ? AND queue_session_id = ?", projectID, session.ID). Group("status").Scan(&grouped).Error; err != nil { writeError(w, err) return } - counts := make(map[string]int64, len(grouped)) + ticketCounts := make(map[string]int64, len(grouped)) + peopleCounts := make(map[string]int64, len(grouped)) for _, row := range grouped { - counts[row.Status] = row.Count + ticketCounts[row.Status] = row.TicketCount + peopleCounts[row.Status] = row.PeopleCount } var waiting []model.QueueTicket if err := s.db.WithContext(r.Context()).Where("project_id = ? AND queue_session_id = ? AND status = ?", projectID, session.ID, model.TicketWaiting). @@ -130,10 +143,24 @@ func (s *Server) queueSnapshot(w http.ResponseWriter, r *http.Request) { } lastTicketNumber = formatted } + var lastWaiting model.QueueTicket + lastWaitingErr := s.db.WithContext(r.Context()). + Select("id", "party_size"). + Where("project_id = ? AND queue_session_id = ? AND status = ?", projectID, session.ID, model.TicketWaiting). + Order("ticket_number DESC").First(&lastWaiting).Error + if lastWaitingErr != nil && !errors.Is(lastWaitingErr, gorm.ErrRecordNotFound) { + writeError(w, lastWaitingErr) + return + } + waitingPeople := peopleCounts[model.TicketWaiting] + peopleAheadOfLast := int(waitingPeople) + if lastWaitingErr == nil { + peopleAheadOfLast = max(0, peopleAheadOfLast-lastWaiting.PartySize) + } estimatedWait, err := domain.CalculateETA(domain.ETAInput{ - PeopleAhead: max(0, int(counts[model.TicketWaiting])-1), - IntervalPerNumber: time.Duration(project.ETAIntervalSeconds) * time.Second, - Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && counts[model.TicketWaiting] > 0, + PeopleAhead: peopleAheadOfLast, + IntervalPerPerson: time.Duration(project.ETAIntervalSeconds) * time.Second, + Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && ticketCounts[model.TicketWaiting] > 0, }) if err != nil { writeError(w, err) @@ -142,13 +169,17 @@ func (s *Server) queueSnapshot(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "project": projectView(project), "session": map[string]any{"id": session.ID, "business_date": session.BusinessDate.Format("2006-01-02"), "status": session.Status}, - "revision": session.Revision, "counts": counts, "waiting": waitingViews, "current_batch": batchView, + "revision": session.Revision, "counts": ticketCounts, "people_counts": peopleCounts, "waiting": waitingViews, "current_batch": batchView, "metrics": map[string]any{ - "waiting_count": counts[model.TicketWaiting], - "called_count": counts[model.TicketCalled] + counts[model.TicketArrived], - "estimated_wait": estimatedWait, - "last_ticket_number": lastTicketNumber, - "next_ticket_number": nextTicketNumber, + "waiting_count": ticketCounts[model.TicketWaiting], + "waiting_ticket_count": ticketCounts[model.TicketWaiting], + "waiting_people_count": peopleCounts[model.TicketWaiting], + "called_count": ticketCounts[model.TicketCalled] + ticketCounts[model.TicketArrived], + "called_ticket_count": ticketCounts[model.TicketCalled] + ticketCounts[model.TicketArrived], + "called_people_count": peopleCounts[model.TicketCalled] + peopleCounts[model.TicketArrived], + "estimated_wait": estimatedWait, + "last_ticket_number": lastTicketNumber, + "next_ticket_number": nextTicketNumber, }, "server_time": s.now(), "last_success_at": s.now(), }) @@ -175,6 +206,7 @@ type createTicketRequest struct { Phone string `json:"phone"` LastName string `json:"last_name"` Honorific string `json:"honorific"` + PartySize *int `json:"party_size"` AllowDuplicate bool `json:"allow_duplicate"` } @@ -188,6 +220,12 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } + user := currentPrincipal(r.Context()).User + s.createTicketForActor(w, r, user.ID, false) +} + +func (s *Server) createTicketForActor(w http.ResponseWriter, r *http.Request, actorID string, publicView bool) { + projectID := r.PathValue("id") key, err := readIdempotencyKey(r) if err != nil { writeError(w, err) @@ -198,6 +236,10 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } + if input.PartySize == nil || *input.PartySize < 1 { + writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_PARTY_SIZE", Message: "请选择本号人数"}) + return + } phone, err := security.NormalizePhone(input.Phone) if err != nil { writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_PHONE", Message: err.Error()}) @@ -223,16 +265,30 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) { return } - user := currentPrincipal(r.Context()).User + scope := "CREATE_TICKET" + auditAction := "TICKET_CREATED" + duplicateMessage := "该手机号已有活动号码,请员工确认后继续" + if publicView { + scope = "PUBLIC_CREATE_TICKET" + auditAction = "PUBLIC_TICKET_CREATED" + duplicateMessage = "该手机号已有活动号码,请确认后继续" + } var responseBody []byte responseCode := http.StatusCreated var revision int64 err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { - _, session, err := s.lockRunningProjectAndSession(tx, projectID) + project, session, err := s.lockRunningProjectAndSession(tx, projectID) if err != nil { return err } - if stored, code, found, err := loadIdempotent(tx, projectID, user.ID, "CREATE_TICKET", key, hash, s.now()); err != nil { + if *input.PartySize < project.MinPartySize || *input.PartySize > project.MaxPartySize { + return &apiError{ + Status: http.StatusUnprocessableEntity, Code: "INVALID_PARTY_SIZE", + Message: fmt.Sprintf("本项目每个号码可绑定 %d 到 %d 人", project.MinPartySize, project.MaxPartySize), + Details: map[string]any{"min_party_size": project.MinPartySize, "max_party_size": project.MaxPartySize}, + } + } + if stored, code, found, err := loadIdempotent(tx, projectID, actorID, scope, key, hash, s.now()); err != nil { return err } else if found { responseBody, responseCode = stored, code @@ -252,7 +308,11 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) { for _, duplicate := range duplicates { existing = append(existing, map[string]any{"id": duplicate.ID, "display_number": duplicate.DisplayNumber, "status": duplicate.Status}) } - return &apiError{Status: http.StatusConflict, Code: "DUPLICATE_PHONE", Message: "该手机号已有活动号码,请员工确认后继续", Details: map[string]any{"tickets": existing}} + var details any = map[string]any{"tickets": existing} + if publicView { + details = nil + } + return &apiError{Status: http.StatusConflict, Code: "DUPLICATE_PHONE", Message: duplicateMessage, Details: details} } publicToken, err := security.GenerateToken() @@ -277,11 +337,11 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) { now := s.now() ticket := model.QueueTicket{ ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: session.ID, - TicketNumber: session.NextTicketNumber, DisplayNumber: displayNumber, + TicketNumber: session.NextTicketNumber, DisplayNumber: displayNumber, PartySize: *input.PartySize, PublicTokenHash: security.HashToken(publicToken), PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: stringPointer(phoneDigest), LastNameCiphertext: lastNameCiphertext, LastNameNonce: lastNameNonce, Honorific: input.Honorific, Status: model.TicketWaiting, JoinedAt: now, - PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: user.ID, CreatedAt: now, UpdatedAt: now, + PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: actorID, CreatedAt: now, UpdatedAt: now, } if err := tx.Create(&ticket).Error; err != nil { return err @@ -291,25 +351,39 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) { Updates(map[string]any{"next_ticket_number": session.NextTicketNumber + 1, "revision": revision, "updated_at": now}).Error; err != nil { return err } - if err := s.addAudit(tx, r, &projectID, &user.ID, "TICKET_CREATED", "QUEUE_TICKET", &ticket.ID, - map[string]any{"display_number": displayNumber, "duplicate_count": len(duplicates), "duplicate_confirmed": input.AllowDuplicate}); err != nil { + if err := s.addAudit(tx, r, &projectID, &actorID, auditAction, "QUEUE_TICKET", &ticket.ID, + map[string]any{"display_number": displayNumber, "party_size": ticket.PartySize, "duplicate_count": len(duplicates), "duplicate_confirmed": input.AllowDuplicate}); err != nil { return err } - ticketResponse, err := s.staffTicketView(ticket) - if err != nil { - return err + var ticketResponse map[string]any + if publicView { + phoneLast4, err := s.ticketPhoneLast4(ticket) + if err != nil { + return err + } + ticketResponse = map[string]any{ + "id": ticket.ID, "display_number": ticket.DisplayNumber, "ticket_number": ticket.DisplayNumber, + "status": ticket.Status, "party_size": ticket.PartySize, "phone_last4": phoneLast4, "created_at": ticket.JoinedAt, + } + } else { + ticketResponse, err = s.staffTicketView(ticket) + if err != nil { + return err + } + } + if !publicView { + ticketResponse["public_token"] = publicToken + ticketResponse["public_url"] = "/visitor/" + publicToken } - ticketResponse["public_token"] = publicToken - ticketResponse["public_url"] = "/visitor/" + publicToken response := map[string]any{ - "ticket": ticketResponse, "public_token": publicToken, + "ticket": ticketResponse, "public_token": publicToken, "public_url": "/visitor/" + publicToken, "status_path": "/api/public/status/" + publicToken, "revision": revision, } responseBody, err = marshalResponse(response) if err != nil { return err } - return saveIdempotent(tx, projectID, user.ID, "CREATE_TICKET", key, hash, responseCode, responseBody, &ticket.ID, now) + return saveIdempotent(tx, projectID, actorID, scope, key, hash, responseCode, responseBody, &ticket.ID, now) }) if err != nil { writeError(w, err) @@ -323,6 +397,7 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) { type callNextRequest struct { ExpectedRevision *int64 `json:"expected_revision"` + Mode string `json:"mode"` Count *int `json:"count"` } @@ -345,12 +420,20 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) { writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "EXPECTED_REVISION_REQUIRED", Message: "必须提供当前队列 revision"}) return } + mode := strings.ToUpper(strings.TrimSpace(input.Mode)) + if mode == "" { + mode = model.CallModeTicket + } + if mode != model.CallModeTicket && mode != model.CallModePeople { + writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_CALL_MODE", Message: "叫号方式不正确"}) + return + } count := 1 if input.Count != nil { count = *input.Count } - if count < 1 || count > 100 { - writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_CALL_COUNT", Message: "叫号数量必须在 1 到 100 之间"}) + if count < 1 || count > 10000 { + writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_CALL_COUNT", Message: "叫号数量必须是有效的正整数"}) return } key, err := readIdempotencyKey(r) @@ -358,7 +441,7 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - hash, _ := requestHash(map[string]any{"operation": "CALL_NEXT", "project_id": projectID, "expected_revision": *input.ExpectedRevision, "count": count}) + hash, _ := requestHash(map[string]any{"operation": "CALL_NEXT", "project_id": projectID, "expected_revision": *input.ExpectedRevision, "mode": mode, "count": count}) user := currentPrincipal(r.Context()).User var responseBody []byte responseCode := http.StatusOK @@ -380,15 +463,42 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) { Details: map[string]any{"expected_revision": *input.ExpectedRevision, "current_revision": session.Revision}, } } - var tickets []model.QueueTicket + if !projectAllowsCallMode(project.CallMode, mode) { + return &apiError{Status: http.StatusUnprocessableEntity, Code: "CALL_MODE_NOT_ALLOWED", Message: "当前项目不支持该叫号方式"} + } + limit := project.MaxCallTicketCount + unit := "个号码" + if mode == model.CallModePeople { + limit = project.MaxCallPeopleCount + unit = "人" + } + if count > limit { + return &apiError{ + Status: http.StatusUnprocessableEntity, Code: "CALL_COUNT_EXCEEDS_LIMIT", + Message: fmt.Sprintf("本次最多可输入 %d %s", limit, unit), + Details: map[string]any{"mode": mode, "max_count": limit}, + } + } + var waitingTickets []model.QueueTicket if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("project_id = ? AND queue_session_id = ? AND status = ?", projectID, session.ID, model.TicketWaiting). - Order("ticket_number ASC").Limit(count).Find(&tickets).Error; err != nil { + Order("ticket_number ASC").Limit(count).Find(&waitingTickets).Error; err != nil { return err } - if len(tickets) == 0 { + if len(waitingTickets) == 0 { return &apiError{Status: http.StatusConflict, Code: "QUEUE_EMPTY", Message: "当前没有等待中的号码"} } + tickets, peopleCount, err := domain.SelectTicketsForCall(waitingTickets, mode, count) + if errors.Is(err, domain.ErrFirstTicketExceedsPeopleTarget) { + return &apiError{ + Status: http.StatusConflict, Code: "NEXT_TICKET_EXCEEDS_PEOPLE_TARGET", + Message: fmt.Sprintf("队首号码有 %d 人,请输入不少于 %d 人", waitingTickets[0].PartySize, waitingTickets[0].PartySize), + Details: map[string]any{"next_ticket_number": waitingTickets[0].DisplayNumber, "party_size": waitingTickets[0].PartySize}, + } + } + if err != nil { + return err + } now := s.now() autoCompletedBatchIDs, autoCompletedTicketCount, err := autoCompleteActiveBatches(tx, projectID, now) if err != nil { @@ -403,6 +513,7 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) { batch := model.CallBatch{ ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: session.ID, BatchSequence: lastSequence + 1, Revision: revision, Status: "CALLED", + CallMode: mode, RequestedCount: count, TicketCount: len(tickets), PeopleCount: peopleCount, RequestedBy: user.ID, CalledAt: now, CreatedAt: now, } if err := tx.Create(&batch).Error; err != nil { @@ -438,7 +549,8 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) { } if err := s.addAudit(tx, r, &projectID, &user.ID, "CALL_NEXT", "CALL_BATCH", &batch.ID, map[string]any{ - "ticket_count": len(tickets), "revision": revision, "device_outcome": outcome, + "call_mode": mode, "requested_count": count, "ticket_count": len(tickets), "people_count": peopleCount, + "revision": revision, "device_outcome": outcome, "auto_completed_batch_ids": autoCompletedBatchIDs, "auto_completed_ticket_count": autoCompletedTicketCount, }); err != nil { return err @@ -469,6 +581,10 @@ func (s *Server) callNext(w http.ResponseWriter, r *http.Request) { writeRawJSON(w, responseCode, responseBody) } +func projectAllowsCallMode(projectMode, requestedMode string) bool { + return projectMode == model.CallModeBoth || projectMode == requestedMode +} + func autoCompleteActiveBatches(tx *gorm.DB, projectID string, now time.Time) ([]string, int64, error) { var batches []model.CallBatch if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). @@ -693,6 +809,7 @@ func (s *Server) staffTicketView(ticket model.QueueTicket) (map[string]any, erro } return map[string]any{ "id": ticket.ID, "display_number": ticket.DisplayNumber, "ticket_number": ticket.DisplayNumber, "status": ticket.Status, + "party_size": ticket.PartySize, "reissued_from_ticket_id": ticket.ReissuedFromTicketID, "phone": phone, "last_name": lastName, "honorific": ticket.Honorific, "joined_at": ticket.JoinedAt, "called_at": ticket.CalledAt, @@ -712,6 +829,8 @@ func (s *Server) staffCallBatchView(batch model.CallBatch, tickets []model.Queue } return map[string]any{ "id": batch.ID, "sequence": batch.BatchSequence, "batch_number": batch.BatchSequence, "status": batch.Status, + "call_mode": batch.CallMode, "requested_count": batch.RequestedCount, + "ticket_count": batch.TicketCount, "people_count": batch.PeopleCount, "called_at": batch.CalledAt, "revision": batch.Revision, "tickets": views, }, nil } diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 5a80ad5..0887959 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -10,6 +10,10 @@ const ( RoleStaff = "STAFF" // SuperAdminUsername identifies the permanent production owner account. SuperAdminUsername = "xqkwljtadmin" + // PublicVisitorUsername is a non-login system actor used for anonymous + // visitor-created tickets so existing foreign keys and idempotency records + // remain attributable without borrowing a real staff account. + PublicVisitorUsername = "public-visitor" ProjectNotOpen = "NOT_OPEN" ProjectRunning = "RUNNING" @@ -19,6 +23,10 @@ const ( ETAFixedBatch = "FIXED_BATCH" ETAContinuous = "CONTINUOUS" + CallModeTicket = "TICKET" + CallModePeople = "PEOPLE" + CallModeBoth = "BOTH" + TicketWaiting = "WAITING" TicketCalled = "CALLED" TicketArrived = "ARRIVED" @@ -46,7 +54,13 @@ type Project struct { Status string `gorm:"size:16;not null"` Timezone string `gorm:"size:64;not null"` TicketPrefix string `gorm:"size:8;not null"` - CallBatchSize int `gorm:"not null"` + CallBatchSize int `gorm:"column:call_batch_size;not null"` + CallMode string `gorm:"column:call_mode;size:16;not null;default:BOTH"` + MaxCallTicketCount int `gorm:"column:max_call_ticket_count;not null;default:100"` + DefaultCallPeopleCount int `gorm:"column:default_call_people_count;not null;default:1"` + MaxCallPeopleCount int `gorm:"column:max_call_people_count;not null;default:100"` + MinPartySize int `gorm:"column:min_party_size;not null;default:1"` + MaxPartySize int `gorm:"column:max_party_size;not null;default:10"` GracePeriodMinutes int `gorm:"not null"` ETAMode string `gorm:"column:eta_mode;size:24;not null"` AverageBatchIntervalSeconds int `gorm:"not null"` @@ -54,6 +68,7 @@ type Project struct { ETABufferMinutes int `gorm:"column:eta_buffer_minutes;not null"` ETAIntervalSeconds int `gorm:"column:eta_interval_seconds;not null;default:60"` VisitorNotice string `gorm:"column:visitor_notice;size:240;not null"` + ExperiencedPeopleStart int `gorm:"column:experienced_people_start;not null;default:0"` DisplayTokenHash *string `gorm:"type:char(64)"` DeviceSimulationMode string `gorm:"size:16;not null"` CreatedAt time.Time @@ -99,6 +114,7 @@ type QueueTicket struct { ReissuedFromTicketID *string `gorm:"type:uuid;uniqueIndex"` TicketNumber int `gorm:"not null"` DisplayNumber string `gorm:"size:24;not null"` + PartySize int `gorm:"column:party_size;not null;default:1"` PublicTokenHash string `gorm:"type:char(64);not null;uniqueIndex"` PhoneCiphertext []byte `gorm:"type:bytea"` PhoneNonce []byte `gorm:"type:bytea"` @@ -126,6 +142,10 @@ type CallBatch struct { BatchSequence int `gorm:"not null"` Revision int64 `gorm:"not null"` Status string `gorm:"size:16;not null"` + CallMode string `gorm:"column:call_mode;size:16;not null;default:TICKET"` + RequestedCount int `gorm:"column:requested_count;not null;default:1"` + TicketCount int `gorm:"column:ticket_count;not null;default:1"` + PeopleCount int `gorm:"column:people_count;not null;default:1"` RequestedBy string `gorm:"type:uuid;not null"` CalledAt time.Time `gorm:"not null"` CompletedAt *time.Time diff --git a/server/migrations/000009_public_visitor_actor.down.sql b/server/migrations/000009_public_visitor_actor.down.sql new file mode 100644 index 0000000..b0bce3e --- /dev/null +++ b/server/migrations/000009_public_visitor_actor.down.sql @@ -0,0 +1,4 @@ +DELETE FROM users AS public_user +WHERE public_user.username = 'public-visitor' + AND NOT EXISTS (SELECT 1 FROM queue_tickets WHERE created_by = public_user.id) + AND NOT EXISTS (SELECT 1 FROM idempotency_keys WHERE user_id = public_user.id); diff --git a/server/migrations/000009_public_visitor_actor.up.sql b/server/migrations/000009_public_visitor_actor.up.sql new file mode 100644 index 0000000..c122a24 --- /dev/null +++ b/server/migrations/000009_public_visitor_actor.up.sql @@ -0,0 +1,3 @@ +INSERT INTO users (username, password_hash, role, active) +VALUES ('public-visitor', '!', 'STAFF', false) +ON CONFLICT (username) DO NOTHING; diff --git a/server/migrations/000010_experienced_people_start.down.sql b/server/migrations/000010_experienced_people_start.down.sql new file mode 100644 index 0000000..45191e1 --- /dev/null +++ b/server/migrations/000010_experienced_people_start.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE projects + DROP CONSTRAINT IF EXISTS projects_experienced_people_start_check; + +ALTER TABLE projects + DROP COLUMN IF EXISTS experienced_people_start; diff --git a/server/migrations/000010_experienced_people_start.up.sql b/server/migrations/000010_experienced_people_start.up.sql new file mode 100644 index 0000000..6d1e95c --- /dev/null +++ b/server/migrations/000010_experienced_people_start.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE projects + ADD COLUMN experienced_people_start integer NOT NULL DEFAULT 0; + +ALTER TABLE projects + ADD CONSTRAINT projects_experienced_people_start_check + CHECK (experienced_people_start BETWEEN 0 AND 1000000000); diff --git a/server/migrations/000011_party_size_and_call_modes.down.sql b/server/migrations/000011_party_size_and_call_modes.down.sql new file mode 100644 index 0000000..47d3ad8 --- /dev/null +++ b/server/migrations/000011_party_size_and_call_modes.down.sql @@ -0,0 +1,34 @@ +ALTER TABLE call_batches + DROP CONSTRAINT IF EXISTS call_batches_people_count_check, + DROP CONSTRAINT IF EXISTS call_batches_ticket_count_check, + DROP CONSTRAINT IF EXISTS call_batches_requested_count_check, + DROP CONSTRAINT IF EXISTS call_batches_call_mode_check; + +ALTER TABLE call_batches + DROP COLUMN IF EXISTS people_count, + DROP COLUMN IF EXISTS ticket_count, + DROP COLUMN IF EXISTS requested_count, + DROP COLUMN IF EXISTS call_mode; + +ALTER TABLE queue_tickets + DROP CONSTRAINT IF EXISTS queue_tickets_party_size_check; + +ALTER TABLE queue_tickets + DROP COLUMN IF EXISTS party_size; + +ALTER TABLE projects + DROP CONSTRAINT IF EXISTS projects_party_size_range_check, + DROP CONSTRAINT IF EXISTS projects_min_party_size_check, + DROP CONSTRAINT IF EXISTS projects_call_people_default_limit_check, + DROP CONSTRAINT IF EXISTS projects_max_call_people_count_check, + DROP CONSTRAINT IF EXISTS projects_call_ticket_default_limit_check, + DROP CONSTRAINT IF EXISTS projects_max_call_ticket_count_check, + DROP CONSTRAINT IF EXISTS projects_call_mode_check; + +ALTER TABLE projects + DROP COLUMN IF EXISTS max_party_size, + DROP COLUMN IF EXISTS min_party_size, + DROP COLUMN IF EXISTS max_call_people_count, + DROP COLUMN IF EXISTS default_call_people_count, + DROP COLUMN IF EXISTS max_call_ticket_count, + DROP COLUMN IF EXISTS call_mode; diff --git a/server/migrations/000011_party_size_and_call_modes.up.sql b/server/migrations/000011_party_size_and_call_modes.up.sql new file mode 100644 index 0000000..0d76db8 --- /dev/null +++ b/server/migrations/000011_party_size_and_call_modes.up.sql @@ -0,0 +1,59 @@ +ALTER TABLE projects + ADD COLUMN call_mode varchar(16) NOT NULL DEFAULT 'BOTH', + ADD COLUMN max_call_ticket_count integer NOT NULL DEFAULT 100, + ADD COLUMN default_call_people_count integer NOT NULL DEFAULT 1, + ADD COLUMN max_call_people_count integer NOT NULL DEFAULT 100, + ADD COLUMN min_party_size integer NOT NULL DEFAULT 1, + ADD COLUMN max_party_size integer NOT NULL DEFAULT 10; + +ALTER TABLE projects + ADD CONSTRAINT projects_call_mode_check + CHECK (call_mode IN ('TICKET', 'PEOPLE', 'BOTH')), + ADD CONSTRAINT projects_max_call_ticket_count_check + CHECK (max_call_ticket_count BETWEEN 1 AND 10000), + ADD CONSTRAINT projects_call_ticket_default_limit_check + CHECK (call_batch_size <= max_call_ticket_count), + ADD CONSTRAINT projects_max_call_people_count_check + CHECK (max_call_people_count BETWEEN 1 AND 10000), + ADD CONSTRAINT projects_call_people_default_limit_check + CHECK (default_call_people_count BETWEEN 1 AND max_call_people_count), + ADD CONSTRAINT projects_min_party_size_check + CHECK (min_party_size BETWEEN 1 AND 10000), + ADD CONSTRAINT projects_party_size_range_check + CHECK (max_party_size BETWEEN min_party_size AND 10000); + +ALTER TABLE queue_tickets + ADD COLUMN party_size integer NOT NULL DEFAULT 1; + +ALTER TABLE queue_tickets + ADD CONSTRAINT queue_tickets_party_size_check + CHECK (party_size BETWEEN 1 AND 10000); + +ALTER TABLE call_batches + ADD COLUMN call_mode varchar(16) NOT NULL DEFAULT 'TICKET', + ADD COLUMN requested_count integer NOT NULL DEFAULT 1, + ADD COLUMN ticket_count integer NOT NULL DEFAULT 1, + ADD COLUMN people_count integer NOT NULL DEFAULT 1; + +UPDATE call_batches AS batch +SET ticket_count = totals.ticket_count, + people_count = totals.people_count +FROM ( + SELECT cbt.call_batch_id, + count(*)::integer AS ticket_count, + COALESCE(sum(ticket.party_size), 0)::integer AS people_count + FROM call_batch_tickets AS cbt + JOIN queue_tickets AS ticket ON ticket.id = cbt.ticket_id + GROUP BY cbt.call_batch_id +) AS totals +WHERE totals.call_batch_id = batch.id; + +ALTER TABLE call_batches + ADD CONSTRAINT call_batches_call_mode_check + CHECK (call_mode IN ('TICKET', 'PEOPLE')), + ADD CONSTRAINT call_batches_requested_count_check + CHECK (requested_count > 0), + ADD CONSTRAINT call_batches_ticket_count_check + CHECK (ticket_count > 0), + ADD CONSTRAINT call_batches_people_count_check + CHECK (people_count > 0); diff --git a/server/migrations/000012_expand_ticket_call_limit.down.sql b/server/migrations/000012_expand_ticket_call_limit.down.sql new file mode 100644 index 0000000..227edaf --- /dev/null +++ b/server/migrations/000012_expand_ticket_call_limit.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE projects + DROP CONSTRAINT projects_batch_size_check; + +ALTER TABLE projects + ADD CONSTRAINT projects_batch_size_check + CHECK (call_batch_size BETWEEN 1 AND 100); diff --git a/server/migrations/000012_expand_ticket_call_limit.up.sql b/server/migrations/000012_expand_ticket_call_limit.up.sql new file mode 100644 index 0000000..e7e2ef0 --- /dev/null +++ b/server/migrations/000012_expand_ticket_call_limit.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE projects + DROP CONSTRAINT projects_batch_size_check; + +ALTER TABLE projects + ADD CONSTRAINT projects_batch_size_check + CHECK (call_batch_size BETWEEN 1 AND 10000); diff --git a/task_plan.md b/task_plan.md index 6c5d2bf..8a08709 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,10 +4,68 @@ 在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。 ## Current Phase -Phase 29(游客手机号运营测试查询) +Phase 36(员工端取号同行人数输入修复) ## Phases +### Phase 36: 员工端取号同行人数输入修复 +- [x] 复现并定位同行人数输入框被 `1` 固定的状态更新原因 +- [x] 允许用户正常清空后输入合法人数,同时保留项目人数上下限校验 +- [x] 补充输入交互回归测试并完成页面验证 +- **Status:** complete + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---|---| +| 新增的“先清空再输入”回归断言在旧实现下收到值 `1` 而不是空值 | 1 | 作为缺陷复现基线保留;下一步修改表单草稿类型与 `onChange`,让编辑过程允许短暂空值 | +| 代码和测试修复后,自动化直接 `fill("")` 首次仍显示 `1` | 1 | 确认 Vite 已加载新源码;改用用户真实操作的全选 + Backspace 后值稳定为空,再输入 `3` 成功,判定为数字输入框与自动化 `fill` 的行为差异 | + +### Phase 35: 员工端队列按 10 个增量展开 +- [x] 确认前端与 API 服务已启动并通过健康检查 +- [x] 将“查看更多”从一次展开全部改为每次追加 10 个号码 +- [x] 保留收起能力,并在项目/场景切换时恢复默认 10 个 +- [x] 补充增量展开回归测试并完成全量验证 +- **Status:** complete + +### Phase 34: 员工端批量叫号按钮与指标分隔优化 +- [x] 将两个批量操作统一为浅色风格,并改名为“批量叫号 / 批量叫人” +- [x] 补回移动端“下一个号 / 下个号人数”之间的竖向分割线 +- [x] 更新员工端回归测试,保持叫号模式、参数与禁用逻辑不变 +- [x] 完成类型检查、全量测试、生产构建、真实页面和截图对照验收 +- **Status:** complete + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---|---| +| 已释放后重新接管的预览标签在 `fullPage` 截图中出现横向压缩,但 DOM 盒模型和普通视口截图均正常 | 1 | 不使用失真的全页图作为证据,改用同一 390×844 标签的普通视口截图并核对实际盒模型尺寸 | +| 追加 Phase 34 验收记录时误用了 Phase 33 的二级标题上下文 | 1 | 读取当前计划后改用实际三级标题和更小补丁,未影响业务文件 | + +### Phase 33: 项目新建/维护表单结构化 +- [x] 将现有字段归纳为“基础信息 / 叫号规则 / 其他规则”三个语义分组 +- [x] 优化默认值与单次上限的成对布局,保留现有提交契约 +- [x] 确保新建和维护页面共享结构,并补充可访问语义回归测试 +- [x] 完成类型检查、前端测试、生产构建与页面验收 +- **Status:** complete + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---|---| +| 带辅助文案的上限字段使成对输入框基线错位 | 1 | 将分组内 `.field` 设为顶部对齐,1440px 桌面与 390px 窄屏复验均通过 | + +### Phase 32: 员工端叫号指标与队列展示优化 +- [x] 删除统一叫号模块内部横向分隔线,并统一按号码/按人数按钮颜色 +- [x] 将叫号指标调整为下个号人数、剩余未叫号、剩余未叫人数 +- [x] 将队列行改为三列均匀对称布局,默认展示 10 个号码并支持展开/收起 +- [x] 移除队列内部滚动容器,让展开内容随页面自然延展 +- [x] 完成自动化、构建、真实页面和截图对照验收 +- **Status:** complete + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---|---| +| Product Design 用户上下文预检脚本按插件根目录推断路径时不存在 | 1 | 使用技能目录内实际脚本 `skills/user-context/scripts/user_context_preflight.py` 重新执行,确认无已保存用户上下文 | +| 由手机视口切换到桌面视口后直接刷新,应用内预览页重新挂载超时 | 1 | 保留现有浏览器连接,按故障指引新建同浏览器标签后继续桌面验收 | + ### Phase 28: 创建项目与项目维护表单统一 - [x] 让创建项目复用项目维护的完整字段与布局 - [x] 保证创建提交的项目配置与页面默认值一致 @@ -322,6 +380,12 @@ Phase 29(游客手机号运营测试查询) | 前端生产构建发现 BatchCard 测试仍传入已删除的 `readOnly` 属性 | 1 | 删除两个遗留测试属性后类型检查和生产构建通过 | | 本轮首次连接本地浏览器时运行时尚未初始化 | 1 | 按浏览器技能规范初始化运行时后重新连接,未影响代码验证 | | 本轮一次 Go 回归从仓库根目录执行,未找到 `server/go.mod` | 1 | 改在 `server/` 模块目录执行,时区回归测试通过 | +| Phase 31 初始化误将既有长期台账当作新文件 | 1 | 立即从 HEAD 精确恢复三份台账,确认无差异后改为只追加本轮记录;业务代码未受影响 | +| Phase 31 首次追加台账时误判 `progress.md` 标题 | 1 | 读取真实标题 `# Progress Log` 后改用精确上下文,不重复原失败补丁 | +| Phase 31 基线测试命令经 `pnpm` 透传后运行了全部前端测试,暴露 2 个既有游客查询测试失败 | 1 | 不修改无关游客功能;改用 `pnpm exec vitest run src/pages/StaffPage.test.tsx` 精确执行目标测试,并在最终全量验证中单独说明既有失败边界 | +| Phase 31 更新验证进度的补丁包含空 hunk | 1 | 删除无效 hunk 并按两个文件的精确上下文重新追加,业务代码与验证结果不受影响 | +| 浏览器连接在登录后打印 Statsig 外部初始化超时 | 1 | 页面导航、登录和 DOM 均正常完成;按应用自身控制台与页面状态继续验收,不把浏览器宿主统计请求误判为产品故障 | +| 浏览器手动删除通知分成两个步骤后,通知已先达到 5 秒自动消失 | 1 | 该结果先证明自动清理生效;手动路径改为在同一次浏览器调用内触发新通知并立即删除,避免工具往返时间干扰 | ## Phase 30 — 手机号查询后进入独立号码页(2026-07-15) @@ -334,3 +398,13 @@ Phase 29(游客手机号运营测试查询) - 所有网络资料保留来源链接和访问时间(2026-07-10)。 - 关键架构决策前重读本计划与 findings.md。 - taste 只用于反模板化与视觉纪律,不直接主导后台仪表盘;GSAP 只用于有明确状态/层级目的的编排。 + +### Phase 31: 员工端叫号模块合并、置顶与通知自动消失(2026-07-15) +- [x] 审计员工端当前叫号状态、通知、布局和既有未提交改动 +- [x] 叫号通知支持手动关闭,并在创建 5 秒后自动消失 +- [x] 按号码数量和按人数叫号合并为一个顶部大模块 +- [x] 删除两个模式小标题,只保留“按号数/人数叫号”主标题 +- [x] 整个叫号模块在员工页滚动时保持置顶 +- [x] 补齐定时、手动关闭、文案和滚动布局回归测试 +- [x] 通过前端测试、类型检查、生产构建和视觉验收 +- **Status:** complete diff --git a/web/src/api.test.ts b/web/src/api.test.ts index 1bd0856..ecbacf3 100644 --- a/web/src/api.test.ts +++ b/web/src/api.test.ts @@ -15,17 +15,18 @@ afterEach(() => { describe("mutating queue requests", () => { it("sends an idempotency key when creating a ticket", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ - ticket: { id: "ticket-1", display_number: "00001", status: "WAITING" }, + ticket: { id: "ticket-1", display_number: "00001", status: "WAITING", party_size: 3 }, revision: 2, })); vi.stubGlobal("fetch", fetchMock); - const response = await api.createTicket("project-1", { phone: "13800138000" }, "ticket-intent-key"); + const response = await api.createTicket("project-1", { phone: "13800138000", party_size: 3 }, "ticket-intent-key"); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; const headers = init.headers as Headers; expect(headers.get("Idempotency-Key")).toBe("ticket-intent-key"); expect(response.ticket.ticket_number).toBe("00001"); + expect(response.ticket.party_size).toBe(3); }); it("sends the current revision and an idempotency key when calling next", async () => { @@ -36,12 +37,12 @@ describe("mutating queue requests", () => { })); vi.stubGlobal("fetch", fetchMock); - await api.callNext("project-1", 7, 3, "call-intent-key"); + await api.callNext("project-1", 7, "PEOPLE", 3, "call-intent-key"); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; const headers = init.headers as Headers; expect(headers.get("Idempotency-Key")).toBe("call-intent-key"); - expect(JSON.parse(String(init.body))).toEqual({ expected_revision: 7, count: 3 }); + expect(JSON.parse(String(init.body))).toEqual({ expected_revision: 7, mode: "PEOPLE", count: 3 }); }); it("exposes nested API error code and details for explicit duplicate confirmation", async () => { @@ -57,7 +58,7 @@ describe("mutating queue requests", () => { })); vi.stubGlobal("fetch", fetchMock); - await expect(api.createTicket("project-1", { phone: "13800138000" })).rejects.toMatchObject({ + await expect(api.createTicket("project-1", { phone: "13800138000", party_size: 2 })).rejects.toMatchObject({ code: "DUPLICATE_PHONE", status: 409, details: { existing_ticket: "00001" }, @@ -80,6 +81,31 @@ describe("public visitor lookup", () => { expect(JSON.parse(String(init.body))).toEqual({ phone: "13800138000" }); expect(response.tickets[0].ticket_number).toBe("00012"); }); + + it("loads public projects and creates a ticket through the public endpoint", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ projects: [{ id: "project-1", name: "云岭漂流", status: "RUNNING", min_party_size: 1, max_party_size: 8 }] })) + .mockResolvedValueOnce(jsonResponse({ + ticket: { display_number: "00013", status: "WAITING", phone_last4: "8000", party_size: 4 }, + public_token: "visitor-token", + public_url: "/visitor/visitor-token", + revision: 3, + })); + vi.stubGlobal("fetch", fetchMock); + + await expect(api.publicProjects()).resolves.toEqual({ + projects: [{ id: "project-1", name: "云岭漂流", status: "RUNNING", min_party_size: 1, max_party_size: 8 }], + }); + const response = await api.publicCreateTicket("project-1", { phone: "13800138000", party_size: 4 }, "visitor-ticket-key"); + const [url, init] = fetchMock.mock.calls[1] as [string, RequestInit]; + + expect(url).toBe("/api/public/projects/project-1/tickets"); + expect(init.method).toBe("POST"); + expect((init.headers as Headers).get("Idempotency-Key")).toBe("visitor-ticket-key"); + expect(response.ticket.ticket_number).toBe("00013"); + expect(response.ticket.party_size).toBe(4); + expect(response.public_token).toBe("visitor-token"); + }); }); describe("isolated portal authentication", () => { diff --git a/web/src/api.ts b/web/src/api.ts index 0c9e525..bb4b1d0 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -12,6 +12,7 @@ import type { QueueTicketDto, CallBatchDto, AdminUserDto, + PublicProjectDto, } from "./types"; const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? ""; @@ -83,6 +84,7 @@ function normalizeTicket(ticket: QueueTicketDto): QueueTicketDto { return { ...ticket, ticket_number: ticket.ticket_number || ticket.display_number || "暂无", + party_size: Number(ticket.party_size) || 1, }; } @@ -91,9 +93,15 @@ function normalizeBatch(batch?: CallBatchDto | null): CallBatchDto | null | unde } function normalizeQueue(payload: QueueSnapshotDto): QueueSnapshotDto { + const waiting = (payload.waiting ?? []).map(normalizeTicket); return { ...payload, - waiting: (payload.waiting ?? []).map(normalizeTicket), + waiting, + metrics: { + ...payload.metrics, + waiting_ticket_count: payload.metrics.waiting_ticket_count ?? payload.metrics.waiting_count ?? waiting.length, + waiting_people_count: payload.metrics.waiting_people_count ?? waiting.reduce((sum, ticket) => sum + ticket.party_size, 0), + }, current_batch: normalizeBatch(payload.current_batch), recent_batches: payload.recent_batches?.map((batch) => normalizeBatch(batch) as CallBatchDto), }; @@ -125,11 +133,21 @@ export const api = { body: JSON.stringify(payload), }).then((response) => ({ ...response, ticket: normalizeTicket(response.ticket) })); }, - callNext(projectId: string, expectedRevision: string | number, count = 1, idempotencyKey?: string) { + publicProjects(signal?: AbortSignal) { + return request<{ projects: PublicProjectDto[] }>("/api/public/projects", { signal }); + }, + publicCreateTicket(projectId: string, payload: CreateTicketPayload, idempotencyKey?: string) { + return request(`/api/public/projects/${encodeURIComponent(projectId)}/tickets`, { + method: "POST", + headers: idempotencyHeaders(idempotencyKey), + body: JSON.stringify(payload), + }).then((response) => ({ ...response, ticket: normalizeTicket(response.ticket) })); + }, + callNext(projectId: string, expectedRevision: string | number, mode: "TICKET" | "PEOPLE", count = 1, idempotencyKey?: string) { return request(`/api/staff/projects/${encodeURIComponent(projectId)}/call-next`, { method: "POST", headers: idempotencyHeaders(idempotencyKey), - body: JSON.stringify({ expected_revision: expectedRevision, count }), + body: JSON.stringify({ expected_revision: expectedRevision, mode, count }), }).then((response) => ({ ...response, batch: normalizeBatch(response.batch) as CallBatchDto })); }, publicStatus(token: string, signal?: AbortSignal) { diff --git a/web/src/components/BatchCard.test.tsx b/web/src/components/BatchCard.test.tsx index 2c1f53e..3b89bb1 100644 --- a/web/src/components/BatchCard.test.tsx +++ b/web/src/components/BatchCard.test.tsx @@ -14,6 +14,7 @@ const batch = { last_name: "张", honorific: "先生", status: "CALLED", + party_size: 3, }], }; @@ -26,6 +27,8 @@ describe("BatchCard contact details", () => { expect(screen.queryByText("第 3 批")).not.toBeInTheDocument(); expect(screen.getByText("13800138000")).toBeVisible(); expect(screen.getByText("张先生")).toBeVisible(); + expect(screen.getByText("1 个号码 · 3 人")).toBeVisible(); + expect(screen.getByText("3 人")).toBeVisible(); expect(screen.getByText("您的号码已叫到")).toBeVisible(); }); diff --git a/web/src/components/BatchCard.tsx b/web/src/components/BatchCard.tsx index a59e603..a46cc9f 100644 --- a/web/src/components/BatchCard.tsx +++ b/web/src/components/BatchCard.tsx @@ -9,6 +9,8 @@ export function BatchCard({ batch: CallBatchDto; showContactDetails?: boolean; }) { + const peopleCount = batch.people_count ?? batch.tickets.reduce((total, ticket) => total + ticket.party_size, 0); + return (
@@ -18,7 +20,7 @@ export function BatchCard({

- {batchTicketCount(batch)} 个号码 + {batchTicketCount(batch)} 个号码 · {peopleCount} 人 {formatDateTime(batch.called_at)}

    @@ -26,6 +28,7 @@ export function BatchCard({
  • {ticket.ticket_number} + {ticket.party_size} 人 {showContactDetails ? ( 手机号 {ticket.phone || "未返回"} diff --git a/web/src/pages/AdminPage.test.tsx b/web/src/pages/AdminPage.test.tsx index 969b517..8f4857f 100644 --- a/web/src/pages/AdminPage.test.tsx +++ b/web/src/pages/AdminPage.test.tsx @@ -12,7 +12,7 @@ vi.mock("../hooks/usePollingResource", () => ({ { id: "super-1", username: "xqkwljtadmin", role: "ADMIN", active: true, protected: true, project_ids: [] }, ], } : { - summary: { running_projects: 1, waiting_count: 1, anomaly_projects: 0, offline_devices: 0 }, + summary: { running_projects: 1, waiting_count: 1, waiting_ticket_count: 1, waiting_people_count: 3, anomaly_projects: 0, offline_devices: 0 }, projects: [{ id: "project-1", code: "EAST-RIDE", @@ -22,20 +22,35 @@ vi.mock("../hooks/usePollingResource", () => ({ status: "RUNNING", batch_size: 5, call_batch_size: 5, + call_mode: "BOTH", + default_call_ticket_count: 5, + max_call_ticket_count: 20, + default_call_people_count: 8, + max_call_people_count: 30, + min_party_size: 1, + max_party_size: 8, grace_period_minutes: 5, + experienced_people_start: 10, visitor_notice: "请在入口附近等候。", - eta: { interval_per_number_seconds: 120 }, + eta: { interval_per_person_seconds: 120 }, waiting_count: 1, + waiting_ticket_count: 1, + waiting_people_count: 3, + issued_ticket_count: 15, + latest_ticket_number: "00015", current_batch: { id: "batch-1", batch_number: 4, status: "ACTIVE", tickets: [ - { id: "ticket-2", ticket_number: "00013", phone: "13800138000", status: "CALLED" }, - { id: "ticket-3", ticket_number: "00014", phone: "13800138001", status: "CALLED" }, + { id: "ticket-2", ticket_number: "00013", phone: "13800138000", status: "CALLED", party_size: 2 }, + { id: "ticket-3", ticket_number: "00014", phone: "13800138001", status: "CALLED", party_size: 3 }, ], + ticket_count: 2, + people_count: 5, }, estimated_wait: { available: true, lower_minutes: 5, upper_minutes: 15 }, + experienced_people: 12, device_status: { label: "模拟设备执行成功" }, }], active_tickets: [{ @@ -46,6 +61,7 @@ vi.mock("../hooks/usePollingResource", () => ({ phone: "13800138000", last_name: "张", honorific: "先生", + party_size: 3, status: "WAITING", created_at: "2026-07-10T09:00:00Z", }], @@ -68,7 +84,8 @@ describe("AdminPage active tickets", () => { expect(screen.getByRole("list", { name: "项目队列" })).toHaveTextContent("东门观光车"); expect(screen.getByRole("list", { name: "项目队列" })).toHaveTextContent("00013 至 00014"); expect(screen.getByText("运行项目").parentElement).toHaveTextContent("1"); - expect(screen.getByText("总等待号码").parentElement).toHaveTextContent("1"); + expect(screen.getByText("总等待人数").parentElement).toHaveTextContent("3"); + expect(screen.getByRole("list", { name: "项目队列" })).toHaveTextContent("3人 · 1 个号码"); expect(screen.queryByText("最近操作")).not.toBeInTheDocument(); expect(screen.queryByText("异常与提醒")).not.toBeInTheDocument(); expect(screen.queryByText("最新叫号")).not.toBeInTheDocument(); @@ -106,13 +123,25 @@ describe("AdminPage active tickets", () => { expect(screen.getByRole("form", { name: "维护项目" })).toBeVisible(); expect(screen.getByRole("form", { name: "维护项目" })).toHaveTextContent("项目名称"); expect(screen.getByRole("form", { name: "维护项目" })).toHaveTextContent("项目状态"); - expect(screen.queryByText("基础信息")).not.toBeInTheDocument(); - expect(screen.queryByText("规则配置")).not.toBeInTheDocument(); + expect(screen.getByRole("region", { name: "基础信息" })).toBeVisible(); + expect(screen.getByRole("region", { name: "叫号规则" })).toBeVisible(); + expect(screen.getByRole("region", { name: "其他规则" })).toBeVisible(); + expect(screen.getByRole("group", { name: "支持方式" })).toBeVisible(); + expect(screen.getByRole("group", { name: "单号人数" })).toBeVisible(); + expect(screen.getByRole("group", { name: "批量叫号" })).toBeVisible(); + expect(screen.getByRole("group", { name: "批量叫人" })).toBeVisible(); expect(screen.getByRole("combobox", { name: "票号格式" })).toHaveDisplayValue("00000"); expect(screen.queryByRole("textbox", { name: "时区" })).not.toBeInTheDocument(); expect(screen.queryByRole("textbox", { name: "票号前缀" })).not.toBeInTheDocument(); - expect(screen.getByRole("spinbutton", { name: "每次叫号数量" })).toHaveValue(5); - expect(screen.getByRole("spinbutton", { name: "单个号码预计间隔时间(秒)" })).toHaveValue(120); + expect(screen.getByRole("combobox", { name: "支持的叫号方式" })).toHaveDisplayValue("两种都支持"); + expect(screen.getByRole("spinbutton", { name: "单号最少人数" })).toHaveValue(1); + expect(screen.getByRole("spinbutton", { name: "单号最多人数" })).toHaveValue(8); + expect(screen.getByRole("spinbutton", { name: "批量叫号默认数量" })).toHaveValue(5); + expect(screen.getByRole("spinbutton", { name: "批量叫号单次上限" })).toHaveValue(20); + expect(screen.getByRole("spinbutton", { name: "批量叫人默认人数" })).toHaveValue(8); + expect(screen.getByRole("spinbutton", { name: "批量叫人单次上限" })).toHaveValue(30); + expect(screen.getByRole("spinbutton", { name: "已体验人数起始展示数" })).toHaveValue(10); + expect(screen.getByRole("spinbutton", { name: "每人预计间隔时间(秒)" })).toHaveValue(120); expect(screen.queryByRole("combobox", { name: "预计等待时间方式" })).not.toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "游客官方提示" })).toHaveValue("请在入口附近等候。"); expect(screen.getByRole("button", { name: "保存项目" })).toBeVisible(); @@ -121,12 +150,22 @@ describe("AdminPage active tickets", () => { it("创建项目与维护项目使用相同字段", () => { render(); expect(screen.getByRole("form", { name: "创建项目" })).toBeVisible(); + expect(screen.getByRole("region", { name: "基础信息" })).toBeVisible(); + expect(screen.getByRole("region", { name: "叫号规则" })).toBeVisible(); + expect(screen.getByRole("region", { name: "其他规则" })).toBeVisible(); expect(screen.getByRole("textbox", { name: "项目名称" })).toBeVisible(); expect(screen.getByRole("textbox", { name: "项目编码" })).toBeVisible(); expect(screen.getByRole("combobox", { name: "票号格式" })).toHaveDisplayValue("00000"); expect(screen.getByRole("combobox", { name: "项目状态" })).toHaveDisplayValue("未开放"); - expect(screen.getByRole("spinbutton", { name: "每次叫号数量" })).toHaveValue(1); - expect(screen.getByRole("spinbutton", { name: "单个号码预计间隔时间(秒)" })).toHaveValue(60); + expect(screen.getByRole("combobox", { name: "支持的叫号方式" })).toHaveDisplayValue("两种都支持"); + expect(screen.getByRole("spinbutton", { name: "单号最少人数" })).toHaveValue(1); + expect(screen.getByRole("spinbutton", { name: "单号最多人数" })).toHaveValue(10); + expect(screen.getByRole("spinbutton", { name: "批量叫号默认数量" })).toHaveValue(1); + expect(screen.getByRole("spinbutton", { name: "批量叫号单次上限" })).toHaveValue(100); + expect(screen.getByRole("spinbutton", { name: "批量叫人默认人数" })).toHaveValue(1); + expect(screen.getByRole("spinbutton", { name: "批量叫人单次上限" })).toHaveValue(100); + expect(screen.getByRole("spinbutton", { name: "已体验人数起始展示数" })).toHaveValue(0); + expect(screen.getByRole("spinbutton", { name: "每人预计间隔时间(秒)" })).toHaveValue(60); expect(screen.getByRole("textbox", { name: "游客官方提示" })).toHaveValue("请您在景区附近等候,注意听从工作人员指引。"); expect(screen.queryByRole("textbox", { name: "时区" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "创建项目" })).toBeVisible(); @@ -146,8 +185,15 @@ describe("AdminPage active tickets", () => { expect(createProject).toHaveBeenCalledWith({ name: "新项目", code: "NEW-RIDE", timezone: "Asia/Shanghai", ticket_prefix: "A" }); expect(updateProjectSettings).toHaveBeenCalledWith("project-2", { status: "NOT_OPEN", - call_batch_size: 1, + call_mode: "BOTH", + default_call_ticket_count: 1, + max_call_ticket_count: 100, + default_call_people_count: 1, + max_call_people_count: 100, + min_party_size: 1, + max_party_size: 10, grace_period_minutes: 5, + experienced_people_start: 0, eta_interval_seconds: 60, visitor_notice: "请您在景区附近等候,注意听从工作人员指引。", }); @@ -166,13 +212,20 @@ describe("AdminPage active tickets", () => { expect(screen.queryByText("13800138000")).not.toBeInTheDocument(); expect(screen.queryByText("全项目实时监控")).not.toBeInTheDocument(); expect(screen.queryByText("当前批次")).not.toBeInTheDocument(); + expect(screen.getByText("当前叫号")).toBeInTheDocument(); + expect(screen.queryByText("当前叫号 · 5 人")).not.toBeInTheDocument(); expect(screen.queryByText("设备状态")).not.toBeInTheDocument(); expect(screen.queryByText("大屏中心只展示公开票号与运行状态,不展示游客联系方式。")).not.toBeInTheDocument(); expect(screen.getByLabelText(/当前时间/)).toBeInTheDocument(); expect(screen.queryByText("当前项目")).not.toBeInTheDocument(); expect(screen.getByRole("heading", { name: "东门观光车" })).toBeVisible(); expect(screen.getByRole("heading", { name: "最新取号码:00015号" })).toBeInTheDocument(); - expect(screen.getByLabelText("总计等待:1 个号码")).toBeInTheDocument(); + expect(screen.getByLabelText("累计取号数:15 个")).toBeInTheDocument(); + expect(screen.getByLabelText("累计等待人数:3 人")).toBeInTheDocument(); + expect(screen.getByLabelText("已体验人数:12 人")).toBeInTheDocument(); + expect(document.querySelectorAll("[data-project-screen] .screen-tile__current-metrics .screen-tile__metric")).toHaveLength(4); + expect(document.querySelector("[data-project-screen] .screen-tile__clock .screen-tile__logo")).toBeInTheDocument(); + expect(document.querySelector("[data-project-screen] .screen-tile__project .screen-tile__logo")).toBeNull(); expect(screen.getByText("预计等待时间")).toBeVisible(); expect(screen.queryByText("后续号段预计")).not.toBeInTheDocument(); expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument(); diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index 4dcb0c9..e4ef933 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -6,7 +6,7 @@ import { EmptyState, FeedbackBanner, FreshnessBanner, LoadingState } from "../co import { StatusBadge } from "../components/StatusBadge"; import { usePollingResource } from "../hooks/usePollingResource"; import { formatDateTime, formatTicketNumberRange, isTimestampStale } from "../lib/format"; -import type { AdminOverviewDto, AdminProjectDto, AdminUserDto, CallBatchDto } from "../types"; +import type { AdminOverviewDto, AdminProjectDto, AdminUserDto, CallBatchDto, CallMode } from "../types"; import { forecastRows } from "./DisplayPage"; function currentBatchLabel(value: AdminProjectDto["current_batch"]): string { @@ -60,7 +60,7 @@ function OperationsOverview({ data, refreshing, onRefresh }: { data: AdminOvervi
    运行项目{summary.running_projects}
    -
    总等待号码{summary.waiting_count}
    +
    总等待人数{summary.waiting_people_count ?? 0}
    @@ -80,7 +80,7 @@ function OperationsOverview({ data, refreshing, onRefresh }: { data: AdminOvervi {projects.map((project) => (
    {project.name}{deviceStatusLabel(project.device_status)} - {project.waiting_count ?? 0}个号码 + {project.waiting_people_count ?? 0}人 · {project.waiting_ticket_count ?? project.waiting_count ?? 0} 个号码 {currentBatchLabel(project.current_batch)} @@ -104,8 +104,15 @@ type ProjectDraft = { name: string; code: string; status: string; - callBatchSize: string; + callMode: CallMode; + defaultCallTicketCount: string; + maxCallTicketCount: string; + defaultCallPeopleCount: string; + maxCallPeopleCount: string; + minPartySize: string; + maxPartySize: string; gracePeriodMinutes: string; + experiencedPeopleStart: string; etaIntervalSeconds: string; visitorNotice: string; }; @@ -115,9 +122,16 @@ function draftFor(project?: AdminProjectDto): ProjectDraft { name: project?.name ?? "", code: project?.code ?? "", status: project?.status ?? "NOT_OPEN", - callBatchSize: String(project?.call_batch_size ?? project?.batch_size ?? 1), + callMode: project?.call_mode ?? "BOTH", + defaultCallTicketCount: String(project?.default_call_ticket_count ?? project?.call_batch_size ?? project?.batch_size ?? 1), + maxCallTicketCount: String(project?.max_call_ticket_count ?? 100), + defaultCallPeopleCount: String(project?.default_call_people_count ?? 1), + maxCallPeopleCount: String(project?.max_call_people_count ?? 100), + minPartySize: String(project?.min_party_size ?? 1), + maxPartySize: String(project?.max_party_size ?? 10), gracePeriodMinutes: String(project?.grace_period_minutes ?? 5), - etaIntervalSeconds: String(project?.eta?.interval_per_number_seconds ?? 60), + experiencedPeopleStart: String(project?.experienced_people_start ?? 0), + etaIntervalSeconds: String(project?.eta?.interval_per_person_seconds ?? 60), visitorNotice: project ? project.visitor_notice ?? "" : DEFAULT_VISITOR_NOTICE, }; } @@ -154,8 +168,15 @@ function ProjectForm({ project, onSaved }: { project?: AdminProjectDto; onSaved: if (!projectId) throw new Error("项目创建未返回项目编号。"); await api.updateProjectSettings(projectId, { status: draft.status, - call_batch_size: Number(draft.callBatchSize), + call_mode: draft.callMode, + default_call_ticket_count: Number(draft.defaultCallTicketCount), + max_call_ticket_count: Number(draft.maxCallTicketCount), + default_call_people_count: Number(draft.defaultCallPeopleCount), + max_call_people_count: Number(draft.maxCallPeopleCount), + min_party_size: Number(draft.minPartySize), + max_party_size: Number(draft.maxPartySize), grace_period_minutes: Number(draft.gracePeriodMinutes), + experienced_people_start: Number(draft.experiencedPeopleStart), eta_interval_seconds: Number(draft.etaIntervalSeconds), visitor_notice: draft.visitorNotice, }); @@ -175,42 +196,138 @@ function ProjectForm({ project, onSaved }: { project?: AdminProjectDto; onSaved: return (
    {!project ?

    创建项目

    创建项目并同时设置运行规则。

    : null} -
    - - - - - - -