1221 lines
40 KiB
TypeScript
1221 lines
40 KiB
TypeScript
import { Inject, Provide } from '@midwayjs/core';
|
||
import { In, Like, Not, Repository } from 'typeorm';
|
||
import { Product } from '../entity/product.entity';
|
||
import { paginate } from '../utils/paginate.util';
|
||
import { PaginationParams } from '../interface';
|
||
import {
|
||
CreateProductDTO,
|
||
UpdateProductDTO,
|
||
} from '../dto/product.dto';
|
||
import {
|
||
BrandPaginatedResponse,
|
||
FlavorsPaginatedResponse,
|
||
ProductPaginatedResponse,
|
||
StrengthPaginatedResponse,
|
||
SizePaginatedResponse,
|
||
} from '../dto/reponse.dto';
|
||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||
import { WpProduct } from '../entity/wp_product.entity';
|
||
import { Variation } from '../entity/variation.entity';
|
||
import { Dict } from '../entity/dict.entity';
|
||
import { DictItem } from '../entity/dict_item.entity';
|
||
import { Context } from '@midwayjs/koa';
|
||
import { TemplateService } from './template.service';
|
||
import { StockService } from './stock.service';
|
||
import { Stock } from '../entity/stock.entity';
|
||
import { StockPoint } from '../entity/stock_point.entity';
|
||
import { ProductStockComponent } from '../entity/product_stock_component.entity';
|
||
import { Category } from '../entity/category.entity';
|
||
|
||
@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(WpProduct)
|
||
wpProductModel: Repository<WpProduct>;
|
||
|
||
@InjectEntityModel(Variation)
|
||
variationModel: Repository<Variation>;
|
||
|
||
@InjectEntityModel(Stock)
|
||
stockModel: Repository<Stock>;
|
||
|
||
@InjectEntityModel(StockPoint)
|
||
stockPointModel: Repository<StockPoint>;
|
||
|
||
@InjectEntityModel(ProductStockComponent)
|
||
productStockComponentModel: Repository<ProductStockComponent>;
|
||
|
||
@InjectEntityModel(Category)
|
||
categoryModel: Repository<Category>;
|
||
|
||
|
||
// 获取所有 WordPress 商品
|
||
async getWpProducts() {
|
||
return this.wpProductModel.find();
|
||
}
|
||
|
||
|
||
|
||
// 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`);
|
||
}
|
||
|
||
// 英文名关键词匹配 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(
|
||
pagination: PaginationParams,
|
||
name?: string,
|
||
brandId?: number
|
||
): Promise<ProductPaginatedResponse> {
|
||
const qb = this.productModel
|
||
.createQueryBuilder('product')
|
||
.leftJoinAndSelect('product.attributes', 'attribute')
|
||
.leftJoinAndSelect('attribute.dict', 'dict')
|
||
.leftJoinAndSelect('product.category', 'category');
|
||
|
||
// 模糊搜索 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);
|
||
}
|
||
|
||
// 品牌过滤
|
||
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;
|
||
});
|
||
}
|
||
|
||
// 分页
|
||
qb.skip((pagination.current - 1) * pagination.pageSize).take(
|
||
pagination.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,
|
||
...pagination,
|
||
};
|
||
}
|
||
|
||
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 { name, description, attributes, sku, price, categoryId } = createProductDTO;
|
||
|
||
// 条件判断(中文注释:校验属性输入)
|
||
if (!Array.isArray(attributes) || attributes.length === 0) {
|
||
// 如果提供了 categoryId 但没有 attributes,初始化为空数组
|
||
if (!attributes && categoryId) {
|
||
// 继续执行,下面会处理 categoryId
|
||
} else {
|
||
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 } });
|
||
if (!categoryItem) throw new Error(`分类 ID ${categoryId} 不存在`);
|
||
}
|
||
|
||
for (const attr of safeAttributes) {
|
||
// 中文注释:如果属性是分类,特殊处理
|
||
if (attr.dictName === 'category') {
|
||
if (attr.id) {
|
||
categoryItem = await this.categoryModel.findOneBy({ id: attr.id });
|
||
} else if (attr.name) {
|
||
categoryItem = await this.categoryModel.findOneBy({ name: attr.name });
|
||
}
|
||
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);
|
||
}
|
||
|
||
// 检查完全相同属性组合是否已存在(中文注释:避免重复)
|
||
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();
|
||
product.name = name;
|
||
product.description = description;
|
||
product.attributes = resolvedAttributes;
|
||
if (categoryItem) {
|
||
product.category = categoryItem;
|
||
}
|
||
// 条件判断(中文注释:设置商品类型,默认 simple)
|
||
product.type = (createProductDTO.type as any) || 'single';
|
||
|
||
// 生成或设置 SKU(中文注释:基于属性字典项的 name 生成)
|
||
if (sku) {
|
||
product.sku = sku;
|
||
} else {
|
||
const attributeMap: Record<string, string> = {};
|
||
for (const a of resolvedAttributes) {
|
||
if (a?.dict?.name && a?.name) attributeMap[a.dict.name] = a.name;
|
||
}
|
||
product.sku = await this.templateService.render('product_sku', {
|
||
brand: attributeMap['brand'] || '',
|
||
flavor: attributeMap['flavor'] || '',
|
||
strength: attributeMap['strength'] || '',
|
||
humidity: attributeMap['humidity'] || '',
|
||
});
|
||
}
|
||
|
||
// 价格与促销价(中文注释:可选字段)
|
||
if (price !== undefined) {
|
||
product.price = Number(price);
|
||
}
|
||
const promotionPrice = (createProductDTO as any)?.promotionPrice;
|
||
if (promotionPrice !== undefined) {
|
||
product.promotionPrice = Number(promotionPrice);
|
||
}
|
||
|
||
return await this.productModel.save(product);
|
||
}
|
||
|
||
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} 不存在`);
|
||
}
|
||
|
||
// 处理基础字段更新(若传入则更新)
|
||
if (updateProductDTO.name !== undefined) {
|
||
product.name = updateProductDTO.name;
|
||
}
|
||
if (updateProductDTO.description !== undefined) {
|
||
product.description = updateProductDTO.description;
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
if (updateProductDTO.price !== undefined) {
|
||
product.price = Number(updateProductDTO.price);
|
||
}
|
||
if ((updateProductDTO as any).promotionPrice !== undefined) {
|
||
product.promotionPrice = Number((updateProductDTO as any).promotionPrice);
|
||
}
|
||
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);
|
||
return saved;
|
||
}
|
||
|
||
// 中文注释:获取产品的库存组成列表(表关联版本)
|
||
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') {
|
||
throw new Error('单品无需设置组成');
|
||
}
|
||
|
||
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 sku = product.sku;
|
||
|
||
// 查询 wp_product 表中是否存在与该 SKU 关联的产品
|
||
const wpProduct = await this.wpProductModel
|
||
.createQueryBuilder('wp_product')
|
||
.where('JSON_CONTAINS(wp_product.constitution, :sku)', {
|
||
sku: JSON.stringify({ sku: sku }),
|
||
})
|
||
.getOne();
|
||
if (wpProduct) {
|
||
throw new Error('无法删除,请先删除关联的WP产品');
|
||
}
|
||
|
||
const variation = await this.variationModel
|
||
.createQueryBuilder('variation')
|
||
.where('JSON_CONTAINS(variation.constitution, :sku)', {
|
||
sku: JSON.stringify({ sku: sku }),
|
||
})
|
||
.getOne();
|
||
|
||
if (variation) {
|
||
console.log(variation);
|
||
throw new Error('无法删除,请先删除关联的WP变体');
|
||
}
|
||
|
||
// 删除产品
|
||
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 }
|
||
): 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.dict = dict;
|
||
return await this.dictItemModel.save(item);
|
||
}
|
||
|
||
// 通用属性:更新字典项
|
||
async updateAttribute(
|
||
id: number,
|
||
payload: { title?: string; name?: 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;
|
||
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 文本
|
||
async exportProductsCSV(): Promise<string> {
|
||
// 查询所有产品及其属性(中文注释:包含字典关系)
|
||
const products = await this.productModel.find({
|
||
relations: ['attributes', 'attributes.dict'],
|
||
order: { id: 'ASC' },
|
||
});
|
||
|
||
// 定义 CSV 表头(中文注释:与导入字段一致)
|
||
const headers = [
|
||
'sku',
|
||
'name',
|
||
'nameCn',
|
||
'price',
|
||
'promotionPrice',
|
||
'type',
|
||
'stock',
|
||
'brand',
|
||
'flavor',
|
||
'strength',
|
||
'size',
|
||
'description',
|
||
];
|
||
|
||
// 中文注释: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 rows: string[] = [];
|
||
rows.push(headers.join(','));
|
||
for (const p of products) {
|
||
// 中文注释:逐行输出产品数据
|
||
const row = [
|
||
esc(p.sku),
|
||
esc(p.name),
|
||
esc(p.nameCn),
|
||
esc(p.price),
|
||
esc(p.promotionPrice),
|
||
esc(p.type),
|
||
esc(p.stock),
|
||
esc(pickAttr(p, 'brand')),
|
||
esc(pickAttr(p, 'flavor')),
|
||
esc(pickAttr(p, 'strength')),
|
||
esc(pickAttr(p, 'size')),
|
||
esc(p.description),
|
||
].join(',');
|
||
rows.push(row);
|
||
}
|
||
|
||
return rows.join('\n');
|
||
}
|
||
|
||
// 中文注释:从 CSV 导入产品;存在则更新,不存在则创建
|
||
async importProductsCSV(buffer: Buffer): Promise<{ created: number; updated: number; errors: string[] }> {
|
||
// 解析 CSV(中文注释:使用 csv-parse/sync 按表头解析)
|
||
const { parse } = await import('csv-parse/sync');
|
||
let records: any[] = [];
|
||
try {
|
||
records = parse(buffer, {
|
||
columns: true,
|
||
skip_empty_lines: true,
|
||
trim: true,
|
||
});
|
||
} catch (e: any) {
|
||
return { created: 0, updated: 0, errors: [`CSV 解析失败:${e?.message || e}`] };
|
||
}
|
||
|
||
let created = 0;
|
||
let updated = 0;
|
||
const errors: string[] = [];
|
||
|
||
// 中文注释:逐条处理记录
|
||
for (const rec of records) {
|
||
try {
|
||
// 条件判断:必须包含 sku
|
||
const sku: string = (rec.sku || '').trim();
|
||
if (!sku) {
|
||
// 缺少 SKU 直接跳过
|
||
errors.push('缺少 SKU 的记录已跳过');
|
||
continue;
|
||
}
|
||
|
||
// 查找现有产品
|
||
const exist = await this.productModel.findOne({ where: { sku }, relations: ['attributes', 'attributes.dict'] });
|
||
|
||
// 中文注释:准备基础字段
|
||
const base = {
|
||
name: rec.name || '',
|
||
nameCn: rec.nameCn || '',
|
||
description: rec.description || '',
|
||
price: rec.price ? Number(rec.price) : undefined,
|
||
promotionPrice: rec.promotionPrice ? Number(rec.promotionPrice) : undefined,
|
||
type: rec.type || '',
|
||
stock: rec.stock ? Number(rec.stock) : undefined,
|
||
sku,
|
||
} as any;
|
||
|
||
// 中文注释:解析属性字段(分号分隔多值)
|
||
const parseList = (v: string) => (v ? String(v).split(';').map(s => s.trim()).filter(Boolean) : []);
|
||
const brands = parseList(rec.brand);
|
||
const flavors = parseList(rec.flavor);
|
||
const strengths = parseList(rec.strength);
|
||
const sizes = parseList(rec.size);
|
||
|
||
// 中文注释:将属性解析为 DTO 输入
|
||
const attrDTOs: { dictName: string; title: string }[] = [];
|
||
for (const b of brands) attrDTOs.push({ dictName: 'brand', title: b });
|
||
for (const f of flavors) attrDTOs.push({ dictName: 'flavor', title: f });
|
||
for (const s of strengths) attrDTOs.push({ dictName: 'strength', title: s });
|
||
for (const z of sizes) attrDTOs.push({ dictName: 'size', title: z });
|
||
|
||
if (!exist) {
|
||
// 中文注释:创建新产品
|
||
const dto = {
|
||
name: base.name,
|
||
description: base.description,
|
||
price: base.price,
|
||
sku: base.sku,
|
||
attributes: attrDTOs,
|
||
} as any;
|
||
const createdProduct = await this.createProduct(dto);
|
||
// 条件判断:更新可选字段
|
||
const patch: any = {};
|
||
if (base.nameCn) patch.nameCn = base.nameCn;
|
||
if (base.promotionPrice !== undefined) patch.promotionPrice = base.promotionPrice;
|
||
if (base.type) patch.type = base.type;
|
||
if (base.stock !== undefined) patch.stock = base.stock;
|
||
if (Object.keys(patch).length > 0) await this.productModel.update(createdProduct.id, patch);
|
||
created += 1;
|
||
} else {
|
||
// 中文注释:更新产品
|
||
const updateDTO: any = {
|
||
name: base.name || exist.name,
|
||
description: base.description || exist.description,
|
||
price: base.price !== undefined ? base.price : exist.price,
|
||
sku: base.sku,
|
||
attributes: attrDTOs,
|
||
};
|
||
// 条件判断:附加可选字段
|
||
if (base.nameCn) updateDTO.nameCn = base.nameCn;
|
||
if (base.promotionPrice !== undefined) updateDTO.promotionPrice = base.promotionPrice;
|
||
if (base.type) updateDTO.type = base.type;
|
||
if (base.stock !== undefined) updateDTO.stock = base.stock;
|
||
await this.updateProduct(exist.id, updateDTO);
|
||
updated += 1;
|
||
}
|
||
} catch (e: any) {
|
||
errors.push(e?.message || String(e));
|
||
}
|
||
}
|
||
|
||
return { created, updated, errors };
|
||
}
|
||
}
|