# 产品族（SPU）合并 — 一期后端实施计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 在原产品库与 Good 之间落地 ProductFamily（产品族）层：链接名结构化解析、族 CRUD、自动建族、成员管理（含自定义商品）、价格推导矩阵 + 人工覆盖、同步钩子与回填脚本。

**Architecture:** `OriginGood` 保持纯 SDS 镜像不动；新增 `product-families` NestJS 模块（controller/service/DTO）+ 独立的 `FamilyRecomputeService`（并集与矩阵物化，可被同步钩子异步调用）。价格 = 成员变体按 (物流, 工艺) 归因的并集矩阵，`FamilyPriceOverride` 覆盖表按格改价。设计依据：`docs/superpowers/specs/2026-08-28-product-family-merge-design.md`。

**Tech Stack:** NestJS 10 + Prisma 5 + PostgreSQL + Jest（测试与现有 `*.spec.ts` 同构，mock PrismaService）。

**分期:** 本计划为一期后端。二期（admin 前端）、三期（公开读路径灰度 + Good.familyId）在一期合并后另出计划。

**环境基线（已就绪）:** `apps/api/.env` 已配置；依赖已安装；远程库漂移已修复（3 个迁移已对齐）；现有 15 套件 107 测试全绿。

---

## 文件结构总览

```
apps/api/
  prisma/
    schema.prisma                                    # 修改：+ProductFamily/+FamilyPriceOverride/+OriginGood 解析列
    migrations/<ts>_add_product_families/migration.sql
    backfill-product-families.ts                     # 新建：解析回填+自动建族+全量重算脚本
  src/
    product-families/                                # 新建模块
      origin-name.parser.ts                          # 纯函数解析器（零依赖）
      origin-name.parser.spec.ts
      family-recompute.service.ts                    # 并集/矩阵物化核心
      family-recompute.service.spec.ts
      product-families.service.ts                    # CRUD/auto-group/members/overrides
      product-families.service.spec.ts
      product-families.controller.ts
      product-families.module.ts
      dto/ (create/patch/auto-group/members/overrides)
  src/sync/sync.service.ts                           # 修改：upsertOriginGood 解析列；persistProductDetail 后钩子
  src/origin-goods/origin-goods.service.ts           # 修改：树叶子节点 + 族信息
  src/goods/goods.service.ts                         # 修改：createCustom 扩展解析列与 familyId
  src/app.module.ts                                  # 修改：注册 ProductFamiliesModule
```

**约定（全程适用）:**
- 价格矩阵 JSON 的格子维度键：`sizeId = variant.sizeId ?? variant.sizeName`、`colorId = variant.colorId ?? variant.colorName`（无 id 用名称，两者皆空则该变体不可归格，跳过）。
- Decimal 一律 `toString()` 后入 JSON。
- Prisma JSON 写入用 `value ?? Prisma.DbNull` 惯例（与 `persistProductDetail` 一致）。
- 每个 Task 完成 = 测试绿 + conventional commit。

---

### Task 1: 链接名解析器（纯函数，TDD）

**Files:**
- Create: `apps/api/src/product-families/origin-name.parser.ts`
- Test: `apps/api/src/product-families/origin-name.parser.spec.ts`

- [ ] **Step 1: 写失败测试**

