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:
@@ -13,6 +13,15 @@ describe('auto-tag-rules / deriveLinkTagNames', () => {
|
||||
).toEqual(['双面印花', '烫画', '不包邮']);
|
||||
});
|
||||
|
||||
it('工艺段写法「直喷双面/直喷单面」→ 裸 双面/单面 也归入印花数量', () => {
|
||||
expect(
|
||||
deriveLinkTagNames('日本(包邮)200g纯棉T恤-JPTM001-直喷双面'),
|
||||
).toEqual(['双面印花', '直喷', '包邮']);
|
||||
expect(
|
||||
deriveLinkTagNames('日本(包邮)200g纯棉T恤-JPTM001-直喷单面'),
|
||||
).toEqual(['单面印花', '直喷', '包邮']);
|
||||
});
|
||||
|
||||
it('不打印 + 光板(物流备注)+ 不包邮 → 归并为单个 不打印 工艺标签(光板即不打印)', () => {
|
||||
expect(
|
||||
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
|
||||
|
||||
@@ -47,8 +47,10 @@ export function deriveLinkTagNames(name: string | null | undefined): string[] {
|
||||
if (!name) return [];
|
||||
const names: string[] = [];
|
||||
|
||||
if (name.includes('双面印花')) names.push('双面印花');
|
||||
else if (name.includes('单面印花')) names.push('单面印花');
|
||||
// 「双面印花/单面印花」显式命中,或工艺段写作「直喷双面/直喷单面」的裸「双面/单面」;
|
||||
// 双面优先判断,避免「单面」误吞
|
||||
if (name.includes('双面')) names.push('双面印花');
|
||||
else if (name.includes('单面')) names.push('单面印花');
|
||||
|
||||
const craftTags = new Set<string>();
|
||||
for (const [keyword, tagName] of Object.entries(CRAFT_KEYWORD_MAP)) {
|
||||
|
||||
@@ -210,7 +210,12 @@ export class PriceOverrideItemDto {
|
||||
@IsNotEmpty()
|
||||
colorId!: string;
|
||||
|
||||
@ApiProperty({ example: '单面印花' })
|
||||
@ApiProperty({ example: '单面印花', description: '印花数量维:单面印花 / 双面印花' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
printCount!: string;
|
||||
|
||||
@ApiProperty({ example: '烫画', description: '工艺维:烫画 / 直喷 / 不打印' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
craft!: string;
|
||||
@@ -250,7 +255,12 @@ export class PriceOverrideCellDto {
|
||||
@IsNotEmpty()
|
||||
colorId!: string;
|
||||
|
||||
@ApiProperty({ example: '单面印花' })
|
||||
@ApiProperty({ example: '单面印花', description: '印花数量维:单面印花 / 双面印花' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
printCount!: string;
|
||||
|
||||
@ApiProperty({ example: '烫画', description: '工艺维:烫画 / 直喷 / 不打印' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
craft!: string;
|
||||
|
||||
@@ -14,8 +14,11 @@ describe('FamilyRecomputeService', () => {
|
||||
const stamp = Date.now();
|
||||
const createdOriginGoodIds: bigint[] = [];
|
||||
const createdFamilyIds: bigint[] = [];
|
||||
const createdTagIds: bigint[] = [];
|
||||
const createdTagGroupIds: bigint[] = [];
|
||||
|
||||
const mkOriginGood = async (over: {
|
||||
goodName?: string;
|
||||
craftLabel?: string | null;
|
||||
logisticsLabel?: string | null;
|
||||
variants?: Array<{
|
||||
@@ -34,7 +37,7 @@ describe('FamilyRecomputeService', () => {
|
||||
const og = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `recompute-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
goodName: `测试链接-${stamp}`,
|
||||
goodName: over.goodName ?? `测试链接-${stamp}`,
|
||||
source: 'CUSTOM',
|
||||
craftLabel: over.craftLabel ?? null,
|
||||
logisticsLabel: over.logisticsLabel ?? null,
|
||||
@@ -102,6 +105,8 @@ describe('FamilyRecomputeService', () => {
|
||||
afterAll(async () => {
|
||||
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
|
||||
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
|
||||
await prisma.tag.deleteMany({ where: { id: { in: createdTagIds } } });
|
||||
await prisma.tagGroup.deleteMany({ where: { id: { in: createdTagGroupIds } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
@@ -136,10 +141,9 @@ describe('FamilyRecomputeService', () => {
|
||||
expect(after.stale).toBe(false);
|
||||
});
|
||||
|
||||
it('价格矩阵:同格子取最低价并累积来源;缺失归因维度/停用变体不参与', async () => {
|
||||
it('价格矩阵:五维格子取最低价并累积来源;维度取链接标签、名称回退;停用变体不参与', async () => {
|
||||
const a = await mkOriginGood({
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮',
|
||||
goodName: '美国(包邮)测试A-PA1-单面印花',
|
||||
variants: [
|
||||
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
|
||||
{ sdsVariantId: 'v2', sku: 'A-XL-WHT', sizeId: 'size_XL', sizeName: 'XL', colorId: 'color_wht', colorName: '白色', price: 27 },
|
||||
@@ -147,25 +151,22 @@ describe('FamilyRecomputeService', () => {
|
||||
],
|
||||
});
|
||||
const b = await mkOriginGood({
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮', // 同格子(不同仓库)
|
||||
goodName: '美国(包邮)测试B-PB1-单面印花', // 同格子(不同仓库)
|
||||
variants: [
|
||||
{ sdsVariantId: 'v4', sku: 'B-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 24.5 },
|
||||
{ sdsVariantId: 'v5', sku: 'B-XXXL-BLK', sizeId: 'size_XXXL', sizeName: 'XXXL', colorId: 'color_blk', colorName: '黑色', price: 29 },
|
||||
],
|
||||
});
|
||||
const d = await mkOriginGood({
|
||||
craftLabel: '双面印花',
|
||||
logisticsLabel: '专线',
|
||||
goodName: '美国(包邮)测试D-PD1-直喷双面',
|
||||
variants: [
|
||||
{ sdsVariantId: 'v6', sku: 'D-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 30 },
|
||||
],
|
||||
});
|
||||
const e = await mkOriginGood({
|
||||
craftLabel: null, // 缺工艺 → 不参与矩阵
|
||||
logisticsLabel: '包邮',
|
||||
goodName: '美国(不包邮)测试E-PE1-光板', // 无标签行 → 名称回退:不打印 + 单面印花默认
|
||||
variants: [
|
||||
{ sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 1 },
|
||||
{ sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 22 },
|
||||
],
|
||||
});
|
||||
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.id] });
|
||||
@@ -174,21 +175,64 @@ describe('FamilyRecomputeService', () => {
|
||||
|
||||
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||
const matrix = after.priceMatrix as any;
|
||||
const cell = matrix.rows.find((r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk' && r.craft === '单面印花' && r.logistics === '包邮');
|
||||
const cell = matrix.rows.find(
|
||||
(r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk'
|
||||
&& r.printCount === '单面印花' && r.craft === '烫画' && r.logistics === '包邮',
|
||||
);
|
||||
expect(cell.price).toBe('24.5');
|
||||
expect(cell.manual).toBe(false);
|
||||
expect(cell.sources).toHaveLength(2); // a.v1 + b.v4;停用 v3 排除
|
||||
expect(matrix.rows.find((r: any) => r.craft === '双面印花' && r.price === '30')).toBeTruthy();
|
||||
expect(matrix.rows.some((r: any) => Number(r.price) === 1)).toBe(false); // e 缺归因被排除
|
||||
expect(matrix.crafts.sort()).toEqual(['单面印花', '双面印花']);
|
||||
expect(matrix.logistics.sort()).toEqual(['专线', '包邮']);
|
||||
// 直喷双面 → 印花数量/工艺两维正确拆分
|
||||
const dg = matrix.rows.find((r: any) => r.printCount === '双面印花' && r.craft === '直喷' && r.price === '30');
|
||||
expect(dg).toBeTruthy();
|
||||
// 光板(无标签)→ 工艺=不打印、印花数量回退单面、物流=不包邮
|
||||
const blank = matrix.rows.find((r: any) => r.craft === '不打印' && r.logistics === '不包邮');
|
||||
expect(blank).toBeTruthy();
|
||||
expect(blank.printCount).toBe('单面印花');
|
||||
expect(matrix.printCounts.sort()).toEqual(['单面印花', '双面印花']);
|
||||
expect(matrix.crafts.sort()).toEqual(['不打印', '烫画', '直喷']);
|
||||
expect(matrix.logistics.sort()).toEqual(['不包邮', '包邮']);
|
||||
expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']);
|
||||
});
|
||||
|
||||
it('价格矩阵:人工接管标签优先于名称派生', async () => {
|
||||
const a = await mkOriginGood({
|
||||
goodName: '美国(包邮)测试M-PM1-单面印花', // 名称派生是 单面/烫画/包邮
|
||||
variants: [
|
||||
{ sdsVariantId: 'v1', sku: 'M-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
|
||||
],
|
||||
});
|
||||
// 人工把该链接标签改成 双面印花/直喷/不包邮(手工造标签行,模拟 updateTags 结果)
|
||||
const tagIds: bigint[] = [];
|
||||
for (const groupName of ['印花数量', '印刷工艺', '物流渠道']) {
|
||||
const group = await prisma.tagGroup.findFirst({ where: { groupName } });
|
||||
const g = group ?? (await prisma.tagGroup.create({ data: { groupName } }));
|
||||
if (!group) createdTagGroupIds.push(g.id);
|
||||
for (const tagName of groupName === '印花数量' ? ['双面印花'] : groupName === '印刷工艺' ? ['直喷'] : ['不包邮']) {
|
||||
const existing = await prisma.tag.findFirst({ where: { tagName } });
|
||||
const t = existing ?? (await prisma.tag.create({ data: { tagName, tagGroupId: g.id } }));
|
||||
if (!existing) createdTagIds.push(t.id);
|
||||
tagIds.push(t.id);
|
||||
}
|
||||
}
|
||||
await prisma.originGoodTag.createMany({
|
||||
data: tagIds.map((tagId) => ({ originGoodId: a.id, tagId, manual: true })),
|
||||
});
|
||||
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
|
||||
|
||||
await service.recomputeFamily(family.id);
|
||||
|
||||
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||
const matrix = after.priceMatrix as any;
|
||||
expect(matrix.rows).toHaveLength(1);
|
||||
expect(matrix.rows[0].printCount).toBe('双面印花');
|
||||
expect(matrix.rows[0].craft).toBe('直喷');
|
||||
expect(matrix.rows[0].logistics).toBe('不包邮');
|
||||
});
|
||||
|
||||
it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', async () => {
|
||||
const a = await mkOriginGood({
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮',
|
||||
goodName: '美国(包邮)测试O-PO1-单面印花',
|
||||
variants: [
|
||||
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
|
||||
],
|
||||
@@ -196,8 +240,8 @@ describe('FamilyRecomputeService', () => {
|
||||
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
|
||||
await prisma.familyPriceOverride.createMany({
|
||||
data: [
|
||||
{ familyId: family.id, sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮', price: new Prisma.Decimal(23) },
|
||||
{ familyId: family.id, sizeId: 'size_M', colorId: 'color_red', craft: '三面印花', logistics: '海运', price: new Prisma.Decimal(40) },
|
||||
{ familyId: family.id, sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮', price: new Prisma.Decimal(23) },
|
||||
{ familyId: family.id, sizeId: 'size_M', colorId: 'color_red', printCount: '四面印花', craft: '丝印', logistics: '海运', price: new Prisma.Decimal(40) },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -205,14 +249,18 @@ describe('FamilyRecomputeService', () => {
|
||||
|
||||
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||
const matrix = after.priceMatrix as any;
|
||||
const hit = matrix.rows.find((r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk');
|
||||
const hit = matrix.rows.find(
|
||||
(r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk' && r.printCount === '单面印花' && r.craft === '烫画' && r.logistics === '包邮',
|
||||
);
|
||||
expect(hit.price).toBe('23');
|
||||
expect(hit.manual).toBe(true);
|
||||
const added = matrix.rows.find((r: any) => r.sizeId === 'size_M' && r.craft === '三面印花');
|
||||
const added = matrix.rows.find((r: any) => r.sizeId === 'size_M' && r.craft === '丝印');
|
||||
expect(added).toBeTruthy();
|
||||
expect(added.manual).toBe(true);
|
||||
expect(added.printCount).toBe('四面印花');
|
||||
expect(added.sources).toEqual([]);
|
||||
expect(matrix.crafts).toContain('三面印花');
|
||||
expect(matrix.crafts).toContain('丝印');
|
||||
expect(matrix.printCounts).toContain('四面印花');
|
||||
expect(matrix.logistics).toContain('海运');
|
||||
expect(matrix.sizes.some((s: any) => s.key === 'size_M')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -239,12 +239,12 @@ describe('ProductFamiliesService', () => {
|
||||
|
||||
await expect(
|
||||
service.putPriceOverrides(fid, [
|
||||
{ sizeId: 'size_S', colorId: 'color_blk', craft: '不存在的工艺', logistics: '包邮', price: 1 },
|
||||
{ sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '不存在的工艺', logistics: '包邮', price: 1 },
|
||||
]),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
|
||||
const result = (await service.putPriceOverrides(fid, [
|
||||
{ sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮', price: 23, note: '促销' },
|
||||
{ sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮', price: 23, note: '促销' },
|
||||
])) as any;
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].derivedPrice).toBe('25');
|
||||
@@ -256,7 +256,7 @@ describe('ProductFamiliesService', () => {
|
||||
expect(row.manual).toBe(true);
|
||||
|
||||
const restored = (await service.deletePriceOverrides(fid, {
|
||||
cells: [{ sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮' }],
|
||||
cells: [{ sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮' }],
|
||||
})) as any;
|
||||
expect(restored.items).toHaveLength(0);
|
||||
const after2 = await prisma.productFamily.findUniqueOrThrow({ where: { id: fid } });
|
||||
|
||||
@@ -344,6 +344,7 @@ export class ProductFamiliesService {
|
||||
(r) =>
|
||||
r.sizeId === o.sizeId &&
|
||||
r.colorId === o.colorId &&
|
||||
r.printCount === o.printCount &&
|
||||
r.craft === o.craft &&
|
||||
r.logistics === o.logistics,
|
||||
);
|
||||
@@ -376,6 +377,7 @@ export class ProductFamiliesService {
|
||||
const allowed = {
|
||||
sizes: new Set((matrix?.sizes ?? []).map((s) => s.key)),
|
||||
colors: new Set((matrix?.colors ?? []).map((c) => c.key)),
|
||||
printCounts: new Set(matrix?.printCounts ?? []),
|
||||
crafts: new Set(matrix?.crafts ?? []),
|
||||
logistics: new Set(matrix?.logistics ?? []),
|
||||
};
|
||||
@@ -383,6 +385,7 @@ export class ProductFamiliesService {
|
||||
(i) =>
|
||||
!allowed.sizes.has(i.sizeId) ||
|
||||
!allowed.colors.has(i.colorId) ||
|
||||
!allowed.printCounts.has(i.printCount) ||
|
||||
!allowed.crafts.has(i.craft) ||
|
||||
!allowed.logistics.has(i.logistics),
|
||||
);
|
||||
@@ -392,6 +395,7 @@ export class ProductFamiliesService {
|
||||
invalidCells: invalid.map((i) => ({
|
||||
sizeId: i.sizeId,
|
||||
colorId: i.colorId,
|
||||
printCount: i.printCount,
|
||||
craft: i.craft,
|
||||
logistics: i.logistics,
|
||||
})),
|
||||
@@ -401,10 +405,11 @@ export class ProductFamiliesService {
|
||||
for (const item of items) {
|
||||
await this.prisma.familyPriceOverride.upsert({
|
||||
where: {
|
||||
familyId_sizeId_colorId_craft_logistics: {
|
||||
familyId_sizeId_colorId_printCount_craft_logistics: {
|
||||
familyId: id,
|
||||
sizeId: item.sizeId,
|
||||
colorId: item.colorId,
|
||||
printCount: item.printCount,
|
||||
craft: item.craft,
|
||||
logistics: item.logistics,
|
||||
},
|
||||
@@ -413,6 +418,7 @@ export class ProductFamiliesService {
|
||||
familyId: id,
|
||||
sizeId: item.sizeId,
|
||||
colorId: item.colorId,
|
||||
printCount: item.printCount,
|
||||
craft: item.craft,
|
||||
logistics: item.logistics,
|
||||
price: new Prisma.Decimal(item.price),
|
||||
@@ -435,6 +441,7 @@ export class ProductFamiliesService {
|
||||
familyId: id,
|
||||
sizeId: cell.sizeId,
|
||||
colorId: cell.colorId,
|
||||
printCount: cell.printCount,
|
||||
craft: cell.craft,
|
||||
logistics: cell.logistics,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user