diff --git a/plans/feature/product-family-merge-feature.md b/plans/feature/product-family-merge-feature.md new file mode 100644 index 0000000..b3cdb37 --- /dev/null +++ b/plans/feature/product-family-merge-feature.md @@ -0,0 +1,545 @@ +# 产品族(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/_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 { + 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 = {}): 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-`); +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 复用。