merge: fix price-matrix option sort (canonical display order)
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' } },
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Fix:价格矩阵选项组按词表序输出(印花/工艺/物流按钮顺序稳定)
|
||||||
|
|
||||||
|
日期:2026-09-02
|
||||||
|
类型:Bug 修复(fix)
|
||||||
|
影响面:`apps/api/src/product-families/family-recompute.service.ts` 及其测试、一次性全库重算
|
||||||
|
|
||||||
|
## 背景与问题
|
||||||
|
|
||||||
|
详情接口 `family.priceMatrix` 的 `printCounts / crafts / logistics` 是物化进 JSONB 的字符串数组,H5 按数组原序渲染印花/工艺/物流按钮组(无前端排序)。数组顺序 = 重算时 Set 去重的"首次遇到序",且成员查询 `originGoods` 无 `orderBy`,导致:
|
||||||
|
|
||||||
|
1. 每个族的按钮顺序不一致(实测 goodId=1 为 `['双面印花','单面印花']`、`['不包邮','包邮']`,与词表相反);
|
||||||
|
2. 同一族两次重算顺序理论上可能漂移(Postgres 无顺序保证)。
|
||||||
|
|
||||||
|
对比:尺码/颜色按钮组有 `variants.sortOrder`,顺序稳定;本修复把印花/工艺/物流对齐到同样可预期。
|
||||||
|
|
||||||
|
## 目标顺序(业务确认)
|
||||||
|
|
||||||
|
| 维度 | 展示顺序 |
|
||||||
|
| --- | --- |
|
||||||
|
| printCount | 单面印花 → 双面印花 |
|
||||||
|
| craft | 烫画 → 直喷 → 不打印 |
|
||||||
|
| logistics | 不包邮 → 包邮 |
|
||||||
|
|
||||||
|
无论族内实际出现哪个子集,相对顺序恒定;词表外自由文本(如 CUSTOM 的"海运")沉到最后,相互间保持首次遇到序。
|
||||||
|
|
||||||
|
## 数据边界(2026-09-02 全库实测)
|
||||||
|
|
||||||
|
- crafts 中出现 `双面印花`×2、logistics 中出现 `海运`×2:来自 3 条 CUSTOM 链接 craftLabel/logisticsLabel 自由文本(含维度填错,属数据清理问题,本修复只保证其沉底,不改数据)。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. `derivePriceMatrix`:聚合(Set 去重)+ 人工覆盖 append 之后,对三个数组做稳定排序:
|
||||||
|
- 新增 `OPTION_DISPLAY_ORDER` 常量(printCount/craft/logistics 三组词表序,logistics 与 `DIM_VALUES` 顺序相反是业务要求;`DIM_VALUES` 组合生成语义不动);
|
||||||
|
- 排序键 = 词表下标,未知值 = `canon.length`(沉底),稳定排序保持未知值间首次遇到序。
|
||||||
|
2. `recomputeFamily` 成员查询加 `orderBy: { id: 'asc' }`,使"遇到序"确定(未知值之间顺序也确定)。
|
||||||
|
3. 只影响 `autoManaged` 族物化;非自动族人工字段照旧不碰(现有不变量)。
|
||||||
|
4. 一次性脚本:逐族调用 `recomputeFamily` 全库刷新存量(166 个族)。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不改 API 输出结构(仍是 `string[]`),前端零改动;
|
||||||
|
- 不清理 CUSTOM 维度填错数据(另行处理)。
|
||||||
|
|
||||||
|
## 测试计划(TDD)
|
||||||
|
|
||||||
|
测试文件:`apps/api/src/product-families/family-recompute.service.spec.ts`(`derivePriceMatrix` 为纯函数,可直接单测)。
|
||||||
|
|
||||||
|
1. **打乱输入仍输出词表序**:成员顺序颠倒,crafts 输出仍 `烫画→直喷→不打印`(修复前为遇到序,RED)。
|
||||||
|
2. **子集保序**:只含 `直喷+烫画` → 输出 `烫画,直喷`;只含 `双面` → 单元素。
|
||||||
|
3. **未知值沉底**:logistics 含 `海运` → `不包邮,包邮,海运`;未知值相互保持遇到序。
|
||||||
|
4. **覆盖 append 值参与同一排序**:override 新增取值后数组仍词表序。
|
||||||
|
5. **重算幂等(含顺序)**:同输入跑两遍输出 deep-equal。
|
||||||
|
6. 既有用例全绿;全量 jest + tsc 通过。
|
||||||
|
|
||||||
|
## 风险与回滚
|
||||||
|
|
||||||
|
- 纯物化顺序变化,无 schema/契约变更;回滚 = revert commit + 再跑一次全库重算(旧逻辑会把顺序"洗回"遇到序,仅顺序变化无数据损坏)。
|
||||||
Reference in New Issue
Block a user