feat(api): product families module with CRUD, auto-group, members, custom members and price overrides
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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 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: 'DGTEST',
|
||||
originGoodIds: [a.id.toString()],
|
||||
primaryOriginGoodId: a.id.toString(),
|
||||
})) as any;
|
||||
createdFamilyIds.push(BigInt(f1.id));
|
||||
expect(f1.familyCode).toBe('DGTEST');
|
||||
expect(f1._count.originGoods).toBe(1);
|
||||
expect((f1.priceMatrix as any).rows).toHaveLength(1);
|
||||
|
||||
const f2 = (await service.create({ familyName: '测试T恤二号', familyCode: 'DGTEST' })) as any;
|
||||
createdFamilyIds.push(BigInt(f2.id));
|
||||
expect(f2.familyCode).toBe('DGTEST-2');
|
||||
});
|
||||
|
||||
it('detail:不存在 404', async () => {
|
||||
await expect(service.detail(999999n)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('list:keyword 过滤 familyName/familyCode + 分页字段', async () => {
|
||||
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: 'DGTEST-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('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', craft: '不存在的工艺', logistics: '包邮', price: 1 },
|
||||
]),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
|
||||
const result = (await service.putPriceOverrides(fid, [
|
||||
{ sizeId: 'size_S', colorId: 'color_blk', 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', 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user