```ts
// origin-name.parser.spec.ts
import { parseOriginName, originGroupKey } from './origin-name.parser';

describe('parseOriginName', () => {
  it('解析完整四段名（含仓库）', () => {
    expect(parseOriginName('美国（不包邮）240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓')).toEqual({
      country: '美国', logisticsLabel: '不包邮', productName: '240g涤纶休闲短裤',
      skuCode: 'DG206', craftLabel: '单面印花', warehouseLabel: '美西洛杉矶一仓',
    });
  });
  it('解析三段名（无仓库）', () => {
    expect(parseOriginName('墨西哥（不包邮）180g纯棉女装修身T恤-METP001-单面印花')).toEqual({
      country: '墨西哥', logisticsLabel: '不包邮', productName: '180g纯棉女装修身T恤',
      skuCode: 'METP001', craftLabel: '单面印花', warehouseLabel: null,
    });
  });
  it('物流备注含星号等符号原样保留', () => {
    const r = parseOriginName('波兰（包邮*运费订单结算时支付）250g男女同款抓毛圆领卫衣-PLHM002-双面印花');
    expect(r.logisticsLabel).toBe('包邮*运费订单结算时支付');
    expect(r.skuCode).toBe('PLHM002');
  });
  it('半角括号也能解析', () => {
    const r = parseOriginName('美国(包邮)T恤-DG001-单面印花');
    expect(r.country).toBe('美国'); expect(r.logisticsLabel).toBe('包邮'); expect(r.productName).toBe('T恤');
  });
  it('段1无括号时 country=整段、物流为空', () => {
    const r = parseOriginName('美国T恤-DG001-单面印花');
    expect(r.country).toBe('美国T恤'); expect(r.logisticsLabel).toBeNull(); expect(r.productName).toBeNull();
  });
  it('两段名：只解析国家/物流/品名/SKU', () => {
    const r = parseOriginName('美国（包邮）T恤-DG001');
    expect(r.skuCode).toBe('DG001'); expect(r.craftLabel).toBeNull(); expect(r.warehouseLabel).toBeNull();
  });
  it('一段名/空值/_null 安全', () => {
    expect(parseOriginName('随便一个名字').skuCode).toBeNull();
    expect(parseOriginName(null).country).toBeNull();
    expect(parseOriginName('').country).toBeNull();
  });
  it('段前后空格被 trim', () => {
    const r = parseOriginName('美国（包邮） T恤 - DG001 - 单面印花');
    expect(r.skuCode).toBe('DG001'); expect(r.craftLabel).toBe('单面印花'); expect(r.productName).toBe('T恤');
  });
});

describe('originGroupKey', () => {
  it('与 admin truncateToProcess 语义一致：前3段、物流差异导致不同组', () => {
    expect(originGroupKey('美国（包邮）T恤-DG001-单面印花-美西一仓')).toBe('美国（包邮）T恤-DG001-单面印花');
    expect(originGroupKey('美国（不包邮）T恤-DG001-单面印花')).not.toBe(originGroupKey('美国（包邮）T恤-DG001-单面印花'));
  });
  it('空名返回空串', () => expect(originGroupKey(null)).toBe(''));
});
```

- [ ] **Step 2: 运行确认失败**

Run: `cd apps/api && npx jest origin-name.parser --silent`
Expected: FAIL（模块不存在）

- [ ] **Step 3: 最小实现**

```ts
// origin-name.parser.ts
export interface ParsedOriginName {
  country: string | null;
  logisticsLabel: string | null;
  productName: string | null;
  skuCode: string | null;
  craftLabel: string | null;
  warehouseLabel: string | null;
}

const FULLWIDTH = { open: '（', close: '）' };

function splitSegment1(seg: string): Pick<ParsedOriginName, 'country' | 'logisticsLabel' | 'productName'> {
  const full = seg.indexOf(FULLWIDTH.open);
  if (full >= 0) {
    const close = seg.indexOf(FULLWIDTH.close, full);
    if (close > full) {
      return {
        country: seg.slice(0, full).trim() || null,
        logisticsLabel: seg.slice(full + 1, close).trim() || null,
        productName: seg.slice(close + 1).trim() || null,
      };
    }
  }
  const halfOpen = seg.indexOf('(');
  if (halfOpen >= 0) {
    const close = seg.indexOf(')', halfOpen);
    if (close > halfOpen) {
      return {
        country: seg.slice(0, halfOpen).trim() || null,
        logisticsLabel: seg.slice(halfOpen + 1, close).trim() || null,
        productName: seg.slice(close + 1).trim() || null,
      };
    }
  }
  return { country: seg.trim() || null, logisticsLabel: null, productName: null };
}

export function parseOriginName(name: string | null | undefined): ParsedOriginName {
  const empty: ParsedOriginName = { country: null, logisticsLabel: null, productName: null, skuCode: null, craftLabel: null, warehouseLabel: null };
  if (!name) return empty;
  const segs = name.split('-').map((s) => s.trim());
  if (segs.length === 0 || segs[0] === '') return empty;
  const head = splitSegment1(segs[0]);
  return {
    ...head,
    skuCode: segs[1] || null,
    craftLabel: segs[2] || null,
    warehouseLabel: segs.length > 3 ? segs.slice(3).join('-') : null,
  };
}

/** 与 admin 端 truncateToProcess（KEEP_SEGMENTS=3）语义一致的分组键 */
export function originGroupKey(name: string | null | undefined): string {
  if (!name) return '';
  return name.split('-').slice(0, 3).join('-');
}
```

