diff --git a/apps/admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue index e832017..cbd76dd 100644 --- a/apps/admin/src/views/goods/GoodsView.vue +++ b/apps/admin/src/views/goods/GoodsView.vue @@ -1,2671 +1,2729 @@ - - - - - - - + + + + + + + diff --git a/apps/api/.env.example b/apps/api/.env.example index c9de2c5..f068565 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -19,6 +19,6 @@ THROTTLE_LIMIT=120 PORT=3001 -# Gray release: expose product-family block (union size chart + 5-dim price matrix) -# in GET /public/goods/:goodId responses. Off = response shape identical to before. -PUBLIC_DETAIL_FROM_FAMILY=false +# Product-family public read path (family block + family variant union in +# GET /public/goods/:goodId). Set to 'false' for emergency rollback to legacy behavior. +PUBLIC_DETAIL_FROM_FAMILY=true diff --git a/apps/api/src/goods/dto/good.dto.ts b/apps/api/src/goods/dto/good.dto.ts index 0baf2c9..7afa628 100644 --- a/apps/api/src/goods/dto/good.dto.ts +++ b/apps/api/src/goods/dto/good.dto.ts @@ -13,6 +13,7 @@ export interface GoodRelations { goodName: string | null; goodImage: string | null; goodPrice: unknown; + family?: { id: bigint; familyCode: string | null; familyName: string } | null; detail?: { productCode: string | null; syncedAt: Date; @@ -127,6 +128,7 @@ export class GoodDto { sizeRowCount: number; packageRowCount: number; productCode: string | null; + family?: { familyId: string; familyCode: string | null; familyName: string } | null; } | null; static from( @@ -215,6 +217,13 @@ export class GoodDto { sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart), packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs), productCode: rel.originGood.detail?.productCode ?? null, + family: rel.originGood.family + ? { + familyId: rel.originGood.family.id.toString(), + familyCode: rel.originGood.family.familyCode, + familyName: rel.originGood.family.familyName, + } + : null, } : null, }; diff --git a/apps/api/src/goods/dto/update-good.dto.ts b/apps/api/src/goods/dto/update-good.dto.ts index 54ab7cb..6db9dd9 100644 --- a/apps/api/src/goods/dto/update-good.dto.ts +++ b/apps/api/src/goods/dto/update-good.dto.ts @@ -61,4 +61,15 @@ export class UpdateGoodDto { @IsOptional() @IsString() goodImage?: string | null; + + @ApiProperty({ + required: false, + nullable: true, + type: Number, + description: '所属产品族 id(null = 脱离族)', + }) + @IsOptional() + @IsInt() + @Min(1) + familyId?: number | null; } \ No newline at end of file diff --git a/apps/api/src/goods/goods.service.ts b/apps/api/src/goods/goods.service.ts index c86c245..19ef667 100644 --- a/apps/api/src/goods/goods.service.ts +++ b/apps/api/src/goods/goods.service.ts @@ -31,6 +31,7 @@ const GOOD_INCLUDE = { detail: true, variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, _count: { select: { variants: true } }, + family: { select: { id: true, familyCode: true, familyName: true, stale: true } }, }, }, goodTags: { include: { tag: true } }, @@ -334,6 +335,18 @@ export class GoodsService { } if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority; if (dto.goodImage !== undefined) data.goodImage = dto.goodImage; + if (dto.familyId !== undefined) { + if (dto.familyId !== null) { + const family = await this.prisma.productFamily.findUnique({ + where: { id: BigInt(dto.familyId) }, + select: { id: true }, + }); + if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`); + data.family = { connect: { id: family.id } }; + } else { + data.family = { disconnect: true }; + } + } if (dto.tagIds !== undefined) { for (const tagId of dto.tagIds) { await this.ensureTag(tagId); diff --git a/apps/api/src/product-families/product-families.service.spec.ts b/apps/api/src/product-families/product-families.service.spec.ts index 9e9db98..fbc560f 100644 --- a/apps/api/src/product-families/product-families.service.spec.ts +++ b/apps/api/src/product-families/product-families.service.spec.ts @@ -40,6 +40,7 @@ describe('ProductFamiliesService', () => { }); it('create:挂成员、重算、familyCode 冲突自动加后缀', async () => { + const code = `DG${stamp}T`; const a = await mkOriginGood('美国(包邮)T恤-DGTEST-单面印花', { craftLabel: '单面印花', logisticsLabel: '包邮', @@ -58,18 +59,18 @@ describe('ProductFamiliesService', () => { }); const f1 = (await service.create({ familyName: '测试T恤', - familyCode: 'DGTEST', + familyCode: code, originGoodIds: [a.id.toString()], primaryOriginGoodId: a.id.toString(), })) as any; createdFamilyIds.push(BigInt(f1.id)); - expect(f1.familyCode).toBe('DGTEST'); + expect(f1.familyCode).toBe(code); expect(f1._count.originGoods).toBe(1); expect((f1.priceMatrix as any).rows).toHaveLength(1); - const f2 = (await service.create({ familyName: '测试T恤二号', familyCode: 'DGTEST' })) as any; + const f2 = (await service.create({ familyName: '测试T恤二号', familyCode: code })) as any; createdFamilyIds.push(BigInt(f2.id)); - expect(f2.familyCode).toBe('DGTEST-2'); + expect(f2.familyCode).toBe(`${code}-2`); }); it('detail:不存在 404', async () => { @@ -77,11 +78,12 @@ describe('ProductFamiliesService', () => { }); it('list:keyword 过滤 familyName/familyCode + 分页字段', async () => { + const code = `DG${stamp}T`; const res = (await service.list({ keyword: `测试T恤`, page: 1, pageSize: 10 })) as any; expect(res.total).toBeGreaterThanOrEqual(2); expect(res.items.length).toBeGreaterThanOrEqual(2); expect(res.page).toBe(1); - const byCode = (await service.list({ keyword: 'DGTEST-2' })) as any; + const byCode = (await service.list({ keyword: `${code}-2` })) as any; expect(byCode.total).toBe(1); }); diff --git a/apps/api/src/public/public-family-block.spec.ts b/apps/api/src/public/public-family-block.spec.ts index 78db062..1c02eac 100644 --- a/apps/api/src/public/public-family-block.spec.ts +++ b/apps/api/src/public/public-family-block.spec.ts @@ -92,26 +92,59 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => { await prisma.$disconnect(); }); - it('开关关闭:响应完全不含 family 键(与现状形状一致)', async () => { + it('默认(未设开关):输出族块', async () => { + delete process.env.PUBLIC_DETAIL_FROM_FAMILY + const detail = await service.getGood(sdsGoodId); + expect(detail.goodId).toBe(sdsGoodId); + expect(detail.family).toBeTruthy(); + expect(detail.family!.familyCode).toBe(`PF${stamp}`); + expect(detail.family!.minPrice).toBe('25'); + // 旧字段保留(向后兼容) + expect(detail.variants.length).toBe(1); + }); + + it('显式关闭(false):响应完全不含 family 键(应急回退)', async () => { process.env.PUBLIC_DETAIL_FROM_FAMILY = 'false'; const detail = await service.getGood(sdsGoodId); expect(detail.goodId).toBe(sdsGoodId); expect('family' in detail).toBe(false); }); - it('开关开启:输出族块(物化矩阵 + 并集 + 起价)', async () => { + it('族变体并集:任何族成员链接的 sdsGoodId 均命中同一商品且变体含全体成员', async () => { process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true'; - const detail = await service.getGood(sdsGoodId); - expect(detail.family).not.toBeNull(); - expect(detail.family!.familyCode).toBe(`PF${stamp}`); - expect(detail.family!.familyId).toBe(familyId.toString()); - expect(detail.family!.crafts).toEqual(['单面印花']); - expect(detail.family!.logistics).toEqual(['包邮']); - expect(detail.family!.priceMatrix).toBeTruthy(); - expect(detail.family!.minPrice).toBe('25'); - // 旧字段保留(向后兼容) - expect(detail.variants.length).toBe(1); - expect(detail.sizeChart).toBeDefined(); + // 再加一个同族成员(不同仓库段) + const og2 = await prisma.originGood.create({ + data: { + sdsGoodId: `pubfam-m-${stamp}`, + goodName: `美国(包邮)测试T恤-PF${stamp}-单面印花-二仓`, + craftLabel: '单面印花', + logisticsLabel: '包邮', + familyId, + }, + }); + createdOriginGoodIds.push(og2.id); + await prisma.originGoodVariant.create({ + data: { + originGoodId: og2.id, + sdsVariantId: 'pf-m-v1', + sku: `PF-${stamp}-M`, + sizeId: 'size_M', + sizeName: 'M', + colorId: 'color_blk', + colorName: '黑色', + price: new Prisma.Decimal(26), + }, + }); + + // 用成员链接(非主链接)的 sdsGoodId 访问 → 命中同一商品(响应 goodId 仍为主链接) + const detail = await service.getGood(`pubfam-m-${stamp}`); + expect(detail.goodId).toBe(sdsGoodId); + const skus = detail.variants.map((v) => v.sku); + expect(skus).toContain(`PF-${stamp}-S`); + expect(skus).toContain(`PF-${stamp}-M`); // 族成员变体并集 + // 清理:把成员移出族避免影响其他用例 + await prisma.originGoodVariant.deleteMany({ where: { originGoodId: og2.id } }); + await prisma.originGood.update({ where: { id: og2.id }, data: { familyId: null } }); }); it('无族商品:开关开启也不含 family 键', async () => { diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 70397b5..0e3898b 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -221,7 +221,9 @@ export class PublicService { where: { OR: [ { originGood: { sdsGoodId: goodId, delisted: false } }, - // Merged secondary sources also resolve to the same good. + // 族内任何成员链接均可命中同一商品(替代旧副源关联的可达性语义) + { family: { originGoods: { some: { sdsGoodId: goodId, delisted: false } } } }, + // 历史副源关联(good_origin_goods)只读保留,仍可命中 { mergedOriginGoods: { some: { originGood: { sdsGoodId: goodId, delisted: false } }, @@ -235,7 +237,25 @@ export class PublicService { if (!good) { throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' }); } - const dto = this.toPublicGoodDetail(good); + // 族机制(新):变体并集 = 主链接 ∪ 族成员 ∪ 旧副源(过渡期),按 (链接, 变体) 去重 + let familyVariants: Array<{ + originGoodId: bigint; + variant: PublicGoodRow['originGood']['variants'][number]; + }> = []; + const familyId = good.originGood.family?.id; + if (familyId) { + const members = await this.prisma.originGood.findMany({ + where: { familyId, delisted: false }, + select: { + id: true, + variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, + }, + }); + familyVariants = members + .filter((m) => m.id !== good.originGoodId) + .flatMap((m) => m.variants.map((variant) => ({ originGoodId: m.id, variant }))); + } + const dto = this.toPublicGoodDetail(good, familyVariants); dto.category.categoryIcon = await this.resolveCategoryIcon(good.category); return dto; } @@ -362,13 +382,35 @@ export class PublicService { return null; } - private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto { + private toPublicGoodDetail( + good: PublicGoodRow, + familyVariants: Array<{ + originGoodId: bigint; + variant: PublicGoodRow['originGood']['variants'][number]; + }> = [], + ): PublicGoodDetailDto { const base = this.toPublicGood(good); const detail = good.originGood.detail; - // Merge primary and secondary origin good variants (dedup identical URLs). + // 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期); + // 同一链接可能既是族成员又挂旧副源,按 `${originGoodId}:${sdsVariantId}` 去重。 + const seen = new Set(['']); + const dedupe = (originGoodId: bigint, variant: PublicGoodRow['originGood']['variants'][number]) => { + const key = `${originGoodId}:${variant.sdsVariantId}`; + if (seen.has(key)) return null; + seen.add(key); + return variant; + }; + good.originGood.variants.forEach((v) => dedupe(good.originGoodId, v)); const allVariants = [ ...good.originGood.variants, - ...good.mergedOriginGoods.flatMap((m) => m.originGood.variants), + ...familyVariants + .map(({ originGoodId, variant }) => dedupe(originGoodId, variant)) + .filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null), + ...good.mergedOriginGoods.flatMap((m) => + m.originGood.variants + .map((variant) => dedupe(m.originGoodId, variant)) + .filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null), + ), ]; return { ...base, @@ -417,13 +459,13 @@ export class PublicService { } /** - * 灰度族块(设计 D4:零新增公开端点):仅当 PUBLIC_DETAIL_FROM_FAMILY=true - * 且主链接有所属族时输出;数据全部来自 ProductFamily 的物化 JSON,无额外查询。 + * 族块(设计 D4:零新增公开端点):默认输出;仅当 PUBLIC_DETAIL_FROM_FAMILY + * 显式设为 'false' 时关闭(应急回退开关)。数据全部来自 ProductFamily 的物化 JSON。 */ private familyBlock( good: PublicGoodRow, ): Pick | Record { - if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'true') return {}; + if (process.env.PUBLIC_DETAIL_FROM_FAMILY === 'false') return {}; const family = good.originGood.family; if (!family || !family.priceMatrix) return {}; const matrix = family.priceMatrix as { diff --git a/docs/references/product-center.md b/docs/references/product-center.md index 458c56d..b35c295 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -39,16 +39,15 @@ 宽度低于 1000px 后分类栏变为抽屉;移动端商品网格降为两列或单列,国家筛选仅在自身区域横向滚动,不会撑宽页面。 -## 多源合并商品 +## 多源合并商品(已废弃,由产品族替代) -后台支持把名称相同但工厂/仓库不同的多个 SDS 原产品合并为一个官网商品: +> **2026-08-28 起,该机制已被「产品族」完全替代**:多链接合并由族承载(尺码/包装并集 + +> 五维价格矩阵),配置/编辑商品不再写入 `mergedOriginGoodIds`;`good_origin_goods` 表 +> 只读保留(历史数据仍可命中详情),观察期后删除。以下为历史行为记录: - 数据层:主源存 `goods.origin_good_id`,副源存中间表 `good_origin_goods`。 -- 详情可达性:官网上通过**任一**关联原产品的 `sdsGoodId` 都能访问到该商品详情,即副源的旧链接不会 404。 -- 变体合并:详情中的 SKU 是「主源变体 ∪ 全部副源变体」,按颜色归组展示媒体图;价格与详情页内容以主源为准。 -- 后台维护入口: - - 配置弹窗(右栏拖拽/配置按钮):自动勾选同分类下同名兄弟原产品作为副源提交(`mergedOriginGoodIds`); - - 编辑弹窗「关联原产品」区:可搜索添加副源、移除副源、切换主源(切换后旧主源自动转为副源)。 +- 详情可达性:官网上通过**任一**关联原产品的 `sdsGoodId` 都能访问到该商品详情(族机制下 + 同样成立:族内任何成员链接的 sdsGoodId 均命中同一商品)。 > 该 Good 级合并能力保留可用;新一级的「产品族(SPU)合并」见下节,公开读路径接入族数据属于三期范围。 @@ -101,8 +100,12 @@ pnpm --filter @inkreach/api backfill:product-families # → [1/2] 解析全部链接名 [2/2] 自动建族 [3/3] 全量族重算(幂等,可随时重跑) ``` -**公开读路径灰度(三期已上线,默认关闭)**:环境变量 `PUBLIC_DETAIL_FROM_FAMILY=true` 时, -`GET /public/goods/:goodId` 在既有响应上**增量**输出 `family` 块: +**公开读路径(三期,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。 +`GET /public/goods/:goodId` 行为: + +- **变体并集(替代旧副源机制)**:`variants` = 主链接 ∪ 全体族成员 ∪ 旧副源(过渡期), + 按 `(链接, 变体)` 去重;`mediaByColor` 同源。任何族成员链接的 sdsGoodId 均命中同一商品。 +- **族块**(默认输出):`family` 含并集尺码表/包装 + 五维价格矩阵 + 族起价: ```jsonc { diff --git a/docs/references/structs.md b/docs/references/structs.md index 946b796..82e196a 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -114,7 +114,7 @@ apps/api/ | `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 | | `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 | | `/public/goods` `GET` | 分页商品(支持 `countryId/categoryId/tagIds(逗号分隔)/keyword/page/pageSize`,`tagIds` 为 AND 关系) | 公开 | -| `/public/goods/:id` `GET` | 商品详情;`PUBLIC_DETAIL_FROM_FAMILY=true` 且主链接有族时额外输出 `family` 块(并集尺码表/包装 + 五维价格矩阵 + 族起价),开关关闭时响应形状与现状一致 | 公开 | +| `/public/goods/:id` `GET` | 商品详情;默认输出 `family` 块(并集尺码表/包装 + 五维价格矩阵 + 族起价),`variants` = 主链接 ∪ 族成员 ∪ 旧副源(去重);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退旧行为 | 公开 | | `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT | | `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT | | `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT | diff --git a/plans/feature/product-family-config-integration-feature.md b/plans/feature/product-family-config-integration-feature.md new file mode 100644 index 0000000..2d7acbf --- /dev/null +++ b/plans/feature/product-family-config-integration-feature.md @@ -0,0 +1,13 @@ +# 产品族 × 商品配置页完整融入 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** 「商品配置」页深度接入族:右树按族分组、配置勾选兄弟=挂族(双写兼容)、编辑表单可查看/切换族。 + +**Tasks:** + +1. **后端**:`UpdateGoodDto.familyId?: number | null`;`update()` 校验族存在后写入;`GOOD_INCLUDE.originGood` 加 `family` select 并透出到 `GoodDetailDto`(`family: { familyId, familyCode, familyName } | null`)。测试:update 改族/清族/非法族 404。 +2. **右树按族分组**:`buildRightTree.mapCat` 内按 `og.familyId` 分桶:族节点(`isFamily: true`,label=`编码 · 族名 (n)`)+ 未入族桶;og 节点 `parentId` 指向族节点(`collectSiblings` 语义随之升级为同族);非 OG 节点模板分支渲染族节点。过滤/展开/计数逻辑不改(遍历式,天然兼容)。 +3. **配置弹窗挂族**:`handleConfigSubmit` 在 createGood 前:主链接有族 → `updateMembers(add: 勾选兄弟)`;无族且有勾选 → `POST /product-families`(族名=主链接名 3 段截断, `originGoodIds=[主+勾选]`);**保留 mergedOriginGoodIds 双写**(公开读路径兼容观察期)。 +4. **编辑表单族显示/切换**:编辑弹窗显示 `所属族`(familyCode/familyName),可搜索切换(远程搜索族列表),保存时 `updateGood({ familyId })`。 +5. 验证:api 全量 + admin vitest + vue-tsc/build + 冒烟;文档更新(structs/product-center);合并 develop。