refactor: 重构DisputeResolverService和DIDHandshakeService fix: 修复SovereignWealthFundService中的表名错误 docs: 更新AI模块清单和任务总览文档 chore: 添加多个README文件说明项目结构 style: 优化logger日志输出格式 perf: 改进RecommendationService的性能和类型安全 test: 添加DomainBootstrap和test-domain-bootstrap测试文件 build: 配置dashboard的umi相关文件 ci: 添加GitHub工作流配置
433 lines
17 KiB
TypeScript
433 lines
17 KiB
TypeScript
import db from '../config/database';
|
|
import { logger } from '../utils/logger';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
export interface ReturnRecord {
|
|
id: string;
|
|
tenant_id: string;
|
|
shop_id: string;
|
|
task_id?: string;
|
|
trace_id: string;
|
|
order_id: string;
|
|
order_item_id: string;
|
|
product_id: string;
|
|
sku_id: string;
|
|
platform: string;
|
|
platform_product_id: string;
|
|
platform_sku_id: string;
|
|
buyer_id: string;
|
|
return_id: string;
|
|
return_reason: string;
|
|
return_reason_category: string;
|
|
return_quantity: number;
|
|
refund_amount: decimal;
|
|
return_status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'COMPLETED';
|
|
return_type: 'FULL' | 'PARTIAL';
|
|
inspection_result?: string;
|
|
is_accepted: boolean;
|
|
restocking_fee?: decimal;
|
|
return_shipping_fee?: decimal;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
}
|
|
|
|
export interface SKUReturnMetrics {
|
|
id: string;
|
|
tenant_id: string;
|
|
shop_id: string;
|
|
product_id: string;
|
|
sku_id: string;
|
|
platform: string;
|
|
platform_product_id: string;
|
|
platform_sku_id: string;
|
|
total_orders: number;
|
|
total_returns: number;
|
|
return_rate: decimal;
|
|
return_rate_trend: decimal;
|
|
avg_refund_amount: decimal;
|
|
total_refund_amount: decimal;
|
|
return_by_reason: Record<string, number>;
|
|
return_by_month: Record<string, number>;
|
|
high_risk_threshold: decimal;
|
|
is_high_risk: boolean;
|
|
is_auto_delisted: boolean;
|
|
last_calculated_at: Date;
|
|
calculated_period_start: Date;
|
|
calculated_period_end: Date;
|
|
}
|
|
|
|
export interface ReturnRateThreshold {
|
|
id: string;
|
|
tenant_id: string;
|
|
shop_id?: string;
|
|
platform?: string;
|
|
category?: string;
|
|
threshold_type: 'GLOBAL' | 'PLATFORM' | 'CATEGORY' | 'SKU';
|
|
return_rate_threshold: decimal;
|
|
auto_delist_enabled: boolean;
|
|
delist_duration_hours?: number;
|
|
notification_enabled: boolean;
|
|
notify_emails?: string[];
|
|
created_by: string;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
}
|
|
|
|
export interface ReturnAnalysis {
|
|
product_id: string;
|
|
sku_id: string;
|
|
return_rate: decimal;
|
|
top_return_reasons: string[];
|
|
customer_sentiment: 'POSITIVE' | 'NEUTRAL' | 'NEGATIVE';
|
|
improvement_priority: 'HIGH' | 'MEDIUM' | 'LOW';
|
|
estimated_impact: string;
|
|
recommendations: string[];
|
|
}
|
|
|
|
export default class ReturnRateDatabaseService {
|
|
static readonly TABLE_NAME = 'cf_return_record';
|
|
static readonly SKU_METRICS_TABLE = 'cf_sku_return_metrics';
|
|
static readonly THRESHOLD_TABLE = 'cf_return_rate_threshold';
|
|
|
|
static async initTable(): Promise<void> {
|
|
const hasTable = await db.schema.hasTable(this.TABLE_NAME);
|
|
if (!hasTable) {
|
|
logger.info(`[ReturnRateDatabaseService] Creating ${this.TABLE_NAME} table...`);
|
|
await db.schema.createTable(this.TABLE_NAME, (table) => {
|
|
table.string('id', 36).primary();
|
|
table.string('tenant_id', 64).notNullable().index();
|
|
table.string('shop_id', 64).notNullable().index();
|
|
table.string('task_id', 36);
|
|
table.string('trace_id', 64).notNullable();
|
|
table.string('order_id', 64).notNullable().index();
|
|
table.string('order_item_id', 64).notNullable();
|
|
table.string('product_id', 64).notNullable().index();
|
|
table.string('sku_id', 64).notNullable().index();
|
|
table.string('platform', 64).notNullable().index();
|
|
table.string('platform_product_id', 64).notNullable();
|
|
table.string('platform_sku_id', 64).notNullable();
|
|
table.string('buyer_id', 64).notNullable();
|
|
table.string('return_id', 64).notNullable().unique();
|
|
table.text('return_reason').notNullable();
|
|
table.string('return_reason_category', 64).notNullable();
|
|
table.integer('return_quantity').notNullable();
|
|
table.decimal('refund_amount', 10, 2).notNullable();
|
|
table.enum('return_status', ['PENDING', 'APPROVED', 'REJECTED', 'COMPLETED']).notNullable();
|
|
table.enum('return_type', ['FULL', 'PARTIAL']).notNullable();
|
|
table.text('inspection_result');
|
|
table.boolean('is_accepted').notNullable().defaultTo(false);
|
|
table.decimal('restocking_fee', 10, 2);
|
|
table.decimal('return_shipping_fee', 10, 2);
|
|
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
|
table.datetime('updated_at').notNullable().defaultTo(db.fn.now());
|
|
|
|
table.index(['tenant_id', 'shop_id']);
|
|
table.index(['platform', 'platform_product_id']);
|
|
table.index(['return_status', 'created_at']);
|
|
});
|
|
logger.info(`[ReturnRateDatabaseService] Table ${this.TABLE_NAME} created`);
|
|
}
|
|
|
|
const hasMetricsTable = await db.schema.hasTable(this.SKU_METRICS_TABLE);
|
|
if (!hasMetricsTable) {
|
|
logger.info(`[ReturnRateDatabaseService] Creating ${this.SKU_METRICS_TABLE} table...`);
|
|
await db.schema.createTable(this.SKU_METRICS_TABLE, (table) => {
|
|
table.string('id', 36).primary();
|
|
table.string('tenant_id', 64).notNullable().index();
|
|
table.string('shop_id', 64).notNullable().index();
|
|
table.string('product_id', 64).notNullable().index();
|
|
table.string('sku_id', 64).notNullable().index();
|
|
table.string('platform', 64).notNullable().index();
|
|
table.string('platform_product_id', 64).notNullable();
|
|
table.string('platform_sku_id', 64).notNullable();
|
|
table.integer('total_orders').notNullable().defaultTo(0);
|
|
table.integer('total_returns').notNullable().defaultTo(0);
|
|
table.decimal('return_rate', 5, 2).notNullable().defaultTo(0);
|
|
table.decimal('return_rate_trend', 5, 2).notNullable().defaultTo(0);
|
|
table.decimal('avg_refund_amount', 10, 2).notNullable().defaultTo(0);
|
|
table.decimal('total_refund_amount', 10, 2).notNullable().defaultTo(0);
|
|
table.json('return_by_reason').notNullable();
|
|
table.json('return_by_month').notNullable();
|
|
table.decimal('high_risk_threshold', 5, 2).notNullable().defaultTo(30);
|
|
table.boolean('is_high_risk').notNullable().defaultTo(false);
|
|
table.boolean('is_auto_delisted').notNullable().defaultTo(false);
|
|
table.datetime('last_calculated_at').notNullable();
|
|
table.datetime('calculated_period_start').notNullable();
|
|
table.datetime('calculated_period_end').notNullable();
|
|
|
|
table.index(['tenant_id', 'shop_id', 'platform']);
|
|
table.index(['return_rate', 'is_high_risk']);
|
|
table.index(['is_auto_delisted']);
|
|
});
|
|
logger.info(`[ReturnRateDatabaseService] Table ${this.SKU_METRICS_TABLE} created`);
|
|
}
|
|
|
|
const hasThresholdTable = await db.schema.hasTable(this.THRESHOLD_TABLE);
|
|
if (!hasThresholdTable) {
|
|
logger.info(`[ReturnRateDatabaseService] Creating ${this.THRESHOLD_TABLE} table...`);
|
|
await db.schema.createTable(this.THRESHOLD_TABLE, (table) => {
|
|
table.string('id', 36).primary();
|
|
table.string('tenant_id', 64).notNullable().index();
|
|
table.string('shop_id', 64);
|
|
table.string('platform', 64);
|
|
table.string('category', 64);
|
|
table.enum('threshold_type', ['GLOBAL', 'PLATFORM', 'CATEGORY', 'SKU']).notNullable();
|
|
table.decimal('return_rate_threshold', 5, 2).notNullable();
|
|
table.boolean('auto_delist_enabled').notNullable().defaultTo(true);
|
|
table.integer('delist_duration_hours');
|
|
table.boolean('notification_enabled').notNullable().defaultTo(true);
|
|
table.json('notify_emails');
|
|
table.string('created_by', 64).notNullable();
|
|
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
|
table.datetime('updated_at').notNullable().defaultTo(db.fn.now());
|
|
|
|
table.unique(['tenant_id', 'threshold_type', 'platform', 'category']);
|
|
});
|
|
logger.info(`[ReturnRateDatabaseService] Table ${this.THRESHOLD_TABLE} created`);
|
|
}
|
|
}
|
|
|
|
static async createReturnRecord(record: Omit<ReturnRecord, 'id' | 'created_at' | 'updated_at'>): Promise<ReturnRecord> {
|
|
const id = uuidv4();
|
|
const now = new Date();
|
|
|
|
const newRecord: ReturnRecord = {
|
|
...record,
|
|
id,
|
|
created_at: now,
|
|
updated_at: now
|
|
};
|
|
|
|
await db(this.TABLE_NAME).insert(newRecord);
|
|
logger.info(`[ReturnRateDatabaseService] Return record created: id=${id}, orderId=${record.order_id}, traceId=${record.trace_id}`);
|
|
return newRecord;
|
|
}
|
|
|
|
static async getReturnRecords(filter: {
|
|
tenant_id?: string;
|
|
shop_id?: string;
|
|
product_id?: string;
|
|
sku_id?: string;
|
|
platform?: string;
|
|
return_status?: string;
|
|
start_date?: Date;
|
|
end_date?: Date;
|
|
}, limit: number = 100, offset: number = 0): Promise<{ records: ReturnRecord[]; total: number }> {
|
|
let query = db(this.TABLE_NAME);
|
|
|
|
if (filter.tenant_id) query = query.where({ tenant_id: filter.tenant_id });
|
|
if (filter.shop_id) query = query.where({ shop_id: filter.shop_id });
|
|
if (filter.product_id) query = query.where({ product_id: filter.product_id });
|
|
if (filter.sku_id) query = query.where({ sku_id: filter.sku_id });
|
|
if (filter.platform) query = query.where({ platform: filter.platform });
|
|
if (filter.return_status) query = query.where({ return_status: filter.return_status });
|
|
if (filter.start_date) query = query.where('created_at', '>=', filter.start_date);
|
|
if (filter.end_date) query = query.where('created_at', '<=', filter.end_date);
|
|
|
|
const total = await query.count('id as count').first();
|
|
const records = await query.orderBy('created_at', 'desc').limit(limit).offset(offset);
|
|
|
|
return {
|
|
records: records as ReturnRecord[],
|
|
total: total ? parseInt(total.count as string) : 0
|
|
};
|
|
}
|
|
|
|
static async updateSKUReturnMetrics(tenantId: string, shopId: string, productId: string, skuId: string, platform: string, periodDays: number = 30): Promise<SKUReturnMetrics | null> {
|
|
try {
|
|
const periodEnd = new Date();
|
|
const periodStart = new Date();
|
|
periodStart.setDate(periodStart.getDate() - periodDays);
|
|
|
|
const ordersCount = await db('cf_order_item')
|
|
.join('cf_order', 'cf_order.id', '=', 'cf_order_item.order_id')
|
|
.where('cf_order.tenant_id', tenantId)
|
|
.where('cf_order.shop_id', shopId)
|
|
.where('cf_order_item.product_id', productId)
|
|
.where('cf_order.status', 'COMPLETED')
|
|
.where('cf_order.created_at', '>=', periodStart)
|
|
.count('cf_order_item.id as count')
|
|
.first();
|
|
|
|
const returnsCount = await db(this.TABLE_NAME)
|
|
.where({ tenant_id: tenantId, shop_id: shopId, product_id: productId, return_status: 'COMPLETED' })
|
|
.where('created_at', '>=', periodStart)
|
|
.count('id as count')
|
|
.first();
|
|
|
|
const refundSum = await db(this.TABLE_NAME)
|
|
.where({ tenant_id: tenantId, shop_id: shopId, product_id: productId, return_status: 'COMPLETED' })
|
|
.where('created_at', '>=', periodStart)
|
|
.sum('refund_amount as total')
|
|
.first();
|
|
|
|
const returnReasons = await db(this.TABLE_NAME)
|
|
.select('return_reason_category', db.raw('count(*) as count'))
|
|
.where({ tenant_id: tenantId, shop_id: shopId, product_id: productId })
|
|
.where('created_at', '>=', periodStart)
|
|
.groupBy('return_reason_category');
|
|
|
|
const byReason: Record<string, number> = {};
|
|
returnReasons.forEach((r: any) => { byReason[r.return_reason_category] = parseInt(r.count); });
|
|
|
|
const totalOrders = ordersCount ? parseInt(ordersCount.count as string) : 0;
|
|
const totalReturns = returnsCount ? parseInt(returnsCount.count as string) : 0;
|
|
const returnRate = totalOrders > 0 ? (totalReturns / totalOrders) * 100 : 0;
|
|
const totalRefund = refundSum?.total ? parseFloat(refundSum.total as string) : 0;
|
|
const avgRefund = totalReturns > 0 ? totalRefund / totalReturns : 0;
|
|
|
|
const threshold = await this.getThreshold(tenantId, platform, shopId);
|
|
const thresholdValue = threshold?.return_rate_threshold || 30;
|
|
const isHighRisk = returnRate >= thresholdValue;
|
|
|
|
const id = uuidv4();
|
|
const existing = await db(this.SKU_METRICS_TABLE)
|
|
.where({ tenant_id: tenantId, shop_id: shopId, product_id: productId, sku_id: skuId })
|
|
.first();
|
|
|
|
if (existing) {
|
|
const oldRate = parseFloat(existing.return_rate as string) || 0;
|
|
const trend = returnRate - oldRate;
|
|
|
|
await db(this.SKU_METRICS_TABLE)
|
|
.where({ id: existing.id })
|
|
.update({
|
|
total_orders: totalOrders,
|
|
total_returns: totalReturns,
|
|
return_rate: returnRate,
|
|
return_rate_trend: trend,
|
|
avg_refund_amount: avgRefund,
|
|
total_refund_amount: totalRefund,
|
|
return_by_reason: JSON.stringify(byReason),
|
|
is_high_risk: isHighRisk,
|
|
last_calculated_at: new Date(),
|
|
calculated_period_start: periodStart,
|
|
calculated_period_end: periodEnd,
|
|
updated_at: new Date()
|
|
});
|
|
|
|
return await db(this.SKU_METRICS_TABLE).where({ id: existing.id }).first() as SKUReturnMetrics;
|
|
} else {
|
|
await db(this.SKU_METRICS_TABLE).insert({
|
|
id,
|
|
tenant_id: tenantId,
|
|
shop_id: shopId,
|
|
product_id: productId,
|
|
sku_id: skuId,
|
|
platform,
|
|
platform_product_id: '',
|
|
platform_sku_id: '',
|
|
total_orders: totalOrders,
|
|
total_returns: totalReturns,
|
|
return_rate: returnRate,
|
|
return_rate_trend: 0,
|
|
avg_refund_amount: avgRefund,
|
|
total_refund_amount: totalRefund,
|
|
return_by_reason: JSON.stringify(byReason),
|
|
return_by_month: JSON.stringify({}),
|
|
high_risk_threshold: thresholdValue,
|
|
is_high_risk: isHighRisk,
|
|
is_auto_delisted: false,
|
|
last_calculated_at: new Date(),
|
|
calculated_period_start: periodStart,
|
|
calculated_period_end: periodEnd
|
|
});
|
|
|
|
return await db(this.SKU_METRICS_TABLE).where({ id }).first() as SKUReturnMetrics;
|
|
}
|
|
} catch (error) {
|
|
logger.error(`[ReturnRateDatabaseService] Failed to update SKU return metrics: ${error}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static async getHighRiskSKUs(tenantId: string, shopId?: string, limit: number = 100): Promise<SKUReturnMetrics[]> {
|
|
let query = db(this.SKU_METRICS_TABLE)
|
|
.where({ tenant_id: tenantId, is_high_risk: true })
|
|
.orderBy('return_rate', 'desc')
|
|
.limit(limit);
|
|
|
|
if (shopId) query = query.where({ shop_id: shopId });
|
|
return query as SKUReturnMetrics[];
|
|
}
|
|
|
|
static async getAutoDelistSKUs(tenantId: string): Promise<SKUReturnMetrics[]> {
|
|
return db(this.SKU_METRICS_TABLE)
|
|
.where({ tenant_id: tenantId, is_high_risk: true, is_auto_delisted: false }) as SKUReturnMetrics[];
|
|
}
|
|
|
|
static async markSKUDelisted(id: string, delisted: boolean): Promise<boolean> {
|
|
const result = await db(this.SKU_METRICS_TABLE)
|
|
.where({ id })
|
|
.update({ is_auto_delisted: delisted, updated_at: new Date() });
|
|
return result > 0;
|
|
}
|
|
|
|
static async setThreshold(threshold: Omit<ReturnRateThreshold, 'id' | 'created_at' | 'updated_at'>): Promise<ReturnRateThreshold> {
|
|
const id = uuidv4();
|
|
const now = new Date();
|
|
|
|
const existing = await db(this.THRESHOLD_TABLE)
|
|
.where({ tenant_id: threshold.tenant_id, threshold_type: threshold.threshold_type })
|
|
.whereNull('shop_id')
|
|
.first();
|
|
|
|
if (existing) {
|
|
await db(this.THRESHOLD_TABLE)
|
|
.where({ id: existing.id })
|
|
.update({ ...threshold, updated_at: now });
|
|
return { ...threshold, id: existing.id, created_at: existing.created_at, updated_at: now } as ReturnRateThreshold;
|
|
}
|
|
|
|
await db(this.THRESHOLD_TABLE).insert({ ...threshold, id, created_at: now, updated_at: now });
|
|
return { ...threshold, id, created_at: now, updated_at: now } as ReturnRateThreshold;
|
|
}
|
|
|
|
static async getThreshold(tenantId: string, platform?: string, shopId?: string): Promise<ReturnRateThreshold | null> {
|
|
let threshold = await db(this.THRESHOLD_TABLE)
|
|
.where({ tenant_id: tenantId, threshold_type: 'GLOBAL' })
|
|
.first();
|
|
|
|
if (!threshold && platform) {
|
|
threshold = await db(this.THRESHOLD_TABLE)
|
|
.where({ tenant_id: tenantId, threshold_type: 'PLATFORM', platform })
|
|
.first();
|
|
}
|
|
|
|
return threshold as ReturnRateThreshold || null;
|
|
}
|
|
|
|
static async getAllThresholds(tenantId: string): Promise<ReturnRateThreshold[]> {
|
|
return db(this.THRESHOLD_TABLE).where({ tenant_id: tenantId }) as ReturnRateThreshold[];
|
|
}
|
|
|
|
static async deleteThreshold(id: string): Promise<boolean> {
|
|
const result = await db(this.THRESHOLD_TABLE).where({ id }).del();
|
|
return result > 0;
|
|
}
|
|
|
|
static async getReturnRateTrend(tenantId: string, productId: string, days: number = 90): Promise<{ date: string; rate: number }[]> {
|
|
const startDate = new Date();
|
|
startDate.setDate(startDate.getDate() - days);
|
|
|
|
const returns = await db(this.TABLE_NAME)
|
|
.select(db.raw('DATE(created_at) as date'), db.raw('count(*) as count'))
|
|
.where({ tenant_id: tenantId, product_id: productId, return_status: 'COMPLETED' })
|
|
.where('created_at', '>=', startDate)
|
|
.groupBy(db.raw('DATE(created_at)'))
|
|
.orderBy('date');
|
|
|
|
const result: { date: string; rate: number }[] = [];
|
|
for (let i = days; i >= 0; i--) {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() - i);
|
|
const dateStr = d.toISOString().split('T')[0];
|
|
const found = returns.find((r: any) => r.date === dateStr);
|
|
result.push({ date: dateStr, rate: found ? parseInt(found.count) : 0 });
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
type decimal = number;
|