import { Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { isAutoTagGroupName } from './auto-tag-rules'; /** * 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。 * 设计: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; /** 印花数量维:单面印花 / 双面印花 */ printCount: string; /** 工艺维:烫画 / 直喷 / 不打印 */ 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 }>; printCounts: string[]; 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; originGoodTags: { select: { tag: { select: { tagName: true } } } }; }; }>; type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>; /** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */ const DIM_VALUES = { printCount: ['单面印花', '双面印花'], craft: ['烫画', '直喷', '不打印', '热转印'], logistics: ['包邮', '不包邮'], } 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; logistics: string; } /** * 成员在矩阵中的归因维度组合(纯函数),取值只有两个来源—— * 1. 链接有效标签(人工接管后仍准确,仅认封闭词表内的标签); * 2. CUSTOM 成员的管理员显式标签(craftLabel/logisticsLabel,自由文本)。 * 【不解析名称】:SDS 成员无标签 → 返回空(不进矩阵),由整理(OrganizeService)显式补标签。 * 多值时取笛卡尔积 —— 一个链接理论上只属一个组合,此处只是容错。 */ export function memberMatrixCombos(input: { tagNames: string[]; customLabels?: { craft?: string | null; logistics?: string | null }; }): MatrixCombo[] { const values = (dim: DimKey): string[] => DIM_VALUES[dim].filter((v) => input.tagNames.includes(v)); let printCounts = values('printCount'); let crafts = values('craft'); let logistics = values('logistics'); // CUSTOM 成员:管理员显式标签是唯一来源(craftLabel/logisticsLabel,自由文本) const craftLabel = input.customLabels?.craft ?? ''; const logisticsLabel = input.customLabels?.logistics ?? ''; if (!crafts.length && craftLabel) crafts = [craftLabel]; if (!logistics.length && logisticsLabel) logistics = [logisticsLabel]; // 结构化占位规则(非名称解析):工艺=不打印 时印花面不存在,印花数量固定单面占位, // 保证光板链接的价格进矩阵;工艺=烫画/直喷 而缺印花数量标签 → 缺维度不进矩阵(等整理补标签) const noPrintCraft = crafts.includes('不打印') || craftLabel.includes('不打印') || craftLabel.includes('光板'); if (!printCounts.length && noPrintCraft) { printCounts = [DIM_VALUES.printCount[0]]; } else if (!printCounts.length && craftLabel.includes('双面')) { printCounts = ['双面印花']; } if (!printCounts.length || !crafts.length || !logistics.length) return []; const combos: MatrixCombo[] = []; for (const printCount of printCounts) { for (const craft of crafts) { for (const logistic of logistics) { combos.push({ printCount, craft, logistics: logistic }); } } } return combos; } 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, printCount, craft, logistics) → 价格。 * 尺寸键 = variant.sizeId ?? variant.sizeName;颜色键 = variant.colorId ?? variant.colorName; * 归因维度取成员链接标签(见 memberMatrixCombos)。 * 同格子多来源取最低价,sources 全保留;随后合并人工覆盖(manual=true)。 */ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]): PriceMatrix { const sizes = new Map(); const colors = new Map(); const cellMap = new Map(); const memberCombos = members.map((member) => ({ member, combos: memberMatrixCombos({ tagNames: member.originGoodTags.map((r) => r.tag.tagName), // CUSTOM 成员无同步标签,管理员显式填写的标签字段是其唯一归因来源 customLabels: member.source === 'CUSTOM' ? { craft: member.craftLabel, logistics: member.logisticsLabel } : undefined, }), })); const noteVariant = (variant: Member['variants'][number]) => { const sizeKey = String(variant.sizeId ?? variant.sizeName ?? ''); const colorKey = String(variant.colorId ?? variant.colorName ?? ''); 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, }); } return { sizeKey, colorKey }; }; for (const { member, combos } of memberCombos) { for (const combo of combos) { for (const variant of member.variants) { if (variant.price === null) continue; const { sizeKey, colorKey } = noteVariant(variant); if (!sizeKey && !colorKey) continue; const key = `${sizeKey}|${colorKey}|${combo.printCount}|${combo.craft}|${combo.logistics}`; 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, printCount: combo.printCount, craft: combo.craft, logistics: combo.logistics, price: priceStr, manual: false, sources: [source], }); } else { existing.sources.push(source); if (Number(variant.price) < Number(existing.price)) existing.price = priceStr; } } } } const printCounts = [ ...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.printCount))), ]; const crafts = [...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.craft)))]; const logisticsOptions = [ ...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.logistics))), ]; const rows = [...cellMap.values()]; // 人工覆盖:命中改价,未命中新增行(人工补组合),选项并入覆盖用到的取值 for (const override of overrides) { if (!printCounts.includes(override.printCount)) printCounts.push(override.printCount); 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.printCount}|${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, printCount: override.printCount, 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 })), printCounts: sortOptions(printCounts, OPTION_DISPLAY_ORDER.printCount), crafts: sortOptions(crafts, OPTION_DISPLAY_ORDER.craft), logistics: sortOptions(logisticsOptions, OPTION_DISPLAY_ORDER.logistics), 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 }, // 固定遍历序:保证"首次遇到序"(未知自由文本取值的相对顺序)确定性,重算幂等 orderBy: { id: 'asc' }, include: { detail: true, variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } }, originGoodTags: { select: { tag: { select: { tagName: true } } } }, }, }, 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 }, }); } } /** 把链接的有效标签镜像到其名下商品(good 标签 = 链接标签 ∪ 非自动组既有标签) */ async mirrorLinkTagsToGoods(ogId: bigint): Promise { const rows = await this.prisma.originGoodTag.findMany({ where: { originGoodId: ogId }, select: { tagId: true }, }); const linkTagIds = rows.map((r) => r.tagId); const goods = await this.prisma.good.findMany({ where: { originGoodId: ogId }, include: { goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } }, }, }); if (!goods.length) return; const groups = await this.prisma.tagGroup.findMany({ select: { id: true, groupName: true }, }); const autoGroupIds = new Set( groups.filter((g) => isAutoTagGroupName(g.groupName)).map((g) => g.id.toString()), ); for (const good of goods) { const keep = good.goodTags .filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? '')) .map((gt) => gt.tagId); const target = [...new Set([...keep, ...linkTagIds])].sort((a, b) => Number(a - b), ); const current = good.goodTags.map((gt) => gt.tagId).sort((a, b) => Number(a - b)); const same = target.length === current.length && target.every((id, i) => id === current[i]); if (same) continue; await this.prisma.$transaction([ this.prisma.goodTag.deleteMany({ where: { goodId: good.id } }), ...(!target.length ? [] : [ this.prisma.goodTag.createMany({ data: target.map((tagId) => ({ goodId: good.id, tagId })), }), ]), ]); } } }