- [ ] **Step 4: 测试通过**

Run: `cd apps/api && npx jest origin-name.parser --silent`
Expected: PASS

- [ ] **Step 5: Commit** `feat(api): add origin good name parser`

---

### Task 2: Prisma Schema 与迁移

**Files:**
- Modify: `apps/api/prisma/schema.prisma`

- [ ] **Step 1: schema 增量**

在 `OriginGood` model（L23-44）中 `source` 字段后追加：

```prisma
  familyId       BigInt?  @map("family_id")
  skuCode        String?  @map("sku_code")
  logisticsLabel String?  @map("logistics_label")
  craftLabel     String?  @map("craft_label")
  warehouseLabel String?  @map("warehouse_label")
```

relations 区追加 `family ProductFamily? @relation(fields: [familyId], references: [id], onDelete: SetNull, onUpdate: NoAction)`；`@@index` 区追加 `@@index([familyId])`、`@@index([skuCode])`、`@@index([logisticsLabel])`、`@@index([craftLabel])`。

在 `GoodOriginGood`（L248）之后新增两个 model：

```prisma
// ---------- Product Families (SPU layer over origin goods) ----------
model ProductFamily {
  id                 BigInt   @id @default(autoincrement()) @map("family_id")
  familyCode         String?  @unique @map("family_code")
  familyName         String   @map("family_name")
  familyImage        String?  @map("family_image")
  countryId          BigInt?  @map("country_id")
  categoryId         BigInt?  @map("category_id")
  primaryOriginGoodId BigInt? @map("primary_origin_good_id")
  detail             Json?
  sizeChart          Json?    @map("size_chart")
  packageSpecs       Json?    @map("package_specs")
  priceMatrix        Json?    @map("price_matrix")
  autoManaged        Boolean  @default(true) @map("auto_managed")
  stale              Boolean  @default(false)
  createdAt          DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
  updatedAt          DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)

  originGoods    OriginGood[]
  priceOverrides FamilyPriceOverride[]
  country        Country?   @relation(fields: [countryId], references: [id], onDelete: SetNull, onUpdate: NoAction)
  category       Category?  @relation(fields: [categoryId], references: [id], onDelete: SetNull, onUpdate: NoAction)

  @@index([countryId])
  @@index([categoryId])
  @@map("product_families")
}

model FamilyPriceOverride {
  id        BigInt   @id @default(autoincrement()) @map("family_price_override_id")
  familyId  BigInt   @map("family_id")
  sizeId    String   @map("size_id")
  colorId   String   @map("color_id")
  craft     String
  logistics String
  price     Decimal  @db.Decimal(12, 2)
  note      String?
  createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
  updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)

  family ProductFamily @relation(fields: [familyId], references: [id], onDelete: Cascade, onUpdate: NoAction)

  @@unique([familyId, sizeId, colorId, craft, logistics])
  @@map("family_price_overrides")
}
```

`Country`/`Category` model 的 relations 区各加 `families ProductFamily[]`。注意 `primaryOriginGoodId` 刻意**不建 FK**（避免与 origin_goods 成环依赖，删除主链接时由服务层清理）。

- [ ] **Step 2: 生成并应用迁移**

Run: `cd apps/api && npx prisma migrate dev --name add_product_families && npx prisma generate`
Expected: 生成新 migration 目录，数据库应用成功，`npx jest --silent` 保持全绿。

- [ ] **Step 3: Commit** `feat(api): add product_families schema and migration`

---

### Task 3: FamilyRecomputeService（并集 + 矩阵物化，TDD 核心）

**Files:**
- Create: `apps/api/src/product-families/family-recompute.service.ts`
- Test: `apps/api/src/product-families/family-recompute.service.spec.ts`

**核心类型（导出，供 service/脚本复用）:**

