feat(产品): 添加产品站点SKU功能
添加 ProductSiteSku 实体及相关CRUD操作 在DTO和服务层增加站点SKU字段处理 更新产品导入导出功能支持站点SKU
This commit is contained in:
parent
d7cccad895
commit
40a445830b
|
|
@ -37,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';
|
||||||
|
|
@ -51,6 +52,7 @@ export default {
|
||||||
entities: [
|
entities: [
|
||||||
Product,
|
Product,
|
||||||
ProductStockComponent,
|
ProductStockComponent,
|
||||||
|
ProductSiteSku,
|
||||||
WpProduct,
|
WpProduct,
|
||||||
Variation,
|
Variation,
|
||||||
User,
|
User,
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -179,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;
|
||||||
|
|
@ -187,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;
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -29,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';
|
||||||
|
|
||||||
|
|
@ -67,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>;
|
||||||
|
|
||||||
|
|
@ -242,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) : [];
|
||||||
|
|
@ -421,7 +426,7 @@ export class ProductService {
|
||||||
const product = new Product();
|
const product = new Product();
|
||||||
|
|
||||||
// 使用 merge 填充基础字段,排除特殊处理字段
|
// 使用 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);
|
this.productModel.merge(product, simpleFields);
|
||||||
|
|
||||||
product.attributes = resolvedAttributes;
|
product.attributes = resolvedAttributes;
|
||||||
|
|
@ -449,11 +454,22 @@ export class ProductService {
|
||||||
|
|
||||||
const savedProduct = 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) {
|
if (createProductDTO.components && createProductDTO.components.length > 0) {
|
||||||
await this.setProductComponents(savedProduct.id, createProductDTO.components);
|
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;
|
return savedProduct;
|
||||||
|
|
@ -470,7 +486,7 @@ export class ProductService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 merge 更新基础字段,排除特殊处理字段
|
// 使用 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);
|
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 更新
|
// 处理 SKU 更新
|
||||||
if (updateProductDTO.sku !== undefined) {
|
if (updateProductDTO.sku !== undefined) {
|
||||||
// 校验 SKU 唯一性(如变更)
|
// 校验 SKU 唯一性(如变更)
|
||||||
|
|
@ -587,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;
|
||||||
|
|
||||||
|
|
@ -672,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 || [])
|
||||||
|
|
@ -1285,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,
|
||||||
|
|
@ -1299,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);
|
||||||
|
|
@ -1325,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);
|
||||||
|
|
@ -1363,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),
|
||||||
|
|
@ -1397,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' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1426,6 +1466,7 @@ export class ProductService {
|
||||||
// 定义 CSV 表头(与导入字段一致)
|
// 定义 CSV 表头(与导入字段一致)
|
||||||
const baseHeaders = [
|
const baseHeaders = [
|
||||||
'sku',
|
'sku',
|
||||||
|
'siteSkus',
|
||||||
'name',
|
'name',
|
||||||
'nameCn',
|
'nameCn',
|
||||||
'price',
|
'price',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue