# Tech Spec: 占位 SN 机制（PO 批量预留 + 07 收货激活）

**Status**：Draft v0.1 · 待 plan-review
**Owner**：chentao
**业务来源**：`docs/modules/robot-manager/business-analysis/汇总-v3-v5-业务方迭代.md` § "Placeholder SN — FF SN 主写时机定论"
**流程图位置**：`docs/modules/robot-manager/business-analysis/robot-lifecycle.yaml` 节点 01 / 07

---

## 1. 业务需求（v3 设计）

> 业务方在 v3 解决了一个核心争论：**FF SN 应该在哪个节点首次写入 Master？**
>
> 选定方案：**01 PO_CREATED「一表到底」**（在 Master 创建 N 行占位 SN，07 RECEIVED 时激活替换为正式 SN）

### 业务价值

1. **Sherry 跟进每台进度**：PO 阶段就在 Master 创建行，可以查 PO-2026-Q1-03 关联的每台机器人当前状态
2. **承认 v4 数据现实**：v4 数据库已有 67 条 Ordered/In Transit 记录都已分配 FF SN，业务上确实在 PO 阶段就分配
3. **占位与正式区分**：用 `{PO#}-LINE-{NNN}` 命名标准明示是占位 SN（如 `PO-2026-Q1-03-LINE-001`），收货扫码后才换正式 SN（如 `FFF1226030000123`），原值保留追溯

### 状态对象切换点

| 阶段 | 状态对象 | 主写字段属于哪个表 |
|---|---|---|
| 01 PO_CREATED | **PO 单元级** | PurchaseOrder + 占位 RobotUnit（占位 SN） |
| 02-06 | PO 单元级 | 仍是占位 RobotUnit，状态字段更新 |
| **07 RECEIVED** | **★ 实例化转折点** | **占位 SN → 正式 SN，Supplier SN 扫码录入** |
| 08+ | 机器人个体级 | 每台机器人独立流转（标签/配件/客户/交付/售后） |

---

## 2. 当前实现差距

详见 `docs/modules/robot-manager/implementation-audit.md` § 部分实现 🟡 节点 01 / 07：

- ✅ Schema 层 ready（`PurchaseOrderLine.placeholderPattern` + `RobotUnit.placeholderSnOrig`）
- ✅ `RobotUnitService.create()` 接受 `placeholderSnOrig` 参数
- ❌ **PO 创建后没有自动批量创建占位 RobotUnit**
- ❌ **07 RECEIVED 没有"激活"endpoint** (扫码 → 占位 SN 替换为正式 SN + 原值保留)
- ❌ `generateFfsn()` 只生成正式格式，不支持 `{PO#}-LINE-{NNN}` 占位格式

---

## 3. Schema 变更

### 不需要新加 column ✅

已有：
- `PurchaseOrderLine.placeholderPattern: String?` — 占位 SN 命名模板
- `RobotUnit.placeholderSnOrig: String?` — 激活后保留原占位 SN
- `RobotUnit.ffsn: String` — 主键，PO 阶段是占位 SN，激活后换正式 SN

### 新加索引（可选优化）

```prisma
model RobotUnit {
  // ...
  @@index([purchaseOrderLineId, ffsn])   // 加速"按 PO line 查关联占位 robot 列表"
}
```

---

## 4. API 设计

### 4.1 PO 创建：批量生成占位 RobotUnit

**Endpoint**：`POST /robot-manager/purchase-orders`（已有，扩展行为）

**Request body**（无变更，已支持 lines with `placeholderPattern + quantity`）：

```json
{
  "poNo": "PO-2026-Q1-03",
  "supplierId": "<uuid>",
  "lines": [
    {
      "skuId": "<uuid>",
      "quantity": 50,
      "unitPrice": "...",
      "currencyCode": "USD",
      "placeholderPattern": "{poNo}-LINE-{NNN}",
      "defaultUsageType": "SALES"
    }
  ]
}
```

