# Excel 导入工具 PRD（v3 重做 · v3 draft）

> **module**: robot-manager
> **doc_type**: PRD
> **status**: ⚠️ Draft v3（已应用 R1 multi-role review + doc-review 19 finding；待 user research / M1 实施）
> **owner**: FFOA Team
> **upstream_docs**: 04-state-machine.md, 06-data-model.md, 07-api.md, 13-import-mapping-prd.md（v2 DEPRECATED）
> **last_updated**: 2026-05-18

---

## 0. 文档版本

| 版本 | 日期 | 主要变更 |
|---|---|---|
| v1 | 2026-05-18 | 初稿，已对齐 5 个用户决策（1000 行 / all-or-nothing / 任意 stage / ImportBatch / 动态模板）|
| v2 | 2026-05-18 | 应用 R1 multi-role review 7 Top Blocker + 4 冲突；新增 Vision/Persona/NSM；M1 范围砍；framework 改 M2 抽；移除 v5 历史迁移 UI 路径 |
| **v3** | 2026-05-18 | **应用 doc-review 19 finding (9🔴+8🟡+2🟢)**：跨 schema FK 清单注释 / entries 持久化策略 / multipart DTO / PR-0 子任务清单 / heartbeat 恢复 / TOCTOU retry 上限 / savepoint 工程坑 / FieldMetadata 路径 / i18n key 约定 / error response wrapper / fileHash 算法 / 同步 4 模块文档 drift（07-api / 08-error-codes / 05-ui-spec / 06-data-model）|

---

## 1. 背景与定位

### 1.0 Vision（一句话）

**让 v3 业务团队（供应链 / RLE / 销售）通过 Excel 模板，在 1 分钟内完成日常批量数据录入（PO 创建、主数据维护、售后批量），把单条 UI 操作的工时从 N×30 秒降到一次性 < 30 秒**。

**Vision 不包括**：v5 历史 ETL（用脚本，不上 UI）、跨系统数据同步（SAP 直连方案，不用 Excel）。

### 1.1 历史

- **2026-04-19 v2 实现**：完整 Excel 导入导出（4 controller + 4 service + 列映射 UI + 5 实体 + 3 冲突策略）
- **v3 重构 commit `a40d5ca2`** 主动删除所有 backend Excel 实现（schema 6 状态 → 28 stage）
- **dead reference**：frontend `EntityAdminPage.tsx` + `_lib/api/index.ts` 残留 `entityExcelApi` 调用不存在 endpoint
- **13-import-mapping-prd.md** 状态字段 "Implemented (2026-04-19)" 跟现状脱节

### 1.2 用户原话（驱动需求来源）

> 所有的数据要有一个导入入口，主要针对机器人。Excel 读取过来检查所有主数据是不是 OK，所有过程数据必填的是不是 OK，有没有无法填入的单元格。统一先整理一下有问题的反馈，没有问题的就确认可以导入了。可以下载模板。

**产品翻译**：用户需要一个**自助批量数据录入工具**——输入是 Excel，输出是数据库行。校验在前，写入在后，错了能改。

### 1.3 价值 / 为什么不是其他方案（alternative 对比）

| 方案 | 何时合适 | 不适用本场景的理由 |
|---|---|---|
| **本工具（Excel 导入）** | 业务团队自助 / 批量 5-500 行 / 模板可学 | — |
| 单条 UI 创建 | 日常 1-2 条 / 高质量 | 1000 行 PO 不现实 |
| SQL 直插 | DBA 一次性迁移 | 业务团队无权 / 易出错 / 无校验 |
| 标准 SAP import | SAP-FFOA 双向同步上线后 | 现阶段 SAP 集成未完成；且 SAP 数据 ≠ 全部业务数据 |
| 通用 ETL 工具（Airbyte）| 跨多系统持续同步 | 重量级；FFOA 业务自建模板更精准 |
| **SQL 脚本（v5 迁移）** | **一次性历史数据迁移** | **本 PRD 不重做这件事**——v5 迁移用 SQL 脚本完成，不上 UI 工具（见 §2.2）|

### 1.4 North Star Metric（NSM）

**主指标（M1 上线后 90 天测）**：业务团队**每周成功 import 调用数 ≥ 10 次** + **错误率 < 30%**

**副指标**：
- 平均一次 import 总耗时（从 download template 到 confirm 成功）< 5 分钟
- 错误率高于阈值的批次 < 20%

**Anti-metric（警惕）**：
- 一周 0 次调用 → v2 anti-pattern 复现，停做后续 milestone
- 平均错误率 > 60% → 模板设计有问题或 user research 缺失

### 1.5 Persona

| Persona | 频率 | 当前痛点 | 本工具如何帮 |
|---|---|---|---|
| **供应链 - 小李**（M1 主要服务对象） | 每周建 2-3 张 PO，每张 50-200 行 | 一行一行点 UI 太慢；JD-Excel 已经有数据 | PO importer，复制 Excel 一键上传 |
| **RLE - 老王** | M2 后才用 | 资产盘点时要批量调整 stage | RobotUnit importer（任意 stage） |
| **主数据管理员** | M3 后；初始化或季度刷新 | Customer/Supplier 一批批从邮件 Excel 拷贝 | 主数据 importer 5 类 |
| **售后 - 小张** | M3 后；事件性（如召回） | 召回时 50-100 台批量开 ticket | ServiceTicket importer |

⚠️ **Persona 假设需要 user research 验证**——v2 上线 4 个月期间是否真有这些 persona 在用？见 §17 risks。

---

## 2. 范围

### 2.1 In-scope（4 类 importer）

| 类型 | 模板字段 | 单批上限 | 创建后副作用 |
|---|---|---|---|
| **PO 批量**（M1）| poNo / supplierCode / currencyCode + line × N (lineNo/skuCode/quantity/unitPrice) | 1000 行 | 创建 PO + Line；**占位 RobotUnit 由现有 PO publish 路径触发**（不在 import 路径背双重责任）|
| **RobotUnit 全量**（M2）| 80+ 字段含 ffsn / modelCode / skuCode / poNo / **startingStage** / 各 stage 业务字段 | 1000 行 | 同事务创建 RobotUnit + RobotUnitSnapshot（startingStage） + 第一条 imported_from_external event |
| **主数据 × 5 sub-template**（M3a）| Model / SKU / Customer / Supplier / Location 各一个模板 | 1000 行/类 | 单纯 create |
| **Service Ticket**（M3b）| ticketNo / robotFfsn / issueTypeCode / severity / openedAt | 1000 行 | 创建 ServiceTicket + 触发 RobotLifecycleEvent.service_opened |

### 2.2 Out-of-scope

**v3 永不做**（明确决策不做，避免反复评估）：
- **v5 历史迁移 UI 路径** — 历史数据用 SQL 脚本一次性 ETL（在 `scripts/migration/v5-to-v3.ts`），不走 import UI（Red Team finding：v2 已验证 UI 不胜任此场景）
- **跨实体复合 import**（一个 Excel 同时建 customer + 用此 customer 的 PO）— 复杂度爆炸
- **缺失实体自动创建** — 引用不存在直接报错；不模糊匹配 / 不 fuzzy create

