feat: 主要修复订单 save 逻辑 #46
|
|
@ -98,14 +98,10 @@ export class QueryOrderDTO {
|
|||
}
|
||||
|
||||
export class QueryOrderSalesDTO {
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: '是否为原产品还是库存产品' })
|
||||
@Rule(RuleType.bool().default(false))
|
||||
isSource: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
@Rule(RuleType.bool().default(false))
|
||||
exceptPackage: boolean;
|
||||
|
||||
@ApiProperty({ example: '1', description: '页码' })
|
||||
@Rule(RuleType.number())
|
||||
current: number;
|
||||
|
|
@ -114,19 +110,31 @@ export class QueryOrderSalesDTO {
|
|||
@Rule(RuleType.number())
|
||||
pageSize: number;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: '排序对象,格式如 { productName: "asc", sku: "desc" }',type: 'any', required: false })
|
||||
@Rule(RuleType.object().allow(null))
|
||||
orderBy?: Record<string, 'asc' | 'desc'>;
|
||||
// filter
|
||||
@ApiProperty({ description: '是否排除套餐' })
|
||||
@Rule(RuleType.bool().default(false))
|
||||
exceptPackage: boolean;
|
||||
|
||||
@ApiProperty({ description: '站点ID' })
|
||||
@Rule(RuleType.number())
|
||||
siteId: number;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: '名称' })
|
||||
@Rule(RuleType.string())
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'SKU' })
|
||||
@Rule(RuleType.string())
|
||||
sku: string;
|
||||
|
||||
@ApiProperty({ description: '开始日期' })
|
||||
@Rule(RuleType.date())
|
||||
startDate: Date;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: '结束日期' })
|
||||
@Rule(RuleType.date())
|
||||
endDate: Date;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,6 +272,14 @@ export class Order {
|
|||
@Expose()
|
||||
updatedAt: Date;
|
||||
|
||||
@ApiProperty({ type: 'json', nullable: true, description: '订单项列表' })
|
||||
@Expose()
|
||||
orderItems?: any[];
|
||||
|
||||
@ApiProperty({ type: 'json', nullable: true, description: '销售项列表' })
|
||||
@Expose()
|
||||
orderSales?: any[];
|
||||
|
||||
// 在插入或更新前处理用户代理字符串
|
||||
@BeforeInsert()
|
||||
@BeforeUpdate()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { ApiProperty } from '@midwayjs/swagger';
|
||||
import { Exclude, Expose } from 'class-transformer';
|
||||
import {
|
||||
BeforeInsert,
|
||||
BeforeUpdate,
|
||||
// BeforeInsert,
|
||||
// BeforeUpdate,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
|
|
@ -75,7 +75,7 @@ export class OrderSale {
|
|||
@ApiProperty({ nullable: true })
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Expose()
|
||||
size: number | null;
|
||||
size: number | null; // 其实是 strength
|
||||
|
||||
@ApiProperty()
|
||||
@Column({ default: false })
|
||||
|
|
@ -98,24 +98,24 @@ export class OrderSale {
|
|||
@Expose()
|
||||
updatedAt?: Date;
|
||||
|
||||
// === 自动计算逻辑 ===
|
||||
@BeforeInsert()
|
||||
@BeforeUpdate()
|
||||
setFlags() {
|
||||
if (!this.name) return;
|
||||
const lower = this.name.toLowerCase();
|
||||
this.isYoone = lower.includes('yoone');
|
||||
this.isZex = lower.includes('zex');
|
||||
this.isYooneNew = this.isYoone && lower.includes('new');
|
||||
let size: number | null = null;
|
||||
const sizes = [3, 6, 9, 12, 15, 18];
|
||||
for (const s of sizes) {
|
||||
if (lower.includes(s.toString())) {
|
||||
size = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.size = size;
|
||||
}
|
||||
// // === 自动计算逻辑 ===
|
||||
// @BeforeInsert()
|
||||
// @BeforeUpdate()
|
||||
// setFlags() {
|
||||
// if (!this.name) return;
|
||||
// const lower = this.name.toLowerCase();
|
||||
// this.isYoone = lower.includes('yoone');
|
||||
// this.isZex = lower.includes('zex');
|
||||
// this.isYooneNew = this.isYoone && lower.includes('new');
|
||||
// let size: number | null = null;
|
||||
// const sizes = [3, 6, 9, 12, 15, 18];
|
||||
// for (const s of sizes) {
|
||||
// if (lower.includes(s.toString())) {
|
||||
// size = s;
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// this.size = size;
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -39,6 +39,7 @@ import * as path from 'path';
|
|||
import * as os from 'os';
|
||||
import { UnifiedOrderDTO } from '../dto/site-api.dto';
|
||||
import { CustomerService } from './customer.service';
|
||||
import { ProductService } from './product.service';
|
||||
@Provide()
|
||||
export class OrderService {
|
||||
|
||||
|
|
@ -110,6 +111,8 @@ export class OrderService {
|
|||
|
||||
@Logger()
|
||||
logger; // 注入 Logger 实例
|
||||
@Inject()
|
||||
productService: ProductService;
|
||||
|
||||
/**
|
||||
* 批量同步订单
|
||||
|
|
@ -138,7 +141,7 @@ export class OrderService {
|
|||
updated: 0,
|
||||
errors: []
|
||||
};
|
||||
console.log('开始进入循环同步订单', result.length, '个订单')
|
||||
|
||||
// 遍历每个订单进行同步
|
||||
for (const order of result) {
|
||||
try {
|
||||
|
|
@ -162,7 +165,6 @@ export class OrderService {
|
|||
} else {
|
||||
syncResult.created++;
|
||||
}
|
||||
// console.log('updated', syncResult.updated, 'created:', syncResult.created)
|
||||
} catch (error) {
|
||||
// 记录错误但不中断整个同步过程
|
||||
syncResult.errors.push({
|
||||
|
|
@ -172,8 +174,6 @@ export class OrderService {
|
|||
syncResult.processed++;
|
||||
}
|
||||
}
|
||||
console.log('同步完成', syncResult.updated, 'created:', syncResult.created)
|
||||
|
||||
this.logger.debug('syncOrders result', syncResult)
|
||||
return syncResult;
|
||||
}
|
||||
|
|
@ -281,6 +281,11 @@ export class OrderService {
|
|||
console.error('更新订单状态失败,原因为:', error)
|
||||
}
|
||||
}
|
||||
async getOrderByExternalOrderId(siteId: number, externalOrderId: string) {
|
||||
return await this.orderModel.findOne({
|
||||
where: { externalOrderId: String(externalOrderId), siteId },
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 同步单个订单
|
||||
* 流程说明:
|
||||
|
|
@ -318,53 +323,34 @@ export class OrderService {
|
|||
// console.log('同步进单个订单', order)
|
||||
// 如果订单状态为 AUTO_DRAFT,则跳过处理
|
||||
if (order.status === OrderStatus.AUTO_DRAFT) {
|
||||
this.logger.debug('订单状态为 AUTO_DRAFT,跳过处理', siteId, order.id)
|
||||
return;
|
||||
}
|
||||
// 检查数据库中是否已存在该订单
|
||||
const existingOrder = await this.orderModel.findOne({
|
||||
where: { externalOrderId: String(order.id), siteId: siteId },
|
||||
});
|
||||
// 自动更新订单状态(如果需要)
|
||||
// 这里其实不用过滤不可编辑的行为,而是应在 save 中做判断
|
||||
// if(!order.is_editable && !forceUpdate){
|
||||
// this.logger.debug('订单不可编辑,跳过处理', siteId, order.id)
|
||||
// return;
|
||||
// }
|
||||
// 自动转换远程订单的状态(如果需要)
|
||||
await this.autoUpdateOrderStatus(siteId, order);
|
||||
|
||||
if(existingOrder){
|
||||
// 矫正数据库中的订单数据
|
||||
const updateData: any = { status: order.status };
|
||||
if (this.canUpdateErpStatus(existingOrder.orderStatus)) {
|
||||
updateData.orderStatus = this.mapOrderStatus(order.status as any);
|
||||
}
|
||||
// 更新订单主数据
|
||||
await this.orderModel.update({ externalOrderId: String(order.id), siteId: siteId }, updateData);
|
||||
// 更新 fulfillments 数据
|
||||
await this.saveOrderFulfillments({
|
||||
siteId,
|
||||
orderId: existingOrder.id,
|
||||
externalOrderId:order.id,
|
||||
fulfillments: fulfillments,
|
||||
});
|
||||
}
|
||||
const externalOrderId = String(order.id);
|
||||
// 这里的 saveOrder 已经包括了创建订单和更新订单
|
||||
let orderRecord: Order = await this.saveOrder(siteId, orderData);
|
||||
// 如果订单从未完成变为完成状态,则更新库存
|
||||
if (
|
||||
existingOrder &&
|
||||
existingOrder.orderStatus !== ErpOrderStatus.COMPLETED &&
|
||||
orderRecord &&
|
||||
orderRecord.orderStatus !== ErpOrderStatus.COMPLETED &&
|
||||
orderData.status === OrderStatus.COMPLETED
|
||||
) {
|
||||
this.updateStock(existingOrder);
|
||||
await this.updateStock(orderRecord);
|
||||
// 不再直接返回,继续执行后续的更新操作
|
||||
}
|
||||
// 如果订单不可编辑且不强制更新,则跳过处理
|
||||
if (existingOrder && !existingOrder.is_editable && !forceUpdate) {
|
||||
return;
|
||||
}
|
||||
// 保存订单主数据
|
||||
const orderRecord = await this.saveOrder(siteId, orderData);
|
||||
const externalOrderId = String(order.id);
|
||||
const orderId = orderRecord.id;
|
||||
// 保存订单项
|
||||
await this.saveOrderItems({
|
||||
siteId,
|
||||
orderId,
|
||||
externalOrderId: String(externalOrderId),
|
||||
externalOrderId,
|
||||
orderItems: line_items,
|
||||
});
|
||||
// 保存退款信息
|
||||
|
|
@ -462,7 +448,8 @@ export class OrderService {
|
|||
* @param order 订单数据
|
||||
* @returns 保存后的订单实体
|
||||
*/
|
||||
async saveOrder(siteId: number, order: Partial<UnifiedOrderDTO>): Promise<Order> {
|
||||
// 这里 omit 是因为处理在外头了 其实 saveOrder 应该包括 savelineitems 等
|
||||
async saveOrder(siteId: number, order: Omit<UnifiedOrderDTO, 'line_items' | 'refunds'>): Promise<Order> {
|
||||
// 将外部订单ID转换为字符串
|
||||
const externalOrderId = String(order.id)
|
||||
delete order.id
|
||||
|
|
@ -711,6 +698,8 @@ export class OrderService {
|
|||
*
|
||||
* @param orderItem 订单项实体
|
||||
*/
|
||||
// TODO 这里存的是库存商品实际
|
||||
// 所以叫做 orderInventoryItems 可能更合适
|
||||
async saveOrderSale(orderItem: OrderItem) {
|
||||
const currentOrderSale = await this.orderSaleModel.find({
|
||||
where: {
|
||||
|
|
@ -729,46 +718,47 @@ export class OrderService {
|
|||
});
|
||||
|
||||
if (!product) return;
|
||||
|
||||
const orderSales: OrderSale[] = [];
|
||||
|
||||
if (product.components && product.components.length > 0) {
|
||||
for (const comp of product.components) {
|
||||
const baseProduct = await this.productModel.findOne({
|
||||
const componentDetails: { product: Product, quantity: number }[] = product.components?.length > 0 ? await Promise.all(product.components.map(async comp => {
|
||||
return {
|
||||
product: await this.productModel.findOne({
|
||||
where: { sku: comp.sku },
|
||||
});
|
||||
if (baseProduct) {
|
||||
const orderSaleItem: OrderSale = plainToClass(OrderSale, {
|
||||
orderId: orderItem.orderId,
|
||||
siteId: orderItem.siteId,
|
||||
externalOrderItemId: orderItem.externalOrderItemId,
|
||||
productId: baseProduct.id,
|
||||
name: baseProduct.name,
|
||||
relations: ['components', 'attributes'],
|
||||
}),
|
||||
quantity: comp.quantity * orderItem.quantity,
|
||||
sku: comp.sku,
|
||||
isPackage: orderItem.name.toLowerCase().includes('package'),
|
||||
});
|
||||
orderSales.push(orderSaleItem);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const orderSaleItem: OrderSale = plainToClass(OrderSale, {
|
||||
})) : [{ product, quantity: orderItem.quantity }]
|
||||
|
||||
const orderSales: OrderSale[] = componentDetails.map(componentDetail => {
|
||||
if (!componentDetail.product) return null
|
||||
const attrsObj = this.productService.getAttributesObject(product.attributes)
|
||||
const orderSale = plainToClass(OrderSale, {
|
||||
orderId: orderItem.orderId,
|
||||
siteId: orderItem.siteId,
|
||||
externalOrderItemId: orderItem.externalOrderItemId,
|
||||
productId: product.id,
|
||||
name: product.name,
|
||||
quantity: orderItem.quantity,
|
||||
sku: product.sku,
|
||||
isPackage: orderItem.name.toLowerCase().includes('package'),
|
||||
productId: componentDetail.product.id,
|
||||
name: componentDetail.product.name,
|
||||
quantity: componentDetail.quantity * orderItem.quantity,
|
||||
sku: componentDetail.product.sku,
|
||||
isPackage: componentDetail.product.type === 'bundle',
|
||||
isYoone: attrsObj?.['brand']?.name === 'yoone',
|
||||
isZyn: attrsObj?.['brand']?.name === 'zyn',
|
||||
isZex: attrsObj?.['brand']?.name === 'zex',
|
||||
isYooneNew: attrsObj?.['brand']?.name === 'yoone' && attrsObj?.['version']?.name === 'new',
|
||||
size: this.extractNumberFromString(attrsObj?.['strength']?.name) || null,
|
||||
});
|
||||
orderSales.push(orderSaleItem);
|
||||
}
|
||||
return orderSale
|
||||
}).filter(v => v !== null)
|
||||
|
||||
if (orderSales.length > 0) {
|
||||
await this.orderSaleModel.save(orderSales);
|
||||
}
|
||||
}
|
||||
extractNumberFromString(str: string): number {
|
||||
if (!str) return 0;
|
||||
|
||||
const num = parseInt(str, 10);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存订单退款信息
|
||||
|
|
@ -1237,13 +1227,13 @@ export class OrderService {
|
|||
parameters.push(siteId);
|
||||
}
|
||||
if (startDate) {
|
||||
sqlQuery += ` AND o.date_paid >= ?`;
|
||||
totalQuery += ` AND o.date_paid >= ?`;
|
||||
sqlQuery += ` AND o.date_created >= ?`;
|
||||
totalQuery += ` AND o.date_created >= ?`;
|
||||
parameters.push(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
sqlQuery += ` AND o.date_paid <= ?`;
|
||||
totalQuery += ` AND o.date_paid <= ?`;
|
||||
sqlQuery += ` AND o.date_created <= ?`;
|
||||
totalQuery += ` AND o.date_created <= ?`;
|
||||
parameters.push(endDate);
|
||||
}
|
||||
// 支付方式筛选(使用参数化,避免SQL注入)
|
||||
|
|
@ -1331,7 +1321,7 @@ export class OrderService {
|
|||
// 添加分页到主查询
|
||||
sqlQuery += `
|
||||
GROUP BY o.id
|
||||
ORDER BY o.date_paid DESC
|
||||
ORDER BY o.date_created DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
parameters.push(pageSize, (current - 1) * pageSize);
|
||||
|
|
@ -1429,7 +1419,7 @@ export class OrderService {
|
|||
* @param params 查询参数
|
||||
* @returns 销售统计和分页信息
|
||||
*/
|
||||
async getOrderSales({ siteId, startDate, endDate, current, pageSize, name, exceptPackage }: QueryOrderSalesDTO) {
|
||||
async getOrderSales({ siteId, startDate, endDate, current, pageSize, name, exceptPackage, orderBy }: QueryOrderSalesDTO) {
|
||||
const nameKeywords = name ? name.split(' ').filter(Boolean) : [];
|
||||
const defaultStart = dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss');
|
||||
const defaultEnd = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
|
||||
|
|
@ -1645,11 +1635,12 @@ export class OrderService {
|
|||
* @returns 订单项统计和分页信息
|
||||
*/
|
||||
async getOrderItems({
|
||||
current,
|
||||
pageSize,
|
||||
siteId,
|
||||
startDate,
|
||||
endDate,
|
||||
current,
|
||||
pageSize,
|
||||
sku,
|
||||
name,
|
||||
}: QueryOrderSalesDTO) {
|
||||
const nameKeywords = name ? name.split(' ').filter(Boolean) : [];
|
||||
|
|
@ -2548,7 +2539,7 @@ export class OrderService {
|
|||
'姓名地址': nameAddress,
|
||||
'邮箱': order.customer_email || '',
|
||||
'号码': phone,
|
||||
'订单内容': this.removeLastParenthesesContent(orderContent),
|
||||
'订单内容': orderContent,
|
||||
'盒数': boxCount,
|
||||
'换盒数': exchangeBoxCount,
|
||||
'换货内容': exchangeContent,
|
||||
|
|
@ -2648,85 +2639,4 @@ async exportToCsv(data: any[], options: { type?: 'string' | 'buffer'; fileName?:
|
|||
throw new Error(`导出CSV文件失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除每个分号前面一个左右括号和最后一个左右括号包含的内容(包括括号本身)
|
||||
* @param str 输入字符串
|
||||
* @returns 删除后的字符串
|
||||
*/
|
||||
removeLastParenthesesContent(str: string): string {
|
||||
if (!str || typeof str !== 'string') {
|
||||
return str;
|
||||
}
|
||||
|
||||
// 辅助函数:删除指定位置的括号对及其内容
|
||||
const removeParenthesesAt = (s: string, leftIndex: number): string => {
|
||||
if (leftIndex === -1) return s;
|
||||
|
||||
let rightIndex = -1;
|
||||
let parenCount = 0;
|
||||
|
||||
for (let i = leftIndex; i < s.length; i++) {
|
||||
const char = s[i];
|
||||
if (char === '(') {
|
||||
parenCount++;
|
||||
} else if (char === ')') {
|
||||
parenCount--;
|
||||
if (parenCount === 0) {
|
||||
rightIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rightIndex !== -1) {
|
||||
return s.substring(0, leftIndex) + s.substring(rightIndex + 1);
|
||||
}
|
||||
|
||||
return s;
|
||||
};
|
||||
|
||||
// 1. 处理每个分号前面的括号对
|
||||
let result = str;
|
||||
|
||||
// 找出所有分号的位置
|
||||
const semicolonIndices: number[] = [];
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (result[i] === ';') {
|
||||
semicolonIndices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 从后向前处理每个分号,避免位置变化影响后续处理
|
||||
for (let i = semicolonIndices.length - 1; i >= 0; i--) {
|
||||
const semicolonIndex = semicolonIndices[i];
|
||||
|
||||
// 从分号位置向前查找最近的左括号
|
||||
let lastLeftParenIndex = -1;
|
||||
for (let j = semicolonIndex - 1; j >= 0; j--) {
|
||||
if (result[j] === '(') {
|
||||
lastLeftParenIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到左括号,删除该括号对及其内容
|
||||
if (lastLeftParenIndex !== -1) {
|
||||
result = removeParenthesesAt(result, lastLeftParenIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理整个字符串的最后一个括号对
|
||||
let lastLeftParenIndex = result.lastIndexOf('(');
|
||||
if (lastLeftParenIndex !== -1) {
|
||||
result = removeParenthesesAt(result, lastLeftParenIndex);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1466,7 +1466,12 @@ export class ProductService {
|
|||
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,
|
||||
siteSkus: rec.siteSkus
|
||||
? String(rec.siteSkus)
|
||||
.split(/[;,]/) // 支持英文分号或英文逗号分隔
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
category, // 添加分类字段
|
||||
|
||||
attributes: attributes.length > 0 ? attributes : undefined,
|
||||
|
|
@ -1531,7 +1536,14 @@ export class ProductService {
|
|||
|
||||
return dto;
|
||||
}
|
||||
|
||||
getAttributesObject(attributes:DictItem[]){
|
||||
if(!attributes) return {}
|
||||
const obj:any = {}
|
||||
attributes.forEach(attr=>{
|
||||
obj[attr.dict.name] = attr
|
||||
})
|
||||
return obj
|
||||
}
|
||||
// 将单个产品转换为 CSV 行数组
|
||||
transformProductToCsvRow(
|
||||
p: Product,
|
||||
|
|
|
|||
Loading…
Reference in New Issue