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

479 lines
16 KiB
TypeScript
Raw Normal View History

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;
}
}
}