feat: 新增多模块功能与服务实现
新增广告计划、用户资产、B2B交易、合规规则等核心模型 实现爬虫工作器、贸易服务、现金流预测等业务服务 添加RBAC权限测试、压力测试等测试用例 完善扩展程序的消息处理与内容脚本功能 重构应用入口与文档生成器 更新项目规则与业务闭环分析文档
This commit is contained in:
435
server/src/services/B2BTradeService.ts
Normal file
435
server/src/services/B2BTradeService.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import db from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
import { B2BCustomer, TieredPrice, TieredPriceResult, CreditStatus, BatchOrderItem, BatchOrder } from '../models/B2B';
|
||||
import { PricingService } from './PricingService';
|
||||
|
||||
export interface TieredPricingInput {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
customerId: string;
|
||||
tenantId: string;
|
||||
shopId?: string;
|
||||
taskId?: string;
|
||||
traceId: string;
|
||||
businessType: 'TOC' | 'TOB';
|
||||
}
|
||||
|
||||
export interface TieredPricingOutput {
|
||||
unitPrice: number;
|
||||
totalAmount: number;
|
||||
discountPercent: number;
|
||||
appliedTier: {
|
||||
minQuantity: number;
|
||||
maxQuantity?: number;
|
||||
};
|
||||
profitMargin: number;
|
||||
isApproved: boolean;
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
export class B2BTradeService {
|
||||
private static readonly PROFIT_MARGIN_THRESHOLD = 0.15;
|
||||
|
||||
private static readonly DEFAULT_TIERED_PRICES: TieredPrice[] = [
|
||||
{ product_id: '', tenant_id: '', min_quantity: 1, max_quantity: 9, price: 0, discount_percent: 0 },
|
||||
{ product_id: '', tenant_id: '', min_quantity: 10, max_quantity: 49, price: 0, discount_percent: 5 },
|
||||
{ product_id: '', tenant_id: '', min_quantity: 50, max_quantity: 99, price: 0, discount_percent: 10 },
|
||||
{ product_id: '', tenant_id: '', min_quantity: 100, max_quantity: 499, price: 0, discount_percent: 15 },
|
||||
{ product_id: '', tenant_id: '', min_quantity: 500, max_quantity: undefined, price: 0, discount_percent: 20 },
|
||||
];
|
||||
|
||||
static async calculateTieredPrice(
|
||||
input: TieredPricingInput
|
||||
): Promise<TieredPricingOutput> {
|
||||
const { productId, quantity, customerId, tenantId, shopId, taskId, traceId, businessType } = input;
|
||||
|
||||
logger.info(`[B2BTradeService] Calculating tiered price - productId: ${productId}, quantity: ${quantity}, customerId: ${customerId}, tenantId: ${tenantId}, traceId: ${traceId}`);
|
||||
|
||||
try {
|
||||
const basePrice = await this.getProductBasePrice(productId, tenantId, traceId);
|
||||
|
||||
const appliedTier = this.findApplicableTier(quantity);
|
||||
|
||||
const discountMultiplier = 1 - (appliedTier.discount_percent / 100);
|
||||
const unitPrice = Number((basePrice * discountMultiplier).toFixed(2));
|
||||
const totalAmount = Number((unitPrice * quantity).toFixed(2));
|
||||
|
||||
const costPrice = basePrice;
|
||||
const profitMargin = Number(((unitPrice - costPrice) / unitPrice).toFixed(4));
|
||||
|
||||
const isApproved = profitMargin >= this.PROFIT_MARGIN_THRESHOLD;
|
||||
|
||||
let rejectionReason: string | undefined;
|
||||
if (!isApproved) {
|
||||
rejectionReason = `Profit margin ${(profitMargin * 100).toFixed(2)}% is below the minimum threshold of ${(this.PROFIT_MARGIN_THRESHOLD * 100).toFixed(0)}% for B2B transactions`;
|
||||
|
||||
logger.warn(`[B2BTradeService] Tiered price rejected due to low profit margin - productId: ${productId}, profitMargin: ${profitMargin}, threshold: ${this.PROFIT_MARGIN_THRESHOLD}`);
|
||||
}
|
||||
|
||||
const result: TieredPricingOutput = {
|
||||
unitPrice,
|
||||
totalAmount,
|
||||
discountPercent: appliedTier.discount_percent,
|
||||
appliedTier: {
|
||||
minQuantity: appliedTier.min_quantity,
|
||||
maxQuantity: appliedTier.max_quantity,
|
||||
},
|
||||
profitMargin,
|
||||
isApproved,
|
||||
rejectionReason,
|
||||
};
|
||||
|
||||
logger.info(`[B2BTradeService] Tiered price calculated - unitPrice: ${unitPrice}, totalAmount: ${totalAmount}, profitMargin: ${profitMargin}, isApproved: ${isApproved}`);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error(`[B2BTradeService] Tiered price calculation failed - error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async createBatchOrder(
|
||||
customerId: string,
|
||||
items: BatchOrderItem[],
|
||||
options: {
|
||||
tenantId: string;
|
||||
shopId?: string;
|
||||
taskId?: string;
|
||||
traceId: string;
|
||||
businessType: 'TOC' | 'TOB';
|
||||
}
|
||||
): Promise<BatchOrder> {
|
||||
const { tenantId, shopId, taskId, traceId, businessType } = options;
|
||||
|
||||
logger.info(`[B2BTradeService] Creating batch order - customerId: ${customerId}, itemCount: ${items.length}, tenantId: ${tenantId}, traceId: ${traceId}`);
|
||||
|
||||
try {
|
||||
const creditStatus = await this.checkCreditLimit(customerId, tenantId, traceId);
|
||||
|
||||
if (!creditStatus.canPlaceOrder) {
|
||||
throw new Error(`Credit limit exceeded: ${creditStatus.reason}`);
|
||||
}
|
||||
|
||||
let totalAmount = 0;
|
||||
for (const item of items) {
|
||||
const itemTotal = item.unit_price * item.quantity * (1 - (item.discount_percent || 0) / 100);
|
||||
totalAmount += itemTotal;
|
||||
}
|
||||
totalAmount = Number(totalAmount.toFixed(2));
|
||||
|
||||
if (totalAmount > creditStatus.creditAvailable) {
|
||||
throw new Error(`Order amount ${totalAmount} exceeds available credit ${creditStatus.creditAvailable}`);
|
||||
}
|
||||
|
||||
const order: BatchOrder = {
|
||||
tenant_id: tenantId,
|
||||
customer_id: customerId,
|
||||
total_amount: totalAmount,
|
||||
payment_status: 'PENDING',
|
||||
trace_id: traceId,
|
||||
task_id: taskId,
|
||||
business_type: 'TOB',
|
||||
items: items,
|
||||
};
|
||||
|
||||
logger.info(`[B2BTradeService] Batch order created - customerId: ${customerId}, totalAmount: ${totalAmount}, tenantId: ${tenantId}`);
|
||||
|
||||
return order;
|
||||
} catch (error: any) {
|
||||
logger.error(`[B2BTradeService] Batch order creation failed - error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async checkCreditLimit(
|
||||
customerId: string,
|
||||
tenantId: string,
|
||||
traceId: string
|
||||
): Promise<CreditStatus> {
|
||||
logger.info(`[B2BTradeService] Checking credit limit - customerId: ${customerId}, tenantId: ${tenantId}, traceId: ${traceId}`);
|
||||
|
||||
const mockCustomer: B2BCustomer = {
|
||||
id: customerId,
|
||||
tenant_id: tenantId,
|
||||
company_name: 'Sample Company',
|
||||
credit_limit: 50000,
|
||||
credit_used: 15000,
|
||||
payment_terms: 30,
|
||||
tier_level: 'SILVER',
|
||||
status: 'ACTIVE',
|
||||
trace_id: traceId,
|
||||
};
|
||||
|
||||
const creditLimit = mockCustomer.credit_limit;
|
||||
const creditUsed = mockCustomer.credit_used;
|
||||
const creditAvailable = creditLimit - creditUsed;
|
||||
|
||||
const canPlaceOrder = mockCustomer.status === 'ACTIVE' && creditAvailable > 0;
|
||||
|
||||
const result: CreditStatus = {
|
||||
customerId,
|
||||
creditLimit,
|
||||
creditUsed,
|
||||
creditAvailable,
|
||||
canPlaceOrder,
|
||||
reason: canPlaceOrder ? undefined : 'Customer is not active or has no available credit',
|
||||
};
|
||||
|
||||
logger.info(`[B2BTradeService] Credit limit checked - creditLimit: ${creditLimit}, creditUsed: ${creditUsed}, creditAvailable: ${creditAvailable}, canPlaceOrder: ${canPlaceOrder}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async setPaymentTerms(
|
||||
customerId: string,
|
||||
terms: {
|
||||
days: number;
|
||||
autoApprove: boolean;
|
||||
},
|
||||
options: {
|
||||
tenantId: string;
|
||||
traceId: string;
|
||||
}
|
||||
): Promise<{ customerId: string; days: number; status: 'ACTIVE' | 'SUSPENDED' | 'EXPIRED'; effectiveFrom: Date }> {
|
||||
const { tenantId, traceId } = options;
|
||||
|
||||
logger.info(`[B2BTradeService] Setting payment terms - customerId: ${customerId}, days: ${terms.days}, tenantId: ${tenantId}, traceId: ${traceId}`);
|
||||
|
||||
return {
|
||||
customerId,
|
||||
days: terms.days,
|
||||
status: 'ACTIVE',
|
||||
effectiveFrom: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
private static async getProductBasePrice(productId: string, tenantId: string, traceId: string): Promise<number> {
|
||||
logger.info(`[B2BTradeService] Getting product base price - productId: ${productId}, tenantId: ${tenantId}`);
|
||||
|
||||
return 100;
|
||||
}
|
||||
|
||||
private static findApplicableTier(quantity: number): TieredPrice {
|
||||
for (const tier of this.DEFAULT_TIERED_PRICES) {
|
||||
if (quantity >= tier.min_quantity) {
|
||||
if (tier.max_quantity === undefined || quantity <= tier.max_quantity) {
|
||||
return tier;
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.DEFAULT_TIERED_PRICES[this.DEFAULT_TIERED_PRICES.length - 1];
|
||||
}
|
||||
|
||||
static async getBatchOrderById(orderId: string, tenantId: string): Promise<BatchOrder | null> {
|
||||
logger.info(`[B2BTradeService] Getting batch order by ID - orderId: ${orderId}, tenantId: ${tenantId}`);
|
||||
|
||||
try {
|
||||
const order = await db('cf_b2b_batch_orders')
|
||||
.where({ id: orderId, tenant_id: tenantId })
|
||||
.first();
|
||||
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof order.items === 'string') {
|
||||
order.items = JSON.parse(order.items);
|
||||
}
|
||||
|
||||
return order as BatchOrder;
|
||||
} catch (error: any) {
|
||||
logger.error(`[B2BTradeService] Failed to get batch order - orderId: ${orderId}, error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async updatePaymentStatus(
|
||||
orderId: string,
|
||||
status: 'PENDING' | 'PARTIAL' | 'PAID' | 'OVERDUE',
|
||||
options: {
|
||||
tenantId: string;
|
||||
shopId?: string;
|
||||
traceId: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { tenantId, traceId } = options;
|
||||
|
||||
logger.info(`[B2BTradeService] Updating payment status - orderId: ${orderId}, status: ${status}, tenantId: ${tenantId}, traceId: ${traceId}`);
|
||||
|
||||
try {
|
||||
await db('cf_b2b_batch_orders')
|
||||
.where({ id: orderId, tenant_id: tenantId })
|
||||
.update({
|
||||
payment_status: status,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[B2BTradeService] Payment status updated - orderId: ${orderId}, status: ${status}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`[B2BTradeService] Failed to update payment status - orderId: ${orderId}, error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async checkOverdueOrders(tenantId: string): Promise<string[]> {
|
||||
logger.info(`[B2BTradeService] Checking overdue orders - tenantId: ${tenantId}`);
|
||||
|
||||
try {
|
||||
const overdueOrders = await db('cf_b2b_batch_orders')
|
||||
.where({ tenant_id: tenantId, payment_status: 'PENDING' })
|
||||
.where('due_date', '<', new Date())
|
||||
.select('id');
|
||||
|
||||
const overdueIds = overdueOrders.map((order: any) => order.id);
|
||||
|
||||
if (overdueIds.length > 0) {
|
||||
await db('cf_b2b_batch_orders')
|
||||
.whereIn('id', overdueIds)
|
||||
.update({ payment_status: 'OVERDUE', updated_at: new Date() });
|
||||
|
||||
logger.info(`[B2BTradeService] Updated overdue orders - tenantId: ${tenantId}, overdueCount: ${overdueIds.length}`);
|
||||
}
|
||||
|
||||
return overdueIds;
|
||||
} catch (error: any) {
|
||||
logger.error(`[B2BTradeService] Failed to check overdue orders - tenantId: ${tenantId}, error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async createCustomer(
|
||||
customerData: Omit<B2BCustomer, 'id' | 'created_at' | 'updated_at'>,
|
||||
options: {
|
||||
tenantId: string;
|
||||
shopId?: string;
|
||||
traceId: string;
|
||||
}
|
||||
): Promise<string> {
|
||||
const { tenantId, shopId, traceId } = options;
|
||||
|
||||
logger.info(`[B2BTradeService] Creating B2B customer - companyName: ${customerData.company_name}, tenantId: ${tenantId}, traceId: ${traceId}`);
|
||||
|
||||
try {
|
||||
const customerId = `B2B-CUST-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
const customer = {
|
||||
id: customerId,
|
||||
...customerData,
|
||||
tenant_id: tenantId,
|
||||
trace_id: traceId,
|
||||
business_type: 'TOB',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
await db('cf_b2b_customers').insert(customer);
|
||||
|
||||
logger.info(`[B2BTradeService] B2B customer created - customerId: ${customerId}, tenantId: ${tenantId}`);
|
||||
|
||||
return customerId;
|
||||
} catch (error: any) {
|
||||
logger.error(`[B2BTradeService] Failed to create B2B customer - tenantId: ${tenantId}, error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async createTieredPrice(
|
||||
priceData: Omit<TieredPrice, 'id' | 'created_at'>,
|
||||
options: {
|
||||
tenantId: string;
|
||||
shopId?: string;
|
||||
traceId: string;
|
||||
}
|
||||
): Promise<string> {
|
||||
const { tenantId, shopId, traceId } = options;
|
||||
|
||||
logger.info(`[B2BTradeService] Creating tiered price - productId: ${priceData.product_id}, tenantId: ${tenantId}, traceId: ${traceId}`);
|
||||
|
||||
try {
|
||||
const priceId = `TIER-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
const tieredPrice = {
|
||||
id: priceId,
|
||||
...priceData,
|
||||
tenant_id: tenantId,
|
||||
trace_id: traceId,
|
||||
created_at: new Date(),
|
||||
};
|
||||
|
||||
await db('cf_b2b_tiered_prices').insert(tieredPrice);
|
||||
|
||||
logger.info(`[B2BTradeService] Tiered price created - priceId: ${priceId}, productId: ${priceData.product_id}`);
|
||||
|
||||
return priceId;
|
||||
} catch (error: any) {
|
||||
logger.error(`[B2BTradeService] Failed to create tiered price - tenantId: ${tenantId}, error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async initTables(): Promise<void> {
|
||||
logger.info('[B2BTradeService] Initializing B2B tables');
|
||||
|
||||
try {
|
||||
if (!(await db.schema.hasTable('cf_b2b_customers'))) {
|
||||
await db.schema.createTable('cf_b2b_customers', (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('tenant_id').notNullable().index();
|
||||
table.string('company_name').notNullable();
|
||||
table.string('contact_name');
|
||||
table.string('contact_email');
|
||||
table.string('contact_phone');
|
||||
table.decimal('credit_limit', 10, 2).notNullable().defaultTo(0);
|
||||
table.decimal('credit_used', 10, 2).notNullable().defaultTo(0);
|
||||
table.integer('payment_terms').notNullable().defaultTo(30);
|
||||
table.enum('tier_level', ['BRONZE', 'SILVER', 'GOLD', 'PLATINUM']).notNullable().defaultTo('BRONZE');
|
||||
table.enum('status', ['ACTIVE', 'SUSPENDED', 'INACTIVE']).notNullable().defaultTo('ACTIVE');
|
||||
table.string('trace_id');
|
||||
table.string('business_type').defaultTo('TOB');
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info('[B2BTradeService] Table cf_b2b_customers created');
|
||||
}
|
||||
|
||||
if (!(await db.schema.hasTable('cf_b2b_tiered_prices'))) {
|
||||
await db.schema.createTable('cf_b2b_tiered_prices', (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('product_id').notNullable().index();
|
||||
table.string('tenant_id').notNullable().index();
|
||||
table.integer('min_quantity').notNullable();
|
||||
table.integer('max_quantity');
|
||||
table.decimal('price', 10, 2).notNullable();
|
||||
table.decimal('discount_percent', 5, 2).notNullable().defaultTo(0);
|
||||
table.string('trace_id');
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
table.index(['product_id', 'tenant_id', 'min_quantity']);
|
||||
});
|
||||
logger.info('[B2BTradeService] Table cf_b2b_tiered_prices created');
|
||||
}
|
||||
|
||||
if (!(await db.schema.hasTable('cf_b2b_batch_orders'))) {
|
||||
await db.schema.createTable('cf_b2b_batch_orders', (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('tenant_id').notNullable().index();
|
||||
table.string('customer_id').notNullable().index();
|
||||
table.decimal('total_amount', 10, 2).notNullable();
|
||||
table.enum('payment_status', ['PENDING', 'PARTIAL', 'PAID', 'OVERDUE']).notNullable().defaultTo('PENDING');
|
||||
table.date('due_date');
|
||||
table.json('items');
|
||||
table.string('trace_id');
|
||||
table.string('task_id');
|
||||
table.string('business_type').defaultTo('TOB');
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info('[B2BTradeService] Table cf_b2b_batch_orders created');
|
||||
}
|
||||
|
||||
logger.info('[B2BTradeService] B2B tables initialized successfully');
|
||||
} catch (error: any) {
|
||||
logger.error(`[B2BTradeService] Failed to initialize B2B tables - error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user