# 机器人全生命周期 - 状态机 (v3)

> **v3 终态（2026-05-17）**：`RobotLifecycleStage` enum 28 个 stage（删除 9 个 v2 兼容值）。
> service 层走 `STAGE_TRANSITIONS` 白名单 + 13 个 Guard 函数。
> 详细字段映射见 [最终字段设计.md §9](business-analysis/最终字段设计.md)。

> **module**: robot-manager
> **doc_type**: StateMachine
> **status**: Active (v3)
> **owner**: FFOA Team
> **upstream_docs**: 01-prd.md, 06-data-model.md
> **last_verified**: 2026-05-17

---

## v3 状态机概览

### 28 个 stage 按 6 部门 + 终态 / 例外态分组

| 部门 | Stages | 说明 |
|---|---|---|
| **SUPPLY** | `SUPPLY_PO_CREATED` → `SUPPLY_IN_PRODUCTION` → `SUPPLY_READY_TO_SHIP` | 采购供应链（节点 01-03）|
| **LOGISTICS** | `LOGISTICS_IN_TRANSIT` / `LOGISTICS_BONDED` / `LOGISTICS_CUSTOMS_CLEARED` | 国际物流（节点 04-06）|
| **WAREHOUSE** | `WAREHOUSE_RECEIVED` → `WAREHOUSE_AT_W1_PDI` → `WAREHOUSE_MODIFICATION` → `WAREHOUSE_AT_W2` / `WAREHOUSE_AT_W2_RLE` → `WAREHOUSE_BRANDED_READY` | 仓储 / PDI / 改装（节点 07-11 + 分支 16）|
| **SALES** | `SALES_RESERVED` → `SALES_PAYMENT_VALIDATED` | 销售（节点 12-13）|
| **DELIVERY** | `DELIVERY_APPROVAL` / `DELIVERY_PAYMENT_COLLECTED` → `DELIVERY_READY` → `DELIVERY_DELIVERED` | 交付（DA / P6.1 / 节点 14-15）|
| **RENTAL** | `RENTAL_ACTIVE` | 租赁分支（节点 17）|
| **AFTERSALES** | `AFTERSALES_TICKET` / `RETURN_INITIATED` / `RETURN_RECEIVED` / `AT_W6` / `UNDER_REPAIR` / `QUOTE_APPROVAL` / `REPAIRED` | 售后（ST/RI/RR/W6/18/QA/19）|
| **TERMINAL** | `CLOSED` / `CANCELLED` / `RETURNED` | 终态 |

### 三态终结边界

- **CLOSED** = 资产退役（含 `disposalType`: SCRAPPED / SWAPPED_TO_AGIBOT / CANCELLED）
- **RETURNED** = 退回供应商或客户拒收（D2 7 天内）
- **CANCELLED** = 业务单据取消（机器人可复用回库，不是资产终态）

### STAGE_TRANSITIONS（白名单 + 6 条反向边）

事实源代码见 `backend/src/modules/robot-manager/services/stage-transitions.constants.ts`。

反向边：
1. `WAREHOUSE_AT_W1_PDI` → `RETURNED`（PDI 严重问题退供）
2. `WAREHOUSE_MODIFICATION` → `WAREHOUSE_AT_W1_PDI`（改装失败回检）
3. `SALES_RESERVED` → `WAREHOUSE_BRANDED_READY`（客户违约回库）
4. `DELIVERY_APPROVAL` → `SALES_PAYMENT_VALIDATED`（审批文件不全）
5. `DELIVERY_DELIVERED` → `RETURNED`（D2 7 天内退货）
6. `AFTERSALES_QUOTE_APPROVAL` → `RETURNED` / `DELIVERY_DELIVERED`（客户拒绝报价取回）

### 13 Guard 函数清单

服务层 `lifecycle-guards.service.ts`，return `{ ok, reasons[] }`，失败抛 BadRequest 不切 stage。

