Преглед изворни кода

docs: 更新进出场逻辑流程文档和测试用例

lq пре 1 месец
родитељ
комит
2211731466
3 измењених фајлова са 785 додато и 192 уклоњено
  1. 488 192
      doc/进出场逻辑流程.md
  2. 297 0
      test/testutil/e2e_edge_cases_test.go
  3. BIN
      wails-app.exe

+ 488 - 192
doc/进出场逻辑流程.md

@@ -1,245 +1,541 @@
 # 智慧停车 — 进出场逻辑流程
 
-> 日期:2026-07-24
+> 更新日期:2026-07-29
+
+---
+
+## 0. 车辆类型体系
+
+系统通过 `vehicle_type` 表管理车辆类型,每种类型关联独立的计费配置 `fee_config`。
+
+### 0.1 车辆类型分类
+
+| 维度 | 分类 | 说明 |
+|------|------|------|
+| **按身份** | 临时车(is_system=true) | 新进场自动创建,无车主关联 |
+| | 固定车(is_system=false) | 预先注册,关联 `Owner`,可配置 VIP |
+| **按名单** | 白名单(包月车) | 在 `shortlist` 表中有记录,ListType=白名单 |
+| | 黑名单 | 在 `shortlist` 表中有记录,ListType=黑名单 |
+| | 普通车辆 | 不在任何名单中 |
+| **按VIP** | VIP 车辆 | Owner.IsVip=true 且 VipExpireTime 未过期 |
+| | 非 VIP 车辆 | 无 VIP 或已过期 |
+
+### 0.2 默认类型
+
+系统初始化时自动创建唯一系统内置类型:**临时车**(Name="临时车", IsSystem=true)。
+管理员可在管理后台创建更多车辆类型(如"月租车"、"内部车"、"VIP 车辆"等),每种类型可独立配置 `fee_config`。
+
+### 0.3 计费配置(fee_config)
+
+每个 `vehicle_type` 对应一条计费规则:
+
+| 字段 | 说明 |
+|------|------|
+| StartTime | 免费时长(分钟) |
+| StartFee | 起步价 |
+| UnitTime | 计费单位时间(分钟) |
+| UnitFee | 单位费用 |
+| DailyMaxFee | 每日封顶(0=不封顶) |
+| IsVIPFree | VIP 是否免费 |
+| VIPDiscount | VIP 折扣率(0.8=打8折) |
+
+---
 
 ## 1. 触发方式总览
 
 ```
 车辆到达/离开
-  ├─ RFID 自动识别  → PassageService.HandlePassage()  → 自动进出场
-  ├─ 手动操作       → entryExit.vue                 → 手动进出场
-  └─ 摄像头车牌识别  → (待实现 P0-5)
+  ├─ ① RFID 自动识别 → reader.go → HandlePassage()  → 自动进出场(开闸)
+  ├─ ② 手动操作前端   → entryExit.vue → 手动进场 / 出场
+  └─ 摄像头车牌识别  → (待实现 P0-5)
 ```
 
+### 触发方式对比
+
+| 特性 | RFID 自动 | 手动录入 |
+|------|-----------|---------|
+| 用户体验 | 无需停车,自动抬杆 | 需操作员输入车牌/RFID |
+| 入场流程 | HandlePassage → handleEntry → VehicleEntry → 自动开闸 | 前端 API → VehicleEntry → 手动抬杆(仅记录) |
+| 出场流程 | HandlePassage → handleExit → ExitConfirm(free) → 自动开闸 | 前端 ExitPreview → 选择支付方式 → ExitConfirm → 手动抬杆(仅记录) |
+| 支付方式 | 统一走 `free`(无现金交易,账户结算) | 临时车:现金收款;固定车:免密 free |
+| 通道限制 | 绑定 UHF 设备 → 查 Channel → 校验方向 + 临时车权限 | 无通道级校验 |
+
 ---
 
-## 2. 入场流程
+## 2. 场流程
 
