feat(api): add family recompute service with union and price matrix
This commit is contained in:
@@ -0,0 +1,287 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { FamilyRecomputeService } from './family-recompute.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 集成测试(连真实库,与 goods.service.spec.ts 同模式):
|
||||||
|
* 验证并集尺码表/包装规则裁决、五维价格矩阵推导(最低价/来源累积/维度过滤)、
|
||||||
|
* 覆盖合并、autoManaged 锁定语义与 canonical detail 初始化。
|
||||||
|
*/
|
||||||
|
describe('FamilyRecomputeService', () => {
|
||||||
|
let service: FamilyRecomputeService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const stamp = Date.now();
|
||||||
|
const createdOriginGoodIds: bigint[] = [];
|
||||||
|
const createdFamilyIds: bigint[] = [];
|
||||||
|
|
||||||
|
const mkOriginGood = async (over: {
|
||||||
|
craftLabel?: string | null;
|
||||||
|
logisticsLabel?: string | null;
|
||||||
|
variants?: Array<{
|
||||||
|
sdsVariantId: string;
|
||||||
|
sku: string;
|
||||||
|
sizeId?: string;
|
||||||
|
sizeName?: string;
|
||||||
|
colorId?: string;
|
||||||
|
colorName?: string;
|
||||||
|
price: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}>;
|
||||||
|
sizeChart?: object;
|
||||||
|
packageSpecs?: object;
|
||||||
|
}) => {
|
||||||
|
const og = await prisma.originGood.create({
|
||||||
|
data: {
|
||||||
|
sdsGoodId: `recompute-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
goodName: `测试链接-${stamp}`,
|
||||||
|
source: 'CUSTOM',
|
||||||
|
craftLabel: over.craftLabel ?? null,
|
||||||
|
logisticsLabel: over.logisticsLabel ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdOriginGoodIds.push(og.id);
|
||||||
|
if (over.variants?.length) {
|
||||||
|
await prisma.originGoodVariant.createMany({
|
||||||
|
data: over.variants.map((v) => ({
|
||||||
|
originGoodId: og.id,
|
||||||
|
sdsVariantId: v.sdsVariantId,
|
||||||
|
sku: v.sku,
|
||||||
|
sizeId: v.sizeId ?? null,
|
||||||
|
sizeName: v.sizeName ?? null,
|
||||||
|
colorId: v.colorId ?? null,
|
||||||
|
colorName: v.colorName ?? null,
|
||||||
|
price: new Prisma.Decimal(v.price),
|
||||||
|
enabled: v.enabled ?? true,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (over.sizeChart || over.packageSpecs) {
|
||||||
|
await prisma.originGoodDetail.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: og.id,
|
||||||
|
sizeChart: (over.sizeChart ?? undefined) as Prisma.InputJsonValue,
|
||||||
|
packageSpecs: (over.packageSpecs ?? undefined) as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return og;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mkFamily = async (over: {
|
||||||
|
primaryOriginGoodId?: bigint | null;
|
||||||
|
autoManaged?: boolean;
|
||||||
|
memberIds?: bigint[];
|
||||||
|
}) => {
|
||||||
|
const family = await prisma.productFamily.create({
|
||||||
|
data: {
|
||||||
|
familyName: `测试族-${stamp}-${createdFamilyIds.length}`,
|
||||||
|
autoManaged: over.autoManaged ?? true,
|
||||||
|
primaryOriginGoodId: over.primaryOriginGoodId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdFamilyIds.push(family.id);
|
||||||
|
if (over.memberIds?.length) {
|
||||||
|
await prisma.originGood.updateMany({
|
||||||
|
where: { id: { in: over.memberIds } },
|
||||||
|
data: { familyId: family.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return family;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [FamilyRecomputeService, PrismaService],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(FamilyRecomputeService);
|
||||||
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
|
||||||
|
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('并集尺码表:覆盖最全的成员优先、主链接次之、按首现顺序合并', async () => {
|
||||||
|
// A:主链接,2 行;C:非主链接,3 行 → C 的 S 行胜出
|
||||||
|
const a = await mkOriginGood({
|
||||||
|
craftLabel: '单面印花',
|
||||||
|
logisticsLabel: '包邮',
|
||||||
|
sizeChart: { columns: [{ key: 'chest', name: '胸围' }], rows: [
|
||||||
|
{ sizeId: 'size_S', sizeName: 'S', chest: 100 },
|
||||||
|
{ sizeId: 'size_XL', sizeName: 'XL', chest: 108 },
|
||||||
|
] },
|
||||||
|
});
|
||||||
|
const c = await mkOriginGood({
|
||||||
|
craftLabel: '单面印花',
|
||||||
|
logisticsLabel: '包邮',
|
||||||
|
sizeChart: { columns: [{ key: 'chest', name: '胸围' }], rows: [
|
||||||
|
{ sizeId: 'size_S', sizeName: 'S', chest: 98 },
|
||||||
|
{ sizeId: 'size_XXL', sizeName: 'XXL', chest: 112 },
|
||||||
|
{ sizeId: 'size_XXXL', sizeName: 'XXXL', chest: 116 },
|
||||||
|
] },
|
||||||
|
});
|
||||||
|
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, c.id] });
|
||||||
|
|
||||||
|
await service.recomputeFamily(family.id);
|
||||||
|
|
||||||
|
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
|
const chart = after.sizeChart as { rows: Array<{ sizeName: string; chest: number }> };
|
||||||
|
const names = chart.rows.map((r) => r.sizeName);
|
||||||
|
expect(names).toEqual(['S', 'XXL', 'XXXL', 'XL']); // C(3行) 优先遍历,A 补充 XL
|
||||||
|
expect(chart.rows.find((r) => r.sizeName === 'S')?.chest).toBe(98); // 冲突取覆盖最全成员
|
||||||
|
expect(after.stale).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('价格矩阵:同格子取最低价并累积来源;缺失归因维度/停用变体不参与', async () => {
|
||||||
|
const a = await mkOriginGood({
|
||||||
|
craftLabel: '单面印花',
|
||||||
|
logisticsLabel: '包邮',
|
||||||
|
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 },
|
||||||
|
{ sdsVariantId: 'v3', sku: 'A-S-BLK-OFF', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 20, enabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const b = await mkOriginGood({
|
||||||
|
craftLabel: '单面印花',
|
||||||
|
logisticsLabel: '包邮', // 同格子(不同仓库)
|
||||||
|
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: '专线',
|
||||||
|
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: '包邮',
|
||||||
|
variants: [
|
||||||
|
{ sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.id] });
|
||||||
|
|
||||||
|
await service.recomputeFamily(family.id);
|
||||||
|
|
||||||
|
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 === '包邮');
|
||||||
|
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(['专线', '包邮']);
|
||||||
|
expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', async () => {
|
||||||
|
const a = await mkOriginGood({
|
||||||
|
craftLabel: '单面印花',
|
||||||
|
logisticsLabel: '包邮',
|
||||||
|
variants: [
|
||||||
|
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
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) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.recomputeFamily(family.id);
|
||||||
|
|
||||||
|
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');
|
||||||
|
expect(hit.price).toBe('23');
|
||||||
|
expect(hit.manual).toBe(true);
|
||||||
|
const added = matrix.rows.find((r: any) => r.sizeId === 'size_M' && r.craft === '三面印花');
|
||||||
|
expect(added).toBeTruthy();
|
||||||
|
expect(added.manual).toBe(true);
|
||||||
|
expect(added.sources).toEqual([]);
|
||||||
|
expect(matrix.crafts).toContain('三面印花');
|
||||||
|
expect(matrix.logistics).toContain('海运');
|
||||||
|
expect(matrix.sizes.some((s: any) => s.key === 'size_M')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autoManaged=false:只置 stale,不覆盖物化字段', async () => {
|
||||||
|
const a = await mkOriginGood({
|
||||||
|
craftLabel: '单面印花',
|
||||||
|
logisticsLabel: '包邮',
|
||||||
|
variants: [
|
||||||
|
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id], autoManaged: false });
|
||||||
|
await prisma.productFamily.update({
|
||||||
|
where: { id: family.id },
|
||||||
|
data: { priceMatrix: { version: 'locked' } as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.recomputeFamily(family.id);
|
||||||
|
|
||||||
|
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
|
expect(after.stale).toBe(true);
|
||||||
|
expect((after.priceMatrix as any).version).toBe('locked'); // 未被覆盖
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonical detail:为空时从主链接初始化一次,非空不动', async () => {
|
||||||
|
const a = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮' });
|
||||||
|
await prisma.originGoodDetail.create({
|
||||||
|
data: { originGoodId: a.id, englishName: 'Cotton Tee', materialDescription: '100% 棉' },
|
||||||
|
});
|
||||||
|
const b = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮' });
|
||||||
|
await prisma.originGoodDetail.create({
|
||||||
|
data: { originGoodId: b.id, englishName: 'Should Not Win' },
|
||||||
|
});
|
||||||
|
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id] });
|
||||||
|
|
||||||
|
await service.recomputeFamily(family.id);
|
||||||
|
let after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
|
expect((after.detail as any).englishName).toBe('Cotton Tee');
|
||||||
|
expect((after.detail as any).materialDescription).toBe('100% 棉');
|
||||||
|
|
||||||
|
await prisma.productFamily.update({
|
||||||
|
where: { id: family.id },
|
||||||
|
data: { detail: { englishName: '人工已改' } as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
await service.recomputeFamily(family.id);
|
||||||
|
after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
|
expect((after.detail as any).englishName).toBe('人工已改');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('族不存在时 no-op 不抛错', async () => {
|
||||||
|
await expect(service.recomputeFamily(999999n)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enqueue 去重且串行执行', async () => {
|
||||||
|
const a = await mkOriginGood({
|
||||||
|
craftLabel: '单面印花',
|
||||||
|
logisticsLabel: '包邮',
|
||||||
|
variants: [{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }],
|
||||||
|
});
|
||||||
|
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
|
||||||
|
const spy = jest.spyOn(service, 'recomputeFamily').mockResolvedValue();
|
||||||
|
|
||||||
|
service.enqueue(family.id);
|
||||||
|
service.enqueue(family.id);
|
||||||
|
service.enqueue(family.id);
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
|
||||||
|
* 设计: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;
|
||||||
|
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 }>;
|
||||||
|
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 };
|
||||||
|
}>;
|
||||||
|
type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>;
|
||||||
|
|
||||||
|
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, craft, logistics) → 价格。
|
||||||
|
* 尺寸键 = variant.sizeId ?? variant.sizeName;颜色键 = variant.colorId ?? variant.colorName。
|
||||||
|
* 同格子多来源取最低价,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>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 logisticsOptions = [
|
||||||
|
...new Set(members.map((m) => m.logisticsLabel).filter((l): l is string => !!l)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = [...cellMap.values()];
|
||||||
|
|
||||||
|
// 人工覆盖:命中改价,未命中新增行(人工补组合),选项并入覆盖用到的取值
|
||||||
|
for (const override of overrides) {
|
||||||
|
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 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,
|
||||||
|
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 })),
|
||||||
|
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' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user