Files
inkreach-official-website/apps/api/src/product-families/family-recompute.service.ts
T
yeuimu f1e81872e7 fix(product-family): auto-merge SKUs after manual tag edits; fix session cookie path
问题2(SKU 无法自动合并,GBIU017 案例)四处叠加根因修复:
- 人工标签旧词「热转印」被封闭词表静默踢出合并矩阵 → CRAFT_TAG_ALIASES
  别名归一为「烫画」(矩阵归因与 CUSTOM 标签同路径生效)
- updateTags 缺任一定价维度组即整链掉出矩阵且人工接管后永不恢复 →
  缺啥补啥(按链接名派生补齐,人工勾选值不动,响应带 filledDimensionTags)
- autoGroup 只建新族从不并入已有族(产生 USIU005-2 类碎片)→ 并入已有
  autoManaged 同款族优先,无匹配才新建;同款仅人工锁定族则跳过并报告
- 名称回退分组键含物流备注,包邮/不包邮永不同组 → 新增族语义键
  familyNameKey(国家+品名+SKU),api/admin 两侧同构,合并默认勾选随之修复

附加:
- updateTags 后未归族链接自动并入匹配族(attachToMatchingFamily,响应带
  attachedFamilyId)
- 整理新增碎片族合并 consolidateFragments:纯碎片族并入带商品族并删除
  (保公开 goodId=族ID 稳定),带商品/覆盖价/人工锁定进人工复审报告
- admin:保存标签提示补齐明细,整理完成消息含并入/碎片合并/待人工数

问题1(后台频繁 Unauthorized 掉线):
- refresh cookie path '/auth' 与代理前缀(/api、/v2-api)不匹配导致浏览器
  永远带不上 refresh cookie → 两 cookie path 统一为 '/'
- v2 构建基址带尾斜杠 + 手工拼接产生 /v2-api//auth/refresh 双斜杠 404 →
  request.ts 规范拼接

测试:api jest 187/187、admin vitest 22/22、双侧 tsc 0 错误;
public.service 夹具改为自包含(不依赖共享库既有数据)。
2026-09-02 18:39:35 +08:00

436 lines
16 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, normalizeCraftTag } 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;
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 tagNames = input.tagNames.map(normalizeCraftTag);
const values = (dim: DimKey): string[] =>
DIM_VALUES[dim].filter((v) => tagNames.includes(v));
let printCounts = values('printCount');
let crafts = values('craft');
let logistics = values('logistics');
// CUSTOM 成员:管理员显式标签是唯一来源(craftLabel/logisticsLabel,自由文本)
const craftLabel = normalizeCraftTag(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,
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<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 },
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 })),
}),
]),
]);
}
}
}