docs: update references for product families; fix controller import and test isolation

This commit is contained in:
yeuimu
2026-08-28 12:50:42 +08:00
parent c8531bfe08
commit 502b7eba6e
5 changed files with 81 additions and 12 deletions
+1
View File
@@ -106,6 +106,7 @@ pnpm --filter @inkreach/api test # 单元测试
pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client
pnpm --filter @inkreach/api prisma:migrate # 运行迁移 pnpm --filter @inkreach/api prisma:migrate # 运行迁移
pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio
pnpm --filter @inkreach/api backfill:product-families # 产品族回填(解析链接名→自动建族→全量重算,幂等)
# 官网 # 官网
pnpm --filter @inkreach/website dev # 开发 pnpm --filter @inkreach/website dev # 开发
@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ProductFamiliesService } from './product-families.service'; import { ProductFamiliesService } from './product-families.service';
+8 -6
View File
@@ -48,7 +48,7 @@ describe('SyncService family hooks', () => {
const sdsId = `hook-${stamp}-parse`; const sdsId = `hook-${stamp}-parse`;
const result1 = await (service as any).upsertOriginGood( const result1 = await (service as any).upsertOriginGood(
sdsProduct(sdsId, '美国(包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'), sdsProduct(sdsId, '美国(包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'),
'cat-1', `cat-${stamp}-parse`,
); );
expect(result1).toBe('inserted'); expect(result1).toBe('inserted');
const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } }); const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
@@ -61,7 +61,7 @@ describe('SyncService family hooks', () => {
// 更新为不带仓库的名称 → 解析列全量覆盖(warehouseLabel 置空) // 更新为不带仓库的名称 → 解析列全量覆盖(warehouseLabel 置空)
await (service as any).upsertOriginGood( await (service as any).upsertOriginGood(
sdsProduct(sdsId, '美国(不包邮)240g涤纶休闲短裤-DG206-单面印花'), sdsProduct(sdsId, '美国(不包邮)240g涤纶休闲短裤-DG206-单面印花'),
'cat-1', `cat-${stamp}-parse`,
); );
const og2 = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } }); const og2 = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
expect(og2.logisticsLabel).toBe('不包邮'); expect(og2.logisticsLabel).toBe('不包邮');
@@ -126,12 +126,14 @@ describe('SyncService family hooks', () => {
}); });
it('多族命中时不确定归属 → 不挂载', async () => { it('多族命中时不确定归属 → 不挂载', async () => {
const key = `自动挂${stamp}B`; // 两个族各含一个同分类成员 → 新链接分类命中两个族,归属不明,留给管理员
const sdsCat = `cat-multi-${stamp}`;
const mk = async (suffix: string) => { const mk = async (suffix: string) => {
const og = await prisma.originGood.create({ const og = await prisma.originGood.create({
data: { data: {
sdsGoodId: `hook-${stamp}-multi-${suffix}`, sdsGoodId: `hook-${stamp}-multi-${suffix}`,
goodName: `${key}(包邮)卫衣-ZZB${stamp}-单面印花`, goodName: `${suffix}(包邮)卫衣-ZZB${stamp}-单面印花`,
sdsCategoryId: sdsCat,
craftLabel: '单面印花', craftLabel: '单面印花',
logisticsLabel: '包邮', logisticsLabel: '包邮',
}, },
@@ -149,8 +151,8 @@ describe('SyncService family hooks', () => {
const sdsId = `hook-${stamp}-multi-new`; const sdsId = `hook-${stamp}-multi-new`;
await (service as any).upsertOriginGood( await (service as any).upsertOriginGood(
sdsProduct(sdsId, `${key}(包邮)卫衣-ZZB${stamp}-单面印花-新仓`), sdsProduct(sdsId, `(包邮)卫衣-ZZB${stamp}-单面印花-新仓`),
'cat-1', sdsCat,
); );
const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } }); const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
createdOriginGoodIds.push(og.id); createdOriginGoodIds.push(og.id);
+52
View File
@@ -50,6 +50,58 @@
- 配置弹窗(右栏拖拽/配置按钮):自动勾选同分类下同名兄弟原产品作为副源提交(`mergedOriginGoodIds`); - 配置弹窗(右栏拖拽/配置按钮):自动勾选同分类下同名兄弟原产品作为副源提交(`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 ```bash
+19 -5
View File
@@ -45,8 +45,9 @@ inkreach-official/
``` ```
apps/api/ apps/api/
├── prisma/ ├── prisma/
│ ├── schema.prisma # 数据模型(OriginGood/Country/Category/Tag/Position/Good/User/SyncLog │ ├── schema.prisma # 数据模型(OriginGood/ProductFamily/FamilyPriceOverride/Country/Category/Tag/Position/Good/User/SyncLog
── migrations/ # Prisma migrate 历史 ── migrations/ # Prisma migrate 历史
│ └── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等)
├── src/ ├── src/
│ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化 │ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化
│ ├── app.module.ts # 根模块,聚合所有业务模块 │ ├── app.module.ts # 根模块,聚合所有业务模块
@@ -57,7 +58,8 @@ apps/api/
│ ├── tags/ # 标签 CRUD(受 JWT 保护) │ ├── tags/ # 标签 CRUD(受 JWT 保护)
│ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序) │ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序)
│ ├── positions/ # 坑位 CRUD(受 JWT 保护) │ ├── positions/ # 坑位 CRUD(受 JWT 保护)
│ ├── origin-goods/ # SDS 原始商品快照(只读分页) │ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树,树叶子含族信息
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 │ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志 │ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
│ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情 │ ├── 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 | | `Country` | 国家,关联 goods / positions |
| `Category` | 自引用树形品类,可选 `sds_category_id` | | `Category` | 自引用树形品类,可选 `sds_category_id` |
| `Tag` | 标签,含 `tagColor``tagFontColor``tagGroupId``sortOrder``timing` | | `Tag` | 标签,含 `tagColor``tagFontColor``tagGroupId``sortOrder``timing` |
@@ -95,7 +101,7 @@ apps/api/
- **全局 `ValidationPipe`**`whitelist + transform + forbidNonWhitelisted` - **全局 `ValidationPipe`**`whitelist + transform + forbidNonWhitelisted`
- **全局 `HttpExceptionFilter`**:统一错误响应形态。 - **全局 `HttpExceptionFilter`**:统一错误响应形态。
- **CORS 白名单**`http://localhost:5173`admin)和 `http://localhost:3000`website)。 - **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 路由 ### API 路由
@@ -113,6 +119,14 @@ apps/api/
| `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT | | `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT |
| `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT | | `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |
| `/origin-goods` `GET` | SDS 原始商品快照分页 | 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 | | `/goods` | 后台商品 CRUD + `POST /goods/batch` + `PATCH /goods/batch-priority` | JWT |
| `/sync/categories` `POST` | 手动触发分类同步 | JWT | | `/sync/categories` `POST` | 手动触发分类同步 | JWT |
| `/sync/products` `POST` | 手动触发商品同步 | JWT | | `/sync/products` `POST` | 手动触发商品同步 | JWT |