Files
inkreach-official-website/apps/api/src/product-families/family-recompute.service.ts
T
yeuimu a554b77379 fix(product-families): 热转印词表以 v2 为准——归因不再映射回烫画
合并冲突的行为级裁决:develop 侧 memberMatrixCombos 曾把 热转印→烫画
归一(旧别名方案),与 v2 线上一等公民词表(8903afd)矛盾,导致 v2 侧
三个矩阵用例失败。取 v2 版 family-recompute.service.{ts,spec}(即当前
线上行为)。另修复 v2 分支 structs.md 残留的冲突标记。

Tests: api 23 suites/217 passed (runInBand), admin 27 passed, tsc clean
2026-09-03 02:00:33 +08:00

456 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, readonly string[]>;
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<ChartLike | null | undefined>): ChartSizeRow[] {
const out: ChartSizeRow[] = [];
const seen = new Set<string>();
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<ChartLike | null | undefined>): 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<string, string | null>();
const colors = new Map<string, { name: string | null; hex: string | null; imageUrl: string | null }>();
const cellMap = new Map<string, PriceMatrixRow>();
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<string, Promise<void>>();
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<void> {
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<void> {
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 })),
}),
]),
]);
}
}
}