1671 lines
53 KiB
TypeScript
1671 lines
53 KiB
TypeScript
import { Inject, Provide } from '@midwayjs/core';
|
||
import * as fs from 'fs';
|
||
import { In, Like, Not, Repository } from 'typeorm';
|
||
import { Product } from '../entity/product.entity';
|
||
import { paginate } from '../utils/paginate.util';
|
||
import { PaginationParams } from '../interface';
|
||
import { parse } from 'csv-parse';
|
||
|
||
import {
|
||
CreateProductDTO,
|
||
UpdateProductDTO,
|
||
BatchUpdateProductDTO,
|
||
} from '../dto/product.dto';
|
||
import {
|
||
BrandPaginatedResponse,
|
||
FlavorsPaginatedResponse,
|
||
ProductPaginatedResponse,
|
||
StrengthPaginatedResponse,
|
||
SizePaginatedResponse,
|
||
} from '../dto/reponse.dto';
|
||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||
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 { ProductSiteSku } from '../entity/product_site_sku.entity';
|
||
import { Category } from '../entity/category.entity';
|
||
import { CategoryAttribute } from '../entity/category_attribute.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(Variation)
|
||
variationModel: Repository<Variation>;
|
||
|
||
@InjectEntityModel(Stock)
|
||
stockModel: Repository<Stock>;
|
||
|
||
@InjectEntityModel(StockPoint)
|
||
stockPointModel: Repository<StockPoint>;
|
||
|
||
@InjectEntityModel(ProductStockComponent)
|
||
productStockComponentModel: Repository<ProductStockComponent>;
|
||
|
||
@InjectEntityModel(ProductSiteSku)
|
||
productSiteSkuModel: Repository<ProductSiteSku>;
|
||
|
||
@InjectEntityModel(Category)
|
||
categoryModel: Repository<Category>;
|
||
|
||
// 获取所有分类
|
||
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')
|
||
.leftJoinAndSelect('product.siteSkus', 'siteSku');
|
||
|
||
// 保证 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', 'siteSkus']
|
||
});
|
||
}
|
||
|
||
async getProductList(
|
||
pagination: PaginationParams,
|
||
name?: string,
|
||
brandId?: number,
|
||
sortField?: string,
|
||
sortOrder?: string
|
||
): Promise<ProductPaginatedResponse> {
|
||
const qb = this.productModel
|
||
.createQueryBuilder('product')
|
||
.leftJoinAndSelect('product.attributes', 'attribute')
|
||
.leftJoinAndSelect('attribute.dict', 'dict')
|
||
.leftJoinAndSelect('product.category', 'category')
|
||
.leftJoinAndSelect('product.siteSkus', 'siteSku');
|
||
|
||
// 模糊搜索 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;
|
||
});
|
||
}
|
||
|
||
// 排序
|
||
if (sortField && sortOrder) {
|
||
const order = sortOrder === 'ascend' ? 'ASC' : 'DESC';
|
||
const allowedSortFields = ['price', 'promotionPrice', 'createdAt', 'updatedAt', 'sku', 'name'];
|
||
if (allowedSortFields.includes(sortField)) {
|
||
qb.orderBy(`product.${sortField}`, order);
|
||
}
|
||
} else {
|
||
qb.orderBy('product.createdAt', 'DESC');
|
||
}
|
||
|
||
// 分页
|
||
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 { attributes, sku, 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 },
|
||
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);
|
||
}
|
||
|
||
// 检查完全相同属性组合是否已存在(避免重复)
|
||
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, siteSkus: _siteSkus, ...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);
|
||
|
||
// 保存站点 SKU 列表
|
||
if (createProductDTO.siteSkus && createProductDTO.siteSkus.length > 0) {
|
||
const siteSkus = createProductDTO.siteSkus.map(code => {
|
||
const s = new ProductSiteSku();
|
||
s.siteSku = code;
|
||
s.product = savedProduct;
|
||
return s;
|
||
});
|
||
await this.productSiteSkuModel.save(siteSkus);
|
||
}
|
||
|
||
// 保存组件信息
|
||
if (createProductDTO.components && createProductDTO.components.length > 0) {
|
||
await this.setProductComponents(savedProduct.id, createProductDTO.components);
|
||
// 重新加载带组件的产品
|
||
return await this.productModel.findOne({ where: { id: savedProduct.id }, relations: ['attributes', 'attributes.dict', 'category', 'components', '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, siteSkus: _siteSkus, ...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.siteSkus !== undefined) {
|
||
// 删除旧的 siteSkus
|
||
await this.productSiteSkuModel.delete({ productId: id });
|
||
|
||
// 如果有新的 siteSkus,则保存
|
||
if (updateProductDTO.siteSkus.length > 0) {
|
||
const siteSkus = updateProductDTO.siteSkus.map(code => {
|
||
const s = new ProductSiteSku();
|
||
s.siteSku = code;
|
||
s.productId = id;
|
||
return s;
|
||
});
|
||
await this.productSiteSkuModel.save(siteSkus);
|
||
}
|
||
}
|
||
|
||
// 处理 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, price, promotionPrice
|
||
|
||
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 (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);
|
||
}
|
||
|
||
// 站点SKU绑定:覆盖式绑定一组站点SKU到产品
|
||
async bindSiteSkus(productId: number, codes: string[]): Promise<ProductSiteSku[]> {
|
||
const product = await this.productModel.findOne({ where: { id: productId } });
|
||
if (!product) throw new Error(`产品 ID ${productId} 不存在`);
|
||
const normalized = (codes || [])
|
||
.map(c => String(c).trim())
|
||
.filter(c => c.length > 0);
|
||
await this.productSiteSkuModel.delete({ productId });
|
||
if (normalized.length === 0) return [];
|
||
const entities = normalized.map(code => {
|
||
const e = new ProductSiteSku();
|
||
e.productId = productId;
|
||
e.siteSku = code;
|
||
return e;
|
||
});
|
||
return await this.productSiteSkuModel.save(entities);
|
||
}
|
||
|
||
// 站点SKU绑定:按单个 code 绑定到指定产品(若已有则更新归属)
|
||
async bindProductBySiteSku(code: string, productId: number): Promise<ProductSiteSku> {
|
||
const product = await this.productModel.findOne({ where: { id: productId } });
|
||
if (!product) throw new Error(`产品 ID ${productId} 不存在`);
|
||
const skuCode = String(code || '').trim();
|
||
if (!skuCode) throw new Error('站点SKU不能为空');
|
||
const existing = await this.productSiteSkuModel.findOne({ where: { siteSku: skuCode } });
|
||
if (existing) {
|
||
existing.productId = productId;
|
||
return await this.productSiteSkuModel.save(existing);
|
||
}
|
||
const e = new ProductSiteSku();
|
||
e.productId = productId;
|
||
e.siteSku = skuCode;
|
||
return await this.productSiteSkuModel.save(e);
|
||
}
|
||
|
||
// 重复定义的 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 });
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
|
||
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);
|
||
|
||
// 默认值和特殊处理
|
||
|
||
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);
|
||
|
||
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;
|
||
}
|
||
|
||
// 将单个产品转换为 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.map(s => s.siteSku).join(',') : ''),
|
||
esc(p.name),
|
||
esc(p.nameCn),
|
||
esc(p.price),
|
||
esc(p.promotionPrice),
|
||
esc(p.type),
|
||
|
||
esc(p.description),
|
||
];
|
||
|
||
// 属性数据
|
||
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', 'siteSkus'],
|
||
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',
|
||
];
|
||
|
||
// 动态属性表头
|
||
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(','));
|
||
}
|
||
|
||
return rows.join('\n');
|
||
}
|
||
|
||
// 从 CSV 导入产品;存在则更新,不存在则创建
|
||
async importProductsCSV(file: any): Promise<{ created: number; updated: number; errors: string[] }> {
|
||
let 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('无效的文件输入');
|
||
}
|
||
|
||
// 解析 CSV(使用 csv-parse/sync 按表头解析)
|
||
let records: any[] = [];
|
||
try {
|
||
records = await new Promise((resolve, reject) => {
|
||
parse(buffer, {
|
||
columns: true,
|
||
skip_empty_lines: true,
|
||
trim: true,
|
||
bom: true,
|
||
}, (err, data) => {
|
||
if (err) {
|
||
reject(err);
|
||
} else {
|
||
resolve(data);
|
||
}
|
||
});
|
||
})
|
||
console.log('Parsed records count:', records.length);
|
||
if (records.length > 0) {
|
||
console.log('First record keys:', Object.keys(records[0]));
|
||
}
|
||
} 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 {
|
||
const data = this.transformCsvRecordToData(rec);
|
||
if (!data) {
|
||
errors.push('缺少 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(`产品${rec?.sku}导入失败:${e?.message || String(e)}`);
|
||
}
|
||
}
|
||
|
||
return { 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 };
|
||
}
|
||
|
||
// 获取产品的站点SKU列表
|
||
async getProductSiteSkus(productId: number): Promise<ProductSiteSku[]> {
|
||
return this.productSiteSkuModel.find({
|
||
where: { productId },
|
||
relations: ['product'],
|
||
order: { createdAt: 'ASC' }
|
||
});
|
||
}
|
||
|
||
// 根据ID获取产品详情(包含站点SKU)
|
||
async getProductById(id: number): Promise<Product> {
|
||
const product = await this.productModel.findOne({
|
||
where: { id },
|
||
relations: ['category', 'attributes', 'attributes.dict', 'siteSkus', '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 siteSkuEntity = await this.productSiteSkuModel.findOne({
|
||
where: { siteSku },
|
||
relations: ['product']
|
||
});
|
||
|
||
if (!siteSkuEntity) {
|
||
throw new Error(`站点SKU ${siteSku} 不存在`);
|
||
}
|
||
|
||
// 获取完整的产品信息,包含所有关联数据
|
||
return this.getProductById(siteSkuEntity.product.id);
|
||
}
|
||
}
|