Compare commits

...

2 Commits

Author SHA1 Message Date
tikkhun 40a445830b feat(产品): 添加产品站点SKU功能
添加 ProductSiteSku 实体及相关CRUD操作
在DTO和服务层增加站点SKU字段处理
更新产品导入导出功能支持站点SKU
2025-12-04 14:50:26 +08:00
tikkhun d7cccad895 feat: 添加字典项图片和简称字段并优化产品导入功能
扩展字典项实体和DTO,新增image和shortName字段
重构产品导入逻辑,支持直接处理上传文件
启用默认错误过滤器并配置上传临时目录
合并产品组件功能到主DTO中,简化API设计
优化CSV导入错误处理和异步解析
2025-12-04 10:05:39 +08:00
11 changed files with 298 additions and 150 deletions

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ yarn.lock
container container
scripts scripts
ai ai
tmp_uploads/

View File

@ -1,4 +1,5 @@
import { MidwayConfig } from '@midwayjs/core'; import { MidwayConfig } from '@midwayjs/core';
import { join } from 'path';
import { Product } from '../entity/product.entity'; import { Product } from '../entity/product.entity';
import { WpProduct } from '../entity/wp_product.entity'; import { WpProduct } from '../entity/wp_product.entity';
import { Variation } from '../entity/variation.entity'; import { Variation } from '../entity/variation.entity';
@ -36,6 +37,7 @@ import { DictItem } from '../entity/dict_item.entity';
import { Template } from '../entity/template.entity'; import { Template } from '../entity/template.entity';
import { Area } from '../entity/area.entity'; import { Area } from '../entity/area.entity';
import { ProductStockComponent } from '../entity/product_stock_component.entity'; import { ProductStockComponent } from '../entity/product_stock_component.entity';
import { ProductSiteSku } from '../entity/product_site_sku.entity';
import { CategoryAttribute } from '../entity/category_attribute.entity'; import { CategoryAttribute } from '../entity/category_attribute.entity';
import { Category } from '../entity/category.entity'; import { Category } from '../entity/category.entity';
import DictSeeder from '../db/seeds/dict.seeder'; import DictSeeder from '../db/seeds/dict.seeder';
@ -50,6 +52,7 @@ export default {
entities: [ entities: [
Product, Product,
ProductStockComponent, ProductStockComponent,
ProductSiteSku,
WpProduct, WpProduct,
Variation, Variation,
User, User,
@ -146,5 +149,7 @@ export default {
mode: 'file', mode: 'file',
fileSize: '10mb', // 最大支持的文件大小,默认为 10mb fileSize: '10mb', // 最大支持的文件大小,默认为 10mb
whitelist: ['.csv'], // 支持的文件后缀 whitelist: ['.csv'], // 支持的文件后缀
tmpdir: join(__dirname, '../../tmp_uploads'),
cleanTimeout: 5 * 60 * 1000,
}, },
} as MidwayConfig; } as MidwayConfig;

View File

