From fbbb86ae37913fbbb18e5ef4daa687f91df37eed Mon Sep 17 00:00:00 2001 From: tikkhun Date: Wed, 14 Jan 2026 19:16:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=AD=97=E6=AE=B5=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=AD=97=E5=85=B8=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加产品图片URL字段到产品相关实体和DTO 重命名字典导入方法并优化导入逻辑 新增站点商品实体和ShopYY商品更新接口 优化Excel处理以支持UTF-8编码 --- src/configuration.ts | 3 +- src/controller/dict.controller.ts | 2 +- src/dto/product.dto.ts | 14 ++- src/dto/shopyy.dto.ts | 92 +++++++++++++++++++- src/entity/order_sale.entity.ts | 7 +- src/entity/product.entity.ts | 3 + src/entity/product_stock_component.entity.ts | 2 +- src/entity/site-product.entity.ts | 86 ++++++++++++++++++ src/service/dict.service.ts | 20 ++--- src/service/product.service.ts | 10 ++- 10 files changed, 217 insertions(+), 22 deletions(-) create mode 100644 src/entity/site-product.entity.ts diff --git a/src/configuration.ts b/src/configuration.ts index 9711264..985602e 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -118,8 +118,7 @@ export class MainConfiguration { }); try { - this.logger.info('正在检查数据库是否存在...'); - + this.logger.info(`正在检查数据库是否存在...`+ JSON.stringify(typeormConfig)); // 初始化临时数据源 await tempDataSource.initialize(); diff --git a/src/controller/dict.controller.ts b/src/controller/dict.controller.ts index c3257fd..e103a2b 100644 --- a/src/controller/dict.controller.ts +++ b/src/controller/dict.controller.ts @@ -30,7 +30,7 @@ export class DictController { // 从上传的文件列表中获取第一个文件 const file = files[0]; // 调用服务层方法处理XLSX文件 - const result = await this.dictService.importDictsFromXLSX(file.data); + const result = await this.dictService.importDictsFromTable(file.data); // 返回导入结果 return result; } diff --git a/src/dto/product.dto.ts b/src/dto/product.dto.ts index dfb794b..ca3623c 100644 --- a/src/dto/product.dto.ts +++ b/src/dto/product.dto.ts @@ -86,7 +86,10 @@ export class CreateProductDTO { @Rule(RuleType.number()) promotionPrice?: number; - + // 产品图片URL + @ApiProperty({ description: '产品图片URL', example: 'https://example.com/image.jpg', required: false }) + @Rule(RuleType.string().optional()) + image?: string; // 商品类型(默认 single; bundle 需手动设置组成) @ApiProperty({ description: '商品类型', enum: ['single', 'bundle'], default: 'single', required: false }) @@ -153,7 +156,10 @@ export class UpdateProductDTO { @Rule(RuleType.number()) promotionPrice?: number; - + // 产品图片URL + @ApiProperty({ description: '产品图片URL', example: 'https://example.com/image.jpg', required: false }) + @Rule(RuleType.string().optional()) + image?: string; // 属性更新(可选, 支持增量替换指定字典的属性项) @ApiProperty({ description: '属性列表', type: 'array', required: false }) @@ -228,6 +234,10 @@ export class BatchUpdateProductDTO { @Rule(RuleType.number().optional()) promotionPrice?: number; + @ApiProperty({ description: '产品图片URL', example: 'https://example.com/image.jpg', required: false }) + @Rule(RuleType.string().optional()) + image?: string; + @ApiProperty({ description: '属性列表', type: 'array', required: false }) @Rule(RuleType.array().optional()) attributes?: AttributeInputDTO[]; diff --git a/src/dto/shopyy.dto.ts b/src/dto/shopyy.dto.ts index b28f5c4..a6c4906 100644 --- a/src/dto/shopyy.dto.ts +++ b/src/dto/shopyy.dto.ts @@ -180,6 +180,94 @@ export interface ShopyyProduct { updated_at?: string | number; } +// 商品变体接口(用于更新) +export interface ShopyyProductVariant { + id?: number; // 变体ID + option1_title?: string; // 选项1名称 + option2_title?: string; // 选项2名称 + option3_title?: string; // 选项3名称 + option1_value_title?: string; // 选项1值 + option2_value_title?: string; // 选项2值 + option3_value_title?: string; // 选项3值 + image_id?: number; // 图片ID + src?: string; // 图片URL + title?: string; // 变体标题 + barcode?: string; // 条形码 + sku?: string; // 库存单位 + inventory_quantity?: number; // 库存数量 + price?: string; // 价格 + compare_at_price?: string; // 比较价格 + weight?: string; // 重量 +} + +// 商品图片接口(用于更新) +export interface ShopyyProductImage { + image_id?: number; // 图片ID + src?: string; // 图片URL + alt?: string; // 图片替代文本 +} + +// 选项值接口(用于更新) +export interface ShopyyProductOptionValue { + id?: number; // 选项值ID + option_id?: number; // 所属选项ID + option_value?: string; // 选项值 +} + +// 商品选项接口(用于更新) +export interface ShopyyProductOption { + id?: number; // 选项ID + option_name?: string; // 选项名称 + values?: ShopyyProductOptionValue[]; // 选项值列表 +} + +// 商品分类接口(用于更新) +export interface ShopyyProductCollection { + collection_id?: number; // 分类ID +} + +// Shopyy商品更新参数接口 +export interface ShopyyProductUpdateParams { + product_type?: string; // 商品类型 + handle?: string; // 商品别名 + spu?: string; // 商品编码 + title?: string; // 商品标题 + subtitle?: string; // 商品副标题 + vendor?: string; // 供应商 + meta_title?: string; // 元标题 + meta_descript?: string; // 元描述 + meta_keywords?: string[]; // 元关键词列表 + inner_title?: string; // 内部名称 + inventory_tracking?: number; // 库存跟踪(1:开启, 0:关闭) + spec_mode?: number; // 规格模式 + mini_detail?: string; // 简要描述 + free_shipping?: number; // 免运费(1:是, 0:否) + inventory_policy?: number; // 库存策略 + taxable?: number; // 是否 taxable(1:是, 0:否) + virtual_sale_count?: number; // 虚拟销量 + body_html?: string; // 商品详情HTML + status?: number; // 商品状态(1:上架, 0:下架) + variants?: ShopyyProductVariant[]; // 商品变体列表 + images?: ShopyyProductImage[]; // 商品图片列表 + options?: ShopyyProductOption[]; // 商品选项列表 + tags?: string[]; // 商品标签列表 + collections?: ShopyyProductCollection[]; // 商品分类列表 +} +export interface ShopyyProductUpdateManyParams { + + products: ({ + id: number; + product + })[] +} + +export interface ShopyyProductStockUpdateParams { + data: ({ + sku: string; + sku_code: string; + inventory_quantity: number; + })[] +} // 变体类型 export interface ShopyyVariant { id: number; @@ -525,8 +613,8 @@ export interface ShopyyGetOneOrderResult { refunds: any[]; } -export interface ShopyyOrderCreateParams {} -export interface ShopyyOrderUpdateParams {} +export interface ShopyyOrderCreateParams { } +export interface ShopyyOrderUpdateParams { } // 订单类型 export interface ShopyyOrder { // ======================================== diff --git a/src/entity/order_sale.entity.ts b/src/entity/order_sale.entity.ts index 35b0e0b..2c11dd9 100644 --- a/src/entity/order_sale.entity.ts +++ b/src/entity/order_sale.entity.ts @@ -62,6 +62,11 @@ export class OrderSale { @Expose() isPackage: boolean; + @ApiProperty({ description: '商品品类', type: 'string',nullable: true}) + @Expose() + @Column({ nullable: true }) + category?: string; + // TODO 这个其实还是直接保存 product 比较好 @ApiProperty({ description: '品牌', type: 'string',nullable: true}) @Expose() @Column({ nullable: true }) @@ -85,7 +90,7 @@ export class OrderSale { @ApiProperty({name: '强度', nullable: true }) @Column({ nullable: true }) @Expose() - strength: string | null; + strength: string | null; @ApiProperty({ description: '版本', type: 'string', nullable: true }) @Expose() diff --git a/src/entity/product.entity.ts b/src/entity/product.entity.ts index 1d7b296..eec0e20 100644 --- a/src/entity/product.entity.ts +++ b/src/entity/product.entity.ts @@ -55,6 +55,9 @@ export class Product { @Column({ nullable: true }) description?: string; + @ApiProperty({ example: '图片URL', description: '产品图片URL' }) + @Column({ nullable: true }) + image?: string; // 商品价格 @ApiProperty({ description: '价格', example: 99.99 }) @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) diff --git a/src/entity/product_stock_component.entity.ts b/src/entity/product_stock_component.entity.ts index 4048070..e076a4f 100644 --- a/src/entity/product_stock_component.entity.ts +++ b/src/entity/product_stock_component.entity.ts @@ -15,7 +15,7 @@ export class ProductStockComponent { @ApiProperty({ description: '组件所关联的 SKU', type: 'string' }) @Column({ type: 'varchar', length: 64 }) sku: string; - + @ApiProperty({ type: Number, description: '组成数量' }) @Column({ type: 'int', default: 1 }) quantity: number; diff --git a/src/entity/site-product.entity.ts b/src/entity/site-product.entity.ts new file mode 100644 index 0000000..fa6723f --- /dev/null +++ b/src/entity/site-product.entity.ts @@ -0,0 +1,86 @@ +import { + Column, + CreateDateColumn, + UpdateDateColumn, + Entity, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ApiProperty } from '@midwayjs/swagger'; +import { Site } from './site.entity'; +import { Product } from './product.entity'; + +@Entity('site_product') +export class SiteProduct { + @ApiProperty({ + example: '12345', + description: '站点商品ID', + type: 'string', + required: true, + }) + @Column({ primary: true }) + id: string; + + @ApiProperty({ description: '站点ID' }) + @Column() + siteId: number; + + @ApiProperty({ description: '商品ID' }) + @Column({ nullable: true }) + productId: number; + + @ApiProperty({ description: 'sku'}) + @Column() + sku: string; + + @ApiProperty({ description: '类型' }) + @Column({ length: 16, default: 'single' }) + type: string; + + @ApiProperty({ + description: '产品名称', + type: 'string', + required: true, + }) + @Column() + name: string; + + @ApiProperty({ description: '产品图片' }) + @Column({ default: '' }) + image: string; + + @ApiProperty({ description: '父商品ID', example: '12345' }) + @Column({ nullable: true }) + parentId: string; + + // 站点关联 + @ManyToOne(() => Site, site => site.id) + @JoinColumn({ name: 'siteId' }) + site: Site; + + // 商品关联 + @ManyToOne(() => Product, product => product.id) + @JoinColumn({ name: 'productId' }) + product: Product; + + // 父商品关联 + @ManyToOne(() => SiteProduct, siteProduct => siteProduct.id) + @JoinColumn({ name: 'parentId' }) + parent: SiteProduct; + + @ApiProperty({ + example: '2022-12-12 11:11:11', + description: '创建时间', + required: true, + }) + @CreateDateColumn() + createdAt: Date; + + @ApiProperty({ + example: '2022-12-12 11:11:11', + description: '更新时间', + required: true, + }) + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/service/dict.service.ts b/src/service/dict.service.ts index 6d683a6..4f74fbb 100644 --- a/src/service/dict.service.ts +++ b/src/service/dict.service.ts @@ -50,7 +50,7 @@ export class DictService { } // 从XLSX文件导入字典 - async importDictsFromXLSX(bufferOrPath: Buffer | string) { + async importDictsFromTable(bufferOrPath: Buffer | string) { // 判断传入的是 Buffer 还是文件路径字符串 let buffer: Buffer; if (typeof bufferOrPath === 'string') { @@ -60,7 +60,7 @@ export class DictService { // 如果是 Buffer,直接使用 buffer = bufferOrPath; } - + // 读取缓冲区中的工作簿 const wb = xlsx.read(buffer, { type: 'buffer' }); // 获取第一个工作表的名称 @@ -93,7 +93,7 @@ export class DictService { // 从XLSX文件导入字典项 async importDictItemsFromXLSX(bufferOrPath: Buffer | string, dictId: number): Promise { - if(!dictId){ + if (!dictId) { throw new Error("引入失败, 请输入字典 ID") } @@ -101,7 +101,7 @@ export class DictService { if (!dict) { throw new Error('指定的字典不存在'); } - + // 判断传入的是 Buffer 还是文件路径字符串 let buffer: Buffer; if (typeof bufferOrPath === 'string') { @@ -111,7 +111,7 @@ export class DictService { // 如果是 Buffer,直接使用 buffer = bufferOrPath; } - + const wb = xlsx.read(buffer, { type: 'buffer' }); const wsname = wb.SheetNames[0]; const ws = wb.Sheets[wsname]; @@ -122,7 +122,7 @@ export class DictService { const createdItems = []; const updatedItems = []; const errors = []; - + for (const row of data) { try { const result = await this.upsertDictItem(dictId, { @@ -150,7 +150,7 @@ export class DictService { const processed = createdItems.length + updatedItems.length; - return { + return { total: data.length, processed: processed, updated: updatedItems.length, @@ -216,10 +216,10 @@ export class DictService { // 如果提供了 dictId,则只返回该字典下的项 if (params.dictId) { - return this.dictItemModel.find({ where }); + return this.dictItemModel.find({ where, relations: ['dict'] }); } // 否则,返回所有字典项 - return this.dictItemModel.find(); + return this.dictItemModel.find({ relations: ['dict'] }); } // 创建新字典项 @@ -251,7 +251,7 @@ export class DictService { }) { // 格式化 name const formattedName = this.formatName(itemData.name); - + // 查找是否已存在该字典项(根据 name 和 dictId) const existingItem = await this.dictItemModel.findOne({ where: { diff --git a/src/service/product.service.ts b/src/service/product.service.ts index 6245def..be3d578 100644 --- a/src/service/product.service.ts +++ b/src/service/product.service.ts @@ -774,7 +774,7 @@ export class ProductService { } } else { // 简单字段,直接批量更新以提高性能 - // UpdateProductDTO 里的简单字段: name, nameCn, description, price, promotionPrice, siteSkus + // UpdateProductDTO 里的简单字段: name, nameCn, description, shortDescription, price, promotionPrice, image, siteSkus const simpleUpdate: any = {}; if (updateData.name !== undefined) simpleUpdate.name = updateData.name; @@ -783,6 +783,7 @@ export class ProductService { if (updateData.shortDescription !== undefined) simpleUpdate.shortDescription = updateData.shortDescription; if (updateData.price !== undefined) simpleUpdate.price = updateData.price; if (updateData.promotionPrice !== undefined) simpleUpdate.promotionPrice = updateData.promotionPrice; + if (updateData.image !== undefined) simpleUpdate.image = updateData.image; if (updateData.siteSkus !== undefined) simpleUpdate.siteSkus = updateData.siteSkus; if (Object.keys(simpleUpdate).length > 0) { @@ -1663,7 +1664,9 @@ export class ProductService { rows.push(rowData.join(',')); } - return rows.join('\n'); + // 添加UTF-8 BOM以确保中文在Excel中正确显示 + return '\ufeff' + rows.join('\n'); + } async getRecordsFromTable(file: any) { // 解析文件(使用 xlsx 包自动识别文件类型并解析) @@ -1686,7 +1689,8 @@ export class ProductService { let records: any[] = [] // xlsx 包会自动根据文件内容识别文件类型(CSV 或 XLSX) - const workbook = xlsx.read(buffer, { type: 'buffer' }); + // 添加codepage: 65001以确保正确处理UTF-8编码的中文 + const workbook = xlsx.read(buffer, { type: 'buffer', codepage: 65001 }); // 获取第一个工作表 const worksheet = workbook.Sheets[workbook.SheetNames[0]]; // 将工作表转换为 JSON 数组