Web 日结模块 — CUJ 关键用户旅程

Day-End Closeout & Cash Drawer | 更新时间: 2026-02-09

TOUCHES (Pages): admin/day-end-closeout/page.tsx, stores/[id]/page.tsx (入口按钮)

TOUCHES (Components - Reconciliation/): DayEndCloseoutModal.tsx (主 5 步向导), OrderStatusStep.tsx (Step 1), TipsSettlementStep.tsx (Step 2), RegisterClosureStep.tsx (Step 3), PaymentReconStep.tsx (Step 4), DataLockStep.tsx (Step 5), CloseRegisterModal.tsx, CurrencyCountModal.tsx, TipDistributionPreview.tsx, ChecklistStep.tsx, CloseoutCompletedView.tsx, UnlockCloseoutModal.tsx

TOUCHES (Components - CashDrawer/): CloseRegisterModal.tsx (legacy duplicate)

目录

CUJ 总览与优先级矩阵

CUJ优先级描述触发点业务价值E2E 状态
C1 P0 完整日结流程 5 步走 营业日结束时 核心财务流程 — 确保账目准确并锁定 部分覆盖
C2 P0 收银台管理 营业日开始/结束 现金流追踪,差异管控 部分覆盖
C3 P1 日结历史查询 管理员查看过往记录 审计追溯,财务核查 部分覆盖
C4 P1 解锁日结 发现日结有误需修正 纠错能力(需超级管理员权限) 缺失

日结架构概览

核心组件关系

flowchart TD
    ENTRY1["admin/day-end-closeout/page.tsx"] --> MODAL
    ENTRY2["stores/[id]/page.tsx 日结按钮"] --> MODAL

    MODAL["DayEndCloseoutModal.tsx - 5 步向导"] --> S1["Step 1: OrderStatusStep"]
    MODAL --> S2["Step 2: TipsSettlementStep"]
    MODAL --> S3["Step 3: RegisterClosureStep"]
    MODAL --> S4["Step 4: PaymentReconStep"]
    MODAL --> S5["Step 5: DataLockStep"]

    S2 --> TDP["TipDistributionPreview"]
    S3 --> CRM["CloseRegisterModal"]
    CRM --> CCM["CurrencyCountModal 面额清点"]

    S5 -->|"完成"| CCV["CloseoutCompletedView 汇总"]

    style MODAL fill:#2196F3,stroke:#1565C0,color:#fff
    style CCV fill:#4CAF50,stroke:#2E7D32,color:#fff

日结状态生命周期

stateDiagram-v2
    [*] --> in_progress: 点击 Start Closeout
    in_progress --> completed: Step 5 确认完成
    completed --> locked: 自动锁定

    locked --> completed: 超级管理员解锁
    completed --> locked: 重新锁定

    note right of in_progress: 5 步向导进行中
    note right of locked: 数据不可修改

5 步依赖关系

步骤组件阻塞条件API
Step 1 订单状态 OrderStatusStep 有预约状态不在 completed/cancelled 中 GET /api/day-end-closeout/:id/pending-orders
Step 2 小费结算 TipsSettlementStep Step 1 未完成 / 有未分配小费 GET|POST /api/day-end-closeout/:id/pending-tips
Step 3 收银台关闭 RegisterClosureStep Step 1+2 未完成 / 有未关闭的 cash_drawer_session GET /api/day-end-closeout/:id/unclosed-registers
Step 4 支付对账 PaymentReconStep MVP 无阻塞,手动确认即可 GET /api/day-end-closeout/:id/payment-summary
Step 5 确认锁定 DataLockStep Step 1-4 全部完成 POST /api/day-end-closeout/:id/complete
关键理解: 日结不改变预约的 status 字段。日结锁定的是日期级别的数据 — 锁定后该 business_date 下的所有预约、发票、交易均变为只读。
跨模块链接: Step 1 检查预约状态 ← CUJ-B4 预约状态生命周期 | Step 3 关闭收银台 ← CUJ-F1/F2 结账收款(现金交易关联 cash_drawer_session)| 日结锁定 → 阻止 CUJ-F6 退款(需先解锁 C4)

关键业务规则

唯一约束: 每个门店每个 business_date 只能有一个日结记录
顺序依赖: 5 步必须依次完成,不可跳步
数据锁定: locked 后所有 API 写操作会被 dataLockingService 拦截
小费分配: 两种算法 — equal(均分)或 by_service_price(按服务金额比例),可配置店铺抽成比例
Step 4 MVP: 当前仅为手动确认,未自动对接 CodePay 批量对账报告(Future)

CUJ-C1: 完整日结流程 5 步走

P0 核心财务流程 — 确保每日营业数据准确并锁定

