feat(api): product families module with CRUD, auto-group, members, custom members and price overrides

This commit is contained in:
yeuimu
2026-08-28 12:30:57 +08:00
parent de0f5509b1
commit f68898dea4
6 changed files with 1051 additions and 0 deletions
+2
View File
@@ -11,6 +11,7 @@ import { TagsModule } from './tags/tags.module';
import { TagGroupsModule } from './tag-groups/tag-groups.module'; import { TagGroupsModule } from './tag-groups/tag-groups.module';
import { PositionsModule } from './positions/positions.module'; import { PositionsModule } from './positions/positions.module';
import { OriginGoodsModule } from './origin-goods/origin-goods.module'; import { OriginGoodsModule } from './origin-goods/origin-goods.module';
import { ProductFamiliesModule } from './product-families/product-families.module';
import { GoodsModule } from './goods/goods.module'; import { GoodsModule } from './goods/goods.module';
import { SyncModule } from './sync/sync.module'; import { SyncModule } from './sync/sync.module';
import { PublicModule } from './public/public.module'; import { PublicModule } from './public/public.module';
@@ -37,6 +38,7 @@ import { UploadModule } from './upload/upload.module';
TagGroupsModule, TagGroupsModule,
PositionsModule, PositionsModule,
OriginGoodsModule, OriginGoodsModule,
ProductFamiliesModule,
GoodsModule, GoodsModule,
SyncModule, SyncModule,
PublicModule, PublicModule,
@@ -0,0 +1,270 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsNumber,
IsNumberString,
IsObject,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
export class CreateProductFamilyDto {
@ApiProperty({ example: '180g纯棉T恤(成人款)' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
familyName!: string;
@ApiProperty({ required: false, example: 'DG001' })
@IsOptional()
@IsString()
@MaxLength(100)
familyCode?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
familyImage?: string | null;
@ApiProperty({ required: false, example: '1' })
@IsOptional()
@IsNumberString()
countryId?: string;
@ApiProperty({ required: false, example: '2' })
@IsOptional()
@IsNumberString()
categoryId?: string;
@ApiProperty({
required: false,
description: '主链接 origin_good_idcanonical 详情与跳转兜底)',
example: '123',
})
@IsOptional()
@IsNumberString()
primaryOriginGoodId?: string;
@ApiProperty({ required: false, type: [String], example: ['123', '124'] })
@IsOptional()
@IsArray()
@IsNumberString({}, { each: true })
originGoodIds?: string[];
}
export class PatchProductFamilyDto extends PartialType(CreateProductFamilyDto) {
@ApiProperty({ required: false, description: 'false = 人工锁定,重算只置 stale' })
@IsOptional()
@IsBoolean()
autoManaged?: boolean;
}
export class QueryProductFamilyDto {
@ApiProperty({ required: false, description: '匹配 familyName / familyCode' })
@IsOptional()
@IsString()
keyword?: string;
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
pageSize?: number;
}
export class AutoGroupDto {
@ApiProperty({ required: false, default: false, description: 'true = 实际建族' })
@IsOptional()
@IsBoolean()
apply?: boolean;
}
export class UpdateFamilyMembersDto {
@ApiProperty({ required: false, type: [String] })
@IsOptional()
@IsArray()
@IsNumberString({}, { each: true })
addOriginGoodIds?: string[];
@ApiProperty({ required: false, type: [String] })
@IsOptional()
@IsArray()
@IsNumberString({}, { each: true })
removeOriginGoodIds?: string[];
}
export class CustomMemberVariantDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
sku!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
sizeId?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
sizeName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorId?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorHex?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
imageUrl?: string | null;
@ApiProperty({ example: '28.00' })
@IsNumber()
@Min(0.01)
price!: number;
}
export class CreateCustomMemberDto {
@ApiProperty({ example: '美国(包邮)180g纯棉T恤-DG001-双面印花(自建)' })
@IsString()
@IsNotEmpty()
@MaxLength(300)
goodName!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
goodImage?: string | null;
@ApiProperty({ description: '物流归因(价格矩阵维度,必填)', example: '包邮' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
logisticsLabel!: string;
@ApiProperty({ description: '工艺/印花数量归因(价格矩阵维度,必填)', example: '双面印花' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
craftLabel!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
skuCode?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
warehouseLabel?: string;
@ApiProperty({ type: [CustomMemberVariantDto], minItems: 1 })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CustomMemberVariantDto)
variants!: CustomMemberVariantDto[];
@ApiProperty({
required: false,
type: Object,
description: '可选人工详情(尺码表/包装规则参与并集)',
})
@IsOptional()
@IsObject()
detail?: { sizeChart?: Record<string, unknown>; packageSpecs?: Record<string, unknown> };
}
export class PriceOverrideItemDto {
@ApiProperty({ example: 'size_S' })
@IsString()
@IsNotEmpty()
sizeId!: string;
@ApiProperty({ example: 'color_blk' })
@IsString()
@IsNotEmpty()
colorId!: string;
@ApiProperty({ example: '单面印花' })
@IsString()
@IsNotEmpty()
craft!: string;
@ApiProperty({ example: '包邮' })
@IsString()
@IsNotEmpty()
logistics!: string;
@ApiProperty({ example: '23.00' })
@IsNumber()
@Min(0.01)
price!: number;
@ApiProperty({ required: false, description: '改价原因(审计)' })
@IsOptional()
@IsString()
note?: string;
}
export class PutPriceOverridesDto {
@ApiProperty({ type: [PriceOverrideItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PriceOverrideItemDto)
items!: PriceOverrideItemDto[];
}
export class PriceOverrideCellDto {
@ApiProperty({ example: 'size_S' })
@IsString()
@IsNotEmpty()
sizeId!: string;
@ApiProperty({ example: 'color_blk' })
@IsString()
@IsNotEmpty()
colorId!: string;
@ApiProperty({ example: '单面印花' })
@IsString()
@IsNotEmpty()
craft!: string;
@ApiProperty({ example: '包邮' })
@IsString()
@IsNotEmpty()
logistics!: string;
}
export class DeletePriceOverridesDto {
@ApiProperty({ type: [PriceOverrideCellDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PriceOverrideCellDto)
cells!: PriceOverrideCellDto[];
}
@@ -0,0 +1,88 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ProductFamiliesService } from './product-families.service';
import {
AutoGroupDto,
CreateCustomMemberDto,
CreateProductFamilyDto,
DeletePriceOverridesDto,
PatchProductFamilyDto,
PutPriceOverridesDto,
QueryProductFamilyDto,
UpdateFamilyMembersDto,
} from './dto/product-family.dto';
@ApiTags('product-families')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('product-families')
export class ProductFamiliesController {
constructor(private readonly service: ProductFamiliesService) {}
@Get()
@ApiOperation({ summary: 'List product families with keyword & pagination' })
list(@Query() query: QueryProductFamilyDto) {
return this.service.list(query);
}
@Post('auto-group')
@ApiOperation({ summary: 'Auto-group unassigned origin goods by 3-segment name key' })
autoGroup(@Body() dto: AutoGroupDto) {
return this.service.autoGroup(dto.apply ?? false);
}
@Post()
@ApiOperation({ summary: 'Create a product family (optionally attach members)' })
create(@Body() dto: CreateProductFamilyDto) {
return this.service.create(dto);
}
@Get(':id')
@ApiOperation({ summary: 'Family detail with members & overrides' })
detail(@Param('id', ParseIntPipe) id: string) {
return this.service.detail(BigInt(id));
}
@Patch(':id')
@ApiOperation({ summary: 'Patch canonical fields / autoManaged / primary link' })
patch(@Param('id', ParseIntPipe) id: string, @Body() dto: PatchProductFamilyDto) {
return this.service.patch(BigInt(id), dto);
}
@Post(':id/recompute')
@ApiOperation({ summary: 'Manually recompute union & price matrix' })
recompute(@Param('id', ParseIntPipe) id: string) {
return this.service.recomputeNow(BigInt(id));
}
@Post(':id/members')
@ApiOperation({ summary: 'Add / remove SDS member links' })
updateMembers(@Param('id', ParseIntPipe) id: string, @Body() dto: UpdateFamilyMembersDto) {
return this.service.updateMembers(BigInt(id), dto);
}
@Post(':id/members/custom')
@ApiOperation({ summary: 'Create a custom (manual) member inside the family' })
createCustomMember(@Param('id', ParseIntPipe) id: string, @Body() dto: CreateCustomMemberDto) {
return this.service.createCustomMember(BigInt(id), dto);
}
@Get(':id/price-overrides')
@ApiOperation({ summary: 'List manual price overrides with derived-price comparison' })
listOverrides(@Param('id', ParseIntPipe) id: string) {
return this.service.listPriceOverrides(BigInt(id));
}
@Put(':id/price-overrides')
@ApiOperation({ summary: 'Batch upsert manual price overrides' })
putOverrides(@Param('id', ParseIntPipe) id: string, @Body() dto: PutPriceOverridesDto) {
return this.service.putPriceOverrides(BigInt(id), dto.items);
}
@Delete(':id/price-overrides')
@ApiOperation({ summary: 'Remove overrides to restore derived prices' })
deleteOverrides(@Param('id', ParseIntPipe) id: string, @Body() dto: DeletePriceOverridesDto) {
return this.service.deletePriceOverrides(BigInt(id), dto);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { FamilyRecomputeService } from './family-recompute.service';
import { ProductFamiliesController } from './product-families.controller';
import { ProductFamiliesService } from './product-families.service';
@Module({
imports: [PrismaModule],
controllers: [ProductFamiliesController],
providers: [ProductFamiliesService, FamilyRecomputeService],
exports: [ProductFamiliesService, FamilyRecomputeService],
})
export class ProductFamiliesModule {}
@@ -0,0 +1,238 @@
import { Test } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ProductFamiliesService } from './product-families.service';
import { FamilyRecomputeService } from './family-recompute.service';
import { PrismaService } from '../prisma/prisma.service';
describe('ProductFamiliesService', () => {
let service: ProductFamiliesService;
let prisma: PrismaService;
const stamp = Date.now();
const createdOriginGoodIds: bigint[] = [];
const createdFamilyIds: bigint[] = [];
const mkOriginGood = async (name: string, over: Record<string, unknown> = {}) => {
const og = await prisma.originGood.create({
data: {
sdsGoodId: `famsvc-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`,
goodName: name,
...over,
},
});
createdOriginGoodIds.push(og.id);
return og;
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService],
}).compile();
service = moduleRef.get(ProductFamiliesService);
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('create:挂成员、重算、familyCode 冲突自动加后缀', async () => {
const a = await mkOriginGood('美国(包邮)T恤-DGTEST-单面印花', {
craftLabel: '单面印花',
logisticsLabel: '包邮',
});
await prisma.originGoodVariant.create({
data: {
originGoodId: a.id,
sdsVariantId: 'svc-v1',
sku: 'T-S',
sizeId: 'size_S',
sizeName: 'S',
colorId: 'color_blk',
colorName: '黑色',
price: new Prisma.Decimal(25),
},
});
const f1 = (await service.create({
familyName: '测试T恤',
familyCode: 'DGTEST',
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
})) as any;
createdFamilyIds.push(BigInt(f1.id));
expect(f1.familyCode).toBe('DGTEST');
expect(f1._count.originGoods).toBe(1);
expect((f1.priceMatrix as any).rows).toHaveLength(1);
const f2 = (await service.create({ familyName: '测试T恤二号', familyCode: 'DGTEST' })) as any;
createdFamilyIds.push(BigInt(f2.id));
expect(f2.familyCode).toBe('DGTEST-2');
});
it('detail:不存在 404', async () => {
await expect(service.detail(999999n)).rejects.toThrow(NotFoundException);
});
it('listkeyword 过滤 familyName/familyCode + 分页字段', async () => {
const res = (await service.list({ keyword: `测试T恤`, page: 1, pageSize: 10 })) as any;
expect(res.total).toBeGreaterThanOrEqual(2);
expect(res.items.length).toBeGreaterThanOrEqual(2);
expect(res.page).toBe(1);
const byCode = (await service.list({ keyword: 'DGTEST-2' })) as any;
expect(byCode.total).toBe(1);
});
it('patch:改 canonical 字段并触发重算,不丢成员', async () => {
const f = (await service.create({ familyName: `补丁族-${stamp}` })) as any;
createdFamilyIds.push(BigInt(f.id));
const patched = (await service.patch(BigInt(f.id), {
familyName: `补丁族改-${stamp}`,
autoManaged: false,
})) as any;
expect(patched.familyName).toBe(`补丁族改-${stamp}`);
expect(patched.autoManaged).toBe(false);
// 改回,避免影响后续用例
await service.patch(BigInt(f.id), { autoManaged: true });
});
it('auto-group:预览不写库;apply 建族挂成员且幂等', async () => {
const g1a = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花`);
const g1b = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花-某仓`);
const g2 = await mkOriginGood(`自动组${stamp}(不包邮)卫衣-ZZ${stamp}-单面印花`);
const preview = (await service.autoGroup(false)) as any;
expect(preview.applied).toBe(0);
const hit = preview.groups.find((g: any) => g.groupKey.includes(`ZZ${stamp}`) && g.memberCount === 2);
expect(hit).toBeTruthy();
expect(hit.familyName).toContain('卫衣');
const applied = (await service.autoGroup(true)) as any;
expect(applied.applied).toBeGreaterThanOrEqual(2); // 包邮组 + 不包邮组(物流不同不同组)
const families = await prisma.productFamily.findMany({
where: { familyName: { contains: `自动组${stamp}` } },
});
for (const f of families) createdFamilyIds.push(f.id);
const grouped = await prisma.originGood.findMany({
where: { id: { in: [g1a.id, g1b.id, g2.id] } },
select: { familyId: true },
});
expect(grouped.every((g) => g.familyId !== null)).toBe(true);
expect(grouped[0].familyId).toBe(grouped[1].familyId); // 同组同族
expect(grouped[2].familyId).not.toBe(grouped[0].familyId); // 物流不同不同族
// 幂等:候选已清空
const again = (await service.autoGroup(true)) as any;
const hitAgain = again.groups.filter((g: any) => g.groupKey.includes(`ZZ${stamp}`));
expect(hitAgain).toHaveLength(0);
});
it('members:增删成员、移除主链接后 primary 落到剩余成员', async () => {
const a = await mkOriginGood(`成员${stamp}A`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
const b = await mkOriginGood(`成员${stamp}B`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
const c = await mkOriginGood(`成员${stamp}C`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
const f = (await service.create({
familyName: `成员族-${stamp}`,
originGoodIds: [a.id.toString(), b.id.toString(), c.id.toString()],
primaryOriginGoodId: a.id.toString(),
})) as any;
createdFamilyIds.push(BigInt(f.id));
const removed = (await service.updateMembers(BigInt(f.id), {
removeOriginGoodIds: [a.id.toString()],
})) as any;
expect(removed._count.originGoods).toBe(2);
expect(BigInt(removed.primaryOriginGoodId)).toBe(b.id); // 落到剩余最小 id
const added = (await service.updateMembers(BigInt(f.id), {
addOriginGoodIds: [a.id.toString()],
})) as any;
expect(added._count.originGoods).toBe(3);
await expect(
service.updateMembers(BigInt(f.id), { addOriginGoodIds: ['999999'] }),
).rejects.toThrow(BadRequestException);
});
it('custom member:创建带归因的 CUSTOM 成员并入矩阵', async () => {
const f = (await service.create({ familyName: `自建族-${stamp}` })) as any;
createdFamilyIds.push(BigInt(f.id));
const member = (await service.createCustomMember(BigInt(f.id), {
goodName: `自建商品-${stamp}`,
logisticsLabel: '海运',
craftLabel: '双面印花',
skuCode: `ZZC${stamp}`,
variants: [{ sku: 'C-M', sizeId: 'size_M', sizeName: 'M', colorId: 'color_red', colorName: '红色', price: 33 }],
detail: { sizeChart: { rows: [{ sizeId: 'size_M', sizeName: 'M', chest: 100 }] } },
})) as any;
expect(member.sdsGoodId.startsWith('custom-')).toBe(true);
expect(member.logisticsLabel).toBe('海运');
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: BigInt(f.id) } });
const matrix = after.priceMatrix as any;
expect(matrix.rows).toHaveLength(1);
expect(matrix.rows[0].price).toBe('33');
expect(matrix.logistics).toContain('海运');
expect((after.sizeChart as any).rows).toHaveLength(1); // 自定义成员尺码参与并集
expect(BigInt(after.primaryOriginGoodId!)).toBe(BigInt(member.id)); // 空族首个成员成为主链接
});
it('price overrides:非法维度 400;合法覆盖生效;删除恢复推导价', async () => {
const a = await mkOriginGood(`覆盖${stamp}`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
await prisma.originGoodVariant.create({
data: {
originGoodId: a.id,
sdsVariantId: 'ov-v1',
sku: 'O-S',
sizeId: 'size_S',
sizeName: 'S',
colorId: 'color_blk',
colorName: '黑色',
price: new Prisma.Decimal(25),
},
});
const f = (await service.create({
familyName: `覆盖族-${stamp}`,
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
})) as any;
createdFamilyIds.push(BigInt(f.id));
const fid = BigInt(f.id);
await expect(
service.putPriceOverrides(fid, [
{ sizeId: 'size_S', colorId: 'color_blk', craft: '不存在的工艺', logistics: '包邮', price: 1 },
]),
).rejects.toThrow(BadRequestException);
const result = (await service.putPriceOverrides(fid, [
{ sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮', price: 23, note: '促销' },
])) as any;
expect(result.items).toHaveLength(1);
expect(result.items[0].derivedPrice).toBe('25');
expect(result.items[0].diff).toBe('-2.00');
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: fid } });
const row = (after.priceMatrix as any).rows[0];
expect(row.price).toBe('23');
expect(row.manual).toBe(true);
const restored = (await service.deletePriceOverrides(fid, {
cells: [{ sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮' }],
})) as any;
expect(restored.items).toHaveLength(0);
const after2 = await prisma.productFamily.findUniqueOrThrow({ where: { id: fid } });
expect((after2.priceMatrix as any).rows[0].price).toBe('25');
expect((after2.priceMatrix as any).rows[0].manual).toBe(false);
});
it('recomputeNow:手动重算返回最新族详情', async () => {
const f = (await service.create({ familyName: `手动重算族-${stamp}` })) as any;
createdFamilyIds.push(BigInt(f.id));
const res = (await service.recomputeNow(BigInt(f.id))) as any;
expect(BigInt(res.id)).toBe(BigInt(f.id));
await expect(service.recomputeNow(999999n)).rejects.toThrow(NotFoundException);
});
});
@@ -0,0 +1,440 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService, PriceMatrix } from './family-recompute.service';
import { originGroupKey, parseOriginName } from './origin-name.parser';
import {
CreateCustomMemberDto,
CreateProductFamilyDto,
DeletePriceOverridesDto,
PatchProductFamilyDto,
PriceOverrideItemDto,
QueryProductFamilyDto,
UpdateFamilyMembersDto,
} from './dto/product-family.dto';
const FAMILY_INCLUDE = {
originGoods: {
select: {
id: true,
sdsGoodId: true,
goodName: true,
goodImage: true,
source: true,
delisted: true,
skuCode: true,
logisticsLabel: true,
craftLabel: true,
warehouseLabel: true,
},
},
priceOverrides: true,
_count: { select: { originGoods: true, priceOverrides: true } },
} satisfies Prisma.ProductFamilyInclude;
@Injectable()
export class ProductFamiliesService {
constructor(
private readonly prisma: PrismaService,
private readonly recompute: FamilyRecomputeService,
) {}
async list(query: QueryProductFamilyDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.ProductFamilyWhereInput = query.keyword
? {
OR: [
{ familyName: { contains: query.keyword, mode: 'insensitive' } },
{ familyCode: { contains: query.keyword, mode: 'insensitive' } },
],
}
: {};
const [items, total] = await this.prisma.$transaction([
this.prisma.productFamily.findMany({
where,
include: { _count: { select: { originGoods: true, priceOverrides: true } } },
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.productFamily.count({ where }),
]);
return { items, total, page, pageSize };
}
async detail(id: bigint) {
const family = await this.prisma.productFamily.findUnique({
where: { id },
include: FAMILY_INCLUDE,
});
if (!family) throw new NotFoundException(`product family ${id} not found`);
return family;
}
async create(dto: CreateProductFamilyDto) {
const family = await this.prisma.productFamily.create({
data: {
familyName: dto.familyName,
familyCode: dto.familyCode ? await this.ensureUniqueCode(dto.familyCode) : null,
familyImage: dto.familyImage ?? null,
countryId: dto.countryId ? BigInt(dto.countryId) : null,
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
primaryOriginGoodId: dto.primaryOriginGoodId ? BigInt(dto.primaryOriginGoodId) : null,
},
});
if (dto.originGoodIds?.length) {
await this.attachMembers(
family.id,
dto.originGoodIds.map((v) => BigInt(v)),
);
}
await this.recompute.recomputeFamily(family.id);
return this.detail(family.id);
}
async patch(id: bigint, dto: PatchProductFamilyDto) {
const existing = await this.prisma.productFamily.findUnique({ where: { id } });
if (!existing) throw new NotFoundException(`product family ${id} not found`);
const data: Prisma.ProductFamilyUpdateInput = {};
if (dto.familyName !== undefined) data.familyName = dto.familyName;
if (dto.familyImage !== undefined) data.familyImage = dto.familyImage;
if (dto.autoManaged !== undefined) data.autoManaged = dto.autoManaged;
if (dto.countryId !== undefined) {
data.country = dto.countryId
? { connect: { id: BigInt(dto.countryId) } }
: { disconnect: true };
}
if (dto.categoryId !== undefined) {
data.category = dto.categoryId
? { connect: { id: BigInt(dto.categoryId) } }
: { disconnect: true };
}
if (dto.familyCode !== undefined) {
data.familyCode =
dto.familyCode === ''
? null
: dto.familyCode !== existing.familyCode
? await this.ensureUniqueCode(dto.familyCode, id)
: existing.familyCode;
}
if (dto.primaryOriginGoodId !== undefined) {
data.primaryOriginGoodId = dto.primaryOriginGoodId ? BigInt(dto.primaryOriginGoodId) : null;
}
await this.prisma.productFamily.update({ where: { id }, data });
await this.recompute.recomputeFamily(id);
return this.detail(id);
}
/** 自动建族:按 3 段分组键聚合无族链接;apply=false 仅预览 */
async autoGroup(apply: boolean) {
const candidates = await this.prisma.originGood.findMany({
where: { familyId: null, delisted: false },
select: { id: true, goodName: true, goodImage: true },
orderBy: { id: 'asc' },
});
const groups = new Map<string, typeof candidates>();
for (const og of candidates) {
const key = originGroupKey(og.goodName);
if (!key) continue;
const arr = groups.get(key);
if (arr) arr.push(og);
else groups.set(key, [og]);
}
const preview = [...groups.values()].map((members) => {
const parsed = parseOriginName(members[0].goodName);
return {
groupKey: originGroupKey(members[0].goodName),
familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName),
familyCode: parsed.skuCode ?? null,
memberCount: members.length,
sampleNames: members.slice(0, 3).map((m) => m.goodName ?? ''),
};
});
if (!apply) return { applied: 0, groups: preview };
let applied = 0;
for (const members of groups.values()) {
const parsed = parseOriginName(members[0].goodName);
const family = await this.prisma.productFamily.create({
data: {
familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName),
familyCode: parsed.skuCode ? await this.ensureUniqueCode(parsed.skuCode) : null,
familyImage: members[0].goodImage ?? null,
primaryOriginGoodId: members[0].id,
},
});
await this.attachMembers(
family.id,
members.map((m) => m.id),
);
await this.recompute.recomputeFamily(family.id);
applied += 1;
}
return { applied, groups: preview };
}
async updateMembers(id: bigint, dto: UpdateFamilyMembersDto) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
if (dto.removeOriginGoodIds?.length) {
const removeIds = dto.removeOriginGoodIds.map((v) => BigInt(v));
const remaining = await this.prisma.originGood.count({
where: { familyId: id, id: { notIn: removeIds } },
});
await this.prisma.originGood.updateMany({
where: { id: { in: removeIds }, familyId: id },
data: { familyId: null },
});
// 移除的是主链接(或主链接已不在族内)→ 落到剩余第一个成员
if (remaining > 0) {
const stillPrimary = await this.prisma.originGood.count({
where: { familyId: id, id: family.primaryOriginGoodId ?? -1n },
});
if (!stillPrimary) {
const next = await this.prisma.originGood.findFirst({
where: { familyId: id },
orderBy: { id: 'asc' },
select: { id: true },
});
if (next) {
await this.prisma.productFamily.update({
where: { id },
data: { primaryOriginGoodId: next.id },
});
}
}
} else {
await this.prisma.productFamily.update({
where: { id },
data: { primaryOriginGoodId: null },
});
}
}
if (dto.addOriginGoodIds?.length) {
await this.attachMembers(
id,
dto.addOriginGoodIds.map((v) => BigInt(v)),
);
}
await this.recompute.recomputeFamily(id);
return this.detail(id);
}
/** 在族内创建自定义成员(人工商品),成功后重算 */
async createCustomMember(familyId: bigint, dto: CreateCustomMemberDto) {
const family = await this.prisma.productFamily.findUnique({ where: { id: familyId } });
if (!family) throw new NotFoundException(`product family ${familyId} not found`);
const { randomUUID } = await import('node:crypto');
const originGood = await this.prisma.originGood.create({
data: {
sdsGoodId: `custom-${randomUUID()}`,
goodName: dto.goodName,
goodImage: dto.goodImage ?? null,
source: 'CUSTOM',
familyId,
skuCode: dto.skuCode ?? null,
logisticsLabel: dto.logisticsLabel,
craftLabel: dto.craftLabel,
warehouseLabel: dto.warehouseLabel ?? null,
},
});
await this.prisma.originGoodVariant.createMany({
data: dto.variants.map((v) => ({
originGoodId: originGood.id,
sdsVariantId: `custom-${randomUUID()}`,
sku: v.sku,
sizeId: v.sizeId ?? null,
sizeName: v.sizeName ?? null,
colorId: v.colorId ?? null,
colorName: v.colorName ?? null,
colorHex: v.colorHex ?? null,
imageUrl: v.imageUrl ?? null,
price: new Prisma.Decimal(v.price),
})),
});
if (dto.detail?.sizeChart || dto.detail?.packageSpecs) {
await this.prisma.originGoodDetail.create({
data: {
originGoodId: originGood.id,
sizeChart: (dto.detail?.sizeChart ?? undefined) as Prisma.InputJsonValue,
packageSpecs: (dto.detail?.packageSpecs ?? undefined) as Prisma.InputJsonValue,
},
});
}
if (!family.primaryOriginGoodId) {
await this.prisma.productFamily.update({
where: { id: familyId },
data: { primaryOriginGoodId: originGood.id },
});
}
await this.recompute.recomputeFamily(familyId);
return originGood;
}
async listPriceOverrides(id: bigint) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
const overrides = await this.prisma.familyPriceOverride.findMany({
where: { familyId: id },
orderBy: { updatedAt: 'desc' },
});
const matrix = (family.priceMatrix as PriceMatrix | null) ?? null;
const rows = matrix?.rows ?? [];
return {
items: overrides.map((o) => {
// 推导价 = 该格子全部来源中的最低价(覆盖生效前的推导结果,保留在 sources 里)
const row = rows.find(
(r) =>
r.sizeId === o.sizeId &&
r.colorId === o.colorId &&
r.craft === o.craft &&
r.logistics === o.logistics,
);
const derived =
row?.sources.length && !row.manual
? row.price
: row?.sources.length
? String(Math.min(...row.sources.map((s) => Number(s.price))))
: null;
return {
...o,
derivedPrice: derived,
diff: derived !== null ? (Number(o.price) - Number(derived)).toFixed(2) : null,
};
}),
};
}
async putPriceOverrides(id: bigint, items: PriceOverrideItemDto[]) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
// 矩阵未物化(如刚建族)时先重算,保证维度校验有依据
let matrix = family.priceMatrix as PriceMatrix | null;
if (!matrix) {
await this.recompute.recomputeFamily(id);
const refreshed = await this.prisma.productFamily.findUnique({ where: { id } });
matrix = (refreshed?.priceMatrix as PriceMatrix | null) ?? null;
}
const allowed = {
sizes: new Set((matrix?.sizes ?? []).map((s) => s.key)),
colors: new Set((matrix?.colors ?? []).map((c) => c.key)),
crafts: new Set(matrix?.crafts ?? []),
logistics: new Set(matrix?.logistics ?? []),
};
const invalid = items.filter(
(i) =>
!allowed.sizes.has(i.sizeId) ||
!allowed.colors.has(i.colorId) ||
!allowed.crafts.has(i.craft) ||
!allowed.logistics.has(i.logistics),
);
if (invalid.length) {
throw new BadRequestException({
message: 'price override dimensions must exist in the family matrix',
invalidCells: invalid.map((i) => ({
sizeId: i.sizeId,
colorId: i.colorId,
craft: i.craft,
logistics: i.logistics,
})),
});
}
for (const item of items) {
await this.prisma.familyPriceOverride.upsert({
where: {
familyId_sizeId_colorId_craft_logistics: {
familyId: id,
sizeId: item.sizeId,
colorId: item.colorId,
craft: item.craft,
logistics: item.logistics,
},
},
create: {
familyId: id,
sizeId: item.sizeId,
colorId: item.colorId,
craft: item.craft,
logistics: item.logistics,
price: new Prisma.Decimal(item.price),
note: item.note ?? null,
},
update: {
price: new Prisma.Decimal(item.price),
note: item.note ?? null,
},
});
}
await this.recompute.recomputeFamily(id);
return this.listPriceOverrides(id);
}
async deletePriceOverrides(id: bigint, dto: DeletePriceOverridesDto) {
for (const cell of dto.cells) {
await this.prisma.familyPriceOverride.deleteMany({
where: {
familyId: id,
sizeId: cell.sizeId,
colorId: cell.colorId,
craft: cell.craft,
logistics: cell.logistics,
},
});
}
await this.recompute.recomputeFamily(id);
return this.listPriceOverrides(id);
}
async recomputeNow(id: bigint) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
await this.recompute.recomputeFamily(id);
return this.detail(id);
}
private async attachMembers(familyId: bigint, originGoodIds: bigint[]) {
if (!originGoodIds.length) return;
const existings = await this.prisma.originGood.findMany({
where: { id: { in: originGoodIds } },
select: { id: true },
});
const existingIds = new Set(existings.map((e) => e.id.toString()));
const missing = originGoodIds.filter((v) => !existingIds.has(v.toString()));
if (missing.length) {
throw new BadRequestException({
message: 'origin goods not found',
ids: missing.map(String),
});
}
await this.prisma.originGood.updateMany({
where: { id: { in: originGoodIds } },
data: { familyId },
});
}
/** familyCode 全局唯一:冲突时追加 -2/-3… 后缀(不同国家同 SKU 常见) */
private async ensureUniqueCode(code: string, selfId?: bigint): Promise<string> {
let candidate = code;
let seq = 2;
// eslint-disable-next-line no-constant-condition
while (true) {
const clash = await this.prisma.productFamily.findFirst({
where: { familyCode: candidate, ...(selfId ? { id: { not: selfId } } : {}) },
select: { id: true },
});
if (!clash) return candidate;
candidate = `${code}-${seq++}`;
}
}
}