feat(public): family-first contract — goodId=familyId, strict 5-dim price matrix
公开契约族化(前端只需知道款号/族): - GET /public/goods 一族一条(goodId=族ID,price=族起价,分页作用于分组后); 无族商品(自定义)不进任何公开端点(列表/首页/分类树/标签统计) - GET /public/goods/:goodId 仅认族 ID;公共字段取代表 Good,变体=全体成员并集, 尺码表/包装规格=族物化并集;旧 SDS 链接 ID 寻址 404 - priceMatrix 严格五维:尺码×颜色×印花数量×工艺×物流;维度来源改为链接级 标签(人工接管按人工标签),弃用原始 craftLabel;CUSTOM 成员尊重显式标签 - 名称派生补裸「单面/双面」写法(直喷双面→双面印花+直喷,18 条存量链接修复) - family_price_overrides 加 print_count 列(五键唯一),PUT/DELETE/校验五键化 - admin 编辑弹窗矩阵消费适配(SKU 列直读 printCount,成员格子三维匹配, 改价 payload 带 printCount) - 存量 339 族已全量重算;api 162/162、admin 22/22、双端构建绿
This commit is contained in:
@@ -28,6 +28,9 @@ export interface PriceMatrixRow {
|
||||
sizeName: string | null;
|
||||
colorId: string;
|
||||
colorName: string | null;
|
||||
/** 印花数量维:单面印花 / 双面印花 */
|
||||
printCount: string;
|
||||
/** 工艺维:烫画 / 直喷 / 不打印 */
|
||||
craft: string;
|
||||
logistics: string;
|
||||
price: string;
|
||||
@@ -38,6 +41,7 @@ export interface PriceMatrixRow {
|
||||
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[];
|
||||
@@ -55,10 +59,72 @@ export interface ChartLike {
|
||||
}
|
||||
|
||||
type Member = Prisma.OriginGoodGetPayload<{
|
||||
include: { detail: true; variants: true };
|
||||
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,自由文本);
|
||||
* 3. 链接名称派生(deriveLinkTagNames);
|
||||
* 4. 组内默认值(单面印花 / 烫画 / 包邮)。
|
||||
* 多值时取笛卡尔积 —— 一个链接理论上只属一个组合,此处只是容错。
|
||||
*/
|
||||
export function memberMatrixCombos(input: {
|
||||
goodName: string | null;
|
||||
tagNames: string[];
|
||||
customLabels?: { craft?: string | null; logistics?: string | null };
|
||||
}): MatrixCombo[] {
|
||||
const derived = deriveLinkTagNames(input.goodName);
|
||||
const values = (dim: DimKey): string[] => {
|
||||
const fromTags = DIM_VALUES[dim].filter((v) => input.tagNames.includes(v));
|
||||
if (fromTags.length) return [...fromTags];
|
||||
const label =
|
||||
dim === 'craft' ? input.customLabels?.craft : input.customLabels?.logistics;
|
||||
if (dim !== 'printCount' && label) return [label];
|
||||
const fromName = derived.filter((n) =>
|
||||
(DIM_VALUES[dim] as readonly string[]).includes(n),
|
||||
);
|
||||
if (fromName.length) return fromName;
|
||||
if (dim === 'printCount') {
|
||||
const craftLabel = input.customLabels?.craft ?? '';
|
||||
return [craftLabel.includes('双面') ? '双面印花' : DIM_VALUES[dim][0]];
|
||||
}
|
||||
return [DIM_VALUES[dim][0]];
|
||||
};
|
||||
const printCounts = values('printCount');
|
||||
const crafts = values('craft');
|
||||
const logistics = values('logistics');
|
||||
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 ?? '');
|
||||
}
|
||||
@@ -89,8 +155,9 @@ function firstColumns(charts: Array<ChartLike | null | undefined>): unknown {
|
||||
}
|
||||
|
||||
/**
|
||||
* 推导五维价格矩阵:(sizeKey, colorKey, craft, logistics) → 价格。
|
||||
* 尺寸键 = variant.sizeId ?? variant.sizeName;颜色键 = variant.colorId ?? variant.colorName。
|
||||
* 推导五维价格矩阵:(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 {
|
||||
@@ -98,65 +165,89 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
||||
const colors = new Map<string, { name: string | null; hex: string | null; imageUrl: string | null }>();
|
||||
const cellMap = new Map<string, PriceMatrixRow>();
|
||||
|
||||
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,
|
||||
const memberCombos = members.map((member) => ({
|
||||
member,
|
||||
combos: memberMatrixCombos({
|
||||
goodName: member.goodName,
|
||||
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,
|
||||
manual: false,
|
||||
sources: [source],
|
||||
});
|
||||
} else {
|
||||
existing.sources.push(source);
|
||||
if (Number(variant.price) < Number(existing.price)) existing.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 crafts = [...new Set(members.map((m) => m.craftLabel).filter((c): c is string => !!c))];
|
||||
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(members.map((m) => m.logisticsLabel).filter((l): l is string => !!l)),
|
||||
...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.craft}|${override.logistics}`;
|
||||
const key = `${override.sizeId}|${override.colorId}|${override.printCount}|${override.craft}|${override.logistics}`;
|
||||
const row = cellMap.get(key);
|
||||
if (row) {
|
||||
row.price = override.price.toString();
|
||||
@@ -167,6 +258,7 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
||||
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(),
|
||||
@@ -181,6 +273,7 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
||||
return {
|
||||
sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })),
|
||||
colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })),
|
||||
printCounts,
|
||||
crafts,
|
||||
logistics: logisticsOptions,
|
||||
rows,
|
||||
@@ -243,6 +336,7 @@ export class FamilyRecomputeService {
|
||||
include: {
|
||||
detail: true,
|
||||
variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },
|
||||
originGoodTags: { select: { tag: { select: { tagName: true } } } },
|
||||
},
|
||||
},
|
||||
priceOverrides: true,
|
||||
|
||||
Reference in New Issue
Block a user