436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|