Files
inkreach-official-website/apps/api/src/product-families/family-recompute.service.spec.ts
T
yeuimu a554b77379 fix(product-families): 热转印词表以 v2 为准——归因不再映射回烫画
合并冲突的行为级裁决:develop 侧 memberMatrixCombos 曾把 热转印→烫画
归一(旧别名方案),与 v2 线上一等公民词表(8903afd)矛盾,导致 v2 侧
三个矩阵用例失败。取 v2 版 family-recompute.service.{ts,spec}(即当前
线上行为)。另修复 v2 分支 structs.md 残留的冲突标记。

Tests: api 23 suites/217 passed (runInBand), admin 27 passed, tsc clean
2026-09-03 02:00:33 +08:00

502 lines
22 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 { Prisma } from '@prisma/client';
import {
FamilyRecomputeService,
derivePriceMatrix,
memberMatrixCombos,
} from './family-recompute.service';
import { ProductFamiliesService } from './product-families.service';
import { OrganizeService } from './organize.service';
import { PrismaService } from '../prisma/prisma.service';
/**
* 集成测试(连真实库,与 goods.service.spec.ts 同模式):
* 验证并集尺码表/包装规则裁决、五维价格矩阵推导(最低价/来源累积/维度过滤)、
* 覆盖合并、autoManaged 锁定语义与 canonical detail 初始化。
*/
describe('FamilyRecomputeService', () => {
let service: FamilyRecomputeService;
let organize!: OrganizeService;
let prisma: PrismaService;
const stamp = Date.now();
const createdOriginGoodIds: bigint[] = [];
const createdFamilyIds: bigint[] = [];
const createdTagIds: bigint[] = [];
const createdTagGroupIds: bigint[] = [];
const mkOriginGood = async (over: {
goodName?: string;
source?: 'SDS' | 'CUSTOM';
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: over.goodName ?? `测试链接-${stamp}`,
source: over.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);
organize = new OrganizeService(
moduleRef.get(PrismaService),
service,
new ProductFamiliesService(moduleRef.get(PrismaService), service),
);
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.tag.deleteMany({ where: { id: { in: createdTagIds } } });
await prisma.tagGroup.deleteMany({ where: { id: { in: createdTagGroupIds } } });
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({
source: 'SDS',
goodName: '美国(包邮)测试A-PA1-单面印花',
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({
source: 'SDS',
goodName: '美国(包邮)测试B-PB1-单面印花', // 同格子(不同仓库)
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({
source: 'SDS',
goodName: '美国(包邮)测试D-PD1-直喷双面',
variants: [
{ sdsVariantId: 'v6', sku: 'D-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 30 },
],
});
const e = await mkOriginGood({
source: 'SDS',
goodName: '美国(不包邮)测试E-PE1-光板', // 整理派生:不打印 + 不包邮(无印花数量标签 → 结构化占位单面)
variants: [
{ sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 22 },
],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.id] });
// 整理(显式派生标签)→ 重算(纯聚合)。运行时不解析名称,未派生的成员不进矩阵
await organize.deriveFamilyTags(family.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.printCount === '单面印花' && 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 排除
// 直喷双面 → 印花数量/工艺两维正确拆分
const dg = matrix.rows.find((r: any) => r.printCount === '双面印花' && r.craft === '直喷' && r.price === '30');
expect(dg).toBeTruthy();
// 光板 → 工艺=不打印、印花数量占位单面、物流=不包邮
const blank = matrix.rows.find((r: any) => r.craft === '不打印' && r.logistics === '不包邮');
expect(blank).toBeTruthy();
expect(blank.printCount).toBe('单面印花');
expect(matrix.printCounts.sort()).toEqual(['单面印花', '双面印花']);
expect(matrix.crafts.sort()).toEqual(['不打印', '烫画', '直喷']);
expect(matrix.logistics.sort()).toEqual(['不包邮', '包邮']);
expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']);
});
it('价格矩阵:未整理(无标签)的 SDS 成员不进矩阵', async () => {
const a = await mkOriginGood({
source: 'SDS',
goodName: '美国(包邮)测试U-PU1-单面印花',
variants: [
{ sdsVariantId: 'v1', sku: 'U-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
// 只重算、不整理 → 无标签 → 空矩阵
await service.recomputeFamily(family.id);
let after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect((after.priceMatrix as any).rows).toHaveLength(0);
// 整理后进入矩阵
await organize.deriveFamilyTags(family.id);
await service.recomputeFamily(family.id);
after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect((after.priceMatrix as any).rows).toHaveLength(1);
});
it('价格矩阵:人工接管标签即权威维度(人工标签 > 一切)', async () => {
const a = await mkOriginGood({
goodName: '美国(包邮)测试M-PM1-单面印花', // 名称派生是 单面/烫画/包邮
variants: [
{ sdsVariantId: 'v1', sku: 'M-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
],
});
// 人工把该链接标签改成 双面印花/直喷/不包邮(手工造标签行,模拟 updateTags 结果)
const tagIds: bigint[] = [];
for (const groupName of ['印花数量', '印刷工艺', '物流渠道']) {
const group = await prisma.tagGroup.findFirst({ where: { groupName } });
const g = group ?? (await prisma.tagGroup.create({ data: { groupName } }));
if (!group) createdTagGroupIds.push(g.id);
for (const tagName of groupName === '印花数量' ? ['双面印花'] : groupName === '印刷工艺' ? ['直喷'] : ['不包邮']) {
const existing = await prisma.tag.findFirst({ where: { tagName } });
const t = existing ?? (await prisma.tag.create({ data: { tagName, tagGroupId: g.id } }));
if (!existing) createdTagIds.push(t.id);
tagIds.push(t.id);
}
}
await prisma.originGoodTag.createMany({
data: tagIds.map((tagId) => ({ originGoodId: a.id, tagId, manual: true })),
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
await service.recomputeFamily(family.id);
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
const matrix = after.priceMatrix as any;
expect(matrix.rows).toHaveLength(1);
expect(matrix.rows[0].printCount).toBe('双面印花');
expect(matrix.rows[0].craft).toBe('直喷');
expect(matrix.rows[0].logistics).toBe('不包邮');
});
it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', async () => {
const a = await mkOriginGood({
goodName: '美国(包邮)测试O-PO1-单面印花',
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', printCount: '单面印花', craft: '烫画', logistics: '包邮', price: new Prisma.Decimal(23) },
{ familyId: family.id, sizeId: 'size_M', colorId: 'color_red', printCount: '四面印花', 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' && r.printCount === '单面印花' && r.craft === '烫画' && r.logistics === '包邮',
);
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.printCount).toBe('四面印花');
expect(added.sources).toEqual([]);
expect(matrix.crafts).toContain('丝印');
expect(matrix.printCounts).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();
});
});
/**
* derivePriceMatrix 纯函数单测:选项组排序(词表序 + 未知值沉底)。
* 业务确认的展示顺序:printCount 单面→双面;craft 烫画→直喷→不打印;logistics 不包邮→包邮。
* 词表外自由文本(CUSTOM craftLabel/logisticsLabel,如"海运")沉底,相互间保持首次遇到序。
*/
describe('derivePriceMatrix 选项组排序', () => {
const mkMember = (
id: number,
over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null },
) =>
({
id: BigInt(id),
source: over.source ?? 'SDS',
originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })),
variants: [],
craftLabel: over.craftLabel ?? null,
logisticsLabel: over.logisticsLabel ?? null,
}) as any;
it('成员顺序无关:三个维度恒为词表序', () => {
// 故意让"遇到序"与目标词表序全部相反(双面在前/直喷在前/不包邮在前)
const members = [
mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }),
mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }),
];
const m = derivePriceMatrix(members, []);
expect(m.printCounts).toEqual(['单面印花', '双面印花']);
expect(m.crafts).toEqual(['烫画', '直喷']);
expect(m.logistics).toEqual(['不包邮', '包邮']);
});
it('子集保序:只出现部分取值时相对顺序不变', () => {
const m = derivePriceMatrix([mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] })], []);
expect(m.printCounts).toEqual(['双面印花']);
expect(m.crafts).toEqual(['直喷']);
expect(m.logistics).toEqual(['不包邮']);
});
it('未知自由文本沉底且相互保持首次遇到序', () => {
const members = [
// CUSTOM:自由文本标签 + tags 提供印花数量(无印花维度不进矩阵的既有语义)
mkMember(1, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }),
mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '水洗', logisticsLabel: '空运' }),
mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }),
];
const m = derivePriceMatrix(members, []);
expect(m.printCounts).toEqual(['单面印花', '双面印花']);
expect(m.crafts).toEqual(['烫画', '丝印', '水洗']);
expect(m.logistics).toEqual(['包邮', '海运', '空运']);
});
it('人工覆盖新增取值参与同一排序', () => {
const members = [mkMember(1, { tags: ['烫画', '单面印花', '包邮'] })];
const overrides = [
{
sizeId: 'size_M',
colorId: 'color_red',
printCount: '四面印花',
craft: '丝印',
logistics: '海运',
price: '40',
},
] as any;
const m = derivePriceMatrix(members, overrides);
expect(m.printCounts).toEqual(['单面印花', '四面印花']);
expect(m.crafts).toEqual(['烫画', '丝印']);
expect(m.logistics).toEqual(['包邮', '海运']);
});
it('幂等:同输入多跑两遍输出全等(含顺序),与成员遍历顺序无关', () => {
const members = [
mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }),
mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }),
mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }),
];
const first = derivePriceMatrix(members, []);
const second = derivePriceMatrix(members, []);
const reversed = derivePriceMatrix([...members].reverse(), []);
expect(JSON.stringify(second)).toBe(JSON.stringify(first));
expect(JSON.stringify(reversed)).toBe(JSON.stringify(first));
});
});
/**
* 印刷工艺新增"热转印":矩阵维度词表放开准入 + 展示序追加末尾。
* 自动派生规则不改 —— SDS 链接只能人工打标;不打标的链接/族零影响。
*/
describe('热转印工艺', () => {
const mkMember = (
id: number,
over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null },
) =>
({
id: BigInt(id),
source: over.source ?? 'SDS',
originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })),
variants: [],
craftLabel: over.craftLabel ?? null,
logisticsLabel: over.logisticsLabel ?? null,
}) as any;
it('memberMatrixCombos:打标热转印的链接产出矩阵组合(此前被词表过滤为空)', () => {
const combos = memberMatrixCombos({ tagNames: ['单面印花', '热转印', '包邮'] });
expect(combos).toEqual([{ printCount: '单面印花', craft: '热转印', logistics: '包邮' }]);
});
it('derivePriceMatrixcrafts 展示序为 烫画→直喷→不打印→热转印(词表内,不再沉底)', () => {
const members = [
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }),
mkMember(3, { tags: ['直喷', '单面印花', '不包邮'] }),
mkMember(4, { tags: ['不打印', '单面印花', '包邮'] }),
];
const m = derivePriceMatrix(members, []);
expect(m.crafts).toEqual(['烫画', '直喷', '不打印', '热转印']);
});
it('词表外自由文本(丝印/水洗)仍然沉底,与热转印区分', () => {
const members = [
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
mkMember(2, { source: 'CUSTOM', tags: ['单面印花'], craftLabel: '丝印', logisticsLabel: '包邮' }),
];
const m = derivePriceMatrix(members, []);
expect(m.crafts).toEqual(['热转印', '丝印']);
});
});