import { Test } from '@nestjs/testing'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { ProductFamiliesService } from './product-families.service'; import { FamilyRecomputeService } from './family-recompute.service'; import { PrismaService } from '../prisma/prisma.service'; describe('ProductFamiliesService', () => { let service: ProductFamiliesService; let prisma: PrismaService; const stamp = Date.now(); const createdOriginGoodIds: bigint[] = []; const createdFamilyIds: bigint[] = []; const mkOriginGood = async (name: string, over: Record = {}) => { const og = await prisma.originGood.create({ data: { sdsGoodId: `famsvc-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`, goodName: name, ...over, }, }); createdOriginGoodIds.push(og.id); return og; }; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService], }).compile(); service = moduleRef.get(ProductFamiliesService); 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.$disconnect(); }); it('create:挂成员、重算、familyCode 冲突自动加后缀', async () => { const code = `DG${stamp}T`; const a = await mkOriginGood('美国(包邮)T恤-DGTEST-单面印花', { craftLabel: '单面印花', logisticsLabel: '包邮', }); await prisma.originGoodVariant.create({ data: { originGoodId: a.id, sdsVariantId: 'svc-v1', sku: 'T-S', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: new Prisma.Decimal(25), }, }); const f1 = (await service.create({ familyName: '测试T恤', familyCode: code, originGoodIds: [a.id.toString()], primaryOriginGoodId: a.id.toString(), })) as any; createdFamilyIds.push(BigInt(f1.id)); expect(f1.familyCode).toBe(code); expect(f1._count.originGoods).toBe(1); expect((f1.priceMatrix as any).rows).toHaveLength(1); const f2 = (await service.create({ familyName: '测试T恤二号', familyCode: code })) as any; createdFamilyIds.push(BigInt(f2.id)); expect(f2.familyCode).toBe(`${code}-2`); }); it('detail:不存在 404', async () => { await expect(service.detail(999999n)).rejects.toThrow(NotFoundException); }); it('list:keyword 过滤 familyName/familyCode + 分页字段', async () => { const code = `DG${stamp}T`; const res = (await service.list({ keyword: `测试T恤`, page: 1, pageSize: 10 })) as any; expect(res.total).toBeGreaterThanOrEqual(2); expect(res.items.length).toBeGreaterThanOrEqual(2); expect(res.page).toBe(1); const byCode = (await service.list({ keyword: `${code}-2` })) as any; expect(byCode.total).toBe(1); }); it('patch:改 canonical 字段并触发重算,不丢成员', async () => { const f = (await service.create({ familyName: `补丁族-${stamp}` })) as any; createdFamilyIds.push(BigInt(f.id)); const patched = (await service.patch(BigInt(f.id), { familyName: `补丁族改-${stamp}`, autoManaged: false, })) as any; expect(patched.familyName).toBe(`补丁族改-${stamp}`); expect(patched.autoManaged).toBe(false); // 改回,避免影响后续用例 await service.patch(BigInt(f.id), { autoManaged: true }); }); 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}-单面印花`); const preview = (await service.autoGroup(false)) as any; expect(preview.applied).toBe(0); const hit = preview.groups.find((g: any) => g.groupKey.includes(`ZZ${stamp}`) && g.memberCount === 2); expect(hit).toBeTruthy(); expect(hit.familyName).toContain('卫衣'); const applied = (await service.autoGroup(true)) as any; expect(applied.applied).toBeGreaterThanOrEqual(2); // 包邮组 + 不包邮组(物流不同不同组) const families = await prisma.productFamily.findMany({ where: { familyName: { contains: `自动组${stamp}` } }, }); for (const f of families) createdFamilyIds.push(f.id); const grouped = await prisma.originGood.findMany({ where: { id: { in: [g1a.id, g1b.id, g2.id] } }, select: { familyId: true }, }); expect(grouped.every((g) => g.familyId !== null)).toBe(true); expect(grouped[0].familyId).toBe(grouped[1].familyId); // 同组同族 expect(grouped[2].familyId).not.toBe(grouped[0].familyId); // 物流不同不同族 // 幂等:候选已清空 const again = (await service.autoGroup(true)) as any; const hitAgain = again.groups.filter((g: any) => g.groupKey.includes(`ZZ${stamp}`)); 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: '包邮' }); const c = await mkOriginGood(`成员${stamp}C`, { craftLabel: '单面印花', logisticsLabel: '包邮' }); const f = (await service.create({ familyName: `成员族-${stamp}`, originGoodIds: [a.id.toString(), b.id.toString(), c.id.toString()], primaryOriginGoodId: a.id.toString(), })) as any; createdFamilyIds.push(BigInt(f.id)); const removed = (await service.updateMembers(BigInt(f.id), { removeOriginGoodIds: [a.id.toString()], })) as any; expect(removed._count.originGoods).toBe(2); expect(BigInt(removed.primaryOriginGoodId)).toBe(b.id); // 落到剩余最小 id const added = (await service.updateMembers(BigInt(f.id), { addOriginGoodIds: [a.id.toString()], })) as any; expect(added._count.originGoods).toBe(3); await expect( service.updateMembers(BigInt(f.id), { addOriginGoodIds: ['999999'] }), ).rejects.toThrow(BadRequestException); }); it('custom member:创建带归因的 CUSTOM 成员并入矩阵', async () => { const f = (await service.create({ familyName: `自建族-${stamp}` })) as any; createdFamilyIds.push(BigInt(f.id)); const member = (await service.createCustomMember(BigInt(f.id), { goodName: `自建商品-${stamp}`, logisticsLabel: '海运', craftLabel: '双面印花', skuCode: `ZZC${stamp}`, variants: [{ sku: 'C-M', sizeId: 'size_M', sizeName: 'M', colorId: 'color_red', colorName: '红色', price: 33 }], detail: { sizeChart: { rows: [{ sizeId: 'size_M', sizeName: 'M', chest: 100 }] } }, })) as any; expect(member.sdsGoodId.startsWith('custom-')).toBe(true); expect(member.logisticsLabel).toBe('海运'); const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: BigInt(f.id) } }); const matrix = after.priceMatrix as any; expect(matrix.rows).toHaveLength(1); expect(matrix.rows[0].price).toBe('33'); expect(matrix.logistics).toContain('海运'); expect((after.sizeChart as any).rows).toHaveLength(1); // 自定义成员尺码参与并集 expect(BigInt(after.primaryOriginGoodId!)).toBe(BigInt(member.id)); // 空族首个成员成为主链接 }); it('price overrides:非法维度 400;合法覆盖生效;删除恢复推导价', async () => { const a = await mkOriginGood(`覆盖${stamp}`, { craftLabel: '单面印花', logisticsLabel: '包邮' }); await prisma.originGoodVariant.create({ data: { originGoodId: a.id, sdsVariantId: 'ov-v1', sku: 'O-S', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: new Prisma.Decimal(25), }, }); const f = (await service.create({ familyName: `覆盖族-${stamp}`, originGoodIds: [a.id.toString()], primaryOriginGoodId: a.id.toString(), })) as any; createdFamilyIds.push(BigInt(f.id)); const fid = BigInt(f.id); await expect( service.putPriceOverrides(fid, [ { 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', printCount: '单面印花', craft: '烫画', logistics: '包邮', price: 23, note: '促销' }, ])) as any; expect(result.items).toHaveLength(1); expect(result.items[0].derivedPrice).toBe('25'); expect(result.items[0].diff).toBe('-2.00'); const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: fid } }); const row = (after.priceMatrix as any).rows[0]; expect(row.price).toBe('23'); expect(row.manual).toBe(true); const restored = (await service.deletePriceOverrides(fid, { 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 } }); expect((after2.priceMatrix as any).rows[0].price).toBe('25'); expect((after2.priceMatrix as any).rows[0].manual).toBe(false); }); it('recomputeNow:手动重算返回最新族详情', async () => { const f = (await service.create({ familyName: `手动重算族-${stamp}` })) as any; createdFamilyIds.push(BigInt(f.id)); const res = (await service.recomputeNow(BigInt(f.id))) as any; expect(BigInt(res.id)).toBe(BigInt(f.id)); await expect(service.recomputeNow(999999n)).rejects.toThrow(NotFoundException); }); });