feat(产品): 重构产品属性管理为通用字典项实现

重构产品相关的品牌、口味、规格和尺寸属性管理,改为基于通用字典项的实现方式:
1. 新增 AttributeInputDTO 用于统一处理属性输入
2. 实现通用的字典项增删改查接口
3. 保留旧接口作为兼容层
4. 优化产品创建和更新逻辑以支持新属性结构
This commit is contained in:
tikkhun 2025-11-28 17:59:48 +08:00
parent bc575840b2
commit 64b8468df8
4 changed files with 363 additions and 348 deletions

View File

@ -11,35 +11,9 @@ import {
} from '@midwayjs/core'; } from '@midwayjs/core';
import { ProductService } from '../service/product.service'; import { ProductService } from '../service/product.service';
import { errorResponse, successResponse } from '../utils/response.util'; import { errorResponse, successResponse } from '../utils/response.util';
import { import { CreateProductDTO, QueryProductDTO, UpdateProductDTO } from '../dto/product.dto';
BatchSetSkuDTO,
CreateBrandDTO,
CreateFlavorsDTO,
CreateProductDTO,
CreateStrengthDTO,
CreateSizeDTO,
QueryBrandDTO,
QueryFlavorsDTO,
QueryProductDTO,
QueryStrengthDTO,
QuerySizeDTO,
UpdateBrandDTO,
UpdateFlavorsDTO,
UpdateProductDTO,
UpdateStrengthDTO,
UpdateSizeDTO,
} from '../dto/product.dto';
import { ApiOkResponse } from '@midwayjs/swagger'; import { ApiOkResponse } from '@midwayjs/swagger';
import { import { BooleanRes, ProductListRes, ProductRes, ProductsRes } from '../dto/reponse.dto';
BooleanRes,
ProductBrandListRes,
ProductBrandRes,
ProductSizeListRes,
ProductSizeRes,
ProductListRes,
ProductRes,
ProductsRes,
} from '../dto/reponse.dto';
@Controller('/product') @Controller('/product')
export class ProductController { export class ProductController {
@ -98,9 +72,7 @@ export class ProductController {
} }
} }
@ApiOkResponse({ @ApiOkResponse({ type: ProductRes })
type: ProductRes,
})
@Post('/') @Post('/')
async createProduct(@Body() productData: CreateProductDTO) { async createProduct(@Body() productData: CreateProductDTO) {
try { try {
@ -111,14 +83,9 @@ export class ProductController {
} }
} }
@ApiOkResponse({ @ApiOkResponse({ type: ProductRes })
type: ProductRes,
})
@Put('/:id') @Put('/:id')
async updateProduct( async updateProduct(@Param('id') id: number, @Body() productData: UpdateProductDTO) {
@Param('id') id: number,
@Body() productData: UpdateProductDTO
) {
try { try {
const data = this.productService.updateProduct(id, productData); const data = this.productService.updateProduct(id, productData);
return successResponse(data); return successResponse(data);
@ -127,14 +94,9 @@ export class ProductController {
} }
} }
@ApiOkResponse({ @ApiOkResponse({ type: ProductRes })
type: ProductRes,
})
@Put('updateNameCn/:id/:nameCn') @Put('updateNameCn/:id/:nameCn')
async updateProductNameCn( async updateProductNameCn(@Param('id') id: number, @Param('nameCn') nameCn: string) {
@Param('id') id: number,
@Param('nameCn') nameCn: string
) {
try { try {
const data = this.productService.updateProductNameCn(id, nameCn); const data = this.productService.updateProductNameCn(id, nameCn);
return successResponse(data); return successResponse(data);
@ -143,9 +105,7 @@ export class ProductController {
} }
} }
@ApiOkResponse({ @ApiOkResponse({ type: BooleanRes })
type: BooleanRes,
})
@Del('/:id') @Del('/:id')
async deleteProduct(@Param('id') id: number) { async deleteProduct(@Param('id') id: number) {
try { try {
@ -156,14 +116,19 @@ export class ProductController {
} }
} }
@ApiOkResponse({
type: ProductBrandListRes, // 通用属性接口:分页列表
}) @ApiOkResponse()
@Get('/brands') @Get('/attribute')
async getBrands(@Query() query: QueryBrandDTO) { async getAttributeList(
const { current = 1, pageSize = 10, name } = query; @Query('dictName') dictName: string,
@Query('current') current = 1,
@Query('pageSize') pageSize = 10,
@Query('name') name?: string
) {
try { try {
let data = await this.productService.getBrandList( const data = await this.productService.getAttributeList(
dictName,
{ current, pageSize }, { current, pageSize },
name name
); );
@ -173,95 +138,142 @@ export class ProductController {
} }
} }
// 通用属性接口:全部列表
@ApiOkResponse() @ApiOkResponse()
@Get('/brandAll') @Get('/attributeAll')
async getBrandAll() { async getAttributeAll(@Query('dictName') dictName: string) {
try { try {
let data = await this.productService.getBrandAll(); const data = await this.productService.getAttributeAll(dictName);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
@ApiOkResponse({ // 通用属性接口:创建
type: ProductBrandRes, @ApiOkResponse()
}) @Post('/attribute')
@Post('/brand') async createAttribute(
async createBrand(@Body() brandData: CreateBrandDTO) { @Query('dictName') dictName: string,
try { @Body() body: { title: string; name: string }
const hasBrand = await this.productService.hasAttribute(
'brand',
brandData.name
);
if (hasBrand) {
return errorResponse('品牌已存在');
}
let data = await this.productService.createBrand(brandData);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
@ApiOkResponse({
type: ProductBrandRes,
})
@Put('/brand/:id')
async updateBrand(
@Param('id') id: number,
@Body() brandData: UpdateBrandDTO
) { ) {
try { try {
const hasBrand = await this.productService.hasAttribute( const hasItem = await this.productService.hasAttribute(
'brand', dictName,
brandData.name, body.name
);
if (hasItem) return errorResponse('字典项已存在');
const data = await this.productService.createAttribute(dictName, body);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
// 通用属性接口:更新
@ApiOkResponse()
@Put('/attribute/:id')
async updateAttribute(
@Param('id') id: number,
@Query('dictName') dictName: string,
@Body() body: { title?: string; name?: string }
) {
try {
if (body?.name) {
const hasItem = await this.productService.hasAttribute(
dictName,
body.name,
id id
); );
if (hasBrand) { if (hasItem) return errorResponse('字典项已存在');
return errorResponse('品牌已存在');
} }
const data = this.productService.updateBrand(id, brandData); const data = await this.productService.updateAttribute(id, body);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
@ApiOkResponse({ // 通用属性接口:删除
type: BooleanRes, @ApiOkResponse({ type: BooleanRes })
}) @Del('/attribute/:id')
@Del('/brand/:id') async deleteAttribute(@Param('id') id: number) {
async deleteBrand(@Param('id') id: number) {
try { try {
const hasProducts = await this.productService.hasProductsInAttribute(id); await this.productService.deleteAttribute(id);
if (hasProducts) throw new Error('该品牌下有商品,无法删除'); return successResponse(true);
const data = await this.productService.deleteBrand(id);
return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
@Post('/batchSetSku') // 兼容旧接口:品牌
@ApiOkResponse({ @ApiOkResponse()
description: '批量设置 sku 的响应结果', @Get('/brandAll')
type: BooleanRes, async compatBrandAll() {
})
async batchSetSku(@Body() body: BatchSetSkuDTO) {
try { try {
const result = await this.productService.batchSetSku(body.skus); const data = await this.productService.getAttributeAll('brand'); // 中文注释:返回所有品牌字典项
return successResponse(result, '批量设置 sku 成功'); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error.message, 400); return errorResponse(error?.message || error);
} }
} }
@ApiOkResponse() @ApiOkResponse()
@Get('/flavorsAll') @Get('/brands')
async getFlavorsAll() { async compatBrands(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) {
try { try {
let data = await this.productService.getFlavorsAll(); const data = await this.productService.getAttributeList('brand', { current, pageSize }, name); // 中文注释:分页品牌列表
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
@ApiOkResponse()
@Post('/brand')
async compatCreateBrand(@Body() body: { title: string; name: string }) {
try {
const has = await this.productService.hasAttribute('brand', body.name); // 中文注释:唯一性校验
if (has) return errorResponse('品牌已存在');
const data = await this.productService.createAttribute('brand', body); // 中文注释:创建品牌字典项
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
@ApiOkResponse()
@Put('/brand/:id')
async compatUpdateBrand(@Param('id') id: number, @Body() body: { title?: string; name?: string }) {
try {
if (body?.name) {
const has = await this.productService.hasAttribute('brand', body.name, id); // 中文注释:唯一性校验(排除自身)
if (has) return errorResponse('品牌已存在');
}
const data = await this.productService.updateAttribute(id, body); // 中文注释:更新品牌字典项
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
@ApiOkResponse({ type: BooleanRes })
@Del('/brand/:id')
async compatDeleteBrand(@Param('id') id: number) {
try {
await this.productService.deleteAttribute(id); // 中文注释:删除品牌字典项
return successResponse(true);
} catch (error) {
return errorResponse(error?.message || error);
}
}
// 兼容旧接口:口味
@ApiOkResponse()
@Get('/flavorsAll')
async compatFlavorsAll() {
try {
const data = await this.productService.getAttributeAll('flavor');
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
@ -270,13 +282,9 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Get('/flavors') @Get('/flavors')
async getFlavors(@Query() query: QueryFlavorsDTO) { async compatFlavors(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) {
const { current = 1, pageSize = 10, name } = query;
try { try {
let data = await this.productService.getFlavorsList( const data = await this.productService.getAttributeList('flavor', { current, pageSize }, name);
{ current, pageSize },
name
);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
@ -285,13 +293,11 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Post('/flavors') @Post('/flavors')
async createFlavors(@Body() flavorsData: CreateFlavorsDTO) { async compatCreateFlavors(@Body() body: { title: string; name: string }) {
try { try {
const hasFlavors = await this.productService.hasAttribute('flavor', flavorsData.name); const has = await this.productService.hasAttribute('flavor', body.name);
if (hasFlavors) { if (has) return errorResponse('口味已存在');
return errorResponse('口味已存在'); const data = await this.productService.createAttribute('flavor', body);
}
let data = await this.productService.createFlavors(flavorsData);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
@ -300,42 +306,36 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Put('/flavors/:id') @Put('/flavors/:id')
async updateFlavors( async compatUpdateFlavors(@Param('id') id: number, @Body() body: { title?: string; name?: string }) {
@Param('id') id: number,
@Body() flavorsData: UpdateFlavorsDTO
) {
try { try {
const hasFlavors = await this.productService.hasAttribute('flavor', flavorsData.name, id); if (body?.name) {
if (hasFlavors) { const has = await this.productService.hasAttribute('flavor', body.name, id);
return errorResponse('口味已存在'); if (has) return errorResponse('口味已存在');
} }
const data = this.productService.updateFlavors(id, flavorsData); const data = await this.productService.updateAttribute(id, body);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
@ApiOkResponse({ @ApiOkResponse({ type: BooleanRes })
type: BooleanRes,
})
@Del('/flavors/:id') @Del('/flavors/:id')
async deleteFlavors(@Param('id') id: number) { async compatDeleteFlavors(@Param('id') id: number) {
try { try {
const hasProducts = await this.productService.hasProductsInAttribute(id); await this.productService.deleteAttribute(id);
if (hasProducts) throw new Error('该口味下有商品,无法删除'); return successResponse(true);
const data = await this.productService.deleteFlavors(id);
return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
// 兼容旧接口:规格
@ApiOkResponse() @ApiOkResponse()
@Get('/strengthAll') @Get('/strengthAll')
async getStrengthAll() { async compatStrengthAll() {
try { try {
let data = await this.productService.getStrengthAll(); const data = await this.productService.getAttributeAll('strength');
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
@ -344,13 +344,9 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Get('/strength') @Get('/strength')
async getStrength(@Query() query: QueryStrengthDTO) { async compatStrength(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) {
const { current = 1, pageSize = 10, name } = query;
try { try {
let data = await this.productService.getStrengthList( const data = await this.productService.getAttributeList('strength', { current, pageSize }, name);
{ current, pageSize },
name
);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
@ -359,16 +355,11 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Post('/strength') @Post('/strength')
async createStrength(@Body() strengthData: CreateStrengthDTO) { async compatCreateStrength(@Body() body: { title: string; name: string }) {
try { try {
const hasStrength = await this.productService.hasAttribute( const has = await this.productService.hasAttribute('strength', body.name);
'strength', if (has) return errorResponse('规格已存在');
strengthData.name const data = await this.productService.createAttribute('strength', body);
);
if (hasStrength) {
return errorResponse('规格已存在');
}
let data = await this.productService.createStrength(strengthData);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
@ -377,109 +368,75 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Put('/strength/:id') @Put('/strength/:id')
async updateStrength( async compatUpdateStrength(@Param('id') id: number, @Body() body: { title?: string; name?: string }) {
@Param('id') id: number,
@Body() strengthData: UpdateStrengthDTO
) {
try { try {
const hasStrength = await this.productService.hasAttribute( if (body?.name) {
'strength', const has = await this.productService.hasAttribute('strength', body.name, id);
strengthData.name, if (has) return errorResponse('规格已存在');
id
);
if (hasStrength) {
return errorResponse('规格已存在');
} }
const data = this.productService.updateStrength(id, strengthData); const data = await this.productService.updateAttribute(id, body);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
@ApiOkResponse({ @ApiOkResponse({ type: BooleanRes })
type: BooleanRes,
})
@Del('/strength/:id') @Del('/strength/:id')
async deleteStrength(@Param('id') id: number) { async compatDeleteStrength(@Param('id') id: number) {
try { try {
const hasProducts = await this.productService.hasProductsInAttribute(id); await this.productService.deleteAttribute(id);
if (hasProducts) throw new Error('该规格下有商品,无法删除'); return successResponse(true);
const data = await this.productService.deleteStrength(id);
return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
// size 路由与增删改查 // 兼容旧接口:尺寸
@ApiOkResponse() @ApiOkResponse()
@Get('/sizeAll') @Get('/sizeAll')
async getSizeAll() { async compatSizeAll() {
try { try {
// 中文注释:获取所有尺寸项 const data = await this.productService.getAttributeAll('size');
const data = await this.productService.getSizeAll();
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
@ApiOkResponse({ type: ProductSizeListRes }) @ApiOkResponse()
@Get('/size') @Get('/size')
async getSize(@Query() query: QuerySizeDTO) { async compatSize(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) {
// 中文注释:解析分页与关键字
const { current = 1, pageSize = 10, name } = query;
try { try {
// 中文注释:分页查询尺寸列表 const data = await this.productService.getAttributeList('size', { current, pageSize }, name);
const data = await this.productService.getSizeList(
{ current, pageSize },
name
);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
@ApiOkResponse({ type: ProductSizeRes }) @ApiOkResponse()
@Post('/size') @Post('/size')
async createSize(@Body() sizeData: CreateSizeDTO) { async compatCreateSize(@Body() body: { title: string; name: string }) {
try { try {
// 条件判断(中文注释:唯一性校验,禁止重复) const has = await this.productService.hasAttribute('size', body.name);
const hasSize = await this.productService.hasAttribute( if (has) return errorResponse('尺寸已存在');
'size', const data = await this.productService.createAttribute('size', body);
sizeData.name
);
if (hasSize) {
return errorResponse('尺寸已存在');
}
// 调用服务创建(中文注释:新增尺寸项)
const data = await this.productService.createSize(sizeData);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
@ApiOkResponse({ type: ProductSizeRes }) @ApiOkResponse()
@Put('/size/:id') @Put('/size/:id')
async updateSize( async compatUpdateSize(@Param('id') id: number, @Body() body: { title?: string; name?: string }) {
@Param('id') id: number,
@Body() sizeData: UpdateSizeDTO
) {
try { try {
// 条件判断(中文注释:唯一性校验,排除自身) if (body?.name) {
const hasSize = await this.productService.hasAttribute( const has = await this.productService.hasAttribute('size', body.name, id);
'size', if (has) return errorResponse('尺寸已存在');
sizeData.name,
id
);
if (hasSize) {
return errorResponse('尺寸已存在');
} }
// 调用服务更新(中文注释:提交变更) const data = await this.productService.updateAttribute(id, body);
const data = await this.productService.updateSize(id, sizeData);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
@ -488,14 +445,10 @@ export class ProductController {
@ApiOkResponse({ type: BooleanRes }) @ApiOkResponse({ type: BooleanRes })
@Del('/size/:id') @Del('/size/:id')
async deleteSize(@Param('id') id: number) { async compatDeleteSize(@Param('id') id: number) {
try { try {
// 条件判断(中文注释:若有商品关联则不可删除) await this.productService.deleteAttribute(id);
const hasProducts = await this.productService.hasProductsInAttribute(id); return successResponse(true);
if (hasProducts) throw new Error('该尺寸下有商品,无法删除');
// 调用服务删除(中文注释:返回是否成功)
const data = await this.productService.deleteSize(id);
return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }

View File

@ -42,8 +42,9 @@ export class UpdateDictItemDTO {
title?: string; // 字典项标题 (可选) title?: string; // 字典项标题 (可选)
@Rule(RuleType.string().allow(null)) @Rule(RuleType.string().allow(null))
value?: string; // 字典项值 (可选) titleCN?: string; // 字典项中文标题 (可选)
@Rule(RuleType.string().allow(null)) @Rule(RuleType.string().allow(null))
titleCN?: string; // 字典项中文标题 (可选) value?: string; // 字典项值 (可选)
} }

View File

@ -21,35 +21,52 @@ export class CreateProductDTO {
@Rule(RuleType.string()) @Rule(RuleType.string())
sku?: string; sku?: string;
@ApiProperty({ description: '品牌 ID', type: 'number' }) // 通用属性输入(中文注释:通过 attributes 统一提交品牌/口味/强度/尺寸/干湿等)
@Rule(RuleType.number().required()) @ApiProperty({ description: '属性列表', type: 'array' })
brandId: number; @Rule(RuleType.array().required())
attributes: AttributeInputDTO[];
@ApiProperty({ description: '规格 ID', type: 'number' })
@Rule(RuleType.number().required())
strengthId: number;
@ApiProperty({ description: '口味 ID', type: 'number' })
@Rule(RuleType.number().required())
flavorsId: number;
@ApiProperty()
@Rule(RuleType.string())
humidity: string;
// 商品价格 // 商品价格
@ApiProperty({ description: '价格', example: 99.99, required: false }) @ApiProperty({ description: '价格', example: 99.99, required: false })
@Rule(RuleType.number()) @Rule(RuleType.number())
price?: number; price?: number;
// 促销价格
@ApiProperty({ description: '促销价格', example: 99.99, required: false })
@Rule(RuleType.number())
promotionPrice?: number;
} }
/** /**
* DTO * DTO
*/ */
export class UpdateProductDTO extends CreateProductDTO { export class UpdateProductDTO {
@ApiProperty({ example: 'ZYN 6MG WINTERGREEN', description: '产品名称' }) @ApiProperty({ example: 'ZYN 6MG WINTERGREEN', description: '产品名称' })
@Rule(RuleType.string()) @Rule(RuleType.string())
name: string; name?: string;
@ApiProperty({ example: '产品描述', description: '产品描述' })
@Rule(RuleType.string())
description?: string;
@ApiProperty({ description: '产品 SKU', required: false })
@Rule(RuleType.string())
sku?: string;
// 商品价格
@ApiProperty({ description: '价格', example: 99.99, required: false })
@Rule(RuleType.number())
price?: number;
// 促销价格
@ApiProperty({ description: '促销价格', example: 99.99, required: false })
@Rule(RuleType.number())
promotionPrice?: number;
// 属性更新(中文注释:可选,支持增量替换指定字典的属性项)
@ApiProperty({ description: '属性列表', type: 'array', required: false })
@Rule(RuleType.array())
attributes?: AttributeInputDTO[];
} }
/** /**
@ -73,6 +90,25 @@ export class QueryProductDTO {
brandId: number; brandId: number;
} }
// 属性输入项(中文注释:用于在创建/更新产品时传递字典项信息)
export class AttributeInputDTO {
@ApiProperty({ description: '字典名称', example: 'brand' })
@Rule(RuleType.string().required())
dictName: string;
@ApiProperty({ description: '字典项 ID', required: false })
@Rule(RuleType.number())
id?: number;
@ApiProperty({ description: '字典项显示名称', required: false })
@Rule(RuleType.string())
title?: string;
@ApiProperty({ description: '字典项唯一标识', required: false })
@Rule(RuleType.string())
name?: string;
}
/** /**
* DTO * DTO
*/ */

View File

@ -164,57 +164,14 @@ export class ProductService {
const [items, total] = await qb.getManyAndCount(); const [items, total] = await qb.getManyAndCount();
// 获取所有 SKU 的库存信息
const skus = items.map(item => item.sku).filter(Boolean);
const stocks = await this.stockService.getStocksBySkus(skus);
// 将库存信息映射到 SKU
const stockMap = stocks.reduce((map, stock) => {
map[stock.productSku] = stock.totalQuantity;
return map;
}, {});
// 格式化返回的数据(中文注释:将品牌/口味/规格/尺寸均以 DictItem 对象形式返回,并保留 attributes 列表)
const formattedItems = items.map(product => {
// 函数(中文注释:按字典名称获取对应的属性对象)
const getAttributeByDict = (dictName: string) =>
product.attributes.find(a => a.dict?.name === dictName) || null;
// 条件判断(中文注释:从属性中取出各类维度对象)
const brand = getAttributeByDict('brand');
const flavors = getAttributeByDict('flavor');
const strength = getAttributeByDict('strength');
const size = getAttributeByDict('size');
return { return {
id: product.id, items,
name: product.name,
nameCn: product.nameCn,
description: product.description,
sku: product.sku,
stock: stockMap[product.sku] || 0, // 中文注释:库存使用聚合库存值
price: product.price,
promotionPrice: product.promotionPrice,
source: product.source, // 中文注释:返回产品来源字段
createdAt: product.createdAt,
updatedAt: product.updatedAt,
// 单列属性(中文注释:直接返回 DictItem 对象,方便前端展示与使用)
brand,
flavors,
strength,
size,
// 全量属性列表(中文注释:保留原 attributes包含字典关系
attributes: product.attributes,
};
});
return {
items: formattedItems,
total, total,
...pagination, ...pagination,
}; };
} }
async getOrCreateDictItem( async getOrCreateAttribute(
dictName: string, dictName: string,
itemTitle: string, itemTitle: string,
itemName?: string itemName?: string
@ -243,25 +200,32 @@ export class ProductService {
} }
async createProduct(createProductDTO: CreateProductDTO): Promise<Product> { async createProduct(createProductDTO: CreateProductDTO): Promise<Product> {
const { name, description, brandId, flavorsId, strengthId, humidity, sku, price } = const { name, description, attributes, sku, price } = createProductDTO;
createProductDTO;
// 获取或创建品牌、口味、规格 // 条件判断(中文注释:校验属性输入)
const brandItem = await this.dictItemModel.findOne({ where: { id: brandId } }); if (!Array.isArray(attributes) || attributes.length === 0) {
if (!brandItem) throw new Error('品牌不存在'); throw new Error('属性列表不能为空');
}
const flavorItem = await this.dictItemModel.findOne({ where: { id: flavorsId } }); // 解析属性输入(中文注释:按 id 或 dictName 创建/关联字典项)
if (!flavorItem) throw new Error('口味不存在'); const resolvedAttributes: DictItem[] = [];
for (const attr of attributes) {
if (!attr?.dictName) throw new Error('属性项缺少字典名称');
let item: DictItem | null = null;
if (attr.id) {
item = await this.dictItemModel.findOne({ where: { id: attr.id }, relations: ['dict'] });
if (!item) throw new Error(`字典项 ID ${attr.id} 不存在`);
} else {
const titleOrName = attr.title || attr.name;
if (!titleOrName) throw new Error('新建字典项需要提供 title 或 name');
item = await this.getOrCreateAttribute(attr.dictName, titleOrName, attr.name);
}
resolvedAttributes.push(item);
}
const strengthItem = await this.dictItemModel.findOne({ where: { id: strengthId } }); // 检查完全相同属性组合是否已存在(中文注释:避免重复)
if (!strengthItem) throw new Error('规格不存在');
const humidityItem = await this.getOrCreateDictItem('humidity', humidity);
// 检查具有完全相同属性组合的产品是否已存在
const attributesToMatch = [brandItem, flavorItem, strengthItem, humidityItem];
const qb = this.productModel.createQueryBuilder('product'); const qb = this.productModel.createQueryBuilder('product');
attributesToMatch.forEach((attr, index) => { resolvedAttributes.forEach((attr, index) => {
qb.innerJoin( qb.innerJoin(
'product.attributes', 'product.attributes',
`attr${index}`, `attr${index}`,
@ -269,30 +233,32 @@ export class ProductService {
{ [`attrId${index}`]: attr.id } { [`attrId${index}`]: attr.id }
); );
}); });
const isExit = await qb.getOne(); const isExist = await qb.getOne();
if (isExist) throw new Error('产品已存在');
if (isExit) throw new Error('产品已存在'); // 创建新产品实例(中文注释:绑定属性与基础字段)
// 创建新产品实例
const product = new Product(); const product = new Product();
product.name = name; product.name = name;
product.description = description; product.description = description;
product.attributes = attributesToMatch; product.attributes = resolvedAttributes;
// 如果用户提供了 sku则直接使用否则通过模板引擎生成 // 生成或设置 SKU中文注释基于属性字典项的 name 生成)
if (sku) { if (sku) {
product.sku = sku; product.sku = sku;
} else { } else {
// 生成 SKU const attributeMap: Record<string, string> = {};
for (const a of resolvedAttributes) {
if (a?.dict?.name && a?.name) attributeMap[a.dict.name] = a.name;
}
product.sku = await this.templateService.render('product_sku', { product.sku = await this.templateService.render('product_sku', {
brand: brandItem.name, brand: attributeMap['brand'] || '',
flavor: flavorItem.name, flavor: attributeMap['flavor'] || '',
strength: strengthItem.name, strength: attributeMap['strength'] || '',
humidity: humidityItem.name, humidity: attributeMap['humidity'] || '',
}); });
} }
// 价格与促销价 // 价格与促销价(中文注释:可选字段)
if (price !== undefined) { if (price !== undefined) {
product.price = Number(price); product.price = Number(price);
} }
@ -301,7 +267,6 @@ export class ProductService {
product.promotionPrice = Number(promotionPrice); product.promotionPrice = Number(promotionPrice);
} }
// 保存产品
return await this.productModel.save(product); return await this.productModel.save(product);
} }
@ -340,40 +305,31 @@ export class ProductService {
} }
} }
// 处理属性更新(品牌/口味/强度/干湿) // 处理属性更新(中文注释:若传入 attributes 则按字典名称替换对应项)
if (Array.isArray(updateProductDTO.attributes) && updateProductDTO.attributes.length > 0) {
const nextAttributes: DictItem[] = [...(product.attributes || [])]; const nextAttributes: DictItem[] = [...(product.attributes || [])];
// 根据 dict.name 查找或替换已有属性
const replaceAttr = (dictName: string, item: DictItem) => { const replaceAttr = (dictName: string, item: DictItem) => {
const idx = nextAttributes.findIndex(a => a.dict?.name === dictName); const idx = nextAttributes.findIndex(a => a.dict?.name === dictName);
if (idx >= 0) nextAttributes[idx] = item; else nextAttributes.push(item); if (idx >= 0) nextAttributes[idx] = item; else nextAttributes.push(item);
}; };
// 品牌 for (const attr of updateProductDTO.attributes) {
if (updateProductDTO.brandId !== undefined) { if (!attr?.dictName) throw new Error('属性项缺少字典名称');
const brandItem = await this.dictItemModel.findOne({ where: { id: updateProductDTO.brandId }, relations: ['dict'] }); let item: DictItem | null = null;
if (!brandItem) throw new Error('品牌不存在'); if (attr.id) {
replaceAttr('brand', brandItem); item = await this.dictItemModel.findOne({ where: { id: attr.id }, relations: ['dict'] });
if (!item) throw new Error(`字典项 ID ${attr.id} 不存在`);
} else {
const titleOrName = attr.title || attr.name;
if (!titleOrName) throw new Error('新建字典项需要提供 title 或 name');
item = await this.getOrCreateAttribute(attr.dictName, titleOrName, attr.name);
} }
// 口味 replaceAttr(attr.dictName, item);
if (updateProductDTO.flavorsId !== undefined) {
const flavorItem = await this.dictItemModel.findOne({ where: { id: updateProductDTO.flavorsId }, relations: ['dict'] });
if (!flavorItem) throw new Error('口味不存在');
replaceAttr('flavor', flavorItem);
}
// 强度
if (updateProductDTO.strengthId !== undefined) {
const strengthItem = await this.dictItemModel.findOne({ where: { id: updateProductDTO.strengthId }, relations: ['dict'] });
if (!strengthItem) throw new Error('规格不存在');
replaceAttr('strength', strengthItem);
}
// 干湿(按 title 获取或创建)
if (updateProductDTO.humidity !== undefined) {
const humidityItem = await this.getOrCreateDictItem('humidity', updateProductDTO.humidity);
replaceAttr('humidity', humidityItem);
} }
product.attributes = nextAttributes; product.attributes = nextAttributes;
}
// 保存更新后的产品 // 保存更新后的产品
const saved = await this.productModel.save(product); const saved = await this.productModel.save(product);
@ -752,6 +708,75 @@ export class ProductService {
return await this.dictItemModel.save(strength); return await this.dictItemModel.save(strength);
} }
// 通用属性:分页获取指定字典的字典项
async getAttributeList(
dictName: string,
pagination: PaginationParams,
name?: string
): Promise<BrandPaginatedResponse> {
const dict = await this.dictModel.findOne({ where: { name: dictName } });
if (!dict) return { items: [], total: 0, ...pagination } as any;
const where: any = { dict: { id: dict.id } };
if (name) where.title = Like(`%${name}%`);
const [items, total] = await this.dictItemModel.findAndCount({
where,
skip: (pagination.current - 1) * pagination.pageSize,
take: pagination.pageSize,
order: { sort: 'ASC', id: 'DESC' },
relations: ['dict'],
});
return { items, total, ...pagination } as any;
}
// 通用属性:获取指定字典的全部字典项
async getAttributeAll(dictName: string): Promise<DictItem[]> {
const dict = await this.dictModel.findOne({ where: { name: dictName } });
if (!dict) return [];
return this.dictItemModel.find({
where: { dict: { id: dict.id } },
order: { sort: 'ASC', id: 'DESC' },
relations: ['dict'],
});
}
// 通用属性:创建字典项
async createAttribute(
dictName: string,
payload: { title: string; name: string }
): Promise<DictItem> {
const dict = await this.dictModel.findOne({ where: { name: dictName } });
if (!dict) throw new Error(`字典 ${dictName} 不存在`);
const exists = await this.dictItemModel.findOne({
where: { name: payload.name, dict: { id: dict.id } },
relations: ['dict'],
});
if (exists) throw new Error('字典项已存在');
const item = new DictItem();
item.title = payload.title;
item.name = payload.name;
item.dict = dict;
return await this.dictItemModel.save(item);
}
// 通用属性:更新字典项
async updateAttribute(
id: number,
payload: { title?: string; name?: string }
): Promise<DictItem> {
const item = await this.dictItemModel.findOne({ where: { id } });
if (!item) throw new Error('字典项不存在');
if (payload.title !== undefined) item.title = payload.title;
if (payload.name !== undefined) item.name = payload.name;
return await this.dictItemModel.save(item);
}
// 通用属性:删除字典项(若存在产品关联则禁止删除)
async deleteAttribute(id: number): Promise<void> {
const hasProducts = await this.hasProductsInAttribute(id);
if (hasProducts) throw new Error('当前字典项存在关联产品,无法删除');
await this.dictItemModel.delete({ id });
}
async updateStrength(id: number, updateStrength: UpdateStrengthDTO) { async updateStrength(id: number, updateStrength: UpdateStrengthDTO) {
const strength = await this.dictItemModel.findOneBy({ id }); const strength = await this.dictItemModel.findOneBy({ id });
if (!strength) { if (!strength) {