```ts
export interface PriceMatrixSource { sdsGoodId: string; sdsVariantId: string; price: string }
export interface PriceMatrixRow {
  sizeId: string; sizeName: string | null;
  colorId: string; colorName: string | null;
  craft: string; logistics: string;
  price: string; manual: boolean;
  sources: PriceMatrixSource[];
}
export interface PriceMatrix {
  sizes: Array<{ key: string; name: string | null }>;
  colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
  crafts: string[];
  logistics: string[];
  rows: PriceMatrixRow[];
}
export interface ChartSizeRow { sizeId?: string | null; sizeName?: string | null; measurements?: unknown; [k: string]: unknown }
export interface ChartLike { columns?: unknown; rows?: ChartSizeRow[] }
```

**行为规格（测试逐条覆盖）:**

1. `unionCharts(charts: Array<{ rows: ChartSizeRow[] }>): ChartSizeRow[]` — 按 `sizeName ?? sizeId` 对齐取并集；冲突时按“提供尺码数最多的成员优先，其次主链接”裁决（成员排序：rows 数 desc → isPrimary desc → id asc）。
2. `derivePriceMatrix(members, overrides)` — 规则：
   - 仅统计 `craftLabel && logisticsLabel` 均非空成员的 `enabled=true、price!=null` 变体；
   - 同格子多来源：`price` 取最低，`sources` 全保留；
   - 覆盖命中（sizeId/colorId/craft/logistics 四键相等）→ `price=覆盖价, manual=true`；命中推导不存在的格子 → 新增行（`sources=[]`）；
   - `sizes/colors` 选项 = 全体参与变体的键去重（含覆盖新增键）；`crafts/logistics` = 成员标签去重 ∪ 覆盖用到的值。
3. `recomputeFamily(familyId)`：加载族+成员（`delisted=false`，含 detail/variants/overrides）；`autoManaged=true` → 物化写回 `sizeChart/packageSpecs/priceMatrix` 且 `stale=false`；`autoManaged=false` → 仅 `stale=true` 不改物化字段；族不存在 → no-op。canonical `detail` 仅在为空时从主链接（`primaryOriginGoodId` ?? 第一个成员）初始化一次。
4. `enqueue(familyId)`：进程内 Map 去重，串行执行，异常记 logger.error 不抛出。

**测试样例（mock prisma，参照 `goods.service.spec.ts` 的 `mockPrisma` 模式）:**

```ts
const mkMember = (over: Partial<OriginGood> = {}): any => ({
  id: 1n, sdsGoodId: 'a', craftLabel: '单面印花', logisticsLabel: '包邮', delisted: false,
  detail: null, variants: [], ...over,
});
```

用例清单：
- 并集：`[{S,XL},{XL,XXL}]` → `[S,XL,XXL]` 顺序稳定（按首次出现序）；
- 冲突：成员A(rows=2)与B(rows=3)同尺码不同测量值 → 取B的行；
- 主链接优先于同 rows 数的非主链接；
- 矩阵：两个成员（同物流工艺不同仓库）同格子 25.00/24.50 → price=24.50、sources 两条；
- craft 或 logistics 为空的成员不参与矩阵；
- 覆盖：命中改价 manual=true；未命中格新增行；crafts/logistics 选项并入覆盖值；
- delisted 成员被 where 过滤（验证 findUnique include 的 where 参数）；
- autoManaged=false 只置 stale；
- detail 为空时从主链接初始化、非空不动。

Run: `cd apps/api && npx jest family-recompute --silent`

- [ ]Commit: `feat(api): add family recompute service with union and price matrix`

---

### Task 4: ProductFamilies 模块骨架（CRUD + 注册）

**Files:**
- Create: `src/product-families/product-families.module.ts`、`product-families.service.ts`、`product-families.controller.ts`、`dto/product-family.dto.ts`
- Modify: `src/app.module.ts`（imports 加 `ProductFamiliesModule`）

**DTO（class-validator，与现有 goods DTO 风格一致）:**

```ts
export class CreateProductFamilyDto {
  @IsString() @IsNotEmpty() @MaxLength(200) familyName: string;
  @IsOptional() @IsString() @MaxLength(100) familyCode?: string;
  @IsOptional() @IsString() familyImage?: string;
  @IsOptional() @IsBigInt() countryId?: bigint;
  @IsOptional() @IsBigInt() categoryId?: bigint;
  @IsOptional() @IsBigInt() primaryOriginGoodId?: bigint;
  @IsOptional() @IsArray() originGoodIds?: bigint[];   // 建族时直接挂成员
}
export class PatchProductFamilyDto extends PartialType(CreateProductFamilyDto) {
  @IsOptional() @IsBoolean() autoManaged?: boolean;
}
```