用户流程

flowchart TD
    A["admin/day-end-closeout 或 stores 页面"] --> B["点击 Start Closeout"]
    B --> C["创建 day_end_closeouts 记录 (in_progress)"]
    C --> D["DayEndCloseoutModal 打开"]

    D --> S1["Step 1: OrderStatusStep"]
    S1 --> S1A{"有待处理预约?"}
    S1A -->|"是"| S1B["列出待处理预约,逐个处理"]
    S1A -->|"否"| S1C["自动标记完成"]
    S1B --> S1C

    S1C --> S2["Step 2: TipsSettlementStep"]
    S2 --> S2A["TipDistributionPreview 显示预览"]
    S2A --> S2B["点击 Distribute Tips"]
    S2B --> S2C["tipDistributionService 执行分配"]

    S2C --> S3["Step 3: RegisterClosureStep"]
    S3 --> S3A{"有未关闭收银台?"}
    S3A -->|"是"| S3B["打开 CloseRegisterModal"]
    S3B --> S3C["CurrencyCountModal 面额清点"]
    S3C --> S3D["对比: 实际 vs 系统记录"]
    S3D --> S3E{"有差异?"}
    S3E -->|"是"| S3F["填写差异原因"]
    S3E -->|"否"| S3G["关闭收银台"]
    S3F --> S3G
    S3A -->|"否"| S4

    S3G --> S4["Step 4: PaymentReconStep (MVP)"]
    S4 --> S4A["显示卡/现金交易汇总"]
    S4A --> S4B["点击 Confirm Reviewed"]

    S4B --> S5["Step 5: DataLockStep"]
    S5 --> S5A["显示全日汇总数据"]
    S5A --> S5B["点击 Complete Closeout"]
    S5B --> S5C["status: completed → locked"]
    S5C --> S5D["CloseoutCompletedView 完成"]

    style D fill:#2196F3,stroke:#1565C0,color:#fff
    style S5D fill:#4CAF50,stroke:#2E7D32,color:#fff
    style S3F fill:#FF9800,stroke:#E65100,color:#fff

BDD 场景

场景 C1.1: 完整日结 — Happy Path

Given 用户已登录并具有 day_end_closeout:startday_end_closeout:viewday_end_closeout:complete 权限,且 scope 覆盖当前门店
  And 今日所有预约已 completed 或 cancelled
  And 所有发票的小费已有对应 tip_distributions 记录
  And 所有 cash_drawer_sessions 已关闭
When 用户在 admin/day-end-closeout 页面点击 "Start Closeout"
Then 创建 day_end_closeouts 记录,status = in_progress
  And DayEndCloseoutModal 打开
  And Step 1 自动标记完成(无待处理订单)
When Step 2: 点击 "Distribute Tips"
Then TipDistributionPreview 显示分配预览后执行分配
When Step 3: 所有收银台已关闭,自动标记完成
When Step 4: 确认支付汇总
When Step 5: 点击 "Complete Closeout"
Then 计算最终汇总(total_sales, total_cash, total_card, total_tips, total_variance)
  And status 变为 locked,显示 CloseoutCompletedView
  And 当天所有数据变为只读

测试: day-end-closeout.spec.ts (部分覆盖 — 缺完整 5 步 E2E)

场景 C1.2: Step 1 — 有未完成预约

Given 用户已登录并具有 day_end_closeout:viewday_end_closeout:complete 权限,且 scope 覆盖当前门店
  And 今日有 2 个预约状态为 pending_payment(服务完成未结账)
When 用户打开日结向导
Then Step 1 OrderStatusStep 显示: "2 个待处理订单"
  And Step 2-5 被禁用(灰色不可点击)
When 跳转到预约看板将 2 个预约标记为 completed
  And 返回日结向导,刷新
Then Step 1 标记为完成
  And Step 2 变为可用

测试: day-end-closeout.spec.ts (部分覆盖)

场景 C1.3: Step 2 — 小费分配(含店铺抽成)

Given 用户已登录并具有 day_end_closeout:complete 权限,且 scope 覆盖当前门店
  And Step 1 已完成
  And 今日总小费 $120.00,店铺抽成 10%
  And 分配算法为 by_service_price
When 进入 Step 2
Then TipDistributionPreview 显示:
  — 总小费: $120.00
  — 店铺抽成: $12.00 (10%)
  — Alice: $64.80 (按服务金额占比 60%)
  — Bob: $43.20 (按服务金额占比 40%)
When 点击 "Distribute Tips"
Then 创建 tip_distributions 记录,status = distributed
  And Step 2 完成

测试: 缺失 — 需新增

场景 C1.4: Step 3 — 关闭收银台(有现金差异)