**新行为**：创建 PO + lines 之后，**自动 createMany N 个占位 RobotUnit**：
- 每条 line.quantity = N → 生成 N 个 RobotUnit
- 每个 RobotUnit.ffsn = `{PO#}-LINE-{NNN}`（NNN 按 line.lineNo + seq 顺序填充零）
- 每个 RobotUnit 关联到 `purchaseOrderId` + `purchaseOrderLineId`
- 每个 RobotUnit 初始 stage = `SUPPLY_PO_CREATED`
- 每个 RobotUnit.placeholderSnOrig = NULL（激活时才填）

**Response**：扩展返回值含 `createdRobotUnits: number`（汇总）

### 4.2 07 RECEIVED：占位 SN 激活

**Endpoint**：`POST /robot-manager/:id/activate-sn`（新增）

**Request body**：
```typescript
{
  // 扫供应商物理标签得到
  supplierSn: string;
  // 可选：业务方提前知道正式 FFSN（如从 SAP/D365 同步），不传则 backend 生成
  ffsn?: string;
  // 推进状态机（默认推到 WAREHOUSE_RECEIVED）
  advanceTo?: 'WAREHOUSE_RECEIVED' | 'WAREHOUSE_AT_W1_PDI';
  reason?: string;
}
```

**Behavior**：在 transaction 里：
1. **校验**：当前 RobotUnit.ffsn 必须是占位格式（符合 `placeholderPattern` 或匹配 `{...}-LINE-{...}`），否则 409 Already activated
2. **生成正式 FFSN**：如果 dto.ffsn 没传，调 `generateFfsn(tx, organizationId)` 生成（正式格式 `FF-yyyymm-NNNNNN`）
3. **更新 RobotUnit**：
   - `placeholderSnOrig = robot.ffsn`（保存原占位 SN）
   - `ffsn = newFfsn`（替换为正式）
   - `supplierSn = dto.supplierSn`（扫码录入）
4. **创建 RobotLifecycleEvent**：
   - eventType = `sn_activated`（新 enum 值需加？看现有 enum）
   - payload = `{ placeholderSn, newFfsn, supplierSn, scannedAt }`
5. **推进 stage**：如 `dto.advanceTo` 给定，调用 lifecycle service 推进到 RECEIVED

**Response**：返回更新后的 RobotUnit + 新建的 event。

**Permission**：`robot-manager:activate-sn`（新 permission）。

### 4.3 `generateFfsn()` 增强

**当前**：生成正式 `FF-yyyymm-NNNNNN`（永远正式格式）

**改造**：增加可选参数 `mode: 'official' | 'placeholder'`：
```typescript
generateFfsn(tx, organizationId, opts?: {
  mode?: 'official' | 'placeholder';
  poNo?: string;       // mode=placeholder 时必传
  lineNo?: number;     // mode=placeholder 时必传
  seq?: number;        // mode=placeholder 时必传
  pattern?: string;    // 自定义模板，默认 `{poNo}-LINE-{NNN}`
})
```

- `mode='official'`（默认，保持向后兼容）→ `FF-yyyymm-NNNNNN`
- `mode='placeholder'` → 按 pattern 替换变量：`{poNo}` → poNo, `{NNN}` → seq.padStart(3, '0')

PO 批量创建逻辑用 `mode='placeholder'`，激活时用 `mode='official'`。

---

## 5. 状态机 / Guards

### 现有

`backend/src/modules/robot-manager/services/stage-transitions.constants.ts` 定义状态机。
`backend/src/modules/robot-manager/services/lifecycle-guards.service.ts` 含 guards。

### 新增 guard

**07 RECEIVED 推进 guard**：从 `LOGISTICS_CUSTOMS_CLEARED` 推进到 `WAREHOUSE_RECEIVED` 时，**RobotUnit.ffsn 不能是占位格式 unless activate-sn 同步执行**。