**Service 方法与语义:**
- `list({ keyword?, page, pageSize })` → `{ items, total, page, pageSize }`，items 含 `_count.originGoods`、`stale`、`autoManaged`、`familyCode`；keyword 匹配 familyName/familyCode contains。
- `create(dto)`：`familyCode` 冲突时自动追加 `-2`/`-3` 后缀（`ensureUniqueCode` 私有方法）；挂成员后 `recompute`。
- `detail(id)`：含成员（含解析列）、覆盖列表、物化矩阵摘要。
- `patch(id, dto)`：仅 canonical 字段 + autoManaged + primaryOriginGoodId；改 primary 后 recompute。
- BigInt 序列化沿用现有模式（查询结果直接返回，序列化时 `JSON.stringify` 需 `(BigInt.prototype as any).toJSON` 已有全局处理则复用；无则在返回前 `JSON.parse(JSON.stringify(x, (_, v) => typeof v === 'bigint' ? v.toString() : v))`）。

**Controller 路由（JWT 默认保护，无需额外装饰）:**

```ts
@Controller('product-families')
export class ProductFamiliesController {
  @Get() list(@Query() q) 
  @Post() create(@Body() dto)
  @Get(':id') detail(@Param('id', ParseBigIntPipe) id: bigint)
  @Patch(':id') patch(@Param('id', ParseBigIntPipe) id: bigint, @Body() dto)
}
```

测试：create 的 familyCode 去重后缀、list keyword 过滤、patch 不触碰物化字段。
Run: `cd apps/api && npx jest product-families.service --silent`

- [ ]Commit: `feat(api): product families module skeleton with CRUD`

---

### Task 5: 自动建族 auto-group（预览 + 应用）

**Files:**
- Modify: `src/product-families/product-families.service.ts`、`product-families.controller.ts`、`dto/product-family.dto.ts`

**语义:**
- `POST /product-families/auto-group` body `{ apply?: boolean }`。
- 候选 = `source=SDS, delisted=false, familyId=null` 的 OriginGood，按 `originGroupKey(goodName)` 分组（空键丢弃）；组内 ≥1 条即可成族。
- 预览返回 `{ groups: [{ groupKey, familyName, familyCode, memberCount, sampleNames: string[] }] }`，`familyName=parse.productName ?? seg1`、`familyCode=parse.skuCode`。
- `apply=true`：每组 create（Code 冲突走 `ensureUniqueCode`）+ 挂成员 + 逐族 recompute；跳过组内成员已全部有族的组（防止重复建族）。
- 幂等：重复调用 apply 不产生重复族（候选 familyId=null 过滤保证）。

测试：分组正确性（物流不同不同组）、空键丢弃、apply 幂等、preview 不写库。

- [ ]Commit: `feat(api): family auto-group preview and apply`

---

### Task 6: 成员管理 + 自定义成员

**Files:**
- Modify: `product-families.service.ts`、`product-families.controller.ts`、`dto/product-family.dto.ts`

**语义:**
- `POST /:id/members` body `{ addOriginGoodIds?: bigint[], removeOriginGoodIds?: bigint[] }`：
  - add：校验 OriginGood 存在；成员变更后 recompute；
  - remove：从族摘除（`familyId=null`）；若移除的是 `primaryOriginGoodId` → 自动落到剩余第一个成员并置告警字段（实现：primary 置 null，recompute 用第一个成员兜底）。
- `POST /:id/members/custom` body：

