4 Commits 6b8da253c2 ... b0548c9c3b

Tác giả SHA1 Thông báo Ngày
  lq b0548c9c3b chore: 忽略gocache临时目录与zcode工具目录 3 tuần trước cách đây
  lq fc96282b6c feat: 新增异常处理(incident)模块与闸机/通行异常联动 3 tuần trước cách đây
  lq 4040523c7a feat: 票机按钮接入统一通行入口,新增停车场容量与安全测试 3 tuần trước cách đây
  lq f53794392b docs: 更新项目文档(进度/流程/规划) 3 tuần trước cách đây
54 tập tin đã thay đổi với 4884 bổ sung1848 xóa
  1. 4 0
      .gitignore
  2. 1 0
      config.yaml
  3. 86 64
      doc/PROJECT.md
  4. 97 148
      doc/digital-ticket.md
  5. 82 178
      doc/代码规划.md
  6. 182 0
      doc/功能缺失.md
  7. 398 0
      doc/异常处置设计.md
  8. 72 117
      doc/收费流程.md
  9. 90 298
      doc/进出场流程详解.md
  10. 41 541
      doc/进出场逻辑流程.md
  11. 141 262
      doc/项目进度.md
  12. 17 0
      frontend/src/api/incident.js
  13. 2 2
      frontend/src/view/dashboard/index.vue
  14. 62 2
      frontend/src/view/parking/parkingInfo/index.vue
  15. 44 6
      frontend/src/view/parking/parkingInfo/lotDetail.vue
  16. 365 14
      frontend/src/view/report/sheet.vue
  17. 72 4
      internal/api/v1/parking/gate.go
  18. 1 0
      internal/config/system.go
  19. 62 0
      internal/dao/incident.go
  20. 12 8
      internal/dao/parking_lot.go
  21. 34 0
      internal/initialize/gorm.go
  22. 60 0
      internal/initialize/incident_seed.go
  23. 50 0
      internal/initialize/operation_permission_seed.go
  24. 50 0
      internal/initialize/operation_permission_seed_test.go
  25. 11 6
      internal/initialize/router.go
  26. 70 0
      internal/initialize/router_security_test.go
  27. 6 0
      internal/initialize/seed.go
  28. 8 2
      internal/model/common/passage.go
  29. 15 9
      internal/model/parking/request/parking_lot.go
  30. 4 0
      internal/model/vehicle/request/vehicle.go
  31. 102 0
      internal/modules/incident/api.go
  32. 256 0
      internal/modules/incident/repository/repo.go
  33. 686 0
      internal/modules/incident/service/service.go
  34. 330 0
      internal/modules/incident/service/service_test.go
  35. 9 0
      internal/modules/parking-session/model/request/session.go
  36. 76 0
      internal/modules/parking-session/service/service.go
  37. 91 1
      internal/modules/parking-session/service/service_test.go
  38. 240 94
      internal/modules/printer/api.go
  39. 177 0
      internal/modules/printer/api_test.go
  40. 5 0
      internal/modules/printer/service/csn_dll.go
  41. 8 1
      internal/router/parking/parking.go
  42. 22 0
      internal/router/parking/parking_test.go
  43. 57 0
      internal/service/parking/gate.go
  44. 151 0
      internal/service/parking/incident_hook_test.go
  45. 83 58
      internal/service/parking/parking_lot.go
  46. 98 0
      internal/service/parking/parking_lot_test.go
  47. 105 2
      internal/service/parking/passage.go
  48. 33 12
      internal/service/system/sys_dashboard.go
  49. 50 0
      internal/service/system/sys_dashboard_test.go
  50. 28 14
      internal/service/uhf/reader.go
  51. 73 0
      internal/service/vehicle/parking_capacity_test.go
  52. 6 1
      internal/service/vehicle/parking_session_test.go
  53. 5 1
      internal/service/vehicle/payment_edge_test.go
  54. 54 3
      internal/service/vehicle/vehicle.go

+ 4 - 0
.gitignore

@@ -48,6 +48,10 @@ web/pnpm-lock.yaml
 
 # Go build/test cache redirected to the workspace
 /.gocache/
+/.gocache-*/
+
+# 工具私有目录
+/.zcode/
 
 # Runtime
 /log/

+ 1 - 0
config.yaml

@@ -189,6 +189,7 @@ system:
     use-redis: false
     use-mongo: false
     gate-simulator: true # 本地无硬件联调;生产环境必须关闭
+    debug-routes: false # 默认不注册票机和通道调试接口
 tencent-cos:
     bucket: xxxxx-10005608
     region: ap-shanghai

+ 86 - 64
doc/PROJECT.md

@@ -1,88 +1,110 @@
-# 智慧停车管理系统 (Smart Parking) — Wails 桌面应用
+# 智慧停车管理系统项目说明
 
-> **项目名称**:lc_garage
-> **框架**:Wails v2.13 + GIN-VUE-ADMIN v2.6.3
-> **最后更新**:2026-07-10
+> 项目:`lc_garage`
+> 更新日期:2026-08-07
+> 形态:Wails 单进程 Windows 桌面应用
 
----
+## 1. 项目定位
 
-## 架构概述
+智慧停车管理系统用于停车场的基础配置、车辆进出场、收费结算、数字票、交接班、报表和本地设备联动。
 
-单进程 Wails 桌面应用:Go 后端(Gin HTTP)在 goroutine 运行,Vue 3 前端通过 WebView2 加载,`/api/*` 请求经内置反向代理转发到 Gin
+系统以**停车会话**作为一辆车一次停车过程的事实载体,以**数字票**作为支付和离场状态载体,以**支付流水**作为资金事实载体。三者职责分离,但在进出场事务中保持一致
 
-```
-Wails .exe
-  ├─ goroutine: Gin HTTP Server (:8888)
-  └─ WebView2: Vue 3 SPA
-       └─ /api/* → reverse proxy → Gin (strip /api prefix)
-```
-
----
-
-## 技术栈
+## 2. 技术架构
 
 | 层级 | 技术 |
-|------|------|
-| 桌面框架 | Wails v2.13 |
-| 后端 | Go 1.25 + Gin + GORM + Casbin + JWT |
-| 前端 | Vue 3 + Vite 4 + Element Plus + Pinia + ECharts |
-| 数据库 | SQLite(`%APPDATA%/smart-parking/lc_garage.db`) |
-
----
+| --- | --- |
+| 桌面容器 | Wails v2.13 + WebView2 |
+| 后端 | Go 1.25、Gin、GORM、Casbin、JWT |
+| 前端 | Vue 3、Vite 4、Element Plus、Pinia、ECharts |
+| 数据库 | SQLite,默认 `%APPDATA%/smart-parking/lc_garage.db` |
+| 设备 | UHF 读卡器、模拟/真实闸机、串口或 USB 小票打印机 |
+
+```text
+Wails 桌面程序
+├─ Gin API(默认 :8888)
+├─ Vue 3 WebView2 前端
+│  └─ /api/* 由 Wails 反向代理至 Gin
+└─ SQLite 本地数据库
+```
 
-## 项目结构
+## 3. 当前代码结构
 
-```
+```text
 lc_garage/
-├── main.go                   # Wails 入口
-├── wails.json                # Wails 配置
-├── go.mod                    # 根模块 (wails-app)
-├── config.yaml               # 后端配置
-├── server/                   # Go 后端(独立模块 "server")
-├── frontend/                 # Vue 3 前端
-├── build/bin/                # 构建输出
-├── deploy/                   # Docker/K8s
-├── lc_garage.sql             # 数据库初始化
-└── doc/                      # 文档
+├─ main.go                         # Wails 入口与 API 反向代理
+├─ internal/
+│  ├─ api/v1/                      # 既有 API 控制器
+│  ├─ service/                     # 既有业务与设备服务
+│  ├─ router/                       # 既有路由组
+│  ├─ dao/                          # GORM 实体
+│  ├─ model/                        # 通用、请求与响应模型
+│  ├─ initialize/                   # 数据库、种子数据、路由、设备初始化
+│  └─ modules/                      # 新领域模块
+│     ├─ parking-session/           # 停车会话
+│     ├─ digital-ticket/            # 数字票
+│     ├─ payment/                   # 支付入口、方式和支付流水
+│     ├─ monthly/                   # 包月车
+│     ├─ shift/                     # 交接班
+│     ├─ report/                    # 收入报表
+│     └─ printer/                   # 串口/USB 小票打印
+├─ frontend/src/
+│  ├─ view/parking/                 # 进出场、数字票、支付配置、包月车、交接班
+│  ├─ view/report/                  # 进出记录、支付记录、收入报表
+│  └─ view/dashboard/               # 仪表盘与车辆监控
+└─ doc/                             # 项目、流程、进度和测试文档
 ```
 
----
+## 4. 核心领域模型
 
-## 后端改动(相对于原始 GVA)
+| 对象 | 表/模块 | 职责 |
+| --- | --- | --- |
+| 停车会话 | `vehicle_record` / `parking-session` | 一次停车的入场、出场、停车场、通道设备快照和结算摘要 |
+| 数字票 | `digital_ticket` / `digital-ticket` | 会话的支付和离场状态、票号、事件日志 |
+| 支付流水 | `payment_record` / `payment` | 实际收款、实收、找零、支付入口、支付方式、操作员 |
+| 通道设备 | `channel`、`uhf_reader` | 通道方向、停车场归属、设备绑定与在线状态 |
 
-| 文件 | 改动 | 说明 |
-|------|------|------|
-| `server/main.go` | `package main` → `package server`,导出 `InitBackend()` | 供 Wails 入口调用 |
-| `server/core/viper.go` | 新增 exe 目录 fallback | 双击 exe 时能找到 config.yaml |
+### 停车会话的通道设备快照
 
-其余 `api/` `service/` `dao/` `router/` `middleware/` 等全部不变
+入场和出场分别保存通道 ID、通道编码、通道名称、设备编码、设备名称。名称用于业务查看,编码和 ID 用于追踪;通道或设备后续改名不会影响新记录的历史可读性。
 
----
+对于旧记录,查询时会根据已保存的通道 ID、设备编码补全当前名称;若关联配置已删除,则回退显示编码。
 
-## 前端改动(相对于 lc_garage/web)
+## 5. 启动与构建
 
-| 文件 | 改动 |
-|------|------|
-| `view/parking/parkingInfo/` | 新增:停车场左列表+右Tab联动布局 |
-| `pinia/parkArea.js` | 新增:停车场联动状态 |
-| `view/layout/aside/index.vue` | 新增:角色菜单筛选(operator 隐藏 admin 菜单) |
-| `.env.production` | `VITE_BASE_API = /api`(Wails 内置代理处理) |
-| `vite.config.js` | Dev server 端口 5173 |
+```powershell
+# 首次安装前端依赖
+cd frontend
+npm install
+cd ..
 
----
+# 开发模式
+wails dev
 
-## 启动
+# 前端生产构建校验
+cd frontend
+npm run build
 
-```bash
-# 开发
-cd lc_garage && wails dev
+# 构建 Windows 应用
+cd ..
+wails build
+```
 
-# 构建
-cd lc_garage && wails build
-# 产物: build/bin/smart-parking.exe
+构建产物为 `build/bin/smart-parking.exe`。首次启动会执行 SQLite 自动迁移和系统种子数据初始化。
 
-# 运行
-双击 build/bin/smart-parking.exe
-```
+## 6. 默认账户
+
+首次初始化后可使用:
+
+- 管理员:`admin` / `123456`
+- 操作员权限标识:`618`
+- 管理员权限标识:`888`
+
+## 7. 相关文档
 
-管理员:`admin` / `123456`
+- [项目进度](项目进度.md)
+- [进出场流程详解](进出场流程详解.md)
+- [数字票说明](digital-ticket.md)
+- [收费流程](收费流程.md)
+- [测试用例编写规范](测试用例编写规范.md)
+- [目标代码规划](代码规划.md)

+ 97 - 148
doc/digital-ticket.md

@@ -1,180 +1,129 @@
-# 数字票(Digital Ticket)说明文档
+# 数字票(Digital Ticket)说明
 
-> 版本:1.0 | 日期:2026-07-23
+> 版本:2.0
+> 更新日期:2026-08-07
 
-## 1. 概述
+## 1. 定位
 
-数字票(Digital Ticket)是停车业务中的标准化抽象模型,以电子化的"票"作为核心对象,遵循 **创建 → 支付 → 验证 → 放行** 的线性状态机模式。不绑定特定硬件或支付方式,具有高度的通用性和可扩展性
+数字票是一次停车会话的**支付与离场状态凭证**,不是纸质小票的替代品,也不绑定某一种支付入口
 
-## 2. 核心概念
+- 每次车辆入场都会创建一张数字票,无论来源是 RFID、人工录入、票机还是后续接入的车牌识别。
+- 数字票关联一条停车会话(`vehicle_record`),但不复制会话的通道、设备、车辆图片等事实数据。
+- 支付流水(`payment_record`)是资金事实;数字票和停车会话保存用于业务查询的结算摘要和状态。
+- 纸质小票可打印数字票票号或二维码,但只是数字票的物理凭证之一。
 
-### 设计原则
-
-- **统一抽象**:所有进场方式(RFID、车牌识别、手动录入)最终都创建数字票
-- **状态驱动**:票的状态决定下一步操作,不允许跳跃式状态变更
-- **事件溯源**:关键节点记录事件日志,便于审计和异常追踪
-- **二维码凭证**:以 ticket_no(UUID)作为唯一标识和验证依据
+```text
+停车会话 vehicle_record
+  ├─ 入场/出场时间、停车场、通道设备快照、结算摘要
+  └─ 1 : 1 数字票 digital_ticket
+       ├─ ticket_no、状态、支付摘要、事件日志
+       └─ 关联结算支付流水 payment_record
+            └─ 实际金额、实收、找零、入口、方式、操作员
+```
 
-### 与现有流程的关系
+## 2. 生命周期
 
-```
-任何进场方式(手动/RFID/车牌)
-        │
-        ▼
-  VehicleEntry() ──→ 创建 vehicle_record
-        │                │
-        │                ▼
-        │         创建 digital_ticket
-        │         (状态: pending_payment)
-        │
-        ▼
-  ExitPreview / ExitConfirm
-        │
-        ▼
-  digital_ticket 状态 → paid → exited
+```text
+车辆入场
+  -> 创建停车会话
+  -> 创建数字票(pending_payment)
+  -> 支付成功(paid)
+  -> 确认离场(exited)
 ```
 
-数字票是 `vehicle_record` 的业务状态层,不替代它。报表和监控继续用 `vehicle_record`,业务流转看 `digital_ticket` 状态。
+| 状态 | 含义 | 可执行操作 |
+| --- | --- | --- |
+| `pending_payment` | 已入场、等待支付或免费放行判断 | 支付、出场确认(仅零费用/免费) |
+| `paid` | 已完成支付、尚未出场 | 出场确认 |
+| `exited` | 会话已关闭、车辆已离场 | 只读 |
+| `expired` | 已过期或业务终止 | 只读,需按异常流程处理 |
 
-## 3. 状态机
+`created` 仅作为创建过程中的内部初始状态,创建完成后会进入 `pending_payment`。
 
-```
-                    ┌──────────┐
-               ┌──→ │  expired │ ←──┐
-               │    │  已过期   │    │
-               │    └──────────┘    │
-               │     超时未支付      │ 超时未离场
-               │                    │
-  ┌─────────┐ │  ┌───────────────┐ │  ┌──────┐  ┌────────┐
-  │ created │─→│  │pending_payment│─→│ paid │─→│ exited │
-  │  已创建  │   │    待支付      │  │ 已支付│  │ 已离场  │
-  └─────────┘   └───────────────┘  └──────┘  └────────┘
-   车辆入场        自动转换         支付成功    验证放行
-```
+## 3. 与支付入口、支付方式的关系
 
-| 状态 | 说明 | 触发条件 |
-|------|------|---------|
-| `created` | 车辆入场,数字票生成 | 入场事件触发 |
-| `pending_payment` | 待支付 | 创建完成后自动进入 |
-| `paid` | 已支付,费用结清 | 支付成功回调 |
-| `exited` | 已离场,流程结束 | 验证通过后开闸 |
-| `expired` | 超时未支付或未离场 | 定时任务或超时逻辑 |
-
-### 状态转换规则
-
-| 当前状态 | 允许转换到 |
-|---------|-----------|
-| `created` | `pending_payment` |
-| `pending_payment` | `paid`, `expired` |
-| `paid` | `exited`, `expired` |
-| `exited` | 无(终态) |
-| `expired` | 无(终态) |
-
-状态转换是原子操作,代码层做了合法性校验,不允许跳跃式变更。
-
-## 4. 数据模型
-
-### digital_ticket 表
-
-| 字段 | 类型 | 约束 | 说明 |
-|------|------|------|------|
-| id | INTEGER | PK | 主键 |
-| ticket_no | TEXT(64) | UNIQUE | 票号(32位十六进制UUID) |
-| plate_number | TEXT(20) | | 车牌号 |
-| trigger_mode | TEXT(20) | | 触发方式:rfid / plate_recognition / manual |
-| state | TEXT(20) | default:created | 状态 |
-| vehicle_record_id | INTEGER | FK | 关联 vehicle_record.id |
-| entry_time | DATETIME | | 入场时间 |
-| exit_time | DATETIME | | 出场时间 |
-| fee | REAL | | 计费金额 |
-| payment_method | TEXT(20) | | 支付方式:cash / free / wechat / alipay |
-| payment_order_no | TEXT(64) | | 支付单号 |
-| paid_at | DATETIME | | 支付时间 |
-| expire_at | DATETIME | | 过期时间 |
-| event_log | TEXT | | 事件日志(JSON数组) |
-| created_at | DATETIME | | 创建时间 |
-| updated_at | DATETIME | | 更新时间 |
-
-### event_log 格式
+### 支付入口
 
-```json
-[
-  {"at": "2026-07-23T15:27:08+08:00", "event": "created", "trigger": "manual"},
-  {"at": "2026-07-23T15:35:12+08:00", "event": "pending_payment"},
-  {"at": "2026-07-23T15:36:01+08:00", "event": "paid", "payment_method": "cash", "paid_amount": 20, "payment_order_no": "PO20260723001"},
-  {"at": "2026-07-23T15:36:05+08:00", "event": "exited"}
-]
-```
+支付入口回答“在哪里完成支付”,例如:
 
-## 5. API 接口
+- `counter`:收费岗亭
+- `ticket`:停车小票/扫码入口
+- `central_payment_machine`:中央缴费机
 
-| 方法 | 路径 | 说明 | 鉴权 |
-|------|------|------|:--:|
-| GET | `/digital-ticket/list` | 分页列表(按车牌/状态筛选) | JWT |
-| GET | `/digital-ticket/:ticketNo` | 根据票号查询详情 | JWT |
-| POST | `/digital-ticket/:ticketNo/pay` | 支付(→ paid) | JWT |
-| POST | `/digital-ticket/:ticketNo/exit` | 离场验证(→ exited) | JWT |
+### 支付方式
 
-### 支付请求体
+支付方式回答“如何完成支付”,例如:
 
-```json
-{
-  "payment_method": "cash",
-  "payment_order_no": "PO20260723001",
-  "paid_amount": 20.0
-}
-```
+- `cash`:人工现金
+- `pos`:POS 机
+- `ticket_qr`:小票扫码支付
+- `free`:免费放行
 
-### 列表查询参数
+入口和方式通过 `payment_entry_method` 配置关联。关闭入口会阻止从该入口发起新支付;关闭方式会从该入口可选方式中移除。二者都不改变历史数字票或历史支付流水。
 
-| 参数 | 说明 |
-|------|------|
-| plate_number | 车牌号(模糊匹配) |
-| state | 状态筛选 |
-| page | 页码 |
-| page_size | 每页条数 |
+## 4. 一致性和幂等性
 
-## 6. 代码结构
+支付和离场均在事务内完成:
 
-```
-internal/modules/digital-ticket/
-├── api.go                 # HTTP Handler + 路由注册
-├── service/
-│   └── service.go         # 状态机 + 事件日志 + Create/Transition
-└── repository/
-    └── repo.go            # CRUD
-```
+1. 校验当前数字票和停车会话状态。
+2. 校验支付金额、支付入口和支付方式配置。
+3. 写入 `payment_record`。
+4. 更新停车会话支付摘要及数字票状态。
+5. 出场时保存出口通道/设备快照,关闭停车会话并将数字票更新为 `exited`。
 
-```
-internal/dao/digital_ticket.go  # GORM 实体
-```
+系统会拒绝负金额、金额不足、重复支付和已关闭会话再次离场。并发结算使用会话状态条件更新保证只有一个请求成功;事务失败不会遗留部分支付流水。
 
-### 关键函数
+## 5. 核心字段
 
-| 函数 | 说明 |
-|------|------|
-| `TicketService.Create()` | 创建数字票,生成 UUID ticket_no,写入 event_log |
-| `TicketService.Transition()` | 状态转换,校验合法性,追加事件日志 |
-| `TicketService.GetByTicketNo()` | 扫码查询(二维码凭证验证入口) |
+| 字段 | 说明 |
+| --- | --- |
+| `ticket_no` | 数字票唯一票号,用于二维码、查询和外部支付回调关联 |
+| `vehicle_record_id` | 关联的停车会话 ID |
+| `trigger_mode` | 入场来源,如 `rfid`、`manual`、`camera` |
+| `state` | 当前数字票状态 |
+| `fee` / `paid_amount` | 应收与实收摘要 |
+| `payment_entry` / `payment_method` | 实际完成支付的入口与方式 |
+| `payment_order_no` | 外部支付订单号,可选 |
+| `event_log` | 票据状态变化事件日志 |
 
-## 7. 扩展性
+票号是面向业务和外部凭证的随机唯一标识;停车会话 ID 是内部数据库流水编号,两者不应混用。
 
-数字票作为统一抽象,后续可扩展:
+## 6. API
 
-- **线上支付**:扫码 → 跳转支付页 → 回调 `PayTicket`
-- **自助缴费机**:票机扫码 → 调 `/ticketNo` 查询费用 → POS 支付 → 回调 `/ticketNo/pay`
-- **无牌车处理**:进场出纸质二维码小票(ticket_no 打印在票上)
-- **定时过期**:后台任务扫描 `pending_payment` 超时票 → Transition 到 `expired`
+以下路径会叠加系统 API 前缀(开发环境通常为 `/api`):
 
-## 8. 数据库位置
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET | `/digital-ticket/summary` | 按筛选条件获取待支付、已支付、已离场等汇总 |
+| GET | `/digital-ticket/list` | 数字票分页列表 |
+| GET | `/digital-ticket/:ticketNo` | 数字票详情 |
+| POST | `/digital-ticket/:ticketNo/pay` | 支付数字票 |
+| POST | `/digital-ticket/:ticketNo/exit` | 确认数字票对应车辆离场 |
 
-```
-Windows: %APPDATA%\smart-parking\lc_garage.db
-        → C:\Users\<用户名>\AppData\Roaming\smart-parking\lc_garage.db
+支付请求示例:
+
+```json
+{
+  "payment_entry": "counter",
+  "payment_method": "cash",
+  "payment_order_no": "OPTIONAL-ORDER-NO",
+  "paid_amount": 20.0
+}
 ```
 
-查询数字票数据:
+## 7. 前端页面
 
-```sh
-sqlite3 "%APPDATA%/smart-parking/lc_garage.db" "SELECT id, ticket_no, plate_number, state FROM digital_ticket;"
-```
+`frontend/src/view/parking/digitalTicket.vue` 提供:
+
+- 票据状态汇总和分页筛选
+- 票号复制、票据详情、状态事件展示
+- 基于可用支付入口和支付方式的结算操作
+- 已支付票据的离场确认
+
+进出场工作台 `entryExit.vue` 使用同一会话和数字票逻辑,不会创建另一套支付状态。
+
+## 8. 扩展方式
+
+新增中央缴费机、POS 或线上支付时,应新增或启用一个支付入口、配置可用支付方式,并在支付成功回调中调用已有支付服务。不得绕开数字票、停车会话和支付流水分别修改状态。
+
+建议外部支付回调以 `ticket_no + payment_order_no` 作为幂等键,并保留网关原始流水号以便对账。

+ 82 - 178
doc/代码规划.md

@@ -1,184 +1,88 @@
-针对 **Wails v2 (Go) + Vue3** 的技术栈,建议采用**“按业务功能切片”**的架构设计。
-相较于传统的“把所有 handler 放一个目录,所有 service 放一个目录”,按功能切片(每个模块内部自带 handler/service/repo)能更好地隔离业务逻辑,非常适合这种模块边界清晰的停车系统。
-以下是完整的项目代码模块结构规划:
-### 顶层目录结构
-```text
-parking-system/
-├── build/                      # Wails 构建配置 (图标、安装包脚本)
-├── frontend/                   # Vue3 前端源码 (Vite)
-├── internal/                   # 核心业务代码 (不可被外部引用)
-├── pkg/                        # 通用工具包
-├── app.go                      # Wails 应用主结构体 (统一绑定前端方法)
-├── main.go                     # Wails 程序入口
-├── go.mod
-└── wails.json
-```
-### `internal/` 详细拆解 (核心)
-这是整个后端的灵魂。我们将基础架构、硬件代理、边缘端通信和业务模块分开管理。
+# 代码组织与演进规划
+
+> 更新日期:2026-08-07
+> 本文档描述当前工程边界和后续演进原则,不代表已创建的目录均已实现。
+
+## 1. 当前组织方式
+
+项目保留既有 GVA 风格目录用于稳定功能维护,同时将新增领域能力放到 `internal/modules/`。
+
 ```text
 internal/
-├── core/                       # 基础设施与共享内核
-│   ├── config/                 # 全局配置加载 (解析 config.yaml)
-│   ├── database/               # SQLite/GORM 初始化、连接池、自动迁移
-│   ├── logger/                 # Zap 日志封装
-│   └── response/               # 统一的 API 返回结构封装
-│
-├── hardware/                   # [P0] 本地硬件交互层 (Go直接控制物理设备)
-│   ├── pos/
-│   │   ├── pos_driver.go       # 串口底层管理 (打开/关闭/重连)
-│   │   └── ingenico.go         # POS 机协议解析与指令收发
-│   └── printer/
-│       ├── escpos.go           # ESC/POS 指令生成
-│       └── ticket_template.go  # 小票排版模板渲染
-│
-├── edge/                       # [P0] 边缘端(RK3568)通信层
-│   ├── tcp_server.go           # 监听 RK3568 的 TCP 连接与心跳
-│   ├── protocol.go             # 定义与 RK3568 的上下行报文协议
-│   └── event_bus.go            # 将底层硬件事件分发到业务模块
-│
-├── modules/                    # ★ 业务功能模块 (按功能切片划分) ★
-│   ├── system/                 # [P1] 系统与权限管理模块
-│   ├── parking/                # [P0] 车场与设备管理模块
-│   ├── vehicle/                # [P0] 车辆与车流管理模块
-│   ├── billing/                # [P0] 计费规则引擎模块
-│   ├── order/                  # [P0] 收费与订单管理模块
-│   ├── monitor/                # [P2] 监控与大屏展示模块
-│   └── report/                 # [P2] 数据统计与报表模块
-│
-└── wails_bindings/             # Wails 前端绑定注册中心
-    └── register.go             # 集中实例化所有模块的 Service,并返回给 app.go 用于 Bind
+├─ api/v1/               # 既有 HTTP 控制器
+├─ service/              # 既有业务、设备与统一通行服务
+├─ router/               # 既有路由组
+├─ dao/                  # GORM 实体
+├─ model/                # 请求、响应和公共模型
+├─ initialize/           # GORM、路由、种子数据、设备初始化
+├─ global/ core/         # 配置、数据库、日志、服务启动
+└─ modules/              # 新领域模块
+   ├─ parking-session/   # 停车会话
+   ├─ digital-ticket/    # 数字票
+   ├─ payment/           # 支付配置和支付流水
+   ├─ monthly/           # 包月车
+   ├─ shift/             # 交接班
+   ├─ report/            # 收入报表
+   └─ printer/           # 小票打印
 ```
-### `internal/modules/` 业务模块内部结构
-每一个业务模块内部保持一致的“微型三层架构”。以最核心的 `order` (订单收费) 和 `billing` (计费) 模块为例:
+
+## 2. 新模块约定
+
+新增业务模块采用下列结构,按实际复杂度选择是否需要全部层次:
+
 ```text
-modules/
-├── billing/                    # [P0] 计费规则引擎
-│   ├── model/
-│   │   └── billing_rule.go     # 计费规则表结构体 (GORM Model)
-│   ├── repository/
-│   │   └── rule_repo.go        # 规则的 CRUD 操作
-│   ├── service/
-│   │   ├── calculator.go       # ★ 核心计费算法 (按时长、阶梯、封顶计算)
-│   │   └── rule_service.go     # 规则的增删改查逻辑
-│   └── handler.go              # (可选) 如果有 REST API 路由则放这里
-│
-├── order/                      # [P0] 收费与订单管理 (前端交互最频繁)
-│   ├── model/
-│   │   ├── order.go            # 订单主表 (入场时间、金额、状态)
-│   │   └── shift_record.go     # 交接班记录表
-│   ├── repository/
-│   │   ├── order_repo.go       # 订单流水查询
-│   │   └── shift_repo.go       # 交接班统计查询
-│   ├── service/
-│   │   ├── checkout_service.go # ★ 出场结算逻辑 (调用 billing 计算费用)
-│   │   ├── payment_service.go  # ★ 支付执行 (联动 hardware/pos 和 printer)
-│   │   └── shift_service.go    # 当班对账逻辑
-│   └── api.go                  # ★ 暴露给 Wails 前端的方法 (如 ProcessCheckout, PayByCash)
-│
-├── vehicle/                    # [P0] 车辆管理
-│   ├── model/
-│   │   ├── whitelist.go        # 白名单/内部车
-│   │   └── monthly_card.go     # 月卡车及续费记录
-│   ├── repository/
-│   │   └── vehicle_repo.go
-│   ├── service/
-│   │   ├── whitelist_service.go# 白名单校验逻辑
-│   │   └── monthly_service.go  # 月卡办理与有效期校验
-│   └── api.go                  # 暴露给前端的车辆管理方法
-│
-├── parking/                    # [P0] 车场与设备配置
-│   ├── model/
-│   │   ├── lane.go             # 车道配置
-│   │   └── device.go           # RK3568节点与本地外设参数
-│   ├── repository/
-│   │   └── config_repo.go
-│   ├── service/
-│   │   └── device_service.go   # 设备状态监控、参数下发
-│   └── api.go
-│
-├── system/                     # [P1] 基础权限
-│   ├── model/
-│   │   └── user.go
-│   ├── service/
-│   │   └── auth_service.go     # 登录验证、密码修改
-│   └── api.go
-│
-├── monitor/                    # [P2] 监控大屏
-│   ├── service/
-│   │   └── realtime_service.go # 聚合设备状态与实时过车数据
-│   └── api.go
-│
-└── report/                     # [P2] 报表
-    ├── service/
-    │   ├── finance_service.go  # 财务收入统计
-    │   └── traffic_service.go  # 车流量分析
-    └── api.go
+internal/modules/<module>/
+├─ api.go
+├─ service/
+├─ repository/
+└─ model/
+   ├─ request/
+   └─ response/
 ```
-### 如何在 Wails 中将这些模块暴露给前端?
-在 `app.go` 中,我们不需要把所有的逻辑都塞进去,而是通过 `internal/wails_bindings/register.go` 统一管理依赖注入。
-**1. `internal/wails_bindings/register.go`**
-```go
-package wails_bindings
-import (
-	"parking-system/internal/core/database"
-	"parking-system/internal/hardware/pos"
-	"parking-system/internal/modules/order"
-	"parking-system/internal/modules/billing"
-	// ... 其他模块
-)
-// Register 初始化所有依赖并返回需要绑定给前端的结构体实例
-func Register(db *gorm.DB) []interface{} {
-	// 1. 初始化底层硬件
-	posDriver := pos.NewDriver("COM3", 9600)
-	
-	// 2. 初始化各模块的 Repository
-	orderRepo := order.NewOrderRepo(db)
-	billingRepo := billing.NewRuleRepo(db)
-	
-	// 3. 初始化各模块的 Service (注入 Repo 和硬件依赖)
-	billingSvc := billing.NewService(billingRepo)
-	orderSvc := order.NewService(orderRepo, billingSvc, posDriver)
-	
-	// 4. 初始化各模块的 API (注入 Service)
-	orderAPI := order.NewAPI(orderSvc)
-	// billingAPI := billing.NewAPI(billingSvc)
-	
-	// 返回所有需要暴露给前端的 API 实例
-	return []interface{}{
-		orderAPI,
-		// billingAPI,
-	}
-}
-```
-**2. `main.go` (Wails 入口)**
-```go
-package main
-import (
-	"context"
-	"parking-system/internal/core/database"
-	"parking-system/internal/wails_bindings"
-	"github.com/wailsapp/wails/v2/pkg/options"
-)
-func main() {
-	// 1. 初始化数据库
-	db := database.Init("%APPDATA%/smart-parking/lc_garage.db")
-	
-	// 2. 获取所有前端绑定实例
-	binds := wails_bindings.Register(db)
-	// 3. 启动 Wails
-	err := wails.Run(&options.App{
-		Title:  "智慧停车收费系统",
-		Width:  1280,
-		Height: 800,
-		Bind:   binds, // ★ 将所有模块的 API 绑定给前端
-	})
-	if err != nil {
-		panic(err)
-	}
-}
+
+- `api.go`:HTTP Handler 和路由注册函数。
+- `service`:事务、状态机、领域校验和跨模块编排。
+- `repository`:GORM 查询、条件更新、分页查询。
+- `model`:模块自身请求和响应模型;共享模型仍放在 `internal/model/common`。
+
+## 3. 模块边界
+
+| 模块 | 所有权 | 不应承担 |
+| --- | --- | --- |
+| `parking-session` | 在场会话、身份定位、进出场事实快照、会话状态 | 支付网关、票据状态机 |
+| `digital-ticket` | 票号、状态、事件日志、票据查询 | 通道设备事实、资金流水 |
+| `payment` | 支付入口/方式配置、金额校验、支付流水 | 车辆入场、闸机控制 |
+| `printer` | 票据版式、串口/USB 打印 | 创建会话或决定支付状态 |
+| `service/parking` | 设备通道定位、统一通行、闸机动作 | 直接修改多个领域表绕过事务服务 |
+
+## 4. 当前关键调用关系
+
+```text
+前端/设备事件
+  -> PassageService.HandlePassage
+  -> ParkingSessionService 定位或创建会话
+  -> TicketService 创建或查询数字票
+  -> PaymentService 完成结算
+  -> ParkingSessionService.CloseSessionTx 关闭会话
+  -> TicketService 标记已离场
+  -> GateController 执行开闸/关闸
 ```
-### 这种架构的优势
-1. **按需开发与裁剪**:如果要先开发 P0,你只需要关注 `billing`、`order`、`parking` 和 `hardware` 目录,其他的 `monitor` 和 `report` 目录甚至可以先不建,互不干扰。
-2. **清晰的调用链路**:
-   - 前端按钮点击 -> `order/api.go` -> `order/service/checkout_service.go` (计算费) -> `billing/service/calculator.go` -> 返回金额 -> `order/service/payment_service.go` (调硬件) -> `hardware/pos/pos_driver.go` -> 返回结果给前端。
-3. **Wails 绑定解耦**:通过 `wails_bindings` 统一做依赖注入,避免了业务代码与 Wails 框架强耦合,方便未来做单元测试。
+
+跨模块协作优先通过 `internal/service/enter.go` 的服务注册中心或稳定接口进行,避免模块间随意相互导入形成循环依赖。
+
+## 5. 后续演进
+
+| 优先级 | 演进项 | 目标 |
+| --- | --- | --- |
+| P0 | 摄像头识别适配层 | 将 OCR 识别结果转换为统一通行请求,不复制进出场逻辑 |
+| P0 | 支付回调适配层 | POS、中央缴费机、线上支付通过统一幂等回调进入支付服务 |
+| P1 | 异常处置模块 | 无票、人工抬杆、设备离线、支付不确定状态形成可审计工单 |
+| P1 | 设备事件和告警 | 统一记录设备在线、开闸失败、打印失败等事件 |
+| P2 | 报表查询仓储化 | 报表复杂查询逐步从既有 service 收敛到模块 repository |
+
+## 6. 禁止事项
+
+- 不新增第二套车辆出场或支付状态机。
+- 不把支付入口当作支付方式,或把纸质小票当作数字票本体。
+- 不在 API 层跨表直接更新会话、数字票和支付流水。
+- 不为了新功能修改或删除旧数据字段;新增字段应兼容历史数据,并说明查询回退策略。

+ 182 - 0
doc/功能缺失.md

@@ -0,0 +1,182 @@
+# 智慧停车管理系统功能缺失清单
+
+> 更新日期:2026-08-11  
+> 评估范围:`lc_garage` 当前前端、后端、设备接入和自动化测试代码  
+> 状态说明:本文用于记录尚未完成的业务闭环和生产风险,不代表相关模块完全没有代码。
+
+## 1. 总体结论
+
+当前项目已经具备单机人工收费停车场的基础业务链路,包括停车场配置、车辆管理、统一进出场、停车会话、数字票、人工收费、支付流水、月卡、交接班、报表、道闸模拟和小票打印。
+
+距离正式运营仍缺少三个关键闭环:
+
+1. POS、扫码、中央缴费机等真实资金交易。
+2. ~~业务异常、设备异常的记录和处置~~(已于 2026-08-12 完成核心闭环,见 2.2 节与 `doc/异常处置设计.md`)。
+3. 摄像头、道闸、地感、LED 屏的状态反馈和联动。
+
+“存在支付方式配置”不等同于“已经接入真实支付”。当前非现金支付主要用于业务流程确认,尚未连接实际支付终端或支付网关。
+
+## 2. P0:上线前必须解决
+
+### 2.1 非现金支付没有真实交易能力
+
+**当前情况**
+
+- 已支持支付入口、支付方式及可用关系配置。
+- POS 和外部扫码方式可以直接创建支付流水并把会话标记为已支付。
+- 尚未实现支付订单、POS 驱动、扫码下单、支付网关、异步回调、主动查询和超时处理。
+
+**业务影响**
+
+- 操作员确认不能证明外部资金已经到账。
+- 网络超时、支付成功但本地未更新等情况可能造成资金与业务状态不一致。
+
+**完成标准**
+
+- 增加独立支付订单和明确的待支付、处理中、成功、失败、不确定、已退款状态。
+- 外部支付只能由可信终端结果、回调或主动查询确认成功。
+- 支付回调、重复通知和主动查询必须幂等。
+- 实现退款、撤销、冲正、失败补偿和财务对账。
+
+### 2.2 异常报表和异常处置(核心闭环已完成,扩展点待接入)
+
+**当前情况(2026-08-12 更新)**
+
+- 已实现 `incident_record` 异常事件表 + `internal/modules/incident/` 模块,详见 `doc/异常处置设计.md`。
+- 自动埋点已接入:统一通行五类(开闸失败、重复入场、黑名单拦截、满位拒绝、无入场记录出场)、人工抬杆/关闸(`reason` 可选参数)、设备离线(在线→离线转换,重连自动关闭)。
+- 支持待处理、处理中、已解决、已关闭四状态流转(CAS 条件更新)、处理方式与备注、`event_log` 时间线审计。
+- 权限分级:查看/上报面向全部角色;处置仅 888/9527;强制免费仅 888(前后端双校验)。
+- `device_command_log` 指令流水已实现:记录每次道闸开/关命令(来源、操作员、会话、结果、耗时),开闸失败时与 `gate_failed` 异常关联。
+- 前端 `view/report/sheet.vue` 已重写:统计行 + 筛选 + 分页 + 详情时间线 + 处置表单 + 人工上报。
+- 自动化测试已覆盖:状态机流转、CAS 并发、去重、best-effort、编号生成、统计、开闸失败埋点集成、路由鉴权。
+
+**仍待接入的异常类型(埋点为预留扩展点)**
+
+- 无牌车:票机无牌取票已有唯一会话身份,无独立异常分类,可后续在票机链路补充。
+- 车牌识别冲突:依赖摄像头 OCR 接入(P2),暂无可埋点。
+- 票据打印失败:待打印任务持久化(P0)落地后接入 `print_failed`。
+- 支付失败/支付状态不确定:待支付订单层(P0)落地后接入 `payment_failed`/`payment_uncertain`。
+- 余位为负、会话与数字票状态不一致:依赖对账任务,未实现自动检测。
+
+**完成标准(对照)**
+
+- ✅ 异常自动落库,包含来源、停车会话、车辆、通道、设备和操作员。
+- ✅ 支持待处理、处理中、已解决、已关闭状态和处理备注。
+- ✅ 人工放行和资金相关异常有权限控制与完整审计。
+
+### 2.3 票机打印失败补偿未完成
+
+**当前情况**
+
+- 票机已经接入 `PassageService`,按通道定位停车场和设备,并执行统一准入、会话、数字票和开闸流程。
+- 已按新建停车会话直接返回数字票,不再通过车牌查询最新票据。
+- 无牌取票会生成唯一票机会话身份,避免多辆无牌车共用空车牌。
+- 当前打印仍是同步硬件调用;打印失败时会返回已完成的会话、票据和闸机状态,但尚无持久化任务、自动重试、重打和作废机制。
+
+**完成标准**
+
+- 打印任务持久化,支持失败重试、重打、作废和操作审计。
+- 会话创建、开闸和打印失败之间有明确的补偿规则。
+
+## 3. P1:已有基础实现但业务闭环不足
+
+### 3.1 计费引擎能力不足
+
+- 当前只有免费时长、起步价、固定时间单位、单次日封顶和 VIP 优惠。
+- 超过 24 小时仍只应用一次日封顶,多日停车计费不正确。
+- 缺少自然日或滚动 24 小时规则、跨日分段、夜间价、节假日、阶梯计费和不同停车场费率。
+- 缺少缴费后免费离场时限及超时重新计费。
+- 未配置收费规则时按每分钟 `0.1` 隐式收费,可能造成错误收费。
+- 收费配置缺少负值、折扣范围、时间单位和封顶金额等完整校验。
+
+### 3.2 月卡办理、续费和退卡不是完整事务
+
+- 创建月卡、写白名单和写支付流水没有放在同一事务中,部分错误被忽略。
+- 续费更新月卡、白名单和支付流水时也可能产生部分成功。
+- 月卡收费固定记为收费岗亭现金,不能选择支付入口和支付方式。
+- 退卡只标记 `refunded` 并删除白名单,没有退款金额、退款流水和审批。
+- 月、季、年按 30、90、365 天计算,不是自然月、自然季度和自然年。
+- 缺少过期清理、提前续费、冻结、转车、退款计算等业务规则。
+
+### 3.3 交接班只覆盖现金
+
+- 只统计 `payment_method='cash'`,没有按支付入口和支付方式汇总其他交易。
+- 支付流程没有要求操作员必须处于当班状态。
+- 开班、收款和交班缺少统一事务约束。
+- 缺少管理者复核、差异审批、交班单打印和交班后锁定。
+- 页面缺少完整分页、筛选、备注管理和审核能力。
+
+### 3.4 收入报表维度和财务口径不足
+
+- 当前主要按日期和停车场汇总,只单独展示现金和免费。
+- 缺少支付入口、支付方式、操作员、班次、车型、通道等维度。
+- 缺少退款、撤销、冲正、月卡收入和交班差异。
+- 缺少明细追溯、日结、月结、导出和打印。
+
+### 3.5 数字票过期状态没有自动执行机制
+
+- 已定义 `expired` 状态和状态流转规则。
+- 没有发现根据 `expire_at` 自动过期的定时任务。
+- 需要明确待支付票、已支付未离场票分别在何种条件下过期,以及过期后的恢复策略。
+
+### 3.6 道闸和设备模型不够独立
+
+- 当前工作台从 `UHFReader` 获取道闸设备,读卡器和道闸控制器共用设备模型。
+- 无法完整表达一个通道包含摄像头、读卡器、入口票机、道闸、地感等多设备拓扑。
+- 缺少闸杆状态反馈、地感、防砸、落杆确认、失败重试和指令流水。
+- 模拟道闸配置若在生产环境误开启,会显示在线但不发送真实指令。
+
+### 3.7 停车场基础配置约束不足
+
+- 已修复按 `LotId` 查询条件、容量低于当前在场车辆数校验,以及存在未出场会话时禁止删除。
+- 删除停车场前仍未检查岗亭、通道和设备关联。
+- 已结束的历史停车会话仍允许所属停车场被软删除,历史页面需要统一处理已删除配置的名称展示。
+
+### 3.8 操作审计和敏感数据保护不足
+
+- 操作日志保存请求体和响应体,未见密码、令牌、支付字段统一脱敏。
+- 摄像头账号密码缺少加密存储方案。
+- 设备失败主要写日志,尚未进入业务告警和异常处置中心。
+
+## 4. P2:完全缺失或只有模型
+
+| 功能 | 当前状态 | 目标能力 |
+| --- | --- | --- |
+| 车牌摄像头接入 | 只有 `camera` 数据实体 | 摄像头配置、RTSP/SDK、OCR 回调、置信度、抓拍、防重复识别、通道绑定 |
+| 中央缴费机 | 只有支付入口概念 | 终端注册、心跳、订单查询、支付、出场校验、远程配置 |
+| LED 余位屏 | 未实现 | 屏幕配置、协议适配、余位推送、离线重试 |
+| 线上支付 | 未实现 | 微信/支付宝等下单、回调、查询、退款和对账 |
+| 设备告警中心 | 未实现 | 在线状态、故障事件、告警等级、确认和恢复 |
+| 远程设备诊断 | 未实现 | 设备参数、连通性测试、指令测试、诊断审计 |
+| 数据备份恢复 | 未实现 | SQLite 备份、校验、恢复、保留策略和恢复演练 |
+| 数据归档清理 | 未实现 | 历史会话、日志、抓拍图片的归档和容量管理 |
+| 报表导出打印 | 未实现 | Excel/PDF 导出、日结单、月报和打印 |
+| 打印任务管理 | 未实现 | 打印机配置、任务队列、状态、失败重试和重打权限 |
+| 短信/邮件验证 | 模拟实现 | 服务商配置、发送限制、验证码校验和审计 |
+
+## 5. 测试覆盖缺口
+
+现有自动化测试重点覆盖停车会话、数字票、支付金额边界、重复和并发支付、事务回滚、道闸接口及 USB 票据版式。以下范围仍缺少完整测试:
+
+- 票机打印任务持久化、失败补偿、重打和作废权限。
+- 月卡办理、续费、退卡的跨表事务和退款。
+- 交接班、收入报表和多支付方式对账。
+- 多日计费、跨日规则、缴费后超时和收费配置异常。
+- 异常事件的生成、处置、权限和审计。
+- 摄像头、地感、真实道闸、LED 屏的集成和现场测试。
+- 数据备份、恢复和故障恢复演练。
+
+## 6. 推荐实施顺序
+
+1. ~~建立异常事件、人工处置和设备指令流水~~(已于 2026-08-12 完成,见 2.2)。
+2. 建立支付订单层,接入 POS、扫码和中央缴费机。
+3. 重构多日计费及缴费后离场超时规则。
+4. 完成月卡事务、退款、交接班和财务对账。
+5. 接入摄像头、地感、道闸反馈和 LED 余位屏。
+6. 补充报表导出、打印任务、备份恢复和生产运维能力。
+
+## 7. 维护规则
+
+- 功能开始开发时,在对应条目中补充负责人、计划版本和关联任务。
+- 功能完成后必须同时满足代码实现、权限控制、自动化测试和业务验收,不能仅因页面或配置存在就标记完成。
+- 已完成条目从本文移除前,应同步更新 `项目进度.md`、相关流程文档和测试报告。

+ 398 - 0
doc/异常处置设计.md

@@ -0,0 +1,398 @@
+# 异常处置闭环设计
+
+> 更新日期:2026-08-12
+> 状态说明:~~本文为设计文档~~ **已于 2026-08-12 按本文实现完毕**(后端模块、自动埋点、指令流水、权限种子、前端页面、自动化测试均落地,`go test ./internal/service/... ./internal/modules/... ./internal/initialize` 与前端 `npm run build` 通过)。文中标注(预留)的项为后续扩展点,不在当前范围;标注(建议包含)的设备指令流水已实现。
+> 代码位置:设计基于 2026-08-12 当前代码(`internal/service/parking/passage.go`、`internal/service/parking/gate.go`、`internal/modules/` 等)。
+
+## 1. 现状与问题
+
+### 1.1 异常只存在于 HTTP 响应
+
+- 统一通行 `HandlePassage` 中,业务落库成功但开闸失败时返回 `result + err`(`passage.go:172-176` 入场、`passage.go:231-235` 出场),`GateStatus="failed"`、`BusinessCompleted=true`。该"业务已完成、设备动作失败"的半完成状态**不落库**,重启后无从追踪。
+- 票机打印失败(`printer` 模块)同样只写在响应里(`PrintStatus="failed"`),无持久化任务、重试、重打和作废机制(重打机制属 P0 打印任务持久化,本文不覆盖,但打印失败事件可先挂入异常表,见 7.4)。
+
+### 1.2 无任何异常实体
+
+`vehicle_record`、`payment_record`、`shift_record`、`printer`、`camera`、`uhf_reader` 均无异常标记、重试状态或补偿状态字段。`frontend/src/view/report/sheet.vue` 是纯静态占位页(无数据绑定、无 API 调用),菜单 27"异常报表"已存在并授权 618/888/9527(`internal/initialize/seed.go:135`)。
+
+### 1.3 设备状态三套并存且不一致
+
+| 状态来源 | 位置 | 问题 |
+| --- | --- | --- |
+| DB `uhf_reader.status` | 读协程 `ReadData` 出错置 offline、重连成功置 online | 只覆盖 UHF 读卡器,与内存态脱节 |
+| 内存 `DeviceManager` / `ConnManager` | `GetGateRuntimeStatus`(`gate.go:104-114`) | 仪表盘设备状态据此统计,重启即失效 |
+| `LastOnlineTime` | `internal/dao/uhf_reader.go` | 死字段,全项目无人写入 |
+
+模拟道闸(`config.yaml` 的 `gate-simulator: true`)下所有设备 `IsGateConnected` 恒真,设备真实掉线无法被现有链路感知。
+
+### 1.4 人工抬杆无业务审计
+
+`POST /parking/gate/open|close`(`internal/api/v1/parking/gate.go`)仅调用 `OpenGateByDeviceCode`,不关联会话、不记录操作原因。OperationRecord 中间件只记录原始 HTTP 请求,无法表达"为什么抬杆、抬杆对应哪辆车/哪个会话"。
+
+### 1.5 可复用的现成机制
+
+| 机制 | 位置 | 复用方式 |
+| --- | --- | --- |
+| 数字票事件日志 | `digital_ticket.event_log`(JSON 事件数组,状态机每次转换追加) | 异常表采用同样的 `event_log` 字段记录状态流转时间线 |
+| 操作审计中间件 | `internal/middleware/operation.go`,PrivateGroup 全量挂载 | API 层的处置请求自动留痕,异常表无需重复记录请求体 |
+| 状态机 CAS 更新 | `digital-ticket` 模块 `UpdateStateIfCurrentTx` 模式 | 异常状态流转按当前状态条件更新,防并发覆盖 |
+| 模块化结构 | `internal/modules/payment|shift`(api.go + service/ + repository/) | 新模块完全仿照此结构 |
+| 权限幂等种子 | `internal/initialize/operation_permission_seed.go`(sys_apis + casbin_rule) | 新增 API 权限仿照 `EnsureOperationPermissions` 实现 |
+| 前端蓝本 | `view/report/paymentRecord.vue`(统计行 + 筛选 + 表格 + 分页) | 异常处置页面直接复用该结构 |
+
+## 2. 总体设计
+
+### 2.1 核心概念:异常事件即工单
+
+一次"需要被记录并可能被人工介入"的事件,事件本身与处置过程合一,落在同一张表:
+
+- **自动产生**:业务/设备链路失败时由埋点自动落库(如开闸失败、设备离线)。
+- **人工产生**:操作员在页面主动上报(如无牌车人工处理、丢票)。
+- **可处置**:记录经历 `pending → processing → resolved → closed` 状态流转,处置信息(方式、金额、备注、处理人)随记录保存。
+- **可审计**:`event_log` 保存每次流转的事件时间线;API 请求由 OperationRecord 中间件留痕;人工放行与资金相关处置有独立权限。
+
+### 2.2 设计原则
+
+1. **best-effort 埋点**:自动埋点失败只记日志,绝不影响业务主流程的返回值与事务结果。
+2. **状态机 + CAS**:状态流转使用条件更新(仿数字票),并发时以行级条件保证只有一次生效。
+3. **增量接入**:埋点是在既有链路上的纯增量调用,不改变现有业务返回值和事务边界。
+4. **权限分级**:查看/上报面向全部角色;处置(状态流转)面向管理员;资金类处置仅最高管理员。
+5. **事件与工单不拆分**:v1 不建"事件表 + 工单表"两套,避免双写一致性问题;同一异常类型的去重由业务规则控制(见 5.4)。
+
+### 2.3 模块位置与边界
+
+新增模块 `internal/modules/incident/`,表模型 `internal/dao/incident.go`:
+
+```text
+internal/modules/incident/
+├─ api.go                  # HTTP Handler + SetupIncidentRouter
+├─ service/
+│  ├─ service.go           # 领域校验、状态机、埋点入口、统计
+│  └─ service_test.go
+└─ repository/
+   └─ repo.go              # 分页查询、条件更新、去重查询
+```
+
+模块边界:`incident` 只记录异常事实与处置过程,**不修改**会话、数字票、支付流水和道闸状态;需要联动修正业务数据时(如强制免费后补结算),由调用方在业务事务内先完成修正、再调用埋点记录,异常表不参与业务事务。
+
+## 3. 数据模型
+
+### 3.1 异常事件表 `incident_record`
+
+```go
+type IncidentRecord struct {
+    global.GVA_MODEL
+    IncidentNo    string     // 唯一编号,如 INC20260812-0001
+    Category      string     // 异常分类(见 3.2 枚举)
+    Source        string     // 来源:passage/manual/device/payment/printer/system
+    Level         string     // 等级:info/warning/critical
+    Status        string     // 状态:pending/processing/resolved/closed
+    VehicleRecordID uint     // 关联停车会话(可选)
+    TicketNo      string     // 关联数字票(可选)
+    PlateNumber   string     // 车牌(可选)
+    RFIDTag       string     // RFID(可选)
+    ParkingLotID  uint       // 停车场(可选)
+    ChannelID     uint       // 通道(可选)
+    ChannelCode   string     // 通道编码快照
+    DeviceCode    string     // 设备编码(可选)
+    OperatorID    uint       // 上报人/创建人
+    Description   string     // 异常描述(人读)
+    Detail        string     // JSON 上下文(请求参数、错误信息、设备状态等)
+    HandlerID     uint       // 处理人
+    HandledAt     *time.Time // 处理时间
+    HandleType    string     // 处置方式(见 3.3)
+    HandleRemark  string     // 处置备注(必填)
+    ForceFreeAmount float64  // 强制免费金额(资金类处置时记录)
+    EventLog      string     // JSON 事件时间线(同 digital_ticket.event_log)
+}
+```
+
+字段说明:
+
+- `incident_no` 建立唯一索引,生成规则 `INC + yyyyMMdd + 当日序号`(SQLite 下单事务内 `count + 1` 生成,冲突时重试,避免自增回绕)。
+- `category + device_code + status` 建立联合索引,供去重查询和页面筛选。
+- `Detail` 存 JSON 字符串(如失败请求、错误原文),大小受控(截断至 1000 字符),不存敏感字段(密码、支付卡号)。
+- 资金相关:`force_free_amount` 仅 `force_free` 处置时写入;其余处置方式必须为 0。
+
+### 3.2 异常分类枚举 `Category`
+
+| 值 | 中文 | 默认等级 | v1 埋点源 |
+| --- | --- | --- | --- |
+| `gate_failed` | 开闸失败 | critical | 统一通行(7.1) |
+| `duplicate_entry` | 重复入场 | warning | 统一通行(7.1) |
+| `blacklist` | 黑名单拦截 | warning | 统一通行(7.1) |
+| `lot_full` | 满位拒绝 | info | 统一通行(7.1) |
+| `no_entry_exit` | 无入场记录出场 | critical | 统一通行(7.1) |
+| `manual_raise` | 人工抬杆/关闸 | warning | 人工抬杆 API(7.2) |
+| `device_offline` | 设备离线 | warning | UHF 状态转换(7.3) |
+| `print_failed` | 打印失败 | warning | (预留,打印任务持久化落地后接入) |
+| `payment_failed` | 支付失败 | warning | (预留,支付订单层落地后接入) |
+| `payment_uncertain` | 支付状态不确定 | critical | (预留,外部支付回调接入后接入) |
+
+### 3.3 处置方式枚举 `HandleType`
+
+| 值 | 中文 | 说明 |
+| --- | --- | --- |
+| `manual_gate` | 人工抬杆放行 | 对应 manual_raise 类异常,需备注原因 |
+| `force_free` | 强制免费 | 资金类,必须填金额,仅最高管理员 |
+| `reset` | 状态重置/修正 | 修正会话、票或余位数据后关闭 |
+| `device_repaired` | 设备修复 | 设备恢复后由系统自动或人工确认 |
+| `reprint` | 重新打印 | 预留,配合打印任务持久化 |
+| `ignore` | 误报忽略 | 需备注误报原因 |
+| `other` | 其他 | 需备注 |
+
+### 3.4 指令流水表 `device_command_log`(建议包含,可裁剪)
+
+配合人工抬杆审计与开闸失败追踪,记录每次道闸开/关命令:
+
+```go
+type DeviceCommandLog struct {
+    global.GVA_MODEL
+    DeviceCode   string     // 设备编码
+    DeviceName   string     // 设备名称快照
+    Action       string     // open / close
+    Source       string     // passage(通行链路)/ manual(人工操作)/ test
+    OperatorID   uint       // 操作员(manual 时必填)
+    SessionID    uint       // 关联停车会话(可选)
+    IncidentID   uint       // 关联异常事件(可选,开闸失败时关联)
+    Result       string     // success / failed / timeout
+    ErrorMessage string     // 失败原因
+    DurationMs   int64      // 耗时
+}
+```
+
+- 统一埋点在 `OpenGateByDeviceCode` / `CloseGateByDeviceCode`(`gate.go:117-144`)封装处,所有开闸/关闸(通行、人工、测试)都经过此处,无需改动各调用点。
+- 开闸失败时:指令流水写 `failed`,同时异常埋点生成 `gate_failed`,二者通过 `incident_id` 关联。
+- 裁剪此项不影响异常事件主链路,仅损失"命令级"审计粒度。
+
+## 4. 状态机
+
+### 4.1 状态与流转
+
+```text
+pending ──> processing ──> resolved ──> closed
+   ↑                          │
+   └────────  reopen ─────────┘
+```
+
+| 流转 | 约束 |
+| --- | --- |
+| pending → processing | 记录开始处理,写入 `handler_id` |
+| pending/processing → resolved | 必须携带 `handle_type` + `handle_remark`;`force_free` 必须带 `force_free_amount ≥ 0`;写入 `handled_at` |
+| resolved → closed | 关闭工单,可带补充备注 |
+| closed → pending(reopen) | 重新打开,可带备注 |
+
+禁止其他流转(如 pending → closed 直接关闭、resolved 回退 processing)。所有流转由 `TransitionIncident` 统一入口执行,更新采用条件语句 `UPDATE ... SET status=? WHERE id=? AND status=?`,受影响行数为 0 时返回"状态已变更"错误(CAS,仿数字票 `UpdateStateIfCurrentTx`)。
+
+### 4.2 处置动作与状态的映射
+
+- 自动埋点创建的异常默认 `pending`。
+- `manual_raise` 人工抬杆:创建时即为 `resolved` + `handle_type=manual_gate`(原因即备注),因为动作已完成、仅需留痕;如需复核可人工 reopen。
+- `device_offline`:设备重连时,系统将同设备未关闭(pending/processing)的离线记录批量置为 `resolved` + `device_repaired`,仍保留人工确认入口。
+- 资金类处置(`force_free`)在服务层校验角色权限(见 6.2)。
+
+## 5. 服务层设计(`internal/modules/incident/service/`)
+
+### 5.1 方法清单
+
+| 方法 | 用途 |
+| --- | --- |
+| `RecordIncident(ctx RecordIncidentRequest)` | 自动埋点入口,best-effort:内部错误只记日志并返回 nil,不抛给业务调用方 |
+| `CreateIncident(req)` | 人工上报(API 层调用),返回完整记录 |
+| `ListIncidents(q)` | 分页 + 多条件筛选(状态/分类/等级/来源/车牌/票号/停车场/设备/日期范围/上报人) |
+| `GetIncident(id)` | 详情(含 event_log 解析后的时间线) |
+| `TransitionIncident(id, req)` | 状态流转 + 处置信息,CAS 更新 |
+| `GetIncidentStats()` | 统计:按状态计数(待处理/处理中/已解决/已关闭)、今日新增、资金类待处理数量 |
+| `ResolveDeviceOffline(deviceCode)` | 设备重连时自动关闭离线异常(7.3 调用) |
+
+### 5.2 `RecordIncident` 的入参
+
+```go
+type RecordIncidentRequest struct {
+    Category   string
+    Source     string
+    Level      string        // 缺省按分类默认等级
+    VehicleRecordID uint
+    TicketNo   string
+    PlateNumber string
+    RFIDTag    string
+    ParkingLotID uint
+    ChannelID  uint
+    ChannelCode string
+    DeviceCode string
+    OperatorID uint
+    Description string
+    Detail     interface{}   // 序列化为 JSON 存入 Detail
+}
+```
+
+### 5.3 查询
+
+`repository` 采用既有三段式:`db.Count(&total)` → 默认值兜底(page=1、page_size=10)→ `Order("created_at DESC").Offset((page-1)*pageSize).Limit(pageSize).Scan`,返回 `(list, total, err)`;API 层包 `response.PageResult`。列表关联 `sys_users` 取上报人/处理人昵称(仿 shift repository 的 `operator_name`)。
+
+### 5.4 去重规则
+
+| 分类 | 去重规则 |
+| --- | --- |
+| `device_offline` | 同设备存在 pending/processing 的离线记录时不重复生成 |
+| `gate_failed`/`duplicate_entry` 等通行类 | 同 `device_code + plate_number/rfid + category` 在 5 分钟内不重复生成(防抖复用 `passage.go` 的 3 秒防抖窗口之上再加事件级窗口) |
+| 其他 | 不做自动去重,人工判断 |
+
+### 5.5 事务边界
+
+- 异常表写入**不参与**业务事务:业务事务提交成功后调用 `RecordIncident`(独立写)。
+- 特例:人工抬杆/关闸 API 中,命令执行与异常落库可以共用一次简单事务。命令失败时仍生成记录(见 7.2),此时分类为 `gate_failed`、状态 `pending`,备注写明人工操作失败原因,供复核。
+- `TransitionIncident` 单条更新,无需跨表事务(event_log 追加与状态更新在同一 `UPDATE` 内拼 JSON 完成)。
+
+## 6. HTTP API 与权限
+
+### 6.1 路由(前缀 `/incident`)
+
+注册于 `internal/initialize/router.go` 的 PrivateGroup(自动挂 JWT + Casbin + OperationRecord):
+
+| 方法 | 路径 | 说明 | 角色 |
+| --- | --- | --- | --- |
+| GET | `/incident/list` | 分页筛选列表 | 618/888/9527 |
+| GET | `/incident/:id` | 详情 | 618/888/9527 |
+| POST | `/incident` | 人工上报 | 618/888/9527 |
+| POST | `/incident/:id/transition` | 状态流转+处置 | 888/9527;`force_free` 仅 888 |
+| GET | `/incident/stats` | 状态/今日统计 | 618/888/9527 |
+
+角色约定(沿用现有种子):618=操作员,888=管理员,9527=超级管理员。`force_free` 资金类处置在服务层通过 `utils.GetUserID` + 角色判断二次校验(Casbin 只到路径粒度,资金类需在业务层校验权限,防止低权限角色直接调用)。
+
+### 6.2 权限种子
+
+仿 `operation_permission_seed.go` 新增 `EnsureIncidentPermissions()`:
+
+- `sys_apis` 按 `path+method` 幂等插入上表 5 个 API;
+- `casbin_rule` 给 618/888/9527 插 list/detail/create/stats 规则;给 888/9527 插 transition 规则;
+- 挂到 `SeedSystemData` 新库/旧库两个分支末尾(与 `EnsureOperationPermissions` 同位置,seed.go:23-29 与 533-538)。
+
+### 6.3 菜单与页面
+
+- 复用菜单 27"异常报表"(`view/report/sheet.vue`,已授权 618/888/9527),**不新增菜单**,仅重写页面。
+- 若需将菜单标题改为"异常处置",需在种子中增加"更新已存在菜单标题"逻辑(现有种子只插不更);v1 建议保留原标题以省去迁移,文档标题在页面内体现。
+
+## 7. 自动埋点设计(v1)
+
+### 7.1 统一通行失败(`internal/service/parking/passage.go`)
+
+| 埋点位置 | 触发条件 | 分类 | 等级 |
+| --- | --- | --- | --- |
+| `handleEntry`/`handleExit` 开闸分支(passage.go:172、231) | `OpenGateByDeviceCode` 返回错误 | `gate_failed` | critical |
+| `handleEntry` 前(passage.go:130-132) | `passageStatus[identifier]` 已在场内 | `duplicate_entry` | warning |
+| `HandlePassage` 黑名单检查(passage.go:92-95) | `isBlack == true` | `blacklist` | warning |
+| `handleEntry` 内 `VehicleEntry` 返回 `ErrParkingLotFull` | 满位拒绝 | `lot_full` | info |
+| `handleExit` 内 `ExitConfirm` 返回"无入场记录"类错误 | 无会话/丢票 | `no_entry_exit` | critical |
+
+实现要点:
+
+- 错误识别:`errors.Is(err, parkingSessionService.ErrSessionNotFound)` / `ErrSessionAlreadyOpen` / `ErrParkingLotFull` 判定分类(哨兵错误已存在,见 parking-session/service.go:20-29);`ExitConfirm` 错误中"无入场记录"需按 `errors.Is` 或错误消息兜底判定。
+- 开闸失败场景:在 `passage.go` 返回 `result+err` **之前**调用 `RecordIncident`(此时会话 ID/票号已在 `result` 中),并同时写指令流水(7.5)。
+- 埋点不改变现有返回值:`RecordIncident` 内部吞错,调用前后业务代码零改动语义。
+
+### 7.2 人工抬杆/关闸(`internal/api/v1/parking/gate.go`)
+
+- `POST /parking/gate/open|close` 请求体增加**可选** `reason` 字段(向后兼容,缺省为空)。
+- 命令执行后调用 `RecordIncident`:分类 `manual_raise`、来源 `manual`、状态直接 `resolved`、`handle_type=manual_gate`、`handle_remark=reason`,`operator_id` 从 JWT 取。
+- **命令失败分支**:不生成 `manual_raise`(动作未完成),改生成分类 `gate_failed`、来源 `manual`、状态 `pending` 的待处置记录,`handle_remark` 写明人工操作失败原因与设备状态,等待管理员复核(可能需现场处理或换设备重试)。
+- 关联上下文:若请求体带 `plate_number`/`session_id`(可选字段)则一并写入;不带也可(纯设备操作留痕)。
+- 同时写指令流水(action=open/close、source=manual、result=success/failed)。
+
+### 7.3 设备离线(`internal/service/uhf/reader.go`、`conn_manager.go`)
+
+- 埋点位置:读协程 `ReadData` 出错置 `status=offline` 的分支(reader.go:251-261)与 `conn_manager.go:72` 连接断开检测处。
+- 触发条件:**状态从在线转为离线**(避免重连抖动重复生成);配合 5.4 去重规则(同设备未关闭不重复生成)。
+- 分类 `device_offline`、来源 `device`、等级 `warning`,`device_code`/`parking_lot_id` 从设备记录取。
+- 重连成功(置 `status=online` 分支):调用 `ResolveDeviceOffline(deviceCode)`,将同设备 pending/processing 的离线记录批量置 `resolved` + `device_repaired`,备注"设备恢复在线"。
+- 离线事件同时**不动**内存 `DeviceManager` 与 `GetGateRuntimeStatus`,仅补 DB 事实与异常记录;三套状态一致性问题另立任务,不在本设计内解决(记录到 `doc/功能缺失.md` 3.6 追踪)。
+
+### 7.4 扩展点(非 v1)
+
+- **打印失败**:待打印任务持久化(P0)落地后,在打印服务失败分支调用 `RecordIncident`(分类 `print_failed`),并支持 `reprint` 处置方式重打。
+- **支付失败/不确定**:待支付订单层(P0)落地后,在订单超时、回调失败、对账差异处埋点(分类 `payment_failed`/`payment_uncertain`);POS/缴费机终端超时时直接生成 `payment_uncertain` 工单引导人工核对(呼应 `doc/收费流程.md` 第 5.5 条)。
+
+### 7.5 指令流水埋点(建议包含)
+
+统一在 `OpenGateByDeviceCode`/`CloseGateByDeviceCode` 内包一层:
+
+```go
+start := time.Now()
+err := controller.OpenGate(deviceCode, validTime)
+logCommand(DeviceCommandLog{DeviceCode, Action: "open", Source: 来源, Result: success/failed, ErrorMessage, DurationMs})
+```
+
+- 通行链路调用(passage.go)时 `source=passage`,人工 API 时 `source=manual`(由调用方经参数传入,封装函数加一个 `source`/`operator` 可选参数,缺省 `passage`)。
+- 开闸失败时 `incident_id` 关联 `gate_failed` 记录(先建异常、后写流水,拿到 ID 回填)。
+- 该表只读不参与业务判断,写失败同样 best-effort 记日志。
+
+## 8. 前端页面设计(`view/report/sheet.vue` 重写)
+
+结构完全复用 `paymentRecord.vue` 蓝本:
+
+1. **统计行**(`el-statistic` × 4):待处理、处理中、今日新增、资金类待处理(金额合计,供管理层关注)。
+2. **筛选表单**(`el-form inline`):状态 `el-select`、分类 `el-select`、等级 `el-select`、车牌 `el-input`、票号 `el-input`、停车场 `el-select`(数据源 `obtainPullOverList`,同 revenue.vue)、日期范围 `el-date-picker`、搜索/重置按钮。
+3. **表格**(`el-table` + `el-table-column`):编号、分类(tag 颜色区分)、等级(tag:info 蓝 / warning 橙 / critical 红)、状态(tag:待处理 warning / 处理中 primary / 已解决 success / 已关闭 info)、车牌、停车场、通道/设备、描述(`show-overflow-tooltip`)、上报时间、处理人、操作(详情/处理)。
+4. **分页**:`el-pagination`(page/page_size/total,同 paymentRecord.vue)。
+5. **详情抽屉**(`el-drawer`):完整字段 + `event_log` 时间线(`el-timeline`)+ 处置表单(目标状态、处置方式 `el-select`、强制免费金额 `el-input-number`(仅 force_free 显示)、备注 `el-input textarea`)。处置按钮按角色控制:618 只读,888/9527 可处置,`force_free` 仅 888 可提交(前端隐藏 + 后端强制校验双保险)。
+6. **常量映射**:分类/等级/状态/处置方式 → 中文名 + tag 类型,集中定义在页面顶部常量对象(同 paymentRecord.vue 的 `methodName` 兜底模式)。
+
+新增 `frontend/src/api/incident.js`(仿 `shift.js` 极简模式):
+
+```js
+import service from '@/utils/request'
+export const getIncidentList = (params) => service({ url: '/incident/list', method: 'get', params })
+export const getIncidentDetail = (id) => service({ url: `/incident/${id}`, method: 'get' })
+export const createIncident = (data) => service({ url: '/incident', method: 'post', data })
+export const transitionIncident = (id, data) => service({ url: `/incident/${id}/transition`, method: 'post', data })
+export const getIncidentStats = () => service({ url: '/incident/stats', method: 'get' })
+```
+
+## 9. 数据库迁移与兼容
+
+- `RegisterTables`(`internal/initialize/gorm.go`)的 AutoMigrate 列表追加 `dao.IncidentRecord{}`(及 `dao.DeviceCommandLog{}`,若采纳指令流水)。AutoMigrate 只建新表,不改旧表,无历史数据兼容问题。
+- 埋点为纯增量调用:`passage.go`、`gate.go`、`reader.go` 的现有返回值与事务边界不变;人工抬杆 API 的 `reason` 为可选参数,旧客户端不受影响。
+- 异常记录软删除(GVA_MODEL 自带 DeletedAt),列表默认排除已删除;已删除记录不在统计口径内。
+
+## 10. 测试设计(实施阶段执行)
+
+沿用现有测试模式(`glebarez/sqlite` 内存库 + `global.GVA_DB`,见 `parking-session/service/service_test.go`):
+
+| 测试组 | 场景 |
+| --- | --- |
+| 编号生成 | 当日序号递增、跨日重置、并发生成不重复 |
+| 状态机 | 合法流转全路径、非法流转拒绝(pending→closed 等)、resolved 缺 handle_type/remark 拒绝、force_free 缺金额/负金额拒绝 |
+| CAS 并发 | 两条并发流转同一记录,仅一条成功 |
+| best-effort | 模拟 DB 故障(注入错误连接)时 `RecordIncident` 返回 nil 不阻塞业务 |
+| 去重 | 同设备离线不重复生成、同通行标识时间窗内不重复 |
+| 统计 | 状态计数/今日新增/资金类金额合计 |
+| 埋点集成 | 注入失败 `GateController` 使 `OpenGateByDeviceCode` 失败 → 生成 `gate_failed` + 指令流水;人工抬杆带/不带 reason 均生成记录;离线→重连自动关闭 |
+| 路由安全 | 参照 `router_security_test.go`:匿名/低权限角色访问 `transition` 被拒 |
+
+## 11. 实施顺序与验收标准
+
+### 11.1 实施顺序
+
+1. 模型:`dao.IncidentRecord`(+ `dao.DeviceCommandLog`)+ `RegisterTables` 注册。
+2. 服务层:repository 分页/去重/CAS + service 六方法 + 状态机 + 编号生成。
+3. 埋点:passage.go 五类埋点 + 人工抬杆 reason 参数 + reader.go 离线/重连埋点 + 指令流水封装。
+4. 权限种子:`EnsureIncidentPermissions()` + `SeedSystemData` 挂载。
+5. 前端:`api/incident.js` + `view/report/sheet.vue` 重写。
+6. 测试:第 10 节全部用例 + `npm run build` 前端构建验证。
+
+### 11.2 验收标准(对应 `doc/功能缺失.md` 2.2)
+
+- 异常自动落库,包含来源、停车会话、车辆、通道、设备和操作员(开闸失败、重复入场、黑名单、满位、无入场出场、人工抬杆、设备离线七类 v1 场景实测通过)。
+- 支持待处理、处理中、已解决、已关闭四状态流转,处理备注必填,流转留痕(event_log 可查)。
+- 人工抬杆与资金类(强制免费)处置有权限控制与完整审计(角色校验 + 操作日志 + 金额记录)。
+- 页面:筛选、分页、统计、详情时间线、处置表单可用;低权限角色看不到处置入口。
+- 自动化测试覆盖状态机、并发、去重、埋点集成与权限拒绝,全部通过。
+
+## 12. 与相关文档的衔接
+
+| 文档 | 衔接点 |
+| --- | --- |
+| `doc/功能缺失.md` | 本文对应 2.2 节 P0 项;实施完成后从缺失清单移除,并同步 2.3(打印补偿)与 3.6(设备模型)的追踪 |
+| `doc/项目进度.md` | 实施完成后在"待办与优先级"中更新异常处置闭环状态 |
+| `doc/代码规划.md` | 模块结构与边界遵循第 2、3 节约定;`incident` 加入模块清单 |
+| `doc/收费流程.md` | 支付失败/不确定埋点在支付订单层落地时按第 5.5 条"进入人工核对"原则实现 |

+ 72 - 117
doc/收费流程.md

@@ -1,118 +1,73 @@
-根据您补充的业务场景,这已经涉及到了**硬件交互逻辑**和**岗亭工作流**的设计。这比单纯的计费算法更贴近实际落地。
-我将针对您描述的两种主要模式(月租/VIP、临时车),梳理成详细的**进出场业务流程图**,供开发对接使用。
----
-### 一、 月租车 / VIP车 流程梳理
-**核心逻辑:** 身份识别 -> 权限校验(有效期) -> 自动放行。
-#### 1. 进场流程
-*   **触发方式 A:车牌识别(摄像头)**
-    1.  车辆驶入入口,触发地感/红外。
-    2.  摄像头抓拍,系统识别车牌号。
-    3.  系统查询数据库:
-        *   查询 确认是否为月租车/VIP。
-        *   检查 `Expire_Date`(过期时间)。
-    4.  **判断逻辑:**
-        *   **有效:** 语音播报“欢迎光临,月租有效”,自动开闸。
-        *   **过期:** 语音播报“月租已过期”,转为临时车流程(视策略:拒绝入场 或 按临时车入场)。
-    5.  记录入场日志(车牌、时间、快照)。
-*   **触发方式 B:刷卡/RFID(非车牌识别)**
-    1.  车主在入口票机/读卡器处刷卡/RFID卡。
-    2.  系统读取卡片ID,通过ID查询绑定的车辆信息。
-    3.  校验有效期(逻辑同上)。
-    4.  **有效:** 开闸放行。
-    5.  *注意:此类进场通常需要人工辅助确认车辆身份,或配合视频录像留存。*
-#### 2. 出场流程
-*   **流程:**
-    1.  车辆驶入出口,触发识别(车牌或刷卡)。
-    2.  系统校验状态:
-        *   是否有入场记录?
-        *   月租是否在有效期内?
-    3.  **判断逻辑:**
-        *   **有效:** 语音播报“一路顺风”,自动抬杆放行。
-        *   **过期:**
-            *   策略1:拒绝出场,需去岗亭缴费续费后放行。
-            *   策略2:自动转为临时车计费,需缴纳临时停车费。
-    4.  记录出场日志,销除在场车辆记录。
----
-### 二、 临时车 流程梳理
-**核心逻辑:** 取票进场 -> 扫码计费 -> 支付确认 -> 放行。
-#### 1. 进场流程(统一取票)
-*   **动作:**
-    1.  车辆驶入入口,驾驶员按下票机按钮(或自动感应出票)。
-    2.  **票机动作:**
-        *   打印二维码小票(包含:入场时间、流水号、二维码)。
-        *   *系统后台:* 生成一条“无车牌”或“待关联”的入场记录。
-    3.  道闸开启,车辆进场。
-    4.  *痛点提示:* 此时不识别车牌,出场时完全依赖小票。若小票丢失,需人工核对入场记录(通过入场抓拍图片)。
-#### 2. 出场流程(非人工自助缴费)
-*   **场景:** 票机自带扫码枪 + POS机连接
-*   **流程:**
-    1.  驾驶员在出口票机处,将小票二维码对准扫码窗。
-    2.  **系统计算:**
-        *   解析小票获取入场时间 ($T_{in}$)。
-        *   获取当前时间 ($T_{out}$)。
-        *   调用计费引擎,算出金额 `Amount`。
-    3.  **交互:**
-        *   屏幕/语音提示:“请缴纳费用 XX 元”。
-        *   票机连接的POS机进入待支付状态。
-    4.  **支付:**
-        *   驾驶员在POS机上刷卡(银行卡/会员卡)。
-        *   POS机返回“交易成功”信号给票机/系统。
-    5.  **结果:**
-        *   系统接收到支付成功信号 -> 下发开闸指令 -> 道闸抬杆。
-        *   票机打印/吐出收据凭证(可选)。
-#### 3. 出场流程(人工收费)
-*   **场景:** 现金支付,需岗亭保安协助
-*   **流程:**
-    1.  驾驶员到达出口,将小票交给保安,或自己在票机扫码。
-    2.  **系统计算:**
-        *   屏幕显示计费金额(保安岗亭端可见)。
-    3.  **人工交互:**
-        *   保安收取现金(纸币/硬币)。
-        *   保安在管理软件界面点击“现金收款”或“确认放行”。
-    4.  **结果:**
-        *   系统记录“现金支付”流水。
-        *   软件下发开闸指令 -> 道闸抬杆。
----
-### 三、 系统交互逻辑图解 (开发梳理)
-为了方便您开发,我将上述逻辑转化为系统的**模块交互序列**:
-#### 场景:临时车非人工出场(票机+POS联动)
-```mermaid
-sequenceDiagram
-    participant User as 驾驶员
-    participant Machine as 出口票机(硬件)
-    participant Server as 管理服务器
-    participant POS as 刷卡POS机
-    User->>Machine: 扫描小票二维码
-    Machine->>Server: 上报票号/入场ID
-    Server->>Server: 查询入场时间,计算费用
-    Server-->>Machine: 返回金额 (例如: 15元)
-    Machine->>User: 屏幕显示/语音播报金额
-    Machine->>POS: 发送扣款指令 (金额15元)
-    User->>POS: 刷卡支付
-    POS->>POS: 银行卡扣款处理
-    POS-->>Machine: 返回扣款成功信号
-    Machine->>Server: 上报支付成功
-    Server->>Server: 更新订单状态为“已支付”
-    Server-->>Machine: 下发开闸指令
-    Machine->>Machine: 触发道闸抬杆
+# 停车收费与结算流程
+
+> 更新日期:2026-08-07
+> 本文档以当前实现为准,真实 POS、中央缴费机和线上支付网关尚待接入。
+
+## 1. 结算对象与职责
+
+| 对象 | 作用 | 是否可替代 |
+| --- | --- | --- |
+| 停车会话 `vehicle_record` | 保存本次停车的费用、支付摘要和进出场事实 | 不可替代 |
+| 数字票 `digital_ticket` | 保存待支付、已支付、已离场状态和票号 | 不可替代 |
+| 支付流水 `payment_record` | 保存实际收款、实收、找零、操作员 | 财务事实来源 |
+
+不得只修改数字票状态或只插入支付流水。所有结算必须通过支付服务和停车会话事务统一更新。
+
+## 2. 支付入口与支付方式
+
+支付入口和支付方式为独立配置。
+
+| 概念 | 解决的问题 | 示例 |
+| --- | --- | --- |
+| 支付入口 | 在哪里发起结算 | 收费岗亭、停车小票、中央缴费机 |
+| 支付方式 | 用什么方式收款 | 现金、POS、小票扫码、免费放行 |
+
+入口与方式通过配置关系限制可用组合。例如,中央缴费机可配置扫码和 POS;收费岗亭可配置现金和 POS。禁用配置只影响后续新结算,不修改历史记录。
+
+## 3. 当前结算主流程
+
+```text
+定位停车会话和数字票
+  -> 计算应收金额
+  -> 校验支付入口和支付方式可用
+  -> 校验金额
+  -> 插入 payment_record
+  -> 更新 vehicle_record.payment_status / fee / payment_entry / payment_method
+  -> 更新 digital_ticket 为 paid
+  -> 确认出场时关闭会话并更新数字票为 exited
 ```
----
-### 四、 关键技术点与开发建议
-在实现这套逻辑时,请重点关注以下几个技术细节:
-**1. 票机与服务器的通信协议**
-*   票机通常通过 TCP/IP 或串口与服务端通信。
-*   **建议:** 采用心跳包机制。服务器需要知道票机是否“在线”。如果票机断网,应立即报警,避免车辆堵在出口。
-**2. 小票二维码的数据安全**
-*   小票上的二维码是进出的唯一凭证。
-*   **建议:** 二维码内容不要直接写“时间:20231010”,容易被伪造。建议生成一个**加密的UUID**或**动态Token**,服务器端通过Token反查入场记录,防止逃票。
-**3. POS机支付的异步回调**
-*   刷卡支付可能有延迟(网络波动)。
-*   **建议:** 票机端应设置“支付等待状态”(比如等待30秒)。如果POS机返回成功,但票机没收到信号,需要有“重试机制”或“人工干预按钮”,避免道闸不开导致堵车。
-**4. 无牌车/无票车的兜底逻辑**
-*   **进场:** 取票进场,但进场时摄像头没拍到车牌。
-*   **出场:** 用户票丢了。
-    *   **解决方案:** 保安端软件需提供“查询入场记录”功能,通过时间范围查找入场记录,并匹配摄像头抓拍的图片,确认后手动补交费放行。
-**5. 月租车“过期”的临界点处理**
-*   如果月租车在进场时有效,出场时过期了怎么办?
-*   **逻辑建议:** 通常允许出场(因为在场内过期不应惩罚),或者在进场时检查如果“距离过期少于1小时”,则语音提醒“月租即将到期,请及时续费”。
-这套逻辑梳理涵盖了您提到的硬件交互细节,您可以直接基于此流程图编写开发文档。
+
+### 3.1 规则
+
+- 应收金额、实收金额不能为负数。
+- 现金等手工金额方式,实收不得低于应收;超出部分记录为找零。
+- 零费用允许零金额结算。
+- 已支付会话不允许再次支付;已出场会话不允许再次结算或离场。
+- 预支付数字票在出口只做离场确认,不再创建第二笔支付流水。
+- 任意步骤失败时,事务回滚,不能遗留半完成支付数据。
+
+## 4. 已实现支付场景
+
+| 场景 | 状态 | 说明 |
+| --- | --- | --- |
+| 收费岗亭人工现金 | 已实现 | 支持实收、找零、操作员记录 |
+| 免费放行 | 已实现 | 用于零费用、白名单等合法免费场景 |
+| 数字票结算 | 已实现 | 从数字票页面选择入口和方式并完成支付 |
+| 出口消费预支付票 | 已实现 | 已支付数字票在出口确认离场,不重复收费 |
+| POS 支付方式配置 | 已实现 | 可配置、可选择,真实 POS 交易未接入 |
+| 小票扫码支付方式配置 | 已实现 | 可配置、可选择,真实支付网关未接入 |
+| 中央缴费机入口配置 | 已实现 | 可配置为支付入口,终端协议和回调未接入 |
+
+## 5. 外部支付接入要求
+
+POS、中央缴费机、线上支付接入时必须遵守:
+
+1. 外部终端先根据 `ticket_no` 查询数字票及应收金额。
+2. 终端支付成功后携带 `ticket_no`、外部订单号、支付入口和支付方式回调。
+3. 服务端以票号和外部订单号进行幂等校验,不允许因回调重试产生重复支付。
+4. 支付服务成功后,出口仍由统一出场流程关闭停车会话并开闸。
+5. 终端超时或状态不确定时进入人工核对,不得直接标记支付成功。
+
+## 6. 对账
+
+交接班和收入报表以 `payment_record` 为主进行汇总;停车会话和数字票的支付字段用于业务展示和快速筛选。出现差异时,应以支付流水、操作日志和外部支付订单号核对。

+ 90 - 298
doc/进出场流程详解.md

@@ -1,328 +1,120 @@
-# 智慧停车进出场流程详解
+# 智慧停车进出场流程详解
 
-> 日期:2026-07-30
+> 更新日期:2026-08-07
+> 本文档描述当前已实现的统一进出场主路径。
 
-本文档详细描述车辆进出场完整流程,涵盖车辆类型区分、入场方式、收费结算方式。
+## 1. 核心原则
 
----
+1. 所有触发方式先定位设备和通道,再进入统一通行服务。
+2. 一辆车一次在场过程只能有一个未关闭的停车会话。
+3. 入场原子创建停车会话和数字票;支付、出场均定位同一会话。
+4. 支付事实写入支付流水,通道和设备事实写入停车会话快照。
+5. 开闸是业务成功后的设备动作;闸机失败必须向操作员返回“业务已完成、设备动作失败”的明确状态。
 
-## 1. 车辆类型体系
+## 2. 触发入口
 
-系统通过 `vehicle_type` 表 + `shortlist` 名单 + `monthly_card` 包月卡三层结构区分车辆身份。
+| 来源 | 车辆标识 | 当前状态 | 进入方式 |
+| --- | --- | --- | --- |
+| UHF RFID | RFID,可带车牌 | 已实现 | 设备事件调用 `PassageService.HandlePassage` |
+| 工作台人工操作 | 车牌或 RFID | 已实现 | `entryExit.vue` 调用 `/vehicle/passage` |
+| 票机按钮 | 票机输入/临时车 | 已实现,待现场验收 | 创建入场会话、数字票并打印小票 |
+| 摄像头识别 | 车牌 | 未实现 | 规划接入同一 `PassageService` |
 
-### 1.1 类型分类
+## 3. 进场流程
 
-```
-车辆
-  │
-  ├─ 临时车 (vehicle_type.is_system = true)
-  │    └─ 首次进场自动创建,无车主
-  │
-  ├─ 固定车 (vehicle_type.is_system = false,已注册)
-  │    ├─ VIP 车 (owner.is_vip = true 且未过期)
-  │    │    └─ 享受 VIP 折扣 / 免费
-  │    │
-  │    └─ 普通固定车
-  │         └─ 按对应 vehicle_type 计费
-  │
-  ├─ 月租车 (monthly_card 有效记录)
-  │    └─ 自动加入白名单,有效期内免费通行
-  │
-  ├─ 白名单车 (shortlist.list_type = "白名单")
-  │    └─ 放行,不拦截
-  │
-  └─ 黑名单车 (shortlist.list_type = "黑名单")
-       └─ 拦截,禁止进出
-```
-
-### 1.2 优先级判断
-
-进出场时判断顺序:
-
-```
-1. 黑名单 → 拦截,禁止通行
-2. 白名单 → 放行(月租车在此层)
-3. 余额/支付 → 按车辆类型计费规则
+```text
+识别车辆
+  -> 通过 device_code 查询 UHF 设备和绑定通道
+  -> 校验设备启用、通道方向、停车场归属、黑名单、临停规则
+  -> 按车牌/RFID 定位是否存在未关闭停车会话
+  -> 不存在:创建会话 + 数字票
+  -> 保存入口通道和设备快照
+  -> 更新车辆最近入场时间
+  -> 请求开闸
 ```
 
----
+### 3.1 入场数据写入
 
-## 2. 入场流程
+| 对象 | 入场写入内容 |
+| --- | --- |
+| 停车会话 `vehicle_record` | 车牌、RFID、入场时间、停车场、停车位、入口图片、入口通道/设备 ID、编码、名称 |
+| 数字票 `digital_ticket` | 票号、入场来源、会话 ID、`pending_payment` 状态、事件日志 |
+| 车辆 `vehicle` | 最近入场时间 |
 
-### 2.1 入场方式
+同一车牌或 RFID 已存在未关闭会话时,入场会被拒绝,不会重复建票。
 
-| 方式 | 触发 | 场景 | 当前状态 |
-|------|------|------|:--:|
-| RFID 自动 | UHF 读卡器读取标签 | 有标签车辆 | ✅ |
-| 车牌识别 | 摄像头 OCR | 无标签车辆 | ❌ P0-5 |
-| 手动录入 | 操作员在 entryExit.vue 输入 | 无标签/系统故障兜底 | ✅ |
-| 二维码扫码 | 扫描预约/月卡二维码 | 线上预约 | 🔜 规划中 |
+## 4. 出场与结算流程
 
-### 2.2 入场判断流程
-
-```
-车辆到达入口
-  │
-  ├─ 触发识别 (RFID / 车牌 / 手动)
-  │
-  ▼
-┌─────────────────────────────────────────────────┐
-│  ① 黑名单检查                                    │
-│     CheckVehicleShortlist(plate, rfid)           │
-│     └─ 黑名单 → 🚫 拦截                          │
-│                                                  │
-│  ② 车辆查询/创建                                 │
-│     存在 → 使用现有记录                           │
-│     不存在 → 自动创建 (临时车类型)                 │
-│                                                  │
-│  ③ 重复入场检查                                  │
-│     有未出场记录 → "车辆已入场"                   │
-│                                                  │
-│  ④ 入场方式判断                                  │
-│     ┌─ 月租车 (白名单+未过期) → 🟢 直接放行       │
-│     ├─ 固定车/VIP → 🟢 直接放行                  │
-│     └─ 临时车 → 通道判断                         │
-│          ├─ 通道允许临停 → 🟡 取票/二维码放行     │
-│          └─ 通道不允许 → 🚫 拦截                  │
-│                                                  │
-│  ⑤ 创建入场记录 + 数字票                         │
-│     INSERT vehicle_record                        │
-│     INSERT digital_ticket (state=pending_payment) │
-│     ┌─ RFID → GateOpener 抬杆                    │
-│     └─ 手动 → 仅记录,人工抬杆                   │
-└─────────────────────────────────────────────────┘
+```text
+识别车辆
+  -> 定位未关闭停车会话和数字票
+  -> 计算当前费用,返回出场预览
+  -> 判断数字票是否已支付
+     -> 已支付:直接进入出场确认
+     -> 未支付且应收为零:免费放行确认
+     -> 未支付且应收大于零:选择支付入口和支付方式完成结算
+  -> 写支付流水、更新会话支付摘要、数字票 -> paid
+  -> 保存出口通道和设备快照,关闭会话,数字票 -> exited
+  -> 更新车辆最近出场时间、累计停车时长和费用
+  -> 请求开闸
 ```
 
-### 2.3 放行策略
+### 4.1 支付判断
 
-| 车辆类型 | 放行方式 | 说明 |
-|---------|---------|------|
-| 月租车(有效期内) | **直接放行** | RFID 识别→白名单验证→抬杆,无需任何操作 |
-| VIP/固定车 | **直接放行** | 已注册车辆,自动抬杆 |
-| 临时车(允许临停通道) | **取票放行** | 按票机按钮出纸质小票/或系统生成数字票,抬杆入场 |
-| 临时车(禁止临停通道) | **拦截** | 提示"临时车禁止入场" |
-| 黑名单 | **拦截** | 任何通道均不放行 |
+| 场景 | 处理 |
+| --- | --- |
+| 已支付数字票 | 不再次收费,直接消费原支付记录并确认出场 |
+| 零费用、白名单或免费放行 | 生成零金额结算记录后出场 |
+| 人工现金 | 校验实收不低于应收,记录找零 |
+| POS/扫码 | 由已配置支付入口和方式决定;真实设备或网关回调待接入时不得伪造成功 |
+| 重复请求 | 已支付或已关闭会话会被拒绝,避免重复收费和重复出场 |
 
----
+## 5. 通道与设备快照
 
-## 3. 出场流程
+停车会话保存下列字段:
 
-### 3.1 计费引擎
+| 阶段 | 字段 |
+| --- | --- |
+| 入场 | `entry_channel_id`、`entry_channel_code`、`entry_channel_name`、`entry_device_code`、`entry_device_name` |
+| 出场 | `exit_channel_id`、`exit_channel_code`、`exit_channel_name`、`exit_device_code`、`exit_device_name` |
 
-```
-calculateFee(vehicle, stayTime)
-  │
-  ├─ 1. 查 fee_config WHERE vehicle_type_id = ?
-  │     未找到/全零 → 默认费率 0.1元/分钟
-  │
-  ├─ 2. VIP 判断
-  │     isVip=true && 过期时间未到 && 非零值
-  │
-  ├─ 3. VIP 免费(isVip && IsVIPFree)→ fee=0
-  │
-  ├─ 4. 免费时长内 (stayTime ≤ StartTime) → fee=0
-  │
-  ├─ 5. 超时计费
-  │     extraUnits = ceil((stayTime - StartTime) / UnitTime)
-  │     fee = StartFee + extraUnits × UnitFee
-  │
-  ├─ 6. 每日封顶 (DailyMaxFee)
-  │
-  └─ 7. VIP 折扣 (fee ×= VIPDiscount)
-```
+进出记录详情优先显示名称;编码为名称缺失或配置已删除时的回退值。ID 仅用于关联和查询,不直接作为运营页面的主要显示内容。
 
-### 3.2 出场判断流程
+## 6. 状态关系
 
-```
-车辆到达出口
-  │
-  ├─ 触发识别 (RFID / 车牌 / 手动 / 扫码)
-  │
-  ▼
-┌─────────────────────────────────────────────────┐
-│  ① 黑名单检查                                    │
-│     └─ 黑名单 → 🚫 拦截                          │
-│                                                  │
-│  ② 查询未出场记录                                 │
-│     无记录 → "车辆未入场"                         │
-│                                                  │
-│  ③ 计费                                         │
-│     calculateFee() → 算出金额                    │
-│                                                  │
-│  ④ 结算方式判断                                  │
-│     ┌─ 月租车 (白名单+未过期) → 🟢 直接放行(免费) │
-│     ├─ VIP (免费策略) → 🟢 直接放行(免费)         │
-│     ├─ 临时车 fee=0 → 🟢 直接放行                 │
-│     └─ 临时车 fee>0 → 需结算                     │
-│          ├─ 人工现金收费 → 收费员收款 → 放行      │
-│          ├─ 出口 POS 刷卡 → 刷卡支付 → 放行       │
-│          └─ 中央缴费机 → 提前扫码支付 → 出口验证  │
-│                                                  │
-│  ⑤ 创建支付记录 + 更新数字票状态                  │
-│     INSERT payment_record                        │
-│     UPDATE digital_ticket (state=paid)           │
-│     ┌─ RFID → GateOpener 抬杆                    │
-│     └─ 手动 → 仅记录,人工抬杆                   │
-└─────────────────────────────────────────────────┘
-```
+| 停车会话 | 数字票 | 含义 |
+| --- | --- | --- |
+| `exit_time IS NULL` + `payment_status=unpaid` | `pending_payment` | 在场待支付 |
+| `exit_time IS NULL` + `payment_status=paid` | `paid` | 已支付待离场 |
+| `exit_time IS NOT NULL` | `exited` | 已离场 |
 
----
+`payment_record` 不承担状态机角色,它记录每次实际结算的金额和操作员,是财务对账依据。
 
-## 4. 交易结算方式
+## 7. 闸机处理
 
-### 4.1 结算方式对比
-
-| 结算方式 | 适用场景 | 费用归属 | 当前状态 |
-|---------|---------|---------|:--:|
-| **人工收费** | 岗亭操作员收现金 | 记录到 payment_record + shift_record | ✅ |
-| **免密放行** | 月租/VIP/免费车辆 | payment_method=free | ✅ |
-| **出口 POS 机** | 临时车出口刷卡 | 银行卡/会员卡扣款 | 🔜 P0-4 |
-| **中央缴费机** | 车主出场前在机器扫码支付 | 与中央缴费系统对接 | 🔜 规划中 |
-| **线上支付** | APP/小程序预支付 | 微信/支付宝 | 🔜 规划中 |
-
-### 4.2 人工收费流程(当前)
-
-```
-临时车到达出口
-  │
-  ├─ 操作员在 entryExit.vue 输入车牌 → 查询
-  │
-  ├─ ExitPreview → 显示费用/停留时长/车型
-  │
-  ├─ 临时车:弹出支付界面
-  │    ├─ 选择支付方式 [现金] [扫码(预留)] [POS(预留)]
-  │    └─ 选现金 → 输入实收金额 → 显示找零
-  │
-  ├─ 点击"确认收款并放行"
-  │    └─ ExitConfirm(payment_method=cash, paid_amount, operator_id)
-  │
-  └─ 创建 payment_record + 抬杆
-```
-
-### 4.3 中央缴费机流程(规划中)
-
-```
-出场前车主操作流程:
-
-  车主在自助缴费机 → 输入车牌 或 扫描数字票二维码
-    │
-    ├─ 系统查询 digital_ticket 表
-    │     └─ 找到未支付票 → 显示费用
-    │
-    ├─ 车主选择支付方式
-    │     ├─ 微信/支付宝扫码 → 调支付网关 → 支付成功回调
-    │     └─ 现金/硬币 → 机内识别 → 确认收款
-    │
-    ├─ 支付成功
-    │     └─ digital_ticket.state → paid
-    │     └─ INSERT payment_record
-    │
-    └─ 打印收据(可选)
-
-  车主开车到出口
-    │
-    ├─ RFID/车牌识别 → 查到 digital_ticket
-    │     └─ state=paid → 🟢 直接抬杆放行
-    │
-    └─ 超时未离场(如缴费后15分钟未到出口)
-          └─ 加收超时费 或 自动过期
-```
+开闸和关闸由统一闸机控制服务封装。进出场业务先完成数据库事务,再下发开闸指令:
 
-### 4.4 出口 POS 机流程(规划中)
+- 开闸成功:返回通行成功和闸机已开启。
+- 开闸失败:返回业务已完成、闸机失败,避免操作员误以为可以重复收费或重新出场。
+- 无真实设备时:可使用已启用的模拟设备完成工作台流程验证。
 
-```
-临时车到达出口 → 识别车牌
-  │
-  ├─ 计费 → 屏幕显示金额
-  │
-  ├─ POS 机进入待支付状态
-  │    └─ 车主刷卡/闪付/扫码(银行卡或会员卡)
-  │
-  ├─ POS 返回支付成功
-  │
-  └─ 系统记录 payment_record → 抬杆放行
-```
-
----
-
-## 5. 月租车完整流程
-
-### 5.1 办卡
-
-```
-操作员 → 包月车管理 → 选择车辆 → 选卡类型(月/季/年)
-  │
-  ├─ INSERT monthly_card (start_date, end_date, fee)
-  │
-  ├─ INSERT shortlist (list_type=白名单, expiration_time=end_date)
-  │
-  └─ INSERT payment_record (card fee)
-```
-
-### 5.2 进出场
-
-```
-月租车到达入口
-  │
-  ├─ RFID/车牌识别
-  │
-  ├─ CheckVehicleShortlist → 白名单 + 未过期
-  │    └─ 🟢 直接抬杆放行(零费用)
-  │
-  └─ 创建 vehicle_record + digital_ticket(记录进出时间)
-
-月租车到达出口
-  │
-  ├─ 白名单 + 未过期
-  │    └─ 🟢 直接抬杆放行
-  │
-  └─ 创建 payment_record(payment_method=free)
-```
-
-### 5.3 到期处理
-
-```
-月租到期 → shortlist.expiration_time < now
-  │
-  ├─ 进场时:白名单过期 → 按车辆原类型计费(如临时车)
-  │
-  ├─ 出场时:白名单过期 → 按普通费率收费
-  │
-  └─ 续费:操作员在包月车管理中点击续费
-       └─ end_date 延长 → shortlist 同步更新
-```
-
----
-
-## 6. 数字票状态跟踪
-
-```
-车辆入场
-  │
-  └─ digital_ticket.Create()
-       state: pending_payment
-       │
-       ├─ 月租/VIP/免费 → 直接 paid → exited
-       │
-       └─ 临时车需付费
-            │
-            ├─ 人工收费 → paid → exited
-            ├─ POS 刷卡 → paid → exited(规划)
-            └─ 中央缴费 → paid → exited(规划)
-```
+## 8. 异常与人工兜底
 
----
+| 异常 | 当前处理 | 后续完善 |
+| --- | --- | --- |
+| 黑名单车辆 | 拦截通行 | 增加异常处置记录和审批 |
+| 无未关闭会话的出场 | 拒绝出场 | 增加无票/丢票人工处理工作流 |
+| 闸机失败 | 业务状态保留,返回设备失败 | 增加重试、告警和人工确认 |
+| 设备离线 | 禁止使用该设备通行 | 增加离线事件和仪表盘告警 |
+| 摄像头未识别 | 当前未接入 | 接入 OCR 后进入同一通行入口 |
 
-## 7. 代码入口汇总
+## 9. 关键代码入口
 
-| 流程节点 | 函数 | 文件 |
-|---------|------|------|
-| RFID 入场 | `HandlePassage()` | `internal/service/parking/passage.go` |
-| 手动入场 | `VehicleEntry()` | `internal/service/vehicle/vehicle.go` |
-| 计费引擎 | `calculateFee()` | `internal/service/vehicle/vehicle.go` |
-| 出场预览 | `ExitPreview()` | `internal/service/vehicle/vehicle.go` |
-| 出场确认 | `ExitConfirm()` | `internal/service/vehicle/vehicle.go` |
-| 名单检查 | `CheckVehicleShortlist()` | `internal/service/vehicle/shortlist.go` |
-| 数字票 | `TicketService.Create()` | `internal/modules/digital-ticket/service/service.go` |
-| 支付处理 | `ProcessPayment()` | `internal/modules/payment/service/service.go` |
-| 通道事件 | `PushChannelEvent()` | `internal/service/uhf/reader.go` |
+| 节点 | 文件 | 关键方法 |
+| --- | --- | --- |
+| 统一通行 | `internal/service/parking/passage.go` | `HandlePassage` |
+| 停车会话 | `internal/modules/parking-session/service/service.go` | `OpenSessionTx`、`ResolveActiveSessionTx`、`CloseSessionTx` |
+| 车辆进出场 | `internal/service/vehicle/vehicle.go` | `VehicleEntry`、`ExitPreview`、`ExitConfirm` |
+| 支付 | `internal/modules/payment/service/service.go` | `ProcessPaymentTxAtEntry` |
+| 数字票 | `internal/modules/digital-ticket/service/service.go` | `CreateTx`、`MarkPaidAndExitedTx` |
+| 闸机 | `internal/service/parking/gate.go` | 开闸、关闸、设备状态 |

+ 41 - 541
doc/进出场逻辑流程.md

@@ -1,541 +1,41 @@
-# 智慧停车 — 进出场逻辑流程
-
-> 更新日期: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 自动识别 → 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.1 总体流程
-
-```
-车辆到达入口
-  │
-  ├── 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 总体流程
-
-```
-车辆到达出口
-  │
-  ├── 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. 计费算法多场景推演
-
-```
-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
-  │
-  ├── 3. 免费时长内
-  │      stayMinutes <= StartTime → return 0
-  │
-  ├── 4. 超时计费
-  │      extraTime = stayMinutes - StartTime
-  │      units = ceil(extraTime / UnitTime)
-  │      fee = StartFee + units × UnitFee
-  │
-  ├── 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) |
-
-### 6.3 数字票状态转换
-
-```
-Created → PendingPayment → Paid → Exited
-                   ↘           ↘
-                    Expired     Expired
-```
-
-创建时直接到 PendingPayment,出场支付后到 Paid,可再转为 Exited。
-
----
-
-## 7. 边界情况 & 注意事项
-
-### 7.1 RFID 防抖设计
-
-```go
-passageDebounceSec = 3 // 3 秒内同一 RFID/车牌忽略重复请求
-```
-同一设备读头短时间内可能多次读取同一标签,防抖避免重复入场/出场。
-
-### 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 通道 | 手动通道 |
-|------|----------|---------|
-| 小区/园区(封闭) | 主要出入口,预注册车辆自动放行 | 访客登记入场 |
-| 公共停车场 | 未实现 | 临时车收费出场 |
-
----
-
-## 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: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: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` |
+# 进出场逻辑流程图
+
+> 更新日期:2026-08-07
+> 本文档为流程总览;字段和异常细节见 [进出场流程详解](进出场流程详解.md)。
+
+## 1. 统一流程
+
+```mermaid
+flowchart TD
+    A[设备事件或人工录入] --> B[校验车辆标识]
+    B --> C[通过设备编码定位设备和通道]
+    C --> D{通道方向}
+    D -->|入场| E[定位未关闭停车会话]
+    E -->|不存在| F[创建停车会话和数字票]
+    E -->|已存在| G[拒绝重复入场]
+    F --> H[保存入口通道和设备快照]
+    H --> I[请求开闸]
+    D -->|出场| J[定位停车会话和数字票]
+    J --> K[预览停车费用]
+    K --> L{数字票是否已支付}
+    L -->|否| M[按入口和方式完成支付]
+    M --> N[写支付流水并更新会话和数字票]
+    L -->|是| O[复用既有支付结果]
+    N --> P[保存出口通道和设备快照]
+    O --> P
+    P --> Q[关闭会话并标记数字票已离场]
+    Q --> R[请求开闸]
+```
+
+## 2. 状态约束
+
+| 操作 | 前置状态 | 成功后的状态 |
+| --- | --- | --- |
+| 入场 | 不存在未关闭会话 | 会话在场,数字票 `pending_payment` |
+| 支付 | 会话在场且未支付 | 会话已支付,数字票 `paid` |
+| 出场 | 会话在场且已支付,或合法零费用放行 | 会话关闭,数字票 `exited` |
+| 再次支付/出场 | 已支付或已关闭 | 拒绝,不产生新流水 |
+
+## 3. 设备失败原则
+
+业务数据事务成功后才请求开闸。开闸失败不回滚已完成的支付或出场,而是返回设备失败状态,由操作员重试开闸或按异常流程处理。

+ 141 - 262
doc/项目进度.md

@@ -1,266 +1,145 @@
-# 智慧停车管理系统 — 项目进度
-
-> 更新日期:2026-07-24
-
-## 技术栈
-
-| 层级 | 技术 |
-|------|------|
-| 桌面框架 | Wails v2.13(WebView2) |
-| 后端 | Go 1.25 + Gin + GORM + Casbin v3 + JWT |
-| 前端 | Vue 3 + Vite 4 + Element Plus + Pinia |
-| 数据库 | SQLite(`%APPDATA%/smart-parking/`) |
-
-## 整体进度
-
-| 模块 | 后端 | 前端 | 状态 |
-|------|:--:|:--:|:----:|
-| 用户管理 | ✅ | ✅ | 完成 |
-| 角色权限 | ✅ | ✅ | Casbin RBAC,动态菜单 |
-| 菜单管理 | ✅ | ✅ | - |
-| API 管理 | ✅ | ✅ | - |
-| 数据字典 | ✅ | ✅ | - |
-| 操作日志 | ✅ | ✅ | OperationRecord中间件已全局启用 |
-| **异常日志** | ❌ | ❌ | panic recovery + error log to file |
-| 停车场管理 | ✅ | ✅ | 含 /parking 前缀对齐 |
-| 收费岗亭 | ✅ | ✅ | - |
-| 通道管理 | ✅ | ✅ | 方向/临停开关 |
-| UHF 设备 | ✅ | ✅ | TCP/串口连接/启停 |
-| 车辆信息 | ✅ | ✅ | CRUD |
-| 车辆类型 | ✅ | ✅ | 含 is_system 标记 |
-| 车主管理 | ✅ | ✅ | 含 VIP |
-| 收费配置 | ✅ | ✅ | 起步价/计费单位/每日封顶/VIP折扣 |
-| 黑白名单 | ✅ | ✅ | 进出场三入口全覆盖 |
-| 车辆进场 | ✅ | ✅ | RFID 自动 + 手动 |
-| 车辆出场 | ✅ | ✅ | 计费算法完整 |
-| 进出记录 | ✅ | ✅ | 查询 |
-| 道闸控制 | ✅ | ✅ | RFID识别→继电器开闸 |
-| 手动进出场 | ✅ | ✅ | entryExit.vue,双 Tab 页面 |
-| 收费结算 | ✅ | ✅ | 现金+找零/免密,payment_record |
-| **收费记录查询** | ✅ | ✅ | 按车牌/支付方式/日期/停车场筛选 |
-| **包月车管理** | ✅ | ✅ | 月/季/年卡,办卡=白名单 |
-| **监控仪表盘** | ✅ | ✅ | 车位/在场/收入/进出+设备状态 |
-| 摄像头 | ⚠️ | ❌ | DAO 有定义,无 API/页面 |
-| 个人中心 | ✅ | ✅ | - |
-| 登录页 | ✅ | ✅ | RSA 加密 + 验证码 |
-| **数字票** | ✅ | ❌ | 5状态机+ticket_no二维码+事件日志 |
-| **在场车辆统计** | ✅ | ✅ | 停车场/车牌筛选+详情卡片 |
-| **exe混淆** | ❌ | ❌ | garble 编译混淆,防反编译 |
-
-## 核心业务流完成度
-
-```
-1. 系统设置 ──────── 停车场→岗亭→通道→设备  ✅ 100%
-2. 收费规则配置 ──── 按车型/免费时长/VIP     ✅ 100%
-3. 名单管理 ──────── 白名单/黑名单           ✅ 100%
-4. 车辆进场 ──────── RFID识别→查单→开闸     ✅ 100%
-5. 车辆出场 ──────── 识别→计费→收费→开闸    ✅ 100%
-6. 收费结算 ──────── 现金+找零/免密          ✅ 100%
-7. 手动进出场页面 ─── 车牌/RFID输入→进出场   ✅ 100%
-8. 收费记录查询 ──── 筛选/汇总               ✅ 100%
-9. 包月车管理 ───── 月/季/年卡→白名单       ✅ 100%
-10. 监控仪表盘 ───── 车位/收入/设备状态      ✅ 100%
-11. 小票打印 ─────── ESC/POS 串口打印        ❌ 0%
-12. 摄像头识别 ───── RTSP抓拍→OCR→触发       ❌ 0%
-13. LED车位屏 ────── 余位实时显示             ❌ 0%
-14. 交接班对账 ───── 当班统计/实时收款/差异   ✅ 100%
-15. 收入报表 ─────── 按日+停车场汇总          ✅ 100%
-16. 在场车辆统计 ──── 停车场/车牌筛选+详情    ✅ 100%
-17. 数字票 ──────── 5状态机+ticket_no+日志   ✅ 100%
-18. 操作日志 ──────── OperationRecord中间件   ✅ 100%
-19. 异常日志 ──────── panic recovery+文件    ❌ 0%
-20. exe混淆 ──────── garble编译              ❌ 0%
+# 智慧停车管理系统项目进度
+
+> 更新日期:2026-08-11
+> 状态说明:`已完成` 指功能已接入前后端;`已验证` 指自动化测试或构建已通过;`待验收` 指需在真实设备或现场流程继续验证。
+
+## 1. 当前完成度
+
+| 领域 | 状态 | 当前能力 |
+| --- | --- | --- |
+| 基础权限与系统管理 | 已完成 | 用户、角色、菜单、API、Casbin、字典、操作日志、登录 |
+| 停车场基础配置 | 已完成并验证 | 停车场、岗亭、通道、UHF 设备,容量、占用数、余位及满位准入策略 |
+| 车辆与名单 | 已完成 | 车辆、车主、车型、黑白名单、包月车 |
+| 统一进出场 | 已完成 | 手动和 UHF 统一经过 `PassageService`,支持入场、出场、计费、开闸 |
+| 停车会话 | 已完成并验证 | 防止重复在场、统一定位、支付与离场状态更新、入口/出口通道设备快照 |
+| 数字票 | 已完成并验证 | 创建、待支付、已支付、已离场、事件日志、列表、详情、汇总、支付和离场页面 |
+| 支付 | 已完成并验证 | 支付入口与支付方式分离配置,现金、POS、扫码等可配置方式,支付流水与找零 |
+| 交接班与收入报表 | 已完成 | 交接班记录、收费记录、收入汇总与查询 |
+| 仪表盘与车辆监控 | 已完成 | 在场车辆、进出统计、收入及设备状态展示 |
+| 闸机控制 | 已完成,待现场验收 | 统一开闸/关闸封装,支持真实设备和模拟在线设备 |
+| 小票打印 | 已完成,待真实打印验收 | 串口 ESC/POS 与 USB CSN SDK,二维码与票据排版测试 |
+| 异常处置闭环 | 已完成并验证 | 异常事件表、四状态处置流转、自动埋点(通行失败/人工抬杆/设备离线)、指令流水、前端页面与权限分级 |
+| 摄像头识别 | 未完成 | 已有摄像头实体,尚未接入 RTSP、OCR 和进出场事件 |
+| LED 车位屏 | 未完成 | 尚未接入硬件协议和余位推送 |
+
+## 2. 本轮已完成工作
+
+### 2.0 停车场容量与满位准入
+
+- 停车场新增实际占用数,并根据总容量和占用数维护剩余车位。
+- 入场占位、停车会话创建和数字票创建处于同一事务;出场关闭会话时在同一事务释放车位。
+- 服务启动时根据未出场停车会话自动校准各停车场占用数和余位。
+- 车位满时支持分别配置是否允许临时车、有效月租车和有效 VIP 车继续入场。
+- 超容车辆入场后余位保持为零,直到实际占用数重新低于停车场容量。
+- 停车场详情显示总车位、已占用、剩余车位和满位准入策略。
+- 仪表盘按全部未删除停车场汇总容量和实际占用数,与停车场详情使用相同统计口径。
+- 停车场存在未出场会话时禁止删除,避免继续产生无法归属的在场记录。
+- 已关闭已删除停车场遗留的 48 条测试会话;修复前数据库已备份到 `backups/lc_garage-before-orphan-fix-20260811-112552.db`。
+
+### 2.1 停车会话统一化
+
+- 以 `vehicle_record` 作为停车会话持久化载体。
+- 入场时原子创建停车会话和数字票,避免只有入场记录或只有数字票的半成品数据。
+- 出场、支付、数字票操作都通过会话编号、票号、车牌或 RFID 定位同一在场会话。
+- 旧出场路径已收敛至 `ExitConfirm` 和统一通行入口。
+
+### 2.2 支付与数字票解耦
+
+- 数字票不再绑定“停车小票”这一单一场景,所有车辆入场均创建数字票。
+- 支付入口和支付方式分别配置:入口决定在哪里完成支付,方式决定如何收款。
+- 支付成功写入 `payment_record`,同步更新停车会话和数字票;出场只消费已完成的结算结果,避免重复收费。
+- 已补充负金额、零金额、重复支付、并发结算、事务回滚等测试。
+
+### 2.3 设备、通道和会话可追溯
+
+- `HandlePassage` 根据设备编码定位 UHF 设备及绑定通道。
+- 入场保存入口通道和入口设备快照;出场保存出口通道和出口设备快照。
+- 快照包含 ID、编码和名称。进出记录详情页面优先显示名称。
+- 历史记录若没有名称快照,会根据当前通道 ID、设备编码补全;无法补全时保留编码兜底。
+
+### 2.4 小票打印
+
+- 支持串口 ESC/POS 与 USB CSN DLL 直连两种打印链路。
+- USB 票据按实际纸张出纸方向输出,并包含二维码、切纸前走纸和短写失败处理。
+- 已完成打印命令与版式自动测试;最终字号、物理偏移和二维码密度仍以现场纸张为准。
+
+### 2.5 票机统一通行与调试接口收口
+
+- 票机按钮通过通道编码定位停车场和绑定设备,并统一调用 `PassageService` 完成准入、会话、数字票、入口快照和开闸。
+- 删除停车场 ID、车位 ID 和测试通道的硬编码;同一通道存在多个启用设备时要求明确传入设备编码。
+- 入场响应直接返回 `session_id`、`ticket_id` 和 `ticket_no`,打印不再按车牌查询最新票据。
+- 无牌取票生成唯一 `TKT...` 会话身份,打印票面仍保持车牌为空。
+- 调试路由由 `system.debug-routes` 显式控制,默认关闭;开启后仍要求 JWT、管理员或开发角色权限,并记录操作日志。
+- 打印任务持久化、失败重试、重打和作废仍列为后续 P0 工作。
+
+### 2.6 异常处置闭环
+
+- 新增 `incident_record` 异常事件表与 `internal/modules/incident/` 模块(api + service + repository),详见 `doc/异常处置设计.md`。
+- 状态机:待处理 → 处理中 → 已解决 → 已关闭,支持重新打开;CAS 条件更新防并发覆盖;`event_log` 记录流转时间线。
+- 自动埋点(best-effort,不影响业务主流程):
+  - 统一通行(`PassageService`):开闸失败(critical)、重复入场、黑名单拦截、满位拒绝、无入场记录出场。
+  - 人工抬杆/关闸:`/parking/gate/open|close` 新增可选 `reason` 参数,成功生成已解决的 `manual_raise` 记录,失败生成待处置的 `gate_failed` 记录。
+  - 设备离线:UHF 读卡器在线→离线转换时生成(同设备未关闭不重复),恢复在线自动关闭。
+- 新增 `device_command_log` 指令流水:每次道闸开/关命令(来源/操作员/会话/结果/耗时),开闸失败时关联异常事件。
+- 权限分级:查看/上报 618/888/9527;处置 888/9527;强制免费仅 888(前端隐藏 + 后端强制校验)。
+- 前端:`view/report/sheet.vue` 重写为异常处置页面(统计行 + 筛选 + 分页 + 详情时间线 + 处置表单 + 人工上报),新增 `api/incident.js`;复用菜单 27"异常报表"。
+- 自动化测试:状态机合法/非法流转、强制免费权限与金额校验、CAS 并发、best-effort(DB 故障不阻塞)、设备离线去重与自动关闭、编号生成、统计、开闸失败埋点集成(指令流水 + 异常关联)、路由鉴权(未登录 401)。
+
+## 3. 核心业务闭环
+
+```text
+设备/人工识别车辆
+  -> 定位设备和通道
+  -> 定位在场停车会话
+  -> 无会话:创建会话 + 数字票 + 入场快照 + 开闸
+  -> 有会话:预览费用
+  -> 通过支付入口选择支付方式完成结算
+  -> 支付流水 + 会话支付摘要 + 数字票同步更新
+  -> 保存出场通道设备快照 + 关闭会话 + 数字票离场 + 开闸
 ```
 
-## 车辆进出场触发方式
-
-| 触发方式 | 状态 | 说明 |
-|---------|:--:|------|
-| UHF RFID 自动识别 | ✅ | 读标签→查车辆→进出场+开闸,PassageService 统一入口 |
-| 前端手动录入 | ✅ | entryExit.vue 进出场操作页面,含收费结算 |
-| 数字票统一抽象 | ✅ | 所有进场方式统一创建 digital_ticket,5状态机驱动 |
-| 摄像头车牌识别 | ❌ | `camera` 表已建,无拍照/OCR/触发逻辑 |
-
-```
-车辆靠近
-  ├─ UHF 读卡器 → RFID 标签 → PassageService → VehicleEntry/Exit ✅
-  ├─ 人工       → 手动录入车牌 → VehicleEntry/Exit ✅
-  ├─ 摄像头     → 抓拍 → OCR 车牌 → VehicleEntry/Exit ❌
-              ↓
-         三者统一创建 digital_ticket(数字票状态机)
+## 4. 自动化验证记录
+
+| 范围 | 已覆盖的关键场景 |
+| --- | --- |
+| `parking-session` | 会话创建、重复入场拒绝、身份冲突、支付、关闭、通道设备快照、余位事务、满位准入、超容离场 |
+| `payment` | 负金额、零金额、金额不足、重复结算、并发结算、事务回滚、缺失会话 |
+| `vehicle` | 车辆入场创建会话和数字票、RFID 定位、预览、免费出场、重复支付回滚 |
+| `parking` | 统一通行和闸机相关服务回归 |
+| `system` | 仪表盘只汇总有效停车场,忽略已删除停车场的孤立历史会话 |
+| `printer` | USB 票据顺序、文字方向、二维码、切纸走纸、短写和二维码失败 |
+| 票机适配层 | 统一通行请求、无牌唯一身份、通道设备解析、多设备歧义拒绝、打印部分失败、调试路由隔离 |
+| `incident` | 异常状态机合法/非法流转、强制免费权限与金额、CAS 并发、best-effort(DB 故障不阻塞)、设备离线去重与自动关闭、编号生成、统计、指令流水关联 |
+| 埋点集成 | 开闸失败 → `gate_failed` 异常 + `failed` 指令流水并回填关联;人工抬杆(带原因)生成已解决记录;成功开闸不产生异常 |
+| 路由安全 | `/incident/*` 全部要求 JWT(未登录 401) |
+| 前端 | `npm run build` 生产构建通过 |
+
+最近一次相关验证命令:
+
+```powershell
+$env:GOCACHE='D:\lq\Smart Parking\lc_garage\.gocache'
+go test ./internal/service/... ./internal/modules/... ./internal/initialize
+
+cd frontend
+npm run build
 ```
 
-**摄像头识别待实现内容**:
-
-| 组件 | 说明 |
-|------|------|
-| 硬件对接 | RTSP 视频流抓拍或 SDK 回调 |
-| OCR 识别 | 车牌识别算法或第三方云服务 |
-| 业务触发 | 识别到车牌 → 自动调用 `VehicleEntry()`/`VehicleExit()` |
-| 图片留存 | `VehicleRecord` 已有 `EntryImage`/`ExitImage` 字段 |
-
-## 待开发模块
-
-### P0-1 ─ 道闸控制 ✅
-
-> 已完成:继电器 `CloseRelay1(2)` 已解除注释,`OpenGate()` 公开方法。进场/出场自动开闸。
-
-### P0-2 ─ 手动进出场页面 ✅
-
-> 已完成:`entryExit.vue` 支持车牌/RFID 输入、车辆查询、进场/出场双 Tab。含多语言支持。
-
-### P0-3 ─ 收费结算 ✅
-
-> 已完成:`payment_record` 表、`PaymentService`、现金收款+找零、免密放行。预留 POS/扫码扩展。
-
-### P0-4 ─ 小票打印
-
-> **收完钱要出票**:配合收费结算,收费完成后自动打印小票给车主。
-
-| 功能 | 后端 | 前端 | 说明 |
-|------|:--:|:--:|------|
-| 小票打印 | ❌ | ❌ | 串口 ESC/POS 指令,58mm 热敏小票打印机 |
-
-**待实现内容**:
-
-| 组件 | 说明 |
-|------|------|
-| 串口通信 | 通过串口/USB 连接 58mm 热敏打印机 |
-| ESC/POS 指令 | 封装打印指令集:文字、条码、二维码、切纸 |
-| 小票模板 | 停车小票格式:车牌号、进场时间、出场时间、费用、收款方式 |
-| 打印触发 | 收费完成后自动打印,支持补打 |
-
-### P0-5 ─ 摄像头车牌识别
-
-> **双重校验防作弊**:RFID 标签可能被拆换,车牌识别作为第二道校验。也是第三种进出场触发方式(无标签车辆)。
-
-| 功能 | 后端 | 前端 | 说明 |
-|------|:--:|:--:|------|
-| 摄像头车牌识别 | ❌ | ❌ | RTSP 视频流抓拍 + OCR 车牌识别,`camera` 表已建 |
-
-**待实现内容**:
-
-| 组件 | 说明 |
-|------|------|
-| 硬件对接 | RTSP 视频流拉取或 SDK 回调(海康/大华/宇视等) |
-| 抓拍触发 | 车辆进入识别区 → 抓拍一张图片 |
-| OCR 识别 | 车牌识别算法或第三方云服务(百度/阿里 OCR) |
-| 业务触发 | 识别到车牌 → 自动调用 `VehicleEntry()`/`VehicleExit()` |
-| 图片留存 | `VehicleRecord` 已有 `EntryImage`/`ExitImage` 字段 |
-
-### P1 — 运营管理
-
-| 功能 | 后端 | 前端 | 说明 |
-|------|:--:|:--:|------|
-| 交接班 | ✅ | ✅ | 接班/交班/当班收款/差异计算 |
-| 收入报表 | ✅ | ✅ | 按日+停车场汇总,底部合计行 |
-| 异常报表 | ❌ | ⚠️ | 无牌车、手动抬杆、断网异常记录,前端空占位 |
-| LED 车位显示 | ❌ | ❌ | 入口剩余车位显示屏对接 |
-
-### P2 — 结算终端
-
-| 功能 | 后端 | 前端 | 说明 |
-|------|:--:|:--:|------|
-| **出口 POS 机** | ❌ | ❌ | 临时车出口刷卡支付(银行卡/会员卡),调用支付网关 |
-| **中央缴费机** | ❌ | ❌ | 停车场内自助缴费终端:车牌查询 → 扫码/现金支付 → 出口自动放行 |
-
-### P2 — 监控与边缘端
-
-| 功能 | 后端 | 前端 | 说明 |
-|------|:--:|:--:|------|
-| 实时监控大屏 | ✅ | ✅ | 暗色控制室风格,LED读表字体 |
-| 在线车辆统计 | ✅ | ✅ | 停车场/车牌筛选,卡片网格+详情 |
-| RK3568 边缘端 | ❌ | ❌ | edge/ 通信层(TCP协议 + 事件总线) |
-
-## 前端页面清单(37 页)
-
-| 位置 | 页面 | 功能 | 状态 |
-|------|------|------|:--:|
-| `login/` | index.vue | 登录 | ✅ |
-| `dashboard/` | index.vue + charts + table | 仪表盘 | ✅ |
-| `parking/` | parkingInfo/index.vue | 停车场信息(左列表右Tab) | ✅ |
-| | parkingInfo/booth.vue | 岗亭管理 | ✅ |
-| | parkingInfo/channel.vue | 通道管理 | ✅ |
-| | parkingInfo/device.vue | UHF设备管理 | ✅ |
-| | carInfo.vue | 车辆信息 | ✅ |
-| | carOwner.vue | 车主管理 | ✅ |
-| | pricing.vue | 收费配置 | ✅ |
-| | roster.vue | 黑白名单 | ✅ |
-| `report/` | enter.vue | 进出记录 | ✅ |
-| | sheet.vue | 异常报表 | ⚠️ 空 |
-| `statistics/` | vehicle.vue | 在线车辆统计 | ✅ |
-| `superAdmin/` | user/user.vue | 用户管理 | ✅ |
-| | authority/authority.vue | 角色管理 | ✅ |
-| | menu/menu.vue | 菜单管理 | ✅ |
-| | api/api.vue | API管理 | ✅ |
-| | dictionary/*.vue | 字典管理 | ✅ |
-| | operation/*.vue | 操作日志 | ✅ |
-| `person/` | person.vue | 个人中心 | ✅ |
-| `layout/` | 9 个组件 | 布局框架 | ✅ |
-| `error/` | 2 个组件 | 错误页 | ✅ |
-
-## 后端 API(110+ 接口)
-
-| 模块 | 接口数 | 状态 |
-|------|:-----:|:----:|
-| Base (login/captcha) | 3 | ✅ |
-| User | 11 | ✅ |
-| Menu | 9 | ✅ |
-| API | 9 | ✅ |
-| Authority | 6 | ✅ |
-| Casbin | 2 | ✅ |
-| Dictionary | 10 | ✅ |
-| OperationRecord | 5 | ✅ |
-| FileUpload | 8 | ✅ |
-| Email | 2 | ✅ |
-| Parking Lot | 7 | ✅ |
-| Booth | 7 | ✅ |
-| Channel | 7 | ✅ |
-| Device (UHF) | 8 | ✅ |
-| Vehicle | 8 | ✅ |
-| Vehicle Type | 6 | ✅ |
-| Vehicle Record | 3 | ✅ |
-| Fee Config | 5 | ✅ |
-| Owner | 6 | ✅ |
-| Shortlist | 5 | ✅ |
-
-## 数据库(24 张业务表)
-
-| 表 | 说明 | 状态 |
-|------|------|:--:|
-| parking_lot | 停车场 | ✅ |
-| booth | 收费岗亭 | ✅ |
-| channel | 通道 | ✅ |
-| uhf_reader | UHF读卡器 | ✅ |
-| camera | 摄像头 | ⚠️ 表有,功能无 |
-| vehicle | 车辆 | ✅ |
-| vehicle_type | 车辆类型 | ✅ |
-| vehicle_record | 进出记录 | ✅ |
-| fee_config | 收费标准 | ✅ |
-| owner | 车主 | ✅ |
-| shortlist | 黑白名单 | ✅ |
-| payment_record | 支付记录 | ✅ |
-| monthly_card | 包月卡 | ✅ |
-| digital_ticket | 数字票 | ✅ |
-| sys_* (13张) | GVA 系统表 | ✅ |
-
-## 下一步建议
-
-| 优先级 | 功能 | 预估工时 | 状态 |
-|:--:|------|:--:|:--:|
-| ~~P0-1~~ | ~~道闸控制~~ | - | ✅ |
-| ~~P0-2~~ | ~~手动进出场页面~~ | - | ✅ |
-| ~~P0-3~~ | ~~收费结算~~ | - | ✅ |
-| ~~P0-3~~ | ~~收费结算~~ | - | ✅ |
-| ~~P1~~ | ~~包月车管理~~ | - | ✅ |
-| ~~P2~~ | ~~监控大屏~~ | - | ✅ |
-| ~~--~~ | ~~在场车辆统计~~ | - | ✅ |
-| ~~--~~ | ~~数字票~~ | - | ✅ |
-| **P0-4** | **小票打印** | 1-2天 | ESC/POS 串口指令封装 + 小票模板 |
-| **P0-5** | **摄像头车牌识别** | 3-5天 | RTSP+OCR,RFID+车牌双重校验 |
-| ~~P1~~ | ~~交接班 + 收入报表~~ | - | ✅ |
-| P1 | LED 车位显示 | 1天 | 入口剩余车位屏 |
-| P1 | 异常日志 | 半天 | panic recovery + error log to file |
-| P1 | exe混淆 | 半天 | garble 编译混淆 |
-| P2 | 出口 POS 机 | 3-5天 | 串口/TCP通信+支付网关对接 |
-| P2 | 中央缴费机 | 5-7天 | 自助缴费终端+扫码支付+出口联动放行 |
+## 5. 待办与优先级
+
+| 优先级 | 项目 | 状态 | 说明 |
+| --- | --- | --- | --- |
+| P0 | 真实闸机联调 | 待验收 | 开闸、关闸、设备离线、超时和人工兜底 |
+| P0 | 小票打印现场验收 | 待验收 | 真实 USB/串口打印机的方向、物理偏移、二维码大小和切纸 |
+| P0 | 摄像头车牌识别 | 未开始 | RTSP/SDK 抓拍、OCR、车牌置信度和统一通行入口接入 |
+| P1 | 异常处置扩展点 | 预留 | 打印失败/支付不确定埋点、无牌车独立分类、余位为负与状态不一致自动检测(核心闭环已完成,见 2.6) |
+| P1 | LED 车位屏 | 未开始 | 余位计算、设备协议、定时/事件推送 |
+| P1 | 现场数据与权限验收 | 待验收 | 角色菜单、操作日志、数据归属、交接班对账 |
+| P2 | POS 真机支付 | 待接入 | 以已配置的 POS 支付方式为基础接入交易请求和异步回调 |
+| P2 | 中央缴费机 | 待接入 | 作为支付入口接入,复用数字票和支付流水,不创建独立业务主线 |
+| P2 | 线上支付 | 待接入 | 小程序/App 扫码支付与回调幂等处理 |
+
+## 6. 文档维护规则
+
+- 业务行为变化时,同时更新本文件和对应流程文档。
+- “已完成”不等于真实硬件已验收;涉及闸机、打印机、POS 的功能必须保留现场验收记录。
+- 数据库字段新增依赖 `AutoMigrate` 时,文档应说明旧数据的兼容与回退策略。

+ 17 - 0
frontend/src/api/incident.js

@@ -0,0 +1,17 @@
+import service from '@/utils/request'
+
+export const getIncidentList = (params) => {
+  return service({ url: '/incident/list', method: 'get', params })
+}
+export const getIncidentDetail = (id) => {
+  return service({ url: `/incident/${id}`, method: 'get' })
+}
+export const createIncident = (data) => {
+  return service({ url: '/incident', method: 'post', data })
+}
+export const transitionIncident = (id, data) => {
+  return service({ url: `/incident/${id}/transition`, method: 'post', data })
+}
+export const getIncidentStats = () => {
+  return service({ url: '/incident/stats', method: 'get' })
+}

+ 2 - 2
frontend/src/view/dashboard/index.vue

@@ -44,7 +44,7 @@
               <span class="heading-icon occupancy-icon">
                 <el-icon><Location /></el-icon>
               </span>
-              <span>车位使用情况</span>
+              <span>全部停车场车位</span>
             </div>
             <span class="occupancy-state" :class="{ full: isFull }">
               {{ isFull ? '车位已满' : `使用率 ${occupancyRate}%` }}
@@ -85,7 +85,7 @@
               <span>在场车辆</span>
             </div>
             <p class="metric-value">{{ data.in_park }}<small>台</small></p>
-            <p class="metric-note">当前场内车辆</p>
+            <p class="metric-note">全部停车场在场车辆</p>
           </article>
 
           <article class="metric-card">

+ 62 - 2
frontend/src/view/parking/parkingInfo/index.vue

@@ -56,7 +56,7 @@
     <el-dialog
       v-model="dialogVisible"
       :title="isEdit ? '编辑停车场' : '新增停车场'"
-      width="500"
+      width="560"
     >
       <el-form label-width="120">
         <el-form-item label="区域编码:">
@@ -66,7 +66,23 @@
           <el-input v-model="form.lot_name" />
         </el-form-item>
         <el-form-item label="区域容量:">
-          <el-input v-model="form.capacity" type="number" />
+          <el-input-number v-model="form.capacity" :min="0" :precision="0" controls-position="right" />
+        </el-form-item>
+        <el-form-item label="满位准入:">
+          <div class="admission-policy">
+            <label class="policy-option">
+              <span>临时车</span>
+              <el-switch v-model="form.allow_temporary_when_full" />
+            </label>
+            <label class="policy-option">
+              <span>月租车</span>
+              <el-switch v-model="form.allow_monthly_when_full" />
+            </label>
+            <label class="policy-option">
+              <span>VIP 车</span>
+              <el-switch v-model="form.allow_vip_when_full" />
+            </label>
+          </div>
         </el-form-item>
         <el-form-item label="备注:">
           <el-input v-model="form.description" type="textarea" />
@@ -128,6 +144,9 @@ const form = reactive({
   lot_code: '',
   lot_name: '',
   capacity: 0,
+  allow_temporary_when_full: false,
+  allow_monthly_when_full: true,
+  allow_vip_when_full: true,
   description: '',
 })
 
@@ -137,6 +156,9 @@ const openCreateDialog = () => {
   form.lot_code = ''
   form.lot_name = ''
   form.capacity = 0
+  form.allow_temporary_when_full = false
+  form.allow_monthly_when_full = true
+  form.allow_vip_when_full = true
   form.description = ''
   dialogVisible.value = true
 }
@@ -147,6 +169,9 @@ const openEditDialog = (item) => {
   form.lot_code = item.lot_code
   form.lot_name = item.lot_name
   form.capacity = item.capacity
+  form.allow_temporary_when_full = Boolean(item.allow_temporary_when_full)
+  form.allow_monthly_when_full = Boolean(item.allow_monthly_when_full)
+  form.allow_vip_when_full = Boolean(item.allow_vip_when_full)
   form.description = item.description
   dialogVisible.value = true
 }
@@ -216,4 +241,39 @@ onMounted(() => {
     gap: 4px;
   }
 }
+
+.admission-policy {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  width: 100%;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  background: #fafafa;
+}
+
+.policy-option {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 10px 12px;
+  color: #303133;
+  font-size: 14px;
+
+  & + & {
+    border-left: 1px solid #e4e7ed;
+  }
+}
+
+@media (max-width: 900px) {
+  .admission-policy {
+    grid-template-columns: 1fr;
+  }
+
+  .policy-option + .policy-option {
+    border-top: 1px solid #e4e7ed;
+    border-left: 0;
+  }
+}
 </style>

+ 44 - 6
frontend/src/view/parking/parkingInfo/lotDetail.vue

@@ -2,16 +2,35 @@
   <div v-loading="loading">
     <el-descriptions
       v-if="detail.ID"
-      :column="2"
+      :column="3"
       border
     >
-      <el-descriptions-item label="ID" :span="2">{{ detail.ID }}</el-descriptions-item>
+      <el-descriptions-item label="ID">{{ detail.ID }}</el-descriptions-item>
       <el-descriptions-item label="区域编码">{{ detail.lot_code }}</el-descriptions-item>
       <el-descriptions-item label="区域名称">{{ detail.lot_name }}</el-descriptions-item>
-      <el-descriptions-item label="容量">{{ detail.capacity }}</el-descriptions-item>
-      <el-descriptions-item label="备注">{{ detail.description || '-' }}</el-descriptions-item>
-      <el-descriptions-item label="创建时间" :span="2">{{ detail.CreatedAt || '-' }}</el-descriptions-item>
-      <el-descriptions-item label="更新时间" :span="2">{{ detail.UpdatedAt || '-' }}</el-descriptions-item>
+      <el-descriptions-item label="总车位">{{ detail.capacity }}</el-descriptions-item>
+      <el-descriptions-item label="已占用">
+        <span class="space-value occupied">{{ detail.occupied || 0 }}</span>
+      </el-descriptions-item>
+      <el-descriptions-item label="剩余车位">
+        <span class="space-value" :class="{ full: detail.available === 0 }">{{ detail.available || 0 }}</span>
+      </el-descriptions-item>
+      <el-descriptions-item label="满位准入" :span="3">
+        <div class="policy-tags">
+          <el-tag :type="detail.allow_temporary_when_full ? 'success' : 'info'" effect="plain">
+            临时车{{ detail.allow_temporary_when_full ? '允许' : '禁止' }}
+          </el-tag>
+          <el-tag :type="detail.allow_monthly_when_full ? 'success' : 'info'" effect="plain">
+            月租车{{ detail.allow_monthly_when_full ? '允许' : '禁止' }}
+          </el-tag>
+          <el-tag :type="detail.allow_vip_when_full ? 'success' : 'info'" effect="plain">
+            VIP 车{{ detail.allow_vip_when_full ? '允许' : '禁止' }}
+          </el-tag>
+        </div>
+      </el-descriptions-item>
+      <el-descriptions-item label="备注" :span="3">{{ detail.description || '-' }}</el-descriptions-item>
+      <el-descriptions-item label="创建时间" :span="3">{{ detail.CreatedAt || '-' }}</el-descriptions-item>
+      <el-descriptions-item label="更新时间" :span="3">{{ detail.UpdatedAt || '-' }}</el-descriptions-item>
     </el-descriptions>
 
     <div v-if="!detail.ID && !loading" style="text-align: center; color: #999; margin-top: 40px;">
@@ -80,4 +99,23 @@ watch(
   display: flex;
   gap: 10px;
 }
+
+.space-value {
+  color: #16805b;
+  font-weight: 600;
+}
+
+.space-value.occupied {
+  color: #b26a00;
+}
+
+.space-value.full {
+  color: #c03639;
+}
+
+.policy-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
 </style>

+ 365 - 14
frontend/src/view/report/sheet.vue

@@ -1,29 +1,380 @@
 <template>
   <div>
-    <el-form inline>
-      <el-form-item :label="$t.value.licensePlate">
-        <el-input />
+    <el-row :gutter="16" class="summary-row">
+      <el-col :span="6">
+        <el-statistic title="待处理" :value="stats.pending" />
+      </el-col>
+      <el-col :span="6">
+        <el-statistic title="处理中" :value="stats.processing" />
+      </el-col>
+      <el-col :span="6">
+        <el-statistic title="今日新增" :value="stats.today_created" />
+      </el-col>
+      <el-col :span="6">
+        <el-statistic title="今日强制免费" :value="stats.today_force_free_amount" prefix="¥" :precision="2" />
+      </el-col>
+    </el-row>
+
+    <el-form inline style="margin-top:16px">
+      <el-form-item label="状态">
+        <el-select v-model="queryForm.status" clearable placeholder="全部" style="width:120px">
+          <el-option v-for="(label, code) in statusMap" :key="code" :label="label" :value="code" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="类型">
+        <el-select v-model="queryForm.category" clearable placeholder="全部" style="width:150px">
+          <el-option v-for="(item, code) in categoryMap" :key="code" :label="item.label" :value="code" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="等级">
+        <el-select v-model="queryForm.level" clearable placeholder="全部" style="width:110px">
+          <el-option v-for="(item, code) in levelMap" :key="code" :label="item.label" :value="code" />
+        </el-select>
+      </el-form-item>
+      <el-form-item :label="$t.value.LicensePlateNo">
+        <el-input v-model="queryForm.plate_number" :placeholder="$t.value.carPlateInput" clearable style="width:130px" />
+      </el-form-item>
+      <el-form-item label="票号">
+        <el-input v-model="queryForm.ticket_no" placeholder="数字票号" clearable style="width:130px" />
+      </el-form-item>
+      <el-form-item label="停车场">
+        <el-select v-model="queryForm.parking_lot_id" clearable placeholder="全部" style="width:150px">
+          <el-option v-for="lot in lotList" :key="lot.ID" :label="lot.lot_name" :value="lot.ID" />
+        </el-select>
+      </el-form-item>
+      <el-form-item :label="$t.value.EntryTime">
+        <el-date-picker v-model="dateRange" type="daterange" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DD" />
       </el-form-item>
       <el-form-item>
-        <el-button type="primary">{{ $t.value.Search }}</el-button>
+        <el-button type="primary" @click="fetchList">{{ $t.value.Search }}</el-button>
+        <el-button @click="resetQuery">{{ $t.value.Reset }}</el-button>
+        <el-button type="success" plain @click="createDialogVisible = true">上报异常</el-button>
       </el-form-item>
     </el-form>
-    <el-table>
-      <el-table-column :label="$t.value.licensePlate" />
-      <el-table-column :label="$t.value.ExitTime" />
-      <el-table-column :label="$t.value.ExitTime" />
-      <el-table-column :label="$t.value.ExitBooth" />
-      <el-table-column :label="$t.value.ExitPassage" />
-      <el-table-column :label="$t.value.vehicleNumber" />
-      <el-table-column :label="$t.value.ownerName" />
+
+    <el-table :data="list" height="560" border>
+      <el-table-column label="编号" prop="incident_no" align="center" width="180" />
+      <el-table-column label="类型" align="center" width="120">
+        <template #default="{ row }">
+          <el-tag :type="categoryMap[row.category]?.tag || 'info'">{{ categoryName(row.category) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="等级" align="center" width="90">
+        <template #default="{ row }">
+          <el-tag :type="levelMap[row.level]?.tag || 'info'" size="small">{{ levelName(row.level) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" width="90">
+        <template #default="{ row }">
+          <el-tag :type="statusTag(row.status)">{{ statusName(row.status) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column :label="$t.value.licensePlate" align="center" width="110">
+        <template #default="{ row }">{{ row.plate_number || row.rfid_tag || '-' }}</template>
+      </el-table-column>
+      <el-table-column label="停车场" prop="lot_name" align="center" width="110">
+        <template #default="{ row }">{{ row.lot_name || '-' }}</template>
+      </el-table-column>
+      <el-table-column label="通道/设备" align="center" width="130">
+        <template #default="{ row }">{{ row.channel_code || row.device_code || '-' }}</template>
+      </el-table-column>
+      <el-table-column label="描述" prop="description" min-width="180" show-overflow-tooltip />
+      <el-table-column label="上报时间" align="center" width="170">
+        <template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
+      </el-table-column>
+      <el-table-column label="上报人" prop="operator_name" align="center" width="100">
+        <template #default="{ row }">{{ row.operator_name || '-' }}</template>
+      </el-table-column>
+      <el-table-column label="处理人" prop="handler_name" align="center" width="100">
+        <template #default="{ row }">{{ row.handler_name || '-' }}</template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="130" fixed="right">
+        <template #default="{ row }">
+          <el-button type="primary" link @click="openDetail(row)">详情</el-button>
+          <el-button v-if="canHandle" type="warning" link @click="openHandle(row)">处理</el-button>
+        </template>
+      </el-table-column>
     </el-table>
+
+    <el-pagination
+      v-model:current-page="queryForm.page"
+      :page-size="queryForm.page_size"
+      :total="total"
+      layout="total, prev, pager, next"
+      style="margin-top:12px; justify-content:flex-end"
+      @current-change="fetchList"
+    />
+
+    <!-- 人工上报对话框 -->
+    <el-dialog v-model="createDialogVisible" title="上报异常" width="560px">
+      <el-form :model="createForm" label-width="90px">
+        <el-form-item label="类型" required>
+          <el-select v-model="createForm.category" placeholder="选择异常类型" style="width:100%">
+            <el-option v-for="(item, code) in categoryMap" :key="code" :label="item.label" :value="code" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="车牌">
+          <el-input v-model="createForm.plate_number" placeholder="可选" />
+        </el-form-item>
+        <el-form-item label="票号">
+          <el-input v-model="createForm.ticket_no" placeholder="可选" />
+        </el-form-item>
+        <el-form-item label="停车会话ID">
+          <el-input-number v-model="createForm.vehicle_record_id" :min="0" controls-position="right" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="数字票ID">
+          <el-input-number v-model="createForm.digital_ticket_id" :min="0" controls-position="right" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="支付流水ID">
+          <el-input-number v-model="createForm.payment_record_id" :min="0" controls-position="right" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="通道ID">
+          <el-input-number v-model="createForm.channel_id" :min="0" controls-position="right" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="设备编码">
+          <el-input v-model="createForm.device_code" placeholder="可选" />
+        </el-form-item>
+        <el-form-item label="描述" required>
+          <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="异常情况说明" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="createDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="doCreate">提交</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 详情抽屉 -->
+    <el-drawer v-model="detailVisible" :title="detail.incident_no ? `异常详情 ${detail.incident_no}` : '异常详情'" size="520px">
+      <el-descriptions :column="2" border v-if="detail.id">
+        <el-descriptions-item label="类型">{{ categoryName(detail.category) }}</el-descriptions-item>
+        <el-descriptions-item label="等级">{{ levelName(detail.level) }}</el-descriptions-item>
+        <el-descriptions-item label="状态">{{ statusName(detail.status) }}</el-descriptions-item>
+        <el-descriptions-item label="来源">{{ sourceName(detail.source) }}</el-descriptions-item>
+        <el-descriptions-item label="车牌">{{ detail.plate_number || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="票号">{{ detail.ticket_no || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="停车会话">{{ detail.vehicle_record_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="数字票">{{ detail.digital_ticket_id || detail.ticket_no || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="支付流水">{{ detail.payment_record_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="闸机指令">{{ detail.gate_command_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="停车场">{{ detail.parking_lot_name || detail.parking_lot_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="通道">{{ detail.channel_name || detail.channel_code || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="设备">{{ detail.device_name || detail.device_code || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="上报人">{{ detail.operator_name || detail.operator_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="处理人">{{ detail.handler_name || detail.handler_id || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="处置方式">{{ handleName(detail.handle_type) }}</el-descriptions-item>
+        <el-descriptions-item label="处置金额">
+          {{ detail.handle_type === 'force_free' ? '¥' + (detail.force_free_amount || 0).toFixed(2) : '-' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="上报时间" :span="2">{{ fmtTime(detail.created_at) }}</el-descriptions-item>
+        <el-descriptions-item label="处置备注" :span="2">{{ detail.handle_remark || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="描述" :span="2">{{ detail.description }}</el-descriptions-item>
+        <el-descriptions-item label="上下文" :span="2">
+          <el-input :model-value="detail.detail" type="textarea" :rows="3" readonly />
+        </el-descriptions-item>
+      </el-descriptions>
+      <el-divider content-position="left">流转记录</el-divider>
+      <el-timeline v-if="timeline.length">
+        <el-timeline-item v-for="(evt, idx) in timeline" :key="idx" :timestamp="evt.at" placement="top">
+          {{ eventText(evt) }}
+        </el-timeline-item>
+      </el-timeline>
+      <el-empty v-else description="暂无流转记录" :image-size="60" />
+    </el-drawer>
+
+    <!-- 处置对话框 -->
+    <el-dialog v-model="handleDialogVisible" :title="`处理异常 ${detail.incident_no || ''}`" width="520px">
+      <el-form :model="handleForm" label-width="90px">
+        <el-form-item label="目标状态">
+          <el-select v-model="handleForm.to_status" style="width:100%">
+            <el-option v-for="(label, code) in handleTargets" :key="code" :label="label" :value="code" />
+          </el-select>
+        </el-form-item>
+        <el-form-item v-if="handleForm.to_status === 'resolved'" label="处置方式" required>
+          <el-select v-model="handleForm.handle_type" style="width:100%">
+            <el-option v-for="(item, code) in handleTypeMap" :key="code" :label="item" :value="code" />
+          </el-select>
+        </el-form-item>
+        <el-form-item v-if="handleForm.handle_type === 'force_free'" label="强制免费金额" required>
+          <el-input-number v-model="handleForm.force_free_amount" :min="0" :precision="2" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="备注" required>
+          <el-input v-model="handleForm.handle_remark" type="textarea" :rows="3" placeholder="处置说明(必填)" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="handleDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="doHandle">提交处置</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { createIncident, getIncidentDetail, getIncidentList, getIncidentStats, transitionIncident } from '@/api/incident'
+import { obtainPullOverList } from '@/api/pullOver'
+import { useUserStore } from '@/pinia/modules/user'
 
-</script>
+const userStore = useUserStore()
+
+const statusMap = { pending: '待处理', processing: '处理中', resolved: '已解决', closed: '已关闭' }
+const categoryMap = {
+  gate_failed: { label: '开闸失败', tag: 'danger' },
+  duplicate_entry: { label: '重复入场', tag: 'warning' },
+  blacklist: { label: '黑名单拦截', tag: 'warning' },
+  lot_full: { label: '满位拒绝', tag: 'info' },
+  no_entry_exit: { label: '无入场记录出场', tag: 'danger' },
+  manual_raise: { label: '人工抬杆', tag: 'warning' },
+  device_offline: { label: '设备离线', tag: 'warning' },
+  print_failed: { label: '打印失败', tag: 'warning' },
+  payment_failed: { label: '支付失败', tag: 'warning' },
+  payment_uncertain: { label: '支付状态不确定', tag: 'danger' }
+}
+const levelMap = { info: { label: '提示', tag: 'info' }, warning: { label: '警告', tag: 'warning' }, critical: { label: '严重', tag: 'danger' } }
+const sourceMap = { passage: '通行链路', manual: '人工上报', device: '设备', payment: '支付', printer: '打印', system: '系统' }
+const handleTypeMap = {
+  manual_gate: '人工抬杆放行',
+  force_free: '强制免费',
+  reset: '状态重置/修正',
+  device_repaired: '设备修复',
+  reprint: '重新打印',
+  ignore: '误报忽略',
+  other: '其他'
+}
 
-<style scoped lang="scss">
+const queryForm = reactive({ status: '', category: '', level: '', plate_number: '', ticket_no: '', parking_lot_id: null, page: 1, page_size: 10, start_date: '', end_date: '' })
+const dateRange = ref([])
+const list = ref([])
+const total = ref(0)
+const lotList = ref([])
+const stats = reactive({ pending: 0, processing: 0, today_created: 0, today_force_free_amount: 0 })
+
+const detail = ref({})
+const timeline = ref([])
+const detailVisible = ref(false)
+const createDialogVisible = ref(false)
+const createForm = reactive({ category: '', plate_number: '', ticket_no: '', vehicle_record_id: 0, digital_ticket_id: 0, payment_record_id: 0, gate_command_id: 0, parking_lot_id: 0, channel_id: 0, device_code: '', description: '' })
+
+const handleDialogVisible = ref(false)
+const handleForm = reactive({ to_status: 'processing', handle_type: '', force_free_amount: 0, handle_remark: '' })
+
+// 当前角色:618 操作员只读;888/9527 可处置;强制免费仅 888(后端强制校验)
+const authorityId = computed(() => String(userStore.userInfo.authorityId || ''))
+const canHandle = computed(() => ['888', '9527'].includes(authorityId.value))
+const handleTargets = computed(() => {
+  const targets = { ...statusMap }
+  delete targets[detail.value.status]
+  return targets
+})
+
+onMounted(() => { fetchList(); fetchStats(); loadLotList() })
+
+function categoryName(code) { return categoryMap[code]?.label || code || '-' }
+function levelName(code) { return levelMap[code]?.label || code || '-' }
+function statusName(code) { return statusMap[code] || code || '-' }
+function sourceName(code) { return sourceMap[code] || code || '-' }
+function handleName(code) { return handleTypeMap[code] || code || '-' }
+function statusTag(status) {
+  return { pending: 'warning', processing: 'primary', resolved: 'success', closed: 'info' }[status] || 'info'
+}
+function fmtTime(t) {
+  if (!t) return '-'
+  return String(t).replace('T', ' ').slice(0, 19)
+}
+function eventText(evt) {
+  const parts = [statusMap[evt.event] || evt.event]
+  if (evt.handle_type) parts.push(handleName(evt.handle_type))
+  if (evt.force_free_amount !== undefined) parts.push('¥' + Number(evt.force_free_amount).toFixed(2))
+  if (evt.remark) parts.push(evt.remark)
+  return parts.join(' · ')
+}
+
+function loadLotList() {
+  obtainPullOverList({ page: 1, pageSize: 100 }).then(res => {
+    if (res.code === 0) lotList.value = res.data?.list || []
+  })
+}
+
+function fetchStats() {
+  getIncidentStats().then(res => {
+    if (res.code === 0) Object.assign(stats, res.data || {})
+  })
+}
+
+function fetchList() {
+  if (dateRange.value && dateRange.value.length === 2) {
+    queryForm.start_date = dateRange.value[0]
+    queryForm.end_date = dateRange.value[1]
+  } else {
+    queryForm.start_date = ''
+    queryForm.end_date = ''
+  }
+  getIncidentList(queryForm).then(res => {
+    if (res.code === 0) {
+      list.value = res.data.list || []
+      total.value = res.data.total || 0
+    }
+  })
+}
+
+function resetQuery() {
+  Object.assign(queryForm, { status: '', category: '', level: '', plate_number: '', ticket_no: '', parking_lot_id: null, page: 1 })
+  dateRange.value = []
+  fetchList()
+}
+
+function openDetail(row) {
+  getIncidentDetail(row.ID || row.id).then(res => {
+    if (res.code === 0) {
+      detail.value = res.data
+      try {
+        timeline.value = JSON.parse(res.data.event_log || '[]')
+      } catch (e) {
+        timeline.value = []
+      }
+      detailVisible.value = true
+    }
+  })
+}
+
+function openHandle(row) {
+  openDetail(row)
+  Object.assign(handleForm, { to_status: 'processing', handle_type: '', force_free_amount: 0, handle_remark: '' })
+  handleDialogVisible.value = true
+}
+
+function doCreate() {
+  if (!createForm.category || !createForm.description) {
+    ElMessage.warning('请填写类型和描述')
+    return
+  }
+  createIncident({ ...createForm }).then(res => {
+    if (res.code === 0) {
+      ElMessage.success('上报成功')
+      createDialogVisible.value = false
+      Object.assign(createForm, { category: '', plate_number: '', ticket_no: '', vehicle_record_id: 0, digital_ticket_id: 0, payment_record_id: 0, gate_command_id: 0, parking_lot_id: 0, channel_id: 0, device_code: '', description: '' })
+      fetchList(); fetchStats()
+    }
+  })
+}
+
+function doHandle() {
+  const id = detail.value.ID || detail.value.id
+  if (!id) return
+  transitionIncident(id, { ...handleForm }).then(res => {
+    if (res.code === 0) {
+      ElMessage.success('处置成功')
+      handleDialogVisible.value = false
+      detailVisible.value = false
+      fetchList(); fetchStats()
+    }
+  })
+}
+</script>
 
+<style scoped>
+.summary-row { margin-bottom: 16px; }
+.summary-row .el-statistic { background: #f5f7fa; padding: 16px; border-radius: 8px; }
 </style>

+ 72 - 4
internal/api/v1/parking/gate.go

@@ -5,11 +5,16 @@ import (
 
 	commonModel "wails-app/internal/model/common"
 	"wails-app/internal/model/common/response"
+	incidentService "wails-app/internal/modules/incident/service"
+	utils "wails-app/internal/pkg"
 	"wails-app/internal/service"
+	parkingService "wails-app/internal/service/parking"
 )
 
 var passageService = &service.ServiceGroupApp.ParkingServiceGroup.PassageService
 
+var incidentSvc = incidentService.NewIncidentService()
+
 // ListGateDevices 返回进出场工作台可用的道闸设备。
 func ListGateDevices(c *gin.Context) {
 	devices, err := passageService.ListGateDevices()
@@ -20,30 +25,93 @@ func ListGateDevices(c *gin.Context) {
 	response.OkWithData(devices, c)
 }
 
-// OpenGate 人工开闸。
+// OpenGate 人工开闸。可携带 reason(原因)、plate_number / session_id(关联上下文),
+// 每次操作生成可审计的异常记录(manual_raise 或 gate_failed)。
 func OpenGate(c *gin.Context) {
 	var req commonModel.GateControlRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	if err := passageService.OpenGateByDeviceCode(req.DeviceCode, req.ValidTime); err != nil {
+	operatorID := utils.GetUserID(c)
+	commandLogID, err := passageService.OpenGateWithContext(parkingService.GateCommandContext{
+		Source:     incidentService.SourceManual,
+		OperatorID: operatorID,
+		SessionID:  req.SessionID,
+	}, req.DeviceCode, req.ValidTime)
+	if err != nil {
+		incidentID := incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+			Category:        incidentService.CategoryGateFailed,
+			Source:          incidentService.SourceManual,
+			VehicleRecordID: req.SessionID,
+			PlateNumber:     req.PlateNumber,
+			DeviceCode:      req.DeviceCode,
+			GateCommandID:   commandLogID,
+			OperatorID:      operatorID,
+			Description:     "人工开闸失败",
+			Detail:          err.Error(),
+			HandleRemark:    req.Reason,
+		})
+		incidentSvc.LinkIncidentToCommand(commandLogID, incidentID)
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
+	incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+		Category:        incidentService.CategoryManualRaise,
+		Source:          incidentService.SourceManual,
+		VehicleRecordID: req.SessionID,
+		PlateNumber:     req.PlateNumber,
+		DeviceCode:      req.DeviceCode,
+		GateCommandID:   commandLogID,
+		OperatorID:      operatorID,
+		Description:     "人工开闸",
+		HandleType:      incidentService.HandleManualGate,
+		HandleRemark:    req.Reason,
+	})
 	response.OkWithDetailed(gin.H{"device_code": req.DeviceCode, "action": "open"}, "开闸成功", c)
 }
 
-// CloseGate 人工关闸。
+// CloseGate 人工关闸。同样生成可审计记录。
 func CloseGate(c *gin.Context) {
 	var req commonModel.GateControlRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	if err := passageService.CloseGateByDeviceCode(req.DeviceCode, req.ValidTime); err != nil {
+	operatorID := utils.GetUserID(c)
+	commandLogID, err := passageService.CloseGateWithContext(parkingService.GateCommandContext{
+		Source:     incidentService.SourceManual,
+		OperatorID: operatorID,
+		SessionID:  req.SessionID,
+	}, req.DeviceCode, req.ValidTime)
+	if err != nil {
+		incidentID := incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+			Category:        incidentService.CategoryGateFailed,
+			Source:          incidentService.SourceManual,
+			VehicleRecordID: req.SessionID,
+			PlateNumber:     req.PlateNumber,
+			DeviceCode:      req.DeviceCode,
+			GateCommandID:   commandLogID,
+			OperatorID:      operatorID,
+			Description:     "人工关闸失败",
+			Detail:          err.Error(),
+			HandleRemark:    req.Reason,
+		})
+		incidentSvc.LinkIncidentToCommand(commandLogID, incidentID)
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
+	incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+		Category:        incidentService.CategoryManualRaise,
+		Source:          incidentService.SourceManual,
+		VehicleRecordID: req.SessionID,
+		PlateNumber:     req.PlateNumber,
+		DeviceCode:      req.DeviceCode,
+		GateCommandID:   commandLogID,
+		OperatorID:      operatorID,
+		Description:     "人工关闸",
+		HandleType:      incidentService.HandleManualGate,
+		HandleRemark:    req.Reason,
+	})
 	response.OkWithDetailed(gin.H{"device_code": req.DeviceCode, "action": "close"}, "关闸成功", c)
 }

+ 1 - 0
internal/config/system.go

@@ -11,4 +11,5 @@ type System struct {
 	UseRedis      bool   `mapstructure:"use-redis" json:"use-redis" yaml:"use-redis"`                // 使用redis
 	UseMongo      bool   `mapstructure:"use-mongo" json:"use-mongo" yaml:"use-mongo"`                // 使用mongo
 	GateSimulator bool   `mapstructure:"gate-simulator" json:"gate-simulator" yaml:"gate-simulator"` // 仅用于无硬件联调的模拟道闸
+	DebugRoutes   bool   `mapstructure:"debug-routes" json:"debug-routes" yaml:"debug-routes"`       // 显式开启受管理员鉴权保护的调试路由
 }

+ 62 - 0
internal/dao/incident.go

@@ -0,0 +1,62 @@
+package dao
+
+import (
+	"time"
+	"wails-app/internal/global"
+)
+
+// IncidentRecord 异常事件记录(事件与工单合一)。
+// 自动埋点(通行失败、设备离线、人工抬杆)与人工上报共用一张表,
+// 处置过程(状态流转、处置方式、金额、处理人)随记录保存。
+type IncidentRecord struct {
+	global.GVA_MODEL
+	IncidentNo string `gorm:"size:32;uniqueIndex" json:"incident_no"`
+	Category   string `gorm:"size:32;index" json:"category"`
+	Source     string `gorm:"size:20" json:"source"`
+	Level      string `gorm:"size:20" json:"level"`
+	Status     string `gorm:"size:20;default:pending;index;index:idx_incident_device_status,priority:2" json:"status"`
+	// VehicleRecordID 是停车会话(历史表名 vehicle_record)的关联 ID。
+	VehicleRecordID uint       `gorm:"index" json:"vehicle_record_id"`
+	DigitalTicketID uint       `gorm:"index" json:"digital_ticket_id"`
+	PaymentRecordID uint       `gorm:"index" json:"payment_record_id"`
+	GateCommandID   uint       `gorm:"index" json:"gate_command_id"`
+	TicketNo        string     `gorm:"size:64" json:"ticket_no"`
+	PlateNumber     string     `gorm:"size:20" json:"plate_number"`
+	RFIDTag         string     `gorm:"size:40" json:"rfid_tag"`
+	ParkingLotID    uint       `json:"parking_lot_id"`
+	ParkingLotName  string     `gorm:"size:100" json:"parking_lot_name"`
+	ChannelID       uint       `json:"channel_id"`
+	ChannelCode     string     `gorm:"size:32" json:"channel_code"`
+	ChannelName     string     `gorm:"size:100" json:"channel_name"`
+	DeviceCode      string     `gorm:"size:32;index:idx_incident_device_status,priority:1" json:"device_code"`
+	DeviceName      string     `gorm:"size:100" json:"device_name"`
+	OperatorID      uint       `json:"operator_id"`
+	Description     string     `gorm:"size:500" json:"description"`
+	Detail          string     `gorm:"type:text" json:"detail"`
+	HandlerID       uint       `json:"handler_id"`
+	HandledAt       *time.Time `json:"handled_at"`
+	HandleType      string     `gorm:"size:32" json:"handle_type"`
+	HandleRemark    string     `gorm:"size:500" json:"handle_remark"`
+	ForceFreeAmount float64    `json:"force_free_amount"`
+	EventLog        string     `gorm:"type:text" json:"event_log"`
+}
+
+func (IncidentRecord) TableName() string { return "incident_record" }
+
+// DeviceCommandLog 道闸指令流水:每次开闸/关闸命令的审计记录。
+// 开闸失败时通过 incident_id 关联对应的异常事件。
+type DeviceCommandLog struct {
+	global.GVA_MODEL
+	DeviceCode   string `gorm:"size:32;index" json:"device_code"`
+	DeviceName   string `gorm:"size:64" json:"device_name"`
+	Action       string `gorm:"size:10" json:"action"` // open / close
+	Source       string `gorm:"size:20" json:"source"` // passage / manual / test
+	OperatorID   uint   `json:"operator_id"`
+	SessionID    uint   `json:"session_id"`
+	IncidentID   uint   `json:"incident_id"`
+	Result       string `gorm:"size:20" json:"result"` // success / failed
+	ErrorMessage string `gorm:"size:500" json:"error_message"`
+	DurationMs   int64  `json:"duration_ms"`
+}
+
+func (DeviceCommandLog) TableName() string { return "device_command_log" }

+ 12 - 8
internal/dao/parking_lot.go

@@ -5,14 +5,18 @@ import "wails-app/internal/global"
 // ParkingLot 停车区域
 type ParkingLot struct {
 	global.GVA_MODEL
-	LotCode     string      `gorm:"size:50;not null;uniqueIndex" json:"lot_code"` // 区域编码
-	LotName     string      `gorm:"size:100;not null" json:"lot_name"`            // 区域名称
-	Capacity    int         `gorm:"not null" json:"capacity"`                     // 车位数量
-	Available   int         `gorm:"not null" json:"available"`                    // 可用车位
-	Description string      `gorm:"size:200" json:"description"`                  // 备注
-	Booths      []Booth     `gorm:"foreignKey:ParkingLotID" json:"booths"`        // 岗亭列表(一对多)
-	Channels    []Channel   `gorm:"foreignKey:ParkingLotID" json:"channels"`      // 通道列表(一对多)
-	UHFReaders  []UHFReader `gorm:"foreignKey:ParkingLotID" json:"uhf_readers"`   // UHF读写器列表(一对多)
+	LotCode                string      `gorm:"size:50;not null;uniqueIndex" json:"lot_code"`                                             // 区域编码
+	LotName                string      `gorm:"size:100;not null" json:"lot_name"`                                                        // 区域名称
+	Capacity               int         `gorm:"not null" json:"capacity"`                                                                 // 车位数量
+	Occupied               int         `gorm:"not null;default:0" json:"occupied"`                                                       // 当前占用车位
+	Available              int         `gorm:"not null" json:"available"`                                                                // 可用车位
+	AllowTemporaryWhenFull bool        `gorm:"column:allow_temporary_when_full;not null;default:false" json:"allow_temporary_when_full"` // 满位时允许临时车入场
+	AllowMonthlyWhenFull   bool        `gorm:"column:allow_monthly_when_full;not null;default:true" json:"allow_monthly_when_full"`      // 满位时允许有效月租车入场
+	AllowVIPWhenFull       bool        `gorm:"column:allow_vip_when_full;not null;default:true" json:"allow_vip_when_full"`              // 满位时允许有效 VIP 车入场
+	Description            string      `gorm:"size:200" json:"description"`                                                              // 备注
+	Booths                 []Booth     `gorm:"foreignKey:ParkingLotID" json:"booths"`                                                    // 岗亭列表(一对多)
+	Channels               []Channel   `gorm:"foreignKey:ParkingLotID" json:"channels"`                                                  // 通道列表(一对多)
+	UHFReaders             []UHFReader `gorm:"foreignKey:ParkingLotID" json:"uhf_readers"`                                               // UHF读写器列表(一对多)
 }
 
 func (ParkingLot) TableName() string {

+ 34 - 0
internal/initialize/gorm.go

@@ -66,11 +66,16 @@ func RegisterTables() {
 		dao.DigitalTicket{},
 		dao.ShiftRecord{},
 		dao.Printer{},
+		dao.IncidentRecord{},
+		dao.DeviceCommandLog{},
 	)
 	if err != nil {
 		global.GVA_LOG.Error("register table failed", zap.Error(err))
 		os.Exit(0)
 	}
+	if err := reconcileParkingLotOccupancy(db); err != nil {
+		global.GVA_LOG.Error("reconcile parking lot occupancy failed", zap.Error(err))
+	}
 	// 操作日志复合索引:created_at 位于 GVA_MODEL 内嵌字段(多表共用,不能加 tag),故显式创建
 	// 覆盖高频查询:按用户 + 时间筛选操作日志
 	if err := db.Exec("CREATE INDEX IF NOT EXISTS idx_op_user_time ON sys_operation_records (user_id, created_at)").Error; err != nil {
@@ -78,3 +83,32 @@ func RegisterTables() {
 	}
 	global.GVA_LOG.Info("register table success")
 }
+
+// reconcileParkingLotOccupancy 在服务启动时以未出场停车会话为准校准占用数和余位。
+func reconcileParkingLotOccupancy(db *gorm.DB) error {
+	return db.Transaction(func(tx *gorm.DB) error {
+		var lots []dao.ParkingLot
+		if err := tx.Find(&lots).Error; err != nil {
+			return err
+		}
+		for _, lot := range lots {
+			var occupied int64
+			if err := tx.Model(&dao.VehicleRecord{}).
+				Where("parking_lot_id = ? AND exit_time IS NULL", lot.ID).
+				Count(&occupied).Error; err != nil {
+				return err
+			}
+			available := int64(lot.Capacity) - occupied
+			if available < 0 {
+				available = 0
+			}
+			if err := tx.Model(&lot).UpdateColumns(map[string]interface{}{
+				"occupied":  occupied,
+				"available": available,
+			}).Error; err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}

+ 60 - 0
internal/initialize/incident_seed.go

@@ -0,0 +1,60 @@
+package initialize
+
+import (
+	"errors"
+	"fmt"
+
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+// EnsureIncidentPermissions upgrades both new and existing databases with the
+// API catalogue and Casbin policies required by the incident handling module.
+// 查看/上报对 618/888/9527 开放;处置(状态流转)仅 888/9527。
+func EnsureIncidentPermissions() error {
+	db := global.GVA_DB
+	if db == nil {
+		return nil
+	}
+
+	apis := []dao.SysApi{
+		{Path: "/incident/list", Description: "异常记录列表", ApiGroup: "异常处置", Method: "GET"},
+		{Path: "/incident/stats", Description: "异常统计", ApiGroup: "异常处置", Method: "GET"},
+		{Path: "/incident/:id", Description: "异常记录详情", ApiGroup: "异常处置", Method: "GET"},
+		{Path: "/incident", Description: "上报异常", ApiGroup: "异常处置", Method: "POST"},
+		{Path: "/incident/:id/transition", Description: "异常状态流转与处置", ApiGroup: "异常处置", Method: "POST"},
+	}
+	for _, api := range apis {
+		var existing dao.SysApi
+		err := db.Where("path = ? AND method = ?", api.Path, api.Method).First(&existing).Error
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			err = db.Create(&api).Error
+		}
+		if err != nil {
+			return fmt.Errorf("初始化异常处置API %s %s 失败: %w", api.Method, api.Path, err)
+		}
+	}
+
+	// 查看与上报:全部角色
+	for _, role := range []string{"618", "888", "9527"} {
+		for _, rule := range [][2]string{
+			{"/incident/list", "GET"},
+			{"/incident/stats", "GET"},
+			{"/incident/:id", "GET"},
+			{"/incident", "POST"},
+		} {
+			if err := ensureCasbinRule(role, rule[0], rule[1]); err != nil {
+				return fmt.Errorf("初始化角色%s异常查看权限失败: %w", role, err)
+			}
+		}
+	}
+
+	// 处置:仅管理员与超级管理员(资金类处置另有业务层校验)
+	for _, role := range []string{"888", "9527"} {
+		if err := ensureCasbinRule(role, "/incident/:id/transition", "POST"); err != nil {
+			return fmt.Errorf("初始化角色%s异常处置权限失败: %w", role, err)
+		}
+	}
+	return nil
+}

+ 50 - 0
internal/initialize/operation_permission_seed.go

@@ -25,6 +25,7 @@ func EnsureOperationPermissions() error {
 		{Path: "/parking/gate/devices", Description: "查询道闸设备", ApiGroup: "进出场操作", Method: "GET"},
 		{Path: "/parking/gate/open", Description: "人工开闸", ApiGroup: "进出场操作", Method: "POST"},
 		{Path: "/parking/gate/close", Description: "人工关闸", ApiGroup: "进出场操作", Method: "POST"},
+		{Path: "/ticket-machine/button", Description: "票机按钮入场", ApiGroup: "进出场操作", Method: "POST"},
 	}
 	for _, api := range apis {
 		var existing dao.SysApi
@@ -53,11 +54,60 @@ func EnsureOperationPermissions() error {
 			{"/parking/gate/devices", "GET"},
 			{"/parking/gate/open", "POST"},
 			{"/parking/gate/close", "POST"},
+			{"/ticket-machine/button", "POST"},
 		} {
 			if err := ensureCasbinRule(role, rule[0], rule[1]); err != nil {
 				return fmt.Errorf("初始化角色%s进出场权限失败: %w", role, err)
 			}
 		}
 	}
+
+	debugAPIs := []dao.SysApi{
+		{Path: "/ticket-machine/test", Description: "票机业务链路测试", ApiGroup: "开发调试", Method: "POST"},
+		{Path: "/ticket-machine/debug", Description: "票机原始打印测试", ApiGroup: "开发调试", Method: "POST"},
+		{Path: "/ticket-machine/usb-list", Description: "枚举票机设备", ApiGroup: "开发调试", Method: "GET"},
+		{Path: "/channel/test-event", Description: "模拟通道事件", ApiGroup: "开发调试", Method: "POST"},
+	}
+	for _, api := range debugAPIs {
+		var existing dao.SysApi
+		err := db.Where("path = ? AND method = ?", api.Path, api.Method).First(&existing).Error
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			err = db.Create(&api).Error
+		}
+		if err != nil {
+			return fmt.Errorf("初始化调试API %s %s 失败: %w", api.Method, api.Path, err)
+		}
+	}
+	for _, role := range []string{"888", "9527"} {
+		for _, rule := range [][2]string{
+			{"/ticket-machine/test", "POST"},
+			{"/ticket-machine/debug", "POST"},
+			{"/ticket-machine/usb-list", "GET"},
+			{"/channel/test-event", "POST"},
+		} {
+			if err := ensureCasbinRule(role, rule[0], rule[1]); err != nil {
+				return fmt.Errorf("初始化角色%s调试权限失败: %w", role, err)
+			}
+		}
+	}
+
+	legacyDebugRoutes := [][2]string{
+		{"/ticket-machine/test", "GET"},
+		{"/ticket-machine/debug", "GET"},
+		{"/channel/test-event", "GET"},
+	}
+	for _, route := range legacyDebugRoutes {
+		if err := db.Unscoped().Where("path = ? AND method = ?", route[0], route[1]).Delete(&dao.SysApi{}).Error; err != nil {
+			return fmt.Errorf("清理旧调试API %s %s 失败: %w", route[1], route[0], err)
+		}
+		if err := db.Exec("DELETE FROM casbin_rule WHERE v1 = ? AND v2 = ?", route[0], route[1]).Error; err != nil {
+			return fmt.Errorf("清理旧调试权限 %s %s 失败: %w", route[1], route[0], err)
+		}
+	}
+	for _, api := range debugAPIs {
+		if err := db.Exec("DELETE FROM casbin_rule WHERE v0 = ? AND v1 = ? AND v2 = ?", "618", api.Path, api.Method).Error; err != nil {
+			return fmt.Errorf("撤销操作员调试权限 %s %s 失败: %w", api.Method, api.Path, err)
+		}
+	}
 	return nil
 }

+ 50 - 0
internal/initialize/operation_permission_seed_test.go

@@ -16,6 +16,21 @@ func TestEnsureOperationPermissionsIsIdempotent(t *testing.T) {
 	require.NoError(t, db.AutoMigrate(&dao.SysApi{}))
 	global.GVA_DB = db
 	t.Cleanup(func() { global.GVA_DB = nil })
+	require.NoError(t, db.Create(&dao.SysApi{
+		Path: "/ticket-machine/test", Description: "旧票机测试", ApiGroup: "开发调试", Method: "GET",
+	}).Error)
+	require.NoError(t, db.Exec(`CREATE TABLE casbin_rule (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		ptype TEXT, v0 TEXT, v1 TEXT, v2 TEXT, v3 TEXT, v4 TEXT, v5 TEXT
+	)`).Error)
+	require.NoError(t, db.Exec(
+		"INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES (?, ?, ?, ?)",
+		"p", "618", "/ticket-machine/test", "GET",
+	).Error)
+	require.NoError(t, db.Exec(
+		"INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES (?, ?, ?, ?)",
+		"p", "618", "/channel/test-event", "POST",
+	).Error)
 
 	require.NoError(t, EnsureOperationPermissions())
 	require.NoError(t, EnsureOperationPermissions())
@@ -28,6 +43,7 @@ func TestEnsureOperationPermissionsIsIdempotent(t *testing.T) {
 		{"/parking/gate/devices", "GET"},
 		{"/parking/gate/open", "POST"},
 		{"/parking/gate/close", "POST"},
+		{"/ticket-machine/button", "POST"},
 	}
 	for _, route := range routes {
 		var apiCount int64
@@ -41,4 +57,38 @@ func TestEnsureOperationPermissionsIsIdempotent(t *testing.T) {
 			Count(&ruleCount).Error)
 		require.EqualValues(t, 3, ruleCount, route)
 	}
+
+	debugRoutes := [][2]string{
+		{"/ticket-machine/test", "POST"},
+		{"/ticket-machine/debug", "POST"},
+		{"/ticket-machine/usb-list", "GET"},
+		{"/channel/test-event", "POST"},
+	}
+	for _, route := range debugRoutes {
+		var apiCount int64
+		require.NoError(t, db.Model(&dao.SysApi{}).
+			Where("path = ? AND method = ?", route[0], route[1]).Count(&apiCount).Error)
+		require.EqualValues(t, 1, apiCount, route)
+
+		var adminRuleCount int64
+		require.NoError(t, db.Table("casbin_rule").
+			Where("v0 IN ? AND v1 = ? AND v2 = ?", []string{"888", "9527"}, route[0], route[1]).
+			Count(&adminRuleCount).Error)
+		require.EqualValues(t, 2, adminRuleCount, route)
+
+		var operatorRuleCount int64
+		require.NoError(t, db.Table("casbin_rule").
+			Where("v0 = ? AND v1 = ? AND v2 = ?", "618", route[0], route[1]).
+			Count(&operatorRuleCount).Error)
+		require.Zero(t, operatorRuleCount, route)
+	}
+
+	var legacyAPICount int64
+	require.NoError(t, db.Unscoped().Model(&dao.SysApi{}).
+		Where("path = ? AND method = ?", "/ticket-machine/test", "GET").Count(&legacyAPICount).Error)
+	require.Zero(t, legacyAPICount)
+	var legacyRuleCount int64
+	require.NoError(t, db.Table("casbin_rule").
+		Where("v1 = ? AND v2 = ?", "/ticket-machine/test", "GET").Count(&legacyRuleCount).Error)
+	require.Zero(t, legacyRuleCount)
 }

+ 11 - 6
internal/initialize/router.go

@@ -6,14 +6,15 @@ import (
 	"wails-app/internal/docs"
 	"wails-app/internal/global"
 	"wails-app/internal/middleware"
-	"wails-app/internal/router"
-	"wails-app/internal/router/owner"
 	"wails-app/internal/modules/digital-ticket"
+	"wails-app/internal/modules/incident"
 	"wails-app/internal/modules/monthly"
-	"wails-app/internal/modules/report"
-	"wails-app/internal/modules/shift"
 	"wails-app/internal/modules/payment"
 	"wails-app/internal/modules/printer"
+	"wails-app/internal/modules/report"
+	"wails-app/internal/modules/shift"
+	"wails-app/internal/router"
+	"wails-app/internal/router/owner"
 	"wails-app/internal/router/parking"
 	"wails-app/internal/router/vehicle"
 
@@ -81,9 +82,8 @@ func Routers() *gin.Engine {
 		})
 	}
 	{
-		systemRouter.InitBaseRouter(PublicGroup)    // 注册基础功能路由 不做鉴权
+		systemRouter.InitBaseRouter(PublicGroup)         // 注册基础功能路由 不做鉴权
 		dashboardRouter.InitDashboardRouter(PublicGroup) // 监控仪表盘 不做鉴权
-		printer.SetupPrinterPublic(PublicGroup)    // 票机测试路由 不做鉴权
 	}
 	PrivateGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix)
 	PrivateGroup.Use(middleware.JWTAuth()).Use(middleware.CasbinHandler()).Use(middleware.OperationRecord())
@@ -111,6 +111,7 @@ func Routers() *gin.Engine {
 
 		// 停车相关路由
 		parking.SetupParkingRouter(PrivateGroup)
+		parking.SetupParkingDebugRouter(PrivateGroup, global.GVA_CONFIG.System.DebugRoutes)
 
 		// 支付相关路由
 		payment.SetupPaymentRouter(PrivateGroup)
@@ -125,10 +126,14 @@ func Routers() *gin.Engine {
 		shift.SetupShiftRouter(PrivateGroup)
 		// 打印机路由
 		printer.SetupPrinterRouter(PrivateGroup)
+		printer.SetupPrinterDebugRoutes(PrivateGroup, global.GVA_CONFIG.System.DebugRoutes)
 
 		// 收入报表路由
 		report.SetupReportRouter(PrivateGroup)
 
+		// 异常处置路由
+		incident.SetupIncidentRouter(PrivateGroup)
+
 		// UHF读写器路由
 		//uhf.SetupUHFRouter(PrivateGroup)
 	}

+ 70 - 0
internal/initialize/router_security_test.go

@@ -0,0 +1,70 @@
+package initialize
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/zap"
+	"wails-app/internal/config"
+	"wails-app/internal/global"
+)
+
+func setupRouterSecurityTest(t *testing.T, debugRoutes bool) *gin.Engine {
+	t.Helper()
+	previousConfig := global.GVA_CONFIG
+	previousLog := global.GVA_LOG
+	t.Cleanup(func() {
+		global.GVA_CONFIG = previousConfig
+		global.GVA_LOG = previousLog
+	})
+	global.GVA_CONFIG = config.Server{}
+	global.GVA_CONFIG.System.DebugRoutes = debugRoutes
+	global.GVA_CONFIG.Local.StorePath = "/uploads"
+	global.GVA_LOG = zap.NewNop()
+	gin.SetMode(gin.TestMode)
+	return Routers()
+}
+
+func TestDebugRoutesAreNotRegisteredByDefault(t *testing.T) {
+	router := setupRouterSecurityTest(t, false)
+	routes := map[string]string{}
+	for _, route := range router.Routes() {
+		routes[route.Path] = route.Method
+	}
+	require.Equal(t, "POST", routes["/ticket-machine/button"])
+	require.NotContains(t, routes, "/ticket-machine/test")
+	require.NotContains(t, routes, "/ticket-machine/debug")
+	require.NotContains(t, routes, "/ticket-machine/usb-list")
+	require.NotContains(t, routes, "/channel/test-event")
+}
+
+func TestIncidentRoutesRegisteredAndRequireAuth(t *testing.T) {
+	router := setupRouterSecurityTest(t, false)
+	cases := []struct{ method, path string }{
+		{http.MethodGet, "/incident/list"},
+		{http.MethodGet, "/incident/stats"},
+		{http.MethodGet, "/incident/1"},
+		{http.MethodPost, "/incident"},
+		{http.MethodPost, "/incident/1/transition"},
+	}
+	for _, tc := range cases {
+		recorder := httptest.NewRecorder()
+		request := httptest.NewRequest(tc.method, tc.path, nil)
+		router.ServeHTTP(recorder, request)
+		// 未登录一律 401(说明路由存在且受 JWT 保护)
+		require.Equal(t, http.StatusUnauthorized, recorder.Code, "%s %s", tc.method, tc.path)
+	}
+}
+
+func TestEnabledDebugRoutesStillRequireAuthentication(t *testing.T) {
+	router := setupRouterSecurityTest(t, true)
+	recorder := httptest.NewRecorder()
+	request := httptest.NewRequest(http.MethodPost, "/channel/test-event?plate=TEST", nil)
+	router.ServeHTTP(recorder, request)
+
+	require.Equal(t, http.StatusUnauthorized, recorder.Code)
+	require.Contains(t, recorder.Body.String(), "未登录或非法访问")
+}

+ 6 - 0
internal/initialize/seed.go

@@ -26,6 +26,9 @@ func SeedSystemData() {
 		if err := EnsureOperationPermissions(); err != nil {
 			global.GVA_LOG.Error("operation permission upgrade failed")
 		}
+		if err := EnsureIncidentPermissions(); err != nil {
+			global.GVA_LOG.Error("incident permission upgrade failed")
+		}
 		return
 	}
 
@@ -536,6 +539,9 @@ func SeedSystemData() {
 	if err := EnsureOperationPermissions(); err != nil {
 		global.GVA_LOG.Error("operation permission seed failed")
 	}
+	if err := EnsureIncidentPermissions(); err != nil {
+		global.GVA_LOG.Error("incident permission seed failed")
+	}
 
 	global.GVA_LOG.Info("system data seeded successfully")
 }

+ 8 - 2
internal/model/common/passage.go

@@ -19,14 +19,20 @@ type PassageRequest struct {
 
 // GateControlRequest 人工开关闸请求。
 type GateControlRequest struct {
-	DeviceCode string `json:"device_code" binding:"required"`
-	ValidTime  byte   `json:"valid_time"`
+	DeviceCode  string `json:"device_code" binding:"required"`
+	ValidTime   byte   `json:"valid_time"`
+	Reason      string `json:"reason"`       // 人工抬杆/关闸原因(可选,用于异常留痕审计)
+	PlateNumber string `json:"plate_number"` // 关联车牌(可选)
+	SessionID   uint   `json:"session_id"`   // 关联停车会话(可选)
 }
 
 // PassageResult 进出场结果
 type PassageResult struct {
 	Success           bool    `json:"success"`
 	Message           string  `json:"message"`
+	SessionID         uint    `json:"session_id"`
+	TicketID          uint    `json:"ticket_id"`
+	TicketNo          string  `json:"ticket_no"`
 	Direction         string  `json:"direction"`    // in / out
 	PlateNumber       string  `json:"plate_number"` // 车牌号
 	RFIDTag           string  `json:"rfid_tag"`     // RFID 标签

+ 15 - 9
internal/model/parking/request/parking_lot.go

@@ -2,19 +2,25 @@ package request
 
 // ParkingLotCreate 停车区域创建请求
 type ParkingLotCreate struct {
-	LotCode     string `json:"lot_code"`                 // 区域编码
-	LotName     string `json:"lot_name"`                 // 区域名称
-	Capacity    int    `json:"capacity" binding:"min=0"` // 车位数量
-	Description string `json:"description"`              // 备注
+	LotCode                string `json:"lot_code"`                  // 区域编码
+	LotName                string `json:"lot_name"`                  // 区域名称
+	Capacity               int    `json:"capacity" binding:"min=0"`  // 车位数量
+	AllowTemporaryWhenFull bool   `json:"allow_temporary_when_full"` // 满位时允许临时车入场
+	AllowMonthlyWhenFull   bool   `json:"allow_monthly_when_full"`   // 满位时允许有效月租车入场
+	AllowVIPWhenFull       bool   `json:"allow_vip_when_full"`       // 满位时允许有效 VIP 车入场
+	Description            string `json:"description"`               // 备注
 }
 
 // ParkingLotUpdate 停车区域更新请求
 type ParkingLotUpdate struct {
-	ID          uint   `json:"id"`                       // 区域ID
-	LotCode     string `json:"lot_code"`                 // 区域编码
-	LotName     string `json:"lot_name"`                 // 区域名称
-	Capacity    int    `json:"capacity" binding:"min=0"` // 车位数量
-	Description string `json:"description"`              // 备注
+	ID                     uint   `json:"id"`                        // 区域ID
+	LotCode                string `json:"lot_code"`                  // 区域编码
+	LotName                string `json:"lot_name"`                  // 区域名称
+	Capacity               int    `json:"capacity" binding:"min=0"`  // 车位数量
+	AllowTemporaryWhenFull bool   `json:"allow_temporary_when_full"` // 满位时允许临时车入场
+	AllowMonthlyWhenFull   bool   `json:"allow_monthly_when_full"`   // 满位时允许有效月租车入场
+	AllowVIPWhenFull       bool   `json:"allow_vip_when_full"`       // 满位时允许有效 VIP 车入场
+	Description            string `json:"description"`               // 备注
 }
 
 // ParkingLotQuery 停车区域查询请求

+ 4 - 0
internal/model/vehicle/request/vehicle.go

@@ -56,6 +56,7 @@ type VehicleEntry struct {
 	EntryChannelName string `json:"entry_channel_name"`
 	EntryDeviceCode  string `json:"entry_device_code"`
 	EntryDeviceName  string `json:"entry_device_name"`
+	TriggerMode      string `json:"-"`
 }
 
 // VehicleIdentityRequest 用车牌或 RFID 定位当前车辆及停车会话。
@@ -75,6 +76,9 @@ type VehicleEntryResponse struct {
 	Success bool   `json:"success"`
 	Message string `json:"message"`
 	Data    struct {
+		SessionID        uint   `json:"session_id"`
+		TicketID         uint   `json:"ticket_id"`
+		TicketNo         string `json:"ticket_no"`
 		PlateNumber      string `json:"plate_number"`
 		EntryTime        int64  `json:"entry_time"`
 		ParkingLotID     uint   `json:"parking_lot_id"`

+ 102 - 0
internal/modules/incident/api.go

@@ -0,0 +1,102 @@
+package incident
+
+import (
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/modules/incident/repository"
+	"wails-app/internal/modules/incident/service"
+	utils "wails-app/internal/pkg"
+)
+
+var incidentSvc = service.NewIncidentService()
+
+// adminAuthorityId 管理员角色(888);处置权限见初始化种子中的 Casbin 规则。
+const adminAuthorityId = "888"
+
+// ListIncidents 分页筛选异常记录。
+func ListIncidents(c *gin.Context) {
+	var q repository.IncidentListQuery
+	if err := c.ShouldBindQuery(&q); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	list, total, err := incidentSvc.ListIncidents(q)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithDetailed(response.PageResult{List: list, Total: total, Page: q.Page, PageSize: q.PageSize}, "查询成功", c)
+}
+
+// GetIncident 异常详情。
+func GetIncident(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		response.FailWithMessage("异常记录 ID 无效", c)
+		return
+	}
+	record, err := incidentSvc.GetIncident(uint(id))
+	if err != nil {
+		response.FailWithMessage(service.ErrIncidentNotFound.Error(), c)
+		return
+	}
+	response.OkWithData(record, c)
+}
+
+// CreateIncident 人工上报异常。
+func CreateIncident(c *gin.Context) {
+	var req service.CreateIncidentRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	record, err := incidentSvc.CreateIncident(req, utils.GetUserID(c))
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(record, c)
+}
+
+// TransitionIncident 状态流转+处置。强制免费等资金类处置仅管理员(888)可执行。
+func TransitionIncident(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		response.FailWithMessage("异常记录 ID 无效", c)
+		return
+	}
+	var req service.TransitionRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	isAdmin := strconv.FormatUint(uint64(utils.GetUserAuthorityId(c)), 10) == adminAuthorityId
+	record, err := incidentSvc.TransitionIncident(uint(id), req, utils.GetUserID(c), isAdmin)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(record, c)
+}
+
+// GetIncidentStats 异常统计(页面头部汇总)。
+func GetIncidentStats(c *gin.Context) {
+	stats, err := incidentSvc.GetIncidentStats()
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(stats, c)
+}
+
+// SetupIncidentRouter 注册异常处置路由(挂在 PrivateGroup,自动带 JWT+Casbin+操作日志)。
+func SetupIncidentRouter(router *gin.RouterGroup) {
+	sr := router.Group("/incident")
+	sr.GET("/list", ListIncidents)
+	sr.GET("/stats", GetIncidentStats)
+	sr.GET("/:id", GetIncident)
+	sr.POST("", CreateIncident)
+	sr.POST("/:id/transition", TransitionIncident)
+}

+ 256 - 0
internal/modules/incident/repository/repo.go

@@ -0,0 +1,256 @@
+package repository
+
+import (
+	"encoding/json"
+	"time"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+
+	"gorm.io/gorm"
+)
+
+// appendEventLog 向事件日志 JSON 数组追加一条事件(沿用数字票 event_log 约定)。
+func appendEventLog(log, eventJSON string) string {
+	var events []map[string]interface{}
+	json.Unmarshal([]byte(log), &events)
+	var event map[string]interface{}
+	json.Unmarshal([]byte(eventJSON), &event)
+	events = append(events, event)
+	data, _ := json.Marshal(events)
+	return string(data)
+}
+
+type IncidentRepository struct{}
+
+// Create 创建异常记录。
+func (r *IncidentRepository) Create(record *dao.IncidentRecord) error {
+	return global.GVA_DB.Create(record).Error
+}
+
+// GetByID 查询异常详情(含软删除过滤)。
+func (r *IncidentRepository) GetByID(id uint) (*dao.IncidentRecord, error) {
+	var record dao.IncidentRecord
+	err := global.GVA_DB.First(&record, id).Error
+	if err != nil {
+		return nil, err
+	}
+	return &record, nil
+}
+
+// UpdateStateIfCurrent CAS 状态更新:仅当记录仍处于调用方观察到的状态时生效。
+func (r *IncidentRepository) UpdateStateIfCurrent(id uint, fromStatus, toStatus string, updates map[string]interface{}) (bool, error) {
+	if updates == nil {
+		updates = make(map[string]interface{})
+	}
+	updates["status"] = toStatus
+	result := global.GVA_DB.Model(&dao.IncidentRecord{}).
+		Where("id = ? AND status = ?", id, fromStatus).
+		Updates(updates)
+	return result.RowsAffected == 1, result.Error
+}
+
+// UpdateIncidentID 建立异常与指令流水的双向关联(开闸失败场景)。
+func (r *IncidentRepository) UpdateIncidentID(logID, incidentID uint) error {
+	if logID == 0 || incidentID == 0 {
+		return nil
+	}
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Model(&dao.DeviceCommandLog{}).
+			Where("id = ?", logID).Update("incident_id", incidentID).Error; err != nil {
+			return err
+		}
+		return tx.Model(&dao.IncidentRecord{}).
+			Where("id = ?", incidentID).Update("gate_command_id", logID).Error
+	})
+}
+
+// HasOpenDeviceOffline 同设备是否已有未关闭(待处理/处理中)的离线异常。
+func (r *IncidentRepository) HasOpenDeviceOffline(deviceCode string) (bool, error) {
+	if deviceCode == "" {
+		return false, nil
+	}
+	var count int64
+	err := global.GVA_DB.Model(&dao.IncidentRecord{}).
+		Where("device_code = ? AND category = ? AND status IN (?, ?)",
+			deviceCode, "device_offline", "pending", "processing").
+		Count(&count).Error
+	return count > 0, err
+}
+
+// ExistsRecent 通行类异常去重:同设备 + 同分类 + 同车辆标识,在时间窗口内不重复生成。
+// 无车辆标识(车牌和 RFID 均为空)时不去重。
+func (r *IncidentRepository) ExistsRecent(deviceCode, category, plateNumber, rfidTag string, minutes int) (bool, error) {
+	if plateNumber == "" && rfidTag == "" {
+		return false, nil
+	}
+	db := global.GVA_DB.Model(&dao.IncidentRecord{}).
+		Where("device_code = ? AND category = ? AND status IN (?, ?) AND created_at >= ?",
+			deviceCode, category, "pending", "processing", time.Now().Add(-time.Duration(minutes)*time.Minute))
+	if plateNumber != "" {
+		db = db.Where("plate_number = ?", plateNumber)
+	} else {
+		db = db.Where("rfid_tag = ?", rfidTag)
+	}
+	var count int64
+	err := db.Count(&count).Error
+	return count > 0, err
+}
+
+// CreateCommandLog 写入指令流水。
+func (r *IncidentRepository) CreateCommandLog(log *dao.DeviceCommandLog) error {
+	return global.GVA_DB.Create(log).Error
+}
+
+// ResolveDeviceOfflineByDevice 设备恢复在线时批量关闭该设备未处理的离线异常。
+func (r *IncidentRepository) ResolveDeviceOfflineByDevice(deviceCode, remark string) error {
+	if deviceCode == "" {
+		return nil
+	}
+	var open []dao.IncidentRecord
+	if err := global.GVA_DB.Where("device_code = ? AND category = ? AND status IN (?, ?)",
+		deviceCode, "device_offline", "pending", "processing").Find(&open).Error; err != nil {
+		return err
+	}
+	now := time.Now()
+	for i := range open {
+		event, _ := json.Marshal(map[string]interface{}{
+			"event": "resolved", "handle_type": "device_repaired", "remark": remark, "at": now.Format(time.RFC3339),
+		})
+		if err := global.GVA_DB.Model(&open[i]).Updates(map[string]interface{}{
+			"status": "resolved", "handle_type": "device_repaired",
+			"handled_at": now, "handle_remark": remark, "handler_id": 0,
+			"event_log": appendEventLog(open[i].EventLog, string(event)),
+		}).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+type IncidentListQuery struct {
+	Status       string `form:"status"`
+	Category     string `form:"category"`
+	Level        string `form:"level"`
+	Source       string `form:"source"`
+	PlateNumber  string `form:"plate_number"`
+	TicketNo     string `form:"ticket_no"`
+	ParkingLotID uint   `form:"parking_lot_id"`
+	DeviceCode   string `form:"device_code"`
+	StartDate    string `form:"start_date"`
+	EndDate      string `form:"end_date"`
+	Page         int    `form:"page"`
+	PageSize     int    `form:"page_size"`
+}
+
+type IncidentListItem struct {
+	dao.IncidentRecord
+	OperatorName string `json:"operator_name"`
+	HandlerName  string `json:"handler_name"`
+	LotName      string `json:"lot_name"`
+}
+
+// List 分页筛选异常记录(上报人/处理人昵称、停车场名称联查)。
+func (r *IncidentRepository) List(q IncidentListQuery) ([]IncidentListItem, int64, error) {
+	db := global.GVA_DB.Table("incident_record ir").
+		Select("ir.*, op.nick_name as operator_name, hd.nick_name as handler_name, COALESCE(ir.parking_lot_name, pl.lot_name) as lot_name").
+		Joins("LEFT JOIN sys_users op ON op.id = ir.operator_id").
+		Joins("LEFT JOIN sys_users hd ON hd.id = ir.handler_id").
+		Joins("LEFT JOIN parking_lot pl ON pl.id = ir.parking_lot_id")
+
+	if q.Status != "" {
+		db = db.Where("ir.status = ?", q.Status)
+	}
+	if q.Category != "" {
+		db = db.Where("ir.category = ?", q.Category)
+	}
+	if q.Level != "" {
+		db = db.Where("ir.level = ?", q.Level)
+	}
+	if q.Source != "" {
+		db = db.Where("ir.source = ?", q.Source)
+	}
+	if q.PlateNumber != "" {
+		db = db.Where("ir.plate_number LIKE ?", "%"+q.PlateNumber+"%")
+	}
+	if q.TicketNo != "" {
+		db = db.Where("ir.ticket_no LIKE ?", "%"+q.TicketNo+"%")
+	}
+	if q.ParkingLotID > 0 {
+		db = db.Where("ir.parking_lot_id = ?", q.ParkingLotID)
+	}
+	if q.DeviceCode != "" {
+		db = db.Where("ir.device_code LIKE ?", "%"+q.DeviceCode+"%")
+	}
+	if q.StartDate != "" {
+		db = db.Where("ir.created_at >= ?", q.StartDate+" 00:00:00")
+	}
+	if q.EndDate != "" {
+		db = db.Where("ir.created_at < ?", q.EndDate+" 23:59:59")
+	}
+
+	var total int64
+	db.Count(&total)
+
+	if q.Page <= 0 {
+		q.Page = 1
+	}
+	if q.PageSize <= 0 {
+		q.PageSize = 10
+	}
+	var list []IncidentListItem
+	err := db.Order("ir.id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Scan(&list).Error
+	return list, total, err
+}
+
+type IncidentStats struct {
+	Pending              int64   `json:"pending"`
+	Processing           int64   `json:"processing"`
+	Resolved             int64   `json:"resolved"`
+	Closed               int64   `json:"closed"`
+	TodayCreated         int64   `json:"today_created"`
+	OpenFundRelated      int64   `json:"open_fund_related"`
+	TodayForceFreeAmount float64 `json:"today_force_free_amount"`
+}
+
+// Stats 异常统计:状态分布、今日新增、未关闭资金类数量、今日强制免费金额。
+func (r *IncidentRepository) Stats() (IncidentStats, error) {
+	var stats IncidentStats
+	// 每次 Count 使用全新的查询构建器,避免 GORM Where 条件在复用实例上累积
+	base := func() *gorm.DB {
+		return global.GVA_DB.Model(&dao.IncidentRecord{})
+	}
+	if err := base().Where("status = ?", "pending").Count(&stats.Pending).Error; err != nil {
+		return stats, err
+	}
+	if err := base().Where("status = ?", "processing").Count(&stats.Processing).Error; err != nil {
+		return stats, err
+	}
+	if err := base().Where("status = ?", "resolved").Count(&stats.Resolved).Error; err != nil {
+		return stats, err
+	}
+	if err := base().Where("status = ?", "closed").Count(&stats.Closed).Error; err != nil {
+		return stats, err
+	}
+	if err := base().Where("created_at >= ?", startOfDay()).Count(&stats.TodayCreated).Error; err != nil {
+		return stats, err
+	}
+	// 资金相关分类(支付失败/不确定、开闸失败、无入场记录出场)中未关闭的数量
+	if err := base().Where("status IN (?, ?) AND category IN (?, ?, ?, ?)",
+		"pending", "processing",
+		"payment_failed", "payment_uncertain", "gate_failed", "no_entry_exit").
+		Count(&stats.OpenFundRelated).Error; err != nil {
+		return stats, err
+	}
+	// 今日强制免费金额合计
+	if err := global.GVA_DB.Model(&dao.IncidentRecord{}).
+		Where("handle_type = ? AND handled_at >= ?", "force_free", startOfDay()).
+		Select("COALESCE(SUM(force_free_amount), 0)").Scan(&stats.TodayForceFreeAmount).Error; err != nil {
+		return stats, err
+	}
+	return stats, nil
+}
+
+func startOfDay() time.Time {
+	now := time.Now()
+	return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+}

+ 686 - 0
internal/modules/incident/service/service.go

@@ -0,0 +1,686 @@
+package service
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/modules/incident/repository"
+
+	"go.uber.org/zap"
+)
+
+// ===================== 常量 =====================
+
+// 异常状态
+const (
+	StatusPending    = "pending"
+	StatusProcessing = "processing"
+	StatusResolved   = "resolved"
+	StatusClosed     = "closed"
+)
+
+// 异常来源
+const (
+	SourcePassage = "passage"
+	SourceManual  = "manual"
+	SourceDevice  = "device"
+	SourcePayment = "payment"
+	SourcePrinter = "printer"
+	SourceSystem  = "system"
+)
+
+// 异常分类
+const (
+	CategoryGateFailed       = "gate_failed"
+	CategoryDuplicateEntry   = "duplicate_entry"
+	CategoryBlacklist        = "blacklist"
+	CategoryLotFull          = "lot_full"
+	CategoryNoEntryExit      = "no_entry_exit"
+	CategoryManualRaise      = "manual_raise"
+	CategoryDeviceOffline    = "device_offline"
+	CategoryPrintFailed      = "print_failed"
+	CategoryPaymentFailed    = "payment_failed"
+	CategoryPaymentUncertain = "payment_uncertain"
+)
+
+// 异常等级
+const (
+	LevelInfo     = "info"
+	LevelWarning  = "warning"
+	LevelCritical = "critical"
+)
+
+// 处置方式
+const (
+	HandleManualGate     = "manual_gate"
+	HandleForceFree      = "force_free"
+	HandleReset          = "reset"
+	HandleDeviceRepaired = "device_repaired"
+	HandleReprint        = "reprint"
+	HandleIgnore         = "ignore"
+	HandleOther          = "other"
+)
+
+// 指令流水结果
+const (
+	CommandSuccess = "success"
+	CommandFailed  = "failed"
+)
+
+// 资金相关分类(统计与权限关注)
+var fundRelatedCategories = map[string]bool{
+	CategoryPaymentFailed:    true,
+	CategoryPaymentUncertain: true,
+	CategoryGateFailed:       true,
+	CategoryNoEntryExit:      true,
+}
+
+// 分类默认等级
+var categoryDefaultLevel = map[string]string{
+	CategoryGateFailed:       LevelCritical,
+	CategoryDuplicateEntry:   LevelWarning,
+	CategoryBlacklist:        LevelWarning,
+	CategoryLotFull:          LevelInfo,
+	CategoryNoEntryExit:      LevelCritical,
+	CategoryManualRaise:      LevelWarning,
+	CategoryDeviceOffline:    LevelWarning,
+	CategoryPrintFailed:      LevelWarning,
+	CategoryPaymentFailed:    LevelWarning,
+	CategoryPaymentUncertain: LevelCritical,
+}
+
+// 状态机:仅允许以下流转
+var allowedTransitions = map[string]map[string]bool{
+	StatusPending:    {StatusProcessing: true, StatusResolved: true},
+	StatusProcessing: {StatusResolved: true},
+	StatusResolved:   {StatusClosed: true, StatusPending: true},
+	StatusClosed:     {StatusPending: true},
+}
+
+var (
+	ErrIncidentNotFound = errors.New("异常记录不存在")
+	ErrIncidentChanged  = errors.New("异常状态已被他人变更,请刷新后重试")
+)
+
+// ===================== 服务 =====================
+
+// logError/logWarn/logInfo 带 nil 防护的日志(测试环境与单测中 GVA_LOG 可为空)。
+func logError(msg string, fields ...zap.Field) {
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Error(msg, fields...)
+	}
+}
+
+func logWarn(msg string, fields ...zap.Field) {
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Warn(msg, fields...)
+	}
+}
+
+func logInfo(msg string, fields ...zap.Field) {
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info(msg, fields...)
+	}
+}
+
+type IncidentService struct {
+	repo *repository.IncidentRepository
+}
+
+func NewIncidentService() *IncidentService {
+	return &IncidentService{repo: &repository.IncidentRepository{}}
+}
+
+// RecordIncidentRequest 自动埋点入参。HandleType/HandleRemark 非空时创建即置为已解决
+// (用于人工抬杆等"动作已完成、仅需留痕"的场景)。
+type RecordIncidentRequest struct {
+	Category        string
+	Source          string
+	Level           string
+	VehicleRecordID uint
+	DigitalTicketID uint
+	PaymentRecordID uint
+	GateCommandID   uint
+	TicketNo        string
+	PlateNumber     string
+	RFIDTag         string
+	ParkingLotID    uint
+	ParkingLotName  string
+	ChannelID       uint
+	ChannelCode     string
+	ChannelName     string
+	DeviceCode      string
+	DeviceName      string
+	OperatorID      uint
+	Description     string
+	Detail          interface{}
+	HandleType      string
+	HandleRemark    string
+}
+
+func enrichSessionAssociation(req *RecordIncidentRequest) {
+	var record dao.VehicleRecord
+	if req.VehicleRecordID != 0 && global.GVA_DB.First(&record, req.VehicleRecordID).Error == nil {
+		if req.PlateNumber == "" {
+			req.PlateNumber = record.PlateNumber
+		}
+		if req.RFIDTag == "" {
+			req.RFIDTag = record.RFIDTag
+		}
+		if req.ParkingLotID == 0 {
+			req.ParkingLotID = record.ParkingLotID
+		}
+		if req.ChannelID == 0 {
+			if record.ExitTime != nil && record.ExitChannelID != 0 {
+				req.ChannelID = record.ExitChannelID
+			} else {
+				req.ChannelID = record.EntryChannelID
+			}
+		}
+		if req.ChannelCode == "" {
+			if record.ExitTime != nil && record.ExitChannelCode != "" {
+				req.ChannelCode = record.ExitChannelCode
+			} else {
+				req.ChannelCode = record.EntryChannelCode
+			}
+		}
+		if req.ChannelName == "" {
+			if record.ExitTime != nil && record.ExitChannelName != "" {
+				req.ChannelName = record.ExitChannelName
+			} else {
+				req.ChannelName = record.EntryChannelName
+			}
+		}
+		if req.DeviceCode == "" {
+			if record.ExitTime != nil && record.ExitDeviceCode != "" {
+				req.DeviceCode = record.ExitDeviceCode
+			} else {
+				req.DeviceCode = record.EntryDeviceCode
+			}
+		}
+		if req.DeviceName == "" {
+			if record.ExitTime != nil && record.ExitDeviceName != "" {
+				req.DeviceName = record.ExitDeviceName
+			} else {
+				req.DeviceName = record.EntryDeviceName
+			}
+		}
+	}
+}
+
+// enrichAssociations 根据稳定 ID、票号和设备编码补齐异常上下文快照。
+// 业务埋点通常只知道会话或设备,统一在异常服务中解析,避免各调用方重复查询。
+func enrichAssociations(req *RecordIncidentRequest) {
+	if req == nil || global.GVA_DB == nil {
+		return
+	}
+	enrichSessionAssociation(req)
+
+	var ticket dao.DigitalTicket
+	if req.DigitalTicketID != 0 {
+		_ = global.GVA_DB.First(&ticket, req.DigitalTicketID).Error
+	} else if req.TicketNo != "" {
+		_ = global.GVA_DB.Where("ticket_no = ?", req.TicketNo).First(&ticket).Error
+	} else if req.VehicleRecordID != 0 {
+		_ = global.GVA_DB.Where("vehicle_record_id = ?", req.VehicleRecordID).First(&ticket).Error
+	}
+	if ticket.ID != 0 {
+		req.DigitalTicketID = ticket.ID
+		if req.VehicleRecordID == 0 {
+			req.VehicleRecordID = ticket.VehicleRecordID
+		}
+		if req.TicketNo == "" {
+			req.TicketNo = ticket.TicketNo
+		}
+		if req.PlateNumber == "" {
+			req.PlateNumber = ticket.PlateNumber
+		}
+	}
+	// 仅传票号时会在上一步得到停车会话 ID,此处继续补齐车辆与进出场位置快照。
+	enrichSessionAssociation(req)
+
+	if req.PaymentRecordID == 0 && req.VehicleRecordID != 0 {
+		var payment dao.PaymentRecord
+		if global.GVA_DB.Where("record_id = ?", req.VehicleRecordID).Order("id DESC").First(&payment).Error == nil {
+			req.PaymentRecordID = payment.ID
+		}
+	}
+	if req.GateCommandID != 0 {
+		var command dao.DeviceCommandLog
+		if global.GVA_DB.First(&command, req.GateCommandID).Error == nil {
+			if req.DeviceCode == "" {
+				req.DeviceCode = command.DeviceCode
+			}
+			if req.DeviceName == "" {
+				req.DeviceName = command.DeviceName
+			}
+		}
+	}
+	if req.ChannelID != 0 {
+		var channel dao.Channel
+		if global.GVA_DB.First(&channel, req.ChannelID).Error == nil {
+			if req.ChannelCode == "" {
+				req.ChannelCode = channel.ChannelCode
+			}
+			if req.ChannelName == "" {
+				req.ChannelName = channel.ChannelName
+			}
+			if req.ParkingLotID == 0 {
+				req.ParkingLotID = channel.ParkingLotID
+			}
+		}
+	}
+	if req.DeviceCode != "" {
+		var device dao.UHFReader
+		if global.GVA_DB.Where("device_code = ?", req.DeviceCode).First(&device).Error == nil {
+			if req.DeviceName == "" {
+				req.DeviceName = device.DeviceName
+			}
+			if req.ChannelID == 0 {
+				req.ChannelID = device.ChannelID
+			}
+			if req.ParkingLotID == 0 {
+				req.ParkingLotID = device.ParkingLotID
+			}
+		}
+	}
+	// 设备可能刚刚补齐通道 ID,再查询一次通道名称和所属停车场。
+	if req.ChannelID != 0 && (req.ChannelCode == "" || req.ChannelName == "" || req.ParkingLotID == 0) {
+		var channel dao.Channel
+		if global.GVA_DB.First(&channel, req.ChannelID).Error == nil {
+			if req.ChannelCode == "" {
+				req.ChannelCode = channel.ChannelCode
+			}
+			if req.ChannelName == "" {
+				req.ChannelName = channel.ChannelName
+			}
+			if req.ParkingLotID == 0 {
+				req.ParkingLotID = channel.ParkingLotID
+			}
+		}
+	}
+	if req.ParkingLotID != 0 && req.ParkingLotName == "" {
+		var lot dao.ParkingLot
+		if global.GVA_DB.First(&lot, req.ParkingLotID).Error == nil {
+			req.ParkingLotName = lot.LotName
+		}
+	}
+}
+
+func validateAssociationIDs(req CreateIncidentRequest) error {
+	checks := []struct {
+		id    uint
+		model interface{}
+		name  string
+	}{
+		{req.VehicleRecordID, &dao.VehicleRecord{}, "停车会话"},
+		{req.DigitalTicketID, &dao.DigitalTicket{}, "数字票"},
+		{req.PaymentRecordID, &dao.PaymentRecord{}, "支付流水"},
+		{req.GateCommandID, &dao.DeviceCommandLog{}, "闸机指令"},
+		{req.ParkingLotID, &dao.ParkingLot{}, "停车场"},
+		{req.ChannelID, &dao.Channel{}, "通道"},
+	}
+	for _, check := range checks {
+		if check.id == 0 {
+			continue
+		}
+		var count int64
+		if err := global.GVA_DB.Model(check.model).Where("id = ?", check.id).Count(&count).Error; err != nil {
+			return err
+		}
+		if count == 0 {
+			return fmt.Errorf("关联的%s不存在: %d", check.name, check.id)
+		}
+	}
+	return nil
+}
+
+// RecordIncident 自动埋点入口(best-effort):
+// 内部任何错误只记日志并返回 0,绝不影响业务主流程;成功返回异常记录 ID(供指令流水关联)。
+func (s *IncidentService) RecordIncident(req RecordIncidentRequest) uint {
+	if req.Category == "" {
+		return 0
+	}
+	if global.GVA_DB == nil {
+		logWarn("异常埋点跳过:数据库未初始化", zap.String("category", req.Category))
+		return 0
+	}
+	enrichAssociations(&req)
+	if req.Source == "" {
+		req.Source = SourceSystem
+	}
+	if req.Level == "" {
+		req.Level = categoryDefaultLevel[req.Category]
+		if req.Level == "" {
+			req.Level = LevelWarning
+		}
+	}
+
+	// 去重:离线异常同设备未关闭不重复;通行类同标识时间窗内不重复
+	var exists bool
+	var err error
+	if req.Category == CategoryDeviceOffline {
+		exists, err = s.repo.HasOpenDeviceOffline(req.DeviceCode)
+	} else {
+		exists, err = s.repo.ExistsRecent(req.DeviceCode, req.Category, req.PlateNumber, req.RFIDTag, 5)
+	}
+	if err != nil {
+		logWarn("异常去重查询失败,继续记录", zap.Error(err))
+	} else if exists {
+		return 0
+	}
+
+	status := StatusPending
+	if req.HandleType != "" {
+		status = StatusResolved
+	}
+	detail := ""
+	if req.Detail != nil {
+		if data, marshalErr := json.Marshal(req.Detail); marshalErr == nil {
+			detail = string(data)
+			if len(detail) > 1000 {
+				detail = detail[:1000]
+			}
+		}
+	}
+
+	no, err := s.generateIncidentNo()
+	if err != nil {
+		logError("生成异常编号失败", zap.Error(err))
+		return 0
+	}
+
+	now := time.Now()
+	event := map[string]interface{}{"event": "created", "source": req.Source}
+	record := &dao.IncidentRecord{
+		IncidentNo:      no,
+		Category:        req.Category,
+		Source:          req.Source,
+		Level:           req.Level,
+		Status:          status,
+		VehicleRecordID: req.VehicleRecordID,
+		DigitalTicketID: req.DigitalTicketID,
+		PaymentRecordID: req.PaymentRecordID,
+		GateCommandID:   req.GateCommandID,
+		TicketNo:        req.TicketNo,
+		PlateNumber:     req.PlateNumber,
+		RFIDTag:         req.RFIDTag,
+		ParkingLotID:    req.ParkingLotID,
+		ParkingLotName:  req.ParkingLotName,
+		ChannelID:       req.ChannelID,
+		ChannelCode:     req.ChannelCode,
+		ChannelName:     req.ChannelName,
+		DeviceCode:      req.DeviceCode,
+		DeviceName:      req.DeviceName,
+		OperatorID:      req.OperatorID,
+		Description:     req.Description,
+		Detail:          detail,
+		HandlerID:       req.OperatorID,
+		HandleType:      req.HandleType,
+		HandleRemark:    req.HandleRemark,
+		EventLog:        appendEvent("[]", event),
+	}
+	if status == StatusResolved {
+		record.HandledAt = &now
+	}
+
+	if err := s.repo.Create(record); err != nil {
+		logError("记录异常事件失败",
+			zap.Error(err), zap.String("category", req.Category), zap.String("device_code", req.DeviceCode))
+		return 0
+	}
+	logInfo("异常事件已记录",
+		zap.String("incident_no", no), zap.String("category", req.Category), zap.String("status", status))
+	return record.ID
+}
+
+// CreateIncidentRequest 人工上报入参。
+type CreateIncidentRequest struct {
+	Category        string `json:"category" binding:"required"`
+	Level           string `json:"level"`
+	VehicleRecordID uint   `json:"vehicle_record_id"`
+	DigitalTicketID uint   `json:"digital_ticket_id"`
+	PaymentRecordID uint   `json:"payment_record_id"`
+	GateCommandID   uint   `json:"gate_command_id"`
+	TicketNo        string `json:"ticket_no"`
+	PlateNumber     string `json:"plate_number"`
+	RFIDTag         string `json:"rfid_tag"`
+	ParkingLotID    uint   `json:"parking_lot_id"`
+	ParkingLotName  string `json:"parking_lot_name"`
+	ChannelID       uint   `json:"channel_id"`
+	ChannelCode     string `json:"channel_code"`
+	ChannelName     string `json:"channel_name"`
+	DeviceCode      string `json:"device_code"`
+	DeviceName      string `json:"device_name"`
+	Description     string `json:"description" binding:"required"`
+	Detail          string `json:"detail"`
+}
+
+// CreateIncident 人工上报异常(来源 manual,待处理状态)。
+func (s *IncidentService) CreateIncident(req CreateIncidentRequest, operatorID uint) (*dao.IncidentRecord, error) {
+	if _, known := categoryDefaultLevel[req.Category]; !known {
+		return nil, errors.New("未知异常分类: " + req.Category)
+	}
+	if err := validateAssociationIDs(req); err != nil {
+		return nil, err
+	}
+	level := req.Level
+	if level == "" {
+		level = categoryDefaultLevel[req.Category]
+		if level == "" {
+			level = LevelWarning
+		}
+	}
+	autoReq := RecordIncidentRequest{
+		VehicleRecordID: req.VehicleRecordID, DigitalTicketID: req.DigitalTicketID, PaymentRecordID: req.PaymentRecordID, GateCommandID: req.GateCommandID,
+		TicketNo: req.TicketNo, PlateNumber: req.PlateNumber, RFIDTag: req.RFIDTag, ParkingLotID: req.ParkingLotID, ParkingLotName: req.ParkingLotName,
+		ChannelID: req.ChannelID, ChannelCode: req.ChannelCode, ChannelName: req.ChannelName, DeviceCode: req.DeviceCode, DeviceName: req.DeviceName,
+	}
+	enrichAssociations(&autoReq)
+	no, err := s.generateIncidentNo()
+	if err != nil {
+		return nil, err
+	}
+	record := &dao.IncidentRecord{
+		IncidentNo:      no,
+		Category:        req.Category,
+		Source:          SourceManual,
+		Level:           level,
+		Status:          StatusPending,
+		VehicleRecordID: autoReq.VehicleRecordID,
+		DigitalTicketID: autoReq.DigitalTicketID,
+		PaymentRecordID: autoReq.PaymentRecordID,
+		GateCommandID:   autoReq.GateCommandID,
+		TicketNo:        autoReq.TicketNo,
+		PlateNumber:     autoReq.PlateNumber,
+		RFIDTag:         autoReq.RFIDTag,
+		ParkingLotID:    autoReq.ParkingLotID,
+		ParkingLotName:  autoReq.ParkingLotName,
+		ChannelID:       autoReq.ChannelID,
+		ChannelCode:     autoReq.ChannelCode,
+		ChannelName:     autoReq.ChannelName,
+		DeviceCode:      autoReq.DeviceCode,
+		DeviceName:      autoReq.DeviceName,
+		OperatorID:      operatorID,
+		Description:     req.Description,
+		Detail:          req.Detail,
+		EventLog:        appendEvent("[]", map[string]interface{}{"event": "created", "source": SourceManual}),
+	}
+	if err := s.repo.Create(record); err != nil {
+		return nil, err
+	}
+	return record, nil
+}
+
+// TransitionRequest 状态流转+处置入参。
+type TransitionRequest struct {
+	ToStatus        string  `json:"to_status" binding:"required"`
+	HandleType      string  `json:"handle_type"`
+	HandleRemark    string  `json:"handle_remark"`
+	ForceFreeAmount float64 `json:"force_free_amount"`
+}
+
+// TransitionIncident 状态流转(CAS 条件更新)。
+// isAdmin 表示是否允许资金类处置(force_free)。
+func (s *IncidentService) TransitionIncident(id uint, req TransitionRequest, operatorID uint, isAdmin bool) (*dao.IncidentRecord, error) {
+	incident, err := s.repo.GetByID(id)
+	if err != nil {
+		return nil, ErrIncidentNotFound
+	}
+
+	toStatus := req.ToStatus
+	if toStatus == incident.Status {
+		return incident, nil
+	}
+	if !allowedTransitions[incident.Status][toStatus] {
+		return nil, fmt.Errorf("状态转换不合法: %s → %s", incident.Status, toStatus)
+	}
+
+	// 处置校验
+	if toStatus == StatusResolved {
+		if req.HandleType == "" {
+			return nil, errors.New("解决异常必须填写处置方式")
+		}
+		if req.HandleRemark == "" {
+			return nil, errors.New("解决异常必须填写处置备注")
+		}
+	}
+	if req.HandleType == HandleForceFree {
+		if !isAdmin {
+			return nil, errors.New("强制免费处置需要管理员权限")
+		}
+		if req.ForceFreeAmount < 0 {
+			return nil, errors.New("强制免费金额不能为负数")
+		}
+	}
+
+	updates := map[string]interface{}{
+		"handler_id":    operatorID,
+		"handle_type":   req.HandleType,
+		"handle_remark": req.HandleRemark,
+	}
+	event := map[string]interface{}{"event": toStatus, "handler_id": operatorID}
+	if toStatus == StatusResolved {
+		now := time.Now()
+		updates["handled_at"] = now
+		updates["force_free_amount"] = req.ForceFreeAmount
+		event["handle_type"] = req.HandleType
+		if req.HandleType == HandleForceFree {
+			event["force_free_amount"] = req.ForceFreeAmount
+		}
+	}
+	updates["event_log"] = appendEvent(incident.EventLog, event)
+
+	updated, err := s.repo.UpdateStateIfCurrent(id, incident.Status, toStatus, updates)
+	if err != nil {
+		return nil, err
+	}
+	if !updated {
+		return nil, ErrIncidentChanged
+	}
+	return s.repo.GetByID(id)
+}
+
+// GetIncident 查询详情。
+func (s *IncidentService) GetIncident(id uint) (*dao.IncidentRecord, error) {
+	return s.repo.GetByID(id)
+}
+
+// ListIncidents 分页筛选。
+func (s *IncidentService) ListIncidents(q repository.IncidentListQuery) ([]repository.IncidentListItem, int64, error) {
+	return s.repo.List(q)
+}
+
+// GetIncidentStats 异常统计。
+func (s *IncidentService) GetIncidentStats() (repository.IncidentStats, error) {
+	return s.repo.Stats()
+}
+
+// ResolveDeviceOffline 设备恢复在线:关闭该设备未处理的离线异常(best-effort)。
+func (s *IncidentService) ResolveDeviceOffline(deviceCode string) {
+	if err := s.repo.ResolveDeviceOfflineByDevice(deviceCode, "设备恢复在线"); err != nil {
+		logWarn("关闭设备离线异常失败",
+			zap.Error(err), zap.String("device_code", deviceCode))
+	}
+}
+
+// GateCommandRecord 道闸指令流水入参。
+type GateCommandRecord struct {
+	DeviceCode   string
+	DeviceName   string
+	Action       string // open / close
+	Source       string // passage / manual / test
+	OperatorID   uint
+	SessionID    uint
+	Result       string // success / failed
+	ErrorMessage string
+	DurationMs   int64
+}
+
+// RecordGateCommand 记录道闸指令流水(best-effort),返回流水 ID 供异常关联。
+func (s *IncidentService) RecordGateCommand(rec GateCommandRecord) uint {
+	if rec.DeviceCode == "" {
+		return 0
+	}
+	if rec.Source == "" {
+		rec.Source = SourcePassage
+	}
+	log := &dao.DeviceCommandLog{
+		DeviceCode:   rec.DeviceCode,
+		DeviceName:   rec.DeviceName,
+		Action:       rec.Action,
+		Source:       rec.Source,
+		OperatorID:   rec.OperatorID,
+		SessionID:    rec.SessionID,
+		Result:       rec.Result,
+		ErrorMessage: rec.ErrorMessage,
+		DurationMs:   rec.DurationMs,
+	}
+	if err := s.repo.CreateCommandLog(log); err != nil {
+		logWarn("记录道闸指令流水失败", zap.Error(err))
+		return 0
+	}
+	return log.ID
+}
+
+// LinkIncidentToCommand 回填指令流水关联的异常事件 ID(best-effort)。
+func (s *IncidentService) LinkIncidentToCommand(logID, incidentID uint) {
+	if err := s.repo.UpdateIncidentID(logID, incidentID); err != nil {
+		logWarn("回填指令流水异常关联失败", zap.Error(err))
+	}
+}
+
+// generateIncidentNo 生成唯一编号:INC + yyyyMMdd + 当日序号(冲突时重试)。
+func (s *IncidentService) generateIncidentNo() (string, error) {
+	prefix := "INC" + time.Now().Format("20060102")
+	for attempt := 0; attempt < 5; attempt++ {
+		var count int64
+		if err := global.GVA_DB.Model(&dao.IncidentRecord{}).
+			Where("incident_no LIKE ?", prefix+"%").Count(&count).Error; err != nil {
+			return "", err
+		}
+		no := fmt.Sprintf("%s-%04d", prefix, count+1)
+		var dup int64
+		if err := global.GVA_DB.Model(&dao.IncidentRecord{}).
+			Where("incident_no = ?", no).Count(&dup).Error; err != nil {
+			return "", err
+		}
+		if dup == 0 {
+			return no, nil
+		}
+	}
+	return "", errors.New("生成异常编号失败,请重试")
+}
+
+// appendEvent 向事件日志 JSON 数组追加事件(沿用数字票 event_log 约定)。
+func appendEvent(log string, event map[string]interface{}) string {
+	var events []map[string]interface{}
+	json.Unmarshal([]byte(log), &events)
+	event["at"] = time.Now().Format(time.RFC3339)
+	events = append(events, event)
+	data, _ := json.Marshal(events)
+	return string(data)
+}

+ 330 - 0
internal/modules/incident/service/service_test.go

@@ -0,0 +1,330 @@
+package service
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/modules/incident/repository"
+)
+
+func setupIncidentTest(t *testing.T) *IncidentService {
+	t.Helper()
+	dsn := fmt.Sprintf("file:incident-%d?mode=memory&cache=shared", time.Now().UnixNano())
+	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+	require.NoError(t, err)
+	sqlDB, err := db.DB()
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		_ = sqlDB.Close()
+		if global.GVA_DB == db {
+			global.GVA_DB = nil
+		}
+	})
+	global.GVA_DB = db
+	require.NoError(t, db.AutoMigrate(
+		&dao.IncidentRecord{}, &dao.DeviceCommandLog{}, &dao.SysUser{}, &dao.ParkingLot{},
+		&dao.VehicleRecord{}, &dao.DigitalTicket{}, &dao.PaymentRecord{}, &dao.Channel{}, &dao.UHFReader{},
+	))
+	return NewIncidentService()
+}
+
+func countIncidents(t *testing.T) int64 {
+	var count int64
+	require.NoError(t, global.GVA_DB.Model(&dao.IncidentRecord{}).Count(&count).Error)
+	return count
+}
+
+func TestIncidentFullLifecycle(t *testing.T) {
+	svc := setupIncidentTest(t)
+
+	// 人工上报 → pending
+	created, err := svc.CreateIncident(CreateIncidentRequest{
+		Category: CategoryNoEntryExit, Description: "无牌车无入场记录出场", PlateNumber: "SU-001",
+	}, 1)
+	require.NoError(t, err)
+	require.Equal(t, StatusPending, created.Status)
+	require.Equal(t, SourceManual, created.Source)
+	require.Equal(t, LevelCritical, created.Level) // 分类默认等级
+	require.True(t, strings.HasPrefix(created.IncidentNo, "INC"))
+
+	// pending → processing
+	processing, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusProcessing}, 2, true)
+	require.NoError(t, err)
+	require.Equal(t, StatusProcessing, processing.Status)
+	require.Equal(t, uint(2), processing.HandlerID)
+
+	// processing → resolved(缺处置方式拒绝)
+	_, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusResolved, HandleRemark: "已核实"}, 2, true)
+	require.Error(t, err)
+	// 缺备注拒绝
+	_, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusResolved, HandleType: HandleReset}, 2, true)
+	require.Error(t, err)
+
+	// processing → resolved(合法)
+	resolved, err := svc.TransitionIncident(created.ID, TransitionRequest{
+		ToStatus: StatusResolved, HandleType: HandleReset, HandleRemark: "已人工核实放行",
+	}, 2, true)
+	require.NoError(t, err)
+	require.Equal(t, StatusResolved, resolved.Status)
+	require.NotNil(t, resolved.HandledAt)
+	require.Equal(t, HandleReset, resolved.HandleType)
+	require.Contains(t, resolved.EventLog, "resolved")
+
+	// resolved → closed
+	closed, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusClosed, HandleRemark: "关闭"}, 2, true)
+	require.NoError(t, err)
+	require.Equal(t, StatusClosed, closed.Status)
+
+	// closed → pending(重新打开)
+	reopened, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusPending, HandleRemark: "复核中"}, 2, true)
+	require.NoError(t, err)
+	require.Equal(t, StatusPending, reopened.Status)
+}
+
+func TestIncidentInvalidTransitions(t *testing.T) {
+	svc := setupIncidentTest(t)
+	created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
+	require.NoError(t, err)
+
+	// pending 不允许直接 closed
+	_, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusClosed}, 2, true)
+	require.Error(t, err)
+
+	// 未知目标状态
+	_, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: "unknown"}, 2, true)
+	require.Error(t, err)
+
+	// 不存在的记录
+	_, err = svc.TransitionIncident(99999, TransitionRequest{ToStatus: StatusProcessing}, 2, true)
+	require.ErrorIs(t, err, ErrIncidentNotFound)
+}
+
+func TestForceFreeRequiresAdminAndValidAmount(t *testing.T) {
+	svc := setupIncidentTest(t)
+	created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryNoEntryExit, Description: "丢票"}, 1)
+	require.NoError(t, err)
+
+	// 非管理员禁止强制免费
+	_, err = svc.TransitionIncident(created.ID, TransitionRequest{
+		ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 20,
+	}, 2, false)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "管理员权限")
+
+	// 负金额拒绝
+	_, err = svc.TransitionIncident(created.ID, TransitionRequest{
+		ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: -1,
+	}, 2, true)
+	require.Error(t, err)
+
+	// 管理员 + 合法金额
+	resolved, err := svc.TransitionIncident(created.ID, TransitionRequest{
+		ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 20,
+	}, 2, true)
+	require.NoError(t, err)
+	require.Equal(t, 20.0, resolved.ForceFreeAmount)
+}
+
+func TestRecordIncidentBestEffortAndDedup(t *testing.T) {
+	svc := setupIncidentTest(t)
+
+	// 正常记录
+	id := svc.RecordIncident(RecordIncidentRequest{
+		Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01",
+		PlateNumber: "SU-001", Description: "开闸失败", Detail: "timeout",
+	})
+	require.NotZero(t, id)
+	require.Equal(t, int64(1), countIncidents(t))
+
+	// 同设备同车 5 分钟内重复 → 去重
+	id2 := svc.RecordIncident(RecordIncidentRequest{
+		Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01",
+		PlateNumber: "SU-001", Description: "开闸失败",
+	})
+	require.Zero(t, id2)
+	require.Equal(t, int64(1), countIncidents(t))
+
+	// 不同设备 → 生成
+	id3 := svc.RecordIncident(RecordIncidentRequest{
+		Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-02",
+		PlateNumber: "SU-001", Description: "开闸失败",
+	})
+	require.NotZero(t, id3)
+
+	// 无车辆标识 → 不去重
+	id4 := svc.RecordIncident(RecordIncidentRequest{
+		Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01",
+		Description: "开闸失败",
+	})
+	require.NotZero(t, id4)
+
+	// 数据库未初始化(模拟 DB 故障)→ best-effort 返回 0 不 panic
+	global.GVA_DB = nil
+	id5 := svc.RecordIncident(RecordIncidentRequest{
+		Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "GATE-X", Description: "离线",
+	})
+	require.Zero(t, id5)
+}
+
+func TestDeviceOfflineDedupAndResolve(t *testing.T) {
+	svc := setupIncidentTest(t)
+
+	// 首次离线生成
+	id := svc.RecordIncident(RecordIncidentRequest{
+		Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线",
+	})
+	require.NotZero(t, id)
+	// 重复离线(未关闭)→ 去重
+	require.Zero(t, svc.RecordIncident(RecordIncidentRequest{
+		Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线",
+	}))
+
+	// 设备恢复在线 → 自动关闭
+	svc.ResolveDeviceOffline("READER-01")
+	var record dao.IncidentRecord
+	require.NoError(t, global.GVA_DB.First(&record, id).Error)
+	require.Equal(t, StatusResolved, record.Status)
+	require.Equal(t, HandleDeviceRepaired, record.HandleType)
+	require.Contains(t, record.EventLog, "device_repaired")
+
+	// 再次离线(已解决,可再生成)
+	require.NotZero(t, svc.RecordIncident(RecordIncidentRequest{
+		Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线",
+	}))
+}
+
+func TestIncidentNoSequential(t *testing.T) {
+	svc := setupIncidentTest(t)
+	a, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
+	require.NoError(t, err)
+	b, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
+	require.NoError(t, err)
+	require.NotEqual(t, a.IncidentNo, b.IncidentNo)
+	require.True(t, strings.HasPrefix(a.IncidentNo, "INC"+time.Now().Format("20060102")))
+}
+
+func TestIncidentStats(t *testing.T) {
+	svc := setupIncidentTest(t)
+	svc.RecordIncident(RecordIncidentRequest{Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败"})
+	svc.RecordIncident(RecordIncidentRequest{Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "离线"})
+	// 强制免费处置
+	created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryNoEntryExit, Description: "丢票"}, 1)
+	require.NoError(t, err)
+	_, err = svc.TransitionIncident(created.ID, TransitionRequest{
+		ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 15,
+	}, 2, true)
+	require.NoError(t, err)
+
+	stats, err := svc.GetIncidentStats()
+	require.NoError(t, err)
+	require.Equal(t, int64(2), stats.Pending)  // gate_failed + device_offline
+	require.Equal(t, int64(1), stats.Resolved) // no_entry_exit 已处置
+	require.Equal(t, int64(3), stats.TodayCreated)
+	require.Equal(t, int64(1), stats.OpenFundRelated) // 未关闭资金类:gate_failed(no_entry_exit 已 resolved)
+	require.Equal(t, 15.0, stats.TodayForceFreeAmount)
+}
+
+func TestRecordGateCommandAndLink(t *testing.T) {
+	svc := setupIncidentTest(t)
+
+	// 指令流水写入
+	logID := svc.RecordGateCommand(GateCommandRecord{
+		DeviceCode: "GATE-01", DeviceName: "东门道闸", Action: "open",
+		Source: SourcePassage, SessionID: 7, Result: CommandFailed, ErrorMessage: "timeout", DurationMs: 30,
+	})
+	require.NotZero(t, logID)
+
+	// 异常关联回填
+	incidentID := svc.RecordIncident(RecordIncidentRequest{
+		Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败",
+	})
+	require.NotZero(t, incidentID)
+	svc.LinkIncidentToCommand(logID, incidentID)
+
+	var log dao.DeviceCommandLog
+	require.NoError(t, global.GVA_DB.First(&log, logID).Error)
+	require.Equal(t, incidentID, log.IncidentID)
+	var incident dao.IncidentRecord
+	require.NoError(t, global.GVA_DB.First(&incident, incidentID).Error)
+	require.Equal(t, logID, incident.GateCommandID)
+
+	// 空设备编码不写流水
+	require.Zero(t, svc.RecordGateCommand(GateCommandRecord{Action: "open", Result: CommandSuccess}))
+}
+
+func TestIncidentAssociationEnrichment(t *testing.T) {
+	svc := setupIncidentTest(t)
+	lot := dao.ParkingLot{LotName: "一号停车场"}
+	require.NoError(t, global.GVA_DB.Create(&lot).Error)
+	channel := dao.Channel{ChannelCode: "ENTRY-01", ChannelName: "东入口", Direction: "in", ParkingLotID: lot.ID}
+	require.NoError(t, global.GVA_DB.Create(&channel).Error)
+	device := dao.UHFReader{DeviceCode: "GATE-01", DeviceName: "东入口闸机", DeviceType: "gate", ConnectType: "tcp", ChannelID: channel.ID, ParkingLotID: lot.ID}
+	require.NoError(t, global.GVA_DB.Create(&device).Error)
+	session := dao.VehicleRecord{PlateNumber: "粤A12345", EntryTime: time.Now(), ParkingLotID: lot.ID, EntryChannelID: channel.ID, EntryChannelCode: channel.ChannelCode, EntryChannelName: channel.ChannelName, EntryDeviceCode: device.DeviceCode, EntryDeviceName: device.DeviceName}
+	require.NoError(t, global.GVA_DB.Create(&session).Error)
+	ticket := dao.DigitalTicket{TicketNo: "TICKET-001", PlateNumber: session.PlateNumber, VehicleRecordID: session.ID, EntryTime: time.Now()}
+	require.NoError(t, global.GVA_DB.Create(&ticket).Error)
+	payment := dao.PaymentRecord{RecordID: session.ID, PaymentEntry: "counter", PaymentMethod: "cash", PaidAt: time.Now()}
+	require.NoError(t, global.GVA_DB.Create(&payment).Error)
+
+	created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryPaymentFailed, TicketNo: ticket.TicketNo, Description: "支付异常"}, 1)
+	require.NoError(t, err)
+	require.Equal(t, session.ID, created.VehicleRecordID)
+	require.Equal(t, ticket.ID, created.DigitalTicketID)
+	require.Equal(t, payment.ID, created.PaymentRecordID)
+	require.Equal(t, lot.ID, created.ParkingLotID)
+	require.Equal(t, lot.LotName, created.ParkingLotName)
+	require.Equal(t, channel.ID, created.ChannelID)
+	require.Equal(t, channel.ChannelName, created.ChannelName)
+	require.Equal(t, device.DeviceName, created.DeviceName)
+
+	_, err = svc.CreateIncident(CreateIncidentRequest{Category: CategoryManualRaise, VehicleRecordID: 999999, Description: "错误关联"}, 1)
+	require.ErrorContains(t, err, "停车会话不存在")
+}
+
+func TestTransitionCASConcurrency(t *testing.T) {
+	svc := setupIncidentTest(t)
+	created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1)
+	require.NoError(t, err)
+
+	// 第一条流转成功
+	_, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusProcessing}, 2, true)
+	require.NoError(t, err)
+
+	// 基于陈旧状态直接 CAS 更新(模拟并发竞争中落后的一方)→ 不生效
+	updated, err := svc.repo.UpdateStateIfCurrent(created.ID, StatusPending, StatusProcessing,
+		map[string]interface{}{"handler_id": 3})
+	require.NoError(t, err)
+	require.False(t, updated)
+
+	// 基于当前状态 CAS 更新 → 生效
+	updated, err = svc.repo.UpdateStateIfCurrent(created.ID, StatusProcessing, StatusResolved,
+		map[string]interface{}{"handle_type": HandleReset, "handle_remark": "并发竞争后生效"})
+	require.NoError(t, err)
+	require.True(t, updated)
+}
+
+func TestIncidentListFilter(t *testing.T) {
+	svc := setupIncidentTest(t)
+	svc.RecordIncident(RecordIncidentRequest{Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败"})
+	svc.RecordIncident(RecordIncidentRequest{Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "离线"})
+
+	// 按分类筛选
+	list, total, err := svc.ListIncidents(repository.IncidentListQuery{Category: CategoryDeviceOffline})
+	_ = list
+	require.NoError(t, err)
+	require.Equal(t, int64(1), total)
+
+	// 分页默认值
+	list, total, err = svc.ListIncidents(repository.IncidentListQuery{})
+	require.NoError(t, err)
+	require.Equal(t, int64(2), total)
+	require.Len(t, list, 2)
+}

+ 9 - 0
internal/modules/parking-session/model/request/session.go

@@ -9,6 +9,14 @@ type ResolveActiveSessionRequest struct {
 	RFIDTag     string
 }
 
+// EntryAccessProfile 描述车辆在停车场满位时参与准入判断的身份。
+// 同一车辆可以同时具有多个身份,只要任一身份被停车场允许即可入场。
+type EntryAccessProfile struct {
+	IsTemporary         bool
+	HasValidMonthlyCard bool
+	IsVIP               bool
+}
+
 // OpenSessionRequest 保存一次停车会话不可变的入场信息。
 type OpenSessionRequest struct {
 	PlateNumber      string
@@ -22,6 +30,7 @@ type OpenSessionRequest struct {
 	EntryDeviceName  string
 	EntryImage       string
 	EntryTime        time.Time
+	AccessProfile    EntryAccessProfile
 }
 
 // CloseSessionRequest 保存一次停车会话的最终结算与出场信息。

+ 76 - 0
internal/modules/parking-session/service/service.go

@@ -27,6 +27,8 @@ var (
 	ErrSessionIdentityConflict = errors.New("车牌与RFID对应不同的在场停车会话")
 	ErrSessionAmbiguous        = errors.New("存在多条匹配的在场停车会话")
 	ErrSessionIdentifierEmpty  = errors.New("停车会话必须提供车牌、RFID或会话编号")
+	ErrParkingLotNotFound      = errors.New("停车场不存在")
+	ErrParkingLotFull          = errors.New("停车场车位已满")
 )
 
 // ParkingSessionService owns the parking-stay lifecycle. It deliberately
@@ -188,6 +190,9 @@ func (s *ParkingSessionService) OpenSessionTx(db *gorm.DB, req request.OpenSessi
 	if !errors.Is(err, ErrSessionNotFound) {
 		return nil, err
 	}
+	if err := reserveParkingSpaceTx(db, req.ParkingLotID, req.AccessProfile); err != nil {
+		return nil, err
+	}
 
 	entryTime := req.EntryTime
 	if entryTime.IsZero() {
@@ -213,6 +218,74 @@ func (s *ParkingSessionService) OpenSessionTx(db *gorm.DB, req request.OpenSessi
 	return session, nil
 }
 
+// reserveParkingSpaceTx 在创建会话前原子增加停车场占用数。
+// 满位时只有停车场明确允许的车辆身份可以继续入场。
+func reserveParkingSpaceTx(db *gorm.DB, parkingLotID uint, profile request.EntryAccessProfile) error {
+	if parkingLotID == 0 {
+		return nil
+	}
+
+	result := db.Model(&dao.ParkingLot{}).
+		Where(`id = ? AND (
+			occupied < capacity OR
+			(? AND allow_temporary_when_full) OR
+			(? AND allow_monthly_when_full) OR
+			(? AND allow_vip_when_full)
+		)`, parkingLotID, profile.IsTemporary, profile.HasValidMonthlyCard, profile.IsVIP).
+		UpdateColumns(map[string]interface{}{
+			"occupied":  gorm.Expr("occupied + 1"),
+			"available": gorm.Expr("CASE WHEN capacity > occupied + 1 THEN capacity - occupied - 1 ELSE 0 END"),
+		})
+	if result.Error != nil {
+		return fmt.Errorf("更新停车场占用数失败: %w", result.Error)
+	}
+	if result.RowsAffected == 1 {
+		return nil
+	}
+
+	var lot dao.ParkingLot
+	if err := db.First(&lot, parkingLotID).Error; err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return ErrParkingLotNotFound
+		}
+		return err
+	}
+	if lot.Occupied < lot.Capacity {
+		return errors.New("停车场车位占用状态更新失败")
+	}
+
+	switch {
+	case profile.IsVIP && !lot.AllowVIPWhenFull:
+		return fmt.Errorf("%w,当前停车场不允许 VIP 车满位入场", ErrParkingLotFull)
+	case profile.HasValidMonthlyCard && !lot.AllowMonthlyWhenFull:
+		return fmt.Errorf("%w,当前停车场不允许月租车满位入场", ErrParkingLotFull)
+	case profile.IsTemporary && !lot.AllowTemporaryWhenFull:
+		return fmt.Errorf("%w,当前停车场不允许临时车满位入场", ErrParkingLotFull)
+	default:
+		return ErrParkingLotFull
+	}
+}
+
+// releaseParkingSpaceTx 在会话关闭后释放占用,并根据实际占用数重新计算余位。
+func releaseParkingSpaceTx(db *gorm.DB, parkingLotID uint) error {
+	if parkingLotID == 0 {
+		return nil
+	}
+	result := db.Model(&dao.ParkingLot{}).Where("id = ?", parkingLotID).
+		UpdateColumn("occupied", gorm.Expr("CASE WHEN occupied > 0 THEN occupied - 1 ELSE 0 END"))
+	if result.Error != nil {
+		return fmt.Errorf("释放停车场占用数失败: %w", result.Error)
+	}
+	if result.RowsAffected != 1 {
+		return ErrParkingLotNotFound
+	}
+	if err := db.Model(&dao.ParkingLot{}).Where("id = ?", parkingLotID).
+		UpdateColumn("available", gorm.Expr("CASE WHEN capacity > occupied THEN capacity - occupied ELSE 0 END")).Error; err != nil {
+		return fmt.Errorf("更新停车场余位失败: %w", err)
+	}
+	return nil
+}
+
 // MarkPaidTx 更新在场会话的当前结算摘要。
 // 实际资金流水仍以支付模块创建的 payment_record 为准。
 func (s *ParkingSessionService) MarkPaidTx(db *gorm.DB, sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
@@ -310,6 +383,9 @@ func (s *ParkingSessionService) CloseSessionTx(db *gorm.DB, req request.CloseSes
 	if rows != 1 {
 		return nil, sessionLookupError(db, req.SessionID, gorm.ErrRecordNotFound)
 	}
+	if err := releaseParkingSpaceTx(db, session.ParkingLotID); err != nil {
+		return nil, err
+	}
 	session.ExitTime = &exitTime
 	session.StayTime = req.StayTime
 	session.Fee = req.Fee

+ 91 - 1
internal/modules/parking-session/service/service_test.go

@@ -27,7 +27,10 @@ func setupParkingSessionTest(t *testing.T) *ParkingSessionService {
 		}
 	})
 	global.GVA_DB = db
-	require.NoError(t, db.AutoMigrate(&dao.VehicleRecord{}))
+	require.NoError(t, db.AutoMigrate(&dao.ParkingLot{}, &dao.VehicleRecord{}))
+	require.NoError(t, db.Create(&dao.ParkingLot{
+		LotCode: "SESSION-LOT", LotName: "会话测试停车场", Capacity: 10, Available: 10,
+	}).Error)
 	return NewParkingSessionService()
 }
 
@@ -92,3 +95,90 @@ func TestParkingSessionRejectsDuplicateAndConflictingIdentity(t *testing.T) {
 	_, err = svc.ResolveActiveSession(request.ResolveActiveSessionRequest{PlateNumber: "SESSION-A", RFIDTag: "RFID-B"})
 	require.ErrorIs(t, err, ErrSessionIdentityConflict)
 }
+
+func TestParkingSessionFullAdmissionPolicies(t *testing.T) {
+	tests := []struct {
+		name    string
+		profile request.EntryAccessProfile
+		policy  string
+		allow   bool
+		wantErr bool
+	}{
+		{name: "普通车辆满位拒绝", wantErr: true},
+		{name: "临时车未配置时拒绝", profile: request.EntryAccessProfile{IsTemporary: true}, policy: "allow_temporary_when_full", wantErr: true},
+		{name: "临时车配置后允许", profile: request.EntryAccessProfile{IsTemporary: true}, policy: "allow_temporary_when_full", allow: true},
+		{name: "月租车配置后允许", profile: request.EntryAccessProfile{HasValidMonthlyCard: true}, policy: "allow_monthly_when_full", allow: true},
+		{name: "VIP车配置后允许", profile: request.EntryAccessProfile{IsVIP: true}, policy: "allow_vip_when_full", allow: true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			svc := setupParkingSessionTest(t)
+			updates := map[string]interface{}{
+				"capacity": 0, "occupied": 0, "available": 0,
+				"allow_temporary_when_full": false,
+				"allow_monthly_when_full":   false,
+				"allow_vip_when_full":       false,
+			}
+			if tt.policy != "" {
+				updates[tt.policy] = tt.allow
+			}
+			require.NoError(t, global.GVA_DB.Model(&dao.ParkingLot{}).Where("id = ?", 1).Updates(updates).Error)
+
+			session, err := svc.OpenSessionTx(global.GVA_DB, request.OpenSessionRequest{
+				PlateNumber: "FULL-" + tt.name, ParkingLotID: 1, AccessProfile: tt.profile,
+			})
+			if tt.wantErr {
+				require.ErrorIs(t, err, ErrParkingLotFull)
+				require.Nil(t, session)
+			} else {
+				require.NoError(t, err)
+				require.NotNil(t, session)
+			}
+
+			var lot dao.ParkingLot
+			require.NoError(t, global.GVA_DB.First(&lot, 1).Error)
+			if tt.wantErr {
+				require.Zero(t, lot.Occupied)
+			} else {
+				require.Equal(t, 1, lot.Occupied)
+			}
+			require.Zero(t, lot.Available)
+		})
+	}
+}
+
+func TestParkingSessionOverflowExitKeepsAvailabilityAccurate(t *testing.T) {
+	svc := setupParkingSessionTest(t)
+	require.NoError(t, global.GVA_DB.Model(&dao.ParkingLot{}).Where("id = ?", 1).Updates(map[string]interface{}{
+		"capacity": 1, "occupied": 0, "available": 1, "allow_vip_when_full": true,
+	}).Error)
+
+	regular, err := svc.OpenSessionTx(global.GVA_DB, request.OpenSessionRequest{PlateNumber: "REGULAR", ParkingLotID: 1})
+	require.NoError(t, err)
+	overflow, err := svc.OpenSessionTx(global.GVA_DB, request.OpenSessionRequest{
+		PlateNumber: "VIP-OVERFLOW", ParkingLotID: 1, AccessProfile: request.EntryAccessProfile{IsVIP: true},
+	})
+	require.NoError(t, err)
+
+	closeSession := func(session *dao.VehicleRecord) {
+		require.NoError(t, global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+			if _, err := svc.MarkPaidTx(tx, session.ID, "counter", "free", 0); err != nil {
+				return err
+			}
+			_, err := svc.CloseSessionTx(tx, request.CloseSessionRequest{SessionID: session.ID})
+			return err
+		}))
+	}
+	closeSession(overflow)
+
+	var lot dao.ParkingLot
+	require.NoError(t, global.GVA_DB.First(&lot, 1).Error)
+	require.Equal(t, 1, lot.Occupied)
+	require.Zero(t, lot.Available)
+
+	closeSession(regular)
+	require.NoError(t, global.GVA_DB.First(&lot, 1).Error)
+	require.Zero(t, lot.Occupied)
+	require.Equal(t, 1, lot.Available)
+}

+ 240 - 94
internal/modules/printer/api.go

@@ -1,144 +1,290 @@
 package printer
 
 import (
+	"crypto/rand"
+	"encoding/hex"
 	"fmt"
-	"time"
+	"strings"
 
 	"github.com/gin-gonic/gin"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
+	commonModel "wails-app/internal/model/common"
 	"wails-app/internal/model/common/response"
-	"wails-app/internal/model/vehicle/request"
 	printService "wails-app/internal/modules/printer/service"
-	svc "wails-app/internal/service"
+	"wails-app/internal/service"
 )
 
 var printSvc = printService.NewPrintService()
-var vehicleSvc = svc.ServiceGroupApp.VehicleServiceGroup.VehicleService
+
+type ticketMachinePassage interface {
+	HandlePassage(commonModel.PassageRequest) (*commonModel.PassageResult, error)
+}
+
+type ticketMachinePrinter interface {
+	PrintTicket(printerID uint, lotName, channelCode, plateNo string, ticketID uint, ticketNo string) error
+}
 
 type buttonReq struct {
 	PrinterID   uint   `json:"printer_id"`
 	ChannelCode string `json:"channel_code"`
+	DeviceCode  string `json:"device_code"`
 	PlateNo     string `json:"plate_number"`
 }
 
-func TicketMachineButton(c *gin.Context) {
-	var req buttonReq
-	if err := c.ShouldBindJSON(&req); err != nil {
-		response.FailWithMessage(err.Error(), c)
-		return
+type rawPrintDebugReq struct {
+	DeviceName string `json:"device_name"`
+}
+
+type ticketMachineResult struct {
+	SessionID        uint   `json:"session_id"`
+	TicketID         uint   `json:"ticket_id"`
+	TicketNo         string `json:"ticket_no"`
+	SlipNo           uint   `json:"slip_no"`
+	EntryTime        int64  `json:"entry_time"`
+	PlateNumber      string `json:"plate_number"`
+	AnonymousVehicle bool   `json:"anonymous_vehicle"`
+	ParkingLotID     uint   `json:"parking_lot_id"`
+	ParkingLotName   string `json:"parking_lot_name"`
+	ChannelCode      string `json:"channel_code"`
+	ChannelName      string `json:"channel_name"`
+	DeviceCode       string `json:"device_code"`
+	DeviceName       string `json:"device_name"`
+	BusinessStatus   string `json:"business_status"`
+	GateStatus       string `json:"gate_status"`
+	PrintStatus      string `json:"print_status"`
+}
+
+func generateTicketMachineIdentity(prefix string) (string, error) {
+	data := make([]byte, 8)
+	if _, err := rand.Read(data); err != nil {
+		return "", fmt.Errorf("生成票机车辆标识失败: %w", err)
 	}
+	return prefix + strings.ToUpper(hex.EncodeToString(data)), nil
+}
 
-	// 创建入场记录(临时车,可能无车牌)
-	entryResp, err := vehicleSvc.VehicleEntry(request.VehicleEntry{
-		PlateNumber: req.PlateNo, RFIDTag: "",
-		ParkingLotID: 1, ParkingSpaceID: 1,
-	})
-	if err != nil {
-		response.FailWithMessage(err.Error(), c)
-		return
+func resolveTicketMachineLocation(req buttonReq) (*dao.Channel, *dao.UHFReader, *dao.ParkingLot, error) {
+	if global.GVA_DB == nil {
+		return nil, nil, nil, fmt.Errorf("数据库未初始化")
+	}
+	channelCode := strings.TrimSpace(req.ChannelCode)
+	if channelCode == "" {
+		return nil, nil, nil, fmt.Errorf("通道编码不能为空")
 	}
 
-	// 获取刚创建的数字票
-	var tickets []dao.DigitalTicket
-	global.GVA_DB.Where("plate_number = ?", entryResp.Data.PlateNumber).Order("id DESC").Limit(1).Find(&tickets)
-	if len(tickets) == 0 {
-		response.FailWithMessage("数字票创建失败", c)
-		return
+	var channel dao.Channel
+	if err := global.GVA_DB.Where("channel_code = ?", channelCode).First(&channel).Error; err != nil {
+		return nil, nil, nil, fmt.Errorf("票机通道不存在: %s", channelCode)
+	}
+	if channel.Direction != "in" && channel.Direction != "inout" {
+		return nil, nil, nil, fmt.Errorf("票机只能绑定入口或双向通道: %s", channelCode)
+	}
+	if channel.ParkingLotID == 0 {
+		return nil, nil, nil, fmt.Errorf("票机通道未配置停车场: %s", channelCode)
+	}
+
+	var devices []dao.UHFReader
+	deviceCode := strings.TrimSpace(req.DeviceCode)
+	query := global.GVA_DB.Where("channel_id = ? AND is_active = ?", channel.ID, true)
+	if deviceCode != "" {
+		query = query.Where("device_code = ?", deviceCode)
+	}
+	if err := query.Order("id").Find(&devices).Error; err != nil {
+		return nil, nil, nil, err
+	}
+	if len(devices) == 0 {
+		if deviceCode == "" {
+			return nil, nil, nil, fmt.Errorf("票机通道没有绑定启用的闸机设备: %s", channelCode)
+		}
+		return nil, nil, nil, fmt.Errorf("设备未启用或不属于票机通道: %s", deviceCode)
+	}
+	if len(devices) > 1 {
+		return nil, nil, nil, fmt.Errorf("票机通道绑定了多个启用设备,必须指定 device_code")
 	}
 
-	// 获取停车场名
 	var lot dao.ParkingLot
-	global.GVA_DB.First(&lot, entryResp.Data.ParkingLotID)
+	if err := global.GVA_DB.First(&lot, channel.ParkingLotID).Error; err != nil {
+		return nil, nil, nil, fmt.Errorf("票机通道所属停车场不存在")
+	}
+	return &channel, &devices[0], &lot, nil
+}
 
-	// 打印小票
-	if err := printSvc.PrintTicket(req.PrinterID, lot.LotName, req.ChannelCode, req.PlateNo, tickets[0].ID, tickets[0].TicketNo); err != nil {
-		response.FailWithMessage("打印失败: "+err.Error(), c)
-		return
+func processTicketMachineButton(req buttonReq, passage ticketMachinePassage, printer ticketMachinePrinter) (*ticketMachineResult, error) {
+	if req.PrinterID == 0 {
+		return nil, fmt.Errorf("打印机ID不能为空")
+	}
+	channel, device, lot, err := resolveTicketMachineLocation(req)
+	if err != nil {
+		return nil, err
 	}
 
-	response.OkWithData(gin.H{
-		"ticket_id":    tickets[0].ID,
-		"ticket_no":    tickets[0].TicketNo,
-		"slip_no":      tickets[0].ID,
-		"entry_time":   entryResp.Data.EntryTime,
-		"plate_number": entryResp.Data.PlateNumber,
-	}, c)
+	displayPlate := strings.TrimSpace(req.PlateNo)
+	plateNumber := displayPlate
+	anonymous := plateNumber == ""
+	if anonymous {
+		plateNumber, err = generateTicketMachineIdentity("TKT")
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	passageResult, passageErr := passage.HandlePassage(commonModel.PassageRequest{
+		PlateNumber:   plateNumber,
+		DeviceCode:    device.DeviceCode,
+		Direction:     "in",
+		TriggerSource: "ticket_machine",
+	})
+	if passageResult == nil {
+		if passageErr == nil {
+			passageErr = fmt.Errorf("统一通行入口未返回处理结果")
+		}
+		return nil, passageErr
+	}
+
+	result := &ticketMachineResult{
+		SessionID:        passageResult.SessionID,
+		TicketID:         passageResult.TicketID,
+		TicketNo:         passageResult.TicketNo,
+		SlipNo:           passageResult.TicketID,
+		EntryTime:        passageResult.EntryTime,
+		PlateNumber:      passageResult.PlateNumber,
+		AnonymousVehicle: anonymous,
+		ParkingLotID:     lot.ID,
+		ParkingLotName:   lot.LotName,
+		ChannelCode:      channel.ChannelCode,
+		ChannelName:      channel.ChannelName,
+		DeviceCode:       device.DeviceCode,
+		DeviceName:       device.DeviceName,
+		BusinessStatus:   "completed",
+		GateStatus:       passageResult.GateStatus,
+		PrintStatus:      "pending",
+	}
+	if !passageResult.BusinessCompleted {
+		result.BusinessStatus = "failed"
+		if passageErr == nil {
+			passageErr = fmt.Errorf("统一通行入口未完成入场业务")
+		}
+		return result, passageErr
+	}
+	if result.SessionID == 0 || result.TicketID == 0 || result.TicketNo == "" {
+		result.PrintStatus = "skipped"
+		return result, fmt.Errorf("统一通行入口未返回新建会话和数字票")
+	}
+
+	if err := printer.PrintTicket(req.PrinterID, lot.LotName, channel.ChannelCode, displayPlate, result.TicketID, result.TicketNo); err != nil {
+		result.PrintStatus = "failed"
+		if passageErr != nil {
+			return result, fmt.Errorf("%v;打印失败: %w", passageErr, err)
+		}
+		return result, fmt.Errorf("打印失败: %w", err)
+	}
+	result.PrintStatus = "printed"
+	if passageErr != nil {
+		return result, passageErr
+	}
+	return result, nil
 }
 
-func SetupPrinterRouter(router *gin.RouterGroup) {
-	router.POST("/ticket-machine/button", TicketMachineButton)
+func handleTicketMachineButton(c *gin.Context, req buttonReq) {
+	result, err := processTicketMachineButton(
+		req,
+		&service.ServiceGroupApp.ParkingServiceGroup.PassageService,
+		printSvc,
+	)
+	if err != nil {
+		if result != nil {
+			response.FailWithDetailed(result, err.Error(), c)
+			return
+		}
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(result, c)
 }
 
-// SetupPrinterPublic 公开测试路由(无需JWT)
-func SetupPrinterPublic(router *gin.RouterGroup) {
-	router.GET("/ticket-machine/test", TicketMachineButtonTest)
-	router.GET("/ticket-machine/debug", func(c *gin.Context) {
-		printService.InitCSN(printService.FindCsnDllPath())
-		printService.Csn_EnumUSB()
-		printService.Csn_EnumPRN()
-		h, e := printService.Csn_OpenUSB("VID_0FE6&PID_811E#USB001")
-		printService.Csn_SetPort(h)
-		testData := []byte{0x1B, 0x40, 'T', 'E', 'S', 'T', ' ', 'P', 'R', 'I', 'N', 'T', '\n', '\n', '\n', '\n', 0x1B, 0x69}
-		n := printService.Csn_WriteData(testData)
-		response.OkWithData(gin.H{
-			"handle": h, "err": fmt.Sprint(e), "bytes_written": n,
-		}, c)
-	})
-	router.GET("/ticket-machine/usb-list", func(c *gin.Context) {
-		printService.InitCSN(printService.FindCsnDllPath())
-		usb := printService.Csn_EnumUSBNames()
-		prn := printService.Csn_EnumPRNNames()
-		response.OkWithData(gin.H{"usb": usb, "prn": prn}, c)
-	})
+// TicketMachineButton 处理真实票机按钮事件,业务统一进入 PassageService。
+func TicketMachineButton(c *gin.Context) {
+	var req buttonReq
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	handleTicketMachineButton(c, req)
 }
 
 func TicketMachineButtonTest(c *gin.Context) {
-	// 每次生成唯一测试车牌
-	testPlate := fmt.Sprintf("TEST%04d", time.Now().Unix()%10000)
-	entryResp, err := vehicleSvc.VehicleEntry(request.VehicleEntry{
-		PlateNumber: testPlate, RFIDTag: "",
-		ParkingLotID: 1, ParkingSpaceID: 1,
-	})
-	if err != nil {
+	var req buttonReq
+	if err := c.ShouldBindJSON(&req); err != nil {
 		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	var tickets []dao.DigitalTicket
-	global.GVA_DB.Where("plate_number = ?", entryResp.Data.PlateNumber).Order("id DESC").Limit(1).Find(&tickets)
-	if len(tickets) == 0 {
-		response.FailWithMessage("票创建失败", c)
+	plateNumber, err := generateTicketMachineIdentity("TEST")
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
 		return
 	}
+	req.PlateNo = plateNumber
+	handleTicketMachineButton(c, req)
+}
 
-	// 从入场记录获取真实 ParkingLotID,再查停车场名
-	var rec dao.VehicleRecord
-	if err := global.GVA_DB.Where("plate_number = ?", entryResp.Data.PlateNumber).Order("id DESC").First(&rec).Error; err != nil {
-		response.FailWithMessage("查入场记录失败: "+err.Error(), c)
+func ticketMachineRawPrintDebug(c *gin.Context) {
+	var req rawPrintDebugReq
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
 		return
 	}
-	var lot dao.ParkingLot
-	lot.ID = rec.ParkingLotID
-	global.GVA_DB.First(&lot)
-	lotName := lot.LotName
-	if lotName == "" {
-		lotName = fmt.Sprintf("Lot#%d", rec.ParkingLotID)
+	req.DeviceName = strings.TrimSpace(req.DeviceName)
+	if req.DeviceName == "" {
+		response.FailWithMessage("打印设备名称不能为空", c)
+		return
 	}
-
-	// 尝试打印
-	printErr := ""
-	if err := printSvc.PrintTicket(1, lotName, "IN01", entryResp.Data.PlateNumber, tickets[0].ID, tickets[0].TicketNo); err != nil {
-		printErr = err.Error()
+	if err := printService.InitCSN(printService.FindCsnDllPath()); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	printService.Csn_EnumUSB()
+	printService.Csn_EnumPRN()
+	h, err := printService.Csn_OpenUSB(req.DeviceName)
+	if err != nil {
+		h, err = printService.Csn_OpenPRN(req.DeviceName)
+	}
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	defer printService.Csn_Close(h)
+	printService.Csn_SetPort(h)
+	testData := []byte{0x1B, 0x40, 'T', 'E', 'S', 'T', ' ', 'P', 'R', 'I', 'N', 'T', '\n', '\n', '\n', '\n', 0x1B, 0x69}
+	n := printService.Csn_WriteData(testData)
+	if n != len(testData) {
+		response.FailWithDetailed(gin.H{"bytes_written": n, "bytes_expected": len(testData)}, "打印数据未完整写入", c)
+		return
 	}
-
 	response.OkWithData(gin.H{
-		"ticket_id":    tickets[0].ID,
-		"ticket_no":    tickets[0].TicketNo,
-		"slip_no":      tickets[0].ID,
-		"plate_number": entryResp.Data.PlateNumber,
-		"entry_time":   entryResp.Data.EntryTime,
-		"lot_name":     lotName,
-		"channel_code": "IN01",
-		"print_result": printErr,
+		"device_name": req.DeviceName, "bytes_written": n,
 	}, c)
 }
+
+func ticketMachineUSBList(c *gin.Context) {
+	if err := printService.InitCSN(printService.FindCsnDllPath()); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	usb := printService.Csn_EnumUSBNames()
+	prn := printService.Csn_EnumPRNNames()
+	response.OkWithData(gin.H{"usb": usb, "prn": prn}, c)
+}
+
+func SetupPrinterRouter(router *gin.RouterGroup) {
+	router.POST("/ticket-machine/button", TicketMachineButton)
+}
+
+// SetupPrinterDebugRoutes 仅在显式开发配置开启时注册,调用方必须使用私有鉴权路由组。
+func SetupPrinterDebugRoutes(router *gin.RouterGroup, enabled bool) {
+	if !enabled {
+		return
+	}
+	router.POST("/ticket-machine/test", TicketMachineButtonTest)
+	router.POST("/ticket-machine/debug", ticketMachineRawPrintDebug)
+	router.GET("/ticket-machine/usb-list", ticketMachineUSBList)
+}

+ 177 - 0
internal/modules/printer/api_test.go

@@ -0,0 +1,177 @@
+package printer
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	commonModel "wails-app/internal/model/common"
+)
+
+type fakeTicketMachinePassage struct {
+	req    commonModel.PassageRequest
+	result *commonModel.PassageResult
+	err    error
+}
+
+func (f *fakeTicketMachinePassage) HandlePassage(req commonModel.PassageRequest) (*commonModel.PassageResult, error) {
+	f.req = req
+	return f.result, f.err
+}
+
+type fakeTicketMachinePrinter struct {
+	printerID   uint
+	lotName     string
+	channelCode string
+	plateNo     string
+	ticketID    uint
+	ticketNo    string
+	err         error
+}
+
+func (f *fakeTicketMachinePrinter) PrintTicket(printerID uint, lotName, channelCode, plateNo string, ticketID uint, ticketNo string) error {
+	f.printerID = printerID
+	f.lotName = lotName
+	f.channelCode = channelCode
+	f.plateNo = plateNo
+	f.ticketID = ticketID
+	f.ticketNo = ticketNo
+	return f.err
+}
+
+func setupTicketMachineTest(t *testing.T) (*dao.ParkingLot, *dao.Channel) {
+	t.Helper()
+	dsn := fmt.Sprintf("file:ticket-machine-%d?mode=memory&cache=shared", time.Now().UnixNano())
+	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+	require.NoError(t, err)
+	sqlDB, err := db.DB()
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		_ = sqlDB.Close()
+		if global.GVA_DB == db {
+			global.GVA_DB = nil
+		}
+	})
+	global.GVA_DB = db
+	require.NoError(t, db.AutoMigrate(&dao.ParkingLot{}, &dao.Channel{}, &dao.UHFReader{}))
+
+	lot := &dao.ParkingLot{LotCode: "LOT-TICKET", LotName: "票机停车场", Capacity: 50, Available: 50}
+	require.NoError(t, db.Create(lot).Error)
+	channel := &dao.Channel{
+		ChannelCode: "TICKET-IN", ChannelName: "票机入口", Direction: "in",
+		AllowTemporary: true, ParkingLotID: lot.ID,
+	}
+	require.NoError(t, db.Create(channel).Error)
+	return lot, channel
+}
+
+func createTicketMachineDevice(t *testing.T, channelID uint, code string) *dao.UHFReader {
+	t.Helper()
+	device := &dao.UHFReader{
+		DeviceCode: code, DeviceName: code + "名称", DeviceType: "gate",
+		ConnectType: "tcp", IsActive: true, ChannelID: channelID,
+	}
+	require.NoError(t, global.GVA_DB.Create(device).Error)
+	return device
+}
+
+func TestProcessTicketMachineButtonUsesUnifiedPassageAndReturnedTicket(t *testing.T) {
+	lot, channel := setupTicketMachineTest(t)
+	device := createTicketMachineDevice(t, channel.ID, "GATE-TICKET-01")
+	passage := &fakeTicketMachinePassage{result: &commonModel.PassageResult{
+		Success: true, Direction: "in", PlateNumber: "TKT-RESULT",
+		SessionID: 41, TicketID: 73, TicketNo: "ticket-no-73", EntryTime: 123456,
+		BusinessCompleted: true, GateOpened: true, GateStatus: "opened",
+	}}
+	printer := &fakeTicketMachinePrinter{}
+
+	result, err := processTicketMachineButton(buttonReq{
+		PrinterID: 9, ChannelCode: channel.ChannelCode,
+	}, passage, printer)
+	require.NoError(t, err)
+	require.True(t, result.AnonymousVehicle)
+	require.Equal(t, lot.ID, result.ParkingLotID)
+	require.Equal(t, channel.ChannelCode, result.ChannelCode)
+	require.Equal(t, device.DeviceCode, result.DeviceCode)
+	require.EqualValues(t, 41, result.SessionID)
+	require.EqualValues(t, 73, result.TicketID)
+	require.Equal(t, "ticket-no-73", result.TicketNo)
+	require.Equal(t, "printed", result.PrintStatus)
+
+	require.Equal(t, device.DeviceCode, passage.req.DeviceCode)
+	require.Equal(t, "in", passage.req.Direction)
+	require.Equal(t, "ticket_machine", passage.req.TriggerSource)
+	require.True(t, strings.HasPrefix(passage.req.PlateNumber, "TKT"))
+	require.Len(t, passage.req.PlateNumber, 19)
+	require.EqualValues(t, 9, printer.printerID)
+	require.Equal(t, lot.LotName, printer.lotName)
+	require.Equal(t, channel.ChannelCode, printer.channelCode)
+	require.Empty(t, printer.plateNo)
+	require.EqualValues(t, 73, printer.ticketID)
+	require.Equal(t, "ticket-no-73", printer.ticketNo)
+}
+
+func TestProcessTicketMachineButtonRequiresDeviceWhenChannelHasMultiple(t *testing.T) {
+	_, channel := setupTicketMachineTest(t)
+	createTicketMachineDevice(t, channel.ID, "GATE-A")
+	createTicketMachineDevice(t, channel.ID, "GATE-B")
+
+	result, err := processTicketMachineButton(buttonReq{
+		PrinterID: 1, ChannelCode: channel.ChannelCode,
+	}, &fakeTicketMachinePassage{}, &fakeTicketMachinePrinter{})
+	require.Nil(t, result)
+	require.EqualError(t, err, "票机通道绑定了多个启用设备,必须指定 device_code")
+}
+
+func TestProcessTicketMachineButtonKeepsBusinessResultWhenPrintFails(t *testing.T) {
+	_, channel := setupTicketMachineTest(t)
+	createTicketMachineDevice(t, channel.ID, "GATE-PRINT")
+	passage := &fakeTicketMachinePassage{result: &commonModel.PassageResult{
+		SessionID: 8, TicketID: 9, TicketNo: "ticket-9", PlateNumber: "TEST-PLATE",
+		EntryTime: 100, BusinessCompleted: true, GateStatus: "opened",
+	}}
+	printer := &fakeTicketMachinePrinter{err: fmt.Errorf("paper out")}
+
+	result, err := processTicketMachineButton(buttonReq{
+		PrinterID: 1, ChannelCode: channel.ChannelCode, PlateNo: "TEST-PLATE",
+	}, passage, printer)
+	require.ErrorContains(t, err, "打印失败")
+	require.EqualValues(t, 8, result.SessionID)
+	require.EqualValues(t, 9, result.TicketID)
+	require.Equal(t, "completed", result.BusinessStatus)
+	require.Equal(t, "failed", result.PrintStatus)
+}
+
+func TestPrinterDebugRoutesAreDisabledByDefault(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	group := router.Group("")
+	SetupPrinterRouter(group)
+	SetupPrinterDebugRoutes(group, false)
+
+	routes := router.Routes()
+	require.Len(t, routes, 1)
+	require.Equal(t, "POST", routes[0].Method)
+	require.Equal(t, "/ticket-machine/button", routes[0].Path)
+}
+
+func TestPrinterDebugRoutesUseSafeMethodsWhenEnabled(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	SetupPrinterDebugRoutes(router.Group(""), true)
+
+	methods := map[string]string{}
+	for _, route := range router.Routes() {
+		methods[route.Path] = route.Method
+	}
+	require.Equal(t, "POST", methods["/ticket-machine/test"])
+	require.Equal(t, "POST", methods["/ticket-machine/debug"])
+	require.Equal(t, "GET", methods["/ticket-machine/usb-list"])
+}

+ 5 - 0
internal/modules/printer/service/csn_dll.go

@@ -136,6 +136,11 @@ func csn_Close(handle uintptr) {
 	}
 }
 
+// Csn_Close 关闭调试接口或外部适配层打开的 CSN 端口。
+func Csn_Close(handle uintptr) {
+	csn_Close(handle)
+}
+
 // === Print functions ===
 
 func csn_Reset() {

+ 8 - 1
internal/router/parking/parking.go

@@ -9,7 +9,6 @@ import (
 
 func SetupParkingRouter(router *gin.RouterGroup) {
 	router.GET("/channel/events", parking.GetChannelEvents)
-	router.GET("/channel/test-event", parking.SimulateEvent)
 
 	parkingGroup := router.Group("/parking")
 	gateGroup := parkingGroup.Group("/gate")
@@ -59,3 +58,11 @@ func SetupParkingRouter(router *gin.RouterGroup) {
 		deviceGroup.DELETE("/delete", uhf.DeleteReader)
 	}
 }
+
+// SetupParkingDebugRouter 仅在显式开发配置开启时注册模拟事件接口。
+func SetupParkingDebugRouter(router *gin.RouterGroup, enabled bool) {
+	if !enabled {
+		return
+	}
+	router.POST("/channel/test-event", parking.SimulateEvent)
+}

+ 22 - 0
internal/router/parking/parking_test.go

@@ -0,0 +1,22 @@
+package parking
+
+import (
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParkingDebugRouteRegistration(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	disabled := gin.New()
+	SetupParkingDebugRouter(disabled.Group(""), false)
+	require.Empty(t, disabled.Routes())
+
+	enabled := gin.New()
+	SetupParkingDebugRouter(enabled.Group(""), true)
+	routes := enabled.Routes()
+	require.Len(t, routes, 1)
+	require.Equal(t, "POST", routes[0].Method)
+	require.Equal(t, "/channel/test-event", routes[0].Path)
+}

+ 57 - 0
internal/service/parking/gate.go

@@ -9,10 +9,20 @@ import (
 	"go.uber.org/zap"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
+	incidentService "wails-app/internal/modules/incident/service"
 )
 
 const defaultGateValidTime byte = 2
 
+var incidentSvc = incidentService.NewIncidentService()
+
+// GateCommandContext 道闸指令上下文(用于指令流水审计)。
+type GateCommandContext struct {
+	Source     string // passage / manual / test
+	OperatorID uint
+	SessionID  uint
+}
+
 // GateController 统一道闸设备控制接口,具体协议由设备模块实现。
 type GateController interface {
 	OpenGate(deviceCode string, validTime byte) error
@@ -143,6 +153,53 @@ func (s *PassageService) CloseGateByDeviceCode(deviceCode string, validTime byte
 	return controller.CloseGate(deviceCode, validTime)
 }
 
+// OpenGateWithContext 开闸并记录指令流水(所有开闸路径的审计入口),返回流水 ID。
+func (s *PassageService) OpenGateWithContext(ctx GateCommandContext, deviceCode string, validTime byte) (uint, error) {
+	return s.runGateCommand(ctx, "open", deviceCode, validTime)
+}
+
+// CloseGateWithContext 关闸并记录指令流水,返回流水 ID。
+func (s *PassageService) CloseGateWithContext(ctx GateCommandContext, deviceCode string, validTime byte) (uint, error) {
+	return s.runGateCommand(ctx, "close", deviceCode, validTime)
+}
+
+// runGateCommand 执行道闸指令并写指令流水(best-effort,流水失败不影响指令结果)。
+func (s *PassageService) runGateCommand(ctx GateCommandContext, action, deviceCode string, validTime byte) (uint, error) {
+	if ctx.Source == "" {
+		ctx.Source = "passage"
+	}
+	start := time.Now()
+	var err error
+	if action == "open" {
+		err = s.OpenGateByDeviceCode(deviceCode, validTime)
+	} else {
+		err = s.CloseGateByDeviceCode(deviceCode, validTime)
+	}
+
+	rec := incidentService.GateCommandRecord{
+		DeviceCode: deviceCode,
+		Action:     action,
+		Source:     ctx.Source,
+		OperatorID: ctx.OperatorID,
+		SessionID:  ctx.SessionID,
+		DurationMs: time.Since(start).Milliseconds(),
+	}
+	if err != nil {
+		rec.Result = incidentService.CommandFailed
+		rec.ErrorMessage = err.Error()
+	} else {
+		rec.Result = incidentService.CommandSuccess
+	}
+	// 设备名称快照(查询失败不影响指令结果)
+	var device dao.UHFReader
+	if global.GVA_DB != nil {
+		if qerr := global.GVA_DB.Where("device_code = ?", deviceCode).First(&device).Error; qerr == nil {
+			rec.DeviceName = device.DeviceName
+		}
+	}
+	return incidentSvc.RecordGateCommand(rec), err
+}
+
 // ListGateDevices 返回已启用且已绑定通道的道闸设备。
 func (s *PassageService) ListGateDevices() ([]GateDeviceOption, error) {
 	if global.GVA_DB == nil {

+ 151 - 0
internal/service/parking/incident_hook_test.go

@@ -0,0 +1,151 @@
+package parking
+
+import (
+	"errors"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/zap"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	incidentService "wails-app/internal/modules/incident/service"
+)
+
+// failingGateController 注入失败的闸机控制器,模拟开闸失败。
+type failingGateController struct{}
+
+func (c *failingGateController) OpenGate(deviceCode string, _ byte) error {
+	return errors.New("relay timeout")
+}
+
+func (c *failingGateController) CloseGate(deviceCode string, _ byte) error {
+	return errors.New("relay timeout")
+}
+
+func (c *failingGateController) IsGateConnected(string) bool { return true }
+
+func setupIncidentHookTest(t *testing.T) {
+	t.Helper()
+	dsn := fmt.Sprintf("file:incident-hook-%d?mode=memory&cache=shared", time.Now().UnixNano())
+	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+	require.NoError(t, err)
+	sqlDB, err := db.DB()
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		_ = sqlDB.Close()
+		if global.GVA_DB == db {
+			global.GVA_DB = nil
+		}
+	})
+	global.GVA_DB = db
+	global.GVA_LOG = zap.NewNop() // 避免 nil 日志
+	t.Cleanup(func() { global.GVA_LOG = nil })
+	require.NoError(t, db.AutoMigrate(&dao.IncidentRecord{}, &dao.DeviceCommandLog{}, &dao.UHFReader{}))
+}
+
+// TestOpenGateFailureCreatesIncidentAndCommandLog 开闸失败时:
+// 指令流水(failed) + 异常事件(gate_failed) 同时产生,且流水回填异常关联。
+func TestOpenGateFailureCreatesIncidentAndCommandLog(t *testing.T) {
+	setupIncidentHookTest(t)
+	previous := currentGateController()
+	SetGateController(&failingGateController{})
+	t.Cleanup(func() { SetGateController(previous) })
+
+	require.NoError(t, global.GVA_DB.Create(&dao.UHFReader{DeviceCode: "GATE-01", DeviceName: "东门道闸"}).Error)
+
+	svc := &PassageService{}
+	commandLogID, err := svc.OpenGateWithContext(GateCommandContext{
+		Source: incidentService.SourcePassage, SessionID: 7,
+	}, "GATE-01", 0)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "relay timeout")
+
+	// 调用方(passage.go 同款逻辑):记录异常并回填指令流水关联
+	incidentID := incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+		Category:        incidentService.CategoryGateFailed,
+		Source:          incidentService.SourcePassage,
+		VehicleRecordID: 7,
+		DeviceCode:      "GATE-01",
+		Description:     "入场记录已创建,但开闸失败",
+		Detail:          err.Error(),
+	})
+	require.NotZero(t, incidentID)
+	incidentSvc.LinkIncidentToCommand(commandLogID, incidentID)
+
+	// 指令流水已写入且为 failed
+	require.NotZero(t, commandLogID)
+	var cmd dao.DeviceCommandLog
+	require.NoError(t, global.GVA_DB.First(&cmd, commandLogID).Error)
+	require.Equal(t, "open", cmd.Action)
+	require.Equal(t, incidentService.CommandFailed, cmd.Result)
+	require.Equal(t, "东门道闸", cmd.DeviceName)
+	require.Equal(t, uint(7), cmd.SessionID)
+
+	// 异常事件已生成(gate_failed)
+	var incident dao.IncidentRecord
+	require.NoError(t, global.GVA_DB.Where("category = ?", incidentService.CategoryGateFailed).First(&incident).Error)
+	require.Equal(t, incidentService.StatusPending, incident.Status)
+	require.Equal(t, incidentService.SourcePassage, incident.Source)
+	require.Equal(t, incidentService.LevelCritical, incident.Level)
+	require.Equal(t, "GATE-01", incident.DeviceCode)
+
+	// 指令流水回填了异常关联
+	require.NoError(t, global.GVA_DB.First(&cmd, commandLogID).Error)
+	require.Equal(t, incident.ID, cmd.IncidentID)
+}
+
+// TestOpenGateSuccessRecordsCommandLog 开闸成功只写指令流水,不产生异常。
+func TestOpenGateSuccessRecordsCommandLog(t *testing.T) {
+	setupIncidentHookTest(t)
+	previous := currentGateController()
+	SetGateController(&gateControllerStub{})
+	t.Cleanup(func() { SetGateController(previous) })
+
+	svc := &PassageService{}
+	commandLogID, err := svc.OpenGateWithContext(GateCommandContext{
+		Source: incidentService.SourceManual, OperatorID: 5,
+	}, "GATE-01", 0)
+	require.NoError(t, err)
+	require.NotZero(t, commandLogID)
+
+	var cmd dao.DeviceCommandLog
+	require.NoError(t, global.GVA_DB.First(&cmd, commandLogID).Error)
+	require.Equal(t, incidentService.CommandSuccess, cmd.Result)
+	require.Equal(t, incidentService.SourceManual, cmd.Source)
+	require.Equal(t, uint(5), cmd.OperatorID)
+
+	var count int64
+	require.NoError(t, global.GVA_DB.Model(&dao.IncidentRecord{}).Count(&count).Error)
+	require.Equal(t, int64(0), count)
+}
+
+// TestManualRaiseRecordedAsResolved 人工抬杆(带原因)应生成已解决的 manual_raise 记录。
+func TestManualRaiseRecordedAsResolved(t *testing.T) {
+	setupIncidentHookTest(t)
+
+	// 模拟 API 层人工开闸成功后的埋点调用
+	incidentID := incidentService.NewIncidentService().RecordIncident(incidentService.RecordIncidentRequest{
+		Category:        incidentService.CategoryManualRaise,
+		Source:          incidentService.SourceManual,
+		VehicleRecordID: 3,
+		PlateNumber:     "SU-001",
+		DeviceCode:      "GATE-01",
+		OperatorID:      5,
+		Description:     "人工开闸",
+		HandleType:      incidentService.HandleManualGate,
+		HandleRemark:    "车主称设备未识别",
+	})
+	require.NotZero(t, incidentID)
+
+	var incident dao.IncidentRecord
+	require.NoError(t, global.GVA_DB.First(&incident, incidentID).Error)
+	require.Equal(t, incidentService.StatusResolved, incident.Status)
+	require.Equal(t, incidentService.HandleManualGate, incident.HandleType)
+	require.Equal(t, "车主称设备未识别", incident.HandleRemark)
+	require.NotNil(t, incident.HandledAt)
+	require.Equal(t, uint(5), incident.HandlerID)
+}

+ 83 - 58
internal/service/parking/parking_lot.go

@@ -5,6 +5,8 @@ import (
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	"wails-app/internal/model/parking/request"
+
+	"gorm.io/gorm"
 )
 
 type parkingLotService struct{}
@@ -22,44 +24,70 @@ func (s *parkingLotService) CreateParkingLot(req request.ParkingLotCreate) error
 		return errors.New("区域编码已存在")
 	}
 
-	lot := dao.ParkingLot{
-		LotCode:     req.LotCode,
-		LotName:     req.LotName,
-		Capacity:    req.Capacity,
-		Available:   req.Capacity, // 初始可用车位等于总车位
-		Description: req.Description,
-	}
-
-	return global.GVA_DB.Create(&lot).Error
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		lot := dao.ParkingLot{
+			LotCode:     req.LotCode,
+			LotName:     req.LotName,
+			Capacity:    req.Capacity,
+			Occupied:    0,
+			Available:   req.Capacity,
+			Description: req.Description,
+		}
+		if err := tx.Create(&lot).Error; err != nil {
+			return err
+		}
+		// 显式更新布尔值,确保 false 不会被 GORM 的数据库默认值覆盖。
+		return tx.Model(&lot).Updates(map[string]interface{}{
+			"allow_temporary_when_full": req.AllowTemporaryWhenFull,
+			"allow_monthly_when_full":   req.AllowMonthlyWhenFull,
+			"allow_vip_when_full":       req.AllowVIPWhenFull,
+		}).Error
+	})
 }
 
 // UpdateParkingLot 更新停车区域
 func (s *parkingLotService) UpdateParkingLot(req request.ParkingLotUpdate) error {
-	var lot dao.ParkingLot
-	result := global.GVA_DB.First(&lot, req.ID)
-	if result.RowsAffected == 0 {
-		return errors.New("停车区域不存在")
-	}
-
-	// 检查区域编码是否被其他区域使用
-	var existingLot dao.ParkingLot
-	result = global.GVA_DB.Where("lot_code = ? AND id != ?", req.LotCode, req.ID).First(&existingLot)
-	if result.RowsAffected > 0 {
-		return errors.New("区域编码已被其他区域使用")
-	}
-
-	// 计算可用车位的变化
-	capacityDiff := req.Capacity - lot.Capacity
-	lot.LotCode = req.LotCode
-	lot.LotName = req.LotName
-	lot.Capacity = req.Capacity
-	lot.Available += capacityDiff
-	if lot.Available < 0 {
-		lot.Available = 0
-	}
-	lot.Description = req.Description
-
-	return global.GVA_DB.Save(&lot).Error
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var lot dao.ParkingLot
+		if err := tx.First(&lot, req.ID).Error; err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return errors.New("停车区域不存在")
+			}
+			return err
+		}
+
+		var duplicateCount int64
+		if err := tx.Model(&dao.ParkingLot{}).
+			Where("lot_code = ? AND id != ?", req.LotCode, req.ID).
+			Count(&duplicateCount).Error; err != nil {
+			return err
+		}
+		if duplicateCount > 0 {
+			return errors.New("区域编码已被其他区域使用")
+		}
+
+		var occupied int64
+		if err := tx.Model(&dao.VehicleRecord{}).
+			Where("parking_lot_id = ? AND exit_time IS NULL", req.ID).
+			Count(&occupied).Error; err != nil {
+			return err
+		}
+		if int64(req.Capacity) < occupied {
+			return errors.New("停车场容量不能小于当前在场车辆数")
+		}
+
+		return tx.Model(&lot).Updates(map[string]interface{}{
+			"lot_code":                  req.LotCode,
+			"lot_name":                  req.LotName,
+			"capacity":                  req.Capacity,
+			"occupied":                  occupied,
+			"available":                 int64(req.Capacity) - occupied,
+			"allow_temporary_when_full": req.AllowTemporaryWhenFull,
+			"allow_monthly_when_full":   req.AllowMonthlyWhenFull,
+			"allow_vip_when_full":       req.AllowVIPWhenFull,
+			"description":               req.Description,
+		}).Error
+	})
 }
 
 // GetParkingLotByID 根据ID获取停车区域
@@ -104,7 +132,7 @@ func (s *parkingLotService) ListParkingLots(req request.ParkingLotQuery) (int64,
 		query = query.Where("lot_code LIKE ?", "%"+req.LotCode+"%")
 	}
 
-	if req.LotCode != "" {
+	if req.LotId > 0 {
 		query = query.Where("id = ?", req.LotId)
 	}
 
@@ -127,27 +155,24 @@ func (s *parkingLotService) ListParkingLots(req request.ParkingLotQuery) (int64,
 
 // DeleteParkingLot 删除停车区域
 func (s *parkingLotService) DeleteParkingLot(id uint) error {
-	result := global.GVA_DB.Delete(&dao.ParkingLot{}, id)
-	if result.RowsAffected == 0 {
-		return errors.New("停车区域不存在")
-	}
-	return result.Error
-}
-
-// UpdateParkingLotAvailability 更新停车区域可用车位
-func (s *parkingLotService) UpdateParkingLotAvailability(lotID uint, delta int) error {
-	var lot dao.ParkingLot
-	result := global.GVA_DB.First(&lot, lotID)
-	if result.RowsAffected == 0 {
-		return errors.New("停车区域不存在")
-	}
-
-	lot.Available += delta
-	if lot.Available < 0 {
-		lot.Available = 0
-	} else if lot.Available > lot.Capacity {
-		lot.Available = lot.Capacity
-	}
-
-	return global.GVA_DB.Save(&lot).Error
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var lot dao.ParkingLot
+		if err := tx.First(&lot, id).Error; err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return errors.New("停车区域不存在")
+			}
+			return err
+		}
+
+		var activeSessions int64
+		if err := tx.Model(&dao.VehicleRecord{}).
+			Where("parking_lot_id = ? AND exit_time IS NULL", id).
+			Count(&activeSessions).Error; err != nil {
+			return err
+		}
+		if activeSessions > 0 {
+			return errors.New("停车场仍有在场车辆,不能删除")
+		}
+		return tx.Delete(&lot).Error
+	})
 }

+ 98 - 0
internal/service/parking/parking_lot_test.go

@@ -0,0 +1,98 @@
+package parking
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/model/parking/request"
+)
+
+func setupParkingLotTest(t *testing.T) *parkingLotService {
+	t.Helper()
+	dsn := fmt.Sprintf("file:parking-lot-%d?mode=memory&cache=shared", time.Now().UnixNano())
+	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+	require.NoError(t, err)
+	sqlDB, err := db.DB()
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		_ = sqlDB.Close()
+		if global.GVA_DB == db {
+			global.GVA_DB = nil
+		}
+	})
+	global.GVA_DB = db
+	require.NoError(t, db.AutoMigrate(&dao.ParkingLot{}, &dao.VehicleRecord{}))
+	return NewParkingLotService()
+}
+
+func TestCreateParkingLotPersistsFullAdmissionPolicies(t *testing.T) {
+	svc := setupParkingLotTest(t)
+	require.NoError(t, svc.CreateParkingLot(request.ParkingLotCreate{
+		LotCode: "LOT-A", LotName: "A区", Capacity: 20,
+		AllowTemporaryWhenFull: false,
+		AllowMonthlyWhenFull:   true,
+		AllowVIPWhenFull:       false,
+	}))
+
+	var lot dao.ParkingLot
+	require.NoError(t, global.GVA_DB.Where("lot_code = ?", "LOT-A").First(&lot).Error)
+	require.Zero(t, lot.Occupied)
+	require.Equal(t, 20, lot.Available)
+	require.False(t, lot.AllowTemporaryWhenFull)
+	require.True(t, lot.AllowMonthlyWhenFull)
+	require.False(t, lot.AllowVIPWhenFull)
+}
+
+func TestUpdateParkingLotUsesActiveSessionsForAvailability(t *testing.T) {
+	svc := setupParkingLotTest(t)
+	lot := &dao.ParkingLot{LotCode: "LOT-B", LotName: "B区", Capacity: 2, Available: 2}
+	require.NoError(t, global.GVA_DB.Create(lot).Error)
+	require.NoError(t, global.GVA_DB.Create(&dao.VehicleRecord{
+		PlateNumber: "IN-PARK", ParkingLotID: lot.ID, EntryTime: time.Now(), PaymentStatus: "unpaid",
+	}).Error)
+
+	err := svc.UpdateParkingLot(request.ParkingLotUpdate{
+		ID: lot.ID, LotCode: lot.LotCode, LotName: lot.LotName, Capacity: 0,
+	})
+	require.EqualError(t, err, "停车场容量不能小于当前在场车辆数")
+
+	require.NoError(t, svc.UpdateParkingLot(request.ParkingLotUpdate{
+		ID: lot.ID, LotCode: lot.LotCode, LotName: lot.LotName, Capacity: 3,
+		AllowTemporaryWhenFull: true, AllowMonthlyWhenFull: false, AllowVIPWhenFull: true,
+	}))
+	require.NoError(t, global.GVA_DB.First(&lot, lot.ID).Error)
+	require.Equal(t, 1, lot.Occupied)
+	require.Equal(t, 2, lot.Available)
+	require.True(t, lot.AllowTemporaryWhenFull)
+	require.False(t, lot.AllowMonthlyWhenFull)
+	require.True(t, lot.AllowVIPWhenFull)
+}
+
+func TestDeleteParkingLotRejectsActiveSessions(t *testing.T) {
+	svc := setupParkingLotTest(t)
+	lot := &dao.ParkingLot{LotCode: "LOT-C", LotName: "C区", Capacity: 10, Available: 9, Occupied: 1}
+	require.NoError(t, global.GVA_DB.Create(lot).Error)
+	require.NoError(t, global.GVA_DB.Create(&dao.VehicleRecord{
+		PlateNumber: "ACTIVE-CAR", ParkingLotID: lot.ID, EntryTime: time.Now(), PaymentStatus: "unpaid",
+	}).Error)
+
+	require.EqualError(t, svc.DeleteParkingLot(lot.ID), "停车场仍有在场车辆,不能删除")
+	require.NoError(t, global.GVA_DB.First(&dao.ParkingLot{}, lot.ID).Error)
+}
+
+func TestDeleteParkingLotWithoutActiveSessions(t *testing.T) {
+	svc := setupParkingLotTest(t)
+	lot := &dao.ParkingLot{LotCode: "LOT-D", LotName: "D区", Capacity: 10, Available: 10}
+	require.NoError(t, global.GVA_DB.Create(lot).Error)
+
+	require.NoError(t, svc.DeleteParkingLot(lot.ID))
+	var count int64
+	require.NoError(t, global.GVA_DB.Model(&dao.ParkingLot{}).Where("id = ?", lot.ID).Count(&count).Error)
+	require.Zero(t, count)
+}

+ 105 - 2
internal/service/parking/passage.go

@@ -3,12 +3,15 @@ package parking
 import (
 	"errors"
 	"fmt"
+	"strings"
 	"sync"
 	"time"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	common "wails-app/internal/model/common"
 	"wails-app/internal/model/vehicle/request"
+	incidentService "wails-app/internal/modules/incident/service"
+	sessionService "wails-app/internal/modules/parking-session/service"
 	"wails-app/internal/service/vehicle"
 )
 
@@ -91,6 +94,18 @@ func (s *PassageService) HandlePassage(req common.PassageRequest) (*common.Passa
 	isBlack, _ := shortlistSvc.CheckVehicleShortlist(req.PlateNumber, req.RFIDTag)
 	if isBlack {
 		fmt.Printf("🚫 禁止通行 | 车辆在黑名单中 | 设备:%s 车牌:%s RFID:%s\n", req.DeviceCode, req.PlateNumber, req.RFIDTag)
+		incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+			Category:    incidentService.CategoryBlacklist,
+			Source:      incidentService.SourcePassage,
+			PlateNumber: req.PlateNumber,
+			RFIDTag:     req.RFIDTag,
+			ParkingLotID: channel.ParkingLotID,
+			ChannelID:   channel.ID,
+			ChannelCode: channel.ChannelCode,
+			DeviceCode:  req.DeviceCode,
+			OperatorID:  req.OperatorID,
+			Description: "黑名单车辆通行拦截",
+		})
 		return nil, fmt.Errorf("车辆在黑名单中")
 	}
 
@@ -128,6 +143,18 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 	passageMutex.Unlock()
 
 	if !canIn {
+		incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+			Category:    incidentService.CategoryDuplicateEntry,
+			Source:      incidentService.SourcePassage,
+			PlateNumber: req.PlateNumber,
+			RFIDTag:     req.RFIDTag,
+			ParkingLotID: parkingLotID,
+			ChannelID:   channel.ID,
+			ChannelCode: channel.ChannelCode,
+			DeviceCode:  req.DeviceCode,
+			OperatorID:  req.OperatorID,
+			Description: "车辆已在场内,重复入场拦截",
+		})
 		return nil, fmt.Errorf("车辆已在场内: %s", identifier)
 	}
 
@@ -143,8 +170,24 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 		EntryChannelName: channel.ChannelName,
 		EntryDeviceCode:  req.DeviceCode,
 		EntryDeviceName:  device.DeviceName,
+		TriggerMode:      req.TriggerSource,
 	})
 	if err != nil {
+		if errors.Is(err, sessionService.ErrParkingLotFull) {
+			incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+				Category:     incidentService.CategoryLotFull,
+				Source:       incidentService.SourcePassage,
+				PlateNumber:  req.PlateNumber,
+				RFIDTag:      req.RFIDTag,
+				ParkingLotID: parkingLotID,
+				ChannelID:    channel.ID,
+				ChannelCode:  channel.ChannelCode,
+				DeviceCode:   req.DeviceCode,
+				OperatorID:   req.OperatorID,
+				Description:  "停车场满位,入场被拒绝",
+				Detail:       err.Error(),
+			})
+		}
 		return nil, err
 	}
 
@@ -155,6 +198,9 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 	result := &common.PassageResult{
 		Success:           true,
 		Message:           "入场成功",
+		SessionID:         resp.Data.SessionID,
+		TicketID:          resp.Data.TicketID,
+		TicketNo:          resp.Data.TicketNo,
 		Direction:         "in",
 		PlateNumber:       resp.Data.PlateNumber,
 		RFIDTag:           req.RFIDTag,
@@ -165,10 +211,30 @@ func (s *PassageService) handleEntry(req common.PassageRequest, identifier strin
 	}
 
 	fmt.Printf("🟢 入口开闸 | 设备:%s 车牌:%s RFID:%s\n", req.DeviceCode, req.PlateNumber, req.RFIDTag)
-	if err := s.OpenGateByDeviceCode(req.DeviceCode, defaultGateValidTime); err != nil {
+	commandLogID, err := s.OpenGateWithContext(GateCommandContext{
+		Source:    incidentService.SourcePassage,
+		SessionID: resp.Data.SessionID,
+	}, req.DeviceCode, defaultGateValidTime)
+	if err != nil {
 		result.Success = false
 		result.Message = "入场记录已创建,但开闸失败"
 		result.GateStatus = "failed"
+		incidentID := incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+			Category:        incidentService.CategoryGateFailed,
+			Source:          incidentService.SourcePassage,
+			VehicleRecordID: resp.Data.SessionID,
+			TicketNo:        resp.Data.TicketNo,
+			PlateNumber:     resp.Data.PlateNumber,
+			RFIDTag:         req.RFIDTag,
+			ParkingLotID:    parkingLotID,
+			ChannelID:       channel.ID,
+			ChannelCode:     channel.ChannelCode,
+			DeviceCode:      req.DeviceCode,
+			OperatorID:      req.OperatorID,
+			Description:     "入场记录已创建,但开闸失败",
+			Detail:          err.Error(),
+		})
+		incidentSvc.LinkIncidentToCommand(commandLogID, incidentID)
 		return result, gateOperationError("开闸", req.DeviceCode, err)
 	}
 	result.GateOpened = true
@@ -201,6 +267,23 @@ func (s *PassageService) handleExit(req common.PassageRequest, identifier string
 	}
 	resp, err := vehicleSvc.ExitConfirm(confirmReq, req.OperatorID)
 	if err != nil {
+		// 无入场记录/丢票出场(resolveExitRecordTx 将所有定位失败统一包装为"车辆未入场")
+		if strings.Contains(err.Error(), "车辆未入场") {
+			incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+				Category:    incidentService.CategoryNoEntryExit,
+				Source:      incidentService.SourcePassage,
+				TicketNo:    req.TicketNo,
+				PlateNumber: req.PlateNumber,
+				RFIDTag:     req.RFIDTag,
+				ParkingLotID: channel.ParkingLotID,
+				ChannelID:   channel.ID,
+				ChannelCode: channel.ChannelCode,
+				DeviceCode:  req.DeviceCode,
+				OperatorID:  req.OperatorID,
+				Description: "无入场记录出场或票据不存在",
+				Detail:      err.Error(),
+			})
+		}
 		return nil, err
 	}
 
@@ -224,10 +307,30 @@ func (s *PassageService) handleExit(req common.PassageRequest, identifier string
 	}
 
 	fmt.Printf("🔴 出口开闸 | 设备:%s 车牌:%s RFID:%s\n", req.DeviceCode, req.PlateNumber, req.RFIDTag)
-	if err := s.OpenGateByDeviceCode(req.DeviceCode, defaultGateValidTime); err != nil {
+	commandLogID, err := s.OpenGateWithContext(GateCommandContext{
+		Source:    incidentService.SourcePassage,
+		SessionID: req.SessionID,
+	}, req.DeviceCode, defaultGateValidTime)
+	if err != nil {
 		result.Success = false
 		result.Message = "出场结算已完成,但开闸失败"
 		result.GateStatus = "failed"
+		incidentID := incidentSvc.RecordIncident(incidentService.RecordIncidentRequest{
+			Category:        incidentService.CategoryGateFailed,
+			Source:          incidentService.SourcePassage,
+			VehicleRecordID: req.SessionID,
+			TicketNo:        req.TicketNo,
+			PlateNumber:     resp.PlateNumber,
+			RFIDTag:         req.RFIDTag,
+			ParkingLotID:    channel.ParkingLotID,
+			ChannelID:       channel.ID,
+			ChannelCode:     channel.ChannelCode,
+			DeviceCode:      req.DeviceCode,
+			OperatorID:      req.OperatorID,
+			Description:     "出场结算已完成,但开闸失败",
+			Detail:          err.Error(),
+		})
+		incidentSvc.LinkIncidentToCommand(commandLogID, incidentID)
 		return result, gateOperationError("开闸", req.DeviceCode, err)
 	}
 	result.GateOpened = true

+ 33 - 12
internal/service/system/sys_dashboard.go

@@ -30,24 +30,45 @@ func (s *DashboardService) GetMonitorData() (MonitorData, error) {
 	var d MonitorData
 	today := time.Now().Format("2006-01-02")
 
-	global.GVA_DB.Raw("SELECT COALESCE(SUM(capacity), 0) FROM parking_lot").Scan(&d.TotalSpaces)
-	global.GVA_DB.Raw("SELECT COUNT(*) FROM vehicle_record WHERE exit_time IS NULL").Scan(&d.InPark)
-	d.UsedSpaces = d.InPark
+	var spaces struct {
+		TotalSpaces int64
+		UsedSpaces  int64
+	}
+	if err := global.GVA_DB.Raw(`SELECT
+		COALESCE(SUM(capacity), 0) AS total_spaces,
+		COALESCE(SUM(occupied), 0) AS used_spaces
+		FROM parking_lot WHERE deleted_at IS NULL`).Scan(&spaces).Error; err != nil {
+		return d, err
+	}
+	d.TotalSpaces = spaces.TotalSpaces
+	d.UsedSpaces = spaces.UsedSpaces
+	d.InPark = d.UsedSpaces
 
-	global.GVA_DB.Raw("SELECT COALESCE(SUM(amount), 0) FROM payment_record WHERE paid_at >= ? AND paid_at < ?",
-		today, today+" 23:59:59").Scan(&d.TodayIncome)
+	if err := global.GVA_DB.Raw(`SELECT COALESCE(SUM(amount), 0) FROM payment_record
+		WHERE deleted_at IS NULL AND paid_at >= ? AND paid_at < ?`,
+		today, today+" 23:59:59").Scan(&d.TodayIncome).Error; err != nil {
+		return d, err
+	}
 
-	global.GVA_DB.Raw("SELECT COUNT(*) FROM vehicle_record WHERE entry_time >= ? AND entry_time < ?",
-		today, today+" 23:59:59").Scan(&d.TodayEntry)
-	global.GVA_DB.Raw("SELECT COUNT(*) FROM vehicle_record WHERE exit_time >= ? AND exit_time < ?",
-		today, today+" 23:59:59").Scan(&d.TodayExit)
+	if err := global.GVA_DB.Raw(`SELECT COUNT(*) FROM vehicle_record
+		WHERE deleted_at IS NULL AND entry_time >= ? AND entry_time < ?`,
+		today, today+" 23:59:59").Scan(&d.TodayEntry).Error; err != nil {
+		return d, err
+	}
+	if err := global.GVA_DB.Raw(`SELECT COUNT(*) FROM vehicle_record
+		WHERE deleted_at IS NULL AND exit_time >= ? AND exit_time < ?`,
+		today, today+" 23:59:59").Scan(&d.TodayExit).Error; err != nil {
+		return d, err
+	}
 
-	global.GVA_DB.Raw(`SELECT ch.channel_name, ch.direction,
+	if err := global.GVA_DB.Raw(`SELECT ch.channel_name, ch.direction,
 		COALESCE(uhf.device_code, '') as device_code,
 		COALESCE(uhf.device_name, '-') as device_name,
 		'unbound' as device_status
-		FROM channel ch LEFT JOIN uhf_reader uhf ON uhf.channel_id = ch.id
-		WHERE ch.deleted_at IS NULL ORDER BY ch.id`).Scan(&d.Devices)
+		FROM channel ch LEFT JOIN uhf_reader uhf ON uhf.channel_id = ch.id AND uhf.deleted_at IS NULL
+		WHERE ch.deleted_at IS NULL ORDER BY ch.id`).Scan(&d.Devices).Error; err != nil {
+		return d, err
+	}
 
 	for i := range d.Devices {
 		device := &d.Devices[i]

+ 50 - 0
internal/service/system/sys_dashboard_test.go

@@ -0,0 +1,50 @@
+package system
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func setupDashboardTest(t *testing.T) {
+	t.Helper()
+	dsn := fmt.Sprintf("file:dashboard-%d?mode=memory&cache=shared", time.Now().UnixNano())
+	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+	require.NoError(t, err)
+	sqlDB, err := db.DB()
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		_ = sqlDB.Close()
+		if global.GVA_DB == db {
+			global.GVA_DB = nil
+		}
+	})
+	global.GVA_DB = db
+	require.NoError(t, db.AutoMigrate(
+		&dao.ParkingLot{}, &dao.VehicleRecord{}, &dao.PaymentRecord{}, &dao.Channel{}, &dao.UHFReader{},
+	))
+}
+
+func TestDashboardUsesActiveParkingLotOccupancy(t *testing.T) {
+	setupDashboardTest(t)
+	activeLot := &dao.ParkingLot{LotCode: "ACTIVE", LotName: "有效停车场", Capacity: 80, Occupied: 1, Available: 79}
+	require.NoError(t, global.GVA_DB.Create(activeLot).Error)
+	deletedLot := &dao.ParkingLot{LotCode: "DELETED", LotName: "已删除停车场", Capacity: 20, Occupied: 5, Available: 15}
+	require.NoError(t, global.GVA_DB.Create(deletedLot).Error)
+	require.NoError(t, global.GVA_DB.Delete(deletedLot).Error)
+	require.NoError(t, global.GVA_DB.Create(&dao.VehicleRecord{
+		PlateNumber: "ORPHAN", ParkingLotID: deletedLot.ID, EntryTime: time.Now(), PaymentStatus: "unpaid",
+	}).Error)
+
+	data, err := (&DashboardService{}).GetMonitorData()
+	require.NoError(t, err)
+	require.EqualValues(t, 80, data.TotalSpaces)
+	require.EqualValues(t, 1, data.UsedSpaces)
+	require.EqualValues(t, 1, data.InPark)
+}

+ 28 - 14
internal/service/uhf/reader.go

@@ -11,6 +11,7 @@ import (
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	common "wails-app/internal/model/common"
+	incidentService "wails-app/internal/modules/incident/service"
 	"wails-app/internal/service"
 	"wails-app/internal/service/parking"
 )
@@ -243,22 +244,33 @@ func StartDeviceHandler(device *dao.UHFReader) error {
 			global.GVA_LOG.Info("设备已停止 code:" + device.DeviceCode)
 		}()
 
-		for {
-			select {
-			case <-ctx.Done():
-				return
-			default:
-				buf, err := r.ReadData()
-				if err != nil {
-					global.GVA_DB.Model(device).Update("status", "offline")
-					_ = r.Disconnect() // 先断开
-					time.Sleep(2 * time.Second)
-					if err := r.Connect(); err != nil {
+			for {
+				select {
+				case <-ctx.Done():
+					return
+				default:
+					buf, err := r.ReadData()
+					if err != nil {
+						global.GVA_DB.Model(device).Update("status", "offline")
+						// 离线异常埋点(best-effort,同设备未关闭不重复生成)
+						incidentService.NewIncidentService().RecordIncident(incidentService.RecordIncidentRequest{
+							Category:    incidentService.CategoryDeviceOffline,
+							Source:      incidentService.SourceDevice,
+							ParkingLotID: device.ParkingLotID,
+							DeviceCode:  device.DeviceCode,
+							Description: "UHF 读卡器离线",
+							Detail:      err.Error(),
+						})
+						_ = r.Disconnect() // 先断开
+						time.Sleep(2 * time.Second)
+						if err := r.Connect(); err != nil {
+							continue
+						}
+						global.GVA_DB.Model(device).Update("status", "online")
+						// 设备恢复在线:自动关闭未处理的离线异常
+						incidentService.NewIncidentService().ResolveDeviceOffline(device.DeviceCode)
 						continue
 					}
-					global.GVA_DB.Model(device).Update("status", "online")
-					continue
-				}
 
 				if len(buf) == 0 {
 					continue
@@ -296,6 +308,8 @@ func StartDeviceHandler(device *dao.UHFReader) error {
 	}()
 
 	global.GVA_DB.Model(device).Update("status", "online")
+	// 启动时兜底:关闭历史遗留的同设备离线异常
+	incidentService.NewIncidentService().ResolveDeviceOffline(device.DeviceCode)
 	return nil
 }
 

+ 73 - 0
internal/service/vehicle/parking_capacity_test.go

@@ -0,0 +1,73 @@
+package vehicle
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/model/vehicle/request"
+	parkingSessionService "wails-app/internal/modules/parking-session/service"
+)
+
+func setFullLotPolicies(t *testing.T, temporary, monthly, vip bool) {
+	t.Helper()
+	require.NoError(t, global.GVA_DB.Model(&dao.ParkingLot{}).Where("id = ?", 1).Updates(map[string]interface{}{
+		"capacity": 0, "occupied": 0, "available": 0,
+		"allow_temporary_when_full": temporary,
+		"allow_monthly_when_full":   monthly,
+		"allow_vip_when_full":       vip,
+	}).Error)
+}
+
+func TestVehicleEntryRejectsTemporaryVehicleWhenLotIsFull(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	setFullLotPolicies(t, false, true, true)
+	vehicleType := &dao.VehicleType{Name: "临时车", IsSystem: true}
+	require.NoError(t, global.GVA_DB.Create(vehicleType).Error)
+	require.NoError(t, global.GVA_DB.Create(&dao.Vehicle{PlateNumber: "TEMP-FULL", VehicleTypeID: vehicleType.ID}).Error)
+
+	_, err := svc.VehicleEntry(request.VehicleEntry{PlateNumber: "TEMP-FULL", ParkingLotID: 1})
+	require.ErrorIs(t, err, parkingSessionService.ErrParkingLotFull)
+
+	var sessionCount, ticketCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.VehicleRecord{}).Count(&sessionCount).Error)
+	require.NoError(t, global.GVA_DB.Model(&dao.DigitalTicket{}).Count(&ticketCount).Error)
+	require.Zero(t, sessionCount)
+	require.Zero(t, ticketCount)
+}
+
+func TestVehicleEntryAllowsValidMonthlyVehicleWhenLotIsFull(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	setFullLotPolicies(t, false, true, false)
+	vehicleType := &dao.VehicleType{Name: "月租车型"}
+	require.NoError(t, global.GVA_DB.Create(vehicleType).Error)
+	vehicle := &dao.Vehicle{PlateNumber: "MONTHLY-FULL", VehicleTypeID: vehicleType.ID}
+	require.NoError(t, global.GVA_DB.Create(vehicle).Error)
+	require.NoError(t, global.GVA_DB.Create(&dao.MonthlyCard{
+		VehicleID: vehicle.ID, StartDate: time.Now().Add(-time.Hour), EndDate: time.Now().Add(time.Hour), PaymentStatus: "paid",
+	}).Error)
+
+	_, err := svc.VehicleEntry(request.VehicleEntry{PlateNumber: vehicle.PlateNumber, ParkingLotID: 1})
+	require.NoError(t, err)
+
+	var lot dao.ParkingLot
+	require.NoError(t, global.GVA_DB.First(&lot, 1).Error)
+	require.Equal(t, 1, lot.Occupied)
+	require.Zero(t, lot.Available)
+}
+
+func TestVehicleEntryAllowsValidVIPVehicleWhenLotIsFull(t *testing.T) {
+	svc := setupVehiclePaymentTest(t)
+	setFullLotPolicies(t, false, false, true)
+	owner := &dao.Owner{OwnerName: "VIP车主", IsVip: true, VipExpireTime: time.Now().Add(time.Hour)}
+	require.NoError(t, global.GVA_DB.Create(owner).Error)
+	vehicleType := &dao.VehicleType{Name: "VIP车型"}
+	require.NoError(t, global.GVA_DB.Create(vehicleType).Error)
+	vehicle := &dao.Vehicle{PlateNumber: "VIP-FULL", VehicleTypeID: vehicleType.ID, OwnerId: &owner.ID}
+	require.NoError(t, global.GVA_DB.Create(vehicle).Error)
+
+	_, err := svc.VehicleEntry(request.VehicleEntry{PlateNumber: vehicle.PlateNumber, ParkingLotID: 1})
+	require.NoError(t, err)
+}

+ 6 - 1
internal/service/vehicle/parking_session_test.go

@@ -23,10 +23,11 @@ func TestVehicleEntryOpensSessionUsingCanonicalVehicleIdentity(t *testing.T) {
 	}
 	require.NoError(t, global.GVA_DB.Create(vehicle).Error)
 
-	_, err := svc.VehicleEntry(request.VehicleEntry{
+	entryResult, err := svc.VehicleEntry(request.VehicleEntry{
 		PlateNumber: vehicle.PlateNumber, ParkingLotID: 1,
 		EntryChannelID: 31, EntryChannelCode: "ENTRY-31", EntryChannelName: "入口三号通道",
 		EntryDeviceCode: "GATE-31", EntryDeviceName: "入口三号设备",
+		TriggerMode: "ticket_machine",
 	})
 	require.NoError(t, err)
 
@@ -45,6 +46,10 @@ func TestVehicleEntryOpensSessionUsingCanonicalVehicleIdentity(t *testing.T) {
 	var ticket dao.DigitalTicket
 	require.NoError(t, global.GVA_DB.Where("vehicle_record_id = ?", session.ID).First(&ticket).Error)
 	require.Equal(t, vehicle.PlateNumber, ticket.PlateNumber)
+	require.Equal(t, "ticket_machine", ticket.TriggerMode)
+	require.Equal(t, session.ID, entryResult.Data.SessionID)
+	require.Equal(t, ticket.ID, entryResult.Data.TicketID)
+	require.Equal(t, ticket.TicketNo, entryResult.Data.TicketNo)
 }
 
 func TestOperationContextResolvesRFIDOnlyVehicleForEntry(t *testing.T) {

+ 5 - 1
internal/service/vehicle/payment_edge_test.go

@@ -33,8 +33,12 @@ func setupVehiclePaymentTest(t *testing.T) *VehicleService {
 	require.NoError(t, db.AutoMigrate(
 		&dao.Owner{}, &dao.Vehicle{}, &dao.VehicleType{}, &dao.FeeConfig{}, &dao.VehicleRecord{}, &dao.DigitalTicket{},
 		&dao.PaymentMethodConfig{}, &dao.PaymentEntryConfig{}, &dao.PaymentEntryMethod{}, &dao.PaymentRecord{}, &dao.Shortlist{},
-		&dao.Channel{}, &dao.UHFReader{},
+		&dao.Channel{}, &dao.UHFReader{}, &dao.ParkingLot{}, &dao.MonthlyCard{},
 	))
+	require.NoError(t, db.Create(&dao.ParkingLot{
+		LotCode: "TEST-LOT", LotName: "测试停车场", Capacity: 100, Available: 100,
+		AllowMonthlyWhenFull: true, AllowVIPWhenFull: true,
+	}).Error)
 	require.NoError(t, db.Create(&dao.PaymentMethodConfig{
 		Code: paymentService.PaymentCash, Name: "cash", InputMode: paymentService.InputModeManualAmount,
 		Scenes: []string{paymentService.EntryCounter}, Enabled: true, IsSystem: true,

+ 54 - 3
internal/service/vehicle/vehicle.go

@@ -3,6 +3,7 @@ package vehicle
 import (
 	"errors"
 	"fmt"
+	"strings"
 	"time"
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
@@ -18,6 +19,43 @@ import (
 
 type VehicleService struct{}
 
+// buildEntryAccessProfileTx 计算车辆在停车场满位时使用的准入身份。
+func buildEntryAccessProfileTx(tx *gorm.DB, vehicle *dao.Vehicle, at time.Time) (sessionRequest.EntryAccessProfile, error) {
+	profile := sessionRequest.EntryAccessProfile{}
+	if vehicle == nil || vehicle.ID == 0 {
+		profile.IsTemporary = true
+		return profile, nil
+	}
+
+	if vehicle.VehicleType != nil {
+		profile.IsTemporary = vehicle.VehicleType.IsSystem
+	} else {
+		var vehicleType dao.VehicleType
+		if err := tx.First(&vehicleType, vehicle.VehicleTypeID).Error; err != nil {
+			return profile, err
+		}
+		profile.IsTemporary = vehicleType.IsSystem
+	}
+
+	if vehicle.OwnerId != nil {
+		var owner dao.Owner
+		if err := tx.First(&owner, *vehicle.OwnerId).Error; err == nil {
+			profile.IsVIP = owner.IsVip && !owner.VipExpireTime.IsZero() && owner.VipExpireTime.After(at)
+		} else if !errors.Is(err, gorm.ErrRecordNotFound) {
+			return profile, err
+		}
+	}
+
+	var monthlyCount int64
+	if err := tx.Model(&dao.MonthlyCard{}).
+		Where("vehicle_id = ? AND payment_status = ? AND start_date <= ? AND end_date > ?", vehicle.ID, "paid", at, at).
+		Count(&monthlyCount).Error; err != nil {
+		return profile, err
+	}
+	profile.HasValidMonthlyCard = monthlyCount > 0
+	return profile, nil
+}
+
 // buildPlateOrRfidQuery 按车牌或RFID查询,RFID为空时仅用车牌(避免 rfid_tag=” 匹配所有无标签车辆)
 func buildPlateOrRfidQuery(db *gorm.DB, plateNumber, rfidTag string) *gorm.DB {
 	if rfidTag != "" {
@@ -259,13 +297,21 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		rfidTag = vehicle.RFIDTag
 	}
 	var record *dao.VehicleRecord
+	var ticket *dao.DigitalTicket
 	ticketSvc := ticketService.NewTicketService()
 	sessionSvc := sessionService.NewParkingSessionService()
-	triggerMode := "manual"
-	if req.RFIDTag != "" {
+	triggerMode := strings.TrimSpace(req.TriggerMode)
+	if triggerMode == "" {
+		triggerMode = "manual"
+	}
+	if req.RFIDTag != "" && req.TriggerMode == "" {
 		triggerMode = "rfid"
 	}
 	err = global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		accessProfile, profileErr := buildEntryAccessProfileTx(tx, &vehicle, entryTime)
+		if profileErr != nil {
+			return fmt.Errorf("识别车辆满位准入身份失败: %w", profileErr)
+		}
 		var err error
 		record, err = sessionSvc.OpenSessionTx(tx, sessionRequest.OpenSessionRequest{
 			PlateNumber:      plateNumber,
@@ -279,11 +325,13 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 			EntryDeviceName:  req.EntryDeviceName,
 			EntryImage:       req.EntryImage,
 			EntryTime:        entryTime,
+			AccessProfile:    accessProfile,
 		})
 		if err != nil {
 			return err
 		}
-		if _, err := ticketSvc.CreateTx(tx, plateNumber, triggerMode, record.ID); err != nil {
+		ticket, err = ticketSvc.CreateTx(tx, plateNumber, triggerMode, record.ID)
+		if err != nil {
 			return fmt.Errorf("创建数字票失败: %w", err)
 		}
 		return tx.Model(&dao.Vehicle{}).Where("id = ?", vehicle.ID).
@@ -297,6 +345,9 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		Success: true,
 		Message: "车辆入场成功",
 	}
+	response.Data.SessionID = record.ID
+	response.Data.TicketID = ticket.ID
+	response.Data.TicketNo = ticket.TicketNo
 	response.Data.PlateNumber = plateNumber
 	response.Data.EntryTime = record.EntryTime.Unix()
 	response.Data.ParkingLotID = req.ParkingLotID