实现方式：
```typescript
async assertPreReceived(unit: RobotUnit) {
  if (this.isPlaceholderSn(unit.ffsn) && !unit.supplierSn) {
    throw new BadRequestException('Cannot move to RECEIVED without activating placeholder SN');
  }
}

isPlaceholderSn(ffsn: string): boolean {
  return /-LINE-\d+$/.test(ffsn);  // 简单识别，或 query placeholder_sn_orig is null + match pattern
}
```

但 `POST :id/activate-sn` 端点同步推进 stage 时可绕过此 guard（推进和激活是同一动作）。

---

## 6. 错误处理

| 场景 | HTTP | Error Code | Message |
|---|---|---|---|
| activate-sn 调用但 ffsn 不是占位格式 | 409 | `ALREADY_ACTIVATED` | "SN already activated (not a placeholder)" |
| activate-sn 调用但 supplierSn 缺失 | 400 | `MISSING_SUPPLIER_SN` | "Supplier SN required for activation" |
| activate-sn 校验 stage 必须 ≤ CUSTOMS_CLEARED | 422 | `STAGE_NOT_AVAILABLE` | "Cannot activate at stage X (must be ≤ CUSTOMS_CLEARED)" |
| PO 批量创建时占位 SN 冲突（重名）| 409 | `SN_CONFLICT` | "Placeholder SN {sn} already exists" |
| 推进到 RECEIVED 但 SN 仍是占位 | 422 | `PLACEHOLDER_NOT_ACTIVATED` | "Activate placeholder SN first" |

---

## 7. Frontend 影响

### 7.1 my-work 工作台

**01 PO_CREATED stage 卡片增强**（基于 PO 关联）：
- 每个 PO 一行 (而非每台机器人一行)
- 显示：`PO-2026-Q1-03 · 50 台占位 / 已收 30 / 待收 20`
- 点开 Drawer 看 PO line 详情

**07 RECEIVED stage Drawer 新按钮**：
- "扫码激活" 按钮 → 弹 modal 输入 supplierSn → 调 activate-sn → 推进

### 7.2 详情页

- 占位 SN 在 ffsn 显示位置加 badge "占位 SN（未激活）"
- 已激活的 robot 在 `placeholderSnOrig` 旁加 "← 原占位 SN" 注释

---

## 8. 测试用例（L1 集成测试）

### 8.1 PO 创建 → 批量占位

```typescript
test('PO 创建后自动生成 N 行占位 RobotUnit', async () => {
  const po = await api.purchaseOrders.create({
    poNo: 'PO-TEST-001',
    lines: [{ skuId, quantity: 5, placeholderPattern: '{poNo}-LINE-{NNN}', ... }],
  });

  const units = await api.robotUnits.findByPo(po.id);
  expect(units).toHaveLength(5);
  expect(units[0].ffsn).toBe('PO-TEST-001-LINE-001');
  expect(units[4].ffsn).toBe('PO-TEST-001-LINE-005');
  expect(units.every(u => u.snapshot.currentStage === 'SUPPLY_PO_CREATED')).toBe(true);
});
```

### 8.2 activate-sn happy path

```typescript
test('激活占位 SN 替换为正式 SN', async () => {
  const unit = await createPlaceholderUnit({ ffsn: 'PO-TEST-001-LINE-001' });

  const result = await api.robotUnits.activateSn(unit.id, {
    supplierSn: 'SUP-12345',
    advanceTo: 'WAREHOUSE_RECEIVED',
  });

  expect(result.ffsn).toMatch(/^FF-\d{6}-\d{6}$/);  // 正式格式
  expect(result.placeholderSnOrig).toBe('PO-TEST-001-LINE-001');
  expect(result.supplierSn).toBe('SUP-12345');
  expect(result.snapshot.currentStage).toBe('WAREHOUSE_RECEIVED');

  const events = await api.robotUnits.events(unit.id);
  expect(events).toContainEqual(
    expect.objectContaining({ eventType: 'sn_activated', payload: expect.any(Object) }),
  );
});
```

### 8.3 错误用例

