erp-core-backend-v2/src/modules/partners/services/ranking.service.ts
Adrian Flores Cortes 7a957a69c7 refactor: Consolidate duplicate services and normalize module structure
- Partners: Moved services to services/ directory, consolidated duplicates,
  kept singleton pattern version with ranking service
- Products: Moved service to services/, removed duplicate class-based version,
  kept singleton with deletedAt filtering
- Reports: Moved service to services/, kept raw SQL version for active controller
- Warehouses: Moved service to services/, removed duplicate class-based version,
  kept singleton with proper tenant isolation

All modules now follow consistent structure:
- services/*.service.ts for business logic
- services/index.ts for exports
- Controllers import from ./services/index.js

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 04:40:16 -06:00

432 lines
12 KiB
TypeScript

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<Partner>;
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<RankingCalculationResult> {
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<PartnerRanking | null> {
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<TopPartner[]> {
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<PartnerRanking[]> {
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();