**M4+ 候选**（有 user research evidence 后启动）：
- 列映射 UI（v2 有，本次先固定列名简化）
- 导出（export）功能
- 导入审批工作流（高敏感场景）
- 跨组织导入

### 2.3 桌面端限定

明确「桌面端 only」（min-width 1024px），移动 / iPad 显示降级提示。**不假装支持**多端。

---

## 3. 关键决策

### 3.1 用户已对齐决策（不可推翻）

| # | 决策 | 选择 |
|---|---|---|
| 1 | 单批 dry-run 上限 | **1000 行**（实施前 spike 验证，见 §17.1） |
| 2 | 事务语义 | **全部 all-or-nothing**（但允许 per-PO chunk + savepoint 兜底，见 §7.6） |
| 3 | RobotUnit 起始 stage | **允许任意 stage** + 独立权限点 `robot-manager:import:robot-unit:any-stage` |
| 4 | 审计表架构 | **新加 ImportBatch + ImportBatchEntry**（RobotImportAudit 保留给 v5 历史，**新 import 路径不再写入**）|
| 5 | 模板生成 | **Backend 动态生成 + 代码内 TypeScript FieldMetadata 数组**（不入表、不入 JSONB 配置，复合 standard 16 §4.5 不立 L4 元数据驱动） |

### 3.2 R1 review 后定的默认决策

| 项 | 方案 | 理由 |
|---|---|---|
| 权限 | **新增独立 `robot-manager:import:bulk`**（不复用 `:create`）+ 子权限 `:import:robot-unit:any-stage` | Sales 批量建 ServiceTicket = 1000 个 event = DoS 风险（安全 🟡#3）|
| Dead code | **重写**（删除 v2 `entityExcelApi` dead code，新 ImportWizard 不复用） | v2 设计是 v2 schema 的；复用反而回退抽象（工程 🔴#5 + Red Team 🟡#9）|
| Framework | **M2 抽，不在 M1** | YAGNI；single-implementer 抽象大概率不准（工程 🟡#5 + Red Team 🟡#9）|
| 占位 RobotUnit 触发 | **不在 import 路径触发** | 复用现有 PO publish 路径，避免 import 一行扇出多 entity 的 N+1 反模式（架构 🟡#1 + 工程 🔴#2）|

---

## 4. UX 流程

### 4.1 Wizard 4 step（含 stepper UI / URL 持久化 / 状态恢复）

```
URL: /robot-manager/import
URL: /robot-manager/import/batches/:batchId  ← URL 含 batchId，刷新可恢复

Step 1: Upload                      Step 2: Preview                Step 3: Confirm        Step 4: Done
[ Download Template ]               [ Statistic Cards ]             [ Confirm Button ]      [ Success ]
[ Drag/Select File ]               [ Error Aggregation ]           [ Progress Bar ]        [ View History ]
                                    [ Download Error Report ]
                                    [ Change File ← 可回 step 1 ]
                                                                                            
顶部 stepper：◉ Upload ─ ○ Preview ─ ○ Confirm ─ ○ Done
```

### 4.2 关键 UX 决策（应对 UX/产品 finding）

- **首次访问 onboarding**：empty state + 「3 步完成你的第一次导入」引导卡 + demo 样本下载
- **按角色屏蔽**：无权限 tab **显示但 disabled** + tooltip "联系管理员申请 X 权限"
- **错误聚合**：≥100 错误时按 error code 聚合卡片（"类型错误 ×237 / FK 不存在 ×88"），点卡片下钻；行列表虚拟滚动 + 默认 50/页
- **重传语义**：重传 = **新 batch**（原 batch status=SUPERSEDED）；错误报告 Excel 中 error_detail 列设只读 + 提示
- **加载/空/错误状态**：单独章节 §8.4 列每个页面 4 态规格
- **i18n 全链路**：ValidationIssue 只存 `{code, params}`，**禁止预渲染 message**；错误报告 Excel 按 `Accept-Language` 渲染

### 4.3 Sheet 规格（应对 UX 🔴#3）

| Sheet | 内容 |
|---|---|
| Sheet1 数据 | 表头颜色（黄背景=必填 / 红边框=FK / 普通=可选）；Excel data validation 给 enum 列做下拉；cell comment 标"填 code 例如 'SUP-001'"；日期格式锁 YYYY-MM-DD |
| Sheet2 字段说明 | 必填 / 类型 / 枚举值 / FK 表+列 / 示例 / 失败 error code 列；顶部含 `templateSchemaHash` + 生成时间（防 UAT 测旧模板回归）|
| Sheet3 示例数据 | **合成假数据**（`SUP-EXAMPLE-001`），**禁从真实表 sample**（安全 🔴#5）|

---

## 5. 数据模型

### 5.1 新增表：`ImportBatch`

```prisma
/// 通用导入批次 — 一次 import 操作的总记录
/// 包含 v3 所有 4 类导入场景；RobotImportAudit 保留给 v5 历史导入（只读不再写入）
///
/// 跨 schema FK（按 standard 04 原则 2，仅存 UUID 不建 @relation）：
///   - createdById   → platform_iam.users.id
///   - confirmedById → platform_iam.users.id
/// M1 PR-A 实施时同步更新 robot_manager.prisma 顶部「跨 schema FK 清单」注释段。
model ImportBatch {
  id              String              @id @default(uuid()) @db.Uuid
  type            ImportBatchType
  status          ImportBatchStatus   @default(PENDING)
  fileName        String              @db.VarChar(255) // ⚠️ 入库前 sanitize（白名单 [A-Za-z0-9_.\-]）防 XSS
  /// SHA256 of uploaded file，**backend 接收 multipart 后流式 hash**（避免 binary buffer 整 load）；前端不预算
  fileHash        String              @db.VarChar(64)
  templateSchemaHash String           @map("template_schema_hash") @db.VarChar(16) // 防模板/schema 漂移
  totalRows       Int                 @default(0) @map("total_rows")
  successRows     Int                 @default(0) @map("success_rows")
  errorRows       Int                 @default(0) @map("error_rows")
  warningRows     Int                 @default(0) @map("warning_rows")
  startedAt       DateTime?           @map("started_at") @db.Timestamptz(3)
  completedAt     DateTime?           @map("completed_at") @db.Timestamptz(3)
  confirmedById   String?             @map("confirmed_by_id") @db.Uuid  // 谁点 confirm（可能 ≠ createdById, admin 也能 confirm）
  confirmedAt     DateTime?           @map("confirmed_at") @db.Timestamptz(3)
  clientIp        String?             @map("client_ip") @db.VarChar(45) // IPv6 max
  userAgent       String?             @map("user_agent") @db.VarChar(512)
  errorSummary    Json?               @map("error_summary")
  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)

  entries         ImportBatchEntry[]

  @@index([organizationId, deletedAt])
  @@index([organizationId, type, createdAt(sort: Desc)])
  @@index([organizationId, createdById, createdAt(sort: Desc)])  // 覆盖 SELF scope 列表
  @@index([status])
  @@map("import_batches")
  @@schema("robot_manager")
}

enum ImportBatchType {
  PURCHASE_ORDER
  ROBOT_UNIT
  MASTER_MODEL
  MASTER_SKU
  MASTER_CUSTOMER
  MASTER_SUPPLIER
  MASTER_LOCATION
  SERVICE_TICKET

  @@schema("robot_manager")
}

enum ImportBatchStatus {
  PENDING
  VALIDATING
  VALIDATED
  IMPORTING
  COMPLETED
  FAILED
  SUPERSEDED  // 用户重传后旧 batch 标记

  @@schema("robot_manager")
}
```