Given 用户已登录并具有 day_end_closeout:completecash_drawer:close 权限,且 scope 覆盖当前门店
  And Step 1+2 已完成
  And 门店有 1 个未关闭的 cash_drawer_session
When 进入 Step 3,RegisterClosureStep 显示未关闭列表
  And 点击 "Close Register" 打开 CloseRegisterModal
  And 打开 CurrencyCountModal 清点:
  — $100 x 3, $20 x 5, $10 x 2, $1 x 18 = $438.00
Then 系统应有现金: $443.00
  And 差异: -$5.00
When 填写差异原因 "找零误差"
  And 确认关闭收银台
Then cash_drawer_session status = closed
  And 差异记录保存(cash_difference = -5.00)
  And Step 3 完成

测试: cash-drawer/day-end-closeout.spec.ts, full-flow/11-cash-drawer.spec.ts (部分覆盖)

场景 C1.5: Step 4 — 支付对账 MVP

Given 用户已登录并具有 day_end_closeout:complete 权限,且 scope 覆盖当前门店
  And Step 1-3 已完成
When 进入 Step 4
Then PaymentReconStep 显示:
  — 卡交易: 15 笔,总计 $2,340.00
  — 现金交易: 8 笔,总计 $680.00
When 用户点击 "Confirm Reviewed"
Then Step 4 完成
MVP 限制: 当前 Step 4 仅显示汇总数字并要求手动确认。未来计划对接 CodePay 批量对账报告,自动比对每笔刷卡交易。

场景 C1.6: Step 5 — 最终锁定

Given 用户已登录并具有 day_end_closeout:complete 权限,且 scope 覆盖当前门店
  And Step 1-4 全部完成
When 进入 Step 5 DataLockStep
Then 显示全日汇总: 总营收、各支付方式金额、小费、差异
When 点击 "Complete Closeout"
Then status: in_progresscompletedlocked
  And locked_at 时间戳记录
  And 显示 CloseoutCompletedView(完成汇总页)
  And 当天预约/发票/交易 API 写操作被 dataLockingService 拦截

测试: 缺失 — 需完整 5 步 E2E

场景 C1.7: 继续未完成的日结

Given 用户已登录并具有 day_end_closeout:view 权限,且 scope 覆盖当前门店
  And 有一个 status = in_progress 的日结记录(昨天开始未完成)
When 用户打开日结页面
Then 显示 "Continue Closeout" 按钮(非 "Start")
When 点击继续
Then DayEndCloseoutModal 打开并自动跳转到第一个未完成的步骤

CUJ-C2: 收银台管理

P0 现金流追踪 — 收银台开启/关闭/对账

收银台生命周期

stateDiagram-v2
    [*] --> open: POST /api/cash-drawer/open
    open --> open: 现金交易累计
    open --> closed: POST /api/cash-drawer/close

    note right of open: 追踪所有现金进出
    note right of closed: 计算差异并记录

收银台对账公式

Expected Cash = opening_cash + sum(cash transactions) - sum(cash refunds)
Expected Card = sum(card transactions)
Cash Difference = actual_cash - expected_cash
Total Variance = cash_difference + card_difference

BDD 场景

场景 C2.1: 开启收银台

Given 用户已登录并具有 cash_drawer:open 权限,且 scope 覆盖当前门店
  And 当前门店没有已打开的 cash_drawer_session
When 员工点击 "Open Register"
  And 输入 opening_cash = $200.00
  And 确认