A. 流程图控制门（7 条）：
| Guard | 检查时机 |
|---|---|
| `checkFunctionTest` | PDI → MODIFICATION |
| `checkConversionValidated` | MODIFICATION → BRANDED_READY（7 标签全 VERIFIED + 配件齐全）|
| `checkDeliveryValidation` | DELIVERY_READY → DELIVERED（签字单 SIGNED）|
| `checkG5Payment` | SALES_RESERVED → PAYMENT_VALIDATED（付款 PAID）|
| `checkPGIReady` | DELIVERED 前（验收单 SIGNED + 款项 PAID）|
| `checkRMAEligible` | DELIVERED → RETURN_INITIATED（在售后期内 + 有 open 工单）|
| `checkQuoteApproved` | UNDER_REPAIR → REPAIRED（OOW 路径）|

B. 业务实体硬约束（6 条）：
| Guard | 检查时机 |
|---|---|
| `checkPOHasSupplier` | 创建 PurchaseOrder |
| `checkSONeedsCustomer` | 创建 SalesOrder |
| `checkReserveRequiresBranded` | 进入 SALES_RESERVED（必须来自 BRANDED_READY / W2_RLE 且 SalesOrderLine 已绑定）|
| `checkClosedNeedsDisposal` | 进入 CLOSED（disposalType + retiredAt 非空）|
| `checkRentalNeedsContract` | 进入 RENTAL_ACTIVE（必须有 ACTIVE RentalAgreement）|
| `checkD2EligibleWindow` | DELIVERED → RETURNED（≤ 7 天）|

### CQRS：Event Source + Snapshot Projection

- **写**：`POST /robot-manager/:id/change-stage` 触发 → 写 `RobotLifecycleEvent(eventType=stage_changed)` + 投影到 `RobotUnitSnapshot.currentStage`（同事务）
- **读**：列表 / 详情 查 `RobotUnit` join `RobotUnitSnapshot`（v3 当前状态物化）
- **乐观锁**：snapshot.version 阻止并发写冲突

