feat: 新增多模块功能与服务实现

新增广告计划、用户资产、B2B交易、合规规则等核心模型
实现爬虫工作器、贸易服务、现金流预测等业务服务
添加RBAC权限测试、压力测试等测试用例
完善扩展程序的消息处理与内容脚本功能
重构应用入口与文档生成器
更新项目规则与业务闭环分析文档
This commit is contained in:
2026-03-18 09:38:09 +08:00
parent 72cd7f6f45
commit 037e412aad
158 changed files with 50217 additions and 1313 deletions

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