Files
makemd/server/src/services/ReturnRateDatabaseService.ts

433 lines
17 KiB
TypeScript
Raw Normal View History

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;