API/src/service/product.service.ts

1671 lines
53 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 { 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);
}
}