# Robot Manager — 重构方案 v1（详尽版）

> **created**: 2026-05-16
> **status**: Draft（PR1 实施前最终 review）
> **作者**: chentao + Claude
> **依据**:
> - [standard 16 数据分层与元数据策略](../../../standards/16-data-layering-and-metadata-policy.md)
> - [数据分层方案.md](数据分层方案.md)（表结构总览）
> - [最终字段设计.md](最终字段设计.md)（82 字段归位 + 30 stage + 7 guard）
> - 2026-05-16 生产/UAT 数据查证（生产 89 台机器人为 wenyang.liu 单次批量导入测试数据，3 周无操作）

---

## 1. 目标

把当前 `robot_manager` schema（11 model，状态机表 + RobotFieldDef 动态字段）重构为：
- **L1 平台公共层 `platform_master`**：Customer/Supplier/Partner/Location/Attachment + 字典
- **L2 robot_manager 模块主数据**：Model/Sku/RobotUnit（仅不变属性）+ RobotUnitSnapshot
- **L3 robot_manager 业务表**：PurchaseOrder/SalesOrder/DeliveryRequest/PaymentRecord/ServiceTicket/QualityLabelRecord/RobotPackageReadiness/InspectionRecord/LogisticsLeg/ComplianceCheck + RobotLifecycleEvent
- **删除 L4 元数据驱动层**：RobotFieldDef 表删除；状态用 enum；guard 用 service 层方法

整体当前 11 model → 重构后 22+ model（跨 L1/L2/L3）。

## 1.1 输入约束（已确认）

| 约束 | 状态 |
|---|---|
| 生产 robot_manager 数据可丢 | ✅ 数据查证（生产 89 台均为 wenyang.liu 单次批量导入测试数据）+ 待 Ricky Liu 最终口头确认 |
| 不替代外部系统（D365/SAP/MS Teams/Tools） | ✅ 数据分层方案.md §1.5 |
| 未来 SAP 集成（PR4 之后） | ✅ schema 完整即可，service 层 placeholder |
| 默认不立 L4 元数据驱动 | ✅ standard 16 §4 |
| **不规划 SaaS 化**（FFOA 单租户） | ✅ 接受未来 SaaS 化需结构性返工的代价（重建 FieldDefinition + 字段反向抽出）|
| 跨 schema FK 策略：DB 层 FK + Prisma 不写 @relation + Service 层批量 join | ✅ standard 04 + finding A-MEDIUM-5 |

## 1.1.5 修订记录（multi-agent review 2026-05-16）

| 修订项 | 来源 finding | 影响 |
|---|---|---|
| PR1 enum 改为 atomic 原地扩值（去除"双 enum 并存"幻觉）| A-HIGH-2 + D-HIGH-1 | PR1 SQL 重写 |
| PR2 加 DROP COLUMN robot_units.customer_id/location_id（不留破窗）| A-HIGH-1 | PR2 SQL 增补 |
| PR3 移除 purchaseOrderId/Line FK 列（挪到 PR4a 同 PO 表一起建）| A-HIGH-3 | PR3/PR4a 都改 |
| PR2 TRUNCATE 用显式列表 + RESTART IDENTITY，去 CASCADE | D-HIGH-2 | PR2 SQL 改 |
| 新表标准字段全补：Snapshot 豁免 + Event/Attachment/ImportAudit 加齐 | D-HIGH-3 | schema 改 + 文档同步 |
| 加 SAP/D365 集成 anchor 字段（5 个表）| B-HIGH-1 | PR4 字段补 |
| STAGE_TRANSITIONS 补 6 条反向边 + CANCELLED 语义重定义 | B-HIGH-2 | 最终字段设计 §9b.1 |
| Guard 从 7 扩到 13 | B-HIGH-3 | 最终字段设计 §9b.2 |
| 加 RentalAgreement + RentalPaymentSchedule | B-HIGH-4 | PR4c 加表 |
| PR2 加 13 真实处理人 User seed | B-MEDIUM-2 | PR2 seed 补 |
| 全 PR 加 L1c 数据质量校验 | T-MEDIUM-1 | 各 PR 测试段补 |
| 全 PR 加跨 PR 回归测试 | T-MEDIUM-2 | 各 PR 测试段补 |
| 性能验证规模 90 → 1000 台 + EXPLAIN ANALYZE | T-HIGH-4 | PR3 测试段补 |
| PR4 测试清单按 PR3 粒度补具体用例 | T-HIGH-1 | PR4 测试段重写 |
| PR4 加 i18n 双语回归 | T-HIGH-3 | PR4 测试段补 |
| PR3 Snapshot 事务回滚补 3 反向用例 | T-HIGH-2 | PR3 测试段补 |
| PR3 加 P3009 故障恢复预案 | D-MEDIUM-5 | PR3 风险段补 |
| Snapshot 加 version 乐观锁 | D-MEDIUM-7 | 最终字段设计 §5.4 |
| 跨 schema FK 策略明示（DB 层 FK + 不写 @relation）| A-MEDIUM-5/6 | §8.5 重写 |
| LabelType 归 Dictionary（不立独立表）| A-LOW-1 | PR1 seed 改 |
| dryRun 改用真事务 ROLLBACK 模式 | D-LOW-2 | PR5 §7.4 改 |
| 测试数据隔离明示前缀 + 随机后缀 | T-LOW-1 | §8.2 补 |
| 不 SaaS 化承诺 | A-LOW-2 | §1.1 加入 |
| v5 Conflicts/Cleaning Log/Source Coverage 三 sheet 解析 | B-MEDIUM-5 | PR5 §7.4 补 |

## 1.2 重构范围

| 改动 | 范围 |
|---|---|
| **动**：`backend/prisma/schema/robot_manager.prisma` | 大改 |
| **动**：`backend/prisma/schema/platform_master.prisma`（新建）| 新建 |
| **动**：`backend/src/modules/robot-manager/` 几乎全部 service / controller | 大改 |
| **动**：`backend/src/modules/platform-master/`（新建）| 新建 |
| **动**：`frontend/src/pages/robot-manager/*` 大部分页面 | 中改 |
| **不动**：`platform_iam.User` / `corp_hr.Department` / 其他模块 | 完全不动 |
| **不动**：已合并的迁移文件 | 严格遵守 standard 04 |

---

## 2. PR 拆分总览

按 standard 14 PR 拆分准则 + standard 05 「高风险路径独立 PR」原则：

| PR | 主题 | 风险等级 | 规模估计 | 前置依赖 |
|---|---|---|---|---|
| **PR1** | L1b 字典 + RobotLifecycleStage enum 扩到 29 stage（零侵入）| 低 | 小 | 无 |
| **PR2** | L1a 主数据 + 删除旧 RobotCustomer/Supplier/Location/Attachment 表 | 中 | 中 | PR1 |
| **PR3** | RobotUnit 拆分 + LifecycleEvent 全事件流 + RobotUnitSnapshot + 删除 RobotFieldDef | **高** | 中-大 | PR2 |
| **PR4** | L3 业务表 + Guard 函数（可拆 4a/4b/4c 按业务侧）| 中 | 大 | PR3 |
| **PR5**（可选）| v5 历史 172 行导入 + RobotImportAudit | 中 | 中 | PR4，业务方触发 |

**关键原则**：
- 每个 PR 独立可 merge / 部署 / 回退
- PR3 最高风险（动 RobotUnit 核心实体）—— 强制 L0c + L1 全跑 + L2 MCP
- PR4 可按业务侧再拆 PR4a（采购+销售）/ PR4b（交付+财务）/ PR4c（售后+租赁）

---

## 3. PR1 — L1b 字典 + enum 扩（零侵入）

### 3.1 范围

