diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ebfe7b8..7d958bb 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,7 @@ import { TagsModule } from './tags/tags.module'; import { TagGroupsModule } from './tag-groups/tag-groups.module'; import { PositionsModule } from './positions/positions.module'; import { OriginGoodsModule } from './origin-goods/origin-goods.module'; +import { ProductFamiliesModule } from './product-families/product-families.module'; import { GoodsModule } from './goods/goods.module'; import { SyncModule } from './sync/sync.module'; import { PublicModule } from './public/public.module'; @@ -37,6 +38,7 @@ import { UploadModule } from './upload/upload.module'; TagGroupsModule, PositionsModule, OriginGoodsModule, + ProductFamiliesModule, GoodsModule, SyncModule, PublicModule, diff --git a/apps/api/src/product-families/dto/product-family.dto.ts b/apps/api/src/product-families/dto/product-family.dto.ts new file mode 100644 index 0000000..6cd687e --- /dev/null +++ b/apps/api/src/product-families/dto/product-family.dto.ts @@ -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_id(canonical 详情与跳转兜底)', + 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; packageSpecs?: Record }; +} + +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[]; +} diff --git a/apps/api/src/product-families/product-families.controller.ts b/apps/api/src/product-families/product-families.controller.ts new file mode 100644 index 0000000..718a1b3 --- /dev/null +++ b/apps/api/src/product-families/product-families.controller.ts @@ -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); + } +} diff --git a/apps/api/src/product-families/product-families.module.ts b/apps/api/src/product-families/product-families.module.ts new file mode 100644 index 0000000..9334c37 --- /dev/null +++ b/apps/api/src/product-families/product-families.module.ts @@ -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 {} diff --git a/apps/api/src/product-families/product-families.service.spec.ts b/apps/api/src/product-families/product-families.service.spec.ts new file mode 100644 index 0000000..2cc7e33 --- /dev/null +++ b/apps/api/src/product-families/product-families.service.spec.ts @@ -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 = {}) => { + 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('list:keyword 过滤 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); + }); +}); diff --git a/apps/api/src/product-families/product-families.service.ts b/apps/api/src/product-families/product-families.service.ts new file mode 100644 index 0000000..da701c2 --- /dev/null +++ b/apps/api/src/product-families/product-families.service.ts @@ -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(); + 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 { + 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++}`; + } + } +}