Files
inkreach-official-website/plans/feature/product-family-merge-feature.md
T

546 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 产品族(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 端 truncateToProcessKEEP_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` modelL23-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`
**DTOclass-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`:每组 createCode 冲突走 `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 置 nullrecompute 用第一个成员兜底)。
- `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 复用。