**DataScope**：`@DataScope('robot-manager:import-batch', { default: SELF, override: { admin: ORGANIZATION } })`——SELF 用 `createdById`，admin 走 ORGANIZATION。

### 5.2 新增表：`ImportBatchEntry`

```prisma
/// @skip-data-scope 子表，跟随父表 ImportBatch
model ImportBatchEntry {
  id              String                @id @default(uuid()) @db.Uuid
  batchId         String                @map("batch_id") @db.Uuid
  rowNo           Int                   @map("row_no")
  status          ImportEntryStatus
  entityIds       String[]              @map("entity_ids") @db.Uuid // 支持一行扇出多 entity（PO line 等场景）
  payload         Json                  // ⚠️ confirm 成功 90 天后清空（保留 entityIds + metadata）
  payloadHash     String                @map("payload_hash") @db.VarChar(64)  // SHA256(canonical payload) 防离线篡改
  errorDetail     Json?                 @map("error_detail")  // { field, code, params }[]; 禁止存预渲染 message
  createdAt       DateTime              @default(now()) @map("created_at") @db.Timestamptz(3)
  deletedAt       DateTime?             @map("deleted_at") @db.Timestamptz(3)

  batch           ImportBatch           @relation(fields: [batchId], references: [id], onDelete: Cascade)

  @@index([batchId, rowNo])
  @@index([batchId, status])
  @@map("import_batch_entries")
  @@schema("robot_manager")
}

enum ImportEntryStatus {
  OK
  ERROR
  WARNING

  @@schema("robot_manager")
}
```

### 5.3 不动的表
- `RobotImportAudit`：保留只读；新 import 路径**不再写入**
- `ImportRecordStatus` enum：保留给 RobotImportAudit

### 5.4 PII 处理（应对安全 🔴#3）

FieldMetadata 加 `pii?: boolean` 标记。落 ImportBatchEntry.payload 前：
- `pii: true` 字段 mask（手机后 4 位 / 邮箱前缀 / taxId 中段）
- 校验期内存中流转用完整值，写库前 mask
- pino 日志 redact `payload.email` / `payload.phone` / `payload.taxId` / `payload.customerFeedback`

---

## 6. API 设计

### 6.1 endpoints（每类 6 个）

**Response wrapper**：所有 endpoint 复用项目标准 wrapper：
```
成功 200: { success: true, data: <type>, timestamp, path }
失败 4xx/5xx: { success: false, error: { code, message, details, stack }, timestamp, path, method, statusCode }
details.errors: ValidationIssue[]（见 §7.1）
```

```
GET    /robot-manager/import/:type/template
       Response 200 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
       Response Header: X-Template-Schema-Hash: <sha256(fieldMetadata)>
       Response Header: Content-Disposition: attachment; filename="{type}-template-{schemaHash[:8]}.xlsx"
       Response Header: ETag / Cache-Control: public, max-age=3600（schema hash 不变可 304）

POST   /robot-manager/import/:type/preview
       Content-Type: multipart/form-data
       multipart field name = "file"（NestJS @UploadedFile() FastifyFileInterceptor('file')）
       Body: file（max 10MB；MIME 白名单；nginx client_max_body_size 15M）
       Headers: Idempotency-Key（防重复上传同一文件；fileHash + 24h 内 + 同 user → 返已有 batchId）
       Response 200 data: {
         batchId: string (uuid),
         summary: {
           totalRows: number,
           successRows: number,
           errorRows: number,
           warningRows: number,
           templateSchemaHash: string,
           fileHash: string
         }
       }

GET    /robot-manager/import/batches/:batchId
       SQL: WHERE id = :id AND organizationId = :org AND (createdById = :user OR isAdmin)
       缺一抛 404（不是 403，防探测）
       Response 200 data: {
         batch: ImportBatch,
         entries: ImportBatchEntry[]  // 最多 1000 行；前端分页/虚拟滚动
       }

POST   /robot-manager/import/batches/:batchId/confirm
       SQL: UPDATE ... WHERE id = :id AND createdById = :user AND status = 'VALIDATED' RETURNING *（CAS 锁）
       行为：confirm 阶段重跑全部校验（不信任 preview status；防 TOCTOU）
       重跑失败时 errorDetail.code = 'IMPORT_REFS_CHANGED_RETRY'，建议用户重新 preview 不是修 Excel
       Response 200 data: { batchId, status: 'COMPLETED', completedAt, successRows }
       Response 409 if status != 'VALIDATED'（CAS 失败）
       Response 400 if 重跑校验有新 ERROR

GET    /robot-manager/import/batches/:batchId/error-report.xlsx
       Stream 输出（分页 200 行/批），按当前 user 的字段级 ACL mask payload
       client 断连时 backend 监听 response 'close' event 触发 abort exceljs writer
       Response Header: Content-Disposition: attachment; filename="{batchId}-errors.xlsx"

GET    /robot-manager/import/batches?type=:type&page=N&limit=20
       默认 createdById = currentUser；admin 加 ?scope=all（且记审计 event）
       limit 强制 ≤ 50
       Response 200 data: { items: ImportBatch[], total, page, limit, totalPages }
```

### 6.2 安全护栏

| 攻击面 | 缓解 |
|---|---|
| **IDOR** | findFirst({where: {id, organizationId, createdById/admin}})，缺一抛 404 |
| **XXE** | exceljs secure mode，预扫 zip entry 禁外链 |
| **Zip bomb** | multer fileSize ≤ 10MB；解压后 xml 总字节 ≤ 50MB；超限拒绝 |
| **CSV/Formula injection** | error-report 导出时所有 `=`/`+`/`-`/`@` 起头单元格前置 `'` 转义（OWASP）|
| **TOCTOU** | confirm 阶段重跑校验；记 fileHash 防离线篡改 |
| **DoS** | per-user 同时 in-flight batch ≤ 1；per-org 全局并发 ≤ N（可配）；template 限流 10 req/min |
| **PII 泄漏** | error-report 按字段级 ACL mask；fileName 入库 sanitize；payload mask |
| **越权 confirm** | createdById = currentUser 强校验；admin 替代走单独 endpoint + 审计 |

### 6.3 路径 param 映射
| URL :type | enum |
|---|---|
| `purchase-order` | PURCHASE_ORDER |
| `robot-unit` | ROBOT_UNIT |
| `master-{model\|sku\|customer\|supplier\|location}` | MASTER_* |
| `service-ticket` | SERVICE_TICKET |

---

## 7. 后端实现架构

### 7.1 Importer 接口（M2 抽，**M1 不抽**）

M1 只做 `PurchaseOrderImporter` 具体实现（直写类，不抽框架）。

