forked from yoone/API
1
0
Fork 0
API/src/service/product.service.ts

2222 lines
73 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Inject, Provide } from '@midwayjs/core';
import * as fs from 'fs';
import * as xlsx from 'xlsx';
import { In, Like, Not, Repository } from 'typeorm';
import { Product } from '../entity/product.entity';
import { PaginationParams } from '../interface';
import { paginate } from '../utils/paginate.util';
import { Context } from '@midwayjs/koa';
import { InjectEntityModel } from '@midwayjs/typeorm';
import {
BatchUpdateProductDTO,
CreateProductDTO,
ProductWhereFilter,
UpdateProductDTO
} from '../dto/product.dto';
import {
BrandPaginatedResponse,
FlavorsPaginatedResponse,
ProductPaginatedResponse,
SizePaginatedResponse,
StrengthPaginatedResponse,
} from '../dto/reponse.dto';
import { Dict } from '../entity/dict.entity';
import { DictItem } from '../entity/dict_item.entity';
import { ProductStockComponent } from '../entity/product_stock_component.entity';
import { Stock } from '../entity/stock.entity';
import { StockPoint } from '../entity/stock_point.entity';
import { StockService } from './stock.service';
import { TemplateService } from './template.service';
import { BatchErrorItem, BatchOperationResult, SyncOperationResultDTO, UnifiedSearchParamsDTO } from '../dto/api.dto';
import { UnifiedProductDTO } from '../dto/site-api.dto';
import { ProductSiteSkuDTO, SyncProductToSiteDTO } from '../dto/site-sync.dto';
import { Category } from '../entity/category.entity';
import { CategoryAttribute } from '../entity/category_attribute.entity';
import { SiteApiService } from './site-api.service';
@Provide()
export class ProductService {
@Inject()
ctx: Context;
@Inject()
templateService: TemplateService;
@Inject()
stockService: StockService;
@InjectEntityModel(Product)
productModel: Repository<Product>;
@InjectEntityModel(Dict)
dictModel: Repository<Dict>;
@InjectEntityModel(DictItem)
dictItemModel: Repository<DictItem>;
@InjectEntityModel(Stock)
stockModel: Repository<Stock>;
@InjectEntityModel(StockPoint)
stockPointModel: Repository<StockPoint>;
@InjectEntityModel(ProductStockComponent)
productStockComponentModel: Repository<ProductStockComponent>;
@InjectEntityModel(Category)
categoryModel: Repository<Category>;
@Inject()
siteApiService: SiteApiService;
// 获取所有分类
async getCategoriesAll(): Promise<Category[]> {
return this.categoryModel.find({
order: {
sort: 'ASC',
},
});
}
// 获取分类下的属性配置
async getCategoryAttributes(categoryId: number): Promise<any[]> {
const category = await this.categoryModel.findOne({
where: { id: categoryId },
relations: ['attributes', 'attributes.attributeDict', 'attributes.attributeDict.items'],
});
if (!category) {
return [];
}
// 格式化返回,匹配前端期望的数据结构
return category.attributes.map(attr => ({
id: attr.id,
dictId: attr.attributeDict.id,
name: attr.attributeDict.name,
title: attr.attributeDict.title,
items: attr.attributeDict.items, // 如果需要返回具体的选项
}));
}
// 创建分类
async createCategory(payload: Partial<Category>): Promise<Category> {
const exists = await this.categoryModel.findOne({ where: { name: payload.name } });
if (exists) {
throw new Error('分类已存在');
}
return this.categoryModel.save(payload);
}
// 更新分类
async updateCategory(id: number, payload: Partial<Category>): Promise<Category> {
const category = await this.categoryModel.findOne({ where: { id } });
if (!category) {
throw new Error('分类不存在');
}
await this.categoryModel.update(id, payload);
return this.categoryModel.findOne({ where: { id } });
}
// 删除分类
async deleteCategory(id: number): Promise<boolean> {
const result = await this.categoryModel.delete(id);
return result.affected > 0;
}
// 创建分类属性关联
async createCategoryAttribute(payload: { categoryId: number; dictId: number }): Promise<any> {
const category = await this.categoryModel.findOne({ where: { id: payload.categoryId } });
if (!category) {
throw new Error('分类不存在');
}
const dict = await this.dictModel.findOne({ where: { id: payload.dictId } });
if (!dict) {
throw new Error('字典不存在');
}
const existing = await this.categoryModel.manager.findOne(CategoryAttribute, {
where: {
category: { id: payload.categoryId },
attributeDict: { id: payload.dictId },
},
});
if (existing) {
throw new Error('该属性已关联到此分类');
}
const attr = this.categoryModel.manager.create(CategoryAttribute, {
category,
attributeDict: dict
});
return this.categoryModel.manager.save(attr);
}
// 删除分类属性关联
async deleteCategoryAttribute(id: number): Promise<boolean> {
const result = await this.categoryModel.manager.delete(CategoryAttribute, id);
return result.affected > 0;
}
// async findProductsByName(name: string): Promise<Product[]> {
// const where: any = {};
// const nameFilter = name ? name.split(' ').filter(Boolean) : [];
// if (nameFilter.length > 0) {
// const nameConditions = nameFilter.map(word => Like(`%${word}%`));
// where.name = And(...nameConditions);
// }
// if(name){
// where.nameCn = Like(`%${name}%`)
// }
// where.sku = Not(IsNull());
// // 查询 SKU 不为空且 name 包含关键字的产品,最多返回 50 条
// return this.productModel.find({
// where,
// take: 50,
// });
// }
async findProductsByName(name: string): Promise<Product[]> {
const nameFilter = name ? name.split(' ').filter(Boolean) : [];
const query = this.productModel.createQueryBuilder('product')
.leftJoinAndSelect('product.category', 'category');
// 保证 sku 不为空
query.where('product.sku IS NOT NULL');
if (nameFilter.length > 0 || name) {
const params: Record<string, string> = {};
const conditions: string[] = [];
// 英文名关键词全部匹配(AND)
if (nameFilter.length > 0) {
const nameConds = nameFilter.map((word, index) => {
const key = `name${index}`;
params[key] = `%${word}%`;
return `product.name LIKE :${key}`;
});
conditions.push(`(${nameConds.join(' AND ')})`);
}
// 中文名模糊匹配
if (name) {
params['nameCn'] = `%${name}%`;
conditions.push(`product.nameCn LIKE :nameCn`);
}
// 只有当 conditions 不为空时才添加 AND 条件
if (conditions.length > 0) {
// 英文名关键词匹配 OR 中文名匹配
query.andWhere(`(${conditions.join(' OR ')})`, params);
}
}
query.take(50);
return await query.getMany();
}
async findProductBySku(sku: string): Promise<Product> {
return this.productModel.findOne({
where: {
sku,
},
relations: ['category', 'attributes', 'attributes.dict']
});
}
async getProductList(query: UnifiedSearchParamsDTO<ProductWhereFilter>): Promise<ProductPaginatedResponse> {
const qb = this.productModel
.createQueryBuilder('product')
.leftJoinAndSelect('product.attributes', 'attribute')
.leftJoinAndSelect('attribute.dict', 'dict')
.leftJoinAndSelect('product.category', 'category');
// 处理分页参数(支持新旧两种格式)
const page = query.page || 1;
const pageSize = query.per_page || 10;
// 处理搜索参数
const name = query.where?.name || query.search || '';
// 处理品牌过滤
const brandId = query.where?.brandId;
const brandIds = query.where?.brandIds;
// 处理分类过滤
const categoryId = query.where?.categoryId;
const categoryIds = query.where?.categoryIds;
// 处理排序参数
const orderBy = query.orderBy;
// 模糊搜索 name,支持多个关键词
const nameFilter = name ? name.split(' ').filter(Boolean) : [];
if (nameFilter.length > 0) {
const nameConditions = nameFilter
.map((word, index) => `product.name LIKE :name${index}`)
.join(' AND ');
const nameParams = nameFilter.reduce(
(params, word, index) => ({ ...params, [`name${index}`]: `%${word}%` }),
{}
);
qb.where(`(${nameConditions})`, nameParams);
}
// 处理产品ID过滤
if (query.where?.id) {
qb.andWhere('product.id = :id', { id: query.where.id });
}
// 处理产品ID列表过滤
if (query.where?.ids && query.where.ids.length > 0) {
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过滤
if (query.where?.sku) {
qb.andWhere('product.sku = :sku', { sku: query.where.sku });
}
// 处理SKU列表过滤
if (query.where?.skus && query.where.skus.length > 0) {
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) {
qb.andWhere('product.nameCn LIKE :nameCn', { nameCn: `%${query.where.nameCn}%` });
}
// 处理产品类型过滤
if (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) {
qb.andWhere('product.price >= :minPrice', { minPrice: query.where.minPrice });
}
if (query.where?.maxPrice !== undefined) {
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) {
qb.andWhere('product.promotionPrice >= :minPromotionPrice', { minPromotionPrice: query.where.minPromotionPrice });
}
if (query.where?.maxPromotionPrice !== undefined) {
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) {
qb.andWhere('product.createdAt >= :createdAtStart', { createdAtStart: new Date(query.where.createdAtStart) });
}
if (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) {
qb.andWhere('product.updatedAt >= :updatedAtStart', { updatedAtStart: new Date(query.where.updatedAtStart) });
}
if (query.where?.updatedAtEnd) {
qb.andWhere('product.updatedAt <= :updatedAtEnd', { updatedAtEnd: new Date(query.where.updatedAtEnd) });
}
// 处理where对象中的更新时间范围过滤
if (query.where?.updatedAtStart) {
qb.andWhere('product.updatedAt >= :whereUpdatedAtStart', { whereUpdatedAtStart: new Date(query.where.updatedAtStart) });
}
if (query.where?.updatedAtEnd) {
qb.andWhere('product.updatedAt <= :whereUpdatedAtEnd', { whereUpdatedAtEnd: new Date(query.where.updatedAtEnd) });
}
// 处理属性过滤
const attributeFilters = query.where?.attributes || {};
Object.entries(attributeFilters).forEach(([attributeName, value], index) => {
if (value === 'hasValue') {
// 如果值为'hasValue',则过滤出具有该属性的产品
qb.andWhere(qb => {
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) {
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,
})
.getQuery();
return 'product.id IN ' + subQuery;
});
}
// 处理品牌ID列表过滤
if (brandIds && brandIds.length > 0) {
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 IN (:...brandIds)', {
brandIds,
})
.getQuery();
return 'product.id IN ' + subQuery;
});
}
// 分类过滤(向后兼容)
if (categoryId) {
qb.andWhere('product.categoryId = :categoryId', { categoryId });
}
// 处理分类ID列表过滤
if (categoryIds && categoryIds.length > 0) {
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 (typeof orderBy === 'string') {
// 如果orderBy是字符串尝试解析JSON
try {
const orderByObj = JSON.parse(orderBy);
Object.keys(orderByObj).forEach(key => {
const order = orderByObj[key].toUpperCase();
const allowedSortFields = ['price', 'promotionPrice', 'createdAt', 'updatedAt', 'sku', 'name'];
if (allowedSortFields.includes(key)) {
qb.addOrderBy(`product.${key}`, order as 'ASC' | 'DESC');
}
});
} catch (e) {
// 解析失败,使用默认排序
qb.orderBy('product.createdAt', 'DESC');
}
} else if (typeof orderBy === 'object') {
// 如果orderBy是对象直接使用
Object.keys(orderBy).forEach(key => {
const order = orderBy[key].toUpperCase();
const allowedSortFields = ['price', 'promotionPrice', 'createdAt', 'updatedAt', 'sku', 'name'];
if (allowedSortFields.includes(key)) {
qb.addOrderBy(`product.${key}`, order as 'ASC' | 'DESC');
}
});
}
} else {
qb.orderBy('product.createdAt', 'DESC');
}
// 分页
qb.skip((page - 1) * pageSize).take(pageSize);
const [items, total] = await qb.getManyAndCount();
// 根据类型填充组成信息
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,
current: page,
pageSize,
};
}
async getOrCreateAttribute(
dictName: string,
itemTitle: string,
itemName?: string
): Promise<DictItem> {
// 查找字典
const dict = await this.dictModel.findOne({ where: { name: dictName } });
if (!dict) {
throw new Error(`字典 '${dictName}' 不存在`);
}
const nameForLookup = itemName || itemTitle;
// 查找字典项
let item = await this.dictItemModel.findOne({
where: { name: nameForLookup, dict: { id: dict.id } },
});
// 如果字典项不存在,则创建
if (!item) {
item = new DictItem();
item.title = itemTitle;
item.name = nameForLookup;
item.dict = dict;
await this.dictItemModel.save(item);
}
return item;
}
async createProduct(createProductDTO: CreateProductDTO): Promise<Product> {
const { attributes, sku, categoryId, type } = createProductDTO;
// 条件判断(校验属性输入)
// 当产品类型为 'bundle' 时attributes 可以为空
// 当产品类型为 'single' 时attributes 必须提供且不能为空
if (type === 'single') {
if (!Array.isArray(attributes) || attributes.length === 0) {
throw new Error('单品类型的属性列表不能为空');
}
}
const safeAttributes = attributes || [];
// 解析属性输入(按 id 或 dictName 创建/关联字典项)
const resolvedAttributes: DictItem[] = [];
let categoryItem: Category | null = null;
// 如果提供了 categoryId,设置分类
if (categoryId) {
categoryItem = await this.categoryModel.findOne({
where: { id: categoryId },
relations: ['attributes', 'attributes.attributeDict']
});
if (!categoryItem) throw new Error(`分类 ID ${categoryId} 不存在`);
}
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;
if (attr.id) {
// 如果传入了 id,直接查找字典项并使用,不强制要求 dictName
item = await this.dictItemModel.findOne({ where: { id: attr.id }, relations: ['dict'] });
if (!item) throw new Error(`字典项 ID ${attr.id} 不存在`);
} else {
// 当未提供 id 时,需要 dictName 与 title/name 信息创建或获取字典项
if (!attr?.dictName) throw new Error('属性项缺少字典名称');
const titleOrName = attr.title || attr.name;
if (!titleOrName) throw new Error('新建字典项需要提供 title 或 name');
item = await this.getOrCreateAttribute(attr.dictName, titleOrName, attr.name);
}
resolvedAttributes.push(item);
}
// 检查完全相同属性组合是否已存在(避免重复)
// 仅当产品类型为 'single' 且有属性时才检查重复
if (type === 'single' && resolvedAttributes.length > 0) {
const qb = this.productModel.createQueryBuilder('product');
resolvedAttributes.forEach((attr, index) => {
qb.innerJoin(
'product.attributes',
`attr${index}`,
`attr${index}.id = :attrId${index}`,
{ [`attrId${index}`]: attr.id }
);
});
const isExist = await qb.getOne();
if (isExist) throw new Error('相同产品属性的产品已存在');
}
// 创建新产品实例(绑定属性与基础字段)
const product = new Product();
// 使用 merge 填充基础字段,排除特殊处理字段
const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, ...simpleFields } = createProductDTO;
this.productModel.merge(product, simpleFields);
product.attributes = resolvedAttributes;
if (categoryItem) {
product.category = categoryItem;
}
// 确保默认类型
if (!product.type) product.type = 'single';
// 生成或设置 SKU(基于属性字典项的 name 生成)
if (sku) {
product.sku = sku;
} else {
product.sku = await this.templateService.render('product.sku', { product });
}
const savedProduct = await this.productModel.save(product);
// 保存组件信息
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(
id: number,
updateProductDTO: UpdateProductDTO
): Promise<Product> {
// 检查产品是否存在(包含属性关系)
const product = await this.productModel.findOne({ where: { id }, relations: ['attributes', 'attributes.dict', 'category'] });
if (!product) {
throw new Error(`产品 ID ${id} 不存在`);
}
// 使用 merge 更新基础字段,排除特殊处理字段
const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, ...simpleFields } = updateProductDTO;
this.productModel.merge(product, simpleFields);
// 处理分类更新
if (updateProductDTO.categoryId !== undefined) {
if (updateProductDTO.categoryId) {
const categoryItem = await this.categoryModel.findOne({ where: { id: updateProductDTO.categoryId } });
if (!categoryItem) throw new Error(`分类 ID ${updateProductDTO.categoryId} 不存在`);
product.category = categoryItem;
} else {
// 如果传了 0 或 null,可以清除分类(根据需求)
// product.category = null;
}
}
// 处理 SKU 更新
if (updateProductDTO.sku !== undefined) {
// 校验 SKU 唯一性(如变更)
const newSku = updateProductDTO.sku;
if (newSku && newSku !== product.sku) {
const exist = await this.productModel.findOne({ where: { sku: newSku } });
if (exist) {
throw new Error('SKU 已存在,请更换后重试');
}
product.sku = newSku;
}
}
// 处理属性更新(若传入 attributes 则按字典名称替换对应项)
if (Array.isArray(updateProductDTO.attributes) && updateProductDTO.attributes.length > 0) {
const nextAttributes: DictItem[] = [...(product.attributes || [])];
const replaceAttr = (dictName: string, item: DictItem) => {
const idx = nextAttributes.findIndex(a => a.dict?.name === dictName);
if (idx >= 0) nextAttributes[idx] = item; else nextAttributes.push(item);
};
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;
if (attr.id) {
// 当提供 id 时直接查询字典项,不强制要求 dictName
item = await this.dictItemModel.findOne({ where: { id: attr.id }, relations: ['dict'] });
if (!item) throw new Error(`字典项 ID ${attr.id} 不存在`);
} else {
// 未提供 id 则需要 dictName 与 title/name 信息
if (!attr?.dictName) throw new Error('属性项缺少字典名称');
const titleOrName = attr.title || attr.name;
if (!titleOrName) throw new Error('新建字典项需要提供 title 或 name');
item = await this.getOrCreateAttribute(attr.dictName, titleOrName, attr.name);
}
// 以传入的 dictName 或查询到的 item.dict.name 作为替换键
const dictKey = attr.dictName || item?.dict?.name;
if (!dictKey) throw new Error('无法确定字典名称用于替换属性');
replaceAttr(dictKey, item);
}
product.attributes = nextAttributes;
}
// 条件判断(更新商品类型,如传入)
if (updateProductDTO.type !== undefined) {
product.type = updateProductDTO.type as any;
}
// 保存更新后的产品
const saved = await this.productModel.save(product);
// 处理组件更新
if (updateProductDTO.components !== undefined) {
// 如果 components 为空数组,则删除所有组件? setProductComponents 会处理
await this.setProductComponents(saved.id, updateProductDTO.components);
}
return saved;
}
async batchUpdateProduct(
batchUpdateProductDTO: BatchUpdateProductDTO
): Promise<boolean> {
const { ids, ...updateData } = batchUpdateProductDTO;
if (!ids || ids.length === 0) {
throw new Error('未选择任何产品');
}
// 检查 updateData 中是否有复杂字段 (attributes, categoryId, type, sku)
// 如果包含复杂字段,需要复用 updateProduct 的逻辑
const hasComplexFields =
updateData.attributes !== undefined ||
updateData.categoryId !== undefined ||
updateData.type !== undefined ||
updateData.sku !== undefined;
if (hasComplexFields) {
// 循环调用 updateProduct
for (const id of ids) {
const updateDTO = new UpdateProductDTO();
// 复制属性
Object.assign(updateDTO, updateData);
await this.updateProduct(id, updateDTO);
}
} else {
// 简单字段,直接批量更新以提高性能
// UpdateProductDTO 里的简单字段: name, nameCn, description, shortDescription, price, promotionPrice, image, siteSkus
const simpleUpdate: any = {};
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;
if (updateData.image !== undefined) simpleUpdate.image = updateData.image;
if (updateData.siteSkus !== undefined) simpleUpdate.siteSkus = updateData.siteSkus;
if (Object.keys(simpleUpdate).length > 0) {
await this.productModel.update({ id: In(ids) }, simpleUpdate);
}
}
return true;
}
async batchDeleteProduct(ids: number[]): Promise<{ success: number; failed: number; errors: string[] }> {
if (!ids || ids.length === 0) {
throw new Error('未选择任何产品');
}
let success = 0;
let failed = 0;
const errors: string[] = [];
for (const id of ids) {
try {
await this.deleteProduct(id);
success++;
} catch (error) {
failed++;
errors.push(`ID ${id}: ${error.message}`);
}
}
return { success, failed, errors };
}
// 获取产品的库存组成列表(表关联版本)
async getProductComponents(productId: number): Promise<any[]> {
// 条件判断:确保产品存在
const product = await this.productModel.findOne({ where: { id: productId } });
if (!product) throw new Error(`产品 ID ${productId} 不存在`);
let components: ProductStockComponent[] = [];
// 条件判断(单品 simple 不持久化组成,按 SKU 动态返回单条组成)
if (product.type === 'single') {
const comp = new ProductStockComponent();
comp.productId = productId;
comp.sku = product.sku;
comp.quantity = 1;
components = [comp];
} else {
// 混装 bundle:返回已保存的 SKU 组成
components = await this.productStockComponentModel.find({ where: { productId } });
}
// 获取所有组件的 SKU 列表
const skus = components.map(c => c.sku);
if (skus.length === 0) {
return components;
}
// 查询这些 SKU 的库存信息
const stocks = await this.stockModel.find({
where: { sku: In(skus) },
});
// 获取所有相关的库存点 ID
const stockPointIds = [...new Set(stocks.map(s => s.stockPointId))];
const stockPoints = await this.stockPointModel.find({ where: { id: In(stockPointIds) } });
const stockPointMap = stockPoints.reduce((map, sp) => {
map[sp.id] = sp;
return map;
}, {});
// 将库存信息按 SKU 分组
const stockMap = stocks.reduce((map, stock) => {
if (!map[stock.sku]) {
map[stock.sku] = [];
}
const stockPoint = stockPointMap[stock.stockPointId];
if (stockPoint) {
map[stock.sku].push({
name: stockPoint.name,
quantity: stock.quantity,
});
}
return map;
}, {});
// 将库存信息附加到组件上
const componentsWithStock = components.map(comp => {
return {
...comp,
stock: stockMap[comp.sku] || [],
};
});
return componentsWithStock;
}
// 设置产品的库存组成(覆盖式,表关联版本)
async setProductComponents(
productId: number,
items: { sku: string; quantity: number }[]
): Promise<ProductStockComponent[]> {
// 条件判断:确保产品存在
const product = await this.productModel.findOne({ where: { id: productId } });
if (!product) throw new Error(`产品 ID ${productId} 不存在`);
// 条件判断(单品 simple 不允许手动设置组成)
if (product.type === 'single') {
// 单品类型,直接清空关联的组成(如果有)
await this.productStockComponentModel.delete({ productId });
return [];
}
const validItems = (items || [])
.filter(i => i && i.sku && i.quantity && i.quantity > 0)
.map(i => ({ sku: String(i.sku), quantity: Number(i.quantity) }));
// 删除旧的组成
await this.productStockComponentModel.delete({ productId });
// 插入新的组成
const created: ProductStockComponent[] = [];
for (const i of validItems) {
// 校验 SKU 格式,允许不存在库存但必须非空
if (!i.sku || i.sku.trim().length === 0) {
throw new Error('SKU 不能为空');
}
const comp = new ProductStockComponent();
comp.productId = productId;
comp.sku = i.sku;
comp.quantity = i.quantity;
created.push(await this.productStockComponentModel.save(comp));
}
return created;
}
// 根据 SKU 自动绑定产品的库存组成(匹配所有相同 SKU 的库存,默认数量 1)
async autoBindComponentsBySku(productId: number): Promise<ProductStockComponent[]> {
// 条件判断:确保产品存在
const product = await this.productModel.findOne({ where: { id: productId } });
if (!product) throw new Error(`产品 ID ${productId} 不存在`);
// 按 SKU 自动绑定
// 条件判断:simple 类型不持久化组成,直接返回单条基于 SKU 的组成
if (product.type === 'single') {
const comp = new ProductStockComponent();
comp.productId = productId;
comp.sku = product.sku;
comp.quantity = 1; // 默认数量 1
return [comp];
}
// bundle 类型:若不存在则持久化一条基于 SKU 的组成
const exist = await this.productStockComponentModel.findOne({ where: { productId, sku: product.sku } });
if (!exist) {
const comp = new ProductStockComponent();
comp.productId = productId;
comp.sku = product.sku;
comp.quantity = 1;
await this.productStockComponentModel.save(comp);
}
return await this.getProductComponents(productId);
}
// 重复定义的 getProductList 已合并到前面的实现(移除重复)
async updatenameCn(id: number, nameCn: string): Promise<Product> {
// 确认产品是否存在
const product = await this.productModel.findOneBy({ id });
if (!product) {
throw new Error(`产品 ID ${id} 不存在`);
}
// 更新产品
await this.productModel.update(id, { nameCn });
// 返回更新后的产品
return await this.productModel.findOneBy({ id });
}
async deleteProduct(id: number): Promise<boolean> {
// 检查产品是否存在
const product = await this.productModel.findOneBy({ id });
if (!product) {
throw new Error(`产品 ID ${id} 不存在`);
}
// 不再阻塞于远端站点商品/变体的存在,删除仅按本地引用保护
// 删除产品
const result = await this.productModel.delete(id);
return result.affected > 0; // `affected` 表示删除的行数
}
async hasAttribute(
dictName: string,
title: string,
id?: number
): Promise<boolean> {
const dict = await this.dictModel.findOne({
where: { name: dictName },
});
if (!dict) {
return false;
}
const where: any = { title, dict: { id: dict.id } };
if (id) where.id = Not(id);
const count = await this.dictItemModel.count({
where,
});
return count > 0;
}
async hasProductsInAttribute(attributeId: number): Promise<boolean> {
const count = await this.productModel
.createQueryBuilder('product')
.innerJoin('product.attributes', 'attribute')
.where('attribute.id = :attributeId', { attributeId })
.getCount();
return count > 0;
}
async getBrandList(
pagination: PaginationParams,
title?: string
): Promise<BrandPaginatedResponse> {
// 查找 'brand' 字典
const brandDict = await this.dictModel.findOne({
where: { name: 'brand' },
});
// 如果字典不存在,则返回空
if (!brandDict) {
return {
items: [],
total: 0,
...pagination,
};
}
// 设置查询条件
const where: any = { dict: { id: brandDict.id } };
if (title) {
where.title = Like(`%${title}%`);
}
// 分页查询
return await paginate(this.dictItemModel, { pagination, where });
}
async getBrandAll(): Promise<BrandPaginatedResponse> {
// 查找 'brand' 字典
const brandDict = await this.dictModel.findOne({
where: { name: 'brand' },
});
// 如果字典不存在,则返回空数组
if (!brandDict) {
return [];
}
// 返回所有品牌
return this.dictItemModel.find({ where: { dict: { id: brandDict.id } } });
}
async createBrand(createBrandDTO: any): Promise<DictItem> {
const { title, name } = createBrandDTO;
// 查找 'brand' 字典
const brandDict = await this.dictModel.findOne({
where: { name: 'brand' },
});
// 如果字典不存在,则抛出错误
if (!brandDict) {
throw new Error('品牌字典不存在');
}
// 创建新的品牌实例
const brand = new DictItem();
brand.title = title;
brand.name = name;
brand.dict = brandDict;
// 保存到数据库
return await this.dictItemModel.save(brand);
}
async updateBrand(id: number, updateBrand: any) {
// 确认品牌是否存在
const brand = await this.dictItemModel.findOneBy({ id });
if (!brand) {
throw new Error(`品牌 ID ${id} 不存在`);
}
// 更新品牌
await this.dictItemModel.update(id, updateBrand);
// 返回更新后的品牌
return await this.dictItemModel.findOneBy({ id });
}
async deleteBrand(id: number): Promise<boolean> {
// 检查品牌是否存在
const brand = await this.dictItemModel.findOneBy({ id });
if (!brand) {
throw new Error(`品牌 ID ${id} 不存在`);
}
// 删除品牌
const result = await this.dictItemModel.delete(id);
return result.affected > 0; // `affected` 表示删除的行数
}
async getFlavorsList(
pagination: PaginationParams,
title?: string
): Promise<FlavorsPaginatedResponse> {
const flavorsDict = await this.dictModel.findOne({
where: { name: 'flavor' },
});
if (!flavorsDict) {
return {
items: [],
total: 0,
...pagination,
};
}
const where: any = { dict: { id: flavorsDict.id } };
if (title) {
where.title = Like(`%${title}%`);
}
return await paginate(this.dictItemModel, { pagination, where });
}
async getFlavorsAll(): Promise<FlavorsPaginatedResponse> {
const flavorsDict = await this.dictModel.findOne({
where: { name: 'flavor' },
});
if (!flavorsDict) {
return [];
}
return this.dictItemModel.find({ where: { dict: { id: flavorsDict.id } } });
}
async createFlavors(createFlavorsDTO: any): Promise<DictItem> {
const { title, name } = createFlavorsDTO;
const flavorsDict = await this.dictModel.findOne({
where: { name: 'flavor' },
});
if (!flavorsDict) {
throw new Error('口味字典不存在');
}
const flavors = new DictItem();
flavors.title = title;
flavors.name = name;
flavors.dict = flavorsDict;
return await this.dictItemModel.save(flavors);
}
async updateFlavors(id: number, updateFlavors: any) {
const flavors = await this.dictItemModel.findOneBy({ id });
if (!flavors) {
throw new Error(`口味 ID ${id} 不存在`);
}
await this.dictItemModel.update(id, updateFlavors);
return await this.dictItemModel.findOneBy({ id });
}
async deleteFlavors(id: number): Promise<boolean> {
const flavors = await this.dictItemModel.findOneBy({ id });
if (!flavors) {
throw new Error(`口味 ID ${id} 不存在`);
}
const result = await this.dictItemModel.delete(id);
return result.affected > 0;
}
// size 尺寸相关方法
async getSizeList(
pagination: PaginationParams,
title?: string
): Promise<SizePaginatedResponse> {
// 查找 'size' 字典(用于尺寸)
const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } });
// 条件判断(如果字典不存在则返回空分页)
if (!sizeDict) {
return {
items: [],
total: 0,
...pagination,
} as any;
}
// 构建 where 条件(按标题模糊搜索)
const where: any = { dict: { id: sizeDict.id } };
if (title) {
where.title = Like(`%${title}%`);
}
// 分页查询(复用通用分页工具)
return await paginate(this.dictItemModel, { pagination, where });
}
async getSizeAll(): Promise<SizePaginatedResponse> {
// 查找 'size' 字典(获取所有尺寸项)
const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } });
// 条件判断(如果字典不存在返回空数组)
if (!sizeDict) {
return [] as any;
}
return this.dictItemModel.find({ where: { dict: { id: sizeDict.id } } }) as any;
}
async createSize(createSizeDTO: any): Promise<DictItem> {
const { title, name } = createSizeDTO;
// 获取 size 字典(用于挂载尺寸项)
const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } });
// 条件判断(尺寸字典不存在则抛错)
if (!sizeDict) {
throw new Error('尺寸字典不存在');
}
// 创建字典项(保存尺寸名称与唯一标识)
const size = new DictItem();
size.title = title;
size.name = name;
size.dict = sizeDict;
return await this.dictItemModel.save(size);
}
async updateSize(id: number, updateSize: any) {
// 先查询(确保尺寸项存在)
const size = await this.dictItemModel.findOneBy({ id });
// 条件判断(不存在则报错)
if (!size) {
throw new Error(`尺寸 ID ${id} 不存在`);
}
// 更新(写入变更字段)
await this.dictItemModel.update(id, updateSize);
// 返回最新(再次查询返回)
return await this.dictItemModel.findOneBy({ id });
}
async deleteSize(id: number): Promise<boolean> {
// 先查询(确保尺寸项存在)
const size = await this.dictItemModel.findOneBy({ id });
// 条件判断(不存在则报错)
if (!size) {
throw new Error(`尺寸 ID ${id} 不存在`);
}
// 删除(执行删除并返回受影响行数是否>0)
const result = await this.dictItemModel.delete(id);
return result.affected > 0;
}
async hasStrength(title: string, id?: string): Promise<boolean> {
const strengthDict = await this.dictModel.findOne({
where: { name: 'strength' },
});
if (!strengthDict) {
return false;
}
const where: any = { title, dict: { id: strengthDict.id } };
if (id) where.id = Not(id);
const count = await this.dictItemModel.count({
where,
});
return count > 0;
}
async getStrengthList(
pagination: PaginationParams,
title?: string
): Promise<StrengthPaginatedResponse> {
const strengthDict = await this.dictModel.findOne({
where: { name: 'strength' },
});
if (!strengthDict) {
return {
items: [],
total: 0,
...pagination,
};
}
const where: any = { dict: { id: strengthDict.id } };
if (title) {
where.title = Like(`%${title}%`);
}
return await paginate(this.dictItemModel, { pagination, where });
}
async getStrengthAll(): Promise<StrengthPaginatedResponse> {
const strengthDict = await this.dictModel.findOne({
where: { name: 'strength' },
});
if (!strengthDict) {
return [];
}
return this.dictItemModel.find({ where: { dict: { id: strengthDict.id } } });
}
async createStrength(createStrengthDTO: any): Promise<DictItem> {
const { title, name } = createStrengthDTO;
const strengthDict = await this.dictModel.findOne({
where: { name: 'strength' },
});
if (!strengthDict) {
throw new Error('规格字典不存在');
}
const strength = new DictItem();
strength.title = title;
strength.name = name;
strength.dict = strengthDict;
return await this.dictItemModel.save(strength);
}
// 通用属性:分页获取指定字典的字典项
async getAttributeList(
dictName: string,
pagination: PaginationParams,
name?: string
): Promise<BrandPaginatedResponse> {
const dict = await this.dictModel.findOne({ where: { name: dictName } });
if (!dict) return { items: [], total: 0, ...pagination } as any;
const where: any = { dict: { id: dict.id } };
if (name) where.title = Like(`%${name}%`);
const [items, total] = await this.dictItemModel.findAndCount({
where,
skip: (pagination.current - 1) * pagination.pageSize,
take: pagination.pageSize,
order: { sort: 'ASC', id: 'DESC' },
relations: ['dict'],
});
return { items, total, ...pagination } as any;
}
// 通用属性:获取指定字典的全部字典项
async getAttributeAll(dictName: string): Promise<DictItem[]> {
const dict = await this.dictModel.findOne({ where: { name: dictName } });
if (!dict) return [];
return this.dictItemModel.find({
where: { dict: { id: dict.id } },
order: { sort: 'ASC', id: 'DESC' },
relations: ['dict'],
});
}
// 通用属性:创建字典项
async createAttribute(
dictName: string,
payload: { title: string; name: string; image?: string; shortName?: string }
): Promise<DictItem> {
const dict = await this.dictModel.findOne({ where: { name: dictName } });
if (!dict) throw new Error(`字典 ${dictName} 不存在`);
const exists = await this.dictItemModel.findOne({
where: { name: payload.name, dict: { id: dict.id } },
relations: ['dict'],
});
if (exists) throw new Error('字典项已存在');
const item = new DictItem();
item.title = payload.title;
item.name = payload.name;
item.image = payload.image;
item.shortName = payload.shortName;
item.dict = dict;
return await this.dictItemModel.save(item);
}
// 通用属性:更新字典项
async updateAttribute(
id: number,
payload: { title?: string; name?: string; image?: string; shortName?: string }
): Promise<DictItem> {
const item = await this.dictItemModel.findOne({ where: { id } });
if (!item) throw new Error('字典项不存在');
if (payload.title !== undefined) item.title = payload.title;
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);
}
// 通用属性:删除字典项(若存在产品关联则禁止删除)
async deleteAttribute(id: number): Promise<void> {
const hasProducts = await this.hasProductsInAttribute(id);
if (hasProducts) throw new Error('当前字典项存在关联产品,无法删除');
await this.dictItemModel.delete({ id });
}
async updateStrength(id: number, updateStrength: any) {
const strength = await this.dictItemModel.findOneBy({ id });
if (!strength) {
throw new Error(`规格 ID ${id} 不存在`);
}
await this.dictItemModel.update(id, updateStrength);
return await this.dictItemModel.findOneBy({ id });
}
async deleteStrength(id: number): Promise<boolean> {
const strength = await this.dictItemModel.findOneBy({ id });
if (!strength) {
throw new Error(`规格 ID ${id} 不存在`);
}
const result = await this.dictItemModel.delete(id);
return result.affected > 0;
}
async batchSetSku(skus: { productId: number; sku: string }[]) {
// 提取所有 sku
const skuList = skus.map(item => item.sku);
// 检查是否存在重复 sku
const existingProducts = await this.productModel.find({
where: { sku: In(skuList) },
});
if (existingProducts.length > 0) {
const existingSkus = existingProducts.map(product => product.sku);
throw new Error(`以下 SKU 已存在: ${existingSkus.join(', ')}`);
}
// 遍历检查产品 ID 是否存在,并更新 sku
for (const { productId, sku } of skus) {
const product = await this.productModel.findOne({
where: { id: productId },
});
if (!product) {
throw new Error(`产品 ID '${productId}' 不存在`);
}
product.sku = sku;
await this.productModel.save(product);
}
return `成功更新 ${skus.length} 个 sku`;
}
// 将单条 CSV 记录转换为数据对象
transformCsvRecordToData(rec: any): CreateProductDTO & { sku: string } | null {
// 必须包含 sku
const sku: string = (rec.sku || '').trim();
if (!sku) {
return null;
}
// 辅助函数:处理空字符串为 undefined
const val = (v: any) => {
if (v === undefined || v === null) return undefined;
const s = String(v).trim();
return s === '' ? undefined : s;
};
// 辅助函数:处理数字
const num = (v: any) => {
const s = val(v);
return s ? Number(s) : undefined;
};
// 解析属性字段(分号分隔多值)
const parseList = (v: string) => (v ? String(v).split(';').map(s => s.trim()).filter(Boolean) : []);
// 将属性解析为 DTO 输入
const attributes: any[] = [];
// 处理动态属性字段 (attribute_*)
for (const key of Object.keys(rec)) {
if (key.startsWith('attribute_')) {
const dictName = key.replace('attribute_', '');
if (dictName) {
const list = parseList(rec[key]);
for (const item of list) attributes.push({ dictName, title: item });
}
}
}
// 处理分类字段
const category = val(rec.category);
return {
sku,
name: val(rec.name),
nameCn: val(rec.nameCn),
description: val(rec.description),
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,
category, // 添加分类字段
attributes: attributes.length > 0 ? attributes : undefined,
} as any;
}
// 准备创建产品的 DTO, 处理类型转换和默认值
prepareCreateProductDTO(data: any): CreateProductDTO {
const dto = new CreateProductDTO();
// 基础字段赋值
dto.name = data.name;
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);
if (data.promotionPrice !== undefined) dto.promotionPrice = Number(data.promotionPrice);
// 处理分类字段
if (data.categoryId !== undefined) {
dto.categoryId = Number(data.categoryId);
} else if (data.category) {
// 如果是字符串需要后续在createProduct中处理
dto.attributes = [...(dto.attributes || []), { dictName: 'category', title: data.category }];
}
// 默认值和特殊处理
dto.attributes = Array.isArray(data.attributes) ? data.attributes : [];
// 如果有组件信息,透传
dto.type = data.type || 'single';
return dto;
}
// 准备更新产品的 DTO, 处理类型转换
prepareUpdateProductDTO(data: any): UpdateProductDTO {
const dto = new UpdateProductDTO();
if (data.name !== undefined) dto.name = data.name;
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);
// 处理分类字段
if (data.categoryId !== undefined) {
dto.categoryId = Number(data.categoryId);
} else if (data.category) {
// 如果是字符串需要后续在updateProduct中处理
dto.attributes = [...(dto.attributes || []), { dictName: 'category', title: data.category }];
}
if (data.type !== undefined) dto.type = data.type;
if (data.attributes !== undefined) dto.attributes = data.attributes;
if (data.components !== undefined) dto.components = data.components;
return dto;
}
getAttributesObject(attributes: DictItem[]) {
if (!attributes) return {}
const obj: any = {}
attributes.forEach(attr => {
obj[attr.dict.name] = attr
})
return obj
}
// 将单个产品转换为 CSV 行数组
transformProductToCsvRow(
p: Product,
sortedDictNames: string[],
maxComponentCount: number
): string[] {
// CSV 字段转义,处理逗号与双引号
const esc = (v: any) => {
const s = v === undefined || v === null ? '' : String(v);
const needsQuote = /[",\n]/.test(s);
const escaped = s.replace(/"/g, '""');
return needsQuote ? `"${escaped}"` : escaped;
};
// 将属性列表转为字典名到显示值的映射
const pickAttr = (prod: Product, key: string) => {
const list = (prod.attributes || []).filter(a => a?.dict?.name === key);
if (list.length === 0) return '';
// 多个值使用分号分隔
return list.map(a => a.title || a.name).join(';');
};
// 基础数据
const rowData = [
esc(p.sku),
esc(p.siteSkus ? p.siteSkus.join(',') : ''),
esc(p.name),
esc(p.nameCn),
esc(p.price),
esc(p.promotionPrice),
esc(p.type),
esc(p.description),
esc(p.category ? p.category.name || p.category.title : ''), // 添加分类字段
];
// 属性数据
for (const dictName of sortedDictNames) {
rowData.push(esc(pickAttr(p, dictName)));
}
// 组件数据
const components = p.components || [];
for (let i = 0; i < maxComponentCount; i++) {
const comp = components[i];
if (comp) {
rowData.push(esc(comp.sku));
rowData.push(esc(comp.quantity));
} else {
rowData.push('');
rowData.push('');
}
}
return rowData;
}
// 导出所有产品为 CSV 文本
async exportProductsCSV(): Promise<string> {
// 查询所有产品及其属性(包含字典关系)、组成和分类
const products = await this.productModel.find({
relations: ['attributes', 'attributes.dict', 'components', 'category'],
order: { id: 'ASC' },
});
// 1. 收集所有动态属性的 dictName
const dictNames = new Set<string>();
// 2. 收集最大的组件数量
let maxComponentCount = 0;
for (const p of products) {
if (p.attributes) {
for (const attr of p.attributes) {
if (attr.dict && attr.dict.name) {
dictNames.add(attr.dict.name);
}
}
}
if (p.components) {
if (p.components.length > maxComponentCount) {
maxComponentCount = p.components.length;
}
}
}
const sortedDictNames = Array.from(dictNames).sort();
// 定义 CSV 表头(与导入字段一致)
const baseHeaders = [
'sku',
'siteSkus',
'name',
'nameCn',
'price',
'promotionPrice',
'type',
'description',
'category',
];
// 动态属性表头
const attributeHeaders = sortedDictNames.map(name => `attribute_${name}`);
// 动态组件表头
const componentHeaders = [];
for (let i = 1; i <= maxComponentCount; i++) {
componentHeaders.push(`component_${i}_sku`);
componentHeaders.push(`component_${i}_quantity`);
}
const allHeaders = [...baseHeaders, ...attributeHeaders, ...componentHeaders];
const rows: string[] = [];
rows.push(allHeaders.join(','));
for (const p of products) {
const rowData = this.transformProductToCsvRow(p, sortedDictNames, maxComponentCount);
rows.push(rowData.join(','));
}
// 添加UTF-8 BOM以确保中文在Excel中正确显示
return '\ufeff' + rows.join('\n');
}
async getRecordsFromTable(file: any) {
// 解析文件(使用 xlsx 包自动识别文件类型并解析)
try {
let buffer: 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('无效的文件输入');
}
let records: any[] = []
// xlsx 包会自动根据文件内容识别文件类型(CSV 或 XLSX)
// 添加codepage: 65001以确保正确处理UTF-8编码的中文
const workbook = xlsx.read(buffer, { type: 'buffer', codepage: 65001 });
// 获取第一个工作表
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
// 将工作表转换为 JSON 数组
records = xlsx.utils.sheet_to_json(worksheet);
console.log('Parsed records count:', records.length);
if (records.length > 0) {
console.log('First record keys:', Object.keys(records[0]));
}
return records;
} catch (e: any) {
throw new Error(`文件解析失败:${e?.message || e}`);
}
}
// 从 CSV 导入产品;存在则更新,不存在则创建
async importProductsFromTable(file: any): Promise<BatchOperationResult> {
let created = 0;
let updated = 0;
const errors: BatchErrorItem[] = [];
const records = await this.getRecordsFromTable(file);
// 逐条处理记录
for (const rec of records) {
try {
const data = this.transformCsvRecordToData(rec);
if (!data) {
errors.push({ identifier: data.sku, error: '缺少 SKU 的记录已跳过' });
continue;
}
const { sku } = data;
// 查找现有产品
const exist = await this.productModel.findOne({ where: { sku }, relations: ['attributes', 'attributes.dict'] });
if (!exist) {
// 创建新产品
const createDTO = this.prepareCreateProductDTO(data);
await this.createProduct(createDTO);
created += 1;
} else {
// 更新产品
const updateDTO = this.prepareUpdateProductDTO(data);
await this.updateProduct(exist.id, updateDTO);
updated += 1;
}
} catch (e: any) {
errors.push({ identifier: '' + rec.sku, error: `产品${rec?.sku}导入失败:${e?.message || String(e)}` });
}
}
return { total: records.length, processed: records.length - errors.length, created, updated, errors };
}
// 将库存记录的 sku 添加到产品单品中
async syncStockToProduct(): Promise<{ added: number; errors: string[] }> {
// 1. 获取所有库存记录的 SKU (去重)
const stockSkus = await this.stockModel
.createQueryBuilder('stock')
.select('DISTINCT(stock.sku)', 'sku')
.getRawMany();
const skus = stockSkus.map(s => s.sku).filter(Boolean);
let added = 0;
const errors: string[] = [];
// 2. 遍历 SKU检查并添加
for (const sku of skus) {
try {
const exist = await this.productModel.findOne({ where: { sku } });
if (!exist) {
const product = new Product();
product.sku = sku;
product.name = sku; // 默认使用 SKU 作为名称
product.type = 'single';
product.price = 0;
product.promotionPrice = 0;
await this.productModel.save(product);
added++;
}
} catch (error) {
errors.push(`SKU ${sku} 添加失败: ${error.message}`);
}
}
return { added, errors };
}
// 根据ID获取产品详情(包含站点SKU)
async getProductById(id: number): Promise<Product> {
const product = await this.productModel.findOne({
where: { id },
relations: ['category', 'attributes', 'attributes.dict', 'components']
});
if (!product) {
throw new Error(`产品 ID ${id} 不存在`);
}
// 根据类型填充组成信息
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 product;
}
// 根据站点SKU查询产品
async findProductBySiteSku(siteSku: string): Promise<Product> {
const product = await this.productModel.findOne({
where: { siteSkus: Like(`%${siteSku}%`) },
relations: ['category', 'attributes', 'attributes.dict', 'components']
});
if (!product) {
throw new Error(`站点SKU ${siteSku} 不存在`);
}
// 获取完整的产品信息,包含所有关联数据
return this.getProductById(product.id);
}
// 获取产品的站点SKU列表
async getProductSiteSkus(productId: number): Promise<string[]> {
const product = await this.productModel.findOne({ where: { id: productId } });
if (!product) {
throw new Error(`产品 ID ${productId} 不存在`);
}
return product.siteSkus || [];
}
// 绑定产品的站点SKU列表
async bindSiteSkus(productId: number, siteSkus: string[]): Promise<string[]> {
const product = await this.productModel.findOne({ where: { id: productId } });
if (!product) {
throw new Error(`产品 ID ${productId} 不存在`);
}
const normalizedSiteSkus = (siteSkus || [])
.map(c => String(c).trim())
.filter(c => c.length > 0);
product.siteSkus = normalizedSiteSkus;
await this.productModel.save(product);
return product.siteSkus || [];
}
/**
* 将本地产品同步到站点
* @param productId 本地产品ID
* @param siteId 站点ID
* @returns 同步结果
*/
async syncToSite(params: SyncProductToSiteDTO): Promise<any> {
// 获取本地产品信息
const localProduct = await this.getProductById(params.productId);
if (!localProduct) {
throw new Error(`本地产品 ID ${params.productId} 不存在`);
}
// 将本地产品转换为站点API所需格式
const unifiedProduct = await this.mapLocalToUnifiedProduct(localProduct, params.siteSku);
// 调用站点API的upsertProduct方法
try {
const result = await this.siteApiService.upsertProduct(params.siteId, unifiedProduct);
// 绑定站点SKU
await this.bindSiteSkus(localProduct.id, [unifiedProduct.sku]);
return result;
} catch (error) {
throw new Error(`同步产品到站点失败: ${error?.response?.data?.message ?? error.message}`);
}
}
/**
* 批量将本地产品同步到站点
* @param siteId 站点ID
* @param data 产品站点SKU列表
* @returns 批量同步结果
*/
async batchSyncToSite(siteId: number, data: ProductSiteSkuDTO[]): Promise<SyncOperationResultDTO> {
const results: SyncOperationResultDTO = {
total: data.length,
processed: 0,
synced: 0,
errors: []
};
for (const item of data) {
try {
// 先同步产品到站点
await this.syncToSite({
productId: item.productId,
siteId,
siteSku: item.siteSku
});
results.synced++;
results.processed++;
} catch (error) {
results.processed++;
results.errors.push({
identifier: String(item.productId),
error: `产品ID ${item.productId} 同步失败: ${error.message}`
});
}
}
return results;
}
/**
* 从站点同步产品到本地
* @param siteId 站点ID
* @param siteProductId 站点产品ID
* @returns 同步后的本地产品
*/
async syncProductFromSite(siteId: number, siteProductId: string | number, sku: string): Promise<any> {
const adapter = await this.siteApiService.getAdapter(siteId);
const siteProduct = await adapter.getProduct({ id: siteProductId });
// 从站点获取产品信息
if (!siteProduct) {
throw new Error(`站点产品 ID ${siteProductId} 不存在`);
}
// 将站点产品转换为本地产品格式
const productData = await this.mapUnifiedToLocalProduct(siteProduct);
return await this.upsertProduct({ sku }, productData);
}
async upsertProduct(where: Partial<Pick<Product, 'id' | 'sku'>>, productData: any) {
const existingProduct = await this.productModel.findOne({ where: where });
if (existingProduct) {
// 更新现有产品
const updateData: UpdateProductDTO = productData;
return await this.updateProduct(existingProduct.id, updateData);
} else {
// 创建新产品
const createData: CreateProductDTO = productData;
return await this.createProduct(createData);
}
}
/**
* 批量从站点同步产品到本地
* @param siteId 站点ID
* @param siteProductIds 站点产品ID数组
* @returns 批量同步结果
*/
async batchSyncFromSite(siteId: number, data: Array<{ siteProductId: string, sku: string }>): Promise<{ synced: number, errors: string[] }> {
const results = {
synced: 0,
errors: []
};
for (const item of data) {
try {
await this.syncProductFromSite(siteId, item.siteProductId, item.sku);
results.synced++;
} catch (error) {
results.errors.push(`站点产品ID ${item.siteProductId} 同步失败: ${error.message}`);
}
}
return results;
}
/**
* 将站点产品转换为本地产品格式
* @param siteProduct 站点产品对象
* @returns 本地产品数据
*/
private async mapUnifiedToLocalProduct(siteProduct: any): Promise<CreateProductDTO> {
const productData: any = {
sku: siteProduct.sku,
name: siteProduct.name,
nameCn: siteProduct.name,
price: siteProduct.price ? parseFloat(siteProduct.price) : 0,
promotionPrice: siteProduct.sale_price ? parseFloat(siteProduct.sale_price) : 0,
description: siteProduct.description || '',
images: [],
attributes: [],
categoryId: null
};
// 处理图片
if (siteProduct.images && Array.isArray(siteProduct.images)) {
productData.images = siteProduct.images.map((img: any) => ({
url: img.src || img.url,
name: img.name || img.alt || '',
alt: img.alt || ''
}));
}
// 处理分类
if (siteProduct.categories && Array.isArray(siteProduct.categories) && siteProduct.categories.length > 0) {
// 尝试通过分类名称匹配本地分类
const categoryName = siteProduct.categories[0].name;
const category = await this.findCategoryByName(categoryName);
if (category) {
productData.categoryId = category.id;
}
}
// 处理属性
if (siteProduct.attributes && Array.isArray(siteProduct.attributes)) {
productData.attributes = siteProduct.attributes.map((attr: any) => ({
name: attr.name,
value: attr.options && attr.options.length > 0 ? attr.options[0] : ''
}));
}
return productData;
}
/**
* 根据分类名称查找分类
* @param name 分类名称
* @returns 分类对象
*/
private async findCategoryByName(name: string): Promise<Category | null> {
try {
return await this.categoryModel.findOne({
where: { name }
});
} catch (error) {
return null;
}
}
/**
* 将本地产品转换为统一产品格式
* @param localProduct 本地产品对象
* @returns 统一产品对象
*/
private async mapLocalToUnifiedProduct(localProduct: Product, siteSku?: string): Promise<Partial<UnifiedProductDTO>> {
const tags = localProduct.attributes?.map(a => ({ name: a.name })) || [];
// 将本地产品数据转换为UnifiedProductDTO格式
const unifiedProduct: any = {
id: localProduct.id ? String(localProduct.id) : undefined, // 如果产品已存在使用现有ID
name: localProduct.name,
type: localProduct.type === 'single' ? 'simple' : 'bundle', // 默认类型,可以根据实际需要调整
status: 'publish', // 默认状态,可以根据实际需要调整
sku: siteSku || await this.templateService.render('site.product.sku', { product: localProduct, sku: localProduct.sku }),
regular_price: String(localProduct.price || 0),
sale_price: String(localProduct.promotionPrice || localProduct.price || 0),
price: String(localProduct.price || 0),
// TODO 库存暂时无法同步
// stock_status: localProduct.components && localProduct.stockQuantity > 0 ? 'instock' : 'outofstock',
// stock_quantity: localProduct.stockQuantity || 0,
// images: localProduct.images ? localProduct.images.map(img => ({
// id: img.id,
// src: img.url,
// name: img.name || '',
// alt: img.alt || ''
// })) : [],
tags,
categories: localProduct.category ? [{
id: localProduct.category.id,
name: localProduct.category.name
}] : [],
attributes: localProduct.attributes ? localProduct.attributes.map(attr => ({
id: attr.dict.id,
name: attr.dict.name,
position: attr.dict.sort || 0,
visible: true,
variation: false,
options: [attr.name]
})) : [],
variations: [],
date_created: localProduct.createdAt ? new Date(localProduct.createdAt).toISOString() : new Date().toISOString(),
date_modified: localProduct.updatedAt ? new Date(localProduct.updatedAt).toISOString() : new Date().toISOString(),
raw: {
...localProduct
}
};
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;
}
}