-### 2.1 流程图
+### 2.1 总体流程
 
 ```
 车辆到达入口
-  ├── 手动录入 ──────────────────────────────┐
-  │   前端 entryExit.vue                       │
-  │   输入车牌/RFID → POST /vehicle/entry       │
-  │                                           │
-  ├── RFID 自动识别 ─────────────────────────  │
-  │   reader.go → ParseBuffer()               │
-  │   → PassageService.HandlePassage()        │
-  │   → 防抖检查(3秒去重)                     │
-  │   → handleEntry()                         │
-  │                                           │
-  ▼                                           ▼
-┌─────────────────────────────────────────────────┐
-│              VehicleEntry()                      │
-│  internal/service/vehicle/vehicle.go            │
-│                                                  │
-│  ① 黑名单检查                                     │
-│     CheckVehicleShortlist(plate, rfid)           │
-│     ├─ 在黑名单 → 拦截,返回错误                    │
-│     └─ 不在 → 继续                               │
-│                                                  │
-│  ② 车辆查询                                      │
-│     GetVehicleByPlateNumber(plate, rfid)         │
-│     ├─ 找到 → 使用现有车辆                        │
-│     └─ 未找到 → 新建车辆(默认 is_system类型=临时车) │
-│                                                  │
-│  ③ 重复入场检查                                   │
-│     ┌── 已有未出场记录 → 拦截                     │
-│     └── 无 → 继续                                │
-│                                                  │
-│  ④ 创建入场记录                                   │
-│     INSERT vehicle_record                        │
-│     (plate_number, rfid_tag, entry_time,         │
-│      parking_lot_id, payment_status=unpaid)      │
-│                                                  │
-│  ⑤ 创建数字票                                    │
-│     digital_ticket.Create()                      │
-│     状态: pending_payment                        │
-│     事件日志: [{"event":"created", ...}]          │
-│                                                  │
-│  ⑥ 更新车辆统计                                   │
-│     vehicle.last_entry_time = now                │
-│                                                  │
-│  ⑦ RFID → 开闸(GateOpener)                     │
-│     手动 → 仅记录,需手动命令抬杆                    │
-└─────────────────────────────────────────────────┘
-```
-
-### 2.2 RFID 入场特有检查
-
-```
-RFID 扫描 → PassageService.HandlePassage()
-  │
-  ├── 防抖(3秒内重复扫描忽略)
-  ├── 查询设备 → 获取通道 → 确定方向/direction
-  ├── 查询通道 → allowTemporary(是否允许临停)
-  ├── 查询车辆 → 判断是否为临时车(VehicleType.IsSystem)
-  ├── 黑名单检查
-  ├── 临时车 && 通道不允许临停 → 拦截
-  └── 入场状态检查(epcStatus 防重入)
+  ├── RFID 自动识别 ─────────────────────────────────────────────
+  │   reader.go 协程读取 UHF 数据
+  │   → ParseBuffer() 解析报文 + CRC校验
+  │   → HandlePassage(PassageRequest{RFIDTag: epc, DeviceCode})
+  │
+  ├── 手动录入 ──────────────────────────────────────────────────
+  │   entryExit.vue 进场Tab
+  │   输入 车牌/RFID → 查询车辆信息(显示类型/车主)
+  │   选择停车区域 → 点击"确认入场"
+  │   → POST /vehicle/entry → VehicleEntry()
+  │
+  ├── (摄像头计划) ──────────────────────────────────────────────
+  │   (待实现)
+  │
+  ▼
+┌─────────────────────────────────────────────────────────────────┐
+│                    VehicleEntry() 核心入口                        │
+│  internal/service/vehicle/vehicle.go:177                        │
+│                                                                  │
+│  ① 黑名单检查                                                    │
+│     CheckVehicleShortlist(plate, rfid)                           │
+│     ├─ 在黑名单 → 拦截,返回 "车辆在黑名单中,禁止入场"               │
+│     └─ 不在 → 继续                                               │
+│                                                                  │
+│  ② 车辆查询/自动创建                                              │
+│     GetVehicleByPlateNumber(plate, rfid)                         │
+│     ├─ 找到 → 使用现有车辆信息(类型/RFID/Owner)                   │
+│     └─ 未找到 → 自动创建临时车                                    │
+│        创建 vehicle(IsSystem=true 的类型, 无Owner)                │
+│                                                                  │
+│  ③ 重复入场检查                                                  │
+│     WHERE (plate=? OR rfid=?) AND exit_time IS NULL              │
+│     ├─ 已有未出场记录 → "车辆已入场,未出场"                         │
+│     └─ 无 → 继续                                                 │
+│                                                                  │
+│  ④ 创建入场记录                                                  │
+│     INSERT vehicle_record                                        │
+│     (plate_number, rfid_tag, entry_time,                         │
+│      parking_lot_id, parking_space_id, payment_status='unpaid')  │
+│                                                                  │
+│  ⑤ 创建数字票                                                    │
+│     digital_ticket.Create()                                      │
+│     状态: pending_payment                                        │
+│     event_log: [{"event":"created","trigger":"manual|rfid"}]     │
+│                                                                  │
+│  ⑥ 更新车辆统计                                                  │
+│     vehicle.last_entry_time = now                                │
+│                                                                  │
+│  ⑦ 响应返回                                                      │
+│     RFID(HandlePassage内)→ GateOpener 开闸                      │
+│     手动 → 仅返回成功,操作员另行手动抬杆                           │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+### 2.2 RFID 入场特有流程(HandlePassage)
+
+```
+RFID 上报 → reader.go handleReportData()
+  │
+  ├── 解析 EPC(hex编码 12字节)
+  ├── 构造 PassageRequest{RFIDTag: epc, DeviceCode: 设备编码}
+  └── HandlePassage()
+        │
+        ├── 1. 防抖(3秒内同一 identifier 的重复请求忽略)
+        │        identifier = RFID优先,其次车牌
+        │        passageLastTime[identifier] 记录时间戳
+        │
+        ├── 2. 查 UHF 设备 → 获取绑定通道
+        │        device = first(uhf_reader WHERE device_code = ?)
+        │        channel = device.Channel
+        │
+        ├── 3. 获取通道方向
+        │        channel.Direction → "in" / "out" / "inout"
+        │
+        ├── 4. 查询车辆
+        │        v = GetVehicleByPlateNumber(PlateNumber, RFIDTag)
+        │        isTempVehicle = v.VehicleType.IsSystem 或 未找到车辆
+        │
+        ├── 5. 黑名单检查
+        │        CheckVehicleShortlist(plate, rfid)
+        │
+        ├── 6. 临时车规则检查
+        │        if isTempVehicle && !channel.AllowTemporary
+        │            → "临时车不允许此通道" 拦截
+        │
+        ├── 7. 方向判断
+        │        direction == "in" → handleEntry()
+        │
+        ├── 8. handleEntry() 入场状态检查
+        │        passageStatus[identifier] → true? "车辆已在场内" 拦截
+        │
+        ├── 9. GateOpener(deviceCode, 2) → 开闸
+        │        通过 UHF 设备继电器控制道闸(Relay1 抬起)
+        │
+        ├── 10. VehicleEntry() → 创建入场记录
+        │
+        └── 11. passageStatus[identifier] = true
+```
+
+### 2.3 手动入场特有流程(entryExit.vue)
+
+```
+操作员打开「进出场操作」页面
+  │
+  ├── 选择「进场」Tab
+  │
+  ├── 输入车牌 或 RFID → 点击查询
+  │    ├─ 查到车辆 → 显示车辆类型、车主、VIP状态
+  │    └─ 未查到 → 显示 "未找到车辆" 提示,仍可入场(自动创建临时车)
+  │
+  ├── 选择停车区域(下拉框选择 parking_lot)
+  │
+  └── 点击「确认入场」
+       → POST /vehicle/entry → VehicleEntry()
+       → 成功:弹窗提示 "入场成功"
+       → 失败:弹窗提示错误原因
+
+注意:手动入场不涉及道闸控制,操作员需另行手动抬杆。
 ```
 
 ---
 
 ## 3. 出场流程
 
-### 3.1 流程图
+### 3.1 总体流程
 
 ```
 车辆到达出口
