diff --git a/apps/admin/src/utils/origin-name.ts b/apps/admin/src/utils/origin-name.ts index 6a169b5..935c7b1 100644 --- a/apps/admin/src/utils/origin-name.ts +++ b/apps/admin/src/utils/origin-name.ts @@ -73,9 +73,10 @@ export function linkDims(name: string | null | undefined): LinkDims { const logistics = openIdx >= 0 && closeIdx > openIdx ? head.slice(openIdx + 1, closeIdx).trim() || null : null; const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))]; - const printCount = name.includes('双面印花') + // 与 api 端一致:裸「双面/单面」也归入印花数量(如「直喷双面」) + const printCount = name.includes('双面') ? '双面印花' - : name.includes('单面印花') + : name.includes('单面') ? '单面印花' : null; return { @@ -87,14 +88,14 @@ export function linkDims(name: string | null | undefined): LinkDims { /** * 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示): - * 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板→不打印,皆无则默认烫画; + * 印花数量:双面 优先于 单面(含「直喷双面」这类工艺段写法);工艺:直喷/不打印/光板→不打印,皆无则默认烫画; * 物流:不包邮 优先于 包邮。 */ export function deriveLinkTagNames(name: string | null | undefined): string[] { if (!name) return []; const names: string[] = []; - if (name.includes('双面印花')) names.push('双面印花'); - else if (name.includes('单面印花')) names.push('单面印花'); + if (name.includes('双面')) names.push('双面印花'); + else if (name.includes('单面')) names.push('单面印花'); const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))]; if (craftHits.length > 0) names.push(...craftHits); else names.push('烫画'); diff --git a/apps/admin/src/views/goods/components/GoodsEditDialog.vue b/apps/admin/src/views/goods/components/GoodsEditDialog.vue index 1e15e98..21e06ca 100644 --- a/apps/admin/src/views/goods/components/GoodsEditDialog.vue +++ b/apps/admin/src/views/goods/components/GoodsEditDialog.vue @@ -133,6 +133,7 @@ interface MemberPriceCell { colorId: string sizeName: string | null colorName: string | null + printCount: string craft: string logistics: string price: string @@ -216,9 +217,24 @@ function memberVariants(id: string) { return memberDetail(id)?.variants ?? [] } +// 矩阵三个归因维度的合法取值(与 api 端 auto-tag-rules 一致) +const PRINT_COUNT_VALUES = ['单面印花', '双面印花'] +const CRAFT_VALUES = ['烫画', '直喷', '不打印'] +const LOGISTICS_VALUES = ['包邮', '不包邮'] + +/** 成员的矩阵归因维度:优先取有效标签(人工接管后仍准确),回退按名称解析 */ function memberDims(id: string) { const row = editFamilyMembers.value.find((m) => m.id === id) - return linkDims(row?.goodName ?? null) + const fallback = linkDims(row?.goodName ?? null) + const tagNames = ((row as any)?.originGoodTags ?? []) + .map((r: any) => r.tag?.tagName) + .filter(Boolean) as string[] + const crafts = CRAFT_VALUES.filter((v) => tagNames.includes(v)) + return { + logistics: LOGISTICS_VALUES.find((v) => tagNames.includes(v)) ?? fallback.logistics, + crafts: crafts.length ? crafts : fallback.crafts, + printCount: PRINT_COUNT_VALUES.find((v) => tagNames.includes(v)) ?? fallback.printCount, + } } async function handleSyncMemberDetail(row: { id: string; sdsGoodId: string }) { @@ -234,21 +250,25 @@ async function handleSyncMemberDetail(row: { id: string; sdsGoodId: string }) { } finally { detailSyncing.value = '' } } -/** 该链接在族价格矩阵中的格子(craft/logistics 即该链接的标签维度) */ +/** 该链接在族价格矩阵中的格子(印花数量/工艺/物流 三维即该链接的归因标签) */ function loadMemberPrices(ogId: string) { - const row = editFamilyMembers.value.find((m) => m.id === ogId) - if (!row?.craftLabel || !row.logisticsLabel) { + const dims = memberDims(ogId) + if (!dims.printCount || !dims.crafts.length || !dims.logistics) { memberPrices.value[ogId] = [] return } const matrixRows = (familyDetailRaw.value?.priceMatrix?.rows ?? []) as any[] memberPrices.value[ogId] = matrixRows - .filter((r: any) => r.craft === row.craftLabel && r.logistics === row.logisticsLabel) + .filter((r: any) => + r.printCount === dims.printCount && + dims.crafts.includes(r.craft) && + r.logistics === dims.logistics) .map((r: any) => ({ sizeId: String(r.sizeId), colorId: String(r.colorId), sizeName: r.sizeName ?? null, colorName: r.colorName ?? null, + printCount: r.printCount, craft: r.craft, logistics: r.logistics, price: String(r.price ?? ''), @@ -263,7 +283,7 @@ async function saveMemberPrices(row: { id: string }) { memberPriceSaving.value = row.id try { await productFamiliesApi.putOverrides(editFamilyId.value, cells.map((c) => ({ - sizeId: c.sizeId, colorId: c.colorId, craft: c.craft, logistics: c.logistics, price: c.editPrice, + sizeId: c.sizeId, colorId: c.colorId, printCount: c.printCount, craft: c.craft, logistics: c.logistics, price: c.editPrice, }))) ElMessage.success('价格已保存') await loadEditFamily() @@ -803,7 +823,7 @@ async function handleDeleteGood() { - + diff --git a/apps/api/prisma/migrations/20260828120000_override_print_count/migration.sql b/apps/api/prisma/migrations/20260828120000_override_print_count/migration.sql new file mode 100644 index 0000000..d834d3b --- /dev/null +++ b/apps/api/prisma/migrations/20260828120000_override_print_count/migration.sql @@ -0,0 +1,6 @@ +-- 价格矩阵五维化:覆盖表补 印花数量 维度(表当前 0 行,无需数据回填) +ALTER TABLE "family_price_overrides" ADD COLUMN "print_count" TEXT NOT NULL DEFAULT '单面印花'; + +DROP INDEX "family_price_overrides_family_id_size_id_color_id_craft_log_key"; +CREATE UNIQUE INDEX "family_price_overrides_family_id_size_id_color_id_print_count_craft_log_key" + ON "family_price_overrides"("family_id", "size_id", "color_id", "print_count", "craft", "logistics"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 6d3036d..e964f93 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -328,20 +328,21 @@ model ProductFamily { // ---------- Family Price Overrides (manual per-cell price) ---------- // 独立于推导矩阵:重算只重建推导部分,本表永不被动覆盖。 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) + 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") + printCount String @default("单面印花") @map("print_count") + 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]) + @@unique([familyId, sizeId, colorId, printCount, craft, logistics]) @@map("family_price_overrides") } diff --git a/apps/api/src/product-families/auto-tag-rules.spec.ts b/apps/api/src/product-families/auto-tag-rules.spec.ts index 16ad9d2..1bf09ce 100644 --- a/apps/api/src/product-families/auto-tag-rules.spec.ts +++ b/apps/api/src/product-families/auto-tag-rules.spec.ts @@ -13,6 +13,15 @@ describe('auto-tag-rules / deriveLinkTagNames', () => { ).toEqual(['双面印花', '烫画', '不包邮']); }); + it('工艺段写法「直喷双面/直喷单面」→ 裸 双面/单面 也归入印花数量', () => { + expect( + deriveLinkTagNames('日本(包邮)200g纯棉T恤-JPTM001-直喷双面'), + ).toEqual(['双面印花', '直喷', '包邮']); + expect( + deriveLinkTagNames('日本(包邮)200g纯棉T恤-JPTM001-直喷单面'), + ).toEqual(['单面印花', '直喷', '包邮']); + }); + it('不打印 + 光板(物流备注)+ 不包邮 → 归并为单个 不打印 工艺标签(光板即不打印)', () => { expect( deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'), diff --git a/apps/api/src/product-families/auto-tag-rules.ts b/apps/api/src/product-families/auto-tag-rules.ts index fd2ae32..371e5b5 100644 --- a/apps/api/src/product-families/auto-tag-rules.ts +++ b/apps/api/src/product-families/auto-tag-rules.ts @@ -47,8 +47,10 @@ export function deriveLinkTagNames(name: string | null | undefined): string[] { if (!name) return []; const names: string[] = []; - if (name.includes('双面印花')) names.push('双面印花'); - else if (name.includes('单面印花')) names.push('单面印花'); + // 「双面印花/单面印花」显式命中,或工艺段写作「直喷双面/直喷单面」的裸「双面/单面」; + // 双面优先判断,避免「单面」误吞 + if (name.includes('双面')) names.push('双面印花'); + else if (name.includes('单面')) names.push('单面印花'); const craftTags = new Set(); for (const [keyword, tagName] of Object.entries(CRAFT_KEYWORD_MAP)) { diff --git a/apps/api/src/product-families/dto/product-family.dto.ts b/apps/api/src/product-families/dto/product-family.dto.ts index 6cd687e..a8a0c71 100644 --- a/apps/api/src/product-families/dto/product-family.dto.ts +++ b/apps/api/src/product-families/dto/product-family.dto.ts @@ -210,7 +210,12 @@ export class PriceOverrideItemDto { @IsNotEmpty() colorId!: string; - @ApiProperty({ example: '单面印花' }) + @ApiProperty({ example: '单面印花', description: '印花数量维:单面印花 / 双面印花' }) + @IsString() + @IsNotEmpty() + printCount!: string; + + @ApiProperty({ example: '烫画', description: '工艺维:烫画 / 直喷 / 不打印' }) @IsString() @IsNotEmpty() craft!: string; @@ -250,7 +255,12 @@ export class PriceOverrideCellDto { @IsNotEmpty() colorId!: string; - @ApiProperty({ example: '单面印花' }) + @ApiProperty({ example: '单面印花', description: '印花数量维:单面印花 / 双面印花' }) + @IsString() + @IsNotEmpty() + printCount!: string; + + @ApiProperty({ example: '烫画', description: '工艺维:烫画 / 直喷 / 不打印' }) @IsString() @IsNotEmpty() craft!: string; diff --git a/apps/api/src/product-families/family-recompute.service.spec.ts b/apps/api/src/product-families/family-recompute.service.spec.ts index 26aa4d5..159110c 100644 --- a/apps/api/src/product-families/family-recompute.service.spec.ts +++ b/apps/api/src/product-families/family-recompute.service.spec.ts @@ -14,8 +14,11 @@ describe('FamilyRecomputeService', () => { const stamp = Date.now(); const createdOriginGoodIds: bigint[] = []; const createdFamilyIds: bigint[] = []; + const createdTagIds: bigint[] = []; + const createdTagGroupIds: bigint[] = []; const mkOriginGood = async (over: { + goodName?: string; craftLabel?: string | null; logisticsLabel?: string | null; variants?: Array<{ @@ -34,7 +37,7 @@ describe('FamilyRecomputeService', () => { const og = await prisma.originGood.create({ data: { sdsGoodId: `recompute-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`, - goodName: `测试链接-${stamp}`, + goodName: over.goodName ?? `测试链接-${stamp}`, source: 'CUSTOM', craftLabel: over.craftLabel ?? null, logisticsLabel: over.logisticsLabel ?? null, @@ -102,6 +105,8 @@ describe('FamilyRecomputeService', () => { afterAll(async () => { await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } }); await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } }); + await prisma.tag.deleteMany({ where: { id: { in: createdTagIds } } }); + await prisma.tagGroup.deleteMany({ where: { id: { in: createdTagGroupIds } } }); await prisma.$disconnect(); }); @@ -136,10 +141,9 @@ describe('FamilyRecomputeService', () => { expect(after.stale).toBe(false); }); - it('价格矩阵:同格子取最低价并累积来源;缺失归因维度/停用变体不参与', async () => { + it('价格矩阵:五维格子取最低价并累积来源;维度取链接标签、名称回退;停用变体不参与', async () => { const a = await mkOriginGood({ - craftLabel: '单面印花', - logisticsLabel: '包邮', + goodName: '美国(包邮)测试A-PA1-单面印花', variants: [ { sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }, { sdsVariantId: 'v2', sku: 'A-XL-WHT', sizeId: 'size_XL', sizeName: 'XL', colorId: 'color_wht', colorName: '白色', price: 27 }, @@ -147,25 +151,22 @@ describe('FamilyRecomputeService', () => { ], }); const b = await mkOriginGood({ - craftLabel: '单面印花', - logisticsLabel: '包邮', // 同格子(不同仓库) + goodName: '美国(包邮)测试B-PB1-单面印花', // 同格子(不同仓库) variants: [ { sdsVariantId: 'v4', sku: 'B-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 24.5 }, { sdsVariantId: 'v5', sku: 'B-XXXL-BLK', sizeId: 'size_XXXL', sizeName: 'XXXL', colorId: 'color_blk', colorName: '黑色', price: 29 }, ], }); const d = await mkOriginGood({ - craftLabel: '双面印花', - logisticsLabel: '专线', + goodName: '美国(包邮)测试D-PD1-直喷双面', variants: [ { sdsVariantId: 'v6', sku: 'D-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 30 }, ], }); const e = await mkOriginGood({ - craftLabel: null, // 缺工艺 → 不参与矩阵 - logisticsLabel: '包邮', + goodName: '美国(不包邮)测试E-PE1-光板', // 无标签行 → 名称回退:不打印 + 单面印花默认 variants: [ - { sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 1 }, + { sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 22 }, ], }); const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.id] }); @@ -174,21 +175,64 @@ describe('FamilyRecomputeService', () => { const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); const matrix = after.priceMatrix as any; - const cell = matrix.rows.find((r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk' && r.craft === '单面印花' && r.logistics === '包邮'); + const cell = matrix.rows.find( + (r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk' + && r.printCount === '单面印花' && r.craft === '烫画' && r.logistics === '包邮', + ); expect(cell.price).toBe('24.5'); expect(cell.manual).toBe(false); expect(cell.sources).toHaveLength(2); // a.v1 + b.v4;停用 v3 排除 - expect(matrix.rows.find((r: any) => r.craft === '双面印花' && r.price === '30')).toBeTruthy(); - expect(matrix.rows.some((r: any) => Number(r.price) === 1)).toBe(false); // e 缺归因被排除 - expect(matrix.crafts.sort()).toEqual(['单面印花', '双面印花']); - expect(matrix.logistics.sort()).toEqual(['专线', '包邮']); + // 直喷双面 → 印花数量/工艺两维正确拆分 + const dg = matrix.rows.find((r: any) => r.printCount === '双面印花' && r.craft === '直喷' && r.price === '30'); + expect(dg).toBeTruthy(); + // 光板(无标签)→ 工艺=不打印、印花数量回退单面、物流=不包邮 + const blank = matrix.rows.find((r: any) => r.craft === '不打印' && r.logistics === '不包邮'); + expect(blank).toBeTruthy(); + expect(blank.printCount).toBe('单面印花'); + expect(matrix.printCounts.sort()).toEqual(['单面印花', '双面印花']); + expect(matrix.crafts.sort()).toEqual(['不打印', '烫画', '直喷']); + expect(matrix.logistics.sort()).toEqual(['不包邮', '包邮']); expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']); }); + it('价格矩阵:人工接管标签优先于名称派生', async () => { + const a = await mkOriginGood({ + goodName: '美国(包邮)测试M-PM1-单面印花', // 名称派生是 单面/烫画/包邮 + variants: [ + { sdsVariantId: 'v1', sku: 'M-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }, + ], + }); + // 人工把该链接标签改成 双面印花/直喷/不包邮(手工造标签行,模拟 updateTags 结果) + const tagIds: bigint[] = []; + for (const groupName of ['印花数量', '印刷工艺', '物流渠道']) { + const group = await prisma.tagGroup.findFirst({ where: { groupName } }); + const g = group ?? (await prisma.tagGroup.create({ data: { groupName } })); + if (!group) createdTagGroupIds.push(g.id); + for (const tagName of groupName === '印花数量' ? ['双面印花'] : groupName === '印刷工艺' ? ['直喷'] : ['不包邮']) { + const existing = await prisma.tag.findFirst({ where: { tagName } }); + const t = existing ?? (await prisma.tag.create({ data: { tagName, tagGroupId: g.id } })); + if (!existing) createdTagIds.push(t.id); + tagIds.push(t.id); + } + } + await prisma.originGoodTag.createMany({ + data: tagIds.map((tagId) => ({ originGoodId: a.id, tagId, manual: true })), + }); + const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] }); + + await service.recomputeFamily(family.id); + + const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + const matrix = after.priceMatrix as any; + expect(matrix.rows).toHaveLength(1); + expect(matrix.rows[0].printCount).toBe('双面印花'); + expect(matrix.rows[0].craft).toBe('直喷'); + expect(matrix.rows[0].logistics).toBe('不包邮'); + }); + it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', async () => { const a = await mkOriginGood({ - craftLabel: '单面印花', - logisticsLabel: '包邮', + goodName: '美国(包邮)测试O-PO1-单面印花', variants: [ { sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }, ], @@ -196,8 +240,8 @@ describe('FamilyRecomputeService', () => { const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] }); await prisma.familyPriceOverride.createMany({ data: [ - { familyId: family.id, sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮', price: new Prisma.Decimal(23) }, - { familyId: family.id, sizeId: 'size_M', colorId: 'color_red', craft: '三面印花', logistics: '海运', price: new Prisma.Decimal(40) }, + { familyId: family.id, sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮', price: new Prisma.Decimal(23) }, + { familyId: family.id, sizeId: 'size_M', colorId: 'color_red', printCount: '四面印花', craft: '丝印', logistics: '海运', price: new Prisma.Decimal(40) }, ], }); @@ -205,14 +249,18 @@ describe('FamilyRecomputeService', () => { const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); const matrix = after.priceMatrix as any; - const hit = matrix.rows.find((r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk'); + const hit = matrix.rows.find( + (r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk' && r.printCount === '单面印花' && r.craft === '烫画' && r.logistics === '包邮', + ); expect(hit.price).toBe('23'); expect(hit.manual).toBe(true); - const added = matrix.rows.find((r: any) => r.sizeId === 'size_M' && r.craft === '三面印花'); + const added = matrix.rows.find((r: any) => r.sizeId === 'size_M' && r.craft === '丝印'); expect(added).toBeTruthy(); expect(added.manual).toBe(true); + expect(added.printCount).toBe('四面印花'); expect(added.sources).toEqual([]); - expect(matrix.crafts).toContain('三面印花'); + expect(matrix.crafts).toContain('丝印'); + expect(matrix.printCounts).toContain('四面印花'); expect(matrix.logistics).toContain('海运'); expect(matrix.sizes.some((s: any) => s.key === 'size_M')).toBe(true); }); diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts index 6c89464..488c70e 100644 --- a/apps/api/src/product-families/family-recompute.service.ts +++ b/apps/api/src/product-families/family-recompute.service.ts @@ -28,6 +28,9 @@ export interface PriceMatrixRow { sizeName: string | null; colorId: string; colorName: string | null; + /** 印花数量维:单面印花 / 双面印花 */ + printCount: string; + /** 工艺维:烫画 / 直喷 / 不打印 */ craft: string; logistics: string; price: string; @@ -38,6 +41,7 @@ export interface PriceMatrixRow { export interface PriceMatrix { sizes: Array<{ key: string; name: string | null }>; colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>; + printCounts: string[]; crafts: string[]; logistics: string[]; rows: PriceMatrixRow[]; @@ -55,10 +59,72 @@ export interface ChartLike { } type Member = Prisma.OriginGoodGetPayload<{ - include: { detail: true; variants: true }; + include: { + detail: true; + variants: true; + originGoodTags: { select: { tag: { select: { tagName: true } } } }; + }; }>; type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>; +/** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */ +const DIM_VALUES = { + printCount: ['单面印花', '双面印花'], + craft: ['烫画', '直喷', '不打印'], + logistics: ['包邮', '不包邮'], +} as const satisfies Record; + +type DimKey = keyof typeof DIM_VALUES; +export interface MatrixCombo { + printCount: string; + craft: string; + logistics: string; +} + +/** + * 成员在矩阵中的归因维度组合(纯函数),取值优先级: + * 1. 链接有效标签(人工接管后仍准确,仅认封闭词表内的标签); + * 2. CUSTOM 成员的管理员显式标签(craftLabel/logisticsLabel,自由文本); + * 3. 链接名称派生(deriveLinkTagNames); + * 4. 组内默认值(单面印花 / 烫画 / 包邮)。 + * 多值时取笛卡尔积 —— 一个链接理论上只属一个组合,此处只是容错。 + */ +export function memberMatrixCombos(input: { + goodName: string | null; + tagNames: string[]; + customLabels?: { craft?: string | null; logistics?: string | null }; +}): MatrixCombo[] { + const derived = deriveLinkTagNames(input.goodName); + const values = (dim: DimKey): string[] => { + const fromTags = DIM_VALUES[dim].filter((v) => input.tagNames.includes(v)); + if (fromTags.length) return [...fromTags]; + const label = + dim === 'craft' ? input.customLabels?.craft : input.customLabels?.logistics; + if (dim !== 'printCount' && label) return [label]; + const fromName = derived.filter((n) => + (DIM_VALUES[dim] as readonly string[]).includes(n), + ); + if (fromName.length) return fromName; + if (dim === 'printCount') { + const craftLabel = input.customLabels?.craft ?? ''; + return [craftLabel.includes('双面') ? '双面印花' : DIM_VALUES[dim][0]]; + } + return [DIM_VALUES[dim][0]]; + }; + const printCounts = values('printCount'); + const crafts = values('craft'); + const logistics = values('logistics'); + const combos: MatrixCombo[] = []; + for (const printCount of printCounts) { + for (const craft of crafts) { + for (const logistic of logistics) { + combos.push({ printCount, craft, logistics: logistic }); + } + } + } + return combos; +} + function chartRowKey(row: ChartSizeRow): string { return String(row.sizeName ?? row.sizeId ?? ''); } @@ -89,8 +155,9 @@ function firstColumns(charts: Array): unknown { } /** - * 推导五维价格矩阵:(sizeKey, colorKey, craft, logistics) → 价格。 - * 尺寸键 = variant.sizeId ?? variant.sizeName;颜色键 = variant.colorId ?? variant.colorName。 + * 推导五维价格矩阵:(sizeKey, colorKey, printCount, craft, logistics) → 价格。 + * 尺寸键 = variant.sizeId ?? variant.sizeName;颜色键 = variant.colorId ?? variant.colorName; + * 归因维度取成员链接标签(见 memberMatrixCombos)。 * 同格子多来源取最低价,sources 全保留;随后合并人工覆盖(manual=true)。 */ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]): PriceMatrix { @@ -98,65 +165,89 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]): const colors = new Map(); const cellMap = new Map(); - for (const member of members) { - if (!member.craftLabel || !member.logisticsLabel) continue; - for (const variant of member.variants) { - if (variant.price === null) continue; - const sizeKey = String(variant.sizeId ?? variant.sizeName ?? ''); - const colorKey = String(variant.colorId ?? variant.colorName ?? ''); - if (!sizeKey && !colorKey) continue; - if (sizeKey && !sizes.has(sizeKey)) sizes.set(sizeKey, variant.sizeName); - if (colorKey) { - const prev = colors.get(colorKey); - colors.set(colorKey, { - name: prev?.name ?? variant.colorName, - hex: prev?.hex ?? variant.colorHex, - imageUrl: prev?.imageUrl ?? variant.imageUrl, - }); - } - const key = `${sizeKey}|${colorKey}|${member.craftLabel}|${member.logisticsLabel}`; - const priceStr = variant.price.toString(); - const source: PriceMatrixSource = { - sdsGoodId: member.sdsGoodId, - sdsVariantId: variant.sdsVariantId, - price: priceStr, - }; - const existing = cellMap.get(key); - if (!existing) { - cellMap.set(key, { - sizeId: sizeKey, - sizeName: variant.sizeName, - colorId: colorKey, - colorName: variant.colorName, - craft: member.craftLabel, - logistics: member.logisticsLabel, + const memberCombos = members.map((member) => ({ + member, + combos: memberMatrixCombos({ + goodName: member.goodName, + tagNames: member.originGoodTags.map((r) => r.tag.tagName), + // CUSTOM 成员无同步标签,管理员显式填写的标签字段是其唯一归因来源 + customLabels: + member.source === 'CUSTOM' + ? { craft: member.craftLabel, logistics: member.logisticsLabel } + : undefined, + }), + })); + + const noteVariant = (variant: Member['variants'][number]) => { + const sizeKey = String(variant.sizeId ?? variant.sizeName ?? ''); + const colorKey = String(variant.colorId ?? variant.colorName ?? ''); + if (sizeKey && !sizes.has(sizeKey)) sizes.set(sizeKey, variant.sizeName); + if (colorKey) { + const prev = colors.get(colorKey); + colors.set(colorKey, { + name: prev?.name ?? variant.colorName, + hex: prev?.hex ?? variant.colorHex, + imageUrl: prev?.imageUrl ?? variant.imageUrl, + }); + } + return { sizeKey, colorKey }; + }; + + for (const { member, combos } of memberCombos) { + for (const combo of combos) { + for (const variant of member.variants) { + if (variant.price === null) continue; + const { sizeKey, colorKey } = noteVariant(variant); + if (!sizeKey && !colorKey) continue; + const key = `${sizeKey}|${colorKey}|${combo.printCount}|${combo.craft}|${combo.logistics}`; + const priceStr = variant.price.toString(); + const source: PriceMatrixSource = { + sdsGoodId: member.sdsGoodId, + sdsVariantId: variant.sdsVariantId, price: priceStr, - manual: false, - sources: [source], - }); - } else { - existing.sources.push(source); - if (Number(variant.price) < Number(existing.price)) existing.price = priceStr; + }; + const existing = cellMap.get(key); + if (!existing) { + cellMap.set(key, { + sizeId: sizeKey, + sizeName: variant.sizeName, + colorId: colorKey, + colorName: variant.colorName, + printCount: combo.printCount, + craft: combo.craft, + logistics: combo.logistics, + price: priceStr, + manual: false, + sources: [source], + }); + } else { + existing.sources.push(source); + if (Number(variant.price) < Number(existing.price)) existing.price = priceStr; + } } } } - const crafts = [...new Set(members.map((m) => m.craftLabel).filter((c): c is string => !!c))]; + const printCounts = [ + ...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.printCount))), + ]; + const crafts = [...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.craft)))]; const logisticsOptions = [ - ...new Set(members.map((m) => m.logisticsLabel).filter((l): l is string => !!l)), + ...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.logistics))), ]; const rows = [...cellMap.values()]; // 人工覆盖:命中改价,未命中新增行(人工补组合),选项并入覆盖用到的取值 for (const override of overrides) { + if (!printCounts.includes(override.printCount)) printCounts.push(override.printCount); if (!crafts.includes(override.craft)) crafts.push(override.craft); if (!logisticsOptions.includes(override.logistics)) logisticsOptions.push(override.logistics); if (override.sizeId && !sizes.has(override.sizeId)) sizes.set(override.sizeId, null); if (override.colorId && !colors.has(override.colorId)) { colors.set(override.colorId, { name: null, hex: null, imageUrl: null }); } - const key = `${override.sizeId}|${override.colorId}|${override.craft}|${override.logistics}`; + const key = `${override.sizeId}|${override.colorId}|${override.printCount}|${override.craft}|${override.logistics}`; const row = cellMap.get(key); if (row) { row.price = override.price.toString(); @@ -167,6 +258,7 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]): sizeName: sizes.get(override.sizeId) ?? null, colorId: override.colorId, colorName: colors.get(override.colorId)?.name ?? null, + printCount: override.printCount, craft: override.craft, logistics: override.logistics, price: override.price.toString(), @@ -181,6 +273,7 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]): return { sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })), colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })), + printCounts, crafts, logistics: logisticsOptions, rows, @@ -243,6 +336,7 @@ export class FamilyRecomputeService { include: { detail: true, variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } }, + originGoodTags: { select: { tag: { select: { tagName: true } } } }, }, }, priceOverrides: true, 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 fbc560f..31a82f6 100644 --- a/apps/api/src/product-families/product-families.service.spec.ts +++ b/apps/api/src/product-families/product-families.service.spec.ts @@ -239,12 +239,12 @@ describe('ProductFamiliesService', () => { await expect( service.putPriceOverrides(fid, [ - { sizeId: 'size_S', colorId: 'color_blk', craft: '不存在的工艺', logistics: '包邮', price: 1 }, + { sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '不存在的工艺', logistics: '包邮', price: 1 }, ]), ).rejects.toThrow(BadRequestException); const result = (await service.putPriceOverrides(fid, [ - { sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮', price: 23, note: '促销' }, + { sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮', price: 23, note: '促销' }, ])) as any; expect(result.items).toHaveLength(1); expect(result.items[0].derivedPrice).toBe('25'); @@ -256,7 +256,7 @@ describe('ProductFamiliesService', () => { expect(row.manual).toBe(true); const restored = (await service.deletePriceOverrides(fid, { - cells: [{ sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮' }], + cells: [{ sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮' }], })) as any; expect(restored.items).toHaveLength(0); const after2 = await prisma.productFamily.findUniqueOrThrow({ where: { id: fid } }); diff --git a/apps/api/src/product-families/product-families.service.ts b/apps/api/src/product-families/product-families.service.ts index 2b1f445..8708eba 100644 --- a/apps/api/src/product-families/product-families.service.ts +++ b/apps/api/src/product-families/product-families.service.ts @@ -344,6 +344,7 @@ export class ProductFamiliesService { (r) => r.sizeId === o.sizeId && r.colorId === o.colorId && + r.printCount === o.printCount && r.craft === o.craft && r.logistics === o.logistics, ); @@ -376,6 +377,7 @@ export class ProductFamiliesService { const allowed = { sizes: new Set((matrix?.sizes ?? []).map((s) => s.key)), colors: new Set((matrix?.colors ?? []).map((c) => c.key)), + printCounts: new Set(matrix?.printCounts ?? []), crafts: new Set(matrix?.crafts ?? []), logistics: new Set(matrix?.logistics ?? []), }; @@ -383,6 +385,7 @@ export class ProductFamiliesService { (i) => !allowed.sizes.has(i.sizeId) || !allowed.colors.has(i.colorId) || + !allowed.printCounts.has(i.printCount) || !allowed.crafts.has(i.craft) || !allowed.logistics.has(i.logistics), ); @@ -392,6 +395,7 @@ export class ProductFamiliesService { invalidCells: invalid.map((i) => ({ sizeId: i.sizeId, colorId: i.colorId, + printCount: i.printCount, craft: i.craft, logistics: i.logistics, })), @@ -401,10 +405,11 @@ export class ProductFamiliesService { for (const item of items) { await this.prisma.familyPriceOverride.upsert({ where: { - familyId_sizeId_colorId_craft_logistics: { + familyId_sizeId_colorId_printCount_craft_logistics: { familyId: id, sizeId: item.sizeId, colorId: item.colorId, + printCount: item.printCount, craft: item.craft, logistics: item.logistics, }, @@ -413,6 +418,7 @@ export class ProductFamiliesService { familyId: id, sizeId: item.sizeId, colorId: item.colorId, + printCount: item.printCount, craft: item.craft, logistics: item.logistics, price: new Prisma.Decimal(item.price), @@ -435,6 +441,7 @@ export class ProductFamiliesService { familyId: id, sizeId: cell.sizeId, colorId: cell.colorId, + printCount: cell.printCount, craft: cell.craft, logistics: cell.logistics, }, diff --git a/apps/api/src/public/dto/public-good-detail.dto.ts b/apps/api/src/public/dto/public-good-detail.dto.ts index b9c764e..2a25840 100644 --- a/apps/api/src/public/dto/public-good-detail.dto.ts +++ b/apps/api/src/public/dto/public-good-detail.dto.ts @@ -65,7 +65,7 @@ export class PublicGoodDetailDto extends PublicGoodDto { required: false, nullable: true, type: Object, - description: '产品族块:并集尺码表/包装规则 + 五维价格矩阵(尺码×颜色×工艺×物流)', + description: '产品族块:并集尺码表/包装规则 + 五维价格矩阵(尺码×颜色×印花数量×工艺×物流)', }) family?: { familyId: string; @@ -73,6 +73,7 @@ export class PublicGoodDetailDto extends PublicGoodDto { familyName: string; sizes: Array<{ key: string; name: string | null }>; colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>; + printCounts: string[]; crafts: string[]; logistics: string[]; sizeChart: Record | null; diff --git a/apps/api/src/public/public-family-block.spec.ts b/apps/api/src/public/public-family-block.spec.ts index 1c02eac..83796ba 100644 --- a/apps/api/src/public/public-family-block.spec.ts +++ b/apps/api/src/public/public-family-block.spec.ts @@ -94,8 +94,8 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => { it('默认(未设开关):输出族块', async () => { delete process.env.PUBLIC_DETAIL_FROM_FAMILY - const detail = await service.getGood(sdsGoodId); - expect(detail.goodId).toBe(sdsGoodId); + const detail = await service.getGood(familyId.toString()); + expect(detail.goodId).toBe(familyId.toString()); expect(detail.family).toBeTruthy(); expect(detail.family!.familyCode).toBe(`PF${stamp}`); expect(detail.family!.minPrice).toBe('25'); @@ -105,12 +105,12 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => { it('显式关闭(false):响应完全不含 family 键(应急回退)', async () => { process.env.PUBLIC_DETAIL_FROM_FAMILY = 'false'; - const detail = await service.getGood(sdsGoodId); - expect(detail.goodId).toBe(sdsGoodId); + const detail = await service.getGood(familyId.toString()); + expect(detail.goodId).toBe(familyId.toString()); expect('family' in detail).toBe(false); }); - it('族变体并集:任何族成员链接的 sdsGoodId 均命中同一商品且变体含全体成员', async () => { + it('族变体并集:族ID 命中商品且变体含全体成员;成员 sdsGoodId 不再可寻址', async () => { process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true'; // 再加一个同族成员(不同仓库段) const og2 = await prisma.originGood.create({ @@ -136,18 +136,20 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => { }, }); - // 用成员链接(非主链接)的 sdsGoodId 访问 → 命中同一商品(响应 goodId 仍为主链接) - const detail = await service.getGood(`pubfam-m-${stamp}`); - expect(detail.goodId).toBe(sdsGoodId); + // 族 ID 是唯一公开键:成员变体并入同一详情 + const detail = await service.getGood(familyId.toString()); + expect(detail.goodId).toBe(familyId.toString()); const skus = detail.variants.map((v) => v.sku); expect(skus).toContain(`PF-${stamp}-S`); expect(skus).toContain(`PF-${stamp}-M`); // 族成员变体并集 + // 成员链接的 sdsGoodId 不再可寻址 + await expect(service.getGood(`pubfam-m-${stamp}`)).rejects.toThrow(); // 清理:把成员移出族避免影响其他用例 await prisma.originGoodVariant.deleteMany({ where: { originGoodId: og2.id } }); await prisma.originGood.update({ where: { id: og2.id }, data: { familyId: null } }); }); - it('无族商品:开关开启也不含 family 键', async () => { + it('无族商品:公开端点不可见(列表不含 / 详情 404)', async () => { process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true'; const og2 = await prisma.originGood.create({ data: { @@ -166,8 +168,13 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => { }, }); createdGoodIds.push(good2.id); - const detail = await service.getGood(`pubfam-2-${stamp}`); - expect('family' in detail).toBe(false); + const list = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: createdCountryIds[0].toString(), + }); + expect(list.items.map((i) => i.goodName)).not.toContain(`无族商品-${stamp}`); + await expect(service.getGood(`pubfam-2-${stamp}`)).rejects.toThrow(); }); }); diff --git a/apps/api/src/public/public.controller.ts b/apps/api/src/public/public.controller.ts index 8487ffe..c7e6423 100644 --- a/apps/api/src/public/public.controller.ts +++ b/apps/api/src/public/public.controller.ts @@ -13,50 +13,50 @@ import { ApiTags, getSchemaPath, } from '@nestjs/swagger'; -import { PublicService } from './public.service'; +import { PublicService } from './public.service'; import { PublicCountryQueryDto, PublicHomeGoodsQueryDto, PublicQueryGoodDto, PublicTagFilterDto, } from './dto/public-query-good.dto'; -import { PublicTagDto } from './dto/public-tag.dto'; +import { PublicTagDto } from './dto/public-tag.dto'; import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto'; import { PublicGoodDto } from './dto/public-good.dto'; - + @ApiTags('public') @ApiExtraModels(PublicTagFilterDto) @Controller('public') -export class PublicController { - constructor(private readonly service: PublicService) {} - +export class PublicController { + constructor(private readonly service: PublicService) {} + @Get('categories') @ApiOperation({ summary: '获取商品分类树;countryId 不传时返回全部国家' }) getCategories(@Query() query: PublicCountryQueryDto) { return this.service.getCategoriesTree(query.countryId); - } - - @Get('countries') - @ApiOperation({ summary: 'Public list of countries that have goods' }) - getCountries() { - return this.service.getCountries(); - } - - @Get('tags') - @ApiOperation({ summary: 'Public list of tags that have goods' }) - getTags(): Promise { - return this.service.getTags(); - } - + } + + @Get('countries') + @ApiOperation({ summary: 'Public list of countries that have goods' }) + getCountries() { + return this.service.getCountries(); + } + + @Get('tags') + @ApiOperation({ summary: 'Public list of tags that have goods' }) + getTags(): Promise { + return this.service.getTags(); + } + @Get('tag-groups') @ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' }) @ApiOkResponse({ type: [PublicTagGroupFilterDto] }) getTagGroups(@Query() query: PublicCountryQueryDto): Promise { return this.service.getTagGroups(query.countryId); - } - + } + @Get('goods') - @ApiOperation({ summary: '分页获取商品' }) + @ApiOperation({ summary: '分页获取商品(族化契约:一族一条,goodId=族ID;无族商品不返回)' }) @ApiQuery({ name: 'tags', required: false, @@ -80,8 +80,8 @@ export class PublicController { } @Get('goods/:goodId') - @ApiOperation({ summary: '获取商品完整详情' }) - @ApiParam({ name: 'goodId', type: String, example: '168746' }) + @ApiOperation({ summary: '获取商品完整详情(goodId = 族 ID)' }) + @ApiParam({ name: 'goodId', type: String, example: '758', description: '产品族 ID' }) @ApiOkResponse({ type: PublicGoodDetailDto }) getGood(@Param('goodId') goodId: string): Promise { return this.service.getGood(goodId); diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index 5099ed9..535d528 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -1,6 +1,7 @@ import { Test } from '@nestjs/testing'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { PublicService } from './public.service'; +import { FamilyRecomputeService } from '../product-families/family-recompute.service'; import { PrismaService } from '../prisma/prisma.service'; describe('PublicService', () => { @@ -15,6 +16,7 @@ describe('PublicService', () => { let filterGroupIds: bigint[] = []; let filterTagIds: bigint[] = []; let originGoodId: bigint; + let familyId: bigint; let goodIds: bigint[] = []; beforeAll(async () => { @@ -57,6 +59,16 @@ describe('PublicService', () => { }); originGoodId = og.id; + // 公开契约族化:商品必须挂族才对外可见 + const family = await prisma.productFamily.create({ + data: { familyName: `Pub Family ${stamp}`, primaryOriginGoodId: og.id }, + }); + familyId = family.id; + await prisma.originGood.update({ + where: { id: og.id }, + data: { familyId: family.id }, + }); + // Seed 3 goods: // high priority + position.indexVal=1 // mid priority + position.indexVal=5 @@ -72,6 +84,7 @@ describe('PublicService', () => { data: { goodName: `Pub High ${stamp}`, originGoodId, + familyId: family.id, countryId, categoryId, goodPriority: 10, @@ -82,6 +95,7 @@ describe('PublicService', () => { data: { goodName: `Pub Mid ${stamp}`, originGoodId, + familyId: family.id, countryId, categoryId, goodPriority: 5, @@ -92,6 +106,7 @@ describe('PublicService', () => { data: { goodName: `Pub NoPos ${stamp}`, originGoodId, + familyId: family.id, countryId, categoryId, tagId, @@ -143,6 +158,8 @@ describe('PublicService', () => { price: 38, }, }); + // 物化族矩阵(公开详情 family 块依赖 priceMatrix 已重算) + await new FamilyRecomputeService(prisma).recomputeFamily(family.id); // Seed a good in `otherCategory` so the "onlyHaveGoods" filter // returns more than one category. @@ -150,6 +167,7 @@ describe('PublicService', () => { data: { goodName: `Pub Other ${stamp}`, originGoodId, + familyId: family.id, countryId, categoryId: otherCategoryId, goodPriority: 1, @@ -162,6 +180,7 @@ describe('PublicService', () => { data: { goodName: `Pub ChildGood ${stamp}`, originGoodId, + familyId: family.id, countryId, categoryId: childCategoryId, goodPriority: 0, @@ -176,6 +195,8 @@ describe('PublicService', () => { await prisma.good.deleteMany({ where: { goodName: { contains: `Pub ` } }, }); + // Good.familyId / OriginGood.familyId 均为 SetNull,先删商品再删族 + await prisma.productFamily.deleteMany({ where: { id: familyId } }); await prisma.position.deleteMany({ where: { countryId }, }); @@ -224,7 +245,7 @@ describe('PublicService', () => { categoryId: categoryId.toString(), // includes child keyword: `Pub `, }); - expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood + expect(filtered.total).toBe(1); // 族化后:同族 4 条在售 Good(High/Mid/NoPos/Child)= 1 个款 expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true); }); @@ -254,9 +275,8 @@ describe('PublicService', () => { }, ], }); - expect(sameGroup.items.map((item) => item.goodName)).toEqual( - expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]), - ); + // 族化后命中族内多条 Good 仍只出代表行(High 优先级最高) + expect(sameGroup.items.map((item) => item.goodName)).toEqual([`Pub High ${stamp}`]); const acrossGroups = await service.getGoods({ page: 1, @@ -293,20 +313,22 @@ describe('PublicService', () => { ).rejects.toBeInstanceOf(BadRequestException); }); - it('returns the SDS product id as the public product id', async () => { + it('returns the family id as the public product id (一族多条 Good 只出一条)', async () => { const result = await service.getGoods({ page: 1, - pageSize: 1, + pageSize: 50, countryId: countryId.toString(), - keyword: `Pub High ${stamp}`, + keyword: `Pub `, }); + // 该族下 5 条 Good(High/Mid/NoPos/Other/Child)→ 列表仅 1 条,goodId=族ID expect(result.items).toHaveLength(1); - expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`); + expect(result.items[0].goodId).toBe(familyId.toString()); expect(result.items[0].goodId).not.toBe(goodIds[0].toString()); + expect(result.items[0].goodName).toBe(`Pub High ${stamp}`); // 代表行 = 排序第一条 }); - it('returns custom goods through the same public product contract', async () => { + it('custom goods (无族) are not visible on public endpoints', async () => { const customPublicId = `custom-public-${stamp}`; const origin = await prisma.originGood.create({ data: { @@ -326,17 +348,22 @@ describe('PublicService', () => { }, }); try { - const detail = await service.getGood(customPublicId); - expect(detail.goodId).toBe(customPublicId); - expect(detail.goodName).toBe(`Pub Custom ${stamp}`); - expect(detail.productCode).toBe(`CUSTOM-${stamp}`); + const list = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + keyword: `Pub Custom`, + }); + expect(list.items).toHaveLength(0); + // sdsGoodId 不再是公开寻址键:非数字直接 404 + await expect(service.getGood(customPublicId)).rejects.toBeInstanceOf(NotFoundException); } finally { await prisma.good.delete({ where: { id: good.id } }); await prisma.originGood.delete({ where: { id: origin.id } }); } }); - it('getGood returns detail and 404 for unknown id', async () => { + it('getGood returns family detail by family id and 404 for unknown id', async () => { const first = await service.getGoods({ page: 1, pageSize: 1, @@ -344,14 +371,17 @@ describe('PublicService', () => { keyword: `Pub `, }); expect(first.items.length).toBe(1); - const detail = await service.getGood(`pub-sds-${stamp}`); + const detail = await service.getGood(familyId.toString()); expect(detail.goodId).toBe(first.items[0].goodId); expect(detail.productCode).toBe('OZ10827003'); expect(detail.details.productionProcess).toBe('白墨烫画'); expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1); expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1); expect(detail.variants).toHaveLength(1); + expect(detail.family?.familyId).toBe(familyId.toString()); + // 旧 sdsGoodId 寻址不再可达(族 ID 是唯一公开键) + await expect(service.getGood(`pub-sds-${stamp}`)).rejects.toBeInstanceOf(NotFoundException); await expect(service.getGood('99999999')).rejects.toBeInstanceOf( NotFoundException, ); @@ -382,7 +412,7 @@ describe('PublicService', () => { }); describe('merged secondary origin goods', () => { - it('resolves a good by secondary sdsGoodId with merged variants', async () => { + it('family members union variants and media (secondary link joins the family)', async () => { const secondary = await prisma.originGood.create({ data: { sdsGoodId: `pub-secondary-${stamp}`, goodName: `Pub Secondary ${stamp}` }, }); @@ -396,20 +426,24 @@ describe('PublicService', () => { imageUrl: 'http://img/black-sec', }, }); - // Attach as secondary source of the highest-priority fixture good. - await prisma.goodOriginGood.create({ - data: { goodId: goodIds[0], originGoodId: secondary.id }, + // 副链归入主 fixture 的族(族机制替代旧 good_origin_goods 关联) + await prisma.originGood.update({ + where: { id: secondary.id }, + data: { familyId }, }); try { - const detail = await service.getGood(`pub-secondary-${stamp}`); - expect(detail.goodId).toBe(`pub-sds-${stamp}`); // 对外 goodId 仍是主源 + const detail = await service.getGood(familyId.toString()); + expect(detail.goodId).toBe(familyId.toString()); // 对外 goodId 恒为族ID expect(detail.variants.length).toBeGreaterThanOrEqual(2); const black = detail.mediaByColor.find((g) => g.colorName === '黑色'); expect(black).toBeTruthy(); expect(black!.images).toContain('http://img/black-sec'); + // sdsGoodId 不是公开键:副链 ID 无法寻址 + await expect(service.getGood(`pub-secondary-${stamp}`)).rejects.toBeInstanceOf( + NotFoundException, + ); } finally { - await prisma.goodOriginGood.deleteMany({ where: { originGoodId: secondary.id } }); await prisma.originGoodVariant.delete({ where: { id: secVariant.id } }).catch(() => undefined); await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined); } @@ -456,20 +490,26 @@ describe('PublicService', () => { media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i9', url: 'http://img/sec-x', sortOrder: 0 }], primaryImageUrl: 'http://img/pri-a' }, }, }); + // 主副链同族 + 一条官网 Good(族化契约:Good 挂族才公开) + const mergeFamily = await prisma.productFamily.create({ + data: { familyName: `Pub Merge Family ${stamp2}`, primaryOriginGoodId: primaryOg.id }, + }); + await prisma.originGood.updateMany({ + where: { id: { in: [primaryOg.id, secondaryOg.id] } }, + data: { familyId: mergeFamily.id }, + }); const mergedGood = await prisma.good.create({ data: { goodName: `Pub Merged ${stamp2}`, originGoodId: primaryOg.id, + familyId: mergeFamily.id, countryId, categoryId, }, }); - await prisma.goodOriginGood.create({ - data: { goodId: mergedGood.id, originGoodId: secondaryOg.id }, - }); try { - const detail = await service.getGood(`pub-pri-${stamp2}`); + const detail = await service.getGood(mergeFamily.id.toString()); // Variants: 3 unique color+size combos (case-insensitive); duplicate // Black|S from the secondary deduped. expect( @@ -496,8 +536,8 @@ describe('PublicService', () => { ]); expect(media.primaryImageUrl).toBe('http://img/pri-a'); } finally { - await prisma.goodOriginGood.deleteMany({ where: { goodId: mergedGood.id } }); await prisma.good.delete({ where: { id: mergedGood.id } }); + await prisma.productFamily.delete({ where: { id: mergeFamily.id } }); await prisma.originGoodVariant.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } }); await prisma.originGoodDetail.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } }); await prisma.originGood.delete({ where: { id: primaryOg.id } }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 2aac1de..9c6a3c6 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -66,6 +66,7 @@ export class PublicService { async getCategoriesTree(countryId?: string): Promise { const goodsWhere: Prisma.GoodWhereInput = { + familyId: { not: null }, originGood: { delisted: false }, ...(countryId ? { countryId: BigInt(countryId) } : {}), }; @@ -109,7 +110,7 @@ export class PublicService { async getCountries(): Promise { const rows = await this.prisma.country.findMany({ - where: { goods: { some: { originGood: { delisted: false } } } }, + where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } }, orderBy: { id: 'asc' }, }); return rows.map(PublicCountryDto.from); @@ -117,7 +118,11 @@ export class PublicService { async getTags(): Promise { const rows = await this.prisma.tag.findMany({ - where: { goodTags: { some: { good: { originGood: { delisted: false } } } } }, + where: { + goodTags: { + some: { good: { familyId: { not: null }, originGood: { delisted: false } } }, + }, + }, orderBy: [ { tagGroup: { sortOrder: 'asc' } }, { sortOrder: 'asc' }, @@ -130,6 +135,7 @@ export class PublicService { async getTagGroups(countryId?: string): Promise { const goodWhere: Prisma.GoodWhereInput = { + familyId: { not: null }, originGood: { delisted: false }, ...(countryId ? { countryId: BigInt(countryId) } : {}), }; @@ -160,7 +166,11 @@ export class PublicService { } async getGoods(query: PublicQueryGoodDto): Promise { - const where: Prisma.GoodWhereInput = { originGood: { delisted: false } }; + // 无族商品(自定义)不进公开列表:只认族 + const where: Prisma.GoodWhereInput = { + familyId: { not: null }, + originGood: { delisted: false }, + }; if (query.countryId) where.countryId = BigInt(query.countryId); if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' }; if (query.categoryId) { @@ -199,65 +209,107 @@ export class PublicService { { id: 'asc' }, ]; - const [total, rows] = await this.prisma.$transaction([ - this.prisma.good.count({ where }), - this.prisma.good.findMany({ - where, - include: PUBLIC_GOOD_INCLUDE, - orderBy, - skip: (query.page - 1) * query.pageSize, - take: query.pageSize, - }), - ]); + // 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID); + // 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配 + // 再内存分组、分页作用于分组结果 —— 若量级上万需改为物化族表查询。 + const rows = await this.prisma.good.findMany({ + where, + include: PUBLIC_GOOD_INCLUDE, + orderBy, + }); + const grouped = new Map(); + for (const good of rows) { + const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`; + const bucket = grouped.get(key); + if (bucket) bucket.push(good); + else grouped.set(key, [good]); + } + let items = [...grouped.values()].map((goods) => { + const rep = goods[0]; + const dto = this.toPublicGood(rep); + // 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价 + const familyMin = this.familyMinPrice(rep); + if (rep.familyId && familyMin !== null) dto.price = familyMin; + return dto; + }); + if (query.sort === 'PRICE_ASC' || query.sort === 'PRICE_DESC') { + // 分组后的最终价(族最低价)重排,null 价沉底 + const num = (v: string | null) => (v === null ? Number.POSITIVE_INFINITY : Number(v)); + items = items.sort((a, b) => + query.sort === 'PRICE_ASC' ? num(a.price) - num(b.price) : num(b.price) - num(a.price), + ); + } + const total = items.length; + const start = (query.page - 1) * query.pageSize; return { - items: rows.map((good) => this.toPublicGood(good)), + items: items.slice(start, start + query.pageSize), total, page: query.page, pageSize: query.pageSize, }; } + /** 族物化矩阵的最低价;无族/无矩阵返回 null */ + private familyMinPrice(good: PublicGoodRow): string | null { + const matrix = good.originGood.family?.priceMatrix as + | { rows?: Array<{ price: string }> } + | null + | undefined; + const prices = (matrix?.rows ?? []).map((r) => Number(r.price)).filter((n) => Number.isFinite(n)); + return prices.length ? String(Math.min(...prices)) : null; + } + + /** + * 详情寻址契约(族化后):goodId 即族 ID;无族商品(自定义)不对外暴露。 + * 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 404。 + */ async getGood(goodId: string): Promise { - const good = await this.prisma.good.findFirst({ - where: { - OR: [ - { originGood: { sdsGoodId: goodId, delisted: false } }, - // 族内任何成员链接均可命中同一商品(替代旧副源关联的可达性语义) - { family: { originGoods: { some: { sdsGoodId: goodId, delisted: false } } } }, - // 历史副源关联(good_origin_goods)只读保留,仍可命中 - { - mergedOriginGoods: { - some: { originGood: { sdsGoodId: goodId, delisted: false } }, - }, - }, - ], + const notFound = () => + new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' }); + if (!/^\d+$/.test(goodId)) throw notFound(); + const detail = await this.getGoodByFamilyId(BigInt(goodId)); + if (!detail) throw notFound(); + return detail; + } + + /** 族视角详情:代表 Good 提供公共字段(名称/主图/国家/分类),变体取全体成员并集 */ + private async getGoodByFamilyId(familyId: bigint): Promise { + const [family, goods] = await Promise.all([ + this.prisma.productFamily.findUnique({ where: { id: familyId } }), + this.prisma.good.findMany({ + where: { familyId, originGood: { delisted: false } }, + include: PUBLIC_GOOD_INCLUDE, + orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }, { id: 'asc' }], + }), + ]); + // 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 走回退路径 + if (!family || goods.length === 0) return null; + const rep = goods[0]; + + const members = await this.prisma.originGood.findMany({ + where: { familyId, delisted: false }, + orderBy: { id: 'asc' }, + select: { + id: true, + detail: true, + variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, }, - include: PUBLIC_GOOD_INCLUDE, - orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }], }); - if (!good) { - throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' }); + const familyVariants = members.flatMap((m) => + m.variants.map((variant) => ({ originGoodId: m.id, variant })), + ); + const familyDetails = members + .filter((m) => m.id !== rep.originGoodId) + .map((m) => m.detail); + + const dto = this.toPublicGoodDetail(rep, familyVariants, familyDetails); + // 尺码表/包装规格以族物化并集为准(款级公共数据),空并集回退主链接合并结果 + if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'false') { + dto.sizeChart = (family.sizeChart as PublicGoodDetailDto['sizeChart']) ?? dto.sizeChart; + dto.packageSpecs = + (family.packageSpecs as PublicGoodDetailDto['packageSpecs']) ?? dto.packageSpecs; } - // 族机制(新):变体并集 = 主链接 ∪ 族成员 ∪ 旧副源(过渡期),按 (链接, 变体) 去重 - 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); + dto.category.categoryIcon = await this.resolveCategoryIcon(rep.category); return dto; } @@ -265,6 +317,7 @@ export class PublicService { const rows = await this.prisma.good.findMany({ where: { positionId: { not: null }, + familyId: { not: null }, originGood: { delisted: false }, ...(query.countryId ? { countryId: BigInt(query.countryId) } : {}), }, @@ -276,7 +329,19 @@ export class PublicService { ], take: query.limit, }); - return rows.map((good) => this.toPublicGood(good)); + // 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit + const seen = new Set(); + const items: PublicGoodDto[] = []; + for (const good of rows) { + const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`; + if (seen.has(key)) continue; + seen.add(key); + const dto = this.toPublicGood(good); + const familyMin = this.familyMinPrice(good); + if (good.familyId && familyMin !== null) dto.price = familyMin; + items.push(dto); + } + return items.slice(0, query.limit); } private toPublicGood(good: PublicGoodRow): PublicGoodDto { @@ -285,7 +350,8 @@ export class PublicService { ? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder } : null; return { - goodId: good.originGood.sdsGoodId, + // 有族 → 族ID(对外契约);无族(自定义商品)→ sdsGoodId + goodId: good.familyId ? good.familyId.toString() : good.originGood.sdsGoodId, goodName: good.goodName, goodPriority: good.goodPriority, country: { @@ -389,14 +455,17 @@ export class PublicService { originGoodId: bigint; variant: PublicGoodRow['originGood']['variants'][number]; }> = [], + /** 族成员 detail(款级公共规格并集来源之一;族视角详情传入,链接视角为空) */ + familyDetails: Array = [], ): PublicGoodDetailDto { const base = this.toPublicGood(good); const detail = good.originGood.detail; - // Detail specs filled from secondaries for sizes/options the primary - // does not have. - const secondaryDetails = good.mergedOriginGoods - .map((m) => m.originGood.detail) - .filter((d): d is NonNullable => Boolean(d)); + // Detail specs filled from secondaries (族成员 + 旧副源) for sizes/options + // the primary does not have. + const secondaryDetails = [ + ...familyDetails, + ...good.mergedOriginGoods.map((m) => m.originGood.detail), + ].filter((d): d is NonNullable => Boolean(d)); // 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期); // 同一链接可能既是族成员又挂旧副源,先按 `${originGoodId}:${sdsVariantId}` 去重, // 再按 color+size 去重(先到先得),避免站点出现重复的尺码×颜色行。 @@ -479,6 +548,7 @@ export class PublicService { const matrix = family.priceMatrix as { sizes: Array<{ key: string; name: string | null }>; colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>; + printCounts: string[]; crafts: string[]; logistics: string[]; rows: Array<{ price: string }>; @@ -491,6 +561,7 @@ export class PublicService { familyName: family.familyName, sizes: matrix.sizes, colors: matrix.colors, + printCounts: matrix.printCounts ?? [], crafts: matrix.crafts, logistics: matrix.logistics, sizeChart: (family.sizeChart as Record | null) ?? null, diff --git a/docs/references/product-center.md b/docs/references/product-center.md index c21a490..7aa51e4 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -100,31 +100,42 @@ pnpm --filter @inkreach/api backfill:product-families # → [1/2] 解析全部链接名 [2/2] 自动建族 [3/3] 全量族重算(幂等,可随时重跑) ``` -**公开读路径(三期,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。 -`GET /public/goods/:goodId` 行为: +**公开读路径(四期族化契约,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。 +公开端点**以款(族)为一等公民**: -- **变体并集(替代旧副源机制)**:`variants` = 主链接 ∪ 全体族成员 ∪ 旧副源(过渡期), - 按 `(链接, 变体)` 去重;`mediaByColor` 同源。任何族成员链接的 sdsGoodId 均命中同一商品。 -- **族块**(默认输出):`family` 含并集尺码表/包装 + 五维价格矩阵 + 族起价: +- `GET /public/goods`:**一族一条**(族内多条 Good 去重,代表行=排序第一条), + `goodId` = **族 ID**,`total` = 族数,分页作用于分组后;`price` = 族矩阵最低价(起价), + 价格排序按族起价重排;**无族商品(自定义)完全不返回**(列表/首页/分类树/标签统计同理)。 +- `GET /public/goods/:goodId`:`goodId` 即**族 ID**(唯一公开寻址键;旧 SDS 链接 ID 与 + 自定义商品 sdsGoodId 均 404)。响应为款级聚合: + + - 公共信息取代表 Good(名称/主图/国家/分类/标签)+ 主链接(详情文案); + - `variants` = 全体族成员 ∪ 旧副源(过渡期),按 `(链接, 变体)` 再按 `颜色+尺码` 去重; + `mediaByColor` 同源;`options/media` 并入族成员 detail; + - `sizeChart` / `packageSpecs` = 族物化并集(空并集回退主链接合并结果); + - **族块**(默认输出):`family` 含并集尺码表/包装 + **严格五维价格矩阵** + 族起价: ```jsonc { "family": { "familyId": "12", "familyCode": "DG015", "familyName": "DG015 180G纯棉T恤", - "sizes": [...], "colors": [...], "crafts": ["单面印花", ...], "logistics": ["包邮", ...], + "sizes": [...], "colors": [...], + "printCounts": ["单面印花", "双面印花"], "crafts": ["烫画", "直喷", "不打印"], "logistics": ["包邮", "不包邮"], "sizeChart": { /* 并集 */ }, "packageSpecs": { /* 并集 */ }, - "priceMatrix": { /* 五维矩阵:rows 为 {sizeId, colorId, craft, logistics, price, manual, sources} */ }, + "priceMatrix": { /* rows 为 {sizeId, colorId, printCount, craft, logistics, price, manual, sources} */ }, "minPrice": "29.5" } } ``` -实测(DG015 族):4 工艺 × 2 物流 × 8 尺码 × 12 颜色 = 653 格矩阵,族起价 ¥29.5 -(主源单链接价为 ¥68.74——族视角展示了光板/单面的更低档价格)。前端本地按五维联动 -`priceMatrix` 即可实时算价,无需新增查价端点(设计 D4)。 +矩阵维度来源是**链接级标签**(印花数量/印刷工艺/物流渠道三组;人工接管后按人工标签), +不再用原始链接名的第 3 段(`craftLabel` 含「直喷单面/光板不打印」等噪声)。名称派生规则已 +覆盖裸「单面/双面」写法(如「直喷双面」→ 双面印花 + 直喷)。光板/不打印链接无印花面概念, +矩阵中印花数量维回退「单面印花」占位。前端本地按五维联动 `priceMatrix` 即可实时算价 +(设计 D4)。 -**边界声明**:商品列表/筛选/排序仍基于主源 `goodPrice`(SQL 层无法廉价解析族矩阵 JSON, -且避免展示价与筛选价不一致);`good_origin_goods` 转只读保留,观察期后另行删除。 +**边界声明**:价格区间筛选(minPrice/maxPrice)仍作用于链接 `goodPrice`(族内任一链接命中即 +返回该族;SQL 层无法廉价解析族矩阵 JSON);`good_origin_goods` 转只读保留,观察期后另行删除。 **后台操作入口(族替代旧主源/副源,界面保持原有布局)**: diff --git a/docs/references/structs.md b/docs/references/structs.md index 870ecb7..63c7e1b 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -113,8 +113,8 @@ apps/api/ | `/public/countries` `GET` | 公开国家列表(仅含已挂商品的国家) | 公开 | | `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 | | `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 | -| `/public/goods` `GET` | 分页商品(支持 `countryId/categoryId/tagIds(逗号分隔)/keyword/page/pageSize`,`tagIds` 为 AND 关系) | 公开 | -| `/public/goods/:id` `GET` | 商品详情;默认输出 `family` 块(并集尺码表/包装 + 五维价格矩阵 + 族起价),`variants` = 主链接 ∪ 族成员 ∪ 旧副源(去重);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退旧行为 | 公开 | +| `/public/goods` `GET` | 分页商品(**族化契约:一族一条**,`goodId`=族ID,`price`=族起价;无族商品不返回;支持 `countryId/categoryId/tags(JSON)/keyword/minPrice/maxPrice/sort/page/pageSize`) | 公开 | +| `/public/goods/:id` `GET` | 款级详情,`:id` = **族 ID**(唯一公开键,SDS 链接 ID 404);公共字段取代表 Good,`variants` = 全体族成员 ∪ 旧副源(去重),`sizeChart/packageSpecs` = 族并集;默认输出 `family` 块(并集尺码表/包装 + 严格五维价格矩阵 尺码×颜色×印花数量×工艺×物流 + 族起价);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退 | 公开 | | `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT | | `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT | | `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT | @@ -127,7 +127,7 @@ apps/api/ | `/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 | +| `/product-families/:id/price-overrides` `GET/PUT/DELETE` | 人工改价:查(含推导价对照与差额)/ 批量 upsert / 按格删除恢复推导价;格子五键 `sizeId+colorId+printCount+craft+logistics` 必须存在于族矩阵选项 | JWT | | `/goods` | 后台商品 CRUD + `POST /goods/batch` + `PATCH /goods/batch-priority` | JWT | | `/sync/categories` `POST` | 手动触发分类同步 | JWT | | `/sync/products` `POST` | 手动触发商品同步 | JWT | diff --git a/plans/feature/public-family-id-feature.md b/plans/feature/public-family-id-feature.md new file mode 100644 index 0000000..20d7606 --- /dev/null +++ b/plans/feature/public-family-id-feature.md @@ -0,0 +1,86 @@ +# 公开契约族化:goodId = 族ID + 五维价格矩阵 + +## 背景 / 动机 + +- 现状 `GET /public/goods/{goodId}` 以 SDS 链接 ID 寻址,`findFirst` 任选一条 Good; + 一个族在库内常配置多条 Good(92 个已配置族中 78 个是多条,如加拿大 CATM001 一族 4 条), + 列表因此出现同款重复,前端被迫理解"族背后有多少商品"。 +- 价格矩阵把 印花数量+工艺 混在一个 `craft` 维(原始链接名第 3 段,含 + `直喷单面`/`光板不打印`/解析噪声,且 47 个成员为 null),不是严格的五维。 + +目标契约(用户拍板):**前端只知道款号(族)**—— + +- `goodId` = 族 ID;前端不知道族背后有几条 Good / 几个链接 +- 详情 = 款的公共信息(名称、主图、国家、分类、商品详情文案、尺码表、包装规格) + + 价格矩阵 +- 价格严格五维:**尺码 × 颜色 × 物流 × 工艺 × 印花数量 → 价格** + +## 事实核查(2026-08-28) + +- 0 个族跨国家/跨分类 → 国家、分类可作为族的公共属性 +- `family_price_overrides` 0 行 → 加维度无数据回填负担 +- 无族 Good 6 条(source=CUSTOM)→ 必须保留 sdsGoodId 寻址回退 +- 链接级标签已全覆盖(536/536),三组派生标签是矩阵维度的可靠来源 + +## 方案 + +### A. priceMatrix 五维化(family-recompute.service.ts) + +- `PriceMatrixRow` 增加 `printCount`;`PriceMatrix` 增加 `printCounts: string[]` +- 成员维度来源改为**有效标签**(OriginGoodTag ∋ 物流渠道/印花数量/印刷工艺三组; + 人工接管时用人工标签),三组各自取值做笛卡尔(通常 1×1×1);任一组缺失则该成员不进矩阵 +- 弃用 `craftLabel` 作为维度(保留字段用于展示回退) +- 同格子多来源取最低价、sources 全保留、人工覆盖并入 —— 规则不变 + +### B. FamilyPriceOverride 加 printCount(prisma migration) + +- 新列 `print_count`(默认 `单面印花`,迁移时 0 行数据) +- 唯一键扩展为 (familyId, sizeId, colorId, printCount, craft, logistics) + +### C. product-families.service 覆盖价三处五键化 + +- `PriceOverrideItemDto` + `putPriceOverrides` 校验(allowed.printCounts) +- `listPriceOverrides` 行匹配五键 + +### D. public 契约族化(public.service.ts) + +1. `getGoods`:过滤条件不变(作用于 Good),**取全量匹配后内存按族分组** + (规模 241 条 Good,注释说明;无族 Good 各自成组)。 + - 组代表 = 现有排序的第一条(priority desc → createdAt desc) + - `goodId`:有族 → `familyId`;无族 → `sdsGoodId`(自定义商品) + - `price`:族 minPrice(批量查 priceMatrix),回退 og price + - `total` = 分组数,分页在分组后 +2. `getGood(id)`:**纯数字先按族 ID 解析**(族 + 其 Good + 成员变体并集), + 未命中再回退 sdsGoodId 旧路径(自定义商品/向后兼容)。 + 族详情:公共字段取代表 Good;detail 文案取主链接;尺码表/包装规格用族并集; + media/mediaByColor/variants 全成员并集;`family` 块 = 五维矩阵 + minPrice。 + 族下无在售 Good → 404。`PUBLIC_DETAIL_FROM_FAMILY=false` 应急开关行为不变。 +3. `getHomeGoods`:按族去重(排序不变,同族保留最前一条)。 + +### E. admin 适配(GoodsEditDialog.vue) + +- SKU 列:印花数量列直接读 `row.printCount`(不再从 craft 字符串猜测) +- `loadMemberPrices`:按 printCount+craft+logistics 三键匹配该成员格子; + 维度优先取成员 `originGoodTags`(人工接管后仍准确),回退 `linkDims(goodName)` +- 保存覆盖价 payload 带 printCount + +### F. 存量族全量重算 + +一次性脚本:对所有 ProductFamily 逐个 `recomputeFamily`(矩阵物化 JSON 结构变更)。 + +### G. 测试与文档 + +- family-recompute spec:五维矩阵(含 人工标签 覆盖派生、缺组跳过、跨成员最低价) +- product-families spec:覆盖价五键校验 +- public spec:列表按族去重(同族 N 条 → 1 条,goodId=familyId)、getGood(族ID)、 + 自定义商品回退、home-goods 去重 +- public-family-block spec:矩阵 printCounts +- admin:vue-tsc + 现有 22 测试 +- 文档:docs/references/product-center.md、structs.md + +## 风险 / 边界 + +- 数字族 ID 与数字 sdsGoodId 理论碰撞:族 ID 优先(sdsGoodId 为 SDS 系统 6 位数, + 实际不碰撞),Swagger 注明 +- 列表内存分组在商品量级 10⁴ 前可接受,注释留优化方向(物化族表) +- 旧前端(website)以 sdsGoodId 寻址仍可达(回退路径),后续 website 侧再切换