From c8531bfe0840519c13e1557e43699c73b320ad8f Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 12:43:58 +0800 Subject: [PATCH] feat(api): category-based family grouping, tree family info and custom goods family attribution --- apps/api/prisma/backfill-product-families.ts | 12 ++++- apps/api/src/goods/dto/custom-good.dto.ts | 25 ++++++++++ apps/api/src/goods/goods.module.ts | 11 +++-- apps/api/src/goods/goods.service.spec.ts | 5 ++ apps/api/src/goods/goods.service.ts | 20 ++++++++ .../src/origin-goods/origin-goods.service.ts | 18 ++++++- .../product-families.service.spec.ts | 36 +++++++++++++- .../product-families.service.ts | 48 ++++++++++++++----- apps/api/src/sync/sync-family-hooks.spec.ts | 14 ++++-- apps/api/src/sync/sync.service.ts | 37 +++++++++----- 10 files changed, 190 insertions(+), 36 deletions(-) diff --git a/apps/api/prisma/backfill-product-families.ts b/apps/api/prisma/backfill-product-families.ts index d19dc5c..8a06555 100644 --- a/apps/api/prisma/backfill-product-families.ts +++ b/apps/api/prisma/backfill-product-families.ts @@ -7,6 +7,7 @@ * 运行:pnpm --filter @inkreach/api backfill:product-families * 幂等性:重复执行时步骤 1 数据不变、步骤 2 候选为空(familyId=null 过滤)。 */ +import { Prisma } from '@prisma/client'; import { PrismaService } from '../src/prisma/prisma.service'; import { FamilyRecomputeService } from '../src/product-families/family-recompute.service'; import { ProductFamiliesService } from '../src/product-families/product-families.service'; @@ -51,9 +52,18 @@ async function main() { const result = await families.autoGroup(true); console.log(`[2/2] created ${result.applied} families`); + // ---- 3. 全量族重算兜底(修复建族早于解析列回填等时序造成的空矩阵) ---- + const allFamilies = await prisma.productFamily.findMany({ select: { id: true } }); + for (const f of allFamilies) { + await recompute.recomputeFamily(f.id); + } + console.log(`[3/3] recomputed ${allFamilies.length} families`); + // ---- 统计 ---- const total = await prisma.productFamily.count(); - const withMatrix = await prisma.productFamily.count({ where: { priceMatrix: { not: null } } }); + const withMatrix = await prisma.productFamily.count({ + where: { priceMatrix: { not: Prisma.DbNull } }, + }); const members = await prisma.originGood.count({ where: { familyId: { not: null } } }); const stale = await prisma.productFamily.count({ where: { stale: true } }); console.log(`stats: families=${total}, materialized=${withMatrix}, members=${members}, stale=${stale}`); diff --git a/apps/api/src/goods/dto/custom-good.dto.ts b/apps/api/src/goods/dto/custom-good.dto.ts index b3874ca..5fa2976 100644 --- a/apps/api/src/goods/dto/custom-good.dto.ts +++ b/apps/api/src/goods/dto/custom-good.dto.ts @@ -211,6 +211,31 @@ export class CreateCustomGoodDto extends OmitType(CreateGoodDto, [ @IsNumberString() goodPrice?: string | null; + @ApiProperty({ required: false, description: '物流归因(入族后参与价格矩阵)', example: '包邮' }) + @IsOptional() + @IsString() + logisticsLabel?: string; + + @ApiProperty({ required: false, description: '工艺/印花数量归因', example: '双面印花' }) + @IsOptional() + @IsString() + craftLabel?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + skuCode?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + warehouseLabel?: string; + + @ApiProperty({ required: false, description: '挂入的产品族 id(缺省为独立商品)', example: '12' }) + @IsOptional() + @IsNumberString() + familyId?: string; + @ApiProperty({ required: false, type: CustomGoodDetailDto }) @IsOptional() @ValidateNested() diff --git a/apps/api/src/goods/goods.module.ts b/apps/api/src/goods/goods.module.ts index 95907bd..1f74a9e 100644 --- a/apps/api/src/goods/goods.module.ts +++ b/apps/api/src/goods/goods.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { GoodsController } from './goods.controller'; -import { GoodsService } from './goods.service'; -import { SyncModule } from '../sync/sync.module'; - -@Module({ - imports: [SyncModule], +import { GoodsService } from './goods.service'; +import { SyncModule } from '../sync/sync.module'; +import { ProductFamiliesModule } from '../product-families/product-families.module'; + +@Module({ + imports: [SyncModule, ProductFamiliesModule], controllers: [GoodsController], providers: [GoodsService], exports: [GoodsService], diff --git a/apps/api/src/goods/goods.service.spec.ts b/apps/api/src/goods/goods.service.spec.ts index b687494..389f4b9 100644 --- a/apps/api/src/goods/goods.service.spec.ts +++ b/apps/api/src/goods/goods.service.spec.ts @@ -6,6 +6,7 @@ import { import { GoodsService } from './goods.service'; import { PrismaService } from '../prisma/prisma.service'; import { SyncService } from '../sync/sync.service'; +import { FamilyRecomputeService } from '../product-families/family-recompute.service'; describe('GoodsService', () => { let service: GoodsService; @@ -30,6 +31,10 @@ describe('GoodsService', () => { provide: SyncService, useValue: { queueProductDetailSync: jest.fn() }, }, + { + provide: FamilyRecomputeService, + useValue: { enqueue: jest.fn() }, + }, ], }).compile(); service = moduleRef.get(GoodsService); diff --git a/apps/api/src/goods/goods.service.ts b/apps/api/src/goods/goods.service.ts index 0d094fa..0c9e850 100644 --- a/apps/api/src/goods/goods.service.ts +++ b/apps/api/src/goods/goods.service.ts @@ -12,6 +12,7 @@ import { BatchCreateGoodDto } from './dto/batch-create-good.dto'; import { BatchPriorityDto } from './dto/batch-priority.dto'; import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto'; import { SyncService } from '../sync/sync.service'; +import { FamilyRecomputeService } from '../product-families/family-recompute.service'; import { randomUUID } from 'crypto'; import { CreateCustomGoodDto, @@ -52,6 +53,7 @@ export class GoodsService { constructor( private readonly prisma: PrismaService, private readonly syncService: SyncService, + private readonly familyRecompute: FamilyRecomputeService, ) {} async findAll(query: QueryGoodDto): Promise { @@ -176,6 +178,14 @@ export class GoodsService { await this.ensureCategory(dto.categoryId); if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId); for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId); + let family: { id: bigint } | null = null; + if (dto.familyId !== undefined) { + 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`); + } const goodId = await this.prisma.$transaction(async (tx) => { const originGood = await tx.originGood.create({ @@ -185,6 +195,15 @@ export class GoodsService { goodName: dto.goodName, goodImage: dto.goodImage ?? null, goodPrice: this.decimal(dto.goodPrice), + ...(family ? { familyId: family.id } : {}), + ...(dto.logisticsLabel !== undefined || dto.craftLabel !== undefined + ? { + logisticsLabel: dto.logisticsLabel ?? null, + craftLabel: dto.craftLabel ?? null, + skuCode: dto.skuCode ?? null, + warehouseLabel: dto.warehouseLabel ?? null, + } + : {}), detail: { create: this.customDetailData( dto.detail ?? {}, @@ -217,6 +236,7 @@ export class GoodsService { } return good.id; }); + if (family) this.familyRecompute.enqueue(family.id); return this.findOne(goodId); } diff --git a/apps/api/src/origin-goods/origin-goods.service.ts b/apps/api/src/origin-goods/origin-goods.service.ts index 9b8d267..ed0fea0 100644 --- a/apps/api/src/origin-goods/origin-goods.service.ts +++ b/apps/api/src/origin-goods/origin-goods.service.ts @@ -41,6 +41,10 @@ export interface OriginGoodsTreeNode { variantCount: number; sizeRowCount: number; packageRowCount: number; + familyId: string | null; + familyName: string | null; + familyCode: string | null; + familyStale: boolean | null; } /** A category node in the hierarchical tree, with origin goods as leaves. */ @@ -129,7 +133,11 @@ export class OriginGoodsService { this.prisma.originGood.findMany({ where: { delisted: false, source: 'SDS' }, orderBy: { goodName: 'asc' }, - include: { detail: true, _count: { select: { variants: true } } }, + include: { + detail: true, + _count: { select: { variants: true } }, + family: { select: { id: true, familyName: true, familyCode: true, stale: true } }, + }, }), this.prisma.good.groupBy({ by: ['originGoodId'], @@ -271,6 +279,10 @@ export class OriginGoodsService { variantCount: og._count.variants, sizeRowCount: this.jsonRows(og.detail?.sizeChart), packageRowCount: this.jsonRows(og.detail?.packageSpecs), + familyId: og.family?.id.toString() ?? null, + familyName: og.family?.familyName ?? null, + familyCode: og.family?.familyCode ?? null, + familyStale: og.family?.stale ?? null, })); const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0); @@ -322,6 +334,10 @@ export class OriginGoodsService { variantCount: og._count.variants, sizeRowCount: this.jsonRows(og.detail?.sizeChart), packageRowCount: this.jsonRows(og.detail?.packageSpecs), + familyId: og.family?.id.toString() ?? null, + familyName: og.family?.familyName ?? null, + familyCode: og.family?.familyCode ?? null, + familyStale: og.family?.stale ?? null, })), }); } 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 2cc7e33..9e9db98 100644 --- a/apps/api/src/product-families/product-families.service.spec.ts +++ b/apps/api/src/product-families/product-families.service.spec.ts @@ -98,7 +98,7 @@ describe('ProductFamiliesService', () => { await service.patch(BigInt(f.id), { autoManaged: true }); }); - it('auto-group:预览不写库;apply 建族挂成员且幂等', async () => { + it('auto-group:无分类时按名称键分组;预览不写库;apply 幂等', async () => { const g1a = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花`); const g1b = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花-某仓`); const g2 = await mkOriginGood(`自动组${stamp}(不包邮)卫衣-ZZ${stamp}-单面印花`); @@ -129,6 +129,40 @@ describe('ProductFamiliesService', () => { expect(hitAgain).toHaveLength(0); }); + it('auto-group:同 SDS 分类(产品模型)跨工艺/编码归一族,族名取分类名', async () => { + const cat = await prisma.category.create({ + data: { categoryName: `ZZF${stamp} 180G纯棉T恤(ZZA${stamp})`, sdsCategoryId: `cat-hook-${stamp}` }, + }); + try { + const a = await mkOriginGood(`美国(包邮)180g纯棉T恤-DGZ${stamp}-单面印花`, { + sdsCategoryId: `cat-hook-${stamp}`, + craftLabel: '单面印花', + logisticsLabel: '包邮', + }); + const b = await mkOriginGood(`美国(不包邮)180GT恤-ZZA${stamp}-双面印花-某仓`, { + sdsCategoryId: `cat-hook-${stamp}`, + craftLabel: '双面印花', + logisticsLabel: '不包邮', + }); + const result = (await service.autoGroup(true)) as any; + const grouped = await prisma.originGood.findMany({ + where: { id: { in: [a.id, b.id] } }, + select: { familyId: true }, + }); + expect(grouped[0].familyId).not.toBeNull(); + expect(grouped[0].familyId).toBe(grouped[1].familyId); // 跨工艺/编码/物流同族 + const family = await prisma.productFamily.findUniqueOrThrow({ + where: { id: grouped[0].familyId! }, + }); + createdFamilyIds.push(family.id); + expect(family.familyName).toBe(`ZZF${stamp} 180G纯棉T恤(ZZA${stamp})`); + expect(family.familyCode).toBe(`ZZF${stamp}`); + expect(result.applied).toBeGreaterThanOrEqual(1); + } finally { + await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined); + } + }); + it('members:增删成员、移除主链接后 primary 落到剩余成员', async () => { const a = await mkOriginGood(`成员${stamp}A`, { craftLabel: '单面印花', logisticsLabel: '包邮' }); const b = await mkOriginGood(`成员${stamp}B`, { craftLabel: '单面印花', logisticsLabel: '包邮' }); diff --git a/apps/api/src/product-families/product-families.service.ts b/apps/api/src/product-families/product-families.service.ts index 798b42d..0beeafa 100644 --- a/apps/api/src/product-families/product-families.service.ts +++ b/apps/api/src/product-families/product-families.service.ts @@ -13,8 +13,14 @@ import { UpdateFamilyMembersDto, } from './dto/product-family.dto'; -const FAMILY_INCLUDE = { - originGoods: { +/** 从分类名提取模型编码:`DG001 180G纯棉T恤(JSA002)` → `DG001` */ +function codeFromCategoryName(categoryName: string | null | undefined): string | null { + if (!categoryName) return null; + const token = categoryName.trim().split(/\s+/)[0] ?? ''; + return /^[A-Za-z0-9]+$/.test(token) ? token : null; +} + +const FAMILY_INCLUDE = { originGoods: { select: { id: true, sdsGoodId: true, @@ -128,16 +134,24 @@ export class ProductFamiliesService { return this.detail(id); } - /** 自动建族:按 3 段分组键聚合无族链接;apply=false 仅预览 */ + /** 自动成族:SDS 叶子分类即产品模型(如 "DG001 180G纯棉T恤(JSA002)"), + * 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退名称 3 段键;apply=false 仅预览 */ async autoGroup(apply: boolean) { const candidates = await this.prisma.originGood.findMany({ where: { familyId: null, delisted: false }, - select: { id: true, goodName: true, goodImage: true, source: true }, + select: { id: true, goodName: true, goodImage: true, source: true, sdsCategoryId: true }, orderBy: { id: 'asc' }, }); + const categories = await this.prisma.category.findMany({ + where: { sdsCategoryId: { not: null } }, + select: { sdsCategoryId: true, categoryName: true }, + }); + const catName = new Map(categories.map((c) => [c.sdsCategoryId as string, c.categoryName])); + const groups = new Map(); for (const og of candidates) { - const key = originGroupKey(og.goodName); + const nameKey = originGroupKey(og.goodName); + const key = og.sdsCategoryId ? `cat:${og.sdsCategoryId}` : nameKey ? `name:${nameKey}` : ''; if (!key) continue; const arr = groups.get(key); if (arr) arr.push(og); @@ -146,10 +160,14 @@ export class ProductFamiliesService { const preview = [...groups.values()].map((members) => { const parsed = parseOriginName(members[0].goodName); + const categoryName = members[0].sdsCategoryId + ? (catName.get(members[0].sdsCategoryId) ?? null) + : null; return { - groupKey: originGroupKey(members[0].goodName), - familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName), - familyCode: parsed.skuCode ?? null, + groupKey: members[0].sdsCategoryId ? `cat:${members[0].sdsCategoryId}` : `name:${originGroupKey(members[0].goodName)}`, + familyName: + categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName), + familyCode: codeFromCategoryName(categoryName) ?? parsed.skuCode ?? null, memberCount: members.length, sampleNames: members.slice(0, 3).map((m) => m.goodName ?? ''), }; @@ -160,15 +178,19 @@ export class ProductFamiliesService { let applied = 0; for (const members of groups.values()) { const parsed = parseOriginName(members[0].goodName); + const categoryName = members[0].sdsCategoryId + ? (catName.get(members[0].sdsCategoryId) ?? null) + : null; const fallbackCode = members[0].source === 'CUSTOM' ? `CUSTOM-${members[0].id}` : null; const family = await this.prisma.productFamily.create({ data: { - familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName), - familyCode: parsed.skuCode - ? await this.ensureUniqueCode(parsed.skuCode) - : fallbackCode - ? await this.ensureUniqueCode(fallbackCode) + familyName: categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName), + familyCode: + codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode + ? await this.ensureUniqueCode( + (codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode)!, + ) : null, familyImage: members[0].goodImage ?? null, primaryOriginGoodId: members[0].id, diff --git a/apps/api/src/sync/sync-family-hooks.spec.ts b/apps/api/src/sync/sync-family-hooks.spec.ts index 7891272..be9fed5 100644 --- a/apps/api/src/sync/sync-family-hooks.spec.ts +++ b/apps/api/src/sync/sync-family-hooks.spec.ts @@ -68,11 +68,16 @@ describe('SyncService family hooks', () => { expect(og2.warehouseLabel).toBeNull(); }); - it('新链接自动挂族:唯一命中族则挂载并触发重算', async () => { + it('新链接自动挂族:按 SDS 分类唯一命中族则挂载并触发重算', async () => { + const sdsCat = `cat-hook-${stamp}`; + const cat = await prisma.category.create({ + data: { categoryName: `挂族测试分类-${stamp}`, sdsCategoryId: sdsCat }, + }); const seed = await prisma.originGood.create({ data: { sdsGoodId: `hook-${stamp}-seed`, goodName: `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花`, + sdsCategoryId: sdsCat, craftLabel: '单面印花', logisticsLabel: '包邮', }, @@ -105,11 +110,11 @@ describe('SyncService family hooks', () => { const before = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); expect((before.priceMatrix as any).rows).toHaveLength(1); - // 同键新链接(多一个仓库段)→ 自动挂进唯一族 + // 同分类新链接(不同工艺)→ 自动挂进唯一族 const newSdsId = `hook-${stamp}-new`; await (service as any).upsertOriginGood( - sdsProduct(newSdsId, `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花-某仓`), - 'cat-1', + sdsProduct(newSdsId, `自动挂${stamp}(不包邮)卫衣-ZZA${stamp}-双面印花-某仓`), + sdsCat, ); const newOg = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: newSdsId } }); createdOriginGoodIds.push(newOg.id); @@ -117,6 +122,7 @@ describe('SyncService family hooks', () => { // 入队的重算已执行(等待异步完成) await new Promise((r) => setTimeout(r, 200)); + await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined); }); it('多族命中时不确定归属 → 不挂载', async () => { diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts index 47d93c0..c923f80 100644 --- a/apps/api/src/sync/sync.service.ts +++ b/apps/api/src/sync/sync.service.ts @@ -673,21 +673,36 @@ export class SyncService { } /** - * 新链接自动挂族:3 段分组键恰好命中唯一族才挂载(多族/零族留给管理员裁决)。 + * 新链接自动挂族:优先按 SDS 分类(=产品模型)匹配已有族的成员; + * 无分类时回退名称 3 段键。恰好命中唯一族才挂载(多族/零族留给管理员裁决)。 * 锁定族(autoManaged=false)不吸收新成员,只置 stale 提示。 */ private async tryAutoAttachToFamily(originGoodId: bigint, goodName: string): Promise { - const key = originGroupKey(goodName); - if (!key) return; - const candidates = await this.prisma.originGood.findMany({ - where: { familyId: { not: null }, goodName: { startsWith: key } }, - select: { familyId: true, goodName: true }, + const self = await this.prisma.originGood.findUnique({ + where: { id: originGoodId }, + select: { sdsCategoryId: true }, }); - const familyIds = new Set( - candidates - .filter((c) => originGroupKey(c.goodName) === key && c.familyId !== null) - .map((c) => c.familyId!.toString()), - ); + let familyIds: Set; + if (self?.sdsCategoryId) { + const siblings = await this.prisma.originGood.findMany({ + where: { sdsCategoryId: self.sdsCategoryId, familyId: { not: null } }, + select: { familyId: true }, + distinct: ['familyId'], + }); + familyIds = new Set(siblings.map((s) => s.familyId!.toString())); + } else { + const key = originGroupKey(goodName); + if (!key) return; + const candidates = await this.prisma.originGood.findMany({ + where: { familyId: { not: null }, goodName: { startsWith: key } }, + select: { familyId: true, goodName: true }, + }); + familyIds = new Set( + candidates + .filter((c) => originGroupKey(c.goodName) === key && c.familyId !== null) + .map((c) => c.familyId!.toString()), + ); + } if (familyIds.size !== 1) return; const familyId = BigInt([...familyIds][0]); const family = await this.prisma.productFamily.findUnique({