-  ├── 手动录入 ─────────────────────────────────┐
-  │   entryExit.vue 出场Tab                      │
-  │   ┌─ 输入车牌/RFID → 查询车辆               │
-  │   ├─ POST /vehicle/exit/preview → 费用预览   │
-  │   ├─ 显示费用 + 支付方式选择                  │
-  │   ├─ 临时车: 现金收款 → 输入实收 → 计算找零   │
-  │   ├─ 非临时车: 免密(payment_method=free)    │
-  │   └─ POST /vehicle/exit/confirm → 确认       │
-  │                                              │
-  ├── RFID 自动识别 ────────────────────────────  │
-  │   PassageService.handleExit()                │
-  │   → 直接调 ExitConfirm(payment_method=free)  │
-  │                                              │
-  ▼                                              ▼
-┌─────────────────────────────────────────────────────┐
-│              ExitConfirm()                           │
-│  internal/service/vehicle/vehicle.go                │
-│                                                      │
-│  ① 黑名单检查                                         │
-│     CheckVehicleShortlist(plate, rfid)               │
-│     └─ 在黑名单 → 拦截                                │
-│                                                      │
-│  ② 查询未出场记录                                      │
-│     WHERE (plate=? OR rfid=?) AND exit_time IS NULL  │
-│     └─ 未找到 → "车辆未入场或已出场"                     │
-│                                                      │
-│  ③ 计算费用                                           │
-│     calculateFee(vehicle, stayTime)                  │
-│     ├─ 查 fee_config(按车型)                         │
-│     ├─ VIP检查(IsVip + 过期检查 + 非零值)              │
-│     ├─ 免费时长内 → 0 元                               │
-│     ├─ 超时计费(StartFee + units × UnitFee)          │
-│     └─ 每日封顶 / VIP折扣                              │
-│                                                      │
-│  ④ 更新出场记录                                        │
-│     vehicle_record.exit_time = now                    │
-│     vehicle_record.fee = calculated                   │
-│     vehicle_record.payment_method = req.method        │
-│                                                      │
-│  ⑤ 更新车辆统计                                        │
-│     vehicle.last_exit_time / total_stay / total_fee   │
-│                                                      │
-│  ⑥ 创建支付记录                                        │
-│     payment_record (amount, method, operator, change) │
-│     vehicle_record.payment_status = "paid"            │
-│                                                      │
-│  ⑦ RFID → 开闸                                       │
-│     手动 → 仅记录                                     │
-└─────────────────────────────────────────────────────┘
-```
-
-### 3.2 计费算法(calculateFee)
-
-```
-calculateFee(vehicle, stayTime)
-  │
-  ├─ 1. 查 fee_config WHERE vehicle_type_id = ?
-  │     ├─ 未找到 → 默认费率 0.1元/分钟
-  │     └─ 全零配置 → 同样回退默认费率
-  │
-  ├─ 2. VIP 判断
-  │     owner.IsVip && VipExpireTime.After(now) && !VipExpireTime.IsZero()
-  │     └─ 过期 / 零值 → 非 VIP
-  │
-  ├─ 3. VIP 免费(isVIP && feeConfig.IsVIPFree)
-  │     └─ return 0
-  │
-  ├─ 4. 免费时长内
-  │     stayTime ≤ StartTime → return 0
-  │
-  ├─ 5. 超时计费
-  │     extraTime = stayTime - StartTime
-  │     units = ceil(extraTime / UnitTime)
-  │     fee = StartFee + units × UnitFee
-  │
-  ├─ 6. 每日封顶
-  │     fee > DailyMaxFee → fee = DailyMaxFee
-  │
-  └─ 7. VIP 折扣
-       fee ×= VIPDiscount
+  ├── RFID 自动识别 ────────────────────────────────────────────
+  │   HandlePassage() → direction == "out" → handleExit()
+  │   → 直接调用 ExitConfirm(free, 免密)
+  │   → 自动开闸
+  │
+  ├── 手动操作 ─────────────────────────────────────────────────
+  │   entryExit.vue 出场Tab
+  │   ① 输入车牌/RFID → 查询车辆
+  │   ② 调用 ExitPreview → 显示费用/停留时间/车型/是否临时车
+  │   ③ 选择支付方式(临时车+现金: 输入实收金额)
+  │   ④ 点击"确认出场并支付"
+  │   → POST /vehicle/exit/confirm → ExitConfirm()
+  │   → 创建支付记录
+  │
+  ├── (摄像头计划) ─────────────────────────────────────────────
+  │   (待实现)
+  │
+  ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│                     ExitConfirm() 核心入口                            │
+│  internal/service/vehicle/vehicle.go:339                            │
+│                                                                      │
+│  ① 黑名单检查(出场同样拦截)                                         │
+│     CheckVehicleShortlist(plate, rfid)                               │
+│     └─ 在黑名单 → "车辆在黑名单中,禁止出场"                            │
+│                                                                      │
+│  ② 查询未出场记录                                                     │
+│     WHERE (plate=? OR rfid=?) AND exit_time IS NULL                  │
+│     └─ 未找到 → "车辆未入场或已出场"                                   │
+│                                                                      │
+│  ③ 计算费用                                                          │
+│     calculateFee(vehicle, stayMinutes)                               │
+│     ├─ 查 fee_config(按 vehicle_type_id)                            │
+│     ├─ VIP检查(IsVip + 过期时间未到 + 非零值)                        │
+│     ├─ 免费时长内 → 0 元                                              │
+│     ├─ 超时计费(StartFee + units × UnitFee)                         │
+│     ├─ 每日封顶                                                       │
+│     └─ VIP折扣                                                        │
+│                                                                      │
+│  ④ 更新出场记录                                                       │
+│     vehicle_record.exit_time = now                                   │
+│     vehicle_record.stay_time = duration                               │
+│     vehicle_record.fee = calculated                                   │
+│     vehicle_record.payment_method = req.method                        │
+│                                                                      │
+│  ⑤ 更新车辆统计                                                       │
+│     vehicle.last_exit_time = now                                      │
+│     vehicle.total_stay_time += duration                               │
+│     vehicle.total_fee += fee                                          │
+│                                                                      │
+│  ⑥ 创建支付记录                                                       │
+│     payment_record (record_id, method, amount, paid_amount,           │
+│                     change_amount, operator_id, paid_at)              │
+│     vehicle_record.payment_status = "paid"                            │
+│                                                                      │
+│  ⑦ 响应返回                                                           │
+│     RFID(handleExit内)→ GateOpener 开闸                             │
+│     手动 → 仅返回成功                                                │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+### 3.2 RFID 出场特有流程(handleExit)
+
+```
+RFID 上报 → HandlePassage() → direction == "out"
+  │
+  ├── 1. 防抖检查(与入场共用逻辑)
+  ├── 2. 查设备/通道(同上)
+  ├── 3. 判断方向为 out
+  │
+  └── handleExit()
+        │
+        ├── 打印日志 + GateOpener 开闸(先开闸,后处理出场)
+        │
+        ├── 构造 ExitConfirmRequest
+        │     PaymentMethod: "free"  ← 重要!RFID 出场免密
+        │     PaidAmount: 0
+        │     OperatorID: 0(系统自动)
+        │
+        ├── ExitConfirm() → 计算费用 + 记录出场 + 创建支付记录
+        │
+        └── passageStatus[identifier] = false(标记离场)
+```
+
+### 3.3 手动出场特有流程(entryExit.vue)
+
+```
+操作员选择「出场」Tab
+  │
+  ├── ① 输入车牌/RFID → 查询车辆信息
+  │
+  ├── ② 自动调用 ExitPreview API
+  │     返回:plate_number, entry_time, stay_time, fee,
+  │           vehicle_type, is_temp(是否临时车)
+  │
+  ├── ③ 显示费用预览面板
+  │     ├─ 入场时间、停留时长、应收费用
+  │     ├─ 车辆类型(临时车/固定车)
+  │     │
+  │     └─ 临时车 → 选支付方式
+  │           ├─ 现金(已实现)
+  │           ├─ 微信(disabled,待对接)
+  │           └─ POS机(disabled,待对接)
+  │
+  ├── ④ 临时车 + 现金
+  │     └─ 输入实收金额 → 自动计算找零
+  │
+  ├── ⑤ 确认出场
+  │     ├─ 临时车:校验 paid_amount >= fee
+  │     ├─ 固定车:payment_method = "free", paid_amount = 0
+  │     └─ 弹出确认弹窗
+  │
+  └── ⑥ POST /vehicle/exit/confirm → ExitConfirm()
+       → 创建支付记录 + 更新出场
+       → 弹窗显示:入场时间 / 停留时长 / 缴费金额
 ```
 
 ---
 
