From 1ffd73a1afe8f273428cb0c918d61c756f575699 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 15:52:41 +0800 Subject: [PATCH 1/2] docs(plan): add price-matrix option sort fix plan --- plans/fix/price-matrix-option-sort-fix.md | 57 +++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 plans/fix/price-matrix-option-sort-fix.md diff --git a/plans/fix/price-matrix-option-sort-fix.md b/plans/fix/price-matrix-option-sort-fix.md new file mode 100644 index 0000000..39298d8 --- /dev/null +++ b/plans/fix/price-matrix-option-sort-fix.md @@ -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 + 再跑一次全库重算(旧逻辑会把顺序"洗回"遇到序,仅顺序变化无数据损坏)。 From a84b3b5daaf5592bbdd1ee414dbf21b0a1bc65d8 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 16:00:12 +0800 Subject: [PATCH 2/2] fix(product-families): sort price-matrix option arrays by canonical display order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/package.json | 3 +- apps/api/prisma/recompute-all-families.ts | 40 +++++++++ .../family-recompute.service.spec.ts | 85 ++++++++++++++++++- .../family-recompute.service.ts | 28 +++++- 4 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 apps/api/prisma/recompute-all-families.ts diff --git a/apps/api/package.json b/apps/api/package.json index 4c8c08d..132e526 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,7 +24,8 @@ "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts", "import:product-detail": "ts-node prisma/import-product-detail.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": { "@nestjs/axios": "^3.0.1", diff --git a/apps/api/prisma/recompute-all-families.ts b/apps/api/prisma/recompute-all-families.ts new file mode 100644 index 0000000..77c7f62 --- /dev/null +++ b/apps/api/prisma/recompute-all-families.ts @@ -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); +}); diff --git a/apps/api/src/product-families/family-recompute.service.spec.ts b/apps/api/src/product-families/family-recompute.service.spec.ts index 9c80615..b238c99 100644 --- a/apps/api/src/product-families/family-recompute.service.spec.ts +++ b/apps/api/src/product-families/family-recompute.service.spec.ts @@ -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)); + }); +}); diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts index f7ff66c..daa3d26 100644 --- a/apps/api/src/product-families/family-recompute.service.ts +++ b/apps/api/src/product-families/family-recompute.service.ts @@ -71,6 +71,26 @@ const DIM_VALUES = { } as const satisfies Record; 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; @@ -268,9 +288,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, }; } @@ -328,6 +348,8 @@ export class FamilyRecomputeService { include: { originGoods: { where: { delisted: false }, + // 固定遍历序:保证"首次遇到序"(未知自由文本取值的相对顺序)确定性,重算幂等 + orderBy: { id: 'asc' }, include: { detail: true, variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },