import { Repository, IsNull } from 'typeorm'; import { AppDataSource } from '../../../config/typeorm.js'; import { Partner } from '../entities/index.js'; import { NotFoundError } from '../../../shared/types/index.js'; import { logger } from '../../../shared/utils/logger.js'; // ============================================================================ // TYPES // ============================================================================ export type ABCClassification = 'A' | 'B' | 'C' | null; export interface PartnerRanking { id: string; tenant_id: string; partner_id: string; partner_name?: string; company_id: string | null; period_start: Date; period_end: Date; total_sales: number; sales_order_count: number; avg_order_value: number; total_purchases: number; purchase_order_count: number; avg_purchase_value: number; avg_payment_days: number | null; on_time_payment_rate: number | null; sales_rank: number | null; purchase_rank: number | null; customer_abc: ABCClassification; supplier_abc: ABCClassification; customer_score: number | null; supplier_score: number | null; overall_score: number | null; sales_trend: number | null; purchase_trend: number | null; calculated_at: Date; } export interface RankingCalculationResult { partners_processed: number; customers_ranked: number; suppliers_ranked: number; } export interface RankingFilters { company_id?: string; period_start?: string; period_end?: string; customer_abc?: ABCClassification; supplier_abc?: ABCClassification; min_sales?: number; min_purchases?: number; page?: number; limit?: number; } export interface TopPartner { id: string; tenant_id: string; name: string; email: string | null; is_customer: boolean; is_supplier: boolean; customer_rank: number | null; supplier_rank: number | null; customer_abc: ABCClassification; supplier_abc: ABCClassification; total_sales_ytd: number; total_purchases_ytd: number; last_ranking_date: Date | null; customer_category: string | null; supplier_category: string | null; } // ============================================================================ // SERVICE // ============================================================================ class RankingService { private partnerRepository: Repository; constructor() { this.partnerRepository = AppDataSource.getRepository(Partner); } /** * Calculate rankings for all partners in a tenant * Uses the database function for atomic calculation */ async calculateRankings( tenantId: string, companyId?: string, periodStart?: string, periodEnd?: string ): Promise { try { const result = await this.partnerRepository.query( `SELECT * FROM core.calculate_partner_rankings($1, $2, $3, $4)`, [tenantId, companyId || null, periodStart || null, periodEnd || null] ); const data = result[0]; if (!data) { throw new Error('Error calculando rankings'); } logger.info('Partner rankings calculated', { tenantId, companyId, periodStart, periodEnd, result: data, }); return { partners_processed: parseInt(data.partners_processed, 10), customers_ranked: parseInt(data.customers_ranked, 10), suppliers_ranked: parseInt(data.suppliers_ranked, 10), }; } catch (error) { logger.error('Error calculating partner rankings', { error: (error as Error).message, tenantId, companyId, }); throw error; } } /** * Get rankings for a specific period */ async findRankings( tenantId: string, filters: RankingFilters = {} ): Promise<{ data: PartnerRanking[]; total: number }> { try { const { company_id, period_start, period_end, customer_abc, supplier_abc, min_sales, min_purchases, page = 1, limit = 20, } = filters; const conditions: string[] = ['pr.tenant_id = $1']; const params: any[] = [tenantId]; let idx = 2; if (company_id) { conditions.push(`pr.company_id = $${idx++}`); params.push(company_id); } if (period_start) { conditions.push(`pr.period_start >= $${idx++}`); params.push(period_start); } if (period_end) { conditions.push(`pr.period_end <= $${idx++}`); params.push(period_end); } if (customer_abc) { conditions.push(`pr.customer_abc = $${idx++}`); params.push(customer_abc); } if (supplier_abc) { conditions.push(`pr.supplier_abc = $${idx++}`); params.push(supplier_abc); } if (min_sales !== undefined) { conditions.push(`pr.total_sales >= $${idx++}`); params.push(min_sales); } if (min_purchases !== undefined) { conditions.push(`pr.total_purchases >= $${idx++}`); params.push(min_purchases); } const whereClause = conditions.join(' AND '); // Count total const countResult = await this.partnerRepository.query( `SELECT COUNT(*) as count FROM core.partner_rankings pr WHERE ${whereClause}`, params ); // Get data with pagination const offset = (page - 1) * limit; params.push(limit, offset); const data = await this.partnerRepository.query( `SELECT pr.*, p.name as partner_name FROM core.partner_rankings pr JOIN core.partners p ON pr.partner_id = p.id WHERE ${whereClause} ORDER BY pr.overall_score DESC NULLS LAST, pr.total_sales DESC LIMIT $${idx} OFFSET $${idx + 1}`, params ); return { data, total: parseInt(countResult[0]?.count || '0', 10), }; } catch (error) { logger.error('Error retrieving partner rankings', { error: (error as Error).message, tenantId, }); throw error; } } /** * Get ranking for a specific partner */ async findPartnerRanking( partnerId: string, tenantId: string, periodStart?: string, periodEnd?: string ): Promise { try { let sql = ` SELECT pr.*, p.name as partner_name FROM core.partner_rankings pr JOIN core.partners p ON pr.partner_id = p.id WHERE pr.partner_id = $1 AND pr.tenant_id = $2 `; const params: any[] = [partnerId, tenantId]; if (periodStart && periodEnd) { sql += ` AND pr.period_start = $3 AND pr.period_end = $4`; params.push(periodStart, periodEnd); } else { // Get most recent ranking sql += ` ORDER BY pr.calculated_at DESC LIMIT 1`; } const result = await this.partnerRepository.query(sql, params); return result[0] || null; } catch (error) { logger.error('Error finding partner ranking', { error: (error as Error).message, partnerId, tenantId, }); throw error; } } /** * Get top partners (customers or suppliers) */ async getTopPartners( tenantId: string, type: 'customers' | 'suppliers', limit: number = 10 ): Promise { try { const orderColumn = type === 'customers' ? 'customer_rank' : 'supplier_rank'; const result = await this.partnerRepository.query( `SELECT * FROM core.top_partners_view WHERE tenant_id = $1 AND ${orderColumn} IS NOT NULL ORDER BY ${orderColumn} ASC LIMIT $2`, [tenantId, limit] ); return result; } catch (error) { logger.error('Error getting top partners', { error: (error as Error).message, tenantId, type, }); throw error; } } /** * Get ABC distribution summary */ async getABCDistribution( tenantId: string, type: 'customers' | 'suppliers', companyId?: string ): Promise<{ A: { count: number; total_value: number; percentage: number }; B: { count: number; total_value: number; percentage: number }; C: { count: number; total_value: number; percentage: number }; }> { try { const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc'; const valueColumn = type === 'customers' ? 'total_sales_ytd' : 'total_purchases_ytd'; const whereClause = `tenant_id = $1 AND ${abcColumn} IS NOT NULL`; const params: any[] = [tenantId]; const result = await this.partnerRepository.query( `SELECT ${abcColumn} as abc, COUNT(*) as count, COALESCE(SUM(${valueColumn}), 0) as total_value FROM core.partners WHERE ${whereClause} AND deleted_at IS NULL GROUP BY ${abcColumn} ORDER BY ${abcColumn}`, params ); // Calculate totals const grandTotal = result.reduce((sum: number, r: any) => sum + parseFloat(r.total_value), 0); const distribution = { A: { count: 0, total_value: 0, percentage: 0 }, B: { count: 0, total_value: 0, percentage: 0 }, C: { count: 0, total_value: 0, percentage: 0 }, }; for (const row of result) { const abc = row.abc as 'A' | 'B' | 'C'; if (abc in distribution) { distribution[abc] = { count: parseInt(row.count, 10), total_value: parseFloat(row.total_value), percentage: grandTotal > 0 ? (parseFloat(row.total_value) / grandTotal) * 100 : 0, }; } } return distribution; } catch (error) { logger.error('Error getting ABC distribution', { error: (error as Error).message, tenantId, type, }); throw error; } } /** * Get ranking history for a partner */ async getPartnerRankingHistory( partnerId: string, tenantId: string, limit: number = 12 ): Promise { try { const result = await this.partnerRepository.query( `SELECT pr.*, p.name as partner_name FROM core.partner_rankings pr JOIN core.partners p ON pr.partner_id = p.id WHERE pr.partner_id = $1 AND pr.tenant_id = $2 ORDER BY pr.period_end DESC LIMIT $3`, [partnerId, tenantId, limit] ); return result; } catch (error) { logger.error('Error getting partner ranking history', { error: (error as Error).message, partnerId, tenantId, }); throw error; } } /** * Get partners by ABC classification */ async findPartnersByABC( tenantId: string, abc: ABCClassification, type: 'customers' | 'suppliers', page: number = 1, limit: number = 20 ): Promise<{ data: TopPartner[]; total: number }> { try { const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc'; const offset = (page - 1) * limit; const countResult = await this.partnerRepository.query( `SELECT COUNT(*) as count FROM core.partners WHERE tenant_id = $1 AND ${abcColumn} = $2 AND deleted_at IS NULL`, [tenantId, abc] ); const data = await this.partnerRepository.query( `SELECT * FROM core.top_partners_view WHERE tenant_id = $1 AND ${abcColumn} = $2 ORDER BY ${type === 'customers' ? 'total_sales_ytd' : 'total_purchases_ytd'} DESC LIMIT $3 OFFSET $4`, [tenantId, abc, limit, offset] ); return { data, total: parseInt(countResult[0]?.count || '0', 10), }; } catch (error) { logger.error('Error finding partners by ABC', { error: (error as Error).message, tenantId, abc, type, }); throw error; } } } export const rankingService = new RankingService();