-## 4. 名单检查逻辑
+## 4. 计费算法多场景推演
 
 ```
-CheckVehicleShortlist(plateNumber, rfidTag)
+calculateFee(vehicle, stayMinutes)
+  │
+  ├── 0. 查 fee_config WHERE vehicle_type_id = ?
+  │      ├─ 未找到 → 默认费率 0.1元/分钟(回退)
+  │      └─ 找到且全零字段 → 同样回退默认费率
+  │
+  ├── 1. VIP 判断
+  │      vehicle.OwnerId != nil
+  │      → 查 Owner.IsVip && VipExpireTime.After(now) && !VipExpireTime.IsZero()
+  │
+  ├── 2. VIP 免费
+  │      isVIP && feeConfig.IsVIPFree → return 0
-  ├─ 按车牌或 RFID 查 vehicle 表
-  │     └─ 未找到 → 返回 false, false
+  ├── 3. 免费时长内
+  │      stayMinutes <= StartTime → return 0
-  ├─ 按 vehicle_id 查 shortlist 表
-  │     └─ 未找到 → 返回 false, false
+  ├── 4. 超时计费
+  │      extraTime = stayMinutes - StartTime
+  │      units = ceil(extraTime / UnitTime)
+  │      fee = StartFee + units × UnitFee
-  └─ shortlist.ListType == "黑名单" → isBlack=true
-     shortlist.ListType == "白名单" → isWhite=true
+  ├── 5. 每日封顶
+  │      fee > DailyMaxFee && DailyMaxFee > 0 → fee = DailyMaxFee
+  │
+  └── 6. VIP折扣
+       isVIP && !IsVIPFree → fee *= VIPDiscount
+```
+
+### 场景示例
+
+| 场景 | 时长(分钟) | StartTime | StartFee | UnitTime | UnitFee | DailyMax | VIP | 结果 |
+|------|-----------|-----------|----------|----------|---------|----------|-----|------|
+| 临时车30分钟内免费 | 25 | 30 | 5 | 15 | 2 | 30 | 否 | 0 |
+| 临时车超1h | 90 | 30 | 5 | 15 | 2 | 30 | 否 | 5+ceil(60/15)×2=13 |
+| 临时车超1h封顶 | 480 | 30 | 5 | 15 | 2 | 30 | 否 | min(5+ceil(450/15)×2=65, 30)=30 |
+| VIP免费 | 120 | 30 | 5 | 15 | 2 | 30 | 是(IsVIPFree) | 0 |
+| VIP折扣 | 120 | 30 | 5 | 15 | 2 | 30 | 是(折扣0.8) | ceil(5+ceil(90/15)×2)×0.8=10.4 |
+| 无配置回退 | 60 | - | - | - | - | - | - | 60×0.1=6 |
+
+---
+
+## 5. 各车辆类型的完整进出场行为
+
+### 5.1 临时车(无固定车主)
+
+| 阶段 | 行为 |
+|------|------|
+| **入场** | • 手动:前端查询不到车辆 → 仍可入场,自动创建临时车记录 |
+| | • RFID:通道 AllowTemporary=true 才放行,否则拦截 |
+| **出场** | • 手动:费用预览 → 收款(现金)→ 确认出场 |
+| | • RFID:自动出场,免密 |
+| **计费** | 按车型 fee_config 计算,VIP 相关规则不适用(无 Owner) |
+
+### 5.2 固定车(有车主,非 VIP)
+
+| 阶段 | 行为 |
+|------|------|
+| **入场** | • 手动:查询到车辆信息(显示类型/车主)→ 确认入场 |
+| | • RFID:白名单校验 → 黑名单校验 → 入场 |
+| **出场** | • 手动:ExitPreview 后按 free(免密)出场 |
+| | • RFID:自动出场,免密 |
+| **计费** | 按 fee_config 正常计费(如有 StartFee/UnitFee 则收费) |
+
+### 5.3 VIP 车辆(车主 IsVip=true 且未过期)
+
+| 阶段 | 行为 |
+|------|------|
+| **入场** | 同固定车 |
+| **出场** | 同固定车 |
+| **计费** | • feeConfig.IsVIPFree → 免费 0 元 |
+| | • 否则 fee × VIPDiscount(如 0.8=8折) |
+
+### 5.4 白名单车辆(包月车)
+
+| 阶段 | 行为 |
+|------|------|
+| **定义** | 车辆加入 `shortlist` 表,ListType="白名单",记录 ExpirationTime |
+| **入场** | • CheckVehicleShortlist → isWhite=true → 放行(无拦截) |
+| | • 即使 ExpirationTime 过期,白名单不拦截入场 |
+| **出场** | • 不拦截出场 |
+| | • 包月车结费规则在 Shortlist 过期处理中决定:若 ExpirationTime < now 则按临时车计费;若未过期则依赖 fee_config 中的费用判断 |
+
+**说明**:目前代码中白名单本身**不修改计费逻辑**,只是放行/不拦截。
+包月车真正的费用减免靠 `fee_config` 的 StartTime/IsVIPFree 等配置控制,
+或通过 `MonthlyCard` 模块进行包月办理管理(只记录办理记录)。
+
+### 5.5 黑名单车辆
+
+| 阶段 | 行为 |
+|------|------|
+| **入场** | CheckVehicleShortlist → isBlack=true → **拦截**,禁止入场 |
+| **出场** | ExitConfirm → CheckVehicleShortlist → isBlack=true → **拦截** |
+| | 注意:黑名单在出场也拦截,需要管理员手动解除黑名单才能出场 |
+| **设置** | 通过后台「黑白名单」管理页面将车辆加入黑名单 |
+
+### 5.6 进出场状态检查矩阵
+
+| 状态 | 手动入场 | RFID入场 | 手动出场 | RFID出场 |
+|------|---------|---------|---------|---------|
+| 黑名单 | ❌ 拦截 | ❌ 拦截 | ❌ 拦截 | ❌ 拦截 |
+| 未入场 | ✅ 正常 | ✅ 正常 | ❌ "未入场或已出场" | ❌(先开闸再出错?待商榷) |
+| 已在场内 | ❌ "已入场,未出场" | ❌ "已在场内" | ✅ 正常 | ✅ 正常 |
+| 临时车+不允许临时通道 | N/A(手动无通道校验) | ❌ 拦截 | N/A | N/A |
+
+---
+
+## 6. 数据表变更汇总
+
+### 6.1 入场写入
+
+| 表 | 操作 | 关键字段 |
+|----|------|---------|
+| vehicle | 查/创建 | plate_number, rfid_tag, vehicle_type_id, last_entry_time |
+| vehicle_record | INSERT | plate_number, rfid_tag, entry_time=now, parking_lot_id, payment_status=unpaid |
+| digital_ticket | INSERT | ticket_no, plate_number, trigger_mode, state=pending_payment, entry_time=now |
+
+### 6.2 出场写入
+
+| 表 | 操作 | 关键字段 |
+|----|------|---------|
+| vehicle_record | UPDATE | exit_time=now, stay_time=fee, payment_method, payment_status=paid |
+| vehicle | UPDATE | last_exit_time, total_stay_time+=, total_fee+= |
+| payment_record | INSERT | record_id, payment_method, amount, paid_amount, change_amount, operator_id, paid_at=now |
+| digital_ticket | UPDATE | state→paid/exit(手动调用Transition) |
 