M2 加 `RobotUnitImporter` 时从 2 个 implementer 共性抽接口：

```typescript
interface Importer<TInput, TPrimaryEntity> {
  type: ImportBatchType;
  fieldMetadata: FieldMetadata[];
  parseRow(rawRow: Record<string, unknown>): TInput;
  validateReferences(rows: TInput[]): Promise<ValidationIssue[]>;
  validateBusinessRules(rows: TInput[]): Promise<ValidationIssue[]>;
  execute(rows: TInput[], tx: PrismaTx): Promise<ExecuteResult>;
  templateExample: Record<string, string>[];
}

interface ExecuteResult {
  rowResults: { rowNo: number; entityIds: string[]; }[];  // 一行可扇出多 entity
  sideEffects?: { robotUnitsCreated?: number; eventsEmitted?: number };
}

interface FieldMetadata {
  field: string;
  label: { zh: string; en: string };
  type: 'string' | 'number' | 'date' | 'enum' | 'uuid' | 'fk';
  required: boolean;
  conditionalRequired?: { whenField: string; whenValues: string[] };  // RobotUnit 任意 stage 需要
  enumValues?: string[];
  fkRef?: { table: string; column: string };  // 模板下载时**只导 label 不导 table/column**
  pii?: boolean;
  description?: { zh: string; en: string };
}

interface ValidationIssue {
  rowNo: number;
  field: string;
  code: string;     // SCREAMING_SNAKE i18n key（如 'IMPORT_FILE_INVALID'），前端按 `t.robotManager.errorCodes[code]` 渲染
  params: Record<string, string>;  // 占位符值（如 { row: '5', field: 'quantity' }）
  severity: 'ERROR' | 'WARNING';
  // ⚠️ 禁止存 message（i18n key 由前端按 locale 渲染）
}
```

### 7.1.1 文件 / 命名约定

```
backend/src/modules/robot-manager/import/
├── importer.interface.ts          # Importer<T,U> + FieldMetadata + ValidationIssue
├── import.controller.ts           # 单 ImportController, 6 endpoint per :type
├── import-batch.service.ts        # ImportBatch / Entry CRUD + DataScope
├── purchase-order/
│   └── purchase-order.importer.ts # static readonly fieldMetadata: FieldMetadata[]
├── robot-unit/                    # M2
└── master-{model,sku,customer,supplier,location}/  # M3a
```

每个 Importer @Injectable + multi-provider token：
```typescript
const IMPORTER_REGISTRY = Symbol('IMPORTER_REGISTRY');
// providers: [{ provide: IMPORTER_REGISTRY, useExisting: PurchaseOrderImporter, multi: true }, ...]
// ImportController constructor 收集成 Map<type, Importer>
```

### 7.2 Importer 注册（NestJS DI）

```typescript
// 使用 multi-provider token
const IMPORTER_REGISTRY = Symbol('IMPORTER_REGISTRY');
// 每个 Importer @Injectable() + providers: [{ provide: IMPORTER_REGISTRY, useExisting: PurchaseOrderImporter, multi: true }, ...]
// ImportController constructor 收集成 Map<type, Importer>
```

### 7.3 execute() batch 语义（应对工程 🔴#4 N+1）

```typescript
// ❌ 反模式
for (entry of entries) {
  entity = await importer.execute([entry.payload], tx)[0];
  update entry.entityId
}

// ✅ batch + per-importer chunk（per-PO chunk + savepoint 兜底 1000 行长事务）
async execute(rows: TInput[], tx: PrismaTx): Promise<ExecuteResult> {
  // 批量 createMany + RETURNING
  const result = await tx.purchaseOrder.createManyAndReturn({ data: rows.map(...) });
  // 用 rowNo 回写 ImportBatchEntry.entityIds（批量 UPDATE）
}
```

### 7.4 模板生成

- 来源：每个 Importer **代码内** TypeScript const FieldMetadata 数组（**不入表，不入 JSONB**，复合 standard 16 §4.5「默认不立 L4 元数据驱动」）
- 用 backend `exceljs` 写 3 sheet（**frontend 不解析 Excel**，本地 preview 是 M4+ candidate）
- **内存 LRU 缓存**（key = type + schemaHash + locale）；首次生成命中即返回；CPU 密集型不允许高频重复生成
- per-user 限流 10 req/min（NestJS `@Throttle`）
- 模板下载 endpoint 权限 = 对应 importer 的 `:create` 权限（不是裸 `:read`）

**exceljs 关键用法**（实施期参考）：
```typescript
import * as ExcelJS from 'exceljs';
const wb = new ExcelJS.Workbook();
const sheet1 = wb.addWorksheet('Data');
// 表头从 row 1 起；row 0 是 sheet 自身的隐式定位
sheet1.addRow(fieldMetadata.map(f => f.label[locale]));
sheet1.getRow(1).font = { bold: true };
fieldMetadata.forEach((f, idx) => {
  const cell = sheet1.getCell(1, idx + 1);
  cell.fill = { type: 'pattern', pattern: 'solid',
    fgColor: { argb: f.required ? 'FFFFEB3B' : 'FFFFFFFF' } };  // 黄=必填
  if (f.fkRef) cell.border = { bottom: { style: 'thick', color: { argb: 'FFE53935' } } };
  if (f.description) cell.note = `${f.description[locale]}（填 code 例如 'SUP-001'）`;
  if (f.enumValues) {
    sheet1.dataValidations.add(`${col}2:${col}1001`, {
      type: 'list', allowBlank: !f.required,
      formulae: [`"${f.enumValues.join(',')}"`],
    });
  }
});
// Sheet2 = 字段说明；Sheet3 = 合成示例（**禁从真实表 sample**）
return await wb.xlsx.writeBuffer();
```

**安全门**（解析前 multer 配置）：
- `multer.memoryStorage()`（不写盘，避免临时文件残留）
- `limits.fileSize = 10 * 1024 * 1024`
- `fileFilter`: 仅 `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` + magic bytes 校验
- exceljs 读取前先扫 zip entries 拒外链 + 总 xml 字节 ≤ 50MB

### 7.5 校验流水线

```
parseExcel(file) → rows: Record<string, unknown>[]
  ↓ catch: IMPORT_FILE_INVALID / IMPORT_FILE_TOO_LARGE / IMPORT_FILE_EMPTY / IMPORT_SHEET_MISSING / 解压炸弹拦截
parseRow × N → typed inputs[]（catch type error → IMPORT_TYPE_MISMATCH）
  ↓
validateReferences(inputs) → batch FK query（必须用 service 层的 findByCodesIn）
  ↓
validateBusinessRules(inputs) → per-row + 跨行 unique
  ↓
聚合 ValidationIssue + 创建 ImportBatch + ImportBatchEntry
返回 batchId
```

**entries 持久化策略**：preview 阶段 ImportBatchEntry **直接持久化到 PG**（不走 redis）：
- 1000 entries × ~5KB payload ≈ 5MB / batch 写入，PG 可承受
- 跨 session 可恢复（user 离开几小时回来 wizard 还能继续）
- 不走 redis 简化部署依赖（项目无 redis cluster）

