# FSD m05 · 工单与看板（ticket）

LLMS 索引： [llms.txt](/llms.txt)

---

> 本册覆盖代码域 `internal/domain/ticket` 的 B 端面（直传三步/注册链部分在 m04）。全局规则见 [00-总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/) §2（A 轴状态机 §2.2、AST 守卫 §2.3）。

## m05 功能节目录

| ID | 名称 | 路由/入口 |
|---|---|---|
| F5.01 | 工单自动生成 | （F4.01 事务链内 CreateInTx） |
| F5.02 | 工单分配与流转 | `POST /api/tickets/:id/assign(-doctor)`、`POST /api/tickets/:id/transition` |
| F5.03 | 状态机 transition() 与三条回退边 | （F5.02 内核；守卫测试锁定） |
| F5.04 | 病历下载与版本组读 | `GET /api/documents/:id/download`、`GET /api/documents/:id/versions` |
| F5.05 | 运营看板（聚合+三超时+对账 tab） | `GET /api/board` |

---

#### F5.01 工单自动生成

> 对应 SRS：F-TICK-001 ｜ 实现落点：`internal/domain/ticket/service.go:236`（CreateInTx，F4.01 注册事务链内调用） ｜ 操作入口：—（系统自动，随注册发生）

##### 1 功能定义

注册事务内自动创建工单：INSERT（status=CREATED, care_stage=NONE）→ 事务内回填 `ticket_no='T'+yyyyMMdd+LPAD(id,4,'0')`（INSERT('')→UPDATE，非状态列豁免申报，uk 兜底唯一）→ lifecycle('CREATED') + op_log(SYSTEM register) 同事务（迁移-日志同路径）。

##### 2 触发条件与前置状态

F4.01 注册事务链；无人工触发路径（MVP 一人一单）。

##### 3 输入与校验

patient_id / expect_city / expect_window / chief_complaint（来自注册表单）；其余列哨兵默认（consultant_id=0/doctor_id=0/hospital_id=0/dept_id=0——M10 起hospital_id 在专家确认时回填，F6.01）。

##### 4 处理流程

见 F4.01 §4 时序图开单段；CreateInTx 内部三步（INSERT → 回填 ticket_no → 双日志）。

##### 5 输出与结果状态

`ticket` 行（CREATED/NONE/version=1）+ ticket_no 唯一编号；响应透出 `{id, ticket_no}`。

##### 6 状态流转

`→ CREATED`（lifecycle event='CREATED'）；后续 PENDING_ASSIGN 由 F5.02/F6.01 流转。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 同日多单 | ticket_no 序号=LPAD(id)——id 自增无计数器状态（uk 兜底） |
| 回填失败 | 事务回滚（整链） |
| patient_id 关联 | 0 哨兵不可能（注册事务内已有 uid） |

##### 8 权限与数据规则

系统触发（actor=SYSTEM）；lifecycle ActorID=patient_id。

---

#### F5.02 工单分配与流转

> 对应 SRS：F-TICK-002 ｜ 实现落点：`internal/domain/ticket/service_bo.go:171`（Assign）/`:216`（Transition）/`:244-268`（NotifyTransition/pushTransition）/`handler_bo.go:77-136` ｜ 操作入口：web `/admin/tickets` 列表→详情（分配顾问/分配医生/流转按钮）

##### 1 功能定义

顾问分配工单（给顾问改派/给医生）与手动流转状态；每次流转 Commit 后三 key SSE 同推（`consultant:all` 列表刷新 / `ticket:<id>` 详情 / `s:<sessID>` 患者流）。

##### 2 触发条件与前置状态

- 分配/流转：`ticket:assign` / `ticket:transition`（CONSULTANT 持有；DOCTOR 均无——只读+写预诊）。
- 流转前置：当前状态在白名单内（F5.03）；reason 必填。

##### 3 输入与校验

| 端点 | 输入 | 校验 |
|---|---|---|
| POST /api/tickets/:id/assign | assignee_id | field 白名单（consultant_id/doctor_id 服务端选定，非客户端输入直达列名）；version CAS |
| POST /api/tickets/:id/assign-doctor | assignee_id | 同上 |
| POST /api/tickets/:id/transition | to + reason | 白名单校验（ErrInvalidTransition 409）；reason 必填 400；status 谓词 CAS |

##### 4 处理流程

```mermaid
sequenceDiagram
    participant W as web 工单详情
    participant H as ticket.Handler(BO)
    participant S as ticket.Service
    participant DB as MySQL
    participant HUB as sse.Hub
    W->>H: POST /api/tickets/:id/assign {assignee_id}
    H->>S: Assign（field 白名单）
    S->>DB: WithTx: UPDATE consultant_id/doctor_id (version CAS) + lifecycle(ASSIGN_*) + op_log
    H-->>W: 204 / 409
    W->>H: POST /api/tickets/:id/transition {to, reason}
    H->>S: Transition
    S->>DB: WithTx: TransitionInTx（白名单→status 谓词 CAS→lifecycle+op_log）
    S->>HUB: Commit 后三 key 同推 ticket 事件
    H-->>W: 200 工单 JSON / 409
    HUB-->>A: 患者端实时进度刷新
```