15 种 eventType + 投影 map 见 [最终字段设计.md §5.5](business-analysis/最终字段设计.md#55-事件--snapshot-字段投影表service-层实现该-map)。

---

## v2 → v3 数据迁移摘要

v2 RobotStatus 10 旧值 → v3 stage 映射（仅供历史参考，dev DB 已 force-reset 不存在旧数据）：

| v2 | v3 |
|---|---|
| ORDERED | SUPPLY_PO_CREATED |
| IN_TRANSIT | LOGISTICS_IN_TRANSIT |
| BONDED | LOGISTICS_BONDED |
| IN_STOCK | WAREHOUSE_BRANDED_READY |
| RESERVED | SALES_RESERVED |
| SOLD | SALES_PAYMENT_VALIDATED |
| DELIVERED | DELIVERY_DELIVERED |
| REPAIR | AFTERSALES_UNDER_REPAIR |
| REPAIRED | AFTERSALES_REPAIRED |
| CANCELLED | CANCELLED（同名保留）|

---

## 历史 v2 状态机（参考）

---

## 状态定义

> Prisma enum 值使用 UPPER_SNAKE_CASE，下方同时列出显示名用于前端映射。
> 完整 enum 定义见 [06-data-model.md](06-data-model.md) → RobotStatus。

| 状态 | Prisma 值 | 显示名 | 说明 |
|------|-----------|--------|------|
| 已下单 | ORDERED | Ordered | 向供应商下单采购，设备尚未发货 |
| 在途 | IN_TRANSIT | In Transit | 设备已发货，运输中 |
| 保税仓 | BONDED | Bonded | 设备到达保税仓/自贸区，待清关 |
| 在库 | IN_STOCK | In Stock | 设备已入库，可供分配 |
| 已预留 | RESERVED | Reserved | 设备已分配给客户，待签合同 |
| 已销售 | SOLD | Sold | 合同签订/付款确认，等待物理交付 |
| 已交付 | DELIVERED | Delivered | 设备已交付给客户 |
| 维修中 | REPAIR | Repair | 设备在维修中 |
| 维修完成 | REPAIRED | Repaired | 维修完成，等待归还客户或重新入库 |
| 已取消 | CANCELLED | Cancelled | 订单取消或设备报废，终态 |

---

## 生命周期流转

```
 ORDERED ──→ CANCELLED
    │
    ▼
 IN_TRANSIT ──→ BONDED ──→ IN_STOCK ◄── REPAIRED ◄── REPAIR ──→ CANCELLED
    │                        │  ▲          │
    └────────────→ IN_STOCK  │  │          │
                        │    │  │          │
                        ▼    │  │          │
                    RESERVED─┘  │          │
                        │       │          │
                        ▼       │          │
                      SOLD ──→ CANCELLED   │
                        │                  │
                        ▼                  │
                    DELIVERED ─────────────┘
                        │
                        └──→ REPAIR
```

> 说明：
> - IN_STOCK 两个出口：→ RESERVED（分配客户）和 → REPAIR（库存故障）
> - RESERVED 两个出口：→ SOLD（签合同/收款）和 → IN_STOCK（取消预留）
> - SOLD 两个出口：→ DELIVERED（物理交付）和 → CANCELLED（撤销销售）
> - DELIVERED 单一出口：→ REPAIR（客户报修）
> - REPAIR 两个出口：→ REPAIRED（修好）和 → CANCELLED（报废）
> - REPAIRED 三个出口：→ DELIVERED（归还原客户）、→ IN_STOCK（重新入库）、→ CANCELLED（报废）
> - CANCELLED 可从 ORDERED / IN_TRANSIT / BONDED / SOLD / REPAIR / REPAIRED 进入（均需 remark）
> - CANCELLED 是终态，不可转出
> - 完整转换关系以下方合法转换表为准

---

## 合法转换表

| 当前状态 | 可转换到 | 说明 | 触发角色（service VALID_TRANSITIONS） |
|----------|---------|------|---------|
| ORDERED | IN_TRANSIT | 供应商发货 | SupplyChain |
| ORDERED | CANCELLED | 取消采购订单 | SupplyChain / RLE |
| IN_TRANSIT | BONDED | 到达保税仓 | SupplyChain |
| IN_TRANSIT | IN_STOCK | 直接入库（无需保税） | SupplyChain |
| IN_TRANSIT | CANCELLED | 物流异常/丢失 | RLE |
| BONDED | IN_STOCK | 清关完成，入库 | SupplyChain |
| BONDED | CANCELLED | 海关扣押/清关失败 | RLE |
| IN_STOCK | RESERVED | 分配给客户 | Sales / RLE |
| IN_STOCK | REPAIR | 库存设备发现故障 | RLE |
| RESERVED | SOLD | 合同签订/付款确认（Guard: salesPrice 必填） | Sales / RLE |
| RESERVED | IN_STOCK | 取消预留，设备回库 | Sales / RLE |
| SOLD | DELIVERED | 物理交付给客户（副作用：设置 deliveredDate） | Sales / RLE |
| SOLD | CANCELLED | 撤销销售（退款场景） | Sales / RLE |
| DELIVERED | REPAIR | 客户报修 | RLE |
| REPAIR | REPAIRED | 维修完成（副作用：ServiceRecord.completedDate） | RLE |
| REPAIR | CANCELLED | 无法修复，设备报废 | RLE |
| REPAIRED | DELIVERED | 归还原客户（Guard: customerId + salesPrice；副作用：deliveredDate） | RLE |
| REPAIRED | IN_STOCK | 重新入库（清除客户绑定，可再分配） | RLE |
| REPAIRED | CANCELLED | 修好但取消，设备报废 | RLE |

> 注：当前后端 PermissionsGuard 仅按 `robot-manager:change-status` 全局权限放行，不按 fromStatus 细粒度卡位。表格"触发角色"列与 service `VALID_TRANSITIONS` 数组中的 `roles` 字段一致，是产品意图，**尚未在代码层强制**。Phase 4 可补按转换路径的细分权限。

---

## 状态与 Location 的联动（v2 配置化）

> v2：location 是独立业务实体表 `RobotLocation`，状态→默认 location 映射存储在 `RobotSystemConfig.status_default_location` 中，由管理员通过 `/admin/settings` 维护。

状态变更时，service 层按以下规则确定 `locationId`：

1. 调用方在 `POST /:id/status` 显式传 `locationId` → 使用该值
2. 否则查 `RobotSystemConfig.status_default_location[targetStatus]`：
   - 值为 `null` → 不更新 `locationId`（保持原值或维持 null，前端按 status 推断显示）
   - 值为 RobotLocation.code → 解析为 location id 后写入
3. 配置不存在 → 保持原值

### 默认配置（seed 初始值）

| 目标状态 | 默认 location code | 含义 |
|---|---|---|
| ORDERED | `null` | 由 UI 推断"未发货" |
| IN_TRANSIT | `null` | 由 UI 推断"运输中" |
| BONDED | `null` | 由 UI 推断"保税区" |
| IN_STOCK | `HQ_FZ` | 福州总部仓库 |
| RESERVED | `HQ_FZ` | 仍在仓库等待交付 |
| DELIVERED | `null` | 交付时由具体 customer 决定（应在调用方传 locationId） |
| REPAIR | `HQ_FZ` | 默认返厂维修 |
| CANCELLED | `null` | 保留最后已知位置 |

> 抽象阶段（CHINA / ON_THE_WAY / FTZ）不再作为 location 的具体值存在。前端在 `locationId` 为 null 时，根据 currentStatus 显示语义标签即可。
> 如果业务需要把抽象阶段也建成具体 location（如"在海上"），可以在 `/admin/locations` 新建对应记录。

---

## 校验规则（必须实现）

### 禁止跳跃状态

状态转换只能按合法转换表执行，不允许跨状态操作。

**示例**：
- Ordered → Delivered ❌（必须经过 In Transit → In Stock → Reserved → Delivered）
- Ordered → In Stock ❌（必须经过 In Transit）

### 不可逆规则

- **Delivered 后不可直接回到 In Stock**：必须先进入 Repair，维修完成后才能回到 In Stock
- **Cancelled 是终态**：进入 Cancelled 后不可转换到任何其他状态

### Repair 入口限制

Repair 状态只能来自以下两个状态：
- **Delivered**：客户使用中报修
- **In Stock**：库存设备发现故障

其他状态不可直接转为 Repair。

### 前置条件（Guard Conditions）

| 转换 | 前置条件（变更前必须满足） |
|------|--------------------------|
| IN_STOCK → RESERVED | `customerId` 不为空（客户已在 IN_STOCK 阶段绑定）|
| RESERVED → DELIVERED | `salesPrice` 不为空 |
| RESERVED → IN_STOCK | 无前置条件（副作用中自动解绑客户）|
| REPAIR → DELIVERED | `customerId` 不为空 且 `salesPrice` 不为空（归还原客户）|
| → CANCELLED（所有合法来源）| `remark` 不为空（必须填写取消/报废原因）|

---

## 状态转换的副作用

> **叠加规则**：每条转换执行"通用副作用"（Location 联动表中的默认 Location 设置）**加上**下表列出的额外副作用。
> 即所有转换都会自动设 Location，下表只列出 Location 之外的额外动作。

> 所有合法转换都会无条件执行两件事：1) 按 `status_default_location` 配置更新 `locationId`；2) 写一条 StatusChangeLog。下表只列出**这些通用动作之外**的 section-specific 副作用。

