feat: 新增多模块功能与服务实现
新增广告计划、用户资产、B2B交易、合规规则等核心模型 实现爬虫工作器、贸易服务、现金流预测等业务服务 添加RBAC权限测试、压力测试等测试用例 完善扩展程序的消息处理与内容脚本功能 重构应用入口与文档生成器 更新项目规则与业务闭环分析文档
This commit is contained in:
478
server/src/services/LogisticsService.ts
Normal file
478
server/src/services/LogisticsService.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user