Compare commits

...

2 Commits

Author SHA1 Message Date
tikkhun a5996363c8 feat(库存): 重构库存组件为基于SKU的设计
- 将库存组件从基于stockId改为基于productSku
- 添加检查SKU库存的接口
- 改进库存查询支持多字段排序
- 优化产品组件查询返回库存点信息
- 允许字典项titleCN为空字符串
2025-11-30 15:57:23 +08:00
tikkhun af9f49ab58 feat(产品): 实现产品CSV导入导出功能并增强类型处理
添加产品CSV导入导出功能,支持完整产品数据包括属性和类型
扩展产品类型处理逻辑,区分simple和bundle类型的不同行为
在库存查询中增加按库存点排序功能
完善产品DTO和实体中的类型字段定义
2025-11-29 11:40:13 +08:00
11 changed files with 430 additions and 47 deletions

View File

@ -14,6 +14,8 @@ import { errorResponse, successResponse } from '../utils/response.util';
import { CreateProductDTO, QueryProductDTO, UpdateProductDTO, SetProductComponentsDTO } from '../dto/product.dto'; import { CreateProductDTO, QueryProductDTO, UpdateProductDTO, SetProductComponentsDTO } from '../dto/product.dto';
import { ApiOkResponse } from '@midwayjs/swagger'; import { ApiOkResponse } from '@midwayjs/swagger';
import { BooleanRes, ProductListRes, ProductRes, ProductsRes } from '../dto/reponse.dto'; import { BooleanRes, ProductListRes, ProductRes, ProductsRes } from '../dto/reponse.dto';
import { ContentType, Files } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
@Controller('/product') @Controller('/product')
export class ProductController { export class ProductController {
@ -21,6 +23,9 @@ export class ProductController {
productService: ProductService; productService: ProductService;
ProductRes; ProductRes;
@Inject()
ctx: Context;
@ApiOkResponse({ @ApiOkResponse({
description: '通过name搜索产品', description: '通过name搜索产品',
type: ProductsRes, type: ProductsRes,
@ -83,6 +88,39 @@ export class ProductController {
} }
} }
// 中文注释:导出所有产品 CSV
@ApiOkResponse()
@Get('/export')
@ContentType('text/csv')
async exportProductsCSV() {
try {
const csv = await this.productService.exportProductsCSV();
// 设置下载文件名(中文注释:附件形式)
const date = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const name = `products-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}.csv`;
this.ctx.set('Content-Disposition', `attachment; filename=${name}`);
return csv;
} catch (error) {
return errorResponse(error?.message || error);
}
}
// 中文注释导入产品CSV 文件)
@ApiOkResponse()
@Post('/import')
async importProductsCSV(@Files() files: any) {
try {
// 条件判断:确保存在文件
const file = files?.[0];
if (!file?.data) return errorResponse('未接收到上传文件');
const result = await this.productService.importProductsCSV(file.data);
return successResponse(result);
} catch (error) {
return errorResponse(error?.message || error);
}
}
@ApiOkResponse({ type: ProductRes }) @ApiOkResponse({ type: ProductRes })
@Put('/:id') @Put('/:id')
async updateProduct(@Param('id') id: number, @Body() productData: UpdateProductDTO) { async updateProduct(@Param('id') id: number, @Body() productData: UpdateProductDTO) {
@ -194,12 +232,8 @@ export class ProductController {
@Body() body: { title: string; name: string } @Body() body: { title: string; name: string }
) { ) {
try { try {
const hasItem = await this.productService.hasAttribute( // 调用 getOrCreateAttribute 方法,如果不存在则创建,如果存在则返回
dictName, const data = await this.productService.getOrCreateAttribute(dictName, body.title, body.name);
body.name
);
if (hasItem) return errorResponse('字典项已存在');
const data = await this.productService.createAttribute(dictName, body);
return successResponse(data); return successResponse(data);
} catch (error) { } catch (error) {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);

View File

@ -176,6 +176,18 @@ export class StockController {
} }
} }
// 中文注释:检查某个 SKU 是否有库存(任一仓库数量大于 0
@ApiOkResponse({ type: BooleanRes })
@Get('/has/:sku')
async hasStock(@Param('sku') sku: string) {
try {
const data = await this.stockService.hasStockBySku(sku);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || '查询失败');
}
}
@ApiOkResponse({ @ApiOkResponse({
type: BooleanRes, type: BooleanRes,
description: '更新库存(入库、出库、调整)', description: '更新库存(入库、出库、调整)',

View File

@ -26,7 +26,7 @@ export class CreateDictItemDTO {
@Rule(RuleType.string().required()) @Rule(RuleType.string().required())
title: string; // 字典项标题 title: string; // 字典项标题
@Rule(RuleType.string().allow(null)) @Rule(RuleType.string().allow('').allow(null))
titleCN?: string; // 字典项中文标题 (可选) titleCN?: string; // 字典项中文标题 (可选)
@Rule(RuleType.number().required()) @Rule(RuleType.number().required())
@ -41,7 +41,7 @@ export class UpdateDictItemDTO {
@Rule(RuleType.string()) @Rule(RuleType.string())
title?: string; // 字典项标题 (可选) title?: string; // 字典项标题 (可选)
@Rule(RuleType.string().allow(null)) @Rule(RuleType.string().allow('').allow(null))
titleCN?: string; // 字典项中文标题 (可选) titleCN?: string; // 字典项中文标题 (可选)
@Rule(RuleType.string().allow(null)) @Rule(RuleType.string().allow(null))

View File

@ -35,6 +35,11 @@ export class CreateProductDTO {
@ApiProperty({ description: '促销价格', example: 99.99, required: false }) @ApiProperty({ description: '促销价格', example: 99.99, required: false })
@Rule(RuleType.number()) @Rule(RuleType.number())
promotionPrice?: number; promotionPrice?: number;
// 中文注释:商品类型(默认 simplebundle 需手动设置组成)
@ApiProperty({ description: '商品类型', enum: ['simple', 'bundle'], default: 'simple', required: false })
@Rule(RuleType.string().valid('simple', 'bundle').default('simple'))
type?: string;
} }
/** /**
@ -67,6 +72,11 @@ export class UpdateProductDTO {
@ApiProperty({ description: '属性列表', type: 'array', required: false }) @ApiProperty({ description: '属性列表', type: 'array', required: false })
@Rule(RuleType.array()) @Rule(RuleType.array())
attributes?: AttributeInputDTO[]; attributes?: AttributeInputDTO[];
// 中文注释商品类型更新simple 或 bundle
@ApiProperty({ description: '商品类型', enum: ['simple', 'bundle'], required: false })
@Rule(RuleType.string().valid('simple', 'bundle'))
type?: string;
} }
/** /**
@ -274,9 +284,9 @@ export class BatchSetSkuDTO {
// 中文注释:产品库存组成项输入 // 中文注释:产品库存组成项输入
export class ProductComponentItemDTO { export class ProductComponentItemDTO {
@ApiProperty({ description: '库存记录ID' }) @ApiProperty({ description: '组件 SKU' })
@Rule(RuleType.number().required()) @Rule(RuleType.string().required())
stockId: number; sku: string;
@ApiProperty({ description: '组成数量', example: 1 }) @ApiProperty({ description: '组成数量', example: 1 })
@Rule(RuleType.number().min(1).default(1)) @Rule(RuleType.number().min(1).default(1))

View File

@ -21,6 +21,18 @@ export class QueryStockDTO {
@ApiProperty() @ApiProperty()
@Rule(RuleType.string()) @Rule(RuleType.string())
productName: string; productName: string;
@ApiProperty()
@Rule(RuleType.string())
productSku: string;
@ApiProperty({ description: '按库存点ID排序', required: false })
@Rule(RuleType.number().allow(null))
sortPointId?: number;
@ApiProperty({ description: '排序对象,格式如 { productName: "asc", productSku: "desc" }', required: false })
@Rule(RuleType.object().allow(null))
order?: Record<string, 'asc' | 'desc'>;
} }
export class QueryPointDTO { export class QueryPointDTO {
@ApiProperty({ example: '1', description: '页码' }) @ApiProperty({ example: '1', description: '页码' })

View File

@ -51,7 +51,7 @@ export class Product {
price: number; price: number;
// 类型 主要用来区分混装和单品 单品死 // 类型 主要用来区分混装和单品 单品死
@ApiProperty({ description: '类型' }) @ApiProperty({ description: '类型' })
@Column() @Column({ length: 16, default: 'simple' })
type: string; type: string;
// 促销价格 // 促销价格
@ApiProperty({ description: '促销价格', example: 99.99 }) @ApiProperty({ description: '促销价格', example: 99.99 })

View File

@ -1,7 +1,6 @@
import { ApiProperty } from '@midwayjs/swagger'; import { ApiProperty } from '@midwayjs/swagger';
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { Product } from './product.entity'; import { Product } from './product.entity';
import { Stock } from './stock.entity';
@Entity('product_stock_component') @Entity('product_stock_component')
export class ProductStockComponent { export class ProductStockComponent {
@ -13,9 +12,9 @@ export class ProductStockComponent {
@Column() @Column()
productId: number; productId: number;
@ApiProperty({ type: Number }) @ApiProperty({ description: '组件所关联的 SKU', type: 'string' })
@Column() @Column({ type: 'varchar', length: 64 })
stockId: number; productSku: string;
@ApiProperty({ type: Number, description: '组成数量' }) @ApiProperty({ type: Number, description: '组成数量' })
@Column({ type: 'int', default: 1 }) @Column({ type: 'int', default: 1 })
@ -25,10 +24,6 @@ export class ProductStockComponent {
@ManyToOne(() => Product, (product) => product.components, { onDelete: 'CASCADE' }) @ManyToOne(() => Product, (product) => product.components, { onDelete: 'CASCADE' })
product: Product; product: Product;
// 中文注释:多对一,组件引用一个库存记录
@ManyToOne(() => Stock, { eager: true, onDelete: 'CASCADE' })
stock: Stock;
@ApiProperty({ description: '创建时间' }) @ApiProperty({ description: '创建时间' })
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
@ -37,4 +32,3 @@ export class ProductStockComponent {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt: Date; updatedAt: Date;
} }

View File

@ -55,7 +55,7 @@ export class DictService {
// 生成并返回字典项的XLSX模板 // 生成并返回字典项的XLSX模板
getDictItemXLSXTemplate() { getDictItemXLSXTemplate() {
const headers = ['name', 'title', 'value', 'sort']; const headers = ['name', 'title', 'titleCN', 'value', 'sort'];
const ws = xlsx.utils.aoa_to_sheet([headers]); const ws = xlsx.utils.aoa_to_sheet([headers]);
const wb = xlsx.utils.book_new(); const wb = xlsx.utils.book_new();
xlsx.utils.book_append_sheet(wb, ws, 'DictItems'); xlsx.utils.book_append_sheet(wb, ws, 'DictItems');
@ -71,12 +71,14 @@ export class DictService {
const wb = xlsx.read(buffer, { type: 'buffer' }); const wb = xlsx.read(buffer, { type: 'buffer' });
const wsname = wb.SheetNames[0]; const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname]; const ws = wb.Sheets[wsname];
const data = xlsx.utils.sheet_to_json(ws, { header: ['name', 'title', 'value', 'sort'] }).slice(1); // 支持titleCN字段的导入
const data = xlsx.utils.sheet_to_json(ws, { header: ['name', 'title', 'titleCN', 'value', 'sort'] }).slice(1);
const items = data.map((row: any) => { const items = data.map((row: any) => {
const item = new DictItem(); const item = new DictItem();
item.name = row.name; item.name = row.name;
item.title = row.title; item.title = row.title;
item.titleCN = row.titleCN; // 保存中文名称
item.value = row.value; item.value = row.value;
item.sort = row.sort || 0; item.sort = row.sort || 0;
item.dict = dict; item.dict = dict;
@ -143,6 +145,7 @@ export class DictService {
const item = new DictItem(); const item = new DictItem();
item.name = createDictItemDTO.name; item.name = createDictItemDTO.name;
item.title = createDictItemDTO.title; item.title = createDictItemDTO.title;
item.titleCN = createDictItemDTO.titleCN; // 保存中文名称
item.dict = dict; item.dict = dict;
return this.dictItemModel.save(item); return this.dictItemModel.save(item);
} }

View File

@ -31,6 +31,7 @@ import { Context } from '@midwayjs/koa';
import { TemplateService } from './template.service'; import { TemplateService } from './template.service';
import { StockService } from './stock.service'; import { StockService } from './stock.service';
import { Stock } from '../entity/stock.entity'; import { Stock } from '../entity/stock.entity';
import { StockPoint } from '../entity/stock_point.entity';
import { ProductStockComponent } from '../entity/product_stock_component.entity'; import { ProductStockComponent } from '../entity/product_stock_component.entity';
@Provide() @Provide()
@ -62,6 +63,9 @@ export class ProductService {
@InjectEntityModel(Stock) @InjectEntityModel(Stock)
stockModel: Repository<Stock>; stockModel: Repository<Stock>;
@InjectEntityModel(StockPoint)
stockPointModel: Repository<StockPoint>;
@InjectEntityModel(ProductStockComponent) @InjectEntityModel(ProductStockComponent)
productStockComponentModel: Repository<ProductStockComponent>; productStockComponentModel: Repository<ProductStockComponent>;
@ -172,6 +176,21 @@ export class ProductService {
const [items, total] = await qb.getManyAndCount(); const [items, total] = await qb.getManyAndCount();
// 中文注释:根据类型填充组成信息
for (const p of items) {
if (p.type === 'simple') {
// 中文注释:单品不持久化组成,这里仅返回一个基于 SKU 的虚拟组成
const comp = new ProductStockComponent();
comp.productId = p.id;
comp.productSku = p.sku;
comp.quantity = 1;
p.components = [comp];
} else {
// 中文注释:混装商品返回持久化的 SKU 组成
p.components = await this.productStockComponentModel.find({ where: { productId: p.id } });
}
}
return { return {
items, items,
total, total,
@ -190,16 +209,18 @@ export class ProductService {
throw new Error(`字典 '${dictName}' 不存在`); throw new Error(`字典 '${dictName}' 不存在`);
} }
const nameForLookup = itemName || itemTitle;
// 查找字典项 // 查找字典项
let item = await this.dictItemModel.findOne({ let item = await this.dictItemModel.findOne({
where: { title: itemTitle, dict: { id: dict.id } }, where: { name: nameForLookup, dict: { id: dict.id } },
}); });
// 如果字典项不存在,则创建 // 如果字典项不存在,则创建
if (!item) { if (!item) {
item = new DictItem(); item = new DictItem();
item.title = itemTitle; item.title = itemTitle;
item.name = itemName || itemTitle; // 如果没有提供 name则使用 title item.name = nameForLookup;
item.dict = dict; item.dict = dict;
await this.dictItemModel.save(item); await this.dictItemModel.save(item);
} }
@ -251,6 +272,8 @@ export class ProductService {
product.name = name; product.name = name;
product.description = description; product.description = description;
product.attributes = resolvedAttributes; product.attributes = resolvedAttributes;
// 条件判断(中文注释:设置商品类型,默认 simple
product.type = (createProductDTO.type as any) || 'simple';
// 生成或设置 SKU中文注释基于属性字典项的 name 生成) // 生成或设置 SKU中文注释基于属性字典项的 name 生成)
if (sku) { if (sku) {
@ -346,31 +369,96 @@ export class ProductService {
product.attributes = nextAttributes; product.attributes = nextAttributes;
} }
// 条件判断(中文注释:更新商品类型,如传入)
if (updateProductDTO.type !== undefined) {
product.type = updateProductDTO.type as any;
}
// 保存更新后的产品 // 保存更新后的产品
const saved = await this.productModel.save(product); const saved = await this.productModel.save(product);
return saved; return saved;
} }
// 中文注释:获取产品的库存组成列表(表关联版本) // 中文注释:获取产品的库存组成列表(表关联版本)
async getProductComponents(productId: number): Promise<ProductStockComponent[]> { async getProductComponents(productId: number): Promise<any[]> {
// 条件判断:确保产品存在 // 条件判断:确保产品存在
const product = await this.productModel.findOne({ where: { id: productId } }); const product = await this.productModel.findOne({ where: { id: productId } });
if (!product) throw new Error(`产品 ID ${productId} 不存在`); if (!product) throw new Error(`产品 ID ${productId} 不存在`);
return await this.productStockComponentModel.find({ where: { productId } });
let components: ProductStockComponent[] = [];
// 条件判断(中文注释:单品 simple 不持久化组成,按 SKU 动态返回单条组成)
if (product.type === 'simple') {
const comp = new ProductStockComponent();
comp.productId = productId;
comp.productSku = product.sku;
comp.quantity = 1;
components = [comp];
} else {
// 混装 bundle返回已保存的 SKU 组成
components = await this.productStockComponentModel.find({ where: { productId } });
}
// 中文注释:获取所有组件的 SKU 列表
const skus = components.map(c => c.productSku);
if (skus.length === 0) {
return components;
}
// 中文注释:查询这些 SKU 的库存信息
const stocks = await this.stockModel.find({
where: { productSku: 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.productSku]) {
map[stock.productSku] = [];
}
const stockPoint = stockPointMap[stock.stockPointId];
if (stockPoint) {
map[stock.productSku].push({
name: stockPoint.name,
quantity: stock.quantity,
});
}
return map;
}, {});
// 中文注释:将库存信息附加到组件上
const componentsWithStock = components.map(comp => {
return {
...comp,
stock: stockMap[comp.productSku] || [],
};
});
return componentsWithStock;
} }
// 中文注释:设置产品的库存组成(覆盖式,表关联版本) // 中文注释:设置产品的库存组成(覆盖式,表关联版本)
async setProductComponents( async setProductComponents(
productId: number, productId: number,
items: { stockId: number; quantity: number }[] items: { sku: string; quantity: number }[]
): Promise<ProductStockComponent[]> { ): Promise<ProductStockComponent[]> {
// 条件判断:确保产品存在 // 条件判断:确保产品存在
const product = await this.productModel.findOne({ where: { id: productId } }); const product = await this.productModel.findOne({ where: { id: productId } });
if (!product) throw new Error(`产品 ID ${productId} 不存在`); if (!product) throw new Error(`产品 ID ${productId} 不存在`);
// 条件判断(中文注释:单品 simple 不允许手动设置组成)
if (product.type === 'simple') {
throw new Error('单品无需设置组成');
}
const validItems = (items || []) const validItems = (items || [])
.filter(i => i && i.stockId && i.quantity && i.quantity > 0) .filter(i => i && i.sku && i.quantity && i.quantity > 0)
.map(i => ({ stockId: Number(i.stockId), quantity: Number(i.quantity) })); .map(i => ({ sku: String(i.sku), quantity: Number(i.quantity) }));
// 删除旧的组成 // 删除旧的组成
await this.productStockComponentModel.delete({ productId }); await this.productStockComponentModel.delete({ productId });
@ -378,13 +466,14 @@ export class ProductService {
// 插入新的组成 // 插入新的组成
const created: ProductStockComponent[] = []; const created: ProductStockComponent[] = [];
for (const i of validItems) { for (const i of validItems) {
const stock = await this.stockModel.findOne({ where: { id: i.stockId } }); // 中文注释:校验 SKU 格式,允许不存在库存但必须非空
if (!stock) throw new Error(`库存 ID ${i.stockId} 不存在`); if (!i.sku || i.sku.trim().length === 0) {
throw new Error('SKU 不能为空');
}
const comp = new ProductStockComponent(); const comp = new ProductStockComponent();
comp.productId = productId; comp.productId = productId;
comp.stockId = i.stockId; comp.productSku = i.sku;
comp.quantity = i.quantity; comp.quantity = i.quantity;
comp.stock = stock;
created.push(await this.productStockComponentModel.save(comp)); created.push(await this.productStockComponentModel.save(comp));
} }
return created; return created;
@ -395,23 +484,28 @@ export class ProductService {
// 条件判断:确保产品存在 // 条件判断:确保产品存在
const product = await this.productModel.findOne({ where: { id: productId } }); const product = await this.productModel.findOne({ where: { id: productId } });
if (!product) throw new Error(`产品 ID ${productId} 不存在`); if (!product) throw new Error(`产品 ID ${productId} 不存在`);
// 中文注释:按 SKU 自动绑定
const stocks = await this.stockModel.find({ where: { productSku: product.sku } }); // 条件判断simple 类型不持久化组成,直接返回单条基于 SKU 的组成
if (stocks.length === 0) return []; if (product.type === 'simple') {
for (const stock of stocks) {
// 条件判断:若已存在相同 stockId 的组成则跳过
const exist = await this.productStockComponentModel.findOne({ where: { productId, stockId: stock.id } });
if (exist) continue;
const comp = new ProductStockComponent(); const comp = new ProductStockComponent();
comp.productId = productId; comp.productId = productId;
comp.stockId = stock.id; comp.productSku = product.sku;
comp.quantity = 1; // 默认数量 1 comp.quantity = 1; // 默认数量 1
comp.stock = stock; return [comp];
}
// bundle 类型:若不存在则持久化一条基于 SKU 的组成
const exist = await this.productStockComponentModel.findOne({ where: { productId, productSku: product.sku } });
if (!exist) {
const comp = new ProductStockComponent();
comp.productId = productId;
comp.productSku = product.sku;
comp.quantity = 1;
await this.productStockComponentModel.save(comp); await this.productStockComponentModel.save(comp);
} }
return await this.getProductComponents(productId); return await this.getProductComponents(productId);
} }
// 重复定义的 getProductList 已合并到前面的实现(中文注释:移除重复)
async updateProductNameCn(id: number, nameCn: string): Promise<Product> { async updateProductNameCn(id: number, nameCn: string): Promise<Product> {
// 确认产品是否存在 // 确认产品是否存在
@ -901,4 +995,170 @@ export class ProductService {
return `成功更新 ${skus.length} 个 sku`; return `成功更新 ${skus.length} 个 sku`;
} }
// 中文注释:导出所有产品为 CSV 文本
async exportProductsCSV(): Promise<string> {
// 查询所有产品及其属性(中文注释:包含字典关系)
const products = await this.productModel.find({
relations: ['attributes', 'attributes.dict'],
order: { id: 'ASC' },
});
// 定义 CSV 表头(中文注释:与导入字段一致)
const headers = [
'sku',
'name',
'nameCn',
'price',
'promotionPrice',
'type',
'stock',
'brand',
'flavor',
'strength',
'size',
'description',
];
// 中文注释CSV 字段转义,处理逗号与双引号
const esc = (v: any) => {
const s = v === undefined || v === null ? '' : String(v);
const needsQuote = /[",\n]/.test(s);
const escaped = s.replace(/"/g, '""');
return needsQuote ? `"${escaped}"` : escaped;
};
// 中文注释:将属性列表转为字典名到显示值的映射
const pickAttr = (prod: Product, key: string) => {
const list = (prod.attributes || []).filter(a => a?.dict?.name === key);
if (list.length === 0) return '';
// 多个值使用分号分隔
return list.map(a => a.title || a.name).join(';');
};
const rows: string[] = [];
rows.push(headers.join(','));
for (const p of products) {
// 中文注释:逐行输出产品数据
const row = [
esc(p.sku),
esc(p.name),
esc(p.nameCn),
esc(p.price),
esc(p.promotionPrice),
esc(p.type),
esc(p.stock),
esc(pickAttr(p, 'brand')),
esc(pickAttr(p, 'flavor')),
esc(pickAttr(p, 'strength')),
esc(pickAttr(p, 'size')),
esc(p.description),
].join(',');
rows.push(row);
}
return rows.join('\n');
}
// 中文注释:从 CSV 导入产品;存在则更新,不存在则创建
async importProductsCSV(buffer: Buffer): Promise<{ created: number; updated: number; errors: string[] }> {
// 解析 CSV中文注释使用 csv-parse/sync 按表头解析)
const { parse } = await import('csv-parse/sync');
let records: any[] = [];
try {
records = parse(buffer, {
columns: true,
skip_empty_lines: true,
trim: true,
});
} catch (e: any) {
return { created: 0, updated: 0, errors: [`CSV 解析失败:${e?.message || e}`] };
}
let created = 0;
let updated = 0;
const errors: string[] = [];
// 中文注释:逐条处理记录
for (const rec of records) {
try {
// 条件判断:必须包含 sku
const sku: string = (rec.sku || '').trim();
if (!sku) {
// 缺少 SKU 直接跳过
errors.push('缺少 SKU 的记录已跳过');
continue;
}
// 查找现有产品
const exist = await this.productModel.findOne({ where: { sku }, relations: ['attributes', 'attributes.dict'] });
// 中文注释:准备基础字段
const base = {
name: rec.name || '',
nameCn: rec.nameCn || '',
description: rec.description || '',
price: rec.price ? Number(rec.price) : undefined,
promotionPrice: rec.promotionPrice ? Number(rec.promotionPrice) : undefined,
type: rec.type || '',
stock: rec.stock ? Number(rec.stock) : undefined,
sku,
} as any;
// 中文注释:解析属性字段(分号分隔多值)
const parseList = (v: string) => (v ? String(v).split(';').map(s => s.trim()).filter(Boolean) : []);
const brands = parseList(rec.brand);
const flavors = parseList(rec.flavor);
const strengths = parseList(rec.strength);
const sizes = parseList(rec.size);
// 中文注释:将属性解析为 DTO 输入
const attrDTOs: { dictName: string; title: string }[] = [];
for (const b of brands) attrDTOs.push({ dictName: 'brand', title: b });
for (const f of flavors) attrDTOs.push({ dictName: 'flavor', title: f });
for (const s of strengths) attrDTOs.push({ dictName: 'strength', title: s });
for (const z of sizes) attrDTOs.push({ dictName: 'size', title: z });
if (!exist) {
// 中文注释:创建新产品
const dto = {
name: base.name,
description: base.description,
price: base.price,
sku: base.sku,
attributes: attrDTOs,
} as any;
const createdProduct = await this.createProduct(dto);
// 条件判断:更新可选字段
const patch: any = {};
if (base.nameCn) patch.nameCn = base.nameCn;
if (base.promotionPrice !== undefined) patch.promotionPrice = base.promotionPrice;
if (base.type) patch.type = base.type;
if (base.stock !== undefined) patch.stock = base.stock;
if (Object.keys(patch).length > 0) await this.productModel.update(createdProduct.id, patch);
created += 1;
} else {
// 中文注释:更新产品
const updateDTO: any = {
name: base.name || exist.name,
description: base.description || exist.description,
price: base.price !== undefined ? base.price : exist.price,
sku: base.sku,
attributes: attrDTOs,
};
// 条件判断:附加可选字段
if (base.nameCn) updateDTO.nameCn = base.nameCn;
if (base.promotionPrice !== undefined) updateDTO.promotionPrice = base.promotionPrice;
if (base.type) updateDTO.type = base.type;
if (base.stock !== undefined) updateDTO.stock = base.stock;
await this.updateProduct(exist.id, updateDTO);
updated += 1;
}
} catch (e: any) {
errors.push(e?.message || String(e));
}
}
return { created, updated, errors };
}
} }

View File

@ -187,6 +187,16 @@ export class StockService {
); );
} }
// 中文注释:检查指定 SKU 是否在任一仓库有库存(数量大于 0
async hasStockBySku(sku: string): Promise<boolean> {
const count = await this.stockModel
.createQueryBuilder('stock')
.where('stock.productSku = :sku', { sku })
.andWhere('stock.quantity > 0')
.getCount();
return count > 0;
}
async delPurchaseOrder(id: number) { async delPurchaseOrder(id: number) {
const purchaseOrder = await this.purchaseOrderModel.findOneBy({ id }); const purchaseOrder = await this.purchaseOrderModel.findOneBy({ id });
if (!purchaseOrder) throw new Error(`采购订单 ID ${id} 不存在`); if (!purchaseOrder) throw new Error(`采购订单 ID ${id} 不存在`);
@ -230,7 +240,7 @@ export class StockService {
// 获取库存列表 // 获取库存列表
async getStocks(query: QueryStockDTO) { async getStocks(query: QueryStockDTO) {
const { current = 1, pageSize = 10, productName } = query; const { current = 1, pageSize = 10, productName, productSku } = query;
const nameKeywords = productName const nameKeywords = productName
? productName.split(' ').filter(Boolean) ? productName.split(' ').filter(Boolean)
: []; : [];
@ -254,6 +264,10 @@ export class StockService {
.createQueryBuilder('stock') .createQueryBuilder('stock')
.select('COUNT(DISTINCT stock.productSku)', 'count') .select('COUNT(DISTINCT stock.productSku)', 'count')
.leftJoin(Product, 'product', 'product.sku = stock.productSku'); .leftJoin(Product, 'product', 'product.sku = stock.productSku');
if (productSku) {
queryBuilder.andWhere('stock.productSku = :productSku', { productSku });
totalQueryBuilder.andWhere('stock.productSku = :productSku', { productSku });
}
if (nameKeywords.length) { if (nameKeywords.length) {
nameKeywords.forEach((name, index) => { nameKeywords.forEach((name, index) => {
queryBuilder.andWhere( queryBuilder.andWhere(
@ -274,8 +288,51 @@ export class StockService {
); );
}); });
} }
const items = await queryBuilder.getRawMany(); if (query.order) {
const total = await totalQueryBuilder.getRawOne(); const sortFieldMap: Record<string, string> = {
productName: 'product.name',
productSku: 'stock.productSku',
updatedAt: 'updatedAt',
createdAt: 'createdAt',
};
let isFirstSort = true;
Object.entries(query.order).forEach(([field, direction]) => {
const orderDirection = direction === 'asc' ? 'ASC' : 'DESC';
if (field.startsWith('point_')) {
const pointId = field.split('_')[1];
const sortExpr = `SUM(CASE WHEN stock.stockPointId = :pointId THEN stock.quantity ELSE 0 END)`;
const sortAlias = `pointSort_${pointId}`;
queryBuilder
.addSelect(sortExpr, sortAlias)
.setParameter('pointId', Number(pointId));
if (isFirstSort) {
queryBuilder.orderBy(sortAlias, orderDirection);
isFirstSort = false;
} else {
queryBuilder.addOrderBy(sortAlias, orderDirection);
}
} else {
const actualSortField = sortFieldMap[field] || field;
if (isFirstSort) {
queryBuilder.orderBy(actualSortField, orderDirection);
isFirstSort = false;
} else {
queryBuilder.addOrderBy(actualSortField, orderDirection);
}
}
});
} else {
// 默认按产品名称排序
queryBuilder.orderBy('product.name', 'ASC');
}
const items = await queryBuilder
.offset((current - 1) * pageSize)
.limit(pageSize)
.getRawMany();
const totalResult = await totalQueryBuilder.getRawOne();
const total = parseInt(totalResult.count, 10);
const transfer = await this.transferModel const transfer = await this.transferModel
.createQueryBuilder('t') .createQueryBuilder('t')
.select(['ti.productSku as productSku', 'SUM(ti.quantity) as quantity']) .select(['ti.productSku as productSku', 'SUM(ti.quantity) as quantity'])

1
tsconfig.tsbuildinfo Normal file
View File

@ -0,0 +1 @@
{"root":["./src/configuration.ts","./src/interface.ts","./src/config/config.default.ts","./src/config/config.local.ts","./src/config/config.unittest.ts","./src/controller/api.controller.ts","./src/controller/area.controller.ts","./src/controller/customer.controller.ts","./src/controller/dict.controller.ts","./src/controller/locale.controller.ts","./src/controller/logistics.controller.ts","./src/controller/order.controller.ts","./src/controller/product.controller.ts","./src/controller/site.controller.ts","./src/controller/statistics.controller.ts","./src/controller/stock.controller.ts","./src/controller/subscription.controller.ts","./src/controller/template.controller.ts","./src/controller/user.controller.ts","./src/controller/webhook.controller.ts","./src/controller/wp_product.controller.ts","./src/db/datasource.ts","./src/db/migrations/1764238434984-product-dict-item-many-to-many.ts","./src/db/migrations/1764294088896-area.ts","./src/db/migrations/1764299629279-productstock.ts","./src/db/seeds/area.seeder.ts","./src/db/seeds/dict.seeder.ts","./src/db/seeds/template.seeder.ts","./src/decorator/user.decorator.ts","./src/dto/area.dto.ts","./src/dto/customer.dto.ts","./src/dto/dict.dto.ts","./src/dto/freightcom.dto.ts","./src/dto/logistics.dto.ts","./src/dto/order.dto.ts","./src/dto/product.dto.ts","./src/dto/reponse.dto.ts","./src/dto/site.dto.ts","./src/dto/statistics.dto.ts","./src/dto/stock.dto.ts","./src/dto/subscription.dto.ts","./src/dto/template.dto.ts","./src/dto/user.dto.ts","./src/dto/wp_product.dto.ts","./src/entity/area.entity.ts","./src/entity/auth_code.ts","./src/entity/customer.entity.ts","./src/entity/customer_tag.entity.ts","./src/entity/device_whitelist.ts","./src/entity/dict.entity.ts","./src/entity/dict_item.entity.ts","./src/entity/order.entity.ts","./src/entity/order_coupon.entity.ts","./src/entity/order_fee.entity.ts","./src/entity/order_item.entity.ts","./src/entity/order_item_original.entity.ts","./src/entity/order_items_original.entity.ts","./src/entity/order_note.entity.ts","./src/entity/order_refund.entity.ts","./src/entity/order_refund_item.entity.ts","./src/entity/order_sale.entity.ts","./src/entity/order_shipment.entity.ts","./src/entity/order_shipping.entity.ts","./src/entity/product.entity.ts","./src/entity/product_stock_component.entity.ts","./src/entity/purchase_order.entity.ts","./src/entity/purchase_order_item.entity.ts","./src/entity/service.entity.ts","./src/entity/shipment.entity.ts","./src/entity/shipment_item.entity.ts","./src/entity/shipping_address.entity.ts","./src/entity/site.entity.ts","./src/entity/stock.entity.ts","./src/entity/stock_point.entity.ts","./src/entity/stock_record.entity.ts","./src/entity/subscription.entity.ts","./src/entity/template.entity.ts","./src/entity/transfer.entity.ts","./src/entity/transfer_item.entity.ts","./src/entity/user.entity.ts","./src/entity/variation.entity.ts","./src/entity/wp_product.entity.ts","./src/enums/base.enum.ts","./src/filter/default.filter.ts","./src/filter/notfound.filter.ts","./src/job/sync_products.job.ts","./src/job/sync_shipment.job.ts","./src/middleware/auth.middleware.ts","./src/middleware/report.middleware.ts","./src/service/area.service.ts","./src/service/authcode.service.ts","./src/service/canadapost.service.ts","./src/service/customer.service.ts","./src/service/devicewhitelist.service.ts","./src/service/dict.service.ts","./src/service/freightcom.service.ts","./src/service/logistics.service.ts","./src/service/mail.service.ts","./src/service/order.service.ts","./src/service/product.service.ts","./src/service/site.service.ts","./src/service/statistics.service.ts","./src/service/stock.service.ts","./src/service/subscription.service.ts","./src/service/template.service.ts","./src/service/uni_express.service.ts","./src/service/user.service.ts","./src/service/wp.service.ts","./src/service/wp_product.service.ts","./src/utils/helper.util.ts","./src/utils/object-transform.util.ts","./src/utils/paginate.util.ts","./src/utils/paginated-response.util.ts","./src/utils/response-wrapper.util.ts","./src/utils/response.util.ts"],"version":"5.9.3"}