Then 创建 cash_drawer_session(status = open
  And 后续现金交易自动关联到此 session

测试: full-flow/11-cash-drawer.spec.ts (部分覆盖)

场景 C2.2: 重复开启 — 被拒绝

Given 用户已登录并具有 cash_drawer:open 权限,且 scope 覆盖当前门店
  And 当前门店已有一个 open 的 cash_drawer_session
When 另一员工尝试 "Open Register"
Then 被拒绝: "该门店已有一个打开的收银台"

场景 C2.3: 关闭收银台 — 无差异

Given 用户已登录并具有 cash_drawer:close 权限,且 scope 覆盖当前门店
  And 收银台 opening_cash = $200, 今日现金收入 $350, 现金退款 $20
  And 系统 expected_cash = $530
When CloseRegisterModal 中清点实际现金 = $530
Then cash_difference = $0.00
  And 无需填写差异原因
When 输入 bank_deposit(银行存款金额)和 next_day_opening(次日开班金额)
  And 确认关闭
Then session status = closed

场景 C2.4: 关闭收银台 — 有差异

Given 用户已登录并具有 cash_drawer:close 权限,且 scope 覆盖当前门店
  And 系统 expected_cash = $530
When 清点实际现金 = $525
Then 显示 cash_difference = -$5.00
  And 差异原因字段变为必填
When 填写 "找零误差"
  And 确认关闭
Then 差异记录保存
  And 日结 total_variance 更新

测试: cash-drawer/day-end-closeout.spec.ts (部分覆盖)

场景 C2.5: 面额清点

Given 用户已登录并具有 cash_drawer:close 权限,且 scope 覆盖当前门店
  And CloseRegisterModal 中点击 "Count by Denomination"
When CurrencyCountModal 输入:
  $100 x 3 = $300, $50 x 0, $20 x 5 = $100,
  $10 x 2 = $20, $5 x 0, $1 x 18 = $18, coins = $0
Then 合计显示: $438.00
  And 自动填入 CloseRegisterModal 的 actual_cash 字段

测试: full-flow/11-cash-drawer.spec.ts (部分覆盖)

场景 C2.6: 查看当前收银台状态

Given 用户已登录并具有 cash_drawer:open 权限,且 scope 覆盖当前门店
  And 收银台已打开
When 查询 GET /api/cash-drawer/current?store_id=xxx
Then 显示: 开班员工、开班时间、opening_cash
  And 实时统计: cash_sales, card_sales, total_transactions, expected_cash

CUJ-C3: 日结历史查询

P1 审计追溯 — 查看过往日结记录

BDD 场景

场景 C3.1: 查看历史日结

Given 用户已登录并拥有对应功能所需权限与范围(scope),在 admin/day-end-closeout 页面
When 选择过去的日期(如 2026-02-07)
Then 显示该日期的日结汇总:
  — 总营收、现金/卡交易金额
  — 小费总计和分配详情
  — 现金差异
  — 操作人、完成时间
  And 所有数据为只读(status = locked

测试: day-end-closeout.spec.ts (部分覆盖)

场景 C3.2: 查看未完成日结

Given 有 status = in_progress 的日结记录
When 在历史列表中查看
Then 显示状态标签 "In Progress"
  And 提供 "Continue" 按钮继续完成

CUJ-C4: 解锁日结

P1 纠错能力 — 超级管理员解锁已完成的日结

用户流程

flowchart TD
    A["查看已锁定的日结 (status: locked)"] --> B["发现数据有误"]
    B --> C["点击 Unlock Closeout"]
    C --> D{"权限检查: day_end_closeout:unlock"}
    D -->|"有权限"| E["UnlockCloseoutModal 打开"]
    D -->|"无权限"| F["按钮不可见/禁用"]
    E --> G["输入解锁原因(必填)"]
    G --> H["POST /api/day-end-closeout/:id/unlock"]
    H --> I["status: locked → completed"]
    I --> J["数据解锁,可修改"]
    J --> K["修正数据后重新完成日结"]

    style E fill:#FF9800,stroke:#E65100,color:#fff
    style I fill:#4CAF50,stroke:#2E7D32,color:#fff
    style F fill:#f44336,stroke:#c62828,color:#fff

BDD 场景

场景 C4.1: 超级管理员解锁

Given 用户已登录并具有 day_end_closeout:unlock 权限与对应范围(scope)
  And 查看 status = locked 的日结
When 点击 "Unlock Closeout"
  And UnlockCloseoutModal 中输入原因: "发现小费分配错误需修正"
  And 确认解锁
Then status 变为 completed(非 in_progress)
  And 数据变为可编辑
  And 审计日志记录: 解锁人、解锁时间、原因

测试: 缺失 — 需新增

场景 C4.2: 权限不足

Given 用户为普通 manager,无 day_end_closeout:unlock 权限
When 查看已锁定的日结
Then "Unlock Closeout" 按钮不可见

测试: 缺失 — 需新增

场景 C4.3: 解锁后重新锁定

Given 日结已解锁,status = completed
When 修正完数据后,重新打开 DayEndCloseoutModal
  And 完成 Step 5
Then status 重新变为 locked
  And 更新 locked_at 和 completed_by

E2E 覆盖状态

测试文件覆盖的 CUJ测试场景
day-end-closeout.spec.ts C1, C3 日结页面加载、检查项列表、历史查看
cash-drawer/day-end-closeout.spec.ts C1 (Step 3), C2 现金柜关闭、差异对账
full-flow/11-cash-drawer.spec.ts C2 现金柜完整流程(开启→交易→关闭)

覆盖缺口

缺口CUJ缺失内容
完整 5 步端到端 C1 从 Start → Step 1-5 → locked 的完整 E2E
小费分配流程 C1 (Step 2) TipDistributionPreview + distribute-tips 调用
解锁日结 C4 UnlockCloseoutModal 完整 E2E
数据锁定验证 C1 locked 后尝试修改预约/交易被拦截