```ts
export class CreateCustomMemberDto {
  @IsString() @IsNotEmpty() goodName: string;
  @IsOptional() @IsString() goodImage?: string;
  @IsString() @IsNotEmpty() logisticsLabel: string;   // 必填：矩阵归因
  @IsString() @IsNotEmpty() craftLabel: string;       // 必填：矩阵归因
  @IsOptional() @IsString() skuCode?: string;
  @IsOptional() @IsString() warehouseLabel?: string;
  @IsArray() @ValidateNested({ each: true }) @Type(() => CustomVariantDto)
  variants: CustomVariantDto[];                        // 至少1条
  @IsOptional() @IsObject() detail?: { sizeChart?: object; packageSpecs?: object; [k: string]: unknown };
}
export class CustomVariantDto {
  @IsString() sku: string;
  @IsOptional() @IsString() sizeId?: string; @IsOptional() @IsString() sizeName?: string;
  @IsOptional() @IsString() colorId?: string; @IsOptional() @IsString() colorName?: string;
  @IsOptional() @IsString() colorHex?: string; @IsOptional() @IsString() imageUrl?: string;
  @IsNumber() @Min(0.01) price: number;
}
```

  - 创建 `OriginGood { source: CUSTOM, sdsGoodId: 'custom-'+randomUUID(), familyId:族id, 解析列=dto }` + variants + 可选 `OriginGoodDetail{sizeChart, packageSpecs}`；成功后 recompute。
- CUSTOM 成员在 recompute 中与 SDS 同权（读其变体价格），重算永不写其变体/详情。

测试：成员增删触发 recompute、移除主链接的 primary 兜底、custom 成员创建字段落库（sdsGoodId 前缀 custom-、解析列来自 dto）、recompute 计入 custom 变体。

- [ ]Commit: `feat(api): family member management and custom members`

---

### Task 7: 价格覆盖表

**Files:**
- Modify: `product-families.service.ts`、`product-families.controller.ts`、`dto/product-family.dto.ts`

**语义:**
- `GET /:id/price-overrides` → `{ items: [...override, derivedPrice: string|null, diff: string|null] }`（对照推导价与差额，便于 admin 展示）。
- `PUT /:id/price-overrides` body `{ items: [{ sizeId, colorId, craft, logistics, price, note? }] }`：批量 upsert（`@@unique` 五键）；写后 recompute。
- `DELETE /:id/price-overrides` body `{ cells: [{ sizeId, colorId, craft, logistics }] }`：删覆盖恢复推导价，recompute。
- 校验：`price > 0`；四维度键必须 ∈ 族当前矩阵选项（`priceMatrix.sizes/colors/crafts/logistics`），否则 400（错误信息列出非法键）。
- **边界**：族尚未物化（priceMatrix 为 null，如刚建族未 recompute）→ 先 recompute 再校验。

测试：upsert 幂等、非法维度键 400、删除后恢复推导价、覆盖后矩阵行 manual=true、derivedPrice/diff 计算。

- [ ]Commit: `feat(api): family price overrides endpoints`

---

### Task 8: 手动重算端点 + 同步钩子 + 新链接自动挂族

**Files:**
- Modify: `product-families.controller.ts`（`POST /:id/recompute`）
- Modify: `src/sync/sync.service.ts`:
  - `upsertOriginGood`（L686-742）：`goodName` 解析后把 `skuCode/logisticsLabel/craftLabel/warehouseLabel` 并入 create/update data（字段级：解析结果全量覆盖，空值也写 null，保持镜像纯度）；
  - `upsertOriginGood` 新插入（`!existing`）时尝试**自动挂族**：按 `originGroupKey(goodName)` 找同键已有族的成员 → 唯一族：`autoManaged=true` 直接挂+enqueue，`false` 只置 `stale=true`；多族/零族：跳过（留给管理员）；
  - `persistProductDetail`（L606-659）事务成功返回后：查 `originGood.familyId`，非空则 `familyRecompute.enqueue(familyId)`。
- `SyncModule` imports `ProductFamiliesModule`（导出 `FamilyRecomputeService`）；`SyncService` 构造注入。
- 循环依赖防护：`ProductFamiliesModule` **不** import `SyncModule`。

测试：upsert data 含解析列、新链接唯一族命中挂载、多族不挂、persist 后 enqueue 被调用（spy）。

- [ ]Commit: `feat(api): sync hooks for parsing, auto-attach and family recompute`

---

### Task 9: 回填脚本（解析 → 自动建族 → 全量重算）

**Files:**
- Create: `apps/api/prisma/backfill-product-families.ts`（package.json 加 script `"backfill:product-families": "ts-node prisma/backfill-product-families.ts"`）