-名单检查位置:
-  ├─ VehicleEntry (手动+RIFD进场)      ✅
-  ├─ ExitConfirm (手动+RIFD出场)       ✅
-  └─ PassageService.HandlePassage      ✅
+### 6.3 数字票状态转换
+
+```
+Created → PendingPayment → Paid → Exited
+                   ↘           ↘
+                    Expired     Expired
 ```
 
+创建时直接到 PendingPayment,出场支付后到 Paid,可再转为 Exited。
+
 ---
 
-## 5. 数字票状态跟踪
+## 7. 边界情况 & 注意事项
+
+### 7.1 RFID 防抖设计
 
+```go
+passageDebounceSec = 3 // 3 秒内同一 RFID/车牌忽略重复请求
 ```
-VehicleEntry()
-  │
-  └─ digital_ticket.Create()
-       │
-       状态: pending_payment
-       event_log: [{"event":"created","trigger":"manual|rfid"}]
-       │
-       ▼ ExitConfirm()
-       │  └─ 创建 payment_record
-       │
-       状态: paid (手动调用)
-       event_log: [...,{"event":"paid","payment_method":"cash"}]
+同一设备读头短时间内可能多次读取同一标签,防抖避免重复入场/出场。
+
+### 7.2 出场先开闸后扣费
+
+```go
+// passage.go handleExit() — 先开闸
+GateOpener(req.DeviceCode, 2)
+// 再处理出场扣费
+vehicleSvc.ExitConfirm(confirmReq, 0)
 ```
