import { Test } from '@nestjs/testing'; import { Prisma } from '@prisma/client'; import { FamilyRecomputeService } from './family-recompute.service'; import { ProductFamiliesService } from './product-families.service'; import { OrganizeService } from './organize.service'; import { PrismaService } from '../prisma/prisma.service'; /** * 集成测试(连真实库,与 goods.service.spec.ts 同模式): * 验证并集尺码表/包装规则裁决、五维价格矩阵推导(最低价/来源累积/维度过滤)、 * 覆盖合并、autoManaged 锁定语义与 canonical detail 初始化。 */ describe('FamilyRecomputeService', () => { let service: FamilyRecomputeService; let organize!: OrganizeService; let prisma: PrismaService; const stamp = Date.now(); const createdOriginGoodIds: bigint[] = []; const createdFamilyIds: bigint[] = []; const createdTagIds: bigint[] = []; const createdTagGroupIds: bigint[] = []; const mkOriginGood = async (over: { goodName?: string; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null; variants?: Array<{ sdsVariantId: string; sku: string; sizeId?: string; sizeName?: string; colorId?: string; colorName?: string; price: number; enabled?: boolean; }>; sizeChart?: object; packageSpecs?: object; }) => { const og = await prisma.originGood.create({ data: { sdsGoodId: `recompute-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`, goodName: over.goodName ?? `测试链接-${stamp}`, source: over.source ?? 'CUSTOM', craftLabel: over.craftLabel ?? null, logisticsLabel: over.logisticsLabel ?? null, }, }); createdOriginGoodIds.push(og.id); if (over.variants?.length) { await prisma.originGoodVariant.createMany({ data: over.variants.map((v) => ({ originGoodId: og.id, sdsVariantId: v.sdsVariantId, sku: v.sku, sizeId: v.sizeId ?? null, sizeName: v.sizeName ?? null, colorId: v.colorId ?? null, colorName: v.colorName ?? null, price: new Prisma.Decimal(v.price), enabled: v.enabled ?? true, })), }); } if (over.sizeChart || over.packageSpecs) { await prisma.originGoodDetail.create({ data: { originGoodId: og.id, sizeChart: (over.sizeChart ?? undefined) as Prisma.InputJsonValue, packageSpecs: (over.packageSpecs ?? undefined) as Prisma.InputJsonValue, }, }); } return og; }; const mkFamily = async (over: { primaryOriginGoodId?: bigint | null; autoManaged?: boolean; memberIds?: bigint[]; }) => { const family = await prisma.productFamily.create({ data: { familyName: `测试族-${stamp}-${createdFamilyIds.length}`, autoManaged: over.autoManaged ?? true, primaryOriginGoodId: over.primaryOriginGoodId ?? null, }, }); createdFamilyIds.push(family.id); if (over.memberIds?.length) { await prisma.originGood.updateMany({ where: { id: { in: over.memberIds } }, data: { familyId: family.id }, }); } return family; }; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ providers: [FamilyRecomputeService, PrismaService], }).compile(); service = moduleRef.get(FamilyRecomputeService); organize = new OrganizeService( moduleRef.get(PrismaService), service, new ProductFamiliesService(moduleRef.get(PrismaService), service), ); prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); }); 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(); }); it('并集尺码表:覆盖最全的成员优先、主链接次之、按首现顺序合并', async () => { // A:主链接,2 行;C:非主链接,3 行 → C 的 S 行胜出 const a = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮', sizeChart: { columns: [{ key: 'chest', name: '胸围' }], rows: [ { sizeId: 'size_S', sizeName: 'S', chest: 100 }, { sizeId: 'size_XL', sizeName: 'XL', chest: 108 }, ] }, }); const c = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮', sizeChart: { columns: [{ key: 'chest', name: '胸围' }], rows: [ { sizeId: 'size_S', sizeName: 'S', chest: 98 }, { sizeId: 'size_XXL', sizeName: 'XXL', chest: 112 }, { sizeId: 'size_XXXL', sizeName: 'XXXL', chest: 116 }, ] }, }); const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, c.id] }); await service.recomputeFamily(family.id); const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); const chart = after.sizeChart as { rows: Array<{ sizeName: string; chest: number }> }; const names = chart.rows.map((r) => r.sizeName); expect(names).toEqual(['S', 'XXL', 'XXXL', 'XL']); // C(3行) 优先遍历,A 补充 XL expect(chart.rows.find((r) => r.sizeName === 'S')?.chest).toBe(98); // 冲突取覆盖最全成员 expect(after.stale).toBe(false); }); it('价格矩阵:五维格子取最低价并累积来源;维度取整理派生的链接标签;停用变体不参与', async () => { const a = await mkOriginGood({ source: 'SDS', 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 }, { sdsVariantId: 'v3', sku: 'A-S-BLK-OFF', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 20, enabled: false }, ], }); const b = await mkOriginGood({ source: 'SDS', 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({ source: 'SDS', 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({ source: 'SDS', goodName: '美国(不包邮)测试E-PE1-光板', // 整理派生:不打印 + 不包邮(无印花数量标签 → 结构化占位单面) variants: [ { 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] }); // 整理(显式派生标签)→ 重算(纯聚合)。运行时不解析名称,未派生的成员不进矩阵 await organize.deriveFamilyTags(family.id); await service.recomputeFamily(family.id); 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.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 排除 // 直喷双面 → 印花数量/工艺两维正确拆分 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('价格矩阵:未整理(无标签)的 SDS 成员不进矩阵', async () => { const a = await mkOriginGood({ source: 'SDS', goodName: '美国(包邮)测试U-PU1-单面印花', variants: [ { sdsVariantId: 'v1', sku: 'U-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }, ], }); const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] }); // 只重算、不整理 → 无标签 → 空矩阵 await service.recomputeFamily(family.id); let after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); expect((after.priceMatrix as any).rows).toHaveLength(0); // 整理后进入矩阵 await organize.deriveFamilyTags(family.id); await service.recomputeFamily(family.id); after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); expect((after.priceMatrix as any).rows).toHaveLength(1); }); 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({ goodName: '美国(包邮)测试O-PO1-单面印花', variants: [ { sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }, ], }); const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] }); await prisma.familyPriceOverride.createMany({ data: [ { 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) }, ], }); await service.recomputeFamily(family.id); 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' && 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 === '丝印'); expect(added).toBeTruthy(); expect(added.manual).toBe(true); expect(added.printCount).toBe('四面印花'); expect(added.sources).toEqual([]); expect(matrix.crafts).toContain('丝印'); expect(matrix.printCounts).toContain('四面印花'); expect(matrix.logistics).toContain('海运'); expect(matrix.sizes.some((s: any) => s.key === 'size_M')).toBe(true); }); it('autoManaged=false:只置 stale,不覆盖物化字段', async () => { const a = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮', variants: [ { sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }, ], }); const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id], autoManaged: false }); await prisma.productFamily.update({ where: { id: family.id }, data: { priceMatrix: { version: 'locked' } as Prisma.InputJsonValue }, }); await service.recomputeFamily(family.id); const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); expect(after.stale).toBe(true); expect((after.priceMatrix as any).version).toBe('locked'); // 未被覆盖 }); it('canonical detail:为空时从主链接初始化一次,非空不动', async () => { const a = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮' }); await prisma.originGoodDetail.create({ data: { originGoodId: a.id, englishName: 'Cotton Tee', materialDescription: '100% 棉' }, }); const b = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮' }); await prisma.originGoodDetail.create({ data: { originGoodId: b.id, englishName: 'Should Not Win' }, }); const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id] }); await service.recomputeFamily(family.id); let after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); expect((after.detail as any).englishName).toBe('Cotton Tee'); expect((after.detail as any).materialDescription).toBe('100% 棉'); await prisma.productFamily.update({ where: { id: family.id }, data: { detail: { englishName: '人工已改' } as Prisma.InputJsonValue }, }); await service.recomputeFamily(family.id); after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); expect((after.detail as any).englishName).toBe('人工已改'); }); it('族不存在时 no-op 不抛错', async () => { await expect(service.recomputeFamily(999999n)).resolves.toBeUndefined(); }); it('enqueue 去重且串行执行', async () => { const a = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮', variants: [{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }], }); const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] }); const spy = jest.spyOn(service, 'recomputeFamily').mockResolvedValue(); service.enqueue(family.id); service.enqueue(family.id); service.enqueue(family.id); await new Promise((r) => setTimeout(r, 50)); expect(spy).toHaveBeenCalledTimes(1); spy.mockRestore(); }); });