合并冲突的行为级裁决: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
456 lines
17 KiB
TypeScript
456 lines
17 KiB
TypeScript
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 })),
|
||
}),
|
||
]),
|
||
]);
|
||
}
|
||
}
|
||
|
||
|
||
}
|