Compare commits

...

7 Commits

Author SHA1 Message Date
tikkhun b3b7ee4793 refactor(订单服务): 重构订单组件详情获取逻辑
将订单服务中的产品详情查询逻辑提取到产品服务中
处理混合SKU和bundle产品的特殊情况
2026-01-17 16:58:09 +08:00
tikkhun b7101ac866 refactor(service): 重构订单服务中的品牌属性检查逻辑
将硬编码的品牌检查逻辑改为直接存储品牌和其他属性值,提高代码的可维护性和扩展性
2026-01-17 16:58:09 +08:00
tikkhun 72cd20fcd6 fix(订单服务): 修正套餐类型判断逻辑并添加注释
将isPackage的判断从子产品改为父产品类型,与业务逻辑一致
添加externalOrderItemId的注释说明
2026-01-17 16:58:09 +08:00
tikkhun 8766cf4a4c feat(订单): 添加父产品ID字段用于统计套餐
在订单服务和订单销售实体中添加 parentProductId 字段,用于区分套餐产品和单品。如果是套餐产品则记录父产品ID,单品则不记录该字段
2026-01-17 16:58:09 +08:00
tikkhun d39341d683 feat(产品服务): 增加分类名称支持并优化产品查询逻辑
添加分类名称字段支持,允许通过分类名称创建和更新产品
移除重复的查询条件逻辑,简化产品服务查询方法
重构产品导入功能,改进CSV记录到产品对象的映射
2026-01-17 16:58:09 +08:00
tikkhun 7f04de4583 fix(product): 将sku精确匹配改为模糊查询
移除重复的sku过滤条件,统一使用LIKE进行模糊查询
2026-01-17 16:58:09 +08:00
tikkhun bdac4860df feat(产品): 添加产品属性过滤和分组功能
- 在 ProductWhereFilter 接口中添加 attributes 字段用于属性过滤
- 新增 getAllProducts 方法支持按品牌过滤产品
- 新增 getProductsGroupedByAttribute 方法实现按属性分组产品
- 在查询构建器中添加属性过滤逻辑
2026-01-17 16:58:09 +08:00
7 changed files with 344 additions and 163 deletions

View File

