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:
@@ -24,7 +24,8 @@
|
|||||||
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
|
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
|
||||||
"import:product-detail": "ts-node prisma/import-product-detail.ts",
|
"import:product-detail": "ts-node prisma/import-product-detail.ts",
|
||||||
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
|
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
|
||||||
"organize": "ts-node prisma/backfill-product-families.ts"
|
"organize": "ts-node prisma/backfill-product-families.ts",
|
||||||
|
"recompute:families": "ts-node prisma/recompute-all-families.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^3.0.1",
|
"@nestjs/axios": "^3.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 全量族重算(一次性维护脚本):
|
||||||
|
* 逐族调用 FamilyRecomputeService.recomputeFamily,刷新物化字段
|
||||||
|
* (并集尺码表/包装规则 + 五维价格矩阵,含选项组词表序排序)。
|
||||||
|
* autoManaged=false 的族内部只置 stale,不会覆盖人工物化字段。
|
||||||
|
*
|
||||||
|
* 运行:pnpm --filter @inkreach/api recompute:families
|
||||||
|
*/
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaService();
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
const recompute = new FamilyRecomputeService(prisma);
|
||||||
|
|
||||||
|
const families = await prisma.productFamily.findMany({
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const { id } of families) {
|
||||||
|
try {
|
||||||
|
await recompute.recomputeFamily(id);
|
||||||
|
ok++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
console.error(`family ${id} recompute failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[recompute:families] total=${families.length} ok=${ok} failed=${failed}`);
|
||||||
|
await prisma.onModuleDestroy();
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { Prisma } from '@prisma/client';
|
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 { ProductFamiliesService } from './product-families.service';
|
||||||
import { OrganizeService } from './organize.service';
|
import { OrganizeService } from './organize.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -368,3 +368,86 @@ describe('FamilyRecomputeService', () => {
|
|||||||
spy.mockRestore();
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -71,6 +71,26 @@ const DIM_VALUES = {
|
|||||||
} as const satisfies Record<string, readonly string[]>;
|
} as const satisfies Record<string, readonly string[]>;
|
||||||
|
|
||||||
type DimKey = keyof typeof DIM_VALUES;
|
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 {
|
export interface MatrixCombo {
|
||||||
printCount: string;
|
printCount: string;
|
||||||
craft: string;
|
craft: string;
|
||||||
@@ -268,9 +288,9 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
|||||||
return {
|
return {
|
||||||
sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })),
|
sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })),
|
||||||
colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })),
|
colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })),
|
||||||
printCounts,
|
printCounts: sortOptions(printCounts, OPTION_DISPLAY_ORDER.printCount),
|
||||||
crafts,
|
crafts: sortOptions(crafts, OPTION_DISPLAY_ORDER.craft),
|
||||||
logistics: logisticsOptions,
|
logistics: sortOptions(logisticsOptions, OPTION_DISPLAY_ORDER.logistics),
|
||||||
rows,
|
rows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -328,6 +348,8 @@ export class FamilyRecomputeService {
|
|||||||
include: {
|
include: {
|
||||||
originGoods: {
|
originGoods: {
|
||||||
where: { delisted: false },
|
where: { delisted: false },
|
||||||
|
// 固定遍历序:保证"首次遇到序"(未知自由文本取值的相对顺序)确定性,重算幂等
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
detail: true,
|
detail: true,
|
||||||
variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },
|
variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },
|
||||||
|
|||||||
Reference in New Issue
Block a user