diff --git a/apps/api/package.json b/apps/api/package.json index 221d99c..1931d82 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,7 +22,8 @@ "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts", - "import:product-detail": "ts-node prisma/import-product-detail.ts" + "import:product-detail": "ts-node prisma/import-product-detail.ts", + "backfill:product-families": "ts-node prisma/backfill-product-families.ts" }, "dependencies": { "@nestjs/axios": "^3.0.1", diff --git a/apps/api/prisma/backfill-product-families.ts b/apps/api/prisma/backfill-product-families.ts new file mode 100644 index 0000000..d19dc5c --- /dev/null +++ b/apps/api/prisma/backfill-product-families.ts @@ -0,0 +1,71 @@ +/** + * 产品族回填脚本(一次性 / 幂等): + * 1. 全量 OriginGood 回填四个链接名解析列; + * 2. auto-group 全量建族并挂成员(每族建立即重算); + * 3. 输出统计与不可解析清单。 + * + * 运行:pnpm --filter @inkreach/api backfill:product-families + * 幂等性:重复执行时步骤 1 数据不变、步骤 2 候选为空(familyId=null 过滤)。 + */ +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'; +import { parseOriginName } from '../src/product-families/origin-name.parser'; + +async function main() { + const prisma = new PrismaService(); + await prisma.onModuleInit(); + const recompute = new FamilyRecomputeService(prisma); + const families = new ProductFamiliesService(prisma, recompute); + + // ---- 1. 解析列回填(分批) ---- + const BATCH = 100; + let parsed = 0; + const unparsable: string[] = []; + for (;;) { + const batch = await prisma.originGood.findMany({ + orderBy: { id: 'asc' }, + take: BATCH, + skip: parsed, + select: { id: true, goodName: true }, + }); + if (batch.length === 0) break; + for (const og of batch) { + const p = parseOriginName(og.goodName); + if (!p.skuCode && !p.craftLabel) unparsable.push(`#${og.id} ${og.goodName ?? ''}`); + await prisma.originGood.update({ + where: { id: og.id }, + data: { + skuCode: p.skuCode, + logisticsLabel: p.logisticsLabel, + craftLabel: p.craftLabel, + warehouseLabel: p.warehouseLabel, + }, + }); + } + parsed += batch.length; + } + console.log(`[1/2] parsed ${parsed} origin goods (${unparsable.length} without sku/craft)`); + + // ---- 2. 自动建族(含逐族重算) ---- + const result = await families.autoGroup(true); + console.log(`[2/2] created ${result.applied} families`); + + // ---- 统计 ---- + const total = await prisma.productFamily.count(); + const withMatrix = await prisma.productFamily.count({ where: { priceMatrix: { not: null } } }); + 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}`); + if (unparsable.length) { + console.log('unparsable names:'); + for (const line of unparsable) console.log(` - ${line}`); + } + + await prisma.$disconnect(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/api/src/product-families/product-families.service.ts b/apps/api/src/product-families/product-families.service.ts index da701c2..798b42d 100644 --- a/apps/api/src/product-families/product-families.service.ts +++ b/apps/api/src/product-families/product-families.service.ts @@ -132,7 +132,7 @@ export class ProductFamiliesService { async autoGroup(apply: boolean) { const candidates = await this.prisma.originGood.findMany({ where: { familyId: null, delisted: false }, - select: { id: true, goodName: true, goodImage: true }, + select: { id: true, goodName: true, goodImage: true, source: true }, orderBy: { id: 'asc' }, }); const groups = new Map(); @@ -160,10 +160,16 @@ export class ProductFamiliesService { let applied = 0; for (const members of groups.values()) { const parsed = parseOriginName(members[0].goodName); + 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) : null, + familyCode: parsed.skuCode + ? await this.ensureUniqueCode(parsed.skuCode) + : fallbackCode + ? await this.ensureUniqueCode(fallbackCode) + : null, familyImage: members[0].goodImage ?? null, primaryOriginGoodId: members[0].id, }, diff --git a/apps/api/src/sync/sync.service.spec.ts b/apps/api/src/sync/sync.service.spec.ts index 0f32ce3..0e7944a 100644 --- a/apps/api/src/sync/sync.service.spec.ts +++ b/apps/api/src/sync/sync.service.spec.ts @@ -7,8 +7,9 @@ import { } from './sync.service'; import { SdsClientService } from './sds-client.service'; import { PrismaService } from '../prisma/prisma.service'; +import { FamilyRecomputeService } from '../product-families/family-recompute.service'; -describe('SyncService', () => { +describe('SyncService', () => { let service: SyncService; let sds: jest.Mocked; let prisma: PrismaService; @@ -17,22 +18,23 @@ describe('SyncService', () => { beforeAll(async () => { const sdsMock: Partial = { - fetchCategoryTree: jest.fn(), - fetchProductsPage: jest.fn(), - fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })), + fetchCategoryTree: jest.fn(), + fetchProductsPage: jest.fn(), + fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })), }; const moduleRef = await Test.createTestingModule({ imports: [ConfigModule.forRoot({ isGlobal: true })], providers: [ SyncService, { provide: SdsClientService, useValue: sdsMock }, + { provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } }, PrismaService, ], }).compile(); - service = moduleRef.get(SyncService); - jest - .spyOn(service, 'syncConfiguredProductDetails') - .mockResolvedValue({ synced: 0, failed: 0 }); + service = moduleRef.get(SyncService); + jest + .spyOn(service, 'syncConfiguredProductDetails') + .mockResolvedValue({ synced: 0, failed: 0 }); sds = moduleRef.get(SdsClientService) as jest.Mocked; prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); @@ -275,68 +277,71 @@ describe('SyncService', () => { expect(logs.length).toBeGreaterThan(0); }); }); -}); - -describe('SyncService product detail scopes', () => { - const originGoods = [ - { id: 1n, sdsGoodId: 'all-1' }, - { id: 2n, sdsGoodId: 'all-2' }, - ]; - - function createService() { - const prisma = { - originGood: { findMany: jest.fn().mockResolvedValue(originGoods) }, - } as unknown as PrismaService; - const sds = { - fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })), - } as unknown as SdsClientService; - const scopedService = new SyncService(prisma, sds); - jest - .spyOn(scopedService as any, 'persistProductDetail') - .mockResolvedValue(undefined); - return { scopedService, prisma, sds }; - } - - it('manual detail sync selects every active origin product', async () => { - const { scopedService, prisma, sds } = createService(); - const result = await scopedService.syncAllProductDetails(); - - expect(prisma.originGood.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { delisted: false, source: 'SDS' } }), - ); - expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2); - expect(result).toEqual({ total: 2, synced: 2, failed: 0 }); - }); - - it('hourly detail refresh remains limited to configured products', async () => { - const { scopedService, prisma } = createService(); - await scopedService.syncConfiguredProductDetails(); - - expect(prisma.originGood.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { delisted: false, source: 'SDS', goods: { some: {} } }, - }), - ); - }); - - it('keeps hourly category/product sync separate from the daily detail sync', async () => { - const { scopedService } = createService(); - const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({ - inserted: 0, updated: 0, total: 0, deletedStale: 0, - }); - const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({ - inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0, - }); - const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({ - total: 0, synced: 0, failed: 0, - }); - - await scopedService.hourlyCron(); - expect(categories).toHaveBeenCalledTimes(1); - expect(products).toHaveBeenCalledTimes(1); - expect(details).not.toHaveBeenCalled(); - - await scopedService.dailyProductDetailCron(); - expect(details).toHaveBeenCalledTimes(1); - }); -}); +}); + +describe('SyncService product detail scopes', () => { + const originGoods = [ + { id: 1n, sdsGoodId: 'all-1' }, + { id: 2n, sdsGoodId: 'all-2' }, + ]; + + function createService() { + const prisma = { + originGood: { findMany: jest.fn().mockResolvedValue(originGoods) }, + } as unknown as PrismaService; + const sds = { + fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })), + } as unknown as SdsClientService; + const familyRecompute = { + enqueue: jest.fn(), + } as unknown as FamilyRecomputeService; + const scopedService = new SyncService(prisma, sds, familyRecompute); + jest + .spyOn(scopedService as any, 'persistProductDetail') + .mockResolvedValue(undefined); + return { scopedService, prisma, sds }; + } + + it('manual detail sync selects every active origin product', async () => { + const { scopedService, prisma, sds } = createService(); + const result = await scopedService.syncAllProductDetails(); + + expect(prisma.originGood.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { delisted: false, source: 'SDS' } }), + ); + expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2); + expect(result).toEqual({ total: 2, synced: 2, failed: 0 }); + }); + + it('hourly detail refresh remains limited to configured products', async () => { + const { scopedService, prisma } = createService(); + await scopedService.syncConfiguredProductDetails(); + + expect(prisma.originGood.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { delisted: false, source: 'SDS', goods: { some: {} } }, + }), + ); + }); + + it('keeps hourly category/product sync separate from the daily detail sync', async () => { + const { scopedService } = createService(); + const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({ + inserted: 0, updated: 0, total: 0, deletedStale: 0, + }); + const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({ + inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0, + }); + const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({ + total: 0, synced: 0, failed: 0, + }); + + await scopedService.hourlyCron(); + expect(categories).toHaveBeenCalledTimes(1); + expect(products).toHaveBeenCalledTimes(1); + expect(details).not.toHaveBeenCalled(); + + await scopedService.dailyProductDetailCron(); + expect(details).toHaveBeenCalledTimes(1); + }); +});