**语义（与 auto-group 复用 service，脚本只做编排）:**
1. 全量 SDS+CUSTOM OriginGood 回填四解析列（`prisma.$transaction` 分批 100 条 update）；
2. 调 `autoGroup({ apply: true })`（此时 familyId 全 null，等价全量建族；CUSTOM 单条成族、code 取 skuCode ?? `CUSTOM-<id>`）；
3. 遍历所有族逐个 `recomputeFamily`，输出统计 `{ parsed, unparsable, familiesCreated, recomputed }` 与不可解析清单；
4. 幂等：重复执行时步骤1无变化、步骤2候选为空、步骤3重算结果相同。

Run（真实库执行一次）: `cd apps/api && pnpm backfill:product-families`
Expected: 约 544 条解析、>90% 成族、0 报错。

- [ ]Commit: `feat(api): product families backfill script`

---

### Task 10: origin-goods 树接入族信息

**Files:**
- Modify: `src/origin-goods/origin-goods.service.ts`（`getTree` L116 起）

**语义（最小增量，不破坏现有响应形状）:**
- `findMany originGoods` 的 include 增加 `family: { select: { id: true, familyName: true, familyCode: true, stale: true, autoManaged: true } }`；
- 树叶子节点 payload 增加 `familyId: string | null`、`familyName: string | null`、`familyCode: string | null` 字段（沿用该文件现有的叶子映射处，逐字段展开；BigInt → string 与现有一致）；
- 类型 `OriginGoodsTreeResponse` 相应扩展（该文件内或 dto 文件内的 interface）。

测试：叶子包含族字段、无族时为 null。

- [ ]Commit: `feat(api): expose family info in origin goods tree`

---

### Task 11: goods.service createCustom 扩展

**Files:**
- Modify: `src/goods/goods.service.ts`（`createCustom` L174-221）、`src/goods/dto/`（对应 create-custom DTO）

**语义:**
- `POST /goods/custom` 请求体可选新增：`logisticsLabel/craftLabel/skuCode/warehouseLabel/familyId`；
- 有 `familyId` → 创建的 CUSTOM OriginGood 直接入族（校验族存在，404 否则）并 enqueue recompute；
- 无 `familyId` → 维持现状（独立 CUSTOM 商品，familyId=null，后续可被挂族或运行回填脚本时单条成族）；
- 现有调用方不传新字段 → 行为完全不变（回归红线）。

测试：带 familyId 创建入族、不带时与旧路径一致（现有 spec 全绿即证）。

- [ ]Commit: `feat(api): custom goods support family attribution`

---

### Task 12: 文档更新 + 全量验证

**Files:**
- Modify: `docs/references/structs.md`（新模块/新表/新端点）、`docs/references/product-center.md`（族概念与端点用法示例）、`README.md`（backfill 命令）
- Modify: `docs/references/authority-matrix-ui.md`（product-families 读写权限行）

- [ ] **Step 1:** `cd apps/api && npx jest --silent` 全绿（现有 107 + 新增全部）。
- [ ] **Step 2:** `pnpm -r build` 通过（api/admin/website 均编译）。
- [ ] **Step 3:** 启动 `pnpm --filter @inkreach/api dev`，Swagger `/api/docs` 冒烟：auto-group 预览 → 应用 → 族详情含矩阵 → 覆盖改价 → 重算。
- [ ] **Step 4:** 遵循 verification-before-completion 技能自查。
- [ ]Commit: `docs: update references for product families`

---

## Self-Review 记录

- **Spec 覆盖:** §5.1/5.2/5.5/5.6 数据模型→Task 2；§6 解析→Task 1/8/9；§7 并集→Task 3；§8 价格→Task 3/7；§9 同步联动→Task 8；§10.1 端点→Task 4-8、10、11；§12 迁移→Task 2/9。三期范围（公开读路径/Good.familyId/good_origin_goods 废弃）不在本计划，已声明。
- **占位符:** 无 TBD/TODO；核心算法任务（1/3/5/6/7）含完整测试规格与代码。
- **类型一致:** `PriceMatrix/PriceMatrixRow` 在 Task 3 定义、Task 7 校验引用同一类型；`ensureUniqueCode` 在 Task 4 定义、Task 5 复用；`enqueue/recomputeFamily` 在 Task 3 定义、Task 6/8/9/11 复用。