+**风险**:如果 ExitConfirm 失败(如数据库错误),车辆已出场但未记录。
+设计意图是保证通行效率 > 计费准确性,损失可通过后台对账发现。
+
+### 7.3 RFID 出场免密
+
+RFID 自动出场统一走 `PaymentMethod: "free"`,即设备触发不开单、不收费、仅记录。
+临时车通过 RFID 出场时一样免密——这意味着 RFID 出场不产生实收费用。
+
+**设计决策**:如果需要临时车 RFID 出场时收费,需要在出口部署收费岗亭或对接线上支付。目前设计为:
+- **RFID 通道**:封闭场景(如小区/园区),车辆都经过授权(白名单或固定车),计费走账户/月租
+- **临时车**:走人工通道,现金/扫码支付后出场
+
+### 7.4 手动出场支付方法
+
+| 场景 | payment_method | paid_amount | operator_id |
+|------|---------------|-------------|-------------|
+| 临时车现金收款 | "cash" | 实收金额 | 当前登录用户ID |
+| 固定车免密 | "free" | 0 | 当前登录用户ID |
+| RFID自动出场 | "free" | 0 | 0(系统) |
+
+### 7.5 黑名单出场拦截
+
+黑名单在 `ExitConfirm()` 中同样检查。如果车辆在出场时被加入黑名单,会拦截出场:
+需要先通过「黑白名单管理」移除该车辆的黑名单状态,才能正常出场。
+
+### 7.6 小区/园区封闭场景 vs 公共停车场
+
+| 场景 | RFID 通道 | 手动通道 |
+|------|----------|---------|
+| 小区/园区(封闭) | 主要出入口,预注册车辆自动放行 | 访客登记入场 |
+| 公共停车场 | 未实现 | 临时车收费出场 |
 
 ---
 
-## 6. 代码入口汇总
+## 8. 相关 API 汇总
+
+| 方法 | 路由 | 功能 | 文件 |
+|------|------|------|------|
+| POST | /vehicle/entry | 手动入场 | `internal/api/v1/vehicle/vehicle.go:131` |
+| POST | /vehicle/exit | 出场(旧接口) | `internal/api/v1/vehicle/vehicle.go:148` |
+| POST | /vehicle/exit/preview | 出场费用预览 | `internal/modules/payment/api.go:17` |
+| POST | /vehicle/exit/confirm | 确认出场+支付 | `internal/modules/payment/api.go:32` |
+| GET | /vehicle/get-by-plate | 查询车辆信息 | `internal/api/v1/vehicle/vehicle.go:66` |
+| POST | /vehicle/entry/rfid | RFID 自动进出场 | `internal/service/uhf/reader.go` 内部调用 |
+
+---
+
+## 9. 代码入口汇总
 
 | 触发方式 | 入口函数 | 文件 |
 |---------|---------|------|
 | 手动进场 | `VehicleEntry()` | `internal/service/vehicle/vehicle.go:177` |
 | RFID 进场 | `PassageService.handleEntry()` | `internal/service/parking/passage.go:103` |
-| 手动出场(预览) | `ExitPreview()` | `internal/service/vehicle/vehicle.go:296` |
-| 手动出场(确认) | `ExitConfirm()` | `internal/service/vehicle/vehicle.go:334` |
-| RFID 出场 | `PassageService.handleExit()` | `internal/service/parking/passage.go:150` |
-| 计费引擎 | `calculateFee()` | `internal/service/vehicle/vehicle.go:384` |
+| 手动出场(预览) | `ExitPreview()` | `internal/service/vehicle/vehicle.go:302` |
+| 手动出场(确认) | `ExitConfirm()` | `internal/service/vehicle/vehicle.go:339` |
+| RFID 出场 | `PassageService.handleExit()` | `internal/service/parking/passage.go:148` |
+| RFID 统一入口 | `HandlePassage()` | `internal/service/parking/passage.go:40` |
+| 计费引擎 | `calculateFee()` | `internal/service/vehicle/vehicle.go:390` |
 | 名单检查 | `CheckVehicleShortlist()` | `internal/service/vehicle/shortlist.go:30` |
-| 数字票创建 | `TicketService.Create()` | `internal/modules/digital-ticket/service/service.go:52` |
-| 道闸控制 | `GateOpener()` | `internal/service/uhf/reader.go:88` |
-
-## 7. 包月车进出场识别
-
-```
-车辆进场 / 出场
-  │
-  ├─ CheckVehicleShortlist(plate, rfid)
-  │
-  └─ shortlist.ListType == "白名单" && ExpirationTime > now
-       ├─ 未过期 → 白名单放行(后续按免密处理)
-       └─ 已过期 → 按临时车计费
-```
-
-包月车办理时自动写入 `shortlist(白名单, ExpirationTime=end_date)`,进出场复用现有白名单检查,零改动。
+| 数字票创建 | `TicketService.Create()` | `internal/modules/digital-ticket/service/service.go:46` |
+| 道闸控制 | `GateOpener()` / `OpenGateByDeviceCode()` | `internal/service/uhf/reader.go:96` |
+| UHF 数据解析 | `parseReportData()` | `internal/service/uhf/reader.go:221` |
+| UHF 上报处理 | `handleReportData()` | `internal/service/uhf/reader.go:250` |
+| 支付处理 | `ProcessPayment()` | `internal/modules/payment/service/service.go:29` |

+ 297 - 0
test/testutil/e2e_edge_cases_test.go

@@ -788,3 +788,300 @@ func createVehicleWithOwner(t *testing.T, env *TestEnv, plateNumber, rfidTag str
 		t.Fatalf("Failed to create vehicle with owner: %s", result.Msg)
 	}
 }