```typescript
test('已激活的 SN 不能再激活', async () => {
  await expect(
    api.robotUnits.activateSn(activatedUnit.id, { supplierSn: 'SUP-XXX' }),
  ).rejects.toThrow(/ALREADY_ACTIVATED/);
});

test('推进到 RECEIVED 但 SN 仍占位 → 阻断', async () => {
  await expect(
    api.robotUnits.changeStage(placeholderUnit.id, { toStage: 'WAREHOUSE_RECEIVED' }),
  ).rejects.toThrow(/PLACEHOLDER_NOT_ACTIVATED/);
});

test('占位 SN 冲突', async () => {
  await api.purchaseOrders.create({ poNo: 'PO-DUP', lines: [{ quantity: 3, ... }] });
  await expect(
    api.purchaseOrders.create({ poNo: 'PO-DUP', lines: [{ quantity: 1, ... }] }),
  ).rejects.toThrow(/SN_CONFLICT/);
});
```

---

## 9. 迁移策略（兼容已有数据）

当前 db 有 30 台机器人都是正式 SN（`FF-202605-XXXXX`），**没有占位 SN**。这是因为 seed 数据直接生成正式 SN。

迁移方向（2 选 1）：

### A. 不动现有数据，仅对新 PO 生效
- 历史数据 `placeholderSnOrig = NULL`（已是默认）
- 新 PO 走占位 → 激活流程
- 业务上"渐进迁移"
- **推荐**：风险最低

### B. 倒推已有数据为"已激活"
- 给所有现有 robot.placeholderSnOrig 填一个假占位（如 `LEGACY-{ffsn}`）
- 标记 "v5 之前的数据，无真实占位"
- 不推荐：人为伪造数据，无业务价值

---

## 10. Rollout 计划

| Step | 内容 | 工作量 |
|---|---|---|
| **P1** | Schema 索引（可选）+ `generateFfsn` 增强占位模式 | 30min |
| **P2** | `PurchaseOrderService.create()` 批量 createMany 占位 RobotUnit | 2h |
| **P3** | 新 endpoint `POST :id/activate-sn` + service + guards + permission | 3h |
| **P4** | `lifecycle-guards.service` 加 `assertPreReceived` 占位校验 | 1h |
| **P5** | L1 集成测试：3 个 happy path + 5 个错误用例 | 2h |
| **P6** | Frontend：my-work 工作台 SUPPLY_PO_CREATED 卡片改成 PO 维度 | 2h |
| **P7** | Frontend：详情页 + 工作台 Drawer 加"扫码激活"按钮 | 2h |

**总工作量**：~1-1.5 天（AI 节奏）。可分 P2-P5 一批 PR、P6-P7 一批 PR。

---

## 11. Out of Scope（本 spec 不覆盖）

- 真实扫码硬件集成（QRScanner 已在前端，是否调用、Camera permission 等不在 backend spec 范围）
- 占位 SN 冲突防护跨 organization（当前在 unique constraint 内，未来多租户跨 org 冲突另议）
- 批量激活（多个机器人同时扫码）— 单 robot endpoint 先做，bulk 后续按需补
- SAP/D365 同步（spec 后面写）

---

## 12. Decision Points（需要业务方 / plan-review 确认）

| 决策点 | 选项 | 建议 |
|---|---|---|
| 占位 SN 模板是否强制 `{PO#}-LINE-{NNN}` | A) 强制 / B) 允许 PO 自定义 pattern | **B**：placeholderPattern 字段已 schema 支持自定义，给业务方灵活度 |
| 激活时如果业务方手动指定 ffsn，是否允许 | A) 始终 backend 生成 / B) 允许手动覆盖 | **B**：用户已在 SAP 系统拿到了正式 FFSN 的场景需要支持 |
| 推进到 RECEIVED 是否必须先激活 | A) 强制（hard gate）/ B) 警告（soft）| **A**：v3 业务设计明示，hard gate 防止数据混乱 |
| 历史数据迁移策略 | A / B（见 § 9）| **A**：新 PO 走新流程，旧数据保留 |