##### 5 输出与结果状态

分配 204；流转 200 返回更新后工单；SSE `ticket` 事件 `{id, ticket_no, status, care_stage, updated_at, version}`（web/app 前端注册表消费——SSE 事件注册表守卫对齐）。

##### 6 状态流转

A 轴全图见总则 §2.2；分配列（consultant_id/doctor_id）非状态机列——version CAS 申报豁免（沿 BindPatientInTx 惯例），lifecycle event=ASSIGN_CONSULTANT/ASSIGN_DOCTOR（from/to 填旧/新 assignee id 串，可回放）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 并发流转（from 漂移） | 409 ErrVersionConflict |
| 白名单外迁移 | 409 ErrInvalidTransition |
| 推送失败 | 仅 warn——SSE 是通知不是事实源，客户端重连 resync；三 key 相互独立不短路（M5 核查 A3：StaffKey 失败不得吞掉 TicketKey） |
| > ⚠ DIF-F5-1（观察项） | 分配不校验 assignee 存在性/角色：现状未校验（id 由前端用户列表提供，service_bo.go:171 仅 version CAS 写列）——登记 99-附录 B 观察项 |

##### 8 权限与数据规则

`ticket:assign`/`ticket:transition` 路由级；分配与流转均双日志同事务（W4 补齐——M5 曾遗留分配无审计缺口）。

---

#### F5.03 状态机 transition() 与三条回退边

> 对应 SRS：F-TICK-002（状态机机制，SRS 只述 8 状态未述回退） ｜ 实现落点：`internal/domain/ticket/pure.go:92`（ticketTransitions）/`:103`（CanTransition）/`service.go:267`（TransitionInTx）/`guard/ast_status_guard_test.go`（AST 守卫） ｜ 操作入口：—（机制层；实际触发见 F5.02/F6.02/F7.02）

##### 1 功能定义

状态机唯一权威的代码化：白名单 map（8 状态 7 出边 + 3 回退边）→ `CanTransition` 纯函数 → `TransitionInTx` 封装（白名单前置→status 谓词 CAS→lifecycle+op_log 同事务）。三条回退边是设计补充（SRS 未定义）：边 1 支付失败重谈（PENDING_PAYMENT→PLAN_CONFIRMING）、边 2 改方案（PENDING_DEPARTURE→PLAN_CONFIRMING）、边 3 医生拒绝重派（PREDIAGNOSING→PENDING_ASSIGN）。

##### 2 触发条件与前置状态

一切 ticket.status 变更的唯一合法路径；绕过即 AST 守卫测试红（`guard/ast_status_guard_test.go`：状态列字符串字面量只准出现在白名单函数）。

##### 3 输入与校验

`(ticketID, from, to, reason, actorID, actorRole)`；from 漂移（并发）→ ErrVersionConflict。

##### 4 处理流程

```mermaid
flowchart LR
    A[TransitionInTx] --> B{CanTransition from→to<br/>白名单纯函数}
    B -->|否| E409a[ErrInvalidTransition 409]
    B -->|是| C["UPDATE ticket SET status=?, version=version+1<br/>WHERE id=? AND status=?"]
    C -->|affected=0| E409b[ErrVersionConflict 409]
    C -->|成功| D["lifecycle(from→to) + op_log(reason)<br/>同事务"]
```

##### 5 输出与结果状态

状态列已迁移、version+1、双日志落库；守卫测试断言 `ticketTransitions` 与 8 状态+3 回退边逐一对应（防静默增删）。

##### 6 状态流转

即本节本体（总则 §2.2 A 轴图）；回退边 1 由支付回调驱动（F7.02）、边 3 由医生拒绝驱动（F6.02）、边 2 手动触发权限待产品确认（tech-design §15 风险 3——UI 不出按钮，白名单内 API 可达）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| COMPLETED 迁出 | 409（终态无出边） |
| 同状态自迁 | 409（白名单无自环） |
| AST 守卫边界 | 动态拼接 SQL 扫不到——守卫是兜底不是完备证明，拼接绕过须 review 把关（⚠ DIF-M5 ② 申报） |

##### 8 权限与数据规则

reason 必填（关键操作审计纪律）；payment/trip/user/hospital 族状态机同构封装（`TransitionOrderInTx` 等 8 白名单函数，见总则 §2.3-2）。

---

#### F5.04 病历下载与版本组读

> 对应 SRS：F-CARE-002（版本管理读闭环，就医前部分） ｜ 实现落点：`internal/domain/ticket/service_bo.go:271`（DocumentFile）/`:307`（PatientDocument）/`:347`（DocumentVersions）/`handler_bo.go:137-180` ｜ 操作入口：web 工单详情文档列表（下载/版本）；app 进度页文档下载

