fix(product-families): sort price-matrix option arrays by canonical display order

printCounts/crafts/logistics were materialized in first-encounter order
(originGoods had no orderBy), so the H5 print/craft/logistics button
groups rendered in an unstable, per-family arbitrary order. Add
OPTION_DISPLAY_ORDER (单面→双面, 烫画→直喷→不打印, 不包邮→包邮) and
stable-sort the aggregated arrays before materializing; out-of-vocabulary
free-text labels (e.g. 海运) sink to the end preserving encounter order.
Fix traversal order with orderBy id asc so recomputes are fully
deterministic. Add recompute:families script for one-off re-materialize.
This commit is contained in:
yeuimu
2026-09-02 16:00:12 +08:00
parent 1ffd73a1af
commit a84b3b5daa
4 changed files with 151 additions and 5 deletions
@@ -1,6 +1,6 @@
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import { FamilyRecomputeService } from './family-recompute.service';
import { FamilyRecomputeService, derivePriceMatrix } from './family-recompute.service';
import { ProductFamiliesService } from './product-families.service';
import { OrganizeService } from './organize.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -368,3 +368,86 @@ 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', 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));
});
});