Files
inkreach-official-website/apps/api/src/product-families/product-families.service.spec.ts
T
yeuimu 8a052773cd feat(public): family-first contract — goodId=familyId, strict 5-dim price matrix
公开契约族化(前端只需知道款号/族):
- GET /public/goods 一族一条(goodId=族ID,price=族起价,分页作用于分组后);
  无族商品(自定义)不进任何公开端点(列表/首页/分类树/标签统计)
- GET /public/goods/:goodId 仅认族 ID;公共字段取代表 Good,变体=全体成员并集,
  尺码表/包装规格=族物化并集;旧 SDS 链接 ID 寻址 404
- priceMatrix 严格五维:尺码×颜色×印花数量×工艺×物流;维度来源改为链接级
  标签(人工接管按人工标签),弃用原始 craftLabel;CUSTOM 成员尊重显式标签
- 名称派生补裸「单面/双面」写法(直喷双面→双面印花+直喷,18 条存量链接修复)
- family_price_overrides 加 print_count 列(五键唯一),PUT/DELETE/校验五键化
- admin 编辑弹窗矩阵消费适配(SKU 列直读 printCount,成员格子三维匹配,
  改价 payload 带 printCount)
- 存量 339 族已全量重算;api 162/162、admin 22/22、双端构建绿
2026-08-28 18:43:30 +08:00

275 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> = {}) => {
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('listkeyword 过滤 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);
});
});