⚠️ **跨模块依赖（M0 卡点）**：platform-master service 必须暴露下列 5 个批量 query method。开工前 grep；缺则 M1 PR-0 阻塞。详见 §13 M0。

### 7.6 confirm 阶段（应对安全 🔴#4 TOCTOU + 工程 🔴#4 N+1）

```
1. UPDATE WHERE status='VALIDATED' AND createdById=currentUser RETURNING（CAS 锁）→ status=IMPORTING
2. load entries WHERE batchId AND status=OK
3. 重跑 validateReferences + validateBusinessRules（防 preview→confirm 窗口期 drift）
4. 若任何 entry 变 ERROR → ROLLBACK + status=FAILED + errorSummary={code:'IMPORT_REFS_CHANGED_RETRY'}
   ⚠️ **重跑只跑 1 次，不 retry**：失败后前端引导用户重新 preview（生成新 batch），避免死循环
5. 否则 prisma.$transaction(async tx => {
     ↓ per-importer chunk（PO importer 内部按 PO 边界 chunk + savepoint）
     for chunk in chunks:
       await tx.$executeRawUnsafe(`SAVEPOINT chunk_${i}`);  // ⚠️ Prisma 不支持 SAVEPOINT，需 raw
       try {
         result = await importer.execute(chunk, tx);  // batch createMany + RETURNING
         回写 ImportBatchEntry.entityIds（批量 UPDATE）
         await tx.$executeRawUnsafe(`RELEASE SAVEPOINT chunk_${i}`);
       } catch (e) {
         await tx.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT chunk_${i}`);
         throw e;  // 整事务 rollback（all-or-nothing 顶层语义）
       }
     update batch.status = COMPLETED
   })
6. 失败 → 全 ROLLBACK + status=FAILED；savepoint 在 chunk 边界给精细回滚信号（但顶层语义仍 all-or-nothing）

注：Prisma client 不直接 expose SAVEPOINT/RELEASE/ROLLBACK TO，必须用 $executeRawUnsafe；
参考 https://github.com/prisma/prisma/issues/6053
```

### 7.7 启动恢复（应对 SRE 🟡#2 + doc-review C1/C3）

**问题**：confirm 进入 IMPORTING 后 4min NestJS 崩溃 → updatedAt 4min ago → 启动恢复 5min 阈值过不去 → batch 卡死 IMPORTING → 用户 confirm 不了（CAS 锁 WHERE status=VALIDATED 不让进）。

**方案**：双管齐下
1. **heartbeat**：IMPORTING / VALIDATING 阶段每 30s `UPDATE batch SET updatedAt=NOW()`；崩溃后无 heartbeat → updatedAt 不再前进
2. **startup hook 阈值 60s**：扫 `status IN (VALIDATING, IMPORTING)` 且 `updatedAt < now - 60s` 的 batch → 标 FAILED + errorSummary={code:'IMPORT_BATCH_INTERRUPTED'}
3. 同时扫 VALIDATING 状态（不仅 IMPORTING）—— preview 解析中途 OOM 也要清

### 7.7.1 并发上传同文件去重（应对 doc-review C4）

`POST /preview` 入口先查：
```
SELECT * FROM import_batches
WHERE createdById = :user AND fileHash = :hash
  AND createdAt > now() - INTERVAL '24 hours'
  AND status NOT IN ('COMPLETED', 'FAILED', 'SUPERSEDED')
