公开契约族化(前端只需知道款号/族): - 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、双端构建绿
275 lines
12 KiB
TypeScript
275 lines
12 KiB
TypeScript
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('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);
|
||
});
|
||
});
|