| 转换 | 额外副作用 |
|------|----------|
| → IN_TRANSIT | — |
| → BONDED | — |
| → IN_STOCK | — |
| IN_STOCK → RESERVED | —（客户绑定是 Guard 前置条件，非副作用）|
| RESERVED → DELIVERED | 覆盖 `deliveredDate = 今天` |
| DELIVERED → REPAIR / IN_STOCK → REPAIR | 自动创建 `RobotServiceRecord`，`serviceTypeCode` 取自 `RobotSystemConfig.repair_auto_service_type`（默认 `REPAIR`）|
| RESERVED → IN_STOCK | 清空 `customerId` 和 `salesOrderId` |
| REPAIR → IN_STOCK | 自动补齐最近一条未完成 ServiceRecord 的 `completedDate`；清空 `customerId` 和 `salesOrderId` |
| REPAIR → DELIVERED | 自动补齐最近一条未完成 ServiceRecord 的 `completedDate`；覆盖 `deliveredDate = 今天` |
| → CANCELLED | —（只有通用 StatusChangeLog，CANCELLED 的 remark 是 Guard 必填条件）|

---

## 初始状态

- **新建 Unit（API）**：默认初始状态为 `ORDERED`，Prisma schema 中 `@default(ORDERED)`
- **Excel 导入**：允许指定初始状态（必须是合法状态值），未指定时默认 `ORDERED`
- 如果设备已到货需直接录入为 `IN_STOCK`，通过 Excel 导入指定状态实现