+
+// ===================== 交接班对账 (Shift) 测试 =====================
+//
+// 注意:由于 TestEnv 是全局缓存的,所有测试共享同一个数据库和操作员,
+// 每个 shift 测试必须清除前序测试遗留的当班记录。
+
+// closeActiveShift 清除操作员活动中当班(DB直接操作,不经过 API)
+func closeActiveShift(env *TestEnv) {
+	env.DB.Model(&dao.ShiftRecord{}).
+		Where("operator_id = ? AND status = ?", env.UserID, "active").
+		Update("status", "closed")
+}
+
+// TestShiftStart SH01: 正常接班
+func TestShiftStart(t *testing.T) {
+	env := InitTestEnv(t)
+	closeActiveShift(env) // 清理前序状态
+
+	resp, body := env.DoPost("/shift/start", map[string]interface{}{
+		"starting_cash": 100.0,
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+
+	// 验证 DB 中创建了当班记录
+	var rec dao.ShiftRecord
+	env.DB.Where("operator_id = ? AND status = ?", env.UserID, "active").First(&rec)
+	if rec.ID == 0 {
+		t.Fatalf("Shift record not created in DB")
+	}
+	if rec.StartingCash != 100.0 {
+		t.Fatalf("Expected starting_cash=100, got %.2f", rec.StartingCash)
+	}
+	fmt.Printf("PASS: Shift start successful, recordID=%d\n", rec.ID)
+}
+
+// TestShiftEnd SH02: 正常交班(含当班现金收款统计与差异计算)
+func TestShiftEnd(t *testing.T) {
+	env := InitTestEnv(t)
+	closeActiveShift(env)
+
+	// 1. 接班
+	startResp, startBody := env.DoPost("/shift/start", map[string]interface{}{
+		"starting_cash": 100.0,
+	})
+	AssertResponse(t, startResp, startBody, http.StatusOK, response.SUCCESS)
+
+	// 2. 伪造一笔现金收款记录(模拟当班期间收款50元)
+	now := time.Now()
+	env.DB.Exec(`INSERT INTO payment_record
+		(record_id, payment_method, amount, paid_amount, change_amount, operator_id, paid_at, created_at, updated_at)
+		VALUES (9999, 'cash', 50.0, 50.0, 0, ?, ?, ?, ?)`,
+		env.UserID, now, now, now)
+
+	// 3. 交班 — 接班100 + 当班收款50 = 应交150,实交150 → 差异0
+	resp, body := env.DoPost("/shift/end", map[string]interface{}{
+		"actual_cash": 150.0,
+		"remark":      "test shift end",
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+
+	// 4. 验证 DB 记录
+	var rec dao.ShiftRecord
+	env.DB.Where("operator_id = ? AND status = ?", env.UserID, "closed").Order("id DESC").First(&rec)
+	if rec.ID == 0 {
+		t.Fatalf("Shift record not updated in DB")
+	}
+	if rec.CollectedCash != 50.0 {
+		t.Fatalf("Expected collected_cash=50, got %.2f", rec.CollectedCash)
+	}
+	if rec.ExpectedTotal != 150.0 {
+		t.Fatalf("Expected expected_total=150, got %.2f", rec.ExpectedTotal)
+	}
+	if rec.Difference != 0.0 {
+		t.Fatalf("Expected difference=0, got %.2f", rec.Difference)
+	}
+	if rec.Remark != "test shift end" {
+		t.Fatalf("Remark mismatch: expected 'test shift end', got '%s'", rec.Remark)
+	}
+	fmt.Printf("PASS: Shift end successful, collected=%.2f expected=%.2f diff=%.2f\n",
+		rec.CollectedCash, rec.ExpectedTotal, rec.Difference)
+}
+
+// TestShiftDoubleStart SH03: 重复接班被拦截
+func TestShiftDoubleStart(t *testing.T) {
+	env := InitTestEnv(t)
+	closeActiveShift(env)
+
+	// 第一次接班 — 成功
+	startResp, startBody := env.DoPost("/shift/start", map[string]interface{}{
+		"starting_cash": 100.0,
+	})
+	AssertResponse(t, startResp, startBody, http.StatusOK, response.SUCCESS)
+
+	// 第二次接班 — 应被拦截
+	_, body := env.DoPost("/shift/start", map[string]interface{}{
+		"starting_cash": 200.0,
+	})
+	var result struct {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+	}
+	json.Unmarshal(body, &result)
+	if result.Code == response.SUCCESS {
+		t.Fatalf("BUG: Double shift start was NOT blocked! code=%d msg=%s", result.Code, result.Msg)
+	}
+	fmt.Printf("PASS: Double start correctly rejected: msg=%s\n", result.Msg)
+}
+
+// TestShiftEndWithoutStart SH04: 未接班直接交班失败
+func TestShiftEndWithoutStart(t *testing.T) {
+	env := InitTestEnv(t)
+	closeActiveShift(env) // 确保没有当班记录
+
+	resp, body := env.DoPost("/shift/end", map[string]interface{}{
+		"actual_cash": 100.0,
+		"remark":      "no start",
+	})
+	// 期望返回错误(无当班记录)
+	AssertResponse(t, resp, body, http.StatusOK, response.ERROR)
+
+	var result struct {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+	}
+	json.Unmarshal(body, &result)
+	fmt.Printf("PASS: End without start correctly rejected: code=%d msg=%s\n", result.Code, result.Msg)
+}
+
+// TestShiftCurrent SH05: 查询当前当班信息
+func TestShiftCurrent(t *testing.T) {
+	env := InitTestEnv(t)
+	closeActiveShift(env)
+
+	// 先接班
+	_, _ = env.DoPost("/shift/start", map[string]interface{}{
+		"starting_cash": 200.0,
+	})
+
+	// 查询当班
+	resp, body := env.DoGet("/shift/current", nil)
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+
+	var result struct {
+		Code int                        `json:"code"`
+		Data map[string]interface{}     `json:"data"`
+		Msg  string                     `json:"msg"`
+	}
+	json.Unmarshal(body, &result)
+
+	if result.Data == nil {
+		t.Fatalf("Shift current data is nil")
+	}
+	startCash, ok := result.Data["starting_cash"].(float64)
+	if !ok || startCash != 200.0 {
+		t.Fatalf("Expected starting_cash=200, got %v", result.Data["starting_cash"])
+	}
+	fmt.Printf("PASS: Shift current query successful, starting_cash=%.2f\n", startCash)
+}
+
+// TestShiftList SH06: 交接班记录列表
+func TestShiftList(t *testing.T) {
+	env := InitTestEnv(t)
+	closeActiveShift(env)
+
+	// 接班 → 交班 → 产生一条历史记录
+	_, _ = env.DoPost("/shift/start", map[string]interface{}{"starting_cash": 50.0})
+	_, _ = env.DoPost("/shift/end", map[string]interface{}{"actual_cash": 50.0, "remark": "list test"})
+
+	// 查询列表
+	resp, body := env.DoGet("/shift/list", map[string]string{
+		"page":      "1",
+		"page_size": "10",
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+
+	list := ParsePageList(body)
+	if len(list) == 0 {
+		t.Fatalf("Shift list is empty after creating a record")
+	}
+	fmt.Printf("PASS: Shift list returned %d records\n", len(list))
+}
+
+// ===================== 收入报表 (Revenue Report) 测试 =====================
+
+// createParkingPayment 辅助函数:创建一笔车辆进出+支付记录(用于报表测试)
+func createParkingPayment(t *testing.T, env *TestEnv, plateNumber, rfidTag string, amount float64, method string) {
+	t.Helper()
+
+	createVehicle(t, env, plateNumber, rfidTag, 1)
+
+	entryResp, entryBody := env.DoPost("/vehicle/entry", map[string]interface{}{
+		"plate_number":     plateNumber,
+		"rfid_tag":         rfidTag,
+		"parking_lot_id":   1,
+		"parking_space_id": 1,
+		"entry_image":      "test_revenue_entry.jpg",
+	})
+	AssertResponse(t, entryResp, entryBody, http.StatusOK, response.SUCCESS)
+
+	// 修改入场时间为数小时前,以确保产生费用
+	entryTime := time.Now().Add(-3 * time.Hour)
+	env.DB.Model(&dao.VehicleRecord{}).Where("plate_number = ?", plateNumber).Update("entry_time", entryTime)
+
+	exitResp, exitBody := env.DoPost("/vehicle/exit/confirm", map[string]interface{}{
+		"plate_number":   plateNumber,
+		"rfid_tag":       rfidTag,
+		"payment_method": method,
+		"paid_amount":    amount,
+	})
+	AssertResponse(t, exitResp, exitBody, http.StatusOK, response.SUCCESS)
+}
+
+// TestRevenueReportBasic RR01: 基础收入报表查询(无参数)
+// BUG: 后端 RevenueReport 当 start_date/end_date 为空时,SQL 条件为
+//   paid_at >= '' AND paid_at <= ' 23:59:59'
+// 导致所有记录被过滤,data 返回 null。
+func TestRevenueReportBasic(t *testing.T) {
+	env := InitTestEnv(t)
+
+	createParkingPayment(t, env, "JingREV001", "RFID_REV001", 15.0, "cash")
+
+	resp, body := env.DoGet("/report/revenue", nil)
+	// HTTP 层面是 200
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("Expected 200, got %d", resp.StatusCode)
+	}
+
+	var result struct {
+		Code int              `json:"code"`
+		Data json.RawMessage  `json:"data"`
+		Msg  string           `json:"msg"`
+	}
+	json.Unmarshal(body, &result)
+
+	// BUG: 无日期参数时 data 应为有数据,但后端返回 null
+	if result.Code != response.SUCCESS {
+		t.Fatalf("Revenue report failed: code=%d msg=%s", result.Code, result.Msg)
+	}
+	if result.Data == nil || string(result.Data) == "null" {
+		t.Fatalf("BUG: Revenue report with no date params returns null data! "+
+			"Cause: SQL condition 'paid_at >= \"\" AND paid_at <= \" 23:59:59\"' filters out all records. "+
+			"Fix: when start/end_date are empty, omit the WHERE clause or default to today.")
+	}
+	fmt.Printf("PASS: Revenue report basic query successful, data=%s\n", string(result.Data))
+}
+
+// TestRevenueReportDateRange RR02: 指定日期范围的收入报表
+func TestRevenueReportDateRange(t *testing.T) {
+	env := InitTestEnv(t)
+
+	createParkingPayment(t, env, "JingREV002", "RFID_REV002", 20.0, "cash")
+
+	today := time.Now().Format("2006-01-02")
+	resp, body := env.DoGet("/report/revenue", map[string]string{
+		"start_date": today,
+		"end_date":   today,
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+
+	var result struct {
+		Code int              `json:"code"`
+		Data json.RawMessage  `json:"data"`
+		Msg  string           `json:"msg"`
+	}
+	json.Unmarshal(body, &result)
+	if len(result.Data) == 0 || string(result.Data) == "null" {
+		t.Fatalf("Revenue report with date range returned empty")
+	}
+	fmt.Printf("PASS: Revenue report with date range successful, data=%s\n", string(result.Data))
+}
+
+// TestRevenueReportWithLotFilter RR03: 指定停车场的收入报表
+func TestRevenueReportWithLotFilter(t *testing.T) {
+	env := InitTestEnv(t)
+
+	createParkingPayment(t, env, "JingREV003", "RFID_REV003", 25.0, "cash")
+
+	today := time.Now().Format("2006-01-02")
+	resp, body := env.DoGet("/report/revenue", map[string]string{
+		"parking_lot_id": "1",
+		"start_date":     today,
+		"end_date":       today,
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+
+	var result struct {
+		Code int              `json:"code"`
+		Data json.RawMessage  `json:"data"`
+		Msg  string           `json:"msg"`
+	}
+	json.Unmarshal(body, &result)
+	if len(result.Data) == 0 || string(result.Data) == "null" {
+		t.Fatalf("Revenue report with lot filter returned empty")
+	}
+	fmt.Printf("PASS: Revenue report with lot filter successful, data=%s\n", string(result.Data))
+}
+