feat(api): product families module with CRUD, auto-group, members, custom members and price overrides
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<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('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);
|
||||
});
|
||||
});
|
||||
@@ -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++}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user