feat(产品): 添加产品站点SKU功能

添加 ProductSiteSku 实体及相关CRUD操作
在DTO和服务层增加站点SKU字段处理
更新产品导入导出功能支持站点SKU
This commit is contained in:
tikkhun 2025-12-04 14:50:26 +08:00
parent d7cccad895
commit 40a445830b
5 changed files with 118 additions and 6 deletions

View File

@ -37,6 +37,7 @@ import { DictItem } from '../entity/dict_item.entity';
import { Template } from '../entity/template.entity';
import { Area } from '../entity/area.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 { Category } from '../entity/category.entity';
import DictSeeder from '../db/seeds/dict.seeder';
@ -51,6 +52,7 @@ export default {
entities: [
Product,
ProductStockComponent,
ProductSiteSku,
WpProduct,
Variation,
User,

View File

@ -46,6 +46,10 @@ export class CreateProductDTO {
@Rule(RuleType.string())
description: string;
@ApiProperty({ example: '产品简短描述', description: '产品简短描述' })
@Rule(RuleType.string().optional())
shortDescription?: string;
@ApiProperty({ description: '产品 SKU', required: false })
@Rule(RuleType.string())
sku?: string;
@ -54,6 +58,10 @@ export class CreateProductDTO {
@Rule(RuleType.number())
categoryId?: number;
@ApiProperty({ description: '站点 SKU 列表', type: 'array', required: false })
@Rule(RuleType.array().items(RuleType.string()).optional())
siteSkus?: string[];
// 通用属性输入(通过 attributes 统一提交品牌/口味/强度/尺寸/干湿等)
@ApiProperty({ description: '属性列表', type: 'array' })
@Rule(RuleType.array().required())
@ -110,6 +118,10 @@ export class UpdateProductDTO {
@Rule(RuleType.string())
description?: string;
@ApiProperty({ example: '产品简短描述', description: '产品简短描述' })
@Rule(RuleType.string().optional())
shortDescription?: string;
@ApiProperty({ description: '产品 SKU', required: false })
@Rule(RuleType.string())
sku?: string;
@ -118,6 +130,10 @@ export class UpdateProductDTO {
@Rule(RuleType.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 })
@Rule(RuleType.number())
@ -179,6 +195,10 @@ export class BatchUpdateProductDTO {
@Rule(RuleType.string().optional())
description?: string;
@ApiProperty({ example: '产品简短描述', description: '产品简短描述', required: false })
@Rule(RuleType.string().optional())
shortDescription?: string;
@ApiProperty({ description: '产品 SKU', required: false })
@Rule(RuleType.string().optional())
sku?: string;
@ -187,6 +207,10 @@ export class BatchUpdateProductDTO {
@Rule(RuleType.number().optional())
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 })
@Rule(RuleType.number().optional())
price?: number;

View File

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

@ -29,6 +29,7 @@ import { StockService } from './stock.service';
import { Stock } from '../entity/stock.entity';
import { StockPoint } from '../entity/stock_point.entity';
import { ProductStockComponent } from '../entity/product_stock_component.entity';
import { ProductSiteSku } from '../entity/product_site_sku.entity';
import { Category } from '../entity/category.entity';
import { CategoryAttribute } from '../entity/category_attribute.entity';
@ -67,6 +68,9 @@ export class ProductService {
@InjectEntityModel(ProductStockComponent)
productStockComponentModel: Repository<ProductStockComponent>;
@InjectEntityModel(ProductSiteSku)
productSiteSkuModel: Repository<ProductSiteSku>;
@InjectEntityModel(Category)
categoryModel: Repository<Category>;
@ -242,7 +246,8 @@ export class ProductService {
.createQueryBuilder('product')
.leftJoinAndSelect('product.attributes', 'attribute')
.leftJoinAndSelect('attribute.dict', 'dict')
.leftJoinAndSelect('product.category', 'category');
.leftJoinAndSelect('product.category', 'category')
.leftJoinAndSelect('product.siteSkus', 'siteSku');
// 模糊搜索 name,支持多个关键词
const nameFilter = name ? name.split(' ').filter(Boolean) : [];
@ -421,7 +426,7 @@ export class ProductService {
const product = new Product();
// 使用 merge 填充基础字段,排除特殊处理字段
const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, ...simpleFields } = createProductDTO;
const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, siteSkus: _siteSkus, ...simpleFields } = createProductDTO;
this.productModel.merge(product, simpleFields);
product.attributes = resolvedAttributes;
@ -449,11 +454,22 @@ export class ProductService {
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'] });
return await this.productModel.findOne({ where: { id: savedProduct.id }, relations: ['attributes', 'attributes.dict', 'category', 'components', 'siteSkus'] });
}
return savedProduct;
@ -470,7 +486,7 @@ export class ProductService {
}
// 使用 merge 更新基础字段,排除特殊处理字段
const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, ...simpleFields } = updateProductDTO;
const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, siteSkus: _siteSkus, ...simpleFields } = updateProductDTO;
this.productModel.merge(product, simpleFields);
// 处理分类更新
@ -485,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 更新
if (updateProductDTO.sku !== undefined) {
// 校验 SKU 唯一性(如变更)
@ -587,6 +620,7 @@ export class ProductService {
if (updateData.name !== undefined) simpleUpdate.name = updateData.name;
if (updateData.nameCn !== undefined) simpleUpdate.nameCn = updateData.nameCn;
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.promotionPrice !== undefined) simpleUpdate.promotionPrice = updateData.promotionPrice;
@ -672,7 +706,9 @@ export class ProductService {
if (!product) throw new Error(`产品 ID ${productId} 不存在`);
// 条件判断(单品 simple 不允许手动设置组成)
if (product.type === 'single') {
throw new Error('单品无需设置组成');
// 单品类型,直接清空关联的组成(如果有)
await this.productStockComponentModel.delete({ productId });
return [];
}
const validItems = (items || [])
@ -1285,6 +1321,7 @@ export class ProductService {
price: num(rec.price),
promotionPrice: num(rec.promotionPrice),
type: val(rec.type),
siteSkus: rec.siteSkus ? String(rec.siteSkus).split(',').map(s => s.trim()).filter(Boolean) : undefined,
attributes: attributes.length > 0 ? attributes : undefined,
components: components.length > 0 ? components : undefined,
@ -1299,6 +1336,7 @@ export class ProductService {
dto.nameCn = data.nameCn;
dto.description = data.description;
dto.sku = data.sku;
if (data.siteSkus) dto.siteSkus = data.siteSkus;
// 数值类型转换
if (data.price !== undefined) dto.price = Number(data.price);
@ -1325,6 +1363,7 @@ export class ProductService {
if (data.nameCn !== undefined) dto.nameCn = data.nameCn;
if (data.description !== undefined) dto.description = data.description;
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.promotionPrice !== undefined) dto.promotionPrice = Number(data.promotionPrice);
@ -1363,6 +1402,7 @@ export class ProductService {
// 基础数据
const rowData = [
esc(p.sku),
esc(p.siteSkus ? p.siteSkus.map(s => s.code).join(',') : ''),
esc(p.name),
esc(p.nameCn),
esc(p.price),
@ -1397,7 +1437,7 @@ export class ProductService {
async exportProductsCSV(): Promise<string> {
// 查询所有产品及其属性(包含字典关系)和组成
const products = await this.productModel.find({
relations: ['attributes', 'attributes.dict', 'components'],
relations: ['attributes', 'attributes.dict', 'components', 'siteSkus'],
order: { id: 'ASC' },
});
@ -1426,6 +1466,7 @@ export class ProductService {
// 定义 CSV 表头(与导入字段一致)
const baseHeaders = [
'sku',
'siteSkus',
'name',
'nameCn',
'price',