##### 1 功能定义

B 端代理下载病历/阶段资料（服务端 blob 转发——`<a>` 带不了 Authorization）、查询同组版本历史；患者端下载走归属谓词双轨（已归属按 ticket.patient_id；未归属按绑定会话 gsid 锚）。

##### 2 触发条件与前置状态

- B 端：`ticket:read`；viewer 须为该工单顾问/医生/ADMIN（否则 403 ErrForbidden）。
- 患者端：pt JWT；归属谓词双轨（`PatientDocument`）。

##### 3 输入与校验

`:docId` path；未归属文档（ticket_id=0）B 端不可见（404——guest 所有权锚阶段）。

##### 4 处理流程

```mermaid
flowchart LR
    A[GET /api/documents/:id/download] --> B{文档存在且已归属?}
    B -->|否/未归属| E404[404 ErrNotFound]
    B -->|是| C{viewer=顾问/医生/ADMIN?}
    C -->|否| E403[403 ErrForbidden]
    C -->|是| D["store.Get(objectKey) blob 代理<br/>mime 以实际内容为准"]
    P[GET /pt/documents/:id/download] --> Q{已归属?}
    Q -->|是| R{ticket.patient_id==me?}
    Q -->|否| S{session_gsid==本人绑定会话?}
    R -->|是| D
    S -->|是| D
    R -->|否| E403
    S -->|否| E404
```

##### 5 输出与结果状态

文件 blob（Content-Type=实际 mime）；版本端点返回同组全版本（version_no 降序；doc_group=0 历史散件返回单行自身）。

##### 6 状态流转

无状态变更（读路径）；版本替换的写路径在 F8.02。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 未归属文档 B 端访问 | 404（不可见性——不暴露 guest 阶段产物） |
| 跨工单访问 | 403 |
| 患者未绑定会话查未归属文档 | 404（无法证明所有权） |
| 对象已删（GDPR 后） | 500/404 视存储实现（文档行已随抹除链处理） |

##### 8 权限与数据规则

`ticket:read`（B 端）/ pt JWT 谓词（患者）；≤20MB 内存载（MVP 量级）；患者端下载 M8 兑现（DIF-M7 ⑦）。

---

#### F5.05 运营看板（聚合+三超时+对账 tab）

> 对应 SRS：F-ADMIN-003（工单监控：总览看板、超时提醒、搜索筛选） ｜ 实现落点：`internal/domain/ticket/service_bo.go:415`（Board）/`pure.go:137-154`（BoardThresholds/IsTimedOut）/`main.go:150-154`（config 注入） ｜ 操作入口：web `/admin/board` 运营看板页

##### 1 功能定义

单端点被动展示聚合（裁决 8——无主动通知/定时器）：status/care_stage 两条 GROUP BY 计数 + 三个等待态（PENDING_ASSIGN/PREDIAGNOSING/PENDING_PAYMENT）超时工单表（阈值 config `board.*` 三键注入，内存 IsTimedOut 纯函数过滤）。

##### 2 触发条件与前置状态

`ops:read`（CONSULTANT+ADMIN）；页面刷新触发（被动展示口径）。

##### 3 输入与校验

无入参；阈值来自 config（`timeout_pending_assign_hours / timeout_prediagnosing_hours / timeout_pending_payment_hours`）。

##### 4 处理流程

```mermaid
flowchart LR
    A["GET /api/board"] --> B["GROUP BY status → status_counts"]
    A --> C["GROUP BY care_stage → stage_counts"]
    A --> D["SELECT 等待态全量（3 状态）"]
    D --> E["IsTimedOut 纯函数内存过滤（updated_at+阈值）"]
    E --> F["timeout_tickets[]（含 hours_in_status）"]
```

##### 5 输出与结果状态

`{status_counts: {CREATED: n,...}, stage_counts: {...}, timeout_tickets: [{id, ticket_no, status, care_stage, patient_name, updated_at, hours_in_status}]}`。

##### 6 状态流转

只读聚合；不建看板物化表（单日百级工单实时聚合足够——tech-design §5.1 裁决）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 超时阈值未配 | 零值阈值=永超时为假（IsTimedOut 语义——config 缺省值兜底） |
| 工单量大 | 等待态全量拉取内存过滤——MVP 量级充分，量级证明需要再物化 |
| 搜索筛选 | 列表页（F5.02 List）承担 status/city 筛选；看板只做总览 |

##### 8 权限与数据规则

`ops:read`；对账 tab（支付残留单 `GET /api/payments/pending` + FAILED 邮件 `GET /api/notifications/failed`）在 m07/m10 各节，看板页聚合入口。

---

反链：

- [功能规格说明书(FSD)](/prd/fsd-medilink/)
- [MediLink Global 功能规格说明书（FSD）v1.0 · 总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/)
