From de0f5509b1067061a0db969f4152b7f2b4b559d8 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 12:27:29 +0800 Subject: [PATCH] feat(api): add family recompute service with union and price matrix --- .../family-recompute.service.spec.ts | 287 +++++++++++++++++ .../family-recompute.service.ts | 293 ++++++++++++++++++ 2 files changed, 580 insertions(+) create mode 100644 apps/api/src/product-families/family-recompute.service.spec.ts create mode 100644 apps/api/src/product-families/family-recompute.service.ts diff --git a/apps/api/src/product-families/family-recompute.service.spec.ts b/apps/api/src/product-families/family-recompute.service.spec.ts new file mode 100644 index 0000000..26aa4d5 --- /dev/null +++ b/apps/api/src/product-families/family-recompute.service.spec.ts @@ -0,0 +1,287 @@ +import { Test } from '@nestjs/testing'; +import { Prisma } from '@prisma/client'; +import { FamilyRecomputeService } from './family-recompute.service'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * 集成测试(连真实库,与 goods.service.spec.ts 同模式): + * 验证并集尺码表/包装规则裁决、五维价格矩阵推导(最低价/来源累积/维度过滤)、 + * 覆盖合并、autoManaged 锁定语义与 canonical detail 初始化。 + */ +describe('FamilyRecomputeService', () => { + let service: FamilyRecomputeService; + let prisma: PrismaService; + const stamp = Date.now(); + const createdOriginGoodIds: bigint[] = []; + const createdFamilyIds: bigint[] = []; + + const mkOriginGood = async (over: { + craftLabel?: string | null; + logisticsLabel?: string | null; + variants?: Array<{ + sdsVariantId: string; + sku: string; + sizeId?: string; + sizeName?: string; + colorId?: string; + colorName?: string; + price: number; + enabled?: boolean; + }>; + sizeChart?: object; + packageSpecs?: object; + }) => { + const og = await prisma.originGood.create({ + data: { + sdsGoodId: `recompute-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`, + goodName: `测试链接-${stamp}`, + source: 'CUSTOM', + craftLabel: over.craftLabel ?? null, + logisticsLabel: over.logisticsLabel ?? null, + }, + }); + createdOriginGoodIds.push(og.id); + if (over.variants?.length) { + await prisma.originGoodVariant.createMany({ + data: over.variants.map((v) => ({ + originGoodId: og.id, + sdsVariantId: v.sdsVariantId, + sku: v.sku, + sizeId: v.sizeId ?? null, + sizeName: v.sizeName ?? null, + colorId: v.colorId ?? null, + colorName: v.colorName ?? null, + price: new Prisma.Decimal(v.price), + enabled: v.enabled ?? true, + })), + }); + } + if (over.sizeChart || over.packageSpecs) { + await prisma.originGoodDetail.create({ + data: { + originGoodId: og.id, + sizeChart: (over.sizeChart ?? undefined) as Prisma.InputJsonValue, + packageSpecs: (over.packageSpecs ?? undefined) as Prisma.InputJsonValue, + }, + }); + } + return og; + }; + + const mkFamily = async (over: { + primaryOriginGoodId?: bigint | null; + autoManaged?: boolean; + memberIds?: bigint[]; + }) => { + const family = await prisma.productFamily.create({ + data: { + familyName: `测试族-${stamp}-${createdFamilyIds.length}`, + autoManaged: over.autoManaged ?? true, + primaryOriginGoodId: over.primaryOriginGoodId ?? null, + }, + }); + createdFamilyIds.push(family.id); + if (over.memberIds?.length) { + await prisma.originGood.updateMany({ + where: { id: { in: over.memberIds } }, + data: { familyId: family.id }, + }); + } + return family; + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [FamilyRecomputeService, PrismaService], + }).compile(); + service = moduleRef.get(FamilyRecomputeService); + prisma = moduleRef.get(PrismaService); + await prisma.onModuleInit(); + }); + + afterAll(async () => { + await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } }); + await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } }); + await prisma.$disconnect(); + }); + + it('并集尺码表:覆盖最全的成员优先、主链接次之、按首现顺序合并', async () => { + // A:主链接,2 行;C:非主链接,3 行 → C 的 S 行胜出 + const a = await mkOriginGood({ + craftLabel: '单面印花', + logisticsLabel: '包邮', + sizeChart: { columns: [{ key: 'chest', name: '胸围' }], rows: [ + { sizeId: 'size_S', sizeName: 'S', chest: 100 }, + { sizeId: 'size_XL', sizeName: 'XL', chest: 108 }, + ] }, + }); + const c = await mkOriginGood({ + craftLabel: '单面印花', + logisticsLabel: '包邮', + sizeChart: { columns: [{ key: 'chest', name: '胸围' }], rows: [ + { sizeId: 'size_S', sizeName: 'S', chest: 98 }, + { sizeId: 'size_XXL', sizeName: 'XXL', chest: 112 }, + { sizeId: 'size_XXXL', sizeName: 'XXXL', chest: 116 }, + ] }, + }); + const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, c.id] }); + + await service.recomputeFamily(family.id); + + const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + const chart = after.sizeChart as { rows: Array<{ sizeName: string; chest: number }> }; + const names = chart.rows.map((r) => r.sizeName); + expect(names).toEqual(['S', 'XXL', 'XXXL', 'XL']); // C(3行) 优先遍历,A 补充 XL + expect(chart.rows.find((r) => r.sizeName === 'S')?.chest).toBe(98); // 冲突取覆盖最全成员 + expect(after.stale).toBe(false); + }); + + it('价格矩阵:同格子取最低价并累积来源;缺失归因维度/停用变体不参与', async () => { + const a = await mkOriginGood({ + craftLabel: '单面印花', + logisticsLabel: '包邮', + variants: [ + { sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }, + { sdsVariantId: 'v2', sku: 'A-XL-WHT', sizeId: 'size_XL', sizeName: 'XL', colorId: 'color_wht', colorName: '白色', price: 27 }, + { sdsVariantId: 'v3', sku: 'A-S-BLK-OFF', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 20, enabled: false }, + ], + }); + const b = await mkOriginGood({ + craftLabel: '单面印花', + logisticsLabel: '包邮', // 同格子(不同仓库) + variants: [ + { sdsVariantId: 'v4', sku: 'B-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 24.5 }, + { sdsVariantId: 'v5', sku: 'B-XXXL-BLK', sizeId: 'size_XXXL', sizeName: 'XXXL', colorId: 'color_blk', colorName: '黑色', price: 29 }, + ], + }); + const d = await mkOriginGood({ + craftLabel: '双面印花', + logisticsLabel: '专线', + variants: [ + { sdsVariantId: 'v6', sku: 'D-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 30 }, + ], + }); + const e = await mkOriginGood({ + craftLabel: null, // 缺工艺 → 不参与矩阵 + logisticsLabel: '包邮', + variants: [ + { sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 1 }, + ], + }); + const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.id] }); + + await service.recomputeFamily(family.id); + + const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + const matrix = after.priceMatrix as any; + const cell = matrix.rows.find((r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk' && r.craft === '单面印花' && r.logistics === '包邮'); + expect(cell.price).toBe('24.5'); + expect(cell.manual).toBe(false); + expect(cell.sources).toHaveLength(2); // a.v1 + b.v4;停用 v3 排除 + expect(matrix.rows.find((r: any) => r.craft === '双面印花' && r.price === '30')).toBeTruthy(); + expect(matrix.rows.some((r: any) => Number(r.price) === 1)).toBe(false); // e 缺归因被排除 + expect(matrix.crafts.sort()).toEqual(['单面印花', '双面印花']); + expect(matrix.logistics.sort()).toEqual(['专线', '包邮']); + expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']); + }); + + it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', async () => { + const a = await mkOriginGood({ + craftLabel: '单面印花', + logisticsLabel: '包邮', + variants: [ + { sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }, + ], + }); + const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] }); + await prisma.familyPriceOverride.createMany({ + data: [ + { familyId: family.id, sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮', price: new Prisma.Decimal(23) }, + { familyId: family.id, sizeId: 'size_M', colorId: 'color_red', craft: '三面印花', logistics: '海运', price: new Prisma.Decimal(40) }, + ], + }); + + await service.recomputeFamily(family.id); + + const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + const matrix = after.priceMatrix as any; + const hit = matrix.rows.find((r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk'); + expect(hit.price).toBe('23'); + expect(hit.manual).toBe(true); + const added = matrix.rows.find((r: any) => r.sizeId === 'size_M' && r.craft === '三面印花'); + expect(added).toBeTruthy(); + expect(added.manual).toBe(true); + expect(added.sources).toEqual([]); + expect(matrix.crafts).toContain('三面印花'); + expect(matrix.logistics).toContain('海运'); + expect(matrix.sizes.some((s: any) => s.key === 'size_M')).toBe(true); + }); + + it('autoManaged=false:只置 stale,不覆盖物化字段', async () => { + const a = await mkOriginGood({ + craftLabel: '单面印花', + logisticsLabel: '包邮', + variants: [ + { sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }, + ], + }); + const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id], autoManaged: false }); + await prisma.productFamily.update({ + where: { id: family.id }, + data: { priceMatrix: { version: 'locked' } as Prisma.InputJsonValue }, + }); + + await service.recomputeFamily(family.id); + + const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + expect(after.stale).toBe(true); + expect((after.priceMatrix as any).version).toBe('locked'); // 未被覆盖 + }); + + it('canonical detail:为空时从主链接初始化一次,非空不动', async () => { + const a = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮' }); + await prisma.originGoodDetail.create({ + data: { originGoodId: a.id, englishName: 'Cotton Tee', materialDescription: '100% 棉' }, + }); + const b = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮' }); + await prisma.originGoodDetail.create({ + data: { originGoodId: b.id, englishName: 'Should Not Win' }, + }); + const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id] }); + + await service.recomputeFamily(family.id); + let after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + expect((after.detail as any).englishName).toBe('Cotton Tee'); + expect((after.detail as any).materialDescription).toBe('100% 棉'); + + await prisma.productFamily.update({ + where: { id: family.id }, + data: { detail: { englishName: '人工已改' } as Prisma.InputJsonValue }, + }); + await service.recomputeFamily(family.id); + after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + expect((after.detail as any).englishName).toBe('人工已改'); + }); + + it('族不存在时 no-op 不抛错', async () => { + await expect(service.recomputeFamily(999999n)).resolves.toBeUndefined(); + }); + + it('enqueue 去重且串行执行', async () => { + const a = await mkOriginGood({ + craftLabel: '单面印花', + logisticsLabel: '包邮', + variants: [{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }], + }); + const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] }); + const spy = jest.spyOn(service, 'recomputeFamily').mockResolvedValue(); + + service.enqueue(family.id); + service.enqueue(family.id); + service.enqueue(family.id); + await new Promise((r) => setTimeout(r, 50)); + + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); +}); diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts new file mode 100644 index 0000000..6e334bd --- /dev/null +++ b/apps/api/src/product-families/family-recompute.service.ts @@ -0,0 +1,293 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。 + * 设计:docs/superpowers/specs/2026-08-28-product-family-merge-design.md §7-§9 + * + * 关键不变量: + * - 推导输入全部为确定性数据(成员镜像、覆盖表),重算幂等; + * - autoManaged=false 的族只置 stale,绝不覆盖人工物化字段; + * - 覆盖表与自定义成员数据只读,永不写回。 + */ + +export interface PriceMatrixSource { + sdsGoodId: string; + sdsVariantId: string; + price: string; +} + +export interface PriceMatrixRow { + sizeId: string; + sizeName: string | null; + colorId: string; + colorName: string | null; + craft: string; + logistics: string; + price: string; + manual: boolean; + sources: PriceMatrixSource[]; +} + +export interface PriceMatrix { + sizes: Array<{ key: string; name: string | null }>; + colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>; + crafts: string[]; + logistics: string[]; + rows: PriceMatrixRow[]; +} + +export interface ChartSizeRow { + sizeId?: string | null; + sizeName?: string | null; + [key: string]: unknown; +} + +export interface ChartLike { + columns?: unknown; + rows?: ChartSizeRow[]; +} + +type Member = Prisma.OriginGoodGetPayload<{ + include: { detail: true; variants: true }; +}>; +type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>; + +function chartRowKey(row: ChartSizeRow): string { + return String(row.sizeName ?? row.sizeId ?? ''); +} + +/** + * 按优先级顺序(已排序的成员 → 各自的 chart)合并尺码行并集。 + * 同键冲突时先到先得 —— 调用方负责把"覆盖最全的成员 / 主链接"排在前面。 + */ +export function unionChartRows(charts: Array): ChartSizeRow[] { + const out: ChartSizeRow[] = []; + const seen = new Set(); + for (const chart of charts) { + for (const row of chart?.rows ?? []) { + const key = chartRowKey(row); + if (!key || seen.has(key)) continue; + seen.add(key); + out.push(row); + } + } + return out; +} + +function firstColumns(charts: Array): unknown { + for (const chart of charts) { + if (chart?.columns !== undefined && chart?.columns !== null) return chart.columns; + } + return undefined; +} + +/** + * 推导五维价格矩阵:(sizeKey, colorKey, craft, logistics) → 价格。 + * 尺寸键 = variant.sizeId ?? variant.sizeName;颜色键 = variant.colorId ?? variant.colorName。 + * 同格子多来源取最低价,sources 全保留;随后合并人工覆盖(manual=true)。 + */ +export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]): PriceMatrix { + const sizes = new Map(); + const colors = new Map(); + const cellMap = new Map(); + + for (const member of members) { + if (!member.craftLabel || !member.logisticsLabel) continue; + for (const variant of member.variants) { + if (variant.price === null) continue; + const sizeKey = String(variant.sizeId ?? variant.sizeName ?? ''); + const colorKey = String(variant.colorId ?? variant.colorName ?? ''); + if (!sizeKey && !colorKey) continue; + if (sizeKey && !sizes.has(sizeKey)) sizes.set(sizeKey, variant.sizeName); + if (colorKey) { + const prev = colors.get(colorKey); + colors.set(colorKey, { + name: prev?.name ?? variant.colorName, + hex: prev?.hex ?? variant.colorHex, + imageUrl: prev?.imageUrl ?? variant.imageUrl, + }); + } + const key = `${sizeKey}|${colorKey}|${member.craftLabel}|${member.logisticsLabel}`; + const priceStr = variant.price.toString(); + const source: PriceMatrixSource = { + sdsGoodId: member.sdsGoodId, + sdsVariantId: variant.sdsVariantId, + price: priceStr, + }; + const existing = cellMap.get(key); + if (!existing) { + cellMap.set(key, { + sizeId: sizeKey, + sizeName: variant.sizeName, + colorId: colorKey, + colorName: variant.colorName, + craft: member.craftLabel, + logistics: member.logisticsLabel, + price: priceStr, + manual: false, + sources: [source], + }); + } else { + existing.sources.push(source); + if (Number(variant.price) < Number(existing.price)) existing.price = priceStr; + } + } + } + + const crafts = [...new Set(members.map((m) => m.craftLabel).filter((c): c is string => !!c))]; + const logisticsOptions = [ + ...new Set(members.map((m) => m.logisticsLabel).filter((l): l is string => !!l)), + ]; + + const rows = [...cellMap.values()]; + + // 人工覆盖:命中改价,未命中新增行(人工补组合),选项并入覆盖用到的取值 + for (const override of overrides) { + if (!crafts.includes(override.craft)) crafts.push(override.craft); + if (!logisticsOptions.includes(override.logistics)) logisticsOptions.push(override.logistics); + if (override.sizeId && !sizes.has(override.sizeId)) sizes.set(override.sizeId, null); + if (override.colorId && !colors.has(override.colorId)) { + colors.set(override.colorId, { name: null, hex: null, imageUrl: null }); + } + const key = `${override.sizeId}|${override.colorId}|${override.craft}|${override.logistics}`; + const row = cellMap.get(key); + if (row) { + row.price = override.price.toString(); + row.manual = true; + } else { + const added: PriceMatrixRow = { + sizeId: override.sizeId, + sizeName: sizes.get(override.sizeId) ?? null, + colorId: override.colorId, + colorName: colors.get(override.colorId)?.name ?? null, + craft: override.craft, + logistics: override.logistics, + price: override.price.toString(), + manual: true, + sources: [], + }; + cellMap.set(key, added); + rows.push(added); + } + } + + return { + sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })), + colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })), + crafts, + logistics: logisticsOptions, + rows, + }; +} + +/** canonical detail 的文本字段(从主链接 detail 摘取,不含 JSON 物化与时间戳) */ +function pickDetailText(detail: Prisma.OriginGoodDetailGetPayload<{}>): Prisma.InputJsonValue { + return { + productCode: detail.productCode, + englishName: detail.englishName, + blankDesignUrl: detail.blankDesignUrl, + detailsPageVideoUrl: detail.detailsPageVideoUrl, + textureName: detail.textureName, + productionCycleHours: detail.productionCycleHours, + minWeightG: detail.minWeightG ? detail.minWeightG.toString() : null, + reminder: detail.reminder, + productionProcess: detail.productionProcess, + materialDescription: detail.materialDescription, + productPerformance: detail.productPerformance, + applicableScenarios: detail.applicableScenarios, + washingInstructions: detail.washingInstructions, + specialDescription: detail.specialDescription, + designExplanation: detail.designExplanation, + designArea: detail.designArea, + pictureRequest: detail.pictureRequest, + }; +} + +const json = (value: unknown): Prisma.InputJsonValue | typeof Prisma.DbNull => + (value === undefined || value === null ? Prisma.DbNull : value) as Prisma.InputJsonValue; + +@Injectable() +export class FamilyRecomputeService { + private readonly logger = new Logger(FamilyRecomputeService.name); + private readonly pending = new Map>(); + + constructor(private readonly prisma: PrismaService) {} + + /** 进程内去重的异步重算入口(同步钩子用) */ + enqueue(familyId: bigint): void { + const key = familyId.toString(); + if (this.pending.has(key)) return; + const run = this.recomputeFamily(familyId) + .catch((error) => { + this.logger.error(`family ${key} recompute failed: ${String(error)}`); + }) + .finally(() => { + this.pending.delete(key); + }); + this.pending.set(key, run); + } + + async recomputeFamily(familyId: bigint): Promise { + const family = await this.prisma.productFamily.findUnique({ + where: { id: familyId }, + include: { + originGoods: { + where: { delisted: false }, + include: { + detail: true, + variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } }, + }, + }, + priceOverrides: true, + }, + }); + if (!family) return; + + const members = family.originGoods; + const primary = members.find((m) => m.id === family.primaryOriginGoodId) ?? members[0] ?? null; + + // 冲突裁决优先级:尺码覆盖最全 → 主链接 → id 升序(与设计 §7 一致) + const priority = [...members].sort((a, b) => { + const aRows = a.detail?.sizeChart ? ((a.detail.sizeChart as ChartLike).rows?.length ?? 0) : 0; + const bRows = b.detail?.sizeChart ? ((b.detail.sizeChart as ChartLike).rows?.length ?? 0) : 0; + if (bRows !== aRows) return bRows - aRows; + const aPrimary = a.id === primary?.id ? 1 : 0; + const bPrimary = b.id === primary?.id ? 1 : 0; + if (aPrimary !== bPrimary) return bPrimary - aPrimary; + return Number(a.id - b.id); + }); + + const sizeCharts = priority.map((m) => m.detail?.sizeChart as ChartLike | undefined); + const packageCharts = priority.map((m) => m.detail?.packageSpecs as ChartLike | undefined); + const sizeChart = unionChartRows(sizeCharts).length + ? { columns: firstColumns(sizeCharts), rows: unionChartRows(sizeCharts) } + : null; + const packageSpecs = unionChartRows(packageCharts).length + ? { columns: firstColumns(packageCharts), rows: unionChartRows(packageCharts) } + : null; + const matrix = derivePriceMatrix(members, family.priceOverrides); + + if (family.autoManaged) { + await this.prisma.productFamily.update({ + where: { id: family.id }, + data: { + sizeChart: json(sizeChart), + packageSpecs: json(packageSpecs), + priceMatrix: json(matrix), + stale: false, + // canonical detail 仅在为空时从主链接初始化一次,人工编辑后永不被覆盖 + ...(family.detail === null && primary?.detail + ? { detail: pickDetailText(primary.detail) } + : {}), + }, + }); + } else { + await this.prisma.productFamily.update({ + where: { id: family.id }, + data: { stale: true }, + }); + } + } +}