### 新建 Unit 时的 Location 初始化

API 新建和 Excel 导入均按 `RobotSystemConfig.status_default_location` 设置初始 `locationId`：
- API 新建（默认 ORDERED）→ 查 `status_default_location[ORDERED]`，默认 `null`（locationId 留空，UI 推断）
- Excel 导入 → 按导入的 currentStatus 对应的默认 location code（可在 Excel `Location Code` 列覆盖）

### API 新建 vs Excel 导入的校验差异

| 维度 | API 新建 | Excel 导入 |
|------|---------|-----------|
| 定位 | 新采购设备，信息逐步补充 | 存量数据迁移，信息应完整 |
| 必填 | `modelId`、`skuId`（最小集） | 按状态的字段必填表（较严格） |
| FFSN | 系统自动生成（按 `RobotSystemConfig.ffsn_rule`） | Excel 提供，系统校验唯一性 |
| supplier | 可选（后续由 SC 补充） | ORDERED 及以后状态 `Supplier Code` 必填 |
| 状态 | 固定 ORDERED | 可指定合法状态（除 REPAIR/CANCELLED） |

> API 新建校验较宽松是因为设备刚下单，供应链/销售/财务信息尚未产生，后续通过字段编辑和状态流转逐步补全。

### FFSN 在不同入口的处理

| 入口 | FFSN 处理 |
|------|-----------|
| API 新建 | 系统自动生成，不接受外部输入 |
| Excel 导入 | **Excel 中必须提供 FFSN**，双层保证：service 层 + DB partial unique index（见 [06-data-model.md](06-data-model.md)）|

> 说明：API 新建面向新采购的设备（FFSN 尚不存在）；Excel 导入面向存量数据迁移或批量录入（FFSN 已知）。

### 导入是否产生 StatusChangeLog

Excel 导入时，系统为每台设备创建一条初始化日志：
- `fromStatus` = null（表示初始导入，非状态流转）
- `toStatus` = 导入时指定的状态
- `remark` = "Excel 导入"
- `changedBy` = 执行导入的用户

### Excel 导入按状态的字段必填规则

> 导入时跳过了状态流转过程，因此必须校验目标状态所需的关联字段完整性。

| 导入状态 | 必填字段（除 FFSN / Model Code / SKU Code 外） |
|----------|--------------------------------|
| ORDERED | Supplier Code |
| IN_TRANSIT | Supplier Code, PO Number, Purchase Date |
| BONDED | Supplier Code, PO Number, Purchase Date |
| IN_STOCK | Supplier Code, PO Number, Arrival Date |
| RESERVED | Supplier Code, PO Number, Customer Code |
| DELIVERED | Supplier Code, PO Number, Customer Code, Sales Price, Delivered Date |
| REPAIR | 允许（2026-04-19 放开）— 导入时不自动创建 ServiceRecord，需人工在 UI 补录维修记录 |
| CANCELLED | 允许（2026-04-19 放开）— remark 可为空；保留的是"历史已取消"事实而非取消原因 |

---

## 设计说明

### CANCELLED 必须经过 REPAIR

IN_STOCK / RESERVED / DELIVERED 的设备不能直接取消，必须先进入 REPAIR 再转 CANCELLED。
这是刻意设计：**报废必须经过维修评估流程**，防止设备在没有检测记录的情况下被直接报废。
REPAIR 阶段会创建 ServiceRecord 记录评估结果，为报废提供可追溯的依据。

---

## 实现要求

- `currentStatus` 字段不可通过普通编辑 API 修改，必须通过专用的状态变更 API
- **状态变更必须在数据库事务中执行**：更新 status + locationId + version → 执行副作用（ServiceRecord 等）→ 写入 StatusChangeLog，任一步骤失败则整体回滚
- 状态变更 API 执行流程：校验合法性 → 检查前置条件 → 检查 version（乐观锁）→ \[事务开始\] 更新状态 + version + 1 → 执行副作用 → 写入 StatusChangeLog \[事务结束\]
- 每次状态变更必须记录：变更前状态、变更后状态、操作人、操作时间
- 状态变更失败时必须返回明确的错误信息，说明当前状态和目标状态不合法，或前置条件未满足
- 乐观锁冲突时返回 409 Conflict，提示用户刷新后重试