LIMIT 1
```
- 找到 → 返已有 batchId（idempotent，不创新）
- 找不到 → 创新 batch

per-user in-flight = "status NOT IN (COMPLETED, FAILED, SUPERSEDED)"；超过 1 个 → 拒新 preview（IMPORT_CONCURRENT_BATCH）。

### 7.8 上传文件存储

**决策**：**不存原文件**。preview 解析完 ImportBatchEntry.payload 保留行级数据，error-report 由 entries 重新组装 .xlsx。

---

## 8. 前端实现架构

### 8.1 新 page：`/robot-manager/import`

- 4 top tab：PO / RobotUnit / MasterData / ServiceTicket
- MasterData 内 5 sub-tab；或改"导入类型下拉"（UX 🟢#1 可选）
- **无权限 tab disabled + tooltip**

### 8.2 ImportWizard 组件

完全**重写**（不复用 v2 dead code）：
- Stepper UI 顶部
- URL 持久化：`/import/batches/:batchId`
- Step 间允许"换文件"返回 step 1（旧 batch 标 SUPERSEDED）
- Step 2 错误聚合卡片 + 行虚拟滚动 + 100+ 错误自动折叠
- Step 3 confirm 进度 bar + 网络断后 retry 入口
- Step 4 完成跳「查看历史」

**技术栈**（沿用项目现有约定）：
- 状态管理：react-query (server state) + useState (local wizard state)；不引入 zustand 等新库
- 文件上传：FormData + axios/fetch（不预解析 Excel；frontend 不引入 SheetJS）
- 二进制下载：`response.blob()` + `Content-Disposition` 解析 + 临时 `<a>` 触发下载
- i18n：useTranslation hook + locale aware；ValidationIssue `code` 经 `t.robotManager.errorCodes[code]` 渲染

**PO importer 与 publish 路径解耦**（应对 doc-review B2）：
- M1 import 路径**只建 PO + Line**（status=DRAFT），**不触发占位 RobotUnit 生成**
- 用户后续在 PO 详情页点 publish 按钮触发占位 unit（复用现有 `POST /purchase-orders/:id/publish`）
- M4+ candidate：wizard step 4 可选 checkbox「提交后自动 publish」减少多步

### 8.3 i18n / 空数据 / 加载 / 错误态（§8.4 单独细化）

- 每个页面 4 态规格（empty / loading / network-error / server-error）
- 上传 progress / 校验 indeterminate progress + "正在校验 X/1000 行"
- 切换 zh-CN / en-US：错误消息 / 模板表头 / 全 wizard 无硬编码

### 8.4 删 dead code

- 删除 `EntityAdminPage.tsx` 的 excelResource prop / Template/Export/Import 按钮
- 删除 `_lib/api/index.ts` 的 entityExcelApi
- 新 ImportWizard 独立组件

---

## 9. 权限矩阵

| 角色 | PO | RobotUnit（普通）| RobotUnit any-stage | 主数据 | ServiceTicket |
|---|---|---|---|---|---|
| Administrator | ✓ | ✓ | ✓ | ✓ | ✓ |
| RobotManagerRLE | ✓ | ✓ | - | ✓ | ✓ |
| SupplyChain | ✓ | - | - | 仅 Supplier | - |
| Sales | - | - | - | 仅 Customer | ✓ |
| Finance | - | - | - | - | - |

**新权限点**（替代默认方案）：
- `robot-manager:import:bulk`：所有 importer 的通用门
- `robot-manager:import:robot-unit:any-stage`：仅 admin（迁移期解锁）
- 每类 importer 子权限：`:import:purchase-order` / `:import:master-customer` 等

**限速**：per-user 同时 in-flight batch ≤ 1；per-org 全局并发 ≤ N（config 可调）

---

## 10. 错误码

| code | 中文（仅举例，前端按 locale 渲染） | 触发 |
|---|---|---|
| `IMPORT_FILE_INVALID` | Excel 文件格式不合法 / 损坏 | 解析失败 |
| `IMPORT_FILE_TOO_LARGE` | 文件超 10MB 限制 | multer reject |
| `IMPORT_FILE_EMPTY` | Excel 空 | 0 行数据 |
| `IMPORT_SHEET_MISSING` | 缺 Sheet1 | 表结构错 |
| `IMPORT_COLUMN_MISSING` | 缺必填列：{field} | 表头不含 |
| `IMPORT_ROW_LIMIT` | 单批最多 1000 行（实际 {actual}，请拆分为多批） | 超上限 |
| `IMPORT_TYPE_MISMATCH` | 第 {row} 行 {field}：类型应为 {expected} | 类型转换失败 |
| `IMPORT_REQUIRED_MISSING` | 第 {row} 行 {field}：必填字段为空 | 必填漏 |
| `IMPORT_ENUM_INVALID` | 第 {row} 行 {field}：{value} 不在范围（前 5 个 + ...等 N 项） | 枚举错 |
| `IMPORT_FK_NOT_FOUND` | 第 {row} 行 {field}：引用 {value} 在{业务术语}不存在 | FK 找不到 |
| `IMPORT_DUPLICATE_KEY` | 第 {row} 行 {field}：{value} 已存在 | unique 冲突 |
| `IMPORT_BUSINESS_RULE_VIOLATION` | 第 {row} 行：{ruleDescription} | 业务规则 |
| `IMPORT_BATCH_NOT_FOUND` | 导入批次不存在 | 404（也用于 IDOR 探测） |
| `IMPORT_BATCH_HAS_ERRORS` | 批次仍有 {n} 个错误行 | confirm 时 errorRows > 0 |
| `IMPORT_BATCH_ALREADY_CONFIRMED` | 批次已确认导入 | 重复 confirm |
| `IMPORT_BATCH_EXPIRED` | VALIDATED batch 已超时 | N 天未 confirm |
| `IMPORT_BATCH_INTERRUPTED` | 处理中断（进程崩溃）| OnBootstrap 扫到 |
| `IMPORT_PERMISSION_DENIED` | 缺权限 | 角色不够 |
| `IMPORT_FILE_TAMPERED` | 文件与 preview 时不一致 | fileHash 不匹配 |
| `IMPORT_REFS_CHANGED_RETRY` | 引用数据在等待期间被修改 | confirm 阶段重校验失败 |
| `IMPORT_CONCURRENT_BATCH` | 当前已有 in-flight batch | per-user 并发限 |

---

## 11. 测试场景

### 11.1 L0 契约测试（每个 endpoint）

- L0a: frontend 调用契约（请求字段名）
- L0b: backend DTO/响应字段
- L0c: 响应快照（preview 响应 summary 结构 / batch detail entries 结构）

### 11.2 L1 集成测试（按 importer 拆，每个 importer ≥ 10 场景）

**通用 sad path（每 importer 都要）**：
1. 文件格式损坏
2. 文件 > 10MB
3. 文件空
4. Sheet1 缺失
5. 列缺失
6. 类型错误
7. 必填空
8. 枚举非法
9. FK 不存在
10. 唯一冲突
11. 业务规则失败
12. 超 1000 行 / **1000 边界 / 1001 边界**
13. confirm 时仍有错
14. 重复 confirm
15. **cross-org 隔离**（A org 用户访问 B org batchId → 404）
16. **并发 confirm**（同 batchId 双请求 → 409）
17. **preview→confirm drift**（FK 中间被软删 → all-or-nothing rollback，0 残留）
18. **TOCTOU 文件改**（fileHash 不一致 → IMPORT_FILE_TAMPERED）
19. **大 payload 边界**（80+ 字段 × 1000 行 PG TOAST 是否撑爆）
20. **WARNING 处理**（5 warning + 95 OK → confirm 成功）

**Happy path（每 importer 至少 3 场景）**：
- PO 100 行 → 全 OK → confirm → 验证表内行数 + 占位 unit 数（占位 unit 由 publish 路径触发，跟 import 是分开调用）
- RobotUnit 50 行起始 stage=DELIVERY_DELIVERED + 50 行起始 stage=SUPPLY_PO_CREATED → snapshot 正确
- 5 主数据 importer × 1 happy
- **批次 retention**：90 天后 payload 清空但 entityIds 保留

**回归**：全模块 L1（现有 28 + 新 import ≥ 80）100% pass 作 M1/M2/M3 出口条件

### 11.3 L2 E2E（MCP）

- Fixture：generator 模式 `testing/fixtures/robot-manager/import/<scenario>-generator.ts`，beforeEach 写 `/tmp/`，不 commit 二进制
- 场景：
  - Happy：模板下载 → 上传 → preview 全绿 → confirm → 历史页
  - Sad：上传错文件 → 错误聚合 → 下载错误报告 → 修正 → 重传 → 成功
  - **双语**：zh-CN / en-US 各跑一次，验证错误消息双语 + 模板表头双语
  - **跨端拒绝**：移动 viewport → 降级提示

### 11.4 性能测试

- 基线机器：CI runner (4 vCPU / 8GB) + dockerized PG 16，串行单用户场景
- 1000 行 PO importer 实测断言：解析+校验 < 15s / confirm 入库 < 15s
- **M1 实测数据（2026-05-18, slot-3 dev DB, 1000 PO × 1 line × quantity=10）**：
  - preview + 三层校验：**666 ms**（22.5× 余量）
  - confirm 写库（1000 PO + 1000 Line, 2000 INSERT）：**5117 ms**（2.9× 余量）
  - 总耗时：**5.8 s**（PRD §17.1 红线 30s 的 19%）
  - 测试位置：`testing/backend/integration/robot-manager/import-spike.integration.test.ts`（`SPIKE=1` 触发，默认 CI 跳过）

---

## 12. 非功能性需求

### 12.1 性能（基线 + 单事务安全）

- 1000 行解析 < 5s / 校验 < 10s / confirm < 15s（基线机器规格见 §11.4）
- **per-PO chunk + savepoint 保护**（**M1 spike 实测未触发**，详见 §17.1，作为 M2 RobotUnit importer 真撞红线时的预案）
- 模板生成内存 LRU 缓存命中 < 50ms
- per-user 同时 in-flight batch ≤ 1

### 12.2 安全护栏（汇总）

- IDOR：findFirst 三元组 + 404
- XLSX 攻击面：multer 10MB / exceljs secure / xml 50MB / formula injection escape
- TOCTOU：confirm 重校验 + fileHash
- DoS：per-user 限速 + per-org 并发限
- PII：FieldMetadata `pii: true` 字段落 payload 前 mask；pino redact
- 审计：fileName sanitize / `confirmedById` / `clientIp` / `userAgent` 入库；DB trigger 防 UPDATE 篡改（M4 候选）

### 12.3 数据生命周期（应对 TB-4 payload 累积）

- ImportBatchEntry.payload：confirm COMPLETED 后 **90 天自动 null 化**（保留 entityIds 审计）
- FAILED / VALIDATED 未 confirm：**7 天清理**
- ImportBatch 元数据：**永久保留**（GDPR 类合规由 deletedAt 标记）
- cron：`scripts/ops/robot-manager/archive-import-entries.sh` 每日跑
- ImportBatch 加 `@@index([organizationId, createdById, createdAt(sort: Desc)])` 覆盖 SELF 列表

### 12.4 灰度上线

- M1 上线先加 feature flag `import.maxRowsPerBatch=100` 灰度 7 天
- 监控真实事务时长 / 错误率，达标后调到 1000
- 或按 organizationId 白名单分批放开

### 12.5 监控告警（最简版）

- NestJS `/metrics` endpoint expose 4 个 metric：
  - `import_batch_total{type, status}`
  - `import_duration_seconds{phase}`（phase = parse / validate / execute）
  - `import_template_generation_seconds`
  - `import_validation_errors_total{code}`
- 告警阈值：confirm 失败率 > 5%（5 min window）/ 事务时长 p99 > 20s

### 12.6 国际化

- ValidationIssue 只存 `{code, params}`，无预渲染 message
- 错误报告 Excel 按 `Accept-Language` 渲染表头 + error_detail 列
- 模板 Sheet2 双语字段说明
- L2 双语 MCP 回归（zh-CN ↔ en-US）

### 12.7 可观察性 / 调试

- Helper：`replayImportBatchEntry(entryId)` 把 payload 重过 importer pipeline，用于 prod issue 本地复现
- pino 日志层只 log `code` 字段 + 结构化 context；中文 message 只回前端

### 12.8 a11y

- WCAG 2.1 AA：键盘可达 + 焦点可见 + 错误 aria-live 播报
- 桌面端 only（min-width 1024px），移动 viewport 显示降级提示

---

## 13. 分阶段交付（已重新拆分，应对工程 🔴#1+🔴#2）

| Milestone | 范围 | PR 拆分 | 工作量 |
|---|---|---|---|
| **M0**（前置）| platform-master / robot-manager 加 5 个批量 `findByCodesIn` query method（见下方清单） | PR-0 | 1-2 天 |
| **M1**（PO importer）| ImportBatch + Entry schema + **PurchaseOrderImporter 具体实现（不抽 framework）** + 模板下载（PO 一类）+ 错误报告 + L1 + L2 happy/sad + **per-user 并发限 + 灰度 flag** | PR-A (schema) / PR-B (importer + endpoint + L1) / PR-C (frontend wizard + L2) | **1.5-2 周** |
| **M2**（RobotUnit）| RobotUnitImporter（任意 stage）+ 从 2 个 implementer 提取 Importer framework + 重构 PO 走 framework + 模板覆盖至所有 importer + L1 全 sad path | PR-D (M2 framework + RobotUnit) | **1.5 周** |
| **M3a**（主数据）| 5 主数据 importer + import 历史页 | PR-E | **1 周** |
| **M3b**（ServiceTicket + 收尾）| ServiceTicket importer + 双语回归 + UX 收口 | PR-F | **0.5 周** |
| **总计** | | | **5-6 周** |

**M0 PR-0 子任务清单**（M1 阻塞前置，开工前 grep 现状再决定是否需要）：
```
SupplierService.findByCodesIn(codes: string[]): Promise<Map<string, Supplier>>   // M1 用
RobotSkuService.findByCodesIn(codes: string[]): Promise<Map<string, RobotSku>>   // M1 用
CustomerService.findByCodesIn(codes: string[]): Promise<Map<string, Customer>>   // M3a 用，但 M1 PO importer 也用（SO ref）
RobotModelService.findByCodesIn(codes: string[]): Promise<Map<string, RobotModel>>  // M2 用
LocationService.findByCodesIn(codes: string[]): Promise<Map<string, Location>>      // M2/M3 用

