From d20a933e340eddca9da298c2f4e6f1d7f74ca4e1 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 12:33:33 +0800 Subject: [PATCH] feat(api): sync hooks for parsing, auto-attach and family recompute --- apps/api/src/sync/sync-family-hooks.spec.ts | 174 ++++++++++++++++++++ apps/api/src/sync/sync.module.ts | 3 +- apps/api/src/sync/sync.service.ts | 65 +++++++- 3 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/sync/sync-family-hooks.spec.ts diff --git a/apps/api/src/sync/sync-family-hooks.spec.ts b/apps/api/src/sync/sync-family-hooks.spec.ts new file mode 100644 index 0000000..7891272 --- /dev/null +++ b/apps/api/src/sync/sync-family-hooks.spec.ts @@ -0,0 +1,174 @@ +import { Test } from '@nestjs/testing'; +import { Prisma } from '@prisma/client'; +import { SyncService } from './sync.service'; +import { SdsClientService } from './sds-client.service'; +import { FamilyRecomputeService } from '../product-families/family-recompute.service'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * 同步钩子集成测试:upsertOriginGood 的解析列写入、新链接自动挂族、 + * persistProductDetail 后的族重算入队。 + */ +describe('SyncService family hooks', () => { + let service: SyncService; + let prisma: PrismaService; + let recompute: FamilyRecomputeService; + const stamp = Date.now(); + const createdOriginGoodIds: bigint[] = []; + const createdFamilyIds: bigint[] = []; + + const sdsProduct = (id: string, name: string) => + ({ id, name, pic: 'https://example.com/pic.jpg' }) as any; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + SyncService, + { + provide: SdsClientService, + useValue: {}, + }, + FamilyRecomputeService, + PrismaService, + ], + }).compile(); + service = moduleRef.get(SyncService); + prisma = moduleRef.get(PrismaService); + recompute = moduleRef.get(FamilyRecomputeService); + await prisma.onModuleInit(); + }); + + afterAll(async () => { + await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } }); + await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } }); + await prisma.$disconnect(); + }); + + it('upsertOriginGood:写入四个解析列(create 与 update 全量覆盖)', async () => { + const sdsId = `hook-${stamp}-parse`; + const result1 = await (service as any).upsertOriginGood( + sdsProduct(sdsId, '美国(包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'), + 'cat-1', + ); + expect(result1).toBe('inserted'); + const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } }); + createdOriginGoodIds.push(og.id); + expect(og.skuCode).toBe('DG206'); + expect(og.logisticsLabel).toBe('包邮'); + expect(og.craftLabel).toBe('单面印花'); + expect(og.warehouseLabel).toBe('美西洛杉矶一仓'); + + // 更新为不带仓库的名称 → 解析列全量覆盖(warehouseLabel 置空) + await (service as any).upsertOriginGood( + sdsProduct(sdsId, '美国(不包邮)240g涤纶休闲短裤-DG206-单面印花'), + 'cat-1', + ); + const og2 = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } }); + expect(og2.logisticsLabel).toBe('不包邮'); + expect(og2.warehouseLabel).toBeNull(); + }); + + it('新链接自动挂族:唯一命中族则挂载并触发重算', async () => { + const seed = await prisma.originGood.create({ + data: { + sdsGoodId: `hook-${stamp}-seed`, + goodName: `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花`, + craftLabel: '单面印花', + logisticsLabel: '包邮', + }, + }); + createdOriginGoodIds.push(seed.id); + await prisma.originGoodVariant.create({ + data: { + originGoodId: seed.id, + sdsVariantId: 'seed-v1', + sku: 'SEED-S', + sizeId: 'size_S', + sizeName: 'S', + colorId: 'color_blk', + colorName: '黑色', + price: new Prisma.Decimal(25), + }, + }); + const family = await prisma.productFamily.create({ + data: { + familyName: `自动挂族-${stamp}`, + primaryOriginGoodId: seed.id, + }, + }); + createdFamilyIds.push(family.id); + await prisma.originGood.update({ + where: { id: seed.id }, + data: { familyId: family.id }, + }); + await recompute.recomputeFamily(family.id); + 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', + ); + const newOg = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: newSdsId } }); + createdOriginGoodIds.push(newOg.id); + expect(newOg.familyId).toBe(family.id); + + // 入队的重算已执行(等待异步完成) + await new Promise((r) => setTimeout(r, 200)); + }); + + it('多族命中时不确定归属 → 不挂载', async () => { + const key = `自动挂${stamp}B`; + const mk = async (suffix: string) => { + const og = await prisma.originGood.create({ + data: { + sdsGoodId: `hook-${stamp}-multi-${suffix}`, + goodName: `${key}(包邮)卫衣-ZZB${stamp}-单面印花`, + craftLabel: '单面印花', + logisticsLabel: '包邮', + }, + }); + createdOriginGoodIds.push(og.id); + const family = await prisma.productFamily.create({ + data: { familyName: `多族${suffix}-${stamp}`, primaryOriginGoodId: og.id }, + }); + createdFamilyIds.push(family.id); + await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } }); + return og; + }; + await mk('x'); + await mk('y'); + + const sdsId = `hook-${stamp}-multi-new`; + await (service as any).upsertOriginGood( + sdsProduct(sdsId, `${key}(包邮)卫衣-ZZB${stamp}-单面印花-新仓`), + 'cat-1', + ); + const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } }); + createdOriginGoodIds.push(og.id); + expect(og.familyId).toBeNull(); // 两个候选族 → 留给管理员 + }); + + it('maybeEnqueueFamilyRecompute:有族入队、无族跳过', async () => { + const enqueueSpy = jest.spyOn(recompute, 'enqueue').mockImplementation(() => undefined); + + const og = await prisma.originGood.create({ + data: { sdsGoodId: `hook-${stamp}-noattach`, goodName: `不成组名称-${stamp}` }, + }); + createdOriginGoodIds.push(og.id); + await (service as any).maybeEnqueueFamilyRecompute(og.id); + expect(enqueueSpy).not.toHaveBeenCalled(); + + const family = await prisma.productFamily.create({ + data: { familyName: `入队族-${stamp}` }, + }); + createdFamilyIds.push(family.id); + await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } }); + await (service as any).maybeEnqueueFamilyRecompute(og.id); + expect(enqueueSpy).toHaveBeenCalledWith(family.id); + + enqueueSpy.mockRestore(); + }); +}); diff --git a/apps/api/src/sync/sync.module.ts b/apps/api/src/sync/sync.module.ts index b04ab68..b82a647 100644 --- a/apps/api/src/sync/sync.module.ts +++ b/apps/api/src/sync/sync.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; import { HttpModule } from '@nestjs/axios'; +import { ProductFamiliesModule } from '../product-families/product-families.module'; import { SyncController } from './sync.controller'; import { SyncService } from './sync.service'; import { SdsClientService } from './sds-client.service'; @Module({ - imports: [ScheduleModule.forRoot(), HttpModule], + imports: [ScheduleModule.forRoot(), HttpModule, ProductFamiliesModule], controllers: [SyncController], providers: [SyncService, SdsClientService], exports: [SyncService, SdsClientService], diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts index be13f69..47d93c0 100644 --- a/apps/api/src/sync/sync.service.ts +++ b/apps/api/src/sync/sync.service.ts @@ -14,6 +14,8 @@ import { SdsProductDetail, } from './sds-client.service'; import { normalizeProductDetail } from './sds-product-detail.mapper'; +import { FamilyRecomputeService } from '../product-families/family-recompute.service'; +import { originGroupKey, parseOriginName } from '../product-families/origin-name.parser'; export interface CategorySyncResult { inserted: number; @@ -75,6 +77,7 @@ export class SyncService { constructor( private readonly prisma: PrismaService, private readonly sds: SdsClientService, + private readonly familyRecompute: FamilyRecomputeService, ) {} /** @@ -656,6 +659,54 @@ export class SyncService { }, }); }); + // 族成员的详情/变体变化 → 异步重算该族(进程内去重) + await this.maybeEnqueueFamilyRecompute(originGoodId); + } + + /** 详情同步后的族重算钩子:链接有族归属才入队 */ + private async maybeEnqueueFamilyRecompute(originGoodId: bigint): Promise { + const og = await this.prisma.originGood.findUnique({ + where: { id: originGoodId }, + select: { familyId: true }, + }); + if (og?.familyId) this.familyRecompute.enqueue(og.familyId); + } + + /** + * 新链接自动挂族: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 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({ + where: { id: familyId }, + select: { autoManaged: true }, + }); + if (!family) return; + if (family.autoManaged) { + await this.prisma.originGood.update({ + where: { id: originGoodId }, + data: { familyId }, + }); + this.familyRecompute.enqueue(familyId); + } else { + await this.prisma.productFamily.update({ + where: { id: familyId }, + data: { stale: true }, + }); + } } /** @@ -715,23 +766,35 @@ export class SyncService { goodPrice = new Prisma.Decimal(n); } } + // 链接名结构化解析列(镜像纯度:全量覆盖,含空值) + const parsed = parseOriginName(goodName); + const parsedData = { + skuCode: parsed.skuCode, + logisticsLabel: parsed.logisticsLabel, + craftLabel: parsed.craftLabel, + warehouseLabel: parsed.warehouseLabel, + }; const data: Prisma.OriginGoodUncheckedUpdateInput = { sdsCategoryId, goodName, goodImage, goodPrice, + ...parsedData, }; if (!existing) { - await this.prisma.originGood.create({ + const created = await this.prisma.originGood.create({ data: { sdsGoodId, sdsCategoryId, goodName, goodImage, goodPrice, + ...parsedData, }, + select: { id: true }, }); + await this.tryAutoAttachToFamily(created.id, goodName); return 'inserted'; } await this.prisma.originGood.update({