479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
|
|
import db from '../config/database';
|
||
|
|
import { logger } from '../utils/logger';
|
||
|
|
|
||
|
|
export interface LogisticsOption {
|
||
|
|
carrierId: string;
|
||
|
|
carrierName: string;
|
||
|
|
serviceType: 'STANDARD' | 'EXPRESS' | 'ECONOMY' | 'AIR_FREIGHT' | 'SEA_FREIGHT';
|
||
|
|
estimatedDays: number;
|
||
|
|
baseCost: number;
|
||
|
|
reliability: number;
|
||
|
|
trackingSupported: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LogisticsStrategy {
|
||
|
|
orderId: string;
|
||
|
|
destination: string;
|
||
|
|
recommendedOption: LogisticsOption;
|
||
|
|
alternatives: LogisticsOption[];
|
||
|
|
strategyReason: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ChannelSelection {
|
||
|
|
orderId: string;
|
||
|
|
channelId: string;
|
||
|
|
channelName: string;
|
||
|
|
carrierId: string;
|
||
|
|
estimatedDelivery: Date;
|
||
|
|
cost: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface FreightCalculation {
|
||
|
|
productId: string;
|
||
|
|
quantity: number;
|
||
|
|
destination: string;
|
||
|
|
weight: number;
|
||
|
|
volume: number;
|
||
|
|
freightCost: number;
|
||
|
|
breakdown: {
|
||
|
|
baseFreight: number;
|
||
|
|
fuelSurcharge: number;
|
||
|
|
remoteAreaFee: number;
|
||
|
|
insuranceFee: number;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LogisticsStrategyInput {
|
||
|
|
orderId: string;
|
||
|
|
destination: string;
|
||
|
|
tenantId: string;
|
||
|
|
shopId?: string;
|
||
|
|
taskId?: string;
|
||
|
|
traceId: string;
|
||
|
|
businessType: 'TOC' | 'TOB';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* [BE-LOG001] 物流策略引擎
|
||
|
|
* [BE-LOG002] 渠道选择算法
|
||
|
|
* [BE-LOG003] 运费计算接口
|
||
|
|
*
|
||
|
|
* @description 物流策略与渠道选择核心服务
|
||
|
|
*/
|
||
|
|
export class LogisticsService {
|
||
|
|
private static readonly CARRIERS = [
|
||
|
|
{ id: 'DHL', name: 'DHL Express', type: 'EXPRESS', baseRate: 15, reliability: 0.95 },
|
||
|
|
{ id: 'FEDEX', name: 'FedEx', type: 'EXPRESS', baseRate: 14, reliability: 0.93 },
|
||
|
|
{ id: 'UPS', name: 'UPS', type: 'STANDARD', baseRate: 12, reliability: 0.92 },
|
||
|
|
{ id: 'SF', name: 'SF Express', type: 'EXPRESS', baseRate: 10, reliability: 0.90 },
|
||
|
|
{ id: 'EMS', name: 'EMS', type: 'STANDARD', baseRate: 8, reliability: 0.85 },
|
||
|
|
{ id: 'SEA_ECONOMY', name: 'Sea Freight Economy', type: 'SEA_FREIGHT', baseRate: 3, reliability: 0.80 },
|
||
|
|
];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* [BE-LOG001] 物流策略引擎
|
||
|
|
* 根据订单ID和目的地推荐最优物流方案
|
||
|
|
*/
|
||
|
|
static async getLogisticsStrategy(input: LogisticsStrategyInput): Promise<LogisticsStrategy> {
|
||
|
|
const { orderId, destination, tenantId, shopId, taskId, traceId, businessType } = input;
|
||
|
|
|
||
|
|
logger.info(`[LogisticsService] Getting logistics strategy - orderId: ${orderId}, destination: ${destination}, tenantId: ${tenantId}, traceId: ${traceId}`);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const order = await this.getOrderInfo(orderId, tenantId);
|
||
|
|
|
||
|
|
const options = await this.evaluateLogisticsOptions(destination, order);
|
||
|
|
|
||
|
|
const recommendedOption = this.selectOptimalOption(options, businessType);
|
||
|
|
|
||
|
|
const alternatives = options.filter(o => o.carrierId !== recommendedOption.carrierId).slice(0, 3);
|
||
|
|
|
||
|
|
const strategyReason = this.generateStrategyReason(recommendedOption, businessType);
|
||
|
|
|
||
|
|
const strategy: LogisticsStrategy = {
|
||
|
|
orderId,
|
||
|
|
destination,
|
||
|
|
recommendedOption,
|
||
|
|
alternatives,
|
||
|
|
strategyReason,
|
||
|
|
};
|
||
|
|
|
||
|
|
await this.logStrategyDecision(tenantId, orderId, strategy, traceId);
|
||
|
|
|
||
|
|
return strategy;
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error(`[LogisticsService] Failed to get logistics strategy - orderId: ${orderId}, error: ${error.message}`);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* [BE-LOG002] 渠道选择算法
|
||
|
|
* 根据订单和物流方案选择最优渠道
|
||
|
|
*/
|
||
|
|
static async selectChannel(
|
||
|
|
orderId: string,
|
||
|
|
logisticsOption: LogisticsOption,
|
||
|
|
options: {
|
||
|
|
tenantId: string;
|
||
|
|
shopId?: string;
|
||
|
|
taskId?: string;
|
||
|
|
traceId: string;
|
||
|
|
}
|
||
|
|
): Promise<ChannelSelection> {
|
||
|
|
const { tenantId, traceId } = options;
|
||
|
|
|
||
|
|
logger.info(`[LogisticsService] Selecting channel - orderId: ${orderId}, carrierId: ${logisticsOption.carrierId}, tenantId: ${tenantId}, traceId: ${traceId}`);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const channel = await this.findOptimalChannel(logisticsOption, tenantId);
|
||
|
|
|
||
|
|
const estimatedDelivery = this.calculateEstimatedDelivery(logisticsOption.estimatedDays);
|
||
|
|
|
||
|
|
const selection: ChannelSelection = {
|
||
|
|
orderId,
|
||
|
|
channelId: channel.id,
|
||
|
|
channelName: channel.name,
|
||
|
|
carrierId: logisticsOption.carrierId,
|
||
|
|
estimatedDelivery,
|
||
|
|
cost: logisticsOption.baseCost,
|
||
|
|
};
|
||
|
|
|
||
|
|
await db('cf_logistics_channel_selections').insert({
|
||
|
|
tenant_id: tenantId,
|
||
|
|
order_id: orderId,
|
||
|
|
channel_id: channel.id,
|
||
|
|
carrier_id: logisticsOption.carrierId,
|
||
|
|
estimated_delivery: estimatedDelivery,
|
||
|
|
cost: logisticsOption.baseCost,
|
||
|
|
trace_id: traceId,
|
||
|
|
created_at: new Date(),
|
||
|
|
});
|
||
|
|
|
||
|
|
logger.info(`[LogisticsService] Channel selected - orderId: ${orderId}, channelId: ${channel.id}`);
|
||
|
|
|
||
|
|
return selection;
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error(`[LogisticsService] Failed to select channel - orderId: ${orderId}, error: ${error.message}`);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* [BE-LOG003] 运费计算接口
|
||
|
|
* 根据商品ID、数量和目的地计算运费
|
||
|
|
*/
|
||
|
|
static async calculateFreight(
|
||
|
|
productId: string,
|
||
|
|
quantity: number,
|
||
|
|
destination: string,
|
||
|
|
options: {
|
||
|
|
tenantId: string;
|
||
|
|
shopId?: string;
|
||
|
|
taskId?: string;
|
||
|
|
traceId: string;
|
||
|
|
}
|
||
|
|
): Promise<FreightCalculation> {
|
||
|
|
const { tenantId, traceId } = options;
|
||
|
|
|
||
|
|
logger.info(`[LogisticsService] Calculating freight - productId: ${productId}, quantity: ${quantity}, destination: ${destination}, tenantId: ${tenantId}, traceId: ${traceId}`);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const product = await this.getProductInfo(productId, tenantId);
|
||
|
|
|
||
|
|
const weight = (product.weight || 0.5) * quantity;
|
||
|
|
const volume = (product.volume || 0.001) * quantity;
|
||
|
|
|
||
|
|
const baseFreight = this.calculateBaseFreight(weight, volume, destination);
|
||
|
|
const fuelSurcharge = baseFreight * 0.15;
|
||
|
|
const remoteAreaFee = this.getRemoteAreaFee(destination);
|
||
|
|
const insuranceFee = baseFreight * 0.02;
|
||
|
|
|
||
|
|
const freightCost = Number((baseFreight + fuelSurcharge + remoteAreaFee + insuranceFee).toFixed(2));
|
||
|
|
|
||
|
|
const calculation: FreightCalculation = {
|
||
|
|
productId,
|
||
|
|
quantity,
|
||
|
|
destination,
|
||
|
|
weight,
|
||
|
|
volume,
|
||
|
|
freightCost,
|
||
|
|
breakdown: {
|
||
|
|
baseFreight: Number(baseFreight.toFixed(2)),
|
||
|
|
fuelSurcharge: Number(fuelSurcharge.toFixed(2)),
|
||
|
|
remoteAreaFee: Number(remoteAreaFee.toFixed(2)),
|
||
|
|
insuranceFee: Number(insuranceFee.toFixed(2)),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
await db('cf_freight_calculations').insert({
|
||
|
|
tenant_id: tenantId,
|
||
|
|
product_id: productId,
|
||
|
|
quantity,
|
||
|
|
destination,
|
||
|
|
weight,
|
||
|
|
volume,
|
||
|
|
freight_cost: freightCost,
|
||
|
|
breakdown: JSON.stringify(calculation.breakdown),
|
||
|
|
trace_id: traceId,
|
||
|
|
created_at: new Date(),
|
||
|
|
});
|
||
|
|
|
||
|
|
logger.info(`[LogisticsService] Freight calculated - productId: ${productId}, freightCost: ${freightCost}`);
|
||
|
|
|
||
|
|
return calculation;
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error(`[LogisticsService] Failed to calculate freight - productId: ${productId}, error: ${error.message}`);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async getOrderInfo(orderId: string, tenantId: string): Promise<any> {
|
||
|
|
const order = await db('cf_consumer_orders')
|
||
|
|
.where({ id: orderId, tenant_id: tenantId })
|
||
|
|
.first();
|
||
|
|
|
||
|
|
return order || { id: orderId, weight: 1, value: 100, priority: 'NORMAL' };
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async getProductInfo(productId: string, tenantId: string): Promise<any> {
|
||
|
|
const product = await db('cf_product')
|
||
|
|
.where({ id: productId, tenant_id: tenantId })
|
||
|
|
.first();
|
||
|
|
|
||
|
|
return product || { id: productId, weight: 0.5, volume: 0.001 };
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async evaluateLogisticsOptions(destination: string, order: any): Promise<LogisticsOption[]> {
|
||
|
|
const isRemote = this.isRemoteDestination(destination);
|
||
|
|
const isHighValue = (order.value || 100) > 500;
|
||
|
|
|
||
|
|
const options: LogisticsOption[] = this.CARRIERS.map(carrier => {
|
||
|
|
let estimatedDays = this.getEstimatedDays(carrier.type, destination);
|
||
|
|
let baseCost = carrier.baseRate * (order.weight || 1);
|
||
|
|
|
||
|
|
if (isRemote) {
|
||
|
|
baseCost *= 1.3;
|
||
|
|
estimatedDays += 2;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isHighValue && carrier.type !== 'EXPRESS') {
|
||
|
|
estimatedDays += 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
carrierId: carrier.id,
|
||
|
|
carrierName: carrier.name,
|
||
|
|
serviceType: carrier.type as any,
|
||
|
|
estimatedDays,
|
||
|
|
baseCost: Number(baseCost.toFixed(2)),
|
||
|
|
reliability: carrier.reliability,
|
||
|
|
trackingSupported: carrier.type !== 'SEA_FREIGHT',
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
return options;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static selectOptimalOption(options: LogisticsOption[], businessType: string): LogisticsOption {
|
||
|
|
const scoredOptions = options.map(option => {
|
||
|
|
let score = 0;
|
||
|
|
|
||
|
|
score += option.reliability * 40;
|
||
|
|
|
||
|
|
score += (1 / option.estimatedDays) * 30;
|
||
|
|
|
||
|
|
score += (1 / option.baseCost) * 20;
|
||
|
|
|
||
|
|
if (option.trackingSupported) {
|
||
|
|
score += 10;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (businessType === 'TOB') {
|
||
|
|
score += option.reliability * 10;
|
||
|
|
}
|
||
|
|
|
||
|
|
return { ...option, score };
|
||
|
|
});
|
||
|
|
|
||
|
|
scoredOptions.sort((a, b) => b.score - a.score);
|
||
|
|
|
||
|
|
return scoredOptions[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
private static generateStrategyReason(option: LogisticsOption, businessType: string): string {
|
||
|
|
return `Recommended ${option.carrierName} (${option.serviceType}) for ${businessType} order. ` +
|
||
|
|
`Estimated delivery: ${option.estimatedDays} days. ` +
|
||
|
|
`Reliability: ${(option.reliability * 100).toFixed(0)}%. ` +
|
||
|
|
`Cost: $${option.baseCost}.`;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async logStrategyDecision(
|
||
|
|
tenantId: string,
|
||
|
|
orderId: string,
|
||
|
|
strategy: LogisticsStrategy,
|
||
|
|
traceId: string
|
||
|
|
): Promise<void> {
|
||
|
|
try {
|
||
|
|
await db('cf_logistics_strategy_decisions').insert({
|
||
|
|
tenant_id: tenantId,
|
||
|
|
order_id: orderId,
|
||
|
|
recommended_carrier: strategy.recommendedOption.carrierId,
|
||
|
|
recommended_cost: strategy.recommendedOption.baseCost,
|
||
|
|
estimated_days: strategy.recommendedOption.estimatedDays,
|
||
|
|
strategy_reason: strategy.strategyReason,
|
||
|
|
alternatives: JSON.stringify(strategy.alternatives),
|
||
|
|
trace_id: traceId,
|
||
|
|
created_at: new Date(),
|
||
|
|
});
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.warn(`[LogisticsService] Failed to log strategy decision: ${error.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async findOptimalChannel(option: LogisticsOption, tenantId: string): Promise<{ id: string; name: string }> {
|
||
|
|
const channel = await db('cf_logistics_channels')
|
||
|
|
.where({ tenant_id: tenantId, carrier_id: option.carrierId, status: 'ACTIVE' })
|
||
|
|
.first();
|
||
|
|
|
||
|
|
if (channel) {
|
||
|
|
return { id: channel.id, name: channel.name };
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: `CH-${option.carrierId}-${Date.now()}`,
|
||
|
|
name: `${option.carrierName} Channel`,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private static calculateEstimatedDelivery(days: number): Date {
|
||
|
|
const delivery = new Date();
|
||
|
|
delivery.setDate(delivery.getDate() + days);
|
||
|
|
return delivery;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static calculateBaseFreight(weight: number, volume: number, destination: string): number {
|
||
|
|
const volumetricWeight = volume * 5000;
|
||
|
|
const chargeableWeight = Math.max(weight, volumetricWeight);
|
||
|
|
|
||
|
|
let ratePerKg = 5;
|
||
|
|
|
||
|
|
if (destination.startsWith('US') || destination.startsWith('CA')) {
|
||
|
|
ratePerKg = 8;
|
||
|
|
} else if (destination.startsWith('EU') || destination.startsWith('UK')) {
|
||
|
|
ratePerKg = 7;
|
||
|
|
} else if (destination.startsWith('AU') || destination.startsWith('NZ')) {
|
||
|
|
ratePerKg = 6;
|
||
|
|
}
|
||
|
|
|
||
|
|
return chargeableWeight * ratePerKg;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static getRemoteAreaFee(destination: string): number {
|
||
|
|
const remoteAreas = ['AK', 'HI', 'PR', 'GU', 'VI', 'NT', 'YT', 'NU'];
|
||
|
|
const isRemote = remoteAreas.some(area => destination.includes(area));
|
||
|
|
return isRemote ? 15 : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static isRemoteDestination(destination: string): boolean {
|
||
|
|
const remotePatterns = ['AK', 'HI', 'PR', 'GU', 'VI', 'NT', 'YT', 'NU', 'REMOTE'];
|
||
|
|
return remotePatterns.some(pattern => destination.toUpperCase().includes(pattern));
|
||
|
|
}
|
||
|
|
|
||
|
|
private static getEstimatedDays(serviceType: string, destination: string): number {
|
||
|
|
const baseDays: Record<string, number> = {
|
||
|
|
'EXPRESS': 3,
|
||
|
|
'STANDARD': 7,
|
||
|
|
'ECONOMY': 14,
|
||
|
|
'AIR_FREIGHT': 5,
|
||
|
|
'SEA_FREIGHT': 30,
|
||
|
|
};
|
||
|
|
|
||
|
|
let days = baseDays[serviceType] || 7;
|
||
|
|
|
||
|
|
if (destination.startsWith('US') || destination.startsWith('CA')) {
|
||
|
|
days += 2;
|
||
|
|
} else if (destination.startsWith('EU') || destination.startsWith('UK')) {
|
||
|
|
days += 3;
|
||
|
|
} else if (destination.startsWith('AU') || destination.startsWith('NZ')) {
|
||
|
|
days += 4;
|
||
|
|
}
|
||
|
|
|
||
|
|
return days;
|
||
|
|
}
|
||
|
|
|
||
|
|
static async initTables(): Promise<void> {
|
||
|
|
logger.info('[LogisticsService] Initializing logistics tables');
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (!(await db.schema.hasTable('cf_logistics_strategy_decisions'))) {
|
||
|
|
await db.schema.createTable('cf_logistics_strategy_decisions', (table) => {
|
||
|
|
table.string('id').primary().defaultTo(db.raw('uuid()'));
|
||
|
|
table.string('tenant_id').notNullable().index();
|
||
|
|
table.string('order_id').notNullable().index();
|
||
|
|
table.string('recommended_carrier').notNullable();
|
||
|
|
table.decimal('recommended_cost', 10, 2).notNullable();
|
||
|
|
table.integer('estimated_days').notNullable();
|
||
|
|
table.text('strategy_reason');
|
||
|
|
table.json('alternatives');
|
||
|
|
table.string('trace_id');
|
||
|
|
table.timestamp('created_at').defaultTo(db.fn.now());
|
||
|
|
});
|
||
|
|
logger.info('[LogisticsService] Table cf_logistics_strategy_decisions created');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!(await db.schema.hasTable('cf_logistics_channel_selections'))) {
|
||
|
|
await db.schema.createTable('cf_logistics_channel_selections', (table) => {
|
||
|
|
table.string('id').primary().defaultTo(db.raw('uuid()'));
|
||
|
|
table.string('tenant_id').notNullable().index();
|
||
|
|
table.string('order_id').notNullable().index();
|
||
|
|
table.string('channel_id').notNullable();
|
||
|
|
table.string('channel_name');
|
||
|
|
table.string('carrier_id').notNullable();
|
||
|
|
table.date('estimated_delivery');
|
||
|
|
table.decimal('cost', 10, 2).notNullable();
|
||
|
|
table.string('trace_id');
|
||
|
|
table.timestamp('created_at').defaultTo(db.fn.now());
|
||
|
|
});
|
||
|
|
logger.info('[LogisticsService] Table cf_logistics_channel_selections created');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!(await db.schema.hasTable('cf_freight_calculations'))) {
|
||
|
|
await db.schema.createTable('cf_freight_calculations', (table) => {
|
||
|
|
table.string('id').primary().defaultTo(db.raw('uuid()'));
|
||
|
|
table.string('tenant_id').notNullable().index();
|
||
|
|
table.string('product_id').notNullable().index();
|
||
|
|
table.integer('quantity').notNullable();
|
||
|
|
table.string('destination').notNullable();
|
||
|
|
table.decimal('weight', 10, 3).notNullable();
|
||
|
|
table.decimal('volume', 10, 6).notNullable();
|
||
|
|
table.decimal('freight_cost', 10, 2).notNullable();
|
||
|
|
table.json('breakdown');
|
||
|
|
table.string('trace_id');
|
||
|
|
table.timestamp('created_at').defaultTo(db.fn.now());
|
||
|
|
});
|
||
|
|
logger.info('[LogisticsService] Table cf_freight_calculations created');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!(await db.schema.hasTable('cf_logistics_channels'))) {
|
||
|
|
await db.schema.createTable('cf_logistics_channels', (table) => {
|
||
|
|
table.string('id').primary();
|
||
|
|
table.string('tenant_id').notNullable().index();
|
||
|
|
table.string('name').notNullable();
|
||
|
|
table.string('carrier_id').notNullable().index();
|
||
|
|
table.enum('status', ['ACTIVE', 'INACTIVE', 'SUSPENDED']).notNullable().defaultTo('ACTIVE');
|
||
|
|
table.string('trace_id');
|
||
|
|
table.timestamp('created_at').defaultTo(db.fn.now());
|
||
|
|
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||
|
|
});
|
||
|
|
logger.info('[LogisticsService] Table cf_logistics_channels created');
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info('[LogisticsService] Logistics tables initialized successfully');
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error(`[LogisticsService] Failed to initialize logistics tables - error: ${error.message}`);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|