接口约定：
- 返回 Map<code, Entity>，找不到 code 在 Map 缺 key（不抛错），调用方判断 size
- 过滤软删（deletedAt IS NULL），调用方不再过滤
- 入参 codes ≤ 1000（跟单批一致）
- L1 测试：M1 PR-0 必加覆盖每个 method 至少 3 用例（happy / 部分缺 / 全空）
```

**M1 出口条件**：
- M0 PR-0 5 method 全部就绪 + L1 ≥ 15 用例
- Spike 1000 行 PO × quantity=10 实测事务时长 / WAL / pg_locks（见 §17.1）✅ **已通过（5.8s 总耗时，§11.4）**
- L1 现有 28 + 新增 ≥ 20 用例 100% pass
- L0a/L0b/L0c 全 endpoint pass
- L2 happy + sad path 2 个双语跑通
- 灰度 7 天指标达标
- **同步 4 个模块文档 drift**：07-api.md §5 重写为 v3 import API（v2 `/excel/*` 已删，见顶部状态段）；08-error-codes.md 加 v3 21 错误码段；05-ui-spec.md §5 重写；06-data-model.md 加 ImportBatch 段（doc-review D1-D5）

---

## 14. 历史参考

- `13-import-mapping-prd.md`（v2 时代，已 DEPRECATED）：v2 列映射 UI / 冲突策略 / RobotEntityExcelService 5 实体——v3 重构删除
- `testing/reports/robot-manager-2026-05-18-e2e-report.md`：v3 主路径 E2E 基准

---

## 15. PRD review 状态

- **R1 multi-role review（2026-05-18）**：8 角色并行，39 🔴 + 38 🟡 + 33 🟢 = 110 finding；7 Top Blockers 已应用到 v2 PRD
- **R2 multi-role review**：v2 PRD 完成后由用户决定是否跑（按 dogfood 实验 R2 仍可贡献 ~50 真新 finding）
- **user research**（Red Team 🔴#1 强烈建议）：v2 上线 4 个月调用日志 + 当前数据录入渠道，作为 M1 启动前 evidence

---

## 16. 待 review 角色

由 prd-multi-role-review skill 跑 R2 / 或单独 stakeholder review：
- 产品 / 架构 / 安全 / UX / 工程 / QA / 运维 / Red Team

---

## 17. 已知风险 / 决策点

### 17.1 1000 行 PG 单事务实际压力（M1 强制 spike）✅ 已通过

- PO importer 一行原本要触发 RobotUnit + Snapshot + Event × N — **v2 PRD 已改方案**：占位 RobotUnit 用现有 PO publish 路径，**不在 import 路径触发**
- 即便如此，1000 行 PO + 1000 Line 仍是 2000 INSERT + FK lock + unique check
- **M1 出口 spike**：dev 1000 行 × quantity=10，实测事务时长
- > 30s 或 WAL > 500MB → 按 PO chunk + savepoint 分割（仍保 all-or-nothing 语义）

**M1 spike 结果（2026-05-18，详见 §11.4）**：confirm 5117 ms，远低于 30 s 红线 → **保留当前单事务实现，不拆 chunk**。1000 行作为产品上限直接放行（不走 100 行 → 1000 行的灰度阶梯）。后续如发现真实负载下时长上升至接近红线，再启用 chunk 重构。

### 17.2 v5 历史迁移路径明确不在本 PRD（应对 Red Team 🔴#2）

- 本 PRD 不做 v5 历史 ETL
- v5 迁移用 SQL 脚本 `scripts/migration/v5-to-v3.ts`（单独 PR / 后续 issue）
- "RobotUnit 任意 stage import" 仅服务**当前业务**场景（如资产盘点、临时调入），不为 v5 大批量历史迁移服务
- 单批 1000 行限制对历史迁移不友好但本来不该走这条路

### 17.3 user research 缺失（应对 Red Team 🔴#1 + 产品 🔴#1）

- v2（2026-04-19）上线 4 个月后被主动删 = 强 anti-signal
- M1 启动前必须答：(a) v2 上线 4 个月期间 import 调用日志数；(b) 当前业务团队数据录入实际渠道；(c) Persona §1.5 假设是否真实存在
- 没 evidence 不应推进 M1

### 17.4 framework 抽象时机（M2 不是 M1）

- M1 只做 PurchaseOrderImporter 具体实现
- M2 加 RobotUnit 时从 2 个 implementer 共性提取 Importer 接口
- M3 直接复用稳定 framework
- M2 必须为 M1 PO importer 的 framework 重构留出 2 天预算

### 17.5 模板字段 metadata 漂移风险（v2 死因之一）

- 复合 standard 16 §4.5「不立 L4 元数据驱动」原则：**代码内 TypeScript const**，不入表
- 加 L0 静态契约测："每个 Importer.fieldMetadata 必填字段是 Prisma model required 字段的超集"
- 模板生成跑 snapshot test，schema 变就触发 snapshot diff

### 17.6 dead code 重写（不复用 v2）

- 已决策：删 v2 `EntityAdminPage` 的 excelResource prop / Template/Export/Import 按钮
- 删 `_lib/api/index.ts` 的 entityExcelApi
- 新 ImportWizard 独立组件不复用

---

## 18. R1 finding 应用对照表

记录哪些 finding 应用到 v2 PRD，哪些保留待 R2 或后续决策。

### 已应用（v2 已修）

| Finding | 来源 | 应用位置 |
|---|---|---|
| TB-1 1000 行单事务压力 | 架构/SRE/Red Team/工程 | §3.1（标 "spike 验证"）/ §7.3 batch 语义 / §7.6 per-PO chunk + savepoint / §17.1 |
| TB-2 IDOR / TOCTOU / 越权 | 安全/Red Team/QA | §5.1 fileHash 字段 / §6.1 三元组 + CAS / §6.2 安全护栏表 / §7.6 confirm 重校验 / §11.2 L1 测试 |
| TB-3 M1 工作量 | 工程/Red Team/产品 | §3.2 framework M2 抽 / §13 重拆 M1+M2+M3a+M3b（5-6 周）/ §17.4 |
| TB-4 payload 持久化 | 架构/安全/SRE | §5.2 加 deletedAt / §5.4 PII mask / §12.3 retention 策略 |
| TB-5 L4 元数据驱动风险 | 架构/Red Team | §3.1 决策 5 改 "代码内 const" / §7.4 引用 standard 16 §4.5 |
| TB-6 Vision 缺失 | 红队/产品 | §1.0 Vision 一句话 / §1.3 alternative 对比 / §1.4 NSM / §1.5 Persona / §17.3 user research 风险 |
| TB-7 UX 错误规模 + 模板 | UX/产品 | §4.2 错误聚合 / §4.3 Sheet 规格 / §8.2 wizard 重写 / §10 error code 补 7 个 |
| 冲突1 dead code | 工程/Red Team | §3.2 默认决策改"重写" / §8.4 删除 dead code |
| 冲突2 v5 历史迁移 | Red Team | §1.0 不包括 / §2.2 永不做 / §17.2 用 SQL 脚本 |
| 冲突3 framework 时机 | 工程/Red Team | §3.2 + §13 + §17.4 |
| 冲突4 任意 stage 权限 | Red Team/安全 | §9 加 `:import:robot-unit:any-stage` 独立权限点 |

### 待 R2 决策或后续

| Finding | 来源 | 状态 |
|---|---|---|
| 文件归属 / 不存储原文件 | SRE 🔴#5 | §7.8 决策"不存"，待 R2 确认 |
| 监控 / metric 详细告警阈值 | SRE 🟡#1 | §12.5 最简版，详细阈值待运维定 |
| 启动恢复 / Idempotency-Key | SRE 🟡#2 | v3 §7.7 + §7.7.1 + §6.1 已细化 |
| ImportBatch 加密 at rest | 安全 🟢#1 | 待 M4 决策 |
| MasterData 5 sub-tab 改下拉 | UX 🟢#1 | 待 frontend 实现期决定 |
| 重新校验按钮 | UX 🟢#2 | 待 M3 加 |
| 配套 user research 之前不做 M2 | Red Team 🔴#1 | §17.3 已记 |

### v3 应用 doc-review finding（2026-05-18）

| Finding | Lens | 应用位置 |
|---|---|---|
| A1 跨 schema FK 注释 | Lens A 🔴 | §5.1 schema 注释段；M1 PR-A 同步 robot_manager.prisma 顶部清单 |
| A2 entries 持久化策略（PG 不走 redis）| Lens A 🔴 | §7.5 末尾段 |
| A3 multipart field name + response DTO shape | Lens A 🔴 | §6.1 完整 OpenAPI-like spec |
| A4 error response wrapper format | Lens A 🟡 | §6.1 头部 |
| A5 exceljs 代码 stub + 安全门 | Lens A 🟡 | §7.4 |
| A6 Prisma savepoint $executeRawUnsafe | Lens A 🟡 | §7.6 |
| A7 frontend state 选型（react-query + useState）| Lens A 🟢 | §8.2 技术栈段 |
| A8 fileHash 流式 SHA256 backend 算 | Lens A 🟢 | §5.1 schema 注释 |
| B1 PR-0 5 method 子任务清单 | Lens B 🔴 | §13 M0 出口条件 |
| B2 PO publish 路径解耦 | Lens B 🟡 | §8.2 末尾段 |
| B3 frontend 不解析 Excel | Lens B 🟡 | §7.4 + §8.2 |
| C1+C3 启动恢复 heartbeat + 60s 阈值 + 扫 VALIDATING | Lens C 🔴 | §7.7 |
| C2 confirm 重跑只跑 1 次不 retry | Lens C 🔴 | §7.6 step 4 |
| C4 同 file idempotent + per-user in-flight | Lens C 🟡 | §7.7.1 |
| C5 error-report cancellation | Lens C 🟡 | §6.1 endpoint 注释 |
| D1 07-api.md drift（v2 `/excel/*` 删 + §5 v3 import API 重写）| Lens D 🔴 | M1 PR-B 同步（M1 出口条件已加） |
| D2 08-error-codes.md drift | Lens D 🔴 | M1 PR-B 同步 |
| D3 05-ui-spec.md §5 重写 | Lens D 🔴 | M1 PR-C 同步 |
| D4+D5 06-data-model.md 加 §X + L212 修订 | Lens D 🔴 | M1 PR-A 同步 |
| D6 FieldMetadata 文件路径约定 | Lens D 🟡 | §7.1.1 新加 |
| D7 i18n key 命名约定 | Lens D 🟡 | §7.1 ValidationIssue 注释 |
