# 预约/售票 (TicketBooking) 模块 — 完整业务流程文档

> 基于 `/packages/private-materials/src/components/ticketBooking/` 和 `booking/` 两个目录（约 110 个文件）的全部代码提取。
> 提取日期: 2026-05-08

---

## 一、业务定位

这是一个 POS 收银端的**预约/售票系统**，服务于宠物店、美容院、活动场馆等需要"预约时间 + 分配资源 + 选择服务"的场景。系统支持两个业务子类型：

| 业务类型 | 说明 |
|---------|------|
| **Ticket（票务）** | 按时段预约服务/商品，支持资源分配、Holder 绑定 |
| **Food（餐饮）** | 餐饮点单模式，商品展示布局不同，但底层逻辑一致 |

---

## 二、核心业务实体

| 实体 | 含义 |
|------|------|
| **Booking（预约订单）** | 包含客户、日期、商品列表、支付状态、预约状态的完整预约单 |
| **Product（商品/服务）** | 可预约的库存商品，分多种扩展类型 |
| **Holder（预约主体）** | 预约服务的使用人/宠物，通过表单管理，与商品绑定 |
| **Resource（资源）** | 实体资源（房间/场地/设备），承载时间槽和容量约束 |
| **Service / Cart Item（购物车项）** | 添加到购物车的具体商品实例，含数量、价格、规格、时间等 |
| **Menu List（餐牌）** | 商品分类/分组的标识 |
| **Addon（附加服务）** | 主商品之外的附加项 |
| **Deposit（定金）** | 预约的预付款 |

---

## 三、预约模式分类

系统通过 `renderType` 区分三种预约模式：

### 3.1 普通预约 (booking)
- 标准时段预约，商品扩展类型为 `product_appointment`
- 按分钟/灵活时长排期
- 支持资源分配、Holder 绑定、加时服务

### 3.2 活动预约 (eventBooking)
- 独立的预约流程，不走常规商品逻辑
- 调用独立的 API：`createEventBooking` / `getEventBookingDetail` / `editEventBooking`
- 不显示备注模块，不显示加时按钮

### 3.3 跨日预约 (dayBooking)
- `renderType === 'dayBooking'`，按天计算时长
- 商品会被拆分为多个子商品，每天一个
- 通过 `groupId` 将同一天的商品分组管理
- 日期范围限制：结束日期不超过开始日期后 8 个月
- 仅显示按天计时的商品，过滤掉按分钟计时的

---

## 四、主要用户操作流程

### 4.1 创建预约（完整流程）

```
选择业务类型(Ticket/Food)
→ 选择日期和时间(TimeBar)
→ (可选)选择客户
→ 浏览商品(分类Tab + 搜索)
→ 选择商品：
  ├─ 普通商品：直接加入购物车
  ├─ 需弹窗商品：选择规格/套餐/场次/数量后确认
  ├─ 跨日商品：选择日期范围后生成子预约
  └─ 称重商品：输入毛重/净重后确认
→ (自动)为商品分配 Holder
→ (自动)检查库存 → 库存紧张时二次确认
→ (自动)触发促销计算 → 更新价格/赠品
→ (自动)触发报价单价格同步
→ 购物车管理(增/删/改)
→ 填写备注/自定义表单
→ 提交结算
→ (可选)设置定金
```

### 4.2 编辑预约

```
从预约列表/扫码进入
→ 校验 business_code（不支持则拒绝编辑）
→ 判断是否可编辑(支付状态 + 预约状态)
→ 可编辑态：增删商品、修改时间、切换客户、修改备注
→ 不可编辑态：仅查看
→ 拖动日历资源时弹出确认弹窗，展示原时段 → 新时段
```

### 4.3 扫码操作

支持两种扫码目标：
- **客户扫码**（钱包/通行证）：自动设置当前客户
- **商品扫码**：匹配 variant.code / variant.barcode → 加入购物车
- 扫码失败 → toast 提示"无搜索结果"

### 4.4 清空购物车

支付成功后触发，流程：清空购物车 → 恢复 intervalSetTime → 清除非 Walk-in 客户 → 重新激活扫码监听 → 清除折扣

---

## 五、核心业务规则

### 5.1 编辑禁用规则

以下情况预约**不可编辑**：

| 条件 | 说明 |
|------|------|
| 预约列表中任何商品缺少 detail 信息 | 数据不完整 |
| `appointment_status === 'cancelled'` | 已取消的预约 |
| 支付状态不在白名单内 | 仅 unfulfilled / paid / unpaid / partially_paid / payment_processing 可编辑 |
| `disabledEdit = true`（整单） | 通过状态计算得出 |
| `channelDisabledEdit = true` | 被其他渠道编辑过 |

### 5.2 支付状态枚举

| 状态 | 含义 | 可编辑 |
|------|------|--------|
| authorized | 已授权 | ❌ |
| paid | 已付款 | ✅ |
| partially_paid | 部分付款 | ✅ |
| unpaid | 未付款 | ✅ |
| unfulfilled | 未履行 | ✅ |
| payment_processing | 支付处理中 | ✅ |
| payment_pending | 等待付款 | ❌ |
| partially_refunded | 部分退款 | ❌ |
| refunded | 已退款 | ❌ |
| voided | 已作废 | ❌ |

