From af9f49ab58796cd91c72278980a74c835813ef33 Mon Sep 17 00:00:00 2001 From: tikkhun Date: Sat, 29 Nov 2025 11:40:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BA=A7=E5=93=81):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BA=A7=E5=93=81CSV=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=B9=B6=E5=A2=9E=E5=BC=BA=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加产品CSV导入导出功能,支持完整产品数据包括属性和类型 扩展产品类型处理逻辑,区分simple和bundle类型的不同行为 在库存查询中增加按库存点排序功能 完善产品DTO和实体中的类型字段定义 --- src/controller/product.controller.ts | 38 +++++ src/dto/product.dto.ts | 10 ++ src/dto/stock.dto.ts | 8 + src/entity/product.entity.ts | 2 +- src/service/product.service.ts | 229 ++++++++++++++++++++++++++- src/service/stock.service.ts | 8 +- 6 files changed, 288 insertions(+), 7 deletions(-) diff --git a/src/controller/product.controller.ts b/src/controller/product.controller.ts index 96e1617..754156f 100644 --- a/src/controller/product.controller.ts +++ b/src/controller/product.controller.ts @@ -14,6 +14,8 @@ import { errorResponse, successResponse } from '../utils/response.util'; import { CreateProductDTO, QueryProductDTO, UpdateProductDTO, SetProductComponentsDTO } from '../dto/product.dto'; import { ApiOkResponse } from '@midwayjs/swagger'; import { BooleanRes, ProductListRes, ProductRes, ProductsRes } from '../dto/reponse.dto'; +import { ContentType, Files } from '@midwayjs/core'; +import { Context } from '@midwayjs/koa'; @Controller('/product') export class ProductController { @@ -21,6 +23,9 @@ export class ProductController { productService: ProductService; ProductRes; + @Inject() + ctx: Context; + @ApiOkResponse({ description: '通过name搜索产品', type: ProductsRes, @@ -83,6 +88,39 @@ export class ProductController { } } + // 中文注释:导出所有产品 CSV + @ApiOkResponse() + @Get('/export') + @ContentType('text/csv') + async exportProductsCSV() { + try { + const csv = await this.productService.exportProductsCSV(); + // 设置下载文件名(中文注释:附件形式) + const date = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const name = `products-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}.csv`; + this.ctx.set('Content-Disposition', `attachment; filename=${name}`); + return csv; + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 中文注释:导入产品(CSV 文件) + @ApiOkResponse() + @Post('/import') + async importProductsCSV(@Files() files: any) { + try { + // 条件判断:确保存在文件 + const file = files?.[0]; + if (!file?.data) return errorResponse('未接收到上传文件'); + const result = await this.productService.importProductsCSV(file.data); + return successResponse(result); + } catch (error) { + return errorResponse(error?.message || error); + } + } + @ApiOkResponse({ type: ProductRes }) @Put('/:id') async updateProduct(@Param('id') id: number, @Body() productData: UpdateProductDTO) { diff --git a/src/dto/product.dto.ts b/src/dto/product.dto.ts index 6f0fa36..becabc9 100644 --- a/src/dto/product.dto.ts +++ b/src/dto/product.dto.ts @@ -35,6 +35,11 @@ export class CreateProductDTO { @ApiProperty({ description: '促销价格', example: 99.99, required: false }) @Rule(RuleType.number()) promotionPrice?: number; + + // 中文注释:商品类型(默认 simple;bundle 需手动设置组成) + @ApiProperty({ description: '商品类型', enum: ['simple', 'bundle'], default: 'simple', required: false }) + @Rule(RuleType.string().valid('simple', 'bundle').default('simple')) + type?: string; } /** @@ -67,6 +72,11 @@ export class UpdateProductDTO { @ApiProperty({ description: '属性列表', type: 'array', required: false }) @Rule(RuleType.array()) attributes?: AttributeInputDTO[]; + + // 中文注释:商品类型更新(simple 或 bundle) + @ApiProperty({ description: '商品类型', enum: ['simple', 'bundle'], required: false }) + @Rule(RuleType.string().valid('simple', 'bundle')) + type?: string; } /** diff --git a/src/dto/stock.dto.ts b/src/dto/stock.dto.ts index 24cfdd8..353ff18 100644 --- a/src/dto/stock.dto.ts +++ b/src/dto/stock.dto.ts @@ -21,6 +21,14 @@ export class QueryStockDTO { @ApiProperty() @Rule(RuleType.string()) productName: string; + + @ApiProperty({ description: '按库存点ID排序', required: false }) + @Rule(RuleType.number().allow(null)) + sortPointId?: number; + + @ApiProperty({ description: '排序方向', enum: ['ascend', 'descend'], required: false }) + @Rule(RuleType.string().valid('ascend', 'descend').allow('')) + sortOrder?: 'ascend' | 'descend' | ''; } export class QueryPointDTO { @ApiProperty({ example: '1', description: '页码' }) diff --git a/src/entity/product.entity.ts b/src/entity/product.entity.ts index 4776bd5..06629b5 100644 --- a/src/entity/product.entity.ts +++ b/src/entity/product.entity.ts @@ -51,7 +51,7 @@ export class Product { price: number; // 类型 主要用来区分混装和单品 单品死 @ApiProperty({ description: '类型' }) - @Column() + @Column({ length: 16, default: 'simple' }) type: string; // 促销价格 @ApiProperty({ description: '促销价格', example: 99.99 }) diff --git a/src/service/product.service.ts b/src/service/product.service.ts index f357188..c76e9a7 100644 --- a/src/service/product.service.ts +++ b/src/service/product.service.ts @@ -172,6 +172,23 @@ export class ProductService { const [items, total] = await qb.getManyAndCount(); + // 中文注释:根据类型填充组成信息 + for (const p of items) { + if (p.type === 'simple') { + const stocks = await this.stockModel.find({ where: { productSku: p.sku } }); + p.components = stocks.map(s => { + const comp = new ProductStockComponent(); + comp.productId = p.id; + comp.stockId = s.id; + comp.quantity = 1; + comp.stock = s as any; + return comp; + }); + } else { + p.components = await this.productStockComponentModel.find({ where: { productId: p.id } }); + } + } + return { items, total, @@ -251,6 +268,8 @@ export class ProductService { product.name = name; product.description = description; product.attributes = resolvedAttributes; + // 条件判断(中文注释:设置商品类型,默认 simple) + product.type = (createProductDTO.type as any) || 'simple'; // 生成或设置 SKU(中文注释:基于属性字典项的 name 生成) if (sku) { @@ -346,6 +365,11 @@ export class ProductService { product.attributes = nextAttributes; } + // 条件判断(中文注释:更新商品类型,如传入) + if (updateProductDTO.type !== undefined) { + product.type = updateProductDTO.type as any; + } + // 保存更新后的产品 const saved = await this.productModel.save(product); return saved; @@ -356,6 +380,20 @@ export class ProductService { // 条件判断:确保产品存在 const product = await this.productModel.findOne({ where: { id: productId } }); if (!product) throw new Error(`产品 ID ${productId} 不存在`); + // 条件判断(中文注释:单品 simple 不持久化组成,按 sku 动态生成) + if (product.type === 'simple') { + const stocks = await this.stockModel.find({ where: { productSku: product.sku } }); + // 中文注释:将同 sku 的库存映射为组成信息(数量默认为 1) + return stocks.map(s => { + const comp = new ProductStockComponent(); + comp.productId = productId; + comp.stockId = s.id; + comp.quantity = 1; + comp.stock = s as any; + return comp; + }); + } + // 混装 bundle:返回已保存的组成 return await this.productStockComponentModel.find({ where: { productId } }); } @@ -367,6 +405,10 @@ export class ProductService { // 条件判断:确保产品存在 const product = await this.productModel.findOne({ where: { id: productId } }); if (!product) throw new Error(`产品 ID ${productId} 不存在`); + // 条件判断(中文注释:单品 simple 不允许手动设置组成) + if (product.type === 'simple') { + throw new Error('单品无需设置组成'); + } const validItems = (items || []) .filter(i => i && i.stockId && i.quantity && i.quantity > 0) @@ -395,23 +437,34 @@ export class ProductService { // 条件判断:确保产品存在 const product = await this.productModel.findOne({ where: { id: productId } }); if (!product) throw new Error(`产品 ID ${productId} 不存在`); - const stocks = await this.stockModel.find({ where: { productSku: product.sku } }); if (stocks.length === 0) return []; - + // 条件判断(中文注释:simple 类型不持久化组成,直接返回动态映射) + if (product.type === 'simple') { + return stocks.map(s => { + const comp = new ProductStockComponent(); + comp.productId = productId; + comp.stockId = s.id; + comp.quantity = 1; // 默认数量 1 + comp.stock = s as any; + return comp; + }); + } + // bundle 类型:持久化组成 for (const stock of stocks) { - // 条件判断:若已存在相同 stockId 的组成则跳过 const exist = await this.productStockComponentModel.findOne({ where: { productId, stockId: stock.id } }); if (exist) continue; const comp = new ProductStockComponent(); comp.productId = productId; comp.stockId = stock.id; - comp.quantity = 1; // 默认数量 1 - comp.stock = stock; + comp.quantity = 1; + comp.stock = stock as any; await this.productStockComponentModel.save(comp); } return await this.getProductComponents(productId); } + + // 重复定义的 getProductList 已合并到前面的实现(中文注释:移除重复) async updateProductNameCn(id: number, nameCn: string): Promise { // 确认产品是否存在 @@ -901,4 +954,170 @@ export class ProductService { return `成功更新 ${skus.length} 个 sku`; } + + // 中文注释:导出所有产品为 CSV 文本 + async exportProductsCSV(): Promise { + // 查询所有产品及其属性(中文注释:包含字典关系) + const products = await this.productModel.find({ + relations: ['attributes', 'attributes.dict'], + order: { id: 'ASC' }, + }); + + // 定义 CSV 表头(中文注释:与导入字段一致) + const headers = [ + 'sku', + 'name', + 'nameCn', + 'price', + 'promotionPrice', + 'type', + 'stock', + 'brand', + 'flavor', + 'strength', + 'size', + 'description', + ]; + + // 中文注释:CSV 字段转义,处理逗号与双引号 + const esc = (v: any) => { + const s = v === undefined || v === null ? '' : String(v); + const needsQuote = /[",\n]/.test(s); + const escaped = s.replace(/"/g, '""'); + return needsQuote ? `"${escaped}"` : escaped; + }; + + // 中文注释:将属性列表转为字典名到显示值的映射 + const pickAttr = (prod: Product, key: string) => { + const list = (prod.attributes || []).filter(a => a?.dict?.name === key); + if (list.length === 0) return ''; + // 多个值使用分号分隔 + return list.map(a => a.title || a.name).join(';'); + }; + + const rows: string[] = []; + rows.push(headers.join(',')); + for (const p of products) { + // 中文注释:逐行输出产品数据 + const row = [ + esc(p.sku), + esc(p.name), + esc(p.nameCn), + esc(p.price), + esc(p.promotionPrice), + esc(p.type), + esc(p.stock), + esc(pickAttr(p, 'brand')), + esc(pickAttr(p, 'flavor')), + esc(pickAttr(p, 'strength')), + esc(pickAttr(p, 'size')), + esc(p.description), + ].join(','); + rows.push(row); + } + + return rows.join('\n'); + } + + // 中文注释:从 CSV 导入产品;存在则更新,不存在则创建 + async importProductsCSV(buffer: Buffer): Promise<{ created: number; updated: number; errors: string[] }> { + // 解析 CSV(中文注释:使用 csv-parse/sync 按表头解析) + const { parse } = await import('csv-parse/sync'); + let records: any[] = []; + try { + records = parse(buffer, { + columns: true, + skip_empty_lines: true, + trim: true, + }); + } catch (e: any) { + return { created: 0, updated: 0, errors: [`CSV 解析失败:${e?.message || e}`] }; + } + + let created = 0; + let updated = 0; + const errors: string[] = []; + + // 中文注释:逐条处理记录 + for (const rec of records) { + try { + // 条件判断:必须包含 sku + const sku: string = (rec.sku || '').trim(); + if (!sku) { + // 缺少 SKU 直接跳过 + errors.push('缺少 SKU 的记录已跳过'); + continue; + } + + // 查找现有产品 + const exist = await this.productModel.findOne({ where: { sku }, relations: ['attributes', 'attributes.dict'] }); + + // 中文注释:准备基础字段 + const base = { + name: rec.name || '', + nameCn: rec.nameCn || '', + description: rec.description || '', + price: rec.price ? Number(rec.price) : undefined, + promotionPrice: rec.promotionPrice ? Number(rec.promotionPrice) : undefined, + type: rec.type || '', + stock: rec.stock ? Number(rec.stock) : undefined, + sku, + } as any; + + // 中文注释:解析属性字段(分号分隔多值) + const parseList = (v: string) => (v ? String(v).split(';').map(s => s.trim()).filter(Boolean) : []); + const brands = parseList(rec.brand); + const flavors = parseList(rec.flavor); + const strengths = parseList(rec.strength); + const sizes = parseList(rec.size); + + // 中文注释:将属性解析为 DTO 输入 + const attrDTOs: { dictName: string; title: string }[] = []; + for (const b of brands) attrDTOs.push({ dictName: 'brand', title: b }); + for (const f of flavors) attrDTOs.push({ dictName: 'flavor', title: f }); + for (const s of strengths) attrDTOs.push({ dictName: 'strength', title: s }); + for (const z of sizes) attrDTOs.push({ dictName: 'size', title: z }); + + if (!exist) { + // 中文注释:创建新产品 + const dto = { + name: base.name, + description: base.description, + price: base.price, + sku: base.sku, + attributes: attrDTOs, + } as any; + const createdProduct = await this.createProduct(dto); + // 条件判断:更新可选字段 + const patch: any = {}; + if (base.nameCn) patch.nameCn = base.nameCn; + if (base.promotionPrice !== undefined) patch.promotionPrice = base.promotionPrice; + if (base.type) patch.type = base.type; + if (base.stock !== undefined) patch.stock = base.stock; + if (Object.keys(patch).length > 0) await this.productModel.update(createdProduct.id, patch); + created += 1; + } else { + // 中文注释:更新产品 + const updateDTO: any = { + name: base.name || exist.name, + description: base.description || exist.description, + price: base.price !== undefined ? base.price : exist.price, + sku: base.sku, + attributes: attrDTOs, + }; + // 条件判断:附加可选字段 + if (base.nameCn) updateDTO.nameCn = base.nameCn; + if (base.promotionPrice !== undefined) updateDTO.promotionPrice = base.promotionPrice; + if (base.type) updateDTO.type = base.type; + if (base.stock !== undefined) updateDTO.stock = base.stock; + await this.updateProduct(exist.id, updateDTO); + updated += 1; + } + } catch (e: any) { + errors.push(e?.message || String(e)); + } + } + + return { created, updated, errors }; + } } diff --git a/src/service/stock.service.ts b/src/service/stock.service.ts index 0525c8c..24d214e 100644 --- a/src/service/stock.service.ts +++ b/src/service/stock.service.ts @@ -230,7 +230,7 @@ export class StockService { // 获取库存列表 async getStocks(query: QueryStockDTO) { - const { current = 1, pageSize = 10, productName } = query; + const { current = 1, pageSize = 10, productName, sortPointId, sortOrder } = query; const nameKeywords = productName ? productName.split(' ').filter(Boolean) : []; @@ -274,6 +274,12 @@ export class StockService { ); }); } + if (sortPointId && sortOrder) { + const sortExpr = `SUM(CASE WHEN stock.stockPointId = :sortPointId THEN stock.quantity ELSE 0 END)`; + queryBuilder.addSelect(sortExpr, 'pointSort').setParameter('sortPointId', Number(sortPointId)); + queryBuilder.orderBy('pointSort', sortOrder === 'ascend' ? 'ASC' : 'DESC'); + } + const items = await queryBuilder.getRawMany(); const total = await totalQueryBuilder.getRawOne(); const transfer = await this.transferModel