- 新建 `platform_master` Postgres schema + prisma 文件
- 创建 L1b 字典表：`Currency` / `Country` / `Region` / `UnitOfMeasure` / `LabelType` + 通用 `Dictionary`
- 重命名 `RobotStatus` enum 为 `RobotLifecycleStage`，扩到 29 stage（含部门前缀）
- 新增 `RobotUsageType` / `RobotDisposalType` / 各 `*Status` enum（按 [最终字段设计.md §9](最终字段设计.md#9-状态枚举一览))
- seed 字典初始数据
- **不动 robot_manager 业务表结构**（除 enum 重命名）

### 3.2 Prisma schema 改动

新建 `backend/prisma/schema/platform_master.prisma`：

```prisma
// L1b 字典 / 参考数据
model Currency {
  code      String  @id @db.VarChar(3) // USD/CNY/EUR
  name      String
  symbol    String?
  decimals  Int     @default(2)
  enabled   Boolean @default(true)
  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  @@map("currencies")
  @@schema("platform_master")
}

model Country {
  code   String @id @db.VarChar(2) // CN/US/UAE
  iso3   String @unique @db.VarChar(3)
  name   String
  region String?
  @@map("countries")
  @@schema("platform_master")
}

model Region {
  code      String @id @db.VarChar(8)
  name      String
  countries String[]
  @@map("regions")
  @@schema("platform_master")
}

model UnitOfMeasure {
  code     String @id @db.VarChar(8)
  name     String
  category String? // length/weight/volume
  @@map("units_of_measure")
  @@schema("platform_master")
}

model LabelType {
  code  String @id @db.VarChar(32) // BODY_FCC / BATTERY_SN / ...
  name  String
  scope String // robot_modification / packaging / ...
  @@map("label_types")
  @@schema("platform_master")
}

// 通用字典（纯枚举无专属字段的合并表）
model Dictionary {
  category  String @db.VarChar(32) // tariff_type / declaration_type / industry / service_issue_type
  code      String @db.VarChar(32)
  labelEn   String @map("label_en")
  labelZh   String? @map("label_zh")
  sortOrder Int    @default(0) @map("sort_order")
  enabled   Boolean @default(true)
  metadata  Json   @default("{}")
  @@id([category, code])
  @@index([category, enabled, sortOrder])
  @@map("dictionaries")
  @@schema("platform_master")
}
```

修改 `backend/prisma/schema/robot_manager.prisma`：

```prisma
// 旧：enum RobotStatus { ORDERED IN_TRANSIT ... }（10 值）
// 新：扩到 29 stage 含部门前缀
enum RobotLifecycleStage {
  SUPPLY_PO_CREATED
  SUPPLY_IN_PRODUCTION
  // ... 见 最终字段设计.md §9 完整 29 stage
}

enum RobotUsageType {
  SALES RND MARKETING CAPITAL PRODUCT TO_AGIBOT LAUNCH_EVENT
}

enum RobotDisposalType {
  SCRAPPED SWAPPED_TO_AGIBOT CANCELLED
}

// 各业务表 enum（PR4 才用，PR1 先建好）
enum PurchaseOrderStatus { DRAFT ORDERED PARTIAL_RECEIVED RECEIVED CLOSED CANCELLED }
enum SalesOrderStatus    { RESERVED PENDING APPROVED FULFILLING COMPLETED CANCELLED }
// ... 其余完整 enum 列表见 最终字段设计.md §9
```

### 3.3 Migration SQL 模板

`backend/prisma/migrations/{timestamp}_pr1_platform_master_dict_enums/migration.sql`：

```sql
-- 1. 新建 platform_master schema
CREATE SCHEMA IF NOT EXISTS "platform_master";

-- 2. 建字典表（Prisma 自动生成，列举关键 DDL）
CREATE TABLE "platform_master"."currencies" (
  "code"       VARCHAR(3)  PRIMARY KEY,
  "name"       TEXT NOT NULL,
  "symbol"     TEXT,
  "decimals"   INTEGER NOT NULL DEFAULT 2,
  "enabled"    BOOLEAN NOT NULL DEFAULT true,
  "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE "platform_master"."countries" (
  "code"   VARCHAR(2) PRIMARY KEY,
  "iso3"   VARCHAR(3) NOT NULL UNIQUE,
  "name"   TEXT NOT NULL,
  "region" TEXT
);

CREATE TABLE "platform_master"."regions" (
  "code"      VARCHAR(8) PRIMARY KEY,
  "name"      TEXT NOT NULL,
  "countries" TEXT[] NOT NULL DEFAULT '{}'
);

CREATE TABLE "platform_master"."units_of_measure" (
  "code"     VARCHAR(8) PRIMARY KEY,
  "name"     TEXT NOT NULL,
  "category" TEXT
);

CREATE TABLE "platform_master"."label_types" (
  "code"  VARCHAR(32) PRIMARY KEY,
  "name"  TEXT NOT NULL,
  "scope" TEXT NOT NULL
);

CREATE TABLE "platform_master"."dictionaries" (
  "category"   VARCHAR(32) NOT NULL,
  "code"       VARCHAR(32) NOT NULL,
  "label_en"   TEXT NOT NULL,
  "label_zh"   TEXT,
  "sort_order" INTEGER NOT NULL DEFAULT 0,
  "enabled"    BOOLEAN NOT NULL DEFAULT true,
  "metadata"   JSONB NOT NULL DEFAULT '{}',
  PRIMARY KEY ("category", "code")
);
CREATE INDEX "dictionaries_category_enabled_sort_order_idx"
  ON "platform_master"."dictionaries" ("category", "enabled", "sort_order");

-- 3. 重命名 RobotStatus enum + 原地扩值（atomic 切换，避免"双 enum 并存"）
-- Postgres 14+ 支持事务内 ALTER TYPE ADD VALUE；project Postgres ≥ 16 ✓
-- 3a. 重命名 enum 类型
ALTER TYPE "robot_manager"."RobotStatus" RENAME TO "RobotLifecycleStage";

-- 3b. 原地扩值（追加 19 个新 stage）— 必须 IF NOT EXISTS 保幂等
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'SUPPLY_PO_CREATED';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'SUPPLY_IN_PRODUCTION';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'SUPPLY_READY_TO_SHIP';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'LOGISTICS_IN_TRANSIT';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'LOGISTICS_BONDED';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'LOGISTICS_CUSTOMS_CLEARED';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'WAREHOUSE_RECEIVED';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'WAREHOUSE_AT_W1_PDI';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'WAREHOUSE_MODIFICATION';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'WAREHOUSE_AT_W2';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'WAREHOUSE_AT_W2_RLE';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'WAREHOUSE_BRANDED_READY';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'SALES_RESERVED';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'SALES_PAYMENT_VALIDATED';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'DELIVERY_APPROVAL';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'DELIVERY_PAYMENT_COLLECTED';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'DELIVERY_READY';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'DELIVERY_DELIVERED';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'RENTAL_ACTIVE';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'AFTERSALES_TICKET';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'AFTERSALES_RETURN_INITIATED';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'AFTERSALES_RETURN_RECEIVED';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'AFTERSALES_AT_W6';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'AFTERSALES_UNDER_REPAIR';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'AFTERSALES_QUOTE_APPROVAL';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'AFTERSALES_REPAIRED';
ALTER TYPE "robot_manager"."RobotLifecycleStage" ADD VALUE IF NOT EXISTS 'CLOSED';
-- CANCELLED 旧值已存在
-- RETURNED 旧值（如有）保留

-- 3c. 数据值迁移：把旧值映射到新值（生产数据已确认可丢，但为完整性写好）
UPDATE "robot_manager"."robot_units" SET "current_status" = 'SUPPLY_PO_CREATED'        WHERE "current_status" = 'ORDERED';
UPDATE "robot_manager"."robot_units" SET "current_status" = 'LOGISTICS_IN_TRANSIT'     WHERE "current_status" = 'IN_TRANSIT';
UPDATE "robot_manager"."robot_units" SET "current_status" = 'LOGISTICS_BONDED'         WHERE "current_status" = 'BONDED';
UPDATE "robot_manager"."robot_units" SET "current_status" = 'WAREHOUSE_BRANDED_READY'  WHERE "current_status" = 'IN_STOCK';
UPDATE "robot_manager"."robot_units" SET "current_status" = 'SALES_RESERVED'           WHERE "current_status" = 'RESERVED';
UPDATE "robot_manager"."robot_units" SET "current_status" = 'SALES_PAYMENT_VALIDATED'  WHERE "current_status" = 'SOLD';
UPDATE "robot_manager"."robot_units" SET "current_status" = 'DELIVERY_DELIVERED'       WHERE "current_status" = 'DELIVERED';
UPDATE "robot_manager"."robot_units" SET "current_status" = 'AFTERSALES_UNDER_REPAIR'  WHERE "current_status" = 'REPAIR';
UPDATE "robot_manager"."robot_units" SET "current_status" = 'AFTERSALES_REPAIRED'      WHERE "current_status" = 'REPAIRED';
-- CANCELLED 不动

-- 同上对 status_change_logs
UPDATE "robot_manager"."robot_status_change_logs" SET "from_status" = 'SUPPLY_PO_CREATED' WHERE "from_status" = 'ORDERED';
-- ... 同上一系列 UPDATE（生产 89 台逐项映射）

-- 3d. 旧 enum 值留在 enum 类型里不删（Postgres 不支持 enum 删值 + Prisma migration 也不需要清）

-- 4. 新建其他业务 enum
CREATE TYPE "robot_manager"."RobotUsageType" AS ENUM (
  'SALES', 'RND', 'MARKETING', 'CAPITAL', 'PRODUCT', 'TO_AGIBOT', 'LAUNCH_EVENT'
);
CREATE TYPE "robot_manager"."RobotDisposalType" AS ENUM (
  'SCRAPPED', 'SWAPPED_TO_AGIBOT', 'CANCELLED'
);
-- ... 其他 enum 按 最终字段设计.md §9 完整列表
```

> **关键变化**：放弃"双 enum 兼容"幻觉（PR1 不动 column type 但写新值会报错）。改为 atomic 原地扩值 + 一次性 UPDATE 数据。Postgres 14+ 支持事务内 `ALTER TYPE ADD VALUE`，Prisma 16+ migrate 接受这种 SQL。

### 3.4 Seed 数据

`backend/prisma/seed/platform-master-seed.ts`（新建）：

```typescript
const currencies = [
  { code: 'USD', name: 'US Dollar', symbol: '$', decimals: 2 },
  { code: 'CNY', name: '人民币', symbol: '¥', decimals: 2 },
  { code: 'EUR', name: 'Euro', symbol: '€', decimals: 2 },
  { code: 'AED', name: 'UAE Dirham', symbol: 'د.إ', decimals: 2 },
]
const countries = [
  { code: 'CN', iso3: 'CHN', name: 'China', region: 'APAC' },
  { code: 'US', iso3: 'USA', name: 'United States', region: 'NA' },
  { code: 'AE', iso3: 'ARE', name: 'United Arab Emirates', region: 'MEA' },
  // ...
]
const labelTypes = [
  { code: 'BODY_FCC',         name: '机身 FCC 标',     scope: 'robot_modification' },
  { code: 'BODY_MADE_IN_CN',  name: '机身 Made in CN', scope: 'robot_modification' },
  { code: 'BODY_SN',          name: '机身 SN 标',      scope: 'robot_modification' },
  { code: 'REMOTE_SN',        name: '遥控器 SN 标',    scope: 'robot_modification' },
  { code: 'BATTERY_SN',       name: '电池 SN 标',      scope: 'robot_modification' },
  { code: 'INSPECTION_SHEET', name: '检验单标',        scope: 'robot_modification' },
  { code: 'SHIPPING_BOX',     name: '出货箱标',        scope: 'packaging' },
]
const dictionaries = [
  // tariff_type
  { category: 'tariff_type', code: 'GENERAL', labelEn: 'General Tariff', labelZh: '一般关税', sortOrder: 1 },
  { category: 'tariff_type', code: 'BONDED',  labelEn: 'Bonded Zone',    labelZh: '保税区',   sortOrder: 2 },
  // declaration_type
  { category: 'declaration_type', code: 'NORMAL', labelEn: 'Normal Import', labelZh: '一般进口', sortOrder: 1 },
  // service_issue_type
  { category: 'service_issue_type', code: 'WATER_DAMAGE',  labelEn: 'Water Damage',   labelZh: '进水', sortOrder: 1 },
  { category: 'service_issue_type', code: 'FRONT_DAMAGE',  labelEn: 'Front Damage',   labelZh: '前部损伤', sortOrder: 2 },
  { category: 'service_issue_type', code: 'DISSEMBLED',    labelEn: 'Dissembled',     labelZh: '已拆解', sortOrder: 3 },
  // ... 其余字典见 最终字段设计.md
]
```

### 3.5 Service / API 改动

| 改动 | 范围 |
|---|---|
| 新建 `PlatformMasterModule`（NestJS）| 含 currencies / countries / regions / unitOfMeasure / dictionary 5 个 service + repository（LabelType 归 Dictionary，不立独立 service）|
| 新建 API `GET /api/platform-master/dictionaries?category=xxx` | 字典查询，前端下拉（category=label_type / tariff_type / declaration_type / industry / service_issue_type 等）|
| 新建 API `GET /api/platform-master/currencies` 等 | 标准字典 CRUD |
| `RobotManagerModule` 内 `RobotStatus` 引用全改 `RobotLifecycleStage`，TS enum 直接换 | service 层批量替换 |
| Migration UPDATE 把生产 89 行旧 enum 值映射到新值 | 一次性 |

**部署原子性**：PR1 单 migration 完成 `ALTER TYPE RENAME + ADD VALUE + UPDATE 数据`，**无中间态**——service 层部署完即可写新值。

### 3.6 前端改动

| 改动 | 范围 |
|---|---|
| 新建 `frontend/src/api/platform-master.ts` | API client |
| `frontend/src/locales/{zh,en}/robot-manager.json` 加 29 stage 中英文标签 | i18n |
| 现有 `RobotStatus` enum 类型定义改为兼容版本 | TS interface |

PR1 不需要新建任何 UI 页面。

### 3.7 测试清单

| 层 | 内容 | 工具 |
|---|---|---|
| **L0a/L0b** | 字段名静态对比 | `npx ts-node testing/scripts/contract-check.ts` |
| **L0c** | API response 快照 (currencies/countries/dictionary) | CI 门禁 |
| **L1** | `platform-master` 集成测试：CRUD + seed 数据存在性 | Jest + Docker PG |
| **L1** | enum 扩值后旧值 UPDATE 一次性脚本正确 | Jest |
| **L1c 数据质量** | 字典 seed 完整性（labelEn/labelZh 非空、category 枚举合法）| 静态校验脚本 |
| **L1c** | 现有 robot_units 旧 status 值已全部 UPDATE 到新 stage 值 | SQL assert |
| L2 / L3 | 无 UI 改动跳过 | — |
| **回归** | 现有 robot-manager 列表/详情仍能展示（前端 enum 类型已切换）| MCP 一遍 |

### 3.8 风险与回退

| 风险 | 等级 | 缓解 |
|---|---|---|
| seed 与现有 RobotOption 冲突 | 低 | 启动前清理；seed 用 upsert |
| `ALTER TYPE ADD VALUE` 在事务内执行 | 低 | Postgres 14+ 支持；项目 PG 16 已满足 |
| UPDATE 旧值未覆盖所有可能历史值 | 中 | UPDATE 前 `SELECT DISTINCT current_status` 列出所有旧值核对映射表完整 |

**回退**：删除 platform_master schema + revert UPDATE 数据 + `ALTER TYPE ... RENAME TO RobotStatus`（enum 加的值无法删，但留着不影响）。

**P3009 故障恢复**：见 [docs/ops/01-server-infrastructure.md §9](../../../ops/01-server-infrastructure.md) 手动 SQL 标记 + 修正 + 重跑流程。

### 3.9 验收点

- [ ] `cd backend && npm run prisma:generate && npm run build` 通过
- [ ] `cd backend && npm run db:push && npm run db:seed` 成功
- [ ] `SELECT * FROM platform_master.currencies` 返回 4 行
- [ ] `SELECT * FROM platform_master.label_types` 返回 7 行
- [ ] 现有机器人模块前端列表页打开正常（兼容性）
- [ ] L1 集成测试全绿

---

## 4. PR2 — L1a 主数据 + 删除旧 Robot{Customer,Supplier,Location,Attachment} 表

### 4.1 范围

- 在 `platform_master.prisma` 加 L1a 主数据：`Customer / Supplier / Partner / Location / Attachment` + 各自 contacts/addresses 子表
- `robot_manager.RobotUnit.customer_id/supplier_id/location_id` FK 改指向 `platform_master.*`
- `robot_manager.RobotAttachment` 删除 + 数据迁到 `platform_master.Attachment(ownerType='robot_unit')`
- **生产数据可丢**：TRUNCATE 旧表后用 seed 重建几条标杆数据（PR2 实施前 Ricky Liu 最终确认）

### 4.2 Prisma schema 改动

```prisma
// platform_master.prisma 增加

enum CustomerType { B2B B2C INTERNAL }
enum SupplierType { MANUFACTURER PARTS LOGISTICS SERVICE }
enum PartnerRole  { SALES_AGENT MENTOR MENTEE CUSTOMS_BROKER TRAINING_PARTNER }
enum LocationType { WAREHOUSE CUSTOMER_SITE FACTORY BONDED_ZONE IN_TRANSIT SERVICE_CENTER OFFICE }

model Customer {
  id             String @id @default(uuid()) @db.Uuid
  code           String @unique
  name           String
  type           CustomerType @default(B2B)
  industry       String?
  countryCode    String?  @map("country_code") @db.VarChar(2)
  taxId          String?  @map("tax_id")
  creditLimit    Decimal? @map("credit_limit") @db.Decimal(14,2)
  currency       String?  @db.VarChar(3)
  organizationId String   @map("organization_id") @db.Uuid
  createdAt      DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt      DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  createdById    String   @map("created_by_id") @db.Uuid
  deletedAt      DateTime? @map("deleted_at") @db.Timestamptz(3)
  metadata       Json     @default("{}")

  contacts  CustomerContact[]
  addresses CustomerAddress[]

  @@index([organizationId])
  @@index([organizationId, deletedAt])
  @@map("customers")
  @@schema("platform_master")
}

model CustomerContact {
  id         String @id @default(uuid()) @db.Uuid
  customerId String @map("customer_id") @db.Uuid
  name       String
  role       String?
  phone      String?
  email      String?
  isPrimary  Boolean @default(false) @map("is_primary")
  customer   Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
  @@index([customerId])
  @@map("customer_contacts")
  @@schema("platform_master")
}

model CustomerAddress {
  id         String @id @default(uuid()) @db.Uuid
  customerId String @map("customer_id") @db.Uuid
  addressType String @map("address_type") // BILLING / SHIPPING
  line1      String
  line2      String?
  city       String?
  state      String?
  postalCode String?  @map("postal_code")
  countryCode String? @map("country_code") @db.VarChar(2)
  customer   Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
  @@index([customerId])
  @@map("customer_addresses")
  @@schema("platform_master")
}

// Supplier / Partner / Location 类似结构（含 contacts）
// 完整定义见后续 schema 文件

model Attachment {
  id           String @id @default(uuid()) @db.Uuid
  ownerType    String @map("owner_type") // robot_unit / customer / purchase_order / ...
  ownerId      String @map("owner_id") @db.Uuid
  filename     String
  mimeType     String @map("mime_type")
  sizeBytes    BigInt @map("size_bytes")
  storagePath  String @map("storage_path")
  category     String?
  uploadedById String @map("uploaded_by_id") @db.Uuid
  uploadedAt   DateTime @default(now()) @map("uploaded_at") @db.Timestamptz(3)
  organizationId String @map("organization_id") @db.Uuid
  @@index([ownerType, ownerId])
  @@index([organizationId])
  @@map("attachments")
  @@schema("platform_master")
}
```

`robot_manager.prisma` 改动：

```prisma
// RobotUnit 删除：customerId / supplierId / locationId 列 (PR3 才彻底删)
// RobotUnit 改 FK 指向 platform_master.*
model RobotUnit {
  // ...
  supplierId  String?  @map("supplier_id") @db.Uuid       // → platform_master.Supplier
  customerId  String?  @map("customer_id") @db.Uuid       // → platform_master.Customer（PR3 会移除，放 Snapshot）
  locationId  String?  @map("location_id") @db.Uuid       // → platform_master.Location（PR3 会移除，放 Snapshot）
  // FK 关系：禁止跨 schema FK（standard 04 关键约束），只存 UUID 引用，不建 @relation
}

// 删除：model RobotCustomer / RobotSupplier / RobotLocation / RobotAttachment
```

### 4.3 Migration SQL

```sql
-- 1. 创建 platform_master.customers/suppliers/partners/locations/attachments + 子表
CREATE TABLE "platform_master"."customers" (...);
CREATE TABLE "platform_master"."customer_contacts" (...);
-- ...

-- 2. 数据迁移：显式列表 TRUNCATE 旧表（避免 CASCADE 顺序坑）
TRUNCATE TABLE
  "robot_manager"."robot_attachments",
  "robot_manager"."robot_status_change_logs",
  "robot_manager"."robot_service_records",
  "robot_manager"."robot_units",
  "robot_manager"."robot_locations",
  "robot_manager"."robot_customers",
  "robot_manager"."robot_suppliers"
RESTART IDENTITY;

-- 3. 删除旧表
DROP TABLE IF EXISTS "robot_manager"."robot_customers";
DROP TABLE IF EXISTS "robot_manager"."robot_suppliers";
DROP TABLE IF EXISTS "robot_manager"."robot_locations";
DROP TABLE IF EXISTS "robot_manager"."robot_attachments";

-- 4. RobotUnit FK 列调整：
--    a. customer_id / location_id 是"会变的状态"，违反 standard 16 §3.1，DROP COLUMN（PR3 由 Snapshot 接管）
--    b. supplier_id 改名 original_supplier_id 作为"出厂供应商"不变属性保留
ALTER TABLE "robot_manager"."robot_units" DROP CONSTRAINT IF EXISTS "robot_units_customer_id_fkey";
ALTER TABLE "robot_manager"."robot_units" DROP CONSTRAINT IF EXISTS "robot_units_supplier_id_fkey";
ALTER TABLE "robot_manager"."robot_units" DROP CONSTRAINT IF EXISTS "robot_units_location_id_fkey";

ALTER TABLE "robot_manager"."robot_units"
  DROP COLUMN IF EXISTS "customer_id",
  DROP COLUMN IF EXISTS "location_id";

ALTER TABLE "robot_manager"."robot_units"
  RENAME COLUMN "supplier_id" TO "original_supplier_id";

-- 5. 跨 schema FK 重建（DB 层 FK 保完整性，Prisma 层不写 @relation）
ALTER TABLE "robot_manager"."robot_units"
  ADD CONSTRAINT "robot_units_original_supplier_id_fkey"
  FOREIGN KEY ("original_supplier_id")
  REFERENCES "platform_master"."suppliers"("id") ON DELETE SET NULL;
```

> **PR2 不留破窗**：不再保留 `customer_id` / `location_id` 列；这两个"会变的状态"由 PR3 引入的 `RobotUnitSnapshot.currentCustomerId / currentLocationId` 接管。PR2 部署后到 PR3 上线前的窗口期，机器人没有"当前客户/位置"信息——可接受（生产数据已 TRUNCATE，新流程从 PR3 开始）。

### 4.4 Seed 数据（重建标杆数据 + 13 真实处理人 User）

`backend/prisma/seed/platform-master-l1a-seed.ts`：

```typescript
// 重建 10-20 条标杆数据用于演示 + L1 测试
const customers = [
  { code: 'C-2026-001', name: 'NS Federation', type: 'B2B', countryCode: 'US', currency: 'USD', organizationId: '...' },
  { code: 'C-2026-002', name: 'Pinnacle RE',   type: 'B2B', countryCode: 'US', currency: 'USD', ... },
  // ...
]
const suppliers = [
  { code: 'S-001', name: 'Agibot', type: 'MANUFACTURER', countryCode: 'CN', currency: 'CNY' },
]
const locations = [
  { code: 'LOC-HQ',     name: 'FF HQ',         type: 'OFFICE',       countryCode: 'US' },
  { code: 'LOC-W1',     name: 'Warehouse W1',  type: 'WAREHOUSE',    countryCode: 'US' },
  { code: 'LOC-W2',     name: 'Warehouse W2',  type: 'WAREHOUSE',    countryCode: 'US' },
  { code: 'LOC-W3',     name: 'Warehouse W3',  type: 'WAREHOUSE',    countryCode: 'US' },
  { code: 'LOC-W4',     name: 'Warehouse W4',  type: 'WAREHOUSE',    countryCode: 'US' },
  { code: 'LOC-W6',     name: 'Warehouse W6 (Aftersales)', type: 'WAREHOUSE', countryCode: 'US' },
  { code: 'LOC-FTZ',    name: 'FTZ Bonded',    type: 'BONDED_ZONE',  countryCode: 'US' },
  { code: 'LOC-FACTORY',name: 'CN Factory',    type: 'FACTORY',      countryCode: 'CN' },
]
```

`backend/prisma/seed/robot-manager-users-seed.ts`（新增，13 真实处理人 User + 部门映射）：

```typescript
// 业务方流程图揭示的真实处理人 — UAT 演示给业务方需要看到真实姓名
const robotManagerUsers = [
  // 采购供应链
  { username: 'sherry.ding',    displayName: 'Sherry Ding',     email: 'sherry.ding@ff.com',    department: 'Supply Chain' },
  { username: 'chao.zang',      displayName: 'Chao Zang',       email: 'chao.zang@ff.com',      department: 'Logistics' },
  // 仓储 / 收货 / PDI / 改装
  { username: 'hongliang.liu',  displayName: 'Hongliang Liu',   email: 'hongliang.liu@ff.com',  department: 'Warehouse' },
  { username: 'mike.domke',     displayName: 'Mike Domke',      email: 'mike.domke@ff.com',     department: 'Warehouse' },
  { username: 'luis.santana',   displayName: 'Luis Santana',    email: 'luis.santana@ff.com',   department: 'QA' },
  { username: 'prasadh',        displayName: 'Prasadh',         email: 'prasadh@ff.com',        department: 'QA' },
  { username: 'vlad',           displayName: 'Vlad',            email: 'vlad@ff.com',           department: 'Modification' },
  { username: 'alberto.gill',   displayName: 'Alberto Gill',    email: 'alberto.gill@ff.com',   department: 'Warehouse' },
  { username: 'david.michaelis',displayName: 'David Michaelis', email: 'david.michaelis@ff.com',department: 'After-Sales' },
  // 销售 / 财务
  { username: 'fiona.xu',       displayName: 'Fiona Xu',        email: 'fiona.xu@ff.com',       department: 'Sales' },
  { username: 'gina.ramirez',   displayName: 'Gina Ramirez',    email: 'gina.ramirez@ff.com',   department: 'Finance' },
  { username: 'louie.medina',   displayName: 'Louie Medina',    email: 'louie.medina@ff.com',   department: 'Delivery' },
  // 售后
  { username: 'chris.chen',     displayName: 'Chris Chen',      email: 'chris.chen@ff.com',     department: 'After-Sales' },
]
// 用 upsert，跟现有 LDAP 同步不冲突
```

**为什么是 PR2 而不是 PR4**：UAT 演示给业务方时第一眼看到真实姓名 → 业务方有体感，知道系统能用。延后到 PR4 太晚。

### 4.5 Service / API 改动

| 改动 | 范围 |
|---|---|
| 新建 `PlatformMasterCustomerModule` / `SupplierModule` / `LocationModule` / `PartnerModule` / `AttachmentModule` | 5 个 NestJS module |
| API `GET/POST/PUT/DELETE /api/platform-master/customers` | 完整 CRUD |
| `RobotManagerModule.UnitService` 改 FK 引用查询：旧 `robot.customer.name` → 新 `await prisma.customer.findUnique(...)` | 跨 schema 显式查询 |
| 删除 `RobotCustomerService` / `RobotSupplierService` / `RobotLocationService` | 退役 |

### 4.6 前端改动

| 改动 | 范围 |
|---|---|
| 新建 `frontend/src/pages/platform-master/customers/` 等管理页（CRUD）| 5 个新页面 |
| 现有 `robot-manager/customers` 页 → 跳转到 `platform-master/customers` | URL rewrite |
| 现有 robot 列表里的 "客户/供应商/位置" 列：改成调 platform-master API | 数据源切换 |

### 4.7 测试清单

| 层 | 内容 |
|---|---|
| **L0a/L0b** | 前后端字段对比 contract-check |
| **L0c** | platform-master customers/suppliers/locations/partners/attachments 5 个 API response 快照 |
| **L1** | 数据迁移测试：TRUNCATE 后能正常 seed + 关联查询 |
| **L1** | platform-master CRUD 集成测试（含 contacts/addresses 子表）|
| **L1** | 跨 schema FK 数据完整性：删 platform_master.customer 时 robot_units 的引用 SET NULL 而不是 cascade |
| **L1** | 13 真实 User seed upsert 成功 + 关联 department |
| **L1c 数据质量** | seed 数据 FK 引用有效（countryCode 在 countries 表里、currency 在 currencies 表里）|
| **L1c** | RobotUnit 表无 customer_id / location_id 列（schema 校验）|
| **L2 MCP** | 走改动页（customers / suppliers / locations / partners 新页面）+ 机器人详情页关联显示 |
| **L2 i18n** | zh-CN ↔ en-US 切换，customer/supplier/location 字段双语 |
| **L3** | 人工：旧 URL 跳转、新页面 CRUD、机器人列表关联显示、13 user 在用户列表能看到真实姓名 |
| **回归** | PR1 功能（字典 / enum）仍正常 |

### 4.8 风险与回退

| 风险 | 等级 | 缓解 |
|---|---|---|
| TRUNCATE 误删 | **高** | 实施前最终跟 Ricky Liu 确认；备份生产 DB；用 prisma migration 而非手工 SQL |
| 跨 schema 查询性能 | 中 | 显式 service 层 join，避免 N+1（用 `findMany` + 一次性查 FK） |
| 老页面 URL 引用 | 低 | URL rewrite + 旧路径 301 |

**回退**：从备份恢复 + revert PR2 migration。

### 4.9 验收点

- [ ] `platform_master.customers` 等表有 seed 数据
- [ ] `robot_units` 表清空（验证生产数据可丢）
- [ ] 新前端 customers 管理页可 CRUD
- [ ] 机器人详情页能正常显示 customer/supplier/location（跨 schema 查询）
- [ ] L1 集成测试全绿（含跨 schema 关联）
- [ ] L2 MCP 关键页面通过
- [ ] 一周内无新增 P0/P1 工单

---

## 5. PR3 — RobotUnit 拆分 + LifecycleEvent 全事件流 + Snapshot + 删除 RobotFieldDef

> **本 PR 风险最高**——动 RobotUnit 核心实体 + 删除动态字段层。强制 L0c + L1 全跑 + L2 MCP。

### 5.1 范围

- `RobotUnit` 瘦身：状态字段移出（currentStatus / customerId / locationId）+ 新增不变字段（placeholderSnOrig / supplierSn / manufactureDate / purchaseOrderId / purchaseOrderLineId / originalSupplierId / retiredAt / disposalType / usageType）
- 新建 `RobotUnitSnapshot`（物化当前状态，1:1，同事务刷新）
- `RobotStatusChangeLog → RobotLifecycleEvent`：扩 eventType + relatedType/Id + payload；老表删除
- **删除 RobotFieldDef 表 + 配套动态字段框架**：31 个字段定义全部翻译成强类型 prisma 字段
- service 层重写状态机：所有 stage 切换走 `RobotLifecycleEventService.recordEvent()` → 同事务刷 snapshot

### 5.2 Prisma schema 改动

```prisma
// robot_manager.prisma 大改

// 删除：model RobotFieldDef { ... }
// 删除：model RobotStatusChangeLog { ... }

model RobotUnit {
  id                      String  @id @default(uuid()) @db.Uuid
  organizationId          String  @map("organization_id") @db.Uuid
  version                 Int     @default(0)

  // 身份
  ffsn                    String  @unique
  ffsnDisplay             String? @map("ffsn_display")
  placeholderSnOrig       String? @map("placeholder_sn_orig")
  supplierSn              String? @map("supplier_sn")

  // 型号
  modelId                 String  @map("model_id") @db.Uuid
  skuId                   String  @map("sku_id") @db.Uuid
  usageType               RobotUsageType @map("usage_type")

  // 来源（不变追溯）
  purchaseOrderId         String? @map("purchase_order_id") @db.Uuid       // → PurchaseOrder, PR4 才有
  purchaseOrderLineId     String? @map("purchase_order_line_id") @db.Uuid  // → PurchaseOrderLine
  originalSupplierId      String? @map("original_supplier_id") @db.Uuid    // → platform_master.Supplier
  manufactureDate         DateTime? @map("manufacture_date") @db.Date

  // 终态
  retiredAt               DateTime? @map("retired_at") @db.Timestamptz(3)
  disposalType            RobotDisposalType? @map("disposal_type")
  disposalNotes           String?   @map("disposal_notes")

  // 兜底
  metadata                Json @default("{}")

  // 标准字段
  createdAt               DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt               DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  createdById             String   @map("created_by_id") @db.Uuid
  deletedAt               DateTime? @map("deleted_at") @db.Timestamptz(3)

  // Relations (模块内)
  model    RobotModel @relation(fields: [modelId], references: [id])
  sku      RobotSku   @relation(fields: [skuId], references: [id])
  snapshot RobotUnitSnapshot?
  events   RobotLifecycleEvent[]

  @@index([organizationId, ffsn])
  @@index([organizationId, modelId])
  @@index([organizationId, deletedAt])
  @@map("robot_units")
  @@schema("robot_manager")
}

model RobotUnitSnapshot {
  robotUnitId               String @id @map("robot_unit_id") @db.Uuid
  currentStage              RobotLifecycleStage @map("current_stage")
  isHeld                    Boolean @default(false) @map("is_held")
  holdReason                String? @map("hold_reason")
  currentLocationId         String? @map("current_location_id") @db.Uuid
  currentCustomerId         String? @map("current_customer_id") @db.Uuid
  currentSalesOrderId       String? @map("current_sales_order_id") @db.Uuid
  currentDeliveryRequestId  String? @map("current_delivery_request_id") @db.Uuid
  currentSpecialistId       String? @map("current_specialist_id") @db.Uuid
  physicalProductStatus     RobotPhysicalStatus? @map("physical_product_status")
  warrantyStatus            RobotWarrantyStatus? @map("warranty_status")
  daysReadyForDelivery      Int?    @map("days_ready_for_delivery") // computed
  lastEventId               String? @map("last_event_id") @db.Uuid
  lastEventAt               DateTime? @map("last_event_at") @db.Timestamptz(3)
  derivedAt                 DateTime @default(now()) @map("derived_at") @db.Timestamptz(3)

  robotUnit RobotUnit @relation(fields: [robotUnitId], references: [id], onDelete: Cascade)

  @@index([currentStage])
  @@index([currentLocationId])
  @@index([currentCustomerId])
  @@index([currentSalesOrderId])
  @@map("robot_unit_snapshots")
  @@schema("robot_manager")
}

model RobotLifecycleEvent {
  id                String   @id @default(uuid()) @db.Uuid
  robotUnitId       String   @map("robot_unit_id") @db.Uuid
  eventType         RobotLifecycleEventType @map("event_type")
  fromStage         RobotLifecycleStage? @map("from_stage")
  toStage           RobotLifecycleStage? @map("to_stage")
  fromLocationId    String?  @map("from_location_id") @db.Uuid
  toLocationId      String?  @map("to_location_id") @db.Uuid
  actorUserId       String?  @map("actor_user_id") @db.Uuid
  partnerId         String?  @map("partner_id") @db.Uuid
  customerId        String?  @map("customer_id") @db.Uuid
  relatedType       RobotEventRelatedType? @map("related_type")
  relatedId         String?  @map("related_id") @db.Uuid
  payload           Json     @default("{}")
  notes             String?
  occurredAt        DateTime @map("occurred_at") @db.Timestamptz(3)
  recordedAt        DateTime @default(now()) @map("recorded_at") @db.Timestamptz(3)

  robotUnit RobotUnit @relation(fields: [robotUnitId], references: [id], onDelete: Cascade)

  @@index([robotUnitId, occurredAt(sort: Desc)])
  @@index([eventType, occurredAt])
  @@index([relatedType, relatedId])
  @@map("robot_lifecycle_events")
  @@schema("robot_manager")
}

enum RobotLifecycleEventType {
  stage_changed held unheld
  location_moved sn_activated usage_type_changed
  label_applied inspection_logged readiness_completed
  payment_collected delivery_signed
  service_opened service_closed
  imported_from_v5 note_added
}
enum RobotEventRelatedType {
  PO SO DELIVERY_REQUEST SERVICE_TICKET INSPECTION
  COMPLIANCE_CHECK READINESS LABEL LOGISTICS_LEG PAYMENT
}

enum RobotPhysicalStatus { STORED IN_TRANSIT DELIVERED IN_SERVICE RETIRED }
enum RobotWarrantyStatus { INACTIVE ACTIVE EXPIRED VOIDED }
```

### 5.3 Migration SQL

```sql
-- 1. 删除老表（数据已通过 PR2 TRUNCATE 清空）
DROP TABLE "robot_manager"."robot_field_defs";
DROP TABLE "robot_manager"."robot_status_change_logs";

-- 2. RobotUnit 字段重组（删除 + 新增）
ALTER TABLE "robot_manager"."robot_units" 
  DROP COLUMN "current_status",
  DROP COLUMN "customer_id",
  DROP COLUMN "location_id",
  DROP COLUMN "supplier_id"; -- (出厂 supplier 改名 original_supplier_id)

-- 注意：purchase_order_id / purchase_order_line_id 不在 PR3 加（PO 表 PR4a 才建）
-- 注意：original_supplier_id 已在 PR2 通过 RENAME 处理，不在此重复加
ALTER TABLE "robot_manager"."robot_units"
  ADD COLUMN "ffsn_display"            TEXT,
  ADD COLUMN "placeholder_sn_orig"     TEXT,
  ADD COLUMN "supplier_sn"             TEXT,
  ADD COLUMN "usage_type"              "robot_manager"."RobotUsageType" NOT NULL DEFAULT 'SALES',
  ADD COLUMN "manufacture_date"        DATE,
  ADD COLUMN "retired_at"              TIMESTAMPTZ(3),
  ADD COLUMN "disposal_type"           "robot_manager"."RobotDisposalType",
  ADD COLUMN "disposal_notes"          TEXT,
  ADD COLUMN "sap_material_no"         TEXT;

-- 3. 创建 Snapshot
CREATE TABLE "robot_manager"."robot_unit_snapshots" (
  "robot_unit_id"               UUID PRIMARY KEY,
  "current_stage"               "robot_manager"."RobotLifecycleStage" NOT NULL,
  "is_held"                     BOOLEAN NOT NULL DEFAULT false,
  "hold_reason"                 TEXT,
  "current_location_id"         UUID,
  "current_customer_id"         UUID,
  "current_sales_order_id"      UUID,
  "current_delivery_request_id" UUID,
  "current_specialist_id"       UUID,
  "physical_product_status"     "robot_manager"."RobotPhysicalStatus",
  "warranty_status"             "robot_manager"."RobotWarrantyStatus",
  "days_ready_for_delivery"     INTEGER,
  "last_event_id"               UUID,
  "last_event_at"               TIMESTAMPTZ(3),
  "derived_at"                  TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "version"                     INTEGER NOT NULL DEFAULT 0,
  FOREIGN KEY ("robot_unit_id") REFERENCES "robot_manager"."robot_units"("id") ON DELETE CASCADE,
  -- 跨 schema FK（DB 层完整性，Prisma 层不写 @relation）
  FOREIGN KEY ("current_location_id") REFERENCES "platform_master"."locations"("id") ON DELETE SET NULL,
  FOREIGN KEY ("current_customer_id") REFERENCES "platform_master"."customers"("id") ON DELETE SET NULL,
  FOREIGN KEY ("current_specialist_id") REFERENCES "platform_iam"."users"("id") ON DELETE SET NULL
);
CREATE INDEX "snapshot_stage_idx"    ON "robot_manager"."robot_unit_snapshots"("current_stage");
CREATE INDEX "snapshot_location_idx" ON "robot_manager"."robot_unit_snapshots"("current_location_id");
CREATE INDEX "snapshot_customer_idx" ON "robot_manager"."robot_unit_snapshots"("current_customer_id");
CREATE INDEX "snapshot_so_idx"       ON "robot_manager"."robot_unit_snapshots"("current_sales_order_id");

-- 4. 创建 LifecycleEvent
CREATE TYPE "robot_manager"."RobotLifecycleEventType" AS ENUM (
  'stage_changed', 'held', 'unheld',
  'location_moved', 'sn_activated', 'usage_type_changed',
  'label_applied', 'inspection_logged', 'readiness_completed',
  'payment_collected', 'delivery_signed',
  'service_opened', 'service_closed',
  'imported_from_v5', 'note_added'
);
CREATE TYPE "robot_manager"."RobotEventRelatedType" AS ENUM (
  'PO', 'SO', 'DELIVERY_REQUEST', 'SERVICE_TICKET', 'INSPECTION',
  'COMPLIANCE_CHECK', 'READINESS', 'LABEL', 'LOGISTICS_LEG', 'PAYMENT'
);
CREATE TABLE "robot_manager"."robot_lifecycle_events" (
  "id"                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  "robot_unit_id"      UUID NOT NULL,
  "event_type"         "robot_manager"."RobotLifecycleEventType" NOT NULL,
  "from_stage"         "robot_manager"."RobotLifecycleStage",
  "to_stage"           "robot_manager"."RobotLifecycleStage",
  "from_location_id"   UUID,
  "to_location_id"     UUID,
  "actor_user_id"      UUID,
  "partner_id"         UUID,
  "customer_id"        UUID,
  "related_type"       "robot_manager"."RobotEventRelatedType",
  "related_id"         UUID,
  "payload"            JSONB NOT NULL DEFAULT '{}',
  "notes"              TEXT,
  "occurred_at"        TIMESTAMPTZ(3) NOT NULL,
  "recorded_at"        TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY ("robot_unit_id") REFERENCES "robot_manager"."robot_units"("id") ON DELETE CASCADE
);
CREATE INDEX "lifecycle_event_robot_occurred_idx" ON "robot_manager"."robot_lifecycle_events"("robot_unit_id", "occurred_at" DESC);
CREATE INDEX "lifecycle_event_type_idx" ON "robot_manager"."robot_lifecycle_events"("event_type", "occurred_at");
CREATE INDEX "lifecycle_event_related_idx" ON "robot_manager"."robot_lifecycle_events"("related_type", "related_id");
```

### 5.4 Service / API 改动

| 改动 | 范围 |
|---|---|
| 新建 `RobotLifecycleEventService` | `recordEvent()` 同事务 emit + 刷 snapshot |
| 新建 `RobotUnitSnapshotService` | 物化刷新 |
| 新建 `RobotLifecycleGuardService` | 7 个 guard 函数（最终字段设计.md §9b.2）|
| 重写 `RobotUnitService.changeStage()` | 调用 guard → recordEvent → 刷 snapshot 三步同事务 |
| 删除 `RobotFieldDefService` + 所有动态字段渲染代码 | 静态字段直接用 DTO |
| 重写 RobotUnit DTO（含 snapshot 关联）| TS interface 全改 |

### 5.5 前端改动

| 改动 | 范围 |
|---|---|
| 机器人列表页：状态列从 `robot.currentStatus` → `robot.snapshot.currentStage` + 部门前缀颜色显示 | 关键 UI |
| 机器人详情页：状态历史从 `statusChangeLog` → `lifecycleEvent`，事件类型多样化展示 | 改动较大 |
| 删除"动态字段管理页"（FieldDef 表已删）| 退役 |
| Guard 失败 toast：业务异常详细文案 | 新增 |
| i18n：29 stage 中英文 + 7 guard 失败原因 | 文案 |

### 5.6 测试清单

| 层 | 内容 | 重要度 |
|---|---|---|
| **L0a/L0b** | 字段对比 | 强制 |
| **L0c** | RobotUnit/Snapshot/Event 三个 API response 快照 | 强制 |
| **L1** | RobotUnit 创建/更新/删除集成测试 | 强制 |
| **L1** | 状态切换：`changeStage(SUPPLY_PO_CREATED → SUPPLY_IN_PRODUCTION)` 触发 LifecycleEvent + Snapshot 同步 | 强制 |
| **L1** | **STAGE_TRANSITIONS 完整覆盖**：所有 29 stage × 合法 transitions 走通 + 非法 transitions 被拒 | 强制 |
| **L1 Guard 7+6** | **13 个 Guard 函数全覆盖**：每个 Guard 至少一条 reject 用例（验证 stage 未变 + event 未写 + snapshot 未变三件套）+ 一条 happy path | 强制 |
| **L1** | Snapshot 事务一致性（**4 方向用例**）：(1) 事件 insert 失败时 snapshot 不变；(2) Snapshot upsert 失败时 event 也回滚；(3) 并发两个 changeStage 触发 version 乐观锁，一败一胜，败者不留脏数据；(4) RobotUnit update 约束冲突时 event/snapshot 都回滚 | 强制 |
| **L1** | 事件→Snapshot 字段投影完整：每个 RobotLifecycleEventType 触发后 Snapshot 字段更新正确（按 最终字段设计.md §5.5 映射表）| 强制 |
| **L1 性能** | **1000 台 RobotUnit + 跨 schema join 性能**：列表查询 P95 < 100ms / 详情页 P95 < 200ms；跑 `EXPLAIN ANALYZE` 抓 N+1 落 `testing/reports/perf/` | 强制 |
| **L1c 数据质量** | 新表强制 5 字段（id/createdAt/createdById/updatedAt/organizationId）存在 + JSONB payload schema 校验 | 强制 |
| **L1c** | Snapshot 性质豁免文档明示 | 强制 |
| **L2 MCP** | 机器人列表 + 详情 + 状态切换交互 | 强制 |
| **L2 MCP** | i18n 双语切换（29 stage + 13 Guard 失败原因中英文）| 强制 |
| **L3 人工** | 关键业务流程：从 SUPPLY_PO_CREATED 到 DELIVERY_DELIVERED 全流程走一遍 | 强制 |
| **跨 PR 回归** | PR2 platform-master CRUD 仍正常 + 13 用户 seed 仍存在 | 强制 |

### 5.7 风险与回退

| 风险 | 等级 | 缓解 |
|---|---|---|
| Snapshot 跟事件不一致（事件成功 snapshot 失败）| 高 | 严格同事务 + 集成测试覆盖 4 方向事务失败 + version 乐观锁 |
| 老 RobotStatus 值在前端缓存里 | 中 | 部署后强制刷新 + i18n 兼容 |
| Guard 漏写导致非法 stage 切换 | 中 | service 层 default-deny + 测试覆盖完整 STAGE_TRANSITIONS + 13 Guard 全覆盖 |
| 历史 RobotStatusChangeLog 数据丢失 | 低 | 数据已确认可丢；如改主意，PR3 前导出备份 |
| 前端列表性能（1000 台规模）| 中 | snapshot 索引 + 跨 schema 批量 join + L1 EXPLAIN ANALYZE 验证 |
| PR3 大 migration 中途失败 | 中 | 见下方 P3009 故障恢复 |

**回退**：revert PR3 migration（snapshot/event 表 drop） + 恢复 RobotUnit 字段 + 恢复 RobotStatusChangeLog 表（数据没了）。**回退后 robot-manager 整个模块不可用**——所以 PR3 部署前必须 L1+L2 全绿。

**P3009 故障恢复预案**（PR3 大 migration 中途失败）：
```bash
# 1. 看哪条 migration 标记 failed
docker exec ffoa-uat-postgres psql -U ffws_uat -d ffws_uat -c \
  "SELECT migration_name, started_at, finished_at, rolled_back_at FROM _prisma_migrations ORDER BY started_at DESC LIMIT 5;"

# 2. 手工 SQL 反向回退失败部分（参考本节 §5.3 反向 DDL）
docker exec -it ffoa-uat-postgres psql -U ffws_uat -d ffws_uat
> BEGIN;
> -- 例：DROP TABLE robot_unit_snapshots; DROP TABLE robot_lifecycle_events;
> -- 例：ALTER TABLE robot_units DROP COLUMN ffsn_display; ...
> COMMIT;

# 3. 清失败标记 + 重跑
docker exec ffoa-uat-postgres psql -U ffws_uat -d ffws_uat -c \
  "DELETE FROM _prisma_migrations WHERE migration_name = '<pr3_migration_name>';"
npx prisma migrate deploy
```

详见 [docs/ops/01-server-infrastructure.md §9.1 P3009](../../../ops/01-server-infrastructure.md)。

### 5.8 验收点

- [ ] RobotUnit 表无 currentStatus / customerId / locationId 列
- [ ] RobotUnitSnapshot 表存在且 1:1 RobotUnit
- [ ] RobotLifecycleEvent 表存在 + 索引正确
- [ ] L1 集成测试 100% 通过（含事务一致性）
- [ ] L2 MCP 关键页面（列表 / 详情 / 状态切换）通过
- [ ] L3 人工验收：完整 PO→DELIVERED 全流程
- [ ] Guard 函数全覆盖（7 个）+ 失败原因可读

---

## 6. PR4 — L3 业务表 + Guard 函数（可拆 4a/4b/4c）

### 6.1 范围与拆分

| 子 PR | 业务侧 | 表 |
|---|---|---|
| **PR4a** | 采购 + 销售 | PurchaseOrder + Line / SalesOrder + Line |
| **PR4b** | 交付 + 财务 | DeliveryRequest / DeliveryFulfillment / PaymentRecord |
| **PR4c** | 售后 + 仓储补全 | ServiceTicket + ServiceTicketActivity / QualityLabelRecord / RobotPackageReadiness / InspectionRecord / LogisticsLeg / ComplianceCheck |

每个子 PR 独立可 merge，前后端工作量按业务侧切。

### 6.2 Prisma schema（节选 PR4a 示例）

```prisma
model PurchaseOrder {
  id             String   @id @default(uuid()) @db.Uuid
  poNo           String   @unique @map("po_no")
  supplierId     String   @map("supplier_id") @db.Uuid // → platform_master.Supplier
  currency       String   @db.VarChar(3)               // → Currency
  totalAmount    Decimal  @map("total_amount") @db.Decimal(14,2)
  status         PurchaseOrderStatus
  orderedAt      DateTime @map("ordered_at") @db.Timestamptz(3)
  expectedAt     DateTime? @map("expected_at") @db.Timestamptz(3)
  closedAt       DateTime? @map("closed_at") @db.Timestamptz(3)
  organizationId String   @map("organization_id") @db.Uuid
  createdAt      DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt      DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  createdById    String   @map("created_by_id") @db.Uuid

  lines PurchaseOrderLine[]

  @@index([organizationId])
  @@index([organizationId, status])
  @@map("purchase_orders")
  @@schema("robot_manager")
}

model PurchaseOrderLine {
  id                  String  @id @default(uuid()) @db.Uuid
  purchaseOrderId     String  @map("purchase_order_id") @db.Uuid
  lineNo              Int     @map("line_no")
  skuId               String  @map("sku_id") @db.Uuid
  quantity            Int
  unitPrice           Decimal @map("unit_price") @db.Decimal(14,2)
  totalPrice          Decimal @map("total_price") @db.Decimal(14,2)
  currency            String  @db.VarChar(3)
  defaultUsageType    RobotUsageType? @map("default_usage_type")
  placeholderPattern  String? @map("placeholder_pattern")
  expectedAt          DateTime? @map("expected_at") @db.Timestamptz(3)

  purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
  sku           RobotSku      @relation(fields: [skuId], references: [id])

  @@unique([purchaseOrderId, lineNo])
  @@index([skuId])
  @@map("purchase_order_lines")
  @@schema("robot_manager")
}

// SalesOrder / SalesOrderLine 类似结构（含 mentorId/menteeId → Partner FK 跨 schema）
```

完整字段以 [最终字段设计.md §6](最终字段设计.md#6-l3-业务表) 为准。

### 6.3 Service / API 改动（每个子 PR）

| PR4a | PR4b | PR4c |
|---|---|---|
| `PurchaseOrderService` CRUD + 创建 PO 时自动 emit `stage_changed` 到 SUPPLY_PO_CREATED + Guard `checkPOHasSupplier` | `DeliveryRequestService` / `DeliveryFulfillmentService` + Guard `checkPGIReady` + `checkDeliveryValidation` + `checkD2EligibleWindow` | `ServiceTicketService` + RMA 工作流 + Guard `checkRMAEligible` |
| `SalesOrderService` CRUD + Guard `checkSONeedsCustomer` + Guard `checkReserveRequiresBranded`（RESERVED 时关联 RobotUnit）+ emit event | `PaymentRecordService` 含 G5 关联 + Guard `checkG5Payment` | `QualityLabelService` (7 行/台) / `PackageReadinessService` / `InspectionService` / `LogisticsLegService` / `ComplianceCheckService` / `RentalAgreementService` + Guard `checkRentalNeedsContract` / `checkClosedNeedsDisposal` |
| **加 PR3 时未引入的 `RobotUnit.purchaseOrderId/purchaseOrderLineId` FK 列**（PR4a 同 PO 表一起 ADD COLUMN + ADD CONSTRAINT）| — | — |

### 6.4 前端改动

每个子 PR 加对应业务模块管理页：
- PR4a：`/purchase-orders` + `/sales-orders` 列表/详情/编辑
- PR4b：`/delivery-requests` + `/payments` 列表/详情
- PR4c：`/service-tickets` 列表 + 在 robot 详情页加 readiness/inspection/label/logistics 子 tab

### 6.5 测试清单（每个子 PR 严格按 PR3 粒度）

**PR4a（采购 + 销售）测试清单**

| 层 | 用例 |
|---|---|
| L0a/L0b | 字段对比 contract-check |
| L0c | 新增 ~6 个 API response 快照（PO CRUD / SO CRUD）入 CI 门禁 |
| L1 | PurchaseOrder / PurchaseOrderLine CRUD + 创建 PO 时正确 emit `stage_changed → SUPPLY_PO_CREATED` + 占位 RobotUnit 行批量创建 |
| L1 | SalesOrder CRUD + RESERVED 时正确关联 RobotUnit + emit event (relatedType=SO) |
| L1 | Guard `checkPOHasSupplier` reject 路径（Supplier 已删 / 不存在） |
| L1 | Guard `checkSONeedsCustomer` reject 路径（currency 不一致 / Customer 已删） |
| L1 | Guard `checkReserveRequiresBranded` reject 路径（机器人未到 WAREHOUSE_BRANDED_READY） |
| L1 | PR3 RobotUnit 加 purchaseOrderId/Line FK + ADD CONSTRAINT 成功 |
| L1c | seed 数据 FK 引用有效（PO 引用的 Supplier 存在） |
| L2 MCP | `/purchase-orders` `/sales-orders` 列表/详情/编辑 CRUD |
| L2 i18n | zh-CN ↔ en-US 切换 PO/SO 状态枚举文案 |
| L3 人工 | 业务流程：创建 PO → 占位 RobotUnit → 模拟收货 → 创建 SO → RESERVED |
| 跨 PR 回归 | PR3 状态机 changeStage 在新业务表存在时仍正常 emit event |

**PR4b（交付 + 财务）测试清单**

| 层 | 用例 |
|---|---|
| L0a/L0b/L0c | 字段对比 + 新增 ~5 个 API 快照 |
| L1 | DeliveryRequest / DeliveryFulfillment / PaymentRecord CRUD + 关联 SO/RobotUnit |
| L1 | Guard `checkG5Payment`（硬阻塞）reject 路径 |
| L1 | Guard `checkPGIReady` / `checkDeliveryValidation` reject 路径 |
| L1 | Guard `checkD2EligibleWindow`：7 天内 vs 7 天后退货 |
| L1 | 反向 transition：DELIVERY_APPROVAL → SALES_PAYMENT_VALIDATED（补件场景）|
| L1c | seed FK 完整性 |
| L2 MCP | `/delivery-requests` `/payments` 列表/详情；财务字段（Cost/Margin/Revenue/Invoice）人工录入 + 提示 SAP 集成 placeholder |
| L2 i18n | zh-CN ↔ en-US 切换 Delivery/Payment 状态枚举 |
| L3 人工 | 业务流程：DELIVERED → PGI 触发 → 收入确认 |
| 跨 PR 回归 | PR4a PO/SO 仍正常 |

**PR4c（售后 + 仓储补全 + 租赁）测试清单**

| 层 | 用例 |
|---|---|
| L0a/L0b/L0c | 字段对比 + 新增 ~10 个 API 快照（含 ServiceTicket / Rental / QualityLabel / Readiness 等）|
| L1 | ServiceTicket + ServiceTicketActivity CRUD + RMA 工作流（stage 转 AFTERSALES_RETURN_INITIATED → RECEIVED → AT_W6 → UNDER_REPAIR → REPAIRED）|
| L1 | Guard `checkRMAEligible` reject（保修期外）|
| L1 | Guard `checkQuoteApproved` reject（OOW 但客户未审批报价）|
| L1 | Guard `checkClosedNeedsDisposal` reject（缺 disposalType / retiredAt）|
| L1 | Guard `checkRentalNeedsContract` reject（缺 LEASE PaymentRecord）|
| L1 | RentalAgreement + RentalPaymentSchedule CRUD + 月度租金到期 placeholder |
| L1 | QualityLabel 7 行/台 唯一约束（同 robotUnitId + labelTypeCode）|
| L1 | RobotPackageReadiness 10 配件 boolean + completedAt 标记 |
| L1 | 反向 transition：RENTAL_ACTIVE ↔ AFTERSALES_TICKET（租赁中报修又复租）|
| L1c | 13 LabelType Dictionary seed 正确 + 完整性 |
| L2 MCP | `/service-tickets` 列表 + 在 robot 详情页加 readiness/inspection/label/logistics 子 tab |
| L2 MCP | 租赁页面 CRUD |
| L2 i18n | zh-CN ↔ en-US 切换售后 / 租赁状态枚举 + 13 Guard 失败原因 |
| L3 人工 | 全业务流程：RECEIVED → PDI → MODIFICATION（含 7 LABEL）→ BRANDED_READY → RESERVED → DELIVERED → 报修 → 维修 → 回 DELIVERED |
| 跨 PR 回归 | PR4a/PR4b 功能仍正常 |

每个子 PR 验收要求：
- [ ] L0c 新 API 全部入 CI contract-check 门禁
- [ ] L1 集成测试 100% 通过
- [ ] L1c 静态校验通过
- [ ] L2 MCP a11y 优先定位（**禁止 data-testid**）
- [ ] L2 i18n 双语完整
- [ ] L3 人工：业务流程跑通

### 6.6 风险

| 风险 | 等级 |
|---|---|
| 业务表之间 event 关联出错 | 中 |
| 前端新页面工作量大 | 中（拆 4a/4b/4c 缓解）|
| Guard 函数集成 | 低（PR3 已建框架）|

### 6.7 验收点

每个子 PR：
- [ ] 所有 CRUD 通过 L1 集成测试
- [ ] 创建业务表行时正确 emit LifecycleEvent
- [ ] L2 MCP 关键页面通过
- [ ] L3 人工：业务流程跑一遍

---

## 7. PR5（可选）— v5 历史 172 行导入 + RobotImportAudit

### 7.1 触发条件

业务方明确要求导入 v5 历史数据。如果业务方不要求（"过去的就过去了"），**此 PR 跳过**。

### 7.2 范围

- 新建 `robot_manager.RobotImportAudit` 表（v5 GLOBAL 3 字段专用）
- 写一次性导入脚本 `scripts/data-migration/import-v5-master-2026-05.ts`
- 跑 dry-run 验证 + UAT 跑通后再生产跑
- 每台机器人插入 `eventType=imported_from_v5` 审计事件

### 7.3 Prisma schema 改动

```prisma
model RobotImportAudit {
  id              String @id @default(uuid()) @db.Uuid
  robotUnitId     String @unique @map("robot_unit_id") @db.Uuid
  sources         String[] // ['Robot_Unit_Lifecycle_Tracker', 'Robotics_Inventory_Readiness', ...]
  conflictCount   Int      @map("conflict_count") @default(0)
  recordStatus    ImportRecordStatus @map("record_status")
  conflictDetail  Json?    @map("conflict_detail")
  importedAt      DateTime @default(now()) @map("imported_at") @db.Timestamptz(3)
  importBatch     String?  @map("import_batch") // v5-2026-05
  robotUnit       RobotUnit @relation(fields: [robotUnitId], references: [id], onDelete: Cascade)
  @@index([importBatch])
  @@map("robot_import_audits")
  @@schema("robot_manager")
}
enum ImportRecordStatus { OK CONFLICT PHANTOM }
```

### 7.4 导入脚本主结构（含 4 sheet 全解析 + dryRun 真事务 ROLLBACK）

```typescript
// scripts/data-migration/import-v5-master-2026-05.ts
import { PrismaClient } from '@prisma/client'
import { parseXlsx } from './lib/xlsx-parser'
import { mapV5RowToRobotUnit } from './lib/v5-mapper'

async function main(dryRun: boolean) {
  // 解析 4 个 sheet（不仅 Master）
  const masterRows     = parseXlsx('Master_Metadata_v5.xlsx', 'Master')
  const cleaningLog    = parseXlsx('Master_Metadata_v5.xlsx', 'Cleaning Log')
  const sourceCoverage = parseXlsx('Master_Metadata_v5.xlsx', 'Source Coverage')
  const conflicts      = parseXlsx('Master_Metadata_v5.xlsx', 'Conflicts')
  const fieldMapping   = parseXlsx('Master_Metadata_v5.xlsx', 'Field Mapping')  // 4 上游字段对照

  console.log(`Found ${masterRows.length} records, dry-run=${dryRun}`)

  try {
    await prisma.$transaction(async (tx) => {
      for (const row of masterRows) {
        const mapped = mapV5RowToRobotUnit(row, sourceCoverage, conflicts, cleaningLog, fieldMapping)
        await tx.robotUnit.create({ data: mapped.unit })
        await tx.robotUnitSnapshot.create({ data: mapped.snapshot })
        await tx.robotLifecycleEvent.create({
          data: { ...mapped.importEvent, eventType: 'imported_from_v5' }
        })
        await tx.robotImportAudit.create({
          data: {
            ...mapped.audit,
            conflictDetail: mapped.conflictDetail,  // 来自 Conflicts sheet 解析
            importBatch: 'v5-2026-05',
          },
        })
      }
      // dryRun：故意抛错触发事务 ROLLBACK
      if (dryRun) throw new Error('DRY_RUN_ROLLBACK')
    })
  } catch (e: any) {
    if (e.message === 'DRY_RUN_ROLLBACK') {
      console.log('Dry-run completed — all FK/enum/unique constraints OK, no data written.')
    } else {
      throw e
    }
  }

  // 导入审计报告
  if (!dryRun) writeImportReport({ batch: 'v5-2026-05', count: masterRows.length })
}

const dryRun = process.argv.includes('--dry-run')
main(dryRun)
```

**dryRun 改进**：用真事务 + 故意 throw 触发 ROLLBACK 验证所有 FK/enum/unique 约束，而非简单跳过 `prisma.*.create()`。这样 dry-run 能真实暴露 schema 不兼容。

**conflictDetail 结构 schema**（来自 Conflicts sheet 解析）：

```typescript
type ConflictDetail = {
  field: string
  sources: { name: string; value: string }[]
  chosenValue: string
  chosenSource: string
  cleaningRule?: string  // 引用 Cleaning Log sheet
}
```

**导入审计报告**：写到 `testing/reports/v5-import-2026-05/`，含 (1) 导入条数 (2) 每条 sources 分布 (3) 冲突字段分布 (4) Cleaning Log 应用次数。

### 7.5 Enum 值清洗映射

v5 实际取值 → 规范化 enum：

```typescript
const USAGE_TYPE_MAP: Record<string, RobotUsageType> = {
  'Sales': 'SALES',
  'R&D': 'RND',
  'Marketing': 'MARKETING',
  'Capital': 'CAPITAL',
  'Product': 'PRODUCT',
  'To AGIBOT': 'TO_AGIBOT',
  'Launch Event': 'LAUNCH_EVENT',
}
const PAYMENT_STATUS_MAP: Record<string, PaymentStatus> = {
  'Paid':         'PAID',
  'Not Paid Yet': 'NOT_PAID_YET',
  'Paid in April': 'PAID',  // 脏数据归一化
  'Canceled':     'CANCELLED',
  'Draft':        'DRAFT',
}
// ... 其他 enum 映射
```

### 7.6 测试清单

- [ ] dry-run 跑通，输出预期 172 行
- [ ] UAT 实跑：172 行成功 import + 每条有 LifecycleEvent + Audit 三行同事务
- [ ] 抽样核对 10 条：v5 字段 → schema 字段一一对应
- [ ] L1 测试：被导入机器人的 changeStage / 业务表关联都正常
- [ ] 生产实跑前再次确认+备份

### 7.7 风险与回退

| 风险 | 等级 |
|---|---|
| v5 字段清洗规则缺失（脏数据漏映射）| 中 |
| 一次性脚本误跑两次 | 中 |
| 导入后发现业务方还有补丁 | 低 |

**回退**：删除本批次导入数据
```sql
DELETE FROM robot_manager.robot_import_audits WHERE import_batch = 'v5-2026-05';
DELETE FROM robot_manager.robot_lifecycle_events WHERE event_type = 'imported_from_v5' AND recorded_at >= '...';
DELETE FROM robot_manager.robot_unit_snapshots WHERE robot_unit_id IN (...);
DELETE FROM robot_manager.robot_units WHERE id IN (...);
```

---

## 8. 跨 PR 协同事项

### 8.1 命名约定

- 分支：`feature/robot-manager-refactor-pr{N}-{topic}`
  - PR1: `feature/robot-manager-refactor-pr1-platform-dict-enum`
  - PR2: `feature/robot-manager-refactor-pr2-platform-master`
  - PR3: `feature/robot-manager-refactor-pr3-unit-snapshot-event`
  - PR4a: `feature/robot-manager-refactor-pr4a-po-so`
  - 类推
- Commit 主题：约定式 + 中文
  - `feat(robot-manager): PR1 立 platform_master 字典 + 扩 RobotLifecycleStage`
- Migration 文件名：`{timestamp}_robot_manager_pr{N}_{topic}.sql`

### 8.2 测试数据库

每个 PR 集成测试用独立测试 DB（按 standard 05 测试规则）；前端 MCP 用 slot 内 dev 数据库。

### 8.3 数据库迁移规则

- **每次提交最多 1 个迁移文件**（standard 04）—— PR3 schema 改动多但合并为 1 个 migration
- PR1 / PR2 / PR3 / 每个 PR4 子 PR / PR5 各 1 个 migration 文件

### 8.4 部署顺序

PR1 → merge → 部署 UAT → 验证 → 部署生产 → 然后开 PR2 ...

**严禁 PR1 + PR2 同时部署**：一旦 PR2 失败回退会触发 PR1 二阶段问题。

### 8.2.1 测试数据隔离（standard 05）

集成测试统一前缀 + 随机后缀：
- code 前缀：`t_robot_` + `Date.now()` + 随机
  - 例：`t_robot_1747800000_customer_a1b2`
- cleanup 用前缀过滤（不按表 DELETE）：
  ```sql
  DELETE FROM platform_master.customers WHERE code LIKE 't_robot_%';
  DELETE FROM robot_manager.purchase_orders WHERE po_no LIKE 't_robot_%';
  ```
- 禁硬编码 `'Test Organization'` 这种固定名（standard 05 §测试数据生命周期）

### 8.5 跨 schema FK 策略（finding A-MEDIUM-5/6）

**策略明示**：

| 层 | 做法 |
|---|---|
| **DB 层** | 跨 schema 建 FK constraint（Postgres 支持），`ON DELETE SET NULL`；数据完整性兜底 |
| **Prisma 层** | **不**写 `@relation`，仅 UUID 列；避免 Prisma type 推导污染 |
| **Service 层** | 显式批量 join：先查主表 → 收集 FK ID 数组 → 批量查关联表（`findMany({ where: { id: { in: [...] } } })`）→ 拼装 DTO；避免 N+1 |

完整跨 schema 引用清单：

| from | to | 注释 |
|---|---|---|
| `robot_manager.robot_units.original_supplier_id` | `platform_master.suppliers.id` | 不变属性 |
| `robot_manager.robot_unit_snapshots.current_location_id` | `platform_master.locations.id` | 派生 |
| `robot_manager.robot_unit_snapshots.current_customer_id` | `platform_master.customers.id` | 派生 |
| `robot_manager.robot_unit_snapshots.current_specialist_id` | `platform_iam.users.id` | 派生 |
| `robot_manager.robot_lifecycle_events.actor_user_id` | `platform_iam.users.id` | 操作人 |
| `robot_manager.robot_lifecycle_events.partner_id` | `platform_master.partners.id` | 交接 partner |
| `robot_manager.robot_lifecycle_events.customer_id` | `platform_master.customers.id` | 关联客户 |
| `robot_manager.purchase_orders.supplier_id` | `platform_master.suppliers.id` | |
| `robot_manager.sales_orders.customer_id` | `platform_master.customers.id` | |
| `robot_manager.sales_orders.mentor_id / mentee_id` | `platform_master.partners.id` | |
| `robot_manager.logistics_legs.from_location_id / to_location_id` | `platform_master.locations.id` | |
| `robot_manager.compliance_checks.checked_by_id` | `platform_iam.users.id` | |
| `robot_manager.inspection_records.inspector_id` | `platform_iam.users.id` | |
| `robot_manager.robot_package_readinesses.specialist_id` | `platform_iam.users.id` | |
| `robot_manager.service_tickets.opened_by_id / resolved_by_id` | `platform_iam.users.id` | |
| `*.attachment_id` | `platform_master.attachments.id` | 多态附件 |

**Service 层标准 helper**：

```typescript
// backend/src/modules/robot-manager/lib/cross-schema-loader.ts
export async function loadRobotUnitDetailWithRelations(robotUnitId: string) {
  const unit = await prisma.robotUnit.findUnique({ where: { id: robotUnitId }, include: { snapshot: true } })
  if (!unit) return null
  // 批量收集所有 FK ID
  const customerIds = unique([unit.snapshot?.currentCustomerId])
  const locationIds = unique([unit.snapshot?.currentLocationId])
  const userIds = unique([unit.snapshot?.currentSpecialistId])
  // 一次性批量查
  const [customers, locations, users] = await Promise.all([
    prisma.customer.findMany({ where: { id: { in: customerIds } } }),
    prisma.location.findMany({ where: { id: { in: locationIds } } }),
    prisma.user.findMany({ where: { id: { in: userIds } } }),
  ])
  return assemble(unit, customers, locations, users)
}
```

---

## 9. 时间评估

| PR | 后端 | 前端 | 测试 | 合计 |
|---|---|---|---|---|
| PR1 | 1.5 天 | 0.5 天 | 0.5 天 | **2.5 天** |
| PR2 | 2 天 | 2 天 | 1 天 | **5 天** |
| PR3 | 3 天 | 3 天 | 2 天 | **8 天** |
| PR4a | 2 天 | 2 天 | 1 天 | **5 天** |
| PR4b | 2 天 | 2 天 | 1 天 | **5 天** |
| PR4c | 3 天 | 3 天 | 2 天 | **8 天** |
| PR5 (可选) | 1 天 | 0 天 | 1 天 | **2 天** |
| **合计** | **14.5 天** | **12.5 天** | **8.5 天** | **~35.5 天**（不含 PR5）|

AI 协作加速比传统估算 5-10×，实际可能 **5-10 工作日**完成 PR1-PR4。

---

## 10. 风险清单 + 缓解措施汇总

| # | 风险 | 影响 PR | 等级 | 缓解 |
|---|---|---|---|---|
| 1 | TRUNCATE 误删未确认数据 | PR2 | 高 | Ricky Liu 最终口头确认 + 备份 DB |
| 2 | Snapshot 跟事件事务不一致 | PR3 | 高 | 严格 `prisma.$transaction()` + 测试覆盖事务失败 |
| 3 | Guard 漏写非法 stage 切换 | PR3 | 中 | service 层 default-deny + 完整 STAGE_TRANSITIONS 测试 |
| 4 | 跨 schema 查询性能 | PR2/3 | 中 | service 层批量查 + 验证 90 台规模 <50ms |
| 5 | i18n 29 stage 文案漏译 | PR3 | 低 | L2 双语 MCP 验证 |
| 6 | 业务方临时要保留生产历史数据 | PR2 | 低 | PR2 实施前再确认；备份永留 |
| 7 | v5 字段脏数据清洗规则缺失 | PR5 | 中 | dry-run + 抽样核对 + 业务方 review 映射表 |

---

## 11. 下一步

1. **本文档 review** —— 你拍板：详细度 OK？拆分顺序 OK？时间评估合理？
2. **Ricky Liu 确认** —— "89 台生产数据可不可丢"明示
3. **PR1 开干** —— 开分支 `feature/robot-manager-refactor-pr1-platform-dict-enum`，按 §3 实施
4. 中间过程跟你定期同步进度
