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 { 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_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