@ -9,8 +9,8 @@ import * as validate from '@midwayjs/validate';
import * as info from '@midwayjs/info'; import * as info from '@midwayjs/info';
import * as orm from '@midwayjs/typeorm'; import * as orm from '@midwayjs/typeorm';
import { join } from 'path'; import { join } from 'path';
// import { DefaultErrorFilter } from './filter/default.filter'; import { DefaultErrorFilter } from './filter/default.filter';
// import { NotFoundFilter } from './filter/notfound.filter'; import { NotFoundFilter } from './filter/notfound.filter';
import { ReportMiddleware } from './middleware/report.middleware'; import { ReportMiddleware } from './middleware/report.middleware';
import * as swagger from '@midwayjs/swagger'; import * as swagger from '@midwayjs/swagger';
import * as crossDomain from '@midwayjs/cross-domain'; import * as crossDomain from '@midwayjs/cross-domain';
@ -55,7 +55,7 @@ export class MainConfiguration {
// add middleware // add middleware
this.app.useMiddleware([ReportMiddleware, AuthMiddleware]); this.app.useMiddleware([ReportMiddleware, AuthMiddleware]);
// add filter // add filter
// this.app.useFilter([NotFoundFilter, DefaultErrorFilter]); this.app.useFilter([NotFoundFilter, DefaultErrorFilter]);
this.decoratorService.registerParameterHandler( this.decoratorService.registerParameterHandler(
USER_KEY, USER_KEY,

View File

@ -9,10 +9,9 @@ import {
Query, Query,
Controller, Controller,
} from '@midwayjs/core'; } from '@midwayjs/core';
import * as fs from 'fs';
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 { CreateProductDTO, QueryProductDTO, UpdateProductDTO, SetProductComponentsDTO, BatchUpdateProductDTO } from '../dto/product.dto'; import { CreateProductDTO, QueryProductDTO, UpdateProductDTO, BatchUpdateProductDTO } from '../dto/product.dto';
import { ApiOkResponse } from '@midwayjs/swagger'; import { ApiOkResponse } from '@midwayjs/swagger';
import { BooleanRes, ProductListRes, ProductRes, ProductsRes } from '../dto/reponse.dto'; import { BooleanRes, ProductListRes, ProductRes, ProductsRes } from '../dto/reponse.dto';
import { ContentType, Files } from '@midwayjs/core'; import { ContentType, Files } from '@midwayjs/core';
@ -83,7 +82,7 @@ export class ProductController {
@Post('/') @Post('/')
async createProduct(@Body() productData: CreateProductDTO) { async createProduct(@Body() productData: CreateProductDTO) {
try { try {
const data = this.productService.createProduct(productData); const data = await this.productService.createProduct(productData);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
@ -115,19 +114,9 @@ export class ProductController {
try { try {
// 条件判断:确保存在文件 // 条件判断:确保存在文件
const file = files?.[0]; const file = files?.[0];
if (!file?.data) return errorResponse('未接收到上传文件'); if (!file) return errorResponse('未接收到上传文件');
// midway/upload file 模式下,data 是临时文件路径 const result = await this.productService.importProductsCSV(file);
let buffer = file.data;
if (typeof file.data === 'string') {
try {
buffer = fs.readFileSync(file.data);
} catch (err) {
return errorResponse('读取上传文件失败');
}
}
const result = await this.productService.importProductsCSV(buffer);
return successResponse(result); return successResponse(result);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
@ -138,7 +127,7 @@ export class ProductController {
@Put('/:id') @Put('/:id')
async updateProduct(@Param('id') id: number, @Body() productData: UpdateProductDTO) { async updateProduct(@Param('id') id: number, @Body() productData: UpdateProductDTO) {
try { try {
const data = this.productService.updateProduct(id, productData); const data = await this.productService.updateProduct(id, productData);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
@ -160,7 +149,7 @@ export class ProductController {
@Put('updateNameCn/:id/:nameCn') @Put('updateNameCn/:id/:nameCn')
async updatenameCn(@Param('id') id: number, @Param('nameCn') nameCn: string) { async updatenameCn(@Param('id') id: number, @Param('nameCn') nameCn: string) {
try { try {
const data = this.productService.updatenameCn(id, nameCn); const data = await this.productService.updatenameCn(id, nameCn);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
@ -190,17 +179,6 @@ export class ProductController {
} }
} }
// 设置产品的库存组成(覆盖式)
@ApiOkResponse()
@Post('/:id/components')
async setProductComponents(@Param('id') id: number, @Body() body: SetProductComponentsDTO) {
try {
const data = await this.productService.setProductComponents(id, body?.components || []);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
// 根据 SKU 自动绑定组成(匹配所有相同 SKU 的库存) // 根据 SKU 自动绑定组成(匹配所有相同 SKU 的库存)
@ApiOkResponse() @ApiOkResponse()
@ -339,7 +317,7 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Post('/brand') @Post('/brand')
async compatCreateBrand(@Body() body: { title: string; name: string }) { async compatCreateBrand(@Body() body: { title: string; name: string; image?: string; shortName?: string }) {
try { try {
const has = await this.productService.hasAttribute('brand', body.name); // 唯一性校验 const has = await this.productService.hasAttribute('brand', body.name); // 唯一性校验
if (has) return errorResponse('品牌已存在'); if (has) return errorResponse('品牌已存在');
@ -352,7 +330,7 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Put('/brand/:id') @Put('/brand/:id')
async compatUpdateBrand(@Param('id') id: number, @Body() body: { title?: string; name?: string }) { async compatUpdateBrand(@Param('id') id: number, @Body() body: { title?: string; name?: string; image?: string; shortName?: string }) {
try { try {
if (body?.name) { if (body?.name) {
const has = await this.productService.hasAttribute('brand', body.name, id); // 唯一性校验(排除自身) const has = await this.productService.hasAttribute('brand', body.name, id); // 唯一性校验(排除自身)
@ -401,7 +379,7 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Post('/flavors') @Post('/flavors')
async compatCreateFlavors(@Body() body: { title: string; name: string }) { async compatCreateFlavors(@Body() body: { title: string; name: string; image?: string; shortName?: string }) {
try { try {
const has = await this.productService.hasAttribute('flavor', body.name); const has = await this.productService.hasAttribute('flavor', body.name);
if (has) return errorResponse('口味已存在'); if (has) return errorResponse('口味已存在');
@ -414,7 +392,7 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Put('/flavors/:id') @Put('/flavors/:id')
async compatUpdateFlavors(@Param('id') id: number, @Body() body: { title?: string; name?: string }) { async compatUpdateFlavors(@Param('id') id: number, @Body() body: { title?: string; name?: string; image?: string; shortName?: string }) {
try { try {
if (body?.name) { if (body?.name) {
const has = await this.productService.hasAttribute('flavor', body.name, id); const has = await this.productService.hasAttribute('flavor', body.name, id);
@ -463,7 +441,7 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Post('/strength') @Post('/strength')
async compatCreateStrength(@Body() body: { title: string; name: string }) { async compatCreateStrength(@Body() body: { title: string; name: string; image?: string; shortName?: string }) {
try { try {
const has = await this.productService.hasAttribute('strength', body.name); const has = await this.productService.hasAttribute('strength', body.name);
if (has) return errorResponse('规格已存在'); if (has) return errorResponse('规格已存在');
@ -476,7 +454,7 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Put('/strength/:id') @Put('/strength/:id')
async compatUpdateStrength(@Param('id') id: number, @Body() body: { title?: string; name?: string }) { async compatUpdateStrength(@Param('id') id: number, @Body() body: { title?: string; name?: string; image?: string; shortName?: string }) {
try { try {
if (body?.name) { if (body?.name) {
const has = await this.productService.hasAttribute('strength', body.name, id); const has = await this.productService.hasAttribute('strength', body.name, id);
@ -525,7 +503,7 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Post('/size') @Post('/size')
async compatCreateSize(@Body() body: { title: string; name: string }) { async compatCreateSize(@Body() body: { title: string; name: string; image?: string; shortName?: string }) {
try { try {
const has = await this.productService.hasAttribute('size', body.name); const has = await this.productService.hasAttribute('size', body.name);
if (has) return errorResponse('尺寸已存在'); if (has) return errorResponse('尺寸已存在');
@ -538,7 +516,7 @@ export class ProductController {
@ApiOkResponse() @ApiOkResponse()
@Put('/size/:id') @Put('/size/:id')
async compatUpdateSize(@Param('id') id: number, @Body() body: { title?: string; name?: string }) { async compatUpdateSize(@Param('id') id: number, @Body() body: { title?: string; name?: string; image?: string; shortName?: string }) {
try { try {
if (body?.name) { if (body?.name) {
const has = await this.productService.hasAttribute('size', body.name, id); const has = await this.productService.hasAttribute('size', body.name, id);

View File

@ -29,6 +29,12 @@ export class CreateDictItemDTO {
@Rule(RuleType.string().allow('').allow(null)) @Rule(RuleType.string().allow('').allow(null))
titleCN?: string; // 字典项中文标题 (可选) titleCN?: string; // 字典项中文标题 (可选)
@Rule(RuleType.string().allow('').allow(null))
image?: string; // 图片 (可选)
@Rule(RuleType.string().allow('').allow(null))
shortName?: string; // 简称 (可选)
@Rule(RuleType.number().required()) @Rule(RuleType.number().required())
dictId: number; // 所属字典的ID dictId: number; // 所属字典的ID
} }
@ -47,4 +53,10 @@ export class UpdateDictItemDTO {
@Rule(RuleType.string().allow(null)) @Rule(RuleType.string().allow(null))
value?: string; // 字典项值 (可选) value?: string; // 字典项值 (可选)
@Rule(RuleType.string().allow('').allow(null))
image?: string; // 图片 (可选)
@Rule(RuleType.string().allow('').allow(null))
shortName?: string; // 简称 (可选)
} }

View File

@ -46,6 +46,10 @@ export class CreateProductDTO {
@Rule(RuleType.string()) @Rule(RuleType.string())
description: string; description: string;
@ApiProperty({ example: '产品简短描述', description: '产品简短描述' })
@Rule(RuleType.string().optional())
shortDescription?: string;
@ApiProperty({ description: '产品 SKU', required: false }) @ApiProperty({ description: '产品 SKU', required: false })
@Rule(RuleType.string()) @Rule(RuleType.string())
sku?: string; sku?: string;
@ -54,6 +58,10 @@ export class CreateProductDTO {
@Rule(RuleType.number()) @Rule(RuleType.number())
categoryId?: number; categoryId?: number;
@ApiProperty({ description: '站点 SKU 列表', type: 'array', required: false })
@Rule(RuleType.array().items(RuleType.string()).optional())
siteSkus?: string[];
// 通用属性输入(通过 attributes 统一提交品牌/口味/强度/尺寸/干湿等) // 通用属性输入(通过 attributes 统一提交品牌/口味/强度/尺寸/干湿等)
@ApiProperty({ description: '属性列表', type: 'array' }) @ApiProperty({ description: '属性列表', type: 'array' })
@Rule(RuleType.array().required()) @Rule(RuleType.array().required())
@ -110,6 +118,10 @@ export class UpdateProductDTO {
@Rule(RuleType.string()) @Rule(RuleType.string())
description?: string; description?: string;
@ApiProperty({ example: '产品简短描述', description: '产品简短描述' })
@Rule(RuleType.string().optional())
shortDescription?: string;
@ApiProperty({ description: '产品 SKU', required: false }) @ApiProperty({ description: '产品 SKU', required: false })
@Rule(RuleType.string()) @Rule(RuleType.string())
sku?: string; sku?: string;
@ -118,6 +130,10 @@ export class UpdateProductDTO {
@Rule(RuleType.number()) @Rule(RuleType.number())
categoryId?: number; categoryId?: number;
@ApiProperty({ description: '站点 SKU 列表', type: 'array', required: false })
@Rule(RuleType.array().items(RuleType.string()).optional())
siteSkus?: string[];
// 商品价格 // 商品价格
@ApiProperty({ description: '价格', example: 99.99, required: false }) @ApiProperty({ description: '价格', example: 99.99, required: false })
@Rule(RuleType.number()) @Rule(RuleType.number())
@ -139,6 +155,23 @@ export class UpdateProductDTO {
@ApiProperty({ description: '商品类型', enum: ['single', 'bundle'], required: false }) @ApiProperty({ description: '商品类型', enum: ['single', 'bundle'], required: false })
@Rule(RuleType.string().valid('single', 'bundle')) @Rule(RuleType.string().valid('single', 'bundle'))
type?: string; type?: string;
// 仅当 type 为 'bundle' 时,才需要提供 components
@ApiProperty({ description: '产品组成', type: 'array', required: false })
@Rule(
RuleType.array()
.items(
RuleType.object({
sku: RuleType.string().required(),
quantity: RuleType.number().required(),
})
)
.when('type', {
is: 'bundle',
then: RuleType.array().optional(),
})
)
components?: { sku: string; quantity: number }[];
} }
@ -162,6 +195,10 @@ export class BatchUpdateProductDTO {
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
description?: string; description?: string;
@ApiProperty({ example: '产品简短描述', description: '产品简短描述', required: false })
@Rule(RuleType.string().optional())
shortDescription?: string;
@ApiProperty({ description: '产品 SKU', required: false }) @ApiProperty({ description: '产品 SKU', required: false })
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
sku?: string; sku?: string;
@ -170,6 +207,10 @@ export class BatchUpdateProductDTO {
@Rule(RuleType.number().optional()) @Rule(RuleType.number().optional())
categoryId?: number; categoryId?: number;
@ApiProperty({ description: '站点 SKU 列表', type: 'array', required: false })
@Rule(RuleType.array().items(RuleType.string()).optional())
siteSkus?: string[];
@ApiProperty({ description: '价格', example: 99.99, required: false }) @ApiProperty({ description: '价格', example: 99.99, required: false })
@Rule(RuleType.number().optional()) @Rule(RuleType.number().optional())
price?: number; price?: number;
@ -233,20 +274,3 @@ export class QueryProductDTO {
sortOrder?: string; sortOrder?: string;
} }
/**
* DTO
*/
export class SetProductComponentsDTO {
@ApiProperty({ description: '产品组成', type: 'array', required: true })
@Rule(
RuleType.array()
.items(
RuleType.object({
sku: RuleType.string().required(),
quantity: RuleType.number().required(),
})
)
.required()
)
components: { sku: string; quantity: number }[];
}

View File

@ -38,6 +38,12 @@ export class DictItem {
@Column({ nullable: true, comment: '字典项值' }) @Column({ nullable: true, comment: '字典项值' })
value?: string; value?: string;
@Column({ nullable: true, comment: '图片' })
image: string;
@Column({ nullable: true, comment: '简称' })
shortName: string;
// 排序 // 排序
@Column({ default: 0, comment: '排序' }) @Column({ default: 0, comment: '排序' })
sort: number; sort: number;

View File

@ -13,6 +13,7 @@ import {
import { ApiProperty } from '@midwayjs/swagger'; import { ApiProperty } from '@midwayjs/swagger';
import { DictItem } from './dict_item.entity'; import { DictItem } from './dict_item.entity';
import { ProductStockComponent } from './product_stock_component.entity'; import { ProductStockComponent } from './product_stock_component.entity';
import { ProductSiteSku } from './product_site_sku.entity';
import { Category } from './category.entity'; import { Category } from './category.entity';
@Entity() @Entity()
@ -49,6 +50,10 @@ export class Product {
@Column({ nullable: true }) @Column({ nullable: true })
description?: string; description?: string;
@ApiProperty({ example: '产品简短描述', description: '产品简短描述' })
@Column({ nullable: true })
shortDescription?: string;
@ApiProperty({ description: 'sku'}) @ApiProperty({ description: 'sku'})
@Column({ unique: true }) @Column({ unique: true })
sku: string; sku: string;
@ -82,6 +87,10 @@ export class Product {
@OneToMany(() => ProductStockComponent, (component) => component.product, { cascade: true }) @OneToMany(() => ProductStockComponent, (component) => component.product, { cascade: true })
components: ProductStockComponent[]; components: ProductStockComponent[];
@ApiProperty({ description: '站点 SKU 列表', type: ProductSiteSku, isArray: true })
@OneToMany(() => ProductSiteSku, (siteSku) => siteSku.product, { cascade: true })
siteSkus: ProductSiteSku[];
// 来源 // 来源
@ApiProperty({ description: '来源', example: '1' }) @ApiProperty({ description: '来源', example: '1' })
@Column({ default: 0 }) @Column({ default: 0 })

View File

@ -0,0 +1,36 @@
import {
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Entity,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ApiProperty } from '@midwayjs/swagger';
import { Product } from './product.entity';
@Entity('product_site_sku')
export class ProductSiteSku {
@PrimaryGeneratedColumn()
id: number;
@ApiProperty({ description: '站点 SKU' })
@Column({ length: 100, comment: '站点 SKU' })
code: string;
@ManyToOne(() => Product, product => product.siteSkus, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'productId' })
product: Product;
@Column()
productId: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -61,7 +61,7 @@ export class DictService {
// 生成并返回字典项的XLSX模板 // 生成并返回字典项的XLSX模板
getDictItemXLSXTemplate() { getDictItemXLSXTemplate() {
const headers = ['name', 'title', 'titleCN', 'value', 'sort']; const headers = ['name', 'title', 'titleCN', 'value', 'sort', 'image', 'shortName'];
const ws = xlsx.utils.aoa_to_sheet([headers]); const ws = xlsx.utils.aoa_to_sheet([headers]);
const wb = xlsx.utils.book_new(); const wb = xlsx.utils.book_new();
xlsx.utils.book_append_sheet(wb, ws, 'DictItems'); xlsx.utils.book_append_sheet(wb, ws, 'DictItems');
@ -78,7 +78,7 @@ export class DictService {
const wsname = wb.SheetNames[0]; const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname]; const ws = wb.Sheets[wsname];
// 支持titleCN字段的导入 // 支持titleCN字段的导入
const data = xlsx.utils.sheet_to_json(ws, { header: ['name', 'title', 'titleCN', 'value', 'sort'] }).slice(1); const data = xlsx.utils.sheet_to_json(ws, { header: ['name', 'title', 'titleCN', 'value', 'sort', 'image', 'shortName'] }).slice(1);
const items = data.map((row: any) => { const items = data.map((row: any) => {
const item = new DictItem(); const item = new DictItem();
@ -86,6 +86,8 @@ export class DictService {
item.title = row.title; item.title = row.title;
item.titleCN = row.titleCN; // 保存中文名称 item.titleCN = row.titleCN; // 保存中文名称
item.value = row.value; item.value = row.value;
item.image = row.image;
item.shortName = row.shortName;
item.sort = row.sort || 0; item.sort = row.sort || 0;
item.dict = dict; item.dict = dict;
return item; return item;
@ -168,6 +170,8 @@ export class DictService {
item.name = this.formatName(createDictItemDTO.name); item.name = this.formatName(createDictItemDTO.name);
item.title = createDictItemDTO.title; item.title = createDictItemDTO.title;
item.titleCN = createDictItemDTO.titleCN; // 保存中文名称 item.titleCN = createDictItemDTO.titleCN; // 保存中文名称
item.image = createDictItemDTO.image;
item.shortName = createDictItemDTO.shortName;
item.dict = dict; item.dict = dict;
return this.dictItemModel.save(item); return this.dictItemModel.save(item);
} }

View File

@ -1,8 +1,11 @@
import { Inject, Provide } from '@midwayjs/core'; import { Inject, Provide } from '@midwayjs/core';
import * as fs from 'fs';
import { In, Like, Not, Repository } from 'typeorm'; import { In, Like, Not, Repository } from 'typeorm';
import { Product } from '../entity/product.entity'; import { Product } from '../entity/product.entity';
import { paginate } from '../utils/paginate.util'; import { paginate } from '../utils/paginate.util';
import { PaginationParams } from '../interface'; import { PaginationParams } from '../interface';
import { parse } from 'csv-parse';
import { import {
CreateProductDTO, CreateProductDTO,
UpdateProductDTO, UpdateProductDTO,
@ -26,6 +29,7 @@ import { StockService } from './stock.service';
import { Stock } from '../entity/stock.entity'; import { Stock } from '../entity/stock.entity';
import { StockPoint } from '../entity/stock_point.entity'; import { StockPoint } from '../entity/stock_point.entity';
import { ProductStockComponent } from '../entity/product_stock_component.entity'; import { ProductStockComponent } from '../entity/product_stock_component.entity';
import { ProductSiteSku } from '../entity/product_site_sku.entity';
import { Category } from '../entity/category.entity'; import { Category } from '../entity/category.entity';
import { CategoryAttribute } from '../entity/category_attribute.entity'; import { CategoryAttribute } from '../entity/category_attribute.entity';
@ -64,6 +68,9 @@ export class ProductService {
@InjectEntityModel(ProductStockComponent) @InjectEntityModel(ProductStockComponent)
productStockComponentModel: Repository<ProductStockComponent>; productStockComponentModel: Repository<ProductStockComponent>;
@InjectEntityModel(ProductSiteSku)
productSiteSkuModel: Repository<ProductSiteSku>;
@InjectEntityModel(Category) @InjectEntityModel(Category)
categoryModel: Repository<Category>; categoryModel: Repository<Category>;
@ -239,7 +246,8 @@ export class ProductService {
.createQueryBuilder('product') .createQueryBuilder('product')
.leftJoinAndSelect('product.attributes', 'attribute') .leftJoinAndSelect('product.attributes', 'attribute')
.leftJoinAndSelect('attribute.dict', 'dict') .leftJoinAndSelect('attribute.dict', 'dict')
.leftJoinAndSelect('product.category', 'category'); .leftJoinAndSelect('product.category', 'category')
.leftJoinAndSelect('product.siteSkus', 'siteSku');
// 模糊搜索 name,支持多个关键词 // 模糊搜索 name,支持多个关键词
const nameFilter = name ? name.split(' ').filter(Boolean) : []; const nameFilter = name ? name.split(' ').filter(Boolean) : [];
@ -412,13 +420,13 @@ export class ProductService {
); );
}); });
const isExist = await qb.getOne(); const isExist = await qb.getOne();
if (isExist) throw new Error('产品已存在'); if (isExist) throw new Error('相同产品属性的产品已存在');
// 创建新产品实例(绑定属性与基础字段) // 创建新产品实例(绑定属性与基础字段)
const product = new Product(); const product = new Product();
// 使用 merge 填充基础字段,排除特殊处理字段 // 使用 merge 填充基础字段,排除特殊处理字段
const { attributes: _attrs, categoryId: _cid, sku: _sku, ...simpleFields } = createProductDTO; const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, siteSkus: _siteSkus, ...simpleFields } = createProductDTO;
this.productModel.merge(product, simpleFields); this.productModel.merge(product, simpleFields);
product.attributes = resolvedAttributes; product.attributes = resolvedAttributes;
@ -436,7 +444,7 @@ export class ProductService {
for (const a of resolvedAttributes) { for (const a of resolvedAttributes) {
if (a?.dict?.name && a?.name) attributeMap[a.dict.name] = a.name; 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: attributeMap['brand'] || '', brand: attributeMap['brand'] || '',
flavor: attributeMap['flavor'] || '', flavor: attributeMap['flavor'] || '',
strength: attributeMap['strength'] || '', strength: attributeMap['strength'] || '',
@ -444,7 +452,27 @@ export class ProductService {
}); });
} }
return await this.productModel.save(product); const savedProduct = await this.productModel.save(product);
// 保存站点 SKU 列表
if (createProductDTO.siteSkus && createProductDTO.siteSkus.length > 0) {
const siteSkus = createProductDTO.siteSkus.map(code => {
const s = new ProductSiteSku();
s.code = code;
s.product = savedProduct;
return s;
});
await this.productSiteSkuModel.save(siteSkus);
}
// 保存组件信息
if (createProductDTO.components && createProductDTO.components.length > 0) {
await this.setProductComponents(savedProduct.id, createProductDTO.components);
// 重新加载带组件的产品
return await this.productModel.findOne({ where: { id: savedProduct.id }, relations: ['attributes', 'attributes.dict', 'category', 'components', 'siteSkus'] });
}
return savedProduct;
} }
async updateProduct( async updateProduct(
@ -458,7 +486,7 @@ export class ProductService {
} }
// 使用 merge 更新基础字段,排除特殊处理字段 // 使用 merge 更新基础字段,排除特殊处理字段
const { attributes: _attrs, categoryId: _cid, sku: _sku, ...simpleFields } = updateProductDTO; const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, siteSkus: _siteSkus, ...simpleFields } = updateProductDTO;
this.productModel.merge(product, simpleFields); this.productModel.merge(product, simpleFields);
// 处理分类更新 // 处理分类更新
@ -473,6 +501,23 @@ export class ProductService {
} }
} }
// 处理站点 SKU 更新
if (updateProductDTO.siteSkus !== undefined) {
// 删除旧的 siteSkus
await this.productSiteSkuModel.delete({ productId: id });
// 如果有新的 siteSkus则保存
if (updateProductDTO.siteSkus.length > 0) {
const siteSkus = updateProductDTO.siteSkus.map(code => {
const s = new ProductSiteSku();
s.code = code;
s.productId = id;
return s;
});
await this.productSiteSkuModel.save(siteSkus);
}
}
// 处理 SKU 更新 // 处理 SKU 更新
if (updateProductDTO.sku !== undefined) { if (updateProductDTO.sku !== undefined) {
// 校验 SKU 唯一性(如变更) // 校验 SKU 唯一性(如变更)
@ -533,6 +578,13 @@ export class ProductService {
// 保存更新后的产品 // 保存更新后的产品
const saved = await this.productModel.save(product); const saved = await this.productModel.save(product);
// 处理组件更新
if (updateProductDTO.components !== undefined) {
// 如果 components 为空数组,则删除所有组件? setProductComponents 会处理
await this.setProductComponents(saved.id, updateProductDTO.components);
}
return saved; return saved;
} }
@ -568,6 +620,7 @@ export class ProductService {
if (updateData.name !== undefined) simpleUpdate.name = updateData.name; if (updateData.name !== undefined) simpleUpdate.name = updateData.name;
if (updateData.nameCn !== undefined) simpleUpdate.nameCn = updateData.nameCn; if (updateData.nameCn !== undefined) simpleUpdate.nameCn = updateData.nameCn;
if (updateData.description !== undefined) simpleUpdate.description = updateData.description; if (updateData.description !== undefined) simpleUpdate.description = updateData.description;
if (updateData.shortDescription !== undefined) simpleUpdate.shortDescription = updateData.shortDescription;
if (updateData.price !== undefined) simpleUpdate.price = updateData.price; if (updateData.price !== undefined) simpleUpdate.price = updateData.price;
if (updateData.promotionPrice !== undefined) simpleUpdate.promotionPrice = updateData.promotionPrice; if (updateData.promotionPrice !== undefined) simpleUpdate.promotionPrice = updateData.promotionPrice;
@ -653,7 +706,9 @@ export class ProductService {
if (!product) throw new Error(`产品 ID ${productId} 不存在`); if (!product) throw new Error(`产品 ID ${productId} 不存在`);
// 条件判断(单品 simple 不允许手动设置组成) // 条件判断(单品 simple 不允许手动设置组成)
if (product.type === 'single') { if (product.type === 'single') {
throw new Error('单品无需设置组成'); // 单品类型,直接清空关联的组成(如果有)
await this.productStockComponentModel.delete({ productId });
return [];
} }
const validItems = (items || []) const validItems = (items || [])
@ -1113,7 +1168,7 @@ export class ProductService {
// 通用属性:创建字典项 // 通用属性:创建字典项
async createAttribute( async createAttribute(
dictName: string, dictName: string,
payload: { title: string; name: string } payload: { title: string; name: string; image?: string; shortName?: string }
): Promise<DictItem> { ): Promise<DictItem> {
const dict = await this.dictModel.findOne({ where: { name: dictName } }); const dict = await this.dictModel.findOne({ where: { name: dictName } });
if (!dict) throw new Error(`字典 ${dictName} 不存在`); if (!dict) throw new Error(`字典 ${dictName} 不存在`);
@ -1125,6 +1180,8 @@ export class ProductService {
const item = new DictItem(); const item = new DictItem();
item.title = payload.title; item.title = payload.title;
item.name = payload.name; item.name = payload.name;
item.image = payload.image;
item.shortName = payload.shortName;
item.dict = dict; item.dict = dict;
return await this.dictItemModel.save(item); return await this.dictItemModel.save(item);
} }
@ -1132,12 +1189,14 @@ export class ProductService {
// 通用属性:更新字典项 // 通用属性:更新字典项
async updateAttribute( async updateAttribute(
id: number, id: number,
payload: { title?: string; name?: string } payload: { title?: string; name?: string; image?: string; shortName?: string }
): Promise<DictItem> { ): Promise<DictItem> {
const item = await this.dictItemModel.findOne({ where: { id } }); const item = await this.dictItemModel.findOne({ where: { id } });
if (!item) throw new Error('字典项不存在'); if (!item) throw new Error('字典项不存在');
if (payload.title !== undefined) item.title = payload.title; if (payload.title !== undefined) item.title = payload.title;
if (payload.name !== undefined) item.name = payload.name; if (payload.name !== undefined) item.name = payload.name;
if (payload.image !== undefined) item.image = payload.image;
if (payload.shortName !== undefined) item.shortName = payload.shortName;
return await this.dictItemModel.save(item); return await this.dictItemModel.save(item);
} }
@ -1262,6 +1321,7 @@ export class ProductService {
price: num(rec.price), price: num(rec.price),
promotionPrice: num(rec.promotionPrice), promotionPrice: num(rec.promotionPrice),
type: val(rec.type), type: val(rec.type),
siteSkus: rec.siteSkus ? String(rec.siteSkus).split(',').map(s => s.trim()).filter(Boolean) : undefined,
attributes: attributes.length > 0 ? attributes : undefined, attributes: attributes.length > 0 ? attributes : undefined,
components: components.length > 0 ? components : undefined, components: components.length > 0 ? components : undefined,
@ -1276,6 +1336,7 @@ export class ProductService {
dto.nameCn = data.nameCn; dto.nameCn = data.nameCn;
dto.description = data.description; dto.description = data.description;
dto.sku = data.sku; dto.sku = data.sku;
if (data.siteSkus) dto.siteSkus = data.siteSkus;
// 数值类型转换 // 数值类型转换
if (data.price !== undefined) dto.price = Number(data.price); if (data.price !== undefined) dto.price = Number(data.price);
@ -1288,7 +1349,7 @@ export class ProductService {
dto.attributes = Array.isArray(data.attributes) ? data.attributes : []; dto.attributes = Array.isArray(data.attributes) ? data.attributes : [];
// 如果有组件信息,透传 // 如果有组件信息,透传
dto.type = data.type || data.components?.length? 'bundle':'single' dto.type = data.type || data.components?.length ? 'bundle' : 'single'
if (data.components) dto.components = data.components; if (data.components) dto.components = data.components;
return dto; return dto;
@ -1302,6 +1363,7 @@ export class ProductService {
if (data.nameCn !== undefined) dto.nameCn = data.nameCn; if (data.nameCn !== undefined) dto.nameCn = data.nameCn;
if (data.description !== undefined) dto.description = data.description; if (data.description !== undefined) dto.description = data.description;
if (data.sku !== undefined) dto.sku = data.sku; if (data.sku !== undefined) dto.sku = data.sku;
if (data.siteSkus !== undefined) dto.siteSkus = data.siteSkus;
if (data.price !== undefined) dto.price = Number(data.price); if (data.price !== undefined) dto.price = Number(data.price);
if (data.promotionPrice !== undefined) dto.promotionPrice = Number(data.promotionPrice); if (data.promotionPrice !== undefined) dto.promotionPrice = Number(data.promotionPrice);
@ -1310,6 +1372,7 @@ export class ProductService {
if (data.type !== undefined) dto.type = data.type; if (data.type !== undefined) dto.type = data.type;
if (data.attributes !== undefined) dto.attributes = data.attributes; if (data.attributes !== undefined) dto.attributes = data.attributes;
if (data.components !== undefined) dto.components = data.components;
return dto; return dto;
} }
@ -1339,6 +1402,7 @@ export class ProductService {
// 基础数据 // 基础数据
const rowData = [ const rowData = [
esc(p.sku), esc(p.sku),
esc(p.siteSkus ? p.siteSkus.map(s => s.code).join(',') : ''),
esc(p.name), esc(p.name),
esc(p.nameCn), esc(p.nameCn),
esc(p.price), esc(p.price),
@ -1373,7 +1437,7 @@ export class ProductService {
async exportProductsCSV(): Promise<string> { async exportProductsCSV(): Promise<string> {
// 查询所有产品及其属性(包含字典关系)和组成 // 查询所有产品及其属性(包含字典关系)和组成
const products = await this.productModel.find({ const products = await this.productModel.find({
relations: ['attributes', 'attributes.dict', 'components'], relations: ['attributes', 'attributes.dict', 'components', 'siteSkus'],
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
@ -1402,6 +1466,7 @@ export class ProductService {
// 定义 CSV 表头(与导入字段一致) // 定义 CSV 表头(与导入字段一致)
const baseHeaders = [ const baseHeaders = [
'sku', 'sku',
'siteSkus',
'name', 'name',
'nameCn', 'nameCn',
'price', 'price',
@ -1435,17 +1500,37 @@ export class ProductService {
} }
// 从 CSV 导入产品;存在则更新,不存在则创建 // 从 CSV 导入产品;存在则更新,不存在则创建
async importProductsCSV(buffer: Buffer): Promise<{ created: number; updated: number; errors: string[] }> { async importProductsCSV(file: any): Promise<{ created: number; updated: number; errors: string[] }> {
let buffer: Buffer;
if (Buffer.isBuffer(file)) {
buffer = file;
} else if (file?.data) {
if (typeof file.data === 'string') {
buffer = fs.readFileSync(file.data);
} else {
buffer = file.data;
}
} else {
throw new Error('无效的文件输入');
}
// 解析 CSV(使用 csv-parse/sync 按表头解析) // 解析 CSV(使用 csv-parse/sync 按表头解析)
const { parse } = await import('csv-parse/sync');
let records: any[] = []; let records: any[] = [];
try { try {
records = parse(buffer, { records = await new Promise((resolve, reject) => {
parse(buffer, {
columns: true, columns: true,
skip_empty_lines: true, skip_empty_lines: true,
trim: true, trim: true,
bom: true, bom: true,
}, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
}); });
})
console.log('Parsed records count:', records.length); console.log('Parsed records count:', records.length);
if (records.length > 0) { if (records.length > 0) {
console.log('First record keys:', Object.keys(records[0])); console.log('First record keys:', Object.keys(records[0]));
@ -1466,10 +1551,7 @@ export class ProductService {
errors.push('缺少 SKU 的记录已跳过'); errors.push('缺少 SKU 的记录已跳过');
continue; continue;
} }
const { sku, components } = data; const { sku } = data;
let currentProductId: number;
let currentProductType: string = data.type || 'single';
// 查找现有产品 // 查找现有产品
const exist = await this.productModel.findOne({ where: { sku }, relations: ['attributes', 'attributes.dict'] }); const exist = await this.productModel.findOne({ where: { sku }, relations: ['attributes', 'attributes.dict'] });
@ -1477,25 +1559,16 @@ export class ProductService {
if (!exist) { if (!exist) {
// 创建新产品 // 创建新产品
const createDTO = this.prepareCreateProductDTO(data); const createDTO = this.prepareCreateProductDTO(data);
const createdProduct = await this.createProduct(createDTO); await this.createProduct(createDTO);
currentProductId = createdProduct.id;
currentProductType = createdProduct.type;
created += 1; created += 1;
} else { } else {
// 更新产品 // 更新产品
const updateDTO = this.prepareUpdateProductDTO(data); const updateDTO = this.prepareUpdateProductDTO(data);
await this.updateProduct(exist.id, updateDTO); await this.updateProduct(exist.id, updateDTO);
currentProductId = exist.id;
currentProductType = updateDTO.type || exist.type;
updated += 1; updated += 1;
} }
// 4. 保存组件信息
if (currentProductType !== 'single' && components && components.length > 0) {
await this.setProductComponents(currentProductId, components);
}
} catch (e: any) { } catch (e: any) {
errors.push(e?.message || String(e)); errors.push(`产品${rec?.sku}导入失败:${e?.message || String(e)}`);
} }
} }