From 64b8468df8216196ac18893a89b72130344c9db9 Mon Sep 17 00:00:00 2001 From: tikkhun Date: Fri, 28 Nov 2025 17:59:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BA=A7=E5=93=81):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E5=B1=9E=E6=80=A7=E7=AE=A1=E7=90=86=E4=B8=BA?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E5=AD=97=E5=85=B8=E9=A1=B9=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构产品相关的品牌、口味、规格和尺寸属性管理,改为基于通用字典项的实现方式: 1. 新增 AttributeInputDTO 用于统一处理属性输入 2. 实现通用的字典项增删改查接口 3. 保留旧接口作为兼容层 4. 优化产品创建和更新逻辑以支持新属性结构 --- src/controller/product.controller.ts | 403 ++++++++++++--------------- src/dto/dict.dto.ts | 5 +- src/dto/product.dto.ts | 70 +++-- src/service/product.service.ts | 233 +++++++++------- 4 files changed, 363 insertions(+), 348 deletions(-) diff --git a/src/controller/product.controller.ts b/src/controller/product.controller.ts index 8c3cdf5..c8bcaf2 100644 --- a/src/controller/product.controller.ts +++ b/src/controller/product.controller.ts @@ -11,35 +11,9 @@ import { } from '@midwayjs/core'; import { ProductService } from '../service/product.service'; import { errorResponse, successResponse } from '../utils/response.util'; -import { - BatchSetSkuDTO, - CreateBrandDTO, - CreateFlavorsDTO, - CreateProductDTO, - CreateStrengthDTO, - CreateSizeDTO, - QueryBrandDTO, - QueryFlavorsDTO, - QueryProductDTO, - QueryStrengthDTO, - QuerySizeDTO, - UpdateBrandDTO, - UpdateFlavorsDTO, - UpdateProductDTO, - UpdateStrengthDTO, - UpdateSizeDTO, -} from '../dto/product.dto'; +import { CreateProductDTO, QueryProductDTO, UpdateProductDTO } from '../dto/product.dto'; import { ApiOkResponse } from '@midwayjs/swagger'; -import { - BooleanRes, - ProductBrandListRes, - ProductBrandRes, - ProductSizeListRes, - ProductSizeRes, - ProductListRes, - ProductRes, - ProductsRes, -} from '../dto/reponse.dto'; +import { BooleanRes, ProductListRes, ProductRes, ProductsRes } from '../dto/reponse.dto'; @Controller('/product') export class ProductController { @@ -98,9 +72,7 @@ export class ProductController { } } - @ApiOkResponse({ - type: ProductRes, - }) + @ApiOkResponse({ type: ProductRes }) @Post('/') async createProduct(@Body() productData: CreateProductDTO) { try { @@ -111,14 +83,9 @@ export class ProductController { } } - @ApiOkResponse({ - type: ProductRes, - }) + @ApiOkResponse({ type: ProductRes }) @Put('/:id') - async updateProduct( - @Param('id') id: number, - @Body() productData: UpdateProductDTO - ) { + async updateProduct(@Param('id') id: number, @Body() productData: UpdateProductDTO) { try { const data = this.productService.updateProduct(id, productData); return successResponse(data); @@ -127,14 +94,9 @@ export class ProductController { } } - @ApiOkResponse({ - type: ProductRes, - }) + @ApiOkResponse({ type: ProductRes }) @Put('updateNameCn/:id/:nameCn') - async updateProductNameCn( - @Param('id') id: number, - @Param('nameCn') nameCn: string - ) { + async updateProductNameCn(@Param('id') id: number, @Param('nameCn') nameCn: string) { try { const data = this.productService.updateProductNameCn(id, nameCn); return successResponse(data); @@ -143,9 +105,7 @@ export class ProductController { } } - @ApiOkResponse({ - type: BooleanRes, - }) + @ApiOkResponse({ type: BooleanRes }) @Del('/:id') async deleteProduct(@Param('id') id: number) { try { @@ -156,14 +116,19 @@ export class ProductController { } } - @ApiOkResponse({ - type: ProductBrandListRes, - }) - @Get('/brands') - async getBrands(@Query() query: QueryBrandDTO) { - const { current = 1, pageSize = 10, name } = query; + + // 通用属性接口:分页列表 + @ApiOkResponse() + @Get('/attribute') + async getAttributeList( + @Query('dictName') dictName: string, + @Query('current') current = 1, + @Query('pageSize') pageSize = 10, + @Query('name') name?: string + ) { try { - let data = await this.productService.getBrandList( + const data = await this.productService.getAttributeList( + dictName, { current, pageSize }, name ); @@ -173,95 +138,142 @@ export class ProductController { } } + // 通用属性接口:全部列表 @ApiOkResponse() - @Get('/brandAll') - async getBrandAll() { + @Get('/attributeAll') + async getAttributeAll(@Query('dictName') dictName: string) { try { - let data = await this.productService.getBrandAll(); + const data = await this.productService.getAttributeAll(dictName); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ - type: ProductBrandRes, - }) - @Post('/brand') - async createBrand(@Body() brandData: CreateBrandDTO) { - try { - 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 + // 通用属性接口:创建 + @ApiOkResponse() + @Post('/attribute') + async createAttribute( + @Query('dictName') dictName: string, + @Body() body: { title: string; name: string } ) { try { - const hasBrand = await this.productService.hasAttribute( - 'brand', - brandData.name, - id + const hasItem = await this.productService.hasAttribute( + dictName, + body.name ); - if (hasBrand) { - return errorResponse('品牌已存在'); + 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 + ); + if (hasItem) return errorResponse('字典项已存在'); } - const data = this.productService.updateBrand(id, brandData); + 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 deleteBrand(@Param('id') id: number) { + // 通用属性接口:删除 + @ApiOkResponse({ type: BooleanRes }) + @Del('/attribute/:id') + async deleteAttribute(@Param('id') id: number) { try { - const hasProducts = await this.productService.hasProductsInAttribute(id); - if (hasProducts) throw new Error('该品牌下有商品,无法删除'); - const data = await this.productService.deleteBrand(id); - return successResponse(data); + await this.productService.deleteAttribute(id); + return successResponse(true); } catch (error) { return errorResponse(error?.message || error); } } - @Post('/batchSetSku') - @ApiOkResponse({ - description: '批量设置 sku 的响应结果', - type: BooleanRes, - }) - async batchSetSku(@Body() body: BatchSetSkuDTO) { + // 兼容旧接口:品牌 + @ApiOkResponse() + @Get('/brandAll') + async compatBrandAll() { try { - const result = await this.productService.batchSetSku(body.skus); - return successResponse(result, '批量设置 sku 成功'); + const data = await this.productService.getAttributeAll('brand'); // 中文注释:返回所有品牌字典项 + return successResponse(data); } catch (error) { - return errorResponse(error.message, 400); + return errorResponse(error?.message || error); } } @ApiOkResponse() - @Get('/flavorsAll') - async getFlavorsAll() { + @Get('/brands') + async compatBrands(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) { 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); } catch (error) { return errorResponse(error?.message || error); @@ -270,13 +282,9 @@ export class ProductController { @ApiOkResponse() @Get('/flavors') - async getFlavors(@Query() query: QueryFlavorsDTO) { - const { current = 1, pageSize = 10, name } = query; + async compatFlavors(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) { try { - let data = await this.productService.getFlavorsList( - { current, pageSize }, - name - ); + const data = await this.productService.getAttributeList('flavor', { current, pageSize }, name); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -285,13 +293,11 @@ export class ProductController { @ApiOkResponse() @Post('/flavors') - async createFlavors(@Body() flavorsData: CreateFlavorsDTO) { + async compatCreateFlavors(@Body() body: { title: string; name: string }) { try { - const hasFlavors = await this.productService.hasAttribute('flavor', flavorsData.name); - if (hasFlavors) { - return errorResponse('口味已存在'); - } - let data = await this.productService.createFlavors(flavorsData); + const has = await this.productService.hasAttribute('flavor', body.name); + if (has) return errorResponse('口味已存在'); + const data = await this.productService.createAttribute('flavor', body); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -300,42 +306,36 @@ export class ProductController { @ApiOkResponse() @Put('/flavors/:id') - async updateFlavors( - @Param('id') id: number, - @Body() flavorsData: UpdateFlavorsDTO - ) { + async compatUpdateFlavors(@Param('id') id: number, @Body() body: { title?: string; name?: string }) { try { - const hasFlavors = await this.productService.hasAttribute('flavor', flavorsData.name, id); - if (hasFlavors) { - return errorResponse('口味已存在'); + if (body?.name) { + const has = await this.productService.hasAttribute('flavor', body.name, id); + if (has) return errorResponse('口味已存在'); } - const data = this.productService.updateFlavors(id, flavorsData); + const data = await this.productService.updateAttribute(id, body); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ - type: BooleanRes, - }) + @ApiOkResponse({ type: BooleanRes }) @Del('/flavors/:id') - async deleteFlavors(@Param('id') id: number) { + async compatDeleteFlavors(@Param('id') id: number) { try { - const hasProducts = await this.productService.hasProductsInAttribute(id); - if (hasProducts) throw new Error('该口味下有商品,无法删除'); - const data = await this.productService.deleteFlavors(id); - return successResponse(data); + await this.productService.deleteAttribute(id); + return successResponse(true); } catch (error) { return errorResponse(error?.message || error); } } + // 兼容旧接口:规格 @ApiOkResponse() @Get('/strengthAll') - async getStrengthAll() { + async compatStrengthAll() { try { - let data = await this.productService.getStrengthAll(); + const data = await this.productService.getAttributeAll('strength'); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -344,13 +344,9 @@ export class ProductController { @ApiOkResponse() @Get('/strength') - async getStrength(@Query() query: QueryStrengthDTO) { - const { current = 1, pageSize = 10, name } = query; + async compatStrength(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) { try { - let data = await this.productService.getStrengthList( - { current, pageSize }, - name - ); + const data = await this.productService.getAttributeList('strength', { current, pageSize }, name); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -359,16 +355,11 @@ export class ProductController { @ApiOkResponse() @Post('/strength') - async createStrength(@Body() strengthData: CreateStrengthDTO) { + async compatCreateStrength(@Body() body: { title: string; name: string }) { try { - const hasStrength = await this.productService.hasAttribute( - 'strength', - strengthData.name - ); - if (hasStrength) { - return errorResponse('规格已存在'); - } - let data = await this.productService.createStrength(strengthData); + const has = await this.productService.hasAttribute('strength', body.name); + if (has) return errorResponse('规格已存在'); + const data = await this.productService.createAttribute('strength', body); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -377,109 +368,75 @@ export class ProductController { @ApiOkResponse() @Put('/strength/:id') - async updateStrength( - @Param('id') id: number, - @Body() strengthData: UpdateStrengthDTO - ) { + async compatUpdateStrength(@Param('id') id: number, @Body() body: { title?: string; name?: string }) { try { - const hasStrength = await this.productService.hasAttribute( - 'strength', - strengthData.name, - id - ); - if (hasStrength) { - return errorResponse('规格已存在'); + if (body?.name) { + const has = await this.productService.hasAttribute('strength', body.name, id); + if (has) return errorResponse('规格已存在'); } - const data = this.productService.updateStrength(id, strengthData); + const data = await this.productService.updateAttribute(id, body); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ - type: BooleanRes, - }) + @ApiOkResponse({ type: BooleanRes }) @Del('/strength/:id') - async deleteStrength(@Param('id') id: number) { + async compatDeleteStrength(@Param('id') id: number) { try { - const hasProducts = await this.productService.hasProductsInAttribute(id); - if (hasProducts) throw new Error('该规格下有商品,无法删除'); - const data = await this.productService.deleteStrength(id); - return successResponse(data); + await this.productService.deleteAttribute(id); + return successResponse(true); } catch (error) { return errorResponse(error?.message || error); } } - // size 路由与增删改查 + // 兼容旧接口:尺寸 @ApiOkResponse() @Get('/sizeAll') - async getSizeAll() { + async compatSizeAll() { try { - // 中文注释:获取所有尺寸项 - const data = await this.productService.getSizeAll(); + const data = await this.productService.getAttributeAll('size'); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ type: ProductSizeListRes }) + @ApiOkResponse() @Get('/size') - async getSize(@Query() query: QuerySizeDTO) { - // 中文注释:解析分页与关键字 - const { current = 1, pageSize = 10, name } = query; + async compatSize(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) { try { - // 中文注释:分页查询尺寸列表 - const data = await this.productService.getSizeList( - { current, pageSize }, - name - ); + const data = await this.productService.getAttributeList('size', { current, pageSize }, name); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ type: ProductSizeRes }) + @ApiOkResponse() @Post('/size') - async createSize(@Body() sizeData: CreateSizeDTO) { + async compatCreateSize(@Body() body: { title: string; name: string }) { try { - // 条件判断(中文注释:唯一性校验,禁止重复) - const hasSize = await this.productService.hasAttribute( - 'size', - sizeData.name - ); - if (hasSize) { - return errorResponse('尺寸已存在'); - } - // 调用服务创建(中文注释:新增尺寸项) - const data = await this.productService.createSize(sizeData); + const has = await this.productService.hasAttribute('size', body.name); + if (has) return errorResponse('尺寸已存在'); + const data = await this.productService.createAttribute('size', body); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ type: ProductSizeRes }) + @ApiOkResponse() @Put('/size/:id') - async updateSize( - @Param('id') id: number, - @Body() sizeData: UpdateSizeDTO - ) { + async compatUpdateSize(@Param('id') id: number, @Body() body: { title?: string; name?: string }) { try { - // 条件判断(中文注释:唯一性校验,排除自身) - const hasSize = await this.productService.hasAttribute( - 'size', - sizeData.name, - id - ); - if (hasSize) { - return errorResponse('尺寸已存在'); + if (body?.name) { + const has = await this.productService.hasAttribute('size', body.name, id); + if (has) return errorResponse('尺寸已存在'); } - // 调用服务更新(中文注释:提交变更) - const data = await this.productService.updateSize(id, sizeData); + const data = await this.productService.updateAttribute(id, body); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -488,14 +445,10 @@ export class ProductController { @ApiOkResponse({ type: BooleanRes }) @Del('/size/:id') - async deleteSize(@Param('id') id: number) { + async compatDeleteSize(@Param('id') id: number) { try { - // 条件判断(中文注释:若有商品关联则不可删除) - const hasProducts = await this.productService.hasProductsInAttribute(id); - if (hasProducts) throw new Error('该尺寸下有商品,无法删除'); - // 调用服务删除(中文注释:返回是否成功) - const data = await this.productService.deleteSize(id); - return successResponse(data); + await this.productService.deleteAttribute(id); + return successResponse(true); } catch (error) { return errorResponse(error?.message || error); } diff --git a/src/dto/dict.dto.ts b/src/dto/dict.dto.ts index 85c8b02..b3f015e 100644 --- a/src/dto/dict.dto.ts +++ b/src/dto/dict.dto.ts @@ -41,9 +41,10 @@ export class UpdateDictItemDTO { @Rule(RuleType.string()) title?: string; // 字典项标题 (可选) + @Rule(RuleType.string().allow(null)) + titleCN?: string; // 字典项中文标题 (可选) + @Rule(RuleType.string().allow(null)) value?: string; // 字典项值 (可选) - @Rule(RuleType.string().allow(null)) - titleCN?: string; // 字典项中文标题 (可选) } diff --git a/src/dto/product.dto.ts b/src/dto/product.dto.ts index 2419073..cc95206 100644 --- a/src/dto/product.dto.ts +++ b/src/dto/product.dto.ts @@ -21,35 +21,52 @@ export class CreateProductDTO { @Rule(RuleType.string()) sku?: string; - @ApiProperty({ description: '品牌 ID', type: 'number' }) - @Rule(RuleType.number().required()) - brandId: number; - - @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; + // 通用属性输入(中文注释:通过 attributes 统一提交品牌/口味/强度/尺寸/干湿等) + @ApiProperty({ description: '属性列表', type: 'array' }) + @Rule(RuleType.array().required()) + attributes: AttributeInputDTO[]; // 商品价格 @ApiProperty({ description: '价格', example: 99.99, required: false }) @Rule(RuleType.number()) price?: number; + + // 促销价格 + @ApiProperty({ description: '促销价格', example: 99.99, required: false }) + @Rule(RuleType.number()) + promotionPrice?: number; } /** * DTO 用于更新产品 */ -export class UpdateProductDTO extends CreateProductDTO { +export class UpdateProductDTO { @ApiProperty({ example: 'ZYN 6MG WINTERGREEN', description: '产品名称' }) @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; } +// 属性输入项(中文注释:用于在创建/更新产品时传递字典项信息) +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 用于创建品牌 */ diff --git a/src/service/product.service.ts b/src/service/product.service.ts index b5d20b6..487773b 100644 --- a/src/service/product.service.ts +++ b/src/service/product.service.ts @@ -164,57 +164,14 @@ export class ProductService { 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 { - id: product.id, - 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, + items, total, ...pagination, }; } - async getOrCreateDictItem( + async getOrCreateAttribute( dictName: string, itemTitle: string, itemName?: string @@ -243,25 +200,32 @@ export class ProductService { } async createProduct(createProductDTO: CreateProductDTO): Promise { - const { name, description, brandId, flavorsId, strengthId, humidity, sku, price } = - createProductDTO; + const { name, description, attributes, sku, price } = createProductDTO; - // 获取或创建品牌、口味、规格 - const brandItem = await this.dictItemModel.findOne({ where: { id: brandId } }); - if (!brandItem) throw new Error('品牌不存在'); + // 条件判断(中文注释:校验属性输入) + if (!Array.isArray(attributes) || attributes.length === 0) { + throw new Error('属性列表不能为空'); + } - const flavorItem = await this.dictItemModel.findOne({ where: { id: flavorsId } }); - if (!flavorItem) throw new Error('口味不存在'); + // 解析属性输入(中文注释:按 id 或 dictName 创建/关联字典项) + 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'); - attributesToMatch.forEach((attr, index) => { + resolvedAttributes.forEach((attr, index) => { qb.innerJoin( 'product.attributes', `attr${index}`, @@ -269,30 +233,32 @@ export class ProductService { { [`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(); product.name = name; product.description = description; - product.attributes = attributesToMatch; + product.attributes = resolvedAttributes; - // 如果用户提供了 sku,则直接使用;否则,通过模板引擎生成 + // 生成或设置 SKU(中文注释:基于属性字典项的 name 生成) if (sku) { product.sku = sku; } else { - // 生成 SKU + const attributeMap: Record = {}; + 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', { - brand: brandItem.name, - flavor: flavorItem.name, - strength: strengthItem.name, - humidity: humidityItem.name, + brand: attributeMap['brand'] || '', + flavor: attributeMap['flavor'] || '', + strength: attributeMap['strength'] || '', + humidity: attributeMap['humidity'] || '', }); } - // 价格与促销价 + // 价格与促销价(中文注释:可选字段) if (price !== undefined) { product.price = Number(price); } @@ -301,7 +267,6 @@ export class ProductService { product.promotionPrice = Number(promotionPrice); } - // 保存产品 return await this.productModel.save(product); } @@ -340,40 +305,31 @@ export class ProductService { } } - // 处理属性更新(品牌/口味/强度/干湿) - const nextAttributes: DictItem[] = [...(product.attributes || [])]; + // 处理属性更新(中文注释:若传入 attributes 则按字典名称替换对应项) + if (Array.isArray(updateProductDTO.attributes) && updateProductDTO.attributes.length > 0) { + const nextAttributes: DictItem[] = [...(product.attributes || [])]; - // 根据 dict.name 查找或替换已有属性 - const replaceAttr = (dictName: string, item: DictItem) => { - const idx = nextAttributes.findIndex(a => a.dict?.name === dictName); - if (idx >= 0) nextAttributes[idx] = item; else nextAttributes.push(item); - }; + const replaceAttr = (dictName: string, item: DictItem) => { + const idx = nextAttributes.findIndex(a => a.dict?.name === dictName); + if (idx >= 0) nextAttributes[idx] = item; else nextAttributes.push(item); + }; - // 品牌 - if (updateProductDTO.brandId !== undefined) { - const brandItem = await this.dictItemModel.findOne({ where: { id: updateProductDTO.brandId }, relations: ['dict'] }); - if (!brandItem) throw new Error('品牌不存在'); - replaceAttr('brand', brandItem); - } - // 口味 - 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); - } + for (const attr of updateProductDTO.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); + } + replaceAttr(attr.dictName, item); + } - product.attributes = nextAttributes; + product.attributes = nextAttributes; + } // 保存更新后的产品 const saved = await this.productModel.save(product); @@ -752,6 +708,75 @@ export class ProductService { return await this.dictItemModel.save(strength); } + // 通用属性:分页获取指定字典的字典项 + async getAttributeList( + dictName: string, + pagination: PaginationParams, + name?: string + ): Promise { + 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 { + 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 { + 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 { + 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 { + const hasProducts = await this.hasProductsInAttribute(id); + if (hasProducts) throw new Error('当前字典项存在关联产品,无法删除'); + await this.dictItemModel.delete({ id }); + } + async updateStrength(id: number, updateStrength: UpdateStrengthDTO) { const strength = await this.dictItemModel.findOneBy({ id }); if (!strength) {