merge: refactor/v2 完全并入 develop(v2 为准,v2 血统 20+ 提交收敛为主干)

This commit is contained in:
yeuimu
2026-09-03 01:54:32 +08:00
22 changed files with 1996 additions and 444 deletions
@@ -1,6 +1,10 @@
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import { FamilyRecomputeService } from './family-recompute.service';
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';
@@ -400,3 +404,130 @@ describe('FamilyRecomputeService', () => {
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(['热转印', '丝印']);
});
});
@@ -66,11 +66,31 @@ type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>;
/** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */
const DIM_VALUES = {
printCount: ['单面印花', '双面印花'],
craft: ['烫画', '直喷', '不打印'],
craft: ['烫画', '直喷', '不打印', '热转印'],
logistics: ['包邮', '不包邮'],
} as const satisfies Record<string, readonly string[]>;
type DimKey = keyof typeof DIM_VALUES;
/**
* 选项组展示顺序(业务确认):printCount 单面→双面;craft 烫画→直喷→不打印→热转印;logistics 不包邮→包邮。
* 与 DIM_VALUES 的"组合生成序"是两回事,物化前对聚合数组按此稳定排序;
* 词表外自由文本(CUSTOM 标签,如"海运")沉底,相互间保持首次遇到序。
*/
const OPTION_DISPLAY_ORDER = {
printCount: ['单面印花', '双面印花'],
craft: ['烫画', '直喷', '不打印', '热转印'],
logistics: ['不包邮', '包邮'],
} as const;
function optionRank(value: string, canon: readonly string[]): number {
const i = canon.indexOf(value);
return i === -1 ? canon.length : i;
}
function sortOptions(values: string[], canon: readonly string[]): string[] {
return [...values].sort((a, b) => optionRank(a, canon) - optionRank(b, canon));
}
export interface MatrixCombo {
printCount: string;
craft: string;
@@ -270,9 +290,9 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
return {
sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })),
colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })),
printCounts,
crafts,
logistics: logisticsOptions,
printCounts: sortOptions(printCounts, OPTION_DISPLAY_ORDER.printCount),
crafts: sortOptions(crafts, OPTION_DISPLAY_ORDER.craft),
logistics: sortOptions(logisticsOptions, OPTION_DISPLAY_ORDER.logistics),
rows,
};
}
@@ -330,6 +350,8 @@ export class FamilyRecomputeService {
include: {
originGoods: {
where: { delisted: false },
// 固定遍历序:保证"首次遇到序"(未知自由文本取值的相对顺序)确定性,重算幂等
orderBy: { id: 'asc' },
include: {
detail: true,
variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },