feat(api): add family recompute service with union and price matrix

This commit is contained in:
yeuimu
2026-08-28 12:27:29 +08:00
parent c07b28def7
commit de0f5509b1
2 changed files with 580 additions and 0 deletions
@@ -0,0 +1,287 @@
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import { FamilyRecomputeService } from './family-recompute.service';
import { PrismaService } from '../prisma/prisma.service';
/**
* 集成测试(连真实库,与 goods.service.spec.ts 同模式):
* 验证并集尺码表/包装规则裁决、五维价格矩阵推导(最低价/来源累积/维度过滤)、
* 覆盖合并、autoManaged 锁定语义与 canonical detail 初始化。
*/
describe('FamilyRecomputeService', () => {
let service: FamilyRecomputeService;
let prisma: PrismaService;
const stamp = Date.now();
const createdOriginGoodIds: bigint[] = [];
const createdFamilyIds: bigint[] = [];
const mkOriginGood = async (over: {
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: `测试链接-${stamp}`,
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);
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('并集尺码表:覆盖最全的成员优先、主链接次之、按首现顺序合并', 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({
craftLabel: '单面印花',
logisticsLabel: '包邮',
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({
craftLabel: '单面印花',
logisticsLabel: '包邮', // 同格子(不同仓库)
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({
craftLabel: '双面印花',
logisticsLabel: '专线',
variants: [
{ sdsVariantId: 'v6', sku: 'D-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 30 },
],
});
const e = await mkOriginGood({
craftLabel: null, // 缺工艺 → 不参与矩阵
logisticsLabel: '包邮',
variants: [
{ sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 1 },
],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.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.craft === '单面印花' && r.logistics === '包邮');
expect(cell.price).toBe('24.5');
expect(cell.manual).toBe(false);
expect(cell.sources).toHaveLength(2); // a.v1 + b.v4;停用 v3 排除
expect(matrix.rows.find((r: any) => r.craft === '双面印花' && r.price === '30')).toBeTruthy();
expect(matrix.rows.some((r: any) => Number(r.price) === 1)).toBe(false); // e 缺归因被排除
expect(matrix.crafts.sort()).toEqual(['单面印花', '双面印花']);
expect(matrix.logistics.sort()).toEqual(['专线', '包邮']);
expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']);
});
it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', 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] });
await prisma.familyPriceOverride.createMany({
data: [
{ familyId: family.id, sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮', price: new Prisma.Decimal(23) },
{ familyId: family.id, sizeId: 'size_M', colorId: 'color_red', 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');
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.sources).toEqual([]);
expect(matrix.crafts).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();
});
});