diff --git a/README.md b/README.md index 9837c6e..846cd01 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ pnpm --filter @inkreach/api test # 单元测试 pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client pnpm --filter @inkreach/api prisma:migrate # 运行迁移 pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio +pnpm --filter @inkreach/api backfill:product-families # 产品族回填(解析链接名→自动建族→全量重算,幂等) # 官网 pnpm --filter @inkreach/website dev # 开发 diff --git a/apps/api/src/product-families/product-families.controller.ts b/apps/api/src/product-families/product-families.controller.ts index 718a1b3..cb82799 100644 --- a/apps/api/src/product-families/product-families.controller.ts +++ b/apps/api/src/product-families/product-families.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Put, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { ProductFamiliesService } from './product-families.service'; diff --git a/apps/api/src/sync/sync-family-hooks.spec.ts b/apps/api/src/sync/sync-family-hooks.spec.ts index be9fed5..4532536 100644 --- a/apps/api/src/sync/sync-family-hooks.spec.ts +++ b/apps/api/src/sync/sync-family-hooks.spec.ts @@ -48,7 +48,7 @@ describe('SyncService family hooks', () => { const sdsId = `hook-${stamp}-parse`; const result1 = await (service as any).upsertOriginGood( sdsProduct(sdsId, '美国(包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'), - 'cat-1', + `cat-${stamp}-parse`, ); expect(result1).toBe('inserted'); const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } }); @@ -61,7 +61,7 @@ describe('SyncService family hooks', () => { // 更新为不带仓库的名称 → 解析列全量覆盖(warehouseLabel 置空) await (service as any).upsertOriginGood( sdsProduct(sdsId, '美国(不包邮)240g涤纶休闲短裤-DG206-单面印花'), - 'cat-1', + `cat-${stamp}-parse`, ); const og2 = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } }); expect(og2.logisticsLabel).toBe('不包邮'); @@ -126,12 +126,14 @@ describe('SyncService family hooks', () => { }); it('多族命中时不确定归属 → 不挂载', async () => { - const key = `自动挂${stamp}B`; + // 两个族各含一个同分类成员 → 新链接分类命中两个族,归属不明,留给管理员 + const sdsCat = `cat-multi-${stamp}`; const mk = async (suffix: string) => { const og = await prisma.originGood.create({ data: { sdsGoodId: `hook-${stamp}-multi-${suffix}`, - goodName: `${key}(包邮)卫衣-ZZB${stamp}-单面印花`, + goodName: `${suffix}(包邮)卫衣-ZZB${stamp}-单面印花`, + sdsCategoryId: sdsCat, craftLabel: '单面印花', logisticsLabel: '包邮', }, @@ -149,8 +151,8 @@ describe('SyncService family hooks', () => { const sdsId = `hook-${stamp}-multi-new`; await (service as any).upsertOriginGood( - sdsProduct(sdsId, `${key}(包邮)卫衣-ZZB${stamp}-单面印花-新仓`), - 'cat-1', + sdsProduct(sdsId, `新(包邮)卫衣-ZZB${stamp}-单面印花-新仓`), + sdsCat, ); const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } }); createdOriginGoodIds.push(og.id); diff --git a/docs/references/product-center.md b/docs/references/product-center.md index 18a2b15..8b4ecf5 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -50,6 +50,58 @@ - 配置弹窗(右栏拖拽/配置按钮):自动勾选同分类下同名兄弟原产品作为副源提交(`mergedOriginGoodIds`); - 编辑弹窗「关联原产品」区:可搜索添加副源、移除副源、切换主源(切换后旧主源自动转为副源)。 +> 该 Good 级合并能力保留可用;新一级的「产品族(SPU)合并」见下节,公开读路径接入族数据属于三期范围。 + +## 产品族(SPU 层,一期后端已上线) + +设计文档:`docs/superpowers/specs/2026-08-28-product-family-merge-design.md`。 + +**模型**:SDS 叶子分类即产品模型(如分类 `DG001 180G纯棉T恤(JSA002)` 下 16 条链接), +族 = 同分类链接的合并体;链接名解析列(`国家(物流)品名-SKU-工艺[-仓库]`)提供 +物流/工艺归因。例:DG001 族 = 16 链接 × 3 工艺 × 3 物流 × 9 尺码(S~XXXXXL 并集)× 20 颜色 ≈ 470 格价格矩阵,起价取光板(不打印)最低价。 + +**价格**:`价格 = f(尺码, 颜色, 物流, 工艺[含印花数量])`,全部从成员链接变体推导透传 +(同格子多仓库取最低价,来源全保留);`family_price_overrides` 表支持按格人工改价, +删覆盖即恢复推导价。 + +**关键端点用法示例**(JWT): + +```bash +# 1. 自动成族:先预览 +curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" \ + -d '{"apply": false}' +# → { applied: 0, groups: [{ groupKey: "cat:8240", familyName: "DG001 180G纯棉T恤(JSA002)", memberCount: 16, ... }] } + +# 2. 应用(幂等,可重复执行) +curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" -d '{"apply": true}' + +# 3. 族详情:成员 + 并集尺码表/包装 + 五维价格矩阵 +curl /product-families/12 -H "Authorization: Bearer $T" + +# 4. 人工改价(维度必须存在于族矩阵选项,否则 400 并列出非法键) +curl -X PUT /product-families/12/price-overrides -H "Authorization: Bearer $T" \ + -d '{"items": [{"sizeId":"size_S","colorId":"color_blk","craft":"单面印花","logistics":"包邮","price": 23, "note": "促销"}]}' + +# 5. 删覆盖恢复推导价 +curl -X DELETE /product-families/12/price-overrides -H "Authorization: Bearer $T" \ + -d '{"cells": [{"sizeId":"size_S","colorId":"color_blk","craft":"单面印花","logistics":"包邮"}]}' + +# 6. 族内创建自定义商品(人工补链接,物流/工艺归因必填) +curl -X POST /product-families/12/members/custom -H "Authorization: Bearer $T" \ + -d '{"goodName":"美国(海运)180g纯棉T恤-DG001-烫画","logisticsLabel":"海运","craftLabel":"烫画", + "variants":[{"sku":"C-M","sizeId":"size_M","sizeName":"M","colorId":"color_red","colorName":"红色","price": 33}]}' +``` + +**同步联动**:商品同步落库时刷新解析列;新链接与已有族**同分类唯一命中**时自动挂族 +(多族/零族留给管理员;锁定族只置 `stale`);详情同步提交后异步重算受影响族 +(进程内去重、幂等)。回填/修复脚本: + +```bash +pnpm --filter @inkreach/api backfill:product-families +# → [1/2] 解析全部链接名 [2/2] 自动建族 [3/3] 全量族重算(幂等,可随时重跑) +``` + + ## 验证 ```bash diff --git a/docs/references/structs.md b/docs/references/structs.md index 506972b..8f83d68 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -45,8 +45,9 @@ inkreach-official/ ``` apps/api/ ├── prisma/ -│ ├── schema.prisma # 数据模型(OriginGood/Country/Category/Tag/Position/Good/User/SyncLog) -│ └── migrations/ # Prisma migrate 历史 +│ ├── schema.prisma # 数据模型(OriginGood/ProductFamily/FamilyPriceOverride/Country/Category/Tag/Position/Good/User/SyncLog) +│ ├── migrations/ # Prisma migrate 历史 +│ └── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等) ├── src/ │ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化 │ ├── app.module.ts # 根模块,聚合所有业务模块 @@ -57,7 +58,8 @@ apps/api/ │ ├── tags/ # 标签 CRUD(受 JWT 保护) │ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序) │ ├── positions/ # 坑位 CRUD(受 JWT 保护) -│ ├── origin-goods/ # SDS 原始商品快照(只读分页) +│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树,树叶子含族信息) +│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算 │ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 │ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志 │ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情 @@ -77,7 +79,11 @@ apps/api/ | 模型 | 说明 | |------|------| -| `OriginGood` | SDS 原始商品缓存,关联 `sds_good_id`(唯一) | +| `OriginGood` | SDS 原始商品缓存,关联 `sds_good_id`(唯一);含四个链接名解析列(`skuCode/logisticsLabel/craftLabel/warehouseLabel`,格式 `国家(物流)品名-SKU-工艺[-仓库]`)与 `familyId` 族归属 | +| `OriginGoodDetail` | SDS `/products/{id}` 详情缓存(文本字段 + `sizeChart/packageSpecs/options/media` JSON) | +| `OriginGoodVariant` | SDS 子 SKU 缓存(尺码 × 颜色 × 价格),价格矩阵推导的数据源 | +| `ProductFamily` | 产品族(SPU 层):同 SDS 分类(产品模型)的多条链接合并为一族;物化并集尺码表/包装规则与五维价格矩阵(`priceMatrix` JSON);`autoManaged=false` 为人工锁定(重算只置 `stale`);`primaryOriginGoodId` 主链接(无 FK,服务层维护) | +| `FamilyPriceOverride` | 人工按格改价:`(familyId, sizeId, colorId, craft, logistics)` 唯一 → `price`;独立于推导矩阵,重算永不覆盖 | | `Country` | 国家,关联 goods / positions | | `Category` | 自引用树形品类,可选 `sds_category_id` | | `Tag` | 标签,含 `tagColor`、`tagFontColor`、`tagGroupId`、`sortOrder`、`timing` | @@ -95,7 +101,7 @@ apps/api/ - **全局 `ValidationPipe`**:`whitelist + transform + forbidNonWhitelisted`。 - **全局 `HttpExceptionFilter`**:统一错误响应形态。 - **CORS 白名单**:`http://localhost:5173`(admin)和 `http://localhost:3000`(website)。 -- **JWT**:所有 `/goods /categories /countries /tags /positions /origin-goods /sync/*` 路由受 `JwtAuthGuard` 保护;`/public/*` 与 `/auth/*` 公开。 +- **JWT**:所有 `/goods /categories /countries /tags /positions /origin-goods /product-families /sync/*` 路由受 `JwtAuthGuard` 保护;`/public/*` 与 `/auth/*` 公开。 ### API 路由 @@ -113,6 +119,14 @@ apps/api/ | `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT | | `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT | | `/origin-goods` `GET` | SDS 原始商品快照分页 | JWT | +| `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale`) | JWT | +| `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT | +| `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接;`{apply:false}` 仅预览,`{apply:true}` 落库并逐族重算(幂等) | JWT | +| `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT | +| `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT | +| `/product-families/:id/members` `POST` | 成员增删 `{addOriginGoodIds, removeOriginGoodIds}`;移除主链接后 primary 落到剩余成员 | JWT | +| `/product-families/:id/members/custom` `POST` | 族内创建自定义成员(人工商品:物流/工艺归因必填 + 变体价格 + 可选尺码表/包装) | JWT | +| `/product-families/:id/price-overrides` `GET/PUT/DELETE` | 人工改价:查(含推导价对照与差额)/ 批量 upsert / 按格删除恢复推导价;维度必须存在于族矩阵选项 | JWT | | `/goods` | 后台商品 CRUD + `POST /goods/batch` + `PATCH /goods/batch-priority` | JWT | | `/sync/categories` `POST` | 手动触发分类同步 | JWT | | `/sync/products` `POST` | 手动触发商品同步 | JWT |