### 5.3 购物车商品合并/叠加规则

两个商品被视为"相同"的条件（四把 Key 都相同才合并）：

- **rowKey**：product_id + variant_id + option + bundle + session + schedule
- **serviceKey**：product_id + duration + resource + start_time + total
- **holderKey**：product_id + holder_ids
- **note**：备注内容

即使 Key 相同也**不合并**的情况：
- 有商品券折扣（good_pass）的
- 被编辑过（edit=true）的
- 套餐中有商品券折扣的
- 正在参与营销活动的非赠品商品
- 赠品商品（始终独立）
- 称重商品

叠加配置通过 `cart_product_overlay` 控制：
- `product_all`：全部可合并
- `product_ids`：仅指定商品可合并，其余拆分为独立卡片

### 5.4 商品价格计算

```
单价 = 商品基础价 + (套餐子商品价格 × 数量) + (单规格价格 × 数量)
```

特殊情况：
- 选择了 `product_variant_id` → 使用变体价格
- 称重商品 → 使用称重计算的总金额
- 价格覆盖（priceOverride）→ 强制使用指定价格
- 整单折扣分摊 → 按各商品原价占比，最后一个吸收舍入误差
- 促销修改 → `_promotion.finalPrice` 覆盖原价

税费计算：
- 商品价格 ≤ 0 不计算
- 税率异常 `(1+税率) ≤ 0` 不计算
- 舍入差值追加到最后一个含税商品

附加费计算：
- 百分比附加费和固定附加费可同时收取
- 固定附加费按商品数量均摊，余数追加到最后一个商品
- 自定义商品不参与指定商品的附加费匹配

### 5.5 资源可用性判断（三维度）

| 维度 | 规则 |
|------|------|
| 时间范围 | 服务时间段必须在资源营业时间内；灵活时长仅检查开始时间 |
| 容量 | 单人模式：同一时段严格互斥；多人模式：剩余容量 ≥ 当前人数 |
| 商品关联 | 全商品/分类集合匹配/精确 product_id 匹配 |

**最终可用 = 时间满足 AND 容量满足 AND 商品关联满足**

### 5.6 预约时间排列模式

- **sequential（顺序）**：新加服务开始时间 = 上一个服务结束时间
- **parallel（并行）**：新加服务开始时间 = 上一个服务开始时间

### 5.7 Holder 管理

添加商品时自动弹出 Holder 选择弹窗的条件（全部满足）：
1. 商品不是普通预约商品
2. 商品当前无 holder_id
3. 当前客户不是 Walk-in
4. Holder 类型配置为 form
5. 配置允许添加时弹出

Holder 分配时：
- 多选 Holder → 商品拆分为独立卡片，每个绑定一个 Holder
- 必选 Holder 但用户取消 → 商品不能加入购物车
- 提交时校验：必选商品必须有 holder，数量必须匹配

### 5.8 Walk-in 客户判定

客户 id 为 0、1、"0"、"1" 或空字符串时，视为 Walk-in（散客）。散客不触发 Holder 选择逻辑。

### 5.9 时长类型

- **固定时长（minutes）**：以分钟为单位，从预设时间切片中选择
- **灵活时长（flexible）**：可延长到资源允许的最晚时间或营业结束时间（取较早者）

---

## 六、促销引擎（Promotion）

### 6.1 基本流程

```
购物车变更 → 分离主商品/赠品/编辑商品 → 转换格式
→ 查询每个商品的适用策略 → 调用 evaluator 计算定价
→ 映射回商品格式 → 标注 _promotion 信息
→ 计算赠品增/删/减操作 → 自动处理赠品
→ 计算未满足促销提示（"再买X件即可..."）
```

### 6.2 促销类型

- **X件Y元**：修改商品价格为促销价，`inPromotion: true`
- **买X送Y**：触发条件的主商品上标记 `giftInfo`，赠品价格为 0

### 6.3 赠品管理

- 自动添加：单选项赠品且策略赠品数量增加时自动补回
- 自动减少：策略赠品数量减少时按后进先出原则削减
- 自动删除：策略不适用时删除所有相关赠品
- 用户手动减少的赠品不会被自动补回（通过 `lastEvaluatedGiftCount` 判断）

### 6.4 不参与促销的情况

- 带 `booking_id` 的已有预约商品（编辑态保护）
- 赠品本身（不参与二次促销）

---

## 七、报价单（Quotation）

- 客户或日期变更后自动触发重新报价
- 跨日商品按日期获取每日报价
- 套餐子商品被后台删除时弹窗警告
- 报价单为空时用 `_extend.price` 兜底
- 编辑预约模式下不触发价格更新

---

## 八、定金（Deposit）

- 显示条件：存在 bookingId 且 `is_deposit !== 0`
- 操作限制：
  - 预约已取消不显示操作按钮
  - 定金已支付不显示操作按钮
- 删除 = 将 `deposit_amount` 设为 0

---