@ -750,4 +750,31 @@ export class ProductController {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
// 获取所有产品,支持按品牌过滤
@ApiOkResponse({ description: '获取所有产品', type: ProductListRes })
@Get('/all')
async getAllProducts(@Query('brand') brand?: string) {
try {
const data = await this.productService.getAllProducts(brand);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
// 获取按属性分组的产品,默认按强度划分
@ApiOkResponse({ description: '获取按属性分组的产品' })
@Get('/grouped')
async getGroupedProducts(
@Query('brand') brand?: string,
@Query('attribute') attribute: string = 'strength'
) {
try {
const data = await this.productService.getProductsGroupedByAttribute(brand, attribute);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
} }

View File

@ -59,6 +59,10 @@ export class CreateProductDTO {
@Rule(RuleType.number()) @Rule(RuleType.number())
categoryId?: number; categoryId?: number;
@ApiProperty({ description: '分类名称', required: false })
@Rule(RuleType.string().optional())
categoryName?: string;
@ApiProperty({ description: '站点 SKU 列表', type: 'array', required: false }) @ApiProperty({ description: '站点 SKU 列表', type: 'array', required: false })
@Rule(RuleType.array().items(RuleType.string()).optional()) @Rule(RuleType.array().items(RuleType.string()).optional())
siteSkus?: string[]; siteSkus?: string[];
@ -142,6 +146,10 @@ export class UpdateProductDTO {
@Rule(RuleType.number()) @Rule(RuleType.number())
categoryId?: number; categoryId?: number;
@ApiProperty({ description: '分类名称', required: false })
@Rule(RuleType.string().optional())
categoryName?: string;
@ApiProperty({ description: '站点 SKU 列表', type: 'array', required: false }) @ApiProperty({ description: '站点 SKU 列表', type: 'array', required: false })
@Rule(RuleType.array().items(RuleType.string()).optional()) @Rule(RuleType.array().items(RuleType.string()).optional())
siteSkus?: string[]; siteSkus?: string[];
@ -311,6 +319,8 @@ export interface ProductWhereFilter {
updatedAtStart?: string; updatedAtStart?: string;
// 更新时间范围结束 // 更新时间范围结束
updatedAtEnd?: string; updatedAtEnd?: string;
// TODO 使用 attributes 过滤
attributes?: Record<string, string>;
} }
/** /**

View File

@ -37,11 +37,16 @@ export class OrderSale {
@Expose() @Expose()
externalOrderItemId: string; // WooCommerce 订单item ID externalOrderItemId: string; // WooCommerce 订单item ID
@ApiProperty({name: "父产品 ID"})
@Column({ nullable: true })
@Expose()
parentProductId?: number; // 父产品 ID 用于统计套餐 如果是单品则不记录
@ApiProperty({name: "产品 ID"}) @ApiProperty({name: "产品 ID"})
@Column() @Column()
@Expose() @Expose()
productId: number; productId: number;
@ApiProperty() @ApiProperty()
@Column() @Column()
@Expose() @Expose()
@ -50,7 +55,7 @@ export class OrderSale {
@ApiProperty({ description: 'sku', type: 'string' }) @ApiProperty({ description: 'sku', type: 'string' })
@Expose() @Expose()
@Column() @Column()
sku: string; sku: string;// 库存产品sku
@ApiProperty() @ApiProperty()
@Column() @Column()

View File

@ -73,6 +73,10 @@ export class Product {
@JoinColumn({ name: 'categoryId' }) @JoinColumn({ name: 'categoryId' })
category: Category; category: Category;
@ApiProperty({ description: '分类 ID', nullable: true, example: 1 })
@Column({ nullable: true })
categoryId?: number;
@ManyToMany(() => DictItem, dictItem => dictItem.products, { @ManyToMany(() => DictItem, dictItem => dictItem.products, {
cascade: true, cascade: true,
}) })

View File

@ -715,21 +715,18 @@ export class OrderService {
} }
if (!orderItem.sku) return; if (!orderItem.sku) return;
// 从数据库查询产品,关联查询组件 // 从数据库查询产品,关联查询组件
const product = await this.productModel.findOne({ const productDetail = await this.productService.getComponentDetailFromSiteSku({ sku: orderItem.sku, name: orderItem.name });
where: { siteSkus: Like(`%${orderItem.sku}%`) },
relations: ['components','attributes','attributes.dict'], if (!productDetail || !productDetail.quantity) return;
}); const {product, quantity} = productDetail
if (!product) return;
const componentDetails: { product: Product, quantity: number }[] = product.components?.length > 0 ? await Promise.all(product.components.map(async comp => { const componentDetails: { product: Product, quantity: number }[] = product.components?.length > 0 ? await Promise.all(product.components.map(async comp => {
return { return {
product: await this.productModel.findOne({ product: await this.productModel.findOne({
where: { sku: comp.sku }, where: { id: comp.productId },
relations: ['components', 'attributes','attributes.dict'],
}), }),
quantity: comp.quantity * orderItem.quantity, quantity: comp.quantity * orderItem.quantity,
} }
})) : [{ product, quantity: orderItem.quantity }] })) : [{ product, quantity }]
const orderSales: OrderSale[] = componentDetails.map(componentDetail => { const orderSales: OrderSale[] = componentDetails.map(componentDetail => {
if (!componentDetail.product) return null if (!componentDetail.product) return null
@ -737,18 +734,21 @@ export class OrderService {
const orderSale = plainToClass(OrderSale, { const orderSale = plainToClass(OrderSale, {
orderId: orderItem.orderId, orderId: orderItem.orderId,
siteId: orderItem.siteId, siteId: orderItem.siteId,
externalOrderItemId: orderItem.externalOrderItemId, externalOrderItemId: orderItem.externalOrderItemId,// 原始 itemId
parentProductId: product.id, // 父产品 ID 用于统计套餐 如果是单品则不记录
productId: componentDetail.product.id, productId: componentDetail.product.id,
isPackage: product.type === 'bundle',// 这里是否是套餐取决于父产品
name: componentDetail.product.name, name: componentDetail.product.name,
quantity: componentDetail.quantity * orderItem.quantity, quantity: componentDetail.quantity * orderItem.quantity,
sku: componentDetail.product.sku, sku: componentDetail.product.sku,
// 理论上直接存 product 的全部数据才是对的,因为这样我的数据才全面。 // 理论上直接存 product 的全部数据才是对的,因为这样我的数据才全面。
isPackage: componentDetail.product.type === 'bundle', brand: attrsObj?.['brand']?.name,
isYoone: attrsObj?.['brand']?.name === 'yoone', version: attrsObj?.['version']?.name,
isZyn: attrsObj?.['brand']?.name === 'zyn',
isZex: attrsObj?.['brand']?.name === 'zex',
isYooneNew: attrsObj?.['brand']?.name === 'yoone' && attrsObj?.['version']?.name === 'new',
strength: attrsObj?.['strength']?.name, strength: attrsObj?.['strength']?.name,
flavor: attrsObj?.['flavor']?.name,
humidity: attrsObj?.['humidity']?.name,
size: attrsObj?.['size']?.name,
category: componentDetail.product.category.name,
}); });
return orderSale return orderSale
}).filter(v => v !== null) }).filter(v => v !== null)

View File

@ -276,19 +276,9 @@ export class ProductService {
qb.andWhere('product.id IN (:...ids)', { ids: query.where.ids }); qb.andWhere('product.id IN (:...ids)', { ids: query.where.ids });
} }
// 处理where对象中的id过滤
if (query.where?.id) {
qb.andWhere('product.id = :whereId', { whereId: query.where.id });
}
// 处理where对象中的ids过滤
if (query.where?.ids && query.where.ids.length > 0) {
qb.andWhere('product.id IN (:...whereIds)', { whereIds: query.where.ids });
}
// 处理SKU过滤 // 处理SKU过滤
if (query.where?.sku) { if (query.where?.sku) {
qb.andWhere('product.sku = :sku', { sku: query.where.sku }); qb.andWhere('product.sku LIKE :sku', { sku: `%${query.where.sku}%` });
} }
// 处理SKU列表过滤 // 处理SKU列表过滤
@ -296,16 +286,6 @@ export class ProductService {
qb.andWhere('product.sku IN (:...skus)', { skus: query.where.skus }); qb.andWhere('product.sku IN (:...skus)', { skus: query.where.skus });
} }
// 处理where对象中的sku过滤
if (query.where?.sku) {
qb.andWhere('product.sku = :whereSku', { whereSku: query.where.sku });
}
// 处理where对象中的skus过滤
if (query.where?.skus && query.where.skus.length > 0) {
qb.andWhere('product.sku IN (:...whereSkus)', { whereSkus: query.where.skus });
}
// 处理产品中文名称过滤 // 处理产品中文名称过滤
if (query.where?.nameCn) { if (query.where?.nameCn) {
qb.andWhere('product.nameCn LIKE :nameCn', { nameCn: `%${query.where.nameCn}%` }); qb.andWhere('product.nameCn LIKE :nameCn', { nameCn: `%${query.where.nameCn}%` });
@ -316,11 +296,6 @@ export class ProductService {
qb.andWhere('product.type = :type', { type: query.where.type }); qb.andWhere('product.type = :type', { type: query.where.type });
} }
// 处理where对象中的type过滤
if (query.where?.type) {
qb.andWhere('product.type = :whereType', { whereType: query.where.type });
}
// 处理价格范围过滤 // 处理价格范围过滤
if (query.where?.minPrice !== undefined) { if (query.where?.minPrice !== undefined) {
qb.andWhere('product.price >= :minPrice', { minPrice: query.where.minPrice }); qb.andWhere('product.price >= :minPrice', { minPrice: query.where.minPrice });
@ -330,15 +305,6 @@ export class ProductService {
qb.andWhere('product.price <= :maxPrice', { maxPrice: query.where.maxPrice }); qb.andWhere('product.price <= :maxPrice', { maxPrice: query.where.maxPrice });
} }
// 处理where对象中的价格范围过滤
if (query.where?.minPrice !== undefined) {
qb.andWhere('product.price >= :whereMinPrice', { whereMinPrice: query.where.minPrice });
}
if (query.where?.maxPrice !== undefined) {
qb.andWhere('product.price <= :whereMaxPrice', { whereMaxPrice: query.where.maxPrice });
}
// 处理促销价格范围过滤 // 处理促销价格范围过滤
if (query.where?.minPromotionPrice !== undefined) { if (query.where?.minPromotionPrice !== undefined) {
qb.andWhere('product.promotionPrice >= :minPromotionPrice', { minPromotionPrice: query.where.minPromotionPrice }); qb.andWhere('product.promotionPrice >= :minPromotionPrice', { minPromotionPrice: query.where.minPromotionPrice });
@ -348,15 +314,6 @@ export class ProductService {
qb.andWhere('product.promotionPrice <= :maxPromotionPrice', { maxPromotionPrice: query.where.maxPromotionPrice }); qb.andWhere('product.promotionPrice <= :maxPromotionPrice', { maxPromotionPrice: query.where.maxPromotionPrice });
} }
// 处理where对象中的促销价格范围过滤
if (query.where?.minPromotionPrice !== undefined) {
qb.andWhere('product.promotionPrice >= :whereMinPromotionPrice', { whereMinPromotionPrice: query.where.minPromotionPrice });
}
if (query.where?.maxPromotionPrice !== undefined) {
qb.andWhere('product.promotionPrice <= :whereMaxPromotionPrice', { whereMaxPromotionPrice: query.where.maxPromotionPrice });
}
// 处理创建时间范围过滤 // 处理创建时间范围过滤
if (query.where?.createdAtStart) { if (query.where?.createdAtStart) {
qb.andWhere('product.createdAt >= :createdAtStart', { createdAtStart: new Date(query.where.createdAtStart) }); qb.andWhere('product.createdAt >= :createdAtStart', { createdAtStart: new Date(query.where.createdAtStart) });
@ -366,15 +323,6 @@ export class ProductService {
qb.andWhere('product.createdAt <= :createdAtEnd', { createdAtEnd: new Date(query.where.createdAtEnd) }); qb.andWhere('product.createdAt <= :createdAtEnd', { createdAtEnd: new Date(query.where.createdAtEnd) });
} }
// 处理where对象中的创建时间范围过滤
if (query.where?.createdAtStart) {
qb.andWhere('product.createdAt >= :whereCreatedAtStart', { whereCreatedAtStart: new Date(query.where.createdAtStart) });
}
if (query.where?.createdAtEnd) {
qb.andWhere('product.createdAt <= :whereCreatedAtEnd', { whereCreatedAtEnd: new Date(query.where.createdAtEnd) });
}
// 处理更新时间范围过滤 // 处理更新时间范围过滤
if (query.where?.updatedAtStart) { if (query.where?.updatedAtStart) {
qb.andWhere('product.updatedAt >= :updatedAtStart', { updatedAtStart: new Date(query.where.updatedAtStart) }); qb.andWhere('product.updatedAt >= :updatedAtStart', { updatedAtStart: new Date(query.where.updatedAtStart) });
@ -384,14 +332,40 @@ export class ProductService {
qb.andWhere('product.updatedAt <= :updatedAtEnd', { updatedAtEnd: new Date(query.where.updatedAtEnd) }); qb.andWhere('product.updatedAt <= :updatedAtEnd', { updatedAtEnd: new Date(query.where.updatedAtEnd) });
} }
// 处理where对象中的更新时间范围过滤 // 处理属性过滤
if (query.where?.updatedAtStart) { const attributeFilters = query.where?.attributes || {};
qb.andWhere('product.updatedAt >= :whereUpdatedAtStart', { whereUpdatedAtStart: new Date(query.where.updatedAtStart) }); Object.entries(attributeFilters).forEach(([attributeName, value], index) => {
} if (value === 'hasValue') {
// 如果值为'hasValue',则过滤出具有该属性的产品
if (query.where?.updatedAtEnd) { qb.andWhere(qb => {
qb.andWhere('product.updatedAt <= :whereUpdatedAtEnd', { whereUpdatedAtEnd: new Date(query.where.updatedAtEnd) }); const subQuery = qb
} .subQuery()
.select('product_attributes_dict_item.productId')
.from('product_attributes_dict_item', 'product_attributes_dict_item')
.innerJoin('dict_item', 'dict_item', 'product_attributes_dict_item.dictItemId = dict_item.id')
.innerJoin('dict', 'dict', 'dict_item.dictId = dict.id')
.where('dict.name = :attributeName', {
attributeName,
})
.getQuery();
return 'product.id IN ' + subQuery;
});
} else if (typeof value === 'number' || !isNaN(Number(value))) {
// 如果值是数字,则过滤出该属性等于该值的产品
const attributeValueId = Number(value);
qb.andWhere(qb => {
const subQuery = qb
.subQuery()
.select('product_attributes_dict_item.productId')
.from('product_attributes_dict_item', 'product_attributes_dict_item')
.where('product_attributes_dict_item.dictItemId = :attributeValueId', {
attributeValueId,
})
.getQuery();
return 'product.id IN ' + subQuery;
});
}
});
// 品牌过滤(向后兼容) // 品牌过滤(向后兼容)
if (brandId) { if (brandId) {
@ -433,16 +407,6 @@ export class ProductService {
qb.andWhere('product.categoryId IN (:...categoryIds)', { categoryIds }); qb.andWhere('product.categoryId IN (:...categoryIds)', { categoryIds });
} }
// 处理where对象中的分类ID过滤
if (query.where?.categoryId) {
qb.andWhere('product.categoryId = :whereCategoryId', { whereCategoryId: query.where.categoryId });
}
// 处理where对象中的分类ID列表过滤
if (query.where?.categoryIds && query.where.categoryIds.length > 0) {
qb.andWhere('product.categoryId IN (:...whereCategoryIds)', { whereCategoryIds: query.where.categoryIds });
}
// 处理排序(支持新旧两种格式) // 处理排序(支持新旧两种格式)
if (orderBy) { if (orderBy) {
if (typeof orderBy === 'string') { if (typeof orderBy === 'string') {
@ -534,9 +498,8 @@ export class ProductService {
return item; return item;
} }
async createProduct(createProductDTO: CreateProductDTO): Promise<Product> { async createProduct(createProductDTO: CreateProductDTO): Promise<Product> {
const { attributes, sku, categoryId, type } = createProductDTO; const { attributes, sku, categoryId, categoryName, type } = createProductDTO;
// 条件判断(校验属性输入) // 条件判断(校验属性输入)
// 当产品类型为 'bundle' 时attributes 可以为空 // 当产品类型为 'bundle' 时attributes 可以为空
@ -547,47 +510,38 @@ export class ProductService {
} }
} }
const safeAttributes = attributes || [];
// 解析属性输入(按 id 或 dictName 创建/关联字典项) // 解析属性输入(按 id 或 dictName 创建/关联字典项)
const resolvedAttributes: DictItem[] = [];
let categoryItem: Category | null = null; let categoryItem: Category | null = null;
// 如果提供了 categoryId,设置分类 // 如果提供了 categoryId,设置分类
if (categoryId) { if (categoryId) {
categoryItem = await this.categoryModel.findOne({ categoryItem = await this.categoryModel.findOne({
where: { id: categoryId }, where: { id: categoryId },
relations: ['attributes', 'attributes.attributeDict'] relations: ['attributes', 'attributes.attributeDict']
}); });
if (!categoryItem) throw new Error(`分类 ID ${categoryId} 不存在`);
} }
if (!categoryItem && categoryName) {
categoryItem = await this.categoryModel.findOne({
where: { name: categoryName },
relations: ['attributes', 'attributes.attributeDict']
});
}
if (!categoryItem && categoryName) {
const category = new Category();
category.name = categoryName || '';
category.title = categoryName || '';
const savedCategory = await this.categoryModel.save(category);
categoryItem = await this.categoryModel.findOne({
where: { id: savedCategory.id },
relations: ['attributes', 'attributes.attributeDict']
});
if (!categoryItem) throw new Error(`分类名称 ${categoryName} 不存在`);
}
// 创造一定要有商品分类
if (!categoryItem) throw new Error('必须提供分类 ID 或分类名称');
const resolvedAttributes: DictItem[] = [];
const safeAttributes = attributes || [];
for (const attr of safeAttributes) { for (const attr of safeAttributes) {
// 如果属性是分类,特殊处理
if (attr.dictName === 'category') {
if (attr.id) {
categoryItem = await this.categoryModel.findOne({
where: { id: attr.id },
relations: ['attributes', 'attributes.attributeDict']
});
} else if (attr.name) {
categoryItem = await this.categoryModel.findOne({
where: { name: attr.name },
relations: ['attributes', 'attributes.attributeDict']
});
} else if (attr.title) {
// 尝试用 title 匹配 name 或 title
categoryItem = await this.categoryModel.findOne({
where: [
{ name: attr.title },
{ title: attr.title }
],
relations: ['attributes', 'attributes.attributeDict']
});
}
continue;
}
let item: DictItem | null = null; let item: DictItem | null = null;
if (attr.id) { if (attr.id) {
// 如果传入了 id,直接查找字典项并使用,不强制要求 dictName // 如果传入了 id,直接查找字典项并使用,不强制要求 dictName
@ -663,27 +617,46 @@ export class ProductService {
} }
// 使用 merge 更新基础字段,排除特殊处理字段 // 使用 merge 更新基础字段,排除特殊处理字段
const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, ...simpleFields } = updateProductDTO; const { attributes, categoryId, categoryName, sku, components, ...simpleFields } = updateProductDTO;
this.productModel.merge(product, simpleFields); this.productModel.merge(product, simpleFields);
// 解析属性输入(按 id 或 dictName 创建/关联字典项)
// 处理分类更新 let categoryItem: Category | null = null;
if (updateProductDTO.categoryId !== undefined) { // 如果提供了 categoryId,设置分类
if (updateProductDTO.categoryId) { if (categoryId) {
const categoryItem = await this.categoryModel.findOne({ where: { id: updateProductDTO.categoryId } }); categoryItem = await this.categoryModel.findOne({
if (!categoryItem) throw new Error(`分类 ID ${updateProductDTO.categoryId} 不存在`); where: { id: categoryId },
product.category = categoryItem; relations: ['attributes', 'attributes.attributeDict']
} else { });
// 如果传了 0 或 null,可以清除分类(根据需求)
// product.category = null;
}
} }
if (!categoryItem && categoryName) {
categoryItem = await this.categoryModel.findOne({
where: { name: categoryName },
relations: ['attributes', 'attributes.attributeDict']
});
}
function nameToTitle(name: string) {
return name.replace('-', ' ');
}
if (!categoryItem && categoryName) {
const category = new Category();
category.name = categoryName || '';
category.title = nameToTitle(categoryName || '');
const savedCategory = await this.categoryModel.save(category);
categoryItem = await this.categoryModel.findOne({
where: { id: savedCategory.id },
relations: ['attributes', 'attributes.attributeDict']
});
if (!categoryItem) throw new Error(`分类名称 ${categoryName} 不存在`);
}
// 创造一定要有商品分类
if (!categoryItem) throw new Error('必须提供分类 ID 或分类名称');
product.categoryId = categoryItem.id;
// 处理 SKU 更新 // 处理 SKU 更新
if (updateProductDTO.sku !== undefined) { if (updateProductDTO.sku !== undefined) {
// 校验 SKU 唯一性(如变更) // 校验 SKU 唯一性(如变更)
const newSku = updateProductDTO.sku; const newSku = updateProductDTO.sku;
if (newSku && newSku !== product.sku) { if (newSku && newSku !== product.sku) {
const exist = await this.productModel.findOne({ where: { sku: newSku } }); const exist = await this.productModel.findOne({ where: { id: Not(id), sku: newSku } });
if (exist) { if (exist) {
throw new Error('SKU 已存在,请更换后重试'); throw new Error('SKU 已存在,请更换后重试');
} }
@ -701,14 +674,6 @@ export class ProductService {
}; };
for (const attr of updateProductDTO.attributes) { for (const attr of updateProductDTO.attributes) {
// 如果属性是分类,特殊处理
if (attr.dictName === 'category') {
if (attr.id) {
const categoryItem = await this.categoryModel.findOneBy({ id: attr.id });
if (categoryItem) product.category = categoryItem;
}
continue;
}
let item: DictItem | null = null; let item: DictItem | null = null;
if (attr.id) { if (attr.id) {
@ -1419,7 +1384,8 @@ export class ProductService {
} }
// 将单条 CSV 记录转换为数据对象 // 将单条 CSV 记录转换为数据对象
transformCsvRecordToData(rec: any): CreateProductDTO & { sku: string } | null { mapTableRecordToProduct(rec: any): CreateProductDTO | UpdateProductDTO | null {
const keys = Object.keys(rec);
// 必须包含 sku // 必须包含 sku
const sku: string = (rec.sku || '').trim(); const sku: string = (rec.sku || '').trim();
if (!sku) { if (!sku) {
@ -1440,43 +1406,105 @@ export class ProductService {
}; };
// 解析属性字段(分号分隔多值) // 解析属性字段(分号分隔多值)
const parseList = (v: string) => (v ? String(v).split(';').map(s => s.trim()).filter(Boolean) : []); const parseList = (v: string) => (v && String(v).split(/[;,]/).map(s => s.trim()).filter(Boolean));
// 将属性解析为 DTO 输入 // 将属性解析为 DTO 输入
const attributes: any[] = []; const attributes: any[] = [];
// 处理动态属性字段 (attribute_*) // 处理动态属性字段 (attribute_*)
for (const key of Object.keys(rec)) { for (const key of keys) {
if (key.startsWith('attribute_')) { if (key.startsWith('attribute_')) {
const dictName = key.replace('attribute_', ''); const dictName = key.replace('attribute_', '');
if (dictName) { if (dictName) {
const list = parseList(rec[key]); const list = parseList(rec[key]) || [];
for (const item of list) attributes.push({ dictName, title: item }); for (const item of list) attributes.push({ dictName, title: item });
} }
} }
} }
// 目前的 components 由 component_{index}_sku和component_{index}_quantity组成
const component_sku_keys = keys.filter(key => key.startsWith('component_') && key.endsWith('_sku'));
const components = [];
for (const key of component_sku_keys) {
const index = key.replace('component_', '').replace('_sku', '');
if (index) {
const sku = val(rec[`component_${index}_sku`]);
const quantity = num(rec[`component_${index}_quantity`]);
if (sku && quantity) {
components.push({ sku, quantity });
}
}
}
// 处理分类字段 // 处理分类字段
const category = val(rec.category); const categoryName = val(rec.category);
return { return {
sku, sku,
name: val(rec.name), name: val(rec.name),
nameCn: val(rec.nameCn), nameCn: val(rec.nameCn),
image: val(rec.image),
description: val(rec.description), description: val(rec.description),
shortDescription: val(rec.shortDescription),
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 siteSkus: rec.siteSkus ? parseList(rec.siteSkus) : undefined,
? String(rec.siteSkus) categoryName, // 添加分类字段
.split(/[;,]/) // 支持英文分号或英文逗号分隔 components,
.map(s => s.trim())
.filter(Boolean)
: undefined,
category, // 添加分类字段
attributes: attributes.length > 0 ? attributes : undefined, attributes: attributes.length > 0 ? attributes : undefined,
} as any; }
}
isMixedSku(sku: string){
const splitSKu = sku.split('-')
const last = splitSKu[splitSKu.length - 1]
const second = splitSKu[splitSKu.length - 2]
// 这里判断 second 是否是数字
return sku.includes('-MX-') || sku.includes('-Mixed-') || /^\d+$/.test(second) && /^\d+$/.test(last)
}
async getComponentDetailFromSiteSku(siteProduct: { sku: string, name: string }) {
if (!siteProduct.sku) {
throw new Error('siteSku 不能为空')
}
let product = await this.productModel.findOne({
where: { siteSkus: Like(`%${siteProduct.sku}%`) },
relations: ['components', 'attributes', 'attributes.dict'],
});
let quantity = 1;
// 这里处理一下特殊情况,就是无法直接通过 siteProduct.sku去获取 但有一定规则转换成有的产品,就是 bundle 的部分
// 考察各个站点的 bundle 规则, 会发现
// wordpress:
// togovape YOONE Wintergreen 9MG (Moisture) - 10 cans TV-YOONE-NP-S-WG-9MG-0010
// togovape mixed 是这样的 TV-YOONE-NP-G-12MG-MX-0003 TV-ZEX-NP-Mixed-12MG-0001
//
// shopyy: shopyy 已经
// 只有 bundle 做这个处理
if (!product && !this.isMixedSku(siteProduct.sku)) {
const skuSplitArr = siteProduct.sku.split('-')
const quantityStr = skuSplitArr[skuSplitArr.length - 1]
const isBundleSku = quantityStr.startsWith('0')
if(!isBundleSku){
return undefined
}
quantity = Number(quantityStr)
if(!isBundleSku){
return undefined
}
// 更正为正确的站点 sku
const childSku = skuSplitArr.slice(0, skuSplitArr.length - 1).join('-')
// 重新获取匹配的商品
product = await this.productModel.findOne({
where: { siteSkus: Like(`%${childSku}%`) },
relations: ['components', 'attributes', 'attributes.dict'],
});
}
if (!product) {
throw new Error(`产品 ${siteProduct.sku} 不存在`);
}
return {
product,
quantity,
}
} }
// 准备创建产品的 DTO, 处理类型转换和默认值 // 准备创建产品的 DTO, 处理类型转换和默认值
@ -1715,7 +1743,7 @@ export class ProductService {
// 逐条处理记录 // 逐条处理记录
for (const rec of records) { for (const rec of records) {
try { try {
const data = this.transformCsvRecordToData(rec); const data = this.mapTableRecordToProduct(rec);
if (!data) { if (!data) {
errors.push({ identifier: data.sku, error: '缺少 SKU 的记录已跳过' }); errors.push({ identifier: data.sku, error: '缺少 SKU 的记录已跳过' });
continue; continue;
@ -1723,17 +1751,17 @@ export class ProductService {
const { sku } = data; const { sku } = data;
// 查找现有产品 // 查找现有产品
const exist = await this.productModel.findOne({ where: { sku }, relations: ['attributes', 'attributes.dict'] }); const exist = await this.productModel.findOne({ where: { sku } });
if (!exist) { if (!exist) {
// 创建新产品 // 创建新产品
const createDTO = this.prepareCreateProductDTO(data); // const createDTO = this.prepareCreateProductDTO(data);
await this.createProduct(createDTO); await this.createProduct(data as CreateProductDTO)
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, data);
updated += 1; updated += 1;
} }
} catch (e: any) { } catch (e: any) {
@ -2076,4 +2104,111 @@ export class ProductService {
return unifiedProduct; return unifiedProduct;
} }
/**
*
* @param brand
* @returns
*/
async getAllProducts(brand?: string): Promise<{ items: Product[], total: number }> {
const qb = this.productModel
.createQueryBuilder('product')
.leftJoinAndSelect('product.attributes', 'attribute')
.leftJoinAndSelect('attribute.dict', 'dict')
.leftJoinAndSelect('product.category', 'category');
// 按品牌过滤
if (brand) {
// 先获取品牌对应的字典项
const brandDict = await this.dictModel.findOne({ where: { name: 'brand' } });
if (brandDict) {
// 查找品牌名称对应的字典项(支持标题和名称匹配)
const brandItem = await this.dictItemModel.findOne({
where: [
{
title: brand,
dict: { id: brandDict.id }
},
{
name: brand,
dict: { id: brandDict.id }
}
]
});
if (brandItem) {
qb.andWhere(qb => {
const subQuery = qb
.subQuery()
.select('product_attributes_dict_item.productId')
.from('product_attributes_dict_item', 'product_attributes_dict_item')
.where('product_attributes_dict_item.dictItemId = :brandId', {
brandId: brandItem.id,
})
.getQuery();
return 'product.id IN ' + subQuery;
});
}
}
}
// 根据类型填充组成信息
const items = await qb.getMany();
for (const product of items) {
if (product.type === 'single') {
// 单品不持久化组成,这里仅返回一个基于 SKU 的虚拟组成
const component = new ProductStockComponent();
component.productId = product.id;
component.sku = product.sku;
component.quantity = 1;
product.components = [component];
} else {
// 混装商品返回持久化的 SKU 组成
product.components = await this.productStockComponentModel.find({
where: { productId: product.id },
});
}
// 确保属性按强度正确划分,只保留强度相关的属性
// 这里根据需求,如果需要可以进一步过滤或重组属性
}
return {
items,
total: items.length
};
}
/**
*
* @param brand
* @returns
*/
async getProductsGroupedByAttribute(brand?: string, attributeName: string = 'strength'): Promise<{ [key: string]: Product[] }> {
// 首先获取所有产品
const { items } = await this.getAllProducts(brand);
// 按指定属性分组
const groupedProducts: { [key: string]: Product[] } = {};
items.forEach(product => {
// 获取产品的指定属性值
const attribute = product.attributes.find(attr => attr.dict.name === attributeName);
if (attribute) {
const attributeValue = attribute.title || attribute.name;
if (!groupedProducts[attributeValue]) {
groupedProducts[attributeValue] = [];
}
groupedProducts[attributeValue].push(product);
} else {
// 如果没有该属性,放入未分组
if (!groupedProducts['未分组']) {
groupedProducts['未分组'] = [];
}
groupedProducts['未分组'].push(product);
}
});
return groupedProducts;
}
} }

View File