674 lines
19 KiB
TypeScript
674 lines
19 KiB
TypeScript
|
|
import db from '../config/database';
|
||
|
|
import { logger } from '../utils/logger';
|
||
|
|
|
||
|
|
export interface ReturnRequest {
|
||
|
|
orderId: string;
|
||
|
|
reason: string;
|
||
|
|
items: ReturnItem[];
|
||
|
|
tenantId: string;
|
||
|
|
shopId: string;
|
||
|
|
taskId: string;
|
||
|
|
traceId: string;
|
||
|
|
businessType: 'TOC' | 'TOB';
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ReturnItem {
|
||
|
|
productId: string;
|
||
|
|
skuId: string;
|
||
|
|
quantity: number;
|
||
|
|
reason?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ReturnRecord {
|
||
|
|
id: string;
|
||
|
|
orderId: string;
|
||
|
|
status: ReturnStatus;
|
||
|
|
reason: string;
|
||
|
|
items: ReturnItem[];
|
||
|
|
totalRefundAmount: number;
|
||
|
|
tenantId: string;
|
||
|
|
shopId: string;
|
||
|
|
taskId: string;
|
||
|
|
traceId: string;
|
||
|
|
businessType: 'TOC' | 'TOB';
|
||
|
|
createdAt: Date;
|
||
|
|
updatedAt: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type ReturnStatus =
|
||
|
|
| 'PENDING'
|
||
|
|
| 'APPROVED'
|
||
|
|
| 'REJECTED'
|
||
|
|
| 'RETURNED'
|
||
|
|
| 'REFUNDED'
|
||
|
|
| 'CLOSED';
|
||
|
|
|
||
|
|
export interface RefundRequest {
|
||
|
|
returnId: string;
|
||
|
|
approvalResult: 'APPROVED' | 'REJECTED';
|
||
|
|
approvedAmount?: number;
|
||
|
|
reason?: string;
|
||
|
|
tenantId: string;
|
||
|
|
shopId: string;
|
||
|
|
taskId: string;
|
||
|
|
traceId: string;
|
||
|
|
businessType: 'TOC' | 'TOB';
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface RefundRecord {
|
||
|
|
id: string;
|
||
|
|
returnId: string;
|
||
|
|
orderId: string;
|
||
|
|
amount: number;
|
||
|
|
status: RefundStatus;
|
||
|
|
method: string;
|
||
|
|
tenantId: string;
|
||
|
|
shopId: string;
|
||
|
|
taskId: string;
|
||
|
|
traceId: string;
|
||
|
|
businessType: 'TOC' | 'TOB';
|
||
|
|
createdAt: Date;
|
||
|
|
updatedAt: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type RefundStatus =
|
||
|
|
| 'PENDING_REVIEW'
|
||
|
|
| 'APPROVED'
|
||
|
|
| 'PROCESSING'
|
||
|
|
| 'COMPLETED'
|
||
|
|
| 'FAILED'
|
||
|
|
| 'CANCELLED';
|
||
|
|
|
||
|
|
export interface ServiceTicket {
|
||
|
|
id: string;
|
||
|
|
returnId: string;
|
||
|
|
type: TicketType;
|
||
|
|
priority: TicketPriority;
|
||
|
|
status: TicketStatus;
|
||
|
|
subject: string;
|
||
|
|
description: string;
|
||
|
|
messages: TicketMessage[];
|
||
|
|
tenantId: string;
|
||
|
|
shopId: string;
|
||
|
|
taskId: string;
|
||
|
|
traceId: string;
|
||
|
|
businessType: 'TOC' | 'TOB';
|
||
|
|
createdAt: Date;
|
||
|
|
updatedAt: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type TicketType =
|
||
|
|
| 'RETURN'
|
||
|
|
| 'REFUND'
|
||
|
|
| 'EXCHANGE'
|
||
|
|
| 'COMPLAINT'
|
||
|
|
| 'INQUIRY';
|
||
|
|
|
||
|
|
export type TicketPriority =
|
||
|
|
| 'LOW'
|
||
|
|
| 'MEDIUM'
|
||
|
|
| 'HIGH'
|
||
|
|
| 'URGENT';
|
||
|
|
|
||
|
|
export type TicketStatus =
|
||
|
|
| 'OPEN'
|
||
|
|
| 'IN_PROGRESS'
|
||
|
|
| 'WAITING_CUSTOMER'
|
||
|
|
| 'WAITING_INTERNAL'
|
||
|
|
| 'RESOLVED'
|
||
|
|
| 'CLOSED';
|
||
|
|
|
||
|
|
export interface TicketMessage {
|
||
|
|
id: string;
|
||
|
|
sender: 'CUSTOMER' | 'AGENT' | 'SYSTEM';
|
||
|
|
content: string;
|
||
|
|
attachments?: string[];
|
||
|
|
createdAt: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export class AfterSalesService {
|
||
|
|
static async createReturnRequest(input: ReturnRequest): Promise<ReturnRecord> {
|
||
|
|
const { orderId, reason, items, tenantId, shopId, taskId, traceId, businessType } = input;
|
||
|
|
|
||
|
|
logger.info('[AfterSalesService] Creating return request', {
|
||
|
|
orderId,
|
||
|
|
tenantId,
|
||
|
|
shopId,
|
||
|
|
taskId,
|
||
|
|
traceId,
|
||
|
|
businessType,
|
||
|
|
itemCount: items.length,
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
const order = await this.getOrderById(orderId, tenantId, traceId);
|
||
|
|
if (!order) {
|
||
|
|
throw new Error(`Order not found: ${orderId}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (order.status !== 'DELIVERED' && order.status !== 'COMPLETED') {
|
||
|
|
throw new Error(`Order status ${order.status} does not allow return`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const returnDaysLimit = 30;
|
||
|
|
const deliveredAt = order.deliveredAt || order.updatedAt;
|
||
|
|
const daysSinceDelivery = Math.floor(
|
||
|
|
(Date.now() - deliveredAt.getTime()) / (1000 * 60 * 60 * 24)
|
||
|
|
);
|
||
|
|
|
||
|
|
if (daysSinceDelivery > returnDaysLimit) {
|
||
|
|
throw new Error(`Return period expired (${daysSinceDelivery} days)`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const returnId = `RET-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||
|
|
|
||
|
|
const totalRefundAmount = await this.calculateRefundAmount(
|
||
|
|
order,
|
||
|
|
items,
|
||
|
|
tenantId,
|
||
|
|
traceId
|
||
|
|
);
|
||
|
|
|
||
|
|
const returnRecord: ReturnRecord = {
|
||
|
|
id: returnId,
|
||
|
|
orderId,
|
||
|
|
status: 'PENDING',
|
||
|
|
reason,
|
||
|
|
items,
|
||
|
|
totalRefundAmount,
|
||
|
|
tenantId,
|
||
|
|
shopId,
|
||
|
|
taskId,
|
||
|
|
traceId,
|
||
|
|
businessType,
|
||
|
|
createdAt: new Date(),
|
||
|
|
updatedAt: new Date(),
|
||
|
|
};
|
||
|
|
|
||
|
|
await db('cf_return_requests').insert({
|
||
|
|
id: returnRecord.id,
|
||
|
|
order_id: returnRecord.orderId,
|
||
|
|
status: returnRecord.status,
|
||
|
|
reason: returnRecord.reason,
|
||
|
|
items: JSON.stringify(returnRecord.items),
|
||
|
|
total_refund_amount: returnRecord.totalRefundAmount,
|
||
|
|
tenant_id: returnRecord.tenantId,
|
||
|
|
shop_id: returnRecord.shopId,
|
||
|
|
task_id: returnRecord.taskId,
|
||
|
|
trace_id: returnRecord.traceId,
|
||
|
|
business_type: returnRecord.businessType,
|
||
|
|
created_at: returnRecord.createdAt,
|
||
|
|
updated_at: returnRecord.updatedAt,
|
||
|
|
});
|
||
|
|
|
||
|
|
await this.createServiceTicket({
|
||
|
|
returnId,
|
||
|
|
type: 'RETURN',
|
||
|
|
priority: this.determinePriority(reason),
|
||
|
|
subject: `Return Request - Order ${orderId}`,
|
||
|
|
description: reason,
|
||
|
|
tenantId,
|
||
|
|
shopId,
|
||
|
|
taskId,
|
||
|
|
traceId,
|
||
|
|
businessType,
|
||
|
|
});
|
||
|
|
|
||
|
|
logger.info('[AfterSalesService] Return request created', {
|
||
|
|
returnId,
|
||
|
|
orderId,
|
||
|
|
tenantId,
|
||
|
|
traceId,
|
||
|
|
status: returnRecord.status,
|
||
|
|
});
|
||
|
|
|
||
|
|
return returnRecord;
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error('[AfterSalesService] Failed to create return request', {
|
||
|
|
orderId,
|
||
|
|
tenantId,
|
||
|
|
traceId,
|
||
|
|
error: error.message,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
static async processRefund(input: RefundRequest): Promise<RefundRecord> {
|
||
|
|
const { returnId, approvalResult, approvedAmount, reason, tenantId, shopId, taskId, traceId, businessType } = input;
|
||
|
|
|
||
|
|
logger.info('[AfterSalesService] Processing refund', {
|
||
|
|
returnId,
|
||
|
|
approvalResult,
|
||
|
|
tenantId,
|
||
|
|
shopId,
|
||
|
|
taskId,
|
||
|
|
traceId,
|
||
|
|
businessType,
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
const returnRecord = await this.getReturnById(returnId, tenantId, traceId);
|
||
|
|
if (!returnRecord) {
|
||
|
|
throw new Error(`Return request not found: ${returnId}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (returnRecord.status !== 'PENDING' && returnRecord.status !== 'APPROVED') {
|
||
|
|
throw new Error(`Return status ${returnRecord.status} cannot be refunded`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const refundId = `REF-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||
|
|
const refundAmount = approvedAmount || returnRecord.totalRefundAmount;
|
||
|
|
|
||
|
|
let refundStatus: RefundStatus;
|
||
|
|
if (approvalResult === 'REJECTED') {
|
||
|
|
refundStatus = 'CANCELLED';
|
||
|
|
} else {
|
||
|
|
refundStatus = 'PENDING_REVIEW';
|
||
|
|
}
|
||
|
|
|
||
|
|
const refundRecord: RefundRecord = {
|
||
|
|
id: refundId,
|
||
|
|
returnId,
|
||
|
|
orderId: returnRecord.orderId,
|
||
|
|
amount: refundAmount,
|
||
|
|
status: refundStatus,
|
||
|
|
method: 'ORIGINAL_PAYMENT',
|
||
|
|
tenantId,
|
||
|
|
shopId,
|
||
|
|
taskId,
|
||
|
|
traceId,
|
||
|
|
businessType,
|
||
|
|
createdAt: new Date(),
|
||
|
|
updatedAt: new Date(),
|
||
|
|
};
|
||
|
|
|
||
|
|
await db('cf_refunds').insert({
|
||
|
|
id: refundRecord.id,
|
||
|
|
return_id: refundRecord.returnId,
|
||
|
|
order_id: refundRecord.orderId,
|
||
|
|
amount: refundRecord.amount,
|
||
|
|
status: refundRecord.status,
|
||
|
|
method: refundRecord.method,
|
||
|
|
tenant_id: refundRecord.tenantId,
|
||
|
|
shop_id: refundRecord.shopId,
|
||
|
|
task_id: refundRecord.taskId,
|
||
|
|
trace_id: refundRecord.traceId,
|
||
|
|
business_type: refundRecord.businessType,
|
||
|
|
created_at: refundRecord.createdAt,
|
||
|
|
updated_at: refundRecord.updatedAt,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (approvalResult === 'APPROVED') {
|
||
|
|
await db('cf_return_requests')
|
||
|
|
.where({ id: returnId, tenant_id: tenantId })
|
||
|
|
.update({ status: 'APPROVED', updated_at: new Date() });
|
||
|
|
|
||
|
|
await this.executeRefundWorkflow(refundRecord, tenantId, traceId);
|
||
|
|
} else {
|
||
|
|
await db('cf_return_requests')
|
||
|
|
.where({ id: returnId, tenant_id: tenantId })
|
||
|
|
.update({ status: 'REJECTED', updated_at: new Date() });
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info('[AfterSalesService] Refund processed', {
|
||
|
|
refundId,
|
||
|
|
returnId,
|
||
|
|
status: refundRecord.status,
|
||
|
|
tenantId,
|
||
|
|
traceId,
|
||
|
|
});
|
||
|
|
|
||
|
|
return refundRecord;
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error('[AfterSalesService] Failed to process refund', {
|
||
|
|
returnId,
|
||
|
|
tenantId,
|
||
|
|
traceId,
|
||
|
|
error: error.message,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
static async createServiceTicket(input: {
|
||
|
|
returnId: string;
|
||
|
|
type: TicketType;
|
||
|
|
priority: TicketPriority;
|
||
|
|
subject: string;
|
||
|
|
description: string;
|
||
|
|
tenantId: string;
|
||
|
|
shopId: string;
|
||
|
|
taskId: string;
|
||
|
|
traceId: string;
|
||
|
|
businessType: 'TOC' | 'TOB';
|
||
|
|
}): Promise<ServiceTicket> {
|
||
|
|
const { returnId, type, priority, subject, description, tenantId, shopId, taskId, traceId, businessType } = input;
|
||
|
|
|
||
|
|
logger.info('[AfterSalesService] Creating service ticket', {
|
||
|
|
returnId,
|
||
|
|
type,
|
||
|
|
priority,
|
||
|
|
tenantId,
|
||
|
|
shopId,
|
||
|
|
taskId,
|
||
|
|
traceId,
|
||
|
|
businessType,
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
const ticketId = `TKT-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||
|
|
|
||
|
|
const initialMessage: TicketMessage = {
|
||
|
|
id: `MSG-${Date.now()}`,
|
||
|
|
sender: 'SYSTEM',
|
||
|
|
content: `Ticket created: ${subject}`,
|
||
|
|
createdAt: new Date(),
|
||
|
|
};
|
||
|
|
|
||
|
|
const ticket: ServiceTicket = {
|
||
|
|
id: ticketId,
|
||
|
|
returnId,
|
||
|
|
type,
|
||
|
|
priority,
|
||
|
|
status: 'OPEN',
|
||
|
|
subject,
|
||
|
|
description,
|
||
|
|
messages: [initialMessage],
|
||
|
|
tenantId,
|
||
|
|
shopId,
|
||
|
|
taskId,
|
||
|
|
traceId,
|
||
|
|
businessType,
|
||
|
|
createdAt: new Date(),
|
||
|
|
updatedAt: new Date(),
|
||
|
|
};
|
||
|
|
|
||
|
|
await db('cf_service_tickets').insert({
|
||
|
|
id: ticket.id,
|
||
|
|
return_id: ticket.returnId,
|
||
|
|
type: ticket.type,
|
||
|
|
priority: ticket.priority,
|
||
|
|
status: ticket.status,
|
||
|
|
subject: ticket.subject,
|
||
|
|
description: ticket.description,
|
||
|
|
messages: JSON.stringify(ticket.messages),
|
||
|
|
tenant_id: ticket.tenantId,
|
||
|
|
shop_id: ticket.shopId,
|
||
|
|
task_id: ticket.taskId,
|
||
|
|
trace_id: ticket.traceId,
|
||
|
|
business_type: ticket.businessType,
|
||
|
|
created_at: ticket.createdAt,
|
||
|
|
updated_at: ticket.updatedAt,
|
||
|
|
});
|
||
|
|
|
||
|
|
logger.info('[AfterSalesService] Service ticket created', {
|
||
|
|
ticketId,
|
||
|
|
returnId,
|
||
|
|
type,
|
||
|
|
priority,
|
||
|
|
tenantId,
|
||
|
|
traceId,
|
||
|
|
});
|
||
|
|
|
||
|
|
return ticket;
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error('[AfterSalesService] Failed to create service ticket', {
|
||
|
|
returnId,
|
||
|
|
tenantId,
|
||
|
|
traceId,
|
||
|
|
error: error.message,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
static async addTicketMessage(
|
||
|
|
ticketId: string,
|
||
|
|
sender: 'CUSTOMER' | 'AGENT' | 'SYSTEM',
|
||
|
|
content: string,
|
||
|
|
options: {
|
||
|
|
tenantId: string;
|
||
|
|
traceId: string;
|
||
|
|
attachments?: string[];
|
||
|
|
}
|
||
|
|
): Promise<ServiceTicket> {
|
||
|
|
const { tenantId, traceId, attachments } = options;
|
||
|
|
|
||
|
|
logger.info('[AfterSalesService] Adding ticket message', {
|
||
|
|
ticketId,
|
||
|
|
sender,
|
||
|
|
tenantId,
|
||
|
|
traceId,
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
const ticket = await db('cf_service_tickets')
|
||
|
|
.where({ id: ticketId, tenant_id: tenantId })
|
||
|
|
.first();
|
||
|
|
|
||
|
|
if (!ticket) {
|
||
|
|
throw new Error(`Ticket not found: ${ticketId}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const messages: TicketMessage[] = JSON.parse(ticket.messages || '[]');
|
||
|
|
const newMessage: TicketMessage = {
|
||
|
|
id: `MSG-${Date.now()}`,
|
||
|
|
sender,
|
||
|
|
content,
|
||
|
|
attachments,
|
||
|
|
createdAt: new Date(),
|
||
|
|
};
|
||
|
|
|
||
|
|
messages.push(newMessage);
|
||
|
|
|
||
|
|
await db('cf_service_tickets')
|
||
|
|
.where({ id: ticketId, tenant_id: tenantId })
|
||
|
|
.update({
|
||
|
|
messages: JSON.stringify(messages),
|
||
|
|
status: sender === 'CUSTOMER' ? 'IN_PROGRESS' : 'WAITING_CUSTOMER',
|
||
|
|
updated_at: new Date(),
|
||
|
|
});
|
||
|
|
|
||
|
|
logger.info('[AfterSalesService] Ticket message added', {
|
||
|
|
ticketId,
|
||
|
|
sender,
|
||
|
|
tenantId,
|
||
|
|
traceId,
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
...ticket,
|
||
|
|
messages,
|
||
|
|
} as ServiceTicket;
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error('[AfterSalesService] Failed to add ticket message', {
|
||
|
|
ticketId,
|
||
|
|
tenantId,
|
||
|
|
traceId,
|
||
|
|
error: error.message,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
static async getReturnById(returnId: string, tenantId: string, traceId: string): Promise<ReturnRecord | null> {
|
||
|
|
const record = await db('cf_return_requests')
|
||
|
|
.where({ id: returnId, tenant_id: tenantId })
|
||
|
|
.first();
|
||
|
|
|
||
|
|
if (!record) return null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: record.id,
|
||
|
|
orderId: record.order_id,
|
||
|
|
status: record.status,
|
||
|
|
reason: record.reason,
|
||
|
|
items: JSON.parse(record.items || '[]'),
|
||
|
|
totalRefundAmount: Number(record.total_refund_amount),
|
||
|
|
tenantId: record.tenant_id,
|
||
|
|
shopId: record.shop_id,
|
||
|
|
taskId: record.task_id,
|
||
|
|
traceId: record.trace_id,
|
||
|
|
businessType: record.business_type,
|
||
|
|
createdAt: record.created_at,
|
||
|
|
updatedAt: record.updated_at,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async getOrderById(orderId: string, tenantId: string, traceId: string): Promise<any> {
|
||
|
|
const order = await db('cf_orders')
|
||
|
|
.where({ id: orderId, tenant_id: tenantId })
|
||
|
|
.first();
|
||
|
|
|
||
|
|
return order;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async calculateRefundAmount(
|
||
|
|
order: any,
|
||
|
|
items: ReturnItem[],
|
||
|
|
tenantId: string,
|
||
|
|
traceId: string
|
||
|
|
): Promise<number> {
|
||
|
|
const orderItems = JSON.parse(order.items || '[]');
|
||
|
|
let totalAmount = 0;
|
||
|
|
|
||
|
|
for (const returnItem of items) {
|
||
|
|
const orderItem = orderItems.find((oi: any) =>
|
||
|
|
oi.productId === returnItem.productId && oi.skuId === returnItem.skuId
|
||
|
|
);
|
||
|
|
|
||
|
|
if (orderItem) {
|
||
|
|
totalAmount += orderItem.unitPrice * returnItem.quantity;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return Number(totalAmount.toFixed(2));
|
||
|
|
}
|
||
|
|
|
||
|
|
private static determinePriority(reason: string): TicketPriority {
|
||
|
|
const highPriorityKeywords = ['损坏', '缺失', '错误', 'damaged', 'missing', 'wrong', 'defective'];
|
||
|
|
const urgentKeywords = ['欺诈', '投诉', 'fraud', 'complaint', 'legal'];
|
||
|
|
|
||
|
|
const lowerReason = reason.toLowerCase();
|
||
|
|
|
||
|
|
if (urgentKeywords.some(kw => lowerReason.includes(kw))) {
|
||
|
|
return 'URGENT';
|
||
|
|
}
|
||
|
|
if (highPriorityKeywords.some(kw => lowerReason.includes(kw))) {
|
||
|
|
return 'HIGH';
|
||
|
|
}
|
||
|
|
return 'MEDIUM';
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async executeRefundWorkflow(
|
||
|
|
refund: RefundRecord,
|
||
|
|
tenantId: string,
|
||
|
|
traceId: string
|
||
|
|
): Promise<void> {
|
||
|
|
logger.info('[AfterSalesService] Executing refund workflow', {
|
||
|
|
refundId: refund.id,
|
||
|
|
tenantId,
|
||
|
|
traceId,
|
||
|
|
});
|
||
|
|
|
||
|
|
await db('cf_refunds')
|
||
|
|
.where({ id: refund.id, tenant_id: tenantId })
|
||
|
|
.update({ status: 'PROCESSING', updated_at: new Date() });
|
||
|
|
|
||
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
|
|
||
|
|
await db('cf_refunds')
|
||
|
|
.where({ id: refund.id, tenant_id: tenantId })
|
||
|
|
.update({ status: 'COMPLETED', updated_at: new Date() });
|
||
|
|
|
||
|
|
await db('cf_return_requests')
|
||
|
|
.where({ id: refund.returnId, tenant_id: tenantId })
|
||
|
|
.update({ status: 'REFUNDED', updated_at: new Date() });
|
||
|
|
|
||
|
|
logger.info('[AfterSalesService] Refund workflow completed', {
|
||
|
|
refundId: refund.id,
|
||
|
|
tenantId,
|
||
|
|
traceId,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
static async initializeTables(): Promise<void> {
|
||
|
|
logger.info('[AfterSalesService] Initializing after-sales tables');
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (!(await db.schema.hasTable('cf_return_requests'))) {
|
||
|
|
await db.schema.createTable('cf_return_requests', table => {
|
||
|
|
table.string('id').primary();
|
||
|
|
table.string('order_id').notNullable();
|
||
|
|
table.enum('status', ['PENDING', 'APPROVED', 'REJECTED', 'RETURNED', 'REFUNDED', 'CLOSED']).defaultTo('PENDING');
|
||
|
|
table.text('reason');
|
||
|
|
table.json('items');
|
||
|
|
table.decimal('total_refund_amount', 12, 2).defaultTo(0);
|
||
|
|
table.string('tenant_id').notNullable();
|
||
|
|
table.string('shop_id');
|
||
|
|
table.string('task_id');
|
||
|
|
table.string('trace_id');
|
||
|
|
table.enum('business_type', ['TOC', 'TOB']).defaultTo('TOC');
|
||
|
|
table.timestamp('created_at').defaultTo(db.fn.now());
|
||
|
|
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||
|
|
table.index(['order_id', 'tenant_id']);
|
||
|
|
table.index(['status', 'tenant_id']);
|
||
|
|
});
|
||
|
|
logger.info('[AfterSalesService] Table cf_return_requests created');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!(await db.schema.hasTable('cf_refunds'))) {
|
||
|
|
await db.schema.createTable('cf_refunds', table => {
|
||
|
|
table.string('id').primary();
|
||
|
|
table.string('return_id').notNullable();
|
||
|
|
table.string('order_id').notNullable();
|
||
|
|
table.decimal('amount', 12, 2).notNullable();
|
||
|
|
table.enum('status', ['PENDING_REVIEW', 'APPROVED', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED']).defaultTo('PENDING_REVIEW');
|
||
|
|
table.string('method').defaultTo('ORIGINAL_PAYMENT');
|
||
|
|
table.string('tenant_id').notNullable();
|
||
|
|
table.string('shop_id');
|
||
|
|
table.string('task_id');
|
||
|
|
table.string('trace_id');
|
||
|
|
table.enum('business_type', ['TOC', 'TOB']).defaultTo('TOC');
|
||
|
|
table.timestamp('created_at').defaultTo(db.fn.now());
|
||
|
|
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||
|
|
table.index(['return_id', 'tenant_id']);
|
||
|
|
table.index(['order_id', 'tenant_id']);
|
||
|
|
table.index(['status', 'tenant_id']);
|
||
|
|
});
|
||
|
|
logger.info('[AfterSalesService] Table cf_refunds created');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!(await db.schema.hasTable('cf_service_tickets'))) {
|
||
|
|
await db.schema.createTable('cf_service_tickets', table => {
|
||
|
|
table.string('id').primary();
|
||
|
|
table.string('return_id');
|
||
|
|
table.enum('type', ['RETURN', 'REFUND', 'EXCHANGE', 'COMPLAINT', 'INQUIRY']).defaultTo('INQUIRY');
|
||
|
|
table.enum('priority', ['LOW', 'MEDIUM', 'HIGH', 'URGENT']).defaultTo('MEDIUM');
|
||
|
|
table.enum('status', ['OPEN', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'WAITING_INTERNAL', 'RESOLVED', 'CLOSED']).defaultTo('OPEN');
|
||
|
|
table.string('subject');
|
||
|
|
table.text('description');
|
||
|
|
table.json('messages');
|
||
|
|
table.string('tenant_id').notNullable();
|
||
|
|
table.string('shop_id');
|
||
|
|
table.string('task_id');
|
||
|
|
table.string('trace_id');
|
||
|
|
table.enum('business_type', ['TOC', 'TOB']).defaultTo('TOC');
|
||
|
|
table.timestamp('created_at').defaultTo(db.fn.now());
|
||
|
|
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||
|
|
table.index(['return_id', 'tenant_id']);
|
||
|
|
table.index(['status', 'tenant_id']);
|
||
|
|
table.index(['priority', 'tenant_id']);
|
||
|
|
});
|
||
|
|
logger.info('[AfterSalesService] Table cf_service_tickets created');
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info('[AfterSalesService] After-sales tables initialized successfully');
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error(`[AfterSalesService] Failed to initialize after-sales tables - error: ${error.message}`);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|