- 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>
432 lines
12 KiB
TypeScript
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();
|