## 九、主屏/副屏双屏模式

- 通过 `runtime.role === 'secondary-screen'` 判断
- 主屏：完整功能，通过 `useSync` 将 `modalState` 同步到副屏
- 副屏：仅展示购物车，支持缩放和列数配置，连点 5 次打开设置弹窗
- 同步数据：client、date、pet、notes、contacts_info、service.value
- 时间变更（key='date'）不触发同步
- 仅 isActive 页面才发送同步

---

## 十、弹窗模式（Dialog）

支持两种使用场景：

| 场景 | 入口 | 行为 |
|------|------|------|
| 编辑模式 | 传入 order_id | 拉取预约详情 → 格式化 → 判断 business_code → 判断可编辑性 |
| 新建模式 | 传入 createModeConfig | 直接使用预填 modalState 初始化 |

---

## 十一、缓存策略

- 产品列表和 BoardConfig 使用 localStorage 缓存
- 以 osKey 区分不同页面实例
- 支持 maxAge 过期控制
- SWR 风格：先返回缓存，后台拉取最新数据更新

---

## 十二、商品扩展类型

| 扩展类型 | 说明 |
|---------|------|
| `product_appointment` | 预约商品（按时段，需资源和时间） |
| `session_product` | 场次商品 |
| `session_ticket` | 场次票务 |
| `appointment_ticket` | 预约票务 |
| `service_product` | 服务商品 |
| `normal` | 普通商品（无预约属性） |
| `product_add_time` | 加时商品 |

---

## 十三、商品分类判断

| 类型 | 判断条件 | 行为 |
|------|---------|------|
| Session 商品 | 有场次信息 | 时间受场次约束 |
| 纯 Session 商品 | 仅有场次、无规格/套餐 | 直接添加 |
| 需弹窗商品 | 有规格/套餐/Session | 弹出选择弹窗 |
| 普通预约商品 | 无以上属性 | 直接添加，弹窗自动关闭 |
| 跨日商品 | metadata.groupId 存在 | 先弹日期范围选择 |
| 称重商品 | open_sold_weight = true | 弹称重弹窗 |

---

## 十四、异常/边界处理汇总

| 场景 | 处理 |
|------|------|
| 不在可售时间 | 弹出 NotAvailable 弹窗 |
| 库存售罄 | 阻止添加，toast "已售罄" |
| 库存紧张 | 二次确认弹窗，可勾选"今日不再提示" |
| 资源冲突 | 标记为不可选，提示具体冲突原因 |
| 报价单商品变更 | 弹窗警告"商品套餐已变更" |
| 重复快速添加 | 防抖合并，同商品累积数量 |
| 网络离线/弱网 | 状态指示 + 本地缓存继续可用 |
| business_code 不支持 | 编辑模式下拒绝编辑 |
| 提交时无预约商品且无 order_type | 自动切换为 virtual 虚拟订单 |
| 持有人必选但未选 | 提交校验失败，滚动到对应行 |
| Terminal 版本过旧 | 弹出升级提示弹窗 |
| 编辑已有预约时不显示加时按钮 | 防止编辑态误操作 |
| 关闭弹窗时未保存 | 弹出退出确认 |
| 同一商品短时间多次添加 | 合并为一次，防重复 |

---

## 十五、核心 API 清单

| API | 用途 |
|-----|------|
| `getClients` | 获取客户列表（分页） |
| `getClientsEs` | 从 ES 获取客户数据 |
| `getServices` | 获取服务/商品列表 |
| `getProducts` | 查询产品列表 |
| `getResources` | 获取资源列表 |
| `getForms` | 获取表单列表 |
| `getFormRecords` | 获取表单记录 |
| `getPets` | 获取宠物信息 |
| `getPetsData` | 获取宠物数据 |
| `getBookingConfig` | 获取预约 Board 配置 |
| `getBookingDetail` | 获取预约详情 |
| `getEventBookingDetail` | 获取活动预约详情 |
| `getBookingList2` | 获取预约列表 |
| `getOrderInfoByCode` | 根据机器码获取订单信息 |
| `getOrderDetail` | 获取订单详情 |
| `createBooking` | 创建预约 |
| `editBooking` | 编辑预约 |
| `editBookingStatus` | 更新预约状态 |
| `createEventBooking` | 创建活动预约 |
| `editEventBooking` | 编辑活动预约 |
| `voidAppointment` | 作废预约 |
| `getAddonsByOrderId` | 获取预约附加服务 |
| `saveAddonsByOrderId` | 保存预约附加服务 |
| `getNotesByOrder` | 获取预约备注 |
| `addNotesByOrder` | 添加预约备注 |
| `editNoteByOrder` | 编辑预约备注 |
| `removeNoteByOrder` | 删除预约备注 |
| `editOrderNote` | 编辑订单备注 |
| `saveBookingFormData` | 保存预约表单数据 |
| `getPaymentList` | 获取支付列表 |
| `getRefundInfo` | 获取退款信息 |
| `getDepositShow` | 获取定金显示配置 |
| `editDepositAmount` | 编辑定金金额 |
| `getProductList` | 获取产品列表 |