feat: Implement Phase 3 & 4 - AI Reports & ERP Integrations
## Phase 3: DeepSeek AI Integration ### AI Services (apps/api/src/services/ai/) - DeepSeek API client with streaming, rate limiting, retries - AI service for financial analysis, executive summaries, recommendations - Optimized prompts for Mexican CFO digital assistant - Redis caching for AI responses ### Report Generation (apps/api/src/services/reports/) - ReportGenerator: monthly, quarterly, annual, custom reports - PDF generator with corporate branding (PDFKit) - Report templates for PYME, Startup, Enterprise - Spanish prompts for financial narratives - MinIO storage for generated PDFs ### AI & Reports API Routes - POST /api/ai/analyze - Financial metrics analysis - POST /api/ai/chat - CFO digital chat with streaming - POST /api/reports/generate - Async report generation - GET /api/reports/:id/download - PDF download - BullMQ jobs for background processing ### Frontend Pages - /reportes - Report listing with filters - /reportes/nuevo - 3-step wizard for report generation - /reportes/[id] - Full report viewer with charts - /asistente - CFO Digital chat interface - Components: ChatInterface, ReportCard, AIInsightCard ## Phase 4: ERP Integrations ### CONTPAQi Connector (SQL Server) - Contabilidad: catalog, polizas, balanza, estado de resultados - Comercial: clientes, proveedores, facturas, inventario - Nominas: empleados, nominas, percepciones/deducciones ### Aspel Connector (Firebird/SQL Server) - COI: accounting, polizas, balanza, auxiliares - SAE: sales, purchases, inventory, A/R, A/P - NOI: payroll, employees, receipts - BANCO: bank accounts, movements, reconciliation - Latin1 to UTF-8 encoding handling ### Odoo Connector (XML-RPC) - Accounting: chart of accounts, journal entries, reports - Invoicing: customer/vendor invoices, payments - Partners: customers, vendors, statements - Inventory: products, stock levels, valuations - Multi-company and version 14-17 support ### Alegra Connector (REST API) - Invoices, credit/debit notes with CFDI support - Contacts with Mexican fiscal fields (RFC, regimen) - Payments and bank reconciliation - Financial reports: trial balance, P&L, balance sheet - Webhook support for real-time sync ### SAP Business One Connector (OData/Service Layer) - Session management with auto-refresh - Financials: chart of accounts, journal entries, reports - Sales: invoices, credit notes, delivery notes, orders - Purchasing: bills, POs, goods receipts - Inventory: items, stock, transfers, valuation - Banking: accounts, payments, cash flow ### Integration Manager - Unified interface for all connectors - BullMQ scheduler for automated sync - Webhook handling for real-time updates - Migration 003_integrations.sql with sync tables - Frontend page /integraciones for configuration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,3 +56,8 @@ LOG_FORMAT=simple
|
|||||||
# Feature Flags
|
# Feature Flags
|
||||||
ENABLE_SWAGGER=true
|
ENABLE_SWAGGER=true
|
||||||
ENABLE_METRICS=true
|
ENABLE_METRICS=true
|
||||||
|
|
||||||
|
# DeepSeek AI
|
||||||
|
DEEPSEEK_API_KEY=your-deepseek-api-key
|
||||||
|
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"minio": "^7.1.3",
|
"minio": "^7.1.3",
|
||||||
|
"mssql": "^10.0.2",
|
||||||
|
"pdfkit": "^0.15.0",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
@@ -40,7 +42,9 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.5",
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/mssql": "^9.1.5",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/pdfkit": "^0.13.4",
|
||||||
"@types/pg": "^8.10.9",
|
"@types/pg": "^8.10.9",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
|
|||||||
853
apps/api/src/controllers/ai.controller.ts
Normal file
853
apps/api/src/controllers/ai.controller.ts
Normal file
@@ -0,0 +1,853 @@
|
|||||||
|
/**
|
||||||
|
* AI Controller
|
||||||
|
*
|
||||||
|
* Handles AI-powered analysis, explanations, recommendations, and chat
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getDatabase, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
AppError,
|
||||||
|
RateLimitError,
|
||||||
|
ValidationError,
|
||||||
|
} from '../types/index.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface AIUsage {
|
||||||
|
userId: string;
|
||||||
|
tenantId: string;
|
||||||
|
tokensUsed: number;
|
||||||
|
requestCount: number;
|
||||||
|
lastRequestAt: Date;
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIContext {
|
||||||
|
metrics?: Record<string, unknown>;
|
||||||
|
transactions?: unknown[];
|
||||||
|
categories?: unknown[];
|
||||||
|
dateRange?: { start: string; end: string };
|
||||||
|
previousPeriod?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
timestamp?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const AnalyzeRequestSchema = z.object({
|
||||||
|
metrics: z.array(z.string()).min(1, 'Debes especificar al menos una metrica'),
|
||||||
|
dateRange: z.object({
|
||||||
|
start: z.string().datetime(),
|
||||||
|
end: z.string().datetime(),
|
||||||
|
}),
|
||||||
|
compareWith: z.enum(['previous_period', 'year_ago', 'none']).optional().default('previous_period'),
|
||||||
|
depth: z.enum(['quick', 'standard', 'detailed']).optional().default('standard'),
|
||||||
|
focusAreas: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ExplainRequestSchema = z.object({
|
||||||
|
type: z.enum(['metric', 'anomaly', 'trend', 'transaction', 'forecast']),
|
||||||
|
entityId: z.string().optional(),
|
||||||
|
context: z.object({
|
||||||
|
metricCode: z.string().optional(),
|
||||||
|
value: z.number().optional(),
|
||||||
|
previousValue: z.number().optional(),
|
||||||
|
changePercent: z.number().optional(),
|
||||||
|
dateRange: z.object({
|
||||||
|
start: z.string().datetime(),
|
||||||
|
end: z.string().datetime(),
|
||||||
|
}).optional(),
|
||||||
|
}),
|
||||||
|
language: z.enum(['es', 'en']).optional().default('es'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RecommendRequestSchema = z.object({
|
||||||
|
area: z.enum([
|
||||||
|
'cost_reduction',
|
||||||
|
'revenue_growth',
|
||||||
|
'cash_flow',
|
||||||
|
'tax_optimization',
|
||||||
|
'budget_planning',
|
||||||
|
'general',
|
||||||
|
]),
|
||||||
|
dateRange: z.object({
|
||||||
|
start: z.string().datetime(),
|
||||||
|
end: z.string().datetime(),
|
||||||
|
}),
|
||||||
|
constraints: z.object({
|
||||||
|
maxBudget: z.number().optional(),
|
||||||
|
riskTolerance: z.enum(['low', 'medium', 'high']).optional().default('medium'),
|
||||||
|
timeHorizon: z.enum(['short', 'medium', 'long']).optional().default('medium'),
|
||||||
|
}).optional(),
|
||||||
|
excludeCategories: z.array(z.string().uuid()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChatRequestSchema = z.object({
|
||||||
|
message: z.string().min(1, 'El mensaje no puede estar vacio').max(2000),
|
||||||
|
conversationId: z.string().uuid().optional(),
|
||||||
|
context: z.object({
|
||||||
|
currentView: z.string().optional(),
|
||||||
|
selectedMetrics: z.array(z.string()).optional(),
|
||||||
|
dateRange: z.object({
|
||||||
|
start: z.string().datetime(),
|
||||||
|
end: z.string().datetime(),
|
||||||
|
}).optional(),
|
||||||
|
}).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Rate Limiting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface RateLimitConfig {
|
||||||
|
maxRequestsPerMinute: number;
|
||||||
|
maxRequestsPerDay: number;
|
||||||
|
maxTokensPerDay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AI_RATE_LIMITS: Record<string, RateLimitConfig> = {
|
||||||
|
free: {
|
||||||
|
maxRequestsPerMinute: 5,
|
||||||
|
maxRequestsPerDay: 50,
|
||||||
|
maxTokensPerDay: 50000,
|
||||||
|
},
|
||||||
|
starter: {
|
||||||
|
maxRequestsPerMinute: 10,
|
||||||
|
maxRequestsPerDay: 200,
|
||||||
|
maxTokensPerDay: 200000,
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
maxRequestsPerMinute: 20,
|
||||||
|
maxRequestsPerDay: 500,
|
||||||
|
maxTokensPerDay: 500000,
|
||||||
|
},
|
||||||
|
enterprise: {
|
||||||
|
maxRequestsPerMinute: 50,
|
||||||
|
maxRequestsPerDay: 2000,
|
||||||
|
maxTokensPerDay: 2000000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-memory rate limit tracking (should use Redis in production)
|
||||||
|
const rateLimitCache = new Map<string, {
|
||||||
|
minuteCount: number;
|
||||||
|
minuteStart: number;
|
||||||
|
dayCount: number;
|
||||||
|
dayStart: number;
|
||||||
|
tokensUsed: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
async function checkRateLimit(
|
||||||
|
userId: string,
|
||||||
|
tenantId: string,
|
||||||
|
planId: string,
|
||||||
|
estimatedTokens: number
|
||||||
|
): Promise<void> {
|
||||||
|
const limits = AI_RATE_LIMITS[planId] || AI_RATE_LIMITS.free;
|
||||||
|
const cacheKey = `${tenantId}:${userId}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const minuteWindow = 60 * 1000;
|
||||||
|
const dayWindow = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
let usage = rateLimitCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (!usage) {
|
||||||
|
usage = {
|
||||||
|
minuteCount: 0,
|
||||||
|
minuteStart: now,
|
||||||
|
dayCount: 0,
|
||||||
|
dayStart: now,
|
||||||
|
tokensUsed: 0,
|
||||||
|
};
|
||||||
|
rateLimitCache.set(cacheKey, usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset minute window if needed
|
||||||
|
if (now - usage.minuteStart > minuteWindow) {
|
||||||
|
usage.minuteCount = 0;
|
||||||
|
usage.minuteStart = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset day window if needed
|
||||||
|
if (now - usage.dayStart > dayWindow) {
|
||||||
|
usage.dayCount = 0;
|
||||||
|
usage.dayStart = now;
|
||||||
|
usage.tokensUsed = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check limits
|
||||||
|
if (usage.minuteCount >= limits.maxRequestsPerMinute) {
|
||||||
|
const waitTime = Math.ceil((minuteWindow - (now - usage.minuteStart)) / 1000);
|
||||||
|
throw new RateLimitError(
|
||||||
|
`Has excedido el limite de ${limits.maxRequestsPerMinute} solicitudes por minuto. Espera ${waitTime} segundos.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usage.dayCount >= limits.maxRequestsPerDay) {
|
||||||
|
throw new RateLimitError(
|
||||||
|
`Has excedido el limite de ${limits.maxRequestsPerDay} solicitudes diarias. Intenta manana.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usage.tokensUsed + estimatedTokens > limits.maxTokensPerDay) {
|
||||||
|
throw new RateLimitError(
|
||||||
|
`Has excedido el limite de tokens diarios. Intenta manana o mejora tu plan.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update usage
|
||||||
|
usage.minuteCount++;
|
||||||
|
usage.dayCount++;
|
||||||
|
usage.tokensUsed += estimatedTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getTenantContext = (req: Request): TenantContext => {
|
||||||
|
if (!req.user || !req.tenantSchema) {
|
||||||
|
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
schemaName: req.tenantSchema,
|
||||||
|
userId: req.user.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getUserPlan(tenantId: string): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const result = await db.queryPublic<{ plan_id: string }>(
|
||||||
|
'SELECT plan_id FROM public.tenants WHERE id = $1',
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
return result.rows[0]?.plan_id || 'free';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContextData(
|
||||||
|
tenant: TenantContext,
|
||||||
|
dateRange: { start: string; end: string }
|
||||||
|
): Promise<AIContext> {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Get key metrics
|
||||||
|
const metricsQuery = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense,
|
||||||
|
COUNT(*) as transaction_count,
|
||||||
|
COUNT(DISTINCT contact_id) as unique_contacts
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const metricsResult = await db.queryTenant(tenant, metricsQuery, [dateRange.start, dateRange.end]);
|
||||||
|
|
||||||
|
// Get top categories
|
||||||
|
const categoriesQuery = `
|
||||||
|
SELECT
|
||||||
|
c.name,
|
||||||
|
c.type,
|
||||||
|
SUM(t.amount) as total,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM transactions t
|
||||||
|
JOIN categories c ON t.category_id = c.id
|
||||||
|
WHERE t.date >= $1 AND t.date <= $2 AND t.status != 'cancelled'
|
||||||
|
GROUP BY c.id, c.name, c.type
|
||||||
|
ORDER BY SUM(t.amount) DESC
|
||||||
|
LIMIT 10
|
||||||
|
`;
|
||||||
|
|
||||||
|
const categoriesResult = await db.queryTenant(tenant, categoriesQuery, [dateRange.start, dateRange.end]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics: metricsResult.rows[0],
|
||||||
|
categories: categoriesResult.rows,
|
||||||
|
dateRange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logAIRequest(
|
||||||
|
tenant: TenantContext,
|
||||||
|
requestType: string,
|
||||||
|
tokensUsed: number,
|
||||||
|
durationMs: number
|
||||||
|
): Promise<void> {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
`INSERT INTO ai_usage_logs (user_id, request_type, tokens_used, duration_ms)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[tenant.userId, requestType, tokensUsed, durationMs]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail the request
|
||||||
|
logger.warn('Failed to log AI request', { error, tenantId: tenant.tenantId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Controller Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze metrics with AI
|
||||||
|
*/
|
||||||
|
export const analyzeMetrics = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = req.body as z.infer<typeof AnalyzeRequestSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const planId = await getUserPlan(tenant.tenantId);
|
||||||
|
|
||||||
|
// Estimate tokens (rough estimate based on depth)
|
||||||
|
const estimatedTokens = body.depth === 'detailed' ? 2000 : body.depth === 'standard' ? 1000 : 500;
|
||||||
|
|
||||||
|
// Check rate limits
|
||||||
|
await checkRateLimit(req.user!.sub, tenant.tenantId, planId, estimatedTokens);
|
||||||
|
|
||||||
|
// Get context data
|
||||||
|
const context = await getContextData(tenant, body.dateRange);
|
||||||
|
|
||||||
|
// TODO: Call actual AI service (OpenAI/Claude)
|
||||||
|
// For now, return a mock response
|
||||||
|
|
||||||
|
const analysis = {
|
||||||
|
summary: 'Analisis de metricas financieras',
|
||||||
|
insights: [
|
||||||
|
{
|
||||||
|
type: 'observation',
|
||||||
|
title: 'Tendencia de ingresos',
|
||||||
|
description: 'Los ingresos muestran una tendencia estable durante el periodo analizado.',
|
||||||
|
confidence: 0.85,
|
||||||
|
importance: 'medium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Gastos operativos',
|
||||||
|
description: 'Los gastos operativos representan un porcentaje alto de los ingresos.',
|
||||||
|
confidence: 0.90,
|
||||||
|
importance: 'high',
|
||||||
|
recommendation: 'Considera revisar los gastos de las categorias principales.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metrics: body.metrics.map(m => ({
|
||||||
|
code: m,
|
||||||
|
analysis: `Analisis detallado de ${m}`,
|
||||||
|
trend: 'stable',
|
||||||
|
healthScore: 75,
|
||||||
|
})),
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
tokensUsed: estimatedTokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Log the request
|
||||||
|
await logAIRequest(tenant, 'analyze', estimatedTokens, durationMs);
|
||||||
|
|
||||||
|
logger.info('AI analysis completed', {
|
||||||
|
userId: req.user!.sub,
|
||||||
|
depth: body.depth,
|
||||||
|
metricsCount: body.metrics.length,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: analysis,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
tokensUsed: estimatedTokens,
|
||||||
|
durationMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explain a metric, anomaly, or trend
|
||||||
|
*/
|
||||||
|
export const explainEntity = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = req.body as z.infer<typeof ExplainRequestSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const planId = await getUserPlan(tenant.tenantId);
|
||||||
|
|
||||||
|
const estimatedTokens = 500;
|
||||||
|
|
||||||
|
await checkRateLimit(req.user!.sub, tenant.tenantId, planId, estimatedTokens);
|
||||||
|
|
||||||
|
// TODO: Call actual AI service
|
||||||
|
|
||||||
|
const explanation = {
|
||||||
|
type: body.type,
|
||||||
|
title: getExplanationTitle(body.type, body.context),
|
||||||
|
explanation: getExplanationText(body.type, body.context, body.language),
|
||||||
|
keyPoints: [
|
||||||
|
'Punto clave 1 sobre la metrica o anomalia',
|
||||||
|
'Punto clave 2 con contexto relevante',
|
||||||
|
'Punto clave 3 con implicaciones',
|
||||||
|
],
|
||||||
|
relatedMetrics: ['cash_flow', 'profit_margin'],
|
||||||
|
suggestedActions: [
|
||||||
|
{
|
||||||
|
action: 'Revisar transacciones del periodo',
|
||||||
|
priority: 'high',
|
||||||
|
effort: 'low',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
await logAIRequest(tenant, 'explain', estimatedTokens, durationMs);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: explanation,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
tokensUsed: estimatedTokens,
|
||||||
|
durationMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AI-powered recommendations
|
||||||
|
*/
|
||||||
|
export const getRecommendations = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = req.body as z.infer<typeof RecommendRequestSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const planId = await getUserPlan(tenant.tenantId);
|
||||||
|
|
||||||
|
const estimatedTokens = 1500;
|
||||||
|
|
||||||
|
await checkRateLimit(req.user!.sub, tenant.tenantId, planId, estimatedTokens);
|
||||||
|
|
||||||
|
// Get context data
|
||||||
|
const context = await getContextData(tenant, body.dateRange);
|
||||||
|
|
||||||
|
// TODO: Call actual AI service
|
||||||
|
|
||||||
|
const recommendations = {
|
||||||
|
area: body.area,
|
||||||
|
summary: `Recomendaciones para ${getAreaLabel(body.area)}`,
|
||||||
|
recommendations: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Optimizar gastos recurrentes',
|
||||||
|
description: 'Identifica suscripciones y servicios que pueden reducirse o eliminarse.',
|
||||||
|
impact: 'high',
|
||||||
|
effort: 'medium',
|
||||||
|
estimatedSavings: 5000,
|
||||||
|
timeToImplement: '1-2 semanas',
|
||||||
|
steps: [
|
||||||
|
'Revisar lista de gastos recurrentes',
|
||||||
|
'Identificar servicios subutilizados',
|
||||||
|
'Negociar mejores tarifas o cancelar',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Mejorar terminos de pago',
|
||||||
|
description: 'Negocia mejores terminos con proveedores para mejorar flujo de efectivo.',
|
||||||
|
impact: 'medium',
|
||||||
|
effort: 'low',
|
||||||
|
estimatedSavings: 2000,
|
||||||
|
timeToImplement: '2-4 semanas',
|
||||||
|
steps: [
|
||||||
|
'Identificar top 5 proveedores por volumen',
|
||||||
|
'Preparar propuesta de terminos',
|
||||||
|
'Negociar con cada proveedor',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
constraints: body.constraints,
|
||||||
|
contextUsed: {
|
||||||
|
totalIncome: context.metrics?.total_income,
|
||||||
|
totalExpense: context.metrics?.total_expense,
|
||||||
|
topCategories: (context.categories as unknown[])?.slice(0, 3),
|
||||||
|
},
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
await logAIRequest(tenant, 'recommend', estimatedTokens, durationMs);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: recommendations,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
tokensUsed: estimatedTokens,
|
||||||
|
durationMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat with CFO Digital
|
||||||
|
* Supports streaming responses
|
||||||
|
*/
|
||||||
|
export const chat = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = req.body as z.infer<typeof ChatRequestSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const planId = await getUserPlan(tenant.tenantId);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const estimatedTokens = 800;
|
||||||
|
|
||||||
|
await checkRateLimit(req.user!.sub, tenant.tenantId, planId, estimatedTokens);
|
||||||
|
|
||||||
|
// Get or create conversation
|
||||||
|
let conversationId = body.conversationId;
|
||||||
|
let messages: ChatMessage[] = [];
|
||||||
|
|
||||||
|
if (conversationId) {
|
||||||
|
// Fetch existing conversation
|
||||||
|
const convResult = await db.queryTenant<{ messages: ChatMessage[] }>(
|
||||||
|
tenant,
|
||||||
|
'SELECT messages FROM ai_conversations WHERE id = $1 AND user_id = $2',
|
||||||
|
[conversationId, req.user!.sub]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (convResult.rows.length > 0) {
|
||||||
|
messages = convResult.rows[0].messages || [];
|
||||||
|
} else {
|
||||||
|
conversationId = undefined; // Create new if not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user message
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: body.message,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get relevant context if provided
|
||||||
|
let contextData: AIContext | undefined;
|
||||||
|
if (body.context?.dateRange) {
|
||||||
|
contextData = await getContextData(tenant, body.context.dateRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Call actual AI service with streaming
|
||||||
|
// For now, return a mock response
|
||||||
|
|
||||||
|
const assistantMessage = generateChatResponse(body.message, contextData);
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: assistantMessage,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save or create conversation
|
||||||
|
if (conversationId) {
|
||||||
|
await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
`UPDATE ai_conversations
|
||||||
|
SET messages = $2, updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[conversationId, JSON.stringify(messages)]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const createResult = await db.queryTenant<{ id: string }>(
|
||||||
|
tenant,
|
||||||
|
`INSERT INTO ai_conversations (user_id, title, messages)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
req.user!.sub,
|
||||||
|
body.message.slice(0, 50) + (body.message.length > 50 ? '...' : ''),
|
||||||
|
JSON.stringify(messages),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
conversationId = createResult.rows[0]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
await logAIRequest(tenant, 'chat', estimatedTokens, durationMs);
|
||||||
|
|
||||||
|
// Check if client wants streaming
|
||||||
|
const wantsStream = req.headers.accept?.includes('text/event-stream');
|
||||||
|
|
||||||
|
if (wantsStream) {
|
||||||
|
// SSE streaming response
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
|
// Simulate streaming by sending chunks
|
||||||
|
const words = assistantMessage.split(' ');
|
||||||
|
for (let i = 0; i < words.length; i += 3) {
|
||||||
|
const chunk = words.slice(i, i + 3).join(' ');
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'chunk', content: chunk + ' ' })}\n\n`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'done', conversationId })}\n\n`);
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
// Regular JSON response
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
conversationId,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: assistantMessage,
|
||||||
|
},
|
||||||
|
messagesCount: messages.length,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
tokensUsed: estimatedTokens,
|
||||||
|
durationMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AI usage statistics
|
||||||
|
*/
|
||||||
|
export const getUsage = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
const planId = await getUserPlan(tenant.tenantId);
|
||||||
|
const limits = AI_RATE_LIMITS[planId] || AI_RATE_LIMITS.free;
|
||||||
|
|
||||||
|
// Get current day's usage
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const usageQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as request_count,
|
||||||
|
COALESCE(SUM(tokens_used), 0) as tokens_used,
|
||||||
|
MAX(created_at) as last_request
|
||||||
|
FROM ai_usage_logs
|
||||||
|
WHERE user_id = $1 AND created_at >= $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const usageResult = await db.queryTenant<{
|
||||||
|
request_count: string;
|
||||||
|
tokens_used: string;
|
||||||
|
last_request: Date;
|
||||||
|
}>(tenant, usageQuery, [req.user!.sub, today.toISOString()]);
|
||||||
|
|
||||||
|
const usage = usageResult.rows[0];
|
||||||
|
|
||||||
|
// Get usage by type
|
||||||
|
const byTypeQuery = `
|
||||||
|
SELECT
|
||||||
|
request_type,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(tokens_used) as tokens
|
||||||
|
FROM ai_usage_logs
|
||||||
|
WHERE user_id = $1 AND created_at >= $2
|
||||||
|
GROUP BY request_type
|
||||||
|
`;
|
||||||
|
|
||||||
|
const byTypeResult = await db.queryTenant(tenant, byTypeQuery, [
|
||||||
|
req.user!.sub,
|
||||||
|
today.toISOString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get weekly trend
|
||||||
|
const trendQuery = `
|
||||||
|
SELECT
|
||||||
|
DATE(created_at) as date,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
SUM(tokens_used) as tokens
|
||||||
|
FROM ai_usage_logs
|
||||||
|
WHERE user_id = $1 AND created_at >= NOW() - INTERVAL '7 days'
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date
|
||||||
|
`;
|
||||||
|
|
||||||
|
const trendResult = await db.queryTenant(tenant, trendQuery, [req.user!.sub]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
today: {
|
||||||
|
requestCount: parseInt(usage?.request_count || '0', 10),
|
||||||
|
tokensUsed: parseInt(usage?.tokens_used || '0', 10),
|
||||||
|
lastRequest: usage?.last_request,
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
maxRequestsPerDay: limits.maxRequestsPerDay,
|
||||||
|
maxTokensPerDay: limits.maxTokensPerDay,
|
||||||
|
requestsRemaining: Math.max(0, limits.maxRequestsPerDay - parseInt(usage?.request_count || '0', 10)),
|
||||||
|
tokensRemaining: Math.max(0, limits.maxTokensPerDay - parseInt(usage?.tokens_used || '0', 10)),
|
||||||
|
},
|
||||||
|
byType: byTypeResult.rows,
|
||||||
|
weeklyTrend: trendResult.rows,
|
||||||
|
plan: planId,
|
||||||
|
resetsAt: new Date(today.getTime() + 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function getExplanationTitle(type: string, context?: { metricCode?: string }): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'metric':
|
||||||
|
return `Explicacion de ${context?.metricCode || 'metrica'}`;
|
||||||
|
case 'anomaly':
|
||||||
|
return 'Deteccion de anomalia';
|
||||||
|
case 'trend':
|
||||||
|
return 'Analisis de tendencia';
|
||||||
|
case 'transaction':
|
||||||
|
return 'Analisis de transaccion';
|
||||||
|
case 'forecast':
|
||||||
|
return 'Explicacion de proyeccion';
|
||||||
|
default:
|
||||||
|
return 'Explicacion';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExplanationText(
|
||||||
|
type: string,
|
||||||
|
context?: { value?: number; previousValue?: number; changePercent?: number },
|
||||||
|
language?: string
|
||||||
|
): string {
|
||||||
|
const isSpanish = language !== 'en';
|
||||||
|
|
||||||
|
if (context?.changePercent !== undefined) {
|
||||||
|
const direction = context.changePercent > 0 ? 'aumento' : 'disminucion';
|
||||||
|
return isSpanish
|
||||||
|
? `Se observa un ${direction} del ${Math.abs(context.changePercent).toFixed(1)}% respecto al periodo anterior. Este cambio puede atribuirse a varios factores que se detallan a continuacion.`
|
||||||
|
: `A ${context.changePercent > 0 ? 'increase' : 'decrease'} of ${Math.abs(context.changePercent).toFixed(1)}% is observed compared to the previous period.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSpanish
|
||||||
|
? 'Esta metrica refleja el estado actual de tu negocio basado en los datos disponibles.'
|
||||||
|
: 'This metric reflects the current state of your business based on available data.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAreaLabel(area: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
cost_reduction: 'reduccion de costos',
|
||||||
|
revenue_growth: 'crecimiento de ingresos',
|
||||||
|
cash_flow: 'flujo de efectivo',
|
||||||
|
tax_optimization: 'optimizacion fiscal',
|
||||||
|
budget_planning: 'planificacion de presupuesto',
|
||||||
|
general: 'mejora general',
|
||||||
|
};
|
||||||
|
return labels[area] || area;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateChatResponse(message: string, context?: AIContext): string {
|
||||||
|
// TODO: Replace with actual AI call
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerMessage.includes('hola') || lowerMessage.includes('hello')) {
|
||||||
|
return 'Hola! Soy tu CFO Digital. Estoy aqui para ayudarte a entender tus finanzas y tomar mejores decisiones. Puedes preguntarme sobre tus metricas, pedirme analisis o recomendaciones.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerMessage.includes('ingreso') || lowerMessage.includes('revenue')) {
|
||||||
|
const income = context?.metrics?.total_income || 'no disponible';
|
||||||
|
return `Segun los datos del periodo, tus ingresos totales son de $${income} MXN. Te puedo ayudar a analizar las tendencias o identificar oportunidades de crecimiento.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerMessage.includes('gasto') || lowerMessage.includes('expense')) {
|
||||||
|
const expense = context?.metrics?.total_expense || 'no disponible';
|
||||||
|
return `Tus gastos totales en el periodo son de $${expense} MXN. Si deseas, puedo analizar las categorias de mayor gasto y sugerirte formas de optimizar.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerMessage.includes('ayuda') || lowerMessage.includes('help')) {
|
||||||
|
return 'Puedo ayudarte con:\n\n1. Analisis de metricas financieras\n2. Explicaciones de anomalias\n3. Recomendaciones para mejorar\n4. Proyecciones futuras\n5. Responder preguntas sobre tus finanzas\n\nSimplemente preguntame lo que necesites saber!';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Entiendo tu consulta. Basandome en los datos disponibles, puedo decirte que tu situacion financiera muestra patrones interesantes. Seria util que me dieras mas contexto o que me preguntes algo especifico para darte una respuesta mas detallada.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
analyzeMetrics,
|
||||||
|
explainEntity,
|
||||||
|
getRecommendations,
|
||||||
|
chat,
|
||||||
|
getUsage,
|
||||||
|
};
|
||||||
11
apps/api/src/controllers/index.ts
Normal file
11
apps/api/src/controllers/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Controllers Index
|
||||||
|
*
|
||||||
|
* Export all controllers for easy importing
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './reports.controller.js';
|
||||||
|
export * from './ai.controller.js';
|
||||||
|
|
||||||
|
export { default as reportsController } from './reports.controller.js';
|
||||||
|
export { default as aiController } from './ai.controller.js';
|
||||||
742
apps/api/src/controllers/reports.controller.ts
Normal file
742
apps/api/src/controllers/reports.controller.ts
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
/**
|
||||||
|
* Reports Controller
|
||||||
|
*
|
||||||
|
* Handles report generation, listing, and download operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getDatabase, TenantContext } from '@horux/database';
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
AppError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
} from '../types/index.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { getReportQueue } from '../jobs/report.job.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Report {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
type: ReportType;
|
||||||
|
status: ReportStatus;
|
||||||
|
template_id: string | null;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
file_path: string | null;
|
||||||
|
file_size: number | null;
|
||||||
|
generated_at: Date | null;
|
||||||
|
expires_at: Date | null;
|
||||||
|
created_by: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
error_message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportType =
|
||||||
|
| 'financial_summary'
|
||||||
|
| 'income_statement'
|
||||||
|
| 'balance_sheet'
|
||||||
|
| 'cash_flow'
|
||||||
|
| 'tax_report'
|
||||||
|
| 'expense_analysis'
|
||||||
|
| 'customer_analysis'
|
||||||
|
| 'budget_vs_actual'
|
||||||
|
| 'forecast'
|
||||||
|
| 'custom';
|
||||||
|
|
||||||
|
export type ReportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'expired';
|
||||||
|
|
||||||
|
export interface ReportTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: ReportType;
|
||||||
|
default_parameters: Record<string, unknown>;
|
||||||
|
is_active: boolean;
|
||||||
|
is_premium: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ReportTypeEnum = z.enum([
|
||||||
|
'financial_summary',
|
||||||
|
'income_statement',
|
||||||
|
'balance_sheet',
|
||||||
|
'cash_flow',
|
||||||
|
'tax_report',
|
||||||
|
'expense_analysis',
|
||||||
|
'customer_analysis',
|
||||||
|
'budget_vs_actual',
|
||||||
|
'forecast',
|
||||||
|
'custom',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ReportStatusEnum = z.enum([
|
||||||
|
'pending',
|
||||||
|
'processing',
|
||||||
|
'completed',
|
||||||
|
'failed',
|
||||||
|
'expired',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ReportFiltersSchema = z.object({
|
||||||
|
page: z.string().optional().transform((v) => (v ? parseInt(v, 10) : 1)),
|
||||||
|
limit: z.string().optional().transform((v) => (v ? Math.min(parseInt(v, 10), 100) : 20)),
|
||||||
|
type: ReportTypeEnum.optional(),
|
||||||
|
status: ReportStatusEnum.optional(),
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ReportIdSchema = z.object({
|
||||||
|
id: z.string().uuid('ID de reporte invalido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GenerateReportSchema = z.object({
|
||||||
|
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres').max(100),
|
||||||
|
type: ReportTypeEnum,
|
||||||
|
templateId: z.string().uuid().optional(),
|
||||||
|
parameters: z.object({
|
||||||
|
startDate: z.string().datetime(),
|
||||||
|
endDate: z.string().datetime(),
|
||||||
|
categories: z.array(z.string().uuid()).optional(),
|
||||||
|
contacts: z.array(z.string().uuid()).optional(),
|
||||||
|
includeCharts: z.boolean().optional().default(true),
|
||||||
|
includeDetails: z.boolean().optional().default(true),
|
||||||
|
currency: z.string().length(3).optional().default('MXN'),
|
||||||
|
language: z.enum(['es', 'en']).optional().default('es'),
|
||||||
|
format: z.enum(['pdf', 'xlsx', 'csv']).optional().default('pdf'),
|
||||||
|
comparisonPeriod: z.enum(['previous', 'year_ago', 'none']).optional().default('none'),
|
||||||
|
}),
|
||||||
|
priority: z.enum(['low', 'normal', 'high']).optional().default('normal'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getTenantContext = (req: Request): TenantContext => {
|
||||||
|
if (!req.user || !req.tenantSchema) {
|
||||||
|
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
schemaName: req.tenantSchema,
|
||||||
|
userId: req.user.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Controller Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List reports with filters and pagination
|
||||||
|
*/
|
||||||
|
export const listReports = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const filters = req.query as z.infer<typeof ReportFiltersSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Build filter conditions
|
||||||
|
const conditions: string[] = ['1 = 1'];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.type) {
|
||||||
|
conditions.push(`r.type = $${paramIndex++}`);
|
||||||
|
params.push(filters.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
conditions.push(`r.status = $${paramIndex++}`);
|
||||||
|
params.push(filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
conditions.push(`r.created_at >= $${paramIndex++}`);
|
||||||
|
params.push(filters.startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
conditions.push(`r.created_at <= $${paramIndex++}`);
|
||||||
|
params.push(filters.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
conditions.push(`r.name ILIKE $${paramIndex++}`);
|
||||||
|
params.push(`%${filters.search}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||||
|
const offset = (filters.page - 1) * filters.limit;
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countQuery = `SELECT COUNT(*) as total FROM reports r ${whereClause}`;
|
||||||
|
const countResult = await db.queryTenant<{ total: string }>(tenant, countQuery, params);
|
||||||
|
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||||
|
|
||||||
|
// Get reports
|
||||||
|
const dataQuery = `
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.type,
|
||||||
|
r.status,
|
||||||
|
r.template_id,
|
||||||
|
r.parameters,
|
||||||
|
r.file_path,
|
||||||
|
r.file_size,
|
||||||
|
r.generated_at,
|
||||||
|
r.expires_at,
|
||||||
|
r.error_message,
|
||||||
|
r.created_at,
|
||||||
|
r.updated_at,
|
||||||
|
u.email as created_by_email,
|
||||||
|
u.first_name as created_by_first_name,
|
||||||
|
u.last_name as created_by_last_name
|
||||||
|
FROM reports r
|
||||||
|
LEFT JOIN public.users u ON r.created_by = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dataResult = await db.queryTenant(tenant, dataQuery, [...params, filters.limit, offset]);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: dataResult.rows,
|
||||||
|
meta: {
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / filters.limit),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single report by ID
|
||||||
|
*/
|
||||||
|
export const getReport = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
r.*,
|
||||||
|
u.email as created_by_email,
|
||||||
|
u.first_name as created_by_first_name,
|
||||||
|
u.last_name as created_by_last_name
|
||||||
|
FROM reports r
|
||||||
|
LEFT JOIN public.users u ON r.created_by = u.id
|
||||||
|
WHERE r.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Reporte');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new report
|
||||||
|
*/
|
||||||
|
export const generateReport = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const body = req.body as z.infer<typeof GenerateReportSchema>;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Validate date range
|
||||||
|
const startDate = new Date(body.parameters.startDate);
|
||||||
|
const endDate = new Date(body.parameters.endDate);
|
||||||
|
|
||||||
|
if (startDate >= endDate) {
|
||||||
|
throw new ValidationError('La fecha de inicio debe ser anterior a la fecha de fin');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reasonable date range (max 5 years)
|
||||||
|
const maxRangeDays = 365 * 5;
|
||||||
|
const daysDiff = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (daysDiff > maxRangeDays) {
|
||||||
|
throw new ValidationError('El rango de fechas no puede ser mayor a 5 anos');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check concurrent report limit (max 3 pending reports per user)
|
||||||
|
const pendingCheck = await db.queryTenant<{ count: string }>(
|
||||||
|
tenant,
|
||||||
|
`SELECT COUNT(*) as count FROM reports WHERE created_by = $1 AND status IN ('pending', 'processing')`,
|
||||||
|
[req.user!.sub]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingCount = parseInt(pendingCheck.rows[0]?.count || '0', 10);
|
||||||
|
if (pendingCount >= 3) {
|
||||||
|
throw new ValidationError('Ya tienes 3 reportes en proceso. Espera a que terminen antes de generar mas.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create report record
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO reports (
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
template_id,
|
||||||
|
parameters,
|
||||||
|
created_by
|
||||||
|
) VALUES ($1, $2, 'pending', $3, $4, $5)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const insertResult = await db.queryTenant(tenant, insertQuery, [
|
||||||
|
body.name,
|
||||||
|
body.type,
|
||||||
|
body.templateId || null,
|
||||||
|
JSON.stringify(body.parameters),
|
||||||
|
req.user!.sub,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const report = insertResult.rows[0];
|
||||||
|
|
||||||
|
// Add job to queue
|
||||||
|
const queue = getReportQueue();
|
||||||
|
const priority = body.priority === 'high' ? 1 : body.priority === 'low' ? 10 : 5;
|
||||||
|
|
||||||
|
const job = await queue.add(
|
||||||
|
'generate-report',
|
||||||
|
{
|
||||||
|
reportId: report.id,
|
||||||
|
tenantId: tenant.tenantId,
|
||||||
|
schemaName: tenant.schemaName,
|
||||||
|
userId: req.user!.sub,
|
||||||
|
type: body.type,
|
||||||
|
parameters: body.parameters,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
priority,
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 5000,
|
||||||
|
},
|
||||||
|
removeOnComplete: {
|
||||||
|
age: 24 * 3600, // Keep completed jobs for 24 hours
|
||||||
|
count: 100,
|
||||||
|
},
|
||||||
|
removeOnFail: {
|
||||||
|
age: 7 * 24 * 3600, // Keep failed jobs for 7 days
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Report generation job queued', {
|
||||||
|
reportId: report.id,
|
||||||
|
jobId: job.id,
|
||||||
|
type: body.type,
|
||||||
|
userId: req.user!.sub,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...report,
|
||||||
|
jobId: job.id,
|
||||||
|
estimatedTime: getEstimatedTime(body.type, daysDiff),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(202).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a report PDF
|
||||||
|
*/
|
||||||
|
export const downloadReport = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Get report
|
||||||
|
const query = `
|
||||||
|
SELECT id, name, status, file_path, file_size, parameters, expires_at
|
||||||
|
FROM reports
|
||||||
|
WHERE id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Reporte');
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = result.rows[0];
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
if (report.status !== 'completed') {
|
||||||
|
throw new ValidationError(
|
||||||
|
report.status === 'processing'
|
||||||
|
? 'El reporte aun se esta generando'
|
||||||
|
: report.status === 'failed'
|
||||||
|
? 'El reporte fallo al generarse'
|
||||||
|
: 'El reporte no esta disponible'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (report.expires_at && new Date(report.expires_at) < new Date()) {
|
||||||
|
throw new ValidationError('El reporte ha expirado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file exists
|
||||||
|
if (!report.file_path) {
|
||||||
|
throw new AppError('Archivo de reporte no encontrado', 'FILE_NOT_FOUND', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file from storage (MinIO)
|
||||||
|
// For now, return a signed URL or stream the file
|
||||||
|
// This would integrate with a storage service
|
||||||
|
|
||||||
|
const format = (report.parameters as { format?: string })?.format || 'pdf';
|
||||||
|
const contentType = format === 'pdf'
|
||||||
|
? 'application/pdf'
|
||||||
|
: format === 'xlsx'
|
||||||
|
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
: 'text/csv';
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
downloadUrl: `/api/v1/reports/${id}/file`,
|
||||||
|
fileName: `${report.name}.${format}`,
|
||||||
|
fileSize: report.file_size,
|
||||||
|
contentType,
|
||||||
|
expiresAt: report.expires_at,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a report
|
||||||
|
*/
|
||||||
|
export const deleteReport = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Get report
|
||||||
|
const existingResult = await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
'SELECT id, status, file_path, created_by FROM reports WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResult.rows.length === 0) {
|
||||||
|
throw new NotFoundError('Reporte');
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = existingResult.rows[0];
|
||||||
|
|
||||||
|
// Only allow deletion by owner or admin
|
||||||
|
if (report.created_by !== req.user!.sub && req.user!.role !== 'owner' && req.user!.role !== 'admin') {
|
||||||
|
throw new AppError('No tienes permisos para eliminar este reporte', 'FORBIDDEN', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If report is processing, cancel the job
|
||||||
|
if (report.status === 'pending' || report.status === 'processing') {
|
||||||
|
const queue = getReportQueue();
|
||||||
|
const jobs = await queue.getJobs(['waiting', 'active', 'delayed']);
|
||||||
|
const reportJob = jobs.find(j => j.data?.reportId === id);
|
||||||
|
|
||||||
|
if (reportJob) {
|
||||||
|
await reportJob.remove();
|
||||||
|
logger.info('Cancelled pending report job', { reportId: id, jobId: reportJob.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Delete file from storage if exists
|
||||||
|
if (report.file_path) {
|
||||||
|
// await storageService.deleteFile(report.file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete report record
|
||||||
|
await db.queryTenant(tenant, 'DELETE FROM reports WHERE id = $1', [id]);
|
||||||
|
|
||||||
|
logger.info('Report deleted', { reportId: id, userId: req.user!.sub });
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
deleted: true,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available report templates
|
||||||
|
*/
|
||||||
|
export const listTemplates = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const tenant = getTenantContext(req);
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Get user's plan to filter premium templates
|
||||||
|
const planResult = await db.queryPublic<{ plan_id: string }>(
|
||||||
|
'SELECT plan_id FROM public.tenants WHERE id = $1',
|
||||||
|
[tenant.tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const planId = planResult.rows[0]?.plan_id || 'free';
|
||||||
|
const isPremium = planId !== 'free';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
default_parameters,
|
||||||
|
is_premium,
|
||||||
|
preview_image_url,
|
||||||
|
category
|
||||||
|
FROM report_templates
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN is_premium AND $1 = false THEN 1 ELSE 0 END,
|
||||||
|
category,
|
||||||
|
name
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.queryTenant(tenant, query, [isPremium]);
|
||||||
|
|
||||||
|
// If no templates in tenant schema, return default templates
|
||||||
|
const templates = result.rows.length > 0 ? result.rows : getDefaultTemplates();
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: templates.map(t => ({
|
||||||
|
...t,
|
||||||
|
available: isPremium || !t.is_premium,
|
||||||
|
})),
|
||||||
|
meta: {
|
||||||
|
isPremiumPlan: isPremium,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function getEstimatedTime(type: ReportType, daysDiff: number): string {
|
||||||
|
// Estimate based on report type and date range
|
||||||
|
let baseMinutes = 1;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'financial_summary':
|
||||||
|
baseMinutes = 2;
|
||||||
|
break;
|
||||||
|
case 'income_statement':
|
||||||
|
case 'balance_sheet':
|
||||||
|
case 'cash_flow':
|
||||||
|
baseMinutes = 3;
|
||||||
|
break;
|
||||||
|
case 'tax_report':
|
||||||
|
baseMinutes = 5;
|
||||||
|
break;
|
||||||
|
case 'expense_analysis':
|
||||||
|
case 'customer_analysis':
|
||||||
|
baseMinutes = 4;
|
||||||
|
break;
|
||||||
|
case 'budget_vs_actual':
|
||||||
|
case 'forecast':
|
||||||
|
baseMinutes = 6;
|
||||||
|
break;
|
||||||
|
case 'custom':
|
||||||
|
baseMinutes = 5;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add time based on date range
|
||||||
|
if (daysDiff > 365) {
|
||||||
|
baseMinutes *= 2;
|
||||||
|
} else if (daysDiff > 180) {
|
||||||
|
baseMinutes *= 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.ceil(baseMinutes)} minutos`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultTemplates(): Partial<ReportTemplate>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'tpl-financial-summary',
|
||||||
|
name: 'Resumen Financiero',
|
||||||
|
description: 'Resumen ejecutivo de la situacion financiera con metricas clave',
|
||||||
|
type: 'financial_summary',
|
||||||
|
is_active: true,
|
||||||
|
is_premium: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-income-statement',
|
||||||
|
name: 'Estado de Resultados',
|
||||||
|
description: 'Detalle de ingresos, gastos y utilidad neta',
|
||||||
|
type: 'income_statement',
|
||||||
|
is_active: true,
|
||||||
|
is_premium: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-balance-sheet',
|
||||||
|
name: 'Balance General',
|
||||||
|
description: 'Activos, pasivos y capital contable',
|
||||||
|
type: 'balance_sheet',
|
||||||
|
is_active: true,
|
||||||
|
is_premium: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-cash-flow',
|
||||||
|
name: 'Flujo de Efectivo',
|
||||||
|
description: 'Movimientos de entrada y salida de efectivo',
|
||||||
|
type: 'cash_flow',
|
||||||
|
is_active: true,
|
||||||
|
is_premium: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-tax-report',
|
||||||
|
name: 'Reporte Fiscal',
|
||||||
|
description: 'Resumen de obligaciones fiscales y CFDIs',
|
||||||
|
type: 'tax_report',
|
||||||
|
is_active: true,
|
||||||
|
is_premium: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-expense-analysis',
|
||||||
|
name: 'Analisis de Gastos',
|
||||||
|
description: 'Desglose detallado de gastos por categoria',
|
||||||
|
type: 'expense_analysis',
|
||||||
|
is_active: true,
|
||||||
|
is_premium: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-customer-analysis',
|
||||||
|
name: 'Analisis de Clientes',
|
||||||
|
description: 'Metricas de clientes, ingresos por cliente y tendencias',
|
||||||
|
type: 'customer_analysis',
|
||||||
|
is_active: true,
|
||||||
|
is_premium: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-budget-vs-actual',
|
||||||
|
name: 'Presupuesto vs Real',
|
||||||
|
description: 'Comparacion de presupuesto planificado contra resultados reales',
|
||||||
|
type: 'budget_vs_actual',
|
||||||
|
is_active: true,
|
||||||
|
is_premium: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-forecast',
|
||||||
|
name: 'Proyeccion Financiera',
|
||||||
|
description: 'Proyecciones basadas en datos historicos e IA',
|
||||||
|
type: 'forecast',
|
||||||
|
is_active: true,
|
||||||
|
is_premium: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
listReports,
|
||||||
|
getReport,
|
||||||
|
generateReport,
|
||||||
|
downloadReport,
|
||||||
|
deleteReport,
|
||||||
|
listTemplates,
|
||||||
|
};
|
||||||
@@ -18,6 +18,9 @@ import contactsRoutes from './routes/contacts.routes.js';
|
|||||||
import cfdisRoutes from './routes/cfdis.routes.js';
|
import cfdisRoutes from './routes/cfdis.routes.js';
|
||||||
import categoriesRoutes from './routes/categories.routes.js';
|
import categoriesRoutes from './routes/categories.routes.js';
|
||||||
import alertsRoutes from './routes/alerts.routes.js';
|
import alertsRoutes from './routes/alerts.routes.js';
|
||||||
|
import reportsRoutes from './routes/reports.routes.js';
|
||||||
|
import aiRoutes from './routes/ai.routes.js';
|
||||||
|
import { startReportWorker, stopReportWorker } from './jobs/report.job.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Application Setup
|
// Application Setup
|
||||||
@@ -175,6 +178,8 @@ app.use(`${apiPrefix}/contacts`, authenticate, tenantContext, contactsRoutes);
|
|||||||
app.use(`${apiPrefix}/cfdis`, authenticate, tenantContext, cfdisRoutes);
|
app.use(`${apiPrefix}/cfdis`, authenticate, tenantContext, cfdisRoutes);
|
||||||
app.use(`${apiPrefix}/categories`, authenticate, tenantContext, categoriesRoutes);
|
app.use(`${apiPrefix}/categories`, authenticate, tenantContext, categoriesRoutes);
|
||||||
app.use(`${apiPrefix}/alerts`, authenticate, tenantContext, alertsRoutes);
|
app.use(`${apiPrefix}/alerts`, authenticate, tenantContext, alertsRoutes);
|
||||||
|
app.use(`${apiPrefix}/reports`, authenticate, tenantContext, reportsRoutes);
|
||||||
|
app.use(`${apiPrefix}/ai`, authenticate, tenantContext, aiRoutes);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API Info Route
|
// API Info Route
|
||||||
@@ -213,6 +218,14 @@ app.use(errorHandler);
|
|||||||
|
|
||||||
const startServer = async (): Promise<void> => {
|
const startServer = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
// Start report generation worker
|
||||||
|
try {
|
||||||
|
startReportWorker();
|
||||||
|
logger.info('Report worker started');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to start report worker (Redis may not be available)', { error });
|
||||||
|
}
|
||||||
|
|
||||||
// Start listening
|
// Start listening
|
||||||
const server = app.listen(config.server.port, config.server.host, () => {
|
const server = app.listen(config.server.port, config.server.host, () => {
|
||||||
logger.info(`Horux Strategy API started`, {
|
logger.info(`Horux Strategy API started`, {
|
||||||
@@ -233,7 +246,7 @@ const startServer = async (): Promise<void> => {
|
|||||||
const gracefulShutdown = async (signal: string): Promise<void> => {
|
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||||
logger.info(`${signal} received. Starting graceful shutdown...`);
|
logger.info(`${signal} received. Starting graceful shutdown...`);
|
||||||
|
|
||||||
server.close((err) => {
|
server.close(async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error('Error during server close', { error: err });
|
logger.error('Error during server close', { error: err });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -241,6 +254,14 @@ const startServer = async (): Promise<void> => {
|
|||||||
|
|
||||||
logger.info('Server closed. Cleaning up...');
|
logger.info('Server closed. Cleaning up...');
|
||||||
|
|
||||||
|
// Stop report worker
|
||||||
|
try {
|
||||||
|
await stopReportWorker();
|
||||||
|
logger.info('Report worker stopped');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error stopping report worker', { error });
|
||||||
|
}
|
||||||
|
|
||||||
// Add any cleanup logic here (close database connections, etc.)
|
// Add any cleanup logic here (close database connections, etc.)
|
||||||
|
|
||||||
logger.info('Graceful shutdown completed');
|
logger.info('Graceful shutdown completed');
|
||||||
|
|||||||
11
apps/api/src/jobs/index.ts
Normal file
11
apps/api/src/jobs/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Jobs Index
|
||||||
|
*
|
||||||
|
* Export all background job processors
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './report.job.js';
|
||||||
|
export { default as reportJob } from './report.job.js';
|
||||||
|
|
||||||
|
// Integration sync jobs
|
||||||
|
export { processSyncJob, type SyncJobPayload, type SyncJobContext } from './sync.job.js';
|
||||||
784
apps/api/src/jobs/report.job.ts
Normal file
784
apps/api/src/jobs/report.job.ts
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
/**
|
||||||
|
* Report Generation Job
|
||||||
|
*
|
||||||
|
* BullMQ job processor for generating reports in background
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Queue, Worker, Job, QueueEvents } from 'bullmq';
|
||||||
|
import IORedis from 'ioredis';
|
||||||
|
import { getDatabase, TenantContext } from '@horux/database';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ReportJobData {
|
||||||
|
reportId: string;
|
||||||
|
tenantId: string;
|
||||||
|
schemaName: string;
|
||||||
|
userId: string;
|
||||||
|
type: string;
|
||||||
|
parameters: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
categories?: string[];
|
||||||
|
contacts?: string[];
|
||||||
|
includeCharts?: boolean;
|
||||||
|
includeDetails?: boolean;
|
||||||
|
currency?: string;
|
||||||
|
language?: string;
|
||||||
|
format?: 'pdf' | 'xlsx' | 'csv';
|
||||||
|
comparisonPeriod?: 'previous' | 'year_ago' | 'none';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportJobResult {
|
||||||
|
reportId: string;
|
||||||
|
filePath: string;
|
||||||
|
fileSize: number;
|
||||||
|
generatedAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
pageCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Queue Configuration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const QUEUE_NAME = 'report-generation';
|
||||||
|
const REDIS_CONFIG = {
|
||||||
|
host: new URL(config.redis.url).hostname || 'localhost',
|
||||||
|
port: parseInt(new URL(config.redis.url).port || '6379', 10),
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let queue: Queue<ReportJobData, ReportJobResult> | null = null;
|
||||||
|
let worker: Worker<ReportJobData, ReportJobResult> | null = null;
|
||||||
|
let queueEvents: QueueEvents | null = null;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Queue Initialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the report queue
|
||||||
|
*/
|
||||||
|
export function getReportQueue(): Queue<ReportJobData, ReportJobResult> {
|
||||||
|
if (!queue) {
|
||||||
|
const connection = new IORedis(REDIS_CONFIG);
|
||||||
|
|
||||||
|
queue = new Queue<ReportJobData, ReportJobResult>(QUEUE_NAME, {
|
||||||
|
connection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 5000,
|
||||||
|
},
|
||||||
|
removeOnComplete: {
|
||||||
|
age: 24 * 3600, // 24 hours
|
||||||
|
count: 1000,
|
||||||
|
},
|
||||||
|
removeOnFail: {
|
||||||
|
age: 7 * 24 * 3600, // 7 days
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.on('error', (error) => {
|
||||||
|
logger.error('Report queue error', { error: error.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Report queue initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the queue events listener
|
||||||
|
*/
|
||||||
|
export function getQueueEvents(): QueueEvents {
|
||||||
|
if (!queueEvents) {
|
||||||
|
const connection = new IORedis(REDIS_CONFIG);
|
||||||
|
|
||||||
|
queueEvents = new QueueEvents(QUEUE_NAME, { connection });
|
||||||
|
|
||||||
|
queueEvents.on('completed', ({ jobId, returnvalue }) => {
|
||||||
|
logger.info('Report job completed', { jobId, returnvalue });
|
||||||
|
});
|
||||||
|
|
||||||
|
queueEvents.on('failed', ({ jobId, failedReason }) => {
|
||||||
|
logger.error('Report job failed', { jobId, failedReason });
|
||||||
|
});
|
||||||
|
|
||||||
|
queueEvents.on('progress', ({ jobId, data }) => {
|
||||||
|
logger.debug('Report job progress', { jobId, progress: data });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return queueEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Job Processor
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a report generation job
|
||||||
|
*/
|
||||||
|
async function processReportJob(
|
||||||
|
job: Job<ReportJobData, ReportJobResult>
|
||||||
|
): Promise<ReportJobResult> {
|
||||||
|
const { reportId, tenantId, schemaName, userId, type, parameters } = job.data;
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const tenant: TenantContext = {
|
||||||
|
tenantId,
|
||||||
|
schemaName,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('Starting report generation', {
|
||||||
|
jobId: job.id,
|
||||||
|
reportId,
|
||||||
|
type,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update status to processing
|
||||||
|
await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
`UPDATE reports SET status = 'processing', updated_at = NOW() WHERE id = $1`,
|
||||||
|
[reportId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await job.updateProgress(10);
|
||||||
|
|
||||||
|
// Fetch data based on report type
|
||||||
|
const reportData = await fetchReportData(tenant, type, parameters);
|
||||||
|
await job.updateProgress(40);
|
||||||
|
|
||||||
|
// Generate the report document
|
||||||
|
const document = await generateReportDocument(type, reportData, parameters);
|
||||||
|
await job.updateProgress(70);
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
const { filePath, fileSize } = await saveReportToStorage(
|
||||||
|
reportId,
|
||||||
|
tenantId,
|
||||||
|
document,
|
||||||
|
parameters.format || 'pdf'
|
||||||
|
);
|
||||||
|
await job.updateProgress(90);
|
||||||
|
|
||||||
|
// Calculate expiration (30 days from now)
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||||
|
|
||||||
|
// Update report record
|
||||||
|
await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
`UPDATE reports
|
||||||
|
SET status = 'completed',
|
||||||
|
file_path = $2,
|
||||||
|
file_size = $3,
|
||||||
|
generated_at = NOW(),
|
||||||
|
expires_at = $4,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[reportId, filePath, fileSize, expiresAt.toISOString()]
|
||||||
|
);
|
||||||
|
|
||||||
|
await job.updateProgress(100);
|
||||||
|
|
||||||
|
// Send notification to user
|
||||||
|
await notifyUser(tenant, userId, {
|
||||||
|
type: 'report_ready',
|
||||||
|
reportId,
|
||||||
|
title: `Tu reporte esta listo`,
|
||||||
|
message: `El reporte "${type}" ha sido generado exitosamente.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Report generation completed', {
|
||||||
|
jobId: job.id,
|
||||||
|
reportId,
|
||||||
|
filePath,
|
||||||
|
fileSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportId,
|
||||||
|
filePath,
|
||||||
|
fileSize,
|
||||||
|
generatedAt: new Date(),
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
||||||
|
|
||||||
|
logger.error('Report generation failed', {
|
||||||
|
jobId: job.id,
|
||||||
|
reportId,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update report status to failed
|
||||||
|
await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
`UPDATE reports
|
||||||
|
SET status = 'failed',
|
||||||
|
error_message = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[reportId, errorMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify user of failure
|
||||||
|
await notifyUser(tenant, userId, {
|
||||||
|
type: 'report_failed',
|
||||||
|
reportId,
|
||||||
|
title: 'Error al generar reporte',
|
||||||
|
message: `No se pudo generar el reporte. Por favor intenta de nuevo.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Data Fetching
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface ReportData {
|
||||||
|
metrics: Record<string, unknown>;
|
||||||
|
transactions: unknown[];
|
||||||
|
categories: unknown[];
|
||||||
|
summary: Record<string, unknown>;
|
||||||
|
previousPeriod?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReportData(
|
||||||
|
tenant: TenantContext,
|
||||||
|
type: string,
|
||||||
|
parameters: ReportJobData['parameters']
|
||||||
|
): Promise<ReportData> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const { startDate, endDate, categories, contacts } = parameters;
|
||||||
|
|
||||||
|
// Build category filter
|
||||||
|
const categoryFilter = categories?.length
|
||||||
|
? `AND t.category_id IN (${categories.map((_, i) => `$${i + 3}`).join(',')})`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Build contact filter
|
||||||
|
const contactFilter = contacts?.length
|
||||||
|
? `AND t.contact_id IN (${contacts.map((_, i) => `$${i + 3 + (categories?.length || 0)}`).join(',')})`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const filterParams = [
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
...(categories || []),
|
||||||
|
...(contacts || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get summary metrics
|
||||||
|
const metricsQuery = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense,
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) -
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as net_profit,
|
||||||
|
COUNT(*) as transaction_count,
|
||||||
|
COUNT(DISTINCT contact_id) as unique_contacts,
|
||||||
|
AVG(amount) as average_transaction
|
||||||
|
FROM transactions t
|
||||||
|
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
|
||||||
|
${categoryFilter}
|
||||||
|
${contactFilter}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const metricsResult = await db.queryTenant(tenant, metricsQuery, filterParams);
|
||||||
|
|
||||||
|
// Get transactions with details
|
||||||
|
const transactionsQuery = `
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.date,
|
||||||
|
t.type,
|
||||||
|
t.amount,
|
||||||
|
t.description,
|
||||||
|
t.reference,
|
||||||
|
t.status,
|
||||||
|
c.name as category_name,
|
||||||
|
c.color as category_color,
|
||||||
|
ct.name as contact_name
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
LEFT JOIN contacts ct ON t.contact_id = ct.id
|
||||||
|
WHERE t.date >= $1 AND t.date <= $2 AND t.status != 'cancelled'
|
||||||
|
${categoryFilter}
|
||||||
|
${contactFilter}
|
||||||
|
ORDER BY t.date DESC
|
||||||
|
LIMIT 1000
|
||||||
|
`;
|
||||||
|
|
||||||
|
const transactionsResult = await db.queryTenant(tenant, transactionsQuery, filterParams);
|
||||||
|
|
||||||
|
// Get category breakdown
|
||||||
|
const categoriesQuery = `
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.type,
|
||||||
|
c.color,
|
||||||
|
c.icon,
|
||||||
|
COUNT(*) as transaction_count,
|
||||||
|
SUM(t.amount) as total_amount,
|
||||||
|
AVG(t.amount) as average_amount
|
||||||
|
FROM transactions t
|
||||||
|
JOIN categories c ON t.category_id = c.id
|
||||||
|
WHERE t.date >= $1 AND t.date <= $2 AND t.status != 'cancelled'
|
||||||
|
${categoryFilter}
|
||||||
|
${contactFilter}
|
||||||
|
GROUP BY c.id, c.name, c.type, c.color, c.icon
|
||||||
|
ORDER BY SUM(t.amount) DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const categoriesResult = await db.queryTenant(tenant, categoriesQuery, filterParams);
|
||||||
|
|
||||||
|
// Get previous period data if comparison requested
|
||||||
|
let previousPeriod: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
if (parameters.comparisonPeriod && parameters.comparisonPeriod !== 'none') {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
const duration = end.getTime() - start.getTime();
|
||||||
|
|
||||||
|
let prevStart: Date;
|
||||||
|
let prevEnd: Date;
|
||||||
|
|
||||||
|
if (parameters.comparisonPeriod === 'year_ago') {
|
||||||
|
prevStart = new Date(start);
|
||||||
|
prevStart.setFullYear(prevStart.getFullYear() - 1);
|
||||||
|
prevEnd = new Date(end);
|
||||||
|
prevEnd.setFullYear(prevEnd.getFullYear() - 1);
|
||||||
|
} else {
|
||||||
|
prevEnd = new Date(start);
|
||||||
|
prevStart = new Date(prevEnd.getTime() - duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevResult = await db.queryTenant(tenant, metricsQuery, [
|
||||||
|
prevStart.toISOString(),
|
||||||
|
prevEnd.toISOString(),
|
||||||
|
...(categories || []),
|
||||||
|
...(contacts || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
previousPeriod = prevResult.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-specific data
|
||||||
|
const typeSpecificData = await fetchTypeSpecificData(tenant, type, parameters);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics: metricsResult.rows[0],
|
||||||
|
transactions: transactionsResult.rows,
|
||||||
|
categories: categoriesResult.rows,
|
||||||
|
summary: typeSpecificData,
|
||||||
|
previousPeriod,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTypeSpecificData(
|
||||||
|
tenant: TenantContext,
|
||||||
|
type: string,
|
||||||
|
parameters: ReportJobData['parameters']
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const { startDate, endDate } = parameters;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'tax_report': {
|
||||||
|
// Get CFDI summary
|
||||||
|
const cfdiQuery = `
|
||||||
|
SELECT
|
||||||
|
tipo_comprobante,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(total) as total,
|
||||||
|
SUM(subtotal) as subtotal,
|
||||||
|
SUM(total_impuestos_trasladados) as taxes_transferred,
|
||||||
|
SUM(total_impuestos_retenidos) as taxes_withheld
|
||||||
|
FROM cfdis
|
||||||
|
WHERE fecha_emision >= $1 AND fecha_emision <= $2 AND status = 'vigente'
|
||||||
|
GROUP BY tipo_comprobante
|
||||||
|
`;
|
||||||
|
|
||||||
|
const cfdiResult = await db.queryTenant(tenant, cfdiQuery, [startDate, endDate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cfdiSummary: cfdiResult.rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cash_flow': {
|
||||||
|
// Get daily cash flow
|
||||||
|
const cashFlowQuery = `
|
||||||
|
SELECT
|
||||||
|
DATE(date) as day,
|
||||||
|
SUM(CASE WHEN type = 'income' AND status = 'completed' THEN amount ELSE 0 END) as inflow,
|
||||||
|
SUM(CASE WHEN type = 'expense' AND status = 'completed' THEN amount ELSE 0 END) as outflow,
|
||||||
|
SUM(CASE WHEN type = 'income' AND status = 'completed' THEN amount ELSE 0 END) -
|
||||||
|
SUM(CASE WHEN type = 'expense' AND status = 'completed' THEN amount ELSE 0 END) as net
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2
|
||||||
|
GROUP BY DATE(date)
|
||||||
|
ORDER BY day
|
||||||
|
`;
|
||||||
|
|
||||||
|
const cashFlowResult = await db.queryTenant(tenant, cashFlowQuery, [startDate, endDate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dailyCashFlow: cashFlowResult.rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'customer_analysis': {
|
||||||
|
// Get customer metrics
|
||||||
|
const customerQuery = `
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.email,
|
||||||
|
c.type,
|
||||||
|
COUNT(t.id) as transaction_count,
|
||||||
|
SUM(t.amount) as total_revenue,
|
||||||
|
AVG(t.amount) as average_transaction,
|
||||||
|
MIN(t.date) as first_transaction,
|
||||||
|
MAX(t.date) as last_transaction
|
||||||
|
FROM contacts c
|
||||||
|
LEFT JOIN transactions t ON c.id = t.contact_id
|
||||||
|
AND t.type = 'income'
|
||||||
|
AND t.date >= $1 AND t.date <= $2
|
||||||
|
AND t.status != 'cancelled'
|
||||||
|
WHERE c.type = 'customer'
|
||||||
|
GROUP BY c.id, c.name, c.email, c.type
|
||||||
|
HAVING COUNT(t.id) > 0
|
||||||
|
ORDER BY SUM(t.amount) DESC
|
||||||
|
LIMIT 50
|
||||||
|
`;
|
||||||
|
|
||||||
|
const customerResult = await db.queryTenant(tenant, customerQuery, [startDate, endDate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
customers: customerResult.rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Document Generation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface ReportDocument {
|
||||||
|
content: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
extension: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateReportDocument(
|
||||||
|
type: string,
|
||||||
|
data: ReportData,
|
||||||
|
parameters: ReportJobData['parameters']
|
||||||
|
): Promise<ReportDocument> {
|
||||||
|
const format = parameters.format || 'pdf';
|
||||||
|
|
||||||
|
// TODO: Integrate with actual PDF/Excel generation library
|
||||||
|
// For now, create a placeholder
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'pdf':
|
||||||
|
return generatePdfReport(type, data, parameters);
|
||||||
|
|
||||||
|
case 'xlsx':
|
||||||
|
return generateExcelReport(type, data, parameters);
|
||||||
|
|
||||||
|
case 'csv':
|
||||||
|
return generateCsvReport(type, data, parameters);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return generatePdfReport(type, data, parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePdfReport(
|
||||||
|
type: string,
|
||||||
|
data: ReportData,
|
||||||
|
parameters: ReportJobData['parameters']
|
||||||
|
): Promise<ReportDocument> {
|
||||||
|
// TODO: Use PDFKit or similar library to generate actual PDF
|
||||||
|
// This is a placeholder implementation
|
||||||
|
|
||||||
|
const content = Buffer.from(JSON.stringify({
|
||||||
|
reportType: type,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
parameters,
|
||||||
|
summary: data.metrics,
|
||||||
|
transactionCount: data.transactions.length,
|
||||||
|
categoryCount: data.categories.length,
|
||||||
|
note: 'PDF generation placeholder - implement with PDFKit',
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
extension: 'pdf',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateExcelReport(
|
||||||
|
type: string,
|
||||||
|
data: ReportData,
|
||||||
|
_parameters: ReportJobData['parameters']
|
||||||
|
): Promise<ReportDocument> {
|
||||||
|
// TODO: Use exceljs or similar library
|
||||||
|
const content = Buffer.from(JSON.stringify({
|
||||||
|
reportType: type,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
transactions: data.transactions,
|
||||||
|
categories: data.categories,
|
||||||
|
note: 'Excel generation placeholder - implement with exceljs',
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
extension: 'xlsx',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateCsvReport(
|
||||||
|
_type: string,
|
||||||
|
data: ReportData,
|
||||||
|
_parameters: ReportJobData['parameters']
|
||||||
|
): Promise<ReportDocument> {
|
||||||
|
// Generate CSV from transactions
|
||||||
|
const headers = ['Date', 'Type', 'Amount', 'Description', 'Category', 'Contact', 'Status'];
|
||||||
|
const rows = (data.transactions as Array<{
|
||||||
|
date: string;
|
||||||
|
type: string;
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
category_name: string;
|
||||||
|
contact_name: string;
|
||||||
|
status: string;
|
||||||
|
}>).map(t => [
|
||||||
|
t.date,
|
||||||
|
t.type,
|
||||||
|
t.amount,
|
||||||
|
`"${(t.description || '').replace(/"/g, '""')}"`,
|
||||||
|
t.category_name || '',
|
||||||
|
t.contact_name || '',
|
||||||
|
t.status,
|
||||||
|
].join(','));
|
||||||
|
|
||||||
|
const csvContent = [headers.join(','), ...rows].join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: Buffer.from(csvContent, 'utf-8'),
|
||||||
|
mimeType: 'text/csv',
|
||||||
|
extension: 'csv',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Storage
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function saveReportToStorage(
|
||||||
|
reportId: string,
|
||||||
|
tenantId: string,
|
||||||
|
document: ReportDocument,
|
||||||
|
_format: string
|
||||||
|
): Promise<{ filePath: string; fileSize: number }> {
|
||||||
|
// TODO: Integrate with MinIO storage service
|
||||||
|
// For now, return a placeholder path
|
||||||
|
|
||||||
|
const filePath = `reports/${tenantId}/${reportId}.${document.extension}`;
|
||||||
|
const fileSize = document.content.length;
|
||||||
|
|
||||||
|
// In production:
|
||||||
|
// await minioClient.putObject(bucket, filePath, document.content, {
|
||||||
|
// 'Content-Type': document.mimeType,
|
||||||
|
// });
|
||||||
|
|
||||||
|
logger.debug('Report saved to storage', { filePath, fileSize });
|
||||||
|
|
||||||
|
return { filePath, fileSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Notifications
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface NotificationData {
|
||||||
|
type: string;
|
||||||
|
reportId: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyUser(
|
||||||
|
tenant: TenantContext,
|
||||||
|
userId: string,
|
||||||
|
notification: NotificationData
|
||||||
|
): Promise<void> {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create in-app notification
|
||||||
|
await db.queryTenant(
|
||||||
|
tenant,
|
||||||
|
`INSERT INTO alerts (
|
||||||
|
user_id,
|
||||||
|
type,
|
||||||
|
priority,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
action_url,
|
||||||
|
action_label,
|
||||||
|
metadata
|
||||||
|
) VALUES ($1, 'info', 'medium', $2, $3, $4, $5, $6)`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
notification.title,
|
||||||
|
notification.message,
|
||||||
|
`/reports/${notification.reportId}`,
|
||||||
|
notification.type === 'report_ready' ? 'Ver reporte' : 'Reintentar',
|
||||||
|
JSON.stringify({ reportId: notification.reportId }),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Send email notification if configured
|
||||||
|
// TODO: Send push notification if mobile app
|
||||||
|
|
||||||
|
logger.debug('User notified', { userId, type: notification.type });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to notify user', { userId, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Worker Initialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the report worker
|
||||||
|
*/
|
||||||
|
export function startReportWorker(): Worker<ReportJobData, ReportJobResult> {
|
||||||
|
if (worker) {
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = new IORedis(REDIS_CONFIG);
|
||||||
|
|
||||||
|
worker = new Worker<ReportJobData, ReportJobResult>(
|
||||||
|
QUEUE_NAME,
|
||||||
|
processReportJob,
|
||||||
|
{
|
||||||
|
connection,
|
||||||
|
concurrency: 3, // Process up to 3 reports concurrently
|
||||||
|
limiter: {
|
||||||
|
max: 10,
|
||||||
|
duration: 60000, // 10 jobs per minute max
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.on('completed', (job, result) => {
|
||||||
|
logger.info('Report worker: job completed', {
|
||||||
|
jobId: job.id,
|
||||||
|
reportId: result.reportId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('failed', (job, error) => {
|
||||||
|
logger.error('Report worker: job failed', {
|
||||||
|
jobId: job?.id,
|
||||||
|
error: error.message,
|
||||||
|
attempts: job?.attemptsMade,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('error', (error) => {
|
||||||
|
logger.error('Report worker error', { error: error.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('stalled', (jobId) => {
|
||||||
|
logger.warn('Report worker: job stalled', { jobId });
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Report worker started', { concurrency: 3 });
|
||||||
|
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the report worker gracefully
|
||||||
|
*/
|
||||||
|
export async function stopReportWorker(): Promise<void> {
|
||||||
|
if (worker) {
|
||||||
|
await worker.close();
|
||||||
|
worker = null;
|
||||||
|
logger.info('Report worker stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queueEvents) {
|
||||||
|
await queueEvents.close();
|
||||||
|
queueEvents = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue) {
|
||||||
|
await queue.close();
|
||||||
|
queue = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get queue statistics
|
||||||
|
*/
|
||||||
|
export async function getQueueStats(): Promise<{
|
||||||
|
waiting: number;
|
||||||
|
active: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
delayed: number;
|
||||||
|
}> {
|
||||||
|
const q = getReportQueue();
|
||||||
|
|
||||||
|
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
||||||
|
q.getWaitingCount(),
|
||||||
|
q.getActiveCount(),
|
||||||
|
q.getCompletedCount(),
|
||||||
|
q.getFailedCount(),
|
||||||
|
q.getDelayedCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { waiting, active, completed, failed, delayed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getReportQueue,
|
||||||
|
getQueueEvents,
|
||||||
|
startReportWorker,
|
||||||
|
stopReportWorker,
|
||||||
|
getQueueStats,
|
||||||
|
};
|
||||||
707
apps/api/src/jobs/sync.job.ts
Normal file
707
apps/api/src/jobs/sync.job.ts
Normal file
@@ -0,0 +1,707 @@
|
|||||||
|
/**
|
||||||
|
* Sync Job
|
||||||
|
*
|
||||||
|
* Background job processor for integration synchronizations.
|
||||||
|
* Handles progress tracking, logging, and retries with exponential backoff.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Job } from 'bullmq';
|
||||||
|
import { getDatabase, TenantContext } from '@horux/database';
|
||||||
|
import { logger, createContextLogger } from '../utils/logger.js';
|
||||||
|
import { integrationManager } from '../services/integrations/integration.manager.js';
|
||||||
|
import {
|
||||||
|
IntegrationType,
|
||||||
|
SyncStatus,
|
||||||
|
SyncResult,
|
||||||
|
SyncError,
|
||||||
|
SyncRecordResult,
|
||||||
|
SyncEntityType,
|
||||||
|
SyncDirection,
|
||||||
|
IIntegrationConnector,
|
||||||
|
SyncOptions,
|
||||||
|
FetchOptions,
|
||||||
|
} from '../services/integrations/integration.types.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SyncJobPayload {
|
||||||
|
tenantId: string;
|
||||||
|
tenantSchema: string;
|
||||||
|
integrationId: string;
|
||||||
|
integrationType: IntegrationType;
|
||||||
|
scheduleId?: string;
|
||||||
|
options: SyncOptions;
|
||||||
|
priority: 'low' | 'normal' | 'high';
|
||||||
|
triggeredBy: 'schedule' | 'manual' | 'webhook' | 'system';
|
||||||
|
triggeredByUserId?: string;
|
||||||
|
retryCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncJobContext {
|
||||||
|
tenant: TenantContext;
|
||||||
|
integrationId: string;
|
||||||
|
integrationType: IntegrationType;
|
||||||
|
jobId: string;
|
||||||
|
logger: ReturnType<typeof createContextLogger>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SYNC JOB PROCESSOR
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a sync job
|
||||||
|
*/
|
||||||
|
export async function processSyncJob(job: Job<SyncJobPayload>): Promise<SyncResult> {
|
||||||
|
const { data } = job;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Create context logger
|
||||||
|
const contextLogger = createContextLogger({
|
||||||
|
jobId: job.id,
|
||||||
|
integrationId: data.integrationId,
|
||||||
|
integrationType: data.integrationType,
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
action: 'sync',
|
||||||
|
});
|
||||||
|
|
||||||
|
contextLogger.info('Starting sync job');
|
||||||
|
|
||||||
|
// Create tenant context
|
||||||
|
const tenant: TenantContext = {
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
schemaName: data.tenantSchema,
|
||||||
|
userId: data.triggeredByUserId || 'system',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create job context
|
||||||
|
const context: SyncJobContext = {
|
||||||
|
tenant,
|
||||||
|
integrationId: data.integrationId,
|
||||||
|
integrationType: data.integrationType,
|
||||||
|
jobId: job.id!,
|
||||||
|
logger: contextLogger,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize result
|
||||||
|
const result: SyncResult = {
|
||||||
|
jobId: job.id!,
|
||||||
|
integrationId: data.integrationId,
|
||||||
|
integrationType: data.integrationType,
|
||||||
|
status: SyncStatus.RUNNING,
|
||||||
|
direction: data.options.direction || SyncDirection.IMPORT,
|
||||||
|
entityType: data.options.entityTypes?.[0] || SyncEntityType.TRANSACTIONS,
|
||||||
|
startedAt: new Date(),
|
||||||
|
totalRecords: 0,
|
||||||
|
processedRecords: 0,
|
||||||
|
createdRecords: 0,
|
||||||
|
updatedRecords: 0,
|
||||||
|
skippedRecords: 0,
|
||||||
|
failedRecords: 0,
|
||||||
|
progress: 0,
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update job progress
|
||||||
|
await job.updateProgress(5);
|
||||||
|
|
||||||
|
// Get integration details
|
||||||
|
const integration = await integrationManager.getIntegration(tenant, data.integrationId);
|
||||||
|
if (!integration) {
|
||||||
|
throw new Error('Integration not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!integration.isActive) {
|
||||||
|
throw new Error('Integration is not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
await job.updateProgress(10);
|
||||||
|
|
||||||
|
// Get connector
|
||||||
|
const connector = integrationManager.getConnector(data.integrationType);
|
||||||
|
|
||||||
|
// Connect to external system
|
||||||
|
contextLogger.info('Connecting to external system');
|
||||||
|
await connector.connect(integration.config);
|
||||||
|
await job.updateProgress(15);
|
||||||
|
|
||||||
|
// Determine entities to sync
|
||||||
|
const entityTypes = data.options.entityTypes ||
|
||||||
|
(integration.config as any).enabledEntities ||
|
||||||
|
connector.getSupportedEntities();
|
||||||
|
|
||||||
|
// Calculate progress increments
|
||||||
|
const progressPerEntity = 80 / entityTypes.length;
|
||||||
|
let currentProgress = 15;
|
||||||
|
|
||||||
|
// Sync each entity type
|
||||||
|
for (const entityType of entityTypes) {
|
||||||
|
contextLogger.info(`Syncing entity type: ${entityType}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entityResult = await syncEntityType(
|
||||||
|
context,
|
||||||
|
connector,
|
||||||
|
entityType,
|
||||||
|
data.options,
|
||||||
|
job,
|
||||||
|
currentProgress,
|
||||||
|
progressPerEntity
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aggregate results
|
||||||
|
result.totalRecords += entityResult.totalRecords;
|
||||||
|
result.processedRecords += entityResult.processedRecords;
|
||||||
|
result.createdRecords += entityResult.createdRecords;
|
||||||
|
result.updatedRecords += entityResult.updatedRecords;
|
||||||
|
result.skippedRecords += entityResult.skippedRecords;
|
||||||
|
result.failedRecords += entityResult.failedRecords;
|
||||||
|
result.errors.push(...entityResult.errors);
|
||||||
|
result.warnings.push(...entityResult.warnings);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
contextLogger.error(`Failed to sync ${entityType}`, { error: errorMessage });
|
||||||
|
|
||||||
|
result.errors.push({
|
||||||
|
code: 'ENTITY_SYNC_FAILED',
|
||||||
|
message: `Failed to sync ${entityType}: ${errorMessage}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
retryable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentProgress += progressPerEntity;
|
||||||
|
await job.updateProgress(Math.min(currentProgress, 95));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect from external system
|
||||||
|
await connector.disconnect();
|
||||||
|
|
||||||
|
// Determine final status
|
||||||
|
if (result.errors.length === 0) {
|
||||||
|
result.status = SyncStatus.COMPLETED;
|
||||||
|
} else if (result.processedRecords > 0) {
|
||||||
|
result.status = SyncStatus.PARTIAL;
|
||||||
|
} else {
|
||||||
|
result.status = SyncStatus.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.completedAt = new Date();
|
||||||
|
result.durationMs = Date.now() - startTime;
|
||||||
|
result.progress = 100;
|
||||||
|
|
||||||
|
await job.updateProgress(100);
|
||||||
|
|
||||||
|
// Log final results
|
||||||
|
await logSyncResult(context, result);
|
||||||
|
|
||||||
|
contextLogger.info('Sync job completed', {
|
||||||
|
status: result.status,
|
||||||
|
totalRecords: result.totalRecords,
|
||||||
|
processedRecords: result.processedRecords,
|
||||||
|
createdRecords: result.createdRecords,
|
||||||
|
updatedRecords: result.updatedRecords,
|
||||||
|
failedRecords: result.failedRecords,
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
result.status = SyncStatus.FAILED;
|
||||||
|
result.completedAt = new Date();
|
||||||
|
result.durationMs = Date.now() - startTime;
|
||||||
|
result.errors.push({
|
||||||
|
code: 'SYNC_FAILED',
|
||||||
|
message: errorMessage,
|
||||||
|
timestamp: new Date(),
|
||||||
|
retryable: isRetryableError(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log failure
|
||||||
|
await logSyncResult(context, result);
|
||||||
|
|
||||||
|
contextLogger.error('Sync job failed', { error: errorMessage });
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ENTITY SYNC
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a single entity type
|
||||||
|
*/
|
||||||
|
async function syncEntityType(
|
||||||
|
context: SyncJobContext,
|
||||||
|
connector: IIntegrationConnector,
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
options: SyncOptions,
|
||||||
|
job: Job,
|
||||||
|
startProgress: number,
|
||||||
|
progressRange: number
|
||||||
|
): Promise<{
|
||||||
|
totalRecords: number;
|
||||||
|
processedRecords: number;
|
||||||
|
createdRecords: number;
|
||||||
|
updatedRecords: number;
|
||||||
|
skippedRecords: number;
|
||||||
|
failedRecords: number;
|
||||||
|
errors: SyncError[];
|
||||||
|
warnings: string[];
|
||||||
|
}> {
|
||||||
|
const result = {
|
||||||
|
totalRecords: 0,
|
||||||
|
processedRecords: 0,
|
||||||
|
createdRecords: 0,
|
||||||
|
updatedRecords: 0,
|
||||||
|
skippedRecords: 0,
|
||||||
|
failedRecords: 0,
|
||||||
|
errors: [] as SyncError[],
|
||||||
|
warnings: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchSize = options.batchSize || 100;
|
||||||
|
let offset = 0;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
// Fetch batch of records
|
||||||
|
const fetchOptions: FetchOptions = {
|
||||||
|
limit: batchSize,
|
||||||
|
offset,
|
||||||
|
startDate: options.startDate,
|
||||||
|
endDate: options.endDate,
|
||||||
|
modifiedSince: options.fullSync ? undefined : await getLastSyncDate(context, entityType),
|
||||||
|
filters: options.filters,
|
||||||
|
};
|
||||||
|
|
||||||
|
context.logger.debug(`Fetching ${entityType} records`, { offset, limit: batchSize });
|
||||||
|
|
||||||
|
const records = await connector.fetchRecords(entityType, fetchOptions);
|
||||||
|
|
||||||
|
if (records.length === 0) {
|
||||||
|
hasMore = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.totalRecords += records.length;
|
||||||
|
|
||||||
|
// Process records in batch
|
||||||
|
if (options.dryRun) {
|
||||||
|
// Dry run - just count
|
||||||
|
result.processedRecords += records.length;
|
||||||
|
result.skippedRecords += records.length;
|
||||||
|
} else {
|
||||||
|
// Actually process records
|
||||||
|
const batchResults = await processBatch(context, entityType, records, options);
|
||||||
|
|
||||||
|
result.processedRecords += batchResults.processedRecords;
|
||||||
|
result.createdRecords += batchResults.createdRecords;
|
||||||
|
result.updatedRecords += batchResults.updatedRecords;
|
||||||
|
result.skippedRecords += batchResults.skippedRecords;
|
||||||
|
result.failedRecords += batchResults.failedRecords;
|
||||||
|
result.errors.push(...batchResults.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
const batchProgress = (offset / (result.totalRecords || 1)) * progressRange;
|
||||||
|
await job.updateProgress(Math.min(startProgress + batchProgress, startProgress + progressRange));
|
||||||
|
|
||||||
|
// Check if we got a full batch (more records might exist)
|
||||||
|
if (records.length < batchSize) {
|
||||||
|
hasMore = false;
|
||||||
|
} else {
|
||||||
|
offset += batchSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add small delay between batches to avoid overwhelming the external system
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a batch of records
|
||||||
|
*/
|
||||||
|
async function processBatch(
|
||||||
|
context: SyncJobContext,
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
records: unknown[],
|
||||||
|
options: SyncOptions
|
||||||
|
): Promise<{
|
||||||
|
processedRecords: number;
|
||||||
|
createdRecords: number;
|
||||||
|
updatedRecords: number;
|
||||||
|
skippedRecords: number;
|
||||||
|
failedRecords: number;
|
||||||
|
errors: SyncError[];
|
||||||
|
}> {
|
||||||
|
const result = {
|
||||||
|
processedRecords: 0,
|
||||||
|
createdRecords: 0,
|
||||||
|
updatedRecords: 0,
|
||||||
|
skippedRecords: 0,
|
||||||
|
failedRecords: 0,
|
||||||
|
errors: [] as SyncError[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
try {
|
||||||
|
const recordResult = await processRecord(context, entityType, record);
|
||||||
|
|
||||||
|
result.processedRecords++;
|
||||||
|
|
||||||
|
switch (recordResult.action) {
|
||||||
|
case 'created':
|
||||||
|
result.createdRecords++;
|
||||||
|
break;
|
||||||
|
case 'updated':
|
||||||
|
result.updatedRecords++;
|
||||||
|
break;
|
||||||
|
case 'skipped':
|
||||||
|
result.skippedRecords++;
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
result.failedRecords++;
|
||||||
|
if (recordResult.errorMessage) {
|
||||||
|
result.errors.push({
|
||||||
|
code: recordResult.errorCode || 'RECORD_FAILED',
|
||||||
|
message: recordResult.errorMessage,
|
||||||
|
sourceId: recordResult.sourceId,
|
||||||
|
timestamp: new Date(),
|
||||||
|
retryable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
result.failedRecords++;
|
||||||
|
result.errors.push({
|
||||||
|
code: 'RECORD_ERROR',
|
||||||
|
message: errorMessage,
|
||||||
|
sourceId: (record as any).id || 'unknown',
|
||||||
|
timestamp: new Date(),
|
||||||
|
retryable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single record
|
||||||
|
*/
|
||||||
|
async function processRecord(
|
||||||
|
context: SyncJobContext,
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
record: unknown
|
||||||
|
): Promise<SyncRecordResult> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const recordData = record as Record<string, unknown>;
|
||||||
|
const sourceId = String(recordData.id || recordData.uuid || recordData.code || '');
|
||||||
|
|
||||||
|
// Check if record already exists
|
||||||
|
const existingRecord = await findExistingRecord(context, entityType, sourceId);
|
||||||
|
|
||||||
|
if (existingRecord) {
|
||||||
|
// Update existing record
|
||||||
|
await updateRecord(context, entityType, existingRecord.id, recordData);
|
||||||
|
return {
|
||||||
|
sourceId,
|
||||||
|
targetId: existingRecord.id,
|
||||||
|
success: true,
|
||||||
|
action: 'updated',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Create new record
|
||||||
|
const targetId = await createRecord(context, entityType, recordData);
|
||||||
|
return {
|
||||||
|
sourceId,
|
||||||
|
targetId,
|
||||||
|
success: true,
|
||||||
|
action: 'created',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find existing record by source ID
|
||||||
|
*/
|
||||||
|
async function findExistingRecord(
|
||||||
|
context: SyncJobContext,
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
sourceId: string
|
||||||
|
): Promise<{ id: string } | null> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const tableName = getTableName(entityType);
|
||||||
|
|
||||||
|
const result = await db.queryTenant<{ id: string }>(
|
||||||
|
context.tenant,
|
||||||
|
`SELECT id FROM ${tableName} WHERE external_id = $1 OR id::text = $1 LIMIT 1`,
|
||||||
|
[sourceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new record
|
||||||
|
*/
|
||||||
|
async function createRecord(
|
||||||
|
context: SyncJobContext,
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const tableName = getTableName(entityType);
|
||||||
|
|
||||||
|
// Map fields based on entity type
|
||||||
|
const mappedData = mapRecordFields(entityType, data);
|
||||||
|
|
||||||
|
const columns = Object.keys(mappedData);
|
||||||
|
const values = Object.values(mappedData);
|
||||||
|
const placeholders = columns.map((_, i) => `$${i + 1}`);
|
||||||
|
|
||||||
|
const result = await db.queryTenant<{ id: string }>(
|
||||||
|
context.tenant,
|
||||||
|
`INSERT INTO ${tableName} (${columns.join(', ')})
|
||||||
|
VALUES (${placeholders.join(', ')})
|
||||||
|
RETURNING id`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing record
|
||||||
|
*/
|
||||||
|
async function updateRecord(
|
||||||
|
context: SyncJobContext,
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
id: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const tableName = getTableName(entityType);
|
||||||
|
|
||||||
|
// Map fields based on entity type
|
||||||
|
const mappedData = mapRecordFields(entityType, data);
|
||||||
|
|
||||||
|
const setClause = Object.keys(mappedData)
|
||||||
|
.map((col, i) => `${col} = $${i + 2}`)
|
||||||
|
.join(', ');
|
||||||
|
const values = [id, ...Object.values(mappedData)];
|
||||||
|
|
||||||
|
await db.queryTenant(
|
||||||
|
context.tenant,
|
||||||
|
`UPDATE ${tableName} SET ${setClause}, updated_at = NOW() WHERE id = $1`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last sync date for incremental sync
|
||||||
|
*/
|
||||||
|
async function getLastSyncDate(
|
||||||
|
context: SyncJobContext,
|
||||||
|
entityType: SyncEntityType
|
||||||
|
): Promise<Date | undefined> {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const result = await db.queryTenant<{ last_sync_at: Date }>(
|
||||||
|
context.tenant,
|
||||||
|
`SELECT last_sync_at FROM integration_sync_logs
|
||||||
|
WHERE integration_id = $1 AND entity_type = $2 AND status = 'completed'
|
||||||
|
ORDER BY completed_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[context.integrationId, entityType]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0]?.last_sync_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log sync result to database
|
||||||
|
*/
|
||||||
|
async function logSyncResult(context: SyncJobContext, result: SyncResult): Promise<void> {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
await db.queryTenant(
|
||||||
|
context.tenant,
|
||||||
|
`INSERT INTO integration_sync_logs (
|
||||||
|
integration_id,
|
||||||
|
job_id,
|
||||||
|
entity_type,
|
||||||
|
direction,
|
||||||
|
status,
|
||||||
|
started_at,
|
||||||
|
completed_at,
|
||||||
|
duration_ms,
|
||||||
|
total_records,
|
||||||
|
created_records,
|
||||||
|
updated_records,
|
||||||
|
skipped_records,
|
||||||
|
failed_records,
|
||||||
|
error_count,
|
||||||
|
last_error,
|
||||||
|
triggered_by,
|
||||||
|
metadata
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||||
|
[
|
||||||
|
context.integrationId,
|
||||||
|
result.jobId,
|
||||||
|
result.entityType,
|
||||||
|
result.direction,
|
||||||
|
result.status,
|
||||||
|
result.startedAt,
|
||||||
|
result.completedAt,
|
||||||
|
result.durationMs,
|
||||||
|
result.totalRecords,
|
||||||
|
result.createdRecords,
|
||||||
|
result.updatedRecords,
|
||||||
|
result.skippedRecords,
|
||||||
|
result.failedRecords,
|
||||||
|
result.errors.length,
|
||||||
|
result.errors[0]?.message || null,
|
||||||
|
'manual',
|
||||||
|
JSON.stringify({
|
||||||
|
warnings: result.warnings,
|
||||||
|
errors: result.errors.slice(0, 20),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get table name for entity type
|
||||||
|
*/
|
||||||
|
function getTableName(entityType: SyncEntityType): string {
|
||||||
|
const tableMap: Record<SyncEntityType, string> = {
|
||||||
|
[SyncEntityType.TRANSACTIONS]: 'transactions',
|
||||||
|
[SyncEntityType.INVOICES]: 'cfdis',
|
||||||
|
[SyncEntityType.CONTACTS]: 'contacts',
|
||||||
|
[SyncEntityType.PRODUCTS]: 'products',
|
||||||
|
[SyncEntityType.ACCOUNTS]: 'accounts',
|
||||||
|
[SyncEntityType.CATEGORIES]: 'categories',
|
||||||
|
[SyncEntityType.JOURNAL_ENTRIES]: 'journal_entries',
|
||||||
|
[SyncEntityType.PAYMENTS]: 'payments',
|
||||||
|
[SyncEntityType.CFDIS]: 'cfdis',
|
||||||
|
[SyncEntityType.BANK_STATEMENTS]: 'bank_transactions',
|
||||||
|
};
|
||||||
|
|
||||||
|
return tableMap[entityType] || entityType.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map record fields based on entity type
|
||||||
|
*/
|
||||||
|
function mapRecordFields(
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
// Basic field mapping - in production this would be more sophisticated
|
||||||
|
const mapped: Record<string, unknown> = {
|
||||||
|
external_id: data.id || data.uuid || data.code,
|
||||||
|
source: 'integration',
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (entityType) {
|
||||||
|
case SyncEntityType.CONTACTS:
|
||||||
|
mapped.name = data.name || data.razon_social || data.nombre;
|
||||||
|
mapped.rfc = data.rfc || data.tax_id;
|
||||||
|
mapped.email = data.email || data.correo;
|
||||||
|
mapped.phone = data.phone || data.telefono;
|
||||||
|
mapped.type = data.type || 'customer';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncEntityType.TRANSACTIONS:
|
||||||
|
mapped.amount = data.amount || data.monto || data.total;
|
||||||
|
mapped.type = data.type || data.tipo || 'expense';
|
||||||
|
mapped.description = data.description || data.descripcion || data.concepto;
|
||||||
|
mapped.transaction_date = data.date || data.fecha;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncEntityType.INVOICES:
|
||||||
|
case SyncEntityType.CFDIS:
|
||||||
|
mapped.uuid_fiscal = data.uuid || data.uuid_fiscal;
|
||||||
|
mapped.total = data.total || data.monto;
|
||||||
|
mapped.subtotal = data.subtotal || data.total;
|
||||||
|
mapped.fecha_emision = data.date || data.fecha || data.fecha_emision;
|
||||||
|
mapped.emisor_rfc = data.emisor_rfc || data.rfc_emisor;
|
||||||
|
mapped.emisor_nombre = data.emisor_nombre || data.nombre_emisor;
|
||||||
|
mapped.receptor_rfc = data.receptor_rfc || data.rfc_receptor;
|
||||||
|
mapped.receptor_nombre = data.receptor_nombre || data.nombre_receptor;
|
||||||
|
mapped.tipo_comprobante = data.tipo || data.tipo_comprobante || 'I';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncEntityType.ACCOUNTS:
|
||||||
|
mapped.code = data.code || data.codigo || data.numero;
|
||||||
|
mapped.name = data.name || data.nombre || data.descripcion;
|
||||||
|
mapped.type = data.type || data.tipo || data.naturaleza;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncEntityType.CATEGORIES:
|
||||||
|
mapped.code = data.code || data.codigo;
|
||||||
|
mapped.name = data.name || data.nombre;
|
||||||
|
mapped.type = data.type || data.tipo || 'expense';
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Copy all fields as-is
|
||||||
|
Object.assign(mapped, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is retryable
|
||||||
|
*/
|
||||||
|
function isRetryableError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
const nonRetryable = [
|
||||||
|
'authentication failed',
|
||||||
|
'invalid credentials',
|
||||||
|
'unauthorized',
|
||||||
|
'forbidden',
|
||||||
|
'not found',
|
||||||
|
'invalid configuration',
|
||||||
|
'validation error',
|
||||||
|
];
|
||||||
|
return !nonRetryable.some((phrase) => message.includes(phrase));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay helper
|
||||||
|
*/
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default processSyncJob;
|
||||||
173
apps/api/src/routes/ai.routes.ts
Normal file
173
apps/api/src/routes/ai.routes.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* AI Routes
|
||||||
|
*
|
||||||
|
* Routes for AI-powered analysis, explanations, recommendations, and chat
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware.js';
|
||||||
|
import { validate } from '../middleware/validate.middleware.js';
|
||||||
|
import {
|
||||||
|
analyzeMetrics,
|
||||||
|
explainEntity,
|
||||||
|
getRecommendations,
|
||||||
|
chat,
|
||||||
|
getUsage,
|
||||||
|
AnalyzeRequestSchema,
|
||||||
|
ExplainRequestSchema,
|
||||||
|
RecommendRequestSchema,
|
||||||
|
ChatRequestSchema,
|
||||||
|
} from '../controllers/ai.controller.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI-Specific Rate Limiting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stricter rate limiter for AI endpoints
|
||||||
|
* This is in addition to the plan-based limits in the controller
|
||||||
|
*/
|
||||||
|
const aiRateLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 30, // 30 requests per minute max (across all AI endpoints)
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'AI_RATE_LIMIT_EXCEEDED',
|
||||||
|
message: 'Demasiadas solicitudes de AI. Por favor espera un momento.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
keyGenerator: (req) => {
|
||||||
|
// Rate limit by tenant + user
|
||||||
|
return `ai:${req.user?.tenant_id || 'anonymous'}:${req.user?.sub || req.ip}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* More restrictive limiter for chat endpoint (streaming consumes more resources)
|
||||||
|
*/
|
||||||
|
const chatRateLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 10, // 10 chat messages per minute
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'CHAT_RATE_LIMIT_EXCEEDED',
|
||||||
|
message: 'Demasiados mensajes de chat. Por favor espera un momento.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
keyGenerator: (req) => {
|
||||||
|
return `chat:${req.user?.tenant_id || 'anonymous'}:${req.user?.sub || req.ip}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/analyze
|
||||||
|
* Perform AI analysis on metrics
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - metrics: Array of metric codes to analyze
|
||||||
|
* - dateRange: { start, end } date range
|
||||||
|
* - compareWith: Comparison period (previous_period, year_ago, none)
|
||||||
|
* - depth: Analysis depth (quick, standard, detailed)
|
||||||
|
* - focusAreas: Optional areas to focus analysis on
|
||||||
|
*
|
||||||
|
* Returns structured insights and observations
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/analyze',
|
||||||
|
authenticate,
|
||||||
|
aiRateLimiter,
|
||||||
|
validate({ body: AnalyzeRequestSchema }),
|
||||||
|
analyzeMetrics
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/explain
|
||||||
|
* Get AI explanation for a metric, anomaly, or trend
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - type: Type of entity to explain (metric, anomaly, trend, transaction, forecast)
|
||||||
|
* - entityId: Optional ID of the specific entity
|
||||||
|
* - context: Context data for the explanation
|
||||||
|
* - language: Response language (es, en)
|
||||||
|
*
|
||||||
|
* Returns human-readable explanation with key points
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/explain',
|
||||||
|
authenticate,
|
||||||
|
aiRateLimiter,
|
||||||
|
validate({ body: ExplainRequestSchema }),
|
||||||
|
explainEntity
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/recommend
|
||||||
|
* Get AI-powered recommendations
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - area: Focus area (cost_reduction, revenue_growth, cash_flow, etc.)
|
||||||
|
* - dateRange: { start, end } date range to analyze
|
||||||
|
* - constraints: Optional constraints (maxBudget, riskTolerance, timeHorizon)
|
||||||
|
* - excludeCategories: Categories to exclude from recommendations
|
||||||
|
*
|
||||||
|
* Returns prioritized list of actionable recommendations
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/recommend',
|
||||||
|
authenticate,
|
||||||
|
aiRateLimiter,
|
||||||
|
validate({ body: RecommendRequestSchema }),
|
||||||
|
getRecommendations
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/chat
|
||||||
|
* Chat with CFO Digital (AI assistant)
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - message: User's message
|
||||||
|
* - conversationId: Optional ID to continue existing conversation
|
||||||
|
* - context: Optional context about current view, selected metrics, etc.
|
||||||
|
*
|
||||||
|
* Supports SSE streaming with Accept: text/event-stream header
|
||||||
|
* Otherwise returns complete response as JSON
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/chat',
|
||||||
|
authenticate,
|
||||||
|
chatRateLimiter,
|
||||||
|
validate({ body: ChatRequestSchema }),
|
||||||
|
chat
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/usage
|
||||||
|
* Get AI API usage statistics
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Today's usage (requests, tokens)
|
||||||
|
* - Plan limits
|
||||||
|
* - Usage by type (analyze, explain, recommend, chat)
|
||||||
|
* - Weekly trend
|
||||||
|
* - Reset time
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/usage',
|
||||||
|
authenticate,
|
||||||
|
getUsage
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -7,3 +7,6 @@ export { default as contactsRoutes } from './contacts.routes.js';
|
|||||||
export { default as cfdisRoutes } from './cfdis.routes.js';
|
export { default as cfdisRoutes } from './cfdis.routes.js';
|
||||||
export { default as categoriesRoutes } from './categories.routes.js';
|
export { default as categoriesRoutes } from './categories.routes.js';
|
||||||
export { default as alertsRoutes } from './alerts.routes.js';
|
export { default as alertsRoutes } from './alerts.routes.js';
|
||||||
|
export { default as reportsRoutes } from './reports.routes.js';
|
||||||
|
export { default as aiRoutes } from './ai.routes.js';
|
||||||
|
export { default as integrationsRoutes } from './integrations.routes.js';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
122
apps/api/src/routes/reports.routes.ts
Normal file
122
apps/api/src/routes/reports.routes.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Reports Routes
|
||||||
|
*
|
||||||
|
* Routes for report generation, listing, and download
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware.js';
|
||||||
|
import { validate } from '../middleware/validate.middleware.js';
|
||||||
|
import {
|
||||||
|
listReports,
|
||||||
|
getReport,
|
||||||
|
generateReport,
|
||||||
|
downloadReport,
|
||||||
|
deleteReport,
|
||||||
|
listTemplates,
|
||||||
|
ReportFiltersSchema,
|
||||||
|
ReportIdSchema,
|
||||||
|
GenerateReportSchema,
|
||||||
|
} from '../controllers/reports.controller.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/reports
|
||||||
|
* List all reports with pagination and filters
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - page: Page number (default: 1)
|
||||||
|
* - limit: Items per page (default: 20, max: 100)
|
||||||
|
* - type: Filter by report type
|
||||||
|
* - status: Filter by status
|
||||||
|
* - startDate: Filter by creation date start
|
||||||
|
* - endDate: Filter by creation date end
|
||||||
|
* - search: Search by name
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
validate({ query: ReportFiltersSchema }),
|
||||||
|
listReports
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/reports/templates
|
||||||
|
* List available report templates
|
||||||
|
*
|
||||||
|
* Returns templates filtered by user's plan (free vs premium)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/templates',
|
||||||
|
authenticate,
|
||||||
|
listTemplates
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/reports/:id
|
||||||
|
* Get a specific report by ID
|
||||||
|
*
|
||||||
|
* Returns full report details including generation status
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: ReportIdSchema }),
|
||||||
|
getReport
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/reports/generate
|
||||||
|
* Generate a new report
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - name: Report name
|
||||||
|
* - type: Report type (financial_summary, income_statement, etc.)
|
||||||
|
* - templateId: Optional template ID to use
|
||||||
|
* - parameters: Report parameters including date range, filters
|
||||||
|
* - priority: Job priority (low, normal, high)
|
||||||
|
*
|
||||||
|
* Returns immediately with job ID and estimated time
|
||||||
|
* Report is generated in background via BullMQ
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/generate',
|
||||||
|
authenticate,
|
||||||
|
validate({ body: GenerateReportSchema }),
|
||||||
|
generateReport
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/reports/:id/download
|
||||||
|
* Get download URL for a completed report
|
||||||
|
*
|
||||||
|
* Returns signed URL or streams the file
|
||||||
|
* Only available for completed reports
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id/download',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: ReportIdSchema }),
|
||||||
|
downloadReport
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/reports/:id
|
||||||
|
* Delete a report
|
||||||
|
*
|
||||||
|
* Also cancels pending/processing jobs and removes files from storage
|
||||||
|
* Only the report creator or admins can delete
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
validate({ params: ReportIdSchema }),
|
||||||
|
deleteReport
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
789
apps/api/src/services/ai/ai.service.ts
Normal file
789
apps/api/src/services/ai/ai.service.ts
Normal file
@@ -0,0 +1,789 @@
|
|||||||
|
/**
|
||||||
|
* AI Service
|
||||||
|
*
|
||||||
|
* Servicio de alto nivel para análisis financiero con DeepSeek AI.
|
||||||
|
* Proporciona:
|
||||||
|
* - Generación de insights financieros
|
||||||
|
* - Resúmenes ejecutivos
|
||||||
|
* - Recomendaciones estratégicas
|
||||||
|
* - Cache de respuestas con Redis
|
||||||
|
* - Prompts optimizados para análisis financiero en español
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import {
|
||||||
|
DeepSeekClient,
|
||||||
|
DeepSeekError,
|
||||||
|
getDeepSeekClient,
|
||||||
|
DeepSeekRateLimitError,
|
||||||
|
} from './deepseek.client.js';
|
||||||
|
import {
|
||||||
|
DeepSeekMessage,
|
||||||
|
FinancialAnalysisContext,
|
||||||
|
FinancialMetricsInput,
|
||||||
|
FinancialTrends,
|
||||||
|
FinancialInsightResult,
|
||||||
|
ExecutiveSummaryResult,
|
||||||
|
RecommendationsResult,
|
||||||
|
Recommendation,
|
||||||
|
FinancialAlert,
|
||||||
|
AICacheEntry,
|
||||||
|
Usage,
|
||||||
|
RateLimitInfo,
|
||||||
|
} from './deepseek.types.js';
|
||||||
|
import { logger, auditLog } from '../../utils/logger.js';
|
||||||
|
import { ExternalServiceError, RateLimitError } from '../../types/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const CACHE_PREFIX = 'horux:ai:';
|
||||||
|
const DEFAULT_CACHE_TTL = 3600; // 1 hora
|
||||||
|
|
||||||
|
// TTL específicos por tipo de operación
|
||||||
|
const OPERATION_CACHE_TTL: Record<string, number> = {
|
||||||
|
insight: 1800, // 30 minutos
|
||||||
|
summary: 3600, // 1 hora
|
||||||
|
recommendations: 7200, // 2 horas
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// System Prompts
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const SYSTEM_PROMPTS = {
|
||||||
|
financialAnalyst: `Eres un analista financiero experto especializado en empresas mexicanas y latinoamericanas.
|
||||||
|
Tu rol es proporcionar análisis financieros precisos, accionables y en español.
|
||||||
|
|
||||||
|
Principios guía:
|
||||||
|
- Usa terminología financiera estándar en español de México
|
||||||
|
- Sé conciso pero completo
|
||||||
|
- Prioriza información accionable
|
||||||
|
- Identifica patrones y tendencias
|
||||||
|
- Destaca riesgos y oportunidades
|
||||||
|
- Contextualiza los números con el entorno económico mexicano
|
||||||
|
- Considera las particularidades fiscales y regulatorias de México (SAT, CFDI, etc.)
|
||||||
|
|
||||||
|
Formato de respuestas:
|
||||||
|
- Usa formato JSON cuando se solicite
|
||||||
|
- Estructura clara con secciones definidas
|
||||||
|
- Incluye métricas específicas cuando sea relevante
|
||||||
|
- Evita jerga innecesaria`,
|
||||||
|
|
||||||
|
executiveSummary: `Eres un analista financiero senior preparando un resumen ejecutivo para la alta dirección.
|
||||||
|
Tu objetivo es comunicar de manera clara y concisa el estado financiero de la empresa.
|
||||||
|
|
||||||
|
Características del resumen:
|
||||||
|
- Lenguaje ejecutivo y profesional en español
|
||||||
|
- Enfoque en resultados y tendencias clave
|
||||||
|
- Destaca logros y áreas de preocupación
|
||||||
|
- Incluye comparativas con período anterior
|
||||||
|
- Termina con recomendaciones de alto nivel
|
||||||
|
- Máximo 3-4 párrafos para el resumen general`,
|
||||||
|
|
||||||
|
strategicAdvisor: `Eres un consultor estratégico financiero para empresas en México.
|
||||||
|
Tu objetivo es proporcionar recomendaciones prácticas y priorizadas.
|
||||||
|
|
||||||
|
Enfoque de las recomendaciones:
|
||||||
|
- Basadas en datos, no en suposiciones
|
||||||
|
- Priorizadas por impacto y urgencia
|
||||||
|
- Considerando el contexto mexicano (fiscal, económico, regulatorio)
|
||||||
|
- Con plazos realistas de implementación
|
||||||
|
- Identificando recursos necesarios
|
||||||
|
- Mencionando riesgos de no actuar`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Prompt Templates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera el prompt para análisis de métricas financieras
|
||||||
|
*/
|
||||||
|
function buildFinancialInsightPrompt(
|
||||||
|
metrics: FinancialMetricsInput,
|
||||||
|
context: FinancialAnalysisContext
|
||||||
|
): string {
|
||||||
|
const previousComparison = metrics.previousPeriod
|
||||||
|
? `
|
||||||
|
Comparación con período anterior:
|
||||||
|
- Ingresos anteriores: ${formatCurrency(metrics.previousPeriod.revenue, context.currency)}
|
||||||
|
- Cambio en ingresos: ${calculateChange(metrics.revenue, metrics.previousPeriod.revenue)}%
|
||||||
|
- Gastos anteriores: ${formatCurrency(metrics.previousPeriod.expenses, context.currency)}
|
||||||
|
- Cambio en gastos: ${calculateChange(metrics.expenses, metrics.previousPeriod.expenses)}%
|
||||||
|
- Utilidad anterior: ${formatCurrency(metrics.previousPeriod.netProfit, context.currency)}
|
||||||
|
- Cambio en utilidad: ${calculateChange(metrics.netProfit, metrics.previousPeriod.netProfit)}%`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const additionalMetrics = metrics.additional
|
||||||
|
? `
|
||||||
|
Métricas adicionales:
|
||||||
|
${metrics.additional.mrr ? `- MRR: ${formatCurrency(metrics.additional.mrr, context.currency)}` : ''}
|
||||||
|
${metrics.additional.arr ? `- ARR: ${formatCurrency(metrics.additional.arr, context.currency)}` : ''}
|
||||||
|
${metrics.additional.burnRate ? `- Burn Rate: ${formatCurrency(metrics.additional.burnRate, context.currency)}/mes` : ''}
|
||||||
|
${metrics.additional.runway ? `- Runway: ${metrics.additional.runway} meses` : ''}
|
||||||
|
${metrics.additional.churnRate ? `- Churn Rate: ${(metrics.additional.churnRate * 100).toFixed(2)}%` : ''}
|
||||||
|
${metrics.additional.ebitda ? `- EBITDA: ${formatCurrency(metrics.additional.ebitda, context.currency)}` : ''}
|
||||||
|
${metrics.additional.currentRatio ? `- Ratio de Liquidez: ${metrics.additional.currentRatio.toFixed(2)}` : ''}
|
||||||
|
${metrics.additional.quickRatio ? `- Prueba Ácida: ${metrics.additional.quickRatio.toFixed(2)}` : ''}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `Analiza las siguientes métricas financieras y proporciona insights accionables.
|
||||||
|
|
||||||
|
Empresa: ${context.companyName || 'No especificada'}
|
||||||
|
Industria: ${context.industry || 'No especificada'}
|
||||||
|
Período: ${context.period.type} (${formatDate(context.period.from)} - ${formatDate(context.period.to)})
|
||||||
|
Moneda: ${context.currency}
|
||||||
|
|
||||||
|
Métricas principales:
|
||||||
|
- Ingresos: ${formatCurrency(metrics.revenue, context.currency)}
|
||||||
|
- Gastos: ${formatCurrency(metrics.expenses, context.currency)}
|
||||||
|
- Utilidad Neta: ${formatCurrency(metrics.netProfit, context.currency)}
|
||||||
|
- Margen de Utilidad: ${(metrics.profitMargin * 100).toFixed(2)}%
|
||||||
|
- Flujo de Caja: ${formatCurrency(metrics.cashFlow, context.currency)}
|
||||||
|
- Cuentas por Cobrar: ${formatCurrency(metrics.accountsReceivable, context.currency)}
|
||||||
|
- Cuentas por Pagar: ${formatCurrency(metrics.accountsPayable, context.currency)}
|
||||||
|
${previousComparison}
|
||||||
|
${additionalMetrics}
|
||||||
|
|
||||||
|
Responde en formato JSON con la siguiente estructura:
|
||||||
|
{
|
||||||
|
"summary": "Resumen ejecutivo de 2-3 oraciones",
|
||||||
|
"keyPoints": ["punto clave 1", "punto clave 2", "punto clave 3"],
|
||||||
|
"healthScore": <número del 0 al 100>,
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"type": "warning|critical|info",
|
||||||
|
"title": "título de la alerta",
|
||||||
|
"description": "descripción detallada",
|
||||||
|
"metric": "métrica afectada"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera el prompt para resumen ejecutivo
|
||||||
|
*/
|
||||||
|
function buildExecutiveSummaryPrompt(
|
||||||
|
metrics: FinancialMetricsInput,
|
||||||
|
context: FinancialAnalysisContext,
|
||||||
|
trends?: FinancialTrends
|
||||||
|
): string {
|
||||||
|
const trendAnalysis = trends && trends.revenueTrend && trends.expensesTrend
|
||||||
|
? `
|
||||||
|
Tendencias (últimos períodos):
|
||||||
|
- Tendencia de ingresos: ${describeTrend(trends.revenueTrend)}
|
||||||
|
- Tendencia de gastos: ${describeTrend(trends.expensesTrend)}
|
||||||
|
- Tendencia de utilidad: ${describeTrend(trends.profitTrend)}
|
||||||
|
- Tendencia de flujo de caja: ${describeTrend(trends.cashFlowTrend)}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `Prepara un resumen ejecutivo para la alta dirección basado en los siguientes datos.
|
||||||
|
|
||||||
|
Empresa: ${context.companyName || 'La empresa'}
|
||||||
|
Período: ${context.period.type} (${formatDate(context.period.from)} - ${formatDate(context.period.to)})
|
||||||
|
|
||||||
|
Datos financieros:
|
||||||
|
- Ingresos: ${formatCurrency(metrics.revenue, context.currency)}
|
||||||
|
- Gastos: ${formatCurrency(metrics.expenses, context.currency)}
|
||||||
|
- Utilidad Neta: ${formatCurrency(metrics.netProfit, context.currency)}
|
||||||
|
- Margen: ${(metrics.profitMargin * 100).toFixed(2)}%
|
||||||
|
- Flujo de Caja: ${formatCurrency(metrics.cashFlow, context.currency)}
|
||||||
|
${
|
||||||
|
metrics.previousPeriod
|
||||||
|
? `
|
||||||
|
Cambios vs período anterior:
|
||||||
|
- Ingresos: ${calculateChange(metrics.revenue, metrics.previousPeriod.revenue)}%
|
||||||
|
- Gastos: ${calculateChange(metrics.expenses, metrics.previousPeriod.expenses)}%
|
||||||
|
- Utilidad: ${calculateChange(metrics.netProfit, metrics.previousPeriod.netProfit)}%`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${trendAnalysis}
|
||||||
|
|
||||||
|
Responde en formato JSON con la siguiente estructura:
|
||||||
|
{
|
||||||
|
"title": "Título del resumen (incluir período)",
|
||||||
|
"overview": "Resumen general de 3-4 oraciones",
|
||||||
|
"achievements": ["logro 1", "logro 2"],
|
||||||
|
"challenges": ["desafío 1", "desafío 2"],
|
||||||
|
"nextSteps": ["acción recomendada 1", "acción recomendada 2"]
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera el prompt para recomendaciones
|
||||||
|
*/
|
||||||
|
function buildRecommendationsPrompt(
|
||||||
|
metrics: FinancialMetricsInput,
|
||||||
|
context: FinancialAnalysisContext,
|
||||||
|
_trends?: FinancialTrends
|
||||||
|
): string {
|
||||||
|
return `Genera recomendaciones estratégicas basadas en el siguiente análisis financiero.
|
||||||
|
|
||||||
|
Empresa: ${context.companyName || 'La empresa'}
|
||||||
|
Industria: ${context.industry || 'No especificada'}
|
||||||
|
Período: ${formatDate(context.period.from)} - ${formatDate(context.period.to)}
|
||||||
|
|
||||||
|
Estado financiero actual:
|
||||||
|
- Ingresos: ${formatCurrency(metrics.revenue, context.currency)}
|
||||||
|
- Gastos: ${formatCurrency(metrics.expenses, context.currency)}
|
||||||
|
- Utilidad Neta: ${formatCurrency(metrics.netProfit, context.currency)} (margen: ${(metrics.profitMargin * 100).toFixed(2)}%)
|
||||||
|
- Flujo de Caja: ${formatCurrency(metrics.cashFlow, context.currency)}
|
||||||
|
- Cuentas por Cobrar: ${formatCurrency(metrics.accountsReceivable, context.currency)}
|
||||||
|
- Cuentas por Pagar: ${formatCurrency(metrics.accountsPayable, context.currency)}
|
||||||
|
${
|
||||||
|
metrics.previousPeriod
|
||||||
|
? `
|
||||||
|
Variaciones vs período anterior:
|
||||||
|
- Ingresos: ${calculateChange(metrics.revenue, metrics.previousPeriod.revenue)}%
|
||||||
|
- Gastos: ${calculateChange(metrics.expenses, metrics.previousPeriod.expenses)}%
|
||||||
|
- Utilidad: ${calculateChange(metrics.netProfit, metrics.previousPeriod.netProfit)}%
|
||||||
|
- Flujo de Caja: ${calculateChange(metrics.cashFlow, metrics.previousPeriod.cashFlow)}%`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${
|
||||||
|
metrics.additional
|
||||||
|
? `
|
||||||
|
Indicadores adicionales:
|
||||||
|
${metrics.additional.currentRatio ? `- Ratio de Liquidez: ${metrics.additional.currentRatio.toFixed(2)}` : ''}
|
||||||
|
${metrics.additional.runway ? `- Runway: ${metrics.additional.runway} meses` : ''}
|
||||||
|
${metrics.additional.burnRate ? `- Burn Rate: ${formatCurrency(metrics.additional.burnRate, context.currency)}/mes` : ''}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
Responde en formato JSON con la siguiente estructura:
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
{
|
||||||
|
"priority": "high|medium|low",
|
||||||
|
"category": "cost_reduction|revenue_growth|cash_flow|risk_management|operational",
|
||||||
|
"title": "Título de la recomendación",
|
||||||
|
"description": "Descripción detallada de qué hacer y por qué",
|
||||||
|
"expectedImpact": "Impacto esperado cuantificado si es posible",
|
||||||
|
"timeframe": "immediate|short_term|medium_term|long_term"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"opportunities": ["oportunidad identificada 1", "oportunidad 2"],
|
||||||
|
"risks": ["riesgo a mitigar 1", "riesgo 2"]
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, currency: string): string {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return new Intl.DateTimeFormat('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateChange(current: number, previous: number): string {
|
||||||
|
if (previous === 0) return current > 0 ? '+100.00' : '0.00';
|
||||||
|
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||||
|
return change > 0 ? `+${change.toFixed(2)}` : change.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTrend(trendData: { period: string; value: number; changePercent?: number }[]): string {
|
||||||
|
if (!trendData || trendData.length < 2) return 'Datos insuficientes';
|
||||||
|
|
||||||
|
const lastChange = trendData[trendData.length - 1]?.changePercent;
|
||||||
|
if (lastChange === undefined) return 'Sin cambio';
|
||||||
|
|
||||||
|
if (lastChange > 5) return `Creciente (+${lastChange.toFixed(1)}%)`;
|
||||||
|
if (lastChange < -5) return `Decreciente (${lastChange.toFixed(1)}%)`;
|
||||||
|
return 'Estable';
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCacheKey(operation: string, tenantId: string, input: unknown): string {
|
||||||
|
const inputHash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(JSON.stringify(input))
|
||||||
|
.digest('hex')
|
||||||
|
.substring(0, 16);
|
||||||
|
|
||||||
|
return `${CACHE_PREFIX}${operation}:${tenantId}:${inputHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI Service Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface AIServiceOptions {
|
||||||
|
/** Cliente Redis para cache */
|
||||||
|
redis?: Redis;
|
||||||
|
/** Habilitar cache (por defecto: true) */
|
||||||
|
enableCache?: boolean;
|
||||||
|
/** Cliente DeepSeek personalizado */
|
||||||
|
deepSeekClient?: DeepSeekClient;
|
||||||
|
/** TTL de cache por defecto en segundos */
|
||||||
|
defaultCacheTTL?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio de IA para análisis financiero
|
||||||
|
*/
|
||||||
|
export class AIService {
|
||||||
|
private client: DeepSeekClient;
|
||||||
|
private redis: Redis | null;
|
||||||
|
private enableCache: boolean;
|
||||||
|
private defaultCacheTTL: number;
|
||||||
|
private totalTokensUsed: number = 0;
|
||||||
|
|
||||||
|
constructor(options: AIServiceOptions = {}) {
|
||||||
|
this.client = options.deepSeekClient || getDeepSeekClient();
|
||||||
|
this.redis = options.redis || null;
|
||||||
|
this.enableCache = options.enableCache !== false;
|
||||||
|
this.defaultCacheTTL = options.defaultCacheTTL || DEFAULT_CACHE_TTL;
|
||||||
|
|
||||||
|
// Escuchar eventos de uso
|
||||||
|
this.client.on('usage', (usage: Usage) => {
|
||||||
|
this.totalTokensUsed += usage.total_tokens;
|
||||||
|
logger.debug('AI tokens used', { usage, totalTokensUsed: this.totalTokensUsed });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escuchar advertencias de rate limit
|
||||||
|
this.client.on('rateLimitWarning', (info: RateLimitInfo) => {
|
||||||
|
logger.warn('DeepSeek rate limit warning', { info });
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('AIService initialized', {
|
||||||
|
cacheEnabled: this.enableCache,
|
||||||
|
model: this.client.getModel(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera insights financieros basados en métricas
|
||||||
|
*/
|
||||||
|
async generateFinancialInsight(
|
||||||
|
metrics: FinancialMetricsInput,
|
||||||
|
context: FinancialAnalysisContext
|
||||||
|
): Promise<FinancialInsightResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const cacheKey = generateCacheKey('insight', context.tenantId, { metrics, context });
|
||||||
|
|
||||||
|
// Intentar obtener del cache
|
||||||
|
if (this.enableCache) {
|
||||||
|
const cached = await this.getFromCache<FinancialInsightResult>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
logger.debug('Financial insight retrieved from cache', { tenantId: context.tenantId });
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages: DeepSeekMessage[] = [
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPTS.financialAnalyst },
|
||||||
|
{ role: 'user', content: buildFinancialInsightPrompt(metrics, context) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await this.client.chat(messages, {
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 1500,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = response.choices[0]?.message?.content || '{}';
|
||||||
|
const parsed = this.parseJSONResponse<{
|
||||||
|
summary: string;
|
||||||
|
keyPoints: string[];
|
||||||
|
healthScore: number;
|
||||||
|
alerts: FinancialAlert[];
|
||||||
|
}>(content);
|
||||||
|
|
||||||
|
const result: FinancialInsightResult = {
|
||||||
|
summary: parsed.summary || 'No se pudo generar el análisis.',
|
||||||
|
keyPoints: parsed.keyPoints || [],
|
||||||
|
healthScore: Math.min(100, Math.max(0, parsed.healthScore || 50)),
|
||||||
|
alerts: parsed.alerts || [],
|
||||||
|
tokensUsed: response.usage?.total_tokens || 0,
|
||||||
|
generationTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Guardar en cache
|
||||||
|
if (this.enableCache) {
|
||||||
|
await this.saveToCache(cacheKey, result, OPERATION_CACHE_TTL.insight);
|
||||||
|
}
|
||||||
|
|
||||||
|
auditLog('AI_INSIGHT_GENERATED', null, context.tenantId, {
|
||||||
|
tokensUsed: result.tokensUsed,
|
||||||
|
generationTime: result.generationTime,
|
||||||
|
healthScore: result.healthScore,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return this.handleAIError(error, 'generateFinancialInsight', context.tenantId, startTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un resumen ejecutivo del período
|
||||||
|
*/
|
||||||
|
async generateExecutiveSummary(
|
||||||
|
metrics: FinancialMetricsInput,
|
||||||
|
context: FinancialAnalysisContext,
|
||||||
|
trends?: FinancialTrends
|
||||||
|
): Promise<ExecutiveSummaryResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const cacheKey = generateCacheKey('summary', context.tenantId, { metrics, context, trends });
|
||||||
|
|
||||||
|
// Intentar obtener del cache
|
||||||
|
if (this.enableCache) {
|
||||||
|
const cached = await this.getFromCache<ExecutiveSummaryResult>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
logger.debug('Executive summary retrieved from cache', { tenantId: context.tenantId });
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages: DeepSeekMessage[] = [
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPTS.executiveSummary },
|
||||||
|
{ role: 'user', content: buildExecutiveSummaryPrompt(metrics, context, trends) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await this.client.chat(messages, {
|
||||||
|
temperature: 0.4,
|
||||||
|
max_tokens: 2000,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = response.choices[0]?.message?.content || '{}';
|
||||||
|
const parsed = this.parseJSONResponse<{
|
||||||
|
title: string;
|
||||||
|
overview: string;
|
||||||
|
achievements: string[];
|
||||||
|
challenges: string[];
|
||||||
|
nextSteps: string[];
|
||||||
|
}>(content);
|
||||||
|
|
||||||
|
const result: ExecutiveSummaryResult = {
|
||||||
|
title: parsed.title || `Resumen Ejecutivo - ${context.period.type}`,
|
||||||
|
overview: parsed.overview || 'No se pudo generar el resumen.',
|
||||||
|
achievements: parsed.achievements || [],
|
||||||
|
challenges: parsed.challenges || [],
|
||||||
|
nextSteps: parsed.nextSteps || [],
|
||||||
|
tokensUsed: response.usage?.total_tokens || 0,
|
||||||
|
generationTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Guardar en cache
|
||||||
|
if (this.enableCache) {
|
||||||
|
await this.saveToCache(cacheKey, result, OPERATION_CACHE_TTL.summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
auditLog('AI_SUMMARY_GENERATED', null, context.tenantId, {
|
||||||
|
tokensUsed: result.tokensUsed,
|
||||||
|
generationTime: result.generationTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return this.handleAIError(error, 'generateExecutiveSummary', context.tenantId, startTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera recomendaciones estratégicas
|
||||||
|
*/
|
||||||
|
async generateRecommendations(
|
||||||
|
metrics: FinancialMetricsInput,
|
||||||
|
context: FinancialAnalysisContext,
|
||||||
|
trends?: FinancialTrends
|
||||||
|
): Promise<RecommendationsResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const cacheKey = generateCacheKey('recommendations', context.tenantId, { metrics, context, trends });
|
||||||
|
|
||||||
|
// Intentar obtener del cache
|
||||||
|
if (this.enableCache) {
|
||||||
|
const cached = await this.getFromCache<RecommendationsResult>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
logger.debug('Recommendations retrieved from cache', { tenantId: context.tenantId });
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages: DeepSeekMessage[] = [
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPTS.strategicAdvisor },
|
||||||
|
{ role: 'user', content: buildRecommendationsPrompt(metrics, context, trends) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await this.client.chat(messages, {
|
||||||
|
temperature: 0.5,
|
||||||
|
max_tokens: 2500,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = response.choices[0]?.message?.content || '{}';
|
||||||
|
const parsed = this.parseJSONResponse<{
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
opportunities: string[];
|
||||||
|
risks: string[];
|
||||||
|
}>(content);
|
||||||
|
|
||||||
|
const result: RecommendationsResult = {
|
||||||
|
recommendations: parsed.recommendations || [],
|
||||||
|
opportunities: parsed.opportunities || [],
|
||||||
|
risks: parsed.risks || [],
|
||||||
|
tokensUsed: response.usage?.total_tokens || 0,
|
||||||
|
generationTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ordenar recomendaciones por prioridad
|
||||||
|
result.recommendations.sort((a, b) => {
|
||||||
|
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||||
|
return (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Guardar en cache
|
||||||
|
if (this.enableCache) {
|
||||||
|
await this.saveToCache(cacheKey, result, OPERATION_CACHE_TTL.recommendations);
|
||||||
|
}
|
||||||
|
|
||||||
|
auditLog('AI_RECOMMENDATIONS_GENERATED', null, context.tenantId, {
|
||||||
|
tokensUsed: result.tokensUsed,
|
||||||
|
generationTime: result.generationTime,
|
||||||
|
recommendationCount: result.recommendations.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return this.handleAIError(error, 'generateRecommendations', context.tenantId, startTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera una respuesta de chat personalizada
|
||||||
|
*/
|
||||||
|
async chat(
|
||||||
|
prompt: string,
|
||||||
|
context: FinancialAnalysisContext,
|
||||||
|
conversationHistory?: DeepSeekMessage[]
|
||||||
|
): Promise<{ response: string; tokensUsed: number }> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages: DeepSeekMessage[] = [
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPTS.financialAnalyst },
|
||||||
|
...(conversationHistory || []),
|
||||||
|
{ role: 'user', content: prompt },
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await this.client.chat(messages, {
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
response: response.choices[0]?.message?.content || '',
|
||||||
|
tokensUsed: response.usage?.total_tokens || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
auditLog('AI_CHAT', null, context.tenantId, {
|
||||||
|
tokensUsed: result.tokensUsed,
|
||||||
|
generationTime: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AI chat failed', { error, tenantId: context.tenantId });
|
||||||
|
throw this.transformError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadísticas de uso del servicio
|
||||||
|
*/
|
||||||
|
getUsageStats(): { totalTokensUsed: number; rateLimitInfo: unknown } {
|
||||||
|
return {
|
||||||
|
totalTokensUsed: this.totalTokensUsed,
|
||||||
|
rateLimitInfo: this.client.getRateLimitInfo(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalida el cache para un tenant
|
||||||
|
*/
|
||||||
|
async invalidateCache(tenantId: string): Promise<number> {
|
||||||
|
if (!this.redis) return 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pattern = `${CACHE_PREFIX}*:${tenantId}:*`;
|
||||||
|
const keys = await this.redis.keys(pattern);
|
||||||
|
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await this.redis.del(...keys);
|
||||||
|
logger.info('AI cache invalidated', { tenantId, keysDeleted: keys.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.length;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to invalidate AI cache', { error, tenantId });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Methods - Cache
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un valor del cache
|
||||||
|
*/
|
||||||
|
private async getFromCache<T>(key: string): Promise<T | null> {
|
||||||
|
if (!this.redis) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.redis.get(key);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const entry = JSON.parse(data) as AICacheEntry<T>;
|
||||||
|
|
||||||
|
// Verificar expiración
|
||||||
|
if (new Date(entry.expiresAt) < new Date()) {
|
||||||
|
await this.redis.del(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Cache get error', { key, error });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarda un valor en el cache
|
||||||
|
*/
|
||||||
|
private async saveToCache<T>(key: string, data: T, ttl: number = this.defaultCacheTTL): Promise<void> {
|
||||||
|
if (!this.redis) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entry: AICacheEntry<T> = {
|
||||||
|
data,
|
||||||
|
createdAt: new Date(),
|
||||||
|
expiresAt: new Date(Date.now() + ttl * 1000),
|
||||||
|
tokensUsed: (data as any).tokensUsed || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.redis.setex(key, ttl, JSON.stringify(entry));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Cache set error', { key, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Methods - Error Handling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maneja errores de la API de AI
|
||||||
|
*/
|
||||||
|
private handleAIError(
|
||||||
|
error: unknown,
|
||||||
|
operation: string,
|
||||||
|
tenantId: string,
|
||||||
|
startTime: number
|
||||||
|
): never {
|
||||||
|
const generationTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
logger.error('AI operation failed', {
|
||||||
|
operation,
|
||||||
|
tenantId,
|
||||||
|
generationTime,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
|
||||||
|
throw this.transformError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforma errores de DeepSeek a errores de la aplicación
|
||||||
|
*/
|
||||||
|
private transformError(error: unknown): Error {
|
||||||
|
if (error instanceof DeepSeekRateLimitError) {
|
||||||
|
return new RateLimitError(
|
||||||
|
`DeepSeek rate limit excedido. Reintentar en ${Math.ceil(error.retryAfter / 1000)} segundos.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof DeepSeekError) {
|
||||||
|
return new ExternalServiceError('DeepSeek AI', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return new ExternalServiceError('DeepSeek AI', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExternalServiceError('DeepSeek AI', 'Error desconocido');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea una respuesta JSON de manera segura
|
||||||
|
*/
|
||||||
|
private parseJSONResponse<T>(content: string): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(content) as T;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to parse AI JSON response', { content: content.substring(0, 200) });
|
||||||
|
|
||||||
|
// Intentar extraer JSON del contenido
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonMatch[0]) as T;
|
||||||
|
} catch {
|
||||||
|
// Falló el segundo intento
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let aiServiceInstance: AIService | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una instancia singleton del servicio de AI
|
||||||
|
*/
|
||||||
|
export function getAIService(options?: AIServiceOptions): AIService {
|
||||||
|
if (!aiServiceInstance) {
|
||||||
|
aiServiceInstance = new AIService(options);
|
||||||
|
}
|
||||||
|
return aiServiceInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una nueva instancia del servicio de AI
|
||||||
|
*/
|
||||||
|
export function createAIService(options?: AIServiceOptions): AIService {
|
||||||
|
return new AIService(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AIService;
|
||||||
655
apps/api/src/services/ai/deepseek.client.ts
Normal file
655
apps/api/src/services/ai/deepseek.client.ts
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
/**
|
||||||
|
* DeepSeek AI Client
|
||||||
|
*
|
||||||
|
* Cliente HTTP para la API de DeepSeek con soporte para:
|
||||||
|
* - Chat completions (normal y streaming)
|
||||||
|
* - Rate limiting con backoff exponencial
|
||||||
|
* - Reintentos automáticos
|
||||||
|
* - Tipado completo de request/response
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import {
|
||||||
|
DeepSeekConfig,
|
||||||
|
DeepSeekModel,
|
||||||
|
DeepSeekMessage,
|
||||||
|
ChatCompletionRequest,
|
||||||
|
ChatCompletionResponse,
|
||||||
|
StreamChunk,
|
||||||
|
DeepSeekAPIError,
|
||||||
|
DeepSeekErrorResponse,
|
||||||
|
RateLimitInfo,
|
||||||
|
Usage,
|
||||||
|
} from './deepseek.types.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { ExternalServiceError } from '../../types/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: Partial<DeepSeekConfig> = {
|
||||||
|
baseUrl: 'https://api.deepseek.com',
|
||||||
|
model: 'deepseek-chat',
|
||||||
|
timeout: 30000,
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelay: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_ENDPOINTS = {
|
||||||
|
chatCompletions: '/v1/chat/completions',
|
||||||
|
models: '/v1/models',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// HTTP status codes that should trigger a retry
|
||||||
|
const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Classes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error específico de DeepSeek
|
||||||
|
*/
|
||||||
|
export class DeepSeekError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly type: string,
|
||||||
|
public readonly statusCode: number,
|
||||||
|
public readonly param?: string | null,
|
||||||
|
public readonly code?: string | null,
|
||||||
|
public readonly retryable: boolean = false
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DeepSeekError';
|
||||||
|
Object.setPrototypeOf(this, DeepSeekError.prototype);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromAPIError(error: DeepSeekAPIError, statusCode: number): DeepSeekError {
|
||||||
|
const retryable = statusCode === 429 || statusCode >= 500;
|
||||||
|
return new DeepSeekError(
|
||||||
|
error.message,
|
||||||
|
error.type,
|
||||||
|
statusCode,
|
||||||
|
error.param,
|
||||||
|
error.code,
|
||||||
|
retryable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error de rate limiting de DeepSeek
|
||||||
|
*/
|
||||||
|
export class DeepSeekRateLimitError extends DeepSeekError {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly retryAfter: number,
|
||||||
|
public readonly rateLimitInfo?: RateLimitInfo
|
||||||
|
) {
|
||||||
|
super(message, 'rate_limit_error', 429, null, 'rate_limit_exceeded', true);
|
||||||
|
this.name = 'DeepSeekRateLimitError';
|
||||||
|
Object.setPrototypeOf(this, DeepSeekRateLimitError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DeepSeek Client
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente para la API de DeepSeek
|
||||||
|
*/
|
||||||
|
export class DeepSeekClient extends EventEmitter {
|
||||||
|
private readonly config: DeepSeekConfig;
|
||||||
|
private rateLimitInfo: RateLimitInfo | null = null;
|
||||||
|
|
||||||
|
constructor(config: Partial<DeepSeekConfig> & { apiKey: string }) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
if (!config.apiKey) {
|
||||||
|
throw new Error('DeepSeek API key is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...config,
|
||||||
|
baseUrl: (config.baseUrl || DEFAULT_CONFIG.baseUrl!).replace(/\/$/, ''),
|
||||||
|
} as DeepSeekConfig;
|
||||||
|
|
||||||
|
logger.debug('DeepSeekClient initialized', {
|
||||||
|
baseUrl: this.config.baseUrl,
|
||||||
|
model: this.config.model,
|
||||||
|
timeout: this.config.timeout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una solicitud de chat completion
|
||||||
|
*/
|
||||||
|
async chat(
|
||||||
|
messages: DeepSeekMessage[],
|
||||||
|
options?: Partial<Omit<ChatCompletionRequest, 'messages' | 'stream'>>
|
||||||
|
): Promise<ChatCompletionResponse> {
|
||||||
|
const request: ChatCompletionRequest = {
|
||||||
|
model: options?.model || this.config.model,
|
||||||
|
messages,
|
||||||
|
stream: false,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.executeWithRetry<ChatCompletionResponse>(async () => {
|
||||||
|
const response = await this.makeRequest<ChatCompletionResponse>(
|
||||||
|
API_ENDPOINTS.chatCompletions,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
this.emit('usage', response.usage);
|
||||||
|
logger.debug('Chat completion successful', {
|
||||||
|
model: response.model,
|
||||||
|
usage: response.usage,
|
||||||
|
finishReason: response.choices[0]?.finish_reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una solicitud de chat completion con streaming
|
||||||
|
*/
|
||||||
|
async *streamChat(
|
||||||
|
messages: DeepSeekMessage[],
|
||||||
|
options?: Partial<Omit<ChatCompletionRequest, 'messages' | 'stream'>>
|
||||||
|
): AsyncGenerator<StreamChunk, void, unknown> {
|
||||||
|
const request: ChatCompletionRequest = {
|
||||||
|
model: options?.model || this.config.model,
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.makeStreamRequest(
|
||||||
|
API_ENDPOINTS.chatCompletions,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalUsage: Usage | null = null;
|
||||||
|
|
||||||
|
for await (const chunk of this.parseSSEStream(response)) {
|
||||||
|
if (chunk.usage) {
|
||||||
|
totalUsage = chunk.usage;
|
||||||
|
}
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalUsage) {
|
||||||
|
this.emit('usage', totalUsage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Método de conveniencia para obtener una respuesta simple de texto
|
||||||
|
*/
|
||||||
|
async getTextResponse(
|
||||||
|
prompt: string,
|
||||||
|
systemPrompt?: string,
|
||||||
|
options?: Partial<Omit<ChatCompletionRequest, 'messages' | 'stream'>>
|
||||||
|
): Promise<string> {
|
||||||
|
const messages: DeepSeekMessage[] = [];
|
||||||
|
|
||||||
|
if (systemPrompt) {
|
||||||
|
messages.push({ role: 'system', content: systemPrompt });
|
||||||
|
}
|
||||||
|
messages.push({ role: 'user', content: prompt });
|
||||||
|
|
||||||
|
const response = await this.chat(messages, options);
|
||||||
|
return response.choices[0]?.message?.content || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene información de rate limiting actual
|
||||||
|
*/
|
||||||
|
getRateLimitInfo(): RateLimitInfo | null {
|
||||||
|
return this.rateLimitInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cambia el modelo por defecto
|
||||||
|
*/
|
||||||
|
setModel(model: DeepSeekModel): void {
|
||||||
|
this.config.model = model;
|
||||||
|
logger.debug('Model changed', { model });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el modelo actual
|
||||||
|
*/
|
||||||
|
getModel(): DeepSeekModel {
|
||||||
|
return this.config.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Methods - HTTP
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una solicitud HTTP a la API
|
||||||
|
*/
|
||||||
|
private async makeRequest<T>(endpoint: string, body: unknown): Promise<T> {
|
||||||
|
const url = `${this.config.baseUrl}${endpoint}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateRateLimitInfo(response.headers);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await this.handleErrorResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as T;
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DeepSeekError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
throw new DeepSeekError(
|
||||||
|
`Request timeout after ${this.config.timeout}ms`,
|
||||||
|
'timeout_error',
|
||||||
|
408,
|
||||||
|
null,
|
||||||
|
'timeout',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ExternalServiceError('DeepSeek', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ExternalServiceError('DeepSeek', 'Unknown error occurred');
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una solicitud HTTP con streaming
|
||||||
|
*/
|
||||||
|
private async makeStreamRequest(
|
||||||
|
endpoint: string,
|
||||||
|
body: unknown
|
||||||
|
): Promise<ReadableStream<Uint8Array>> {
|
||||||
|
const url = `${this.config.baseUrl}${endpoint}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout || 60000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
this.updateRateLimitInfo(response.headers);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await this.handleErrorResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new DeepSeekError(
|
||||||
|
'No response body for streaming request',
|
||||||
|
'invalid_response',
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DeepSeekError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new ExternalServiceError('DeepSeek', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ExternalServiceError('DeepSeek', 'Unknown error occurred');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los headers para las solicitudes
|
||||||
|
*/
|
||||||
|
private getHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.config.apiKey}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.config.organization) {
|
||||||
|
headers['OpenAI-Organization'] = this.config.organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maneja respuestas de error de la API
|
||||||
|
*/
|
||||||
|
private async handleErrorResponse(response: Response): Promise<never> {
|
||||||
|
let errorData: DeepSeekErrorResponse | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
errorData = await response.json() as DeepSeekErrorResponse;
|
||||||
|
} catch {
|
||||||
|
// Si no podemos parsear el JSON, usamos un error genérico
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar rate limiting
|
||||||
|
if (response.status === 429) {
|
||||||
|
const retryAfter = this.parseRetryAfter(response.headers);
|
||||||
|
throw new DeepSeekRateLimitError(
|
||||||
|
errorData?.error?.message || 'Rate limit exceeded',
|
||||||
|
retryAfter,
|
||||||
|
this.rateLimitInfo || undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorData?.error) {
|
||||||
|
throw DeepSeekError.fromAPIError(errorData.error, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DeepSeekError(
|
||||||
|
`HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
'http_error',
|
||||||
|
response.status,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
RETRYABLE_STATUS_CODES.includes(response.status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Methods - Streaming
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea el stream SSE y genera chunks
|
||||||
|
*/
|
||||||
|
private async *parseSSEStream(
|
||||||
|
stream: ReadableStream<Uint8Array>
|
||||||
|
): AsyncGenerator<StreamChunk, void, unknown> {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
|
if (!trimmedLine || trimmedLine.startsWith(':')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine === 'data: [DONE]') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith('data: ')) {
|
||||||
|
const jsonStr = trimmedLine.slice(6);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(jsonStr) as StreamChunk;
|
||||||
|
yield chunk;
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.warn('Failed to parse SSE chunk', { jsonStr, error: parseError });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar cualquier dato restante en el buffer
|
||||||
|
if (buffer.trim() && buffer.startsWith('data: ') && buffer !== 'data: [DONE]') {
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(buffer.slice(6)) as StreamChunk;
|
||||||
|
yield chunk;
|
||||||
|
} catch {
|
||||||
|
// Ignorar errores de parseo del buffer final
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Methods - Retry Logic
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una operación con reintentos
|
||||||
|
*/
|
||||||
|
private async executeWithRetry<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
attempt: number = 1
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
const shouldRetry = this.shouldRetry(error, attempt);
|
||||||
|
|
||||||
|
if (!shouldRetry) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = this.calculateRetryDelay(error, attempt);
|
||||||
|
|
||||||
|
logger.warn('Retrying DeepSeek request', {
|
||||||
|
attempt,
|
||||||
|
maxRetries: this.config.maxRetries,
|
||||||
|
delayMs: delay,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sleep(delay);
|
||||||
|
return this.executeWithRetry(operation, attempt + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determina si se debe reintentar la operación
|
||||||
|
*/
|
||||||
|
private shouldRetry(error: unknown, attempt: number): boolean {
|
||||||
|
if (attempt >= (this.config.maxRetries || 3)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof DeepSeekError) {
|
||||||
|
return error.retryable;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el delay para el siguiente reintento
|
||||||
|
*/
|
||||||
|
private calculateRetryDelay(error: unknown, attempt: number): number {
|
||||||
|
const baseDelay = this.config.retryDelay || 1000;
|
||||||
|
|
||||||
|
// Si es rate limit, usar el retry-after header si está disponible
|
||||||
|
if (error instanceof DeepSeekRateLimitError && error.retryAfter > 0) {
|
||||||
|
return error.retryAfter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backoff exponencial con jitter
|
||||||
|
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
|
||||||
|
const jitter = Math.random() * 1000;
|
||||||
|
|
||||||
|
return Math.min(exponentialDelay + jitter, 60000); // Max 60 segundos
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Methods - Rate Limiting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza la información de rate limiting desde los headers
|
||||||
|
*/
|
||||||
|
private updateRateLimitInfo(headers: Headers): void {
|
||||||
|
const limitRequests = headers.get('x-ratelimit-limit-requests');
|
||||||
|
const limitTokens = headers.get('x-ratelimit-limit-tokens');
|
||||||
|
const remainingRequests = headers.get('x-ratelimit-remaining-requests');
|
||||||
|
const remainingTokens = headers.get('x-ratelimit-remaining-tokens');
|
||||||
|
const resetRequests = headers.get('x-ratelimit-reset-requests');
|
||||||
|
const resetTokens = headers.get('x-ratelimit-reset-tokens');
|
||||||
|
|
||||||
|
if (limitRequests || limitTokens) {
|
||||||
|
this.rateLimitInfo = {
|
||||||
|
limitRequests: parseInt(limitRequests || '0', 10),
|
||||||
|
limitTokens: parseInt(limitTokens || '0', 10),
|
||||||
|
remainingRequests: parseInt(remainingRequests || '0', 10),
|
||||||
|
remainingTokens: parseInt(remainingTokens || '0', 10),
|
||||||
|
resetRequests: this.parseResetTime(resetRequests),
|
||||||
|
resetTokens: this.parseResetTime(resetTokens),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emitir evento si estamos cerca del límite
|
||||||
|
if (this.rateLimitInfo.remainingRequests < 10) {
|
||||||
|
this.emit('rateLimitWarning', this.rateLimitInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea el tiempo de reset del rate limit
|
||||||
|
*/
|
||||||
|
private parseResetTime(value: string | null): number {
|
||||||
|
if (!value) return 0;
|
||||||
|
|
||||||
|
// Puede ser un número de segundos o una duración como "1m30s"
|
||||||
|
const numericValue = parseInt(value, 10);
|
||||||
|
if (!isNaN(numericValue)) {
|
||||||
|
return numericValue * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsear formato de duración
|
||||||
|
const match = value.match(/(?:(\d+)m)?(?:(\d+)s)?/);
|
||||||
|
if (match) {
|
||||||
|
const minutes = parseInt(match[1] || '0', 10);
|
||||||
|
const seconds = parseInt(match[2] || '0', 10);
|
||||||
|
return (minutes * 60 + seconds) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea el header Retry-After
|
||||||
|
*/
|
||||||
|
private parseRetryAfter(headers: Headers): number {
|
||||||
|
const retryAfter = headers.get('retry-after');
|
||||||
|
if (!retryAfter) {
|
||||||
|
return 5000; // Default 5 segundos
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = parseInt(retryAfter, 10);
|
||||||
|
if (!isNaN(seconds)) {
|
||||||
|
return seconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puede ser una fecha HTTP
|
||||||
|
const date = new Date(retryAfter);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return Math.max(0, date.getTime() - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
return 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Methods - Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Espera un tiempo determinado
|
||||||
|
*/
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let deepSeekClientInstance: DeepSeekClient | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una instancia singleton del cliente DeepSeek
|
||||||
|
*/
|
||||||
|
export function getDeepSeekClient(
|
||||||
|
config?: Partial<DeepSeekConfig> & { apiKey: string }
|
||||||
|
): DeepSeekClient {
|
||||||
|
if (!deepSeekClientInstance) {
|
||||||
|
if (!config?.apiKey) {
|
||||||
|
const apiKey = process.env.DEEPSEEK_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('DEEPSEEK_API_KEY environment variable is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
deepSeekClientInstance = new DeepSeekClient({
|
||||||
|
apiKey,
|
||||||
|
baseUrl: process.env.DEEPSEEK_BASE_URL || DEFAULT_CONFIG.baseUrl,
|
||||||
|
model: (process.env.DEEPSEEK_MODEL as DeepSeekModel) || DEFAULT_CONFIG.model,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
deepSeekClientInstance = new DeepSeekClient(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deepSeekClientInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una nueva instancia del cliente DeepSeek
|
||||||
|
*/
|
||||||
|
export function createDeepSeekClient(
|
||||||
|
config: Partial<DeepSeekConfig> & { apiKey: string }
|
||||||
|
): DeepSeekClient {
|
||||||
|
return new DeepSeekClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeepSeekClient;
|
||||||
627
apps/api/src/services/ai/deepseek.types.ts
Normal file
627
apps/api/src/services/ai/deepseek.types.ts
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
/**
|
||||||
|
* DeepSeek AI Types
|
||||||
|
*
|
||||||
|
* Definiciones de tipos completas para la integración con DeepSeek API.
|
||||||
|
* Soporta los modelos deepseek-chat y deepseek-coder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Configuration Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración del cliente DeepSeek
|
||||||
|
*/
|
||||||
|
export interface DeepSeekConfig {
|
||||||
|
/** API Key de DeepSeek */
|
||||||
|
apiKey: string;
|
||||||
|
/** URL base de la API (por defecto: https://api.deepseek.com) */
|
||||||
|
baseUrl: string;
|
||||||
|
/** Modelo a utilizar */
|
||||||
|
model: DeepSeekModel;
|
||||||
|
/** Timeout en milisegundos (por defecto: 30000) */
|
||||||
|
timeout?: number;
|
||||||
|
/** Número máximo de reintentos (por defecto: 3) */
|
||||||
|
maxRetries?: number;
|
||||||
|
/** Delay base entre reintentos en ms (por defecto: 1000) */
|
||||||
|
retryDelay?: number;
|
||||||
|
/** Organización (opcional) */
|
||||||
|
organization?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modelos disponibles de DeepSeek
|
||||||
|
*/
|
||||||
|
export type DeepSeekModel =
|
||||||
|
| 'deepseek-chat'
|
||||||
|
| 'deepseek-coder'
|
||||||
|
| 'deepseek-reasoner';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Message Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roles de mensaje soportados
|
||||||
|
*/
|
||||||
|
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mensaje base de DeepSeek
|
||||||
|
*/
|
||||||
|
export interface DeepSeekMessage {
|
||||||
|
/** Rol del mensaje */
|
||||||
|
role: MessageRole;
|
||||||
|
/** Contenido del mensaje */
|
||||||
|
content: string;
|
||||||
|
/** Nombre del participante (opcional) */
|
||||||
|
name?: string;
|
||||||
|
/** Llamadas a herramientas (solo para assistant) */
|
||||||
|
tool_calls?: ToolCall[];
|
||||||
|
/** ID de la llamada a herramienta (solo para tool) */
|
||||||
|
tool_call_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mensaje de sistema
|
||||||
|
*/
|
||||||
|
export interface SystemMessage extends DeepSeekMessage {
|
||||||
|
role: 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mensaje de usuario
|
||||||
|
*/
|
||||||
|
export interface UserMessage extends DeepSeekMessage {
|
||||||
|
role: 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mensaje de asistente
|
||||||
|
*/
|
||||||
|
export interface AssistantMessage extends DeepSeekMessage {
|
||||||
|
role: 'assistant';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Llamada a herramienta
|
||||||
|
*/
|
||||||
|
export interface ToolCall {
|
||||||
|
/** ID único de la llamada */
|
||||||
|
id: string;
|
||||||
|
/** Tipo de herramienta */
|
||||||
|
type: 'function';
|
||||||
|
/** Función llamada */
|
||||||
|
function: {
|
||||||
|
/** Nombre de la función */
|
||||||
|
name: string;
|
||||||
|
/** Argumentos en JSON */
|
||||||
|
arguments: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Request Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solicitud de chat completion
|
||||||
|
*/
|
||||||
|
export interface ChatCompletionRequest {
|
||||||
|
/** Modelo a utilizar */
|
||||||
|
model: DeepSeekModel;
|
||||||
|
/** Mensajes de la conversación */
|
||||||
|
messages: DeepSeekMessage[];
|
||||||
|
/** Temperatura de muestreo (0-2, por defecto: 1) */
|
||||||
|
temperature?: number;
|
||||||
|
/** Top-p sampling (0-1, por defecto: 1) */
|
||||||
|
top_p?: number;
|
||||||
|
/** Número de completaciones a generar */
|
||||||
|
n?: number;
|
||||||
|
/** Si debe ser streaming */
|
||||||
|
stream?: boolean;
|
||||||
|
/** Secuencias de parada */
|
||||||
|
stop?: string | string[];
|
||||||
|
/** Máximo de tokens a generar */
|
||||||
|
max_tokens?: number;
|
||||||
|
/** Penalización de presencia (-2 a 2) */
|
||||||
|
presence_penalty?: number;
|
||||||
|
/** Penalización de frecuencia (-2 a 2) */
|
||||||
|
frequency_penalty?: number;
|
||||||
|
/** Modificar probabilidad de tokens específicos */
|
||||||
|
logit_bias?: Record<string, number>;
|
||||||
|
/** ID de usuario para tracking */
|
||||||
|
user?: string;
|
||||||
|
/** Herramientas disponibles */
|
||||||
|
tools?: Tool[];
|
||||||
|
/** Elección de herramienta */
|
||||||
|
tool_choice?: 'none' | 'auto' | { type: 'function'; function: { name: string } };
|
||||||
|
/** Formato de respuesta */
|
||||||
|
response_format?: ResponseFormat;
|
||||||
|
/** Semilla para reproducibilidad */
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Definición de herramienta
|
||||||
|
*/
|
||||||
|
export interface Tool {
|
||||||
|
/** Tipo de herramienta */
|
||||||
|
type: 'function';
|
||||||
|
/** Definición de la función */
|
||||||
|
function: FunctionDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Definición de función
|
||||||
|
*/
|
||||||
|
export interface FunctionDefinition {
|
||||||
|
/** Nombre de la función */
|
||||||
|
name: string;
|
||||||
|
/** Descripción de la función */
|
||||||
|
description?: string;
|
||||||
|
/** Parámetros de la función (JSON Schema) */
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formato de respuesta
|
||||||
|
*/
|
||||||
|
export interface ResponseFormat {
|
||||||
|
/** Tipo de formato */
|
||||||
|
type: 'text' | 'json_object';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respuesta de chat completion
|
||||||
|
*/
|
||||||
|
export interface ChatCompletionResponse {
|
||||||
|
/** ID único de la respuesta */
|
||||||
|
id: string;
|
||||||
|
/** Tipo de objeto */
|
||||||
|
object: 'chat.completion';
|
||||||
|
/** Timestamp de creación (Unix) */
|
||||||
|
created: number;
|
||||||
|
/** Modelo utilizado */
|
||||||
|
model: string;
|
||||||
|
/** Elecciones generadas */
|
||||||
|
choices: ChatCompletionChoice[];
|
||||||
|
/** Estadísticas de uso */
|
||||||
|
usage: Usage;
|
||||||
|
/** Fingerprint del sistema */
|
||||||
|
system_fingerprint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elección de chat completion
|
||||||
|
*/
|
||||||
|
export interface ChatCompletionChoice {
|
||||||
|
/** Índice de la elección */
|
||||||
|
index: number;
|
||||||
|
/** Mensaje generado */
|
||||||
|
message: AssistantMessage;
|
||||||
|
/** Probabilidades de tokens (si se solicitó) */
|
||||||
|
logprobs?: LogProbs | null;
|
||||||
|
/** Razón de finalización */
|
||||||
|
finish_reason: FinishReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Razones de finalización
|
||||||
|
*/
|
||||||
|
export type FinishReason =
|
||||||
|
| 'stop' // Parada natural o por secuencia de stop
|
||||||
|
| 'length' // Límite de tokens alcanzado
|
||||||
|
| 'tool_calls' // Modelo quiere llamar herramientas
|
||||||
|
| 'content_filter' // Contenido filtrado
|
||||||
|
| 'function_call'; // Deprecated: llamada a función
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probabilidades de tokens
|
||||||
|
*/
|
||||||
|
export interface LogProbs {
|
||||||
|
/** Contenido con probabilidades */
|
||||||
|
content: LogProbContent[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contenido con probabilidades
|
||||||
|
*/
|
||||||
|
export interface LogProbContent {
|
||||||
|
/** Token */
|
||||||
|
token: string;
|
||||||
|
/** Log probability */
|
||||||
|
logprob: number;
|
||||||
|
/** Bytes del token */
|
||||||
|
bytes: number[] | null;
|
||||||
|
/** Top logprobs */
|
||||||
|
top_logprobs: TopLogProb[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top log probabilities
|
||||||
|
*/
|
||||||
|
export interface TopLogProb {
|
||||||
|
/** Token */
|
||||||
|
token: string;
|
||||||
|
/** Log probability */
|
||||||
|
logprob: number;
|
||||||
|
/** Bytes del token */
|
||||||
|
bytes: number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Streaming Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk de streaming
|
||||||
|
*/
|
||||||
|
export interface StreamChunk {
|
||||||
|
/** ID único */
|
||||||
|
id: string;
|
||||||
|
/** Tipo de objeto */
|
||||||
|
object: 'chat.completion.chunk';
|
||||||
|
/** Timestamp de creación */
|
||||||
|
created: number;
|
||||||
|
/** Modelo utilizado */
|
||||||
|
model: string;
|
||||||
|
/** Fingerprint del sistema */
|
||||||
|
system_fingerprint?: string;
|
||||||
|
/** Elecciones delta */
|
||||||
|
choices: StreamChoice[];
|
||||||
|
/** Uso (solo en último chunk con stream_options) */
|
||||||
|
usage?: Usage | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elección de streaming
|
||||||
|
*/
|
||||||
|
export interface StreamChoice {
|
||||||
|
/** Índice de la elección */
|
||||||
|
index: number;
|
||||||
|
/** Delta del mensaje */
|
||||||
|
delta: StreamDelta;
|
||||||
|
/** Probabilidades de tokens */
|
||||||
|
logprobs?: LogProbs | null;
|
||||||
|
/** Razón de finalización */
|
||||||
|
finish_reason: FinishReason | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delta de streaming
|
||||||
|
*/
|
||||||
|
export interface StreamDelta {
|
||||||
|
/** Rol (solo en primer chunk) */
|
||||||
|
role?: MessageRole;
|
||||||
|
/** Contenido incremental */
|
||||||
|
content?: string;
|
||||||
|
/** Llamadas a herramientas incrementales */
|
||||||
|
tool_calls?: StreamToolCall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Llamada a herramienta en streaming
|
||||||
|
*/
|
||||||
|
export interface StreamToolCall {
|
||||||
|
/** Índice de la llamada */
|
||||||
|
index: number;
|
||||||
|
/** ID (solo en primer chunk de la llamada) */
|
||||||
|
id?: string;
|
||||||
|
/** Tipo */
|
||||||
|
type?: 'function';
|
||||||
|
/** Función */
|
||||||
|
function?: {
|
||||||
|
/** Nombre (solo en primer chunk) */
|
||||||
|
name?: string;
|
||||||
|
/** Argumentos incrementales */
|
||||||
|
arguments?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Usage & Statistics Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estadísticas de uso
|
||||||
|
*/
|
||||||
|
export interface Usage {
|
||||||
|
/** Tokens en el prompt */
|
||||||
|
prompt_tokens: number;
|
||||||
|
/** Tokens en la respuesta */
|
||||||
|
completion_tokens: number;
|
||||||
|
/** Tokens totales */
|
||||||
|
total_tokens: number;
|
||||||
|
/** Detalles del prompt (opcional) */
|
||||||
|
prompt_tokens_details?: PromptTokensDetails;
|
||||||
|
/** Detalles de la respuesta (opcional) */
|
||||||
|
completion_tokens_details?: CompletionTokensDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detalles de tokens del prompt
|
||||||
|
*/
|
||||||
|
export interface PromptTokensDetails {
|
||||||
|
/** Tokens cacheados */
|
||||||
|
cached_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detalles de tokens de la respuesta
|
||||||
|
*/
|
||||||
|
export interface CompletionTokensDetails {
|
||||||
|
/** Tokens de razonamiento */
|
||||||
|
reasoning_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error de la API de DeepSeek
|
||||||
|
*/
|
||||||
|
export interface DeepSeekAPIError {
|
||||||
|
/** Mensaje de error */
|
||||||
|
message: string;
|
||||||
|
/** Tipo de error */
|
||||||
|
type: DeepSeekErrorType;
|
||||||
|
/** Parámetro que causó el error (si aplica) */
|
||||||
|
param?: string | null;
|
||||||
|
/** Código de error */
|
||||||
|
code?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de error de DeepSeek
|
||||||
|
*/
|
||||||
|
export type DeepSeekErrorType =
|
||||||
|
| 'invalid_request_error'
|
||||||
|
| 'authentication_error'
|
||||||
|
| 'permission_error'
|
||||||
|
| 'not_found_error'
|
||||||
|
| 'rate_limit_error'
|
||||||
|
| 'server_error'
|
||||||
|
| 'service_unavailable_error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respuesta de error de la API
|
||||||
|
*/
|
||||||
|
export interface DeepSeekErrorResponse {
|
||||||
|
error: DeepSeekAPIError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Rate Limiting Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información de rate limiting
|
||||||
|
*/
|
||||||
|
export interface RateLimitInfo {
|
||||||
|
/** Límite de requests por minuto */
|
||||||
|
limitRequests: number;
|
||||||
|
/** Límite de tokens por minuto */
|
||||||
|
limitTokens: number;
|
||||||
|
/** Requests restantes */
|
||||||
|
remainingRequests: number;
|
||||||
|
/** Tokens restantes */
|
||||||
|
remainingTokens: number;
|
||||||
|
/** Tiempo hasta reset de requests (ms) */
|
||||||
|
resetRequests: number;
|
||||||
|
/** Tiempo hasta reset de tokens (ms) */
|
||||||
|
resetTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Financial Analysis Types (for AI Service)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexto para análisis financiero
|
||||||
|
*/
|
||||||
|
export interface FinancialAnalysisContext {
|
||||||
|
/** ID del tenant */
|
||||||
|
tenantId: string;
|
||||||
|
/** Nombre de la empresa */
|
||||||
|
companyName?: string;
|
||||||
|
/** Industria */
|
||||||
|
industry?: string;
|
||||||
|
/** Período del análisis */
|
||||||
|
period: {
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
type: 'monthly' | 'quarterly' | 'yearly';
|
||||||
|
};
|
||||||
|
/** Moneda */
|
||||||
|
currency: string;
|
||||||
|
/** Idioma preferido */
|
||||||
|
language: 'es' | 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Métricas financieras para análisis
|
||||||
|
*/
|
||||||
|
export interface FinancialMetricsInput {
|
||||||
|
/** Ingresos */
|
||||||
|
revenue: number;
|
||||||
|
/** Gastos */
|
||||||
|
expenses: number;
|
||||||
|
/** Utilidad neta */
|
||||||
|
netProfit: number;
|
||||||
|
/** Margen de utilidad */
|
||||||
|
profitMargin: number;
|
||||||
|
/** Flujo de caja */
|
||||||
|
cashFlow: number;
|
||||||
|
/** Cuentas por cobrar */
|
||||||
|
accountsReceivable: number;
|
||||||
|
/** Cuentas por pagar */
|
||||||
|
accountsPayable: number;
|
||||||
|
/** Comparación con período anterior */
|
||||||
|
previousPeriod?: {
|
||||||
|
revenue: number;
|
||||||
|
expenses: number;
|
||||||
|
netProfit: number;
|
||||||
|
cashFlow: number;
|
||||||
|
};
|
||||||
|
/** Métricas adicionales (startup/enterprise) */
|
||||||
|
additional?: {
|
||||||
|
mrr?: number;
|
||||||
|
arr?: number;
|
||||||
|
burnRate?: number;
|
||||||
|
runway?: number;
|
||||||
|
churnRate?: number;
|
||||||
|
ebitda?: number;
|
||||||
|
currentRatio?: number;
|
||||||
|
quickRatio?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tendencias financieras
|
||||||
|
*/
|
||||||
|
export interface FinancialTrends {
|
||||||
|
/** Tendencia de ingresos */
|
||||||
|
revenueTrend: TrendData[];
|
||||||
|
/** Tendencia de gastos */
|
||||||
|
expensesTrend: TrendData[];
|
||||||
|
/** Tendencia de utilidad */
|
||||||
|
profitTrend: TrendData[];
|
||||||
|
/** Tendencia de flujo de caja */
|
||||||
|
cashFlowTrend: TrendData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Punto de datos de tendencia
|
||||||
|
*/
|
||||||
|
export interface TrendData {
|
||||||
|
/** Período */
|
||||||
|
period: string;
|
||||||
|
/** Valor */
|
||||||
|
value: number;
|
||||||
|
/** Cambio porcentual */
|
||||||
|
changePercent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado de insight financiero
|
||||||
|
*/
|
||||||
|
export interface FinancialInsightResult {
|
||||||
|
/** Resumen ejecutivo */
|
||||||
|
summary: string;
|
||||||
|
/** Puntos clave */
|
||||||
|
keyPoints: string[];
|
||||||
|
/** Nivel de salud financiera */
|
||||||
|
healthScore: number;
|
||||||
|
/** Indicadores de alerta */
|
||||||
|
alerts: FinancialAlert[];
|
||||||
|
/** Tokens utilizados */
|
||||||
|
tokensUsed: number;
|
||||||
|
/** Tiempo de generación (ms) */
|
||||||
|
generationTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alerta financiera
|
||||||
|
*/
|
||||||
|
export interface FinancialAlert {
|
||||||
|
/** Tipo de alerta */
|
||||||
|
type: 'warning' | 'critical' | 'info';
|
||||||
|
/** Título */
|
||||||
|
title: string;
|
||||||
|
/** Descripción */
|
||||||
|
description: string;
|
||||||
|
/** Métrica afectada */
|
||||||
|
metric?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado de resumen ejecutivo
|
||||||
|
*/
|
||||||
|
export interface ExecutiveSummaryResult {
|
||||||
|
/** Título del resumen */
|
||||||
|
title: string;
|
||||||
|
/** Resumen general */
|
||||||
|
overview: string;
|
||||||
|
/** Logros del período */
|
||||||
|
achievements: string[];
|
||||||
|
/** Desafíos identificados */
|
||||||
|
challenges: string[];
|
||||||
|
/** Próximos pasos sugeridos */
|
||||||
|
nextSteps: string[];
|
||||||
|
/** Tokens utilizados */
|
||||||
|
tokensUsed: number;
|
||||||
|
/** Tiempo de generación (ms) */
|
||||||
|
generationTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado de recomendaciones
|
||||||
|
*/
|
||||||
|
export interface RecommendationsResult {
|
||||||
|
/** Recomendaciones prioritarias */
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
/** Oportunidades identificadas */
|
||||||
|
opportunities: string[];
|
||||||
|
/** Riesgos a mitigar */
|
||||||
|
risks: string[];
|
||||||
|
/** Tokens utilizados */
|
||||||
|
tokensUsed: number;
|
||||||
|
/** Tiempo de generación (ms) */
|
||||||
|
generationTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomendación individual
|
||||||
|
*/
|
||||||
|
export interface Recommendation {
|
||||||
|
/** Prioridad */
|
||||||
|
priority: 'high' | 'medium' | 'low';
|
||||||
|
/** Categoría */
|
||||||
|
category: 'cost_reduction' | 'revenue_growth' | 'cash_flow' | 'risk_management' | 'operational';
|
||||||
|
/** Título */
|
||||||
|
title: string;
|
||||||
|
/** Descripción detallada */
|
||||||
|
description: string;
|
||||||
|
/** Impacto esperado */
|
||||||
|
expectedImpact: string;
|
||||||
|
/** Plazo de implementación */
|
||||||
|
timeframe: 'immediate' | 'short_term' | 'medium_term' | 'long_term';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cache Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clave de cache para respuestas de AI
|
||||||
|
*/
|
||||||
|
export interface AICacheKey {
|
||||||
|
/** Tipo de operación */
|
||||||
|
operation: 'insight' | 'summary' | 'recommendations';
|
||||||
|
/** ID del tenant */
|
||||||
|
tenantId: string;
|
||||||
|
/** Hash del input */
|
||||||
|
inputHash: string;
|
||||||
|
/** Período */
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entrada de cache
|
||||||
|
*/
|
||||||
|
export interface AICacheEntry<T> {
|
||||||
|
/** Datos cacheados */
|
||||||
|
data: T;
|
||||||
|
/** Timestamp de creación */
|
||||||
|
createdAt: Date;
|
||||||
|
/** Timestamp de expiración */
|
||||||
|
expiresAt: Date;
|
||||||
|
/** Tokens utilizados */
|
||||||
|
tokensUsed: number;
|
||||||
|
}
|
||||||
25
apps/api/src/services/ai/index.ts
Normal file
25
apps/api/src/services/ai/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* AI Services Index
|
||||||
|
*
|
||||||
|
* Exporta todos los módulos relacionados con integración de AI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './deepseek.types.js';
|
||||||
|
|
||||||
|
// Client
|
||||||
|
export {
|
||||||
|
DeepSeekClient,
|
||||||
|
DeepSeekError,
|
||||||
|
DeepSeekRateLimitError,
|
||||||
|
getDeepSeekClient,
|
||||||
|
createDeepSeekClient,
|
||||||
|
} from './deepseek.client.js';
|
||||||
|
|
||||||
|
// Service
|
||||||
|
export {
|
||||||
|
AIService,
|
||||||
|
AIServiceOptions,
|
||||||
|
getAIService,
|
||||||
|
createAIService,
|
||||||
|
} from './ai.service.js';
|
||||||
@@ -15,3 +15,12 @@ export * from './metrics/metrics.cache.js';
|
|||||||
export * from './sat/sat.types.js';
|
export * from './sat/sat.types.js';
|
||||||
export * from './sat/cfdi.parser.js';
|
export * from './sat/cfdi.parser.js';
|
||||||
export * from './sat/fiel.service.js';
|
export * from './sat/fiel.service.js';
|
||||||
|
|
||||||
|
// AI
|
||||||
|
export * from './ai/index.js';
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
export * from './reports/index.js';
|
||||||
|
|
||||||
|
// Integrations
|
||||||
|
export * from './integrations/index.js';
|
||||||
|
|||||||
457
apps/api/src/services/integrations/alegra/alegra.client.ts
Normal file
457
apps/api/src/services/integrations/alegra/alegra.client.ts
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
/**
|
||||||
|
* Alegra REST API Client
|
||||||
|
* Cliente HTTP para comunicarse con el API de Alegra
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlegraConfig,
|
||||||
|
AlegraError,
|
||||||
|
AlegraRateLimitError,
|
||||||
|
AlegraAuthError,
|
||||||
|
AlegraErrorCode,
|
||||||
|
AlegraPaginationParams,
|
||||||
|
} from './alegra.types.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = 'https://api.alegra.com/api/v1';
|
||||||
|
const DEFAULT_TIMEOUT = 30000;
|
||||||
|
const DEFAULT_MAX_RETRIES = 3;
|
||||||
|
const RATE_LIMIT_DELAY = 1000; // 1 segundo entre requests
|
||||||
|
const MAX_REQUESTS_PER_MINUTE = 60; // Limite conservador
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Rate Limiter
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple rate limiter para respetar los limites de Alegra
|
||||||
|
*/
|
||||||
|
class RateLimiter {
|
||||||
|
private tokens: number;
|
||||||
|
private lastRefill: number;
|
||||||
|
private readonly maxTokens: number;
|
||||||
|
private readonly refillRate: number; // tokens por segundo
|
||||||
|
|
||||||
|
constructor(maxTokens: number = MAX_REQUESTS_PER_MINUTE, refillRate: number = 1) {
|
||||||
|
this.maxTokens = maxTokens;
|
||||||
|
this.tokens = maxTokens;
|
||||||
|
this.lastRefill = Date.now();
|
||||||
|
this.refillRate = refillRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquire(): Promise<void> {
|
||||||
|
this.refill();
|
||||||
|
|
||||||
|
if (this.tokens < 1) {
|
||||||
|
const waitTime = Math.ceil((1 - this.tokens) / this.refillRate) * 1000;
|
||||||
|
await this.sleep(waitTime);
|
||||||
|
this.refill();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tokens -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refill(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = (now - this.lastRefill) / 1000;
|
||||||
|
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
|
||||||
|
this.lastRefill = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Alegra Client
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente REST para el API de Alegra
|
||||||
|
*/
|
||||||
|
export class AlegraClient {
|
||||||
|
private readonly config: Required<Pick<AlegraConfig, 'email' | 'token' | 'baseUrl' | 'timeout' | 'maxRetries'>> & Partial<AlegraConfig>;
|
||||||
|
private readonly authHeader: string;
|
||||||
|
private readonly rateLimiter: RateLimiter;
|
||||||
|
|
||||||
|
constructor(config: AlegraConfig) {
|
||||||
|
this.config = {
|
||||||
|
...config,
|
||||||
|
baseUrl: config.baseUrl || DEFAULT_BASE_URL,
|
||||||
|
timeout: config.timeout || DEFAULT_TIMEOUT,
|
||||||
|
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crear header de autenticacion Basic
|
||||||
|
const credentials = Buffer.from(`${this.config.email}:${this.config.token}`).toString('base64');
|
||||||
|
this.authHeader = `Basic ${credentials}`;
|
||||||
|
|
||||||
|
// Inicializar rate limiter
|
||||||
|
this.rateLimiter = new RateLimiter();
|
||||||
|
|
||||||
|
logger.info('AlegraClient initialized', { baseUrl: this.config.baseUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core HTTP Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion GET
|
||||||
|
*/
|
||||||
|
async get<T>(endpoint: string, params?: Record<string, unknown>): Promise<T> {
|
||||||
|
return this.request<T>('GET', endpoint, undefined, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion POST
|
||||||
|
*/
|
||||||
|
async post<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||||
|
return this.request<T>('POST', endpoint, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion PUT
|
||||||
|
*/
|
||||||
|
async put<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||||
|
return this.request<T>('PUT', endpoint, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion DELETE
|
||||||
|
*/
|
||||||
|
async delete<T>(endpoint: string): Promise<T> {
|
||||||
|
return this.request<T>('DELETE', endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pagination Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los resultados de un endpoint paginado
|
||||||
|
*/
|
||||||
|
async getAllPaginated<T>(
|
||||||
|
endpoint: string,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
maxPages: number = 100
|
||||||
|
): Promise<T[]> {
|
||||||
|
const allResults: T[] = [];
|
||||||
|
let start = 0;
|
||||||
|
const limit = 30; // Maximo permitido por Alegra
|
||||||
|
let hasMore = true;
|
||||||
|
let pageCount = 0;
|
||||||
|
|
||||||
|
while (hasMore && pageCount < maxPages) {
|
||||||
|
const response = await this.get<T[]>(endpoint, {
|
||||||
|
...params,
|
||||||
|
start,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
allResults.push(...response);
|
||||||
|
hasMore = response.length === limit;
|
||||||
|
start += limit;
|
||||||
|
pageCount++;
|
||||||
|
} else {
|
||||||
|
hasMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resultados paginados con metadata
|
||||||
|
*/
|
||||||
|
async getPaginated<T>(
|
||||||
|
endpoint: string,
|
||||||
|
pagination: AlegraPaginationParams = {},
|
||||||
|
filters?: Record<string, unknown>
|
||||||
|
): Promise<{ data: T[]; total: number; hasMore: boolean }> {
|
||||||
|
const { start = 0, limit = 30, orderField, order } = pagination;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
...filters,
|
||||||
|
start,
|
||||||
|
limit: Math.min(limit, 30),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (orderField) {
|
||||||
|
params.orderField = orderField;
|
||||||
|
params.order = order || 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.get<T[]>(endpoint, params);
|
||||||
|
const data = Array.isArray(response) ? response : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: data.length, // Alegra no devuelve total, estimamos
|
||||||
|
hasMore: data.length === params.limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion HTTP con reintentos y rate limiting
|
||||||
|
*/
|
||||||
|
private async request<T>(
|
||||||
|
method: string,
|
||||||
|
endpoint: string,
|
||||||
|
data?: unknown,
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
): Promise<T> {
|
||||||
|
// Aplicar rate limiting
|
||||||
|
await this.rateLimiter.acquire();
|
||||||
|
|
||||||
|
const url = this.buildUrl(endpoint, params);
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await this.executeRequest<T>(method, url, data);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
if (error instanceof AlegraRateLimitError) {
|
||||||
|
// Esperar antes de reintentar en caso de rate limit
|
||||||
|
const waitTime = error.retryAfter * 1000 || RATE_LIMIT_DELAY * (attempt + 1);
|
||||||
|
logger.warn('Rate limit hit, waiting before retry', {
|
||||||
|
attempt,
|
||||||
|
waitTime,
|
||||||
|
endpoint
|
||||||
|
});
|
||||||
|
await this.sleep(waitTime);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof AlegraAuthError) {
|
||||||
|
// No reintentar errores de autenticacion
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldRetry(error) && attempt < this.config.maxRetries) {
|
||||||
|
const delay = this.calculateBackoff(attempt);
|
||||||
|
logger.warn('Request failed, retrying', {
|
||||||
|
attempt,
|
||||||
|
delay,
|
||||||
|
endpoint,
|
||||||
|
error: (error as Error).message
|
||||||
|
});
|
||||||
|
await this.sleep(delay);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new AlegraError('Max retries exceeded', 'NETWORK_ERROR');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta la peticion HTTP usando fetch nativo
|
||||||
|
*/
|
||||||
|
private async executeRequest<T>(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
data?: unknown
|
||||||
|
): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data && (method === 'POST' || method === 'PUT')) {
|
||||||
|
options.body = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Alegra API request', { method, url });
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
// Manejar errores HTTP
|
||||||
|
if (!response.ok) {
|
||||||
|
await this.handleErrorResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsear respuesta
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {} as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maneja respuestas de error
|
||||||
|
*/
|
||||||
|
private async handleErrorResponse(response: Response): Promise<never> {
|
||||||
|
let errorData: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
errorData = await response.json();
|
||||||
|
} catch {
|
||||||
|
// No se pudo parsear el error
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = (errorData.message as string) ||
|
||||||
|
(errorData.error as string) ||
|
||||||
|
`HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
|
||||||
|
switch (response.status) {
|
||||||
|
case 401:
|
||||||
|
throw new AlegraAuthError(message);
|
||||||
|
|
||||||
|
case 403:
|
||||||
|
throw new AlegraError(message, 'FORBIDDEN', 403, errorData);
|
||||||
|
|
||||||
|
case 404:
|
||||||
|
throw new AlegraError(message, 'NOT_FOUND', 404, errorData);
|
||||||
|
|
||||||
|
case 400:
|
||||||
|
case 422:
|
||||||
|
throw new AlegraError(message, 'VALIDATION_ERROR', response.status, errorData);
|
||||||
|
|
||||||
|
case 429:
|
||||||
|
const retryAfter = parseInt(response.headers.get('Retry-After') || '60', 10);
|
||||||
|
throw new AlegraRateLimitError(retryAfter, message);
|
||||||
|
|
||||||
|
case 500:
|
||||||
|
case 502:
|
||||||
|
case 503:
|
||||||
|
const code: AlegraErrorCode = response.status === 503 ? 'SERVICE_UNAVAILABLE' : 'INTERNAL_ERROR';
|
||||||
|
throw new AlegraError(message, code, response.status, errorData);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new AlegraError(message, 'INTERNAL_ERROR', response.status, errorData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye la URL con parametros
|
||||||
|
*/
|
||||||
|
private buildUrl(endpoint: string, params?: Record<string, unknown>): string {
|
||||||
|
const baseUrl = this.config.baseUrl.replace(/\/$/, '');
|
||||||
|
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||||
|
const url = new URL(`${baseUrl}${path}`);
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
url.searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determina si se debe reintentar basado en el error
|
||||||
|
*/
|
||||||
|
private shouldRetry(error: unknown): boolean {
|
||||||
|
if (error instanceof AlegraError) {
|
||||||
|
return ['NETWORK_ERROR', 'TIMEOUT', 'INTERNAL_ERROR', 'SERVICE_UNAVAILABLE'].includes(error.code);
|
||||||
|
}
|
||||||
|
return error instanceof Error && error.name === 'AbortError';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el delay de backoff exponencial
|
||||||
|
*/
|
||||||
|
private calculateBackoff(attempt: number): number {
|
||||||
|
const baseDelay = RATE_LIMIT_DELAY;
|
||||||
|
const maxDelay = 30000; // 30 segundos max
|
||||||
|
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
|
||||||
|
// Agregar jitter
|
||||||
|
return delay + Math.random() * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Espera un tiempo determinado
|
||||||
|
*/
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si las credenciales son validas
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Intentar obtener la lista de impuestos (endpoint ligero)
|
||||||
|
await this.get('/taxes', { limit: 1 });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AlegraAuthError) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene informacion de la compania
|
||||||
|
*/
|
||||||
|
async getCompanyInfo(): Promise<Record<string, unknown>> {
|
||||||
|
return this.get('/company');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el pais configurado
|
||||||
|
*/
|
||||||
|
getCountry(): string | undefined {
|
||||||
|
return this.config.country;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si es una cuenta de Mexico
|
||||||
|
*/
|
||||||
|
isMexicoAccount(): boolean {
|
||||||
|
return this.config.country === 'MX';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del cliente de Alegra
|
||||||
|
*/
|
||||||
|
export function createAlegraClient(config: AlegraConfig): AlegraClient {
|
||||||
|
return new AlegraClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlegraClient;
|
||||||
620
apps/api/src/services/integrations/alegra/alegra.schema.ts
Normal file
620
apps/api/src/services/integrations/alegra/alegra.schema.ts
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
/**
|
||||||
|
* Alegra Validation Schemas
|
||||||
|
* Schemas Zod para validacion de datos de Alegra
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Configuration Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para configuracion de Alegra
|
||||||
|
*/
|
||||||
|
export const AlegraConfigSchema = z.object({
|
||||||
|
email: z.string().email('Email invalido'),
|
||||||
|
token: z.string().min(1, 'Token es requerido'),
|
||||||
|
baseUrl: z.string().url().optional().default('https://api.alegra.com/api/v1'),
|
||||||
|
timeout: z.number().int().positive().optional().default(30000),
|
||||||
|
maxRetries: z.number().int().min(0).max(10).optional().default(3),
|
||||||
|
country: z.enum(['MX', 'CO', 'PE', 'AR', 'CL', 'ES', 'PA', 'DO', 'CR', 'EC']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AlegraConfigInput = z.infer<typeof AlegraConfigSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pagination & Filter Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de paginacion
|
||||||
|
*/
|
||||||
|
export const PaginationSchema = z.object({
|
||||||
|
start: z.number().int().min(0).optional().default(0),
|
||||||
|
limit: z.number().int().min(1).max(30).optional().default(30),
|
||||||
|
orderField: z.string().optional(),
|
||||||
|
order: z.enum(['ASC', 'DESC']).optional().default('DESC'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de filtro por fecha
|
||||||
|
*/
|
||||||
|
export const DateFilterSchema = z.object({
|
||||||
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato de fecha invalido (YYYY-MM-DD)').optional(),
|
||||||
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato de fecha invalido (YYYY-MM-DD)').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de periodo
|
||||||
|
*/
|
||||||
|
export const PeriodSchema = z.object({
|
||||||
|
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato de fecha invalido (YYYY-MM-DD)'),
|
||||||
|
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato de fecha invalido (YYYY-MM-DD)'),
|
||||||
|
}).refine(
|
||||||
|
(data) => new Date(data.from) <= new Date(data.to),
|
||||||
|
{ message: 'La fecha inicial debe ser menor o igual a la fecha final' }
|
||||||
|
);
|
||||||
|
|
||||||
|
export type PaginationInput = z.infer<typeof PaginationSchema>;
|
||||||
|
export type DateFilterInput = z.infer<typeof DateFilterSchema>;
|
||||||
|
export type PeriodInput = z.infer<typeof PeriodSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Contact Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de identificacion
|
||||||
|
*/
|
||||||
|
export const IdentificationTypeSchema = z.enum([
|
||||||
|
'RFC', 'CURP', 'NIT', 'CC', 'CE', 'PASS', 'DIE', 'TI', 'OTHER',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de contacto
|
||||||
|
*/
|
||||||
|
export const ContactTypeSchema = z.enum(['client', 'provider', 'client,provider']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de direccion
|
||||||
|
*/
|
||||||
|
export const AddressSchema = z.object({
|
||||||
|
address: z.string().max(500).optional(),
|
||||||
|
city: z.string().max(100).optional(),
|
||||||
|
department: z.string().max(100).optional(),
|
||||||
|
country: z.string().max(100).optional(),
|
||||||
|
zipCode: z.string().max(20).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de contacto interno
|
||||||
|
*/
|
||||||
|
export const InternalContactSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
phone: z.string().max(50).optional(),
|
||||||
|
sendNotifications: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de contacto (creacion/actualizacion)
|
||||||
|
*/
|
||||||
|
export const ContactInputSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Nombre es requerido').max(300),
|
||||||
|
identification: z.string().max(50).optional(),
|
||||||
|
identificationType: IdentificationTypeSchema.optional(),
|
||||||
|
email: z.string().email('Email invalido').optional(),
|
||||||
|
phonePrimary: z.string().max(50).optional(),
|
||||||
|
phoneSecondary: z.string().max(50).optional(),
|
||||||
|
fax: z.string().max(50).optional(),
|
||||||
|
mobile: z.string().max(50).optional(),
|
||||||
|
address: AddressSchema.optional(),
|
||||||
|
type: z.array(ContactTypeSchema).min(1),
|
||||||
|
seller: z.object({ id: z.number() }).optional(),
|
||||||
|
term: z.object({ id: z.number() }).optional(),
|
||||||
|
priceList: z.object({ id: z.number() }).optional(),
|
||||||
|
internalContacts: z.array(InternalContactSchema).optional(),
|
||||||
|
statementAttached: z.boolean().optional(),
|
||||||
|
observations: z.string().max(1000).optional(),
|
||||||
|
creditLimit: z.number().min(0).optional(),
|
||||||
|
ignoreRepeated: z.boolean().optional(),
|
||||||
|
kindOfPerson: z.enum(['PERSON_ENTITY', 'LEGAL_ENTITY']).optional(),
|
||||||
|
// Mexico
|
||||||
|
regimen: z.string().optional(),
|
||||||
|
cfdiUse: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de filtro de contactos
|
||||||
|
*/
|
||||||
|
export const ContactFilterSchema = PaginationSchema.extend({
|
||||||
|
type: ContactTypeSchema.optional(),
|
||||||
|
query: z.string().optional(),
|
||||||
|
status: z.enum(['active', 'inactive']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ContactInput = z.infer<typeof ContactInputSchema>;
|
||||||
|
export type ContactFilter = z.infer<typeof ContactFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invoice Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estados de factura
|
||||||
|
*/
|
||||||
|
export const InvoiceStatusSchema = z.enum(['draft', 'open', 'paid', 'void', 'overdue']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de item de factura
|
||||||
|
*/
|
||||||
|
export const InvoiceItemSchema = z.object({
|
||||||
|
id: z.number().optional(),
|
||||||
|
name: z.string().min(1).max(500),
|
||||||
|
description: z.string().max(1000).optional(),
|
||||||
|
price: z.number().min(0),
|
||||||
|
discount: z.number().min(0).max(100).optional(),
|
||||||
|
quantity: z.number().min(0.01),
|
||||||
|
unit: z.string().max(50).optional(),
|
||||||
|
tax: z.array(z.object({ id: z.number() })).optional(),
|
||||||
|
reference: z.string().max(100).optional(),
|
||||||
|
warehouse: z.object({ id: z.number() }).optional(),
|
||||||
|
productKey: z.string().max(20).optional(),
|
||||||
|
unitKey: z.string().max(20).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de pago en factura
|
||||||
|
*/
|
||||||
|
export const InvoicePaymentSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de factura (creacion)
|
||||||
|
*/
|
||||||
|
export const InvoiceInputSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
|
||||||
|
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
|
||||||
|
client: z.union([
|
||||||
|
z.object({ id: z.number() }),
|
||||||
|
z.object({ name: z.string() }),
|
||||||
|
]),
|
||||||
|
items: z.array(InvoiceItemSchema).min(1, 'Se requiere al menos un item'),
|
||||||
|
numberTemplate: z.object({ id: z.number() }).optional(),
|
||||||
|
costCenter: z.object({ id: z.number() }).optional(),
|
||||||
|
seller: z.object({ id: z.number() }).optional(),
|
||||||
|
currency: z.object({ code: z.string() }).optional(),
|
||||||
|
exchangeRate: z.number().positive().optional(),
|
||||||
|
observations: z.string().max(2000).optional(),
|
||||||
|
anotation: z.string().max(2000).optional(),
|
||||||
|
termsConditions: z.string().max(2000).optional(),
|
||||||
|
status: InvoiceStatusSchema.optional(),
|
||||||
|
payments: z.array(InvoicePaymentSchema).optional(),
|
||||||
|
priceList: z.object({ id: z.number() }).optional(),
|
||||||
|
warehouse: z.object({ id: z.number() }).optional(),
|
||||||
|
// Mexico CFDI
|
||||||
|
cfdiUse: z.string().optional(),
|
||||||
|
paymentForm: z.string().optional(),
|
||||||
|
paymentMethod: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de filtro de facturas
|
||||||
|
*/
|
||||||
|
export const InvoiceFilterSchema = PaginationSchema.extend({
|
||||||
|
...DateFilterSchema.shape,
|
||||||
|
status: InvoiceStatusSchema.optional(),
|
||||||
|
clientId: z.number().optional(),
|
||||||
|
numberTemplateId: z.number().optional(),
|
||||||
|
query: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InvoiceInput = z.infer<typeof InvoiceInputSchema>;
|
||||||
|
export type InvoiceFilter = z.infer<typeof InvoiceFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Credit Note Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de nota de credito (creacion)
|
||||||
|
*/
|
||||||
|
export const CreditNoteInputSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
|
||||||
|
client: z.union([
|
||||||
|
z.object({ id: z.number() }),
|
||||||
|
z.object({ name: z.string() }),
|
||||||
|
]),
|
||||||
|
items: z.array(InvoiceItemSchema).min(1, 'Se requiere al menos un item'),
|
||||||
|
numberTemplate: z.object({ id: z.number() }).optional(),
|
||||||
|
costCenter: z.object({ id: z.number() }).optional(),
|
||||||
|
currency: z.object({ code: z.string() }).optional(),
|
||||||
|
observations: z.string().max(2000).optional(),
|
||||||
|
relatedInvoices: z.array(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
amount: z.number().positive().optional(),
|
||||||
|
})).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de filtro de notas de credito
|
||||||
|
*/
|
||||||
|
export const CreditNoteFilterSchema = PaginationSchema.extend({
|
||||||
|
...DateFilterSchema.shape,
|
||||||
|
status: z.enum(['draft', 'open', 'closed', 'void']).optional(),
|
||||||
|
clientId: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreditNoteInput = z.infer<typeof CreditNoteInputSchema>;
|
||||||
|
export type CreditNoteFilter = z.infer<typeof CreditNoteFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Debit Note Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de nota de debito (creacion)
|
||||||
|
*/
|
||||||
|
export const DebitNoteInputSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
|
||||||
|
client: z.union([
|
||||||
|
z.object({ id: z.number() }),
|
||||||
|
z.object({ name: z.string() }),
|
||||||
|
]),
|
||||||
|
items: z.array(InvoiceItemSchema).min(1, 'Se requiere al menos un item'),
|
||||||
|
numberTemplate: z.object({ id: z.number() }).optional(),
|
||||||
|
costCenter: z.object({ id: z.number() }).optional(),
|
||||||
|
currency: z.object({ code: z.string() }).optional(),
|
||||||
|
observations: z.string().max(2000).optional(),
|
||||||
|
relatedInvoices: z.array(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
amount: z.number().positive().optional(),
|
||||||
|
})).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de filtro de notas de debito
|
||||||
|
*/
|
||||||
|
export const DebitNoteFilterSchema = PaginationSchema.extend({
|
||||||
|
...DateFilterSchema.shape,
|
||||||
|
status: z.enum(['draft', 'open', 'closed', 'void']).optional(),
|
||||||
|
clientId: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DebitNoteInput = z.infer<typeof DebitNoteInputSchema>;
|
||||||
|
export type DebitNoteFilter = z.infer<typeof DebitNoteFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Payment Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metodos de pago
|
||||||
|
*/
|
||||||
|
export const PaymentMethodSchema = z.enum([
|
||||||
|
'cash', 'debit-card', 'credit-card', 'transfer', 'check',
|
||||||
|
'deposit', 'electronic-money', 'consignment', 'other',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de pago recibido (creacion)
|
||||||
|
*/
|
||||||
|
export const PaymentReceivedInputSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
|
||||||
|
amount: z.number().positive('El monto debe ser positivo'),
|
||||||
|
client: z.object({ id: z.number() }),
|
||||||
|
bankAccount: z.object({ id: z.number() }).optional(),
|
||||||
|
paymentMethod: PaymentMethodSchema.optional(),
|
||||||
|
observations: z.string().max(1000).optional(),
|
||||||
|
invoices: z.array(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
})).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de pago realizado (creacion)
|
||||||
|
*/
|
||||||
|
export const PaymentMadeInputSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
|
||||||
|
amount: z.number().positive('El monto debe ser positivo'),
|
||||||
|
provider: z.object({ id: z.number() }),
|
||||||
|
bankAccount: z.object({ id: z.number() }).optional(),
|
||||||
|
paymentMethod: PaymentMethodSchema.optional(),
|
||||||
|
observations: z.string().max(1000).optional(),
|
||||||
|
bills: z.array(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
})).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de filtro de pagos
|
||||||
|
*/
|
||||||
|
export const PaymentFilterSchema = PaginationSchema.extend({
|
||||||
|
...DateFilterSchema.shape,
|
||||||
|
bankAccountId: z.number().optional(),
|
||||||
|
contactId: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PaymentReceivedInput = z.infer<typeof PaymentReceivedInputSchema>;
|
||||||
|
export type PaymentMadeInput = z.infer<typeof PaymentMadeInputSchema>;
|
||||||
|
export type PaymentFilter = z.infer<typeof PaymentFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bank Account Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de cuenta bancaria
|
||||||
|
*/
|
||||||
|
export const BankAccountTypeSchema = z.enum(['bank', 'credit-card', 'cash', 'other']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de cuenta bancaria (creacion)
|
||||||
|
*/
|
||||||
|
export const BankAccountInputSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
number: z.string().max(50).optional(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
type: BankAccountTypeSchema,
|
||||||
|
initialBalance: z.number().optional().default(0),
|
||||||
|
initialBalanceDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
currency: z.object({ code: z.string() }).optional(),
|
||||||
|
bank: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
code: z.string().optional(),
|
||||||
|
}).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de filtro de cuentas bancarias
|
||||||
|
*/
|
||||||
|
export const BankAccountFilterSchema = PaginationSchema.extend({
|
||||||
|
type: BankAccountTypeSchema.optional(),
|
||||||
|
status: z.enum(['active', 'inactive']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de filtro de transacciones bancarias
|
||||||
|
*/
|
||||||
|
export const BankTransactionFilterSchema = PaginationSchema.extend({
|
||||||
|
...DateFilterSchema.shape,
|
||||||
|
type: z.enum([
|
||||||
|
'deposit', 'withdrawal', 'transfer', 'payment-in',
|
||||||
|
'payment-out', 'fee', 'interest', 'other',
|
||||||
|
]).optional(),
|
||||||
|
reconciled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BankAccountInput = z.infer<typeof BankAccountInputSchema>;
|
||||||
|
export type BankAccountFilter = z.infer<typeof BankAccountFilterSchema>;
|
||||||
|
export type BankTransactionFilter = z.infer<typeof BankTransactionFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Item Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de item
|
||||||
|
*/
|
||||||
|
export const ItemTypeSchema = z.enum(['product', 'service', 'kit']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de precio de item
|
||||||
|
*/
|
||||||
|
export const ItemPriceSchema = z.object({
|
||||||
|
idPriceList: z.number(),
|
||||||
|
price: z.number().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de inventario de item
|
||||||
|
*/
|
||||||
|
export const ItemInventorySchema = z.object({
|
||||||
|
unit: z.string().max(50).optional(),
|
||||||
|
unitCost: z.number().min(0).optional(),
|
||||||
|
initialQuantity: z.number().min(0).optional(),
|
||||||
|
minQuantity: z.number().min(0).optional(),
|
||||||
|
maxQuantity: z.number().min(0).optional(),
|
||||||
|
warehouses: z.array(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
initialQuantity: z.number().optional(),
|
||||||
|
})).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de item (creacion)
|
||||||
|
*/
|
||||||
|
export const ItemInputSchema = z.object({
|
||||||
|
name: z.string().min(1).max(500),
|
||||||
|
description: z.string().max(1000).optional(),
|
||||||
|
reference: z.string().max(100).optional(),
|
||||||
|
price: z.array(ItemPriceSchema).min(1),
|
||||||
|
tax: z.array(z.object({ id: z.number() })).optional(),
|
||||||
|
category: z.object({ id: z.number() }).optional(),
|
||||||
|
inventory: ItemInventorySchema.optional(),
|
||||||
|
type: ItemTypeSchema,
|
||||||
|
productKey: z.string().max(20).optional(),
|
||||||
|
unitKey: z.string().max(20).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de filtro de items
|
||||||
|
*/
|
||||||
|
export const ItemFilterSchema = PaginationSchema.extend({
|
||||||
|
type: ItemTypeSchema.optional(),
|
||||||
|
categoryId: z.number().optional(),
|
||||||
|
query: z.string().optional(),
|
||||||
|
status: z.enum(['active', 'inactive']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ItemInput = z.infer<typeof ItemInputSchema>;
|
||||||
|
export type ItemFilter = z.infer<typeof ItemFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Category & Cost Center Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de categoria
|
||||||
|
*/
|
||||||
|
export const CategoryInputSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
type: z.enum(['income', 'expense', 'cost', 'other']).optional(),
|
||||||
|
parent: z.object({ id: z.number() }).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de centro de costo
|
||||||
|
*/
|
||||||
|
export const CostCenterInputSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
code: z.string().max(50).optional(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CategoryInput = z.infer<typeof CategoryInputSchema>;
|
||||||
|
export type CostCenterInput = z.infer<typeof CostCenterInputSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tax Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de impuesto
|
||||||
|
*/
|
||||||
|
export const TaxInputSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
percentage: z.number().min(0).max(100),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
type: z.enum(['IVA', 'ISR', 'IEPS', 'ICA', 'RETENTION', 'OTHER']),
|
||||||
|
satTaxCode: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TaxInput = z.infer<typeof TaxInputSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de solicitud de balance de comprobacion
|
||||||
|
*/
|
||||||
|
export const TrialBalanceRequestSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
|
||||||
|
level: z.number().int().min(1).max(10).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de solicitud de estado de resultados
|
||||||
|
*/
|
||||||
|
export const ProfitAndLossRequestSchema = PeriodSchema.extend({
|
||||||
|
costCenterId: z.number().optional(),
|
||||||
|
comparePreviousPeriod: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de solicitud de balance general
|
||||||
|
*/
|
||||||
|
export const BalanceSheetRequestSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
|
||||||
|
comparePreviousYear: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de solicitud de flujo de efectivo
|
||||||
|
*/
|
||||||
|
export const CashFlowRequestSchema = PeriodSchema.extend({
|
||||||
|
method: z.enum(['direct', 'indirect']).optional().default('indirect'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de solicitud de reporte de impuestos
|
||||||
|
*/
|
||||||
|
export const TaxReportRequestSchema = PeriodSchema.extend({
|
||||||
|
taxType: z.enum(['IVA', 'ISR', 'IEPS', 'ALL']).optional().default('ALL'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TrialBalanceRequest = z.infer<typeof TrialBalanceRequestSchema>;
|
||||||
|
export type ProfitAndLossRequest = z.infer<typeof ProfitAndLossRequestSchema>;
|
||||||
|
export type BalanceSheetRequest = z.infer<typeof BalanceSheetRequestSchema>;
|
||||||
|
export type CashFlowRequest = z.infer<typeof CashFlowRequestSchema>;
|
||||||
|
export type TaxReportRequest = z.infer<typeof TaxReportRequestSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Webhook Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eventos de webhook
|
||||||
|
*/
|
||||||
|
export const WebhookEventSchema = z.enum([
|
||||||
|
'invoice.created', 'invoice.updated', 'invoice.deleted', 'invoice.voided', 'invoice.paid',
|
||||||
|
'contact.created', 'contact.updated', 'contact.deleted',
|
||||||
|
'payment.created', 'payment.updated', 'payment.deleted',
|
||||||
|
'item.created', 'item.updated', 'item.deleted',
|
||||||
|
'credit-note.created', 'credit-note.updated',
|
||||||
|
'debit-note.created', 'debit-note.updated',
|
||||||
|
'bill.created', 'bill.updated', 'bill.paid',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de subscripcion de webhook
|
||||||
|
*/
|
||||||
|
export const WebhookSubscriptionInputSchema = z.object({
|
||||||
|
url: z.string().url('URL invalida'),
|
||||||
|
events: z.array(WebhookEventSchema).min(1, 'Se requiere al menos un evento'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WebhookSubscriptionInput = z.infer<typeof WebhookSubscriptionInputSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sync Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema de configuracion de sincronizacion
|
||||||
|
*/
|
||||||
|
export const SyncConfigSchema = z.object({
|
||||||
|
tenantId: z.string().uuid('Tenant ID invalido'),
|
||||||
|
alegraConfig: AlegraConfigSchema,
|
||||||
|
syncInvoices: z.boolean().optional().default(true),
|
||||||
|
syncContacts: z.boolean().optional().default(true),
|
||||||
|
syncPayments: z.boolean().optional().default(true),
|
||||||
|
syncCreditNotes: z.boolean().optional().default(true),
|
||||||
|
syncItems: z.boolean().optional().default(false),
|
||||||
|
incrementalSync: z.boolean().optional().default(true),
|
||||||
|
period: PeriodSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SyncConfig = z.infer<typeof SyncConfigSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida la configuracion de Alegra
|
||||||
|
*/
|
||||||
|
export function validateAlegraConfig(config: unknown): AlegraConfigInput {
|
||||||
|
return AlegraConfigSchema.parse(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida un periodo de fechas
|
||||||
|
*/
|
||||||
|
export function validatePeriod(period: unknown): PeriodInput {
|
||||||
|
return PeriodSchema.parse(period);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida paginacion
|
||||||
|
*/
|
||||||
|
export function validatePagination(params: unknown): PaginationInput {
|
||||||
|
return PaginationSchema.parse(params);
|
||||||
|
}
|
||||||
757
apps/api/src/services/integrations/alegra/alegra.sync.ts
Normal file
757
apps/api/src/services/integrations/alegra/alegra.sync.ts
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
/**
|
||||||
|
* Alegra Sync Service
|
||||||
|
* Servicio de sincronizacion de datos entre Alegra y Horux Strategy
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AlegraClient, createAlegraClient } from './alegra.client.js';
|
||||||
|
import { AlegraInvoicesConnector, createInvoicesConnector } from './invoices.connector.js';
|
||||||
|
import { AlegraContactsConnector, createContactsConnector } from './contacts.connector.js';
|
||||||
|
import { AlegraPaymentsConnector, createPaymentsConnector } from './payments.connector.js';
|
||||||
|
import {
|
||||||
|
AlegraConfig,
|
||||||
|
AlegraInvoice,
|
||||||
|
AlegraContact,
|
||||||
|
AlegraPaymentReceived,
|
||||||
|
AlegraCreditNote,
|
||||||
|
AlegraSyncResult,
|
||||||
|
AlegraSyncState,
|
||||||
|
AlegraWebhookEvent,
|
||||||
|
AlegraWebhookPayload,
|
||||||
|
AlegraWebhookSubscription,
|
||||||
|
} from './alegra.types.js';
|
||||||
|
import { SyncConfig, SyncConfigSchema } from './alegra.schema.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types for Horux Integration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaccion de Horux Strategy
|
||||||
|
*/
|
||||||
|
interface HoruxTransaction {
|
||||||
|
id?: string;
|
||||||
|
tenantId: string;
|
||||||
|
externalId: string;
|
||||||
|
externalSource: 'alegra';
|
||||||
|
type: 'invoice' | 'credit_note' | 'payment_received' | 'payment_made';
|
||||||
|
date: Date;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
contactId?: string;
|
||||||
|
contactName?: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
syncedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contacto de Horux Strategy
|
||||||
|
*/
|
||||||
|
interface HoruxContact {
|
||||||
|
id?: string;
|
||||||
|
tenantId: string;
|
||||||
|
externalId: string;
|
||||||
|
externalSource: 'alegra';
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
identification?: string;
|
||||||
|
type: 'customer' | 'vendor';
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
syncedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sync Service Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio de sincronizacion con Alegra
|
||||||
|
*/
|
||||||
|
export class AlegraSyncService {
|
||||||
|
private client: AlegraClient;
|
||||||
|
private invoicesConnector: AlegraInvoicesConnector;
|
||||||
|
private contactsConnector: AlegraContactsConnector;
|
||||||
|
private paymentsConnector: AlegraPaymentsConnector;
|
||||||
|
|
||||||
|
constructor(config: AlegraConfig) {
|
||||||
|
this.client = createAlegraClient(config);
|
||||||
|
this.invoicesConnector = createInvoicesConnector(this.client);
|
||||||
|
this.contactsConnector = createContactsConnector(this.client);
|
||||||
|
this.paymentsConnector = createPaymentsConnector(this.client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Sync Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza todos los datos de Alegra a Horux
|
||||||
|
*/
|
||||||
|
async syncToHorux(
|
||||||
|
tenantId: string,
|
||||||
|
config: SyncConfig,
|
||||||
|
callbacks?: {
|
||||||
|
onProgress?: (progress: { type: string; current: number; total: number }) => void;
|
||||||
|
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
|
||||||
|
onContact?: (contact: HoruxContact) => Promise<void>;
|
||||||
|
}
|
||||||
|
): Promise<AlegraSyncResult> {
|
||||||
|
const startedAt = new Date();
|
||||||
|
const validatedConfig = SyncConfigSchema.parse(config);
|
||||||
|
|
||||||
|
logger.info('Starting Alegra sync', {
|
||||||
|
tenantId,
|
||||||
|
syncInvoices: validatedConfig.syncInvoices,
|
||||||
|
syncContacts: validatedConfig.syncContacts,
|
||||||
|
syncPayments: validatedConfig.syncPayments,
|
||||||
|
incrementalSync: validatedConfig.incrementalSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: AlegraSyncResult = {
|
||||||
|
success: true,
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date(),
|
||||||
|
stats: {
|
||||||
|
invoices: { synced: 0, errors: 0 },
|
||||||
|
contacts: { synced: 0, errors: 0 },
|
||||||
|
payments: { synced: 0, errors: 0 },
|
||||||
|
creditNotes: { synced: 0, errors: 0 },
|
||||||
|
},
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determinar periodo de sincronizacion
|
||||||
|
const period = validatedConfig.period || this.getDefaultPeriod();
|
||||||
|
|
||||||
|
// Sincronizar contactos primero (para tener referencias)
|
||||||
|
if (validatedConfig.syncContacts) {
|
||||||
|
await this.syncContacts(tenantId, result, callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sincronizar facturas
|
||||||
|
if (validatedConfig.syncInvoices) {
|
||||||
|
await this.syncInvoices(tenantId, period, result, callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sincronizar notas de credito
|
||||||
|
if (validatedConfig.syncCreditNotes) {
|
||||||
|
await this.syncCreditNotes(tenantId, period, result, callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sincronizar pagos
|
||||||
|
if (validatedConfig.syncPayments) {
|
||||||
|
await this.syncPayments(tenantId, period, result, callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.completedAt = new Date();
|
||||||
|
result.success = result.errors.length === 0;
|
||||||
|
|
||||||
|
logger.info('Alegra sync completed', {
|
||||||
|
tenantId,
|
||||||
|
duration: result.completedAt.getTime() - startedAt.getTime(),
|
||||||
|
stats: result.stats,
|
||||||
|
errorsCount: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.success = false;
|
||||||
|
result.errors.push({
|
||||||
|
type: 'sync',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
logger.error('Alegra sync failed', {
|
||||||
|
tenantId,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincronizacion incremental desde la ultima fecha conocida
|
||||||
|
*/
|
||||||
|
async syncIncremental(
|
||||||
|
tenantId: string,
|
||||||
|
lastSyncAt: Date,
|
||||||
|
callbacks?: {
|
||||||
|
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
|
||||||
|
onContact?: (contact: HoruxContact) => Promise<void>;
|
||||||
|
}
|
||||||
|
): Promise<AlegraSyncResult> {
|
||||||
|
logger.info('Starting incremental Alegra sync', { tenantId, lastSyncAt });
|
||||||
|
|
||||||
|
const startedAt = new Date();
|
||||||
|
const result: AlegraSyncResult = {
|
||||||
|
success: true,
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date(),
|
||||||
|
stats: {
|
||||||
|
invoices: { synced: 0, errors: 0 },
|
||||||
|
contacts: { synced: 0, errors: 0 },
|
||||||
|
payments: { synced: 0, errors: 0 },
|
||||||
|
creditNotes: { synced: 0, errors: 0 },
|
||||||
|
},
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Obtener elementos modificados desde la ultima sincronizacion
|
||||||
|
const [invoices, contacts, payments] = await Promise.all([
|
||||||
|
this.invoicesConnector.getInvoicesModifiedSince(lastSyncAt),
|
||||||
|
this.contactsConnector.getContactsModifiedSince(lastSyncAt),
|
||||||
|
this.paymentsConnector.getPaymentsModifiedSince(lastSyncAt, 'all'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sincronizar contactos modificados
|
||||||
|
for (const contact of contacts) {
|
||||||
|
try {
|
||||||
|
const horuxContact = this.mapAlegraContactToHorux(tenantId, contact);
|
||||||
|
if (callbacks?.onContact) {
|
||||||
|
await callbacks.onContact(horuxContact);
|
||||||
|
}
|
||||||
|
result.stats.contacts.synced++;
|
||||||
|
} catch (error) {
|
||||||
|
result.stats.contacts.errors++;
|
||||||
|
result.errors.push({
|
||||||
|
type: 'contact',
|
||||||
|
id: contact.id,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sincronizar facturas modificadas
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
try {
|
||||||
|
const transaction = this.mapAlegraInvoiceToTransaction(tenantId, invoice);
|
||||||
|
if (callbacks?.onTransaction) {
|
||||||
|
await callbacks.onTransaction(transaction);
|
||||||
|
}
|
||||||
|
result.stats.invoices.synced++;
|
||||||
|
} catch (error) {
|
||||||
|
result.stats.invoices.errors++;
|
||||||
|
result.errors.push({
|
||||||
|
type: 'invoice',
|
||||||
|
id: invoice.id,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sincronizar pagos modificados
|
||||||
|
for (const payment of payments) {
|
||||||
|
try {
|
||||||
|
const transaction = this.mapAlegraPaymentToTransaction(
|
||||||
|
tenantId,
|
||||||
|
payment as AlegraPaymentReceived
|
||||||
|
);
|
||||||
|
if (callbacks?.onTransaction) {
|
||||||
|
await callbacks.onTransaction(transaction);
|
||||||
|
}
|
||||||
|
result.stats.payments.synced++;
|
||||||
|
} catch (error) {
|
||||||
|
result.stats.payments.errors++;
|
||||||
|
result.errors.push({
|
||||||
|
type: 'payment',
|
||||||
|
id: (payment as AlegraPaymentReceived).id,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.completedAt = new Date();
|
||||||
|
result.success = result.errors.length === 0;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.success = false;
|
||||||
|
result.errors.push({
|
||||||
|
type: 'sync',
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Individual Sync Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza contactos
|
||||||
|
*/
|
||||||
|
private async syncContacts(
|
||||||
|
tenantId: string,
|
||||||
|
result: AlegraSyncResult,
|
||||||
|
callbacks?: {
|
||||||
|
onProgress?: (progress: { type: string; current: number; total: number }) => void;
|
||||||
|
onContact?: (contact: HoruxContact) => Promise<void>;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
logger.debug('Syncing contacts from Alegra');
|
||||||
|
|
||||||
|
const contacts = await this.contactsConnector.getAllContacts('all');
|
||||||
|
const total = contacts.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < contacts.length; i++) {
|
||||||
|
const contact = contacts[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const horuxContact = this.mapAlegraContactToHorux(tenantId, contact);
|
||||||
|
|
||||||
|
if (callbacks?.onContact) {
|
||||||
|
await callbacks.onContact(horuxContact);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.stats.contacts.synced++;
|
||||||
|
|
||||||
|
callbacks?.onProgress?.({
|
||||||
|
type: 'contacts',
|
||||||
|
current: i + 1,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.stats.contacts.errors++;
|
||||||
|
result.errors.push({
|
||||||
|
type: 'contact',
|
||||||
|
id: contact.id,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza facturas
|
||||||
|
*/
|
||||||
|
private async syncInvoices(
|
||||||
|
tenantId: string,
|
||||||
|
period: { from: string; to: string },
|
||||||
|
result: AlegraSyncResult,
|
||||||
|
callbacks?: {
|
||||||
|
onProgress?: (progress: { type: string; current: number; total: number }) => void;
|
||||||
|
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
logger.debug('Syncing invoices from Alegra', { period });
|
||||||
|
|
||||||
|
const invoices = await this.invoicesConnector.getAllInvoices({
|
||||||
|
startDate: period.from,
|
||||||
|
endDate: period.to,
|
||||||
|
});
|
||||||
|
const total = invoices.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < invoices.length; i++) {
|
||||||
|
const invoice = invoices[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transaction = this.mapAlegraInvoiceToTransaction(tenantId, invoice);
|
||||||
|
|
||||||
|
if (callbacks?.onTransaction) {
|
||||||
|
await callbacks.onTransaction(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.stats.invoices.synced++;
|
||||||
|
|
||||||
|
callbacks?.onProgress?.({
|
||||||
|
type: 'invoices',
|
||||||
|
current: i + 1,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.stats.invoices.errors++;
|
||||||
|
result.errors.push({
|
||||||
|
type: 'invoice',
|
||||||
|
id: invoice.id,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza notas de credito
|
||||||
|
*/
|
||||||
|
private async syncCreditNotes(
|
||||||
|
tenantId: string,
|
||||||
|
period: { from: string; to: string },
|
||||||
|
result: AlegraSyncResult,
|
||||||
|
callbacks?: {
|
||||||
|
onProgress?: (progress: { type: string; current: number; total: number }) => void;
|
||||||
|
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
logger.debug('Syncing credit notes from Alegra', { period });
|
||||||
|
|
||||||
|
const creditNotes = await this.invoicesConnector.getAllCreditNotes({
|
||||||
|
startDate: period.from,
|
||||||
|
endDate: period.to,
|
||||||
|
});
|
||||||
|
const total = creditNotes.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < creditNotes.length; i++) {
|
||||||
|
const creditNote = creditNotes[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transaction = this.mapAlegraCreditNoteToTransaction(tenantId, creditNote);
|
||||||
|
|
||||||
|
if (callbacks?.onTransaction) {
|
||||||
|
await callbacks.onTransaction(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.stats.creditNotes.synced++;
|
||||||
|
|
||||||
|
callbacks?.onProgress?.({
|
||||||
|
type: 'creditNotes',
|
||||||
|
current: i + 1,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.stats.creditNotes.errors++;
|
||||||
|
result.errors.push({
|
||||||
|
type: 'credit_note',
|
||||||
|
id: creditNote.id,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza pagos
|
||||||
|
*/
|
||||||
|
private async syncPayments(
|
||||||
|
tenantId: string,
|
||||||
|
period: { from: string; to: string },
|
||||||
|
result: AlegraSyncResult,
|
||||||
|
callbacks?: {
|
||||||
|
onProgress?: (progress: { type: string; current: number; total: number }) => void;
|
||||||
|
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
logger.debug('Syncing payments from Alegra', { period });
|
||||||
|
|
||||||
|
const paymentsReceived = await this.paymentsConnector.getAllPaymentsReceived(period);
|
||||||
|
const total = paymentsReceived.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < paymentsReceived.length; i++) {
|
||||||
|
const payment = paymentsReceived[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transaction = this.mapAlegraPaymentToTransaction(tenantId, payment);
|
||||||
|
|
||||||
|
if (callbacks?.onTransaction) {
|
||||||
|
await callbacks.onTransaction(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.stats.payments.synced++;
|
||||||
|
|
||||||
|
callbacks?.onProgress?.({
|
||||||
|
type: 'payments',
|
||||||
|
current: i + 1,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.stats.payments.errors++;
|
||||||
|
result.errors.push({
|
||||||
|
type: 'payment',
|
||||||
|
id: payment.id,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mapping Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea una factura de Alegra a transaccion de Horux
|
||||||
|
*/
|
||||||
|
mapAlegraInvoiceToTransaction(tenantId: string, invoice: AlegraInvoice): HoruxTransaction {
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
externalId: `alegra-invoice-${invoice.id}`,
|
||||||
|
externalSource: 'alegra',
|
||||||
|
type: 'invoice',
|
||||||
|
date: new Date(invoice.date),
|
||||||
|
amount: invoice.total,
|
||||||
|
currency: invoice.currency?.code || 'MXN',
|
||||||
|
contactId: invoice.client ? `alegra-contact-${invoice.client.id}` : undefined,
|
||||||
|
contactName: invoice.client?.name,
|
||||||
|
description: `Factura ${invoice.numberTemplate?.fullNumber || invoice.id}`,
|
||||||
|
status: invoice.status,
|
||||||
|
metadata: {
|
||||||
|
alegraId: invoice.id,
|
||||||
|
numberTemplate: invoice.numberTemplate,
|
||||||
|
dueDate: invoice.dueDate,
|
||||||
|
totalPaid: invoice.totalPaid,
|
||||||
|
balance: invoice.balance,
|
||||||
|
items: invoice.items?.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
price: item.price,
|
||||||
|
})),
|
||||||
|
stamp: invoice.stamp,
|
||||||
|
costCenter: invoice.costCenter,
|
||||||
|
seller: invoice.seller,
|
||||||
|
},
|
||||||
|
syncedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea una nota de credito de Alegra a transaccion de Horux
|
||||||
|
*/
|
||||||
|
mapAlegraCreditNoteToTransaction(tenantId: string, creditNote: AlegraCreditNote): HoruxTransaction {
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
externalId: `alegra-creditnote-${creditNote.id}`,
|
||||||
|
externalSource: 'alegra',
|
||||||
|
type: 'credit_note',
|
||||||
|
date: new Date(creditNote.date),
|
||||||
|
amount: -creditNote.total, // Negativo porque es un credito
|
||||||
|
currency: creditNote.currency?.code || 'MXN',
|
||||||
|
contactId: creditNote.client ? `alegra-contact-${creditNote.client.id}` : undefined,
|
||||||
|
contactName: creditNote.client?.name,
|
||||||
|
description: `Nota de Credito ${creditNote.numberTemplate?.fullNumber || creditNote.id}`,
|
||||||
|
status: creditNote.status,
|
||||||
|
metadata: {
|
||||||
|
alegraId: creditNote.id,
|
||||||
|
numberTemplate: creditNote.numberTemplate,
|
||||||
|
relatedInvoices: creditNote.relatedInvoices,
|
||||||
|
items: creditNote.items?.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
price: item.price,
|
||||||
|
})),
|
||||||
|
stamp: creditNote.stamp,
|
||||||
|
},
|
||||||
|
syncedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea un pago de Alegra a transaccion de Horux
|
||||||
|
*/
|
||||||
|
mapAlegraPaymentToTransaction(
|
||||||
|
tenantId: string,
|
||||||
|
payment: AlegraPaymentReceived
|
||||||
|
): HoruxTransaction {
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
externalId: `alegra-payment-${payment.id}`,
|
||||||
|
externalSource: 'alegra',
|
||||||
|
type: 'payment_received',
|
||||||
|
date: new Date(payment.date),
|
||||||
|
amount: payment.amount,
|
||||||
|
currency: 'MXN', // Default
|
||||||
|
contactId: payment.client ? `alegra-contact-${payment.client.id}` : undefined,
|
||||||
|
contactName: payment.client?.name,
|
||||||
|
description: `Pago ${payment.number || payment.id}`,
|
||||||
|
status: 'completed',
|
||||||
|
metadata: {
|
||||||
|
alegraId: payment.id,
|
||||||
|
paymentMethod: payment.paymentMethod,
|
||||||
|
bankAccount: payment.bankAccount,
|
||||||
|
invoices: payment.invoices,
|
||||||
|
observations: payment.observations,
|
||||||
|
},
|
||||||
|
syncedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea un contacto de Alegra a contacto de Horux
|
||||||
|
*/
|
||||||
|
mapAlegraContactToHorux(tenantId: string, contact: AlegraContact): HoruxContact {
|
||||||
|
const isCustomer = contact.type.includes('client');
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
externalId: `alegra-contact-${contact.id}`,
|
||||||
|
externalSource: 'alegra',
|
||||||
|
name: contact.name,
|
||||||
|
email: contact.email,
|
||||||
|
phone: contact.phonePrimary || contact.mobile,
|
||||||
|
identification: contact.identification,
|
||||||
|
type: isCustomer ? 'customer' : 'vendor',
|
||||||
|
metadata: {
|
||||||
|
alegraId: contact.id,
|
||||||
|
identificationType: contact.identificationType,
|
||||||
|
address: contact.address,
|
||||||
|
type: contact.type,
|
||||||
|
seller: contact.seller,
|
||||||
|
term: contact.term,
|
||||||
|
regimen: contact.regimen,
|
||||||
|
cfdiUse: contact.cfdiUse,
|
||||||
|
creditLimit: contact.creditLimit,
|
||||||
|
},
|
||||||
|
syncedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Webhook Handling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Procesa un evento de webhook de Alegra
|
||||||
|
*/
|
||||||
|
async handleWebhook(
|
||||||
|
tenantId: string,
|
||||||
|
payload: AlegraWebhookPayload,
|
||||||
|
callbacks?: {
|
||||||
|
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
|
||||||
|
onContact?: (contact: HoruxContact) => Promise<void>;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
logger.info('Processing Alegra webhook', {
|
||||||
|
event: payload.event,
|
||||||
|
timestamp: payload.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = payload.event;
|
||||||
|
const data = payload.data;
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case 'invoice.created':
|
||||||
|
case 'invoice.updated':
|
||||||
|
case 'invoice.paid':
|
||||||
|
if (callbacks?.onTransaction) {
|
||||||
|
const transaction = this.mapAlegraInvoiceToTransaction(tenantId, data as AlegraInvoice);
|
||||||
|
await callbacks.onTransaction(transaction);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'invoice.voided':
|
||||||
|
case 'invoice.deleted':
|
||||||
|
// Marcar como anulado/eliminado
|
||||||
|
if (callbacks?.onTransaction) {
|
||||||
|
const invoice = data as AlegraInvoice;
|
||||||
|
const transaction = this.mapAlegraInvoiceToTransaction(tenantId, {
|
||||||
|
...invoice,
|
||||||
|
status: 'void',
|
||||||
|
});
|
||||||
|
await callbacks.onTransaction(transaction);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'contact.created':
|
||||||
|
case 'contact.updated':
|
||||||
|
if (callbacks?.onContact) {
|
||||||
|
const contact = this.mapAlegraContactToHorux(tenantId, data as AlegraContact);
|
||||||
|
await callbacks.onContact(contact);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'payment.created':
|
||||||
|
case 'payment.updated':
|
||||||
|
if (callbacks?.onTransaction) {
|
||||||
|
const transaction = this.mapAlegraPaymentToTransaction(tenantId, data as AlegraPaymentReceived);
|
||||||
|
await callbacks.onTransaction(transaction);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'credit-note.created':
|
||||||
|
case 'credit-note.updated':
|
||||||
|
if (callbacks?.onTransaction) {
|
||||||
|
const transaction = this.mapAlegraCreditNoteToTransaction(tenantId, data as AlegraCreditNote);
|
||||||
|
await callbacks.onTransaction(transaction);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn('Unhandled Alegra webhook event', { event });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra un webhook en Alegra
|
||||||
|
*/
|
||||||
|
async registerWebhook(
|
||||||
|
url: string,
|
||||||
|
events: AlegraWebhookEvent[]
|
||||||
|
): Promise<AlegraWebhookSubscription> {
|
||||||
|
logger.info('Registering Alegra webhook', { url, events });
|
||||||
|
|
||||||
|
return this.client.post<AlegraWebhookSubscription>('/webhooks', {
|
||||||
|
url,
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina un webhook de Alegra
|
||||||
|
*/
|
||||||
|
async deleteWebhook(webhookId: number): Promise<void> {
|
||||||
|
logger.info('Deleting Alegra webhook', { webhookId });
|
||||||
|
await this.client.delete(`/webhooks/${webhookId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista webhooks registrados
|
||||||
|
*/
|
||||||
|
async listWebhooks(): Promise<AlegraWebhookSubscription[]> {
|
||||||
|
return this.client.get<AlegraWebhookSubscription[]>('/webhooks');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el periodo por defecto (ultimo mes)
|
||||||
|
*/
|
||||||
|
private getDefaultPeriod(): { from: string; to: string } {
|
||||||
|
const to = new Date();
|
||||||
|
const from = new Date();
|
||||||
|
from.setMonth(from.getMonth() - 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: from.toISOString().split('T')[0],
|
||||||
|
to: to.toISOString().split('T')[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica la conexion con Alegra
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<boolean> {
|
||||||
|
return this.client.testConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado de sincronizacion
|
||||||
|
*/
|
||||||
|
getSyncState(tenantId: string): AlegraSyncState {
|
||||||
|
// Esto deberia obtenerse de la base de datos
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
status: 'idle',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un servicio de sincronizacion de Alegra
|
||||||
|
*/
|
||||||
|
export function createAlegraSyncService(config: AlegraConfig): AlegraSyncService {
|
||||||
|
return new AlegraSyncService(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlegraSyncService;
|
||||||
1060
apps/api/src/services/integrations/alegra/alegra.types.ts
Normal file
1060
apps/api/src/services/integrations/alegra/alegra.types.ts
Normal file
File diff suppressed because it is too large
Load Diff
549
apps/api/src/services/integrations/alegra/contacts.connector.ts
Normal file
549
apps/api/src/services/integrations/alegra/contacts.connector.ts
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
/**
|
||||||
|
* Alegra Contacts Connector
|
||||||
|
* Conector para gestion de contactos: clientes y proveedores
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AlegraClient } from './alegra.client.js';
|
||||||
|
import {
|
||||||
|
AlegraContact,
|
||||||
|
AlegraContactType,
|
||||||
|
AlegraContactBalance,
|
||||||
|
AlegraContactStatement,
|
||||||
|
AlegraStatementTransaction,
|
||||||
|
} from './alegra.types.js';
|
||||||
|
import {
|
||||||
|
ContactFilter,
|
||||||
|
ContactInput,
|
||||||
|
ContactFilterSchema,
|
||||||
|
ContactInputSchema,
|
||||||
|
PeriodInput,
|
||||||
|
} from './alegra.schema.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Contacts Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para operaciones de contactos en Alegra
|
||||||
|
*/
|
||||||
|
export class AlegraContactsConnector {
|
||||||
|
constructor(private readonly client: AlegraClient) {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Contact CRUD Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene listado de contactos con filtros opcionales
|
||||||
|
*/
|
||||||
|
async getContacts(
|
||||||
|
type?: 'customer' | 'vendor' | 'all',
|
||||||
|
filters: Partial<ContactFilter> = {}
|
||||||
|
): Promise<{
|
||||||
|
data: AlegraContact[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}> {
|
||||||
|
const validatedFilters = ContactFilterSchema.partial().parse(filters);
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
start: validatedFilters.start || 0,
|
||||||
|
limit: validatedFilters.limit || 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mapear tipo de contacto
|
||||||
|
if (type && type !== 'all') {
|
||||||
|
params.type = type === 'customer' ? 'client' : 'provider';
|
||||||
|
} else if (validatedFilters.type) {
|
||||||
|
params.type = validatedFilters.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validatedFilters.query) {
|
||||||
|
params.query = validatedFilters.query;
|
||||||
|
}
|
||||||
|
if (validatedFilters.status) {
|
||||||
|
params.status = validatedFilters.status;
|
||||||
|
}
|
||||||
|
if (validatedFilters.orderField) {
|
||||||
|
params.orderField = validatedFilters.orderField;
|
||||||
|
params.order = validatedFilters.order || 'ASC';
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Getting contacts from Alegra', { type, filters: params });
|
||||||
|
|
||||||
|
const response = await this.client.get<AlegraContact[]>('/contacts', params);
|
||||||
|
const data = Array.isArray(response) ? response : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
hasMore: data.length === (validatedFilters.limit || 30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los contactos (paginacion automatica)
|
||||||
|
*/
|
||||||
|
async getAllContacts(type?: 'customer' | 'vendor' | 'all'): Promise<AlegraContact[]> {
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (type && type !== 'all') {
|
||||||
|
params.type = type === 'customer' ? 'client' : 'provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAllPaginated<AlegraContact>('/contacts', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los clientes
|
||||||
|
*/
|
||||||
|
async getCustomers(filters?: Partial<ContactFilter>): Promise<AlegraContact[]> {
|
||||||
|
const { data } = await this.getContacts('customer', {
|
||||||
|
...filters,
|
||||||
|
limit: 30,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los proveedores
|
||||||
|
*/
|
||||||
|
async getVendors(filters?: Partial<ContactFilter>): Promise<AlegraContact[]> {
|
||||||
|
const { data } = await this.getContacts('vendor', {
|
||||||
|
...filters,
|
||||||
|
limit: 30,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un contacto por ID
|
||||||
|
*/
|
||||||
|
async getContactById(id: number): Promise<AlegraContact> {
|
||||||
|
logger.debug('Getting contact by ID from Alegra', { id });
|
||||||
|
return this.client.get<AlegraContact>(`/contacts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca contactos por nombre, identificacion o email
|
||||||
|
*/
|
||||||
|
async searchContacts(
|
||||||
|
query: string,
|
||||||
|
type?: 'customer' | 'vendor' | 'all',
|
||||||
|
limit: number = 30
|
||||||
|
): Promise<AlegraContact[]> {
|
||||||
|
const { data } = await this.getContacts(type, { query, limit });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca contacto por identificacion fiscal (RFC, NIT, etc.)
|
||||||
|
*/
|
||||||
|
async getContactByIdentification(identification: string): Promise<AlegraContact | null> {
|
||||||
|
const contacts = await this.searchContacts(identification);
|
||||||
|
return contacts.find(c => c.identification === identification) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca contacto por email
|
||||||
|
*/
|
||||||
|
async getContactByEmail(email: string): Promise<AlegraContact | null> {
|
||||||
|
const contacts = await this.searchContacts(email);
|
||||||
|
return contacts.find(c => c.email?.toLowerCase() === email.toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un nuevo contacto
|
||||||
|
*/
|
||||||
|
async createContact(input: ContactInput): Promise<AlegraContact> {
|
||||||
|
const validatedInput = ContactInputSchema.parse(input);
|
||||||
|
logger.info('Creating contact in Alegra', { name: input.name });
|
||||||
|
return this.client.post<AlegraContact>('/contacts', validatedInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza un contacto existente
|
||||||
|
*/
|
||||||
|
async updateContact(id: number, input: Partial<ContactInput>): Promise<AlegraContact> {
|
||||||
|
logger.info('Updating contact in Alegra', { id });
|
||||||
|
return this.client.put<AlegraContact>(`/contacts/${id}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina un contacto
|
||||||
|
*/
|
||||||
|
async deleteContact(id: number): Promise<void> {
|
||||||
|
logger.info('Deleting contact in Alegra', { id });
|
||||||
|
await this.client.delete(`/contacts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Balance & Statement Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el balance de un contacto
|
||||||
|
*/
|
||||||
|
async getContactBalance(id: number): Promise<AlegraContactBalance> {
|
||||||
|
logger.debug('Getting contact balance from Alegra', { id });
|
||||||
|
|
||||||
|
// Alegra no tiene endpoint directo de balance, lo calculamos
|
||||||
|
const contact = await this.getContactById(id);
|
||||||
|
|
||||||
|
// Obtener facturas pendientes del cliente
|
||||||
|
const pendingInvoices = await this.client.get<Array<{
|
||||||
|
id: number;
|
||||||
|
total: number;
|
||||||
|
totalPaid?: number;
|
||||||
|
balance?: number;
|
||||||
|
status: string;
|
||||||
|
dueDate: string;
|
||||||
|
}>>('/invoices', {
|
||||||
|
client: id,
|
||||||
|
status: 'open',
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalDue = 0;
|
||||||
|
let invoicesPending = 0;
|
||||||
|
let maxOverdueDays = 0;
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
for (const invoice of pendingInvoices) {
|
||||||
|
const balance = invoice.balance ?? (invoice.total - (invoice.totalPaid || 0));
|
||||||
|
totalDue += balance;
|
||||||
|
invoicesPending++;
|
||||||
|
|
||||||
|
const dueDate = new Date(invoice.dueDate);
|
||||||
|
if (dueDate < today) {
|
||||||
|
const overdueDays = Math.floor((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
maxOverdueDays = Math.max(maxOverdueDays, overdueDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contactId: id,
|
||||||
|
balance: totalDue,
|
||||||
|
currency: 'MXN', // Default, podria obtenerse de la config
|
||||||
|
invoicesPending,
|
||||||
|
totalDue,
|
||||||
|
overdueDays: maxOverdueDays > 0 ? maxOverdueDays : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado de cuenta de un contacto en un periodo
|
||||||
|
*/
|
||||||
|
async getContactStatement(
|
||||||
|
id: number,
|
||||||
|
period: PeriodInput
|
||||||
|
): Promise<AlegraContactStatement> {
|
||||||
|
logger.debug('Getting contact statement from Alegra', { id, period });
|
||||||
|
|
||||||
|
const contact = await this.getContactById(id);
|
||||||
|
|
||||||
|
// Obtener todas las transacciones del periodo
|
||||||
|
const [invoices, creditNotes, payments] = await Promise.all([
|
||||||
|
this.client.get<Array<{
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
numberTemplate?: { fullNumber?: string };
|
||||||
|
total: number;
|
||||||
|
status: string;
|
||||||
|
}>>('/invoices', {
|
||||||
|
client: id,
|
||||||
|
start_date: period.from,
|
||||||
|
end_date: period.to,
|
||||||
|
}),
|
||||||
|
this.client.get<Array<{
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
numberTemplate?: { fullNumber?: string };
|
||||||
|
total: number;
|
||||||
|
}>>('/credit-notes', {
|
||||||
|
client: id,
|
||||||
|
start_date: period.from,
|
||||||
|
end_date: period.to,
|
||||||
|
}),
|
||||||
|
this.client.get<Array<{
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
number?: string;
|
||||||
|
amount: number;
|
||||||
|
}>>('/payments', {
|
||||||
|
client: id,
|
||||||
|
start_date: period.from,
|
||||||
|
end_date: period.to,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Construir transacciones
|
||||||
|
const transactions: AlegraStatementTransaction[] = [];
|
||||||
|
|
||||||
|
// Agregar facturas
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
if (invoice.status !== 'void') {
|
||||||
|
transactions.push({
|
||||||
|
date: invoice.date,
|
||||||
|
type: 'invoice',
|
||||||
|
documentNumber: invoice.numberTemplate?.fullNumber || `INV-${invoice.id}`,
|
||||||
|
description: `Factura`,
|
||||||
|
debit: invoice.total,
|
||||||
|
credit: 0,
|
||||||
|
balance: 0, // Se calculara despues
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar notas de credito
|
||||||
|
for (const creditNote of creditNotes) {
|
||||||
|
transactions.push({
|
||||||
|
date: creditNote.date,
|
||||||
|
type: 'credit_note',
|
||||||
|
documentNumber: creditNote.numberTemplate?.fullNumber || `NC-${creditNote.id}`,
|
||||||
|
description: `Nota de credito`,
|
||||||
|
debit: 0,
|
||||||
|
credit: creditNote.total,
|
||||||
|
balance: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar pagos
|
||||||
|
for (const payment of payments) {
|
||||||
|
transactions.push({
|
||||||
|
date: payment.date,
|
||||||
|
type: 'payment',
|
||||||
|
documentNumber: payment.number || `PAY-${payment.id}`,
|
||||||
|
description: `Pago recibido`,
|
||||||
|
debit: 0,
|
||||||
|
credit: payment.amount,
|
||||||
|
balance: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por fecha
|
||||||
|
transactions.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||||
|
|
||||||
|
// Calcular balances
|
||||||
|
// Obtener balance inicial (simplificado - balance al inicio del periodo)
|
||||||
|
const openingBalance = 0; // Idealmente se calcularia con historico
|
||||||
|
|
||||||
|
let runningBalance = openingBalance;
|
||||||
|
for (const transaction of transactions) {
|
||||||
|
runningBalance += transaction.debit - transaction.credit;
|
||||||
|
transaction.balance = runningBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contactId: id,
|
||||||
|
contact,
|
||||||
|
period: {
|
||||||
|
from: period.from,
|
||||||
|
to: period.to,
|
||||||
|
},
|
||||||
|
openingBalance,
|
||||||
|
transactions,
|
||||||
|
closingBalance: runningBalance,
|
||||||
|
currency: 'MXN',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bulk Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea o actualiza un contacto (upsert por identificacion)
|
||||||
|
*/
|
||||||
|
async upsertContact(input: ContactInput): Promise<AlegraContact> {
|
||||||
|
if (input.identification) {
|
||||||
|
const existing = await this.getContactByIdentification(input.identification);
|
||||||
|
if (existing) {
|
||||||
|
return this.updateContact(existing.id, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.createContact(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importa multiples contactos
|
||||||
|
*/
|
||||||
|
async importContacts(
|
||||||
|
contacts: ContactInput[],
|
||||||
|
options?: {
|
||||||
|
skipErrors?: boolean;
|
||||||
|
upsert?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
created: AlegraContact[];
|
||||||
|
updated: AlegraContact[];
|
||||||
|
errors: Array<{ input: ContactInput; error: string }>;
|
||||||
|
}> {
|
||||||
|
const created: AlegraContact[] = [];
|
||||||
|
const updated: AlegraContact[] = [];
|
||||||
|
const errors: Array<{ input: ContactInput; error: string }> = [];
|
||||||
|
|
||||||
|
for (const input of contacts) {
|
||||||
|
try {
|
||||||
|
if (options?.upsert) {
|
||||||
|
const contact = await this.upsertContact(input);
|
||||||
|
if (input.identification) {
|
||||||
|
const existing = await this.getContactByIdentification(input.identification);
|
||||||
|
if (existing && existing.id === contact.id) {
|
||||||
|
updated.push(contact);
|
||||||
|
} else {
|
||||||
|
created.push(contact);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
created.push(contact);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const contact = await this.createContact(input);
|
||||||
|
created.push(contact);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
if (options?.skipErrors) {
|
||||||
|
errors.push({ input, error: errorMessage });
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Statistics & Analytics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadisticas de contactos
|
||||||
|
*/
|
||||||
|
async getContactStatistics(): Promise<{
|
||||||
|
totalCustomers: number;
|
||||||
|
totalVendors: number;
|
||||||
|
activeCustomers: number;
|
||||||
|
activeVendors: number;
|
||||||
|
customersWithBalance: number;
|
||||||
|
totalReceivables: number;
|
||||||
|
}> {
|
||||||
|
const [customers, vendors] = await Promise.all([
|
||||||
|
this.getAllContacts('customer'),
|
||||||
|
this.getAllContacts('vendor'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let customersWithBalance = 0;
|
||||||
|
let totalReceivables = 0;
|
||||||
|
|
||||||
|
// Para cada cliente, verificar si tiene balance pendiente
|
||||||
|
// Nota: Esto puede ser lento con muchos clientes
|
||||||
|
const balancePromises = customers.slice(0, 100).map(async customer => {
|
||||||
|
try {
|
||||||
|
const balance = await this.getContactBalance(customer.id);
|
||||||
|
return balance;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = await Promise.all(balancePromises);
|
||||||
|
for (const balance of balances) {
|
||||||
|
if (balance && balance.balance > 0) {
|
||||||
|
customersWithBalance++;
|
||||||
|
totalReceivables += balance.balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCustomers: customers.length,
|
||||||
|
totalVendors: vendors.length,
|
||||||
|
activeCustomers: customers.filter(c => c.status !== 'inactive').length,
|
||||||
|
activeVendors: vendors.filter(v => v.status !== 'inactive').length,
|
||||||
|
customersWithBalance,
|
||||||
|
totalReceivables,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los mejores clientes por facturacion
|
||||||
|
*/
|
||||||
|
async getTopCustomers(
|
||||||
|
period: PeriodInput,
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<Array<{
|
||||||
|
contact: AlegraContact;
|
||||||
|
totalInvoiced: number;
|
||||||
|
invoiceCount: number;
|
||||||
|
}>> {
|
||||||
|
// Obtener todas las facturas del periodo
|
||||||
|
const invoices = await this.client.getAllPaginated<{
|
||||||
|
id: number;
|
||||||
|
client: { id: number };
|
||||||
|
total: number;
|
||||||
|
}>('/invoices', {
|
||||||
|
start_date: period.from,
|
||||||
|
end_date: period.to,
|
||||||
|
status: 'paid,open',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por cliente
|
||||||
|
const customerStats = new Map<number, { total: number; count: number }>();
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
const clientId = invoice.client.id;
|
||||||
|
const current = customerStats.get(clientId) || { total: 0, count: 0 };
|
||||||
|
current.total += invoice.total;
|
||||||
|
current.count++;
|
||||||
|
customerStats.set(clientId, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por total facturado
|
||||||
|
const sorted = Array.from(customerStats.entries())
|
||||||
|
.sort(([, a], [, b]) => b.total - a.total)
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
// Obtener detalles de los contactos
|
||||||
|
const results = await Promise.all(
|
||||||
|
sorted.map(async ([clientId, stats]) => {
|
||||||
|
const contact = await this.getContactById(clientId);
|
||||||
|
return {
|
||||||
|
contact,
|
||||||
|
totalInvoiced: stats.total,
|
||||||
|
invoiceCount: stats.count,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene contactos modificados desde una fecha (para sync incremental)
|
||||||
|
*/
|
||||||
|
async getContactsModifiedSince(sinceDate: Date): Promise<AlegraContact[]> {
|
||||||
|
const allContacts = await this.getAllContacts();
|
||||||
|
|
||||||
|
return allContacts.filter(contact => {
|
||||||
|
if (contact.updatedAt) {
|
||||||
|
return new Date(contact.updatedAt) >= sinceDate;
|
||||||
|
}
|
||||||
|
if (contact.createdAt) {
|
||||||
|
return new Date(contact.createdAt) >= sinceDate;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un conector de contactos de Alegra
|
||||||
|
*/
|
||||||
|
export function createContactsConnector(client: AlegraClient): AlegraContactsConnector {
|
||||||
|
return new AlegraContactsConnector(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlegraContactsConnector;
|
||||||
338
apps/api/src/services/integrations/alegra/index.ts
Normal file
338
apps/api/src/services/integrations/alegra/index.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* Alegra Integration Module
|
||||||
|
* Modulo completo de integracion con Alegra - Software contable cloud
|
||||||
|
*
|
||||||
|
* Exporta todos los componentes necesarios para integrar Horux Strategy con Alegra:
|
||||||
|
* - Cliente REST API con rate limiting y reintentos
|
||||||
|
* - Conectores para facturas, contactos, pagos y reportes
|
||||||
|
* - Servicio de sincronizacion bidireccional
|
||||||
|
* - Tipos y schemas de validacion
|
||||||
|
*
|
||||||
|
* Uso basico:
|
||||||
|
* ```typescript
|
||||||
|
* import { createAlegraClient, createAlegraSyncService } from './services/integrations/alegra';
|
||||||
|
*
|
||||||
|
* const client = createAlegraClient({
|
||||||
|
* email: 'usuario@ejemplo.com',
|
||||||
|
* token: 'tu-api-token',
|
||||||
|
* country: 'MX',
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Usar conectores directamente
|
||||||
|
* const invoices = await client.get('/invoices');
|
||||||
|
*
|
||||||
|
* // O usar el servicio de sincronizacion
|
||||||
|
* const syncService = createAlegraSyncService(config);
|
||||||
|
* await syncService.syncToHorux(tenantId, syncConfig);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Client
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export { AlegraClient, createAlegraClient } from './alegra.client.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Connectors
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlegraInvoicesConnector,
|
||||||
|
createInvoicesConnector,
|
||||||
|
} from './invoices.connector.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlegraContactsConnector,
|
||||||
|
createContactsConnector,
|
||||||
|
} from './contacts.connector.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlegraPaymentsConnector,
|
||||||
|
createPaymentsConnector,
|
||||||
|
} from './payments.connector.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlegraReportsConnector,
|
||||||
|
createReportsConnector,
|
||||||
|
} from './reports.connector.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sync Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlegraSyncService,
|
||||||
|
createAlegraSyncService,
|
||||||
|
} from './alegra.sync.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type {
|
||||||
|
// Config & Auth
|
||||||
|
AlegraConfig,
|
||||||
|
AlegraAuth,
|
||||||
|
AlegraCountry,
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
AlegraPaginationParams,
|
||||||
|
AlegraDateFilter,
|
||||||
|
AlegraPaginatedResponse,
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
AlegraContact,
|
||||||
|
AlegraContactType,
|
||||||
|
AlegraIdentificationType,
|
||||||
|
AlegraAddress,
|
||||||
|
AlegraPhone,
|
||||||
|
AlegraInternalContact,
|
||||||
|
AlegraContactBalance,
|
||||||
|
AlegraContactStatement,
|
||||||
|
AlegraStatementTransaction,
|
||||||
|
|
||||||
|
// Invoices
|
||||||
|
AlegraInvoice,
|
||||||
|
AlegraInvoiceStatus,
|
||||||
|
AlegraInvoiceType,
|
||||||
|
AlegraInvoiceItem,
|
||||||
|
AlegraItemTax,
|
||||||
|
AlegraRetention,
|
||||||
|
AlegraInvoiceTax,
|
||||||
|
AlegraStamp,
|
||||||
|
AlegraPaymentReference,
|
||||||
|
AlegraNumberTemplate,
|
||||||
|
|
||||||
|
// Credit & Debit Notes
|
||||||
|
AlegraCreditNote,
|
||||||
|
AlegraDebitNote,
|
||||||
|
|
||||||
|
// Payments
|
||||||
|
AlegraPaymentReceived,
|
||||||
|
AlegraPaymentMade,
|
||||||
|
AlegraPaymentType,
|
||||||
|
AlegraPaymentMethod,
|
||||||
|
|
||||||
|
// Bank
|
||||||
|
AlegraBankAccount,
|
||||||
|
AlegraBankAccountType,
|
||||||
|
AlegraBankTransaction,
|
||||||
|
AlegraBankTransactionType,
|
||||||
|
AlegraBankReconciliation,
|
||||||
|
|
||||||
|
// Items
|
||||||
|
AlegraItem,
|
||||||
|
AlegraItemType,
|
||||||
|
AlegraItemPrice,
|
||||||
|
AlegraItemInventory,
|
||||||
|
AlegraItemVariant,
|
||||||
|
|
||||||
|
// Categories & Cost Centers
|
||||||
|
AlegraCategory,
|
||||||
|
AlegraCategoryType,
|
||||||
|
AlegraCostCenter,
|
||||||
|
|
||||||
|
// Tax & Payment Terms
|
||||||
|
AlegraTax,
|
||||||
|
AlegraTaxType,
|
||||||
|
AlegraPaymentTerm,
|
||||||
|
|
||||||
|
// Currency & Seller
|
||||||
|
AlegraCurrency,
|
||||||
|
AlegraSeller,
|
||||||
|
AlegraPriceList,
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
AlegraTrialBalance,
|
||||||
|
AlegraTrialBalanceAccount,
|
||||||
|
AlegraProfitAndLoss,
|
||||||
|
AlegraPLSection,
|
||||||
|
AlegraBalanceSheet,
|
||||||
|
AlegraBalanceSection,
|
||||||
|
AlegraCashFlow,
|
||||||
|
AlegraCashFlowSection,
|
||||||
|
AlegraTaxReport,
|
||||||
|
|
||||||
|
// Webhooks
|
||||||
|
AlegraWebhookEvent,
|
||||||
|
AlegraWebhookSubscription,
|
||||||
|
AlegraWebhookPayload,
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
AlegraSyncState,
|
||||||
|
AlegraSyncResult,
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
AlegraErrorCode,
|
||||||
|
} from './alegra.types.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlegraError,
|
||||||
|
AlegraRateLimitError,
|
||||||
|
AlegraAuthError,
|
||||||
|
} from './alegra.types.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Config
|
||||||
|
AlegraConfigSchema,
|
||||||
|
type AlegraConfigInput,
|
||||||
|
|
||||||
|
// Pagination & Filters
|
||||||
|
PaginationSchema,
|
||||||
|
DateFilterSchema,
|
||||||
|
PeriodSchema,
|
||||||
|
type PaginationInput,
|
||||||
|
type DateFilterInput,
|
||||||
|
type PeriodInput,
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
ContactInputSchema,
|
||||||
|
ContactFilterSchema,
|
||||||
|
IdentificationTypeSchema,
|
||||||
|
ContactTypeSchema,
|
||||||
|
AddressSchema,
|
||||||
|
type ContactInput,
|
||||||
|
type ContactFilter,
|
||||||
|
|
||||||
|
// Invoices
|
||||||
|
InvoiceInputSchema,
|
||||||
|
InvoiceFilterSchema,
|
||||||
|
InvoiceItemSchema,
|
||||||
|
InvoiceStatusSchema,
|
||||||
|
type InvoiceInput,
|
||||||
|
type InvoiceFilter,
|
||||||
|
|
||||||
|
// Credit Notes
|
||||||
|
CreditNoteInputSchema,
|
||||||
|
CreditNoteFilterSchema,
|
||||||
|
type CreditNoteInput,
|
||||||
|
type CreditNoteFilter,
|
||||||
|
|
||||||
|
// Debit Notes
|
||||||
|
DebitNoteInputSchema,
|
||||||
|
DebitNoteFilterSchema,
|
||||||
|
type DebitNoteInput,
|
||||||
|
type DebitNoteFilter,
|
||||||
|
|
||||||
|
// Payments
|
||||||
|
PaymentReceivedInputSchema,
|
||||||
|
PaymentMadeInputSchema,
|
||||||
|
PaymentFilterSchema,
|
||||||
|
PaymentMethodSchema,
|
||||||
|
type PaymentReceivedInput,
|
||||||
|
type PaymentMadeInput,
|
||||||
|
type PaymentFilter,
|
||||||
|
|
||||||
|
// Bank Accounts
|
||||||
|
BankAccountInputSchema,
|
||||||
|
BankAccountFilterSchema,
|
||||||
|
BankTransactionFilterSchema,
|
||||||
|
BankAccountTypeSchema,
|
||||||
|
type BankAccountInput,
|
||||||
|
type BankAccountFilter,
|
||||||
|
type BankTransactionFilter,
|
||||||
|
|
||||||
|
// Items
|
||||||
|
ItemInputSchema,
|
||||||
|
ItemFilterSchema,
|
||||||
|
ItemTypeSchema,
|
||||||
|
type ItemInput,
|
||||||
|
type ItemFilter,
|
||||||
|
|
||||||
|
// Categories & Cost Centers
|
||||||
|
CategoryInputSchema,
|
||||||
|
CostCenterInputSchema,
|
||||||
|
type CategoryInput,
|
||||||
|
type CostCenterInput,
|
||||||
|
|
||||||
|
// Tax
|
||||||
|
TaxInputSchema,
|
||||||
|
type TaxInput,
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
TrialBalanceRequestSchema,
|
||||||
|
ProfitAndLossRequestSchema,
|
||||||
|
BalanceSheetRequestSchema,
|
||||||
|
CashFlowRequestSchema,
|
||||||
|
TaxReportRequestSchema,
|
||||||
|
type TrialBalanceRequest,
|
||||||
|
type ProfitAndLossRequest,
|
||||||
|
type BalanceSheetRequest,
|
||||||
|
type CashFlowRequest,
|
||||||
|
type TaxReportRequest,
|
||||||
|
|
||||||
|
// Webhooks
|
||||||
|
WebhookEventSchema,
|
||||||
|
WebhookSubscriptionInputSchema,
|
||||||
|
type WebhookSubscriptionInput,
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
SyncConfigSchema,
|
||||||
|
type SyncConfig,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
validateAlegraConfig,
|
||||||
|
validatePeriod,
|
||||||
|
validatePagination,
|
||||||
|
} from './alegra.schema.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Facade Class for Easy Usage
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { AlegraClient, createAlegraClient } from './alegra.client.js';
|
||||||
|
import { AlegraInvoicesConnector, createInvoicesConnector } from './invoices.connector.js';
|
||||||
|
import { AlegraContactsConnector, createContactsConnector } from './contacts.connector.js';
|
||||||
|
import { AlegraPaymentsConnector, createPaymentsConnector } from './payments.connector.js';
|
||||||
|
import { AlegraReportsConnector, createReportsConnector } from './reports.connector.js';
|
||||||
|
import { AlegraSyncService, createAlegraSyncService } from './alegra.sync.js';
|
||||||
|
import type { AlegraConfig } from './alegra.types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clase de fachada que proporciona acceso unificado a toda la funcionalidad de Alegra
|
||||||
|
*/
|
||||||
|
export class Alegra {
|
||||||
|
public readonly client: AlegraClient;
|
||||||
|
public readonly invoices: AlegraInvoicesConnector;
|
||||||
|
public readonly contacts: AlegraContactsConnector;
|
||||||
|
public readonly payments: AlegraPaymentsConnector;
|
||||||
|
public readonly reports: AlegraReportsConnector;
|
||||||
|
public readonly sync: AlegraSyncService;
|
||||||
|
|
||||||
|
constructor(config: AlegraConfig) {
|
||||||
|
this.client = createAlegraClient(config);
|
||||||
|
this.invoices = createInvoicesConnector(this.client);
|
||||||
|
this.contacts = createContactsConnector(this.client);
|
||||||
|
this.payments = createPaymentsConnector(this.client);
|
||||||
|
this.reports = createReportsConnector(this.client);
|
||||||
|
this.sync = createAlegraSyncService(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si las credenciales son validas
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<boolean> {
|
||||||
|
return this.client.testConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene informacion de la compania
|
||||||
|
*/
|
||||||
|
async getCompanyInfo(): Promise<Record<string, unknown>> {
|
||||||
|
return this.client.getCompanyInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia de Alegra con todos los conectores
|
||||||
|
*/
|
||||||
|
export function createAlegra(config: AlegraConfig): Alegra {
|
||||||
|
return new Alegra(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Alegra;
|
||||||
486
apps/api/src/services/integrations/alegra/invoices.connector.ts
Normal file
486
apps/api/src/services/integrations/alegra/invoices.connector.ts
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
/**
|
||||||
|
* Alegra Invoices Connector
|
||||||
|
* Conector para facturacion: facturas, notas de credito y notas de debito
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AlegraClient } from './alegra.client.js';
|
||||||
|
import {
|
||||||
|
AlegraInvoice,
|
||||||
|
AlegraCreditNote,
|
||||||
|
AlegraDebitNote,
|
||||||
|
AlegraPaymentReference,
|
||||||
|
AlegraInvoiceStatus,
|
||||||
|
AlegraStamp,
|
||||||
|
} from './alegra.types.js';
|
||||||
|
import {
|
||||||
|
InvoiceFilter,
|
||||||
|
InvoiceInput,
|
||||||
|
CreditNoteFilter,
|
||||||
|
CreditNoteInput,
|
||||||
|
DebitNoteFilter,
|
||||||
|
DebitNoteInput,
|
||||||
|
InvoiceFilterSchema,
|
||||||
|
CreditNoteFilterSchema,
|
||||||
|
DebitNoteFilterSchema,
|
||||||
|
} from './alegra.schema.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invoices Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para operaciones de facturacion en Alegra
|
||||||
|
*/
|
||||||
|
export class AlegraInvoicesConnector {
|
||||||
|
constructor(private readonly client: AlegraClient) {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invoice Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene listado de facturas con filtros opcionales
|
||||||
|
*/
|
||||||
|
async getInvoices(filters: Partial<InvoiceFilter> = {}): Promise<{
|
||||||
|
data: AlegraInvoice[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}> {
|
||||||
|
const validatedFilters = InvoiceFilterSchema.partial().parse(filters);
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
start: validatedFilters.start || 0,
|
||||||
|
limit: validatedFilters.limit || 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aplicar filtros opcionales
|
||||||
|
if (validatedFilters.startDate) {
|
||||||
|
params.start_date = validatedFilters.startDate;
|
||||||
|
}
|
||||||
|
if (validatedFilters.endDate) {
|
||||||
|
params.end_date = validatedFilters.endDate;
|
||||||
|
}
|
||||||
|
if (validatedFilters.status) {
|
||||||
|
params.status = validatedFilters.status;
|
||||||
|
}
|
||||||
|
if (validatedFilters.clientId) {
|
||||||
|
params.client = validatedFilters.clientId;
|
||||||
|
}
|
||||||
|
if (validatedFilters.numberTemplateId) {
|
||||||
|
params.numberTemplate = validatedFilters.numberTemplateId;
|
||||||
|
}
|
||||||
|
if (validatedFilters.query) {
|
||||||
|
params.query = validatedFilters.query;
|
||||||
|
}
|
||||||
|
if (validatedFilters.orderField) {
|
||||||
|
params.orderField = validatedFilters.orderField;
|
||||||
|
params.order = validatedFilters.order || 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Getting invoices from Alegra', { filters: params });
|
||||||
|
|
||||||
|
const response = await this.client.get<AlegraInvoice[]>('/invoices', params);
|
||||||
|
const data = Array.isArray(response) ? response : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
hasMore: data.length === (validatedFilters.limit || 30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todas las facturas de un periodo (paginacion automatica)
|
||||||
|
*/
|
||||||
|
async getAllInvoices(filters: Partial<InvoiceFilter> = {}): Promise<AlegraInvoice[]> {
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (filters.startDate) params.start_date = filters.startDate;
|
||||||
|
if (filters.endDate) params.end_date = filters.endDate;
|
||||||
|
if (filters.status) params.status = filters.status;
|
||||||
|
if (filters.clientId) params.client = filters.clientId;
|
||||||
|
|
||||||
|
return this.client.getAllPaginated<AlegraInvoice>('/invoices', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una factura por ID
|
||||||
|
*/
|
||||||
|
async getInvoiceById(id: number): Promise<AlegraInvoice> {
|
||||||
|
logger.debug('Getting invoice by ID from Alegra', { id });
|
||||||
|
return this.client.get<AlegraInvoice>(`/invoices/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los pagos asociados a una factura
|
||||||
|
*/
|
||||||
|
async getInvoicePayments(invoiceId: number): Promise<AlegraPaymentReference[]> {
|
||||||
|
const invoice = await this.getInvoiceById(invoiceId);
|
||||||
|
return invoice.payments || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas por estado
|
||||||
|
*/
|
||||||
|
async getInvoicesByStatus(
|
||||||
|
status: AlegraInvoiceStatus,
|
||||||
|
pagination?: { start?: number; limit?: number }
|
||||||
|
): Promise<AlegraInvoice[]> {
|
||||||
|
const { data } = await this.getInvoices({
|
||||||
|
status,
|
||||||
|
start: pagination?.start,
|
||||||
|
limit: pagination?.limit,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas pendientes de pago
|
||||||
|
*/
|
||||||
|
async getPendingInvoices(): Promise<AlegraInvoice[]> {
|
||||||
|
return this.getAllInvoices({ status: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas vencidas
|
||||||
|
*/
|
||||||
|
async getOverdueInvoices(): Promise<AlegraInvoice[]> {
|
||||||
|
const openInvoices = await this.getAllInvoices({ status: 'open' });
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
return openInvoices.filter(invoice => {
|
||||||
|
const dueDate = new Date(invoice.dueDate);
|
||||||
|
return dueDate < today;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas de un cliente
|
||||||
|
*/
|
||||||
|
async getInvoicesByClient(clientId: number): Promise<AlegraInvoice[]> {
|
||||||
|
return this.getAllInvoices({ clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el timbre fiscal (CFDI) de una factura - Mexico
|
||||||
|
*/
|
||||||
|
async getInvoiceStamp(invoiceId: number): Promise<AlegraStamp | null> {
|
||||||
|
const invoice = await this.getInvoiceById(invoiceId);
|
||||||
|
return invoice.stamp || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca facturas por texto
|
||||||
|
*/
|
||||||
|
async searchInvoices(query: string, limit: number = 30): Promise<AlegraInvoice[]> {
|
||||||
|
const { data } = await this.getInvoices({ query, limit });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una nueva factura
|
||||||
|
*/
|
||||||
|
async createInvoice(input: InvoiceInput): Promise<AlegraInvoice> {
|
||||||
|
logger.info('Creating invoice in Alegra', { client: input.client });
|
||||||
|
return this.client.post<AlegraInvoice>('/invoices', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza una factura existente
|
||||||
|
*/
|
||||||
|
async updateInvoice(id: number, input: Partial<InvoiceInput>): Promise<AlegraInvoice> {
|
||||||
|
logger.info('Updating invoice in Alegra', { id });
|
||||||
|
return this.client.put<AlegraInvoice>(`/invoices/${id}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anula una factura
|
||||||
|
*/
|
||||||
|
async voidInvoice(id: number): Promise<AlegraInvoice> {
|
||||||
|
logger.info('Voiding invoice in Alegra', { id });
|
||||||
|
return this.client.put<AlegraInvoice>(`/invoices/${id}`, { status: 'void' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una factura (solo borradores)
|
||||||
|
*/
|
||||||
|
async deleteInvoice(id: number): Promise<void> {
|
||||||
|
logger.info('Deleting invoice in Alegra', { id });
|
||||||
|
await this.client.delete(`/invoices/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envia factura por email
|
||||||
|
*/
|
||||||
|
async sendInvoiceByEmail(
|
||||||
|
invoiceId: number,
|
||||||
|
emails: string[],
|
||||||
|
options?: { subject?: string; message?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
logger.info('Sending invoice by email', { invoiceId, emails });
|
||||||
|
await this.client.post(`/invoices/${invoiceId}/email`, {
|
||||||
|
emails,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Credit Note Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene listado de notas de credito
|
||||||
|
*/
|
||||||
|
async getCreditNotes(filters: Partial<CreditNoteFilter> = {}): Promise<{
|
||||||
|
data: AlegraCreditNote[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}> {
|
||||||
|
const validatedFilters = CreditNoteFilterSchema.partial().parse(filters);
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
start: validatedFilters.start || 0,
|
||||||
|
limit: validatedFilters.limit || 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (validatedFilters.startDate) params.start_date = validatedFilters.startDate;
|
||||||
|
if (validatedFilters.endDate) params.end_date = validatedFilters.endDate;
|
||||||
|
if (validatedFilters.status) params.status = validatedFilters.status;
|
||||||
|
if (validatedFilters.clientId) params.client = validatedFilters.clientId;
|
||||||
|
|
||||||
|
logger.debug('Getting credit notes from Alegra', { filters: params });
|
||||||
|
|
||||||
|
const response = await this.client.get<AlegraCreditNote[]>('/credit-notes', params);
|
||||||
|
const data = Array.isArray(response) ? response : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
hasMore: data.length === (validatedFilters.limit || 30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todas las notas de credito de un periodo
|
||||||
|
*/
|
||||||
|
async getAllCreditNotes(filters: Partial<CreditNoteFilter> = {}): Promise<AlegraCreditNote[]> {
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (filters.startDate) params.start_date = filters.startDate;
|
||||||
|
if (filters.endDate) params.end_date = filters.endDate;
|
||||||
|
if (filters.status) params.status = filters.status;
|
||||||
|
if (filters.clientId) params.client = filters.clientId;
|
||||||
|
|
||||||
|
return this.client.getAllPaginated<AlegraCreditNote>('/credit-notes', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una nota de credito por ID
|
||||||
|
*/
|
||||||
|
async getCreditNoteById(id: number): Promise<AlegraCreditNote> {
|
||||||
|
logger.debug('Getting credit note by ID from Alegra', { id });
|
||||||
|
return this.client.get<AlegraCreditNote>(`/credit-notes/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una nota de credito
|
||||||
|
*/
|
||||||
|
async createCreditNote(input: CreditNoteInput): Promise<AlegraCreditNote> {
|
||||||
|
logger.info('Creating credit note in Alegra', { client: input.client });
|
||||||
|
return this.client.post<AlegraCreditNote>('/credit-notes', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza una nota de credito
|
||||||
|
*/
|
||||||
|
async updateCreditNote(id: number, input: Partial<CreditNoteInput>): Promise<AlegraCreditNote> {
|
||||||
|
logger.info('Updating credit note in Alegra', { id });
|
||||||
|
return this.client.put<AlegraCreditNote>(`/credit-notes/${id}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una nota de credito
|
||||||
|
*/
|
||||||
|
async deleteCreditNote(id: number): Promise<void> {
|
||||||
|
logger.info('Deleting credit note in Alegra', { id });
|
||||||
|
await this.client.delete(`/credit-notes/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Debit Note Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene listado de notas de debito
|
||||||
|
*/
|
||||||
|
async getDebitNotes(filters: Partial<DebitNoteFilter> = {}): Promise<{
|
||||||
|
data: AlegraDebitNote[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}> {
|
||||||
|
const validatedFilters = DebitNoteFilterSchema.partial().parse(filters);
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
start: validatedFilters.start || 0,
|
||||||
|
limit: validatedFilters.limit || 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (validatedFilters.startDate) params.start_date = validatedFilters.startDate;
|
||||||
|
if (validatedFilters.endDate) params.end_date = validatedFilters.endDate;
|
||||||
|
if (validatedFilters.status) params.status = validatedFilters.status;
|
||||||
|
if (validatedFilters.clientId) params.client = validatedFilters.clientId;
|
||||||
|
|
||||||
|
logger.debug('Getting debit notes from Alegra', { filters: params });
|
||||||
|
|
||||||
|
const response = await this.client.get<AlegraDebitNote[]>('/debit-notes', params);
|
||||||
|
const data = Array.isArray(response) ? response : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
hasMore: data.length === (validatedFilters.limit || 30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todas las notas de debito de un periodo
|
||||||
|
*/
|
||||||
|
async getAllDebitNotes(filters: Partial<DebitNoteFilter> = {}): Promise<AlegraDebitNote[]> {
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (filters.startDate) params.start_date = filters.startDate;
|
||||||
|
if (filters.endDate) params.end_date = filters.endDate;
|
||||||
|
if (filters.status) params.status = filters.status;
|
||||||
|
if (filters.clientId) params.client = filters.clientId;
|
||||||
|
|
||||||
|
return this.client.getAllPaginated<AlegraDebitNote>('/debit-notes', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una nota de debito por ID
|
||||||
|
*/
|
||||||
|
async getDebitNoteById(id: number): Promise<AlegraDebitNote> {
|
||||||
|
logger.debug('Getting debit note by ID from Alegra', { id });
|
||||||
|
return this.client.get<AlegraDebitNote>(`/debit-notes/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una nota de debito
|
||||||
|
*/
|
||||||
|
async createDebitNote(input: DebitNoteInput): Promise<AlegraDebitNote> {
|
||||||
|
logger.info('Creating debit note in Alegra', { client: input.client });
|
||||||
|
return this.client.post<AlegraDebitNote>('/debit-notes', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza una nota de debito
|
||||||
|
*/
|
||||||
|
async updateDebitNote(id: number, input: Partial<DebitNoteInput>): Promise<AlegraDebitNote> {
|
||||||
|
logger.info('Updating debit note in Alegra', { id });
|
||||||
|
return this.client.put<AlegraDebitNote>(`/debit-notes/${id}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una nota de debito
|
||||||
|
*/
|
||||||
|
async deleteDebitNote(id: number): Promise<void> {
|
||||||
|
logger.info('Deleting debit note in Alegra', { id });
|
||||||
|
await this.client.delete(`/debit-notes/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Aggregate Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de facturacion de un periodo
|
||||||
|
*/
|
||||||
|
async getInvoicingSummary(startDate: string, endDate: string): Promise<{
|
||||||
|
totalInvoices: number;
|
||||||
|
totalAmount: number;
|
||||||
|
paidAmount: number;
|
||||||
|
pendingAmount: number;
|
||||||
|
invoicesByStatus: Record<AlegraInvoiceStatus, number>;
|
||||||
|
creditNotesTotal: number;
|
||||||
|
debitNotesTotal: number;
|
||||||
|
}> {
|
||||||
|
const [invoices, creditNotes, debitNotes] = await Promise.all([
|
||||||
|
this.getAllInvoices({ startDate, endDate }),
|
||||||
|
this.getAllCreditNotes({ startDate, endDate }),
|
||||||
|
this.getAllDebitNotes({ startDate, endDate }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const invoicesByStatus: Record<AlegraInvoiceStatus, number> = {
|
||||||
|
draft: 0,
|
||||||
|
open: 0,
|
||||||
|
paid: 0,
|
||||||
|
void: 0,
|
||||||
|
overdue: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalAmount = 0;
|
||||||
|
let paidAmount = 0;
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
invoicesByStatus[invoice.status]++;
|
||||||
|
totalAmount += invoice.total;
|
||||||
|
if (invoice.status === 'paid') {
|
||||||
|
paidAmount += invoice.total;
|
||||||
|
} else if (invoice.totalPaid) {
|
||||||
|
paidAmount += invoice.totalPaid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const creditNotesTotal = creditNotes.reduce((sum, cn) => sum + cn.total, 0);
|
||||||
|
const debitNotesTotal = debitNotes.reduce((sum, dn) => sum + dn.total, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalInvoices: invoices.length,
|
||||||
|
totalAmount,
|
||||||
|
paidAmount,
|
||||||
|
pendingAmount: totalAmount - paidAmount,
|
||||||
|
invoicesByStatus,
|
||||||
|
creditNotesTotal,
|
||||||
|
debitNotesTotal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas modificadas desde una fecha (para sync incremental)
|
||||||
|
*/
|
||||||
|
async getInvoicesModifiedSince(
|
||||||
|
sinceDate: Date,
|
||||||
|
pagination?: { start?: number; limit?: number }
|
||||||
|
): Promise<AlegraInvoice[]> {
|
||||||
|
// Alegra no tiene filtro de fecha de modificacion directamente
|
||||||
|
// Obtenemos por fecha de creacion y filtramos
|
||||||
|
const startDate = sinceDate.toISOString().split('T')[0];
|
||||||
|
const endDate = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const { data } = await this.getInvoices({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
start: pagination?.start,
|
||||||
|
limit: pagination?.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrar por updatedAt si esta disponible
|
||||||
|
return data.filter(invoice => {
|
||||||
|
if (invoice.updatedAt) {
|
||||||
|
return new Date(invoice.updatedAt) >= sinceDate;
|
||||||
|
}
|
||||||
|
if (invoice.createdAt) {
|
||||||
|
return new Date(invoice.createdAt) >= sinceDate;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un conector de facturacion de Alegra
|
||||||
|
*/
|
||||||
|
export function createInvoicesConnector(client: AlegraClient): AlegraInvoicesConnector {
|
||||||
|
return new AlegraInvoicesConnector(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlegraInvoicesConnector;
|
||||||
630
apps/api/src/services/integrations/alegra/payments.connector.ts
Normal file
630
apps/api/src/services/integrations/alegra/payments.connector.ts
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
/**
|
||||||
|
* Alegra Payments Connector
|
||||||
|
* Conector para pagos recibidos, pagos realizados, cuentas bancarias y transacciones
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AlegraClient } from './alegra.client.js';
|
||||||
|
import {
|
||||||
|
AlegraPaymentReceived,
|
||||||
|
AlegraPaymentMade,
|
||||||
|
AlegraBankAccount,
|
||||||
|
AlegraBankTransaction,
|
||||||
|
AlegraBankReconciliation,
|
||||||
|
AlegraBankAccountType,
|
||||||
|
AlegraPaymentMethod,
|
||||||
|
} from './alegra.types.js';
|
||||||
|
import {
|
||||||
|
PaymentFilter,
|
||||||
|
PaymentReceivedInput,
|
||||||
|
PaymentMadeInput,
|
||||||
|
BankAccountFilter,
|
||||||
|
BankAccountInput,
|
||||||
|
BankTransactionFilter,
|
||||||
|
PaymentFilterSchema,
|
||||||
|
BankAccountFilterSchema,
|
||||||
|
BankTransactionFilterSchema,
|
||||||
|
PeriodInput,
|
||||||
|
} from './alegra.schema.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Payments Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para operaciones de pagos y bancos en Alegra
|
||||||
|
*/
|
||||||
|
export class AlegraPaymentsConnector {
|
||||||
|
constructor(private readonly client: AlegraClient) {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Payments Received (Cobros)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene listado de pagos recibidos
|
||||||
|
*/
|
||||||
|
async getPaymentsReceived(
|
||||||
|
period?: PeriodInput,
|
||||||
|
filters: Partial<PaymentFilter> = {}
|
||||||
|
): Promise<{
|
||||||
|
data: AlegraPaymentReceived[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}> {
|
||||||
|
const validatedFilters = PaymentFilterSchema.partial().parse(filters);
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
start: validatedFilters.start || 0,
|
||||||
|
limit: validatedFilters.limit || 30,
|
||||||
|
type: 'in', // Pagos recibidos (ingresos)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (period) {
|
||||||
|
params.start_date = period.from;
|
||||||
|
params.end_date = period.to;
|
||||||
|
} else if (validatedFilters.startDate) {
|
||||||
|
params.start_date = validatedFilters.startDate;
|
||||||
|
if (validatedFilters.endDate) {
|
||||||
|
params.end_date = validatedFilters.endDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validatedFilters.bankAccountId) {
|
||||||
|
params.bankAccount = validatedFilters.bankAccountId;
|
||||||
|
}
|
||||||
|
if (validatedFilters.contactId) {
|
||||||
|
params.client = validatedFilters.contactId;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Getting payments received from Alegra', { filters: params });
|
||||||
|
|
||||||
|
const response = await this.client.get<AlegraPaymentReceived[]>('/payments', params);
|
||||||
|
const data = Array.isArray(response) ? response : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
hasMore: data.length === (validatedFilters.limit || 30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los pagos recibidos de un periodo
|
||||||
|
*/
|
||||||
|
async getAllPaymentsReceived(period: PeriodInput): Promise<AlegraPaymentReceived[]> {
|
||||||
|
return this.client.getAllPaginated<AlegraPaymentReceived>('/payments', {
|
||||||
|
type: 'in',
|
||||||
|
start_date: period.from,
|
||||||
|
end_date: period.to,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un pago recibido por ID
|
||||||
|
*/
|
||||||
|
async getPaymentReceivedById(id: number): Promise<AlegraPaymentReceived> {
|
||||||
|
logger.debug('Getting payment received by ID from Alegra', { id });
|
||||||
|
return this.client.get<AlegraPaymentReceived>(`/payments/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un pago recibido
|
||||||
|
*/
|
||||||
|
async createPaymentReceived(input: PaymentReceivedInput): Promise<AlegraPaymentReceived> {
|
||||||
|
logger.info('Creating payment received in Alegra', {
|
||||||
|
amount: input.amount,
|
||||||
|
client: input.client.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...input,
|
||||||
|
type: 'in',
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.client.post<AlegraPaymentReceived>('/payments', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza un pago recibido
|
||||||
|
*/
|
||||||
|
async updatePaymentReceived(
|
||||||
|
id: number,
|
||||||
|
input: Partial<PaymentReceivedInput>
|
||||||
|
): Promise<AlegraPaymentReceived> {
|
||||||
|
logger.info('Updating payment received in Alegra', { id });
|
||||||
|
return this.client.put<AlegraPaymentReceived>(`/payments/${id}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina un pago recibido
|
||||||
|
*/
|
||||||
|
async deletePaymentReceived(id: number): Promise<void> {
|
||||||
|
logger.info('Deleting payment received in Alegra', { id });
|
||||||
|
await this.client.delete(`/payments/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Payments Made (Pagos a Proveedores)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene listado de pagos realizados
|
||||||
|
*/
|
||||||
|
async getPaymentsMade(
|
||||||
|
period?: PeriodInput,
|
||||||
|
filters: Partial<PaymentFilter> = {}
|
||||||
|
): Promise<{
|
||||||
|
data: AlegraPaymentMade[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}> {
|
||||||
|
const validatedFilters = PaymentFilterSchema.partial().parse(filters);
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
start: validatedFilters.start || 0,
|
||||||
|
limit: validatedFilters.limit || 30,
|
||||||
|
type: 'out', // Pagos realizados (egresos)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (period) {
|
||||||
|
params.start_date = period.from;
|
||||||
|
params.end_date = period.to;
|
||||||
|
} else if (validatedFilters.startDate) {
|
||||||
|
params.start_date = validatedFilters.startDate;
|
||||||
|
if (validatedFilters.endDate) {
|
||||||
|
params.end_date = validatedFilters.endDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validatedFilters.bankAccountId) {
|
||||||
|
params.bankAccount = validatedFilters.bankAccountId;
|
||||||
|
}
|
||||||
|
if (validatedFilters.contactId) {
|
||||||
|
params.provider = validatedFilters.contactId;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Getting payments made from Alegra', { filters: params });
|
||||||
|
|
||||||
|
const response = await this.client.get<AlegraPaymentMade[]>('/payments', params);
|
||||||
|
const data = Array.isArray(response) ? response : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
hasMore: data.length === (validatedFilters.limit || 30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los pagos realizados de un periodo
|
||||||
|
*/
|
||||||
|
async getAllPaymentsMade(period: PeriodInput): Promise<AlegraPaymentMade[]> {
|
||||||
|
return this.client.getAllPaginated<AlegraPaymentMade>('/payments', {
|
||||||
|
type: 'out',
|
||||||
|
start_date: period.from,
|
||||||
|
end_date: period.to,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un pago realizado por ID
|
||||||
|
*/
|
||||||
|
async getPaymentMadeById(id: number): Promise<AlegraPaymentMade> {
|
||||||
|
logger.debug('Getting payment made by ID from Alegra', { id });
|
||||||
|
return this.client.get<AlegraPaymentMade>(`/payments/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un pago realizado
|
||||||
|
*/
|
||||||
|
async createPaymentMade(input: PaymentMadeInput): Promise<AlegraPaymentMade> {
|
||||||
|
logger.info('Creating payment made in Alegra', {
|
||||||
|
amount: input.amount,
|
||||||
|
provider: input.provider.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...input,
|
||||||
|
type: 'out',
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.client.post<AlegraPaymentMade>('/payments', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza un pago realizado
|
||||||
|
*/
|
||||||
|
async updatePaymentMade(
|
||||||
|
id: number,
|
||||||
|
input: Partial<PaymentMadeInput>
|
||||||
|
): Promise<AlegraPaymentMade> {
|
||||||
|
logger.info('Updating payment made in Alegra', { id });
|
||||||
|
return this.client.put<AlegraPaymentMade>(`/payments/${id}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina un pago realizado
|
||||||
|
*/
|
||||||
|
async deletePaymentMade(id: number): Promise<void> {
|
||||||
|
logger.info('Deleting payment made in Alegra', { id });
|
||||||
|
await this.client.delete(`/payments/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bank Accounts
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene listado de cuentas bancarias
|
||||||
|
*/
|
||||||
|
async getBankAccounts(
|
||||||
|
filters: Partial<BankAccountFilter> = {}
|
||||||
|
): Promise<{
|
||||||
|
data: AlegraBankAccount[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}> {
|
||||||
|
const validatedFilters = BankAccountFilterSchema.partial().parse(filters);
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
start: validatedFilters.start || 0,
|
||||||
|
limit: validatedFilters.limit || 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (validatedFilters.type) {
|
||||||
|
params.type = validatedFilters.type;
|
||||||
|
}
|
||||||
|
if (validatedFilters.status) {
|
||||||
|
params.status = validatedFilters.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Getting bank accounts from Alegra', { filters: params });
|
||||||
|
|
||||||
|
const response = await this.client.get<AlegraBankAccount[]>('/bank-accounts', params);
|
||||||
|
const data = Array.isArray(response) ? response : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
hasMore: data.length === (validatedFilters.limit || 30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todas las cuentas bancarias
|
||||||
|
*/
|
||||||
|
async getAllBankAccounts(): Promise<AlegraBankAccount[]> {
|
||||||
|
return this.client.getAllPaginated<AlegraBankAccount>('/bank-accounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una cuenta bancaria por ID
|
||||||
|
*/
|
||||||
|
async getBankAccountById(id: number): Promise<AlegraBankAccount> {
|
||||||
|
logger.debug('Getting bank account by ID from Alegra', { id });
|
||||||
|
return this.client.get<AlegraBankAccount>(`/bank-accounts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene cuentas bancarias por tipo
|
||||||
|
*/
|
||||||
|
async getBankAccountsByType(type: AlegraBankAccountType): Promise<AlegraBankAccount[]> {
|
||||||
|
const { data } = await this.getBankAccounts({ type });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una cuenta bancaria
|
||||||
|
*/
|
||||||
|
async createBankAccount(input: BankAccountInput): Promise<AlegraBankAccount> {
|
||||||
|
logger.info('Creating bank account in Alegra', { name: input.name });
|
||||||
|
return this.client.post<AlegraBankAccount>('/bank-accounts', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza una cuenta bancaria
|
||||||
|
*/
|
||||||
|
async updateBankAccount(
|
||||||
|
id: number,
|
||||||
|
input: Partial<BankAccountInput>
|
||||||
|
): Promise<AlegraBankAccount> {
|
||||||
|
logger.info('Updating bank account in Alegra', { id });
|
||||||
|
return this.client.put<AlegraBankAccount>(`/bank-accounts/${id}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una cuenta bancaria
|
||||||
|
*/
|
||||||
|
async deleteBankAccount(id: number): Promise<void> {
|
||||||
|
logger.info('Deleting bank account in Alegra', { id });
|
||||||
|
await this.client.delete(`/bank-accounts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bank Transactions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene transacciones bancarias de una cuenta
|
||||||
|
*/
|
||||||
|
async getBankTransactions(
|
||||||
|
accountId: number,
|
||||||
|
period?: PeriodInput,
|
||||||
|
filters: Partial<BankTransactionFilter> = {}
|
||||||
|
): Promise<{
|
||||||
|
data: AlegraBankTransaction[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}> {
|
||||||
|
const validatedFilters = BankTransactionFilterSchema.partial().parse(filters);
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
start: validatedFilters.start || 0,
|
||||||
|
limit: validatedFilters.limit || 30,
|
||||||
|
bankAccount: accountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (period) {
|
||||||
|
params.start_date = period.from;
|
||||||
|
params.end_date = period.to;
|
||||||
|
} else if (validatedFilters.startDate) {
|
||||||
|
params.start_date = validatedFilters.startDate;
|
||||||
|
if (validatedFilters.endDate) {
|
||||||
|
params.end_date = validatedFilters.endDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validatedFilters.type) {
|
||||||
|
params.type = validatedFilters.type;
|
||||||
|
}
|
||||||
|
if (validatedFilters.reconciled !== undefined) {
|
||||||
|
params.reconciled = validatedFilters.reconciled;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Getting bank transactions from Alegra', { accountId, filters: params });
|
||||||
|
|
||||||
|
// Alegra puede no tener endpoint directo de transacciones bancarias
|
||||||
|
// Usamos el endpoint de movimientos o combinamos pagos
|
||||||
|
const response = await this.client.get<AlegraBankTransaction[]>(
|
||||||
|
'/bank-accounts/transactions',
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const data = Array.isArray(response) ? response : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
hasMore: data.length === (validatedFilters.limit || 30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todas las transacciones bancarias de un periodo
|
||||||
|
*/
|
||||||
|
async getAllBankTransactions(
|
||||||
|
accountId: number,
|
||||||
|
period: PeriodInput
|
||||||
|
): Promise<AlegraBankTransaction[]> {
|
||||||
|
return this.client.getAllPaginated<AlegraBankTransaction>(
|
||||||
|
'/bank-accounts/transactions',
|
||||||
|
{
|
||||||
|
bankAccount: accountId,
|
||||||
|
start_date: period.from,
|
||||||
|
end_date: period.to,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bank Reconciliation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado de conciliacion bancaria
|
||||||
|
*/
|
||||||
|
async getBankReconciliation(
|
||||||
|
accountId: number,
|
||||||
|
period?: PeriodInput
|
||||||
|
): Promise<AlegraBankReconciliation> {
|
||||||
|
logger.debug('Getting bank reconciliation from Alegra', { accountId, period });
|
||||||
|
|
||||||
|
const bankAccount = await this.getBankAccountById(accountId);
|
||||||
|
|
||||||
|
// Determinar periodo (ultimo mes si no se especifica)
|
||||||
|
const endDate = period?.to || new Date().toISOString().split('T')[0];
|
||||||
|
const startDate = period?.from || (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMonth(d.getMonth() - 1);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Obtener transacciones del periodo
|
||||||
|
const transactions = await this.getAllBankTransactions(accountId, {
|
||||||
|
from: startDate,
|
||||||
|
to: endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Separar conciliadas y no conciliadas
|
||||||
|
const reconciled = transactions.filter(t => t.reconciled);
|
||||||
|
const unreconciled = transactions.filter(t => !t.reconciled);
|
||||||
|
|
||||||
|
// Calcular balances
|
||||||
|
const openingBalance = bankAccount.initialBalance || 0;
|
||||||
|
let closingBalance = openingBalance;
|
||||||
|
let reconciledBalance = openingBalance;
|
||||||
|
|
||||||
|
for (const t of transactions) {
|
||||||
|
const amount = t.type === 'deposit' || t.type === 'payment-in'
|
||||||
|
? t.amount
|
||||||
|
: -t.amount;
|
||||||
|
closingBalance += amount;
|
||||||
|
if (t.reconciled) {
|
||||||
|
reconciledBalance += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bankAccountId: accountId,
|
||||||
|
bankAccount,
|
||||||
|
period: {
|
||||||
|
from: startDate,
|
||||||
|
to: endDate,
|
||||||
|
},
|
||||||
|
openingBalance,
|
||||||
|
closingBalance,
|
||||||
|
reconciledBalance,
|
||||||
|
difference: closingBalance - reconciledBalance,
|
||||||
|
transactions: {
|
||||||
|
reconciled,
|
||||||
|
unreconciled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca transacciones como conciliadas
|
||||||
|
*/
|
||||||
|
async reconcileTransactions(transactionIds: number[]): Promise<void> {
|
||||||
|
logger.info('Reconciling transactions in Alegra', { count: transactionIds.length });
|
||||||
|
|
||||||
|
for (const id of transactionIds) {
|
||||||
|
await this.client.put(`/bank-accounts/transactions/${id}`, {
|
||||||
|
reconciled: true,
|
||||||
|
reconciledDate: new Date().toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Summary & Statistics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de pagos de un periodo
|
||||||
|
*/
|
||||||
|
async getPaymentsSummary(period: PeriodInput): Promise<{
|
||||||
|
received: {
|
||||||
|
total: number;
|
||||||
|
count: number;
|
||||||
|
byMethod: Record<AlegraPaymentMethod, number>;
|
||||||
|
};
|
||||||
|
made: {
|
||||||
|
total: number;
|
||||||
|
count: number;
|
||||||
|
byMethod: Record<AlegraPaymentMethod, number>;
|
||||||
|
};
|
||||||
|
netCashFlow: number;
|
||||||
|
}> {
|
||||||
|
const [paymentsReceived, paymentsMade] = await Promise.all([
|
||||||
|
this.getAllPaymentsReceived(period),
|
||||||
|
this.getAllPaymentsMade(period),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byMethodReceived: Record<AlegraPaymentMethod, number> = {
|
||||||
|
'cash': 0,
|
||||||
|
'debit-card': 0,
|
||||||
|
'credit-card': 0,
|
||||||
|
'transfer': 0,
|
||||||
|
'check': 0,
|
||||||
|
'deposit': 0,
|
||||||
|
'electronic-money': 0,
|
||||||
|
'consignment': 0,
|
||||||
|
'other': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const byMethodMade: Record<AlegraPaymentMethod, number> = { ...byMethodReceived };
|
||||||
|
|
||||||
|
let totalReceived = 0;
|
||||||
|
for (const payment of paymentsReceived) {
|
||||||
|
totalReceived += payment.amount;
|
||||||
|
const method = payment.paymentMethod || 'other';
|
||||||
|
byMethodReceived[method] = (byMethodReceived[method] || 0) + payment.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalMade = 0;
|
||||||
|
for (const payment of paymentsMade) {
|
||||||
|
totalMade += payment.amount;
|
||||||
|
const method = payment.paymentMethod || 'other';
|
||||||
|
byMethodMade[method] = (byMethodMade[method] || 0) + payment.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
received: {
|
||||||
|
total: totalReceived,
|
||||||
|
count: paymentsReceived.length,
|
||||||
|
byMethod: byMethodReceived,
|
||||||
|
},
|
||||||
|
made: {
|
||||||
|
total: totalMade,
|
||||||
|
count: paymentsMade.length,
|
||||||
|
byMethod: byMethodMade,
|
||||||
|
},
|
||||||
|
netCashFlow: totalReceived - totalMade,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene balance de cuentas bancarias
|
||||||
|
*/
|
||||||
|
async getBankAccountsBalance(): Promise<Array<{
|
||||||
|
account: AlegraBankAccount;
|
||||||
|
currentBalance: number;
|
||||||
|
}>> {
|
||||||
|
const accounts = await this.getAllBankAccounts();
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
accounts.map(async account => {
|
||||||
|
// Obtener transacciones para calcular balance actual
|
||||||
|
// Simplificado: usamos solo el balance inicial
|
||||||
|
const currentBalance = account.initialBalance || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
currentBalance,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene pagos modificados desde una fecha (para sync incremental)
|
||||||
|
*/
|
||||||
|
async getPaymentsModifiedSince(
|
||||||
|
sinceDate: Date,
|
||||||
|
type: 'received' | 'made' | 'all' = 'all'
|
||||||
|
): Promise<Array<AlegraPaymentReceived | AlegraPaymentMade>> {
|
||||||
|
const startDate = sinceDate.toISOString().split('T')[0];
|
||||||
|
const endDate = new Date().toISOString().split('T')[0];
|
||||||
|
const period = { from: startDate, to: endDate };
|
||||||
|
|
||||||
|
const payments: Array<AlegraPaymentReceived | AlegraPaymentMade> = [];
|
||||||
|
|
||||||
|
if (type === 'received' || type === 'all') {
|
||||||
|
const received = await this.getAllPaymentsReceived(period);
|
||||||
|
payments.push(...received.filter(p => {
|
||||||
|
if (p.updatedAt) return new Date(p.updatedAt) >= sinceDate;
|
||||||
|
if (p.createdAt) return new Date(p.createdAt) >= sinceDate;
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'made' || type === 'all') {
|
||||||
|
const made = await this.getAllPaymentsMade(period);
|
||||||
|
payments.push(...made.filter(p => {
|
||||||
|
if (p.updatedAt) return new Date(p.updatedAt) >= sinceDate;
|
||||||
|
if (p.createdAt) return new Date(p.createdAt) >= sinceDate;
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return payments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un conector de pagos de Alegra
|
||||||
|
*/
|
||||||
|
export function createPaymentsConnector(client: AlegraClient): AlegraPaymentsConnector {
|
||||||
|
return new AlegraPaymentsConnector(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlegraPaymentsConnector;
|
||||||
730
apps/api/src/services/integrations/alegra/reports.connector.ts
Normal file
730
apps/api/src/services/integrations/alegra/reports.connector.ts
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
/**
|
||||||
|
* Alegra Reports Connector
|
||||||
|
* Conector para reportes financieros: balance de comprobacion, P&L, balance general, flujo de caja
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AlegraClient } from './alegra.client.js';
|
||||||
|
import {
|
||||||
|
AlegraTrialBalance,
|
||||||
|
AlegraTrialBalanceAccount,
|
||||||
|
AlegraProfitAndLoss,
|
||||||
|
AlegraBalanceSheet,
|
||||||
|
AlegraCashFlow,
|
||||||
|
AlegraTaxReport,
|
||||||
|
AlegraTaxType,
|
||||||
|
} from './alegra.types.js';
|
||||||
|
import {
|
||||||
|
TrialBalanceRequest,
|
||||||
|
ProfitAndLossRequest,
|
||||||
|
BalanceSheetRequest,
|
||||||
|
CashFlowRequest,
|
||||||
|
TaxReportRequest,
|
||||||
|
PeriodInput,
|
||||||
|
} from './alegra.schema.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Reports Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para operaciones de reportes financieros en Alegra
|
||||||
|
*/
|
||||||
|
export class AlegraReportsConnector {
|
||||||
|
constructor(private readonly client: AlegraClient) {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Trial Balance (Balance de Comprobacion)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el balance de comprobacion a una fecha
|
||||||
|
*/
|
||||||
|
async getTrialBalance(request: TrialBalanceRequest): Promise<AlegraTrialBalance> {
|
||||||
|
logger.debug('Getting trial balance from Alegra', { date: request.date });
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
date: request.date,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.level) {
|
||||||
|
params.level = request.level;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.get<{
|
||||||
|
accounts?: AlegraTrialBalanceAccount[];
|
||||||
|
totals?: {
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
debitBalance: number;
|
||||||
|
creditBalance: number;
|
||||||
|
};
|
||||||
|
}>('/reports/trial-balance', params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: request.date,
|
||||||
|
accounts: response.accounts || [],
|
||||||
|
totals: response.totals || {
|
||||||
|
debit: 0,
|
||||||
|
credit: 0,
|
||||||
|
debitBalance: 0,
|
||||||
|
creditBalance: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting trial balance from Alegra', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
date: request.date,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retornar estructura vacia en caso de error
|
||||||
|
return {
|
||||||
|
date: request.date,
|
||||||
|
accounts: [],
|
||||||
|
totals: {
|
||||||
|
debit: 0,
|
||||||
|
credit: 0,
|
||||||
|
debitBalance: 0,
|
||||||
|
creditBalance: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene balance de comprobacion comparativo (dos fechas)
|
||||||
|
*/
|
||||||
|
async getTrialBalanceComparative(
|
||||||
|
date1: string,
|
||||||
|
date2: string
|
||||||
|
): Promise<{
|
||||||
|
current: AlegraTrialBalance;
|
||||||
|
previous: AlegraTrialBalance;
|
||||||
|
variance: {
|
||||||
|
accounts: Array<{
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
currentBalance: number;
|
||||||
|
previousBalance: number;
|
||||||
|
variance: number;
|
||||||
|
variancePercent: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const [current, previous] = await Promise.all([
|
||||||
|
this.getTrialBalance({ date: date1 }),
|
||||||
|
this.getTrialBalance({ date: date2 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Crear mapa de cuentas del periodo anterior
|
||||||
|
const previousMap = new Map(
|
||||||
|
previous.accounts.map(a => [a.id, a])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calcular variaciones
|
||||||
|
const varianceAccounts = current.accounts.map(account => {
|
||||||
|
const prevAccount = previousMap.get(account.id);
|
||||||
|
const currentBalance = account.debitBalance - account.creditBalance;
|
||||||
|
const previousBalance = prevAccount
|
||||||
|
? prevAccount.debitBalance - prevAccount.creditBalance
|
||||||
|
: 0;
|
||||||
|
const variance = currentBalance - previousBalance;
|
||||||
|
const variancePercent = previousBalance !== 0
|
||||||
|
? (variance / Math.abs(previousBalance)) * 100
|
||||||
|
: currentBalance !== 0 ? 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
code: account.code,
|
||||||
|
name: account.name,
|
||||||
|
currentBalance,
|
||||||
|
previousBalance,
|
||||||
|
variance,
|
||||||
|
variancePercent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
variance: {
|
||||||
|
accounts: varianceAccounts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profit & Loss (Estado de Resultados)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado de resultados de un periodo
|
||||||
|
*/
|
||||||
|
async getProfitAndLoss(request: ProfitAndLossRequest): Promise<AlegraProfitAndLoss> {
|
||||||
|
logger.debug('Getting P&L from Alegra', {
|
||||||
|
from: request.from,
|
||||||
|
to: request.to,
|
||||||
|
});
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
start_date: request.from,
|
||||||
|
end_date: request.to,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.costCenterId) {
|
||||||
|
params.costCenter = request.costCenterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.get<{
|
||||||
|
income?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number }> };
|
||||||
|
expenses?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number }> };
|
||||||
|
costs?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number }> };
|
||||||
|
grossProfit?: number;
|
||||||
|
operatingProfit?: number;
|
||||||
|
netProfit?: number;
|
||||||
|
}>('/reports/profit-and-loss', params);
|
||||||
|
|
||||||
|
const income = response.income || { total: 0, items: [] };
|
||||||
|
const expenses = response.expenses || { total: 0, items: [] };
|
||||||
|
const costs = response.costs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { from: request.from, to: request.to },
|
||||||
|
income,
|
||||||
|
expenses,
|
||||||
|
costs,
|
||||||
|
grossProfit: response.grossProfit ?? (income.total - (costs?.total || 0)),
|
||||||
|
operatingProfit: response.operatingProfit ?? (income.total - (costs?.total || 0) - expenses.total),
|
||||||
|
netProfit: response.netProfit ?? (income.total - (costs?.total || 0) - expenses.total),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting P&L from Alegra', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
period: request,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { from: request.from, to: request.to },
|
||||||
|
income: { total: 0, items: [] },
|
||||||
|
expenses: { total: 0, items: [] },
|
||||||
|
grossProfit: 0,
|
||||||
|
operatingProfit: 0,
|
||||||
|
netProfit: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene P&L comparativo con periodo anterior
|
||||||
|
*/
|
||||||
|
async getProfitAndLossComparative(
|
||||||
|
currentPeriod: PeriodInput,
|
||||||
|
previousPeriod: PeriodInput
|
||||||
|
): Promise<{
|
||||||
|
current: AlegraProfitAndLoss;
|
||||||
|
previous: AlegraProfitAndLoss;
|
||||||
|
variance: {
|
||||||
|
incomeVariance: number;
|
||||||
|
incomeVariancePercent: number;
|
||||||
|
expensesVariance: number;
|
||||||
|
expensesVariancePercent: number;
|
||||||
|
netProfitVariance: number;
|
||||||
|
netProfitVariancePercent: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const [current, previous] = await Promise.all([
|
||||||
|
this.getProfitAndLoss(currentPeriod),
|
||||||
|
this.getProfitAndLoss(previousPeriod),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const calcVariance = (curr: number, prev: number) => ({
|
||||||
|
variance: curr - prev,
|
||||||
|
percent: prev !== 0 ? ((curr - prev) / Math.abs(prev)) * 100 : curr !== 0 ? 100 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const incomeVar = calcVariance(current.income.total, previous.income.total);
|
||||||
|
const expensesVar = calcVariance(current.expenses.total, previous.expenses.total);
|
||||||
|
const netProfitVar = calcVariance(current.netProfit, previous.netProfit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
variance: {
|
||||||
|
incomeVariance: incomeVar.variance,
|
||||||
|
incomeVariancePercent: incomeVar.percent,
|
||||||
|
expensesVariance: expensesVar.variance,
|
||||||
|
expensesVariancePercent: expensesVar.percent,
|
||||||
|
netProfitVariance: netProfitVar.variance,
|
||||||
|
netProfitVariancePercent: netProfitVar.percent,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Balance Sheet (Balance General)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el balance general a una fecha
|
||||||
|
*/
|
||||||
|
async getBalanceSheet(request: BalanceSheetRequest): Promise<AlegraBalanceSheet> {
|
||||||
|
logger.debug('Getting balance sheet from Alegra', { date: request.date });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.get<{
|
||||||
|
assets?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number; children?: Array<{ id: number; name: string; amount: number }> }> };
|
||||||
|
liabilities?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number; children?: Array<{ id: number; name: string; amount: number }> }> };
|
||||||
|
equity?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number; children?: Array<{ id: number; name: string; amount: number }> }> };
|
||||||
|
}>('/reports/balance-sheet', { date: request.date });
|
||||||
|
|
||||||
|
const assets = response.assets || { total: 0, items: [] };
|
||||||
|
const liabilities = response.liabilities || { total: 0, items: [] };
|
||||||
|
const equity = response.equity || { total: 0, items: [] };
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: request.date,
|
||||||
|
assets,
|
||||||
|
liabilities,
|
||||||
|
equity,
|
||||||
|
totalAssets: assets.total,
|
||||||
|
totalLiabilitiesAndEquity: liabilities.total + equity.total,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting balance sheet from Alegra', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
date: request.date,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: request.date,
|
||||||
|
assets: { total: 0, items: [] },
|
||||||
|
liabilities: { total: 0, items: [] },
|
||||||
|
equity: { total: 0, items: [] },
|
||||||
|
totalAssets: 0,
|
||||||
|
totalLiabilitiesAndEquity: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene balance general comparativo
|
||||||
|
*/
|
||||||
|
async getBalanceSheetComparative(
|
||||||
|
currentDate: string,
|
||||||
|
previousDate: string
|
||||||
|
): Promise<{
|
||||||
|
current: AlegraBalanceSheet;
|
||||||
|
previous: AlegraBalanceSheet;
|
||||||
|
variance: {
|
||||||
|
assetsVariance: number;
|
||||||
|
assetsVariancePercent: number;
|
||||||
|
liabilitiesVariance: number;
|
||||||
|
liabilitiesVariancePercent: number;
|
||||||
|
equityVariance: number;
|
||||||
|
equityVariancePercent: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const [current, previous] = await Promise.all([
|
||||||
|
this.getBalanceSheet({ date: currentDate }),
|
||||||
|
this.getBalanceSheet({ date: previousDate }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const calcVariance = (curr: number, prev: number) => ({
|
||||||
|
variance: curr - prev,
|
||||||
|
percent: prev !== 0 ? ((curr - prev) / Math.abs(prev)) * 100 : curr !== 0 ? 100 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assetsVar = calcVariance(current.assets.total, previous.assets.total);
|
||||||
|
const liabilitiesVar = calcVariance(current.liabilities.total, previous.liabilities.total);
|
||||||
|
const equityVar = calcVariance(current.equity.total, previous.equity.total);
|
||||||
|
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
variance: {
|
||||||
|
assetsVariance: assetsVar.variance,
|
||||||
|
assetsVariancePercent: assetsVar.percent,
|
||||||
|
liabilitiesVariance: liabilitiesVar.variance,
|
||||||
|
liabilitiesVariancePercent: liabilitiesVar.percent,
|
||||||
|
equityVariance: equityVar.variance,
|
||||||
|
equityVariancePercent: equityVar.percent,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cash Flow (Flujo de Efectivo)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el flujo de efectivo de un periodo
|
||||||
|
*/
|
||||||
|
async getCashFlow(request: CashFlowRequest): Promise<AlegraCashFlow> {
|
||||||
|
logger.debug('Getting cash flow from Alegra', {
|
||||||
|
from: request.from,
|
||||||
|
to: request.to,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.get<{
|
||||||
|
openingBalance?: number;
|
||||||
|
closingBalance?: number;
|
||||||
|
operating?: { total: number; items: Array<{ name: string; amount: number }> };
|
||||||
|
investing?: { total: number; items: Array<{ name: string; amount: number }> };
|
||||||
|
financing?: { total: number; items: Array<{ name: string; amount: number }> };
|
||||||
|
}>('/reports/cash-flow', {
|
||||||
|
start_date: request.from,
|
||||||
|
end_date: request.to,
|
||||||
|
method: request.method,
|
||||||
|
});
|
||||||
|
|
||||||
|
const operating = response.operating || { total: 0, items: [] };
|
||||||
|
const investing = response.investing || { total: 0, items: [] };
|
||||||
|
const financing = response.financing || { total: 0, items: [] };
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { from: request.from, to: request.to },
|
||||||
|
openingBalance: response.openingBalance || 0,
|
||||||
|
closingBalance: response.closingBalance || 0,
|
||||||
|
operating,
|
||||||
|
investing,
|
||||||
|
financing,
|
||||||
|
netCashFlow: operating.total + investing.total + financing.total,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting cash flow from Alegra', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
period: request,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { from: request.from, to: request.to },
|
||||||
|
openingBalance: 0,
|
||||||
|
closingBalance: 0,
|
||||||
|
operating: { total: 0, items: [] },
|
||||||
|
investing: { total: 0, items: [] },
|
||||||
|
financing: { total: 0, items: [] },
|
||||||
|
netCashFlow: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tax Reports (Reportes de Impuestos)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el reporte de impuestos de un periodo
|
||||||
|
*/
|
||||||
|
async getTaxReport(request: TaxReportRequest): Promise<AlegraTaxReport> {
|
||||||
|
logger.debug('Getting tax report from Alegra', {
|
||||||
|
from: request.from,
|
||||||
|
to: request.to,
|
||||||
|
taxType: request.taxType,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.get<{
|
||||||
|
taxes?: Array<{
|
||||||
|
taxId: number;
|
||||||
|
taxName: string;
|
||||||
|
taxType: AlegraTaxType;
|
||||||
|
collected: number;
|
||||||
|
paid: number;
|
||||||
|
}>;
|
||||||
|
}>('/reports/taxes', {
|
||||||
|
start_date: request.from,
|
||||||
|
end_date: request.to,
|
||||||
|
tax_type: request.taxType === 'ALL' ? undefined : request.taxType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const taxes = (response.taxes || []).map(tax => ({
|
||||||
|
...tax,
|
||||||
|
balance: tax.collected - tax.paid,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalCollected = taxes.reduce((sum, t) => sum + t.collected, 0);
|
||||||
|
const totalPaid = taxes.reduce((sum, t) => sum + t.paid, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { from: request.from, to: request.to },
|
||||||
|
taxes,
|
||||||
|
summary: {
|
||||||
|
totalCollected,
|
||||||
|
totalPaid,
|
||||||
|
netTaxPayable: totalCollected - totalPaid,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting tax report from Alegra', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
period: request,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { from: request.from, to: request.to },
|
||||||
|
taxes: [],
|
||||||
|
summary: {
|
||||||
|
totalCollected: 0,
|
||||||
|
totalPaid: 0,
|
||||||
|
netTaxPayable: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene reporte de IVA para Mexico
|
||||||
|
*/
|
||||||
|
async getIVAReport(period: PeriodInput): Promise<{
|
||||||
|
period: PeriodInput;
|
||||||
|
ivaCollected: number;
|
||||||
|
ivaPaid: number;
|
||||||
|
ivaBalance: number;
|
||||||
|
details: {
|
||||||
|
sales: Array<{ rate: number; base: number; iva: number }>;
|
||||||
|
purchases: Array<{ rate: number; base: number; iva: number }>;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const taxReport = await this.getTaxReport({
|
||||||
|
...period,
|
||||||
|
taxType: 'IVA',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ivaTax = taxReport.taxes.find(t => t.taxType === 'IVA');
|
||||||
|
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
ivaCollected: ivaTax?.collected || 0,
|
||||||
|
ivaPaid: ivaTax?.paid || 0,
|
||||||
|
ivaBalance: ivaTax?.balance || 0,
|
||||||
|
details: {
|
||||||
|
sales: [
|
||||||
|
{ rate: 16, base: 0, iva: ivaTax?.collected || 0 },
|
||||||
|
{ rate: 8, base: 0, iva: 0 },
|
||||||
|
{ rate: 0, base: 0, iva: 0 },
|
||||||
|
],
|
||||||
|
purchases: [
|
||||||
|
{ rate: 16, base: 0, iva: ivaTax?.paid || 0 },
|
||||||
|
{ rate: 8, base: 0, iva: 0 },
|
||||||
|
{ rate: 0, base: 0, iva: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Account Reports
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene movimientos de una cuenta contable
|
||||||
|
*/
|
||||||
|
async getAccountMovements(
|
||||||
|
accountId: number,
|
||||||
|
period: PeriodInput
|
||||||
|
): Promise<{
|
||||||
|
accountId: number;
|
||||||
|
accountName: string;
|
||||||
|
period: PeriodInput;
|
||||||
|
openingBalance: number;
|
||||||
|
movements: Array<{
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
reference?: string;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
balance: number;
|
||||||
|
}>;
|
||||||
|
closingBalance: number;
|
||||||
|
}> {
|
||||||
|
logger.debug('Getting account movements from Alegra', { accountId, period });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.get<{
|
||||||
|
accountName?: string;
|
||||||
|
openingBalance?: number;
|
||||||
|
movements?: Array<{
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
reference?: string;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
}>;
|
||||||
|
}>(`/reports/accounts/${accountId}/movements`, {
|
||||||
|
start_date: period.from,
|
||||||
|
end_date: period.to,
|
||||||
|
});
|
||||||
|
|
||||||
|
const movements = response.movements || [];
|
||||||
|
let runningBalance = response.openingBalance || 0;
|
||||||
|
|
||||||
|
const movementsWithBalance = movements.map(m => {
|
||||||
|
runningBalance += m.debit - m.credit;
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
balance: runningBalance,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
accountName: response.accountName || '',
|
||||||
|
period,
|
||||||
|
openingBalance: response.openingBalance || 0,
|
||||||
|
movements: movementsWithBalance,
|
||||||
|
closingBalance: runningBalance,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting account movements from Alegra', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
accountId,
|
||||||
|
period,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
accountName: '',
|
||||||
|
period,
|
||||||
|
openingBalance: 0,
|
||||||
|
movements: [],
|
||||||
|
closingBalance: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Analytics & KPIs
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene KPIs financieros
|
||||||
|
*/
|
||||||
|
async getFinancialKPIs(period: PeriodInput): Promise<{
|
||||||
|
profitMargin: number;
|
||||||
|
grossMargin: number;
|
||||||
|
currentRatio: number;
|
||||||
|
quickRatio: number;
|
||||||
|
debtToEquity: number;
|
||||||
|
returnOnAssets: number;
|
||||||
|
returnOnEquity: number;
|
||||||
|
}> {
|
||||||
|
const [pl, bs] = await Promise.all([
|
||||||
|
this.getProfitAndLoss(period),
|
||||||
|
this.getBalanceSheet({ date: period.to }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calcular ratios
|
||||||
|
const profitMargin = pl.income.total > 0
|
||||||
|
? (pl.netProfit / pl.income.total) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const grossMargin = pl.income.total > 0
|
||||||
|
? (pl.grossProfit / pl.income.total) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Simplificado - idealmente se separarian activos/pasivos corrientes
|
||||||
|
const currentRatio = bs.liabilities.total > 0
|
||||||
|
? bs.assets.total / bs.liabilities.total
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const quickRatio = currentRatio * 0.8; // Aproximacion
|
||||||
|
|
||||||
|
const debtToEquity = bs.equity.total > 0
|
||||||
|
? bs.liabilities.total / bs.equity.total
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const returnOnAssets = bs.assets.total > 0
|
||||||
|
? (pl.netProfit / bs.assets.total) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const returnOnEquity = bs.equity.total > 0
|
||||||
|
? (pl.netProfit / bs.equity.total) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
profitMargin,
|
||||||
|
grossMargin,
|
||||||
|
currentRatio,
|
||||||
|
quickRatio,
|
||||||
|
debtToEquity,
|
||||||
|
returnOnAssets,
|
||||||
|
returnOnEquity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen financiero completo
|
||||||
|
*/
|
||||||
|
async getFinancialSummary(period: PeriodInput): Promise<{
|
||||||
|
period: PeriodInput;
|
||||||
|
profitAndLoss: {
|
||||||
|
revenue: number;
|
||||||
|
expenses: number;
|
||||||
|
netIncome: number;
|
||||||
|
};
|
||||||
|
balanceSheet: {
|
||||||
|
assets: number;
|
||||||
|
liabilities: number;
|
||||||
|
equity: number;
|
||||||
|
};
|
||||||
|
cashFlow: {
|
||||||
|
operating: number;
|
||||||
|
investing: number;
|
||||||
|
financing: number;
|
||||||
|
netChange: number;
|
||||||
|
};
|
||||||
|
kpis: {
|
||||||
|
profitMargin: number;
|
||||||
|
currentRatio: number;
|
||||||
|
debtToEquity: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const [pl, bs, cf] = await Promise.all([
|
||||||
|
this.getProfitAndLoss(period),
|
||||||
|
this.getBalanceSheet({ date: period.to }),
|
||||||
|
this.getCashFlow(period),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
profitAndLoss: {
|
||||||
|
revenue: pl.income.total,
|
||||||
|
expenses: pl.expenses.total,
|
||||||
|
netIncome: pl.netProfit,
|
||||||
|
},
|
||||||
|
balanceSheet: {
|
||||||
|
assets: bs.assets.total,
|
||||||
|
liabilities: bs.liabilities.total,
|
||||||
|
equity: bs.equity.total,
|
||||||
|
},
|
||||||
|
cashFlow: {
|
||||||
|
operating: cf.operating.total,
|
||||||
|
investing: cf.investing.total,
|
||||||
|
financing: cf.financing.total,
|
||||||
|
netChange: cf.netCashFlow,
|
||||||
|
},
|
||||||
|
kpis: {
|
||||||
|
profitMargin: pl.income.total > 0 ? (pl.netProfit / pl.income.total) * 100 : 0,
|
||||||
|
currentRatio: bs.liabilities.total > 0 ? bs.assets.total / bs.liabilities.total : 0,
|
||||||
|
debtToEquity: bs.equity.total > 0 ? bs.liabilities.total / bs.equity.total : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un conector de reportes de Alegra
|
||||||
|
*/
|
||||||
|
export function createReportsConnector(client: AlegraClient): AlegraReportsConnector {
|
||||||
|
return new AlegraReportsConnector(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlegraReportsConnector;
|
||||||
704
apps/api/src/services/integrations/aspel/aspel.client.ts
Normal file
704
apps/api/src/services/integrations/aspel/aspel.client.ts
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
/**
|
||||||
|
* Aspel Database Client
|
||||||
|
* Cliente de conexion para bases de datos Aspel (Firebird y SQL Server)
|
||||||
|
* Maneja pool de conexiones, encoding Latin1 y deteccion automatica de BD
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
import {
|
||||||
|
AspelConfig,
|
||||||
|
AspelConnectionState,
|
||||||
|
AspelQueryResult,
|
||||||
|
AspelDatabaseType,
|
||||||
|
AspelError,
|
||||||
|
AspelConnectionError,
|
||||||
|
AspelQueryError,
|
||||||
|
AspelEncodingError,
|
||||||
|
} from './aspel.types.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Interfaces para drivers de BD (dynamic import)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface FirebirdConnection {
|
||||||
|
query: (sql: string, params: unknown[], callback: (err: Error | null, result: unknown[]) => void) => void;
|
||||||
|
detach: (callback: (err: Error | null) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FirebirdPool {
|
||||||
|
get: (callback: (err: Error | null, connection: FirebirdConnection) => void) => void;
|
||||||
|
destroy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SqlServerPool {
|
||||||
|
connect: () => Promise<void>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
request: () => SqlServerRequest;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SqlServerRequest {
|
||||||
|
query: (sql: string) => Promise<{ recordset: unknown[]; rowsAffected: number[] }>;
|
||||||
|
input: (name: string, value: unknown) => SqlServerRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Clase principal del cliente Aspel
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class AspelClient extends EventEmitter {
|
||||||
|
private config: Required<AspelConfig>;
|
||||||
|
private firebirdPool: FirebirdPool | null = null;
|
||||||
|
private sqlServerPool: SqlServerPool | null = null;
|
||||||
|
private state: AspelConnectionState;
|
||||||
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
private isInitialized = false;
|
||||||
|
|
||||||
|
constructor(config: AspelConfig) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
// Configuracion con valores por defecto
|
||||||
|
this.config = {
|
||||||
|
databaseType: config.databaseType,
|
||||||
|
host: config.host,
|
||||||
|
port: config.port || (config.databaseType === 'firebird' ? 3050 : 1433),
|
||||||
|
database: config.database,
|
||||||
|
username: config.username,
|
||||||
|
password: config.password,
|
||||||
|
product: config.product,
|
||||||
|
empresaId: config.empresaId ?? 1,
|
||||||
|
encoding: config.encoding ?? 'latin1',
|
||||||
|
connectionTimeout: config.connectionTimeout ?? 30000,
|
||||||
|
poolSize: config.poolSize ?? 5,
|
||||||
|
tablePrefix: config.tablePrefix ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
connected: false,
|
||||||
|
databaseType: this.config.databaseType,
|
||||||
|
activeConnections: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('AspelClient created', {
|
||||||
|
product: this.config.product,
|
||||||
|
databaseType: this.config.databaseType,
|
||||||
|
host: this.config.host,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metodos de conexion
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicializa la conexion al pool de base de datos
|
||||||
|
*/
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('Connecting to Aspel database...', {
|
||||||
|
type: this.config.databaseType,
|
||||||
|
host: this.config.host,
|
||||||
|
database: this.config.database,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.config.databaseType === 'firebird') {
|
||||||
|
await this.connectFirebird();
|
||||||
|
} else {
|
||||||
|
await this.connectSqlServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
this.state.connected = true;
|
||||||
|
this.state.lastConnectedAt = new Date();
|
||||||
|
|
||||||
|
// Detectar version del producto
|
||||||
|
await this.detectProductVersion();
|
||||||
|
|
||||||
|
this.emit('connected', this.state);
|
||||||
|
logger.info('Connected to Aspel database', {
|
||||||
|
product: this.config.product,
|
||||||
|
version: this.state.productVersion,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
this.state.lastError = errorMsg;
|
||||||
|
this.state.connected = false;
|
||||||
|
|
||||||
|
logger.error('Failed to connect to Aspel database', { error: errorMsg });
|
||||||
|
throw new AspelConnectionError(`Failed to connect: ${errorMsg}`, {
|
||||||
|
host: this.config.host,
|
||||||
|
database: this.config.database,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conecta a Firebird
|
||||||
|
*/
|
||||||
|
private async connectFirebird(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Dynamic import para node-firebird
|
||||||
|
const Firebird = await import('node-firebird').catch(() => null);
|
||||||
|
|
||||||
|
if (!Firebird) {
|
||||||
|
throw new Error('node-firebird package not installed. Run: npm install node-firebird');
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port,
|
||||||
|
database: this.config.database,
|
||||||
|
user: this.config.username,
|
||||||
|
password: this.config.password,
|
||||||
|
lowercase_keys: false,
|
||||||
|
role: null,
|
||||||
|
pageSize: 4096,
|
||||||
|
retryConnectionInterval: 1000,
|
||||||
|
blobAsText: true,
|
||||||
|
encoding: this.mapEncodingToFirebird(this.config.encoding),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
Firebird.pool(this.config.poolSize, options, (err: Error | null, pool: FirebirdPool) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.firebirdPool = pool;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new AspelConnectionError(
|
||||||
|
`Firebird connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conecta a SQL Server
|
||||||
|
*/
|
||||||
|
private async connectSqlServer(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Dynamic import para mssql
|
||||||
|
const sql = await import('mssql').catch(() => null);
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
throw new Error('mssql package not installed. Run: npm install mssql');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
server: this.config.host,
|
||||||
|
port: this.config.port,
|
||||||
|
database: this.config.database,
|
||||||
|
user: this.config.username,
|
||||||
|
password: this.config.password,
|
||||||
|
options: {
|
||||||
|
encrypt: false,
|
||||||
|
trustServerCertificate: true,
|
||||||
|
enableArithAbort: true,
|
||||||
|
connectTimeout: this.config.connectionTimeout,
|
||||||
|
},
|
||||||
|
pool: {
|
||||||
|
max: this.config.poolSize,
|
||||||
|
min: 1,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sqlServerPool = new sql.ConnectionPool(config) as unknown as SqlServerPool;
|
||||||
|
await this.sqlServerPool.connect();
|
||||||
|
} catch (error) {
|
||||||
|
throw new AspelConnectionError(
|
||||||
|
`SQL Server connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cierra las conexiones
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.firebirdPool) {
|
||||||
|
this.firebirdPool.destroy();
|
||||||
|
this.firebirdPool = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.sqlServerPool) {
|
||||||
|
await this.sqlServerPool.close();
|
||||||
|
this.sqlServerPool = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.state.connected = false;
|
||||||
|
this.state.activeConnections = 0;
|
||||||
|
|
||||||
|
this.emit('disconnected');
|
||||||
|
logger.info('Disconnected from Aspel database');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error disconnecting from Aspel', { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta la version del producto Aspel
|
||||||
|
*/
|
||||||
|
private async detectProductVersion(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Intentar detectar la version de la tabla de configuracion
|
||||||
|
const versionQueries: Record<string, string> = {
|
||||||
|
COI: "SELECT VALOR FROM CONFIGURACION WHERE LLAVE = 'VERSION'",
|
||||||
|
SAE: "SELECT VALOR FROM CONFIGURACION WHERE LLAVE = 'VERSION'",
|
||||||
|
NOI: "SELECT VALOR FROM CONFIGURACION WHERE LLAVE = 'VERSION'",
|
||||||
|
BANCO: "SELECT VALOR FROM CONFIGURACION WHERE LLAVE = 'VERSION'",
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = versionQueries[this.config.product];
|
||||||
|
if (query) {
|
||||||
|
const result = await this.query<{ VALOR: string }>(query);
|
||||||
|
if (result.rows.length > 0 && result.rows[0]) {
|
||||||
|
this.state.productVersion = result.rows[0].VALOR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Si no se puede detectar la version, continuar sin ella
|
||||||
|
logger.warn('Could not detect Aspel product version');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metodos de consulta
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una consulta SQL
|
||||||
|
*/
|
||||||
|
async query<T = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params: unknown[] = []
|
||||||
|
): Promise<AspelQueryResult<T>> {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let rows: T[];
|
||||||
|
let rowsAffected: number | undefined;
|
||||||
|
|
||||||
|
if (this.config.databaseType === 'firebird') {
|
||||||
|
const result = await this.queryFirebird(sql, params);
|
||||||
|
rows = result.rows as T[];
|
||||||
|
} else {
|
||||||
|
const result = await this.querySqlServer(sql, params);
|
||||||
|
rows = result.rows as T[];
|
||||||
|
rowsAffected = result.rowsAffected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir encoding de Latin1 a UTF-8
|
||||||
|
rows = this.convertEncodingArray(rows);
|
||||||
|
|
||||||
|
const executionTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
rowsAffected,
|
||||||
|
executionTime,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('Aspel query error', { sql, error: errorMsg });
|
||||||
|
throw new AspelQueryError(errorMsg, sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta query en Firebird
|
||||||
|
*/
|
||||||
|
private async queryFirebird(sql: string, params: unknown[]): Promise<{ rows: unknown[] }> {
|
||||||
|
if (!this.firebirdPool) {
|
||||||
|
throw new AspelConnectionError('Firebird pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.firebirdPool!.get((err, connection) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.activeConnections++;
|
||||||
|
|
||||||
|
connection.query(sql, params, (queryErr, result) => {
|
||||||
|
this.state.activeConnections--;
|
||||||
|
|
||||||
|
connection.detach((detachErr) => {
|
||||||
|
if (detachErr) {
|
||||||
|
logger.warn('Error detaching Firebird connection', { error: detachErr });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (queryErr) {
|
||||||
|
reject(queryErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ rows: result || [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta query en SQL Server
|
||||||
|
*/
|
||||||
|
private async querySqlServer(
|
||||||
|
sql: string,
|
||||||
|
params: unknown[]
|
||||||
|
): Promise<{ rows: unknown[]; rowsAffected?: number }> {
|
||||||
|
if (!this.sqlServerPool || !this.sqlServerPool.connected) {
|
||||||
|
throw new AspelConnectionError('SQL Server pool not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.activeConnections++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = this.sqlServerPool.request();
|
||||||
|
|
||||||
|
// Agregar parametros
|
||||||
|
params.forEach((param, index) => {
|
||||||
|
request.input(`p${index}`, param);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reemplazar ? por @pN para SQL Server
|
||||||
|
let paramIndex = 0;
|
||||||
|
const sqlWithParams = sql.replace(/\?/g, () => `@p${paramIndex++}`);
|
||||||
|
|
||||||
|
const result = await request.query(sqlWithParams);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: result.recordset || [],
|
||||||
|
rowsAffected: result.rowsAffected?.[0],
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.state.activeConnections--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una consulta y retorna el primer resultado
|
||||||
|
*/
|
||||||
|
async queryOne<T = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params: unknown[] = []
|
||||||
|
): Promise<T | null> {
|
||||||
|
const result = await this.query<T>(sql, params);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta un comando (INSERT, UPDATE, DELETE)
|
||||||
|
*/
|
||||||
|
async execute(sql: string, params: unknown[] = []): Promise<number> {
|
||||||
|
const result = await this.query(sql, params);
|
||||||
|
return result.rowsAffected || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Manejo de Encoding
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea el encoding de configuracion a Firebird
|
||||||
|
*/
|
||||||
|
private mapEncodingToFirebird(encoding: string): string {
|
||||||
|
const encodingMap: Record<string, string> = {
|
||||||
|
latin1: 'ISO8859_1',
|
||||||
|
utf8: 'UTF8',
|
||||||
|
cp1252: 'WIN1252',
|
||||||
|
};
|
||||||
|
return encodingMap[encoding] || 'ISO8859_1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte una cadena de Latin1 a UTF-8
|
||||||
|
*/
|
||||||
|
private convertEncoding(value: unknown): unknown {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.config.encoding === 'latin1' || this.config.encoding === 'cp1252') {
|
||||||
|
// Crear buffer desde Latin1 y convertir a UTF-8
|
||||||
|
const buffer = Buffer.from(value, 'latin1');
|
||||||
|
return buffer.toString('utf8');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Encoding conversion error', { value, error });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte encoding de un objeto
|
||||||
|
*/
|
||||||
|
private convertEncodingObject<T>(obj: T): T {
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === 'string') {
|
||||||
|
return this.convertEncoding(obj) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(item => this.convertEncodingObject(item)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === 'object') {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
|
result[key] = this.convertEncodingObject(value);
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte encoding de un array de objetos
|
||||||
|
*/
|
||||||
|
private convertEncodingArray<T>(rows: T[]): T[] {
|
||||||
|
return rows.map(row => this.convertEncodingObject(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utilidades para fechas de Aspel
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte una fecha de Aspel a Date de JavaScript
|
||||||
|
* Aspel almacena fechas en formato YYYYMMDD como numero o YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
static parseAspelDate(value: unknown): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Si es numero en formato YYYYMMDD
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
const str = value.toString();
|
||||||
|
if (str.length === 8) {
|
||||||
|
const year = parseInt(str.substring(0, 4));
|
||||||
|
const month = parseInt(str.substring(4, 6)) - 1;
|
||||||
|
const day = parseInt(str.substring(6, 8));
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si es string
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// Formato YYYYMMDD
|
||||||
|
if (/^\d{8}$/.test(value)) {
|
||||||
|
const year = parseInt(value.substring(0, 4));
|
||||||
|
const month = parseInt(value.substring(4, 6)) - 1;
|
||||||
|
const day = parseInt(value.substring(6, 8));
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formato YYYY-MM-DD o ISO
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si ya es Date
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte una fecha de JavaScript a formato Aspel
|
||||||
|
*/
|
||||||
|
static formatAspelDate(date: Date): number {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return parseInt(`${year}${month}${day}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte fecha/hora de Aspel
|
||||||
|
*/
|
||||||
|
static parseAspelDateTime(dateValue: unknown, timeValue?: unknown): Date | null {
|
||||||
|
const date = AspelClient.parseAspelDate(dateValue);
|
||||||
|
if (!date) return null;
|
||||||
|
|
||||||
|
if (timeValue) {
|
||||||
|
// Tiempo en formato HHMMSS
|
||||||
|
const timeStr = typeof timeValue === 'number'
|
||||||
|
? timeValue.toString().padStart(6, '0')
|
||||||
|
: String(timeValue);
|
||||||
|
|
||||||
|
if (timeStr.length >= 4) {
|
||||||
|
const hours = parseInt(timeStr.substring(0, 2));
|
||||||
|
const minutes = parseInt(timeStr.substring(2, 4));
|
||||||
|
const seconds = timeStr.length >= 6 ? parseInt(timeStr.substring(4, 6)) : 0;
|
||||||
|
|
||||||
|
date.setHours(hours, minutes, seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metodos de utilidad
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado actual de la conexion
|
||||||
|
*/
|
||||||
|
getState(): AspelConnectionState {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la configuracion (sin password)
|
||||||
|
*/
|
||||||
|
getConfig(): Omit<AspelConfig, 'password'> {
|
||||||
|
const { password, ...configWithoutPassword } = this.config;
|
||||||
|
return configWithoutPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si esta conectado
|
||||||
|
*/
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.state.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el nombre de tabla con prefijo
|
||||||
|
*/
|
||||||
|
getTableName(tableName: string): string {
|
||||||
|
return this.config.tablePrefix + tableName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapa un identificador SQL
|
||||||
|
*/
|
||||||
|
escapeIdentifier(identifier: string): string {
|
||||||
|
if (this.config.databaseType === 'firebird') {
|
||||||
|
return `"${identifier.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return `[${identifier.replace(/\]/g, ']]')}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test de conexion
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
|
||||||
|
// Query simple para verificar conexion
|
||||||
|
const testQuery = this.config.databaseType === 'firebird'
|
||||||
|
? 'SELECT 1 FROM RDB$DATABASE'
|
||||||
|
: 'SELECT 1 AS test';
|
||||||
|
|
||||||
|
await this.query(testQuery);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Aspel connection test failed', { error });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta automaticamente el tipo de base de datos
|
||||||
|
*/
|
||||||
|
static async detectDatabaseType(host: string, firebirdPort = 3050, sqlServerPort = 1433): Promise<AspelDatabaseType | null> {
|
||||||
|
// Intentar Firebird primero
|
||||||
|
try {
|
||||||
|
const net = await import('net');
|
||||||
|
|
||||||
|
const checkPort = (port: number): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
socket.setTimeout(3000);
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.connect(port, host);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const [firebirdAvailable, sqlServerAvailable] = await Promise.all([
|
||||||
|
checkPort(firebirdPort),
|
||||||
|
checkPort(sqlServerPort),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (firebirdAvailable) return 'firebird';
|
||||||
|
if (sqlServerAvailable) return 'sqlserver';
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del cliente Aspel
|
||||||
|
*/
|
||||||
|
export function createAspelClient(config: AspelConfig): AspelClient {
|
||||||
|
return new AspelClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea y conecta una instancia del cliente Aspel
|
||||||
|
*/
|
||||||
|
export async function connectAspel(config: AspelConfig): Promise<AspelClient> {
|
||||||
|
const client = new AspelClient(config);
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
1164
apps/api/src/services/integrations/aspel/aspel.sync.ts
Normal file
1164
apps/api/src/services/integrations/aspel/aspel.sync.ts
Normal file
File diff suppressed because it is too large
Load Diff
1112
apps/api/src/services/integrations/aspel/aspel.types.ts
Normal file
1112
apps/api/src/services/integrations/aspel/aspel.types.ts
Normal file
File diff suppressed because it is too large
Load Diff
938
apps/api/src/services/integrations/aspel/banco.connector.ts
Normal file
938
apps/api/src/services/integrations/aspel/banco.connector.ts
Normal file
@@ -0,0 +1,938 @@
|
|||||||
|
/**
|
||||||
|
* BANCO Connector - Control Bancario Aspel
|
||||||
|
* Conector para extraer datos bancarios de Aspel BANCO
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AspelClient } from './aspel.client.js';
|
||||||
|
import {
|
||||||
|
CuentaBancaria,
|
||||||
|
MovimientoBancario,
|
||||||
|
ConciliacionBancaria,
|
||||||
|
PeriodoConsulta,
|
||||||
|
RangoFechas,
|
||||||
|
PaginacionAspel,
|
||||||
|
ResultadoPaginado,
|
||||||
|
} from './aspel.types.js';
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Nombres de tablas BANCO
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const BANCO_TABLES = {
|
||||||
|
CUENTAS: 'CTAS_BANCO',
|
||||||
|
MOVIMIENTOS: 'MOVS_BANCO',
|
||||||
|
CONCILIACIONES: 'CONC_BANCO',
|
||||||
|
CONCILIACION_DET: 'CONC_BANCO_DET',
|
||||||
|
BANCOS: 'BANCOS',
|
||||||
|
BENEFICIARIOS: 'BENEFICIARIOS',
|
||||||
|
CHEQUES: 'CHEQUES',
|
||||||
|
TRASPASOS: 'TRASPASOS',
|
||||||
|
CONFIGURACION: 'CONFIG_BANCO',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Clase del conector BANCO
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class BANCOConnector {
|
||||||
|
private client: AspelClient;
|
||||||
|
private empresaId: number;
|
||||||
|
|
||||||
|
constructor(client: AspelClient, empresaId: number = 1) {
|
||||||
|
this.client = client;
|
||||||
|
this.empresaId = empresaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cuentas Bancarias
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todas las cuentas bancarias
|
||||||
|
*/
|
||||||
|
async getCuentasBancarias(options?: {
|
||||||
|
soloActivas?: boolean;
|
||||||
|
banco?: string;
|
||||||
|
moneda?: string;
|
||||||
|
}): Promise<CuentaBancaria[]> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (options?.soloActivas !== false) {
|
||||||
|
conditions.push('ESTATUS = ?');
|
||||||
|
params.push('A');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.banco) {
|
||||||
|
conditions.push('CVE_BANCO = ?');
|
||||||
|
params.push(options.banco);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.moneda) {
|
||||||
|
conditions.push('MONEDA = ?');
|
||||||
|
params.push(options.moneda);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
c.CVE_CTA,
|
||||||
|
c.NOMBRE,
|
||||||
|
c.NUM_CTA,
|
||||||
|
c.CLABE,
|
||||||
|
b.NOMBRE AS NOMBRE_BANCO,
|
||||||
|
c.TIPO_CTA,
|
||||||
|
c.MONEDA,
|
||||||
|
c.SALDO,
|
||||||
|
c.SALDO_DISP,
|
||||||
|
c.CTA_CONTABLE,
|
||||||
|
c.RFC_TITULAR,
|
||||||
|
c.NOMBRE_TIT,
|
||||||
|
c.ESTATUS,
|
||||||
|
c.FECHA_APERT
|
||||||
|
FROM ${BANCO_TABLES.CUENTAS} c
|
||||||
|
LEFT JOIN ${BANCO_TABLES.BANCOS} b ON c.CVE_BANCO = b.CVE_BANCO
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY c.NOMBRE
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, params);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToCuentaBancaria(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una cuenta bancaria por clave
|
||||||
|
*/
|
||||||
|
async getCuentaBancaria(clave: string): Promise<CuentaBancaria | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
c.CVE_CTA,
|
||||||
|
c.NOMBRE,
|
||||||
|
c.NUM_CTA,
|
||||||
|
c.CLABE,
|
||||||
|
b.NOMBRE AS NOMBRE_BANCO,
|
||||||
|
c.TIPO_CTA,
|
||||||
|
c.MONEDA,
|
||||||
|
c.SALDO,
|
||||||
|
c.SALDO_DISP,
|
||||||
|
c.CTA_CONTABLE,
|
||||||
|
c.RFC_TITULAR,
|
||||||
|
c.NOMBRE_TIT,
|
||||||
|
c.ESTATUS,
|
||||||
|
c.FECHA_APERT
|
||||||
|
FROM ${BANCO_TABLES.CUENTAS} c
|
||||||
|
LEFT JOIN ${BANCO_TABLES.BANCOS} b ON c.CVE_BANCO = b.CVE_BANCO
|
||||||
|
WHERE c.CVE_CTA = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const row = await this.client.queryOne<Record<string, unknown>>(sql, [clave]);
|
||||||
|
|
||||||
|
return row ? this.mapToCuentaBancaria(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el saldo total de todas las cuentas
|
||||||
|
*/
|
||||||
|
async getSaldosTotales(): Promise<{
|
||||||
|
totalMXN: number;
|
||||||
|
totalUSD: number;
|
||||||
|
totalEUR: number;
|
||||||
|
porCuenta: Array<{
|
||||||
|
clave: string;
|
||||||
|
nombre: string;
|
||||||
|
moneda: string;
|
||||||
|
saldo: number;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
CVE_CTA,
|
||||||
|
NOMBRE,
|
||||||
|
MONEDA,
|
||||||
|
SALDO
|
||||||
|
FROM ${BANCO_TABLES.CUENTAS}
|
||||||
|
WHERE ESTATUS = 'A'
|
||||||
|
ORDER BY MONEDA, NOMBRE
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<{
|
||||||
|
CVE_CTA: string;
|
||||||
|
NOMBRE: string;
|
||||||
|
MONEDA: string;
|
||||||
|
SALDO: number;
|
||||||
|
}>(sql);
|
||||||
|
|
||||||
|
let totalMXN = 0;
|
||||||
|
let totalUSD = 0;
|
||||||
|
let totalEUR = 0;
|
||||||
|
|
||||||
|
const porCuenta = result.rows.map(row => {
|
||||||
|
const saldo = Number(row.SALDO) || 0;
|
||||||
|
const moneda = String(row.MONEDA || 'MXN').toUpperCase();
|
||||||
|
|
||||||
|
if (moneda === 'MXN' || moneda === 'PESOS') {
|
||||||
|
totalMXN += saldo;
|
||||||
|
} else if (moneda === 'USD' || moneda === 'DOLARES') {
|
||||||
|
totalUSD += saldo;
|
||||||
|
} else if (moneda === 'EUR' || moneda === 'EUROS') {
|
||||||
|
totalEUR += saldo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clave: String(row.CVE_CTA),
|
||||||
|
nombre: String(row.NOMBRE),
|
||||||
|
moneda,
|
||||||
|
saldo,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { totalMXN, totalUSD, totalEUR, porCuenta };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Movimientos Bancarios
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene movimientos bancarios por periodo
|
||||||
|
*/
|
||||||
|
async getMovimientosBancarios(
|
||||||
|
rango: RangoFechas,
|
||||||
|
options?: {
|
||||||
|
cuenta?: string;
|
||||||
|
tipo?: 'D' | 'R' | 'T' | 'C' | 'I';
|
||||||
|
soloConciliados?: boolean;
|
||||||
|
soloNoConciliados?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<MovimientoBancario[]> {
|
||||||
|
const conditions = ['FECHA >= ?', 'FECHA <= ?'];
|
||||||
|
const params: unknown[] = [
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.cuenta) {
|
||||||
|
conditions.push('CVE_CTA = ?');
|
||||||
|
params.push(options.cuenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.tipo) {
|
||||||
|
conditions.push('TIPO_MOV = ?');
|
||||||
|
params.push(options.tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.soloConciliados) {
|
||||||
|
conditions.push('CONCILIADO = ?');
|
||||||
|
params.push('S');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.soloNoConciliados) {
|
||||||
|
conditions.push('(CONCILIADO = ? OR CONCILIADO IS NULL)');
|
||||||
|
params.push('N');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_MOV,
|
||||||
|
CVE_CTA,
|
||||||
|
FECHA,
|
||||||
|
TIPO_MOV,
|
||||||
|
REFERENCIA,
|
||||||
|
NUM_CHEQUE,
|
||||||
|
BENEFICIARIO,
|
||||||
|
CONCEPTO,
|
||||||
|
DEPOSITO,
|
||||||
|
RETIRO,
|
||||||
|
SALDO,
|
||||||
|
CONCILIADO,
|
||||||
|
FECHA_CONC,
|
||||||
|
NUM_POLIZA,
|
||||||
|
TIPO_POLIZA,
|
||||||
|
UUID
|
||||||
|
FROM ${BANCO_TABLES.MOVIMIENTOS}
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY FECHA, ID_MOV
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, params);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToMovimientoBancario(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene movimientos bancarios paginados
|
||||||
|
*/
|
||||||
|
async getMovimientosBancariosPaginados(
|
||||||
|
rango: RangoFechas,
|
||||||
|
paginacion: PaginacionAspel,
|
||||||
|
options?: {
|
||||||
|
cuenta?: string;
|
||||||
|
tipo?: 'D' | 'R' | 'T';
|
||||||
|
}
|
||||||
|
): Promise<ResultadoPaginado<MovimientoBancario>> {
|
||||||
|
const conditions = ['FECHA >= ?', 'FECHA <= ?'];
|
||||||
|
const params: unknown[] = [
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.cuenta) {
|
||||||
|
conditions.push('CVE_CTA = ?');
|
||||||
|
params.push(options.cuenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.tipo) {
|
||||||
|
conditions.push('TIPO_MOV = ?');
|
||||||
|
params.push(options.tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contar total
|
||||||
|
const countSql = `
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM ${BANCO_TABLES.MOVIMIENTOS}
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const countResult = await this.client.queryOne<{ total: number }>(countSql, params);
|
||||||
|
const total = countResult?.total || 0;
|
||||||
|
|
||||||
|
// Obtener pagina
|
||||||
|
const pagina = paginacion.pagina || 1;
|
||||||
|
const porPagina = paginacion.porPagina || 50;
|
||||||
|
const offset = (pagina - 1) * porPagina;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_MOV,
|
||||||
|
CVE_CTA,
|
||||||
|
FECHA,
|
||||||
|
TIPO_MOV,
|
||||||
|
REFERENCIA,
|
||||||
|
NUM_CHEQUE,
|
||||||
|
BENEFICIARIO,
|
||||||
|
CONCEPTO,
|
||||||
|
DEPOSITO,
|
||||||
|
RETIRO,
|
||||||
|
SALDO,
|
||||||
|
CONCILIADO,
|
||||||
|
FECHA_CONC,
|
||||||
|
NUM_POLIZA,
|
||||||
|
TIPO_POLIZA,
|
||||||
|
UUID
|
||||||
|
FROM ${BANCO_TABLES.MOVIMIENTOS}
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY FECHA DESC, ID_MOV DESC
|
||||||
|
${this.getLimitClause(porPagina, offset)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.rows.map(row => this.mapToMovimientoBancario(row)),
|
||||||
|
total,
|
||||||
|
pagina,
|
||||||
|
porPagina,
|
||||||
|
totalPaginas: Math.ceil(total / porPagina),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un movimiento bancario por ID
|
||||||
|
*/
|
||||||
|
async getMovimientoBancario(id: number): Promise<MovimientoBancario | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_MOV,
|
||||||
|
CVE_CTA,
|
||||||
|
FECHA,
|
||||||
|
TIPO_MOV,
|
||||||
|
REFERENCIA,
|
||||||
|
NUM_CHEQUE,
|
||||||
|
BENEFICIARIO,
|
||||||
|
CONCEPTO,
|
||||||
|
DEPOSITO,
|
||||||
|
RETIRO,
|
||||||
|
SALDO,
|
||||||
|
CONCILIADO,
|
||||||
|
FECHA_CONC,
|
||||||
|
NUM_POLIZA,
|
||||||
|
TIPO_POLIZA,
|
||||||
|
UUID
|
||||||
|
FROM ${BANCO_TABLES.MOVIMIENTOS}
|
||||||
|
WHERE ID_MOV = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const row = await this.client.queryOne<Record<string, unknown>>(sql, [id]);
|
||||||
|
|
||||||
|
return row ? this.mapToMovimientoBancario(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de movimientos por cuenta
|
||||||
|
*/
|
||||||
|
async getResumenMovimientos(
|
||||||
|
rango: RangoFechas,
|
||||||
|
cuenta: string
|
||||||
|
): Promise<{
|
||||||
|
saldoInicial: number;
|
||||||
|
depositos: number;
|
||||||
|
retiros: number;
|
||||||
|
saldoFinal: number;
|
||||||
|
movimientos: number;
|
||||||
|
}> {
|
||||||
|
// Obtener saldo inicial (ultimo saldo antes del periodo)
|
||||||
|
const saldoIniSql = `
|
||||||
|
SELECT SALDO
|
||||||
|
FROM ${BANCO_TABLES.MOVIMIENTOS}
|
||||||
|
WHERE CVE_CTA = ? AND FECHA < ?
|
||||||
|
ORDER BY FECHA DESC, ID_MOV DESC
|
||||||
|
${this.getLimitClause(1, 0)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const saldoIniResult = await this.client.queryOne<{ SALDO: number }>(saldoIniSql, [
|
||||||
|
cuenta,
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Obtener resumen del periodo
|
||||||
|
const resumenSql = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(DEPOSITO), 0) AS DEPOSITOS,
|
||||||
|
COALESCE(SUM(RETIRO), 0) AS RETIROS,
|
||||||
|
COUNT(*) AS MOVIMIENTOS
|
||||||
|
FROM ${BANCO_TABLES.MOVIMIENTOS}
|
||||||
|
WHERE CVE_CTA = ? AND FECHA >= ? AND FECHA <= ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resumenResult = await this.client.queryOne<{
|
||||||
|
DEPOSITOS: number;
|
||||||
|
RETIROS: number;
|
||||||
|
MOVIMIENTOS: number;
|
||||||
|
}>(resumenSql, [
|
||||||
|
cuenta,
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const saldoInicial = saldoIniResult?.SALDO || 0;
|
||||||
|
const depositos = resumenResult?.DEPOSITOS || 0;
|
||||||
|
const retiros = resumenResult?.RETIROS || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
saldoInicial,
|
||||||
|
depositos,
|
||||||
|
retiros,
|
||||||
|
saldoFinal: saldoInicial + depositos - retiros,
|
||||||
|
movimientos: resumenResult?.MOVIMIENTOS || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Conciliaciones Bancarias
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las conciliaciones bancarias
|
||||||
|
*/
|
||||||
|
async getConciliaciones(options?: {
|
||||||
|
cuenta?: string;
|
||||||
|
ejercicio?: number;
|
||||||
|
periodo?: number;
|
||||||
|
}): Promise<ConciliacionBancaria[]> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (options?.cuenta) {
|
||||||
|
conditions.push('CVE_CTA = ?');
|
||||||
|
params.push(options.cuenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.ejercicio) {
|
||||||
|
conditions.push('EJERCICIO = ?');
|
||||||
|
params.push(options.ejercicio);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.periodo) {
|
||||||
|
conditions.push('PERIODO = ?');
|
||||||
|
params.push(options.periodo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_CONC,
|
||||||
|
CVE_CTA,
|
||||||
|
PERIODO,
|
||||||
|
EJERCICIO,
|
||||||
|
FECHA_CORTE,
|
||||||
|
SALDO_LIBROS,
|
||||||
|
SALDO_BANCO,
|
||||||
|
DEP_TRANSITO,
|
||||||
|
CHQ_TRANSITO,
|
||||||
|
CARGOS_NO_REG,
|
||||||
|
ABONOS_NO_REG,
|
||||||
|
DIFERENCIA,
|
||||||
|
CUADRADA,
|
||||||
|
FECHA_ELAB
|
||||||
|
FROM ${BANCO_TABLES.CONCILIACIONES}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY EJERCICIO DESC, PERIODO DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, params);
|
||||||
|
|
||||||
|
const conciliaciones: ConciliacionBancaria[] = [];
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const conciliacion = this.mapToConciliacionBancaria(row);
|
||||||
|
|
||||||
|
// Obtener movimientos pendientes
|
||||||
|
conciliacion.movimientosPendientes = await this.getMovimientosPendientesConciliacion(
|
||||||
|
conciliacion.claveCuenta,
|
||||||
|
conciliacion.periodo,
|
||||||
|
conciliacion.ejercicio
|
||||||
|
);
|
||||||
|
|
||||||
|
conciliaciones.push(conciliacion);
|
||||||
|
}
|
||||||
|
|
||||||
|
return conciliaciones;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una conciliacion especifica
|
||||||
|
*/
|
||||||
|
async getConciliacion(
|
||||||
|
cuenta: string,
|
||||||
|
periodo: number,
|
||||||
|
ejercicio: number
|
||||||
|
): Promise<ConciliacionBancaria | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_CONC,
|
||||||
|
CVE_CTA,
|
||||||
|
PERIODO,
|
||||||
|
EJERCICIO,
|
||||||
|
FECHA_CORTE,
|
||||||
|
SALDO_LIBROS,
|
||||||
|
SALDO_BANCO,
|
||||||
|
DEP_TRANSITO,
|
||||||
|
CHQ_TRANSITO,
|
||||||
|
CARGOS_NO_REG,
|
||||||
|
ABONOS_NO_REG,
|
||||||
|
DIFERENCIA,
|
||||||
|
CUADRADA,
|
||||||
|
FECHA_ELAB
|
||||||
|
FROM ${BANCO_TABLES.CONCILIACIONES}
|
||||||
|
WHERE CVE_CTA = ? AND PERIODO = ? AND EJERCICIO = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const row = await this.client.queryOne<Record<string, unknown>>(sql, [
|
||||||
|
cuenta,
|
||||||
|
periodo,
|
||||||
|
ejercicio,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const conciliacion = this.mapToConciliacionBancaria(row);
|
||||||
|
|
||||||
|
// Obtener movimientos pendientes
|
||||||
|
conciliacion.movimientosPendientes = await this.getMovimientosPendientesConciliacion(
|
||||||
|
cuenta,
|
||||||
|
periodo,
|
||||||
|
ejercicio
|
||||||
|
);
|
||||||
|
|
||||||
|
return conciliacion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene movimientos pendientes de conciliar
|
||||||
|
*/
|
||||||
|
async getMovimientosPendientesConciliacion(
|
||||||
|
cuenta: string,
|
||||||
|
periodo: number,
|
||||||
|
ejercicio: number
|
||||||
|
): Promise<MovimientoBancario[]> {
|
||||||
|
// Calcular rango de fechas del periodo
|
||||||
|
const fechaInicio = new Date(ejercicio, periodo - 1, 1);
|
||||||
|
const fechaFin = new Date(ejercicio, periodo, 0); // Ultimo dia del mes
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_MOV,
|
||||||
|
CVE_CTA,
|
||||||
|
FECHA,
|
||||||
|
TIPO_MOV,
|
||||||
|
REFERENCIA,
|
||||||
|
NUM_CHEQUE,
|
||||||
|
BENEFICIARIO,
|
||||||
|
CONCEPTO,
|
||||||
|
DEPOSITO,
|
||||||
|
RETIRO,
|
||||||
|
SALDO,
|
||||||
|
CONCILIADO,
|
||||||
|
FECHA_CONC,
|
||||||
|
NUM_POLIZA,
|
||||||
|
TIPO_POLIZA,
|
||||||
|
UUID
|
||||||
|
FROM ${BANCO_TABLES.MOVIMIENTOS}
|
||||||
|
WHERE CVE_CTA = ?
|
||||||
|
AND FECHA <= ?
|
||||||
|
AND (CONCILIADO = 'N' OR CONCILIADO IS NULL)
|
||||||
|
ORDER BY FECHA, ID_MOV
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [
|
||||||
|
cuenta,
|
||||||
|
AspelClient.formatAspelDate(fechaFin),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToMovimientoBancario(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado de conciliacion de una cuenta
|
||||||
|
*/
|
||||||
|
async getEstadoConciliacion(cuenta: string): Promise<{
|
||||||
|
movimientosPendientes: number;
|
||||||
|
depositosPendientes: number;
|
||||||
|
retirosPendientes: number;
|
||||||
|
totalPendiente: number;
|
||||||
|
ultimaConciliacion?: Date;
|
||||||
|
}> {
|
||||||
|
// Movimientos pendientes
|
||||||
|
const pendientesSql = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS NUM_MOVS,
|
||||||
|
COALESCE(SUM(DEPOSITO), 0) AS DEPOSITOS,
|
||||||
|
COALESCE(SUM(RETIRO), 0) AS RETIROS
|
||||||
|
FROM ${BANCO_TABLES.MOVIMIENTOS}
|
||||||
|
WHERE CVE_CTA = ? AND (CONCILIADO = 'N' OR CONCILIADO IS NULL)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const pendientesResult = await this.client.queryOne<{
|
||||||
|
NUM_MOVS: number;
|
||||||
|
DEPOSITOS: number;
|
||||||
|
RETIROS: number;
|
||||||
|
}>(pendientesSql, [cuenta]);
|
||||||
|
|
||||||
|
// Ultima conciliacion
|
||||||
|
const ultimaConcSql = `
|
||||||
|
SELECT MAX(FECHA_ELAB) AS ULTIMA_CONC
|
||||||
|
FROM ${BANCO_TABLES.CONCILIACIONES}
|
||||||
|
WHERE CVE_CTA = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ultimaConcResult = await this.client.queryOne<{ ULTIMA_CONC: unknown }>(
|
||||||
|
ultimaConcSql,
|
||||||
|
[cuenta]
|
||||||
|
);
|
||||||
|
|
||||||
|
const depositos = pendientesResult?.DEPOSITOS || 0;
|
||||||
|
const retiros = pendientesResult?.RETIROS || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
movimientosPendientes: pendientesResult?.NUM_MOVS || 0,
|
||||||
|
depositosPendientes: depositos,
|
||||||
|
retirosPendientes: retiros,
|
||||||
|
totalPendiente: depositos - retiros,
|
||||||
|
ultimaConciliacion: AspelClient.parseAspelDate(ultimaConcResult?.ULTIMA_CONC) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cheques
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene cheques emitidos
|
||||||
|
*/
|
||||||
|
async getCheques(
|
||||||
|
rango: RangoFechas,
|
||||||
|
options?: {
|
||||||
|
cuenta?: string;
|
||||||
|
estatus?: 'E' | 'C' | 'P'; // Emitido, Cancelado, Pendiente
|
||||||
|
}
|
||||||
|
): Promise<Array<{
|
||||||
|
numero: string;
|
||||||
|
cuenta: string;
|
||||||
|
fecha: Date;
|
||||||
|
beneficiario: string;
|
||||||
|
importe: number;
|
||||||
|
concepto: string;
|
||||||
|
estatus: string;
|
||||||
|
fechaCobro?: Date;
|
||||||
|
}>> {
|
||||||
|
const conditions = ['FECHA >= ?', 'FECHA <= ?'];
|
||||||
|
const params: unknown[] = [
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.cuenta) {
|
||||||
|
conditions.push('CVE_CTA = ?');
|
||||||
|
params.push(options.cuenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.estatus) {
|
||||||
|
conditions.push('ESTATUS = ?');
|
||||||
|
params.push(options.estatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
NUM_CHEQUE,
|
||||||
|
CVE_CTA,
|
||||||
|
FECHA,
|
||||||
|
BENEFICIARIO,
|
||||||
|
IMPORTE,
|
||||||
|
CONCEPTO,
|
||||||
|
ESTATUS,
|
||||||
|
FECHA_COBRO
|
||||||
|
FROM ${BANCO_TABLES.CHEQUES}
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY FECHA, NUM_CHEQUE
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, params);
|
||||||
|
|
||||||
|
return result.rows.map(row => ({
|
||||||
|
numero: String(row.NUM_CHEQUE || ''),
|
||||||
|
cuenta: String(row.CVE_CTA || ''),
|
||||||
|
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
|
||||||
|
beneficiario: String(row.BENEFICIARIO || ''),
|
||||||
|
importe: Number(row.IMPORTE) || 0,
|
||||||
|
concepto: String(row.CONCEPTO || ''),
|
||||||
|
estatus: String(row.ESTATUS || 'E'),
|
||||||
|
fechaCobro: AspelClient.parseAspelDate(row.FECHA_COBRO) || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Traspasos
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene traspasos entre cuentas
|
||||||
|
*/
|
||||||
|
async getTraspasos(rango: RangoFechas): Promise<Array<{
|
||||||
|
id: number;
|
||||||
|
fecha: Date;
|
||||||
|
cuentaOrigen: string;
|
||||||
|
cuentaDestino: string;
|
||||||
|
importe: number;
|
||||||
|
concepto: string;
|
||||||
|
referencia?: string;
|
||||||
|
}>> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_TRASPASO,
|
||||||
|
FECHA,
|
||||||
|
CTA_ORIGEN,
|
||||||
|
CTA_DESTINO,
|
||||||
|
IMPORTE,
|
||||||
|
CONCEPTO,
|
||||||
|
REFERENCIA
|
||||||
|
FROM ${BANCO_TABLES.TRASPASOS}
|
||||||
|
WHERE FECHA >= ? AND FECHA <= ?
|
||||||
|
ORDER BY FECHA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.rows.map(row => ({
|
||||||
|
id: Number(row.ID_TRASPASO) || 0,
|
||||||
|
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
|
||||||
|
cuentaOrigen: String(row.CTA_ORIGEN || ''),
|
||||||
|
cuentaDestino: String(row.CTA_DESTINO || ''),
|
||||||
|
importe: Number(row.IMPORTE) || 0,
|
||||||
|
concepto: String(row.CONCEPTO || ''),
|
||||||
|
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Reportes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el flujo de efectivo por periodo
|
||||||
|
*/
|
||||||
|
async getFlujoEfectivo(
|
||||||
|
rango: RangoFechas,
|
||||||
|
agrupacion: 'dia' | 'semana' | 'mes' = 'dia'
|
||||||
|
): Promise<Array<{
|
||||||
|
fecha: Date;
|
||||||
|
depositos: number;
|
||||||
|
retiros: number;
|
||||||
|
neto: number;
|
||||||
|
saldo: number;
|
||||||
|
}>> {
|
||||||
|
let dateFormat: string;
|
||||||
|
const state = this.client.getState();
|
||||||
|
|
||||||
|
if (state.databaseType === 'firebird') {
|
||||||
|
switch (agrupacion) {
|
||||||
|
case 'semana':
|
||||||
|
dateFormat = "EXTRACT(YEAR FROM FECHA) || '-' || LPAD(EXTRACT(WEEK FROM FECHA), 2, '0')";
|
||||||
|
break;
|
||||||
|
case 'mes':
|
||||||
|
dateFormat = "EXTRACT(YEAR FROM FECHA) || '-' || LPAD(EXTRACT(MONTH FROM FECHA), 2, '0')";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dateFormat = 'FECHA';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (agrupacion) {
|
||||||
|
case 'semana':
|
||||||
|
dateFormat = "FORMAT(FECHA, 'yyyy-ww')";
|
||||||
|
break;
|
||||||
|
case 'mes':
|
||||||
|
dateFormat = "FORMAT(FECHA, 'yyyy-MM')";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dateFormat = 'CAST(FECHA AS DATE)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
${agrupacion === 'dia' ? 'FECHA' : `MIN(FECHA) AS FECHA`},
|
||||||
|
SUM(DEPOSITO) AS DEPOSITOS,
|
||||||
|
SUM(RETIRO) AS RETIROS
|
||||||
|
FROM ${BANCO_TABLES.MOVIMIENTOS}
|
||||||
|
WHERE FECHA >= ? AND FECHA <= ?
|
||||||
|
${agrupacion !== 'dia' ? `GROUP BY ${dateFormat}` : ''}
|
||||||
|
ORDER BY FECHA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<{
|
||||||
|
FECHA: unknown;
|
||||||
|
DEPOSITOS: number;
|
||||||
|
RETIROS: number;
|
||||||
|
}>(sql, [
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calcular saldos acumulados
|
||||||
|
let saldoAcumulado = 0;
|
||||||
|
|
||||||
|
return result.rows.map(row => {
|
||||||
|
const depositos = Number(row.DEPOSITOS) || 0;
|
||||||
|
const retiros = Number(row.RETIROS) || 0;
|
||||||
|
const neto = depositos - retiros;
|
||||||
|
saldoAcumulado += neto;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
|
||||||
|
depositos,
|
||||||
|
retiros,
|
||||||
|
neto,
|
||||||
|
saldo: saldoAcumulado,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utilidades
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene clausula LIMIT/OFFSET segun tipo de BD
|
||||||
|
*/
|
||||||
|
private getLimitClause(limit: number, offset: number): string {
|
||||||
|
const state = this.client.getState();
|
||||||
|
|
||||||
|
if (state.databaseType === 'firebird') {
|
||||||
|
return `ROWS ${offset + 1} TO ${offset + limit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mappers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private mapToCuentaBancaria(row: Record<string, unknown>): CuentaBancaria {
|
||||||
|
return {
|
||||||
|
clave: String(row.CVE_CTA || ''),
|
||||||
|
nombre: String(row.NOMBRE || ''),
|
||||||
|
numeroCuenta: String(row.NUM_CTA || ''),
|
||||||
|
clabe: row.CLABE ? String(row.CLABE) : undefined,
|
||||||
|
banco: String(row.NOMBRE_BANCO || ''),
|
||||||
|
tipoCuenta: String(row.TIPO_CTA || 'C') as CuentaBancaria['tipoCuenta'],
|
||||||
|
moneda: String(row.MONEDA || 'MXN'),
|
||||||
|
saldo: Number(row.SALDO) || 0,
|
||||||
|
saldoDisponible: Number(row.SALDO_DISP) || 0,
|
||||||
|
cuentaContable: row.CTA_CONTABLE ? String(row.CTA_CONTABLE) : undefined,
|
||||||
|
rfcTitular: row.RFC_TITULAR ? String(row.RFC_TITULAR) : undefined,
|
||||||
|
nombreTitular: row.NOMBRE_TIT ? String(row.NOMBRE_TIT) : undefined,
|
||||||
|
activa: row.ESTATUS === 'A',
|
||||||
|
fechaApertura: AspelClient.parseAspelDate(row.FECHA_APERT) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToMovimientoBancario(row: Record<string, unknown>): MovimientoBancario {
|
||||||
|
return {
|
||||||
|
id: Number(row.ID_MOV) || 0,
|
||||||
|
claveCuenta: String(row.CVE_CTA || ''),
|
||||||
|
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
|
||||||
|
tipo: String(row.TIPO_MOV || 'D') as MovimientoBancario['tipo'],
|
||||||
|
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
|
||||||
|
numeroCheque: row.NUM_CHEQUE ? String(row.NUM_CHEQUE) : undefined,
|
||||||
|
beneficiario: row.BENEFICIARIO ? String(row.BENEFICIARIO) : undefined,
|
||||||
|
concepto: String(row.CONCEPTO || ''),
|
||||||
|
deposito: Number(row.DEPOSITO) || 0,
|
||||||
|
retiro: Number(row.RETIRO) || 0,
|
||||||
|
saldo: Number(row.SALDO) || 0,
|
||||||
|
conciliado: row.CONCILIADO === 'S',
|
||||||
|
fechaConciliacion: AspelClient.parseAspelDate(row.FECHA_CONC) || undefined,
|
||||||
|
numeroPoliza: row.NUM_POLIZA ? Number(row.NUM_POLIZA) : undefined,
|
||||||
|
tipoPoliza: row.TIPO_POLIZA ? String(row.TIPO_POLIZA) : undefined,
|
||||||
|
uuid: row.UUID ? String(row.UUID) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToConciliacionBancaria(row: Record<string, unknown>): ConciliacionBancaria {
|
||||||
|
const saldoLibros = Number(row.SALDO_LIBROS) || 0;
|
||||||
|
const saldoBanco = Number(row.SALDO_BANCO) || 0;
|
||||||
|
const diferencia = Number(row.DIFERENCIA) || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.ID_CONC) || 0,
|
||||||
|
claveCuenta: String(row.CVE_CTA || ''),
|
||||||
|
periodo: Number(row.PERIODO) || 0,
|
||||||
|
ejercicio: Number(row.EJERCICIO) || 0,
|
||||||
|
fechaCorte: AspelClient.parseAspelDate(row.FECHA_CORTE) || new Date(),
|
||||||
|
saldoLibros,
|
||||||
|
saldoBanco,
|
||||||
|
depositosTransito: Number(row.DEP_TRANSITO) || 0,
|
||||||
|
chequesTransito: Number(row.CHQ_TRANSITO) || 0,
|
||||||
|
cargosNoRegistrados: Number(row.CARGOS_NO_REG) || 0,
|
||||||
|
abonosNoRegistrados: Number(row.ABONOS_NO_REG) || 0,
|
||||||
|
diferencia,
|
||||||
|
cuadrada: row.CUADRADA === 'S' || Math.abs(diferencia) < 0.01,
|
||||||
|
fechaElaboracion: AspelClient.parseAspelDate(row.FECHA_ELAB) || new Date(),
|
||||||
|
movimientosPendientes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function createBANCOConnector(client: AspelClient, empresaId?: number): BANCOConnector {
|
||||||
|
return new BANCOConnector(client, empresaId);
|
||||||
|
}
|
||||||
809
apps/api/src/services/integrations/aspel/coi.connector.ts
Normal file
809
apps/api/src/services/integrations/aspel/coi.connector.ts
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
/**
|
||||||
|
* COI Connector - Contabilidad Integral Aspel
|
||||||
|
* Conector para extraer datos contables de Aspel COI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AspelClient } from './aspel.client.js';
|
||||||
|
import {
|
||||||
|
CuentaContable,
|
||||||
|
Poliza,
|
||||||
|
MovimientoContable,
|
||||||
|
Balanza,
|
||||||
|
SaldoCuenta,
|
||||||
|
MovimientoAuxiliar,
|
||||||
|
PeriodoConsulta,
|
||||||
|
RangoFechas,
|
||||||
|
PaginacionAspel,
|
||||||
|
ResultadoPaginado,
|
||||||
|
AspelQueryError,
|
||||||
|
} from './aspel.types.js';
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Nombres de tablas COI
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const COI_TABLES = {
|
||||||
|
CUENTAS: 'CUENTAS',
|
||||||
|
POLIZAS: 'POLIZAS',
|
||||||
|
PARTIDAS: 'PARTIDAS',
|
||||||
|
SALDOS: 'SALDOS',
|
||||||
|
PERIODOS: 'PERIODOS',
|
||||||
|
CONFIGURACION: 'CONFIGURACION',
|
||||||
|
DEPARTAMENTOS: 'DEPARTAMENTOS',
|
||||||
|
CUENTAS_SAT: 'CUENTAS_SAT',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Clase del conector COI
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class COIConnector {
|
||||||
|
private client: AspelClient;
|
||||||
|
private empresaId: number;
|
||||||
|
|
||||||
|
constructor(client: AspelClient, empresaId: number = 1) {
|
||||||
|
this.client = client;
|
||||||
|
this.empresaId = empresaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Catalogo de Cuentas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el catalogo completo de cuentas contables
|
||||||
|
*/
|
||||||
|
async getCatalogoCuentas(options?: {
|
||||||
|
soloActivas?: boolean;
|
||||||
|
nivel?: number;
|
||||||
|
tipoCuenta?: 'A' | 'D';
|
||||||
|
}): Promise<CuentaContable[]> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (options?.soloActivas) {
|
||||||
|
conditions.push('ESTATUS = ?');
|
||||||
|
params.push('A');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.nivel !== undefined) {
|
||||||
|
conditions.push('NIVEL = ?');
|
||||||
|
params.push(options.nivel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.tipoCuenta) {
|
||||||
|
conditions.push('TIPO_CTA = ?');
|
||||||
|
params.push(options.tipoCuenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
NUM_CTA,
|
||||||
|
NOMBRE,
|
||||||
|
TIPO_CTA,
|
||||||
|
NATURALEZA,
|
||||||
|
NIVEL,
|
||||||
|
CTA_PADRE,
|
||||||
|
ES_BANCO,
|
||||||
|
ES_IVA,
|
||||||
|
ES_ISR,
|
||||||
|
ESTATUS,
|
||||||
|
CODIGO_SAT,
|
||||||
|
FECHA_ALTA,
|
||||||
|
FECHA_ULT_MOV
|
||||||
|
FROM ${COI_TABLES.CUENTAS}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY NUM_CTA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, params);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToCuentaContable(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una cuenta contable por numero
|
||||||
|
*/
|
||||||
|
async getCuenta(numeroCuenta: string): Promise<CuentaContable | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
NUM_CTA,
|
||||||
|
NOMBRE,
|
||||||
|
TIPO_CTA,
|
||||||
|
NATURALEZA,
|
||||||
|
NIVEL,
|
||||||
|
CTA_PADRE,
|
||||||
|
ES_BANCO,
|
||||||
|
ES_IVA,
|
||||||
|
ES_ISR,
|
||||||
|
ESTATUS,
|
||||||
|
CODIGO_SAT,
|
||||||
|
FECHA_ALTA,
|
||||||
|
FECHA_ULT_MOV
|
||||||
|
FROM ${COI_TABLES.CUENTAS}
|
||||||
|
WHERE NUM_CTA = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const row = await this.client.queryOne<Record<string, unknown>>(sql, [numeroCuenta]);
|
||||||
|
|
||||||
|
return row ? this.mapToCuentaContable(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las subcuentas de una cuenta
|
||||||
|
*/
|
||||||
|
async getSubcuentas(cuentaPadre: string): Promise<CuentaContable[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
NUM_CTA,
|
||||||
|
NOMBRE,
|
||||||
|
TIPO_CTA,
|
||||||
|
NATURALEZA,
|
||||||
|
NIVEL,
|
||||||
|
CTA_PADRE,
|
||||||
|
ES_BANCO,
|
||||||
|
ES_IVA,
|
||||||
|
ES_ISR,
|
||||||
|
ESTATUS,
|
||||||
|
CODIGO_SAT,
|
||||||
|
FECHA_ALTA,
|
||||||
|
FECHA_ULT_MOV
|
||||||
|
FROM ${COI_TABLES.CUENTAS}
|
||||||
|
WHERE CTA_PADRE = ?
|
||||||
|
ORDER BY NUM_CTA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [cuentaPadre]);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToCuentaContable(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Polizas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene polizas de un periodo
|
||||||
|
*/
|
||||||
|
async getPolizas(
|
||||||
|
periodo: PeriodoConsulta,
|
||||||
|
options?: {
|
||||||
|
tipo?: string;
|
||||||
|
conMovimientos?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<Poliza[]> {
|
||||||
|
const conditions = ['PERIODO = ?', 'EJERCICIO = ?'];
|
||||||
|
const params: unknown[] = [periodo.periodo, periodo.ejercicio];
|
||||||
|
|
||||||
|
if (options?.tipo) {
|
||||||
|
conditions.push('TIPO_POLI = ?');
|
||||||
|
params.push(options.tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_POLIZA,
|
||||||
|
TIPO_POLI,
|
||||||
|
NUM_POLIZ,
|
||||||
|
FECHA,
|
||||||
|
CONCEPTO,
|
||||||
|
PERIODO,
|
||||||
|
EJERCICIO,
|
||||||
|
TOTAL_CARGOS,
|
||||||
|
TOTAL_ABONOS,
|
||||||
|
REFERENCIA,
|
||||||
|
FECHA_CAPTURA,
|
||||||
|
USUARIO,
|
||||||
|
UUID_CFDI
|
||||||
|
FROM ${COI_TABLES.POLIZAS}
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY TIPO_POLI, NUM_POLIZ
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, params);
|
||||||
|
|
||||||
|
const polizas: Poliza[] = [];
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const poliza = this.mapToPoliza(row);
|
||||||
|
|
||||||
|
if (options?.conMovimientos) {
|
||||||
|
poliza.movimientos = await this.getMovimientosPoliza(poliza.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
polizas.push(poliza);
|
||||||
|
}
|
||||||
|
|
||||||
|
return polizas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene polizas por rango de fechas
|
||||||
|
*/
|
||||||
|
async getPolizasByFecha(
|
||||||
|
rango: RangoFechas,
|
||||||
|
options?: {
|
||||||
|
tipo?: string;
|
||||||
|
conMovimientos?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<Poliza[]> {
|
||||||
|
const conditions = ['FECHA >= ?', 'FECHA <= ?'];
|
||||||
|
const params: unknown[] = [
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.tipo) {
|
||||||
|
conditions.push('TIPO_POLI = ?');
|
||||||
|
params.push(options.tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_POLIZA,
|
||||||
|
TIPO_POLI,
|
||||||
|
NUM_POLIZ,
|
||||||
|
FECHA,
|
||||||
|
CONCEPTO,
|
||||||
|
PERIODO,
|
||||||
|
EJERCICIO,
|
||||||
|
TOTAL_CARGOS,
|
||||||
|
TOTAL_ABONOS,
|
||||||
|
REFERENCIA,
|
||||||
|
FECHA_CAPTURA,
|
||||||
|
USUARIO,
|
||||||
|
UUID_CFDI
|
||||||
|
FROM ${COI_TABLES.POLIZAS}
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY FECHA, TIPO_POLI, NUM_POLIZ
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, params);
|
||||||
|
|
||||||
|
const polizas: Poliza[] = [];
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const poliza = this.mapToPoliza(row);
|
||||||
|
|
||||||
|
if (options?.conMovimientos) {
|
||||||
|
poliza.movimientos = await this.getMovimientosPoliza(poliza.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
polizas.push(poliza);
|
||||||
|
}
|
||||||
|
|
||||||
|
return polizas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una poliza especifica
|
||||||
|
*/
|
||||||
|
async getPoliza(
|
||||||
|
tipo: string,
|
||||||
|
numero: number,
|
||||||
|
periodo: PeriodoConsulta
|
||||||
|
): Promise<Poliza | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_POLIZA,
|
||||||
|
TIPO_POLI,
|
||||||
|
NUM_POLIZ,
|
||||||
|
FECHA,
|
||||||
|
CONCEPTO,
|
||||||
|
PERIODO,
|
||||||
|
EJERCICIO,
|
||||||
|
TOTAL_CARGOS,
|
||||||
|
TOTAL_ABONOS,
|
||||||
|
REFERENCIA,
|
||||||
|
FECHA_CAPTURA,
|
||||||
|
USUARIO,
|
||||||
|
UUID_CFDI
|
||||||
|
FROM ${COI_TABLES.POLIZAS}
|
||||||
|
WHERE TIPO_POLI = ? AND NUM_POLIZ = ? AND PERIODO = ? AND EJERCICIO = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const row = await this.client.queryOne<Record<string, unknown>>(sql, [
|
||||||
|
tipo,
|
||||||
|
numero,
|
||||||
|
periodo.periodo,
|
||||||
|
periodo.ejercicio,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const poliza = this.mapToPoliza(row);
|
||||||
|
poliza.movimientos = await this.getMovimientosPoliza(poliza.id);
|
||||||
|
|
||||||
|
return poliza;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los movimientos de una poliza
|
||||||
|
*/
|
||||||
|
async getMovimientosPoliza(idPoliza: number): Promise<MovimientoContable[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
p.NUM_CTA,
|
||||||
|
c.NOMBRE AS NOMBRE_CTA,
|
||||||
|
p.CONCEPTO,
|
||||||
|
p.CARGO,
|
||||||
|
p.ABONO,
|
||||||
|
p.NUM_CHEQUE,
|
||||||
|
p.REFERENCIA,
|
||||||
|
p.UUID_CFDI,
|
||||||
|
p.DEPARTAMENTO,
|
||||||
|
p.TIPO_CAMBIO,
|
||||||
|
p.MONEDA
|
||||||
|
FROM ${COI_TABLES.PARTIDAS} p
|
||||||
|
LEFT JOIN ${COI_TABLES.CUENTAS} c ON p.NUM_CTA = c.NUM_CTA
|
||||||
|
WHERE p.ID_POLIZA = ?
|
||||||
|
ORDER BY p.NUM_PARTIDA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [idPoliza]);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToMovimientoContable(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Balanza de Comprobacion
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la balanza de comprobacion de un periodo
|
||||||
|
*/
|
||||||
|
async getBalanza(periodo: PeriodoConsulta): Promise<Balanza> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
s.NUM_CTA,
|
||||||
|
c.NOMBRE,
|
||||||
|
s.SALDO_INI,
|
||||||
|
s.CARGOS,
|
||||||
|
s.ABONOS,
|
||||||
|
s.SALDO_FIN
|
||||||
|
FROM ${COI_TABLES.SALDOS} s
|
||||||
|
JOIN ${COI_TABLES.CUENTAS} c ON s.NUM_CTA = c.NUM_CTA
|
||||||
|
WHERE s.PERIODO = ? AND s.EJERCICIO = ? AND c.TIPO_CTA = 'D'
|
||||||
|
ORDER BY s.NUM_CTA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [
|
||||||
|
periodo.periodo,
|
||||||
|
periodo.ejercicio,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cuentas = result.rows.map(row => this.mapToSaldoCuenta(row, periodo));
|
||||||
|
|
||||||
|
const totalCargos = cuentas.reduce((sum, c) => sum + c.totalCargos, 0);
|
||||||
|
const totalAbonos = cuentas.reduce((sum, c) => sum + c.totalAbonos, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
periodo: periodo.periodo,
|
||||||
|
ejercicio: periodo.ejercicio,
|
||||||
|
fechaGeneracion: new Date(),
|
||||||
|
cuentas,
|
||||||
|
totalCargos,
|
||||||
|
totalAbonos,
|
||||||
|
cuadrada: Math.abs(totalCargos - totalAbonos) < 0.01,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene balanza de comprobacion con saldos iniciales y acumulados
|
||||||
|
*/
|
||||||
|
async getBalanzaCompleta(periodo: PeriodoConsulta): Promise<Balanza> {
|
||||||
|
// Obtener saldos iniciales (acumulado hasta periodo anterior)
|
||||||
|
const sqlSaldosIni = `
|
||||||
|
SELECT
|
||||||
|
NUM_CTA,
|
||||||
|
SUM(SALDO_FIN) AS SALDO_ACUM
|
||||||
|
FROM ${COI_TABLES.SALDOS}
|
||||||
|
WHERE EJERCICIO = ? AND PERIODO < ?
|
||||||
|
GROUP BY NUM_CTA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const saldosIniResult = await this.client.query<{
|
||||||
|
NUM_CTA: string;
|
||||||
|
SALDO_ACUM: number;
|
||||||
|
}>(sqlSaldosIni, [periodo.ejercicio, periodo.periodo]);
|
||||||
|
|
||||||
|
const saldosIniMap = new Map<string, number>();
|
||||||
|
for (const row of saldosIniResult.rows) {
|
||||||
|
saldosIniMap.set(row.NUM_CTA, row.SALDO_ACUM);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener movimientos del periodo
|
||||||
|
const balanza = await this.getBalanza(periodo);
|
||||||
|
|
||||||
|
// Ajustar saldos iniciales
|
||||||
|
for (const cuenta of balanza.cuentas) {
|
||||||
|
const saldoAcumulado = saldosIniMap.get(cuenta.numeroCuenta) || 0;
|
||||||
|
cuenta.saldoInicial += saldoAcumulado;
|
||||||
|
cuenta.saldoFinal = cuenta.saldoInicial + cuenta.totalCargos - cuenta.totalAbonos;
|
||||||
|
}
|
||||||
|
|
||||||
|
return balanza;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auxiliares
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el auxiliar de una cuenta
|
||||||
|
*/
|
||||||
|
async getAuxiliares(
|
||||||
|
numeroCuenta: string,
|
||||||
|
periodo: PeriodoConsulta
|
||||||
|
): Promise<MovimientoAuxiliar[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
pol.FECHA,
|
||||||
|
pol.TIPO_POLI,
|
||||||
|
pol.NUM_POLIZ,
|
||||||
|
par.CONCEPTO,
|
||||||
|
par.CARGO,
|
||||||
|
par.ABONO,
|
||||||
|
par.REFERENCIA
|
||||||
|
FROM ${COI_TABLES.PARTIDAS} par
|
||||||
|
JOIN ${COI_TABLES.POLIZAS} pol ON par.ID_POLIZA = pol.ID_POLIZA
|
||||||
|
WHERE par.NUM_CTA = ? AND pol.PERIODO = ? AND pol.EJERCICIO = ?
|
||||||
|
ORDER BY pol.FECHA, pol.TIPO_POLI, pol.NUM_POLIZ
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [
|
||||||
|
numeroCuenta,
|
||||||
|
periodo.periodo,
|
||||||
|
periodo.ejercicio,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calcular saldo acumulado
|
||||||
|
let saldo = 0;
|
||||||
|
|
||||||
|
// Obtener saldo inicial
|
||||||
|
const saldoIni = await this.getSaldoInicial(numeroCuenta, periodo);
|
||||||
|
saldo = saldoIni;
|
||||||
|
|
||||||
|
return result.rows.map(row => {
|
||||||
|
const cargo = Number(row.CARGO) || 0;
|
||||||
|
const abono = Number(row.ABONO) || 0;
|
||||||
|
saldo = saldo + cargo - abono;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
|
||||||
|
tipoPoliza: String(row.TIPO_POLI || ''),
|
||||||
|
numeroPoliza: Number(row.NUM_POLIZ) || 0,
|
||||||
|
concepto: String(row.CONCEPTO || ''),
|
||||||
|
cargo,
|
||||||
|
abono,
|
||||||
|
saldo,
|
||||||
|
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el auxiliar por rango de fechas
|
||||||
|
*/
|
||||||
|
async getAuxiliaresByFecha(
|
||||||
|
numeroCuenta: string,
|
||||||
|
rango: RangoFechas
|
||||||
|
): Promise<MovimientoAuxiliar[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
pol.FECHA,
|
||||||
|
pol.TIPO_POLI,
|
||||||
|
pol.NUM_POLIZ,
|
||||||
|
par.CONCEPTO,
|
||||||
|
par.CARGO,
|
||||||
|
par.ABONO,
|
||||||
|
par.REFERENCIA
|
||||||
|
FROM ${COI_TABLES.PARTIDAS} par
|
||||||
|
JOIN ${COI_TABLES.POLIZAS} pol ON par.ID_POLIZA = pol.ID_POLIZA
|
||||||
|
WHERE par.NUM_CTA = ? AND pol.FECHA >= ? AND pol.FECHA <= ?
|
||||||
|
ORDER BY pol.FECHA, pol.TIPO_POLI, pol.NUM_POLIZ
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [
|
||||||
|
numeroCuenta,
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let saldo = 0;
|
||||||
|
|
||||||
|
return result.rows.map(row => {
|
||||||
|
const cargo = Number(row.CARGO) || 0;
|
||||||
|
const abono = Number(row.ABONO) || 0;
|
||||||
|
saldo = saldo + cargo - abono;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
|
||||||
|
tipoPoliza: String(row.TIPO_POLI || ''),
|
||||||
|
numeroPoliza: Number(row.NUM_POLIZ) || 0,
|
||||||
|
concepto: String(row.CONCEPTO || ''),
|
||||||
|
cargo,
|
||||||
|
abono,
|
||||||
|
saldo,
|
||||||
|
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el saldo inicial de una cuenta para un periodo
|
||||||
|
*/
|
||||||
|
private async getSaldoInicial(
|
||||||
|
numeroCuenta: string,
|
||||||
|
periodo: PeriodoConsulta
|
||||||
|
): Promise<number> {
|
||||||
|
const sql = `
|
||||||
|
SELECT COALESCE(SUM(SALDO_FIN), 0) AS SALDO
|
||||||
|
FROM ${COI_TABLES.SALDOS}
|
||||||
|
WHERE NUM_CTA = ? AND EJERCICIO = ? AND PERIODO < ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryOne<{ SALDO: number }>(sql, [
|
||||||
|
numeroCuenta,
|
||||||
|
periodo.ejercicio,
|
||||||
|
periodo.periodo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result?.SALDO || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Diario General
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el diario general de un periodo
|
||||||
|
*/
|
||||||
|
async getDiarioGeneral(
|
||||||
|
periodo: PeriodoConsulta,
|
||||||
|
paginacion?: PaginacionAspel
|
||||||
|
): Promise<ResultadoPaginado<Poliza>> {
|
||||||
|
// Contar total de polizas
|
||||||
|
const countSql = `
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM ${COI_TABLES.POLIZAS}
|
||||||
|
WHERE PERIODO = ? AND EJERCICIO = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const countResult = await this.client.queryOne<{ total: number }>(countSql, [
|
||||||
|
periodo.periodo,
|
||||||
|
periodo.ejercicio,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = countResult?.total || 0;
|
||||||
|
|
||||||
|
// Obtener polizas con paginacion
|
||||||
|
const pagina = paginacion?.pagina || 1;
|
||||||
|
const porPagina = paginacion?.porPagina || 50;
|
||||||
|
const offset = (pagina - 1) * porPagina;
|
||||||
|
|
||||||
|
const orderBy = paginacion?.ordenarPor || 'FECHA';
|
||||||
|
const direccion = paginacion?.direccion || 'ASC';
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_POLIZA,
|
||||||
|
TIPO_POLI,
|
||||||
|
NUM_POLIZ,
|
||||||
|
FECHA,
|
||||||
|
CONCEPTO,
|
||||||
|
PERIODO,
|
||||||
|
EJERCICIO,
|
||||||
|
TOTAL_CARGOS,
|
||||||
|
TOTAL_ABONOS,
|
||||||
|
REFERENCIA,
|
||||||
|
FECHA_CAPTURA,
|
||||||
|
USUARIO,
|
||||||
|
UUID_CFDI
|
||||||
|
FROM ${COI_TABLES.POLIZAS}
|
||||||
|
WHERE PERIODO = ? AND EJERCICIO = ?
|
||||||
|
ORDER BY ${orderBy} ${direccion}
|
||||||
|
${this.getLimitClause(porPagina, offset)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [
|
||||||
|
periodo.periodo,
|
||||||
|
periodo.ejercicio,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const polizas: Poliza[] = [];
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const poliza = this.mapToPoliza(row);
|
||||||
|
poliza.movimientos = await this.getMovimientosPoliza(poliza.id);
|
||||||
|
polizas.push(poliza);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: polizas,
|
||||||
|
total,
|
||||||
|
pagina,
|
||||||
|
porPagina,
|
||||||
|
totalPaginas: Math.ceil(total / porPagina),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene diario general por rango de fechas
|
||||||
|
*/
|
||||||
|
async getDiarioGeneralByFecha(
|
||||||
|
rango: RangoFechas,
|
||||||
|
paginacion?: PaginacionAspel
|
||||||
|
): Promise<ResultadoPaginado<Poliza>> {
|
||||||
|
// Contar total de polizas
|
||||||
|
const countSql = `
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM ${COI_TABLES.POLIZAS}
|
||||||
|
WHERE FECHA >= ? AND FECHA <= ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const countResult = await this.client.queryOne<{ total: number }>(countSql, [
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = countResult?.total || 0;
|
||||||
|
|
||||||
|
// Obtener polizas con paginacion
|
||||||
|
const pagina = paginacion?.pagina || 1;
|
||||||
|
const porPagina = paginacion?.porPagina || 50;
|
||||||
|
const offset = (pagina - 1) * porPagina;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_POLIZA,
|
||||||
|
TIPO_POLI,
|
||||||
|
NUM_POLIZ,
|
||||||
|
FECHA,
|
||||||
|
CONCEPTO,
|
||||||
|
PERIODO,
|
||||||
|
EJERCICIO,
|
||||||
|
TOTAL_CARGOS,
|
||||||
|
TOTAL_ABONOS,
|
||||||
|
REFERENCIA,
|
||||||
|
FECHA_CAPTURA,
|
||||||
|
USUARIO,
|
||||||
|
UUID_CFDI
|
||||||
|
FROM ${COI_TABLES.POLIZAS}
|
||||||
|
WHERE FECHA >= ? AND FECHA <= ?
|
||||||
|
ORDER BY FECHA, TIPO_POLI, NUM_POLIZ
|
||||||
|
${this.getLimitClause(porPagina, offset)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const polizas: Poliza[] = [];
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const poliza = this.mapToPoliza(row);
|
||||||
|
poliza.movimientos = await this.getMovimientosPoliza(poliza.id);
|
||||||
|
polizas.push(poliza);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: polizas,
|
||||||
|
total,
|
||||||
|
pagina,
|
||||||
|
porPagina,
|
||||||
|
totalPaginas: Math.ceil(total / porPagina),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utilidades
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene clausula LIMIT/OFFSET segun tipo de BD
|
||||||
|
*/
|
||||||
|
private getLimitClause(limit: number, offset: number): string {
|
||||||
|
const state = this.client.getState();
|
||||||
|
|
||||||
|
if (state.databaseType === 'firebird') {
|
||||||
|
return `ROWS ${offset + 1} TO ${offset + limit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea fila de BD a CuentaContable
|
||||||
|
*/
|
||||||
|
private mapToCuentaContable(row: Record<string, unknown>): CuentaContable {
|
||||||
|
return {
|
||||||
|
numeroCuenta: String(row.NUM_CTA || ''),
|
||||||
|
nombre: String(row.NOMBRE || ''),
|
||||||
|
tipo: (String(row.TIPO_CTA || 'D') as 'A' | 'D'),
|
||||||
|
naturaleza: (String(row.NATURALEZA || 'D') as 'D' | 'A'),
|
||||||
|
nivel: Number(row.NIVEL) || 1,
|
||||||
|
cuentaPadre: row.CTA_PADRE ? String(row.CTA_PADRE) : undefined,
|
||||||
|
esBanco: row.ES_BANCO === 'S' || row.ES_BANCO === true || row.ES_BANCO === 1,
|
||||||
|
esIva: row.ES_IVA === 'S' || row.ES_IVA === true || row.ES_IVA === 1,
|
||||||
|
esIsr: row.ES_ISR === 'S' || row.ES_ISR === true || row.ES_ISR === 1,
|
||||||
|
activa: row.ESTATUS === 'A' || row.ESTATUS === true || row.ESTATUS === 1,
|
||||||
|
codigoSat: row.CODIGO_SAT ? String(row.CODIGO_SAT) : undefined,
|
||||||
|
fechaCreacion: AspelClient.parseAspelDate(row.FECHA_ALTA) || undefined,
|
||||||
|
ultimoMovimiento: AspelClient.parseAspelDate(row.FECHA_ULT_MOV) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea fila de BD a Poliza
|
||||||
|
*/
|
||||||
|
private mapToPoliza(row: Record<string, unknown>): Poliza {
|
||||||
|
const totalCargos = Number(row.TOTAL_CARGOS) || 0;
|
||||||
|
const totalAbonos = Number(row.TOTAL_ABONOS) || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.ID_POLIZA) || 0,
|
||||||
|
tipo: String(row.TIPO_POLI || 'D') as Poliza['tipo'],
|
||||||
|
numero: Number(row.NUM_POLIZ) || 0,
|
||||||
|
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
|
||||||
|
concepto: String(row.CONCEPTO || ''),
|
||||||
|
periodo: Number(row.PERIODO) || 0,
|
||||||
|
ejercicio: Number(row.EJERCICIO) || 0,
|
||||||
|
totalCargos,
|
||||||
|
totalAbonos,
|
||||||
|
cuadrada: Math.abs(totalCargos - totalAbonos) < 0.01,
|
||||||
|
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
|
||||||
|
movimientos: [],
|
||||||
|
fechaCaptura: AspelClient.parseAspelDate(row.FECHA_CAPTURA) || undefined,
|
||||||
|
usuarioCaptura: row.USUARIO ? String(row.USUARIO) : undefined,
|
||||||
|
uuidCfdi: row.UUID_CFDI ? String(row.UUID_CFDI) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea fila de BD a MovimientoContable
|
||||||
|
*/
|
||||||
|
private mapToMovimientoContable(row: Record<string, unknown>): MovimientoContable {
|
||||||
|
return {
|
||||||
|
numeroCuenta: String(row.NUM_CTA || ''),
|
||||||
|
nombreCuenta: String(row.NOMBRE_CTA || ''),
|
||||||
|
concepto: String(row.CONCEPTO || ''),
|
||||||
|
cargo: Number(row.CARGO) || 0,
|
||||||
|
abono: Number(row.ABONO) || 0,
|
||||||
|
numeroCheque: row.NUM_CHEQUE ? String(row.NUM_CHEQUE) : undefined,
|
||||||
|
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
|
||||||
|
uuidCfdi: row.UUID_CFDI ? String(row.UUID_CFDI) : undefined,
|
||||||
|
departamento: row.DEPARTAMENTO ? String(row.DEPARTAMENTO) : undefined,
|
||||||
|
tipoCambio: row.TIPO_CAMBIO ? Number(row.TIPO_CAMBIO) : undefined,
|
||||||
|
moneda: row.MONEDA ? String(row.MONEDA) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea fila de BD a SaldoCuenta
|
||||||
|
*/
|
||||||
|
private mapToSaldoCuenta(row: Record<string, unknown>, periodo: PeriodoConsulta): SaldoCuenta {
|
||||||
|
return {
|
||||||
|
numeroCuenta: String(row.NUM_CTA || ''),
|
||||||
|
nombreCuenta: String(row.NOMBRE || ''),
|
||||||
|
saldoInicial: Number(row.SALDO_INI) || 0,
|
||||||
|
totalCargos: Number(row.CARGOS) || 0,
|
||||||
|
totalAbonos: Number(row.ABONOS) || 0,
|
||||||
|
saldoFinal: Number(row.SALDO_FIN) || 0,
|
||||||
|
periodo: periodo.periodo,
|
||||||
|
ejercicio: periodo.ejercicio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function createCOIConnector(client: AspelClient, empresaId?: number): COIConnector {
|
||||||
|
return new COIConnector(client, empresaId);
|
||||||
|
}
|
||||||
52
apps/api/src/services/integrations/aspel/index.ts
Normal file
52
apps/api/src/services/integrations/aspel/index.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Aspel Integration Module
|
||||||
|
* Exportaciones del conector de integracion con productos Aspel
|
||||||
|
*
|
||||||
|
* Productos soportados:
|
||||||
|
* - COI (Contabilidad Integral)
|
||||||
|
* - SAE (Sistema Administrativo Empresarial)
|
||||||
|
* - NOI (Nominas Integral)
|
||||||
|
* - BANCO (Control Bancario)
|
||||||
|
*
|
||||||
|
* Bases de datos soportadas:
|
||||||
|
* - Firebird (versiones clasicas de Aspel)
|
||||||
|
* - SQL Server (versiones modernas de Aspel)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './aspel.types.js';
|
||||||
|
|
||||||
|
// Client
|
||||||
|
export {
|
||||||
|
AspelClient,
|
||||||
|
createAspelClient,
|
||||||
|
connectAspel,
|
||||||
|
} from './aspel.client.js';
|
||||||
|
|
||||||
|
// Connectors
|
||||||
|
export {
|
||||||
|
COIConnector,
|
||||||
|
createCOIConnector,
|
||||||
|
} from './coi.connector.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
SAEConnector,
|
||||||
|
createSAEConnector,
|
||||||
|
} from './sae.connector.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
NOIConnector,
|
||||||
|
createNOIConnector,
|
||||||
|
} from './noi.connector.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
BANCOConnector,
|
||||||
|
createBANCOConnector,
|
||||||
|
} from './banco.connector.js';
|
||||||
|
|
||||||
|
// Sync Service
|
||||||
|
export {
|
||||||
|
AspelSyncService,
|
||||||
|
createAspelSyncService,
|
||||||
|
connectAspelSyncService,
|
||||||
|
} from './aspel.sync.js';
|
||||||
874
apps/api/src/services/integrations/aspel/noi.connector.ts
Normal file
874
apps/api/src/services/integrations/aspel/noi.connector.ts
Normal file
@@ -0,0 +1,874 @@
|
|||||||
|
/**
|
||||||
|
* NOI Connector - Nominas Integral Aspel
|
||||||
|
* Conector para extraer datos de nominas de Aspel NOI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AspelClient } from './aspel.client.js';
|
||||||
|
import {
|
||||||
|
Empleado,
|
||||||
|
Nomina,
|
||||||
|
ReciboNomina,
|
||||||
|
MovimientoNomina,
|
||||||
|
PeriodoConsulta,
|
||||||
|
RangoFechas,
|
||||||
|
PaginacionAspel,
|
||||||
|
ResultadoPaginado,
|
||||||
|
} from './aspel.types.js';
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Nombres de tablas NOI
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const NOI_TABLES = {
|
||||||
|
EMPLEADOS: 'NOM10001',
|
||||||
|
NOMINAS: 'NOM10010',
|
||||||
|
RECIBOS: 'NOM10011',
|
||||||
|
MOVIMIENTOS: 'NOM10012',
|
||||||
|
DEPARTAMENTOS: 'NOM10002',
|
||||||
|
PUESTOS: 'NOM10003',
|
||||||
|
CONCEPTOS: 'NOM10004',
|
||||||
|
BANCOS: 'NOM10005',
|
||||||
|
PERIODOS: 'NOM10006',
|
||||||
|
CONFIGURACION: 'NOM10000',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Clase del conector NOI
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class NOIConnector {
|
||||||
|
private client: AspelClient;
|
||||||
|
private empresaId: number;
|
||||||
|
|
||||||
|
constructor(client: AspelClient, empresaId: number = 1) {
|
||||||
|
this.client = client;
|
||||||
|
this.empresaId = empresaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Empleados
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los empleados
|
||||||
|
*/
|
||||||
|
async getEmpleados(options?: {
|
||||||
|
soloActivos?: boolean;
|
||||||
|
departamento?: string;
|
||||||
|
puesto?: string;
|
||||||
|
}): Promise<Empleado[]> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (options?.soloActivos !== false) {
|
||||||
|
conditions.push('ESTATUS = ?');
|
||||||
|
params.push('A');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.departamento) {
|
||||||
|
conditions.push('DEPARTAMENTO = ?');
|
||||||
|
params.push(options.departamento);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.puesto) {
|
||||||
|
conditions.push('PUESTO = ?');
|
||||||
|
params.push(options.puesto);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
NUM_EMP,
|
||||||
|
NOMBRE_COMP,
|
||||||
|
NOMBRE,
|
||||||
|
AP_PATERNO,
|
||||||
|
AP_MATERNO,
|
||||||
|
RFC,
|
||||||
|
CURP,
|
||||||
|
NSS,
|
||||||
|
FECHA_NAC,
|
||||||
|
FECHA_ALTA,
|
||||||
|
FECHA_BAJA,
|
||||||
|
TIPO_CONTRATO,
|
||||||
|
DEPARTAMENTO,
|
||||||
|
PUESTO,
|
||||||
|
SUELDO_DIARIO,
|
||||||
|
SDI,
|
||||||
|
TIPO_JORNADA,
|
||||||
|
REG_CONTRAT,
|
||||||
|
RIESGO_TRAB,
|
||||||
|
PERIOD_PAGO,
|
||||||
|
BANCO,
|
||||||
|
NUM_CUENTA,
|
||||||
|
CLABE,
|
||||||
|
ESTATUS,
|
||||||
|
REG_PATRONAL,
|
||||||
|
ENTIDAD_FED,
|
||||||
|
CODIGO_POSTAL
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY NOMBRE_COMP
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, params);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToEmpleado(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un empleado por numero
|
||||||
|
*/
|
||||||
|
async getEmpleado(numeroEmpleado: string): Promise<Empleado | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
NUM_EMP,
|
||||||
|
NOMBRE_COMP,
|
||||||
|
NOMBRE,
|
||||||
|
AP_PATERNO,
|
||||||
|
AP_MATERNO,
|
||||||
|
RFC,
|
||||||
|
CURP,
|
||||||
|
NSS,
|
||||||
|
FECHA_NAC,
|
||||||
|
FECHA_ALTA,
|
||||||
|
FECHA_BAJA,
|
||||||
|
TIPO_CONTRATO,
|
||||||
|
DEPARTAMENTO,
|
||||||
|
PUESTO,
|
||||||
|
SUELDO_DIARIO,
|
||||||
|
SDI,
|
||||||
|
TIPO_JORNADA,
|
||||||
|
REG_CONTRAT,
|
||||||
|
RIESGO_TRAB,
|
||||||
|
PERIOD_PAGO,
|
||||||
|
BANCO,
|
||||||
|
NUM_CUENTA,
|
||||||
|
CLABE,
|
||||||
|
ESTATUS,
|
||||||
|
REG_PATRONAL,
|
||||||
|
ENTIDAD_FED,
|
||||||
|
CODIGO_POSTAL
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
|
||||||
|
WHERE NUM_EMP = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const row = await this.client.queryOne<Record<string, unknown>>(sql, [numeroEmpleado]);
|
||||||
|
|
||||||
|
return row ? this.mapToEmpleado(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca empleados por nombre o RFC
|
||||||
|
*/
|
||||||
|
async buscarEmpleados(termino: string, limite: number = 50): Promise<Empleado[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
NUM_EMP,
|
||||||
|
NOMBRE_COMP,
|
||||||
|
NOMBRE,
|
||||||
|
AP_PATERNO,
|
||||||
|
AP_MATERNO,
|
||||||
|
RFC,
|
||||||
|
CURP,
|
||||||
|
NSS,
|
||||||
|
FECHA_NAC,
|
||||||
|
FECHA_ALTA,
|
||||||
|
FECHA_BAJA,
|
||||||
|
TIPO_CONTRATO,
|
||||||
|
DEPARTAMENTO,
|
||||||
|
PUESTO,
|
||||||
|
SUELDO_DIARIO,
|
||||||
|
SDI,
|
||||||
|
TIPO_JORNADA,
|
||||||
|
REG_CONTRAT,
|
||||||
|
RIESGO_TRAB,
|
||||||
|
PERIOD_PAGO,
|
||||||
|
BANCO,
|
||||||
|
NUM_CUENTA,
|
||||||
|
CLABE,
|
||||||
|
ESTATUS,
|
||||||
|
REG_PATRONAL,
|
||||||
|
ENTIDAD_FED,
|
||||||
|
CODIGO_POSTAL
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
|
||||||
|
WHERE (NOMBRE_COMP LIKE ? OR RFC LIKE ? OR NUM_EMP LIKE ? OR CURP LIKE ?)
|
||||||
|
AND ESTATUS = 'A'
|
||||||
|
ORDER BY NOMBRE_COMP
|
||||||
|
${this.getLimitClause(limite, 0)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const searchTerm = `%${termino}%`;
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [
|
||||||
|
searchTerm,
|
||||||
|
searchTerm,
|
||||||
|
searchTerm,
|
||||||
|
searchTerm,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToEmpleado(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene empleados paginados
|
||||||
|
*/
|
||||||
|
async getEmpleadosPaginados(
|
||||||
|
paginacion: PaginacionAspel,
|
||||||
|
options?: {
|
||||||
|
soloActivos?: boolean;
|
||||||
|
departamento?: string;
|
||||||
|
}
|
||||||
|
): Promise<ResultadoPaginado<Empleado>> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (options?.soloActivos !== false) {
|
||||||
|
conditions.push('ESTATUS = ?');
|
||||||
|
params.push('A');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.departamento) {
|
||||||
|
conditions.push('DEPARTAMENTO = ?');
|
||||||
|
params.push(options.departamento);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
// Contar total
|
||||||
|
const countSql = `
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
|
||||||
|
${whereClause}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const countResult = await this.client.queryOne<{ total: number }>(countSql, params);
|
||||||
|
const total = countResult?.total || 0;
|
||||||
|
|
||||||
|
// Obtener pagina
|
||||||
|
const pagina = paginacion.pagina || 1;
|
||||||
|
const porPagina = paginacion.porPagina || 50;
|
||||||
|
const offset = (pagina - 1) * porPagina;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
NUM_EMP,
|
||||||
|
NOMBRE_COMP,
|
||||||
|
NOMBRE,
|
||||||
|
AP_PATERNO,
|
||||||
|
AP_MATERNO,
|
||||||
|
RFC,
|
||||||
|
CURP,
|
||||||
|
NSS,
|
||||||
|
FECHA_NAC,
|
||||||
|
FECHA_ALTA,
|
||||||
|
FECHA_BAJA,
|
||||||
|
TIPO_CONTRATO,
|
||||||
|
DEPARTAMENTO,
|
||||||
|
PUESTO,
|
||||||
|
SUELDO_DIARIO,
|
||||||
|
SDI,
|
||||||
|
TIPO_JORNADA,
|
||||||
|
REG_CONTRAT,
|
||||||
|
RIESGO_TRAB,
|
||||||
|
PERIOD_PAGO,
|
||||||
|
BANCO,
|
||||||
|
NUM_CUENTA,
|
||||||
|
CLABE,
|
||||||
|
ESTATUS,
|
||||||
|
REG_PATRONAL,
|
||||||
|
ENTIDAD_FED,
|
||||||
|
CODIGO_POSTAL
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY NOMBRE_COMP
|
||||||
|
${this.getLimitClause(porPagina, offset)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.rows.map(row => this.mapToEmpleado(row)),
|
||||||
|
total,
|
||||||
|
pagina,
|
||||||
|
porPagina,
|
||||||
|
totalPaginas: Math.ceil(total / porPagina),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene empleados dados de baja en un periodo
|
||||||
|
*/
|
||||||
|
async getEmpleadosBaja(rango: RangoFechas): Promise<Empleado[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
NUM_EMP,
|
||||||
|
NOMBRE_COMP,
|
||||||
|
NOMBRE,
|
||||||
|
AP_PATERNO,
|
||||||
|
AP_MATERNO,
|
||||||
|
RFC,
|
||||||
|
CURP,
|
||||||
|
NSS,
|
||||||
|
FECHA_NAC,
|
||||||
|
FECHA_ALTA,
|
||||||
|
FECHA_BAJA,
|
||||||
|
TIPO_CONTRATO,
|
||||||
|
DEPARTAMENTO,
|
||||||
|
PUESTO,
|
||||||
|
SUELDO_DIARIO,
|
||||||
|
SDI,
|
||||||
|
TIPO_JORNADA,
|
||||||
|
REG_CONTRAT,
|
||||||
|
RIESGO_TRAB,
|
||||||
|
PERIOD_PAGO,
|
||||||
|
BANCO,
|
||||||
|
NUM_CUENTA,
|
||||||
|
CLABE,
|
||||||
|
ESTATUS,
|
||||||
|
REG_PATRONAL,
|
||||||
|
ENTIDAD_FED,
|
||||||
|
CODIGO_POSTAL
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
|
||||||
|
WHERE ESTATUS = 'B'
|
||||||
|
AND FECHA_BAJA >= ?
|
||||||
|
AND FECHA_BAJA <= ?
|
||||||
|
ORDER BY FECHA_BAJA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToEmpleado(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Nominas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene nominas por periodo
|
||||||
|
*/
|
||||||
|
async getNominas(periodo: PeriodoConsulta): Promise<Nomina[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_NOMINA,
|
||||||
|
PERIODO,
|
||||||
|
EJERCICIO,
|
||||||
|
TIPO_NOMINA,
|
||||||
|
FECHA_PAGO,
|
||||||
|
FECHA_INI,
|
||||||
|
FECHA_FIN,
|
||||||
|
DIAS_PAGADOS,
|
||||||
|
TOTAL_PERC,
|
||||||
|
TOTAL_DEDUC,
|
||||||
|
TOTAL_OTROS,
|
||||||
|
TOTAL_NETO,
|
||||||
|
NUM_EMPLEADOS,
|
||||||
|
ESTATUS,
|
||||||
|
DESCRIPCION
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.NOMINAS)}
|
||||||
|
WHERE PERIODO = ? AND EJERCICIO = ?
|
||||||
|
ORDER BY FECHA_PAGO
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [
|
||||||
|
periodo.periodo,
|
||||||
|
periodo.ejercicio,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToNomina(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene nominas por rango de fechas
|
||||||
|
*/
|
||||||
|
async getNominasByFecha(rango: RangoFechas): Promise<Nomina[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_NOMINA,
|
||||||
|
PERIODO,
|
||||||
|
EJERCICIO,
|
||||||
|
TIPO_NOMINA,
|
||||||
|
FECHA_PAGO,
|
||||||
|
FECHA_INI,
|
||||||
|
FECHA_FIN,
|
||||||
|
DIAS_PAGADOS,
|
||||||
|
TOTAL_PERC,
|
||||||
|
TOTAL_DEDUC,
|
||||||
|
TOTAL_OTROS,
|
||||||
|
TOTAL_NETO,
|
||||||
|
NUM_EMPLEADOS,
|
||||||
|
ESTATUS,
|
||||||
|
DESCRIPCION
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.NOMINAS)}
|
||||||
|
WHERE FECHA_PAGO >= ? AND FECHA_PAGO <= ?
|
||||||
|
ORDER BY FECHA_PAGO
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [
|
||||||
|
AspelClient.formatAspelDate(rango.fechaInicial),
|
||||||
|
AspelClient.formatAspelDate(rango.fechaFinal),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToNomina(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una nomina especifica
|
||||||
|
*/
|
||||||
|
async getNomina(idNomina: number): Promise<Nomina | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
ID_NOMINA,
|
||||||
|
PERIODO,
|
||||||
|
EJERCICIO,
|
||||||
|
TIPO_NOMINA,
|
||||||
|
FECHA_PAGO,
|
||||||
|
FECHA_INI,
|
||||||
|
FECHA_FIN,
|
||||||
|
DIAS_PAGADOS,
|
||||||
|
TOTAL_PERC,
|
||||||
|
TOTAL_DEDUC,
|
||||||
|
TOTAL_OTROS,
|
||||||
|
TOTAL_NETO,
|
||||||
|
NUM_EMPLEADOS,
|
||||||
|
ESTATUS,
|
||||||
|
DESCRIPCION
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.NOMINAS)}
|
||||||
|
WHERE ID_NOMINA = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const row = await this.client.queryOne<Record<string, unknown>>(sql, [idNomina]);
|
||||||
|
|
||||||
|
return row ? this.mapToNomina(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los recibos de una nomina
|
||||||
|
*/
|
||||||
|
async getRecibosNomina(
|
||||||
|
idNomina: number,
|
||||||
|
options?: { conMovimientos?: boolean }
|
||||||
|
): Promise<ReciboNomina[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
r.ID_RECIBO,
|
||||||
|
r.ID_NOMINA,
|
||||||
|
r.NUM_EMP,
|
||||||
|
e.NOMBRE_COMP,
|
||||||
|
e.RFC,
|
||||||
|
e.CURP,
|
||||||
|
r.FECHA_PAGO,
|
||||||
|
r.FECHA_INI,
|
||||||
|
r.FECHA_FIN,
|
||||||
|
r.DIAS_PAGADOS,
|
||||||
|
r.TOTAL_PERC,
|
||||||
|
r.TOTAL_DEDUC,
|
||||||
|
r.TOTAL_OTROS,
|
||||||
|
r.NETO_PAGAR,
|
||||||
|
r.UUID,
|
||||||
|
r.ESTATUS_TIMB
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.RECIBOS)} r
|
||||||
|
JOIN ${this.getTableName(NOI_TABLES.EMPLEADOS)} e ON r.NUM_EMP = e.NUM_EMP
|
||||||
|
WHERE r.ID_NOMINA = ?
|
||||||
|
ORDER BY e.NOMBRE_COMP
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [idNomina]);
|
||||||
|
|
||||||
|
const recibos: ReciboNomina[] = [];
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const recibo = this.mapToReciboNomina(row);
|
||||||
|
|
||||||
|
if (options?.conMovimientos !== false) {
|
||||||
|
const movimientos = await this.getMovimientosRecibo(recibo.id);
|
||||||
|
recibo.percepciones = movimientos.filter(m => m.tipo === 'P');
|
||||||
|
recibo.deducciones = movimientos.filter(m => m.tipo === 'D');
|
||||||
|
recibo.otrosPagos = movimientos.filter(m => m.tipo === 'O');
|
||||||
|
}
|
||||||
|
|
||||||
|
recibos.push(recibo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return recibos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el recibo de un empleado
|
||||||
|
*/
|
||||||
|
async getReciboEmpleado(
|
||||||
|
idNomina: number,
|
||||||
|
numeroEmpleado: string
|
||||||
|
): Promise<ReciboNomina | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
r.ID_RECIBO,
|
||||||
|
r.ID_NOMINA,
|
||||||
|
r.NUM_EMP,
|
||||||
|
e.NOMBRE_COMP,
|
||||||
|
e.RFC,
|
||||||
|
e.CURP,
|
||||||
|
r.FECHA_PAGO,
|
||||||
|
r.FECHA_INI,
|
||||||
|
r.FECHA_FIN,
|
||||||
|
r.DIAS_PAGADOS,
|
||||||
|
r.TOTAL_PERC,
|
||||||
|
r.TOTAL_DEDUC,
|
||||||
|
r.TOTAL_OTROS,
|
||||||
|
r.NETO_PAGAR,
|
||||||
|
r.UUID,
|
||||||
|
r.ESTATUS_TIMB
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.RECIBOS)} r
|
||||||
|
JOIN ${this.getTableName(NOI_TABLES.EMPLEADOS)} e ON r.NUM_EMP = e.NUM_EMP
|
||||||
|
WHERE r.ID_NOMINA = ? AND r.NUM_EMP = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const row = await this.client.queryOne<Record<string, unknown>>(sql, [
|
||||||
|
idNomina,
|
||||||
|
numeroEmpleado,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const recibo = this.mapToReciboNomina(row);
|
||||||
|
|
||||||
|
// Obtener movimientos
|
||||||
|
const movimientos = await this.getMovimientosRecibo(recibo.id);
|
||||||
|
recibo.percepciones = movimientos.filter(m => m.tipo === 'P');
|
||||||
|
recibo.deducciones = movimientos.filter(m => m.tipo === 'D');
|
||||||
|
recibo.otrosPagos = movimientos.filter(m => m.tipo === 'O');
|
||||||
|
|
||||||
|
return recibo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Movimientos de Nomina
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los movimientos de un recibo
|
||||||
|
*/
|
||||||
|
async getMovimientosRecibo(idRecibo: number): Promise<MovimientoNomina[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
m.TIPO_MOV,
|
||||||
|
m.CVE_CONCEPTO,
|
||||||
|
c.DESCRIPCION,
|
||||||
|
m.IMP_GRAVADO,
|
||||||
|
m.IMP_EXENTO,
|
||||||
|
m.IMPORTE
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.MOVIMIENTOS)} m
|
||||||
|
LEFT JOIN ${this.getTableName(NOI_TABLES.CONCEPTOS)} c ON m.CVE_CONCEPTO = c.CVE_CONCEPTO
|
||||||
|
WHERE m.ID_RECIBO = ?
|
||||||
|
ORDER BY m.TIPO_MOV, m.CVE_CONCEPTO
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, [idRecibo]);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToMovimientoNomina(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene movimientos de nomina por periodo
|
||||||
|
*/
|
||||||
|
async getMovimientosNomina(
|
||||||
|
periodo: PeriodoConsulta,
|
||||||
|
options?: {
|
||||||
|
tipoMovimiento?: 'P' | 'D' | 'O';
|
||||||
|
concepto?: string;
|
||||||
|
}
|
||||||
|
): Promise<MovimientoNomina[]> {
|
||||||
|
const conditions = ['n.PERIODO = ?', 'n.EJERCICIO = ?'];
|
||||||
|
const params: unknown[] = [periodo.periodo, periodo.ejercicio];
|
||||||
|
|
||||||
|
if (options?.tipoMovimiento) {
|
||||||
|
conditions.push('m.TIPO_MOV = ?');
|
||||||
|
params.push(options.tipoMovimiento);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.concepto) {
|
||||||
|
conditions.push('m.CVE_CONCEPTO = ?');
|
||||||
|
params.push(options.concepto);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
m.TIPO_MOV,
|
||||||
|
m.CVE_CONCEPTO,
|
||||||
|
c.DESCRIPCION,
|
||||||
|
SUM(m.IMP_GRAVADO) AS IMP_GRAVADO,
|
||||||
|
SUM(m.IMP_EXENTO) AS IMP_EXENTO,
|
||||||
|
SUM(m.IMPORTE) AS IMPORTE
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.MOVIMIENTOS)} m
|
||||||
|
JOIN ${this.getTableName(NOI_TABLES.RECIBOS)} r ON m.ID_RECIBO = r.ID_RECIBO
|
||||||
|
JOIN ${this.getTableName(NOI_TABLES.NOMINAS)} n ON r.ID_NOMINA = n.ID_NOMINA
|
||||||
|
LEFT JOIN ${this.getTableName(NOI_TABLES.CONCEPTOS)} c ON m.CVE_CONCEPTO = c.CVE_CONCEPTO
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
GROUP BY m.TIPO_MOV, m.CVE_CONCEPTO, c.DESCRIPCION
|
||||||
|
ORDER BY m.TIPO_MOV, m.CVE_CONCEPTO
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.query<Record<string, unknown>>(sql, params);
|
||||||
|
|
||||||
|
return result.rows.map(row => this.mapToMovimientoNomina(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de percepciones y deducciones por concepto
|
||||||
|
*/
|
||||||
|
async getResumenConceptos(periodo: PeriodoConsulta): Promise<{
|
||||||
|
percepciones: MovimientoNomina[];
|
||||||
|
deducciones: MovimientoNomina[];
|
||||||
|
otrosPagos: MovimientoNomina[];
|
||||||
|
totales: {
|
||||||
|
percepciones: number;
|
||||||
|
deducciones: number;
|
||||||
|
otrosPagos: number;
|
||||||
|
neto: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const movimientos = await this.getMovimientosNomina(periodo);
|
||||||
|
|
||||||
|
const percepciones = movimientos.filter(m => m.tipo === 'P');
|
||||||
|
const deducciones = movimientos.filter(m => m.tipo === 'D');
|
||||||
|
const otrosPagos = movimientos.filter(m => m.tipo === 'O');
|
||||||
|
|
||||||
|
const totalPercepciones = percepciones.reduce((sum, m) => sum + m.importe, 0);
|
||||||
|
const totalDeducciones = deducciones.reduce((sum, m) => sum + m.importe, 0);
|
||||||
|
const totalOtrosPagos = otrosPagos.reduce((sum, m) => sum + m.importe, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
percepciones,
|
||||||
|
deducciones,
|
||||||
|
otrosPagos,
|
||||||
|
totales: {
|
||||||
|
percepciones: totalPercepciones,
|
||||||
|
deducciones: totalDeducciones,
|
||||||
|
otrosPagos: totalOtrosPagos,
|
||||||
|
neto: totalPercepciones - totalDeducciones + totalOtrosPagos,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Reportes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el acumulado de nomina por empleado
|
||||||
|
*/
|
||||||
|
async getAcumuladoEmpleado(
|
||||||
|
numeroEmpleado: string,
|
||||||
|
ejercicio: number
|
||||||
|
): Promise<{
|
||||||
|
empleado: Empleado | null;
|
||||||
|
totalPercepciones: number;
|
||||||
|
totalDeducciones: number;
|
||||||
|
totalOtrosPagos: number;
|
||||||
|
totalNeto: number;
|
||||||
|
recibos: number;
|
||||||
|
}> {
|
||||||
|
const empleado = await this.getEmpleado(numeroEmpleado);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
SUM(r.TOTAL_PERC) AS TOTAL_PERC,
|
||||||
|
SUM(r.TOTAL_DEDUC) AS TOTAL_DEDUC,
|
||||||
|
SUM(r.TOTAL_OTROS) AS TOTAL_OTROS,
|
||||||
|
SUM(r.NETO_PAGAR) AS TOTAL_NETO,
|
||||||
|
COUNT(*) AS NUM_RECIBOS
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.RECIBOS)} r
|
||||||
|
JOIN ${this.getTableName(NOI_TABLES.NOMINAS)} n ON r.ID_NOMINA = n.ID_NOMINA
|
||||||
|
WHERE r.NUM_EMP = ? AND n.EJERCICIO = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryOne<{
|
||||||
|
TOTAL_PERC: number;
|
||||||
|
TOTAL_DEDUC: number;
|
||||||
|
TOTAL_OTROS: number;
|
||||||
|
TOTAL_NETO: number;
|
||||||
|
NUM_RECIBOS: number;
|
||||||
|
}>(sql, [numeroEmpleado, ejercicio]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
empleado,
|
||||||
|
totalPercepciones: result?.TOTAL_PERC || 0,
|
||||||
|
totalDeducciones: result?.TOTAL_DEDUC || 0,
|
||||||
|
totalOtrosPagos: result?.TOTAL_OTROS || 0,
|
||||||
|
totalNeto: result?.TOTAL_NETO || 0,
|
||||||
|
recibos: result?.NUM_RECIBOS || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadisticas de nomina
|
||||||
|
*/
|
||||||
|
async getEstadisticasNomina(ejercicio: number): Promise<{
|
||||||
|
totalNominas: number;
|
||||||
|
totalEmpleados: number;
|
||||||
|
totalPercepciones: number;
|
||||||
|
totalDeducciones: number;
|
||||||
|
totalNeto: number;
|
||||||
|
promedioNeto: number;
|
||||||
|
}> {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT n.ID_NOMINA) AS TOTAL_NOMINAS,
|
||||||
|
COUNT(DISTINCT r.NUM_EMP) AS TOTAL_EMPLEADOS,
|
||||||
|
SUM(r.TOTAL_PERC) AS TOTAL_PERC,
|
||||||
|
SUM(r.TOTAL_DEDUC) AS TOTAL_DEDUC,
|
||||||
|
SUM(r.NETO_PAGAR) AS TOTAL_NETO,
|
||||||
|
AVG(r.NETO_PAGAR) AS PROMEDIO_NETO
|
||||||
|
FROM ${this.getTableName(NOI_TABLES.RECIBOS)} r
|
||||||
|
JOIN ${this.getTableName(NOI_TABLES.NOMINAS)} n ON r.ID_NOMINA = n.ID_NOMINA
|
||||||
|
WHERE n.EJERCICIO = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryOne<{
|
||||||
|
TOTAL_NOMINAS: number;
|
||||||
|
TOTAL_EMPLEADOS: number;
|
||||||
|
TOTAL_PERC: number;
|
||||||
|
TOTAL_DEDUC: number;
|
||||||
|
TOTAL_NETO: number;
|
||||||
|
PROMEDIO_NETO: number;
|
||||||
|
}>(sql, [ejercicio]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalNominas: result?.TOTAL_NOMINAS || 0,
|
||||||
|
totalEmpleados: result?.TOTAL_EMPLEADOS || 0,
|
||||||
|
totalPercepciones: result?.TOTAL_PERC || 0,
|
||||||
|
totalDeducciones: result?.TOTAL_DEDUC || 0,
|
||||||
|
totalNeto: result?.TOTAL_NETO || 0,
|
||||||
|
promedioNeto: result?.PROMEDIO_NETO || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utilidades
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el nombre de tabla con prefijo de empresa
|
||||||
|
*/
|
||||||
|
private getTableName(baseName: string): string {
|
||||||
|
// NOI usa prefijos numericos para las tablas por empresa (ej: NOM10001 -> NOM20001 para empresa 2)
|
||||||
|
const empresaPrefix = String(this.empresaId);
|
||||||
|
return baseName.replace(/^NOM1/, `NOM${empresaPrefix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene clausula LIMIT/OFFSET segun tipo de BD
|
||||||
|
*/
|
||||||
|
private getLimitClause(limit: number, offset: number): string {
|
||||||
|
const state = this.client.getState();
|
||||||
|
|
||||||
|
if (state.databaseType === 'firebird') {
|
||||||
|
return `ROWS ${offset + 1} TO ${offset + limit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mappers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private mapToEmpleado(row: Record<string, unknown>): Empleado {
|
||||||
|
return {
|
||||||
|
numeroEmpleado: String(row.NUM_EMP || ''),
|
||||||
|
nombreCompleto: String(row.NOMBRE_COMP || ''),
|
||||||
|
nombre: String(row.NOMBRE || ''),
|
||||||
|
apellidoPaterno: String(row.AP_PATERNO || ''),
|
||||||
|
apellidoMaterno: row.AP_MATERNO ? String(row.AP_MATERNO) : undefined,
|
||||||
|
rfc: String(row.RFC || ''),
|
||||||
|
curp: String(row.CURP || ''),
|
||||||
|
nss: row.NSS ? String(row.NSS) : undefined,
|
||||||
|
fechaNacimiento: AspelClient.parseAspelDate(row.FECHA_NAC) || undefined,
|
||||||
|
fechaAlta: AspelClient.parseAspelDate(row.FECHA_ALTA) || new Date(),
|
||||||
|
fechaBaja: AspelClient.parseAspelDate(row.FECHA_BAJA) || undefined,
|
||||||
|
tipoContrato: String(row.TIPO_CONTRATO || '01'),
|
||||||
|
departamento: row.DEPARTAMENTO ? String(row.DEPARTAMENTO) : undefined,
|
||||||
|
puesto: row.PUESTO ? String(row.PUESTO) : undefined,
|
||||||
|
sueldoDiario: Number(row.SUELDO_DIARIO) || 0,
|
||||||
|
salarioDiarioIntegrado: Number(row.SDI) || 0,
|
||||||
|
tipoJornada: row.TIPO_JORNADA ? String(row.TIPO_JORNADA) : undefined,
|
||||||
|
regimenContratacion: String(row.REG_CONTRAT || '02'),
|
||||||
|
riesgoTrabajo: row.RIESGO_TRAB ? String(row.RIESGO_TRAB) : undefined,
|
||||||
|
periodicidadPago: String(row.PERIOD_PAGO || '02'),
|
||||||
|
banco: row.BANCO ? String(row.BANCO) : undefined,
|
||||||
|
numeroCuenta: row.NUM_CUENTA ? String(row.NUM_CUENTA) : undefined,
|
||||||
|
clabe: row.CLABE ? String(row.CLABE) : undefined,
|
||||||
|
estatus: String(row.ESTATUS || 'A') as Empleado['estatus'],
|
||||||
|
registroPatronal: row.REG_PATRONAL ? String(row.REG_PATRONAL) : undefined,
|
||||||
|
entidadFederativa: row.ENTIDAD_FED ? String(row.ENTIDAD_FED) : undefined,
|
||||||
|
codigoPostal: row.CODIGO_POSTAL ? String(row.CODIGO_POSTAL) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToNomina(row: Record<string, unknown>): Nomina {
|
||||||
|
return {
|
||||||
|
id: Number(row.ID_NOMINA) || 0,
|
||||||
|
periodo: Number(row.PERIODO) || 0,
|
||||||
|
ejercicio: Number(row.EJERCICIO) || 0,
|
||||||
|
tipoNomina: String(row.TIPO_NOMINA || 'O') as Nomina['tipoNomina'],
|
||||||
|
fechaPago: AspelClient.parseAspelDate(row.FECHA_PAGO) || new Date(),
|
||||||
|
fechaInicial: AspelClient.parseAspelDate(row.FECHA_INI) || new Date(),
|
||||||
|
fechaFinal: AspelClient.parseAspelDate(row.FECHA_FIN) || new Date(),
|
||||||
|
diasPagados: Number(row.DIAS_PAGADOS) || 0,
|
||||||
|
totalPercepciones: Number(row.TOTAL_PERC) || 0,
|
||||||
|
totalDeducciones: Number(row.TOTAL_DEDUC) || 0,
|
||||||
|
totalOtrosPagos: Number(row.TOTAL_OTROS) || 0,
|
||||||
|
totalNeto: Number(row.TOTAL_NETO) || 0,
|
||||||
|
numeroEmpleados: Number(row.NUM_EMPLEADOS) || 0,
|
||||||
|
estatus: String(row.ESTATUS || 'P') as Nomina['estatus'],
|
||||||
|
descripcion: row.DESCRIPCION ? String(row.DESCRIPCION) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToReciboNomina(row: Record<string, unknown>): ReciboNomina {
|
||||||
|
return {
|
||||||
|
id: Number(row.ID_RECIBO) || 0,
|
||||||
|
idNomina: Number(row.ID_NOMINA) || 0,
|
||||||
|
numeroEmpleado: String(row.NUM_EMP || ''),
|
||||||
|
nombreEmpleado: String(row.NOMBRE_COMP || ''),
|
||||||
|
rfcEmpleado: String(row.RFC || ''),
|
||||||
|
curpEmpleado: String(row.CURP || ''),
|
||||||
|
fechaPago: AspelClient.parseAspelDate(row.FECHA_PAGO) || new Date(),
|
||||||
|
fechaInicial: AspelClient.parseAspelDate(row.FECHA_INI) || new Date(),
|
||||||
|
fechaFinal: AspelClient.parseAspelDate(row.FECHA_FIN) || new Date(),
|
||||||
|
diasPagados: Number(row.DIAS_PAGADOS) || 0,
|
||||||
|
totalPercepciones: Number(row.TOTAL_PERC) || 0,
|
||||||
|
totalDeducciones: Number(row.TOTAL_DEDUC) || 0,
|
||||||
|
totalOtrosPagos: Number(row.TOTAL_OTROS) || 0,
|
||||||
|
netoAPagar: Number(row.NETO_PAGAR) || 0,
|
||||||
|
percepciones: [],
|
||||||
|
deducciones: [],
|
||||||
|
otrosPagos: [],
|
||||||
|
uuid: row.UUID ? String(row.UUID) : undefined,
|
||||||
|
estatusTimbrado: row.ESTATUS_TIMB ? String(row.ESTATUS_TIMB) as ReciboNomina['estatusTimbrado'] : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToMovimientoNomina(row: Record<string, unknown>): MovimientoNomina {
|
||||||
|
return {
|
||||||
|
tipo: String(row.TIPO_MOV || 'P') as MovimientoNomina['tipo'],
|
||||||
|
clave: String(row.CVE_CONCEPTO || ''),
|
||||||
|
descripcion: String(row.DESCRIPCION || ''),
|
||||||
|
importeGravado: Number(row.IMP_GRAVADO) || 0,
|
||||||
|
importeExento: Number(row.IMP_EXENTO) || 0,
|
||||||
|
importe: Number(row.IMPORTE) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function createNOIConnector(client: AspelClient, empresaId?: number): NOIConnector {
|
||||||
|
return new NOIConnector(client, empresaId);
|
||||||
|
}
|
||||||
1210
apps/api/src/services/integrations/aspel/sae.connector.ts
Normal file
1210
apps/api/src/services/integrations/aspel/sae.connector.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,911 @@
|
|||||||
|
/**
|
||||||
|
* CONTPAQi Comercial Connector
|
||||||
|
* Conector para el modulo Comercial de CONTPAQi (ventas/compras)
|
||||||
|
*
|
||||||
|
* TABLAS PRINCIPALES DE CONTPAQi COMERCIAL:
|
||||||
|
*
|
||||||
|
* CLIENTES Y PROVEEDORES:
|
||||||
|
* - admClientes: Catalogo de clientes y proveedores (unificado)
|
||||||
|
* - CIDCLIENTEPROVEEDOR: ID unico
|
||||||
|
* - CCODIGOCLIENTE: Codigo del cliente/proveedor
|
||||||
|
* - CRAZONSOCIAL: Razon social
|
||||||
|
* - CRFC: RFC
|
||||||
|
* - CTIPOCLIENTE: 1=Cliente, 2=ClienteProveedor, 3=Proveedor
|
||||||
|
*
|
||||||
|
* - admDomicilios: Direcciones de clientes/proveedores
|
||||||
|
*
|
||||||
|
* PRODUCTOS:
|
||||||
|
* - admProductos: Catalogo de productos y servicios
|
||||||
|
* - CIDPRODUCTO: ID del producto
|
||||||
|
* - CCODIGOPRODUCTO: Codigo del producto
|
||||||
|
* - CNOMBREPRODUCTO: Nombre
|
||||||
|
* - CTIPOPRODUCTO: 1=Producto, 2=Paquete, 3=Servicio
|
||||||
|
*
|
||||||
|
* DOCUMENTOS:
|
||||||
|
* - admDocumentos: Encabezado de documentos (facturas, notas, etc.)
|
||||||
|
* - CIDDOCUMENTO: ID del documento
|
||||||
|
* - CIDCONCEPTODOCUMENTO: Tipo de documento
|
||||||
|
* - CSERIEFASCICULO: Serie
|
||||||
|
* - CFOLIO: Folio
|
||||||
|
* - CFECHA: Fecha
|
||||||
|
* - CIDCLIENTEPROVEEDOR: Cliente/Proveedor
|
||||||
|
*
|
||||||
|
* - admMovimientos: Detalle de productos en documentos
|
||||||
|
* - CIDMOVIMIENTO: ID del movimiento
|
||||||
|
* - CIDDOCUMENTO: ID del documento padre
|
||||||
|
* - CIDPRODUCTO: Producto
|
||||||
|
* - CUNIDADES: Cantidad
|
||||||
|
* - CPRECIO: Precio unitario
|
||||||
|
*
|
||||||
|
* INVENTARIOS:
|
||||||
|
* - admAlmacenes: Almacenes
|
||||||
|
* - admExistencias: Existencias por almacen
|
||||||
|
* - CIDPRODUCTO: Producto
|
||||||
|
* - CIDALMACEN: Almacen
|
||||||
|
* - CEXISTENCIA: Cantidad
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CONTPAQiClient } from './contpaqi.client.js';
|
||||||
|
import {
|
||||||
|
CONTPAQiCliente,
|
||||||
|
CONTPAQiProveedor,
|
||||||
|
CONTPAQiProducto,
|
||||||
|
CONTPAQiDocumento,
|
||||||
|
CONTPAQiMovimientoDocumento,
|
||||||
|
CONTPAQiExistencia,
|
||||||
|
CONTPAQiAlmacen,
|
||||||
|
CONTPAQiDireccion,
|
||||||
|
ConceptoDocumento,
|
||||||
|
EstadoDocumento,
|
||||||
|
TipoProducto,
|
||||||
|
} from './contpaqi.types.js';
|
||||||
|
import { DocumentoFilter, PaginationInput } from './contpaqi.schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapeo de conceptos de documento de CONTPAQi a tipos internos
|
||||||
|
* Estos IDs varian segun la configuracion de cada empresa
|
||||||
|
* Valores tipicos:
|
||||||
|
*/
|
||||||
|
const CONCEPTO_DOC_MAP: Record<number, ConceptoDocumento> = {
|
||||||
|
1: 'Cotizacion',
|
||||||
|
2: 'Pedido',
|
||||||
|
3: 'Remision',
|
||||||
|
4: 'FacturaCliente',
|
||||||
|
5: 'NotaCreditoCliente',
|
||||||
|
6: 'NotaCargoCliente',
|
||||||
|
7: 'DevolucionCliente',
|
||||||
|
17: 'OrdenCompra',
|
||||||
|
18: 'FacturaProveedor',
|
||||||
|
19: 'NotaCreditoProveedor',
|
||||||
|
20: 'NotaCargoProveedor',
|
||||||
|
21: 'DevolucionProveedor',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para CONTPAQi Comercial
|
||||||
|
*/
|
||||||
|
export class ComercialConnector {
|
||||||
|
constructor(private client: CONTPAQiClient) {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Clientes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el catalogo de clientes
|
||||||
|
*/
|
||||||
|
async getClientes(options?: PaginationInput & {
|
||||||
|
activos?: boolean;
|
||||||
|
busqueda?: string;
|
||||||
|
}): Promise<CONTPAQiCliente[]> {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
cp.CIDCLIENTEPROVEEDOR as id,
|
||||||
|
cp.CCODIGOCLIENTE as codigo,
|
||||||
|
cp.CRAZONSOCIAL as razonSocial,
|
||||||
|
cp.CRFC as rfc,
|
||||||
|
cp.CCURP as curp,
|
||||||
|
cp.CNOMBRECOMERCIAL as nombreComercial,
|
||||||
|
cp.CTIPOCLIENTE as tipo,
|
||||||
|
cp.CESTATUS as estado,
|
||||||
|
cp.CREGIMENFISCAL as regimenFiscal,
|
||||||
|
cp.CUSOCFDI as usoCFDI,
|
||||||
|
cp.CLIMITECREDITO as limiteCredito,
|
||||||
|
cp.CDIASCREDITO as diasCredito,
|
||||||
|
cp.CSALDOACTUAL as saldoActual,
|
||||||
|
cp.CEMAIL1 as email,
|
||||||
|
cp.CTELEFONO1 as telefono,
|
||||||
|
cp.CFECHAALTA as fechaAlta,
|
||||||
|
cp.CFECHAULTIMACOMPRA as fechaUltimaCompra,
|
||||||
|
cp.CMONEDA as moneda,
|
||||||
|
cp.CDESCUENTOGENERAL as descuento
|
||||||
|
FROM admClientes cp
|
||||||
|
WHERE cp.CTIPOCLIENTE IN (1, 2)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (options?.activos !== false) {
|
||||||
|
query += ' AND cp.CESTATUS = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.busqueda) {
|
||||||
|
query += ` AND (
|
||||||
|
cp.CCODIGOCLIENTE LIKE @busqueda
|
||||||
|
OR cp.CRAZONSOCIAL LIKE @busqueda
|
||||||
|
OR cp.CRFC LIKE @busqueda
|
||||||
|
)`;
|
||||||
|
params.busqueda = `%${options.busqueda}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY cp.CRAZONSOCIAL';
|
||||||
|
|
||||||
|
// Paginacion
|
||||||
|
if (options?.limit) {
|
||||||
|
const offset = options.page ? (options.page - 1) * options.limit : 0;
|
||||||
|
query += ` OFFSET ${offset} ROWS FETCH NEXT ${options.limit} ROWS ONLY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
codigo: string;
|
||||||
|
razonSocial: string;
|
||||||
|
rfc: string;
|
||||||
|
curp: string | null;
|
||||||
|
nombreComercial: string | null;
|
||||||
|
tipo: number;
|
||||||
|
estado: number;
|
||||||
|
regimenFiscal: string | null;
|
||||||
|
usoCFDI: string | null;
|
||||||
|
limiteCredito: number | null;
|
||||||
|
diasCredito: number | null;
|
||||||
|
saldoActual: number | null;
|
||||||
|
email: string | null;
|
||||||
|
telefono: string | null;
|
||||||
|
fechaAlta: Date | null;
|
||||||
|
fechaUltimaCompra: Date | null;
|
||||||
|
moneda: string | null;
|
||||||
|
descuento: number | null;
|
||||||
|
}>(query, params);
|
||||||
|
|
||||||
|
// Obtener direcciones
|
||||||
|
const clientes: CONTPAQiCliente[] = [];
|
||||||
|
|
||||||
|
for (const row of result) {
|
||||||
|
const direccion = await this.getDireccionCliente(row.id);
|
||||||
|
|
||||||
|
clientes.push({
|
||||||
|
id: row.id,
|
||||||
|
codigo: row.codigo?.trim() || '',
|
||||||
|
razonSocial: row.razonSocial?.trim() || '',
|
||||||
|
rfc: row.rfc?.trim() || '',
|
||||||
|
curp: row.curp?.trim() || undefined,
|
||||||
|
nombreComercial: row.nombreComercial?.trim() || undefined,
|
||||||
|
tipo: row.tipo,
|
||||||
|
estado: row.estado,
|
||||||
|
regimenFiscal: row.regimenFiscal?.trim() || undefined,
|
||||||
|
usoCFDI: row.usoCFDI?.trim() || undefined,
|
||||||
|
limiteCredito: row.limiteCredito || undefined,
|
||||||
|
diasCredito: row.diasCredito || undefined,
|
||||||
|
saldoActual: row.saldoActual || undefined,
|
||||||
|
direccion,
|
||||||
|
email: row.email?.trim() || undefined,
|
||||||
|
telefono: row.telefono?.trim() || undefined,
|
||||||
|
fechaAlta: row.fechaAlta || undefined,
|
||||||
|
fechaUltimaCompra: row.fechaUltimaCompra || undefined,
|
||||||
|
moneda: row.moneda?.trim() || undefined,
|
||||||
|
descuento: row.descuento || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un cliente por su codigo
|
||||||
|
*/
|
||||||
|
async getClienteByCodigo(codigo: string): Promise<CONTPAQiCliente | null> {
|
||||||
|
const clientes = await this.getClientes({ activos: false, busqueda: codigo });
|
||||||
|
return clientes.find((c) => c.codigo === codigo) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Proveedores
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el catalogo de proveedores
|
||||||
|
*/
|
||||||
|
async getProveedores(options?: PaginationInput & {
|
||||||
|
activos?: boolean;
|
||||||
|
busqueda?: string;
|
||||||
|
}): Promise<CONTPAQiProveedor[]> {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
cp.CIDCLIENTEPROVEEDOR as id,
|
||||||
|
cp.CCODIGOCLIENTE as codigo,
|
||||||
|
cp.CRAZONSOCIAL as razonSocial,
|
||||||
|
cp.CRFC as rfc,
|
||||||
|
cp.CCURP as curp,
|
||||||
|
cp.CNOMBRECOMERCIAL as nombreComercial,
|
||||||
|
cp.CTIPOCLIENTE as tipo,
|
||||||
|
cp.CESTATUS as estado,
|
||||||
|
cp.CREGIMENFISCAL as regimenFiscal,
|
||||||
|
cp.CDIASCREDITO as diasCredito,
|
||||||
|
cp.CSALDOACTUAL as saldoActual,
|
||||||
|
cp.CEMAIL1 as email,
|
||||||
|
cp.CTELEFONO1 as telefono,
|
||||||
|
cp.CCUENTACONTABLE as cuentaContable,
|
||||||
|
cp.CFECHAALTA as fechaAlta,
|
||||||
|
cp.CMONEDA as moneda
|
||||||
|
FROM admClientes cp
|
||||||
|
WHERE cp.CTIPOCLIENTE IN (2, 3)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (options?.activos !== false) {
|
||||||
|
query += ' AND cp.CESTATUS = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.busqueda) {
|
||||||
|
query += ` AND (
|
||||||
|
cp.CCODIGOCLIENTE LIKE @busqueda
|
||||||
|
OR cp.CRAZONSOCIAL LIKE @busqueda
|
||||||
|
OR cp.CRFC LIKE @busqueda
|
||||||
|
)`;
|
||||||
|
params.busqueda = `%${options.busqueda}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY cp.CRAZONSOCIAL';
|
||||||
|
|
||||||
|
if (options?.limit) {
|
||||||
|
const offset = options.page ? (options.page - 1) * options.limit : 0;
|
||||||
|
query += ` OFFSET ${offset} ROWS FETCH NEXT ${options.limit} ROWS ONLY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
codigo: string;
|
||||||
|
razonSocial: string;
|
||||||
|
rfc: string;
|
||||||
|
curp: string | null;
|
||||||
|
nombreComercial: string | null;
|
||||||
|
tipo: number;
|
||||||
|
estado: number;
|
||||||
|
regimenFiscal: string | null;
|
||||||
|
diasCredito: number | null;
|
||||||
|
saldoActual: number | null;
|
||||||
|
email: string | null;
|
||||||
|
telefono: string | null;
|
||||||
|
cuentaContable: string | null;
|
||||||
|
fechaAlta: Date | null;
|
||||||
|
moneda: string | null;
|
||||||
|
}>(query, params);
|
||||||
|
|
||||||
|
const proveedores: CONTPAQiProveedor[] = [];
|
||||||
|
|
||||||
|
for (const row of result) {
|
||||||
|
const direccion = await this.getDireccionCliente(row.id);
|
||||||
|
|
||||||
|
proveedores.push({
|
||||||
|
id: row.id,
|
||||||
|
codigo: row.codigo?.trim() || '',
|
||||||
|
razonSocial: row.razonSocial?.trim() || '',
|
||||||
|
rfc: row.rfc?.trim() || '',
|
||||||
|
curp: row.curp?.trim() || undefined,
|
||||||
|
nombreComercial: row.nombreComercial?.trim() || undefined,
|
||||||
|
tipo: row.tipo,
|
||||||
|
estado: row.estado,
|
||||||
|
regimenFiscal: row.regimenFiscal?.trim() || undefined,
|
||||||
|
diasCredito: row.diasCredito || undefined,
|
||||||
|
saldoActual: row.saldoActual || undefined,
|
||||||
|
direccion,
|
||||||
|
email: row.email?.trim() || undefined,
|
||||||
|
telefono: row.telefono?.trim() || undefined,
|
||||||
|
cuentaContable: row.cuentaContable?.trim() || undefined,
|
||||||
|
fechaAlta: row.fechaAlta || undefined,
|
||||||
|
moneda: row.moneda?.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return proveedores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la direccion fiscal de un cliente/proveedor
|
||||||
|
*/
|
||||||
|
private async getDireccionCliente(clienteId: number): Promise<CONTPAQiDireccion | undefined> {
|
||||||
|
const query = `
|
||||||
|
SELECT TOP 1
|
||||||
|
d.CCALLE as calle,
|
||||||
|
d.CNUMEROEXTERIOR as numeroExterior,
|
||||||
|
d.CNUMEROINTERIOR as numeroInterior,
|
||||||
|
d.CCOLONIA as colonia,
|
||||||
|
d.CCODIGOPOSTAL as codigoPostal,
|
||||||
|
d.CCIUDAD as ciudad,
|
||||||
|
d.CESTADO as estado,
|
||||||
|
d.CPAIS as pais
|
||||||
|
FROM admDomicilios d
|
||||||
|
WHERE d.CIDCLIENTEPROVEEDOR = @clienteId
|
||||||
|
AND d.CTIPO = 1
|
||||||
|
ORDER BY d.CIDDOMICILIO
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryOne<{
|
||||||
|
calle: string | null;
|
||||||
|
numeroExterior: string | null;
|
||||||
|
numeroInterior: string | null;
|
||||||
|
colonia: string | null;
|
||||||
|
codigoPostal: string | null;
|
||||||
|
ciudad: string | null;
|
||||||
|
estado: string | null;
|
||||||
|
pais: string | null;
|
||||||
|
}>(query, { clienteId });
|
||||||
|
|
||||||
|
if (!result) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
calle: result.calle?.trim() || undefined,
|
||||||
|
numeroExterior: result.numeroExterior?.trim() || undefined,
|
||||||
|
numeroInterior: result.numeroInterior?.trim() || undefined,
|
||||||
|
colonia: result.colonia?.trim() || undefined,
|
||||||
|
codigoPostal: result.codigoPostal?.trim() || undefined,
|
||||||
|
ciudad: result.ciudad?.trim() || undefined,
|
||||||
|
estado: result.estado?.trim() || undefined,
|
||||||
|
pais: result.pais?.trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Productos
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el catalogo de productos
|
||||||
|
*/
|
||||||
|
async getProductos(options?: PaginationInput & {
|
||||||
|
activos?: boolean;
|
||||||
|
tipo?: TipoProducto;
|
||||||
|
busqueda?: string;
|
||||||
|
categoria?: string;
|
||||||
|
}): Promise<CONTPAQiProducto[]> {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
p.CIDPRODUCTO as id,
|
||||||
|
p.CCODIGOPRODUCTO as codigo,
|
||||||
|
p.CNOMBREPRODUCTO as nombre,
|
||||||
|
p.CDESCRIPCIONPRODUCTO as descripcion,
|
||||||
|
p.CTIPOPRODUCTO as tipoNum,
|
||||||
|
p.CUNIDADMEDIDA as unidadMedida,
|
||||||
|
p.CCLAVESAT as claveSAT,
|
||||||
|
p.CCLAVEUNIDADSAT as claveUnidadSAT,
|
||||||
|
p.CPRECIOBASE as precioBase,
|
||||||
|
p.CULTIMOCOSTO as ultimoCosto,
|
||||||
|
p.CCOSTOPROMEDIO as costoPromedio,
|
||||||
|
p.CTASAIVA as tasaIVA,
|
||||||
|
p.CTASAIEPS as tasaIEPS,
|
||||||
|
p.CCONTROLEXISTENCIAS as controlExistencias,
|
||||||
|
p.CESTATUS as estado,
|
||||||
|
p.CCATEGORIA as categoria,
|
||||||
|
p.CLINEA as linea,
|
||||||
|
p.CMARCA as marca,
|
||||||
|
p.CFECHAALTA as fechaAlta
|
||||||
|
FROM admProductos p
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (options?.activos !== false) {
|
||||||
|
query += ' AND p.CESTATUS = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.tipo) {
|
||||||
|
const tipoNum = options.tipo === 'Producto' ? 1 : options.tipo === 'Paquete' ? 2 : 3;
|
||||||
|
query += ' AND p.CTIPOPRODUCTO = @tipoNum';
|
||||||
|
params.tipoNum = tipoNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.busqueda) {
|
||||||
|
query += ` AND (
|
||||||
|
p.CCODIGOPRODUCTO LIKE @busqueda
|
||||||
|
OR p.CNOMBREPRODUCTO LIKE @busqueda
|
||||||
|
OR p.CDESCRIPCIONPRODUCTO LIKE @busqueda
|
||||||
|
)`;
|
||||||
|
params.busqueda = `%${options.busqueda}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.categoria) {
|
||||||
|
query += ' AND p.CCATEGORIA = @categoria';
|
||||||
|
params.categoria = options.categoria;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY p.CNOMBREPRODUCTO';
|
||||||
|
|
||||||
|
if (options?.limit) {
|
||||||
|
const offset = options.page ? (options.page - 1) * options.limit : 0;
|
||||||
|
query += ` OFFSET ${offset} ROWS FETCH NEXT ${options.limit} ROWS ONLY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
tipoNum: number;
|
||||||
|
unidadMedida: string;
|
||||||
|
claveSAT: string | null;
|
||||||
|
claveUnidadSAT: string | null;
|
||||||
|
precioBase: number;
|
||||||
|
ultimoCosto: number | null;
|
||||||
|
costoPromedio: number | null;
|
||||||
|
tasaIVA: number | null;
|
||||||
|
tasaIEPS: number | null;
|
||||||
|
controlExistencias: number;
|
||||||
|
estado: number;
|
||||||
|
categoria: string | null;
|
||||||
|
linea: string | null;
|
||||||
|
marca: string | null;
|
||||||
|
fechaAlta: Date | null;
|
||||||
|
}>(query, params);
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
codigo: row.codigo?.trim() || '',
|
||||||
|
nombre: row.nombre?.trim() || '',
|
||||||
|
descripcion: row.descripcion?.trim() || undefined,
|
||||||
|
tipo: this.getTipoProductoNombre(row.tipoNum),
|
||||||
|
unidadMedida: row.unidadMedida?.trim() || '',
|
||||||
|
claveSAT: row.claveSAT?.trim() || undefined,
|
||||||
|
claveUnidadSAT: row.claveUnidadSAT?.trim() || undefined,
|
||||||
|
precioBase: row.precioBase || 0,
|
||||||
|
ultimoCosto: row.ultimoCosto || undefined,
|
||||||
|
costoPromedio: row.costoPromedio || undefined,
|
||||||
|
tasaIVA: row.tasaIVA || undefined,
|
||||||
|
tasaIEPS: row.tasaIEPS || undefined,
|
||||||
|
controlExistencias: row.controlExistencias === 1,
|
||||||
|
estado: row.estado,
|
||||||
|
categoria: row.categoria?.trim() || undefined,
|
||||||
|
linea: row.linea?.trim() || undefined,
|
||||||
|
marca: row.marca?.trim() || undefined,
|
||||||
|
fechaAlta: row.fechaAlta || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Documentos (Facturas, Notas de Credito, etc.)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las facturas emitidas (a clientes) en un periodo
|
||||||
|
*/
|
||||||
|
async getFacturasEmitidas(filter: DocumentoFilter): Promise<CONTPAQiDocumento[]> {
|
||||||
|
return this.getDocumentos({
|
||||||
|
...filter,
|
||||||
|
conceptos: ['FacturaCliente'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las facturas recibidas (de proveedores) en un periodo
|
||||||
|
*/
|
||||||
|
async getFacturasRecibidas(filter: DocumentoFilter): Promise<CONTPAQiDocumento[]> {
|
||||||
|
return this.getDocumentos({
|
||||||
|
...filter,
|
||||||
|
conceptos: ['FacturaProveedor'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene documentos comerciales
|
||||||
|
*/
|
||||||
|
async getDocumentos(filter: DocumentoFilter & {
|
||||||
|
conceptos?: ConceptoDocumento[];
|
||||||
|
}): Promise<CONTPAQiDocumento[]> {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
d.CIDDOCUMENTO as id,
|
||||||
|
d.CIDCONCEPTODOCUMENTO as conceptoId,
|
||||||
|
d.CSERIEFASCICULO as serie,
|
||||||
|
d.CFOLIO as folio,
|
||||||
|
d.CFECHA as fecha,
|
||||||
|
d.CFECHAVENCIMIENTO as fechaVencimiento,
|
||||||
|
cp.CCODIGOCLIENTE as codigoClienteProveedor,
|
||||||
|
cp.CRAZONSOCIAL as nombreClienteProveedor,
|
||||||
|
cp.CRFC as rfcClienteProveedor,
|
||||||
|
d.CSUBTOTAL as subtotal,
|
||||||
|
d.CDESCUENTO as descuento,
|
||||||
|
d.CIVA as iva,
|
||||||
|
d.CIEPS as ieps,
|
||||||
|
d.CRETENCIONES as retenciones,
|
||||||
|
d.CTOTAL as total,
|
||||||
|
d.CMONEDA as moneda,
|
||||||
|
d.CTIPOCAMBIO as tipoCambio,
|
||||||
|
d.CUUID as uuid,
|
||||||
|
d.CFORMAPAGO as formaPago,
|
||||||
|
d.CMETODOPAGO as metodoPago,
|
||||||
|
d.CCONDICIONESPAGO as condicionesPago,
|
||||||
|
d.CPENDIENTE as pendiente,
|
||||||
|
d.CESTATUS as estadoNum,
|
||||||
|
d.CCANCELADO as cancelado,
|
||||||
|
d.CFECHACANCELACION as fechaCancelacion,
|
||||||
|
d.CIMPRESO as impreso,
|
||||||
|
d.COBSERVACIONES as observaciones,
|
||||||
|
d.CREFERENCIA as referencia,
|
||||||
|
d.CLUGAREXPEDICION as lugarExpedicion
|
||||||
|
FROM admDocumentos d
|
||||||
|
INNER JOIN admClientes cp ON d.CIDCLIENTEPROVEEDOR = cp.CIDCLIENTEPROVEEDOR
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// Filtrar por conceptos de documento
|
||||||
|
if (filter.conceptos && filter.conceptos.length > 0) {
|
||||||
|
const conceptoIds = filter.conceptos
|
||||||
|
.map((c) => this.getConceptoDocumentoId(c))
|
||||||
|
.filter((id) => id !== null);
|
||||||
|
|
||||||
|
if (conceptoIds.length > 0) {
|
||||||
|
query += ` AND d.CIDCONCEPTODOCUMENTO IN (${conceptoIds.join(',')})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.fechaInicio) {
|
||||||
|
query += ' AND d.CFECHA >= @fechaInicio';
|
||||||
|
params.fechaInicio = new Date(filter.fechaInicio);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.fechaFin) {
|
||||||
|
query += ' AND d.CFECHA <= @fechaFin';
|
||||||
|
params.fechaFin = new Date(filter.fechaFin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.clienteId) {
|
||||||
|
query += ' AND d.CIDCLIENTEPROVEEDOR = @clienteId';
|
||||||
|
params.clienteId = filter.clienteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.uuid) {
|
||||||
|
query += ' AND d.CUUID = @uuid';
|
||||||
|
params.uuid = filter.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.estado) {
|
||||||
|
query += ' AND d.CESTATUS = @estado';
|
||||||
|
params.estado = this.getEstadoDocumentoNumero(filter.estado);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter.incluyeCancelados) {
|
||||||
|
query += ' AND ISNULL(d.CCANCELADO, 0) = 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY d.CFECHA DESC, d.CFOLIO DESC';
|
||||||
|
|
||||||
|
if (filter.limit) {
|
||||||
|
const offset = filter.page ? (filter.page - 1) * filter.limit : 0;
|
||||||
|
query += ` OFFSET ${offset} ROWS FETCH NEXT ${filter.limit} ROWS ONLY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
conceptoId: number;
|
||||||
|
serie: string | null;
|
||||||
|
folio: number;
|
||||||
|
fecha: Date;
|
||||||
|
fechaVencimiento: Date | null;
|
||||||
|
codigoClienteProveedor: string;
|
||||||
|
nombreClienteProveedor: string;
|
||||||
|
rfcClienteProveedor: string;
|
||||||
|
subtotal: number;
|
||||||
|
descuento: number;
|
||||||
|
iva: number;
|
||||||
|
ieps: number | null;
|
||||||
|
retenciones: number | null;
|
||||||
|
total: number;
|
||||||
|
moneda: string;
|
||||||
|
tipoCambio: number;
|
||||||
|
uuid: string | null;
|
||||||
|
formaPago: string | null;
|
||||||
|
metodoPago: string | null;
|
||||||
|
condicionesPago: string | null;
|
||||||
|
pendiente: number;
|
||||||
|
estadoNum: number;
|
||||||
|
cancelado: number;
|
||||||
|
fechaCancelacion: Date | null;
|
||||||
|
impreso: number;
|
||||||
|
observaciones: string | null;
|
||||||
|
referencia: string | null;
|
||||||
|
lugarExpedicion: string | null;
|
||||||
|
}>(query, params);
|
||||||
|
|
||||||
|
const documentos: CONTPAQiDocumento[] = [];
|
||||||
|
|
||||||
|
for (const row of result) {
|
||||||
|
const movimientos = await this.getMovimientosDocumento(row.id);
|
||||||
|
|
||||||
|
documentos.push({
|
||||||
|
id: row.id,
|
||||||
|
concepto: this.getConceptoDocumentoNombre(row.conceptoId),
|
||||||
|
serie: row.serie?.trim() || undefined,
|
||||||
|
folio: row.folio,
|
||||||
|
fecha: row.fecha,
|
||||||
|
fechaVencimiento: row.fechaVencimiento || undefined,
|
||||||
|
codigoClienteProveedor: row.codigoClienteProveedor?.trim() || '',
|
||||||
|
nombreClienteProveedor: row.nombreClienteProveedor?.trim() || '',
|
||||||
|
rfcClienteProveedor: row.rfcClienteProveedor?.trim() || '',
|
||||||
|
subtotal: row.subtotal || 0,
|
||||||
|
descuento: row.descuento || 0,
|
||||||
|
iva: row.iva || 0,
|
||||||
|
ieps: row.ieps || undefined,
|
||||||
|
retenciones: row.retenciones || undefined,
|
||||||
|
total: row.total || 0,
|
||||||
|
moneda: row.moneda?.trim() || 'MXN',
|
||||||
|
tipoCambio: row.tipoCambio || 1,
|
||||||
|
uuid: row.uuid?.trim() || undefined,
|
||||||
|
formaPago: row.formaPago?.trim() || undefined,
|
||||||
|
metodoPago: row.metodoPago?.trim() || undefined,
|
||||||
|
condicionesPago: row.condicionesPago?.trim() || undefined,
|
||||||
|
pendiente: row.pendiente || 0,
|
||||||
|
estado: this.getEstadoDocumentoNombre(row.estadoNum),
|
||||||
|
cancelado: row.cancelado === 1,
|
||||||
|
fechaCancelacion: row.fechaCancelacion || undefined,
|
||||||
|
impreso: row.impreso === 1,
|
||||||
|
observaciones: row.observaciones?.trim() || undefined,
|
||||||
|
movimientos,
|
||||||
|
referencia: row.referencia?.trim() || undefined,
|
||||||
|
lugarExpedicion: row.lugarExpedicion?.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los movimientos (detalle de productos) de un documento
|
||||||
|
*/
|
||||||
|
async getMovimientosDocumento(documentoId: number): Promise<CONTPAQiMovimientoDocumento[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.CIDMOVIMIENTO as id,
|
||||||
|
m.CIDDOCUMENTO as documentoId,
|
||||||
|
m.CNUMEROMOVIMIENTO as numMovimiento,
|
||||||
|
p.CCODIGOPRODUCTO as codigoProducto,
|
||||||
|
p.CNOMBREPRODUCTO as nombreProducto,
|
||||||
|
m.CUNIDADES as cantidad,
|
||||||
|
m.CUNIDAD as unidad,
|
||||||
|
m.CPRECIO as precioUnitario,
|
||||||
|
m.CDESCUENTO as descuento,
|
||||||
|
m.CIMPORTE as importe,
|
||||||
|
m.CIVA as iva,
|
||||||
|
m.CIEPS as ieps,
|
||||||
|
m.CTOTAL as total,
|
||||||
|
p.CCLAVESAT as claveSAT,
|
||||||
|
p.CCLAVEUNIDADSAT as claveUnidadSAT,
|
||||||
|
m.CREFERENCIA as referencia,
|
||||||
|
m.COBSERVACIONES as observaciones
|
||||||
|
FROM admMovimientos m
|
||||||
|
INNER JOIN admProductos p ON m.CIDPRODUCTO = p.CIDPRODUCTO
|
||||||
|
WHERE m.CIDDOCUMENTO = @documentoId
|
||||||
|
ORDER BY m.CNUMEROMOVIMIENTO
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
documentoId: number;
|
||||||
|
numMovimiento: number;
|
||||||
|
codigoProducto: string;
|
||||||
|
nombreProducto: string;
|
||||||
|
cantidad: number;
|
||||||
|
unidad: string;
|
||||||
|
precioUnitario: number;
|
||||||
|
descuento: number;
|
||||||
|
importe: number;
|
||||||
|
iva: number;
|
||||||
|
ieps: number | null;
|
||||||
|
total: number;
|
||||||
|
claveSAT: string | null;
|
||||||
|
claveUnidadSAT: string | null;
|
||||||
|
referencia: string | null;
|
||||||
|
observaciones: string | null;
|
||||||
|
}>(query, { documentoId });
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
documentoId: row.documentoId,
|
||||||
|
numMovimiento: row.numMovimiento,
|
||||||
|
codigoProducto: row.codigoProducto?.trim() || '',
|
||||||
|
nombreProducto: row.nombreProducto?.trim() || '',
|
||||||
|
cantidad: row.cantidad || 0,
|
||||||
|
unidad: row.unidad?.trim() || '',
|
||||||
|
precioUnitario: row.precioUnitario || 0,
|
||||||
|
descuento: row.descuento || 0,
|
||||||
|
importe: row.importe || 0,
|
||||||
|
iva: row.iva || 0,
|
||||||
|
ieps: row.ieps || undefined,
|
||||||
|
total: row.total || 0,
|
||||||
|
claveSAT: row.claveSAT?.trim() || undefined,
|
||||||
|
claveUnidadSAT: row.claveUnidadSAT?.trim() || undefined,
|
||||||
|
referencia: row.referencia?.trim() || undefined,
|
||||||
|
observaciones: row.observaciones?.trim() || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Inventario
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las existencias de productos
|
||||||
|
*/
|
||||||
|
async getInventario(options?: {
|
||||||
|
almacenId?: number;
|
||||||
|
productoId?: number;
|
||||||
|
soloConExistencia?: boolean;
|
||||||
|
}): Promise<CONTPAQiExistencia[]> {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
e.CIDPRODUCTO as productoId,
|
||||||
|
p.CCODIGOPRODUCTO as codigoProducto,
|
||||||
|
p.CNOMBREPRODUCTO as nombreProducto,
|
||||||
|
e.CIDALMACEN as almacenId,
|
||||||
|
a.CNOMBREALMACEN as nombreAlmacen,
|
||||||
|
e.CEXISTENCIA as existencia,
|
||||||
|
p.CUNIDADMEDIDA as unidad,
|
||||||
|
e.CEXISTENCIAMINIMA as existenciaMinima,
|
||||||
|
e.CEXISTENCIAMAXIMA as existenciaMaxima,
|
||||||
|
e.CPUNTOREORDEN as puntoReorden,
|
||||||
|
p.CULTIMOCOSTO as ultimoCosto,
|
||||||
|
p.CCOSTOPROMEDIO as costoPromedio
|
||||||
|
FROM admExistencias e
|
||||||
|
INNER JOIN admProductos p ON e.CIDPRODUCTO = p.CIDPRODUCTO
|
||||||
|
INNER JOIN admAlmacenes a ON e.CIDALMACEN = a.CIDALMACEN
|
||||||
|
WHERE p.CCONTROLEXISTENCIAS = 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (options?.almacenId) {
|
||||||
|
query += ' AND e.CIDALMACEN = @almacenId';
|
||||||
|
params.almacenId = options.almacenId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.productoId) {
|
||||||
|
query += ' AND e.CIDPRODUCTO = @productoId';
|
||||||
|
params.productoId = options.productoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.soloConExistencia) {
|
||||||
|
query += ' AND e.CEXISTENCIA > 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY p.CNOMBREPRODUCTO, a.CNOMBREALMACEN';
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
productoId: number;
|
||||||
|
codigoProducto: string;
|
||||||
|
nombreProducto: string;
|
||||||
|
almacenId: number;
|
||||||
|
nombreAlmacen: string;
|
||||||
|
existencia: number;
|
||||||
|
unidad: string;
|
||||||
|
existenciaMinima: number | null;
|
||||||
|
existenciaMaxima: number | null;
|
||||||
|
puntoReorden: number | null;
|
||||||
|
ultimoCosto: number | null;
|
||||||
|
costoPromedio: number | null;
|
||||||
|
}>(query, params);
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
productoId: row.productoId,
|
||||||
|
codigoProducto: row.codigoProducto?.trim() || '',
|
||||||
|
nombreProducto: row.nombreProducto?.trim() || '',
|
||||||
|
almacenId: row.almacenId,
|
||||||
|
nombreAlmacen: row.nombreAlmacen?.trim() || '',
|
||||||
|
existencia: row.existencia || 0,
|
||||||
|
unidad: row.unidad?.trim() || '',
|
||||||
|
existenciaMinima: row.existenciaMinima || undefined,
|
||||||
|
existenciaMaxima: row.existenciaMaxima || undefined,
|
||||||
|
puntoReorden: row.puntoReorden || undefined,
|
||||||
|
ultimoCosto: row.ultimoCosto || undefined,
|
||||||
|
costoPromedio: row.costoPromedio || undefined,
|
||||||
|
valorInventario: (row.existencia || 0) * (row.costoPromedio || row.ultimoCosto || 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el catalogo de almacenes
|
||||||
|
*/
|
||||||
|
async getAlmacenes(): Promise<CONTPAQiAlmacen[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
a.CIDALMACEN as id,
|
||||||
|
a.CCODIGOALMACEN as codigo,
|
||||||
|
a.CNOMBREALMACEN as nombre,
|
||||||
|
a.CESPRINCIPAL as esPrincipal,
|
||||||
|
a.CESTATUS as estado
|
||||||
|
FROM admAlmacenes a
|
||||||
|
WHERE a.CESTATUS = 1
|
||||||
|
ORDER BY a.CNOMBREALMACEN
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
esPrincipal: number;
|
||||||
|
estado: number;
|
||||||
|
}>(query);
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
codigo: row.codigo?.trim() || '',
|
||||||
|
nombre: row.nombre?.trim() || '',
|
||||||
|
esPrincipal: row.esPrincipal === 1,
|
||||||
|
estado: row.estado,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private getTipoProductoNombre(tipo: number): TipoProducto {
|
||||||
|
const tipos: Record<number, TipoProducto> = {
|
||||||
|
1: 'Producto',
|
||||||
|
2: 'Paquete',
|
||||||
|
3: 'Servicio',
|
||||||
|
};
|
||||||
|
return tipos[tipo] || 'Producto';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConceptoDocumentoId(concepto: ConceptoDocumento): number | null {
|
||||||
|
const conceptos: Record<ConceptoDocumento, number> = {
|
||||||
|
Cotizacion: 1,
|
||||||
|
Pedido: 2,
|
||||||
|
Remision: 3,
|
||||||
|
FacturaCliente: 4,
|
||||||
|
NotaCreditoCliente: 5,
|
||||||
|
NotaCargoCliente: 6,
|
||||||
|
DevolucionCliente: 7,
|
||||||
|
OrdenCompra: 17,
|
||||||
|
FacturaProveedor: 18,
|
||||||
|
NotaCreditoProveedor: 19,
|
||||||
|
NotaCargoProveedor: 20,
|
||||||
|
DevolucionProveedor: 21,
|
||||||
|
};
|
||||||
|
return conceptos[concepto] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConceptoDocumentoNombre(id: number): ConceptoDocumento {
|
||||||
|
return CONCEPTO_DOC_MAP[id] || 'FacturaCliente';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEstadoDocumentoNumero(estado: EstadoDocumento): number {
|
||||||
|
const estados: Record<EstadoDocumento, number> = {
|
||||||
|
Pendiente: 1,
|
||||||
|
Parcial: 2,
|
||||||
|
Pagado: 3,
|
||||||
|
Cancelado: 4,
|
||||||
|
};
|
||||||
|
return estados[estado];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEstadoDocumentoNombre(estado: number): EstadoDocumento {
|
||||||
|
const estados: Record<number, EstadoDocumento> = {
|
||||||
|
1: 'Pendiente',
|
||||||
|
2: 'Parcial',
|
||||||
|
3: 'Pagado',
|
||||||
|
4: 'Cancelado',
|
||||||
|
};
|
||||||
|
return estados[estado] || 'Pendiente';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del conector Comercial
|
||||||
|
*/
|
||||||
|
export function createComercialConnector(client: CONTPAQiClient): ComercialConnector {
|
||||||
|
return new ComercialConnector(client);
|
||||||
|
}
|
||||||
@@ -0,0 +1,841 @@
|
|||||||
|
/**
|
||||||
|
* CONTPAQi Contabilidad Connector
|
||||||
|
* Conector para el modulo de Contabilidad de CONTPAQi
|
||||||
|
*
|
||||||
|
* TABLAS PRINCIPALES DE CONTPAQi CONTABILIDAD:
|
||||||
|
* - Cuentas: Catalogo de cuentas contables
|
||||||
|
* - Polizas: Encabezado de polizas
|
||||||
|
* - MovPolizas: Movimientos de polizas
|
||||||
|
* - TiposPoliza: Tipos de poliza (Ingresos, Egresos, Diario)
|
||||||
|
* - Ejercicios: Ejercicios fiscales
|
||||||
|
* - Periodos: Periodos contables (meses)
|
||||||
|
* - SaldosCuentas: Saldos por periodo
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CONTPAQiClient } from './contpaqi.client.js';
|
||||||
|
import {
|
||||||
|
CONTPAQiCuenta,
|
||||||
|
CONTPAQiPoliza,
|
||||||
|
CONTPAQiMovimiento,
|
||||||
|
CONTPAQiBalanzaComprobacion,
|
||||||
|
CONTPAQiBalanzaLinea,
|
||||||
|
CONTPAQiEstadoResultados,
|
||||||
|
CONTPAQiEstadoResultadosLinea,
|
||||||
|
TipoCuenta,
|
||||||
|
NaturalezaCuenta,
|
||||||
|
TipoPoliza,
|
||||||
|
TipoMovimiento,
|
||||||
|
CONTPAQiQueryError,
|
||||||
|
} from './contpaqi.types.js';
|
||||||
|
import { PeriodoQuery, PolizaFilter, DateRangeQuery } from './contpaqi.schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para CONTPAQi Contabilidad
|
||||||
|
*/
|
||||||
|
export class ContabilidadConnector {
|
||||||
|
constructor(private client: CONTPAQiClient) {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Catalogo de Cuentas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el catalogo completo de cuentas contables
|
||||||
|
*
|
||||||
|
* Tabla: Cuentas
|
||||||
|
* Campos principales:
|
||||||
|
* - CIDCUENTA: ID de la cuenta
|
||||||
|
* - CCODIGOCUENTA: Codigo de la cuenta
|
||||||
|
* - CNOMBRECUENTA: Nombre de la cuenta
|
||||||
|
* - CTIPOCUENTA: Tipo (1=Activo, 2=Pasivo, 3=Capital, 4=Ingreso, 5=Costo, 6=Gasto, 7=Orden)
|
||||||
|
* - CNATURALEZA: Naturaleza (1=Deudora, 2=Acreedora)
|
||||||
|
* - CNIVEL: Nivel de la cuenta
|
||||||
|
* - CCUENTAPADRE: Codigo de cuenta padre
|
||||||
|
* - CESDECATALOGO: 1 si es cuenta de detalle
|
||||||
|
* - CCODIGOAGRUPADOR: Codigo agrupador SAT
|
||||||
|
*/
|
||||||
|
async getCatalogoCuentas(options?: {
|
||||||
|
activas?: boolean;
|
||||||
|
tipo?: TipoCuenta;
|
||||||
|
nivel?: number;
|
||||||
|
soloDetalle?: boolean;
|
||||||
|
}): Promise<CONTPAQiCuenta[]> {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
CIDCUENTA as id,
|
||||||
|
CCODIGOCUENTA as codigo,
|
||||||
|
CNOMBRECUENTA as nombre,
|
||||||
|
CTIPOCUENTA as tipoNum,
|
||||||
|
CNATURALEZA as naturalezaNum,
|
||||||
|
CNIVEL as nivel,
|
||||||
|
CCUENTAPADRE as codigoPadre,
|
||||||
|
CESDECATALOGO as esDeCatalogoNum,
|
||||||
|
CCODIGOAGRUPADOR as codigoAgrupador,
|
||||||
|
CSALDOINICIAL as saldoInicial,
|
||||||
|
CESTATUS as activa,
|
||||||
|
CFECHAALTA as fechaAlta,
|
||||||
|
CMONEDA as moneda
|
||||||
|
FROM Cuentas
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (options?.activas !== false) {
|
||||||
|
query += ' AND CESTATUS = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.tipo) {
|
||||||
|
query += ' AND CTIPOCUENTA = @tipo';
|
||||||
|
params.tipo = this.getTipoCuentaNumero(options.tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.nivel !== undefined) {
|
||||||
|
query += ' AND CNIVEL = @nivel';
|
||||||
|
params.nivel = options.nivel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.soloDetalle) {
|
||||||
|
query += ' AND CESDECATALOGO = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY CCODIGOCUENTA';
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
tipoNum: number;
|
||||||
|
naturalezaNum: number;
|
||||||
|
nivel: number;
|
||||||
|
codigoPadre: string | null;
|
||||||
|
esDeCatalogoNum: number;
|
||||||
|
codigoAgrupador: string | null;
|
||||||
|
saldoInicial: number | null;
|
||||||
|
activa: number;
|
||||||
|
fechaAlta: Date | null;
|
||||||
|
moneda: string | null;
|
||||||
|
}>(query, params);
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
codigo: row.codigo?.trim() || '',
|
||||||
|
nombre: row.nombre?.trim() || '',
|
||||||
|
tipo: this.getTipoCuentaNombre(row.tipoNum),
|
||||||
|
naturaleza: row.naturalezaNum === 1 ? 'Deudora' : 'Acreedora' as NaturalezaCuenta,
|
||||||
|
nivel: row.nivel,
|
||||||
|
codigoPadre: row.codigoPadre?.trim() || undefined,
|
||||||
|
esDeCatalogo: row.esDeCatalogoNum === 1,
|
||||||
|
codigoAgrupador: row.codigoAgrupador?.trim() || undefined,
|
||||||
|
saldoInicial: row.saldoInicial || 0,
|
||||||
|
activa: row.activa === 1,
|
||||||
|
fechaAlta: row.fechaAlta || undefined,
|
||||||
|
moneda: row.moneda?.trim() || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una cuenta por su codigo
|
||||||
|
*/
|
||||||
|
async getCuentaByCodigo(codigo: string): Promise<CONTPAQiCuenta | null> {
|
||||||
|
const cuentas = await this.getCatalogoCuentas({ activas: false });
|
||||||
|
return cuentas.find((c) => c.codigo === codigo) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Polizas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene polizas contables por rango de fechas
|
||||||
|
*
|
||||||
|
* Tablas:
|
||||||
|
* - Polizas: Encabezado de polizas
|
||||||
|
* - CIDPOLIZA: ID de la poliza
|
||||||
|
* - CTIPOPOLIZA: Tipo (1=Ingresos, 2=Egresos, 3=Diario, 4=Orden)
|
||||||
|
* - CNUMPOLIZA: Numero de poliza
|
||||||
|
* - CFECHA: Fecha
|
||||||
|
* - CCONCEPTO: Concepto
|
||||||
|
* - CIDEJERCICIO: Ejercicio
|
||||||
|
* - CIDPERIODO: Periodo
|
||||||
|
* - CAFECTADA: Si esta afectada a saldos
|
||||||
|
*
|
||||||
|
* - MovPolizas: Movimientos de poliza
|
||||||
|
* - CIDMOVPOLIZA: ID del movimiento
|
||||||
|
* - CIDPOLIZA: ID de poliza padre
|
||||||
|
* - CNUMEROPARTIDA: Numero de movimiento
|
||||||
|
* - CCODIGOCUENTA: Codigo de cuenta
|
||||||
|
* - CCONCEPTO: Concepto del movimiento
|
||||||
|
* - CCARGO: Importe cargo
|
||||||
|
* - CABONO: Importe abono
|
||||||
|
* - CREFERENCIA: Referencia
|
||||||
|
*/
|
||||||
|
async getPolizas(
|
||||||
|
fechaInicio: Date,
|
||||||
|
fechaFin: Date,
|
||||||
|
options?: PolizaFilter
|
||||||
|
): Promise<CONTPAQiPoliza[]> {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
p.CIDPOLIZA as id,
|
||||||
|
p.CTIPOPOLIZA as tipoNum,
|
||||||
|
p.CNUMPOLIZA as numero,
|
||||||
|
p.CFECHA as fecha,
|
||||||
|
p.CCONCEPTO as concepto,
|
||||||
|
p.CIDEJERCICIO as ejercicio,
|
||||||
|
p.CIDPERIODO as periodo,
|
||||||
|
p.CAFECTADA as afectada,
|
||||||
|
p.CIMPRESA as impresa,
|
||||||
|
p.CUSUARIO as usuario,
|
||||||
|
p.CFECHACREACION as fechaCreacion,
|
||||||
|
p.CUUIDCFDI as uuidCFDI
|
||||||
|
FROM Polizas p
|
||||||
|
WHERE p.CFECHA >= @fechaInicio
|
||||||
|
AND p.CFECHA <= @fechaFin
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
fechaInicio,
|
||||||
|
fechaFin,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.tipoPoliza) {
|
||||||
|
query += ' AND p.CTIPOPOLIZA = @tipoPoliza';
|
||||||
|
params.tipoPoliza = this.getTipoPolizaNumero(options.tipoPoliza);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.ejercicio) {
|
||||||
|
query += ' AND p.CIDEJERCICIO = @ejercicio';
|
||||||
|
params.ejercicio = options.ejercicio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.periodoInicio && options?.periodoFin) {
|
||||||
|
query += ' AND p.CIDPERIODO >= @periodoInicio AND p.CIDPERIODO <= @periodoFin';
|
||||||
|
params.periodoInicio = options.periodoInicio;
|
||||||
|
params.periodoFin = options.periodoFin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.soloAfectadas !== false) {
|
||||||
|
query += ' AND p.CAFECTADA = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY p.CFECHA, p.CTIPOPOLIZA, p.CNUMPOLIZA';
|
||||||
|
|
||||||
|
// Aplicar paginacion
|
||||||
|
if (options?.limit) {
|
||||||
|
query += ` OFFSET ${options.page ? (options.page - 1) * options.limit : 0} ROWS`;
|
||||||
|
query += ` FETCH NEXT ${options.limit} ROWS ONLY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const polizasResult = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
tipoNum: number;
|
||||||
|
numero: number;
|
||||||
|
fecha: Date;
|
||||||
|
concepto: string;
|
||||||
|
ejercicio: number;
|
||||||
|
periodo: number;
|
||||||
|
afectada: number;
|
||||||
|
impresa: number | null;
|
||||||
|
usuario: string | null;
|
||||||
|
fechaCreacion: Date | null;
|
||||||
|
uuidCFDI: string | null;
|
||||||
|
}>(query, params);
|
||||||
|
|
||||||
|
// Obtener movimientos de cada poliza
|
||||||
|
const polizas: CONTPAQiPoliza[] = [];
|
||||||
|
|
||||||
|
for (const polizaRow of polizasResult) {
|
||||||
|
const movimientos = await this.getMovimientosPoliza(polizaRow.id);
|
||||||
|
|
||||||
|
const totalCargos = movimientos.reduce(
|
||||||
|
(sum, m) => sum + (m.tipoMovimiento === 'Cargo' ? m.importe : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalAbonos = movimientos.reduce(
|
||||||
|
(sum, m) => sum + (m.tipoMovimiento === 'Abono' ? m.importe : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
polizas.push({
|
||||||
|
id: polizaRow.id,
|
||||||
|
tipo: this.getTipoPolizaNombre(polizaRow.tipoNum),
|
||||||
|
numero: polizaRow.numero,
|
||||||
|
fecha: polizaRow.fecha,
|
||||||
|
concepto: polizaRow.concepto?.trim() || '',
|
||||||
|
ejercicio: polizaRow.ejercicio,
|
||||||
|
periodo: polizaRow.periodo,
|
||||||
|
totalCargos,
|
||||||
|
totalAbonos,
|
||||||
|
cuadrada: Math.abs(totalCargos - totalAbonos) < 0.01,
|
||||||
|
afectada: polizaRow.afectada === 1,
|
||||||
|
movimientos,
|
||||||
|
impresa: polizaRow.impresa === 1,
|
||||||
|
usuario: polizaRow.usuario?.trim() || undefined,
|
||||||
|
fechaCreacion: polizaRow.fechaCreacion || undefined,
|
||||||
|
uuidCFDI: polizaRow.uuidCFDI?.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return polizas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los movimientos de una poliza
|
||||||
|
*/
|
||||||
|
async getMovimientosPoliza(polizaId: number): Promise<CONTPAQiMovimiento[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.CIDMOVPOLIZA as id,
|
||||||
|
m.CIDPOLIZA as polizaId,
|
||||||
|
m.CNUMEROPARTIDA as numMovimiento,
|
||||||
|
m.CCODIGOCUENTA as codigoCuenta,
|
||||||
|
c.CNOMBRECUENTA as nombreCuenta,
|
||||||
|
m.CCONCEPTO as concepto,
|
||||||
|
m.CCARGO as cargo,
|
||||||
|
m.CABONO as abono,
|
||||||
|
m.CREFERENCIA as referencia,
|
||||||
|
m.CSEGMENTONEGOCIO as segmentoNegocio,
|
||||||
|
m.CDIARIO as diario,
|
||||||
|
m.CUUIDCFDI as uuidCFDI,
|
||||||
|
m.CRFCTERCERO as rfcTercero,
|
||||||
|
m.CNUMFACTURA as numFactura,
|
||||||
|
m.CTIPOCAMBIO as tipoCambio,
|
||||||
|
m.CIMPORTEME as importeME
|
||||||
|
FROM MovPolizas m
|
||||||
|
LEFT JOIN Cuentas c ON m.CCODIGOCUENTA = c.CCODIGOCUENTA
|
||||||
|
WHERE m.CIDPOLIZA = @polizaId
|
||||||
|
ORDER BY m.CNUMEROPARTIDA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
polizaId: number;
|
||||||
|
numMovimiento: number;
|
||||||
|
codigoCuenta: string;
|
||||||
|
nombreCuenta: string | null;
|
||||||
|
concepto: string;
|
||||||
|
cargo: number;
|
||||||
|
abono: number;
|
||||||
|
referencia: string | null;
|
||||||
|
segmentoNegocio: string | null;
|
||||||
|
diario: string | null;
|
||||||
|
uuidCFDI: string | null;
|
||||||
|
rfcTercero: string | null;
|
||||||
|
numFactura: string | null;
|
||||||
|
tipoCambio: number | null;
|
||||||
|
importeME: number | null;
|
||||||
|
}>(query, { polizaId });
|
||||||
|
|
||||||
|
return result.map((row) => {
|
||||||
|
const cargo = row.cargo || 0;
|
||||||
|
const abono = row.abono || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
polizaId: row.polizaId,
|
||||||
|
numMovimiento: row.numMovimiento,
|
||||||
|
codigoCuenta: row.codigoCuenta?.trim() || '',
|
||||||
|
nombreCuenta: row.nombreCuenta?.trim() || undefined,
|
||||||
|
concepto: row.concepto?.trim() || '',
|
||||||
|
tipoMovimiento: cargo > 0 ? 'Cargo' : 'Abono' as TipoMovimiento,
|
||||||
|
importe: cargo > 0 ? cargo : abono,
|
||||||
|
referencia: row.referencia?.trim() || undefined,
|
||||||
|
segmentoNegocio: row.segmentoNegocio?.trim() || undefined,
|
||||||
|
diario: row.diario?.trim() || undefined,
|
||||||
|
uuidCFDI: row.uuidCFDI?.trim() || undefined,
|
||||||
|
rfcTercero: row.rfcTercero?.trim() || undefined,
|
||||||
|
numFactura: row.numFactura?.trim() || undefined,
|
||||||
|
tipoCambio: row.tipoCambio || undefined,
|
||||||
|
importeME: row.importeME || undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Balanza de Comprobacion
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera la balanza de comprobacion para un periodo
|
||||||
|
*
|
||||||
|
* Tabla: SaldosCuentas (o calculado de MovPolizas)
|
||||||
|
* - CIDCUENTA: ID de cuenta
|
||||||
|
* - CIDEJERCICIO: Ejercicio
|
||||||
|
* - CIDPERIODO: Periodo
|
||||||
|
* - CSALDOINICIAL: Saldo inicial
|
||||||
|
* - CCARGOS: Total cargos
|
||||||
|
* - CABONOS: Total abonos
|
||||||
|
* - CSALDOFINAL: Saldo final
|
||||||
|
*/
|
||||||
|
async getBalanzaComprobacion(periodo: PeriodoQuery): Promise<CONTPAQiBalanzaComprobacion> {
|
||||||
|
// Primero intentar obtener de la tabla de saldos
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
c.CCODIGOCUENTA as codigoCuenta,
|
||||||
|
c.CNOMBRECUENTA as nombreCuenta,
|
||||||
|
c.CTIPOCUENTA as tipoCuenta,
|
||||||
|
c.CNIVEL as nivel,
|
||||||
|
c.CCODIGOAGRUPADOR as codigoAgrupador,
|
||||||
|
ISNULL(s.CSALDOINICIAL, 0) as saldoInicial,
|
||||||
|
ISNULL(s.CCARGOS, 0) as cargos,
|
||||||
|
ISNULL(s.CABONOS, 0) as abonos,
|
||||||
|
ISNULL(s.CSALDOFINAL, 0) as saldoFinal
|
||||||
|
FROM Cuentas c
|
||||||
|
LEFT JOIN SaldosCuentas s ON c.CIDCUENTA = s.CIDCUENTA
|
||||||
|
AND s.CIDEJERCICIO = @ejercicio
|
||||||
|
AND s.CIDPERIODO = @periodo
|
||||||
|
WHERE c.CESTATUS = 1
|
||||||
|
AND c.CESDECATALOGO = 1
|
||||||
|
ORDER BY c.CCODIGOCUENTA
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
codigoCuenta: string;
|
||||||
|
nombreCuenta: string;
|
||||||
|
tipoCuenta: number;
|
||||||
|
nivel: number;
|
||||||
|
codigoAgrupador: string | null;
|
||||||
|
saldoInicial: number;
|
||||||
|
cargos: number;
|
||||||
|
abonos: number;
|
||||||
|
saldoFinal: number;
|
||||||
|
}>(query, { ejercicio: periodo.ejercicio, periodo: periodo.periodo });
|
||||||
|
|
||||||
|
const lineas: CONTPAQiBalanzaLinea[] = result.map((row) => ({
|
||||||
|
codigoCuenta: row.codigoCuenta?.trim() || '',
|
||||||
|
nombreCuenta: row.nombreCuenta?.trim() || '',
|
||||||
|
tipoCuenta: this.getTipoCuentaNombre(row.tipoCuenta),
|
||||||
|
nivel: row.nivel,
|
||||||
|
saldoInicial: row.saldoInicial,
|
||||||
|
cargos: row.cargos,
|
||||||
|
abonos: row.abonos,
|
||||||
|
saldoFinal: row.saldoFinal,
|
||||||
|
codigoAgrupador: row.codigoAgrupador?.trim() || undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calcular totales
|
||||||
|
const totales = lineas.reduce(
|
||||||
|
(acc, linea) => {
|
||||||
|
if (linea.saldoInicial >= 0) {
|
||||||
|
acc.saldoInicialDeudor += linea.saldoInicial;
|
||||||
|
} else {
|
||||||
|
acc.saldoInicialAcreedor += Math.abs(linea.saldoInicial);
|
||||||
|
}
|
||||||
|
acc.cargos += linea.cargos;
|
||||||
|
acc.abonos += linea.abonos;
|
||||||
|
if (linea.saldoFinal >= 0) {
|
||||||
|
acc.saldoFinalDeudor += linea.saldoFinal;
|
||||||
|
} else {
|
||||||
|
acc.saldoFinalAcreedor += Math.abs(linea.saldoFinal);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
saldoInicialDeudor: 0,
|
||||||
|
saldoInicialAcreedor: 0,
|
||||||
|
cargos: 0,
|
||||||
|
abonos: 0,
|
||||||
|
saldoFinalDeudor: 0,
|
||||||
|
saldoFinalAcreedor: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ejercicio: periodo.ejercicio,
|
||||||
|
periodo: periodo.periodo,
|
||||||
|
fechaGeneracion: new Date(),
|
||||||
|
tipo: 'Normal',
|
||||||
|
lineas,
|
||||||
|
totales,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Si no existe la tabla SaldosCuentas, calcular de movimientos
|
||||||
|
return this.calcularBalanzaDeMovimientos(periodo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula la balanza de comprobacion directamente de los movimientos
|
||||||
|
*/
|
||||||
|
private async calcularBalanzaDeMovimientos(
|
||||||
|
periodo: PeriodoQuery
|
||||||
|
): Promise<CONTPAQiBalanzaComprobacion> {
|
||||||
|
// Calcular saldos iniciales (acumulado de periodos anteriores)
|
||||||
|
const queryInicial = `
|
||||||
|
SELECT
|
||||||
|
m.CCODIGOCUENTA as codigoCuenta,
|
||||||
|
SUM(ISNULL(m.CCARGO, 0)) as cargos,
|
||||||
|
SUM(ISNULL(m.CABONO, 0)) as abonos
|
||||||
|
FROM MovPolizas m
|
||||||
|
INNER JOIN Polizas p ON m.CIDPOLIZA = p.CIDPOLIZA
|
||||||
|
WHERE p.CIDEJERCICIO = @ejercicio
|
||||||
|
AND p.CIDPERIODO < @periodo
|
||||||
|
AND p.CAFECTADA = 1
|
||||||
|
GROUP BY m.CCODIGOCUENTA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const queryPeriodo = `
|
||||||
|
SELECT
|
||||||
|
m.CCODIGOCUENTA as codigoCuenta,
|
||||||
|
SUM(ISNULL(m.CCARGO, 0)) as cargos,
|
||||||
|
SUM(ISNULL(m.CABONO, 0)) as abonos
|
||||||
|
FROM MovPolizas m
|
||||||
|
INNER JOIN Polizas p ON m.CIDPOLIZA = p.CIDPOLIZA
|
||||||
|
WHERE p.CIDEJERCICIO = @ejercicio
|
||||||
|
AND p.CIDPERIODO = @periodo
|
||||||
|
AND p.CAFECTADA = 1
|
||||||
|
GROUP BY m.CCODIGOCUENTA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = { ejercicio: periodo.ejercicio, periodo: periodo.periodo };
|
||||||
|
|
||||||
|
const [saldosIniciales, saldosPeriodo, cuentas] = await Promise.all([
|
||||||
|
this.client.queryMany<{
|
||||||
|
codigoCuenta: string;
|
||||||
|
cargos: number;
|
||||||
|
abonos: number;
|
||||||
|
}>(queryInicial, params),
|
||||||
|
this.client.queryMany<{
|
||||||
|
codigoCuenta: string;
|
||||||
|
cargos: number;
|
||||||
|
abonos: number;
|
||||||
|
}>(queryPeriodo, params),
|
||||||
|
this.getCatalogoCuentas({ activas: true, soloDetalle: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Crear mapa de saldos iniciales
|
||||||
|
const inicialesMap = new Map<string, number>();
|
||||||
|
for (const s of saldosIniciales) {
|
||||||
|
const codigo = s.codigoCuenta?.trim() || '';
|
||||||
|
inicialesMap.set(codigo, s.cargos - s.abonos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear mapa de movimientos del periodo
|
||||||
|
const periodoMap = new Map<string, { cargos: number; abonos: number }>();
|
||||||
|
for (const s of saldosPeriodo) {
|
||||||
|
const codigo = s.codigoCuenta?.trim() || '';
|
||||||
|
periodoMap.set(codigo, { cargos: s.cargos, abonos: s.abonos });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar lineas de balanza
|
||||||
|
const lineas: CONTPAQiBalanzaLinea[] = cuentas.map((cuenta) => {
|
||||||
|
const saldoInicial = inicialesMap.get(cuenta.codigo) || 0;
|
||||||
|
const movs = periodoMap.get(cuenta.codigo) || { cargos: 0, abonos: 0 };
|
||||||
|
const saldoFinal = saldoInicial + movs.cargos - movs.abonos;
|
||||||
|
|
||||||
|
return {
|
||||||
|
codigoCuenta: cuenta.codigo,
|
||||||
|
nombreCuenta: cuenta.nombre,
|
||||||
|
tipoCuenta: cuenta.tipo,
|
||||||
|
nivel: cuenta.nivel,
|
||||||
|
saldoInicial,
|
||||||
|
cargos: movs.cargos,
|
||||||
|
abonos: movs.abonos,
|
||||||
|
saldoFinal,
|
||||||
|
codigoAgrupador: cuenta.codigoAgrupador,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrar cuentas sin movimiento
|
||||||
|
const lineasConMovimiento = lineas.filter(
|
||||||
|
(l) => l.saldoInicial !== 0 || l.cargos !== 0 || l.abonos !== 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calcular totales
|
||||||
|
const totales = lineasConMovimiento.reduce(
|
||||||
|
(acc, linea) => {
|
||||||
|
if (linea.saldoInicial >= 0) {
|
||||||
|
acc.saldoInicialDeudor += linea.saldoInicial;
|
||||||
|
} else {
|
||||||
|
acc.saldoInicialAcreedor += Math.abs(linea.saldoInicial);
|
||||||
|
}
|
||||||
|
acc.cargos += linea.cargos;
|
||||||
|
acc.abonos += linea.abonos;
|
||||||
|
if (linea.saldoFinal >= 0) {
|
||||||
|
acc.saldoFinalDeudor += linea.saldoFinal;
|
||||||
|
} else {
|
||||||
|
acc.saldoFinalAcreedor += Math.abs(linea.saldoFinal);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
saldoInicialDeudor: 0,
|
||||||
|
saldoInicialAcreedor: 0,
|
||||||
|
cargos: 0,
|
||||||
|
abonos: 0,
|
||||||
|
saldoFinalDeudor: 0,
|
||||||
|
saldoFinalAcreedor: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ejercicio: periodo.ejercicio,
|
||||||
|
periodo: periodo.periodo,
|
||||||
|
fechaGeneracion: new Date(),
|
||||||
|
tipo: 'Normal',
|
||||||
|
lineas: lineasConMovimiento,
|
||||||
|
totales,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Estado de Resultados
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera el estado de resultados para un periodo
|
||||||
|
*/
|
||||||
|
async getEstadoResultados(periodo: PeriodoQuery): Promise<CONTPAQiEstadoResultados> {
|
||||||
|
// Obtener balanza para el periodo
|
||||||
|
const balanza = await this.getBalanzaComprobacion(periodo);
|
||||||
|
|
||||||
|
// Filtrar solo cuentas de resultados (Ingresos, Costos, Gastos)
|
||||||
|
const lineasResultados = balanza.lineas.filter((l) =>
|
||||||
|
['Ingreso', 'Costo', 'Gasto'].includes(l.tipoCuenta)
|
||||||
|
);
|
||||||
|
|
||||||
|
const lineas: CONTPAQiEstadoResultadosLinea[] = lineasResultados.map((l) => ({
|
||||||
|
codigoCuenta: l.codigoCuenta,
|
||||||
|
nombreCuenta: l.nombreCuenta,
|
||||||
|
tipo: l.tipoCuenta as 'Ingreso' | 'Costo' | 'Gasto',
|
||||||
|
nivel: l.nivel,
|
||||||
|
importeAcumulado: Math.abs(l.saldoFinal),
|
||||||
|
importePeriodo: Math.abs(l.cargos - l.abonos),
|
||||||
|
esDeCatalogo: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calcular resumen
|
||||||
|
const totalIngresos = lineas
|
||||||
|
.filter((l) => l.tipo === 'Ingreso')
|
||||||
|
.reduce((sum, l) => sum + l.importeAcumulado, 0);
|
||||||
|
|
||||||
|
const totalCostos = lineas
|
||||||
|
.filter((l) => l.tipo === 'Costo')
|
||||||
|
.reduce((sum, l) => sum + l.importeAcumulado, 0);
|
||||||
|
|
||||||
|
const totalGastos = lineas
|
||||||
|
.filter((l) => l.tipo === 'Gasto')
|
||||||
|
.reduce((sum, l) => sum + l.importeAcumulado, 0);
|
||||||
|
|
||||||
|
const utilidadBruta = totalIngresos - totalCostos;
|
||||||
|
const utilidadOperacion = utilidadBruta - totalGastos;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ejercicio: periodo.ejercicio,
|
||||||
|
periodoInicio: 1,
|
||||||
|
periodoFin: periodo.periodo,
|
||||||
|
fechaGeneracion: new Date(),
|
||||||
|
lineas,
|
||||||
|
resumen: {
|
||||||
|
totalIngresos,
|
||||||
|
totalCostos,
|
||||||
|
utilidadBruta,
|
||||||
|
totalGastos,
|
||||||
|
utilidadOperacion,
|
||||||
|
otrosIngresos: 0,
|
||||||
|
otrosGastos: 0,
|
||||||
|
utilidadNeta: utilidadOperacion,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Saldos de Cuentas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los saldos de las cuentas a una fecha determinada
|
||||||
|
*/
|
||||||
|
async getSaldosCuentas(
|
||||||
|
fecha: Date
|
||||||
|
): Promise<Array<{
|
||||||
|
codigoCuenta: string;
|
||||||
|
nombreCuenta: string;
|
||||||
|
tipoCuenta: TipoCuenta;
|
||||||
|
saldo: number;
|
||||||
|
}>> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
c.CCODIGOCUENTA as codigoCuenta,
|
||||||
|
c.CNOMBRECUENTA as nombreCuenta,
|
||||||
|
c.CTIPOCUENTA as tipoCuenta,
|
||||||
|
ISNULL(SUM(ISNULL(m.CCARGO, 0) - ISNULL(m.CABONO, 0)), 0) as saldo
|
||||||
|
FROM Cuentas c
|
||||||
|
LEFT JOIN MovPolizas m ON c.CCODIGOCUENTA = m.CCODIGOCUENTA
|
||||||
|
LEFT JOIN Polizas p ON m.CIDPOLIZA = p.CIDPOLIZA
|
||||||
|
AND p.CFECHA <= @fecha
|
||||||
|
AND p.CAFECTADA = 1
|
||||||
|
WHERE c.CESTATUS = 1
|
||||||
|
AND c.CESDECATALOGO = 1
|
||||||
|
GROUP BY c.CCODIGOCUENTA, c.CNOMBRECUENTA, c.CTIPOCUENTA
|
||||||
|
HAVING ISNULL(SUM(ISNULL(m.CCARGO, 0) - ISNULL(m.CABONO, 0)), 0) <> 0
|
||||||
|
ORDER BY c.CCODIGOCUENTA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
codigoCuenta: string;
|
||||||
|
nombreCuenta: string;
|
||||||
|
tipoCuenta: number;
|
||||||
|
saldo: number;
|
||||||
|
}>(query, { fecha });
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
codigoCuenta: row.codigoCuenta?.trim() || '',
|
||||||
|
nombreCuenta: row.nombreCuenta?.trim() || '',
|
||||||
|
tipoCuenta: this.getTipoCuentaNombre(row.tipoCuenta),
|
||||||
|
saldo: row.saldo,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el saldo de una cuenta especifica
|
||||||
|
*/
|
||||||
|
async getSaldoCuenta(codigoCuenta: string, fecha: Date): Promise<number> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
ISNULL(SUM(ISNULL(m.CCARGO, 0) - ISNULL(m.CABONO, 0)), 0) as saldo
|
||||||
|
FROM MovPolizas m
|
||||||
|
INNER JOIN Polizas p ON m.CIDPOLIZA = p.CIDPOLIZA
|
||||||
|
WHERE m.CCODIGOCUENTA = @codigoCuenta
|
||||||
|
AND p.CFECHA <= @fecha
|
||||||
|
AND p.CAFECTADA = 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryOne<{ saldo: number }>(query, {
|
||||||
|
codigoCuenta,
|
||||||
|
fecha,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result?.saldo || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auxiliares de Cuentas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el auxiliar (movimientos) de una cuenta en un rango de fechas
|
||||||
|
*/
|
||||||
|
async getAuxiliarCuenta(
|
||||||
|
codigoCuenta: string,
|
||||||
|
fechaInicio: Date,
|
||||||
|
fechaFin: Date
|
||||||
|
): Promise<CONTPAQiMovimiento[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.CIDMOVPOLIZA as id,
|
||||||
|
m.CIDPOLIZA as polizaId,
|
||||||
|
m.CNUMEROPARTIDA as numMovimiento,
|
||||||
|
m.CCODIGOCUENTA as codigoCuenta,
|
||||||
|
p.CFECHA as fecha,
|
||||||
|
p.CTIPOPOLIZA as tipoPoliza,
|
||||||
|
p.CNUMPOLIZA as numPoliza,
|
||||||
|
m.CCONCEPTO as concepto,
|
||||||
|
m.CCARGO as cargo,
|
||||||
|
m.CABONO as abono,
|
||||||
|
m.CREFERENCIA as referencia,
|
||||||
|
m.CUUIDCFDI as uuidCFDI,
|
||||||
|
m.CRFCTERCERO as rfcTercero
|
||||||
|
FROM MovPolizas m
|
||||||
|
INNER JOIN Polizas p ON m.CIDPOLIZA = p.CIDPOLIZA
|
||||||
|
WHERE m.CCODIGOCUENTA = @codigoCuenta
|
||||||
|
AND p.CFECHA >= @fechaInicio
|
||||||
|
AND p.CFECHA <= @fechaFin
|
||||||
|
AND p.CAFECTADA = 1
|
||||||
|
ORDER BY p.CFECHA, p.CTIPOPOLIZA, p.CNUMPOLIZA, m.CNUMEROPARTIDA
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
polizaId: number;
|
||||||
|
numMovimiento: number;
|
||||||
|
codigoCuenta: string;
|
||||||
|
fecha: Date;
|
||||||
|
tipoPoliza: number;
|
||||||
|
numPoliza: number;
|
||||||
|
concepto: string;
|
||||||
|
cargo: number;
|
||||||
|
abono: number;
|
||||||
|
referencia: string | null;
|
||||||
|
uuidCFDI: string | null;
|
||||||
|
rfcTercero: string | null;
|
||||||
|
}>(query, { codigoCuenta, fechaInicio, fechaFin });
|
||||||
|
|
||||||
|
return result.map((row) => {
|
||||||
|
const cargo = row.cargo || 0;
|
||||||
|
const abono = row.abono || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
polizaId: row.polizaId,
|
||||||
|
numMovimiento: row.numMovimiento,
|
||||||
|
codigoCuenta: row.codigoCuenta?.trim() || '',
|
||||||
|
concepto: row.concepto?.trim() || '',
|
||||||
|
tipoMovimiento: cargo > 0 ? 'Cargo' : 'Abono' as TipoMovimiento,
|
||||||
|
importe: cargo > 0 ? cargo : abono,
|
||||||
|
referencia: row.referencia?.trim() || undefined,
|
||||||
|
uuidCFDI: row.uuidCFDI?.trim() || undefined,
|
||||||
|
rfcTercero: row.rfcTercero?.trim() || undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private getTipoCuentaNumero(tipo: TipoCuenta): number {
|
||||||
|
const tipos: Record<TipoCuenta, number> = {
|
||||||
|
Activo: 1,
|
||||||
|
Pasivo: 2,
|
||||||
|
Capital: 3,
|
||||||
|
Ingreso: 4,
|
||||||
|
Costo: 5,
|
||||||
|
Gasto: 6,
|
||||||
|
Orden: 7,
|
||||||
|
};
|
||||||
|
return tipos[tipo];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTipoCuentaNombre(tipo: number): TipoCuenta {
|
||||||
|
const tipos: Record<number, TipoCuenta> = {
|
||||||
|
1: 'Activo',
|
||||||
|
2: 'Pasivo',
|
||||||
|
3: 'Capital',
|
||||||
|
4: 'Ingreso',
|
||||||
|
5: 'Costo',
|
||||||
|
6: 'Gasto',
|
||||||
|
7: 'Orden',
|
||||||
|
};
|
||||||
|
return tipos[tipo] || 'Orden';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTipoPolizaNumero(tipo: TipoPoliza): number {
|
||||||
|
const tipos: Record<TipoPoliza, number> = {
|
||||||
|
Ingresos: 1,
|
||||||
|
Egresos: 2,
|
||||||
|
Diario: 3,
|
||||||
|
Orden: 4,
|
||||||
|
};
|
||||||
|
return tipos[tipo];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTipoPolizaNombre(tipo: number): TipoPoliza {
|
||||||
|
const tipos: Record<number, TipoPoliza> = {
|
||||||
|
1: 'Ingresos',
|
||||||
|
2: 'Egresos',
|
||||||
|
3: 'Diario',
|
||||||
|
4: 'Orden',
|
||||||
|
};
|
||||||
|
return tipos[tipo] || 'Diario';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del conector de Contabilidad
|
||||||
|
*/
|
||||||
|
export function createContabilidadConnector(client: CONTPAQiClient): ContabilidadConnector {
|
||||||
|
return new ContabilidadConnector(client);
|
||||||
|
}
|
||||||
652
apps/api/src/services/integrations/contpaqi/contpaqi.client.ts
Normal file
652
apps/api/src/services/integrations/contpaqi/contpaqi.client.ts
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
/**
|
||||||
|
* CONTPAQi SQL Server Client
|
||||||
|
* Cliente de conexion a bases de datos SQL Server de CONTPAQi
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as sql from 'mssql';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import {
|
||||||
|
CONTPAQiConfig,
|
||||||
|
CONTPAQiConnection,
|
||||||
|
CONTPAQiEmpresa,
|
||||||
|
CONTPAQiProducto,
|
||||||
|
CONTPAQiConnectionError,
|
||||||
|
CONTPAQiQueryError,
|
||||||
|
} from './contpaqi.types.js';
|
||||||
|
import { validateConfig } from './contpaqi.schema.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pool Manager - Gestiona multiples conexiones a diferentes empresas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pool de conexiones registrado
|
||||||
|
*/
|
||||||
|
interface RegisteredPool {
|
||||||
|
pool: sql.ConnectionPool;
|
||||||
|
config: CONTPAQiConfig;
|
||||||
|
empresa?: CONTPAQiEmpresa;
|
||||||
|
createdAt: Date;
|
||||||
|
lastUsed: Date;
|
||||||
|
connectionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager de pools de conexion para CONTPAQi
|
||||||
|
* Mantiene un pool por cada base de datos de empresa
|
||||||
|
*/
|
||||||
|
class CONTPAQiPoolManager {
|
||||||
|
private pools: Map<string, RegisteredPool> = new Map();
|
||||||
|
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||||
|
private readonly maxIdleTime = 30 * 60 * 1000; // 30 minutos
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Limpieza automatica de pools inactivos
|
||||||
|
this.startCleanupInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera una clave unica para identificar un pool
|
||||||
|
*/
|
||||||
|
private getPoolKey(config: CONTPAQiConfig, database?: string): string {
|
||||||
|
const db = database || config.database;
|
||||||
|
return `${config.host}:${config.port}:${db}:${config.user}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene o crea un pool de conexiones
|
||||||
|
*/
|
||||||
|
async getPool(config: CONTPAQiConfig, database?: string): Promise<sql.ConnectionPool> {
|
||||||
|
const key = this.getPoolKey(config, database);
|
||||||
|
|
||||||
|
// Verificar si ya existe el pool
|
||||||
|
if (this.pools.has(key)) {
|
||||||
|
const registered = this.pools.get(key)!;
|
||||||
|
registered.lastUsed = new Date();
|
||||||
|
|
||||||
|
// Verificar si el pool esta conectado
|
||||||
|
if (registered.pool.connected) {
|
||||||
|
return registered.pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no esta conectado, intentar reconectar
|
||||||
|
try {
|
||||||
|
await registered.pool.connect();
|
||||||
|
return registered.pool;
|
||||||
|
} catch (error) {
|
||||||
|
// Si falla reconexion, eliminar y crear nuevo
|
||||||
|
this.pools.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear nuevo pool
|
||||||
|
const pool = await this.createPool(config, database);
|
||||||
|
const connectionId = randomUUID();
|
||||||
|
|
||||||
|
this.pools.set(key, {
|
||||||
|
pool,
|
||||||
|
config,
|
||||||
|
createdAt: new Date(),
|
||||||
|
lastUsed: new Date(),
|
||||||
|
connectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un nuevo pool de conexiones
|
||||||
|
*/
|
||||||
|
private async createPool(config: CONTPAQiConfig, database?: string): Promise<sql.ConnectionPool> {
|
||||||
|
const validatedConfig = validateConfig(config);
|
||||||
|
|
||||||
|
const sqlConfig: sql.config = {
|
||||||
|
server: validatedConfig.host,
|
||||||
|
port: validatedConfig.port,
|
||||||
|
user: validatedConfig.user,
|
||||||
|
password: validatedConfig.password,
|
||||||
|
database: database || validatedConfig.database,
|
||||||
|
options: {
|
||||||
|
encrypt: validatedConfig.encrypt,
|
||||||
|
trustServerCertificate: validatedConfig.trustServerCertificate,
|
||||||
|
enableArithAbort: true,
|
||||||
|
// Importante para CONTPAQi que usa latin1_general_ci_as
|
||||||
|
useUTC: false,
|
||||||
|
},
|
||||||
|
connectionTimeout: validatedConfig.connectionTimeout,
|
||||||
|
requestTimeout: validatedConfig.requestTimeout,
|
||||||
|
pool: {
|
||||||
|
min: validatedConfig.poolMin,
|
||||||
|
max: validatedConfig.poolMax,
|
||||||
|
idleTimeoutMillis: validatedConfig.poolIdleTimeout,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pool = new sql.ConnectionPool(sqlConfig);
|
||||||
|
|
||||||
|
// Manejar errores del pool
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error(`[CONTPAQi] Error en pool ${database}:`, err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
await pool.connect();
|
||||||
|
return pool;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Error de conexion';
|
||||||
|
throw new CONTPAQiConnectionError(
|
||||||
|
`No se pudo conectar a la base de datos ${database || config.database}`,
|
||||||
|
'CONNECTION_FAILED',
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cierra un pool especifico
|
||||||
|
*/
|
||||||
|
async closePool(config: CONTPAQiConfig, database?: string): Promise<void> {
|
||||||
|
const key = this.getPoolKey(config, database);
|
||||||
|
|
||||||
|
if (this.pools.has(key)) {
|
||||||
|
const registered = this.pools.get(key)!;
|
||||||
|
try {
|
||||||
|
await registered.pool.close();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[CONTPAQi] Error cerrando pool:`, error);
|
||||||
|
}
|
||||||
|
this.pools.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cierra todos los pools
|
||||||
|
*/
|
||||||
|
async closeAll(): Promise<void> {
|
||||||
|
const closePromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (const [key, registered] of this.pools) {
|
||||||
|
closePromises.push(
|
||||||
|
registered.pool.close().catch((error) => {
|
||||||
|
console.error(`[CONTPAQi] Error cerrando pool ${key}:`, error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(closePromises);
|
||||||
|
this.pools.clear();
|
||||||
|
this.stopCleanupInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicia el intervalo de limpieza de pools inactivos
|
||||||
|
*/
|
||||||
|
private startCleanupInterval(): void {
|
||||||
|
if (this.cleanupInterval) return;
|
||||||
|
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
this.cleanupIdlePools();
|
||||||
|
}, 5 * 60 * 1000); // Cada 5 minutos
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detiene el intervalo de limpieza
|
||||||
|
*/
|
||||||
|
private stopCleanupInterval(): void {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia pools que han estado inactivos demasiado tiempo
|
||||||
|
*/
|
||||||
|
private async cleanupIdlePools(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [key, registered] of this.pools) {
|
||||||
|
const idleTime = now - registered.lastUsed.getTime();
|
||||||
|
|
||||||
|
if (idleTime > this.maxIdleTime) {
|
||||||
|
try {
|
||||||
|
await registered.pool.close();
|
||||||
|
this.pools.delete(key);
|
||||||
|
console.log(`[CONTPAQi] Pool ${key} cerrado por inactividad`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[CONTPAQi] Error limpiando pool ${key}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadisticas de los pools
|
||||||
|
*/
|
||||||
|
getStats(): {
|
||||||
|
totalPools: number;
|
||||||
|
pools: Array<{
|
||||||
|
key: string;
|
||||||
|
database: string;
|
||||||
|
connected: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
lastUsed: Date;
|
||||||
|
}>;
|
||||||
|
} {
|
||||||
|
const pools = Array.from(this.pools.entries()).map(([key, registered]) => ({
|
||||||
|
key,
|
||||||
|
database: registered.config.database,
|
||||||
|
connected: registered.pool.connected,
|
||||||
|
createdAt: registered.createdAt,
|
||||||
|
lastUsed: registered.lastUsed,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalPools: this.pools.size,
|
||||||
|
pools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instancia global del pool manager
|
||||||
|
const poolManager = new CONTPAQiPoolManager();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cliente CONTPAQi
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente para interactuar con bases de datos de CONTPAQi
|
||||||
|
*/
|
||||||
|
export class CONTPAQiClient {
|
||||||
|
private config: CONTPAQiConfig;
|
||||||
|
private currentDatabase: string;
|
||||||
|
private connectionId: string;
|
||||||
|
|
||||||
|
constructor(config: CONTPAQiConfig) {
|
||||||
|
this.config = validateConfig(config);
|
||||||
|
this.currentDatabase = config.database;
|
||||||
|
this.connectionId = randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el pool de conexiones actual
|
||||||
|
*/
|
||||||
|
private async getPool(): Promise<sql.ConnectionPool> {
|
||||||
|
return poolManager.getPool(this.config, this.currentDatabase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cambia a una base de datos diferente (para multi-empresa)
|
||||||
|
*/
|
||||||
|
async useDatabase(database: string): Promise<void> {
|
||||||
|
this.currentDatabase = database;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una consulta parametrizada
|
||||||
|
*/
|
||||||
|
async query<T = Record<string, unknown>>(
|
||||||
|
queryText: string,
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
): Promise<sql.IResult<T>> {
|
||||||
|
const pool = await this.getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = pool.request();
|
||||||
|
|
||||||
|
// Agregar parametros de forma segura
|
||||||
|
if (params) {
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
request.input(key, this.getSqlType(value), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await request.query<T>(queryText);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Error de consulta';
|
||||||
|
throw new CONTPAQiQueryError(
|
||||||
|
`Error ejecutando consulta: ${message}`,
|
||||||
|
queryText,
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una consulta y retorna el primer resultado
|
||||||
|
*/
|
||||||
|
async queryOne<T = Record<string, unknown>>(
|
||||||
|
queryText: string,
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
): Promise<T | null> {
|
||||||
|
const result = await this.query<T>(queryText, params);
|
||||||
|
return result.recordset[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una consulta y retorna todos los resultados
|
||||||
|
*/
|
||||||
|
async queryMany<T = Record<string, unknown>>(
|
||||||
|
queryText: string,
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
): Promise<T[]> {
|
||||||
|
const result = await this.query<T>(queryText, params);
|
||||||
|
return result.recordset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta un stored procedure
|
||||||
|
*/
|
||||||
|
async execute<T = Record<string, unknown>>(
|
||||||
|
procedureName: string,
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
): Promise<sql.IProcedureResult<T>> {
|
||||||
|
const pool = await this.getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = pool.request();
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
request.input(key, this.getSqlType(value), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await request.execute<T>(procedureName);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Error de ejecucion';
|
||||||
|
throw new CONTPAQiQueryError(
|
||||||
|
`Error ejecutando stored procedure ${procedureName}: ${message}`,
|
||||||
|
procedureName,
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta multiples consultas en una transaccion
|
||||||
|
*/
|
||||||
|
async transaction<T>(
|
||||||
|
callback: (transaction: sql.Transaction) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const pool = await this.getPool();
|
||||||
|
const transaction = new sql.Transaction(pool);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transaction.begin();
|
||||||
|
const result = await callback(transaction);
|
||||||
|
await transaction.commit();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica la conexion a la base de datos
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await this.queryOne<{ test: number }>('SELECT 1 as test');
|
||||||
|
return result?.test === 1;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene informacion de la conexion
|
||||||
|
*/
|
||||||
|
getConnectionInfo(): CONTPAQiConnection {
|
||||||
|
return {
|
||||||
|
id: this.connectionId,
|
||||||
|
config: this.config,
|
||||||
|
producto: 'Contabilidad', // Se actualiza segun el producto
|
||||||
|
empresa: {
|
||||||
|
id: 0,
|
||||||
|
codigo: '',
|
||||||
|
nombre: '',
|
||||||
|
rfc: '',
|
||||||
|
baseDatos: this.currentDatabase,
|
||||||
|
activa: true,
|
||||||
|
producto: 'Contabilidad',
|
||||||
|
},
|
||||||
|
connectedAt: new Date(),
|
||||||
|
status: 'connected',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las empresas disponibles en CONTPAQi
|
||||||
|
*/
|
||||||
|
async getEmpresas(producto: CONTPAQiProducto): Promise<CONTPAQiEmpresa[]> {
|
||||||
|
// La estructura de tablas varia segun el producto
|
||||||
|
let query: string;
|
||||||
|
|
||||||
|
switch (producto) {
|
||||||
|
case 'Contabilidad':
|
||||||
|
// Tabla de empresas en CONTPAQi Contabilidad
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
CIDEMPRESA as id,
|
||||||
|
CCODIGOEMPRESA as codigo,
|
||||||
|
CNOMBREEMPRESA as nombre,
|
||||||
|
CRFC as rfc,
|
||||||
|
CRUTADATOS as baseDatos,
|
||||||
|
CRUTAEMPRESA as ruta,
|
||||||
|
CIDEJERCICIO as ejercicioActual,
|
||||||
|
CIDPERIODO as periodoActual,
|
||||||
|
CASE WHEN CESTATUS = 1 THEN 1 ELSE 0 END as activa
|
||||||
|
FROM Empresas
|
||||||
|
WHERE CESTATUS = 1
|
||||||
|
ORDER BY CNOMBREEMPRESA
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Comercial':
|
||||||
|
// Tabla de empresas en CONTPAQi Comercial
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
CIDEMPRESA as id,
|
||||||
|
CCODIGOEMPRESA as codigo,
|
||||||
|
CNOMBREEMPRESA as nombre,
|
||||||
|
CRFC as rfc,
|
||||||
|
CRUTADATOS as baseDatos,
|
||||||
|
CRUTAEMPRESA as ruta,
|
||||||
|
1 as ejercicioActual,
|
||||||
|
1 as periodoActual,
|
||||||
|
CASE WHEN CESTATUS = 1 THEN 1 ELSE 0 END as activa
|
||||||
|
FROM Empresas
|
||||||
|
WHERE CESTATUS = 1
|
||||||
|
ORDER BY CNOMBREEMPRESA
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Nominas':
|
||||||
|
// Tabla de empresas en CONTPAQi Nominas
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
CIDEMPRESA as id,
|
||||||
|
CCODIGOEMPRESA as codigo,
|
||||||
|
CNOMBREEMPRESA as nombre,
|
||||||
|
CRFC as rfc,
|
||||||
|
CRUTADATOS as baseDatos,
|
||||||
|
CRUTAEMPRESA as ruta,
|
||||||
|
CEJERCICIO as ejercicioActual,
|
||||||
|
CPERIODO as periodoActual,
|
||||||
|
CASE WHEN CESTATUS = 1 THEN 1 ELSE 0 END as activa
|
||||||
|
FROM Empresas
|
||||||
|
WHERE CESTATUS = 1
|
||||||
|
ORDER BY CNOMBREEMPRESA
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.queryMany<{
|
||||||
|
id: number;
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
rfc: string;
|
||||||
|
baseDatos: string;
|
||||||
|
ruta?: string;
|
||||||
|
ejercicioActual?: number;
|
||||||
|
periodoActual?: number;
|
||||||
|
activa: number;
|
||||||
|
}>(query);
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
codigo: row.codigo?.trim() || '',
|
||||||
|
nombre: row.nombre?.trim() || '',
|
||||||
|
rfc: row.rfc?.trim() || '',
|
||||||
|
baseDatos: row.baseDatos?.trim() || '',
|
||||||
|
ruta: row.ruta?.trim(),
|
||||||
|
ejercicioActual: row.ejercicioActual,
|
||||||
|
periodoActual: row.periodoActual,
|
||||||
|
activa: row.activa === 1,
|
||||||
|
producto,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
// Si falla, puede ser que la tabla no exista o tenga otro nombre
|
||||||
|
console.warn(`[CONTPAQi] Error obteniendo empresas de ${producto}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conecta a la base de datos de una empresa especifica
|
||||||
|
*/
|
||||||
|
async connectToEmpresa(empresa: CONTPAQiEmpresa): Promise<void> {
|
||||||
|
await this.useDatabase(empresa.baseDatos);
|
||||||
|
|
||||||
|
// Verificar conexion
|
||||||
|
const connected = await this.testConnection();
|
||||||
|
if (!connected) {
|
||||||
|
throw new CONTPAQiConnectionError(
|
||||||
|
`No se pudo conectar a la empresa ${empresa.nombre}`,
|
||||||
|
'EMPRESA_CONNECTION_FAILED',
|
||||||
|
`Base de datos: ${empresa.baseDatos}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cierra la conexion actual
|
||||||
|
*/
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await poolManager.closePool(this.config, this.currentDatabase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determina el tipo SQL de un valor JavaScript
|
||||||
|
*/
|
||||||
|
private getSqlType(value: unknown): sql.ISqlType {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return sql.NVarChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (Number.isInteger(value)) {
|
||||||
|
return sql.Int;
|
||||||
|
}
|
||||||
|
return sql.Decimal(18, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return sql.Bit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return sql.DateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (value.length > 4000) {
|
||||||
|
return sql.NVarChar(sql.MAX);
|
||||||
|
}
|
||||||
|
return sql.NVarChar(4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(value)) {
|
||||||
|
return sql.VarBinary(sql.MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql.NVarChar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una nueva instancia del cliente CONTPAQi
|
||||||
|
*/
|
||||||
|
export function createCONTPAQiClient(config: CONTPAQiConfig): CONTPAQiClient {
|
||||||
|
return new CONTPAQiClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el pool manager global
|
||||||
|
*/
|
||||||
|
export function getCONTPAQiPoolManager(): CONTPAQiPoolManager {
|
||||||
|
return poolManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cierra todos los pools de conexion (para cleanup)
|
||||||
|
*/
|
||||||
|
export async function closeCONTPAQiConnections(): Promise<void> {
|
||||||
|
await poolManager.closeAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica la conexion a un servidor CONTPAQi
|
||||||
|
*/
|
||||||
|
export async function testCONTPAQiConnection(config: CONTPAQiConfig): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
empresas?: CONTPAQiEmpresa[];
|
||||||
|
}> {
|
||||||
|
const client = createCONTPAQiClient(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connected = await client.testConnection();
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'No se pudo establecer conexion con la base de datos',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentar obtener empresas de cada producto
|
||||||
|
const empresas: CONTPAQiEmpresa[] = [];
|
||||||
|
|
||||||
|
for (const producto of ['Contabilidad', 'Comercial', 'Nominas'] as CONTPAQiProducto[]) {
|
||||||
|
try {
|
||||||
|
const productEmpresas = await client.getEmpresas(producto);
|
||||||
|
empresas.push(...productEmpresas);
|
||||||
|
} catch {
|
||||||
|
// Ignorar si no se pueden obtener empresas de un producto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Conexion exitosa. ${empresas.length} empresa(s) encontrada(s).`,
|
||||||
|
empresas,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Error desconocido';
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Error de conexion: ${message}`,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
331
apps/api/src/services/integrations/contpaqi/contpaqi.schema.ts
Normal file
331
apps/api/src/services/integrations/contpaqi/contpaqi.schema.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* CONTPAQi Validation Schemas (Zod)
|
||||||
|
* Schemas de validacion para la configuracion de conexion a CONTPAQi
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Schemas de Configuracion
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para la configuracion de conexion a SQL Server
|
||||||
|
*/
|
||||||
|
export const CONTPAQiConfigSchema = z.object({
|
||||||
|
host: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El host es requerido')
|
||||||
|
.describe('Host del servidor SQL Server'),
|
||||||
|
port: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(65535)
|
||||||
|
.default(1433)
|
||||||
|
.describe('Puerto de SQL Server'),
|
||||||
|
user: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El usuario es requerido')
|
||||||
|
.describe('Usuario de base de datos'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La contrasena es requerida')
|
||||||
|
.describe('Contrasena de base de datos'),
|
||||||
|
database: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El nombre de la base de datos es requerido')
|
||||||
|
.describe('Nombre de la base de datos de empresas'),
|
||||||
|
encrypt: z
|
||||||
|
.boolean()
|
||||||
|
.default(false)
|
||||||
|
.describe('Usar conexion encriptada'),
|
||||||
|
trustServerCertificate: z
|
||||||
|
.boolean()
|
||||||
|
.default(true)
|
||||||
|
.describe('Confiar en certificado del servidor'),
|
||||||
|
connectionTimeout: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1000)
|
||||||
|
.max(60000)
|
||||||
|
.default(15000)
|
||||||
|
.describe('Timeout de conexion en ms'),
|
||||||
|
requestTimeout: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1000)
|
||||||
|
.max(300000)
|
||||||
|
.default(30000)
|
||||||
|
.describe('Timeout de requests en ms'),
|
||||||
|
poolMin: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(100)
|
||||||
|
.default(0)
|
||||||
|
.describe('Pool minimo de conexiones'),
|
||||||
|
poolMax: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.default(10)
|
||||||
|
.describe('Pool maximo de conexiones'),
|
||||||
|
poolIdleTimeout: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1000)
|
||||||
|
.max(3600000)
|
||||||
|
.default(30000)
|
||||||
|
.describe('Timeout de inactividad del pool en ms'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para validar una empresa de CONTPAQi
|
||||||
|
*/
|
||||||
|
export const CONTPAQiEmpresaSchema = z.object({
|
||||||
|
id: z.number().int().positive(),
|
||||||
|
codigo: z.string().min(1),
|
||||||
|
nombre: z.string().min(1),
|
||||||
|
rfc: z.string().regex(
|
||||||
|
/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/,
|
||||||
|
'RFC invalido'
|
||||||
|
),
|
||||||
|
baseDatos: z.string().min(1),
|
||||||
|
ruta: z.string().optional(),
|
||||||
|
ejercicioActual: z.number().int().positive().optional(),
|
||||||
|
periodoActual: z.number().int().min(1).max(13).optional(),
|
||||||
|
activa: z.boolean().default(true),
|
||||||
|
producto: z.enum(['Contabilidad', 'Comercial', 'Nominas']),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para la configuracion de sincronizacion
|
||||||
|
*/
|
||||||
|
export const CONTPAQiSyncConfigSchema = z.object({
|
||||||
|
tenantId: z
|
||||||
|
.string()
|
||||||
|
.uuid('El tenantId debe ser un UUID valido'),
|
||||||
|
connectionConfig: CONTPAQiConfigSchema,
|
||||||
|
productos: z
|
||||||
|
.array(z.enum(['Contabilidad', 'Comercial', 'Nominas']))
|
||||||
|
.min(1, 'Debe seleccionar al menos un producto'),
|
||||||
|
empresas: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.describe('Codigos de empresas a sincronizar (vacio = todas)'),
|
||||||
|
fechaDesde: z
|
||||||
|
.date()
|
||||||
|
.or(z.string().datetime())
|
||||||
|
.optional()
|
||||||
|
.transform((val) => (val ? new Date(val) : undefined)),
|
||||||
|
fechaHasta: z
|
||||||
|
.date()
|
||||||
|
.or(z.string().datetime())
|
||||||
|
.optional()
|
||||||
|
.transform((val) => (val ? new Date(val) : undefined)),
|
||||||
|
incremental: z
|
||||||
|
.boolean()
|
||||||
|
.default(true)
|
||||||
|
.describe('Solo sincronizar cambios desde ultima sincronizacion'),
|
||||||
|
lastSyncTimestamp: z
|
||||||
|
.date()
|
||||||
|
.or(z.string().datetime())
|
||||||
|
.optional()
|
||||||
|
.transform((val) => (val ? new Date(val) : undefined)),
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.fechaDesde && data.fechaHasta) {
|
||||||
|
return data.fechaDesde <= data.fechaHasta;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'La fecha desde debe ser menor o igual a la fecha hasta',
|
||||||
|
path: ['fechaDesde'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Schemas de Consultas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para consulta de periodo
|
||||||
|
*/
|
||||||
|
export const PeriodoQuerySchema = z.object({
|
||||||
|
ejercicio: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(2000)
|
||||||
|
.max(2100)
|
||||||
|
.describe('Ano del ejercicio fiscal'),
|
||||||
|
periodo: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(13)
|
||||||
|
.describe('Mes del periodo (1-12, 13 para cierre)'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para consulta de rango de fechas
|
||||||
|
*/
|
||||||
|
export const DateRangeQuerySchema = z.object({
|
||||||
|
fechaInicio: z
|
||||||
|
.date()
|
||||||
|
.or(z.string().datetime())
|
||||||
|
.transform((val) => new Date(val)),
|
||||||
|
fechaFin: z
|
||||||
|
.date()
|
||||||
|
.or(z.string().datetime())
|
||||||
|
.transform((val) => new Date(val)),
|
||||||
|
}).refine(
|
||||||
|
(data) => data.fechaInicio <= data.fechaFin,
|
||||||
|
{
|
||||||
|
message: 'La fecha de inicio debe ser menor o igual a la fecha de fin',
|
||||||
|
path: ['fechaInicio'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para paginacion
|
||||||
|
*/
|
||||||
|
export const PaginationSchema = z.object({
|
||||||
|
page: z.number().int().min(1).default(1),
|
||||||
|
limit: z.number().int().min(1).max(1000).default(100),
|
||||||
|
orderBy: z.string().optional(),
|
||||||
|
orderDir: z.enum(['ASC', 'DESC']).default('ASC'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para filtro de documentos comerciales
|
||||||
|
*/
|
||||||
|
export const DocumentoFilterSchema = z.object({
|
||||||
|
concepto: z.enum([
|
||||||
|
'FacturaCliente',
|
||||||
|
'FacturaProveedor',
|
||||||
|
'NotaCreditoCliente',
|
||||||
|
'NotaCreditoProveedor',
|
||||||
|
'NotaCargoCliente',
|
||||||
|
'NotaCargoProveedor',
|
||||||
|
'Remision',
|
||||||
|
'Pedido',
|
||||||
|
'Cotizacion',
|
||||||
|
'OrdenCompra',
|
||||||
|
'DevolucionCliente',
|
||||||
|
'DevolucionProveedor',
|
||||||
|
]).optional(),
|
||||||
|
fechaInicio: z.date().or(z.string().datetime()).optional(),
|
||||||
|
fechaFin: z.date().or(z.string().datetime()).optional(),
|
||||||
|
clienteId: z.number().int().positive().optional(),
|
||||||
|
proveedorId: z.number().int().positive().optional(),
|
||||||
|
uuid: z.string().uuid().optional(),
|
||||||
|
estado: z.enum(['Pendiente', 'Parcial', 'Pagado', 'Cancelado']).optional(),
|
||||||
|
incluyeCancelados: z.boolean().default(false),
|
||||||
|
}).merge(PaginationSchema);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para filtro de empleados
|
||||||
|
*/
|
||||||
|
export const EmpleadoFilterSchema = z.object({
|
||||||
|
estado: z.number().int().optional(),
|
||||||
|
departamento: z.string().optional(),
|
||||||
|
puesto: z.string().optional(),
|
||||||
|
tipoContrato: z.enum([
|
||||||
|
'PorTiempoIndeterminado',
|
||||||
|
'PorObraoDeterminado',
|
||||||
|
'PorTemporada',
|
||||||
|
'SujetoPrueba',
|
||||||
|
'CapacitacionInicial',
|
||||||
|
'PorTiempoIndeterminadoDesconexion',
|
||||||
|
]).optional(),
|
||||||
|
incluyeBajas: z.boolean().default(false),
|
||||||
|
}).merge(PaginationSchema);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para filtro de nominas
|
||||||
|
*/
|
||||||
|
export const NominaFilterSchema = z.object({
|
||||||
|
ejercicio: z.number().int().min(2000).max(2100),
|
||||||
|
periodoInicio: z.number().int().min(1).max(53).optional(),
|
||||||
|
periodoFin: z.number().int().min(1).max(53).optional(),
|
||||||
|
tipoNomina: z.enum(['Ordinaria', 'Extraordinaria']).optional(),
|
||||||
|
empleadoId: z.number().int().positive().optional(),
|
||||||
|
}).merge(PaginationSchema);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para filtro de polizas
|
||||||
|
*/
|
||||||
|
export const PolizaFilterSchema = z.object({
|
||||||
|
ejercicio: z.number().int().min(2000).max(2100),
|
||||||
|
periodoInicio: z.number().int().min(1).max(13).optional(),
|
||||||
|
periodoFin: z.number().int().min(1).max(13).optional(),
|
||||||
|
tipoPoliza: z.enum(['Ingresos', 'Egresos', 'Diario', 'Orden']).optional(),
|
||||||
|
fechaInicio: z.date().or(z.string().datetime()).optional(),
|
||||||
|
fechaFin: z.date().or(z.string().datetime()).optional(),
|
||||||
|
soloAfectadas: z.boolean().default(true),
|
||||||
|
}).merge(PaginationSchema);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tipos Inferidos
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type CONTPAQiConfigInput = z.infer<typeof CONTPAQiConfigSchema>;
|
||||||
|
export type CONTPAQiEmpresaInput = z.infer<typeof CONTPAQiEmpresaSchema>;
|
||||||
|
export type CONTPAQiSyncConfigInput = z.infer<typeof CONTPAQiSyncConfigSchema>;
|
||||||
|
export type PeriodoQuery = z.infer<typeof PeriodoQuerySchema>;
|
||||||
|
export type DateRangeQuery = z.infer<typeof DateRangeQuerySchema>;
|
||||||
|
export type PaginationInput = z.infer<typeof PaginationSchema>;
|
||||||
|
export type DocumentoFilter = z.infer<typeof DocumentoFilterSchema>;
|
||||||
|
export type EmpleadoFilter = z.infer<typeof EmpleadoFilterSchema>;
|
||||||
|
export type NominaFilter = z.infer<typeof NominaFilterSchema>;
|
||||||
|
export type PolizaFilter = z.infer<typeof PolizaFilterSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers de Validacion
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida la configuracion de conexion
|
||||||
|
*/
|
||||||
|
export function validateConfig(config: unknown): CONTPAQiConfigInput {
|
||||||
|
return CONTPAQiConfigSchema.parse(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida la configuracion de sincronizacion
|
||||||
|
*/
|
||||||
|
export function validateSyncConfig(config: unknown): CONTPAQiSyncConfigInput {
|
||||||
|
return CONTPAQiSyncConfigSchema.parse(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida un rango de fechas
|
||||||
|
*/
|
||||||
|
export function validateDateRange(range: unknown): DateRangeQuery {
|
||||||
|
return DateRangeQuerySchema.parse(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida un periodo contable
|
||||||
|
*/
|
||||||
|
export function validatePeriodo(periodo: unknown): PeriodoQuery {
|
||||||
|
return PeriodoQuerySchema.parse(periodo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida un RFC mexicano
|
||||||
|
*/
|
||||||
|
export function isValidRFC(rfc: string): boolean {
|
||||||
|
const rfcRegex = /^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/;
|
||||||
|
return rfcRegex.test(rfc.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea un RFC a mayusculas
|
||||||
|
*/
|
||||||
|
export function formatRFC(rfc: string): string {
|
||||||
|
return rfc.toUpperCase().trim();
|
||||||
|
}
|
||||||
1082
apps/api/src/services/integrations/contpaqi/contpaqi.sync.ts
Normal file
1082
apps/api/src/services/integrations/contpaqi/contpaqi.sync.ts
Normal file
File diff suppressed because it is too large
Load Diff
1097
apps/api/src/services/integrations/contpaqi/contpaqi.types.ts
Normal file
1097
apps/api/src/services/integrations/contpaqi/contpaqi.types.ts
Normal file
File diff suppressed because it is too large
Load Diff
263
apps/api/src/services/integrations/contpaqi/index.ts
Normal file
263
apps/api/src/services/integrations/contpaqi/index.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* CONTPAQi Integration Module
|
||||||
|
*
|
||||||
|
* Conector completo para integracion con los sistemas CONTPAQi:
|
||||||
|
* - CONTPAQi Contabilidad
|
||||||
|
* - CONTPAQi Comercial (ventas/compras)
|
||||||
|
* - CONTPAQi Nominas
|
||||||
|
*
|
||||||
|
* ESTRUCTURA DE TABLAS CONSULTADAS:
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* CONTPAQi CONTABILIDAD
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Empresas
|
||||||
|
* - CIDEMPRESA: ID interno
|
||||||
|
* - CCODIGOEMPRESA: Codigo de empresa
|
||||||
|
* - CNOMBREEMPRESA: Nombre
|
||||||
|
* - CRFC: RFC
|
||||||
|
* - CRUTADATOS: Base de datos de la empresa
|
||||||
|
*
|
||||||
|
* Cuentas (Catalogo de cuentas contables)
|
||||||
|
* - CIDCUENTA: ID de cuenta
|
||||||
|
* - CCODIGOCUENTA: Codigo (ej: 1101-001)
|
||||||
|
* - CNOMBRECUENTA: Nombre
|
||||||
|
* - CTIPOCUENTA: 1=Activo, 2=Pasivo, 3=Capital, 4=Ingreso, 5=Costo, 6=Gasto, 7=Orden
|
||||||
|
* - CNATURALEZA: 1=Deudora, 2=Acreedora
|
||||||
|
* - CNIVEL: Nivel jerarquico
|
||||||
|
* - CCUENTAPADRE: Cuenta padre
|
||||||
|
* - CESDECATALOGO: 1=Cuenta de detalle
|
||||||
|
* - CCODIGOAGRUPADOR: Codigo agrupador SAT
|
||||||
|
*
|
||||||
|
* Polizas (Encabezado)
|
||||||
|
* - CIDPOLIZA: ID poliza
|
||||||
|
* - CTIPOPOLIZA: 1=Ingresos, 2=Egresos, 3=Diario, 4=Orden
|
||||||
|
* - CNUMPOLIZA: Numero
|
||||||
|
* - CFECHA: Fecha
|
||||||
|
* - CCONCEPTO: Descripcion
|
||||||
|
* - CIDEJERCICIO: Ano fiscal
|
||||||
|
* - CIDPERIODO: Mes (1-13)
|
||||||
|
* - CAFECTADA: Si esta aplicada a saldos
|
||||||
|
* - CUUIDCFDI: UUID del CFDI relacionado
|
||||||
|
*
|
||||||
|
* MovPolizas (Detalle de polizas)
|
||||||
|
* - CIDMOVPOLIZA: ID movimiento
|
||||||
|
* - CIDPOLIZA: Poliza padre
|
||||||
|
* - CNUMEROPARTIDA: Numero de linea
|
||||||
|
* - CCODIGOCUENTA: Cuenta contable
|
||||||
|
* - CCONCEPTO: Descripcion
|
||||||
|
* - CCARGO: Importe cargo
|
||||||
|
* - CABONO: Importe abono
|
||||||
|
* - CREFERENCIA: Referencia (num cheque, factura, etc)
|
||||||
|
* - CUUIDCFDI: UUID CFDI
|
||||||
|
* - CRFCTERCERO: RFC del tercero
|
||||||
|
*
|
||||||
|
* SaldosCuentas
|
||||||
|
* - CIDCUENTA: Cuenta
|
||||||
|
* - CIDEJERCICIO: Ejercicio
|
||||||
|
* - CIDPERIODO: Periodo
|
||||||
|
* - CSALDOINICIAL: Saldo inicial
|
||||||
|
* - CCARGOS: Total cargos
|
||||||
|
* - CABONOS: Total abonos
|
||||||
|
* - CSALDOFINAL: Saldo final
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* CONTPAQi COMERCIAL
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* admClientes (Clientes y Proveedores - tabla unificada)
|
||||||
|
* - CIDCLIENTEPROVEEDOR: ID
|
||||||
|
* - CCODIGOCLIENTE: Codigo
|
||||||
|
* - CRAZONSOCIAL: Razon social
|
||||||
|
* - CRFC: RFC
|
||||||
|
* - CTIPOCLIENTE: 1=Cliente, 2=ClienteProveedor, 3=Proveedor
|
||||||
|
* - CREGIMENFISCAL: Regimen fiscal
|
||||||
|
* - CUSOCFDI: Uso CFDI predeterminado
|
||||||
|
* - CLIMITECREDITO: Limite de credito
|
||||||
|
* - CDIASCREDITO: Dias de credito
|
||||||
|
* - CSALDOACTUAL: Saldo actual
|
||||||
|
*
|
||||||
|
* admDomicilios (Direcciones)
|
||||||
|
* - CIDDOMICILIO: ID
|
||||||
|
* - CIDCLIENTEPROVEEDOR: Cliente/Proveedor
|
||||||
|
* - CTIPO: Tipo de direccion
|
||||||
|
* - CCALLE, CNUMEROEXTERIOR, CCOLONIA, CCODIGOPOSTAL, etc.
|
||||||
|
*
|
||||||
|
* admProductos (Catalogo de productos)
|
||||||
|
* - CIDPRODUCTO: ID
|
||||||
|
* - CCODIGOPRODUCTO: Codigo
|
||||||
|
* - CNOMBREPRODUCTO: Nombre
|
||||||
|
* - CTIPOPRODUCTO: 1=Producto, 2=Paquete, 3=Servicio
|
||||||
|
* - CUNIDADMEDIDA: Unidad
|
||||||
|
* - CCLAVESAT: Clave SAT
|
||||||
|
* - CPRECIOBASE: Precio base
|
||||||
|
* - CULTIMOCOSTO: Ultimo costo
|
||||||
|
* - CCOSTOPROMEDIO: Costo promedio
|
||||||
|
* - CTASAIVA: Tasa de IVA
|
||||||
|
*
|
||||||
|
* admDocumentos (Facturas, Notas, Remisiones, etc.)
|
||||||
|
* - CIDDOCUMENTO: ID
|
||||||
|
* - CIDCONCEPTODOCUMENTO: Tipo de documento
|
||||||
|
* - CSERIEFASCICULO: Serie
|
||||||
|
* - CFOLIO: Folio
|
||||||
|
* - CFECHA: Fecha
|
||||||
|
* - CIDCLIENTEPROVEEDOR: Cliente/Proveedor
|
||||||
|
* - CSUBTOTAL, CDESCUENTO, CIVA, CTOTAL: Totales
|
||||||
|
* - CUUID: UUID del CFDI
|
||||||
|
* - CFORMAPAGO, CMETODOPAGO: Forma y metodo de pago
|
||||||
|
* - CPENDIENTE: Saldo pendiente
|
||||||
|
* - CCANCELADO: Si esta cancelado
|
||||||
|
*
|
||||||
|
* admMovimientos (Detalle de productos en documentos)
|
||||||
|
* - CIDMOVIMIENTO: ID
|
||||||
|
* - CIDDOCUMENTO: Documento padre
|
||||||
|
* - CIDPRODUCTO: Producto
|
||||||
|
* - CUNIDADES: Cantidad
|
||||||
|
* - CPRECIO: Precio unitario
|
||||||
|
* - CDESCUENTO: Descuento
|
||||||
|
* - CIMPORTE: Importe
|
||||||
|
* - CIVA: IVA
|
||||||
|
*
|
||||||
|
* admAlmacenes (Almacenes)
|
||||||
|
* - CIDALMACEN: ID
|
||||||
|
* - CCODIGOALMACEN: Codigo
|
||||||
|
* - CNOMBREALMACEN: Nombre
|
||||||
|
*
|
||||||
|
* admExistencias (Inventario por almacen)
|
||||||
|
* - CIDPRODUCTO: Producto
|
||||||
|
* - CIDALMACEN: Almacen
|
||||||
|
* - CEXISTENCIA: Cantidad
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* CONTPAQi NOMINAS
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* nomEmpleados (Catalogo de empleados)
|
||||||
|
* - CIDEMPLEAD: ID
|
||||||
|
* - CCODIGOEMPLEADO: Codigo/Numero
|
||||||
|
* - CNOMBRE, CAPELLIDOPATERNO, CAPELLIDOMATERNO: Nombre
|
||||||
|
* - CRFC, CCURP, CNSS: Identificadores
|
||||||
|
* - CFECHAALTA, CFECHABAJA: Fechas
|
||||||
|
* - CTIPOCONTRATO: Tipo de contrato
|
||||||
|
* - CTIPOREGIMEN: Regimen (Sueldos, Asimilados, etc.)
|
||||||
|
* - CPERIODICIDADPAGO: Periodicidad
|
||||||
|
* - CSALARIODIARIO: Salario diario
|
||||||
|
* - CSALARIODIAINTEGRADO: SDI
|
||||||
|
* - CDEPARTAMENTO, CPUESTO: Puesto
|
||||||
|
* - CBANCO, CCLABE: Datos bancarios
|
||||||
|
*
|
||||||
|
* nomPeriodos (Periodos de nomina)
|
||||||
|
* - CIDPERIODO: ID
|
||||||
|
* - CNUMEROPERIODO: Numero de periodo
|
||||||
|
* - CEJERCICIO: Ano
|
||||||
|
* - CFECHAINICIO, CFECHAFIN, CFECHAPAGO: Fechas
|
||||||
|
* - CTIPONOMINA: 1=Ordinaria, 2=Extraordinaria
|
||||||
|
*
|
||||||
|
* nomNominas (Nomina por empleado/periodo)
|
||||||
|
* - CIDNOMINA: ID
|
||||||
|
* - CIDEMPLEAD: Empleado
|
||||||
|
* - CIDPERIODO: Periodo
|
||||||
|
* - CTOTALPERCEPCIONES, CTOTALDEDUCCIONES: Totales
|
||||||
|
* - CNETO: Neto a pagar
|
||||||
|
* - CUUID: UUID del CFDI de nomina
|
||||||
|
*
|
||||||
|
* nomMovimientos (Percepciones y deducciones)
|
||||||
|
* - CIDMOVIMIENTO: ID
|
||||||
|
* - CIDNOMINA: Nomina
|
||||||
|
* - CTIPOMOVIMIENTO: 1=Percepcion, 2=Deduccion, 3=OtroPago
|
||||||
|
* - CIDTIPOMOVIMIENTO: ID del tipo
|
||||||
|
* - CIMPORTE: Importe
|
||||||
|
* - CIMPORTEGRAVADO, CIMPORTEEXENTO: Gravado/Exento
|
||||||
|
*
|
||||||
|
* nomTiposPercepcion (Catalogo de percepciones)
|
||||||
|
* - CIDTIPOPERCEPCION: ID
|
||||||
|
* - CCLAVE: Clave
|
||||||
|
* - CCONCEPTO: Descripcion
|
||||||
|
* - CTIPOPERCEPCIONSAT: Clave SAT
|
||||||
|
*
|
||||||
|
* nomTiposDeduccion (Catalogo de deducciones)
|
||||||
|
* - CIDTIPODEDUCCION: ID
|
||||||
|
* - CCLAVE: Clave
|
||||||
|
* - CCONCEPTO: Descripcion
|
||||||
|
* - CTIPODEDUCCIONSAT: Clave SAT
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import {
|
||||||
|
* createCONTPAQiClient,
|
||||||
|
* createContabilidadConnector,
|
||||||
|
* createComercialConnector,
|
||||||
|
* createNominasConnector,
|
||||||
|
* createCONTPAQiSyncService,
|
||||||
|
* } from './services/integrations/contpaqi';
|
||||||
|
*
|
||||||
|
* // Crear cliente
|
||||||
|
* const client = createCONTPAQiClient({
|
||||||
|
* host: 'localhost',
|
||||||
|
* port: 1433,
|
||||||
|
* user: 'sa',
|
||||||
|
* password: 'password',
|
||||||
|
* database: 'ctCONTPAQi',
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Conectar a una empresa
|
||||||
|
* const empresas = await client.getEmpresas('Contabilidad');
|
||||||
|
* await client.connectToEmpresa(empresas[0]);
|
||||||
|
*
|
||||||
|
* // Usar conectores especificos
|
||||||
|
* const contabilidad = createContabilidadConnector(client);
|
||||||
|
* const cuentas = await contabilidad.getCatalogoCuentas();
|
||||||
|
* const balanza = await contabilidad.getBalanzaComprobacion({ ejercicio: 2024, periodo: 12 });
|
||||||
|
*
|
||||||
|
* // Sincronizar a Horux
|
||||||
|
* const syncService = createCONTPAQiSyncService({ dbPool, getSchemaName });
|
||||||
|
* const result = await syncService.syncToHorux({
|
||||||
|
* tenantId: 'xxx',
|
||||||
|
* connectionConfig: { ... },
|
||||||
|
* productos: ['Contabilidad', 'Comercial'],
|
||||||
|
* fechaDesde: new Date('2024-01-01'),
|
||||||
|
* fechaHasta: new Date('2024-12-31'),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './contpaqi.types.js';
|
||||||
|
|
||||||
|
// Schemas
|
||||||
|
export * from './contpaqi.schema.js';
|
||||||
|
|
||||||
|
// Client
|
||||||
|
export {
|
||||||
|
CONTPAQiClient,
|
||||||
|
createCONTPAQiClient,
|
||||||
|
getCONTPAQiPoolManager,
|
||||||
|
closeCONTPAQiConnections,
|
||||||
|
testCONTPAQiConnection,
|
||||||
|
} from './contpaqi.client.js';
|
||||||
|
|
||||||
|
// Connectors
|
||||||
|
export {
|
||||||
|
ContabilidadConnector,
|
||||||
|
createContabilidadConnector,
|
||||||
|
} from './contabilidad.connector.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
ComercialConnector,
|
||||||
|
createComercialConnector,
|
||||||
|
} from './comercial.connector.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
NominasConnector,
|
||||||
|
createNominasConnector,
|
||||||
|
} from './nominas.connector.js';
|
||||||
|
|
||||||
|
// Sync Service
|
||||||
|
export {
|
||||||
|
CONTPAQiSyncService,
|
||||||
|
createCONTPAQiSyncService,
|
||||||
|
type CONTPAQiSyncServiceConfig,
|
||||||
|
} from './contpaqi.sync.js';
|
||||||
879
apps/api/src/services/integrations/contpaqi/nominas.connector.ts
Normal file
879
apps/api/src/services/integrations/contpaqi/nominas.connector.ts
Normal file
@@ -0,0 +1,879 @@
|
|||||||
|
/**
|
||||||
|
* CONTPAQi Nominas Connector
|
||||||
|
* Conector para el modulo de Nominas de CONTPAQi
|
||||||
|
*
|
||||||
|
* TABLAS PRINCIPALES DE CONTPAQi NOMINAS:
|
||||||
|
*
|
||||||
|
* EMPLEADOS:
|
||||||
|
* - NOM10001 (o nomEmpleados): Catalogo de empleados
|
||||||
|
* - CIDEMPLEAD: ID del empleado
|
||||||
|
* - CCODIGOEMPLEADO: Codigo/Numero de empleado
|
||||||
|
* - CNOMBRE, CAPELLIDOPATERNO, CAPELLIDOMATERNO: Nombre
|
||||||
|
* - CRFC, CCURP, CNSS: Identificadores fiscales
|
||||||
|
* - CFECHAALTA, CFECHABAJA: Fechas de alta/baja
|
||||||
|
* - CSALARIODIARIO: Salario diario
|
||||||
|
*
|
||||||
|
* PERIODOS:
|
||||||
|
* - NOM10002 (o nomPeriodos): Periodos de nomina
|
||||||
|
* - CIDPERIODO: ID del periodo
|
||||||
|
* - CNUMEROPERIODO: Numero de periodo
|
||||||
|
* - CFECHAINICIO, CFECHAFIN, CFECHAPAGO: Fechas
|
||||||
|
*
|
||||||
|
* MOVIMIENTOS DE NOMINA:
|
||||||
|
* - NOM10005 (o nomMovimientosNomina): Movimientos por empleado/periodo
|
||||||
|
* - CIDMOVIMIENTO: ID del movimiento
|
||||||
|
* - CIDEMPLEAD: Empleado
|
||||||
|
* - CIDPERIODO: Periodo
|
||||||
|
* - CIDTIPOMOVIMIENTO: Tipo (percepcion/deduccion)
|
||||||
|
* - CIMPORTE: Importe
|
||||||
|
*
|
||||||
|
* PERCEPCIONES Y DEDUCCIONES:
|
||||||
|
* - NOM10003 (o nomTiposPercepcion): Catalogo de percepciones
|
||||||
|
* - NOM10004 (o nomTiposDeduccion): Catalogo de deducciones
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CONTPAQiClient } from './contpaqi.client.js';
|
||||||
|
import {
|
||||||
|
CONTPAQiEmpleado,
|
||||||
|
CONTPAQiPeriodoNomina,
|
||||||
|
CONTPAQiNomina,
|
||||||
|
CONTPAQiPercepcionNomina,
|
||||||
|
CONTPAQiDeduccionNomina,
|
||||||
|
CONTPAQiOtroPagoNomina,
|
||||||
|
CONTPAQiDireccion,
|
||||||
|
TipoContrato,
|
||||||
|
TipoRegimenNomina,
|
||||||
|
TipoJornada,
|
||||||
|
PeriodicidadPago,
|
||||||
|
TipoNomina,
|
||||||
|
EstadoPeriodoNomina,
|
||||||
|
} from './contpaqi.types.js';
|
||||||
|
import { EmpleadoFilter, NominaFilter, PaginationInput } from './contpaqi.schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para CONTPAQi Nominas
|
||||||
|
*/
|
||||||
|
export class NominasConnector {
|
||||||
|
constructor(private client: CONTPAQiClient) {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Empleados
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el catalogo de empleados
|
||||||
|
*/
|
||||||
|
async getEmpleados(filter?: EmpleadoFilter): Promise<CONTPAQiEmpleado[]> {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
e.CIDEMPLEAD as id,
|
||||||
|
e.CCODIGOEMPLEADO as codigo,
|
||||||
|
e.CNOMBRE as nombre,
|
||||||
|
e.CAPELLIDOPATERNO as apellidoPaterno,
|
||||||
|
e.CAPELLIDOMATERNO as apellidoMaterno,
|
||||||
|
e.CRFC as rfc,
|
||||||
|
e.CCURP as curp,
|
||||||
|
e.CNSS as nss,
|
||||||
|
e.CFECHANACIMIENTO as fechaNacimiento,
|
||||||
|
e.CSEXO as sexo,
|
||||||
|
e.CESTADOCIVIL as estadoCivil,
|
||||||
|
e.CFECHAALTA as fechaAlta,
|
||||||
|
e.CFECHABAJA as fechaBaja,
|
||||||
|
e.CFECHAANTIGUEDAD as fechaAntiguedad,
|
||||||
|
e.CTIPOCONTRATO as tipoContratoNum,
|
||||||
|
e.CTIPOREGIMEN as tipoRegimenNum,
|
||||||
|
e.CTIPOJORNADA as tipoJornadaNum,
|
||||||
|
e.CPERIODICIDADPAGO as periodicidadPagoNum,
|
||||||
|
e.CDEPARTAMENTO as departamento,
|
||||||
|
e.CPUESTO as puesto,
|
||||||
|
e.CSALARIODIARIO as salarioDiario,
|
||||||
|
e.CSALARIODIAINTEGRADO as salarioDiarioIntegrado,
|
||||||
|
e.CSBC as sbc,
|
||||||
|
e.CBANCO as banco,
|
||||||
|
e.CCLABE as clabe,
|
||||||
|
e.CCUENTABANCARIA as cuentaBancaria,
|
||||||
|
e.CEMAIL as email,
|
||||||
|
e.CTELEFONO as telefono,
|
||||||
|
e.CESTATUS as estado,
|
||||||
|
e.CSINDICALIZADO as sindicalizado,
|
||||||
|
e.CREGISTROPATRONAL as registroPatronal,
|
||||||
|
e.CRIESGOTRABAJO as riesgoTrabajo,
|
||||||
|
e.CENTIDADFEDERATIVA as entidadFederativa
|
||||||
|
FROM nomEmpleados e
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (filter?.estado !== undefined) {
|
||||||
|
query += ' AND e.CESTATUS = @estado';
|
||||||
|
params.estado = filter.estado;
|
||||||
|
} else if (!filter?.incluyeBajas) {
|
||||||
|
query += ' AND e.CESTATUS = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.departamento) {
|
||||||
|
query += ' AND e.CDEPARTAMENTO = @departamento';
|
||||||
|
params.departamento = filter.departamento;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.puesto) {
|
||||||
|
query += ' AND e.CPUESTO = @puesto';
|
||||||
|
params.puesto = filter.puesto;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.tipoContrato) {
|
||||||
|
query += ' AND e.CTIPOCONTRATO = @tipoContrato';
|
||||||
|
params.tipoContrato = this.getTipoContratoNumero(filter.tipoContrato);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY e.CAPELLIDOPATERNO, e.CAPELLIDOMATERNO, e.CNOMBRE';
|
||||||
|
|
||||||
|
if (filter?.limit) {
|
||||||
|
const offset = filter.page ? (filter.page - 1) * filter.limit : 0;
|
||||||
|
query += ` OFFSET ${offset} ROWS FETCH NEXT ${filter.limit} ROWS ONLY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
apellidoPaterno: string;
|
||||||
|
apellidoMaterno: string | null;
|
||||||
|
rfc: string;
|
||||||
|
curp: string;
|
||||||
|
nss: string;
|
||||||
|
fechaNacimiento: Date | null;
|
||||||
|
sexo: string | null;
|
||||||
|
estadoCivil: string | null;
|
||||||
|
fechaAlta: Date;
|
||||||
|
fechaBaja: Date | null;
|
||||||
|
fechaAntiguedad: Date | null;
|
||||||
|
tipoContratoNum: number;
|
||||||
|
tipoRegimenNum: number;
|
||||||
|
tipoJornadaNum: number | null;
|
||||||
|
periodicidadPagoNum: number;
|
||||||
|
departamento: string | null;
|
||||||
|
puesto: string | null;
|
||||||
|
salarioDiario: number;
|
||||||
|
salarioDiarioIntegrado: number | null;
|
||||||
|
sbc: number | null;
|
||||||
|
banco: string | null;
|
||||||
|
clabe: string | null;
|
||||||
|
cuentaBancaria: string | null;
|
||||||
|
email: string | null;
|
||||||
|
telefono: string | null;
|
||||||
|
estado: number;
|
||||||
|
sindicalizado: number | null;
|
||||||
|
registroPatronal: string | null;
|
||||||
|
riesgoTrabajo: string | null;
|
||||||
|
entidadFederativa: string | null;
|
||||||
|
}>(query, params);
|
||||||
|
|
||||||
|
const empleados: CONTPAQiEmpleado[] = [];
|
||||||
|
|
||||||
|
for (const row of result) {
|
||||||
|
const direccion = await this.getDireccionEmpleado(row.id);
|
||||||
|
|
||||||
|
empleados.push({
|
||||||
|
id: row.id,
|
||||||
|
codigo: row.codigo?.trim() || '',
|
||||||
|
nombre: row.nombre?.trim() || '',
|
||||||
|
apellidoPaterno: row.apellidoPaterno?.trim() || '',
|
||||||
|
apellidoMaterno: row.apellidoMaterno?.trim() || undefined,
|
||||||
|
rfc: row.rfc?.trim() || '',
|
||||||
|
curp: row.curp?.trim() || '',
|
||||||
|
nss: row.nss?.trim() || '',
|
||||||
|
fechaNacimiento: row.fechaNacimiento || undefined,
|
||||||
|
sexo: (row.sexo?.trim() as 'M' | 'F') || undefined,
|
||||||
|
estadoCivil: row.estadoCivil?.trim() || undefined,
|
||||||
|
fechaAlta: row.fechaAlta,
|
||||||
|
fechaBaja: row.fechaBaja || undefined,
|
||||||
|
fechaAntiguedad: row.fechaAntiguedad || undefined,
|
||||||
|
tipoContrato: this.getTipoContratoNombre(row.tipoContratoNum),
|
||||||
|
tipoRegimen: this.getTipoRegimenNombre(row.tipoRegimenNum),
|
||||||
|
tipoJornada: row.tipoJornadaNum
|
||||||
|
? this.getTipoJornadaNombre(row.tipoJornadaNum)
|
||||||
|
: undefined,
|
||||||
|
periodicidadPago: this.getPeriodicidadPagoNombre(row.periodicidadPagoNum),
|
||||||
|
departamento: row.departamento?.trim() || undefined,
|
||||||
|
puesto: row.puesto?.trim() || undefined,
|
||||||
|
salarioDiario: row.salarioDiario || 0,
|
||||||
|
salarioDiarioIntegrado: row.salarioDiarioIntegrado || undefined,
|
||||||
|
sbc: row.sbc || undefined,
|
||||||
|
banco: row.banco?.trim() || undefined,
|
||||||
|
clabe: row.clabe?.trim() || undefined,
|
||||||
|
cuentaBancaria: row.cuentaBancaria?.trim() || undefined,
|
||||||
|
direccion,
|
||||||
|
email: row.email?.trim() || undefined,
|
||||||
|
telefono: row.telefono?.trim() || undefined,
|
||||||
|
estado: row.estado,
|
||||||
|
sindicalizado: row.sindicalizado === 1,
|
||||||
|
registroPatronal: row.registroPatronal?.trim() || undefined,
|
||||||
|
riesgoTrabajo: row.riesgoTrabajo?.trim() || undefined,
|
||||||
|
entidadFederativa: row.entidadFederativa?.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return empleados;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un empleado por su codigo
|
||||||
|
*/
|
||||||
|
async getEmpleadoByCodigo(codigo: string): Promise<CONTPAQiEmpleado | null> {
|
||||||
|
const query = `
|
||||||
|
SELECT CIDEMPLEAD as id FROM nomEmpleados WHERE CCODIGOEMPLEADO = @codigo
|
||||||
|
`;
|
||||||
|
const result = await this.client.queryOne<{ id: number }>(query, { codigo });
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const empleados = await this.getEmpleados({ incluyeBajas: true });
|
||||||
|
return empleados.find((e) => e.id === result.id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la direccion de un empleado
|
||||||
|
*/
|
||||||
|
private async getDireccionEmpleado(empleadoId: number): Promise<CONTPAQiDireccion | undefined> {
|
||||||
|
const query = `
|
||||||
|
SELECT TOP 1
|
||||||
|
CCALLE as calle,
|
||||||
|
CNUMEROEXTERIOR as numeroExterior,
|
||||||
|
CNUMEROINTERIOR as numeroInterior,
|
||||||
|
CCOLONIA as colonia,
|
||||||
|
CCODIGOPOSTAL as codigoPostal,
|
||||||
|
CCIUDAD as ciudad,
|
||||||
|
CESTADO as estado,
|
||||||
|
CPAIS as pais
|
||||||
|
FROM nomDomicilios
|
||||||
|
WHERE CIDEMPLEAD = @empleadoId
|
||||||
|
ORDER BY CIDDOMICILIO
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryOne<{
|
||||||
|
calle: string | null;
|
||||||
|
numeroExterior: string | null;
|
||||||
|
numeroInterior: string | null;
|
||||||
|
colonia: string | null;
|
||||||
|
codigoPostal: string | null;
|
||||||
|
ciudad: string | null;
|
||||||
|
estado: string | null;
|
||||||
|
pais: string | null;
|
||||||
|
}>(query, { empleadoId });
|
||||||
|
|
||||||
|
if (!result) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
calle: result.calle?.trim() || undefined,
|
||||||
|
numeroExterior: result.numeroExterior?.trim() || undefined,
|
||||||
|
numeroInterior: result.numeroInterior?.trim() || undefined,
|
||||||
|
colonia: result.colonia?.trim() || undefined,
|
||||||
|
codigoPostal: result.codigoPostal?.trim() || undefined,
|
||||||
|
ciudad: result.ciudad?.trim() || undefined,
|
||||||
|
estado: result.estado?.trim() || undefined,
|
||||||
|
pais: result.pais?.trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Periodos de Nomina
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los periodos de nomina
|
||||||
|
*/
|
||||||
|
async getPeriodos(ejercicio: number): Promise<CONTPAQiPeriodoNomina[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
p.CIDPERIODO as id,
|
||||||
|
p.CNUMEROPERIODO as numero,
|
||||||
|
p.CEJERCICIO as ejercicio,
|
||||||
|
p.CFECHAINICIO as fechaInicio,
|
||||||
|
p.CFECHAFIN as fechaFin,
|
||||||
|
p.CFECHAPAGO as fechaPago,
|
||||||
|
p.CTIPONOMINA as tipoNominaNum,
|
||||||
|
p.CDIASPERIODO as diasPeriodo,
|
||||||
|
p.CESTATUS as estadoNum,
|
||||||
|
ISNULL(SUM(m.CIMPORTE), 0) as totalPercepciones,
|
||||||
|
0 as totalDeducciones
|
||||||
|
FROM nomPeriodos p
|
||||||
|
LEFT JOIN nomMovimientos m ON p.CIDPERIODO = m.CIDPERIODO AND m.CTIPOMOVIMIENTO = 1
|
||||||
|
WHERE p.CEJERCICIO = @ejercicio
|
||||||
|
GROUP BY p.CIDPERIODO, p.CNUMEROPERIODO, p.CEJERCICIO, p.CFECHAINICIO,
|
||||||
|
p.CFECHAFIN, p.CFECHAPAGO, p.CTIPONOMINA, p.CDIASPERIODO, p.CESTATUS
|
||||||
|
ORDER BY p.CNUMEROPERIODO
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
numero: number;
|
||||||
|
ejercicio: number;
|
||||||
|
fechaInicio: Date;
|
||||||
|
fechaFin: Date;
|
||||||
|
fechaPago: Date;
|
||||||
|
tipoNominaNum: number;
|
||||||
|
diasPeriodo: number;
|
||||||
|
estadoNum: number;
|
||||||
|
totalPercepciones: number;
|
||||||
|
totalDeducciones: number;
|
||||||
|
}>(query, { ejercicio });
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
numero: row.numero,
|
||||||
|
ejercicio: row.ejercicio,
|
||||||
|
fechaInicio: row.fechaInicio,
|
||||||
|
fechaFin: row.fechaFin,
|
||||||
|
fechaPago: row.fechaPago,
|
||||||
|
tipoNomina: row.tipoNominaNum === 1 ? 'Ordinaria' : 'Extraordinaria' as TipoNomina,
|
||||||
|
diasPeriodo: row.diasPeriodo,
|
||||||
|
estado: this.getEstadoPeriodoNombre(row.estadoNum),
|
||||||
|
totalPercepciones: row.totalPercepciones,
|
||||||
|
totalDeducciones: row.totalDeducciones,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Nominas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las nominas de un periodo
|
||||||
|
*/
|
||||||
|
async getNominas(filter: NominaFilter): Promise<CONTPAQiNomina[]> {
|
||||||
|
let query = `
|
||||||
|
SELECT DISTINCT
|
||||||
|
n.CIDNOMINA as id,
|
||||||
|
n.CIDEMPLEAD as empleadoId,
|
||||||
|
e.CCODIGOEMPLEADO as codigoEmpleado,
|
||||||
|
CONCAT(e.CAPELLIDOPATERNO, ' ', ISNULL(e.CAPELLIDOMATERNO, ''), ' ', e.CNOMBRE) as nombreEmpleado,
|
||||||
|
n.CIDPERIODO as periodoId,
|
||||||
|
p.CNUMEROPERIODO as numeroPeriodo,
|
||||||
|
p.CEJERCICIO as ejercicio,
|
||||||
|
p.CTIPONOMINA as tipoNominaNum,
|
||||||
|
p.CFECHAPAGO as fechaPago,
|
||||||
|
n.CDIASPAGADOS as diasPagados,
|
||||||
|
n.CTOTALPERCEPCIONES as totalPercepciones,
|
||||||
|
n.CTOTALGRAVADO as totalGravado,
|
||||||
|
n.CTOTALEXENTO as totalExento,
|
||||||
|
n.CTOTALDEDUCCIONES as totalDeducciones,
|
||||||
|
n.COTROSPAGOS as otrosPagos,
|
||||||
|
n.CNETO as neto,
|
||||||
|
n.CUUID as uuid,
|
||||||
|
n.CSUBSIDIOCAUSADO as subsidioCausado,
|
||||||
|
n.CISRRETENIDO as isrRetenido
|
||||||
|
FROM nomNominas n
|
||||||
|
INNER JOIN nomEmpleados e ON n.CIDEMPLEAD = e.CIDEMPLEAD
|
||||||
|
INNER JOIN nomPeriodos p ON n.CIDPERIODO = p.CIDPERIODO
|
||||||
|
WHERE p.CEJERCICIO = @ejercicio
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
ejercicio: filter.ejercicio,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filter.periodoInicio !== undefined) {
|
||||||
|
query += ' AND p.CNUMEROPERIODO >= @periodoInicio';
|
||||||
|
params.periodoInicio = filter.periodoInicio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.periodoFin !== undefined) {
|
||||||
|
query += ' AND p.CNUMEROPERIODO <= @periodoFin';
|
||||||
|
params.periodoFin = filter.periodoFin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.tipoNomina) {
|
||||||
|
query += ' AND p.CTIPONOMINA = @tipoNomina';
|
||||||
|
params.tipoNomina = filter.tipoNomina === 'Ordinaria' ? 1 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.empleadoId) {
|
||||||
|
query += ' AND n.CIDEMPLEAD = @empleadoId';
|
||||||
|
params.empleadoId = filter.empleadoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY p.CNUMEROPERIODO, e.CAPELLIDOPATERNO, e.CNOMBRE';
|
||||||
|
|
||||||
|
if (filter.limit) {
|
||||||
|
const offset = filter.page ? (filter.page - 1) * filter.limit : 0;
|
||||||
|
query += ` OFFSET ${offset} ROWS FETCH NEXT ${filter.limit} ROWS ONLY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
empleadoId: number;
|
||||||
|
codigoEmpleado: string;
|
||||||
|
nombreEmpleado: string;
|
||||||
|
periodoId: number;
|
||||||
|
numeroPeriodo: number;
|
||||||
|
ejercicio: number;
|
||||||
|
tipoNominaNum: number;
|
||||||
|
fechaPago: Date;
|
||||||
|
diasPagados: number;
|
||||||
|
totalPercepciones: number;
|
||||||
|
totalGravado: number;
|
||||||
|
totalExento: number;
|
||||||
|
totalDeducciones: number;
|
||||||
|
otrosPagos: number;
|
||||||
|
neto: number;
|
||||||
|
uuid: string | null;
|
||||||
|
subsidioCausado: number | null;
|
||||||
|
isrRetenido: number | null;
|
||||||
|
}>(query, params);
|
||||||
|
|
||||||
|
const nominas: CONTPAQiNomina[] = [];
|
||||||
|
|
||||||
|
for (const row of result) {
|
||||||
|
const [percepciones, deducciones, otrosPagosDetalle] = await Promise.all([
|
||||||
|
this.getPercepcionesNomina(row.id),
|
||||||
|
this.getDeduccionesNomina(row.id),
|
||||||
|
this.getOtrosPagosNomina(row.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
nominas.push({
|
||||||
|
id: row.id,
|
||||||
|
empleadoId: row.empleadoId,
|
||||||
|
codigoEmpleado: row.codigoEmpleado?.trim() || '',
|
||||||
|
nombreEmpleado: row.nombreEmpleado?.trim() || '',
|
||||||
|
periodoId: row.periodoId,
|
||||||
|
numeroPeriodo: row.numeroPeriodo,
|
||||||
|
ejercicio: row.ejercicio,
|
||||||
|
tipoNomina: row.tipoNominaNum === 1 ? 'Ordinaria' : 'Extraordinaria',
|
||||||
|
fechaPago: row.fechaPago,
|
||||||
|
diasPagados: row.diasPagados || 0,
|
||||||
|
totalPercepciones: row.totalPercepciones || 0,
|
||||||
|
totalGravado: row.totalGravado || 0,
|
||||||
|
totalExento: row.totalExento || 0,
|
||||||
|
totalDeducciones: row.totalDeducciones || 0,
|
||||||
|
otrosPagos: row.otrosPagos || 0,
|
||||||
|
neto: row.neto || 0,
|
||||||
|
uuid: row.uuid?.trim() || undefined,
|
||||||
|
percepciones,
|
||||||
|
deducciones,
|
||||||
|
otrosPagosDetalle: otrosPagosDetalle.length > 0 ? otrosPagosDetalle : undefined,
|
||||||
|
subsidioCausado: row.subsidioCausado || undefined,
|
||||||
|
isrRetenido: row.isrRetenido || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return nominas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las percepciones de una nomina
|
||||||
|
*/
|
||||||
|
async getPercepcionesNomina(nominaId: number): Promise<CONTPAQiPercepcionNomina[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.CIDMOVIMIENTO as id,
|
||||||
|
m.CIDNOMINA as nominaId,
|
||||||
|
t.CTIPOPERCEPCIONSAT as tipoPercepcion,
|
||||||
|
t.CCLAVE as clave,
|
||||||
|
t.CCONCEPTO as concepto,
|
||||||
|
m.CIMPORTEGRAVADO as importeGravado,
|
||||||
|
m.CIMPORTEEXENTO as importeExento,
|
||||||
|
ISNULL(m.CIMPORTEGRAVADO, 0) + ISNULL(m.CIMPORTEEXENTO, 0) as total
|
||||||
|
FROM nomMovimientos m
|
||||||
|
INNER JOIN nomTiposPercepcion t ON m.CIDTIPOMOVIMIENTO = t.CIDTIPOPERCEPCION
|
||||||
|
WHERE m.CIDNOMINA = @nominaId
|
||||||
|
AND m.CTIPOMOVIMIENTO = 1
|
||||||
|
ORDER BY t.CORDEN
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
nominaId: number;
|
||||||
|
tipoPercepcion: string;
|
||||||
|
clave: string;
|
||||||
|
concepto: string;
|
||||||
|
importeGravado: number;
|
||||||
|
importeExento: number;
|
||||||
|
total: number;
|
||||||
|
}>(query, { nominaId });
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
nominaId: row.nominaId,
|
||||||
|
tipoPercepcion: row.tipoPercepcion?.trim() || '',
|
||||||
|
clave: row.clave?.trim() || '',
|
||||||
|
concepto: row.concepto?.trim() || '',
|
||||||
|
importeGravado: row.importeGravado || 0,
|
||||||
|
importeExento: row.importeExento || 0,
|
||||||
|
total: row.total || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las deducciones de una nomina
|
||||||
|
*/
|
||||||
|
async getDeduccionesNomina(nominaId: number): Promise<CONTPAQiDeduccionNomina[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.CIDMOVIMIENTO as id,
|
||||||
|
m.CIDNOMINA as nominaId,
|
||||||
|
t.CTIPODEDUCCIONSAT as tipoDeduccion,
|
||||||
|
t.CCLAVE as clave,
|
||||||
|
t.CCONCEPTO as concepto,
|
||||||
|
m.CIMPORTE as importe
|
||||||
|
FROM nomMovimientos m
|
||||||
|
INNER JOIN nomTiposDeduccion t ON m.CIDTIPOMOVIMIENTO = t.CIDTIPODEDUCCION
|
||||||
|
WHERE m.CIDNOMINA = @nominaId
|
||||||
|
AND m.CTIPOMOVIMIENTO = 2
|
||||||
|
ORDER BY t.CORDEN
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
nominaId: number;
|
||||||
|
tipoDeduccion: string;
|
||||||
|
clave: string;
|
||||||
|
concepto: string;
|
||||||
|
importe: number;
|
||||||
|
}>(query, { nominaId });
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
nominaId: row.nominaId,
|
||||||
|
tipoDeduccion: row.tipoDeduccion?.trim() || '',
|
||||||
|
clave: row.clave?.trim() || '',
|
||||||
|
concepto: row.concepto?.trim() || '',
|
||||||
|
importe: row.importe || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene otros pagos de una nomina (subsidio, etc.)
|
||||||
|
*/
|
||||||
|
async getOtrosPagosNomina(nominaId: number): Promise<CONTPAQiOtroPagoNomina[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.CIDMOVIMIENTO as id,
|
||||||
|
m.CIDNOMINA as nominaId,
|
||||||
|
t.CTIPOOTROPAGOSAT as tipoOtroPago,
|
||||||
|
t.CCLAVE as clave,
|
||||||
|
t.CCONCEPTO as concepto,
|
||||||
|
m.CIMPORTE as importe,
|
||||||
|
m.CSUBSIDIOCAUSADO as subsidioCausado
|
||||||
|
FROM nomMovimientos m
|
||||||
|
INNER JOIN nomTiposOtroPago t ON m.CIDTIPOMOVIMIENTO = t.CIDTIPOOTROPAGO
|
||||||
|
WHERE m.CIDNOMINA = @nominaId
|
||||||
|
AND m.CTIPOMOVIMIENTO = 3
|
||||||
|
ORDER BY t.CORDEN
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.client.queryMany<{
|
||||||
|
id: number;
|
||||||
|
nominaId: number;
|
||||||
|
tipoOtroPago: string;
|
||||||
|
clave: string;
|
||||||
|
concepto: string;
|
||||||
|
importe: number;
|
||||||
|
subsidioCausado: number | null;
|
||||||
|
}>(query, { nominaId });
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
nominaId: row.nominaId,
|
||||||
|
tipoOtroPago: row.tipoOtroPago?.trim() || '',
|
||||||
|
clave: row.clave?.trim() || '',
|
||||||
|
concepto: row.concepto?.trim() || '',
|
||||||
|
importe: row.importe || 0,
|
||||||
|
subsidioCausado: row.subsidioCausado || undefined,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// La tabla de otros pagos puede no existir en versiones antiguas
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las percepciones y deducciones de un empleado en un periodo
|
||||||
|
*/
|
||||||
|
async getPercepcionesDeducciones(
|
||||||
|
empleadoId: number,
|
||||||
|
ejercicio?: number,
|
||||||
|
periodo?: number
|
||||||
|
): Promise<{
|
||||||
|
empleado: CONTPAQiEmpleado | null;
|
||||||
|
percepciones: CONTPAQiPercepcionNomina[];
|
||||||
|
deducciones: CONTPAQiDeduccionNomina[];
|
||||||
|
totales: {
|
||||||
|
percepciones: number;
|
||||||
|
deducciones: number;
|
||||||
|
neto: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
// Obtener empleado
|
||||||
|
const empleados = await this.getEmpleados({ incluyeBajas: true });
|
||||||
|
const empleado = empleados.find((e) => e.id === empleadoId) || null;
|
||||||
|
|
||||||
|
// Construir query para movimientos
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
n.CIDNOMINA as nominaId
|
||||||
|
FROM nomNominas n
|
||||||
|
INNER JOIN nomPeriodos p ON n.CIDPERIODO = p.CIDPERIODO
|
||||||
|
WHERE n.CIDEMPLEAD = @empleadoId
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = { empleadoId };
|
||||||
|
|
||||||
|
if (ejercicio !== undefined) {
|
||||||
|
query += ' AND p.CEJERCICIO = @ejercicio';
|
||||||
|
params.ejercicio = ejercicio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (periodo !== undefined) {
|
||||||
|
query += ' AND p.CNUMEROPERIODO = @periodo';
|
||||||
|
params.periodo = periodo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nominasResult = await this.client.queryMany<{ nominaId: number }>(query, params);
|
||||||
|
|
||||||
|
const percepciones: CONTPAQiPercepcionNomina[] = [];
|
||||||
|
const deducciones: CONTPAQiDeduccionNomina[] = [];
|
||||||
|
|
||||||
|
for (const { nominaId } of nominasResult) {
|
||||||
|
const [percs, deds] = await Promise.all([
|
||||||
|
this.getPercepcionesNomina(nominaId),
|
||||||
|
this.getDeduccionesNomina(nominaId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
percepciones.push(...percs);
|
||||||
|
deducciones.push(...deds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPercepciones = percepciones.reduce((sum, p) => sum + p.total, 0);
|
||||||
|
const totalDeducciones = deducciones.reduce((sum, d) => sum + d.importe, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
empleado,
|
||||||
|
percepciones,
|
||||||
|
deducciones,
|
||||||
|
totales: {
|
||||||
|
percepciones: totalPercepciones,
|
||||||
|
deducciones: totalDeducciones,
|
||||||
|
neto: totalPercepciones - totalDeducciones,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Reportes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un resumen de nomina por periodo
|
||||||
|
*/
|
||||||
|
async getResumenNomina(
|
||||||
|
ejercicio: number,
|
||||||
|
periodoInicio?: number,
|
||||||
|
periodoFin?: number
|
||||||
|
): Promise<{
|
||||||
|
periodo: { inicio: number; fin: number };
|
||||||
|
empleadosActivos: number;
|
||||||
|
totalPercepciones: number;
|
||||||
|
totalDeducciones: number;
|
||||||
|
totalNeto: number;
|
||||||
|
desglosePorConcepto: Array<{
|
||||||
|
tipo: 'Percepcion' | 'Deduccion';
|
||||||
|
clave: string;
|
||||||
|
concepto: string;
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
ejercicio,
|
||||||
|
periodoInicio: periodoInicio || 1,
|
||||||
|
periodoFin: periodoFin || 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener totales generales
|
||||||
|
const totalesQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT n.CIDEMPLEAD) as empleadosActivos,
|
||||||
|
SUM(n.CTOTALPERCEPCIONES) as totalPercepciones,
|
||||||
|
SUM(n.CTOTALDEDUCCIONES) as totalDeducciones,
|
||||||
|
SUM(n.CNETO) as totalNeto
|
||||||
|
FROM nomNominas n
|
||||||
|
INNER JOIN nomPeriodos p ON n.CIDPERIODO = p.CIDPERIODO
|
||||||
|
WHERE p.CEJERCICIO = @ejercicio
|
||||||
|
AND p.CNUMEROPERIODO >= @periodoInicio
|
||||||
|
AND p.CNUMEROPERIODO <= @periodoFin
|
||||||
|
`;
|
||||||
|
|
||||||
|
const totales = await this.client.queryOne<{
|
||||||
|
empleadosActivos: number;
|
||||||
|
totalPercepciones: number;
|
||||||
|
totalDeducciones: number;
|
||||||
|
totalNeto: number;
|
||||||
|
}>(totalesQuery, params);
|
||||||
|
|
||||||
|
// Obtener desglose por concepto de percepcion
|
||||||
|
const percepcionesQuery = `
|
||||||
|
SELECT
|
||||||
|
'Percepcion' as tipo,
|
||||||
|
t.CCLAVE as clave,
|
||||||
|
t.CCONCEPTO as concepto,
|
||||||
|
SUM(ISNULL(m.CIMPORTEGRAVADO, 0) + ISNULL(m.CIMPORTEEXENTO, 0)) as total
|
||||||
|
FROM nomMovimientos m
|
||||||
|
INNER JOIN nomNominas n ON m.CIDNOMINA = n.CIDNOMINA
|
||||||
|
INNER JOIN nomPeriodos p ON n.CIDPERIODO = p.CIDPERIODO
|
||||||
|
INNER JOIN nomTiposPercepcion t ON m.CIDTIPOMOVIMIENTO = t.CIDTIPOPERCEPCION
|
||||||
|
WHERE p.CEJERCICIO = @ejercicio
|
||||||
|
AND p.CNUMEROPERIODO >= @periodoInicio
|
||||||
|
AND p.CNUMEROPERIODO <= @periodoFin
|
||||||
|
AND m.CTIPOMOVIMIENTO = 1
|
||||||
|
GROUP BY t.CCLAVE, t.CCONCEPTO
|
||||||
|
ORDER BY t.CORDEN
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Obtener desglose por concepto de deduccion
|
||||||
|
const deduccionesQuery = `
|
||||||
|
SELECT
|
||||||
|
'Deduccion' as tipo,
|
||||||
|
t.CCLAVE as clave,
|
||||||
|
t.CCONCEPTO as concepto,
|
||||||
|
SUM(m.CIMPORTE) as total
|
||||||
|
FROM nomMovimientos m
|
||||||
|
INNER JOIN nomNominas n ON m.CIDNOMINA = n.CIDNOMINA
|
||||||
|
INNER JOIN nomPeriodos p ON n.CIDPERIODO = p.CIDPERIODO
|
||||||
|
INNER JOIN nomTiposDeduccion t ON m.CIDTIPOMOVIMIENTO = t.CIDTIPODEDUCCION
|
||||||
|
WHERE p.CEJERCICIO = @ejercicio
|
||||||
|
AND p.CNUMEROPERIODO >= @periodoInicio
|
||||||
|
AND p.CNUMEROPERIODO <= @periodoFin
|
||||||
|
AND m.CTIPOMOVIMIENTO = 2
|
||||||
|
GROUP BY t.CCLAVE, t.CCONCEPTO
|
||||||
|
ORDER BY t.CORDEN
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [percepcionesDesglose, deduccionesDesglose] = await Promise.all([
|
||||||
|
this.client.queryMany<{
|
||||||
|
tipo: string;
|
||||||
|
clave: string;
|
||||||
|
concepto: string;
|
||||||
|
total: number;
|
||||||
|
}>(percepcionesQuery, params),
|
||||||
|
this.client.queryMany<{
|
||||||
|
tipo: string;
|
||||||
|
clave: string;
|
||||||
|
concepto: string;
|
||||||
|
total: number;
|
||||||
|
}>(deduccionesQuery, params),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const desglosePorConcepto = [
|
||||||
|
...percepcionesDesglose.map((r) => ({
|
||||||
|
tipo: 'Percepcion' as const,
|
||||||
|
clave: r.clave?.trim() || '',
|
||||||
|
concepto: r.concepto?.trim() || '',
|
||||||
|
total: r.total || 0,
|
||||||
|
})),
|
||||||
|
...deduccionesDesglose.map((r) => ({
|
||||||
|
tipo: 'Deduccion' as const,
|
||||||
|
clave: r.clave?.trim() || '',
|
||||||
|
concepto: r.concepto?.trim() || '',
|
||||||
|
total: r.total || 0,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
periodo: {
|
||||||
|
inicio: periodoInicio || 1,
|
||||||
|
fin: periodoFin || 24,
|
||||||
|
},
|
||||||
|
empleadosActivos: totales?.empleadosActivos || 0,
|
||||||
|
totalPercepciones: totales?.totalPercepciones || 0,
|
||||||
|
totalDeducciones: totales?.totalDeducciones || 0,
|
||||||
|
totalNeto: totales?.totalNeto || 0,
|
||||||
|
desglosePorConcepto,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private getTipoContratoNumero(tipo: TipoContrato): number {
|
||||||
|
const tipos: Record<TipoContrato, number> = {
|
||||||
|
PorTiempoIndeterminado: 1,
|
||||||
|
PorObraoDeterminado: 2,
|
||||||
|
PorTemporada: 3,
|
||||||
|
SujetoPrueba: 4,
|
||||||
|
CapacitacionInicial: 5,
|
||||||
|
PorTiempoIndeterminadoDesconexion: 6,
|
||||||
|
};
|
||||||
|
return tipos[tipo];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTipoContratoNombre(tipo: number): TipoContrato {
|
||||||
|
const tipos: Record<number, TipoContrato> = {
|
||||||
|
1: 'PorTiempoIndeterminado',
|
||||||
|
2: 'PorObraoDeterminado',
|
||||||
|
3: 'PorTemporada',
|
||||||
|
4: 'SujetoPrueba',
|
||||||
|
5: 'CapacitacionInicial',
|
||||||
|
6: 'PorTiempoIndeterminadoDesconexion',
|
||||||
|
};
|
||||||
|
return tipos[tipo] || 'PorTiempoIndeterminado';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTipoRegimenNombre(tipo: number): TipoRegimenNomina {
|
||||||
|
const tipos: Record<number, TipoRegimenNomina> = {
|
||||||
|
1: 'Sueldos',
|
||||||
|
2: 'Asimilados',
|
||||||
|
3: 'Jubilados',
|
||||||
|
4: 'Honorarios',
|
||||||
|
};
|
||||||
|
return tipos[tipo] || 'Sueldos';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTipoJornadaNombre(tipo: number): TipoJornada {
|
||||||
|
const tipos: Record<number, TipoJornada> = {
|
||||||
|
1: 'Diurna',
|
||||||
|
2: 'Nocturna',
|
||||||
|
3: 'Mixta',
|
||||||
|
4: 'PorHora',
|
||||||
|
5: 'ReducidaDiscapacidad',
|
||||||
|
6: 'Continuada',
|
||||||
|
};
|
||||||
|
return tipos[tipo] || 'Diurna';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPeriodicidadPagoNombre(tipo: number): PeriodicidadPago {
|
||||||
|
const tipos: Record<number, PeriodicidadPago> = {
|
||||||
|
1: 'Diario',
|
||||||
|
2: 'Semanal',
|
||||||
|
3: 'Catorcenal',
|
||||||
|
4: 'Quincenal',
|
||||||
|
5: 'Mensual',
|
||||||
|
6: 'Bimestral',
|
||||||
|
7: 'PorUnidadObra',
|
||||||
|
8: 'Comision',
|
||||||
|
9: 'PrecioAlzado',
|
||||||
|
10: 'Decenal',
|
||||||
|
99: 'OtraPeriodidad',
|
||||||
|
};
|
||||||
|
return tipos[tipo] || 'Quincenal';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEstadoPeriodoNombre(estado: number): EstadoPeriodoNomina {
|
||||||
|
const estados: Record<number, EstadoPeriodoNomina> = {
|
||||||
|
1: 'Abierto',
|
||||||
|
2: 'Calculado',
|
||||||
|
3: 'Cerrado',
|
||||||
|
4: 'Timbrado',
|
||||||
|
};
|
||||||
|
return estados[estado] || 'Abierto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del conector de Nominas
|
||||||
|
*/
|
||||||
|
export function createNominasConnector(client: CONTPAQiClient): NominasConnector {
|
||||||
|
return new NominasConnector(client);
|
||||||
|
}
|
||||||
42
apps/api/src/services/integrations/index.ts
Normal file
42
apps/api/src/services/integrations/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Integrations Module Exports
|
||||||
|
*
|
||||||
|
* Unified exports for the integrations system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './integration.types.js';
|
||||||
|
|
||||||
|
// Manager
|
||||||
|
export {
|
||||||
|
IntegrationManager,
|
||||||
|
integrationManager,
|
||||||
|
IntegrationError,
|
||||||
|
ConnectorNotFoundError,
|
||||||
|
ConnectionError,
|
||||||
|
SyncError,
|
||||||
|
} from './integration.manager.js';
|
||||||
|
|
||||||
|
// Scheduler
|
||||||
|
export {
|
||||||
|
SyncScheduler,
|
||||||
|
createSyncScheduler,
|
||||||
|
type SyncJobData,
|
||||||
|
type SyncJobResult,
|
||||||
|
type SchedulerConfig,
|
||||||
|
} from './sync.scheduler.js';
|
||||||
|
|
||||||
|
// SAP Business One
|
||||||
|
export * from './sap/index.js';
|
||||||
|
|
||||||
|
// Alegra - Software contable cloud (Mexico, Colombia, LATAM)
|
||||||
|
export * from './alegra/index.js';
|
||||||
|
|
||||||
|
// Odoo ERP (versions 14, 15, 16, 17)
|
||||||
|
export * from './odoo/index.js';
|
||||||
|
|
||||||
|
// CONTPAQi - Sistema ERP Mexicano (Contabilidad, Comercial, Nominas)
|
||||||
|
export * from './contpaqi/index.js';
|
||||||
|
|
||||||
|
// Aspel - Sistema ERP Mexicano (COI, SAE, NOI, BANCO)
|
||||||
|
export * from './aspel/index.js';
|
||||||
1269
apps/api/src/services/integrations/integration.manager.ts
Normal file
1269
apps/api/src/services/integrations/integration.manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
662
apps/api/src/services/integrations/integration.types.ts
Normal file
662
apps/api/src/services/integrations/integration.types.ts
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
/**
|
||||||
|
* Integration Types
|
||||||
|
*
|
||||||
|
* Common types for the unified integration system.
|
||||||
|
* Supports CONTPAQi, Aspel, Odoo, Alegra, SAP, and manual data entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ENUMS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported integration types
|
||||||
|
*/
|
||||||
|
export enum IntegrationType {
|
||||||
|
// ERP/Accounting systems
|
||||||
|
CONTPAQI = 'contpaqi',
|
||||||
|
ASPEL = 'aspel',
|
||||||
|
ODOO = 'odoo',
|
||||||
|
ALEGRA = 'alegra',
|
||||||
|
SAP = 'sap',
|
||||||
|
|
||||||
|
// Manual entry (no external system)
|
||||||
|
MANUAL = 'manual',
|
||||||
|
|
||||||
|
// Fiscal
|
||||||
|
SAT = 'sat',
|
||||||
|
|
||||||
|
// Banks
|
||||||
|
BANK_BBVA = 'bank_bbva',
|
||||||
|
BANK_BANAMEX = 'bank_banamex',
|
||||||
|
BANK_SANTANDER = 'bank_santander',
|
||||||
|
BANK_BANORTE = 'bank_banorte',
|
||||||
|
BANK_HSBC = 'bank_hsbc',
|
||||||
|
|
||||||
|
// Payments
|
||||||
|
STRIPE = 'payments_stripe',
|
||||||
|
OPENPAY = 'payments_openpay',
|
||||||
|
|
||||||
|
// Custom
|
||||||
|
WEBHOOK = 'webhook',
|
||||||
|
API_CUSTOM = 'api_custom',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration status
|
||||||
|
*/
|
||||||
|
export enum IntegrationStatus {
|
||||||
|
PENDING = 'pending',
|
||||||
|
ACTIVE = 'active',
|
||||||
|
INACTIVE = 'inactive',
|
||||||
|
ERROR = 'error',
|
||||||
|
EXPIRED = 'expired',
|
||||||
|
CONFIGURING = 'configuring',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync job status
|
||||||
|
*/
|
||||||
|
export enum SyncStatus {
|
||||||
|
PENDING = 'pending',
|
||||||
|
QUEUED = 'queued',
|
||||||
|
RUNNING = 'running',
|
||||||
|
COMPLETED = 'completed',
|
||||||
|
FAILED = 'failed',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
PARTIAL = 'partial', // Some records synced, some failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync direction
|
||||||
|
*/
|
||||||
|
export enum SyncDirection {
|
||||||
|
IMPORT = 'import', // External -> Horux
|
||||||
|
EXPORT = 'export', // Horux -> External
|
||||||
|
BIDIRECTIONAL = 'bidirectional',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data entity types that can be synced
|
||||||
|
*/
|
||||||
|
export enum SyncEntityType {
|
||||||
|
TRANSACTIONS = 'transactions',
|
||||||
|
INVOICES = 'invoices',
|
||||||
|
CONTACTS = 'contacts',
|
||||||
|
PRODUCTS = 'products',
|
||||||
|
ACCOUNTS = 'accounts',
|
||||||
|
CATEGORIES = 'categories',
|
||||||
|
JOURNAL_ENTRIES = 'journal_entries',
|
||||||
|
PAYMENTS = 'payments',
|
||||||
|
CFDIS = 'cfdis',
|
||||||
|
BANK_STATEMENTS = 'bank_statements',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONFIGURATION TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base configuration for all integrations
|
||||||
|
*/
|
||||||
|
export interface BaseIntegrationConfig {
|
||||||
|
autoSync: boolean;
|
||||||
|
syncFrequency: 'realtime' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||||
|
syncDirection: SyncDirection;
|
||||||
|
enabledEntities: SyncEntityType[];
|
||||||
|
retryOnFailure: boolean;
|
||||||
|
maxRetries: number;
|
||||||
|
notifyOnSuccess: boolean;
|
||||||
|
notifyOnFailure: boolean;
|
||||||
|
notificationEmails?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CONTPAQi configuration
|
||||||
|
*/
|
||||||
|
export interface ContpaqiConfig extends BaseIntegrationConfig {
|
||||||
|
type: IntegrationType.CONTPAQI;
|
||||||
|
// Connection
|
||||||
|
serverHost: string;
|
||||||
|
serverPort: number;
|
||||||
|
databaseName: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
companyRfc: string;
|
||||||
|
// SDK settings
|
||||||
|
sdkPath?: string;
|
||||||
|
sdkVersion?: string;
|
||||||
|
// Mappings
|
||||||
|
accountMappingProfile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aspel configuration
|
||||||
|
*/
|
||||||
|
export interface AspelConfig extends BaseIntegrationConfig {
|
||||||
|
type: IntegrationType.ASPEL;
|
||||||
|
product: 'SAE' | 'COI' | 'NOI' | 'BANCO' | 'CAJA';
|
||||||
|
// Connection
|
||||||
|
serverHost: string;
|
||||||
|
serverPort: number;
|
||||||
|
databasePath: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
companyCode: string;
|
||||||
|
// Version
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odoo configuration
|
||||||
|
*/
|
||||||
|
export interface OdooConfig extends BaseIntegrationConfig {
|
||||||
|
type: IntegrationType.ODOO;
|
||||||
|
// Connection
|
||||||
|
serverUrl: string;
|
||||||
|
database: string;
|
||||||
|
username: string;
|
||||||
|
apiKey: string;
|
||||||
|
// Company
|
||||||
|
companyId: number;
|
||||||
|
// Options
|
||||||
|
useXmlRpc: boolean;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alegra configuration
|
||||||
|
*/
|
||||||
|
export interface AlegraConfig extends BaseIntegrationConfig {
|
||||||
|
type: IntegrationType.ALEGRA;
|
||||||
|
// API credentials
|
||||||
|
email: string;
|
||||||
|
apiToken: string;
|
||||||
|
// Company
|
||||||
|
companyId?: string;
|
||||||
|
// Region
|
||||||
|
country: 'MX' | 'CO' | 'PE' | 'AR' | 'CL';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAP configuration
|
||||||
|
*/
|
||||||
|
export interface SapConfig extends BaseIntegrationConfig {
|
||||||
|
type: IntegrationType.SAP;
|
||||||
|
// Connection
|
||||||
|
serverHost: string;
|
||||||
|
serverPort: number;
|
||||||
|
systemNumber: string;
|
||||||
|
client: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
// Company
|
||||||
|
companyCode: string;
|
||||||
|
// Options
|
||||||
|
sapRouter?: string;
|
||||||
|
language: string;
|
||||||
|
useSsl: boolean;
|
||||||
|
// B1 specific
|
||||||
|
isBusinessOne?: boolean;
|
||||||
|
serviceLayerUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAT configuration
|
||||||
|
*/
|
||||||
|
export interface SatConfig extends BaseIntegrationConfig {
|
||||||
|
type: IntegrationType.SAT;
|
||||||
|
rfc: string;
|
||||||
|
certificateBase64: string;
|
||||||
|
privateKeyBase64: string;
|
||||||
|
privateKeyPassword: string;
|
||||||
|
fielCertificateBase64?: string;
|
||||||
|
fielPrivateKeyBase64?: string;
|
||||||
|
fielPrivateKeyPassword?: string;
|
||||||
|
syncIngresos: boolean;
|
||||||
|
syncEgresos: boolean;
|
||||||
|
syncNomina: boolean;
|
||||||
|
syncPagos: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank configuration
|
||||||
|
*/
|
||||||
|
export interface BankConfig extends BaseIntegrationConfig {
|
||||||
|
type: IntegrationType.BANK_BBVA | IntegrationType.BANK_BANAMEX |
|
||||||
|
IntegrationType.BANK_SANTANDER | IntegrationType.BANK_BANORTE |
|
||||||
|
IntegrationType.BANK_HSBC;
|
||||||
|
accountNumber?: string;
|
||||||
|
clabe?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
connectionProvider?: 'belvo' | 'finerio' | 'plaid' | 'direct';
|
||||||
|
connectionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook configuration
|
||||||
|
*/
|
||||||
|
export interface WebhookConfig extends BaseIntegrationConfig {
|
||||||
|
type: IntegrationType.WEBHOOK;
|
||||||
|
url: string;
|
||||||
|
secret: string;
|
||||||
|
events: string[];
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
retryAttempts: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual entry configuration
|
||||||
|
*/
|
||||||
|
export interface ManualConfig {
|
||||||
|
type: IntegrationType.MANUAL;
|
||||||
|
enabledEntities: SyncEntityType[];
|
||||||
|
defaultCategory?: string;
|
||||||
|
requireApproval: boolean;
|
||||||
|
approvalThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type for all integration configs
|
||||||
|
*/
|
||||||
|
export type IntegrationConfig =
|
||||||
|
| ContpaqiConfig
|
||||||
|
| AspelConfig
|
||||||
|
| OdooConfig
|
||||||
|
| AlegraConfig
|
||||||
|
| SapConfig
|
||||||
|
| SatConfig
|
||||||
|
| BankConfig
|
||||||
|
| WebhookConfig
|
||||||
|
| ManualConfig;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DATA MAPPING TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field mapping between external system and Horux
|
||||||
|
*/
|
||||||
|
export interface FieldMapping {
|
||||||
|
id: string;
|
||||||
|
sourceField: string;
|
||||||
|
targetField: string;
|
||||||
|
transformationType?: 'direct' | 'lookup' | 'formula' | 'constant';
|
||||||
|
transformationValue?: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
defaultValue?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity mapping configuration
|
||||||
|
*/
|
||||||
|
export interface EntityMapping {
|
||||||
|
id: string;
|
||||||
|
integrationId: string;
|
||||||
|
entityType: SyncEntityType;
|
||||||
|
sourceEntity: string;
|
||||||
|
targetEntity: string;
|
||||||
|
fieldMappings: FieldMapping[];
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value mapping for lookups (e.g., account codes)
|
||||||
|
*/
|
||||||
|
export interface ValueMapping {
|
||||||
|
id: string;
|
||||||
|
mappingId: string;
|
||||||
|
sourceValue: string;
|
||||||
|
targetValue: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SYNC RESULT TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a single record sync
|
||||||
|
*/
|
||||||
|
export interface SyncRecordResult {
|
||||||
|
sourceId: string;
|
||||||
|
targetId?: string;
|
||||||
|
success: boolean;
|
||||||
|
action: 'created' | 'updated' | 'skipped' | 'deleted' | 'failed';
|
||||||
|
errorMessage?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a sync job
|
||||||
|
*/
|
||||||
|
export interface SyncResult {
|
||||||
|
jobId: string;
|
||||||
|
integrationId: string;
|
||||||
|
integrationType: IntegrationType;
|
||||||
|
status: SyncStatus;
|
||||||
|
direction: SyncDirection;
|
||||||
|
entityType: SyncEntityType;
|
||||||
|
|
||||||
|
// Timing
|
||||||
|
startedAt: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
durationMs?: number;
|
||||||
|
|
||||||
|
// Counts
|
||||||
|
totalRecords: number;
|
||||||
|
processedRecords: number;
|
||||||
|
createdRecords: number;
|
||||||
|
updatedRecords: number;
|
||||||
|
skippedRecords: number;
|
||||||
|
failedRecords: number;
|
||||||
|
|
||||||
|
// Progress
|
||||||
|
progress: number; // 0-100
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
errors: SyncError[];
|
||||||
|
warnings: string[];
|
||||||
|
|
||||||
|
// Details
|
||||||
|
recordResults?: SyncRecordResult[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync error details
|
||||||
|
*/
|
||||||
|
export interface SyncError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
sourceId?: string;
|
||||||
|
field?: string;
|
||||||
|
timestamp: Date;
|
||||||
|
retryable: boolean;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SYNC LOG TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync job log entry
|
||||||
|
*/
|
||||||
|
export interface SyncLog {
|
||||||
|
id: string;
|
||||||
|
integrationId: string;
|
||||||
|
jobId: string;
|
||||||
|
entityType: SyncEntityType;
|
||||||
|
direction: SyncDirection;
|
||||||
|
status: SyncStatus;
|
||||||
|
|
||||||
|
// Timing
|
||||||
|
startedAt: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
durationMs?: number;
|
||||||
|
|
||||||
|
// Results
|
||||||
|
totalRecords: number;
|
||||||
|
createdRecords: number;
|
||||||
|
updatedRecords: number;
|
||||||
|
skippedRecords: number;
|
||||||
|
failedRecords: number;
|
||||||
|
|
||||||
|
// Error summary
|
||||||
|
errorCount: number;
|
||||||
|
lastError?: string;
|
||||||
|
|
||||||
|
// Trigger info
|
||||||
|
triggeredBy: 'schedule' | 'manual' | 'webhook' | 'system';
|
||||||
|
triggeredByUserId?: string;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SCHEDULE TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync schedule configuration
|
||||||
|
*/
|
||||||
|
export interface SyncSchedule {
|
||||||
|
id: string;
|
||||||
|
integrationId: string;
|
||||||
|
entityType: SyncEntityType;
|
||||||
|
direction: SyncDirection;
|
||||||
|
|
||||||
|
// Schedule
|
||||||
|
isEnabled: boolean;
|
||||||
|
cronExpression: string;
|
||||||
|
timezone: string;
|
||||||
|
|
||||||
|
// Execution window
|
||||||
|
startTime?: string; // HH:mm
|
||||||
|
endTime?: string; // HH:mm
|
||||||
|
daysOfWeek?: number[]; // 0-6
|
||||||
|
|
||||||
|
// Last execution
|
||||||
|
lastRunAt?: Date;
|
||||||
|
nextRunAt?: Date;
|
||||||
|
lastStatus?: SyncStatus;
|
||||||
|
|
||||||
|
// Options
|
||||||
|
priority: 'low' | 'normal' | 'high';
|
||||||
|
timeoutMs: number;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONNECTION TEST TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection test result
|
||||||
|
*/
|
||||||
|
export interface ConnectionTestResult {
|
||||||
|
success: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
// Connection details
|
||||||
|
serverVersion?: string;
|
||||||
|
serverTime?: Date;
|
||||||
|
permissions?: string[];
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
errorCode?: string;
|
||||||
|
errorDetails?: string;
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
capabilities?: {
|
||||||
|
canRead: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
supportedEntities: SyncEntityType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
testedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTEGRATION ENTITY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full integration entity stored in database
|
||||||
|
*/
|
||||||
|
export interface Integration {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
type: IntegrationType;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
status: IntegrationStatus;
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Configuration (encrypted in DB)
|
||||||
|
config: IntegrationConfig;
|
||||||
|
|
||||||
|
// Connection health
|
||||||
|
lastHealthCheckAt?: Date;
|
||||||
|
healthStatus?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
|
||||||
|
healthMessage?: string;
|
||||||
|
|
||||||
|
// Last sync info
|
||||||
|
lastSyncAt?: Date;
|
||||||
|
lastSyncStatus?: SyncStatus;
|
||||||
|
lastSyncError?: string;
|
||||||
|
nextSyncAt?: Date;
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
totalSyncs: number;
|
||||||
|
successfulSyncs: number;
|
||||||
|
failedSyncs: number;
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONNECTOR INTERFACE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface that all connectors must implement
|
||||||
|
*/
|
||||||
|
export interface IIntegrationConnector {
|
||||||
|
type: IntegrationType;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
|
||||||
|
// Connection
|
||||||
|
testConnection(config: IntegrationConfig): Promise<ConnectionTestResult>;
|
||||||
|
connect(config: IntegrationConfig): Promise<void>;
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
healthCheck(): Promise<ConnectionTestResult>;
|
||||||
|
|
||||||
|
// Sync operations
|
||||||
|
sync(options: SyncOptions): Promise<SyncResult>;
|
||||||
|
getSyncStatus(jobId: string): Promise<SyncResult>;
|
||||||
|
cancelSync(jobId: string): Promise<boolean>;
|
||||||
|
|
||||||
|
// Data operations
|
||||||
|
fetchRecords(entityType: SyncEntityType, options: FetchOptions): Promise<unknown[]>;
|
||||||
|
pushRecords(entityType: SyncEntityType, records: unknown[]): Promise<SyncRecordResult[]>;
|
||||||
|
|
||||||
|
// Supported capabilities
|
||||||
|
getSupportedEntities(): SyncEntityType[];
|
||||||
|
getSupportedDirections(): SyncDirection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync options
|
||||||
|
*/
|
||||||
|
export interface SyncOptions {
|
||||||
|
entityTypes?: SyncEntityType[];
|
||||||
|
direction?: SyncDirection;
|
||||||
|
fullSync?: boolean; // If true, sync all records, not just changes
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
batchSize?: number;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch options for retrieving records
|
||||||
|
*/
|
||||||
|
export interface FetchOptions {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
modifiedSince?: Date;
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
orderBy?: string;
|
||||||
|
orderDirection?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// METADATA TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration provider metadata
|
||||||
|
*/
|
||||||
|
export interface IntegrationProvider {
|
||||||
|
type: IntegrationType;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: 'erp' | 'accounting' | 'fiscal' | 'bank' | 'payments' | 'custom';
|
||||||
|
logoUrl?: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
documentationUrl?: string;
|
||||||
|
|
||||||
|
// Features
|
||||||
|
supportedEntities: SyncEntityType[];
|
||||||
|
supportedDirections: SyncDirection[];
|
||||||
|
supportsRealtime: boolean;
|
||||||
|
supportsWebhooks: boolean;
|
||||||
|
|
||||||
|
// Requirements
|
||||||
|
requiresCredentials: boolean;
|
||||||
|
requiredFields: string[];
|
||||||
|
optionalFields: string[];
|
||||||
|
|
||||||
|
// Availability
|
||||||
|
isAvailable: boolean;
|
||||||
|
isBeta: boolean;
|
||||||
|
regions?: string[]; // ISO country codes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EVENT TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration event types
|
||||||
|
*/
|
||||||
|
export enum IntegrationEventType {
|
||||||
|
CONNECTED = 'integration.connected',
|
||||||
|
DISCONNECTED = 'integration.disconnected',
|
||||||
|
SYNC_STARTED = 'integration.sync.started',
|
||||||
|
SYNC_COMPLETED = 'integration.sync.completed',
|
||||||
|
SYNC_FAILED = 'integration.sync.failed',
|
||||||
|
SYNC_PROGRESS = 'integration.sync.progress',
|
||||||
|
HEALTH_CHECK_FAILED = 'integration.health.failed',
|
||||||
|
CONFIG_UPDATED = 'integration.config.updated',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration event payload
|
||||||
|
*/
|
||||||
|
export interface IntegrationEvent {
|
||||||
|
type: IntegrationEventType;
|
||||||
|
integrationId: string;
|
||||||
|
tenantId: string;
|
||||||
|
timestamp: Date;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
918
apps/api/src/services/integrations/odoo/accounting.connector.ts
Normal file
918
apps/api/src/services/integrations/odoo/accounting.connector.ts
Normal file
@@ -0,0 +1,918 @@
|
|||||||
|
/**
|
||||||
|
* Odoo Accounting Connector
|
||||||
|
* Conector para funciones contables de Odoo ERP
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { OdooClient } from './odoo.client.js';
|
||||||
|
import {
|
||||||
|
OdooAccount,
|
||||||
|
OdooAccountType,
|
||||||
|
OdooJournal,
|
||||||
|
OdooJournalEntry,
|
||||||
|
OdooJournalEntryLine,
|
||||||
|
OdooDomain,
|
||||||
|
OdooPagination,
|
||||||
|
OdooPaginatedResponse,
|
||||||
|
ChartOfAccountsLine,
|
||||||
|
TrialBalanceLine,
|
||||||
|
ProfitAndLossReport,
|
||||||
|
ProfitAndLossLine,
|
||||||
|
BalanceSheetReport,
|
||||||
|
BalanceSheetSection,
|
||||||
|
BalanceSheetSubsection,
|
||||||
|
BalanceSheetLine,
|
||||||
|
TaxReport,
|
||||||
|
TaxReportLine,
|
||||||
|
OdooError,
|
||||||
|
} from './odoo.types.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tipos internos
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface DatePeriod {
|
||||||
|
dateFrom: Date;
|
||||||
|
dateTo: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountBalance {
|
||||||
|
accountId: number;
|
||||||
|
accountCode: string;
|
||||||
|
accountName: string;
|
||||||
|
accountType: OdooAccountType;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Accounting Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector de contabilidad para Odoo
|
||||||
|
*/
|
||||||
|
export class OdooAccountingConnector {
|
||||||
|
private client: OdooClient;
|
||||||
|
|
||||||
|
constructor(client: OdooClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Chart of Accounts
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el catalogo de cuentas
|
||||||
|
*/
|
||||||
|
async getChartOfAccounts(
|
||||||
|
options?: {
|
||||||
|
includeDeprecated?: boolean;
|
||||||
|
accountTypes?: OdooAccountType[];
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<ChartOfAccountsLine[]> {
|
||||||
|
const domain: OdooDomain = [];
|
||||||
|
|
||||||
|
if (!options?.includeDeprecated) {
|
||||||
|
domain.push(['deprecated', '=', false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.accountTypes && options.accountTypes.length > 0) {
|
||||||
|
domain.push(['account_type', 'in', options.accountTypes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await this.client.searchRead<OdooAccount>(
|
||||||
|
'account.account',
|
||||||
|
domain,
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'code',
|
||||||
|
'name',
|
||||||
|
'account_type',
|
||||||
|
'group_id',
|
||||||
|
'root_id',
|
||||||
|
'reconcile',
|
||||||
|
'deprecated',
|
||||||
|
],
|
||||||
|
{ order: 'code' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calcular balances de cada cuenta
|
||||||
|
const balances = await this.getAccountBalances(
|
||||||
|
accounts.map((a) => a.id),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const balanceMap = new Map(balances.map((b) => [b.accountId, b]));
|
||||||
|
|
||||||
|
return accounts.map((account) => {
|
||||||
|
const balance = balanceMap.get(account.id);
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
code: account.code,
|
||||||
|
name: account.name,
|
||||||
|
accountType: account.accountType,
|
||||||
|
level: this.getAccountLevel(account.code),
|
||||||
|
parentId: account.groupId?.[0],
|
||||||
|
debit: balance?.debit || 0,
|
||||||
|
credit: balance?.credit || 0,
|
||||||
|
balance: balance?.balance || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una cuenta por su codigo
|
||||||
|
*/
|
||||||
|
async getAccountByCode(code: string): Promise<OdooAccount | null> {
|
||||||
|
const accounts = await this.client.searchRead<OdooAccount>(
|
||||||
|
'account.account',
|
||||||
|
[['code', '=', code]],
|
||||||
|
undefined,
|
||||||
|
{ limit: 1 }
|
||||||
|
);
|
||||||
|
return accounts[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene cuentas por tipo
|
||||||
|
*/
|
||||||
|
async getAccountsByType(
|
||||||
|
accountType: OdooAccountType
|
||||||
|
): Promise<OdooAccount[]> {
|
||||||
|
return this.client.searchRead<OdooAccount>(
|
||||||
|
'account.account',
|
||||||
|
[
|
||||||
|
['account_type', '=', accountType],
|
||||||
|
['deprecated', '=', false],
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
{ order: 'code' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Journal Entries
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene asientos contables de un periodo
|
||||||
|
*/
|
||||||
|
async getJournalEntries(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
state?: 'draft' | 'posted' | 'cancel';
|
||||||
|
journalId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooJournalEntry>> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['date', '>=', this.formatDate(period.dateFrom)],
|
||||||
|
['date', '<=', this.formatDate(period.dateTo)],
|
||||||
|
['move_type', '=', 'entry'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.state) {
|
||||||
|
domain.push(['state', '=', options.state]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.journalId) {
|
||||||
|
domain.push(['journal_id', '=', options.journalId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<OdooJournalEntry>(
|
||||||
|
'account.move',
|
||||||
|
domain,
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'ref',
|
||||||
|
'date',
|
||||||
|
'move_type',
|
||||||
|
'state',
|
||||||
|
'journal_id',
|
||||||
|
'company_id',
|
||||||
|
'currency_id',
|
||||||
|
'line_ids',
|
||||||
|
'partner_id',
|
||||||
|
'amount_total',
|
||||||
|
'amount_total_signed',
|
||||||
|
'narration',
|
||||||
|
'reversed_entry_id',
|
||||||
|
'create_date',
|
||||||
|
'write_date',
|
||||||
|
],
|
||||||
|
{ order: 'date desc, id desc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las lineas de un asiento contable
|
||||||
|
*/
|
||||||
|
async getJournalEntryLines(
|
||||||
|
moveId: number
|
||||||
|
): Promise<OdooJournalEntryLine[]> {
|
||||||
|
return this.client.searchRead<OdooJournalEntryLine>(
|
||||||
|
'account.move.line',
|
||||||
|
[['move_id', '=', moveId]],
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'move_id',
|
||||||
|
'move_name',
|
||||||
|
'sequence',
|
||||||
|
'name',
|
||||||
|
'ref',
|
||||||
|
'date',
|
||||||
|
'journal_id',
|
||||||
|
'company_id',
|
||||||
|
'account_id',
|
||||||
|
'partner_id',
|
||||||
|
'debit',
|
||||||
|
'credit',
|
||||||
|
'balance',
|
||||||
|
'amount_currency',
|
||||||
|
'currency_id',
|
||||||
|
'reconciled',
|
||||||
|
'full_reconcile_id',
|
||||||
|
'matching_number',
|
||||||
|
'analytic_distribution',
|
||||||
|
'tax_ids',
|
||||||
|
'tax_tag_ids',
|
||||||
|
'tax_line_id',
|
||||||
|
'display_type',
|
||||||
|
],
|
||||||
|
{ order: 'sequence, id' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los diarios contables
|
||||||
|
*/
|
||||||
|
async getJournals(
|
||||||
|
options?: {
|
||||||
|
type?: 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<OdooJournal[]> {
|
||||||
|
const domain: OdooDomain = [['active', '=', true]];
|
||||||
|
|
||||||
|
if (options?.type) {
|
||||||
|
domain.push(['type', '=', options.type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchRead<OdooJournal>(
|
||||||
|
'account.journal',
|
||||||
|
domain,
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'code',
|
||||||
|
'type',
|
||||||
|
'company_id',
|
||||||
|
'currency_id',
|
||||||
|
'default_account_id',
|
||||||
|
'suspense_account_id',
|
||||||
|
'profit_account_id',
|
||||||
|
'loss_account_id',
|
||||||
|
'active',
|
||||||
|
'sequence',
|
||||||
|
],
|
||||||
|
{ order: 'sequence, id' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Trial Balance
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la balanza de comprobacion
|
||||||
|
*/
|
||||||
|
async getTrialBalance(
|
||||||
|
date: Date,
|
||||||
|
options?: {
|
||||||
|
fiscalYearStart?: Date;
|
||||||
|
companyId?: number;
|
||||||
|
accountTypes?: OdooAccountType[];
|
||||||
|
}
|
||||||
|
): Promise<TrialBalanceLine[]> {
|
||||||
|
// Determinar inicio del ejercicio fiscal
|
||||||
|
const fiscalYearStart = options?.fiscalYearStart ||
|
||||||
|
new Date(date.getFullYear(), 0, 1);
|
||||||
|
|
||||||
|
// Obtener cuentas
|
||||||
|
const domain: OdooDomain = [['deprecated', '=', false]];
|
||||||
|
if (options?.accountTypes && options.accountTypes.length > 0) {
|
||||||
|
domain.push(['account_type', 'in', options.accountTypes]);
|
||||||
|
}
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await this.client.searchRead<OdooAccount>(
|
||||||
|
'account.account',
|
||||||
|
domain,
|
||||||
|
['id', 'code', 'name', 'account_type'],
|
||||||
|
{ order: 'code' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accounts.length === 0) return [];
|
||||||
|
|
||||||
|
const accountIds = accounts.map((a) => a.id);
|
||||||
|
|
||||||
|
// Obtener saldos iniciales (antes del periodo)
|
||||||
|
const initialBalances = await this.getAccountBalances(
|
||||||
|
accountIds,
|
||||||
|
{ dateFrom: new Date('1900-01-01'), dateTo: new Date(fiscalYearStart.getTime() - 86400000) },
|
||||||
|
options?.companyId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener movimientos del periodo
|
||||||
|
const periodBalances = await this.getAccountBalances(
|
||||||
|
accountIds,
|
||||||
|
{ dateFrom: fiscalYearStart, dateTo: date },
|
||||||
|
options?.companyId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combinar datos
|
||||||
|
const initialMap = new Map(initialBalances.map((b) => [b.accountId, b]));
|
||||||
|
const periodMap = new Map(periodBalances.map((b) => [b.accountId, b]));
|
||||||
|
|
||||||
|
return accounts.map((account) => {
|
||||||
|
const initial = initialMap.get(account.id);
|
||||||
|
const period = periodMap.get(account.id);
|
||||||
|
|
||||||
|
const initialDebit = initial?.debit || 0;
|
||||||
|
const initialCredit = initial?.credit || 0;
|
||||||
|
const initialBalance = initial?.balance || 0;
|
||||||
|
|
||||||
|
const periodDebit = period?.debit || 0;
|
||||||
|
const periodCredit = period?.credit || 0;
|
||||||
|
const periodBalance = period?.balance || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId: account.id,
|
||||||
|
accountCode: account.code,
|
||||||
|
accountName: account.name,
|
||||||
|
accountType: account.accountType,
|
||||||
|
initialDebit,
|
||||||
|
initialCredit,
|
||||||
|
initialBalance,
|
||||||
|
periodDebit,
|
||||||
|
periodCredit,
|
||||||
|
periodBalance,
|
||||||
|
finalDebit: initialDebit + periodDebit,
|
||||||
|
finalCredit: initialCredit + periodCredit,
|
||||||
|
finalBalance: initialBalance + periodBalance,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Profit and Loss
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado de resultados
|
||||||
|
*/
|
||||||
|
async getProfitAndLoss(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<ProfitAndLossReport> {
|
||||||
|
const connection = this.client.getConnection();
|
||||||
|
if (!connection) {
|
||||||
|
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = options?.companyId || connection.activeCompanyId;
|
||||||
|
const company = connection.companies.find((c) => c.id === companyId);
|
||||||
|
|
||||||
|
// Obtener cuentas de ingreso
|
||||||
|
const incomeAccounts = await this.client.searchRead<OdooAccount>(
|
||||||
|
'account.account',
|
||||||
|
[
|
||||||
|
['account_type', 'in', ['income', 'income_other']],
|
||||||
|
['deprecated', '=', false],
|
||||||
|
['company_id', '=', companyId],
|
||||||
|
],
|
||||||
|
['id', 'code', 'name', 'account_type']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener cuentas de gasto
|
||||||
|
const expenseAccounts = await this.client.searchRead<OdooAccount>(
|
||||||
|
'account.account',
|
||||||
|
[
|
||||||
|
['account_type', 'in', ['expense', 'expense_depreciation', 'expense_direct_cost']],
|
||||||
|
['deprecated', '=', false],
|
||||||
|
['company_id', '=', companyId],
|
||||||
|
],
|
||||||
|
['id', 'code', 'name', 'account_type']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener balances
|
||||||
|
const allAccountIds = [
|
||||||
|
...incomeAccounts.map((a) => a.id),
|
||||||
|
...expenseAccounts.map((a) => a.id),
|
||||||
|
];
|
||||||
|
|
||||||
|
const balances = await this.getAccountBalances(
|
||||||
|
allAccountIds,
|
||||||
|
period,
|
||||||
|
companyId
|
||||||
|
);
|
||||||
|
|
||||||
|
const balanceMap = new Map(balances.map((b) => [b.accountId, b]));
|
||||||
|
|
||||||
|
// Calcular ingresos
|
||||||
|
const incomeLines: ProfitAndLossLine[] = incomeAccounts.map((account) => {
|
||||||
|
const balance = balanceMap.get(account.id);
|
||||||
|
// Ingresos son creditos (balance negativo), invertir signo
|
||||||
|
const amount = -(balance?.balance || 0);
|
||||||
|
return {
|
||||||
|
accountId: account.id,
|
||||||
|
accountCode: account.code,
|
||||||
|
accountName: account.name,
|
||||||
|
amount,
|
||||||
|
percentage: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalIncome = incomeLines.reduce((sum, l) => sum + l.amount, 0);
|
||||||
|
|
||||||
|
// Calcular porcentajes de ingreso
|
||||||
|
incomeLines.forEach((line) => {
|
||||||
|
line.percentage = totalIncome > 0 ? (line.amount / totalIncome) * 100 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular gastos
|
||||||
|
const expenseLines: ProfitAndLossLine[] = expenseAccounts.map((account) => {
|
||||||
|
const balance = balanceMap.get(account.id);
|
||||||
|
// Gastos son debitos (balance positivo)
|
||||||
|
const amount = balance?.balance || 0;
|
||||||
|
return {
|
||||||
|
accountId: account.id,
|
||||||
|
accountCode: account.code,
|
||||||
|
accountName: account.name,
|
||||||
|
amount,
|
||||||
|
percentage: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalExpense = expenseLines.reduce((sum, l) => sum + l.amount, 0);
|
||||||
|
|
||||||
|
// Calcular porcentajes de gasto
|
||||||
|
expenseLines.forEach((line) => {
|
||||||
|
line.percentage = totalExpense > 0 ? (line.amount / totalExpense) * 100 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateFrom: this.formatDate(period.dateFrom),
|
||||||
|
dateTo: this.formatDate(period.dateTo),
|
||||||
|
companyId,
|
||||||
|
companyName: company?.name || 'Unknown',
|
||||||
|
currencyId: company?.currencyId || 0,
|
||||||
|
currencyCode: company?.currencyCode || 'MXN',
|
||||||
|
income: {
|
||||||
|
total: totalIncome,
|
||||||
|
lines: incomeLines.filter((l) => l.amount !== 0).sort((a, b) => b.amount - a.amount),
|
||||||
|
},
|
||||||
|
expense: {
|
||||||
|
total: totalExpense,
|
||||||
|
lines: expenseLines.filter((l) => l.amount !== 0).sort((a, b) => b.amount - a.amount),
|
||||||
|
},
|
||||||
|
netProfit: totalIncome - totalExpense,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Balance Sheet
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el balance general
|
||||||
|
*/
|
||||||
|
async getBalanceSheet(
|
||||||
|
date: Date,
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<BalanceSheetReport> {
|
||||||
|
const connection = this.client.getConnection();
|
||||||
|
if (!connection) {
|
||||||
|
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = options?.companyId || connection.activeCompanyId;
|
||||||
|
const company = connection.companies.find((c) => c.id === companyId);
|
||||||
|
|
||||||
|
// Tipos de cuenta por seccion
|
||||||
|
const assetTypes: OdooAccountType[] = [
|
||||||
|
'asset_receivable',
|
||||||
|
'asset_cash',
|
||||||
|
'asset_current',
|
||||||
|
'asset_non_current',
|
||||||
|
'asset_prepayments',
|
||||||
|
'asset_fixed',
|
||||||
|
];
|
||||||
|
|
||||||
|
const liabilityTypes: OdooAccountType[] = [
|
||||||
|
'liability_payable',
|
||||||
|
'liability_credit_card',
|
||||||
|
'liability_current',
|
||||||
|
'liability_non_current',
|
||||||
|
];
|
||||||
|
|
||||||
|
const equityTypes: OdooAccountType[] = [
|
||||||
|
'equity',
|
||||||
|
'equity_unaffected',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Obtener cuentas por tipo
|
||||||
|
const [assetAccounts, liabilityAccounts, equityAccounts] = await Promise.all([
|
||||||
|
this.getAccountsWithBalances(assetTypes, date, companyId),
|
||||||
|
this.getAccountsWithBalances(liabilityTypes, date, companyId),
|
||||||
|
this.getAccountsWithBalances(equityTypes, date, companyId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Agrupar por tipo de cuenta
|
||||||
|
const assets = this.groupAccountsByType(assetAccounts);
|
||||||
|
const liabilities = this.groupAccountsByType(liabilityAccounts);
|
||||||
|
const equity = this.groupAccountsByType(equityAccounts);
|
||||||
|
|
||||||
|
// Calcular resultado del ejercicio
|
||||||
|
const profitLoss = await this.getProfitAndLoss(
|
||||||
|
{ dateFrom: new Date(date.getFullYear(), 0, 1), dateTo: date },
|
||||||
|
{ companyId }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Agregar resultado del ejercicio al capital
|
||||||
|
if (profitLoss.netProfit !== 0) {
|
||||||
|
equity.subsections.push({
|
||||||
|
name: 'Resultado del Ejercicio',
|
||||||
|
total: profitLoss.netProfit,
|
||||||
|
lines: [{
|
||||||
|
accountId: 0,
|
||||||
|
accountCode: 'RES',
|
||||||
|
accountName: 'Resultado del Periodo',
|
||||||
|
balance: profitLoss.netProfit,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
equity.total += profitLoss.netProfit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: this.formatDate(date),
|
||||||
|
companyId,
|
||||||
|
companyName: company?.name || 'Unknown',
|
||||||
|
currencyId: company?.currencyId || 0,
|
||||||
|
currencyCode: company?.currencyCode || 'MXN',
|
||||||
|
assets,
|
||||||
|
liabilities,
|
||||||
|
equity,
|
||||||
|
totalAssets: assets.total,
|
||||||
|
totalLiabilitiesEquity: liabilities.total + equity.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Tax Report
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el reporte de impuestos
|
||||||
|
*/
|
||||||
|
async getTaxReport(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<TaxReport> {
|
||||||
|
const connection = this.client.getConnection();
|
||||||
|
if (!connection) {
|
||||||
|
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = options?.companyId || connection.activeCompanyId;
|
||||||
|
const company = connection.companies.find((c) => c.id === companyId);
|
||||||
|
|
||||||
|
// Obtener impuestos
|
||||||
|
const taxes = await this.client.searchRead<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
typeTaxUse: 'sale' | 'purchase' | 'none';
|
||||||
|
amount: number;
|
||||||
|
amountType: string;
|
||||||
|
}>(
|
||||||
|
'account.tax',
|
||||||
|
[
|
||||||
|
['company_id', '=', companyId],
|
||||||
|
['active', '=', true],
|
||||||
|
],
|
||||||
|
['id', 'name', 'type_tax_use', 'amount', 'amount_type']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener lineas de movimiento con impuestos en el periodo
|
||||||
|
const taxLines = await this.client.searchRead<{
|
||||||
|
taxLineId: [number, string];
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
balance: number;
|
||||||
|
moveId: [number, string];
|
||||||
|
}>(
|
||||||
|
'account.move.line',
|
||||||
|
[
|
||||||
|
['date', '>=', this.formatDate(period.dateFrom)],
|
||||||
|
['date', '<=', this.formatDate(period.dateTo)],
|
||||||
|
['company_id', '=', companyId],
|
||||||
|
['tax_line_id', '!=', false],
|
||||||
|
['parent_state', '=', 'posted'],
|
||||||
|
],
|
||||||
|
['tax_line_id', 'debit', 'credit', 'balance', 'move_id']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Agrupar por impuesto
|
||||||
|
const taxData = new Map<number, {
|
||||||
|
baseAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
invoiceCount: Set<number>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const line of taxLines) {
|
||||||
|
const taxId = line.taxLineId[0];
|
||||||
|
const moveId = line.moveId[0];
|
||||||
|
|
||||||
|
if (!taxData.has(taxId)) {
|
||||||
|
taxData.set(taxId, {
|
||||||
|
baseAmount: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
invoiceCount: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = taxData.get(taxId)!;
|
||||||
|
data.taxAmount += line.balance;
|
||||||
|
data.invoiceCount.add(moveId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir lineas del reporte
|
||||||
|
const lines: TaxReportLine[] = taxes.map((tax) => {
|
||||||
|
const data = taxData.get(tax.id);
|
||||||
|
const taxAmount = data?.taxAmount || 0;
|
||||||
|
// Estimar base del impuesto
|
||||||
|
const baseAmount = tax.amount > 0 ? taxAmount / (tax.amount / 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
taxId: tax.id,
|
||||||
|
taxName: tax.name,
|
||||||
|
taxType: tax.typeTaxUse,
|
||||||
|
baseAmount: Math.abs(baseAmount),
|
||||||
|
taxAmount: Math.abs(taxAmount),
|
||||||
|
invoiceCount: data?.invoiceCount.size || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular totales
|
||||||
|
const salesLines = lines.filter((l) => l.taxType === 'sale');
|
||||||
|
const purchaseLines = lines.filter((l) => l.taxType === 'purchase');
|
||||||
|
|
||||||
|
const totalSalesBase = salesLines.reduce((sum, l) => sum + l.baseAmount, 0);
|
||||||
|
const totalSalesTax = salesLines.reduce((sum, l) => sum + l.taxAmount, 0);
|
||||||
|
const totalPurchasesBase = purchaseLines.reduce((sum, l) => sum + l.baseAmount, 0);
|
||||||
|
const totalPurchasesTax = purchaseLines.reduce((sum, l) => sum + l.taxAmount, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateFrom: this.formatDate(period.dateFrom),
|
||||||
|
dateTo: this.formatDate(period.dateTo),
|
||||||
|
companyId,
|
||||||
|
companyName: company?.name || 'Unknown',
|
||||||
|
lines: lines.filter((l) => l.taxAmount !== 0),
|
||||||
|
totalSalesBase,
|
||||||
|
totalSalesTax,
|
||||||
|
totalPurchasesBase,
|
||||||
|
totalPurchasesTax,
|
||||||
|
netTax: totalSalesTax - totalPurchasesTax,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Private Helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene balances de cuentas
|
||||||
|
*/
|
||||||
|
private async getAccountBalances(
|
||||||
|
accountIds: number[],
|
||||||
|
period?: DatePeriod,
|
||||||
|
companyId?: number
|
||||||
|
): Promise<AccountBalance[]> {
|
||||||
|
if (accountIds.length === 0) return [];
|
||||||
|
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['account_id', 'in', accountIds],
|
||||||
|
['parent_state', '=', 'posted'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (period) {
|
||||||
|
domain.push(['date', '>=', this.formatDate(period.dateFrom)]);
|
||||||
|
domain.push(['date', '<=', this.formatDate(period.dateTo)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
domain.push(['company_id', '=', companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar read_group para agregar por cuenta
|
||||||
|
const result = await this.client.callMethod<Array<{
|
||||||
|
account_id: [number, string];
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
balance: number;
|
||||||
|
__count: number;
|
||||||
|
}>>(
|
||||||
|
'account.move.line',
|
||||||
|
'read_group',
|
||||||
|
[domain, ['account_id', 'debit', 'credit', 'balance'], ['account_id']],
|
||||||
|
{ lazy: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener informacion de cuentas
|
||||||
|
const accounts = await this.client.read<{
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
accountType: OdooAccountType;
|
||||||
|
}>(
|
||||||
|
'account.account',
|
||||||
|
accountIds,
|
||||||
|
['id', 'code', 'name', 'account_type']
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountMap = new Map(accounts.map((a) => [a.id, a]));
|
||||||
|
|
||||||
|
return result.map((r) => {
|
||||||
|
const account = accountMap.get(r.account_id[0]);
|
||||||
|
return {
|
||||||
|
accountId: r.account_id[0],
|
||||||
|
accountCode: account?.code || '',
|
||||||
|
accountName: account?.name || r.account_id[1],
|
||||||
|
accountType: account?.accountType || 'asset_current',
|
||||||
|
debit: r.debit || 0,
|
||||||
|
credit: r.credit || 0,
|
||||||
|
balance: r.balance || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene cuentas con balances
|
||||||
|
*/
|
||||||
|
private async getAccountsWithBalances(
|
||||||
|
accountTypes: OdooAccountType[],
|
||||||
|
date: Date,
|
||||||
|
companyId: number
|
||||||
|
): Promise<Array<OdooAccount & { balance: number }>> {
|
||||||
|
const accounts = await this.client.searchRead<OdooAccount>(
|
||||||
|
'account.account',
|
||||||
|
[
|
||||||
|
['account_type', 'in', accountTypes],
|
||||||
|
['deprecated', '=', false],
|
||||||
|
['company_id', '=', companyId],
|
||||||
|
],
|
||||||
|
['id', 'code', 'name', 'account_type']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accounts.length === 0) return [];
|
||||||
|
|
||||||
|
const balances = await this.getAccountBalances(
|
||||||
|
accounts.map((a) => a.id),
|
||||||
|
{ dateFrom: new Date('1900-01-01'), dateTo: date },
|
||||||
|
companyId
|
||||||
|
);
|
||||||
|
|
||||||
|
const balanceMap = new Map(balances.map((b) => [b.accountId, b]));
|
||||||
|
|
||||||
|
return accounts.map((account) => ({
|
||||||
|
...account,
|
||||||
|
balance: balanceMap.get(account.id)?.balance || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrupa cuentas por tipo
|
||||||
|
*/
|
||||||
|
private groupAccountsByType(
|
||||||
|
accounts: Array<OdooAccount & { balance: number }>
|
||||||
|
): BalanceSheetSection {
|
||||||
|
const typeGroups = new Map<string, Array<OdooAccount & { balance: number }>>();
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const type = account.accountType;
|
||||||
|
if (!typeGroups.has(type)) {
|
||||||
|
typeGroups.set(type, []);
|
||||||
|
}
|
||||||
|
typeGroups.get(type)!.push(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subsections: BalanceSheetSubsection[] = [];
|
||||||
|
|
||||||
|
for (const [type, accts] of typeGroups) {
|
||||||
|
const lines: BalanceSheetLine[] = accts
|
||||||
|
.filter((a) => a.balance !== 0)
|
||||||
|
.map((a) => ({
|
||||||
|
accountId: a.id,
|
||||||
|
accountCode: a.code,
|
||||||
|
accountName: a.name,
|
||||||
|
balance: a.balance,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (lines.length > 0) {
|
||||||
|
subsections.push({
|
||||||
|
name: this.getAccountTypeName(type as OdooAccountType),
|
||||||
|
total: lines.reduce((sum, l) => sum + l.balance, 0),
|
||||||
|
lines,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: subsections.reduce((sum, s) => sum + s.total, 0),
|
||||||
|
subsections,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el nivel de una cuenta basado en su codigo
|
||||||
|
*/
|
||||||
|
private getAccountLevel(code: string): number {
|
||||||
|
const len = code.replace(/[^0-9]/g, '').length;
|
||||||
|
if (len <= 3) return 1;
|
||||||
|
if (len <= 4) return 2;
|
||||||
|
if (len <= 6) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el nombre legible de un tipo de cuenta
|
||||||
|
*/
|
||||||
|
private getAccountTypeName(type: OdooAccountType): string {
|
||||||
|
const names: Record<OdooAccountType, string> = {
|
||||||
|
asset_receivable: 'Cuentas por Cobrar',
|
||||||
|
asset_cash: 'Efectivo y Equivalentes',
|
||||||
|
asset_current: 'Activo Circulante',
|
||||||
|
asset_non_current: 'Activo No Circulante',
|
||||||
|
asset_prepayments: 'Pagos Anticipados',
|
||||||
|
asset_fixed: 'Activo Fijo',
|
||||||
|
liability_payable: 'Cuentas por Pagar',
|
||||||
|
liability_credit_card: 'Tarjetas de Credito',
|
||||||
|
liability_current: 'Pasivo Circulante',
|
||||||
|
liability_non_current: 'Pasivo No Circulante',
|
||||||
|
equity: 'Capital',
|
||||||
|
equity_unaffected: 'Resultados Acumulados',
|
||||||
|
income: 'Ingresos',
|
||||||
|
income_other: 'Otros Ingresos',
|
||||||
|
expense: 'Gastos',
|
||||||
|
expense_depreciation: 'Depreciacion',
|
||||||
|
expense_direct_cost: 'Costos Directos',
|
||||||
|
off_balance: 'Cuentas de Orden',
|
||||||
|
};
|
||||||
|
return names[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea una fecha a string YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
private formatDate(date: Date): string {
|
||||||
|
return date.toISOString().split('T')[0] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un conector de contabilidad
|
||||||
|
*/
|
||||||
|
export function createAccountingConnector(
|
||||||
|
client: OdooClient
|
||||||
|
): OdooAccountingConnector {
|
||||||
|
return new OdooAccountingConnector(client);
|
||||||
|
}
|
||||||
122
apps/api/src/services/integrations/odoo/index.ts
Normal file
122
apps/api/src/services/integrations/odoo/index.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Odoo ERP Integration
|
||||||
|
* Conector completo para integracion con Odoo ERP (versiones 14, 15, 16, 17)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './odoo.types.js';
|
||||||
|
|
||||||
|
// Client
|
||||||
|
export {
|
||||||
|
OdooClient,
|
||||||
|
createOdooClient,
|
||||||
|
clearSessionCache,
|
||||||
|
getSessionCacheSize,
|
||||||
|
} from './odoo.client.js';
|
||||||
|
|
||||||
|
// Connectors
|
||||||
|
export {
|
||||||
|
OdooAccountingConnector,
|
||||||
|
createAccountingConnector,
|
||||||
|
} from './accounting.connector.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
OdooInvoicingConnector,
|
||||||
|
createInvoicingConnector,
|
||||||
|
} from './invoicing.connector.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
OdooPartnersConnector,
|
||||||
|
createPartnersConnector,
|
||||||
|
} from './partners.connector.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
OdooInventoryConnector,
|
||||||
|
createInventoryConnector,
|
||||||
|
} from './inventory.connector.js';
|
||||||
|
|
||||||
|
// Sync Service
|
||||||
|
export {
|
||||||
|
OdooSyncService,
|
||||||
|
createOdooSyncService,
|
||||||
|
getOdooSyncService,
|
||||||
|
} from './odoo.sync.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Convenience Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { OdooClient, createOdooClient } from './odoo.client.js';
|
||||||
|
import { OdooAccountingConnector, createAccountingConnector } from './accounting.connector.js';
|
||||||
|
import { OdooInvoicingConnector, createInvoicingConnector } from './invoicing.connector.js';
|
||||||
|
import { OdooPartnersConnector, createPartnersConnector } from './partners.connector.js';
|
||||||
|
import { OdooInventoryConnector, createInventoryConnector } from './inventory.connector.js';
|
||||||
|
import { OdooConfig } from './odoo.types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instancia completa de Odoo con todos los conectores
|
||||||
|
*/
|
||||||
|
export interface OdooInstance {
|
||||||
|
client: OdooClient;
|
||||||
|
accounting: OdooAccountingConnector;
|
||||||
|
invoicing: OdooInvoicingConnector;
|
||||||
|
partners: OdooPartnersConnector;
|
||||||
|
inventory: OdooInventoryConnector;
|
||||||
|
connect: () => Promise<void>;
|
||||||
|
disconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia completa de Odoo con todos los conectores
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const odoo = createOdooInstance({
|
||||||
|
* url: 'https://mycompany.odoo.com',
|
||||||
|
* database: 'mydb',
|
||||||
|
* username: 'admin',
|
||||||
|
* password: 'admin123',
|
||||||
|
* version: 16,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* await odoo.connect();
|
||||||
|
*
|
||||||
|
* // Obtener clientes
|
||||||
|
* const customers = await odoo.partners.getCustomers();
|
||||||
|
*
|
||||||
|
* // Obtener facturas del mes
|
||||||
|
* const invoices = await odoo.invoicing.getCustomerInvoices({
|
||||||
|
* dateFrom: new Date('2024-01-01'),
|
||||||
|
* dateTo: new Date('2024-01-31'),
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Obtener balance general
|
||||||
|
* const balanceSheet = await odoo.accounting.getBalanceSheet(new Date());
|
||||||
|
*
|
||||||
|
* // Obtener productos con stock
|
||||||
|
* const products = await odoo.inventory.getStockableProducts();
|
||||||
|
*
|
||||||
|
* odoo.disconnect();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createOdooInstance(config: OdooConfig): OdooInstance {
|
||||||
|
const client = createOdooClient(config);
|
||||||
|
const accounting = createAccountingConnector(client);
|
||||||
|
const invoicing = createInvoicingConnector(client);
|
||||||
|
const partners = createPartnersConnector(client);
|
||||||
|
const inventory = createInventoryConnector(client);
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
accounting,
|
||||||
|
invoicing,
|
||||||
|
partners,
|
||||||
|
inventory,
|
||||||
|
connect: async () => {
|
||||||
|
await client.authenticate();
|
||||||
|
},
|
||||||
|
disconnect: () => {
|
||||||
|
client.disconnect();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
865
apps/api/src/services/integrations/odoo/inventory.connector.ts
Normal file
865
apps/api/src/services/integrations/odoo/inventory.connector.ts
Normal file
@@ -0,0 +1,865 @@
|
|||||||
|
/**
|
||||||
|
* Odoo Inventory Connector
|
||||||
|
* Conector para funciones de inventario de Odoo ERP
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { OdooClient } from './odoo.client.js';
|
||||||
|
import {
|
||||||
|
OdooProduct,
|
||||||
|
OdooProductType,
|
||||||
|
OdooStockLevel,
|
||||||
|
OdooStockMove,
|
||||||
|
OdooStockValuation,
|
||||||
|
OdooDomain,
|
||||||
|
OdooPagination,
|
||||||
|
OdooPaginatedResponse,
|
||||||
|
OdooError,
|
||||||
|
} from './odoo.types.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tipos internos
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface DatePeriod {
|
||||||
|
dateFrom: Date;
|
||||||
|
dateTo: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockLocation {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
completeName: string;
|
||||||
|
locationType: 'supplier' | 'view' | 'internal' | 'customer' | 'inventory' | 'production' | 'transit';
|
||||||
|
companyId?: [number, string];
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockWarehouse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
companyId: [number, string];
|
||||||
|
lotStockId: [number, string];
|
||||||
|
viewLocationId: [number, string];
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
completeName: string;
|
||||||
|
parentId?: [number, string];
|
||||||
|
productCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockMoveSummary {
|
||||||
|
incoming: number;
|
||||||
|
outgoing: number;
|
||||||
|
internal: number;
|
||||||
|
incomingValue: number;
|
||||||
|
outgoingValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Inventory Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector de inventario para Odoo
|
||||||
|
*/
|
||||||
|
export class OdooInventoryConnector {
|
||||||
|
private client: OdooClient;
|
||||||
|
|
||||||
|
constructor(client: OdooClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Products
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene productos
|
||||||
|
*/
|
||||||
|
async getProducts(
|
||||||
|
options?: {
|
||||||
|
search?: string;
|
||||||
|
type?: OdooProductType;
|
||||||
|
categoryId?: number;
|
||||||
|
saleOk?: boolean;
|
||||||
|
purchaseOk?: boolean;
|
||||||
|
includeInactive?: boolean;
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooProduct>> {
|
||||||
|
const domain: OdooDomain = [];
|
||||||
|
|
||||||
|
if (!options?.includeInactive) {
|
||||||
|
domain.push(['active', '=', true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.search) {
|
||||||
|
domain.push('|');
|
||||||
|
domain.push('|');
|
||||||
|
domain.push(['name', 'ilike', options.search]);
|
||||||
|
domain.push(['default_code', 'ilike', options.search]);
|
||||||
|
domain.push(['barcode', 'ilike', options.search]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.type) {
|
||||||
|
domain.push(['type', '=', options.type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.categoryId) {
|
||||||
|
domain.push(['categ_id', 'child_of', options.categoryId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.saleOk !== undefined) {
|
||||||
|
domain.push(['sale_ok', '=', options.saleOk]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.purchaseOk !== undefined) {
|
||||||
|
domain.push(['purchase_ok', '=', options.purchaseOk]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push('|');
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
domain.push(['company_id', '=', false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<OdooProduct>(
|
||||||
|
'product.product',
|
||||||
|
domain,
|
||||||
|
this.getProductFields(),
|
||||||
|
{ order: 'name asc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene productos almacenables (con stock)
|
||||||
|
*/
|
||||||
|
async getStockableProducts(
|
||||||
|
options?: {
|
||||||
|
categoryId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooProduct>> {
|
||||||
|
return this.getProducts({
|
||||||
|
type: 'product',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca producto por codigo interno
|
||||||
|
*/
|
||||||
|
async getProductByCode(code: string): Promise<OdooProduct | null> {
|
||||||
|
const products = await this.client.searchRead<OdooProduct>(
|
||||||
|
'product.product',
|
||||||
|
[['default_code', '=', code]],
|
||||||
|
this.getProductFields(),
|
||||||
|
{ limit: 1 }
|
||||||
|
);
|
||||||
|
return products[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca producto por codigo de barras
|
||||||
|
*/
|
||||||
|
async getProductByBarcode(barcode: string): Promise<OdooProduct | null> {
|
||||||
|
const products = await this.client.searchRead<OdooProduct>(
|
||||||
|
'product.product',
|
||||||
|
[['barcode', '=', barcode]],
|
||||||
|
this.getProductFields(),
|
||||||
|
{ limit: 1 }
|
||||||
|
);
|
||||||
|
return products[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un producto por ID
|
||||||
|
*/
|
||||||
|
async getProductById(productId: number): Promise<OdooProduct | null> {
|
||||||
|
const products = await this.client.read<OdooProduct>(
|
||||||
|
'product.product',
|
||||||
|
[productId],
|
||||||
|
this.getProductFields()
|
||||||
|
);
|
||||||
|
return products[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene categorias de productos
|
||||||
|
*/
|
||||||
|
async getProductCategories(
|
||||||
|
options?: {
|
||||||
|
parentId?: number;
|
||||||
|
}
|
||||||
|
): Promise<ProductCategory[]> {
|
||||||
|
const domain: OdooDomain = [];
|
||||||
|
|
||||||
|
if (options?.parentId !== undefined) {
|
||||||
|
if (options.parentId === 0) {
|
||||||
|
domain.push(['parent_id', '=', false]);
|
||||||
|
} else {
|
||||||
|
domain.push(['parent_id', '=', options.parentId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchRead<ProductCategory>(
|
||||||
|
'product.category',
|
||||||
|
domain,
|
||||||
|
['id', 'name', 'complete_name', 'parent_id', 'product_count'],
|
||||||
|
{ order: 'complete_name asc' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Stock Levels
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene niveles de stock
|
||||||
|
*/
|
||||||
|
async getStockLevels(
|
||||||
|
options?: {
|
||||||
|
productIds?: number[];
|
||||||
|
locationId?: number;
|
||||||
|
warehouseId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
onlyAvailable?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<OdooStockLevel[]> {
|
||||||
|
// Primero, obtener las ubicaciones relevantes
|
||||||
|
let locationIds: number[] = [];
|
||||||
|
|
||||||
|
if (options?.locationId) {
|
||||||
|
locationIds = [options.locationId];
|
||||||
|
} else if (options?.warehouseId) {
|
||||||
|
const warehouse = await this.getWarehouse(options.warehouseId);
|
||||||
|
if (warehouse) {
|
||||||
|
locationIds = [warehouse.lotStockId[0]];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Obtener todas las ubicaciones internas
|
||||||
|
const locations = await this.getStockLocations({
|
||||||
|
locationType: 'internal',
|
||||||
|
companyId: options?.companyId,
|
||||||
|
});
|
||||||
|
locationIds = locations.map((l) => l.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationIds.length === 0) return [];
|
||||||
|
|
||||||
|
// Construir dominio para stock.quant
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['location_id', 'in', locationIds],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.productIds && options.productIds.length > 0) {
|
||||||
|
domain.push(['product_id', 'in', options.productIds]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.onlyAvailable) {
|
||||||
|
domain.push(['available_quantity', '>', 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quants = await this.client.searchRead<{
|
||||||
|
productId: [number, string];
|
||||||
|
locationId: [number, string];
|
||||||
|
lotId: [number, string] | false;
|
||||||
|
packageId: [number, string] | false;
|
||||||
|
quantity: number;
|
||||||
|
reservedQuantity: number;
|
||||||
|
availableQuantity: number;
|
||||||
|
productUomId: [number, string];
|
||||||
|
}>(
|
||||||
|
'stock.quant',
|
||||||
|
domain,
|
||||||
|
[
|
||||||
|
'product_id',
|
||||||
|
'location_id',
|
||||||
|
'lot_id',
|
||||||
|
'package_id',
|
||||||
|
'quantity',
|
||||||
|
'reserved_quantity',
|
||||||
|
'available_quantity',
|
||||||
|
'product_uom_id',
|
||||||
|
],
|
||||||
|
{ order: 'product_id, location_id' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener informacion de productos
|
||||||
|
const productIds = [...new Set(quants.map((q) => q.productId[0]))];
|
||||||
|
const products = await this.client.read<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
defaultCode: string;
|
||||||
|
}>(
|
||||||
|
'product.product',
|
||||||
|
productIds,
|
||||||
|
['id', 'name', 'default_code']
|
||||||
|
);
|
||||||
|
|
||||||
|
const productMap = new Map(products.map((p) => [p.id, p]));
|
||||||
|
|
||||||
|
return quants.map((quant) => {
|
||||||
|
const product = productMap.get(quant.productId[0]);
|
||||||
|
return {
|
||||||
|
productId: quant.productId[0],
|
||||||
|
productName: product?.name || quant.productId[1],
|
||||||
|
productCode: product?.defaultCode,
|
||||||
|
locationId: quant.locationId[0],
|
||||||
|
locationName: quant.locationId[1],
|
||||||
|
lotId: quant.lotId ? quant.lotId[0] : undefined,
|
||||||
|
lotName: quant.lotId ? quant.lotId[1] : undefined,
|
||||||
|
packageId: quant.packageId ? quant.packageId[0] : undefined,
|
||||||
|
packageName: quant.packageId ? quant.packageId[1] : undefined,
|
||||||
|
quantity: quant.quantity,
|
||||||
|
reservedQuantity: quant.reservedQuantity,
|
||||||
|
availableQuantity: quant.availableQuantity,
|
||||||
|
uomId: quant.productUomId[0],
|
||||||
|
uomName: quant.productUomId[1],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene stock de un producto
|
||||||
|
*/
|
||||||
|
async getProductStock(
|
||||||
|
productId: number,
|
||||||
|
options?: {
|
||||||
|
locationId?: number;
|
||||||
|
warehouseId?: number;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
onHand: number;
|
||||||
|
reserved: number;
|
||||||
|
available: number;
|
||||||
|
incoming: number;
|
||||||
|
outgoing: number;
|
||||||
|
}> {
|
||||||
|
const levels = await this.getStockLevels({
|
||||||
|
productIds: [productId],
|
||||||
|
locationId: options?.locationId,
|
||||||
|
warehouseId: options?.warehouseId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onHand = levels.reduce((sum, l) => sum + l.quantity, 0);
|
||||||
|
const reserved = levels.reduce((sum, l) => sum + l.reservedQuantity, 0);
|
||||||
|
const available = levels.reduce((sum, l) => sum + l.availableQuantity, 0);
|
||||||
|
|
||||||
|
// Obtener movimientos pendientes
|
||||||
|
const [incoming, outgoing] = await Promise.all([
|
||||||
|
this.getPendingMovements(productId, 'incoming', options?.locationId),
|
||||||
|
this.getPendingMovements(productId, 'outgoing', options?.locationId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onHand,
|
||||||
|
reserved,
|
||||||
|
available,
|
||||||
|
incoming,
|
||||||
|
outgoing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene stock agrupado por producto
|
||||||
|
*/
|
||||||
|
async getStockByProduct(
|
||||||
|
options?: {
|
||||||
|
categoryId?: number;
|
||||||
|
warehouseId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
onlyWithStock?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<Array<{
|
||||||
|
productId: number;
|
||||||
|
productName: string;
|
||||||
|
productCode?: string;
|
||||||
|
onHand: number;
|
||||||
|
reserved: number;
|
||||||
|
available: number;
|
||||||
|
value: number;
|
||||||
|
}>> {
|
||||||
|
// Obtener productos
|
||||||
|
const productDomain: OdooDomain = [
|
||||||
|
['type', '=', 'product'],
|
||||||
|
['active', '=', true],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.categoryId) {
|
||||||
|
productDomain.push(['categ_id', 'child_of', options.categoryId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = await this.client.searchRead<OdooProduct>(
|
||||||
|
'product.product',
|
||||||
|
productDomain,
|
||||||
|
['id', 'name', 'default_code', 'standard_price', 'qty_available', 'virtual_available']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options?.onlyWithStock) {
|
||||||
|
return products
|
||||||
|
.filter((p) => (p.qtyAvailable || 0) > 0)
|
||||||
|
.map((p) => ({
|
||||||
|
productId: p.id,
|
||||||
|
productName: p.name,
|
||||||
|
productCode: p.defaultCode,
|
||||||
|
onHand: p.qtyAvailable || 0,
|
||||||
|
reserved: (p.qtyAvailable || 0) - (p.virtualAvailable || 0),
|
||||||
|
available: p.virtualAvailable || 0,
|
||||||
|
value: (p.qtyAvailable || 0) * p.standardPrice,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return products.map((p) => ({
|
||||||
|
productId: p.id,
|
||||||
|
productName: p.name,
|
||||||
|
productCode: p.defaultCode,
|
||||||
|
onHand: p.qtyAvailable || 0,
|
||||||
|
reserved: Math.max(0, (p.qtyAvailable || 0) - (p.virtualAvailable || 0)),
|
||||||
|
available: p.virtualAvailable || 0,
|
||||||
|
value: (p.qtyAvailable || 0) * p.standardPrice,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Stock Moves
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene movimientos de stock de un periodo
|
||||||
|
*/
|
||||||
|
async getStockMoves(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
productId?: number;
|
||||||
|
locationId?: number;
|
||||||
|
pickingCode?: 'incoming' | 'outgoing' | 'internal';
|
||||||
|
state?: 'draft' | 'waiting' | 'confirmed' | 'assigned' | 'done' | 'cancel';
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooStockMove>> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['date', '>=', this.formatDateTime(period.dateFrom)],
|
||||||
|
['date', '<=', this.formatDateTime(period.dateTo)],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.productId) {
|
||||||
|
domain.push(['product_id', '=', options.productId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.locationId) {
|
||||||
|
domain.push('|');
|
||||||
|
domain.push(['location_id', '=', options.locationId]);
|
||||||
|
domain.push(['location_dest_id', '=', options.locationId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.pickingCode) {
|
||||||
|
domain.push(['picking_code', '=', options.pickingCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.state) {
|
||||||
|
domain.push(['state', '=', options.state]);
|
||||||
|
} else {
|
||||||
|
domain.push(['state', '=', 'done']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<OdooStockMove>(
|
||||||
|
'stock.move',
|
||||||
|
domain,
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'reference',
|
||||||
|
'date',
|
||||||
|
'product_id',
|
||||||
|
'product_uom_qty',
|
||||||
|
'product_uom',
|
||||||
|
'location_id',
|
||||||
|
'location_dest_id',
|
||||||
|
'state',
|
||||||
|
'picking_id',
|
||||||
|
'picking_code',
|
||||||
|
'origin_returned_move_id',
|
||||||
|
'price_unit',
|
||||||
|
'company_id',
|
||||||
|
'partner_id',
|
||||||
|
'origin',
|
||||||
|
'create_date',
|
||||||
|
'write_date',
|
||||||
|
],
|
||||||
|
{ order: 'date desc, id desc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de movimientos de stock
|
||||||
|
*/
|
||||||
|
async getStockMoveSummary(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
productId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<StockMoveSummary> {
|
||||||
|
const moves = await this.getStockMoves(period, {
|
||||||
|
productId: options?.productId,
|
||||||
|
companyId: options?.companyId,
|
||||||
|
state: 'done',
|
||||||
|
pagination: { limit: 10000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
let incoming = 0;
|
||||||
|
let outgoing = 0;
|
||||||
|
let internal = 0;
|
||||||
|
let incomingValue = 0;
|
||||||
|
let outgoingValue = 0;
|
||||||
|
|
||||||
|
for (const move of moves.items) {
|
||||||
|
const value = move.productUomQty * move.priceUnit;
|
||||||
|
|
||||||
|
switch (move.pickingCode) {
|
||||||
|
case 'incoming':
|
||||||
|
incoming += move.productUomQty;
|
||||||
|
incomingValue += value;
|
||||||
|
break;
|
||||||
|
case 'outgoing':
|
||||||
|
outgoing += move.productUomQty;
|
||||||
|
outgoingValue += value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
internal += move.productUomQty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
incoming,
|
||||||
|
outgoing,
|
||||||
|
internal,
|
||||||
|
incomingValue,
|
||||||
|
outgoingValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene historial de movimientos de un producto
|
||||||
|
*/
|
||||||
|
async getProductMovementHistory(
|
||||||
|
productId: number,
|
||||||
|
options?: {
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
locationId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooStockMove>> {
|
||||||
|
const period: DatePeriod = {
|
||||||
|
dateFrom: options?.dateFrom || new Date('1900-01-01'),
|
||||||
|
dateTo: options?.dateTo || new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.getStockMoves(period, {
|
||||||
|
productId,
|
||||||
|
locationId: options?.locationId,
|
||||||
|
pagination: options?.pagination,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Stock Valuation
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene valoracion de inventario
|
||||||
|
*/
|
||||||
|
async getStockValuation(
|
||||||
|
options?: {
|
||||||
|
categoryId?: number;
|
||||||
|
warehouseId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
date?: Date;
|
||||||
|
}
|
||||||
|
): Promise<OdooStockValuation[]> {
|
||||||
|
const connection = this.client.getConnection();
|
||||||
|
if (!connection) {
|
||||||
|
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = options?.companyId || connection.activeCompanyId;
|
||||||
|
const company = connection.companies.find((c) => c.id === companyId);
|
||||||
|
|
||||||
|
// Obtener productos con stock
|
||||||
|
const stockByProduct = await this.getStockByProduct({
|
||||||
|
categoryId: options?.categoryId,
|
||||||
|
warehouseId: options?.warehouseId,
|
||||||
|
companyId,
|
||||||
|
onlyWithStock: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener informacion de costos de productos
|
||||||
|
const productIds = stockByProduct.map((s) => s.productId);
|
||||||
|
|
||||||
|
if (productIds.length === 0) return [];
|
||||||
|
|
||||||
|
const products = await this.client.read<{
|
||||||
|
id: number;
|
||||||
|
standardPrice: number;
|
||||||
|
costMethod: string;
|
||||||
|
}>(
|
||||||
|
'product.product',
|
||||||
|
productIds,
|
||||||
|
['id', 'standard_price', 'cost_method']
|
||||||
|
);
|
||||||
|
|
||||||
|
const productMap = new Map(products.map((p) => [p.id, p]));
|
||||||
|
|
||||||
|
return stockByProduct.map((stock) => {
|
||||||
|
const product = productMap.get(stock.productId);
|
||||||
|
const unitCost = product?.standardPrice || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
productId: stock.productId,
|
||||||
|
productName: stock.productName,
|
||||||
|
productCode: stock.productCode,
|
||||||
|
quantity: stock.onHand,
|
||||||
|
unitCost,
|
||||||
|
totalValue: stock.onHand * unitCost,
|
||||||
|
currencyId: company?.currencyId || 0,
|
||||||
|
currencyCode: company?.currencyCode || 'MXN',
|
||||||
|
valuationMethod: product?.costMethod || 'standard',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene valor total del inventario
|
||||||
|
*/
|
||||||
|
async getTotalInventoryValue(
|
||||||
|
options?: {
|
||||||
|
categoryId?: number;
|
||||||
|
warehouseId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
totalValue: number;
|
||||||
|
totalProducts: number;
|
||||||
|
totalQuantity: number;
|
||||||
|
currencyCode: string;
|
||||||
|
}> {
|
||||||
|
const valuation = await this.getStockValuation(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalValue: valuation.reduce((sum, v) => sum + v.totalValue, 0),
|
||||||
|
totalProducts: valuation.length,
|
||||||
|
totalQuantity: valuation.reduce((sum, v) => sum + v.quantity, 0),
|
||||||
|
currencyCode: valuation[0]?.currencyCode || 'MXN',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Locations & Warehouses
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene ubicaciones de stock
|
||||||
|
*/
|
||||||
|
async getStockLocations(
|
||||||
|
options?: {
|
||||||
|
locationType?: 'supplier' | 'view' | 'internal' | 'customer' | 'inventory' | 'production' | 'transit';
|
||||||
|
warehouseId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
includeInactive?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<StockLocation[]> {
|
||||||
|
const domain: OdooDomain = [];
|
||||||
|
|
||||||
|
if (!options?.includeInactive) {
|
||||||
|
domain.push(['active', '=', true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.locationType) {
|
||||||
|
domain.push(['usage', '=', options.locationType]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.warehouseId) {
|
||||||
|
domain.push(['warehouse_id', '=', options.warehouseId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push('|');
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
domain.push(['company_id', '=', false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const locations = await this.client.searchRead<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
completeName: string;
|
||||||
|
usage: string;
|
||||||
|
companyId: [number, string] | false;
|
||||||
|
active: boolean;
|
||||||
|
}>(
|
||||||
|
'stock.location',
|
||||||
|
domain,
|
||||||
|
['id', 'name', 'complete_name', 'usage', 'company_id', 'active'],
|
||||||
|
{ order: 'complete_name asc' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return locations.map((loc) => ({
|
||||||
|
id: loc.id,
|
||||||
|
name: loc.name,
|
||||||
|
completeName: loc.completeName,
|
||||||
|
locationType: loc.usage as StockLocation['locationType'],
|
||||||
|
companyId: loc.companyId ? [loc.companyId[0], loc.companyId[1]] : undefined,
|
||||||
|
active: loc.active,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene almacenes
|
||||||
|
*/
|
||||||
|
async getWarehouses(
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<StockWarehouse[]> {
|
||||||
|
const domain: OdooDomain = [['active', '=', true]];
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchRead<StockWarehouse>(
|
||||||
|
'stock.warehouse',
|
||||||
|
domain,
|
||||||
|
['id', 'name', 'code', 'company_id', 'lot_stock_id', 'view_location_id', 'active'],
|
||||||
|
{ order: 'name asc' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un almacen por ID
|
||||||
|
*/
|
||||||
|
async getWarehouse(warehouseId: number): Promise<StockWarehouse | null> {
|
||||||
|
const warehouses = await this.client.read<StockWarehouse>(
|
||||||
|
'stock.warehouse',
|
||||||
|
[warehouseId],
|
||||||
|
['id', 'name', 'code', 'company_id', 'lot_stock_id', 'view_location_id', 'active']
|
||||||
|
);
|
||||||
|
return warehouses[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Private Helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene movimientos pendientes (entrantes o salientes)
|
||||||
|
*/
|
||||||
|
private async getPendingMovements(
|
||||||
|
productId: number,
|
||||||
|
direction: 'incoming' | 'outgoing',
|
||||||
|
locationId?: number
|
||||||
|
): Promise<number> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['product_id', '=', productId],
|
||||||
|
['state', 'in', ['waiting', 'confirmed', 'assigned']],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (direction === 'incoming') {
|
||||||
|
domain.push(['picking_code', '=', 'incoming']);
|
||||||
|
} else {
|
||||||
|
domain.push(['picking_code', '=', 'outgoing']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationId) {
|
||||||
|
const field = direction === 'incoming' ? 'location_dest_id' : 'location_id';
|
||||||
|
domain.push([field, '=', locationId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const moves = await this.client.searchRead<{
|
||||||
|
productUomQty: number;
|
||||||
|
}>(
|
||||||
|
'stock.move',
|
||||||
|
domain,
|
||||||
|
['product_uom_qty']
|
||||||
|
);
|
||||||
|
|
||||||
|
return moves.reduce((sum, m) => sum + m.productUomQty, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Campos a leer de productos
|
||||||
|
*/
|
||||||
|
private getProductFields(): string[] {
|
||||||
|
return [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'display_name',
|
||||||
|
'default_code',
|
||||||
|
'barcode',
|
||||||
|
'type',
|
||||||
|
'categ_id',
|
||||||
|
'list_price',
|
||||||
|
'standard_price',
|
||||||
|
'sale_ok',
|
||||||
|
'purchase_ok',
|
||||||
|
'active',
|
||||||
|
'uom_id',
|
||||||
|
'uom_po_id',
|
||||||
|
'company_id',
|
||||||
|
'description',
|
||||||
|
'description_sale',
|
||||||
|
'description_purchase',
|
||||||
|
'weight',
|
||||||
|
'volume',
|
||||||
|
'tracking',
|
||||||
|
'property_account_income_id',
|
||||||
|
'property_account_expense_id',
|
||||||
|
'taxes_id',
|
||||||
|
'supplier_taxes_id',
|
||||||
|
'qty_available',
|
||||||
|
'virtual_available',
|
||||||
|
'incoming_qty',
|
||||||
|
'outgoing_qty',
|
||||||
|
'free_qty',
|
||||||
|
'create_date',
|
||||||
|
'write_date',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea una fecha a string YYYY-MM-DD HH:MM:SS
|
||||||
|
*/
|
||||||
|
private formatDateTime(date: Date): string {
|
||||||
|
return date.toISOString().replace('T', ' ').split('.')[0] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un conector de inventario
|
||||||
|
*/
|
||||||
|
export function createInventoryConnector(
|
||||||
|
client: OdooClient
|
||||||
|
): OdooInventoryConnector {
|
||||||
|
return new OdooInventoryConnector(client);
|
||||||
|
}
|
||||||
939
apps/api/src/services/integrations/odoo/invoicing.connector.ts
Normal file
939
apps/api/src/services/integrations/odoo/invoicing.connector.ts
Normal file
@@ -0,0 +1,939 @@
|
|||||||
|
/**
|
||||||
|
* Odoo Invoicing Connector
|
||||||
|
* Conector para funciones de facturacion de Odoo ERP
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { OdooClient } from './odoo.client.js';
|
||||||
|
import {
|
||||||
|
OdooInvoice,
|
||||||
|
OdooInvoiceLine,
|
||||||
|
OdooInvoiceType,
|
||||||
|
OdooInvoiceState,
|
||||||
|
OdooPaymentState,
|
||||||
|
OdooPayment,
|
||||||
|
OdooBankStatement,
|
||||||
|
OdooBankStatementLine,
|
||||||
|
OdooDomain,
|
||||||
|
OdooPagination,
|
||||||
|
OdooPaginatedResponse,
|
||||||
|
OdooError,
|
||||||
|
} from './odoo.types.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tipos internos
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface DatePeriod {
|
||||||
|
dateFrom: Date;
|
||||||
|
dateTo: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceSummary {
|
||||||
|
total: number;
|
||||||
|
totalPaid: number;
|
||||||
|
totalPending: number;
|
||||||
|
count: number;
|
||||||
|
countPaid: number;
|
||||||
|
countPending: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentSummary {
|
||||||
|
totalInbound: number;
|
||||||
|
totalOutbound: number;
|
||||||
|
countInbound: number;
|
||||||
|
countOutbound: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invoicing Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector de facturacion para Odoo
|
||||||
|
*/
|
||||||
|
export class OdooInvoicingConnector {
|
||||||
|
private client: OdooClient;
|
||||||
|
|
||||||
|
constructor(client: OdooClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Customer Invoices
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas de cliente de un periodo
|
||||||
|
*/
|
||||||
|
async getCustomerInvoices(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
state?: OdooInvoiceState;
|
||||||
|
paymentState?: OdooPaymentState;
|
||||||
|
partnerId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
includeRefunds?: boolean;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooInvoice>> {
|
||||||
|
const invoiceTypes: OdooInvoiceType[] = ['out_invoice'];
|
||||||
|
if (options?.includeRefunds) {
|
||||||
|
invoiceTypes.push('out_refund');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getInvoices(period, invoiceTypes, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene notas de credito de cliente
|
||||||
|
*/
|
||||||
|
async getCustomerRefunds(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
state?: OdooInvoiceState;
|
||||||
|
partnerId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooInvoice>> {
|
||||||
|
return this.getInvoices(period, ['out_refund'], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de facturas de cliente
|
||||||
|
*/
|
||||||
|
async getCustomerInvoiceSummary(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<InvoiceSummary> {
|
||||||
|
return this.getInvoiceSummary(period, ['out_invoice', 'out_refund'], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Vendor Bills
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas de proveedor de un periodo
|
||||||
|
*/
|
||||||
|
async getVendorBills(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
state?: OdooInvoiceState;
|
||||||
|
paymentState?: OdooPaymentState;
|
||||||
|
partnerId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
includeRefunds?: boolean;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooInvoice>> {
|
||||||
|
const invoiceTypes: OdooInvoiceType[] = ['in_invoice'];
|
||||||
|
if (options?.includeRefunds) {
|
||||||
|
invoiceTypes.push('in_refund');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getInvoices(period, invoiceTypes, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene notas de credito de proveedor
|
||||||
|
*/
|
||||||
|
async getVendorRefunds(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
state?: OdooInvoiceState;
|
||||||
|
partnerId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooInvoice>> {
|
||||||
|
return this.getInvoices(period, ['in_refund'], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de facturas de proveedor
|
||||||
|
*/
|
||||||
|
async getVendorBillSummary(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<InvoiceSummary> {
|
||||||
|
return this.getInvoiceSummary(period, ['in_invoice', 'in_refund'], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Open Invoices
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas abiertas (pendientes de pago)
|
||||||
|
*/
|
||||||
|
async getOpenInvoices(
|
||||||
|
options?: {
|
||||||
|
type?: 'customer' | 'vendor' | 'all';
|
||||||
|
partnerId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
dueDateBefore?: Date;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooInvoice>> {
|
||||||
|
let invoiceTypes: OdooInvoiceType[];
|
||||||
|
|
||||||
|
switch (options?.type) {
|
||||||
|
case 'customer':
|
||||||
|
invoiceTypes = ['out_invoice', 'out_refund'];
|
||||||
|
break;
|
||||||
|
case 'vendor':
|
||||||
|
invoiceTypes = ['in_invoice', 'in_refund'];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
invoiceTypes = ['out_invoice', 'out_refund', 'in_invoice', 'in_refund'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['move_type', 'in', invoiceTypes],
|
||||||
|
['state', '=', 'posted'],
|
||||||
|
['payment_state', 'in', ['not_paid', 'partial']],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.partnerId) {
|
||||||
|
domain.push(['partner_id', '=', options.partnerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.dueDateBefore) {
|
||||||
|
domain.push(['invoice_date_due', '<=', this.formatDate(options.dueDateBefore)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<OdooInvoice>(
|
||||||
|
'account.move',
|
||||||
|
domain,
|
||||||
|
this.getInvoiceFields(),
|
||||||
|
{ order: 'invoice_date_due asc, id desc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas vencidas
|
||||||
|
*/
|
||||||
|
async getOverdueInvoices(
|
||||||
|
options?: {
|
||||||
|
type?: 'customer' | 'vendor' | 'all';
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooInvoice>> {
|
||||||
|
return this.getOpenInvoices({
|
||||||
|
...options,
|
||||||
|
dueDateBefore: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el monto total de facturas abiertas
|
||||||
|
*/
|
||||||
|
async getOpenInvoicesTotal(
|
||||||
|
options?: {
|
||||||
|
type?: 'customer' | 'vendor' | 'all';
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
totalAmount: number;
|
||||||
|
totalResidual: number;
|
||||||
|
count: number;
|
||||||
|
overdueAmount: number;
|
||||||
|
overdueCount: number;
|
||||||
|
}> {
|
||||||
|
const allOpen = await this.getOpenInvoices({
|
||||||
|
type: options?.type,
|
||||||
|
companyId: options?.companyId,
|
||||||
|
pagination: { limit: 10000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let overdueAmount = 0;
|
||||||
|
let overdueCount = 0;
|
||||||
|
|
||||||
|
for (const invoice of allOpen.items) {
|
||||||
|
if (invoice.invoiceDateDue && new Date(invoice.invoiceDateDue) < now) {
|
||||||
|
overdueAmount += invoice.amountResidual;
|
||||||
|
overdueCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAmount: allOpen.items.reduce((sum, i) => sum + i.amountTotal, 0),
|
||||||
|
totalResidual: allOpen.items.reduce((sum, i) => sum + i.amountResidual, 0),
|
||||||
|
count: allOpen.total,
|
||||||
|
overdueAmount,
|
||||||
|
overdueCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Invoice Lines
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las lineas de una factura
|
||||||
|
*/
|
||||||
|
async getInvoiceLines(invoiceId: number): Promise<OdooInvoiceLine[]> {
|
||||||
|
return this.client.searchRead<OdooInvoiceLine>(
|
||||||
|
'account.move.line',
|
||||||
|
[
|
||||||
|
['move_id', '=', invoiceId],
|
||||||
|
['display_type', 'in', ['product', 'line_section', 'line_note']],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'move_id',
|
||||||
|
'sequence',
|
||||||
|
'name',
|
||||||
|
'product_id',
|
||||||
|
'product_uom_id',
|
||||||
|
'quantity',
|
||||||
|
'price_unit',
|
||||||
|
'discount',
|
||||||
|
'price_subtotal',
|
||||||
|
'price_total',
|
||||||
|
'tax_ids',
|
||||||
|
'account_id',
|
||||||
|
'analytic_distribution',
|
||||||
|
'debit',
|
||||||
|
'credit',
|
||||||
|
'balance',
|
||||||
|
'amount_currency',
|
||||||
|
'currency_id',
|
||||||
|
'display_type',
|
||||||
|
],
|
||||||
|
{ order: 'sequence, id' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una factura por ID
|
||||||
|
*/
|
||||||
|
async getInvoiceById(invoiceId: number): Promise<OdooInvoice | null> {
|
||||||
|
const invoices = await this.client.read<OdooInvoice>(
|
||||||
|
'account.move',
|
||||||
|
[invoiceId],
|
||||||
|
this.getInvoiceFields()
|
||||||
|
);
|
||||||
|
return invoices[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una factura por numero/nombre
|
||||||
|
*/
|
||||||
|
async getInvoiceByNumber(
|
||||||
|
invoiceNumber: string,
|
||||||
|
options?: { companyId?: number }
|
||||||
|
): Promise<OdooInvoice | null> {
|
||||||
|
const domain: OdooDomain = [['name', '=', invoiceNumber]];
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoices = await this.client.searchRead<OdooInvoice>(
|
||||||
|
'account.move',
|
||||||
|
domain,
|
||||||
|
this.getInvoiceFields(),
|
||||||
|
{ limit: 1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return invoices[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Payments
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene pagos de un periodo
|
||||||
|
*/
|
||||||
|
async getPayments(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
paymentType?: 'inbound' | 'outbound';
|
||||||
|
partnerType?: 'customer' | 'supplier';
|
||||||
|
partnerId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
state?: 'draft' | 'posted' | 'cancel';
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooPayment>> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['date', '>=', this.formatDate(period.dateFrom)],
|
||||||
|
['date', '<=', this.formatDate(period.dateTo)],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.paymentType) {
|
||||||
|
domain.push(['payment_type', '=', options.paymentType]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.partnerType) {
|
||||||
|
domain.push(['partner_type', '=', options.partnerType]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.partnerId) {
|
||||||
|
domain.push(['partner_id', '=', options.partnerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.state) {
|
||||||
|
domain.push(['state', '=', options.state]);
|
||||||
|
} else {
|
||||||
|
domain.push(['state', '=', 'posted']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<OdooPayment>(
|
||||||
|
'account.payment',
|
||||||
|
domain,
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'payment_type',
|
||||||
|
'partner_type',
|
||||||
|
'partner_id',
|
||||||
|
'amount',
|
||||||
|
'currency_id',
|
||||||
|
'date',
|
||||||
|
'journal_id',
|
||||||
|
'company_id',
|
||||||
|
'state',
|
||||||
|
'ref',
|
||||||
|
'payment_method_id',
|
||||||
|
'payment_method_line_id',
|
||||||
|
'destination_account_id',
|
||||||
|
'move_id',
|
||||||
|
'reconciled_invoice_ids',
|
||||||
|
'reconciled_bill_ids',
|
||||||
|
'is_reconciled',
|
||||||
|
'is_matched',
|
||||||
|
'create_date',
|
||||||
|
'write_date',
|
||||||
|
],
|
||||||
|
{ order: 'date desc, id desc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene pagos de cliente
|
||||||
|
*/
|
||||||
|
async getCustomerPayments(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
partnerId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooPayment>> {
|
||||||
|
return this.getPayments(period, {
|
||||||
|
paymentType: 'inbound',
|
||||||
|
partnerType: 'customer',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene pagos a proveedor
|
||||||
|
*/
|
||||||
|
async getVendorPayments(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
partnerId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooPayment>> {
|
||||||
|
return this.getPayments(period, {
|
||||||
|
paymentType: 'outbound',
|
||||||
|
partnerType: 'supplier',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de pagos
|
||||||
|
*/
|
||||||
|
async getPaymentSummary(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<PaymentSummary> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['date', '>=', this.formatDate(period.dateFrom)],
|
||||||
|
['date', '<=', this.formatDate(period.dateTo)],
|
||||||
|
['state', '=', 'posted'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payments = await this.client.searchRead<OdooPayment>(
|
||||||
|
'account.payment',
|
||||||
|
domain,
|
||||||
|
['payment_type', 'amount'],
|
||||||
|
{ limit: 10000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalInbound = 0;
|
||||||
|
let totalOutbound = 0;
|
||||||
|
let countInbound = 0;
|
||||||
|
let countOutbound = 0;
|
||||||
|
|
||||||
|
for (const payment of payments) {
|
||||||
|
if (payment.paymentType === 'inbound') {
|
||||||
|
totalInbound += payment.amount;
|
||||||
|
countInbound++;
|
||||||
|
} else {
|
||||||
|
totalOutbound += payment.amount;
|
||||||
|
countOutbound++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalInbound,
|
||||||
|
totalOutbound,
|
||||||
|
countInbound,
|
||||||
|
countOutbound,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas relacionadas a un pago
|
||||||
|
*/
|
||||||
|
async getPaymentInvoices(paymentId: number): Promise<OdooInvoice[]> {
|
||||||
|
const payment = await this.client.read<OdooPayment>(
|
||||||
|
'account.payment',
|
||||||
|
[paymentId],
|
||||||
|
['reconciled_invoice_ids', 'reconciled_bill_ids']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!payment[0]) return [];
|
||||||
|
|
||||||
|
const invoiceIds = [
|
||||||
|
...(payment[0].reconciledInvoiceIds || []),
|
||||||
|
...(payment[0].reconciledBillIds || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (invoiceIds.length === 0) return [];
|
||||||
|
|
||||||
|
return this.client.read<OdooInvoice>(
|
||||||
|
'account.move',
|
||||||
|
invoiceIds,
|
||||||
|
this.getInvoiceFields()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Bank Statements
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estados de cuenta bancarios
|
||||||
|
*/
|
||||||
|
async getBankStatements(
|
||||||
|
period: DatePeriod,
|
||||||
|
options?: {
|
||||||
|
journalId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
state?: 'open' | 'confirm';
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooBankStatement>> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['date', '>=', this.formatDate(period.dateFrom)],
|
||||||
|
['date', '<=', this.formatDate(period.dateTo)],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.journalId) {
|
||||||
|
domain.push(['journal_id', '=', options.journalId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.state) {
|
||||||
|
domain.push(['state', '=', options.state]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<OdooBankStatement>(
|
||||||
|
'account.bank.statement',
|
||||||
|
domain,
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'date',
|
||||||
|
'journal_id',
|
||||||
|
'company_id',
|
||||||
|
'balance_start',
|
||||||
|
'balance_end_real',
|
||||||
|
'balance_end',
|
||||||
|
'state',
|
||||||
|
'line_ids',
|
||||||
|
'create_date',
|
||||||
|
'write_date',
|
||||||
|
],
|
||||||
|
{ order: 'date desc, id desc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene lineas de un estado de cuenta
|
||||||
|
*/
|
||||||
|
async getBankStatementLines(
|
||||||
|
statementId: number
|
||||||
|
): Promise<OdooBankStatementLine[]> {
|
||||||
|
return this.client.searchRead<OdooBankStatementLine>(
|
||||||
|
'account.bank.statement.line',
|
||||||
|
[['statement_id', '=', statementId]],
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'statement_id',
|
||||||
|
'sequence',
|
||||||
|
'date',
|
||||||
|
'name',
|
||||||
|
'ref',
|
||||||
|
'partner_id',
|
||||||
|
'amount',
|
||||||
|
'amount_currency',
|
||||||
|
'foreign_currency_id',
|
||||||
|
'payment_ref',
|
||||||
|
'transaction_type',
|
||||||
|
'is_reconciled',
|
||||||
|
'move_id',
|
||||||
|
],
|
||||||
|
{ order: 'sequence, id' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Invoice Creation
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una factura de cliente
|
||||||
|
*/
|
||||||
|
async createCustomerInvoice(data: {
|
||||||
|
partnerId: number;
|
||||||
|
invoiceDate?: Date;
|
||||||
|
invoiceDateDue?: Date;
|
||||||
|
lines: Array<{
|
||||||
|
productId?: number;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
priceUnit: number;
|
||||||
|
taxIds?: number[];
|
||||||
|
accountId?: number;
|
||||||
|
}>;
|
||||||
|
ref?: string;
|
||||||
|
narration?: string;
|
||||||
|
journalId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
}): Promise<number> {
|
||||||
|
return this.createInvoice('out_invoice', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una factura de proveedor
|
||||||
|
*/
|
||||||
|
async createVendorBill(data: {
|
||||||
|
partnerId: number;
|
||||||
|
invoiceDate?: Date;
|
||||||
|
invoiceDateDue?: Date;
|
||||||
|
lines: Array<{
|
||||||
|
productId?: number;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
priceUnit: number;
|
||||||
|
taxIds?: number[];
|
||||||
|
accountId?: number;
|
||||||
|
}>;
|
||||||
|
ref?: string;
|
||||||
|
narration?: string;
|
||||||
|
journalId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
}): Promise<number> {
|
||||||
|
return this.createInvoice('in_invoice', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida (publica) una factura
|
||||||
|
*/
|
||||||
|
async postInvoice(invoiceId: number): Promise<boolean> {
|
||||||
|
return this.client.callMethod<boolean>(
|
||||||
|
'account.move',
|
||||||
|
'action_post',
|
||||||
|
[[invoiceId]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancela una factura
|
||||||
|
*/
|
||||||
|
async cancelInvoice(invoiceId: number): Promise<boolean> {
|
||||||
|
return this.client.callMethod<boolean>(
|
||||||
|
'account.move',
|
||||||
|
'button_cancel',
|
||||||
|
[[invoiceId]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Private Helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas con filtros genericos
|
||||||
|
*/
|
||||||
|
private async getInvoices(
|
||||||
|
period: DatePeriod,
|
||||||
|
invoiceTypes: OdooInvoiceType[],
|
||||||
|
options?: {
|
||||||
|
state?: OdooInvoiceState;
|
||||||
|
paymentState?: OdooPaymentState;
|
||||||
|
partnerId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooInvoice>> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['move_type', 'in', invoiceTypes],
|
||||||
|
['invoice_date', '>=', this.formatDate(period.dateFrom)],
|
||||||
|
['invoice_date', '<=', this.formatDate(period.dateTo)],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.state) {
|
||||||
|
domain.push(['state', '=', options.state]);
|
||||||
|
} else {
|
||||||
|
domain.push(['state', '=', 'posted']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.paymentState) {
|
||||||
|
domain.push(['payment_state', '=', options.paymentState]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.partnerId) {
|
||||||
|
domain.push(['partner_id', '=', options.partnerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<OdooInvoice>(
|
||||||
|
'account.move',
|
||||||
|
domain,
|
||||||
|
this.getInvoiceFields(),
|
||||||
|
{ order: 'invoice_date desc, id desc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de facturas
|
||||||
|
*/
|
||||||
|
private async getInvoiceSummary(
|
||||||
|
period: DatePeriod,
|
||||||
|
invoiceTypes: OdooInvoiceType[],
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<InvoiceSummary> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['move_type', 'in', invoiceTypes],
|
||||||
|
['invoice_date', '>=', this.formatDate(period.dateFrom)],
|
||||||
|
['invoice_date', '<=', this.formatDate(period.dateTo)],
|
||||||
|
['state', '=', 'posted'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoices = await this.client.searchRead<OdooInvoice>(
|
||||||
|
'account.move',
|
||||||
|
domain,
|
||||||
|
['amount_total', 'amount_residual', 'payment_state'],
|
||||||
|
{ limit: 10000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let totalPaid = 0;
|
||||||
|
let totalPending = 0;
|
||||||
|
let countPaid = 0;
|
||||||
|
let countPending = 0;
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
total += invoice.amountTotal;
|
||||||
|
|
||||||
|
if (invoice.paymentState === 'paid') {
|
||||||
|
totalPaid += invoice.amountTotal;
|
||||||
|
countPaid++;
|
||||||
|
} else {
|
||||||
|
totalPending += invoice.amountResidual;
|
||||||
|
countPending++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
totalPaid,
|
||||||
|
totalPending,
|
||||||
|
count: invoices.length,
|
||||||
|
countPaid,
|
||||||
|
countPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una factura
|
||||||
|
*/
|
||||||
|
private async createInvoice(
|
||||||
|
moveType: OdooInvoiceType,
|
||||||
|
data: {
|
||||||
|
partnerId: number;
|
||||||
|
invoiceDate?: Date;
|
||||||
|
invoiceDateDue?: Date;
|
||||||
|
lines: Array<{
|
||||||
|
productId?: number;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
priceUnit: number;
|
||||||
|
taxIds?: number[];
|
||||||
|
accountId?: number;
|
||||||
|
}>;
|
||||||
|
ref?: string;
|
||||||
|
narration?: string;
|
||||||
|
journalId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<number> {
|
||||||
|
const connection = this.client.getConnection();
|
||||||
|
if (!connection) {
|
||||||
|
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceData: Record<string, unknown> = {
|
||||||
|
move_type: moveType,
|
||||||
|
partner_id: data.partnerId,
|
||||||
|
invoice_date: data.invoiceDate
|
||||||
|
? this.formatDate(data.invoiceDate)
|
||||||
|
: this.formatDate(new Date()),
|
||||||
|
invoice_line_ids: data.lines.map((line) => [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
product_id: line.productId || false,
|
||||||
|
name: line.name,
|
||||||
|
quantity: line.quantity,
|
||||||
|
price_unit: line.priceUnit,
|
||||||
|
tax_ids: line.taxIds ? [[6, 0, line.taxIds]] : false,
|
||||||
|
account_id: line.accountId || false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.invoiceDateDue) {
|
||||||
|
invoiceData.invoice_date_due = this.formatDate(data.invoiceDateDue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.ref) {
|
||||||
|
invoiceData.ref = data.ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.narration) {
|
||||||
|
invoiceData.narration = data.narration;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.journalId) {
|
||||||
|
invoiceData.journal_id = data.journalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.companyId) {
|
||||||
|
invoiceData.company_id = data.companyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.create('account.move', invoiceData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Campos a leer de facturas
|
||||||
|
*/
|
||||||
|
private getInvoiceFields(): string[] {
|
||||||
|
return [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'ref',
|
||||||
|
'move_type',
|
||||||
|
'state',
|
||||||
|
'payment_state',
|
||||||
|
'partner_id',
|
||||||
|
'partner_shipping_id',
|
||||||
|
'commercial_partner_id',
|
||||||
|
'invoice_date',
|
||||||
|
'invoice_date_due',
|
||||||
|
'date',
|
||||||
|
'journal_id',
|
||||||
|
'company_id',
|
||||||
|
'currency_id',
|
||||||
|
'amount_untaxed',
|
||||||
|
'amount_tax',
|
||||||
|
'amount_total',
|
||||||
|
'amount_residual',
|
||||||
|
'amount_paid',
|
||||||
|
'invoice_payment_term_id',
|
||||||
|
'invoice_line_ids',
|
||||||
|
'narration',
|
||||||
|
'fiscal_position_id',
|
||||||
|
'invoice_origin',
|
||||||
|
'invoice_user_id',
|
||||||
|
'team_id',
|
||||||
|
'reversed',
|
||||||
|
'reversed_entry_id',
|
||||||
|
'amount_total_signed',
|
||||||
|
'amount_residual_signed',
|
||||||
|
'amount_untaxed_signed',
|
||||||
|
'create_date',
|
||||||
|
'write_date',
|
||||||
|
// Campos Mexico
|
||||||
|
'l10n_mx_edi_cfdi_uuid',
|
||||||
|
'l10n_mx_edi_usage',
|
||||||
|
'l10n_mx_edi_payment_method',
|
||||||
|
'l10n_mx_edi_payment_policy',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea una fecha a string YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
private formatDate(date: Date): string {
|
||||||
|
return date.toISOString().split('T')[0] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un conector de facturacion
|
||||||
|
*/
|
||||||
|
export function createInvoicingConnector(
|
||||||
|
client: OdooClient
|
||||||
|
): OdooInvoicingConnector {
|
||||||
|
return new OdooInvoicingConnector(client);
|
||||||
|
}
|
||||||
872
apps/api/src/services/integrations/odoo/odoo.client.ts
Normal file
872
apps/api/src/services/integrations/odoo/odoo.client.ts
Normal file
@@ -0,0 +1,872 @@
|
|||||||
|
/**
|
||||||
|
* Odoo XML-RPC Client
|
||||||
|
* Cliente para comunicarse con Odoo via XML-RPC
|
||||||
|
* Soporta versiones 14, 15, 16, 17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as https from 'https';
|
||||||
|
import * as http from 'http';
|
||||||
|
import { URL } from 'url';
|
||||||
|
import {
|
||||||
|
OdooConfig,
|
||||||
|
OdooConnection,
|
||||||
|
OdooContext,
|
||||||
|
OdooCompany,
|
||||||
|
OdooDomain,
|
||||||
|
OdooFields,
|
||||||
|
OdooValues,
|
||||||
|
OdooPagination,
|
||||||
|
OdooPaginatedResponse,
|
||||||
|
OdooError,
|
||||||
|
OdooAuthError,
|
||||||
|
OdooConnectionError,
|
||||||
|
} from './odoo.types.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// XML-RPC Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface XmlRpcResponse {
|
||||||
|
value?: unknown;
|
||||||
|
fault?: {
|
||||||
|
faultCode: number | string;
|
||||||
|
faultString: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session Cache
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CachedSession {
|
||||||
|
connection: OdooConnection;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCache = new Map<string, CachedSession>();
|
||||||
|
const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Odoo Client Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente XML-RPC para Odoo ERP
|
||||||
|
*/
|
||||||
|
export class OdooClient {
|
||||||
|
private config: Required<OdooConfig>;
|
||||||
|
private connection: OdooConnection | null = null;
|
||||||
|
private cacheKey: string;
|
||||||
|
|
||||||
|
constructor(config: OdooConfig) {
|
||||||
|
this.config = {
|
||||||
|
url: config.url.replace(/\/+$/, ''),
|
||||||
|
database: config.database,
|
||||||
|
username: config.username,
|
||||||
|
password: config.password,
|
||||||
|
useApiKey: config.useApiKey ?? false,
|
||||||
|
version: config.version ?? 16,
|
||||||
|
companyId: config.companyId ?? 1,
|
||||||
|
allowedCompanyIds: config.allowedCompanyIds ?? [],
|
||||||
|
timeout: config.timeout ?? 30000,
|
||||||
|
maxRetries: config.maxRetries ?? 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cacheKey = `${this.config.url}:${this.config.database}:${this.config.username}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Public Methods - Connection
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autentica con Odoo y obtiene la conexion
|
||||||
|
*/
|
||||||
|
async authenticate(): Promise<OdooConnection> {
|
||||||
|
// Verificar cache
|
||||||
|
const cached = sessionCache.get(this.cacheKey);
|
||||||
|
if (cached && cached.expiresAt > new Date()) {
|
||||||
|
this.connection = cached.connection;
|
||||||
|
return this.connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Llamar al metodo authenticate de XML-RPC
|
||||||
|
const uid = await this.xmlRpcCall<number>(
|
||||||
|
'/xmlrpc/2/common',
|
||||||
|
'authenticate',
|
||||||
|
[
|
||||||
|
this.config.database,
|
||||||
|
this.config.username,
|
||||||
|
this.config.password,
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uid) {
|
||||||
|
throw new OdooAuthError('Credenciales invalidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener version del servidor
|
||||||
|
const serverVersion = await this.getServerVersion();
|
||||||
|
|
||||||
|
// Obtener companias
|
||||||
|
const companies = await this.getCompanies(uid);
|
||||||
|
|
||||||
|
// Determinar compania activa
|
||||||
|
const activeCompanyId =
|
||||||
|
this.config.companyId ||
|
||||||
|
(companies.length > 0 ? companies[0]!.id : 1);
|
||||||
|
|
||||||
|
// Crear contexto
|
||||||
|
const context: OdooContext = {
|
||||||
|
lang: 'es_MX',
|
||||||
|
tz: 'America/Mexico_City',
|
||||||
|
uid: uid,
|
||||||
|
allowed_company_ids:
|
||||||
|
this.config.allowedCompanyIds.length > 0
|
||||||
|
? this.config.allowedCompanyIds
|
||||||
|
: companies.map((c) => c.id),
|
||||||
|
company_id: activeCompanyId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crear conexion
|
||||||
|
this.connection = {
|
||||||
|
uid,
|
||||||
|
context,
|
||||||
|
authenticatedAt: new Date(),
|
||||||
|
serverUrl: this.config.url,
|
||||||
|
database: this.config.database,
|
||||||
|
serverVersion,
|
||||||
|
companies,
|
||||||
|
activeCompanyId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Guardar en cache
|
||||||
|
sessionCache.set(this.cacheKey, {
|
||||||
|
connection: this.connection,
|
||||||
|
expiresAt: new Date(Date.now() + SESSION_TTL_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.connection;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OdooError) throw error;
|
||||||
|
throw new OdooAuthError(
|
||||||
|
`Error de autenticacion: ${error instanceof Error ? error.message : 'Error desconocido'}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si hay una conexion activa
|
||||||
|
*/
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.connection !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la conexion actual
|
||||||
|
*/
|
||||||
|
getConnection(): OdooConnection | null {
|
||||||
|
return this.connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cierra la sesion y limpia el cache
|
||||||
|
*/
|
||||||
|
disconnect(): void {
|
||||||
|
this.connection = null;
|
||||||
|
sessionCache.delete(this.cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cambia la compania activa
|
||||||
|
*/
|
||||||
|
async setCompany(companyId: number): Promise<void> {
|
||||||
|
if (!this.connection) {
|
||||||
|
await this.authenticate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = this.connection!.companies.find((c) => c.id === companyId);
|
||||||
|
if (!company) {
|
||||||
|
throw new OdooError(
|
||||||
|
`Compania ${companyId} no encontrada o sin acceso`,
|
||||||
|
'ACCESS_DENIED'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connection!.activeCompanyId = companyId;
|
||||||
|
this.connection!.context.company_id = companyId;
|
||||||
|
|
||||||
|
// Actualizar cache
|
||||||
|
const cached = sessionCache.get(this.cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
cached.connection = this.connection!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Public Methods - CRUD Operations
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca IDs de registros que cumplan el dominio
|
||||||
|
*/
|
||||||
|
async search(
|
||||||
|
model: string,
|
||||||
|
domain: OdooDomain = [],
|
||||||
|
pagination?: OdooPagination
|
||||||
|
): Promise<number[]> {
|
||||||
|
await this.ensureConnection();
|
||||||
|
|
||||||
|
const params: unknown[] = [domain];
|
||||||
|
if (pagination) {
|
||||||
|
params.push({
|
||||||
|
offset: pagination.offset ?? 0,
|
||||||
|
limit: pagination.limit,
|
||||||
|
order: pagination.order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.execute(model, 'search', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuenta registros que cumplan el dominio
|
||||||
|
*/
|
||||||
|
async searchCount(model: string, domain: OdooDomain = []): Promise<number> {
|
||||||
|
await this.ensureConnection();
|
||||||
|
return this.execute(model, 'search_count', [domain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lee campos de registros por sus IDs
|
||||||
|
*/
|
||||||
|
async read<T = Record<string, unknown>>(
|
||||||
|
model: string,
|
||||||
|
ids: number[],
|
||||||
|
fields?: OdooFields
|
||||||
|
): Promise<T[]> {
|
||||||
|
await this.ensureConnection();
|
||||||
|
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
const params: unknown[] = [ids];
|
||||||
|
if (fields && fields.length > 0) {
|
||||||
|
params.push(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.execute<Record<string, unknown>[]>(
|
||||||
|
model,
|
||||||
|
'read',
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.map((r) => this.normalizeRecord<T>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca y lee registros en una sola llamada
|
||||||
|
*/
|
||||||
|
async searchRead<T = Record<string, unknown>>(
|
||||||
|
model: string,
|
||||||
|
domain: OdooDomain = [],
|
||||||
|
fields?: OdooFields,
|
||||||
|
pagination?: OdooPagination
|
||||||
|
): Promise<T[]> {
|
||||||
|
await this.ensureConnection();
|
||||||
|
|
||||||
|
const params: unknown[] = [domain];
|
||||||
|
|
||||||
|
const options: Record<string, unknown> = {};
|
||||||
|
if (fields && fields.length > 0) {
|
||||||
|
options.fields = fields;
|
||||||
|
}
|
||||||
|
if (pagination) {
|
||||||
|
if (pagination.offset !== undefined) options.offset = pagination.offset;
|
||||||
|
if (pagination.limit !== undefined) options.limit = pagination.limit;
|
||||||
|
if (pagination.order) options.order = pagination.order;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(options);
|
||||||
|
|
||||||
|
const result = await this.execute<Record<string, unknown>[]>(
|
||||||
|
model,
|
||||||
|
'search_read',
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.map((r) => this.normalizeRecord<T>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca y lee registros con paginacion
|
||||||
|
*/
|
||||||
|
async searchReadPaginated<T = Record<string, unknown>>(
|
||||||
|
model: string,
|
||||||
|
domain: OdooDomain = [],
|
||||||
|
fields?: OdooFields,
|
||||||
|
pagination?: OdooPagination
|
||||||
|
): Promise<OdooPaginatedResponse<T>> {
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.searchRead<T>(model, domain, fields, pagination),
|
||||||
|
this.searchCount(model, domain),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const offset = pagination?.offset ?? 0;
|
||||||
|
const limit = pagination?.limit ?? items.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
hasMore: offset + items.length < total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un nuevo registro
|
||||||
|
*/
|
||||||
|
async create(model: string, values: OdooValues): Promise<number> {
|
||||||
|
await this.ensureConnection();
|
||||||
|
return this.execute(model, 'create', [values]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea multiples registros
|
||||||
|
*/
|
||||||
|
async createMany(model: string, valuesList: OdooValues[]): Promise<number[]> {
|
||||||
|
await this.ensureConnection();
|
||||||
|
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (const values of valuesList) {
|
||||||
|
const id = await this.create(model, values);
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza registros existentes
|
||||||
|
*/
|
||||||
|
async write(
|
||||||
|
model: string,
|
||||||
|
ids: number[],
|
||||||
|
values: OdooValues
|
||||||
|
): Promise<boolean> {
|
||||||
|
await this.ensureConnection();
|
||||||
|
|
||||||
|
if (ids.length === 0) return true;
|
||||||
|
|
||||||
|
return this.execute(model, 'write', [ids, values]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina registros
|
||||||
|
*/
|
||||||
|
async unlink(model: string, ids: number[]): Promise<boolean> {
|
||||||
|
await this.ensureConnection();
|
||||||
|
|
||||||
|
if (ids.length === 0) return true;
|
||||||
|
|
||||||
|
return this.execute(model, 'unlink', [ids]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta un metodo custom del modelo
|
||||||
|
*/
|
||||||
|
async callMethod<T = unknown>(
|
||||||
|
model: string,
|
||||||
|
method: string,
|
||||||
|
args: unknown[] = [],
|
||||||
|
kwargs: Record<string, unknown> = {}
|
||||||
|
): Promise<T> {
|
||||||
|
await this.ensureConnection();
|
||||||
|
return this.execute<T>(model, method, args, kwargs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los campos disponibles de un modelo
|
||||||
|
*/
|
||||||
|
async getFields(
|
||||||
|
model: string,
|
||||||
|
attributes?: string[]
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
await this.ensureConnection();
|
||||||
|
|
||||||
|
const params: unknown[] = [];
|
||||||
|
if (attributes && attributes.length > 0) {
|
||||||
|
params.push(attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.execute(model, 'fields_get', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lee un registro por ID externo (XML ID)
|
||||||
|
*/
|
||||||
|
async readByXmlId<T = Record<string, unknown>>(
|
||||||
|
xmlId: string,
|
||||||
|
fields?: OdooFields
|
||||||
|
): Promise<T | null> {
|
||||||
|
await this.ensureConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.execute<[number, string]>(
|
||||||
|
'ir.model.data',
|
||||||
|
'xmlid_to_res_model_res_id',
|
||||||
|
[xmlId, true]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result || result.length < 2) return null;
|
||||||
|
|
||||||
|
const [id] = result;
|
||||||
|
const records = await this.read<T>(xmlId.split('.')[0] || '', [id], fields);
|
||||||
|
return records[0] || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Private Methods - XML-RPC
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una operacion en un modelo
|
||||||
|
*/
|
||||||
|
private async execute<T = unknown>(
|
||||||
|
model: string,
|
||||||
|
method: string,
|
||||||
|
args: unknown[] = [],
|
||||||
|
kwargs: Record<string, unknown> = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const context = {
|
||||||
|
...this.connection!.context,
|
||||||
|
...kwargs,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.xmlRpcCall<T>('/xmlrpc/2/object', 'execute_kw', [
|
||||||
|
this.config.database,
|
||||||
|
this.connection!.uid,
|
||||||
|
this.config.password,
|
||||||
|
model,
|
||||||
|
method,
|
||||||
|
args,
|
||||||
|
context,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una llamada XML-RPC
|
||||||
|
*/
|
||||||
|
private async xmlRpcCall<T>(
|
||||||
|
endpoint: string,
|
||||||
|
method: string,
|
||||||
|
params: unknown[]
|
||||||
|
): Promise<T> {
|
||||||
|
const url = new URL(endpoint, this.config.url);
|
||||||
|
const body = this.buildXmlRpcRequest(method, params);
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < this.config.maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await this.sendRequest(url, body);
|
||||||
|
const parsed = this.parseXmlRpcResponse(response);
|
||||||
|
|
||||||
|
if (parsed.fault) {
|
||||||
|
throw new OdooError(
|
||||||
|
parsed.fault.faultString,
|
||||||
|
'XML_RPC_ERROR',
|
||||||
|
parsed.fault,
|
||||||
|
String(parsed.fault.faultCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.value as T;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
// No reintentar errores de autenticacion o validacion
|
||||||
|
if (
|
||||||
|
error instanceof OdooAuthError ||
|
||||||
|
(error instanceof OdooError &&
|
||||||
|
(error.code === 'AUTH_ERROR' || error.code === 'VALIDATION_ERROR'))
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esperar antes de reintentar (backoff exponencial)
|
||||||
|
if (attempt < this.config.maxRetries - 1) {
|
||||||
|
await this.sleep(Math.pow(2, attempt) * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new OdooConnectionError(
|
||||||
|
`Error despues de ${this.config.maxRetries} intentos: ${lastError?.message}`,
|
||||||
|
lastError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envia una solicitud HTTP
|
||||||
|
*/
|
||||||
|
private sendRequest(url: URL, body: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const isHttps = url.protocol === 'https:';
|
||||||
|
const transport = isHttps ? https : http;
|
||||||
|
|
||||||
|
const options: https.RequestOptions = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (isHttps ? 443 : 80),
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/xml; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(body, 'utf8'),
|
||||||
|
'User-Agent': 'HoruxStrategy-OdooConnector/1.0',
|
||||||
|
},
|
||||||
|
timeout: this.config.timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = transport.request(options, (res: http.IncomingMessage) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk: Buffer | string) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(data);
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
new OdooConnectionError(
|
||||||
|
`HTTP Error ${res.statusCode}: ${res.statusMessage}`,
|
||||||
|
{ statusCode: res.statusCode, body: data }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error: Error) => {
|
||||||
|
reject(new OdooConnectionError(`Connection error: ${error.message}`, error));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new OdooError('Request timeout', 'TIMEOUT'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye el XML de una solicitud XML-RPC
|
||||||
|
*/
|
||||||
|
private buildXmlRpcRequest(method: string, params: unknown[]): string {
|
||||||
|
const paramsXml = params.map((p) => `<param>${this.valueToXml(p)}</param>`).join('');
|
||||||
|
|
||||||
|
return `<?xml version="1.0"?>
|
||||||
|
<methodCall>
|
||||||
|
<methodName>${this.escapeXml(method)}</methodName>
|
||||||
|
<params>${paramsXml}</params>
|
||||||
|
</methodCall>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte un valor a XML-RPC
|
||||||
|
*/
|
||||||
|
private valueToXml(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '<value><boolean>0</boolean></value>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return `<value><boolean>${value ? 1 : 0}</boolean></value>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (Number.isInteger(value)) {
|
||||||
|
return `<value><int>${value}</int></value>`;
|
||||||
|
}
|
||||||
|
return `<value><double>${value}</double></value>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return `<value><string>${this.escapeXml(value)}</string></value>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const items = value.map((v) => this.valueToXml(v)).join('');
|
||||||
|
return `<value><array><data>${items}</data></array></value>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const members = Object.entries(value as Record<string, unknown>)
|
||||||
|
.map(
|
||||||
|
([k, v]) =>
|
||||||
|
`<member><name>${this.escapeXml(k)}</name>${this.valueToXml(v)}</member>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
return `<value><struct>${members}</struct></value>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<value><string>${this.escapeXml(String(value))}</string></value>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea una respuesta XML-RPC
|
||||||
|
*/
|
||||||
|
private parseXmlRpcResponse(xml: string): XmlRpcResponse {
|
||||||
|
// Verificar fault
|
||||||
|
const faultMatch = xml.match(/<fault>([\s\S]*?)<\/fault>/);
|
||||||
|
if (faultMatch && faultMatch[1]) {
|
||||||
|
const fault = this.parseXmlValue(faultMatch[1]) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
fault: {
|
||||||
|
faultCode: (fault.faultCode as number | string) || 0,
|
||||||
|
faultString: (fault.faultString as string) || 'Unknown error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsear valor
|
||||||
|
const paramMatch = xml.match(/<params>\s*<param>([\s\S]*?)<\/param>\s*<\/params>/);
|
||||||
|
if (paramMatch && paramMatch[1]) {
|
||||||
|
return {
|
||||||
|
value: this.parseXmlValue(paramMatch[1]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea un valor XML-RPC
|
||||||
|
*/
|
||||||
|
private parseXmlValue(xml: string): unknown {
|
||||||
|
// Boolean
|
||||||
|
const boolMatch = xml.match(/<boolean>(\d)<\/boolean>/);
|
||||||
|
if (boolMatch && boolMatch[1]) return boolMatch[1] === '1';
|
||||||
|
|
||||||
|
// Integer
|
||||||
|
const intMatch = xml.match(/<(?:int|i4)>(-?\d+)<\/(?:int|i4)>/);
|
||||||
|
if (intMatch && intMatch[1]) return parseInt(intMatch[1], 10);
|
||||||
|
|
||||||
|
// Double
|
||||||
|
const doubleMatch = xml.match(/<double>(-?[\d.]+)<\/double>/);
|
||||||
|
if (doubleMatch && doubleMatch[1]) return parseFloat(doubleMatch[1]);
|
||||||
|
|
||||||
|
// String
|
||||||
|
const stringMatch = xml.match(/<string>([\s\S]*?)<\/string>/);
|
||||||
|
if (stringMatch && stringMatch[1] !== undefined) return this.unescapeXml(stringMatch[1]);
|
||||||
|
|
||||||
|
// Nil/None
|
||||||
|
if (xml.includes('<nil/>') || xml.includes('<nil></nil>')) return null;
|
||||||
|
|
||||||
|
// Boolean false (alternate representation)
|
||||||
|
if (xml.includes('<value/>') || xml.match(/<value>\s*<\/value>/)) return false;
|
||||||
|
|
||||||
|
// Array
|
||||||
|
const arrayMatch = xml.match(/<array>\s*<data>([\s\S]*?)<\/data>\s*<\/array>/);
|
||||||
|
if (arrayMatch && arrayMatch[1]) {
|
||||||
|
const items: unknown[] = [];
|
||||||
|
const valueRegex = /<value>([\s\S]*?)<\/value>/g;
|
||||||
|
let match;
|
||||||
|
while ((match = valueRegex.exec(arrayMatch[1])) !== null) {
|
||||||
|
items.push(this.parseXmlValue(match[0]));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Struct
|
||||||
|
const structMatch = xml.match(/<struct>([\s\S]*?)<\/struct>/);
|
||||||
|
if (structMatch && structMatch[1]) {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
const memberRegex = /<member>\s*<name>([\s\S]*?)<\/name>\s*<value>([\s\S]*?)<\/value>\s*<\/member>/g;
|
||||||
|
let match;
|
||||||
|
while ((match = memberRegex.exec(structMatch[1])) !== null) {
|
||||||
|
if (match[1] && match[2]) {
|
||||||
|
const key = this.unescapeXml(match[1]);
|
||||||
|
obj[key] = this.parseXmlValue(`<value>${match[2]}</value>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value wrapper
|
||||||
|
const valueMatch = xml.match(/<value>([\s\S]*?)<\/value>/);
|
||||||
|
if (valueMatch && valueMatch[1] !== undefined) {
|
||||||
|
const inner = valueMatch[1].trim();
|
||||||
|
// Si es texto sin tipo, tratarlo como string
|
||||||
|
if (!inner.startsWith('<')) {
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
return this.parseXmlValue(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
return xml.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapa caracteres especiales para XML
|
||||||
|
*/
|
||||||
|
private escapeXml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desescapa caracteres XML
|
||||||
|
*/
|
||||||
|
private unescapeXml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Private Methods - Helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asegura que hay una conexion activa
|
||||||
|
*/
|
||||||
|
private async ensureConnection(): Promise<void> {
|
||||||
|
if (!this.connection) {
|
||||||
|
await this.authenticate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la version del servidor
|
||||||
|
*/
|
||||||
|
private async getServerVersion(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const version = await this.xmlRpcCall<Record<string, unknown>>(
|
||||||
|
'/xmlrpc/2/common',
|
||||||
|
'version',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
return String(version.server_version || 'unknown');
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las companias disponibles para el usuario
|
||||||
|
*/
|
||||||
|
private async getCompanies(uid: number): Promise<OdooCompany[]> {
|
||||||
|
try {
|
||||||
|
const companies = await this.xmlRpcCall<Record<string, unknown>[]>(
|
||||||
|
'/xmlrpc/2/object',
|
||||||
|
'execute_kw',
|
||||||
|
[
|
||||||
|
this.config.database,
|
||||||
|
uid,
|
||||||
|
this.config.password,
|
||||||
|
'res.company',
|
||||||
|
'search_read',
|
||||||
|
[[]],
|
||||||
|
{
|
||||||
|
fields: [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'currency_id',
|
||||||
|
'vat',
|
||||||
|
'street',
|
||||||
|
'city',
|
||||||
|
'country_id',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return companies.map((c) => ({
|
||||||
|
id: c.id as number,
|
||||||
|
name: c.name as string,
|
||||||
|
currencyId: (c.currency_id as [number, string])?.[0] || 0,
|
||||||
|
currencyCode: (c.currency_id as [number, string])?.[1] || 'MXN',
|
||||||
|
vatNumber: c.vat as string | undefined,
|
||||||
|
street: c.street as string | undefined,
|
||||||
|
city: c.city as string | undefined,
|
||||||
|
countryId: (c.country_id as [number, string])?.[0],
|
||||||
|
countryCode: (c.country_id as [number, string])?.[1],
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Si falla, retornar compania por defecto
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Default Company',
|
||||||
|
currencyId: 1,
|
||||||
|
currencyCode: 'MXN',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliza un registro de Odoo (convierte snake_case a camelCase)
|
||||||
|
*/
|
||||||
|
private normalizeRecord<T>(record: Record<string, unknown>): T {
|
||||||
|
const normalized: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(record)) {
|
||||||
|
const camelKey = key.replace(/_([a-z])/g, (_, letter) =>
|
||||||
|
letter.toUpperCase()
|
||||||
|
);
|
||||||
|
normalized[camelKey] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Espera un tiempo determinado
|
||||||
|
*/
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del cliente Odoo
|
||||||
|
*/
|
||||||
|
export function createOdooClient(config: OdooConfig): OdooClient {
|
||||||
|
return new OdooClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia el cache de sesiones
|
||||||
|
*/
|
||||||
|
export function clearSessionCache(): void {
|
||||||
|
sessionCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el tamano del cache de sesiones
|
||||||
|
*/
|
||||||
|
export function getSessionCacheSize(): number {
|
||||||
|
return sessionCache.size;
|
||||||
|
}
|
||||||
1052
apps/api/src/services/integrations/odoo/odoo.sync.ts
Normal file
1052
apps/api/src/services/integrations/odoo/odoo.sync.ts
Normal file
File diff suppressed because it is too large
Load Diff
911
apps/api/src/services/integrations/odoo/odoo.types.ts
Normal file
911
apps/api/src/services/integrations/odoo/odoo.types.ts
Normal file
@@ -0,0 +1,911 @@
|
|||||||
|
/**
|
||||||
|
* Odoo ERP Integration Types
|
||||||
|
* Tipos completos para la integracion con Odoo ERP (versiones 14, 15, 16, 17)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Configuration Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuracion de conexion a Odoo
|
||||||
|
*/
|
||||||
|
export interface OdooConfig {
|
||||||
|
/** URL base del servidor Odoo (ej: https://mycompany.odoo.com) */
|
||||||
|
url: string;
|
||||||
|
/** Nombre de la base de datos Odoo */
|
||||||
|
database: string;
|
||||||
|
/** Usuario o email para autenticacion */
|
||||||
|
username: string;
|
||||||
|
/** Password o API key */
|
||||||
|
password: string;
|
||||||
|
/** Si es true, usa API key en lugar de password */
|
||||||
|
useApiKey?: boolean;
|
||||||
|
/** Version de Odoo (14, 15, 16, 17) */
|
||||||
|
version?: OdooVersion;
|
||||||
|
/** ID de la compania activa (para multi-company) */
|
||||||
|
companyId?: number;
|
||||||
|
/** IDs de companias permitidas */
|
||||||
|
allowedCompanyIds?: number[];
|
||||||
|
/** Timeout para requests en ms (default: 30000) */
|
||||||
|
timeout?: number;
|
||||||
|
/** Reintentos maximos para operaciones fallidas */
|
||||||
|
maxRetries?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiones soportadas de Odoo
|
||||||
|
*/
|
||||||
|
export type OdooVersion = 14 | 15 | 16 | 17;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conexion activa con Odoo
|
||||||
|
*/
|
||||||
|
export interface OdooConnection {
|
||||||
|
/** UID del usuario autenticado */
|
||||||
|
uid: number;
|
||||||
|
/** Contexto de sesion */
|
||||||
|
context: OdooContext;
|
||||||
|
/** Timestamp de autenticacion */
|
||||||
|
authenticatedAt: Date;
|
||||||
|
/** URL del servidor */
|
||||||
|
serverUrl: string;
|
||||||
|
/** Base de datos conectada */
|
||||||
|
database: string;
|
||||||
|
/** Version del servidor */
|
||||||
|
serverVersion: string;
|
||||||
|
/** Companias disponibles */
|
||||||
|
companies: OdooCompany[];
|
||||||
|
/** Compania activa */
|
||||||
|
activeCompanyId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexto de sesion Odoo
|
||||||
|
*/
|
||||||
|
export interface OdooContext {
|
||||||
|
lang: string;
|
||||||
|
tz: string;
|
||||||
|
uid: number;
|
||||||
|
allowed_company_ids: number[];
|
||||||
|
company_id?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compania en Odoo
|
||||||
|
*/
|
||||||
|
export interface OdooCompany {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
currencyId: number;
|
||||||
|
currencyCode: string;
|
||||||
|
vatNumber?: string;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
countryId?: number;
|
||||||
|
countryCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Partner Types (Customers/Vendors)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partner de Odoo (cliente, proveedor o ambos)
|
||||||
|
*/
|
||||||
|
export interface OdooPartner {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
ref?: string;
|
||||||
|
vat?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
street?: string;
|
||||||
|
street2?: string;
|
||||||
|
city?: string;
|
||||||
|
stateId?: [number, string];
|
||||||
|
zip?: string;
|
||||||
|
countryId?: [number, string];
|
||||||
|
isCompany: boolean;
|
||||||
|
parentId?: [number, string];
|
||||||
|
childIds: number[];
|
||||||
|
companyId?: [number, string];
|
||||||
|
userId?: [number, string];
|
||||||
|
isCustomer?: boolean;
|
||||||
|
isSupplier?: boolean;
|
||||||
|
customerRank: number;
|
||||||
|
supplierRank: number;
|
||||||
|
credit: number;
|
||||||
|
debit: number;
|
||||||
|
creditLimit: number;
|
||||||
|
propertyAccountReceivable?: [number, string];
|
||||||
|
propertyAccountPayable?: [number, string];
|
||||||
|
propertyPaymentTermId?: [number, string];
|
||||||
|
propertySupplierPaymentTermId?: [number, string];
|
||||||
|
lang?: string;
|
||||||
|
categoryId?: [number, string][];
|
||||||
|
active: boolean;
|
||||||
|
createDate: string;
|
||||||
|
writeDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saldo de partner
|
||||||
|
*/
|
||||||
|
export interface PartnerBalance {
|
||||||
|
partnerId: number;
|
||||||
|
partnerName: string;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
balance: number;
|
||||||
|
receivable: number;
|
||||||
|
payable: number;
|
||||||
|
currencyId: number;
|
||||||
|
currencyCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaccion de partner (para statement)
|
||||||
|
*/
|
||||||
|
export interface PartnerTransaction {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
ref?: string;
|
||||||
|
name: string;
|
||||||
|
journalId: [number, string];
|
||||||
|
accountId: [number, string];
|
||||||
|
moveId: [number, string];
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
balance: number;
|
||||||
|
amountCurrency: number;
|
||||||
|
currencyId?: [number, string];
|
||||||
|
reconciled: boolean;
|
||||||
|
fullReconcileId?: [number, string];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invoice Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de factura en Odoo
|
||||||
|
*/
|
||||||
|
export type OdooInvoiceType =
|
||||||
|
| 'out_invoice' // Factura de cliente
|
||||||
|
| 'out_refund' // Nota de credito cliente
|
||||||
|
| 'in_invoice' // Factura de proveedor
|
||||||
|
| 'in_refund' // Nota de credito proveedor
|
||||||
|
| 'out_receipt' // Recibo de cliente
|
||||||
|
| 'in_receipt'; // Recibo de proveedor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de factura
|
||||||
|
*/
|
||||||
|
export type OdooInvoiceState =
|
||||||
|
| 'draft'
|
||||||
|
| 'posted'
|
||||||
|
| 'cancel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de pago de factura
|
||||||
|
*/
|
||||||
|
export type OdooPaymentState =
|
||||||
|
| 'not_paid'
|
||||||
|
| 'in_payment'
|
||||||
|
| 'paid'
|
||||||
|
| 'partial'
|
||||||
|
| 'reversed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factura de Odoo (account.move)
|
||||||
|
*/
|
||||||
|
export interface OdooInvoice {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
moveType: OdooInvoiceType;
|
||||||
|
state: OdooInvoiceState;
|
||||||
|
paymentState: OdooPaymentState;
|
||||||
|
partnerId: [number, string];
|
||||||
|
partnerShippingId?: [number, string];
|
||||||
|
commercialPartnerId?: [number, string];
|
||||||
|
invoiceDate?: string;
|
||||||
|
invoiceDateDue?: string;
|
||||||
|
date: string;
|
||||||
|
journalId: [number, string];
|
||||||
|
companyId: [number, string];
|
||||||
|
currencyId: [number, string];
|
||||||
|
amountUntaxed: number;
|
||||||
|
amountTax: number;
|
||||||
|
amountTotal: number;
|
||||||
|
amountResidual: number;
|
||||||
|
amountPaid: number;
|
||||||
|
invoicePaymentTermId?: [number, string];
|
||||||
|
invoiceLineIds: number[];
|
||||||
|
narration?: string;
|
||||||
|
fiscalPositionId?: [number, string];
|
||||||
|
invoiceOrigin?: string;
|
||||||
|
invoiceUserId?: [number, string];
|
||||||
|
teamId?: [number, string];
|
||||||
|
reversed: boolean;
|
||||||
|
reversedEntryId?: [number, string];
|
||||||
|
amountTotalSigned: number;
|
||||||
|
amountResidualSigned: number;
|
||||||
|
amountUntaxedSigned: number;
|
||||||
|
createDate: string;
|
||||||
|
writeDate: string;
|
||||||
|
// Campos especificos de Mexico (l10n_mx_edi)
|
||||||
|
l10nMxEdiCfdiUuid?: string;
|
||||||
|
l10nMxEdiUsage?: string;
|
||||||
|
l10nMxEdiPaymentMethod?: string;
|
||||||
|
l10nMxEdiPaymentPolicy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linea de factura (account.move.line con producto)
|
||||||
|
*/
|
||||||
|
export interface OdooInvoiceLine {
|
||||||
|
id: number;
|
||||||
|
moveId: [number, string];
|
||||||
|
sequence: number;
|
||||||
|
name: string;
|
||||||
|
productId?: [number, string];
|
||||||
|
productUomId?: [number, string];
|
||||||
|
quantity: number;
|
||||||
|
priceUnit: number;
|
||||||
|
discount: number;
|
||||||
|
priceSubtotal: number;
|
||||||
|
priceTotal: number;
|
||||||
|
taxIds: [number, string][];
|
||||||
|
accountId: [number, string];
|
||||||
|
analyticDistribution?: Record<string, number>;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
balance: number;
|
||||||
|
amountCurrency: number;
|
||||||
|
currencyId: [number, string];
|
||||||
|
displayType: 'product' | 'line_section' | 'line_note' | 'tax' | 'payment_term' | 'rounding';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Payment Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de pago
|
||||||
|
*/
|
||||||
|
export type OdooPaymentType = 'inbound' | 'outbound';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado del pago
|
||||||
|
*/
|
||||||
|
export type OdooPaymentStateType =
|
||||||
|
| 'draft'
|
||||||
|
| 'posted'
|
||||||
|
| 'cancel'
|
||||||
|
| 'reconciled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pago de Odoo (account.payment)
|
||||||
|
*/
|
||||||
|
export interface OdooPayment {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
paymentType: OdooPaymentType;
|
||||||
|
partnerType: 'customer' | 'supplier';
|
||||||
|
partnerId: [number, string];
|
||||||
|
amount: number;
|
||||||
|
currencyId: [number, string];
|
||||||
|
date: string;
|
||||||
|
journalId: [number, string];
|
||||||
|
companyId: [number, string];
|
||||||
|
state: OdooPaymentStateType;
|
||||||
|
ref?: string;
|
||||||
|
paymentMethodId: [number, string];
|
||||||
|
paymentMethodLineId?: [number, string];
|
||||||
|
destinationAccountId?: [number, string];
|
||||||
|
moveId?: [number, string];
|
||||||
|
reconciledInvoiceIds: number[];
|
||||||
|
reconciledBillIds: number[];
|
||||||
|
isReconciled: boolean;
|
||||||
|
isMatched: boolean;
|
||||||
|
createDate: string;
|
||||||
|
writeDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de cuenta bancario
|
||||||
|
*/
|
||||||
|
export interface OdooBankStatement {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
date: string;
|
||||||
|
journalId: [number, string];
|
||||||
|
companyId: [number, string];
|
||||||
|
balanceStart: number;
|
||||||
|
balanceEndReal: number;
|
||||||
|
balanceEnd: number;
|
||||||
|
state: 'open' | 'confirm';
|
||||||
|
lineIds: number[];
|
||||||
|
createDate: string;
|
||||||
|
writeDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linea de estado de cuenta bancario
|
||||||
|
*/
|
||||||
|
export interface OdooBankStatementLine {
|
||||||
|
id: number;
|
||||||
|
statementId: [number, string];
|
||||||
|
sequence: number;
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
partnerId?: [number, string];
|
||||||
|
amount: number;
|
||||||
|
amountCurrency?: number;
|
||||||
|
foreignCurrencyId?: [number, string];
|
||||||
|
paymentRef?: string;
|
||||||
|
transactionType?: string;
|
||||||
|
isReconciled: boolean;
|
||||||
|
moveId?: [number, string];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Account Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de cuenta contable
|
||||||
|
*/
|
||||||
|
export type OdooAccountType =
|
||||||
|
| 'asset_receivable'
|
||||||
|
| 'asset_cash'
|
||||||
|
| 'asset_current'
|
||||||
|
| 'asset_non_current'
|
||||||
|
| 'asset_prepayments'
|
||||||
|
| 'asset_fixed'
|
||||||
|
| 'liability_payable'
|
||||||
|
| 'liability_credit_card'
|
||||||
|
| 'liability_current'
|
||||||
|
| 'liability_non_current'
|
||||||
|
| 'equity'
|
||||||
|
| 'equity_unaffected'
|
||||||
|
| 'income'
|
||||||
|
| 'income_other'
|
||||||
|
| 'expense'
|
||||||
|
| 'expense_depreciation'
|
||||||
|
| 'expense_direct_cost'
|
||||||
|
| 'off_balance';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuenta contable de Odoo
|
||||||
|
*/
|
||||||
|
export interface OdooAccount {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
accountType: OdooAccountType;
|
||||||
|
currencyId?: [number, string];
|
||||||
|
companyId: [number, string];
|
||||||
|
deprecated: boolean;
|
||||||
|
reconcile: boolean;
|
||||||
|
note?: string;
|
||||||
|
groupId?: [number, string];
|
||||||
|
rootId?: [number, string];
|
||||||
|
taxIds: [number, string][];
|
||||||
|
tagIds: [number, string][];
|
||||||
|
internalGroup?: string;
|
||||||
|
createDate: string;
|
||||||
|
writeDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asiento contable (account.move)
|
||||||
|
*/
|
||||||
|
export interface OdooJournalEntry {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
date: string;
|
||||||
|
moveType: 'entry' | OdooInvoiceType;
|
||||||
|
state: 'draft' | 'posted' | 'cancel';
|
||||||
|
journalId: [number, string];
|
||||||
|
companyId: [number, string];
|
||||||
|
currencyId: [number, string];
|
||||||
|
lineIds: number[];
|
||||||
|
partnerId?: [number, string];
|
||||||
|
amountTotal: number;
|
||||||
|
amountTotalSigned: number;
|
||||||
|
autoPost?: 'no' | 'at_date' | 'monthly' | 'quarterly' | 'yearly';
|
||||||
|
toCheck: boolean;
|
||||||
|
narration?: string;
|
||||||
|
reversedEntryId?: [number, string];
|
||||||
|
createDate: string;
|
||||||
|
writeDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linea de asiento contable
|
||||||
|
*/
|
||||||
|
export interface OdooJournalEntryLine {
|
||||||
|
id: number;
|
||||||
|
moveId: [number, string];
|
||||||
|
moveName?: string;
|
||||||
|
sequence: number;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
date: string;
|
||||||
|
journalId: [number, string];
|
||||||
|
companyId: [number, string];
|
||||||
|
accountId: [number, string];
|
||||||
|
partnerId?: [number, string];
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
balance: number;
|
||||||
|
amountCurrency: number;
|
||||||
|
currencyId: [number, string];
|
||||||
|
reconciled: boolean;
|
||||||
|
fullReconcileId?: [number, string];
|
||||||
|
matchingNumber?: string;
|
||||||
|
analyticDistribution?: Record<string, number>;
|
||||||
|
taxIds: [number, string][];
|
||||||
|
taxTagIds: [number, string][];
|
||||||
|
taxLineId?: [number, string];
|
||||||
|
taxRepartitionLineId?: [number, string];
|
||||||
|
displayType: 'product' | 'cogs' | 'tax' | 'rounding' | 'payment_term' | 'line_section' | 'line_note' | 'epd';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diario contable
|
||||||
|
*/
|
||||||
|
export interface OdooJournal {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
|
||||||
|
companyId: [number, string];
|
||||||
|
currencyId?: [number, string];
|
||||||
|
defaultAccountId?: [number, string];
|
||||||
|
suspenseAccountId?: [number, string];
|
||||||
|
profitAccountId?: [number, string];
|
||||||
|
lossAccountId?: [number, string];
|
||||||
|
active: boolean;
|
||||||
|
sequence: number;
|
||||||
|
invoiceReferenceType?: string;
|
||||||
|
invoiceReferenceModel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Product & Inventory Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de producto
|
||||||
|
*/
|
||||||
|
export type OdooProductType = 'consu' | 'service' | 'product';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Producto de Odoo
|
||||||
|
*/
|
||||||
|
export interface OdooProduct {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
defaultCode?: string;
|
||||||
|
barcode?: string;
|
||||||
|
type: OdooProductType;
|
||||||
|
categId: [number, string];
|
||||||
|
listPrice: number;
|
||||||
|
standardPrice: number;
|
||||||
|
saleOk: boolean;
|
||||||
|
purchaseOk: boolean;
|
||||||
|
active: boolean;
|
||||||
|
uomId: [number, string];
|
||||||
|
uomPoId: [number, string];
|
||||||
|
companyId?: [number, string];
|
||||||
|
description?: string;
|
||||||
|
descriptionSale?: string;
|
||||||
|
descriptionPurchase?: string;
|
||||||
|
weight?: number;
|
||||||
|
volume?: number;
|
||||||
|
tracking: 'none' | 'serial' | 'lot';
|
||||||
|
// Cuentas contables
|
||||||
|
propertyAccountIncomeId?: [number, string];
|
||||||
|
propertyAccountExpenseId?: [number, string];
|
||||||
|
// Impuestos
|
||||||
|
taxesId: [number, string][];
|
||||||
|
supplierTaxesId: [number, string][];
|
||||||
|
// Inventario
|
||||||
|
qtyAvailable?: number;
|
||||||
|
virtualAvailable?: number;
|
||||||
|
incomingQty?: number;
|
||||||
|
outgoingQty?: number;
|
||||||
|
freeQty?: number;
|
||||||
|
// Costos
|
||||||
|
valuationMethod?: 'standard' | 'fifo' | 'average';
|
||||||
|
costMethod?: 'standard' | 'fifo' | 'average';
|
||||||
|
createDate: string;
|
||||||
|
writeDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nivel de stock por ubicacion
|
||||||
|
*/
|
||||||
|
export interface OdooStockLevel {
|
||||||
|
productId: number;
|
||||||
|
productName: string;
|
||||||
|
productCode?: string;
|
||||||
|
locationId: number;
|
||||||
|
locationName: string;
|
||||||
|
lotId?: number;
|
||||||
|
lotName?: string;
|
||||||
|
packageId?: number;
|
||||||
|
packageName?: string;
|
||||||
|
quantity: number;
|
||||||
|
reservedQuantity: number;
|
||||||
|
availableQuantity: number;
|
||||||
|
uomId: number;
|
||||||
|
uomName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Movimiento de stock
|
||||||
|
*/
|
||||||
|
export interface OdooStockMove {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
reference?: string;
|
||||||
|
date: string;
|
||||||
|
productId: [number, string];
|
||||||
|
productUomQty: number;
|
||||||
|
productUom: [number, string];
|
||||||
|
locationId: [number, string];
|
||||||
|
locationDestId: [number, string];
|
||||||
|
state: 'draft' | 'waiting' | 'confirmed' | 'assigned' | 'done' | 'cancel';
|
||||||
|
pickingId?: [number, string];
|
||||||
|
pickingCode?: string;
|
||||||
|
originReturnedMoveId?: [number, string];
|
||||||
|
priceUnit: number;
|
||||||
|
companyId: [number, string];
|
||||||
|
partnerId?: [number, string];
|
||||||
|
origin?: string;
|
||||||
|
createDate: string;
|
||||||
|
writeDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valoracion de inventario
|
||||||
|
*/
|
||||||
|
export interface OdooStockValuation {
|
||||||
|
productId: number;
|
||||||
|
productName: string;
|
||||||
|
productCode?: string;
|
||||||
|
quantity: number;
|
||||||
|
unitCost: number;
|
||||||
|
totalValue: number;
|
||||||
|
currencyId: number;
|
||||||
|
currencyCode: string;
|
||||||
|
valuationMethod: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linea del catalogo de cuentas
|
||||||
|
*/
|
||||||
|
export interface ChartOfAccountsLine {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
accountType: OdooAccountType;
|
||||||
|
level: number;
|
||||||
|
parentId?: number;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linea de balanza de comprobacion
|
||||||
|
*/
|
||||||
|
export interface TrialBalanceLine {
|
||||||
|
accountId: number;
|
||||||
|
accountCode: string;
|
||||||
|
accountName: string;
|
||||||
|
accountType: OdooAccountType;
|
||||||
|
initialDebit: number;
|
||||||
|
initialCredit: number;
|
||||||
|
initialBalance: number;
|
||||||
|
periodDebit: number;
|
||||||
|
periodCredit: number;
|
||||||
|
periodBalance: number;
|
||||||
|
finalDebit: number;
|
||||||
|
finalCredit: number;
|
||||||
|
finalBalance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de resultados
|
||||||
|
*/
|
||||||
|
export interface ProfitAndLossReport {
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
companyId: number;
|
||||||
|
companyName: string;
|
||||||
|
currencyId: number;
|
||||||
|
currencyCode: string;
|
||||||
|
income: ProfitAndLossSection;
|
||||||
|
expense: ProfitAndLossSection;
|
||||||
|
netProfit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfitAndLossSection {
|
||||||
|
total: number;
|
||||||
|
lines: ProfitAndLossLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfitAndLossLine {
|
||||||
|
accountId: number;
|
||||||
|
accountCode: string;
|
||||||
|
accountName: string;
|
||||||
|
amount: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Balance general
|
||||||
|
*/
|
||||||
|
export interface BalanceSheetReport {
|
||||||
|
date: string;
|
||||||
|
companyId: number;
|
||||||
|
companyName: string;
|
||||||
|
currencyId: number;
|
||||||
|
currencyCode: string;
|
||||||
|
assets: BalanceSheetSection;
|
||||||
|
liabilities: BalanceSheetSection;
|
||||||
|
equity: BalanceSheetSection;
|
||||||
|
totalAssets: number;
|
||||||
|
totalLiabilitiesEquity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BalanceSheetSection {
|
||||||
|
total: number;
|
||||||
|
subsections: BalanceSheetSubsection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BalanceSheetSubsection {
|
||||||
|
name: string;
|
||||||
|
total: number;
|
||||||
|
lines: BalanceSheetLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BalanceSheetLine {
|
||||||
|
accountId: number;
|
||||||
|
accountCode: string;
|
||||||
|
accountName: string;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reporte de impuestos
|
||||||
|
*/
|
||||||
|
export interface TaxReport {
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
companyId: number;
|
||||||
|
companyName: string;
|
||||||
|
lines: TaxReportLine[];
|
||||||
|
totalSalesBase: number;
|
||||||
|
totalSalesTax: number;
|
||||||
|
totalPurchasesBase: number;
|
||||||
|
totalPurchasesTax: number;
|
||||||
|
netTax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxReportLine {
|
||||||
|
taxId: number;
|
||||||
|
taxName: string;
|
||||||
|
taxType: 'sale' | 'purchase' | 'none';
|
||||||
|
baseAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
invoiceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sync Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado de sincronizacion
|
||||||
|
*/
|
||||||
|
export interface OdooSyncResult {
|
||||||
|
success: boolean;
|
||||||
|
syncId: string;
|
||||||
|
startedAt: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
duration?: number;
|
||||||
|
stats: OdooSyncStats;
|
||||||
|
errors: OdooSyncError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OdooSyncStats {
|
||||||
|
partners: { created: number; updated: number; errors: number };
|
||||||
|
invoices: { created: number; updated: number; errors: number };
|
||||||
|
payments: { created: number; updated: number; errors: number };
|
||||||
|
journalEntries: { created: number; updated: number; errors: number };
|
||||||
|
products: { created: number; updated: number; errors: number };
|
||||||
|
stockMoves: { created: number; updated: number; errors: number };
|
||||||
|
total: { created: number; updated: number; errors: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OdooSyncError {
|
||||||
|
model: string;
|
||||||
|
recordId?: number;
|
||||||
|
externalRef?: string;
|
||||||
|
error: string;
|
||||||
|
details?: unknown;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones de sincronizacion
|
||||||
|
*/
|
||||||
|
export interface OdooSyncOptions {
|
||||||
|
tenantId: string;
|
||||||
|
config: OdooConfig;
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
fullSync?: boolean;
|
||||||
|
includePartners?: boolean;
|
||||||
|
includeInvoices?: boolean;
|
||||||
|
includePayments?: boolean;
|
||||||
|
includeJournalEntries?: boolean;
|
||||||
|
includeProducts?: boolean;
|
||||||
|
includeStockMoves?: boolean;
|
||||||
|
batchSize?: number;
|
||||||
|
onProgress?: (progress: OdooSyncProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OdooSyncProgress {
|
||||||
|
model: string;
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
percentage: number;
|
||||||
|
currentItem?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook de Odoo
|
||||||
|
*/
|
||||||
|
export interface OdooWebhookPayload {
|
||||||
|
model: string;
|
||||||
|
action: 'create' | 'write' | 'unlink';
|
||||||
|
recordIds: number[];
|
||||||
|
timestamp: string;
|
||||||
|
companyId: number;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codigos de error de Odoo
|
||||||
|
*/
|
||||||
|
export type OdooErrorCode =
|
||||||
|
| 'CONNECTION_ERROR'
|
||||||
|
| 'AUTH_ERROR'
|
||||||
|
| 'ACCESS_DENIED'
|
||||||
|
| 'RECORD_NOT_FOUND'
|
||||||
|
| 'VALIDATION_ERROR'
|
||||||
|
| 'XML_RPC_ERROR'
|
||||||
|
| 'TIMEOUT'
|
||||||
|
| 'RATE_LIMIT'
|
||||||
|
| 'UNKNOWN_ERROR';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error de Odoo
|
||||||
|
*/
|
||||||
|
export class OdooError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly code: OdooErrorCode,
|
||||||
|
public readonly details?: unknown,
|
||||||
|
public readonly odooFaultCode?: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'OdooError';
|
||||||
|
Object.setPrototypeOf(this, OdooError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error de autenticacion
|
||||||
|
*/
|
||||||
|
export class OdooAuthError extends OdooError {
|
||||||
|
constructor(message: string, details?: unknown) {
|
||||||
|
super(message, 'AUTH_ERROR', details);
|
||||||
|
this.name = 'OdooAuthError';
|
||||||
|
Object.setPrototypeOf(this, OdooAuthError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error de conexion
|
||||||
|
*/
|
||||||
|
export class OdooConnectionError extends OdooError {
|
||||||
|
constructor(message: string, details?: unknown) {
|
||||||
|
super(message, 'CONNECTION_ERROR', details);
|
||||||
|
this.name = 'OdooConnectionError';
|
||||||
|
Object.setPrototypeOf(this, OdooConnectionError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dominio de busqueda de Odoo (usado en search/read)
|
||||||
|
*/
|
||||||
|
export type OdooDomain = Array<string | OdooDomainTuple | OdooDomain>;
|
||||||
|
|
||||||
|
export type OdooDomainTuple = [string, string, unknown];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones de paginacion
|
||||||
|
*/
|
||||||
|
export interface OdooPagination {
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
order?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respuesta paginada
|
||||||
|
*/
|
||||||
|
export interface OdooPaginatedResponse<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Campos a leer
|
||||||
|
*/
|
||||||
|
export type OdooFields = string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valores para crear/actualizar
|
||||||
|
*/
|
||||||
|
export type OdooValues = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapeo de transaccion de Horux
|
||||||
|
*/
|
||||||
|
export interface HoruxTransactionMapping {
|
||||||
|
externalId: string;
|
||||||
|
externalRef?: string;
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
type: 'income' | 'expense' | 'transfer';
|
||||||
|
category?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
partnerName?: string;
|
||||||
|
accountCode?: string;
|
||||||
|
accountName?: string;
|
||||||
|
currencyCode: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
904
apps/api/src/services/integrations/odoo/partners.connector.ts
Normal file
904
apps/api/src/services/integrations/odoo/partners.connector.ts
Normal file
@@ -0,0 +1,904 @@
|
|||||||
|
/**
|
||||||
|
* Odoo Partners Connector
|
||||||
|
* Conector para contactos (clientes y proveedores) de Odoo ERP
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { OdooClient } from './odoo.client.js';
|
||||||
|
import {
|
||||||
|
OdooPartner,
|
||||||
|
PartnerBalance,
|
||||||
|
PartnerTransaction,
|
||||||
|
OdooDomain,
|
||||||
|
OdooPagination,
|
||||||
|
OdooPaginatedResponse,
|
||||||
|
OdooError,
|
||||||
|
} from './odoo.types.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tipos internos
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PartnerStats {
|
||||||
|
totalInvoiced: number;
|
||||||
|
totalPaid: number;
|
||||||
|
totalPending: number;
|
||||||
|
invoiceCount: number;
|
||||||
|
lastInvoiceDate?: string;
|
||||||
|
averagePaymentDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartnerCreditInfo {
|
||||||
|
credit: number;
|
||||||
|
debit: number;
|
||||||
|
creditLimit: number;
|
||||||
|
availableCredit: number;
|
||||||
|
overdueAmount: number;
|
||||||
|
isBlocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Partners Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector de contactos para Odoo
|
||||||
|
*/
|
||||||
|
export class OdooPartnersConnector {
|
||||||
|
private client: OdooClient;
|
||||||
|
|
||||||
|
constructor(client: OdooClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Customers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene clientes
|
||||||
|
*/
|
||||||
|
async getCustomers(
|
||||||
|
options?: {
|
||||||
|
search?: string;
|
||||||
|
isCompany?: boolean;
|
||||||
|
categoryId?: number;
|
||||||
|
userId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
onlyWithCredit?: boolean;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooPartner>> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['active', '=', true],
|
||||||
|
['customer_rank', '>', 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
this.applyCommonFilters(domain, options);
|
||||||
|
|
||||||
|
if (options?.onlyWithCredit) {
|
||||||
|
domain.push(['credit', '>', 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<OdooPartner>(
|
||||||
|
'res.partner',
|
||||||
|
domain,
|
||||||
|
this.getPartnerFields(),
|
||||||
|
{ order: 'name asc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene clientes con saldo pendiente
|
||||||
|
*/
|
||||||
|
async getCustomersWithBalance(
|
||||||
|
options?: {
|
||||||
|
minBalance?: number;
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooPartner>> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['active', '=', true],
|
||||||
|
['customer_rank', '>', 0],
|
||||||
|
['credit', '>', options?.minBalance || 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<OdooPartner>(
|
||||||
|
'res.partner',
|
||||||
|
domain,
|
||||||
|
this.getPartnerFields(),
|
||||||
|
{ order: 'credit desc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca clientes por RFC/VAT
|
||||||
|
*/
|
||||||
|
async getCustomerByVat(vat: string): Promise<OdooPartner | null> {
|
||||||
|
const partners = await this.client.searchRead<OdooPartner>(
|
||||||
|
'res.partner',
|
||||||
|
[
|
||||||
|
['vat', '=', vat],
|
||||||
|
['customer_rank', '>', 0],
|
||||||
|
],
|
||||||
|
this.getPartnerFields(),
|
||||||
|
{ limit: 1 }
|
||||||
|
);
|
||||||
|
return partners[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Vendors
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene proveedores
|
||||||
|
*/
|
||||||
|
async getVendors(
|
||||||
|
options?: {
|
||||||
|
search?: string;
|
||||||
|
isCompany?: boolean;
|
||||||
|
categoryId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
onlyWithPayables?: boolean;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooPartner>> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['active', '=', true],
|
||||||
|
['supplier_rank', '>', 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
this.applyCommonFilters(domain, options);
|
||||||
|
|
||||||
|
if (options?.onlyWithPayables) {
|
||||||
|
domain.push(['debit', '>', 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<OdooPartner>(
|
||||||
|
'res.partner',
|
||||||
|
domain,
|
||||||
|
this.getPartnerFields(),
|
||||||
|
{ order: 'name asc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene proveedores con saldo pendiente
|
||||||
|
*/
|
||||||
|
async getVendorsWithBalance(
|
||||||
|
options?: {
|
||||||
|
minBalance?: number;
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<OdooPartner>> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['active', '=', true],
|
||||||
|
['supplier_rank', '>', 0],
|
||||||
|
['debit', '>', options?.minBalance || 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<OdooPartner>(
|
||||||
|
'res.partner',
|
||||||
|
domain,
|
||||||
|
this.getPartnerFields(),
|
||||||
|
{ order: 'debit desc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca proveedor por RFC/VAT
|
||||||
|
*/
|
||||||
|
async getVendorByVat(vat: string): Promise<OdooPartner | null> {
|
||||||
|
const partners = await this.client.searchRead<OdooPartner>(
|
||||||
|
'res.partner',
|
||||||
|
[
|
||||||
|
['vat', '=', vat],
|
||||||
|
['supplier_rank', '>', 0],
|
||||||
|
],
|
||||||
|
this.getPartnerFields(),
|
||||||
|
{ limit: 1 }
|
||||||
|
);
|
||||||
|
return partners[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Partner Balance
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el balance de un partner
|
||||||
|
*/
|
||||||
|
async getPartnerBalance(
|
||||||
|
partnerId: number,
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<PartnerBalance> {
|
||||||
|
const connection = this.client.getConnection();
|
||||||
|
if (!connection) {
|
||||||
|
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = await this.client.read<OdooPartner>(
|
||||||
|
'res.partner',
|
||||||
|
[partnerId],
|
||||||
|
['name', 'credit', 'debit']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partner[0]) {
|
||||||
|
throw new OdooError(`Partner ${partnerId} no encontrado`, 'RECORD_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener cuentas por cobrar y pagar
|
||||||
|
const companyId = options?.companyId || connection.activeCompanyId;
|
||||||
|
const company = connection.companies.find((c) => c.id === companyId);
|
||||||
|
|
||||||
|
const receivableBalance = await this.getAccountBalance(
|
||||||
|
partnerId,
|
||||||
|
'asset_receivable',
|
||||||
|
companyId
|
||||||
|
);
|
||||||
|
|
||||||
|
const payableBalance = await this.getAccountBalance(
|
||||||
|
partnerId,
|
||||||
|
'liability_payable',
|
||||||
|
companyId
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
partnerId,
|
||||||
|
partnerName: partner[0].name,
|
||||||
|
debit: partner[0].debit,
|
||||||
|
credit: partner[0].credit,
|
||||||
|
balance: partner[0].credit - partner[0].debit,
|
||||||
|
receivable: receivableBalance,
|
||||||
|
payable: Math.abs(payableBalance),
|
||||||
|
currencyId: company?.currencyId || 0,
|
||||||
|
currencyCode: company?.currencyCode || 'MXN',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene balances de multiples partners
|
||||||
|
*/
|
||||||
|
async getPartnersBalance(
|
||||||
|
partnerIds: number[],
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<PartnerBalance[]> {
|
||||||
|
if (partnerIds.length === 0) return [];
|
||||||
|
|
||||||
|
const results: PartnerBalance[] = [];
|
||||||
|
for (const partnerId of partnerIds) {
|
||||||
|
try {
|
||||||
|
const balance = await this.getPartnerBalance(partnerId, options);
|
||||||
|
results.push(balance);
|
||||||
|
} catch (error) {
|
||||||
|
// Continuar con el siguiente partner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Partner Transactions
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene transacciones de un partner
|
||||||
|
*/
|
||||||
|
async getPartnerTransactions(
|
||||||
|
partnerId: number,
|
||||||
|
options?: {
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
accountType?: 'receivable' | 'payable' | 'all';
|
||||||
|
onlyUnreconciled?: boolean;
|
||||||
|
companyId?: number;
|
||||||
|
pagination?: OdooPagination;
|
||||||
|
}
|
||||||
|
): Promise<OdooPaginatedResponse<PartnerTransaction>> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['partner_id', '=', partnerId],
|
||||||
|
['parent_state', '=', 'posted'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.dateFrom) {
|
||||||
|
domain.push(['date', '>=', this.formatDate(options.dateFrom)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.dateTo) {
|
||||||
|
domain.push(['date', '<=', this.formatDate(options.dateTo)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.accountType === 'receivable') {
|
||||||
|
domain.push(['account_type', '=', 'asset_receivable']);
|
||||||
|
} else if (options?.accountType === 'payable') {
|
||||||
|
domain.push(['account_type', '=', 'liability_payable']);
|
||||||
|
} else if (options?.accountType === 'all') {
|
||||||
|
domain.push(['account_type', 'in', ['asset_receivable', 'liability_payable']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.onlyUnreconciled) {
|
||||||
|
domain.push(['reconciled', '=', false]);
|
||||||
|
domain.push(['amount_residual', '!=', 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchReadPaginated<PartnerTransaction>(
|
||||||
|
'account.move.line',
|
||||||
|
domain,
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'date',
|
||||||
|
'ref',
|
||||||
|
'name',
|
||||||
|
'journal_id',
|
||||||
|
'account_id',
|
||||||
|
'move_id',
|
||||||
|
'debit',
|
||||||
|
'credit',
|
||||||
|
'balance',
|
||||||
|
'amount_currency',
|
||||||
|
'currency_id',
|
||||||
|
'reconciled',
|
||||||
|
'full_reconcile_id',
|
||||||
|
],
|
||||||
|
{ order: 'date desc, id desc', ...options?.pagination }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estado de cuenta de un partner
|
||||||
|
*/
|
||||||
|
async getPartnerStatement(
|
||||||
|
partnerId: number,
|
||||||
|
options?: {
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
accountType?: 'receivable' | 'payable' | 'all';
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
partner: OdooPartner;
|
||||||
|
openingBalance: number;
|
||||||
|
transactions: PartnerTransaction[];
|
||||||
|
closingBalance: number;
|
||||||
|
}> {
|
||||||
|
const partner = await this.getPartnerById(partnerId);
|
||||||
|
if (!partner) {
|
||||||
|
throw new OdooError(`Partner ${partnerId} no encontrado`, 'RECORD_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFrom = options?.dateFrom || new Date('1900-01-01');
|
||||||
|
const dateTo = options?.dateTo || new Date();
|
||||||
|
|
||||||
|
// Obtener balance inicial
|
||||||
|
const openingBalance = await this.getPartnerBalanceAtDate(
|
||||||
|
partnerId,
|
||||||
|
new Date(dateFrom.getTime() - 86400000),
|
||||||
|
options?.accountType,
|
||||||
|
options?.companyId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener transacciones del periodo
|
||||||
|
const transactionsResult = await this.getPartnerTransactions(partnerId, {
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
accountType: options?.accountType,
|
||||||
|
companyId: options?.companyId,
|
||||||
|
pagination: { limit: 10000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular balance final
|
||||||
|
let runningBalance = openingBalance;
|
||||||
|
const transactions = transactionsResult.items.sort(
|
||||||
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const tx of transactions) {
|
||||||
|
runningBalance += tx.balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
partner,
|
||||||
|
openingBalance,
|
||||||
|
transactions,
|
||||||
|
closingBalance: runningBalance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Partner Statistics
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadisticas de un cliente
|
||||||
|
*/
|
||||||
|
async getCustomerStats(
|
||||||
|
partnerId: number,
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<PartnerStats> {
|
||||||
|
return this.getPartnerStats(partnerId, 'customer', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadisticas de un proveedor
|
||||||
|
*/
|
||||||
|
async getVendorStats(
|
||||||
|
partnerId: number,
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<PartnerStats> {
|
||||||
|
return this.getPartnerStats(partnerId, 'vendor', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene informacion de credito de un cliente
|
||||||
|
*/
|
||||||
|
async getCustomerCreditInfo(
|
||||||
|
partnerId: number,
|
||||||
|
options?: {
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): Promise<PartnerCreditInfo> {
|
||||||
|
const partner = await this.client.read<OdooPartner>(
|
||||||
|
'res.partner',
|
||||||
|
[partnerId],
|
||||||
|
['credit', 'debit', 'credit_limit']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partner[0]) {
|
||||||
|
throw new OdooError(`Partner ${partnerId} no encontrado`, 'RECORD_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
const credit = partner[0].credit;
|
||||||
|
const debit = partner[0].debit;
|
||||||
|
const creditLimit = partner[0].creditLimit;
|
||||||
|
|
||||||
|
// Obtener monto vencido
|
||||||
|
const overdueResult = await this.getOverdueAmount(partnerId, options?.companyId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
credit,
|
||||||
|
debit,
|
||||||
|
creditLimit,
|
||||||
|
availableCredit: creditLimit - credit,
|
||||||
|
overdueAmount: overdueResult,
|
||||||
|
isBlocked: creditLimit > 0 && credit >= creditLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Partner CRUD
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un partner por ID
|
||||||
|
*/
|
||||||
|
async getPartnerById(partnerId: number): Promise<OdooPartner | null> {
|
||||||
|
const partners = await this.client.read<OdooPartner>(
|
||||||
|
'res.partner',
|
||||||
|
[partnerId],
|
||||||
|
this.getPartnerFields()
|
||||||
|
);
|
||||||
|
return partners[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca partners
|
||||||
|
*/
|
||||||
|
async searchPartners(
|
||||||
|
query: string,
|
||||||
|
options?: {
|
||||||
|
type?: 'customer' | 'vendor' | 'all';
|
||||||
|
isCompany?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
): Promise<OdooPartner[]> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['active', '=', true],
|
||||||
|
'|',
|
||||||
|
['name', 'ilike', query],
|
||||||
|
['vat', 'ilike', query],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.type === 'customer') {
|
||||||
|
domain.push(['customer_rank', '>', 0]);
|
||||||
|
} else if (options?.type === 'vendor') {
|
||||||
|
domain.push(['supplier_rank', '>', 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.isCompany !== undefined) {
|
||||||
|
domain.push(['is_company', '=', options.isCompany]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.searchRead<OdooPartner>(
|
||||||
|
'res.partner',
|
||||||
|
domain,
|
||||||
|
this.getPartnerFields(),
|
||||||
|
{ limit: options?.limit || 20, order: 'name asc' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un nuevo partner
|
||||||
|
*/
|
||||||
|
async createPartner(data: {
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
vat?: string;
|
||||||
|
street?: string;
|
||||||
|
street2?: string;
|
||||||
|
city?: string;
|
||||||
|
stateId?: number;
|
||||||
|
zip?: string;
|
||||||
|
countryId?: number;
|
||||||
|
isCompany?: boolean;
|
||||||
|
isCustomer?: boolean;
|
||||||
|
isVendor?: boolean;
|
||||||
|
companyId?: number;
|
||||||
|
parentId?: number;
|
||||||
|
}): Promise<number> {
|
||||||
|
const values: Record<string, unknown> = {
|
||||||
|
name: data.name,
|
||||||
|
is_company: data.isCompany ?? true,
|
||||||
|
customer_rank: data.isCustomer ? 1 : 0,
|
||||||
|
supplier_rank: data.isVendor ? 1 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.email) values.email = data.email;
|
||||||
|
if (data.phone) values.phone = data.phone;
|
||||||
|
if (data.mobile) values.mobile = data.mobile;
|
||||||
|
if (data.vat) values.vat = data.vat;
|
||||||
|
if (data.street) values.street = data.street;
|
||||||
|
if (data.street2) values.street2 = data.street2;
|
||||||
|
if (data.city) values.city = data.city;
|
||||||
|
if (data.stateId) values.state_id = data.stateId;
|
||||||
|
if (data.zip) values.zip = data.zip;
|
||||||
|
if (data.countryId) values.country_id = data.countryId;
|
||||||
|
if (data.companyId) values.company_id = data.companyId;
|
||||||
|
if (data.parentId) values.parent_id = data.parentId;
|
||||||
|
|
||||||
|
return this.client.create('res.partner', values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza un partner
|
||||||
|
*/
|
||||||
|
async updatePartner(
|
||||||
|
partnerId: number,
|
||||||
|
data: Partial<{
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
mobile: string;
|
||||||
|
vat: string;
|
||||||
|
street: string;
|
||||||
|
street2: string;
|
||||||
|
city: string;
|
||||||
|
stateId: number;
|
||||||
|
zip: string;
|
||||||
|
countryId: number;
|
||||||
|
isCompany: boolean;
|
||||||
|
creditLimit: number;
|
||||||
|
}>
|
||||||
|
): Promise<boolean> {
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (data.name !== undefined) values.name = data.name;
|
||||||
|
if (data.email !== undefined) values.email = data.email;
|
||||||
|
if (data.phone !== undefined) values.phone = data.phone;
|
||||||
|
if (data.mobile !== undefined) values.mobile = data.mobile;
|
||||||
|
if (data.vat !== undefined) values.vat = data.vat;
|
||||||
|
if (data.street !== undefined) values.street = data.street;
|
||||||
|
if (data.street2 !== undefined) values.street2 = data.street2;
|
||||||
|
if (data.city !== undefined) values.city = data.city;
|
||||||
|
if (data.stateId !== undefined) values.state_id = data.stateId;
|
||||||
|
if (data.zip !== undefined) values.zip = data.zip;
|
||||||
|
if (data.countryId !== undefined) values.country_id = data.countryId;
|
||||||
|
if (data.isCompany !== undefined) values.is_company = data.isCompany;
|
||||||
|
if (data.creditLimit !== undefined) values.credit_limit = data.creditLimit;
|
||||||
|
|
||||||
|
return this.client.write('res.partner', [partnerId], values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene categorias de partner
|
||||||
|
*/
|
||||||
|
async getPartnerCategories(): Promise<Array<{ id: number; name: string }>> {
|
||||||
|
return this.client.searchRead<{ id: number; name: string }>(
|
||||||
|
'res.partner.category',
|
||||||
|
[],
|
||||||
|
['id', 'name'],
|
||||||
|
{ order: 'name asc' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Private Helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplica filtros comunes al dominio
|
||||||
|
*/
|
||||||
|
private applyCommonFilters(
|
||||||
|
domain: OdooDomain,
|
||||||
|
options?: {
|
||||||
|
search?: string;
|
||||||
|
isCompany?: boolean;
|
||||||
|
categoryId?: number;
|
||||||
|
userId?: number;
|
||||||
|
companyId?: number;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
if (options?.search) {
|
||||||
|
domain.push('|');
|
||||||
|
domain.push(['name', 'ilike', options.search]);
|
||||||
|
domain.push(['vat', 'ilike', options.search]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.isCompany !== undefined) {
|
||||||
|
domain.push(['is_company', '=', options.isCompany]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.categoryId) {
|
||||||
|
domain.push(['category_id', 'in', [options.categoryId]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.userId) {
|
||||||
|
domain.push(['user_id', '=', options.userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene balance de una cuenta para un partner
|
||||||
|
*/
|
||||||
|
private async getAccountBalance(
|
||||||
|
partnerId: number,
|
||||||
|
accountType: string,
|
||||||
|
companyId: number
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await this.client.callMethod<Array<{
|
||||||
|
balance: number;
|
||||||
|
}>>(
|
||||||
|
'account.move.line',
|
||||||
|
'read_group',
|
||||||
|
[
|
||||||
|
[
|
||||||
|
['partner_id', '=', partnerId],
|
||||||
|
['account_type', '=', accountType],
|
||||||
|
['parent_state', '=', 'posted'],
|
||||||
|
['company_id', '=', companyId],
|
||||||
|
],
|
||||||
|
['balance'],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
{ lazy: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
return result[0]?.balance || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene balance de partner a una fecha
|
||||||
|
*/
|
||||||
|
private async getPartnerBalanceAtDate(
|
||||||
|
partnerId: number,
|
||||||
|
date: Date,
|
||||||
|
accountType?: 'receivable' | 'payable' | 'all',
|
||||||
|
companyId?: number
|
||||||
|
): Promise<number> {
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['partner_id', '=', partnerId],
|
||||||
|
['date', '<=', this.formatDate(date)],
|
||||||
|
['parent_state', '=', 'posted'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (accountType === 'receivable') {
|
||||||
|
domain.push(['account_type', '=', 'asset_receivable']);
|
||||||
|
} else if (accountType === 'payable') {
|
||||||
|
domain.push(['account_type', '=', 'liability_payable']);
|
||||||
|
} else {
|
||||||
|
domain.push(['account_type', 'in', ['asset_receivable', 'liability_payable']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
domain.push(['company_id', '=', companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.callMethod<Array<{
|
||||||
|
balance: number;
|
||||||
|
}>>(
|
||||||
|
'account.move.line',
|
||||||
|
'read_group',
|
||||||
|
[domain, ['balance'], []],
|
||||||
|
{ lazy: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
return result[0]?.balance || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadisticas de un partner
|
||||||
|
*/
|
||||||
|
private async getPartnerStats(
|
||||||
|
partnerId: number,
|
||||||
|
type: 'customer' | 'vendor',
|
||||||
|
options?: { companyId?: number }
|
||||||
|
): Promise<PartnerStats> {
|
||||||
|
const invoiceTypes = type === 'customer'
|
||||||
|
? ['out_invoice', 'out_refund']
|
||||||
|
: ['in_invoice', 'in_refund'];
|
||||||
|
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['partner_id', '=', partnerId],
|
||||||
|
['move_type', 'in', invoiceTypes],
|
||||||
|
['state', '=', 'posted'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.companyId) {
|
||||||
|
domain.push(['company_id', '=', options.companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoices = await this.client.searchRead<{
|
||||||
|
amountTotal: number;
|
||||||
|
amountResidual: number;
|
||||||
|
paymentState: string;
|
||||||
|
invoiceDate: string;
|
||||||
|
}>(
|
||||||
|
'account.move',
|
||||||
|
domain,
|
||||||
|
['amount_total', 'amount_residual', 'payment_state', 'invoice_date'],
|
||||||
|
{ order: 'invoice_date desc', limit: 1000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalInvoiced = 0;
|
||||||
|
let totalPaid = 0;
|
||||||
|
let totalPending = 0;
|
||||||
|
let lastInvoiceDate: string | undefined;
|
||||||
|
const paymentDays: number[] = [];
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
totalInvoiced += invoice.amountTotal;
|
||||||
|
|
||||||
|
if (invoice.paymentState === 'paid') {
|
||||||
|
totalPaid += invoice.amountTotal;
|
||||||
|
} else {
|
||||||
|
totalPending += invoice.amountResidual;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastInvoiceDate) {
|
||||||
|
lastInvoiceDate = invoice.invoiceDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalInvoiced,
|
||||||
|
totalPaid,
|
||||||
|
totalPending,
|
||||||
|
invoiceCount: invoices.length,
|
||||||
|
lastInvoiceDate,
|
||||||
|
averagePaymentDays: paymentDays.length > 0
|
||||||
|
? paymentDays.reduce((a, b) => a + b, 0) / paymentDays.length
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene monto vencido de un partner
|
||||||
|
*/
|
||||||
|
private async getOverdueAmount(
|
||||||
|
partnerId: number,
|
||||||
|
companyId?: number
|
||||||
|
): Promise<number> {
|
||||||
|
const today = this.formatDate(new Date());
|
||||||
|
|
||||||
|
const domain: OdooDomain = [
|
||||||
|
['partner_id', '=', partnerId],
|
||||||
|
['move_type', 'in', ['out_invoice', 'out_refund']],
|
||||||
|
['state', '=', 'posted'],
|
||||||
|
['payment_state', 'in', ['not_paid', 'partial']],
|
||||||
|
['invoice_date_due', '<', today],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
domain.push(['company_id', '=', companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoices = await this.client.searchRead<{
|
||||||
|
amountResidual: number;
|
||||||
|
}>(
|
||||||
|
'account.move',
|
||||||
|
domain,
|
||||||
|
['amount_residual']
|
||||||
|
);
|
||||||
|
|
||||||
|
return invoices.reduce((sum, inv) => sum + inv.amountResidual, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Campos a leer de partners
|
||||||
|
*/
|
||||||
|
private getPartnerFields(): string[] {
|
||||||
|
return [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'display_name',
|
||||||
|
'ref',
|
||||||
|
'vat',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'mobile',
|
||||||
|
'website',
|
||||||
|
'street',
|
||||||
|
'street2',
|
||||||
|
'city',
|
||||||
|
'state_id',
|
||||||
|
'zip',
|
||||||
|
'country_id',
|
||||||
|
'is_company',
|
||||||
|
'parent_id',
|
||||||
|
'child_ids',
|
||||||
|
'company_id',
|
||||||
|
'user_id',
|
||||||
|
'customer_rank',
|
||||||
|
'supplier_rank',
|
||||||
|
'credit',
|
||||||
|
'debit',
|
||||||
|
'credit_limit',
|
||||||
|
'property_account_receivable_id',
|
||||||
|
'property_account_payable_id',
|
||||||
|
'property_payment_term_id',
|
||||||
|
'property_supplier_payment_term_id',
|
||||||
|
'lang',
|
||||||
|
'category_id',
|
||||||
|
'active',
|
||||||
|
'create_date',
|
||||||
|
'write_date',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea una fecha a string YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
private formatDate(date: Date): string {
|
||||||
|
return date.toISOString().split('T')[0] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un conector de contactos
|
||||||
|
*/
|
||||||
|
export function createPartnersConnector(
|
||||||
|
client: OdooClient
|
||||||
|
): OdooPartnersConnector {
|
||||||
|
return new OdooPartnersConnector(client);
|
||||||
|
}
|
||||||
686
apps/api/src/services/integrations/sap/banking.connector.ts
Normal file
686
apps/api/src/services/integrations/sap/banking.connector.ts
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
/**
|
||||||
|
* SAP Business One Banking Connector
|
||||||
|
* Conector para cuentas bancarias, pagos y estados de cuenta
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SAPClient } from './sap.client.js';
|
||||||
|
import {
|
||||||
|
HouseBankAccount,
|
||||||
|
BankStatement,
|
||||||
|
BankStatementRow,
|
||||||
|
IncomingPayment,
|
||||||
|
OutgoingPayment,
|
||||||
|
Period,
|
||||||
|
ODataQueryOptions,
|
||||||
|
} from './sap.types.js';
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de pago
|
||||||
|
*/
|
||||||
|
export type PaymentStatus = 'all' | 'active' | 'cancelled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de pago
|
||||||
|
*/
|
||||||
|
export type PaymentType = 'cash' | 'check' | 'transfer' | 'creditCard' | 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones de filtro para pagos
|
||||||
|
*/
|
||||||
|
export interface PaymentFilterOptions {
|
||||||
|
cardCode?: string;
|
||||||
|
status?: PaymentStatus;
|
||||||
|
paymentType?: PaymentType;
|
||||||
|
bankAccount?: string;
|
||||||
|
series?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumen de cuenta bancaria
|
||||||
|
*/
|
||||||
|
export interface BankAccountSummary {
|
||||||
|
absoluteEntry: number;
|
||||||
|
bankCode: string;
|
||||||
|
accountNo: string;
|
||||||
|
accountName: string;
|
||||||
|
glAccount: string;
|
||||||
|
currentBalance: number;
|
||||||
|
currency: string;
|
||||||
|
iban?: string;
|
||||||
|
lastStatementDate?: string;
|
||||||
|
lastStatementBalance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumen de flujo de efectivo
|
||||||
|
*/
|
||||||
|
export interface CashFlowSummary {
|
||||||
|
period: Period;
|
||||||
|
openingBalance: number;
|
||||||
|
incomingPayments: number;
|
||||||
|
outgoingPayments: number;
|
||||||
|
netCashFlow: number;
|
||||||
|
closingBalance: number;
|
||||||
|
byPaymentType: {
|
||||||
|
cash: { incoming: number; outgoing: number };
|
||||||
|
check: { incoming: number; outgoing: number };
|
||||||
|
transfer: { incoming: number; outgoing: number };
|
||||||
|
creditCard: { incoming: number; outgoing: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Banking Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para datos bancarios de SAP B1
|
||||||
|
*/
|
||||||
|
export class BankingConnector {
|
||||||
|
constructor(private readonly client: SAPClient) {}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Bank Accounts (Cuentas Bancarias de Empresa)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todas las cuentas bancarias de la empresa
|
||||||
|
*/
|
||||||
|
async getBankAccounts(): Promise<HouseBankAccount[]> {
|
||||||
|
logger.info('SAP Banking: Obteniendo cuentas bancarias');
|
||||||
|
|
||||||
|
return this.client.getAll<HouseBankAccount>('/HouseBankAccounts', {
|
||||||
|
$select: [
|
||||||
|
'AbsoluteEntry',
|
||||||
|
'BankCode',
|
||||||
|
'BranchNumber',
|
||||||
|
'AccountNo',
|
||||||
|
'GLAccount',
|
||||||
|
'Country',
|
||||||
|
'Bank',
|
||||||
|
'AccountName',
|
||||||
|
'ZipCode',
|
||||||
|
'City',
|
||||||
|
'State',
|
||||||
|
'IBAN',
|
||||||
|
'BICSwiftCode',
|
||||||
|
'NextCheckNum',
|
||||||
|
'CompanyName',
|
||||||
|
],
|
||||||
|
$orderby: 'BankCode asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una cuenta bancaria por su entrada
|
||||||
|
*/
|
||||||
|
async getBankAccount(absoluteEntry: number): Promise<HouseBankAccount> {
|
||||||
|
logger.info('SAP Banking: Obteniendo cuenta bancaria', { absoluteEntry });
|
||||||
|
return this.client.get<HouseBankAccount>(`/HouseBankAccounts(${absoluteEntry})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el resumen de cuentas bancarias con saldos
|
||||||
|
*/
|
||||||
|
async getBankAccountsSummary(): Promise<BankAccountSummary[]> {
|
||||||
|
logger.info('SAP Banking: Obteniendo resumen de cuentas bancarias');
|
||||||
|
|
||||||
|
const accounts = await this.getBankAccounts();
|
||||||
|
const summaries: BankAccountSummary[] = [];
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
// Obtener saldo de la cuenta GL asociada
|
||||||
|
let currentBalance = 0;
|
||||||
|
if (account.GLAccount) {
|
||||||
|
try {
|
||||||
|
interface GLAccount {
|
||||||
|
Balance?: number;
|
||||||
|
}
|
||||||
|
const glAccount = await this.client.get<GLAccount>(
|
||||||
|
`/ChartOfAccounts('${account.GLAccount}')`
|
||||||
|
);
|
||||||
|
currentBalance = glAccount.Balance || 0;
|
||||||
|
} catch {
|
||||||
|
// Si no se puede obtener el balance, dejarlo en 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries.push({
|
||||||
|
absoluteEntry: account.AbsoluteEntry,
|
||||||
|
bankCode: account.BankCode,
|
||||||
|
accountNo: account.AccountNo,
|
||||||
|
accountName: account.AccountName || '',
|
||||||
|
glAccount: account.GLAccount,
|
||||||
|
currentBalance,
|
||||||
|
currency: 'MXN', // TODO: Obtener moneda de la cuenta GL
|
||||||
|
iban: account.IBAN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Incoming Payments (Pagos Recibidos)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene pagos recibidos de un periodo
|
||||||
|
*/
|
||||||
|
async getIncomingPayments(
|
||||||
|
period: Period,
|
||||||
|
options?: PaymentFilterOptions
|
||||||
|
): Promise<IncomingPayment[]> {
|
||||||
|
logger.info('SAP Banking: Obteniendo pagos recibidos', { period, options });
|
||||||
|
|
||||||
|
const queryOptions = this.buildPaymentQuery(period, options);
|
||||||
|
return this.client.getAll<IncomingPayment>('/IncomingPayments', queryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un pago recibido por DocEntry
|
||||||
|
*/
|
||||||
|
async getIncomingPayment(docEntry: number): Promise<IncomingPayment> {
|
||||||
|
logger.info('SAP Banking: Obteniendo pago recibido', { docEntry });
|
||||||
|
return this.client.get<IncomingPayment>(`/IncomingPayments(${docEntry})`, {
|
||||||
|
$expand: ['PaymentInvoices', 'PaymentChecks', 'PaymentCreditCards'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene pagos recibidos por cliente
|
||||||
|
*/
|
||||||
|
async getIncomingPaymentsByCustomer(
|
||||||
|
cardCode: string,
|
||||||
|
period?: Period
|
||||||
|
): Promise<IncomingPayment[]> {
|
||||||
|
return this.getIncomingPayments(
|
||||||
|
period || this.getDefaultPeriod(),
|
||||||
|
{ cardCode }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el total de pagos recibidos en un periodo
|
||||||
|
*/
|
||||||
|
async getTotalIncomingPayments(period: Period): Promise<{
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
byCurrency: Record<string, number>;
|
||||||
|
}> {
|
||||||
|
logger.info('SAP Banking: Calculando total de pagos recibidos', { period });
|
||||||
|
|
||||||
|
const payments = await this.getIncomingPayments(period, { status: 'active' });
|
||||||
|
|
||||||
|
const byCurrency: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const payment of payments) {
|
||||||
|
const amount = this.calculatePaymentTotal(payment);
|
||||||
|
const currency = payment.DocCurrency || 'MXN';
|
||||||
|
|
||||||
|
total += amount;
|
||||||
|
byCurrency[currency] = (byCurrency[currency] || 0) + amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: payments.length,
|
||||||
|
total,
|
||||||
|
byCurrency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Outgoing Payments (Pagos Emitidos)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene pagos emitidos de un periodo
|
||||||
|
*/
|
||||||
|
async getOutgoingPayments(
|
||||||
|
period: Period,
|
||||||
|
options?: PaymentFilterOptions
|
||||||
|
): Promise<OutgoingPayment[]> {
|
||||||
|
logger.info('SAP Banking: Obteniendo pagos emitidos', { period, options });
|
||||||
|
|
||||||
|
const queryOptions = this.buildPaymentQuery(period, options);
|
||||||
|
return this.client.getAll<OutgoingPayment>('/VendorPayments', queryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un pago emitido por DocEntry
|
||||||
|
*/
|
||||||
|
async getOutgoingPayment(docEntry: number): Promise<OutgoingPayment> {
|
||||||
|
logger.info('SAP Banking: Obteniendo pago emitido', { docEntry });
|
||||||
|
return this.client.get<OutgoingPayment>(`/VendorPayments(${docEntry})`, {
|
||||||
|
$expand: ['PaymentInvoices', 'PaymentChecks', 'PaymentCreditCards'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene pagos emitidos por proveedor
|
||||||
|
*/
|
||||||
|
async getOutgoingPaymentsByVendor(
|
||||||
|
cardCode: string,
|
||||||
|
period?: Period
|
||||||
|
): Promise<OutgoingPayment[]> {
|
||||||
|
return this.getOutgoingPayments(
|
||||||
|
period || this.getDefaultPeriod(),
|
||||||
|
{ cardCode }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el total de pagos emitidos en un periodo
|
||||||
|
*/
|
||||||
|
async getTotalOutgoingPayments(period: Period): Promise<{
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
byCurrency: Record<string, number>;
|
||||||
|
}> {
|
||||||
|
logger.info('SAP Banking: Calculando total de pagos emitidos', { period });
|
||||||
|
|
||||||
|
const payments = await this.getOutgoingPayments(period, { status: 'active' });
|
||||||
|
|
||||||
|
const byCurrency: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const payment of payments) {
|
||||||
|
const amount = this.calculatePaymentTotal(payment);
|
||||||
|
const currency = payment.DocCurrency || 'MXN';
|
||||||
|
|
||||||
|
total += amount;
|
||||||
|
byCurrency[currency] = (byCurrency[currency] || 0) + amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: payments.length,
|
||||||
|
total,
|
||||||
|
byCurrency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Bank Statements (Estados de Cuenta)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estados de cuenta bancarios
|
||||||
|
*/
|
||||||
|
async getBankStatements(options?: {
|
||||||
|
bankCode?: string;
|
||||||
|
account?: string;
|
||||||
|
fromDate?: Date;
|
||||||
|
toDate?: Date;
|
||||||
|
}): Promise<BankStatement[]> {
|
||||||
|
logger.info('SAP Banking: Obteniendo estados de cuenta', { options });
|
||||||
|
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
if (options?.bankCode) {
|
||||||
|
filters.push(`BankCode eq '${options.bankCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.account) {
|
||||||
|
filters.push(`Account eq '${options.account}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.fromDate) {
|
||||||
|
filters.push(`StatementDate ge '${this.client.formatDate(options.fromDate)}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.toDate) {
|
||||||
|
filters.push(`StatementDate le '${this.client.formatDate(options.toDate)}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<BankStatement>('/BankStatements', {
|
||||||
|
$filter: filters.length > 0 ? this.client.combineFilters(...filters) : undefined,
|
||||||
|
$expand: ['BankStatementRows'],
|
||||||
|
$orderby: 'StatementDate desc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un estado de cuenta por su numero interno
|
||||||
|
*/
|
||||||
|
async getBankStatement(internalNumber: number): Promise<BankStatement> {
|
||||||
|
logger.info('SAP Banking: Obteniendo estado de cuenta', { internalNumber });
|
||||||
|
return this.client.get<BankStatement>(`/BankStatements(${internalNumber})`, {
|
||||||
|
$expand: ['BankStatementRows'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el ultimo estado de cuenta de una cuenta bancaria
|
||||||
|
*/
|
||||||
|
async getLatestBankStatement(bankCode: string, account: string): Promise<BankStatement | null> {
|
||||||
|
logger.info('SAP Banking: Obteniendo ultimo estado de cuenta', { bankCode, account });
|
||||||
|
|
||||||
|
const statements = await this.client.getAll<BankStatement>('/BankStatements', {
|
||||||
|
$filter: `BankCode eq '${bankCode}' and Account eq '${account}'`,
|
||||||
|
$orderby: 'StatementDate desc',
|
||||||
|
$top: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return statements[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene movimientos de un estado de cuenta
|
||||||
|
*/
|
||||||
|
async getBankStatementRows(internalNumber: number): Promise<BankStatementRow[]> {
|
||||||
|
const statement = await this.getBankStatement(internalNumber);
|
||||||
|
return statement.BankStatementRows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Cash Flow Analysis
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el resumen de flujo de efectivo
|
||||||
|
*/
|
||||||
|
async getCashFlowSummary(period: Period): Promise<CashFlowSummary> {
|
||||||
|
logger.info('SAP Banking: Generando resumen de flujo de efectivo', { period });
|
||||||
|
|
||||||
|
const [incomingPayments, outgoingPayments] = await Promise.all([
|
||||||
|
this.getIncomingPayments(period, { status: 'active' }),
|
||||||
|
this.getOutgoingPayments(period, { status: 'active' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calcular totales por tipo de pago
|
||||||
|
const byPaymentType = {
|
||||||
|
cash: { incoming: 0, outgoing: 0 },
|
||||||
|
check: { incoming: 0, outgoing: 0 },
|
||||||
|
transfer: { incoming: 0, outgoing: 0 },
|
||||||
|
creditCard: { incoming: 0, outgoing: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Procesar pagos recibidos
|
||||||
|
for (const payment of incomingPayments) {
|
||||||
|
if (payment.CashSum && payment.CashSum > 0) {
|
||||||
|
byPaymentType.cash.incoming += payment.CashSum;
|
||||||
|
}
|
||||||
|
if (payment.PaymentChecks?.length) {
|
||||||
|
const checkTotal = payment.PaymentChecks.reduce((sum, c) => sum + (c.CheckSum || 0), 0);
|
||||||
|
byPaymentType.check.incoming += checkTotal;
|
||||||
|
}
|
||||||
|
if (payment.TransferSum && payment.TransferSum > 0) {
|
||||||
|
byPaymentType.transfer.incoming += payment.TransferSum;
|
||||||
|
}
|
||||||
|
if (payment.PaymentCreditCards?.length) {
|
||||||
|
const ccTotal = payment.PaymentCreditCards.reduce((sum, c) => sum + (c.CreditSum || 0), 0);
|
||||||
|
byPaymentType.creditCard.incoming += ccTotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar pagos emitidos
|
||||||
|
for (const payment of outgoingPayments) {
|
||||||
|
if (payment.CashSum && payment.CashSum > 0) {
|
||||||
|
byPaymentType.cash.outgoing += payment.CashSum;
|
||||||
|
}
|
||||||
|
if (payment.PaymentChecks?.length) {
|
||||||
|
const checkTotal = payment.PaymentChecks.reduce((sum, c) => sum + (c.CheckSum || 0), 0);
|
||||||
|
byPaymentType.check.outgoing += checkTotal;
|
||||||
|
}
|
||||||
|
if (payment.TransferSum && payment.TransferSum > 0) {
|
||||||
|
byPaymentType.transfer.outgoing += payment.TransferSum;
|
||||||
|
}
|
||||||
|
if (payment.PaymentCreditCards?.length) {
|
||||||
|
const ccTotal = payment.PaymentCreditCards.reduce((sum, c) => sum + (c.CreditSum || 0), 0);
|
||||||
|
byPaymentType.creditCard.outgoing += ccTotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalIncoming = Object.values(byPaymentType).reduce(
|
||||||
|
(sum, type) => sum + type.incoming,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalOutgoing = Object.values(byPaymentType).reduce(
|
||||||
|
(sum, type) => sum + type.outgoing,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
openingBalance: 0, // TODO: Calcular desde estados de cuenta
|
||||||
|
incomingPayments: totalIncoming,
|
||||||
|
outgoingPayments: totalOutgoing,
|
||||||
|
netCashFlow: totalIncoming - totalOutgoing,
|
||||||
|
closingBalance: totalIncoming - totalOutgoing, // Simplificado
|
||||||
|
byPaymentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene proyeccion de flujo de efectivo
|
||||||
|
*/
|
||||||
|
async getCashFlowProjection(daysAhead = 30): Promise<{
|
||||||
|
expectedIncoming: number;
|
||||||
|
expectedOutgoing: number;
|
||||||
|
projectedBalance: number;
|
||||||
|
byWeek: Array<{
|
||||||
|
weekStart: Date;
|
||||||
|
incoming: number;
|
||||||
|
outgoing: number;
|
||||||
|
balance: number;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
logger.info('SAP Banking: Generando proyeccion de flujo de efectivo', { daysAhead });
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setDate(futureDate.getDate() + daysAhead);
|
||||||
|
|
||||||
|
// Obtener facturas de venta abiertas (ingresos esperados)
|
||||||
|
interface OpenInvoice {
|
||||||
|
DocDueDate: string;
|
||||||
|
DocTotal: number;
|
||||||
|
PaidToDate?: number;
|
||||||
|
}
|
||||||
|
const openARInvoices = await this.client.getAll<OpenInvoice>('/Invoices', {
|
||||||
|
$filter: this.client.combineFilters(
|
||||||
|
"DocumentStatus eq 'bost_Open'",
|
||||||
|
"Cancelled eq 'tNO'",
|
||||||
|
this.client.buildDateFilter('DocDueDate', today, futureDate)
|
||||||
|
),
|
||||||
|
$select: ['DocDueDate', 'DocTotal', 'PaidToDate'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener facturas de compra abiertas (pagos esperados)
|
||||||
|
const openAPInvoices = await this.client.getAll<OpenInvoice>('/PurchaseInvoices', {
|
||||||
|
$filter: this.client.combineFilters(
|
||||||
|
"DocumentStatus eq 'bost_Open'",
|
||||||
|
"Cancelled eq 'tNO'",
|
||||||
|
this.client.buildDateFilter('DocDueDate', today, futureDate)
|
||||||
|
),
|
||||||
|
$select: ['DocDueDate', 'DocTotal', 'PaidToDate'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular totales
|
||||||
|
const expectedIncoming = openARInvoices.reduce(
|
||||||
|
(sum, inv) => sum + (inv.DocTotal - (inv.PaidToDate || 0)),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const expectedOutgoing = openAPInvoices.reduce(
|
||||||
|
(sum, inv) => sum + (inv.DocTotal - (inv.PaidToDate || 0)),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Agrupar por semana
|
||||||
|
const byWeek: Array<{
|
||||||
|
weekStart: Date;
|
||||||
|
incoming: number;
|
||||||
|
outgoing: number;
|
||||||
|
balance: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const numWeeks = Math.ceil(daysAhead / 7);
|
||||||
|
let runningBalance = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < numWeeks; i++) {
|
||||||
|
const weekStart = new Date(today);
|
||||||
|
weekStart.setDate(weekStart.getDate() + i * 7);
|
||||||
|
const weekEnd = new Date(weekStart);
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||||
|
|
||||||
|
const weekIncoming = openARInvoices
|
||||||
|
.filter((inv) => {
|
||||||
|
const dueDate = new Date(inv.DocDueDate);
|
||||||
|
return dueDate >= weekStart && dueDate <= weekEnd;
|
||||||
|
})
|
||||||
|
.reduce((sum, inv) => sum + (inv.DocTotal - (inv.PaidToDate || 0)), 0);
|
||||||
|
|
||||||
|
const weekOutgoing = openAPInvoices
|
||||||
|
.filter((inv) => {
|
||||||
|
const dueDate = new Date(inv.DocDueDate);
|
||||||
|
return dueDate >= weekStart && dueDate <= weekEnd;
|
||||||
|
})
|
||||||
|
.reduce((sum, inv) => sum + (inv.DocTotal - (inv.PaidToDate || 0)), 0);
|
||||||
|
|
||||||
|
runningBalance += weekIncoming - weekOutgoing;
|
||||||
|
|
||||||
|
byWeek.push({
|
||||||
|
weekStart,
|
||||||
|
incoming: weekIncoming,
|
||||||
|
outgoing: weekOutgoing,
|
||||||
|
balance: runningBalance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expectedIncoming,
|
||||||
|
expectedOutgoing,
|
||||||
|
projectedBalance: expectedIncoming - expectedOutgoing,
|
||||||
|
byWeek,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye las opciones de consulta para pagos
|
||||||
|
*/
|
||||||
|
private buildPaymentQuery(
|
||||||
|
period: Period,
|
||||||
|
options?: PaymentFilterOptions
|
||||||
|
): ODataQueryOptions {
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
// Filtro de fecha
|
||||||
|
const dateFilter = this.client.buildDateFilter('DocDate', period.startDate, period.endDate);
|
||||||
|
if (dateFilter) {
|
||||||
|
filters.push(dateFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de socio de negocios
|
||||||
|
if (options?.cardCode) {
|
||||||
|
filters.push(`CardCode eq '${options.cardCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de estado
|
||||||
|
if (options?.status === 'active') {
|
||||||
|
filters.push("Cancelled eq 'tNO'");
|
||||||
|
} else if (options?.status === 'cancelled') {
|
||||||
|
filters.push("Cancelled eq 'tYES'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de tipo de pago
|
||||||
|
if (options?.paymentType && options.paymentType !== 'all') {
|
||||||
|
switch (options.paymentType) {
|
||||||
|
case 'cash':
|
||||||
|
filters.push('CashSum gt 0');
|
||||||
|
break;
|
||||||
|
case 'transfer':
|
||||||
|
filters.push('TransferSum gt 0');
|
||||||
|
break;
|
||||||
|
// check y creditCard se manejan en las lineas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de serie
|
||||||
|
if (options?.series !== undefined) {
|
||||||
|
filters.push(`Series eq ${options.series}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
$filter: filters.length > 0 ? this.client.combineFilters(...filters) : undefined,
|
||||||
|
$select: [
|
||||||
|
'DocEntry',
|
||||||
|
'DocNum',
|
||||||
|
'DocType',
|
||||||
|
'DocDate',
|
||||||
|
'CardCode',
|
||||||
|
'CardName',
|
||||||
|
'DocCurrency',
|
||||||
|
'DocRate',
|
||||||
|
'CashSum',
|
||||||
|
'CashAccount',
|
||||||
|
'TransferAccount',
|
||||||
|
'TransferSum',
|
||||||
|
'TransferDate',
|
||||||
|
'TransferReference',
|
||||||
|
'Cancelled',
|
||||||
|
'ControlAccount',
|
||||||
|
'TaxDate',
|
||||||
|
'Series',
|
||||||
|
'BankCode',
|
||||||
|
'BankAccount',
|
||||||
|
'JournalRemarks',
|
||||||
|
],
|
||||||
|
$expand: ['PaymentInvoices'],
|
||||||
|
$orderby: 'DocDate desc, DocNum desc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el total de un pago
|
||||||
|
*/
|
||||||
|
private calculatePaymentTotal(payment: IncomingPayment | OutgoingPayment): number {
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
total += payment.CashSum || 0;
|
||||||
|
total += payment.TransferSum || 0;
|
||||||
|
|
||||||
|
if (payment.PaymentChecks) {
|
||||||
|
total += payment.PaymentChecks.reduce((sum, c) => sum + (c.CheckSum || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.PaymentCreditCards) {
|
||||||
|
total += payment.PaymentCreditCards.reduce((sum, c) => sum + (c.CreditSum || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el periodo por defecto (ultimo mes)
|
||||||
|
*/
|
||||||
|
private getDefaultPeriod(): Period {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setMonth(startDate.getMonth() - 1);
|
||||||
|
|
||||||
|
return { startDate, endDate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del conector bancario
|
||||||
|
*/
|
||||||
|
export function createBankingConnector(client: SAPClient): BankingConnector {
|
||||||
|
return new BankingConnector(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BankingConnector;
|
||||||
633
apps/api/src/services/integrations/sap/financials.connector.ts
Normal file
633
apps/api/src/services/integrations/sap/financials.connector.ts
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
/**
|
||||||
|
* SAP Business One Financials Connector
|
||||||
|
* Conector para datos financieros y contables
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SAPClient } from './sap.client.js';
|
||||||
|
import {
|
||||||
|
ChartOfAccounts,
|
||||||
|
AccountCategory,
|
||||||
|
JournalEntry,
|
||||||
|
TrialBalanceEntry,
|
||||||
|
ProfitAndLossEntry,
|
||||||
|
BalanceSheetEntry,
|
||||||
|
AgingReportEntry,
|
||||||
|
Period,
|
||||||
|
ODataQueryOptions,
|
||||||
|
ODataResponse,
|
||||||
|
SAPError,
|
||||||
|
} from './sap.types.js';
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de reporte de antiguedad
|
||||||
|
*/
|
||||||
|
export type AgingType = 'AR' | 'AP';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones para trial balance
|
||||||
|
*/
|
||||||
|
export interface TrialBalanceOptions {
|
||||||
|
accountCodes?: string[];
|
||||||
|
includeZeroBalance?: boolean;
|
||||||
|
groupByLevel?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones para P&L
|
||||||
|
*/
|
||||||
|
export interface ProfitAndLossOptions {
|
||||||
|
comparePreviousYear?: boolean;
|
||||||
|
compareBudget?: boolean;
|
||||||
|
accountLevel?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones para balance sheet
|
||||||
|
*/
|
||||||
|
export interface BalanceSheetOptions {
|
||||||
|
comparePreviousPeriod?: boolean;
|
||||||
|
comparePreviousYear?: boolean;
|
||||||
|
accountLevel?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones para aging report
|
||||||
|
*/
|
||||||
|
export interface AgingOptions {
|
||||||
|
cardCodes?: string[];
|
||||||
|
asOfDate?: Date;
|
||||||
|
agingPeriods?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Financials Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para datos financieros de SAP B1
|
||||||
|
*/
|
||||||
|
export class FinancialsConnector {
|
||||||
|
constructor(private readonly client: SAPClient) {}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Chart of Accounts
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el catalogo de cuentas completo
|
||||||
|
*/
|
||||||
|
async getChartOfAccounts(options?: {
|
||||||
|
activeOnly?: boolean;
|
||||||
|
postableOnly?: boolean;
|
||||||
|
accountType?: 'at_Revenues' | 'at_Expenses' | 'at_Other';
|
||||||
|
}): Promise<ChartOfAccounts[]> {
|
||||||
|
logger.info('SAP Financials: Obteniendo catalogo de cuentas', { options });
|
||||||
|
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
if (options?.activeOnly) {
|
||||||
|
filters.push("ActiveAccount eq 'tYES'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.postableOnly) {
|
||||||
|
filters.push("Postable eq 'tYES'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.accountType) {
|
||||||
|
filters.push(`AccountType eq '${options.accountType}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryOptions: ODataQueryOptions = {
|
||||||
|
$select: [
|
||||||
|
'Code',
|
||||||
|
'Name',
|
||||||
|
'ForeignName',
|
||||||
|
'Balance',
|
||||||
|
'CashAccount',
|
||||||
|
'ActiveAccount',
|
||||||
|
'AccountType',
|
||||||
|
'ExternalCode',
|
||||||
|
'AcctCurrency',
|
||||||
|
'BalanceSysCurr',
|
||||||
|
'Protected',
|
||||||
|
'ReconcileAccount',
|
||||||
|
'Postable',
|
||||||
|
'BlockManualPosting',
|
||||||
|
'ControlAccount',
|
||||||
|
'AccountLevel',
|
||||||
|
'FatherAccountKey',
|
||||||
|
'ValidFor',
|
||||||
|
'ValidFrom',
|
||||||
|
'ValidTo',
|
||||||
|
'AccountCategory',
|
||||||
|
],
|
||||||
|
$orderby: 'Code asc',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters.length > 0) {
|
||||||
|
queryOptions.$filter = this.client.combineFilters(...filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<ChartOfAccounts>('/ChartOfAccounts', queryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una cuenta por su codigo
|
||||||
|
*/
|
||||||
|
async getAccount(code: string): Promise<ChartOfAccounts> {
|
||||||
|
logger.info('SAP Financials: Obteniendo cuenta', { code });
|
||||||
|
return this.client.get<ChartOfAccounts>(`/ChartOfAccounts('${code}')`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las categorias de cuenta
|
||||||
|
*/
|
||||||
|
async getAccountCategories(): Promise<AccountCategory[]> {
|
||||||
|
logger.info('SAP Financials: Obteniendo categorias de cuenta');
|
||||||
|
return this.client.getAll<AccountCategory>('/AccountCategory', {
|
||||||
|
$orderby: 'CategoryCode asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene cuentas por categoria
|
||||||
|
*/
|
||||||
|
async getAccountsByCategory(categoryCode: number): Promise<ChartOfAccounts[]> {
|
||||||
|
logger.info('SAP Financials: Obteniendo cuentas por categoria', { categoryCode });
|
||||||
|
return this.client.getAll<ChartOfAccounts>('/ChartOfAccounts', {
|
||||||
|
$filter: `AccountCategory eq ${categoryCode}`,
|
||||||
|
$orderby: 'Code asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Journal Entries
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene asientos contables de un periodo
|
||||||
|
*/
|
||||||
|
async getJournalEntries(period: Period, options?: {
|
||||||
|
transactionCode?: string;
|
||||||
|
projectCode?: string;
|
||||||
|
}): Promise<JournalEntry[]> {
|
||||||
|
logger.info('SAP Financials: Obteniendo asientos contables', { period, options });
|
||||||
|
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
// Filtro de fecha
|
||||||
|
const dateFilter = this.client.buildDateFilter('RefDate', period.startDate, period.endDate);
|
||||||
|
if (dateFilter) {
|
||||||
|
filters.push(dateFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.transactionCode) {
|
||||||
|
filters.push(`TransactionCode eq '${options.transactionCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.projectCode) {
|
||||||
|
filters.push(`ProjectCode eq '${options.projectCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<JournalEntry>('/JournalEntries', {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$expand: ['JournalEntryLines'],
|
||||||
|
$orderby: 'RefDate desc, JdtNum desc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un asiento contable por su numero
|
||||||
|
*/
|
||||||
|
async getJournalEntry(jdtNum: number): Promise<JournalEntry> {
|
||||||
|
logger.info('SAP Financials: Obteniendo asiento contable', { jdtNum });
|
||||||
|
return this.client.get<JournalEntry>(`/JournalEntries(${jdtNum})`, {
|
||||||
|
$expand: ['JournalEntryLines'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene asientos contables por cuenta
|
||||||
|
*/
|
||||||
|
async getJournalEntriesByAccount(
|
||||||
|
accountCode: string,
|
||||||
|
period: Period
|
||||||
|
): Promise<JournalEntry[]> {
|
||||||
|
logger.info('SAP Financials: Obteniendo asientos por cuenta', { accountCode, period });
|
||||||
|
|
||||||
|
const dateFilter = this.client.buildDateFilter('RefDate', period.startDate, period.endDate);
|
||||||
|
|
||||||
|
// Usamos una consulta especial porque necesitamos filtrar por lineas
|
||||||
|
const entries = await this.client.getAll<JournalEntry>('/JournalEntries', {
|
||||||
|
$filter: dateFilter,
|
||||||
|
$expand: ['JournalEntryLines'],
|
||||||
|
$orderby: 'RefDate desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrar localmente por cuenta (SAP no soporta filtro directo en expansion)
|
||||||
|
return entries.filter((entry) =>
|
||||||
|
entry.JournalEntryLines?.some((line) => line.AccountCode === accountCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Trial Balance
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la balanza de comprobacion a una fecha
|
||||||
|
* Nota: SAP B1 no tiene un endpoint directo para trial balance,
|
||||||
|
* se calcula a partir de los saldos de cuentas
|
||||||
|
*/
|
||||||
|
async getTrialBalance(
|
||||||
|
asOfDate: Date,
|
||||||
|
options?: TrialBalanceOptions
|
||||||
|
): Promise<TrialBalanceEntry[]> {
|
||||||
|
logger.info('SAP Financials: Calculando balanza de comprobacion', { asOfDate, options });
|
||||||
|
|
||||||
|
// Obtener todas las cuentas postables
|
||||||
|
const accounts = await this.getChartOfAccounts({
|
||||||
|
postableOnly: true,
|
||||||
|
activeOnly: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener movimientos del periodo (desde inicio de anio fiscal)
|
||||||
|
const fiscalYearStart = new Date(asOfDate.getFullYear(), 0, 1);
|
||||||
|
const journalEntries = await this.getJournalEntries({
|
||||||
|
startDate: fiscalYearStart,
|
||||||
|
endDate: asOfDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular saldos
|
||||||
|
const balances = new Map<string, TrialBalanceEntry>();
|
||||||
|
|
||||||
|
// Inicializar con saldos de cuentas
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (options?.accountCodes && !options.accountCodes.includes(account.Code)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
balances.set(account.Code, {
|
||||||
|
AccountCode: account.Code,
|
||||||
|
AccountName: account.Name,
|
||||||
|
DebitBalance: 0,
|
||||||
|
CreditBalance: 0,
|
||||||
|
DebitPeriod: 0,
|
||||||
|
CreditPeriod: 0,
|
||||||
|
OpeningBalance: account.Balance || 0,
|
||||||
|
ClosingBalance: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acumular movimientos
|
||||||
|
for (const entry of journalEntries) {
|
||||||
|
for (const line of entry.JournalEntryLines || []) {
|
||||||
|
const accountCode = line.AccountCode;
|
||||||
|
if (!accountCode || !balances.has(accountCode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const balance = balances.get(accountCode)!;
|
||||||
|
balance.DebitPeriod += line.Debit || 0;
|
||||||
|
balance.CreditPeriod += line.Credit || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular saldos finales
|
||||||
|
const result: TrialBalanceEntry[] = [];
|
||||||
|
|
||||||
|
for (const balance of Array.from(balances.values())) {
|
||||||
|
balance.DebitBalance = Math.max(0, balance.OpeningBalance) + balance.DebitPeriod;
|
||||||
|
balance.CreditBalance = Math.abs(Math.min(0, balance.OpeningBalance)) + balance.CreditPeriod;
|
||||||
|
balance.ClosingBalance = balance.OpeningBalance + balance.DebitPeriod - balance.CreditPeriod;
|
||||||
|
|
||||||
|
// Filtrar saldos en cero si no se solicitan
|
||||||
|
if (!options?.includeZeroBalance && balance.ClosingBalance === 0 && balance.DebitPeriod === 0 && balance.CreditPeriod === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por codigo de cuenta
|
||||||
|
return result.sort((a, b) => a.AccountCode.localeCompare(b.AccountCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Profit & Loss Report
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado de resultados
|
||||||
|
*/
|
||||||
|
async getProfitAndLossReport(
|
||||||
|
period: Period,
|
||||||
|
options?: ProfitAndLossOptions
|
||||||
|
): Promise<ProfitAndLossEntry[]> {
|
||||||
|
logger.info('SAP Financials: Generando estado de resultados', { period, options });
|
||||||
|
|
||||||
|
// Obtener cuentas de ingresos y gastos
|
||||||
|
const [revenueAccounts, expenseAccounts] = await Promise.all([
|
||||||
|
this.getChartOfAccounts({ accountType: 'at_Revenues', postableOnly: true }),
|
||||||
|
this.getChartOfAccounts({ accountType: 'at_Expenses', postableOnly: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allAccounts = [...revenueAccounts, ...expenseAccounts];
|
||||||
|
|
||||||
|
// Obtener movimientos del periodo actual
|
||||||
|
const currentEntries = await this.getJournalEntries(period);
|
||||||
|
|
||||||
|
// Calcular saldos del periodo actual
|
||||||
|
const currentPeriodBalances = this.calculatePeriodBalances(currentEntries, allAccounts);
|
||||||
|
|
||||||
|
// Calcular YTD
|
||||||
|
const yearStart = new Date(period.startDate.getFullYear(), 0, 1);
|
||||||
|
const ytdEntries = await this.getJournalEntries({
|
||||||
|
startDate: yearStart,
|
||||||
|
endDate: period.endDate,
|
||||||
|
});
|
||||||
|
const ytdBalances = this.calculatePeriodBalances(ytdEntries, allAccounts);
|
||||||
|
|
||||||
|
// Opcionalmente obtener periodo anterior
|
||||||
|
let previousYearBalances: Map<string, number> | undefined;
|
||||||
|
if (options?.comparePreviousYear) {
|
||||||
|
const prevYearStart = new Date(period.startDate.getFullYear() - 1, period.startDate.getMonth(), period.startDate.getDate());
|
||||||
|
const prevYearEnd = new Date(period.endDate.getFullYear() - 1, period.endDate.getMonth(), period.endDate.getDate());
|
||||||
|
const prevEntries = await this.getJournalEntries({
|
||||||
|
startDate: prevYearStart,
|
||||||
|
endDate: prevYearEnd,
|
||||||
|
});
|
||||||
|
previousYearBalances = this.calculatePeriodBalances(prevEntries, allAccounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir resultado
|
||||||
|
const result: ProfitAndLossEntry[] = [];
|
||||||
|
let totalRevenue = 0;
|
||||||
|
let totalExpenses = 0;
|
||||||
|
|
||||||
|
for (const account of allAccounts) {
|
||||||
|
const currentPeriod = currentPeriodBalances.get(account.Code) || 0;
|
||||||
|
const yearToDate = ytdBalances.get(account.Code) || 0;
|
||||||
|
const previousYear = previousYearBalances?.get(account.Code);
|
||||||
|
|
||||||
|
if (account.AccountType === 'at_Revenues') {
|
||||||
|
totalRevenue += yearToDate;
|
||||||
|
} else {
|
||||||
|
totalExpenses += yearToDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
AccountCode: account.Code,
|
||||||
|
AccountName: account.Name,
|
||||||
|
AccountType: account.AccountType || '',
|
||||||
|
CurrentPeriod: currentPeriod,
|
||||||
|
YearToDate: yearToDate,
|
||||||
|
PreviousYear: previousYear,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular porcentajes
|
||||||
|
const totalNet = totalRevenue - totalExpenses;
|
||||||
|
for (const entry of result) {
|
||||||
|
entry.PercentOfTotal = totalNet !== 0
|
||||||
|
? (entry.YearToDate / Math.abs(totalNet)) * 100
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.AccountCode.localeCompare(b.AccountCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula los saldos por cuenta para un periodo
|
||||||
|
*/
|
||||||
|
private calculatePeriodBalances(
|
||||||
|
entries: JournalEntry[],
|
||||||
|
accounts: ChartOfAccounts[]
|
||||||
|
): Map<string, number> {
|
||||||
|
const balances = new Map<string, number>();
|
||||||
|
const accountSet = new Set(accounts.map((a) => a.Code));
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
for (const line of entry.JournalEntryLines || []) {
|
||||||
|
if (!line.AccountCode || !accountSet.has(line.AccountCode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = balances.get(line.AccountCode) || 0;
|
||||||
|
const movement = (line.Credit || 0) - (line.Debit || 0);
|
||||||
|
balances.set(line.AccountCode, current + movement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return balances;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Balance Sheet
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el balance general
|
||||||
|
*/
|
||||||
|
async getBalanceSheet(
|
||||||
|
asOfDate: Date,
|
||||||
|
options?: BalanceSheetOptions
|
||||||
|
): Promise<BalanceSheetEntry[]> {
|
||||||
|
logger.info('SAP Financials: Generando balance general', { asOfDate, options });
|
||||||
|
|
||||||
|
// Obtener todas las cuentas de balance (no ingresos/gastos)
|
||||||
|
const accounts = await this.client.getAll<ChartOfAccounts>('/ChartOfAccounts', {
|
||||||
|
$filter: "AccountType eq 'at_Other' and Postable eq 'tYES' and ActiveAccount eq 'tYES'",
|
||||||
|
$orderby: 'Code asc',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener saldos actuales
|
||||||
|
const trialBalance = await this.getTrialBalance(asOfDate);
|
||||||
|
const currentBalances = new Map(
|
||||||
|
trialBalance.map((t) => [t.AccountCode, t.ClosingBalance])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener saldos del periodo anterior si se solicita
|
||||||
|
let previousPeriodBalances: Map<string, number> | undefined;
|
||||||
|
if (options?.comparePreviousPeriod) {
|
||||||
|
const prevMonth = new Date(asOfDate);
|
||||||
|
prevMonth.setMonth(prevMonth.getMonth() - 1);
|
||||||
|
const prevTrialBalance = await this.getTrialBalance(prevMonth);
|
||||||
|
previousPeriodBalances = new Map(
|
||||||
|
prevTrialBalance.map((t) => [t.AccountCode, t.ClosingBalance])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener saldos del ano anterior si se solicita
|
||||||
|
let previousYearBalances: Map<string, number> | undefined;
|
||||||
|
if (options?.comparePreviousYear) {
|
||||||
|
const prevYear = new Date(asOfDate);
|
||||||
|
prevYear.setFullYear(prevYear.getFullYear() - 1);
|
||||||
|
const prevYearTrialBalance = await this.getTrialBalance(prevYear);
|
||||||
|
previousYearBalances = new Map(
|
||||||
|
prevYearTrialBalance.map((t) => [t.AccountCode, t.ClosingBalance])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir resultado
|
||||||
|
const result: BalanceSheetEntry[] = [];
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const currentBalance = currentBalances.get(account.Code) || 0;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
AccountCode: account.Code,
|
||||||
|
AccountName: account.Name,
|
||||||
|
AccountCategory: String(account.AccountCategory || ''),
|
||||||
|
CurrentBalance: currentBalance,
|
||||||
|
PreviousPeriodBalance: previousPeriodBalances?.get(account.Code),
|
||||||
|
PreviousYearBalance: previousYearBalances?.get(account.Code),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Aging Reports
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el reporte de antiguedad de saldos
|
||||||
|
*/
|
||||||
|
async getAgingReport(
|
||||||
|
type: AgingType,
|
||||||
|
options?: AgingOptions
|
||||||
|
): Promise<AgingReportEntry[]> {
|
||||||
|
logger.info('SAP Financials: Generando reporte de antiguedad', { type, options });
|
||||||
|
|
||||||
|
const asOfDate = options?.asOfDate || new Date();
|
||||||
|
const agingPeriods = options?.agingPeriods || [30, 60, 90, 120];
|
||||||
|
|
||||||
|
// Obtener documentos abiertos segun el tipo
|
||||||
|
const endpoint = type === 'AR' ? '/Invoices' : '/PurchaseInvoices';
|
||||||
|
|
||||||
|
const filters: string[] = [
|
||||||
|
"DocumentStatus eq 'bost_Open'",
|
||||||
|
"Cancelled eq 'tNO'",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.cardCodes?.length) {
|
||||||
|
const cardCodesFilter = options.cardCodes.map((c) => `CardCode eq '${c}'`).join(' or ');
|
||||||
|
filters.push(`(${cardCodesFilter})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentWithBalance {
|
||||||
|
DocEntry: number;
|
||||||
|
DocNum: number;
|
||||||
|
CardCode: string;
|
||||||
|
CardName: string;
|
||||||
|
DocDate: string;
|
||||||
|
DocDueDate: string;
|
||||||
|
DocTotal: number;
|
||||||
|
PaidToDate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = await this.client.getAll<DocumentWithBalance>(endpoint, {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$select: ['DocEntry', 'DocNum', 'CardCode', 'CardName', 'DocDate', 'DocDueDate', 'DocTotal', 'PaidToDate'],
|
||||||
|
$orderby: 'DocDueDate asc',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular antiguedad
|
||||||
|
const result: AgingReportEntry[] = [];
|
||||||
|
|
||||||
|
for (const doc of documents) {
|
||||||
|
const balance = doc.DocTotal - (doc.PaidToDate || 0);
|
||||||
|
|
||||||
|
if (balance <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dueDate = new Date(doc.DocDueDate);
|
||||||
|
const daysPastDue = Math.floor(
|
||||||
|
(asOfDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
|
||||||
|
const entry: AgingReportEntry = {
|
||||||
|
CardCode: doc.CardCode,
|
||||||
|
CardName: doc.CardName || '',
|
||||||
|
DocEntry: doc.DocEntry,
|
||||||
|
DocNum: doc.DocNum,
|
||||||
|
DocDate: doc.DocDate,
|
||||||
|
DueDate: doc.DocDueDate,
|
||||||
|
DocTotal: doc.DocTotal,
|
||||||
|
Balance: balance,
|
||||||
|
Current: 0,
|
||||||
|
Days1_30: 0,
|
||||||
|
Days31_60: 0,
|
||||||
|
Days61_90: 0,
|
||||||
|
Days91_120: 0,
|
||||||
|
Over120: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clasificar por antiguedad
|
||||||
|
if (daysPastDue <= 0) {
|
||||||
|
entry.Current = balance;
|
||||||
|
} else if (daysPastDue <= 30) {
|
||||||
|
entry.Days1_30 = balance;
|
||||||
|
} else if (daysPastDue <= 60) {
|
||||||
|
entry.Days31_60 = balance;
|
||||||
|
} else if (daysPastDue <= 90) {
|
||||||
|
entry.Days61_90 = balance;
|
||||||
|
} else if (daysPastDue <= 120) {
|
||||||
|
entry.Days91_120 = balance;
|
||||||
|
} else {
|
||||||
|
entry.Over120 = balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el resumen de antiguedad por cliente/proveedor
|
||||||
|
*/
|
||||||
|
async getAgingSummary(type: AgingType): Promise<Map<string, AgingReportEntry>> {
|
||||||
|
const agingReport = await this.getAgingReport(type);
|
||||||
|
|
||||||
|
const summary = new Map<string, AgingReportEntry>();
|
||||||
|
|
||||||
|
for (const entry of agingReport) {
|
||||||
|
if (summary.has(entry.CardCode)) {
|
||||||
|
const existing = summary.get(entry.CardCode)!;
|
||||||
|
existing.Balance += entry.Balance;
|
||||||
|
existing.Current += entry.Current;
|
||||||
|
existing.Days1_30 += entry.Days1_30;
|
||||||
|
existing.Days31_60 += entry.Days31_60;
|
||||||
|
existing.Days61_90 += entry.Days61_90;
|
||||||
|
existing.Days91_120 += entry.Days91_120;
|
||||||
|
existing.Over120 += entry.Over120;
|
||||||
|
} else {
|
||||||
|
summary.set(entry.CardCode, { ...entry });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del conector de finanzas
|
||||||
|
*/
|
||||||
|
export function createFinancialsConnector(client: SAPClient): FinancialsConnector {
|
||||||
|
return new FinancialsConnector(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FinancialsConnector;
|
||||||
179
apps/api/src/services/integrations/sap/index.ts
Normal file
179
apps/api/src/services/integrations/sap/index.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* SAP Business One Integration for Horux Strategy
|
||||||
|
* Modulo completo para integracion con SAP B1 Service Layer
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export * from './sap.types.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Client
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
SAPClient,
|
||||||
|
createSAPClient,
|
||||||
|
createConnectedSAPClient,
|
||||||
|
} from './sap.client.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Connectors
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Financials
|
||||||
|
export {
|
||||||
|
FinancialsConnector,
|
||||||
|
createFinancialsConnector,
|
||||||
|
} from './financials.connector.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AgingType,
|
||||||
|
TrialBalanceOptions,
|
||||||
|
ProfitAndLossOptions,
|
||||||
|
BalanceSheetOptions,
|
||||||
|
AgingOptions,
|
||||||
|
} from './financials.connector.js';
|
||||||
|
|
||||||
|
// Sales
|
||||||
|
export {
|
||||||
|
SalesConnector,
|
||||||
|
createSalesConnector,
|
||||||
|
} from './sales.connector.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DocumentStatus,
|
||||||
|
DocumentFilterOptions,
|
||||||
|
CustomerBalanceSummary,
|
||||||
|
} from './sales.connector.js';
|
||||||
|
|
||||||
|
// Purchasing
|
||||||
|
export {
|
||||||
|
PurchasingConnector,
|
||||||
|
createPurchasingConnector,
|
||||||
|
} from './purchasing.connector.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PurchaseDocumentStatus,
|
||||||
|
PurchaseDocumentFilterOptions,
|
||||||
|
VendorBalanceSummary,
|
||||||
|
} from './purchasing.connector.js';
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
export {
|
||||||
|
InventoryConnector,
|
||||||
|
createInventoryConnector,
|
||||||
|
} from './inventory.connector.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ItemFilterOptions,
|
||||||
|
ItemStockInfo,
|
||||||
|
InventoryMovement,
|
||||||
|
InventoryGroupSummary,
|
||||||
|
} from './inventory.connector.js';
|
||||||
|
|
||||||
|
// Banking
|
||||||
|
export {
|
||||||
|
BankingConnector,
|
||||||
|
createBankingConnector,
|
||||||
|
} from './banking.connector.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PaymentStatus,
|
||||||
|
PaymentType,
|
||||||
|
PaymentFilterOptions,
|
||||||
|
BankAccountSummary,
|
||||||
|
CashFlowSummary,
|
||||||
|
} from './banking.connector.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Sync Service
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
SAPSyncService,
|
||||||
|
createSAPSyncService,
|
||||||
|
syncSAPToHorux,
|
||||||
|
} from './sap.sync.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
SyncOptions,
|
||||||
|
SyncEntityType,
|
||||||
|
SyncProgress,
|
||||||
|
MappedTransaction,
|
||||||
|
SAPAlert,
|
||||||
|
} from './sap.sync.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { SAPConfig } from './sap.types.js';
|
||||||
|
import { SAPClient, createConnectedSAPClient } from './sap.client.js';
|
||||||
|
import { FinancialsConnector } from './financials.connector.js';
|
||||||
|
import { SalesConnector } from './sales.connector.js';
|
||||||
|
import { PurchasingConnector } from './purchasing.connector.js';
|
||||||
|
import { InventoryConnector } from './inventory.connector.js';
|
||||||
|
import { BankingConnector } from './banking.connector.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conjunto completo de conectores SAP
|
||||||
|
*/
|
||||||
|
export interface SAPConnectors {
|
||||||
|
client: SAPClient;
|
||||||
|
financials: FinancialsConnector;
|
||||||
|
sales: SalesConnector;
|
||||||
|
purchasing: PurchasingConnector;
|
||||||
|
inventory: InventoryConnector;
|
||||||
|
banking: BankingConnector;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea todos los conectores SAP con una sola conexion
|
||||||
|
* @param config Configuracion de conexion SAP
|
||||||
|
* @returns Conjunto de conectores inicializados
|
||||||
|
*/
|
||||||
|
export async function createSAPConnectors(config: SAPConfig): Promise<SAPConnectors> {
|
||||||
|
const client = await createConnectedSAPClient(config);
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
financials: new FinancialsConnector(client),
|
||||||
|
sales: new SalesConnector(client),
|
||||||
|
purchasing: new PurchasingConnector(client),
|
||||||
|
inventory: new InventoryConnector(client),
|
||||||
|
banking: new BankingConnector(client),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica la conexion a SAP Service Layer
|
||||||
|
* @param config Configuracion de conexion SAP
|
||||||
|
* @returns true si la conexion es exitosa
|
||||||
|
*/
|
||||||
|
export async function testSAPConnection(config: SAPConfig): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
sessionId?: string;
|
||||||
|
version?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const client = await createConnectedSAPClient(config);
|
||||||
|
const session = client.getSession();
|
||||||
|
|
||||||
|
await client.logout();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Conexion exitosa a SAP Business One Service Layer',
|
||||||
|
sessionId: session?.sessionId,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Error de conexion desconocido',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
659
apps/api/src/services/integrations/sap/inventory.connector.ts
Normal file
659
apps/api/src/services/integrations/sap/inventory.connector.ts
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
/**
|
||||||
|
* SAP Business One Inventory Connector
|
||||||
|
* Conector para articulos, almacenes y movimientos de inventario
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SAPClient } from './sap.client.js';
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemWarehouseInfo,
|
||||||
|
Warehouse,
|
||||||
|
StockTransfer,
|
||||||
|
Period,
|
||||||
|
InventoryValuationEntry,
|
||||||
|
ODataQueryOptions,
|
||||||
|
} from './sap.types.js';
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones de filtro para articulos
|
||||||
|
*/
|
||||||
|
export interface ItemFilterOptions {
|
||||||
|
itemGroup?: number;
|
||||||
|
warehouseCode?: string;
|
||||||
|
activeOnly?: boolean;
|
||||||
|
inventoryOnly?: boolean;
|
||||||
|
salesOnly?: boolean;
|
||||||
|
purchaseOnly?: boolean;
|
||||||
|
withStock?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Informacion de stock de un articulo
|
||||||
|
*/
|
||||||
|
export interface ItemStockInfo {
|
||||||
|
itemCode: string;
|
||||||
|
itemName: string;
|
||||||
|
totalInStock: number;
|
||||||
|
totalCommitted: number;
|
||||||
|
totalOrdered: number;
|
||||||
|
totalAvailable: number;
|
||||||
|
defaultWarehouse?: string;
|
||||||
|
warehouseStock: Array<{
|
||||||
|
warehouseCode: string;
|
||||||
|
warehouseName?: string;
|
||||||
|
inStock: number;
|
||||||
|
committed: number;
|
||||||
|
ordered: number;
|
||||||
|
available: number;
|
||||||
|
minStock?: number;
|
||||||
|
maxStock?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Movimiento de inventario
|
||||||
|
*/
|
||||||
|
export interface InventoryMovement {
|
||||||
|
docEntry: number;
|
||||||
|
docNum: number;
|
||||||
|
docDate: string;
|
||||||
|
itemCode: string;
|
||||||
|
itemName?: string;
|
||||||
|
warehouseCode: string;
|
||||||
|
quantity: number;
|
||||||
|
direction: 'in' | 'out';
|
||||||
|
movementType: string;
|
||||||
|
reference?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumen de inventario por grupo
|
||||||
|
*/
|
||||||
|
export interface InventoryGroupSummary {
|
||||||
|
groupCode: number;
|
||||||
|
groupName: string;
|
||||||
|
itemCount: number;
|
||||||
|
totalValue: number;
|
||||||
|
totalStock: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Inventory Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para datos de inventario de SAP B1
|
||||||
|
*/
|
||||||
|
export class InventoryConnector {
|
||||||
|
constructor(private readonly client: SAPClient) {}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Items (Articulos)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los articulos
|
||||||
|
*/
|
||||||
|
async getItems(options?: ItemFilterOptions): Promise<Item[]> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo articulos', { options });
|
||||||
|
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
if (options?.activeOnly) {
|
||||||
|
filters.push("Frozen eq 'tNO'");
|
||||||
|
filters.push("Valid eq 'tYES'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.inventoryOnly) {
|
||||||
|
filters.push("InventoryItem eq 'tYES'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.salesOnly) {
|
||||||
|
filters.push("SalesItem eq 'tYES'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.purchaseOnly) {
|
||||||
|
filters.push("PurchaseItem eq 'tYES'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.itemGroup !== undefined) {
|
||||||
|
filters.push(`ItemsGroupCode eq ${options.itemGroup}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.withStock) {
|
||||||
|
filters.push('QuantityOnStock gt 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryOptions: ODataQueryOptions = {
|
||||||
|
$select: [
|
||||||
|
'ItemCode',
|
||||||
|
'ItemName',
|
||||||
|
'ForeignName',
|
||||||
|
'ItemsGroupCode',
|
||||||
|
'SalesVATGroup',
|
||||||
|
'BarCode',
|
||||||
|
'VatLiable',
|
||||||
|
'PurchaseItem',
|
||||||
|
'SalesItem',
|
||||||
|
'InventoryItem',
|
||||||
|
'QuantityOnStock',
|
||||||
|
'QuantityOrderedFromVendors',
|
||||||
|
'QuantityOrderedByCustomers',
|
||||||
|
'ManageSerialNumbers',
|
||||||
|
'ManageBatchNumbers',
|
||||||
|
'Valid',
|
||||||
|
'Frozen',
|
||||||
|
'SalesUnit',
|
||||||
|
'PurchaseUnit',
|
||||||
|
'InventoryUOM',
|
||||||
|
'DefaultWarehouse',
|
||||||
|
'AvgStdPrice',
|
||||||
|
],
|
||||||
|
$orderby: 'ItemCode asc',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters.length > 0) {
|
||||||
|
queryOptions.$filter = this.client.combineFilters(...filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<Item>('/Items', queryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un articulo por su codigo
|
||||||
|
*/
|
||||||
|
async getItem(itemCode: string): Promise<Item> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo articulo', { itemCode });
|
||||||
|
return this.client.get<Item>(`/Items('${itemCode}')`, {
|
||||||
|
$expand: ['ItemPrices', 'ItemWarehouseInfoCollection'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca articulos por nombre o codigo
|
||||||
|
*/
|
||||||
|
async searchItems(query: string, limit = 20): Promise<Item[]> {
|
||||||
|
logger.info('SAP Inventory: Buscando articulos', { query, limit });
|
||||||
|
|
||||||
|
const searchFilter = `contains(ItemName,'${query}') or contains(ItemCode,'${query}') or contains(BarCode,'${query}')`;
|
||||||
|
|
||||||
|
return this.client.getAll<Item>('/Items', {
|
||||||
|
$filter: `(${searchFilter}) and Valid eq 'tYES'`,
|
||||||
|
$select: ['ItemCode', 'ItemName', 'BarCode', 'QuantityOnStock', 'DefaultWarehouse', 'AvgStdPrice'],
|
||||||
|
$top: limit,
|
||||||
|
$orderby: 'ItemName asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene articulos por grupo
|
||||||
|
*/
|
||||||
|
async getItemsByGroup(groupCode: number): Promise<Item[]> {
|
||||||
|
return this.getItems({ itemGroup: groupCode, activeOnly: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene articulos con stock bajo
|
||||||
|
*/
|
||||||
|
async getLowStockItems(threshold?: number): Promise<Item[]> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo articulos con stock bajo', { threshold });
|
||||||
|
|
||||||
|
// Obtener todos los articulos con stock info
|
||||||
|
const items = await this.client.getAll<Item>('/Items', {
|
||||||
|
$filter: "InventoryItem eq 'tYES' and Valid eq 'tYES'",
|
||||||
|
$expand: ['ItemWarehouseInfoCollection'],
|
||||||
|
$select: [
|
||||||
|
'ItemCode',
|
||||||
|
'ItemName',
|
||||||
|
'QuantityOnStock',
|
||||||
|
'DefaultWarehouse',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrar localmente por stock minimo
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (!item.ItemWarehouseInfoCollection) return false;
|
||||||
|
|
||||||
|
return item.ItemWarehouseInfoCollection.some((wh) => {
|
||||||
|
const minStock = wh.MinimalStock || threshold || 0;
|
||||||
|
const available = (wh.InStock || 0) - (wh.Committed || 0);
|
||||||
|
return available <= minStock;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Item Stock
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el stock detallado de un articulo
|
||||||
|
*/
|
||||||
|
async getItemStock(itemCode: string): Promise<ItemStockInfo> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo stock de articulo', { itemCode });
|
||||||
|
|
||||||
|
const item = await this.getItem(itemCode);
|
||||||
|
const warehouses = await this.getWarehouses();
|
||||||
|
|
||||||
|
const warehouseMap = new Map(warehouses.map((w) => [w.WarehouseCode, w.WarehouseName]));
|
||||||
|
|
||||||
|
const warehouseStock = (item.ItemWarehouseInfoCollection || []).map((wh) => ({
|
||||||
|
warehouseCode: wh.WarehouseCode || '',
|
||||||
|
warehouseName: warehouseMap.get(wh.WarehouseCode || ''),
|
||||||
|
inStock: wh.InStock || 0,
|
||||||
|
committed: wh.Committed || 0,
|
||||||
|
ordered: wh.Ordered || 0,
|
||||||
|
available: (wh.InStock || 0) - (wh.Committed || 0),
|
||||||
|
minStock: wh.MinimalStock,
|
||||||
|
maxStock: wh.MaximalStock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalInStock = warehouseStock.reduce((sum, wh) => sum + wh.inStock, 0);
|
||||||
|
const totalCommitted = warehouseStock.reduce((sum, wh) => sum + wh.committed, 0);
|
||||||
|
const totalOrdered = warehouseStock.reduce((sum, wh) => sum + wh.ordered, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemCode: item.ItemCode,
|
||||||
|
itemName: item.ItemName,
|
||||||
|
totalInStock,
|
||||||
|
totalCommitted,
|
||||||
|
totalOrdered,
|
||||||
|
totalAvailable: totalInStock - totalCommitted,
|
||||||
|
defaultWarehouse: item.DefaultWarehouse,
|
||||||
|
warehouseStock,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el stock de multiples articulos
|
||||||
|
*/
|
||||||
|
async getItemsStock(itemCodes: string[]): Promise<ItemStockInfo[]> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo stock de articulos', { count: itemCodes.length });
|
||||||
|
|
||||||
|
const results: ItemStockInfo[] = [];
|
||||||
|
|
||||||
|
// Procesar en lotes para evitar timeouts
|
||||||
|
const batchSize = 50;
|
||||||
|
for (let i = 0; i < itemCodes.length; i += batchSize) {
|
||||||
|
const batch = itemCodes.slice(i, i + batchSize);
|
||||||
|
const stockPromises = batch.map((code) => this.getItemStock(code));
|
||||||
|
const batchResults = await Promise.all(stockPromises);
|
||||||
|
results.push(...batchResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene stock por almacen
|
||||||
|
*/
|
||||||
|
async getStockByWarehouse(warehouseCode: string): Promise<
|
||||||
|
Array<{
|
||||||
|
itemCode: string;
|
||||||
|
itemName: string;
|
||||||
|
inStock: number;
|
||||||
|
committed: number;
|
||||||
|
available: number;
|
||||||
|
value: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo stock por almacen', { warehouseCode });
|
||||||
|
|
||||||
|
const items = await this.client.getAll<Item>('/Items', {
|
||||||
|
$filter: "InventoryItem eq 'tYES' and Valid eq 'tYES'",
|
||||||
|
$expand: ['ItemWarehouseInfoCollection'],
|
||||||
|
$select: ['ItemCode', 'ItemName', 'AvgStdPrice'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: Array<{
|
||||||
|
itemCode: string;
|
||||||
|
itemName: string;
|
||||||
|
inStock: number;
|
||||||
|
committed: number;
|
||||||
|
available: number;
|
||||||
|
value: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const whInfo = item.ItemWarehouseInfoCollection?.find(
|
||||||
|
(wh) => wh.WarehouseCode === warehouseCode
|
||||||
|
);
|
||||||
|
|
||||||
|
if (whInfo && (whInfo.InStock || 0) > 0) {
|
||||||
|
const inStock = whInfo.InStock || 0;
|
||||||
|
const committed = whInfo.Committed || 0;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
itemCode: item.ItemCode,
|
||||||
|
itemName: item.ItemName,
|
||||||
|
inStock,
|
||||||
|
committed,
|
||||||
|
available: inStock - committed,
|
||||||
|
value: inStock * (item.AvgStdPrice || 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => b.value - a.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Warehouses (Almacenes)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los almacenes
|
||||||
|
*/
|
||||||
|
async getWarehouses(activeOnly = true): Promise<Warehouse[]> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo almacenes', { activeOnly });
|
||||||
|
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
if (activeOnly) {
|
||||||
|
filters.push("Inactive eq 'tNO'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<Warehouse>('/Warehouses', {
|
||||||
|
$filter: filters.length > 0 ? this.client.combineFilters(...filters) : undefined,
|
||||||
|
$select: [
|
||||||
|
'WarehouseCode',
|
||||||
|
'WarehouseName',
|
||||||
|
'Location',
|
||||||
|
'Nettable',
|
||||||
|
'DropShip',
|
||||||
|
'Inactive',
|
||||||
|
'Street',
|
||||||
|
'City',
|
||||||
|
'Country',
|
||||||
|
'State',
|
||||||
|
'BranchCode',
|
||||||
|
'EnableBinLocations',
|
||||||
|
],
|
||||||
|
$orderby: 'WarehouseCode asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un almacen por su codigo
|
||||||
|
*/
|
||||||
|
async getWarehouse(warehouseCode: string): Promise<Warehouse> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo almacen', { warehouseCode });
|
||||||
|
return this.client.get<Warehouse>(`/Warehouses('${warehouseCode}')`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Stock Transfers (Transferencias de Stock)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene transferencias de stock de un periodo
|
||||||
|
*/
|
||||||
|
async getStockTransfers(
|
||||||
|
period: Period,
|
||||||
|
options?: {
|
||||||
|
fromWarehouse?: string;
|
||||||
|
toWarehouse?: string;
|
||||||
|
itemCode?: string;
|
||||||
|
}
|
||||||
|
): Promise<StockTransfer[]> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo transferencias de stock', { period, options });
|
||||||
|
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
// Filtro de fecha
|
||||||
|
const dateFilter = this.client.buildDateFilter('DocDate', period.startDate, period.endDate);
|
||||||
|
if (dateFilter) {
|
||||||
|
filters.push(dateFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.fromWarehouse) {
|
||||||
|
filters.push(`FromWarehouse eq '${options.fromWarehouse}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.toWarehouse) {
|
||||||
|
filters.push(`ToWarehouse eq '${options.toWarehouse}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transfers = await this.client.getAll<StockTransfer>('/StockTransfers', {
|
||||||
|
$filter: filters.length > 0 ? this.client.combineFilters(...filters) : undefined,
|
||||||
|
$expand: ['StockTransferLines'],
|
||||||
|
$orderby: 'DocDate desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrar por articulo si se especifica
|
||||||
|
if (options?.itemCode) {
|
||||||
|
return transfers.filter((transfer) =>
|
||||||
|
transfer.StockTransferLines?.some((line) => line.ItemCode === options.itemCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transfers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una transferencia de stock por DocEntry
|
||||||
|
*/
|
||||||
|
async getStockTransfer(docEntry: number): Promise<StockTransfer> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo transferencia de stock', { docEntry });
|
||||||
|
return this.client.get<StockTransfer>(`/StockTransfers(${docEntry})`, {
|
||||||
|
$expand: ['StockTransferLines'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene historial de movimientos de un articulo
|
||||||
|
*/
|
||||||
|
async getItemMovements(
|
||||||
|
itemCode: string,
|
||||||
|
period: Period
|
||||||
|
): Promise<InventoryMovement[]> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo movimientos de articulo', { itemCode, period });
|
||||||
|
|
||||||
|
const movements: InventoryMovement[] = [];
|
||||||
|
|
||||||
|
// Obtener transferencias
|
||||||
|
const transfers = await this.getStockTransfers(period, { itemCode });
|
||||||
|
|
||||||
|
for (const transfer of transfers) {
|
||||||
|
for (const line of transfer.StockTransferLines || []) {
|
||||||
|
if (line.ItemCode !== itemCode) continue;
|
||||||
|
|
||||||
|
// Movimiento de salida
|
||||||
|
movements.push({
|
||||||
|
docEntry: transfer.DocEntry,
|
||||||
|
docNum: transfer.DocNum,
|
||||||
|
docDate: transfer.DocDate,
|
||||||
|
itemCode: line.ItemCode,
|
||||||
|
itemName: line.ItemDescription,
|
||||||
|
warehouseCode: line.FromWarehouseCode || transfer.FromWarehouse,
|
||||||
|
quantity: line.Quantity || 0,
|
||||||
|
direction: 'out',
|
||||||
|
movementType: 'Transfer',
|
||||||
|
reference: `To: ${transfer.ToWarehouse}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Movimiento de entrada
|
||||||
|
movements.push({
|
||||||
|
docEntry: transfer.DocEntry,
|
||||||
|
docNum: transfer.DocNum,
|
||||||
|
docDate: transfer.DocDate,
|
||||||
|
itemCode: line.ItemCode,
|
||||||
|
itemName: line.ItemDescription,
|
||||||
|
warehouseCode: line.WarehouseCode || transfer.ToWarehouse,
|
||||||
|
quantity: line.Quantity || 0,
|
||||||
|
direction: 'in',
|
||||||
|
movementType: 'Transfer',
|
||||||
|
reference: `From: ${transfer.FromWarehouse}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por fecha descendente
|
||||||
|
return movements.sort(
|
||||||
|
(a, b) => new Date(b.docDate).getTime() - new Date(a.docDate).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Inventory Valuation
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la valuacion del inventario
|
||||||
|
*/
|
||||||
|
async getInventoryValuation(options?: {
|
||||||
|
warehouseCode?: string;
|
||||||
|
itemGroup?: number;
|
||||||
|
asOfDate?: Date;
|
||||||
|
}): Promise<InventoryValuationEntry[]> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo valuacion de inventario', { options });
|
||||||
|
|
||||||
|
const filters: string[] = [
|
||||||
|
"InventoryItem eq 'tYES'",
|
||||||
|
"Valid eq 'tYES'",
|
||||||
|
'QuantityOnStock gt 0',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.itemGroup !== undefined) {
|
||||||
|
filters.push(`ItemsGroupCode eq ${options.itemGroup}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await this.client.getAll<Item>('/Items', {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$expand: ['ItemWarehouseInfoCollection'],
|
||||||
|
$select: [
|
||||||
|
'ItemCode',
|
||||||
|
'ItemName',
|
||||||
|
'QuantityOnStock',
|
||||||
|
'QuantityOrderedByCustomers',
|
||||||
|
'AvgStdPrice',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const warehouses = await this.getWarehouses();
|
||||||
|
const warehouseMap = new Map(warehouses.map((w) => [w.WarehouseCode, w.WarehouseName]));
|
||||||
|
|
||||||
|
const result: InventoryValuationEntry[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const warehouseInfos = options?.warehouseCode
|
||||||
|
? item.ItemWarehouseInfoCollection?.filter(
|
||||||
|
(wh) => wh.WarehouseCode === options.warehouseCode
|
||||||
|
)
|
||||||
|
: item.ItemWarehouseInfoCollection;
|
||||||
|
|
||||||
|
for (const whInfo of warehouseInfos || []) {
|
||||||
|
const inStock = whInfo.InStock || 0;
|
||||||
|
if (inStock <= 0) continue;
|
||||||
|
|
||||||
|
const unitPrice = whInfo.StandardAveragePrice || item.AvgStdPrice || 0;
|
||||||
|
const committed = whInfo.Committed || 0;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
ItemCode: item.ItemCode,
|
||||||
|
ItemName: item.ItemName,
|
||||||
|
WarehouseCode: whInfo.WarehouseCode || '',
|
||||||
|
WarehouseName: warehouseMap.get(whInfo.WarehouseCode || ''),
|
||||||
|
InStock: inStock,
|
||||||
|
Committed: committed,
|
||||||
|
Available: inStock - committed,
|
||||||
|
UnitPrice: unitPrice,
|
||||||
|
TotalValue: inStock * unitPrice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por valor total descendente
|
||||||
|
return result.sort((a, b) => b.TotalValue - a.TotalValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de valuacion por grupo de articulos
|
||||||
|
*/
|
||||||
|
async getInventoryValuationByGroup(): Promise<InventoryGroupSummary[]> {
|
||||||
|
logger.info('SAP Inventory: Obteniendo valuacion por grupo');
|
||||||
|
|
||||||
|
const valuation = await this.getInventoryValuation();
|
||||||
|
|
||||||
|
// Obtener grupos de articulos
|
||||||
|
interface ItemGroup {
|
||||||
|
Number: number;
|
||||||
|
GroupName: string;
|
||||||
|
}
|
||||||
|
const groups = await this.client.getAll<ItemGroup>('/ItemGroups', {
|
||||||
|
$select: ['Number', 'GroupName'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupMap = new Map(groups.map((g) => [g.Number, g.GroupName]));
|
||||||
|
|
||||||
|
// Agrupar por grupo de articulo
|
||||||
|
const items = await this.client.getAll<Item>('/Items', {
|
||||||
|
$filter: "InventoryItem eq 'tYES' and QuantityOnStock gt 0",
|
||||||
|
$select: ['ItemCode', 'ItemsGroupCode'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemGroupMap = new Map(items.map((i) => [i.ItemCode, i.ItemsGroupCode]));
|
||||||
|
|
||||||
|
const summary = new Map<number, InventoryGroupSummary>();
|
||||||
|
|
||||||
|
for (const entry of valuation) {
|
||||||
|
const groupCode = itemGroupMap.get(entry.ItemCode) || 0;
|
||||||
|
|
||||||
|
if (!summary.has(groupCode)) {
|
||||||
|
summary.set(groupCode, {
|
||||||
|
groupCode,
|
||||||
|
groupName: groupMap.get(groupCode) || 'Sin Grupo',
|
||||||
|
itemCount: 0,
|
||||||
|
totalValue: 0,
|
||||||
|
totalStock: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = summary.get(groupCode)!;
|
||||||
|
group.itemCount += 1;
|
||||||
|
group.totalValue += entry.TotalValue;
|
||||||
|
group.totalStock += entry.InStock;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(summary.values()).sort((a, b) => b.totalValue - a.totalValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el valor total del inventario
|
||||||
|
*/
|
||||||
|
async getTotalInventoryValue(warehouseCode?: string): Promise<{
|
||||||
|
totalValue: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalStock: number;
|
||||||
|
currency: string;
|
||||||
|
}> {
|
||||||
|
logger.info('SAP Inventory: Calculando valor total de inventario', { warehouseCode });
|
||||||
|
|
||||||
|
const valuation = await this.getInventoryValuation({ warehouseCode });
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalValue: valuation.reduce((sum, v) => sum + v.TotalValue, 0),
|
||||||
|
totalItems: new Set(valuation.map((v) => v.ItemCode)).size,
|
||||||
|
totalStock: valuation.reduce((sum, v) => sum + v.InStock, 0),
|
||||||
|
currency: 'MXN', // Moneda base de la empresa
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del conector de inventario
|
||||||
|
*/
|
||||||
|
export function createInventoryConnector(client: SAPClient): InventoryConnector {
|
||||||
|
return new InventoryConnector(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InventoryConnector;
|
||||||
618
apps/api/src/services/integrations/sap/purchasing.connector.ts
Normal file
618
apps/api/src/services/integrations/sap/purchasing.connector.ts
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
/**
|
||||||
|
* SAP Business One Purchasing Connector
|
||||||
|
* Conector para documentos de compra y proveedores
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SAPClient } from './sap.client.js';
|
||||||
|
import {
|
||||||
|
PurchaseInvoice,
|
||||||
|
PurchaseOrder,
|
||||||
|
GoodsReceiptPO,
|
||||||
|
BusinessPartner,
|
||||||
|
Period,
|
||||||
|
ODataQueryOptions,
|
||||||
|
} from './sap.types.js';
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de documento de compra
|
||||||
|
*/
|
||||||
|
export type PurchaseDocumentStatus = 'open' | 'closed' | 'cancelled' | 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones de filtro para documentos de compra
|
||||||
|
*/
|
||||||
|
export interface PurchaseDocumentFilterOptions {
|
||||||
|
cardCode?: string;
|
||||||
|
status?: PurchaseDocumentStatus;
|
||||||
|
series?: number;
|
||||||
|
includeLines?: boolean;
|
||||||
|
warehouseCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumen de balance de proveedor
|
||||||
|
*/
|
||||||
|
export interface VendorBalanceSummary {
|
||||||
|
cardCode: string;
|
||||||
|
cardName: string;
|
||||||
|
totalBalance: number;
|
||||||
|
openInvoicesBalance: number;
|
||||||
|
openOrdersBalance: number;
|
||||||
|
openGoodsReceiptBalance: number;
|
||||||
|
lastInvoiceDate?: string;
|
||||||
|
lastPaymentDate?: string;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Purchasing Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para datos de compras de SAP B1
|
||||||
|
*/
|
||||||
|
export class PurchasingConnector {
|
||||||
|
constructor(private readonly client: SAPClient) {}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Purchase Invoices (Facturas de Compra)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas de compra de un periodo
|
||||||
|
*/
|
||||||
|
async getPurchaseInvoices(
|
||||||
|
period: Period,
|
||||||
|
options?: PurchaseDocumentFilterOptions
|
||||||
|
): Promise<PurchaseInvoice[]> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo facturas de compra', { period, options });
|
||||||
|
|
||||||
|
const queryOptions = this.buildDocumentQuery(period, options);
|
||||||
|
return this.client.getAll<PurchaseInvoice>('/PurchaseInvoices', queryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una factura de compra por DocEntry
|
||||||
|
*/
|
||||||
|
async getPurchaseInvoice(docEntry: number): Promise<PurchaseInvoice> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo factura de compra', { docEntry });
|
||||||
|
return this.client.get<PurchaseInvoice>(`/PurchaseInvoices(${docEntry})`, {
|
||||||
|
$expand: ['DocumentLines', 'WithholdingTaxDataCollection'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas de compra por proveedor
|
||||||
|
*/
|
||||||
|
async getPurchaseInvoicesByVendor(
|
||||||
|
cardCode: string,
|
||||||
|
period?: Period,
|
||||||
|
status?: PurchaseDocumentStatus
|
||||||
|
): Promise<PurchaseInvoice[]> {
|
||||||
|
return this.getPurchaseInvoices(
|
||||||
|
period || this.getDefaultPeriod(),
|
||||||
|
{ cardCode, status, includeLines: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas de compra pendientes de pago
|
||||||
|
*/
|
||||||
|
async getOpenPurchaseInvoices(cardCode?: string): Promise<PurchaseInvoice[]> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo facturas de compra abiertas', { cardCode });
|
||||||
|
|
||||||
|
const filters: string[] = [
|
||||||
|
"DocumentStatus eq 'bost_Open'",
|
||||||
|
"Cancelled eq 'tNO'",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cardCode) {
|
||||||
|
filters.push(`CardCode eq '${cardCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<PurchaseInvoice>('/PurchaseInvoices', {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$select: this.getDocumentSelectFields(),
|
||||||
|
$expand: ['DocumentLines'],
|
||||||
|
$orderby: 'DocDueDate asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas de compra por vencer
|
||||||
|
*/
|
||||||
|
async getUpcomingPurchaseInvoices(daysAhead = 30): Promise<PurchaseInvoice[]> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo facturas por vencer', { daysAhead });
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setDate(futureDate.getDate() + daysAhead);
|
||||||
|
|
||||||
|
const dateFilter = this.client.buildDateFilter('DocDueDate', today, futureDate);
|
||||||
|
|
||||||
|
return this.client.getAll<PurchaseInvoice>('/PurchaseInvoices', {
|
||||||
|
$filter: this.client.combineFilters(
|
||||||
|
"DocumentStatus eq 'bost_Open'",
|
||||||
|
"Cancelled eq 'tNO'",
|
||||||
|
dateFilter
|
||||||
|
),
|
||||||
|
$select: this.getDocumentSelectFields(),
|
||||||
|
$orderby: 'DocDueDate asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Purchase Orders (Ordenes de Compra)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene ordenes de compra de un periodo
|
||||||
|
*/
|
||||||
|
async getPurchaseOrders(
|
||||||
|
period: Period,
|
||||||
|
options?: PurchaseDocumentFilterOptions
|
||||||
|
): Promise<PurchaseOrder[]> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo ordenes de compra', { period, options });
|
||||||
|
|
||||||
|
const queryOptions = this.buildDocumentQuery(period, options);
|
||||||
|
return this.client.getAll<PurchaseOrder>('/PurchaseOrders', queryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una orden de compra por DocEntry
|
||||||
|
*/
|
||||||
|
async getPurchaseOrder(docEntry: number): Promise<PurchaseOrder> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo orden de compra', { docEntry });
|
||||||
|
return this.client.get<PurchaseOrder>(`/PurchaseOrders(${docEntry})`, {
|
||||||
|
$expand: ['DocumentLines'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene ordenes de compra abiertas
|
||||||
|
*/
|
||||||
|
async getOpenPurchaseOrders(cardCode?: string): Promise<PurchaseOrder[]> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo ordenes de compra abiertas', { cardCode });
|
||||||
|
|
||||||
|
const filters: string[] = [
|
||||||
|
"DocumentStatus eq 'bost_Open'",
|
||||||
|
"Cancelled eq 'tNO'",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cardCode) {
|
||||||
|
filters.push(`CardCode eq '${cardCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<PurchaseOrder>('/PurchaseOrders', {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$select: this.getDocumentSelectFields(),
|
||||||
|
$expand: ['DocumentLines'],
|
||||||
|
$orderby: 'DocDueDate asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene ordenes de compra por proveedor
|
||||||
|
*/
|
||||||
|
async getPurchaseOrdersByVendor(
|
||||||
|
cardCode: string,
|
||||||
|
period?: Period
|
||||||
|
): Promise<PurchaseOrder[]> {
|
||||||
|
return this.getPurchaseOrders(
|
||||||
|
period || this.getDefaultPeriod(),
|
||||||
|
{ cardCode, includeLines: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Goods Receipt PO (Entrada de Mercancias)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene entradas de mercancias de un periodo
|
||||||
|
*/
|
||||||
|
async getGoodsReceiptPO(
|
||||||
|
period: Period,
|
||||||
|
options?: PurchaseDocumentFilterOptions
|
||||||
|
): Promise<GoodsReceiptPO[]> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo entradas de mercancias', { period, options });
|
||||||
|
|
||||||
|
const queryOptions = this.buildDocumentQuery(period, options);
|
||||||
|
return this.client.getAll<GoodsReceiptPO>('/PurchaseDeliveryNotes', queryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una entrada de mercancias por DocEntry
|
||||||
|
*/
|
||||||
|
async getGoodsReceiptPOEntry(docEntry: number): Promise<GoodsReceiptPO> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo entrada de mercancias', { docEntry });
|
||||||
|
return this.client.get<GoodsReceiptPO>(`/PurchaseDeliveryNotes(${docEntry})`, {
|
||||||
|
$expand: ['DocumentLines'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene entradas de mercancias pendientes de facturar
|
||||||
|
*/
|
||||||
|
async getOpenGoodsReceiptPO(cardCode?: string): Promise<GoodsReceiptPO[]> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo entradas pendientes de facturar', { cardCode });
|
||||||
|
|
||||||
|
const filters: string[] = [
|
||||||
|
"DocumentStatus eq 'bost_Open'",
|
||||||
|
"Cancelled eq 'tNO'",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cardCode) {
|
||||||
|
filters.push(`CardCode eq '${cardCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<GoodsReceiptPO>('/PurchaseDeliveryNotes', {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$select: this.getDocumentSelectFields(),
|
||||||
|
$orderby: 'DocDate desc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene entradas de mercancias por almacen
|
||||||
|
*/
|
||||||
|
async getGoodsReceiptPOByWarehouse(
|
||||||
|
warehouseCode: string,
|
||||||
|
period: Period
|
||||||
|
): Promise<GoodsReceiptPO[]> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo entradas por almacen', { warehouseCode, period });
|
||||||
|
|
||||||
|
// SAP no permite filtrar directamente por lineas, obtenemos y filtramos
|
||||||
|
const receipts = await this.getGoodsReceiptPO(period, { includeLines: true });
|
||||||
|
|
||||||
|
return receipts.filter((receipt) =>
|
||||||
|
receipt.DocumentLines?.some((line) => line.WarehouseCode === warehouseCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Vendors (Proveedores)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los proveedores
|
||||||
|
*/
|
||||||
|
async getVendors(options?: {
|
||||||
|
activeOnly?: boolean;
|
||||||
|
groupCode?: number;
|
||||||
|
withBalance?: boolean;
|
||||||
|
}): Promise<BusinessPartner[]> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo proveedores', { options });
|
||||||
|
|
||||||
|
const filters: string[] = ["CardType eq 'cSupplier'"];
|
||||||
|
|
||||||
|
if (options?.activeOnly) {
|
||||||
|
filters.push("Frozen eq 'tNO'");
|
||||||
|
filters.push("Valid eq 'tYES'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.groupCode !== undefined) {
|
||||||
|
filters.push(`GroupCode eq ${options.groupCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFields = [
|
||||||
|
'CardCode',
|
||||||
|
'CardName',
|
||||||
|
'CardType',
|
||||||
|
'CardForeignName',
|
||||||
|
'FederalTaxID',
|
||||||
|
'Address',
|
||||||
|
'ZipCode',
|
||||||
|
'City',
|
||||||
|
'Country',
|
||||||
|
'State',
|
||||||
|
'EmailAddress',
|
||||||
|
'Phone1',
|
||||||
|
'Phone2',
|
||||||
|
'Cellular',
|
||||||
|
'ContactPerson',
|
||||||
|
'PayTermsGrpCode',
|
||||||
|
'Currency',
|
||||||
|
'GroupCode',
|
||||||
|
'Frozen',
|
||||||
|
'Valid',
|
||||||
|
'CreateDate',
|
||||||
|
'UpdateDate',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.withBalance) {
|
||||||
|
selectFields.push('Balance');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<BusinessPartner>('/BusinessPartners', {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$select: selectFields,
|
||||||
|
$orderby: 'CardName asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un proveedor por CardCode
|
||||||
|
*/
|
||||||
|
async getVendor(cardCode: string): Promise<BusinessPartner> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo proveedor', { cardCode });
|
||||||
|
return this.client.get<BusinessPartner>(`/BusinessPartners('${cardCode}')`, {
|
||||||
|
$expand: ['BPAddresses', 'ContactEmployees', 'BPBankAccounts'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca proveedores por nombre o RFC
|
||||||
|
*/
|
||||||
|
async searchVendors(query: string, limit = 20): Promise<BusinessPartner[]> {
|
||||||
|
logger.info('SAP Purchasing: Buscando proveedores', { query, limit });
|
||||||
|
|
||||||
|
const searchFilter = `contains(CardName,'${query}') or contains(CardCode,'${query}') or contains(FederalTaxID,'${query}')`;
|
||||||
|
|
||||||
|
return this.client.getAll<BusinessPartner>('/BusinessPartners', {
|
||||||
|
$filter: `CardType eq 'cSupplier' and (${searchFilter})`,
|
||||||
|
$select: ['CardCode', 'CardName', 'FederalTaxID', 'EmailAddress', 'Phone1', 'Balance'],
|
||||||
|
$top: limit,
|
||||||
|
$orderby: 'CardName asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el balance detallado de un proveedor
|
||||||
|
*/
|
||||||
|
async getVendorBalance(cardCode: string): Promise<VendorBalanceSummary> {
|
||||||
|
logger.info('SAP Purchasing: Obteniendo balance de proveedor', { cardCode });
|
||||||
|
|
||||||
|
// Obtener datos del proveedor
|
||||||
|
const vendor = await this.getVendor(cardCode);
|
||||||
|
|
||||||
|
// Obtener facturas abiertas
|
||||||
|
const openInvoices = await this.getOpenPurchaseInvoices(cardCode);
|
||||||
|
|
||||||
|
const openInvoicesBalance = openInvoices.reduce((sum, inv) => {
|
||||||
|
return sum + (inv.DocTotal - (inv.PaidToDate || 0));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Obtener ordenes de compra abiertas
|
||||||
|
const openOrders = await this.getOpenPurchaseOrders(cardCode);
|
||||||
|
const openOrdersBalance = openOrders.reduce((sum, order) => sum + order.DocTotal, 0);
|
||||||
|
|
||||||
|
// Obtener entradas pendientes de facturar
|
||||||
|
const openReceipts = await this.getOpenGoodsReceiptPO(cardCode);
|
||||||
|
const openGoodsReceiptBalance = openReceipts.reduce((sum, receipt) => sum + receipt.DocTotal, 0);
|
||||||
|
|
||||||
|
// Encontrar ultima factura
|
||||||
|
const lastInvoice = openInvoices.length > 0
|
||||||
|
? openInvoices.sort((a, b) => new Date(b.DocDate).getTime() - new Date(a.DocDate).getTime())[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cardCode: vendor.CardCode,
|
||||||
|
cardName: vendor.CardName,
|
||||||
|
totalBalance: vendor.Balance || 0,
|
||||||
|
openInvoicesBalance,
|
||||||
|
openOrdersBalance,
|
||||||
|
openGoodsReceiptBalance,
|
||||||
|
lastInvoiceDate: lastInvoice?.DocDate,
|
||||||
|
currency: vendor.Currency || 'MXN',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado de cuenta de un proveedor
|
||||||
|
*/
|
||||||
|
async getVendorStatement(
|
||||||
|
cardCode: string,
|
||||||
|
period: Period
|
||||||
|
): Promise<{
|
||||||
|
vendor: BusinessPartner;
|
||||||
|
invoices: PurchaseInvoice[];
|
||||||
|
openBalance: number;
|
||||||
|
periodCharges: number;
|
||||||
|
closingBalance: number;
|
||||||
|
}> {
|
||||||
|
logger.info('SAP Purchasing: Generando estado de cuenta de proveedor', { cardCode, period });
|
||||||
|
|
||||||
|
const [vendor, invoices] = await Promise.all([
|
||||||
|
this.getVendor(cardCode),
|
||||||
|
this.getPurchaseInvoicesByVendor(cardCode, period),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const periodCharges = invoices.reduce((sum, inv) => sum + inv.DocTotal, 0);
|
||||||
|
|
||||||
|
// Calcular balance de apertura (simplificado)
|
||||||
|
const openBalance = (vendor.Balance || 0) - periodCharges;
|
||||||
|
const closingBalance = openBalance + periodCharges;
|
||||||
|
|
||||||
|
return {
|
||||||
|
vendor,
|
||||||
|
invoices,
|
||||||
|
openBalance,
|
||||||
|
periodCharges,
|
||||||
|
closingBalance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Analytics
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de compras por proveedor
|
||||||
|
*/
|
||||||
|
async getPurchaseSummaryByVendor(period: Period): Promise<
|
||||||
|
Array<{
|
||||||
|
cardCode: string;
|
||||||
|
cardName: string;
|
||||||
|
invoiceCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
paidAmount: number;
|
||||||
|
pendingAmount: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
logger.info('SAP Purchasing: Generando resumen de compras por proveedor', { period });
|
||||||
|
|
||||||
|
const invoices = await this.getPurchaseInvoices(period, { status: 'all' });
|
||||||
|
|
||||||
|
const summary = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
cardCode: string;
|
||||||
|
cardName: string;
|
||||||
|
invoiceCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
paidAmount: number;
|
||||||
|
pendingAmount: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
if (!summary.has(invoice.CardCode)) {
|
||||||
|
summary.set(invoice.CardCode, {
|
||||||
|
cardCode: invoice.CardCode,
|
||||||
|
cardName: invoice.CardName || '',
|
||||||
|
invoiceCount: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
paidAmount: 0,
|
||||||
|
pendingAmount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = summary.get(invoice.CardCode)!;
|
||||||
|
entry.invoiceCount += 1;
|
||||||
|
entry.totalAmount += invoice.DocTotal;
|
||||||
|
entry.paidAmount += invoice.PaidToDate || 0;
|
||||||
|
entry.pendingAmount += invoice.DocTotal - (invoice.PaidToDate || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(summary.values()).sort((a, b) => b.totalAmount - a.totalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye las opciones de consulta para documentos
|
||||||
|
*/
|
||||||
|
private buildDocumentQuery(
|
||||||
|
period: Period,
|
||||||
|
options?: PurchaseDocumentFilterOptions
|
||||||
|
): ODataQueryOptions {
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
// Filtro de fecha
|
||||||
|
const dateFilter = this.client.buildDateFilter('DocDate', period.startDate, period.endDate);
|
||||||
|
if (dateFilter) {
|
||||||
|
filters.push(dateFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de proveedor
|
||||||
|
if (options?.cardCode) {
|
||||||
|
filters.push(`CardCode eq '${options.cardCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de estado
|
||||||
|
if (options?.status && options.status !== 'all') {
|
||||||
|
switch (options.status) {
|
||||||
|
case 'open':
|
||||||
|
filters.push("DocumentStatus eq 'bost_Open'");
|
||||||
|
break;
|
||||||
|
case 'closed':
|
||||||
|
filters.push("DocumentStatus eq 'bost_Close'");
|
||||||
|
break;
|
||||||
|
case 'cancelled':
|
||||||
|
filters.push("Cancelled eq 'tYES'");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excluir cancelados por defecto
|
||||||
|
if (!options?.status || options.status !== 'cancelled') {
|
||||||
|
filters.push("Cancelled eq 'tNO'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de serie
|
||||||
|
if (options?.series !== undefined) {
|
||||||
|
filters.push(`Series eq ${options.series}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryOptions: ODataQueryOptions = {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$select: this.getDocumentSelectFields(),
|
||||||
|
$orderby: 'DocDate desc, DocNum desc',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.includeLines) {
|
||||||
|
queryOptions.$expand = ['DocumentLines'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los campos comunes a seleccionar en documentos
|
||||||
|
*/
|
||||||
|
private getDocumentSelectFields(): string[] {
|
||||||
|
return [
|
||||||
|
'DocEntry',
|
||||||
|
'DocNum',
|
||||||
|
'DocType',
|
||||||
|
'DocDate',
|
||||||
|
'DocDueDate',
|
||||||
|
'TaxDate',
|
||||||
|
'CardCode',
|
||||||
|
'CardName',
|
||||||
|
'Address',
|
||||||
|
'NumAtCard',
|
||||||
|
'DocCurrency',
|
||||||
|
'DocRate',
|
||||||
|
'DocTotal',
|
||||||
|
'DocTotalFC',
|
||||||
|
'VatSum',
|
||||||
|
'DiscountPercent',
|
||||||
|
'DiscSum',
|
||||||
|
'PaidToDate',
|
||||||
|
'PaidToDateFC',
|
||||||
|
'Comments',
|
||||||
|
'JournalMemo',
|
||||||
|
'DocumentStatus',
|
||||||
|
'Cancelled',
|
||||||
|
'Series',
|
||||||
|
'CreateDate',
|
||||||
|
'UpdateDate',
|
||||||
|
'U_FolioFiscalUUID',
|
||||||
|
'U_SerieCFDI',
|
||||||
|
'U_FolioCFDI',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el periodo por defecto (ultimo mes)
|
||||||
|
*/
|
||||||
|
private getDefaultPeriod(): Period {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setMonth(startDate.getMonth() - 1);
|
||||||
|
|
||||||
|
return { startDate, endDate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del conector de compras
|
||||||
|
*/
|
||||||
|
export function createPurchasingConnector(client: SAPClient): PurchasingConnector {
|
||||||
|
return new PurchasingConnector(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PurchasingConnector;
|
||||||
550
apps/api/src/services/integrations/sap/sales.connector.ts
Normal file
550
apps/api/src/services/integrations/sap/sales.connector.ts
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
/**
|
||||||
|
* SAP Business One Sales Connector
|
||||||
|
* Conector para documentos de venta y clientes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SAPClient } from './sap.client.js';
|
||||||
|
import {
|
||||||
|
Invoice,
|
||||||
|
CreditNote,
|
||||||
|
DeliveryNote,
|
||||||
|
SalesOrder,
|
||||||
|
BusinessPartner,
|
||||||
|
Period,
|
||||||
|
ODataQueryOptions,
|
||||||
|
SAPError,
|
||||||
|
} from './sap.types.js';
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de documento
|
||||||
|
*/
|
||||||
|
export type DocumentStatus = 'open' | 'closed' | 'cancelled' | 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones de filtro para documentos
|
||||||
|
*/
|
||||||
|
export interface DocumentFilterOptions {
|
||||||
|
cardCode?: string;
|
||||||
|
status?: DocumentStatus;
|
||||||
|
series?: number;
|
||||||
|
salesPersonCode?: number;
|
||||||
|
includeLines?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumen de balance de cliente
|
||||||
|
*/
|
||||||
|
export interface CustomerBalanceSummary {
|
||||||
|
cardCode: string;
|
||||||
|
cardName: string;
|
||||||
|
totalBalance: number;
|
||||||
|
openInvoicesBalance: number;
|
||||||
|
openOrdersBalance: number;
|
||||||
|
openDeliveryNotesBalance: number;
|
||||||
|
creditLimit: number;
|
||||||
|
availableCredit: number;
|
||||||
|
lastInvoiceDate?: string;
|
||||||
|
lastPaymentDate?: string;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sales Connector Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conector para datos de ventas de SAP B1
|
||||||
|
*/
|
||||||
|
export class SalesConnector {
|
||||||
|
constructor(private readonly client: SAPClient) {}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Invoices (Facturas de Venta)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas de venta de un periodo
|
||||||
|
*/
|
||||||
|
async getInvoices(period: Period, options?: DocumentFilterOptions): Promise<Invoice[]> {
|
||||||
|
logger.info('SAP Sales: Obteniendo facturas', { period, options });
|
||||||
|
|
||||||
|
const queryOptions = this.buildDocumentQuery(period, options);
|
||||||
|
return this.client.getAll<Invoice>('/Invoices', queryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una factura por DocEntry
|
||||||
|
*/
|
||||||
|
async getInvoice(docEntry: number): Promise<Invoice> {
|
||||||
|
logger.info('SAP Sales: Obteniendo factura', { docEntry });
|
||||||
|
return this.client.get<Invoice>(`/Invoices(${docEntry})`, {
|
||||||
|
$expand: ['DocumentLines', 'WithholdingTaxDataCollection'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas por cliente
|
||||||
|
*/
|
||||||
|
async getInvoicesByCustomer(
|
||||||
|
cardCode: string,
|
||||||
|
period?: Period,
|
||||||
|
status?: DocumentStatus
|
||||||
|
): Promise<Invoice[]> {
|
||||||
|
logger.info('SAP Sales: Obteniendo facturas por cliente', { cardCode, period, status });
|
||||||
|
|
||||||
|
return this.getInvoices(
|
||||||
|
period || this.getDefaultPeriod(),
|
||||||
|
{ cardCode, status, includeLines: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene facturas pendientes de pago
|
||||||
|
*/
|
||||||
|
async getOpenInvoices(cardCode?: string): Promise<Invoice[]> {
|
||||||
|
logger.info('SAP Sales: Obteniendo facturas abiertas', { cardCode });
|
||||||
|
|
||||||
|
const filters: string[] = [
|
||||||
|
"DocumentStatus eq 'bost_Open'",
|
||||||
|
"Cancelled eq 'tNO'",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cardCode) {
|
||||||
|
filters.push(`CardCode eq '${cardCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<Invoice>('/Invoices', {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$select: this.getDocumentSelectFields(),
|
||||||
|
$expand: ['DocumentLines'],
|
||||||
|
$orderby: 'DocDueDate asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Credit Notes (Notas de Credito)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene notas de credito de un periodo
|
||||||
|
*/
|
||||||
|
async getCreditNotes(period: Period, options?: DocumentFilterOptions): Promise<CreditNote[]> {
|
||||||
|
logger.info('SAP Sales: Obteniendo notas de credito', { period, options });
|
||||||
|
|
||||||
|
const queryOptions = this.buildDocumentQuery(period, options);
|
||||||
|
return this.client.getAll<CreditNote>('/CreditNotes', queryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una nota de credito por DocEntry
|
||||||
|
*/
|
||||||
|
async getCreditNote(docEntry: number): Promise<CreditNote> {
|
||||||
|
logger.info('SAP Sales: Obteniendo nota de credito', { docEntry });
|
||||||
|
return this.client.get<CreditNote>(`/CreditNotes(${docEntry})`, {
|
||||||
|
$expand: ['DocumentLines', 'WithholdingTaxDataCollection'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene notas de credito por cliente
|
||||||
|
*/
|
||||||
|
async getCreditNotesByCustomer(
|
||||||
|
cardCode: string,
|
||||||
|
period?: Period
|
||||||
|
): Promise<CreditNote[]> {
|
||||||
|
return this.getCreditNotes(period || this.getDefaultPeriod(), { cardCode, includeLines: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Delivery Notes (Notas de Entrega / Remisiones)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene notas de entrega de un periodo
|
||||||
|
*/
|
||||||
|
async getDeliveryNotes(period: Period, options?: DocumentFilterOptions): Promise<DeliveryNote[]> {
|
||||||
|
logger.info('SAP Sales: Obteniendo notas de entrega', { period, options });
|
||||||
|
|
||||||
|
const queryOptions = this.buildDocumentQuery(period, options);
|
||||||
|
return this.client.getAll<DeliveryNote>('/DeliveryNotes', queryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una nota de entrega por DocEntry
|
||||||
|
*/
|
||||||
|
async getDeliveryNote(docEntry: number): Promise<DeliveryNote> {
|
||||||
|
logger.info('SAP Sales: Obteniendo nota de entrega', { docEntry });
|
||||||
|
return this.client.get<DeliveryNote>(`/DeliveryNotes(${docEntry})`, {
|
||||||
|
$expand: ['DocumentLines'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene notas de entrega pendientes de facturar
|
||||||
|
*/
|
||||||
|
async getOpenDeliveryNotes(cardCode?: string): Promise<DeliveryNote[]> {
|
||||||
|
logger.info('SAP Sales: Obteniendo remisiones abiertas', { cardCode });
|
||||||
|
|
||||||
|
const filters: string[] = [
|
||||||
|
"DocumentStatus eq 'bost_Open'",
|
||||||
|
"Cancelled eq 'tNO'",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cardCode) {
|
||||||
|
filters.push(`CardCode eq '${cardCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<DeliveryNote>('/DeliveryNotes', {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$select: this.getDocumentSelectFields(),
|
||||||
|
$orderby: 'DocDate desc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Sales Orders (Pedidos de Venta)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene ordenes de venta de un periodo
|
||||||
|
*/
|
||||||
|
async getSalesOrders(period: Period, options?: DocumentFilterOptions): Promise<SalesOrder[]> {
|
||||||
|
logger.info('SAP Sales: Obteniendo pedidos de venta', { period, options });
|
||||||
|
|
||||||
|
const queryOptions = this.buildDocumentQuery(period, options);
|
||||||
|
return this.client.getAll<SalesOrder>('/Orders', queryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una orden de venta por DocEntry
|
||||||
|
*/
|
||||||
|
async getSalesOrder(docEntry: number): Promise<SalesOrder> {
|
||||||
|
logger.info('SAP Sales: Obteniendo pedido de venta', { docEntry });
|
||||||
|
return this.client.get<SalesOrder>(`/Orders(${docEntry})`, {
|
||||||
|
$expand: ['DocumentLines'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene ordenes de venta abiertas
|
||||||
|
*/
|
||||||
|
async getOpenSalesOrders(cardCode?: string): Promise<SalesOrder[]> {
|
||||||
|
logger.info('SAP Sales: Obteniendo pedidos abiertos', { cardCode });
|
||||||
|
|
||||||
|
const filters: string[] = [
|
||||||
|
"DocumentStatus eq 'bost_Open'",
|
||||||
|
"Cancelled eq 'tNO'",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cardCode) {
|
||||||
|
filters.push(`CardCode eq '${cardCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<SalesOrder>('/Orders', {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$select: this.getDocumentSelectFields(),
|
||||||
|
$expand: ['DocumentLines'],
|
||||||
|
$orderby: 'DocDueDate asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Customers (Clientes)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los clientes
|
||||||
|
*/
|
||||||
|
async getCustomers(options?: {
|
||||||
|
activeOnly?: boolean;
|
||||||
|
groupCode?: number;
|
||||||
|
withBalance?: boolean;
|
||||||
|
}): Promise<BusinessPartner[]> {
|
||||||
|
logger.info('SAP Sales: Obteniendo clientes', { options });
|
||||||
|
|
||||||
|
const filters: string[] = ["CardType eq 'cCustomer'"];
|
||||||
|
|
||||||
|
if (options?.activeOnly) {
|
||||||
|
filters.push("Frozen eq 'tNO'");
|
||||||
|
filters.push("Valid eq 'tYES'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.groupCode !== undefined) {
|
||||||
|
filters.push(`GroupCode eq ${options.groupCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFields = [
|
||||||
|
'CardCode',
|
||||||
|
'CardName',
|
||||||
|
'CardType',
|
||||||
|
'CardForeignName',
|
||||||
|
'FederalTaxID',
|
||||||
|
'Address',
|
||||||
|
'ZipCode',
|
||||||
|
'City',
|
||||||
|
'Country',
|
||||||
|
'State',
|
||||||
|
'EmailAddress',
|
||||||
|
'Phone1',
|
||||||
|
'Phone2',
|
||||||
|
'Cellular',
|
||||||
|
'ContactPerson',
|
||||||
|
'PayTermsGrpCode',
|
||||||
|
'CreditLimit',
|
||||||
|
'Currency',
|
||||||
|
'GroupCode',
|
||||||
|
'PriceListNum',
|
||||||
|
'Frozen',
|
||||||
|
'Valid',
|
||||||
|
'CreateDate',
|
||||||
|
'UpdateDate',
|
||||||
|
'SalesPersonCode',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.withBalance) {
|
||||||
|
selectFields.push('Balance', 'OpenDeliveryNotesBalance', 'OpenOrdersBalance');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.getAll<BusinessPartner>('/BusinessPartners', {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$select: selectFields,
|
||||||
|
$orderby: 'CardName asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene un cliente por CardCode
|
||||||
|
*/
|
||||||
|
async getCustomer(cardCode: string): Promise<BusinessPartner> {
|
||||||
|
logger.info('SAP Sales: Obteniendo cliente', { cardCode });
|
||||||
|
return this.client.get<BusinessPartner>(`/BusinessPartners('${cardCode}')`, {
|
||||||
|
$expand: ['BPAddresses', 'ContactEmployees', 'BPBankAccounts'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca clientes por nombre o RFC
|
||||||
|
*/
|
||||||
|
async searchCustomers(query: string, limit = 20): Promise<BusinessPartner[]> {
|
||||||
|
logger.info('SAP Sales: Buscando clientes', { query, limit });
|
||||||
|
|
||||||
|
const searchFilter = `contains(CardName,'${query}') or contains(CardCode,'${query}') or contains(FederalTaxID,'${query}')`;
|
||||||
|
|
||||||
|
return this.client.getAll<BusinessPartner>('/BusinessPartners', {
|
||||||
|
$filter: `CardType eq 'cCustomer' and (${searchFilter})`,
|
||||||
|
$select: ['CardCode', 'CardName', 'FederalTaxID', 'EmailAddress', 'Phone1', 'Balance'],
|
||||||
|
$top: limit,
|
||||||
|
$orderby: 'CardName asc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el balance detallado de un cliente
|
||||||
|
*/
|
||||||
|
async getCustomerBalance(cardCode: string): Promise<CustomerBalanceSummary> {
|
||||||
|
logger.info('SAP Sales: Obteniendo balance de cliente', { cardCode });
|
||||||
|
|
||||||
|
// Obtener datos del cliente
|
||||||
|
const customer = await this.getCustomer(cardCode);
|
||||||
|
|
||||||
|
// Obtener facturas abiertas para calcular balance real
|
||||||
|
const openInvoices = await this.getOpenInvoices(cardCode);
|
||||||
|
|
||||||
|
const openInvoicesBalance = openInvoices.reduce((sum, inv) => {
|
||||||
|
return sum + (inv.DocTotal - (inv.PaidToDate || 0));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Encontrar ultima factura y pago
|
||||||
|
const lastInvoice = openInvoices.length > 0
|
||||||
|
? openInvoices.sort((a, b) => new Date(b.DocDate).getTime() - new Date(a.DocDate).getTime())[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cardCode: customer.CardCode,
|
||||||
|
cardName: customer.CardName,
|
||||||
|
totalBalance: customer.Balance || 0,
|
||||||
|
openInvoicesBalance,
|
||||||
|
openOrdersBalance: customer.OpenOrdersBalance || 0,
|
||||||
|
openDeliveryNotesBalance: customer.OpenDeliveryNotesBalance || 0,
|
||||||
|
creditLimit: customer.CreditLimit || 0,
|
||||||
|
availableCredit: (customer.CreditLimit || 0) - (customer.Balance || 0),
|
||||||
|
lastInvoiceDate: lastInvoice?.DocDate,
|
||||||
|
currency: customer.Currency || 'MXN',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado de cuenta de un cliente
|
||||||
|
*/
|
||||||
|
async getCustomerStatement(
|
||||||
|
cardCode: string,
|
||||||
|
period: Period
|
||||||
|
): Promise<{
|
||||||
|
customer: BusinessPartner;
|
||||||
|
invoices: Invoice[];
|
||||||
|
creditNotes: CreditNote[];
|
||||||
|
openBalance: number;
|
||||||
|
periodDebits: number;
|
||||||
|
periodCredits: number;
|
||||||
|
closingBalance: number;
|
||||||
|
}> {
|
||||||
|
logger.info('SAP Sales: Generando estado de cuenta', { cardCode, period });
|
||||||
|
|
||||||
|
const [customer, invoices, creditNotes] = await Promise.all([
|
||||||
|
this.getCustomer(cardCode),
|
||||||
|
this.getInvoicesByCustomer(cardCode, period),
|
||||||
|
this.getCreditNotesByCustomer(cardCode, period),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const periodDebits = invoices.reduce((sum, inv) => sum + inv.DocTotal, 0);
|
||||||
|
const periodCredits = creditNotes.reduce((sum, cn) => sum + cn.DocTotal, 0);
|
||||||
|
|
||||||
|
// Calcular balance de apertura (simplificado)
|
||||||
|
const openBalance = (customer.Balance || 0) - periodDebits + periodCredits;
|
||||||
|
const closingBalance = openBalance + periodDebits - periodCredits;
|
||||||
|
|
||||||
|
return {
|
||||||
|
customer,
|
||||||
|
invoices,
|
||||||
|
creditNotes,
|
||||||
|
openBalance,
|
||||||
|
periodDebits,
|
||||||
|
periodCredits,
|
||||||
|
closingBalance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye las opciones de consulta para documentos
|
||||||
|
*/
|
||||||
|
private buildDocumentQuery(
|
||||||
|
period: Period,
|
||||||
|
options?: DocumentFilterOptions
|
||||||
|
): ODataQueryOptions {
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
// Filtro de fecha
|
||||||
|
const dateFilter = this.client.buildDateFilter('DocDate', period.startDate, period.endDate);
|
||||||
|
if (dateFilter) {
|
||||||
|
filters.push(dateFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de cliente
|
||||||
|
if (options?.cardCode) {
|
||||||
|
filters.push(`CardCode eq '${options.cardCode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de estado
|
||||||
|
if (options?.status && options.status !== 'all') {
|
||||||
|
switch (options.status) {
|
||||||
|
case 'open':
|
||||||
|
filters.push("DocumentStatus eq 'bost_Open'");
|
||||||
|
break;
|
||||||
|
case 'closed':
|
||||||
|
filters.push("DocumentStatus eq 'bost_Close'");
|
||||||
|
break;
|
||||||
|
case 'cancelled':
|
||||||
|
filters.push("Cancelled eq 'tYES'");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excluir cancelados por defecto
|
||||||
|
if (!options?.status || options.status !== 'cancelled') {
|
||||||
|
filters.push("Cancelled eq 'tNO'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de serie
|
||||||
|
if (options?.series !== undefined) {
|
||||||
|
filters.push(`Series eq ${options.series}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de vendedor
|
||||||
|
if (options?.salesPersonCode !== undefined) {
|
||||||
|
filters.push(`SalesPersonCode eq ${options.salesPersonCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryOptions: ODataQueryOptions = {
|
||||||
|
$filter: this.client.combineFilters(...filters),
|
||||||
|
$select: this.getDocumentSelectFields(),
|
||||||
|
$orderby: 'DocDate desc, DocNum desc',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.includeLines) {
|
||||||
|
queryOptions.$expand = ['DocumentLines'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los campos comunes a seleccionar en documentos
|
||||||
|
*/
|
||||||
|
private getDocumentSelectFields(): string[] {
|
||||||
|
return [
|
||||||
|
'DocEntry',
|
||||||
|
'DocNum',
|
||||||
|
'DocType',
|
||||||
|
'DocDate',
|
||||||
|
'DocDueDate',
|
||||||
|
'TaxDate',
|
||||||
|
'CardCode',
|
||||||
|
'CardName',
|
||||||
|
'Address',
|
||||||
|
'NumAtCard',
|
||||||
|
'DocCurrency',
|
||||||
|
'DocRate',
|
||||||
|
'DocTotal',
|
||||||
|
'DocTotalFC',
|
||||||
|
'VatSum',
|
||||||
|
'DiscountPercent',
|
||||||
|
'DiscSum',
|
||||||
|
'PaidToDate',
|
||||||
|
'PaidToDateFC',
|
||||||
|
'Comments',
|
||||||
|
'JournalMemo',
|
||||||
|
'DocumentStatus',
|
||||||
|
'Cancelled',
|
||||||
|
'SalesPersonCode',
|
||||||
|
'Series',
|
||||||
|
'CreateDate',
|
||||||
|
'UpdateDate',
|
||||||
|
'U_FolioFiscalUUID',
|
||||||
|
'U_SerieCFDI',
|
||||||
|
'U_FolioCFDI',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el periodo por defecto (ultimo mes)
|
||||||
|
*/
|
||||||
|
private getDefaultPeriod(): Period {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setMonth(startDate.getMonth() - 1);
|
||||||
|
|
||||||
|
return { startDate, endDate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del conector de ventas
|
||||||
|
*/
|
||||||
|
export function createSalesConnector(client: SAPClient): SalesConnector {
|
||||||
|
return new SalesConnector(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SalesConnector;
|
||||||
670
apps/api/src/services/integrations/sap/sap.client.ts
Normal file
670
apps/api/src/services/integrations/sap/sap.client.ts
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
/**
|
||||||
|
* SAP Business One Service Layer Client
|
||||||
|
* Cliente HTTP para comunicarse con SAP B1 Service Layer (REST/OData)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
SAPConfig,
|
||||||
|
SAPSession,
|
||||||
|
SAPLoginResponse,
|
||||||
|
SAPErrorResponse,
|
||||||
|
ODataQueryOptions,
|
||||||
|
ODataResponse,
|
||||||
|
BatchOperation,
|
||||||
|
BatchResult,
|
||||||
|
SAPError,
|
||||||
|
SAPAuthError,
|
||||||
|
SAPSessionExpiredError,
|
||||||
|
SAPConnectionError,
|
||||||
|
SAPNotFoundError,
|
||||||
|
} from './sap.types.js';
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = 30000;
|
||||||
|
const DEFAULT_MAX_RETRIES = 3;
|
||||||
|
const DEFAULT_SESSION_LIFETIME = 30; // minutos
|
||||||
|
const SESSION_REFRESH_THRESHOLD = 5; // minutos antes de expiracion
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SAP Service Layer Client
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente para SAP Business One Service Layer
|
||||||
|
* Maneja autenticacion, sesiones y comunicacion HTTP con el servicio
|
||||||
|
*/
|
||||||
|
export class SAPClient {
|
||||||
|
private config: Required<SAPConfig>;
|
||||||
|
private session: SAPSession | null = null;
|
||||||
|
private sessionRefreshTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(config: SAPConfig) {
|
||||||
|
this.config = {
|
||||||
|
language: 'b', // Espanol
|
||||||
|
timeout: DEFAULT_TIMEOUT,
|
||||||
|
sslVerify: true,
|
||||||
|
maxRetries: DEFAULT_MAX_RETRIES,
|
||||||
|
sessionLifetime: DEFAULT_SESSION_LIFETIME,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Session Management
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicia sesion en SAP Service Layer
|
||||||
|
*/
|
||||||
|
async login(): Promise<SAPSession> {
|
||||||
|
try {
|
||||||
|
logger.info('SAP: Iniciando login', {
|
||||||
|
serviceLayerUrl: this.config.serviceLayerUrl,
|
||||||
|
companyDB: this.config.companyDB,
|
||||||
|
userName: this.config.userName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.httpRequest<SAPLoginResponse>('POST', '/Login', {
|
||||||
|
CompanyDB: this.config.companyDB,
|
||||||
|
UserName: this.config.userName,
|
||||||
|
Password: this.config.password,
|
||||||
|
Language: this.config.language,
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
// Extraer cookies de sesion de los headers
|
||||||
|
const sessionId = response.SessionId;
|
||||||
|
const sessionTimeout = response.SessionTimeout || this.config.sessionLifetime;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
this.session = {
|
||||||
|
sessionId,
|
||||||
|
b1Session: sessionId,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: new Date(now.getTime() + sessionTimeout * 60 * 1000),
|
||||||
|
companyDB: this.config.companyDB,
|
||||||
|
userName: this.config.userName,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configurar refresh automatico
|
||||||
|
this.setupSessionRefresh();
|
||||||
|
|
||||||
|
logger.info('SAP: Login exitoso', {
|
||||||
|
sessionId,
|
||||||
|
expiresAt: this.session.expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.session;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('SAP: Error en login', { error });
|
||||||
|
throw new SAPAuthError(
|
||||||
|
`Error de autenticacion SAP: ${error instanceof Error ? error.message : 'Error desconocido'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cierra la sesion actual
|
||||||
|
*/
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
if (!this.session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.httpRequest('POST', '/Logout', undefined, true);
|
||||||
|
logger.info('SAP: Logout exitoso');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('SAP: Error en logout (ignorado)', { error });
|
||||||
|
} finally {
|
||||||
|
this.clearSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si la sesion actual es valida
|
||||||
|
*/
|
||||||
|
isSessionValid(): boolean {
|
||||||
|
if (!this.session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const isNotExpired = this.session.expiresAt > now;
|
||||||
|
return this.session.isValid && isNotExpired;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la sesion actual, haciendo login si es necesario
|
||||||
|
*/
|
||||||
|
async ensureSession(): Promise<SAPSession> {
|
||||||
|
if (!this.isSessionValid()) {
|
||||||
|
return this.login();
|
||||||
|
}
|
||||||
|
return this.session!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configura el refresh automatico de sesion
|
||||||
|
*/
|
||||||
|
private setupSessionRefresh(): void {
|
||||||
|
this.clearSessionRefreshTimer();
|
||||||
|
|
||||||
|
if (!this.session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular tiempo hasta el refresh (5 minutos antes de expirar)
|
||||||
|
const refreshTime = this.session.expiresAt.getTime() - SESSION_REFRESH_THRESHOLD * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
const delay = Math.max(0, refreshTime - now);
|
||||||
|
|
||||||
|
this.sessionRefreshTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
logger.info('SAP: Refrescando sesion');
|
||||||
|
await this.login();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('SAP: Error al refrescar sesion', { error });
|
||||||
|
this.clearSession();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia el timer de refresh de sesion
|
||||||
|
*/
|
||||||
|
private clearSessionRefreshTimer(): void {
|
||||||
|
if (this.sessionRefreshTimer) {
|
||||||
|
clearTimeout(this.sessionRefreshTimer);
|
||||||
|
this.sessionRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia la sesion actual
|
||||||
|
*/
|
||||||
|
private clearSession(): void {
|
||||||
|
this.session = null;
|
||||||
|
this.clearSessionRefreshTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// HTTP Methods
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion GET
|
||||||
|
*/
|
||||||
|
async get<T>(endpoint: string, options?: ODataQueryOptions): Promise<T> {
|
||||||
|
const url = this.buildUrlWithQuery(endpoint, options);
|
||||||
|
return this.request<T>('GET', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion GET con paginacion OData
|
||||||
|
*/
|
||||||
|
async getAll<T>(endpoint: string, options?: ODataQueryOptions): Promise<T[]> {
|
||||||
|
const results: T[] = [];
|
||||||
|
let nextLink: string | undefined = this.buildUrlWithQuery(endpoint, options);
|
||||||
|
|
||||||
|
while (nextLink) {
|
||||||
|
const response = await this.request<ODataResponse<T>>('GET', nextLink);
|
||||||
|
results.push(...response.value);
|
||||||
|
nextLink = response['odata.nextLink'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion GET paginada
|
||||||
|
*/
|
||||||
|
async getPaginated<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options?: ODataQueryOptions
|
||||||
|
): Promise<ODataResponse<T>> {
|
||||||
|
const url = this.buildUrlWithQuery(endpoint, options);
|
||||||
|
return this.request<ODataResponse<T>>('GET', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion POST
|
||||||
|
*/
|
||||||
|
async post<T>(endpoint: string, body: unknown): Promise<T> {
|
||||||
|
return this.request<T>('POST', endpoint, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion PATCH (update parcial)
|
||||||
|
*/
|
||||||
|
async patch<T>(endpoint: string, body: unknown): Promise<T> {
|
||||||
|
return this.request<T>('PATCH', endpoint, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion PUT (update completo)
|
||||||
|
*/
|
||||||
|
async put<T>(endpoint: string, body: unknown): Promise<T> {
|
||||||
|
return this.request<T>('PUT', endpoint, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realiza una peticion DELETE
|
||||||
|
*/
|
||||||
|
async delete(endpoint: string): Promise<void> {
|
||||||
|
await this.request<void>('DELETE', endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta multiples operaciones en batch
|
||||||
|
*/
|
||||||
|
async batch(operations: BatchOperation[]): Promise<BatchResult[]> {
|
||||||
|
const boundary = `batch_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||||
|
const changesetBoundary = `changeset_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||||
|
|
||||||
|
// Construir el cuerpo del batch
|
||||||
|
const batchBody = this.buildBatchBody(operations, boundary, changesetBoundary);
|
||||||
|
|
||||||
|
await this.ensureSession();
|
||||||
|
|
||||||
|
const response = await fetch(`${this.config.serviceLayerUrl}/$batch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': `multipart/mixed; boundary=${boundary}`,
|
||||||
|
'Cookie': `B1SESSION=${this.session!.b1Session}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: batchBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new SAPError(
|
||||||
|
`Error en batch: ${response.statusText}`,
|
||||||
|
'BATCH_ERROR',
|
||||||
|
await response.text(),
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsear la respuesta del batch
|
||||||
|
const responseText = await response.text();
|
||||||
|
return this.parseBatchResponse(responseText, operations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// OData Query Building
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye una URL con parametros OData
|
||||||
|
*/
|
||||||
|
private buildUrlWithQuery(endpoint: string, options?: ODataQueryOptions): string {
|
||||||
|
if (!options) {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: string[] = [];
|
||||||
|
|
||||||
|
if (options.$select?.length) {
|
||||||
|
params.push(`$select=${options.$select.join(',')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.$filter) {
|
||||||
|
params.push(`$filter=${encodeURIComponent(options.$filter)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.$orderby) {
|
||||||
|
params.push(`$orderby=${encodeURIComponent(options.$orderby)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.$skip !== undefined) {
|
||||||
|
params.push(`$skip=${options.$skip}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.$top !== undefined) {
|
||||||
|
params.push(`$top=${options.$top}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.$expand?.length) {
|
||||||
|
params.push(`$expand=${options.$expand.join(',')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.$inlinecount) {
|
||||||
|
params.push(`$inlinecount=${options.$inlinecount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.$apply) {
|
||||||
|
params.push(`$apply=${encodeURIComponent(options.$apply)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.length === 0) {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = endpoint.includes('?') ? '&' : '?';
|
||||||
|
return `${endpoint}${separator}${params.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye un filtro de fecha OData
|
||||||
|
*/
|
||||||
|
buildDateFilter(field: string, from?: Date, to?: Date): string {
|
||||||
|
const filters: string[] = [];
|
||||||
|
|
||||||
|
if (from) {
|
||||||
|
filters.push(`${field} ge '${this.formatDate(from)}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
filters.push(`${field} le '${this.formatDate(to)}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters.join(' and ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combina multiples filtros OData
|
||||||
|
*/
|
||||||
|
combineFilters(...filters: (string | undefined)[]): string {
|
||||||
|
return filters
|
||||||
|
.filter((f): f is string => !!f && f.length > 0)
|
||||||
|
.join(' and ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea una fecha para OData
|
||||||
|
*/
|
||||||
|
formatDate(date: Date): string {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// HTTP Request Implementation
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una peticion HTTP con manejo de sesion y reintentos
|
||||||
|
*/
|
||||||
|
private async request<T>(
|
||||||
|
method: string,
|
||||||
|
endpoint: string,
|
||||||
|
body?: unknown,
|
||||||
|
retryCount = 0
|
||||||
|
): Promise<T> {
|
||||||
|
await this.ensureSession();
|
||||||
|
return this.httpRequest<T>(method, endpoint, body, true, retryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una peticion HTTP raw
|
||||||
|
*/
|
||||||
|
private async httpRequest<T>(
|
||||||
|
method: string,
|
||||||
|
endpoint: string,
|
||||||
|
body?: unknown,
|
||||||
|
withSession = true,
|
||||||
|
retryCount = 0
|
||||||
|
): Promise<T> {
|
||||||
|
const url = endpoint.startsWith('http')
|
||||||
|
? endpoint
|
||||||
|
: `${this.config.serviceLayerUrl}${endpoint}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (withSession && this.session) {
|
||||||
|
headers['Cookie'] = `B1SESSION=${this.session.b1Session}`;
|
||||||
|
if (this.session.routeId) {
|
||||||
|
headers['Cookie'] += `; ROUTEID=${this.session.routeId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// Manejar errores de sesion
|
||||||
|
if (response.status === 401) {
|
||||||
|
if (retryCount < this.config.maxRetries) {
|
||||||
|
logger.warn('SAP: Sesion expirada, reintentando login');
|
||||||
|
this.clearSession();
|
||||||
|
await this.login();
|
||||||
|
return this.httpRequest<T>(method, endpoint, body, withSession, retryCount + 1);
|
||||||
|
}
|
||||||
|
throw new SAPSessionExpiredError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar 404
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new SAPNotFoundError('Recurso', endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar otros errores
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text();
|
||||||
|
let errorMessage = `Error HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
let errorCode = String(response.status);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorJson = JSON.parse(errorBody) as SAPErrorResponse;
|
||||||
|
if (errorJson.error) {
|
||||||
|
errorMessage = errorJson.error.message.value;
|
||||||
|
errorCode = errorJson.error.code;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No es JSON, usar el texto raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reintentar en errores de conexion
|
||||||
|
if (response.status >= 500 && retryCount < this.config.maxRetries) {
|
||||||
|
logger.warn(`SAP: Error del servidor, reintentando (${retryCount + 1}/${this.config.maxRetries})`, {
|
||||||
|
status: response.status,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
await this.sleep(1000 * (retryCount + 1)); // Backoff exponencial
|
||||||
|
return this.httpRequest<T>(method, endpoint, body, withSession, retryCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SAPError(errorMessage, errorCode, errorBody, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar respuesta vacia
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
return undefined as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text || text.length === 0) {
|
||||||
|
return undefined as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (error instanceof SAPError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
throw new SAPConnectionError('Timeout de conexion', `URL: ${url}`);
|
||||||
|
}
|
||||||
|
throw new SAPConnectionError(error.message, `URL: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SAPConnectionError('Error de conexion desconocido', `URL: ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Batch Processing
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye el cuerpo de una peticion batch
|
||||||
|
*/
|
||||||
|
private buildBatchBody(
|
||||||
|
operations: BatchOperation[],
|
||||||
|
boundary: string,
|
||||||
|
changesetBoundary: string
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Separar operaciones de lectura y escritura
|
||||||
|
const readOps = operations.filter((op) => op.method === 'GET');
|
||||||
|
const writeOps = operations.filter((op) => op.method !== 'GET');
|
||||||
|
|
||||||
|
// Agregar operaciones de lectura
|
||||||
|
for (const op of readOps) {
|
||||||
|
parts.push(`--${boundary}`);
|
||||||
|
parts.push('Content-Type: application/http');
|
||||||
|
parts.push('Content-Transfer-Encoding: binary');
|
||||||
|
parts.push('');
|
||||||
|
parts.push(`${op.method} ${op.url} HTTP/1.1`);
|
||||||
|
parts.push('Accept: application/json');
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar changeset para operaciones de escritura
|
||||||
|
if (writeOps.length > 0) {
|
||||||
|
parts.push(`--${boundary}`);
|
||||||
|
parts.push(`Content-Type: multipart/mixed; boundary=${changesetBoundary}`);
|
||||||
|
parts.push('');
|
||||||
|
|
||||||
|
for (let i = 0; i < writeOps.length; i++) {
|
||||||
|
const op = writeOps[i];
|
||||||
|
parts.push(`--${changesetBoundary}`);
|
||||||
|
parts.push('Content-Type: application/http');
|
||||||
|
parts.push('Content-Transfer-Encoding: binary');
|
||||||
|
if (op.contentId) {
|
||||||
|
parts.push(`Content-ID: ${op.contentId}`);
|
||||||
|
}
|
||||||
|
parts.push('');
|
||||||
|
parts.push(`${op.method} ${op.url} HTTP/1.1`);
|
||||||
|
parts.push('Content-Type: application/json');
|
||||||
|
parts.push('Accept: application/json');
|
||||||
|
parts.push('');
|
||||||
|
if (op.body) {
|
||||||
|
parts.push(JSON.stringify(op.body));
|
||||||
|
}
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`--${changesetBoundary}--`);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`--${boundary}--`);
|
||||||
|
return parts.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea la respuesta de una peticion batch
|
||||||
|
*/
|
||||||
|
private parseBatchResponse(responseText: string, operations: BatchOperation[]): BatchResult[] {
|
||||||
|
const results: BatchResult[] = [];
|
||||||
|
|
||||||
|
// Extraer el boundary de la respuesta
|
||||||
|
const boundaryMatch = responseText.match(/--batchresponse_[a-z0-9-]+/);
|
||||||
|
if (!boundaryMatch) {
|
||||||
|
logger.warn('SAP: No se pudo parsear respuesta batch');
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = boundaryMatch[0];
|
||||||
|
const parts = responseText.split(boundary).filter((p) => p.trim() && !p.trim().startsWith('--'));
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length && i < operations.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const result: BatchResult = {
|
||||||
|
status: 200,
|
||||||
|
headers: {},
|
||||||
|
body: null,
|
||||||
|
contentId: operations[i].contentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extraer status
|
||||||
|
const statusMatch = part.match(/HTTP\/1\.1 (\d+)/);
|
||||||
|
if (statusMatch) {
|
||||||
|
result.status = parseInt(statusMatch[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraer body JSON
|
||||||
|
const jsonMatch = part.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
try {
|
||||||
|
result.body = JSON.parse(jsonMatch[0]);
|
||||||
|
} catch {
|
||||||
|
result.body = jsonMatch[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Utility Methods
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Espera un tiempo determinado
|
||||||
|
*/
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la configuracion actual
|
||||||
|
*/
|
||||||
|
getConfig(): Readonly<Required<SAPConfig>> {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la sesion actual
|
||||||
|
*/
|
||||||
|
getSession(): Readonly<SAPSession> | null {
|
||||||
|
return this.session ? { ...this.session } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del cliente SAP B1
|
||||||
|
*/
|
||||||
|
export function createSAPClient(config: SAPConfig): SAPClient {
|
||||||
|
return new SAPClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un cliente SAP y realiza login automatico
|
||||||
|
*/
|
||||||
|
export async function createConnectedSAPClient(config: SAPConfig): Promise<SAPClient> {
|
||||||
|
const client = new SAPClient(config);
|
||||||
|
await client.login();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SAPClient;
|
||||||
869
apps/api/src/services/integrations/sap/sap.sync.ts
Normal file
869
apps/api/src/services/integrations/sap/sap.sync.ts
Normal file
@@ -0,0 +1,869 @@
|
|||||||
|
/**
|
||||||
|
* SAP Business One Sync Service
|
||||||
|
* Servicio de sincronizacion de datos SAP B1 a Horux Strategy
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SAPClient, createConnectedSAPClient } from './sap.client.js';
|
||||||
|
import { SalesConnector } from './sales.connector.js';
|
||||||
|
import { PurchasingConnector } from './purchasing.connector.js';
|
||||||
|
import { FinancialsConnector } from './financials.connector.js';
|
||||||
|
import { InventoryConnector } from './inventory.connector.js';
|
||||||
|
import { BankingConnector } from './banking.connector.js';
|
||||||
|
import {
|
||||||
|
SAPConfig,
|
||||||
|
SAPSyncResult,
|
||||||
|
SAPSyncError,
|
||||||
|
SAPTransactionMapping,
|
||||||
|
Invoice,
|
||||||
|
CreditNote,
|
||||||
|
PurchaseInvoice,
|
||||||
|
IncomingPayment,
|
||||||
|
OutgoingPayment,
|
||||||
|
JournalEntry,
|
||||||
|
BusinessPartner,
|
||||||
|
Item,
|
||||||
|
Period,
|
||||||
|
SAPError,
|
||||||
|
} from './sap.types.js';
|
||||||
|
import { logger, auditLog } from '../../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones de sincronizacion
|
||||||
|
*/
|
||||||
|
export interface SyncOptions {
|
||||||
|
/** ID del tenant en Horux */
|
||||||
|
tenantId: string;
|
||||||
|
/** Configuracion de conexion SAP */
|
||||||
|
config: SAPConfig;
|
||||||
|
/** Periodo a sincronizar */
|
||||||
|
period: Period;
|
||||||
|
/** Entidades a sincronizar */
|
||||||
|
entities?: SyncEntityType[];
|
||||||
|
/** Modo de sincronizacion */
|
||||||
|
mode?: 'full' | 'incremental';
|
||||||
|
/** Callback de progreso */
|
||||||
|
onProgress?: (progress: SyncProgress) => void;
|
||||||
|
/** Generar alertas automaticas */
|
||||||
|
generateAlerts?: boolean;
|
||||||
|
/** Usar campos definidos por usuario */
|
||||||
|
useUDFs?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de entidad a sincronizar
|
||||||
|
*/
|
||||||
|
export type SyncEntityType =
|
||||||
|
| 'invoices'
|
||||||
|
| 'creditNotes'
|
||||||
|
| 'purchaseInvoices'
|
||||||
|
| 'incomingPayments'
|
||||||
|
| 'outgoingPayments'
|
||||||
|
| 'journalEntries'
|
||||||
|
| 'customers'
|
||||||
|
| 'vendors'
|
||||||
|
| 'items';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progreso de sincronizacion
|
||||||
|
*/
|
||||||
|
export interface SyncProgress {
|
||||||
|
entity: SyncEntityType;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
percentage: number;
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'error';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaccion mapeada para Horux
|
||||||
|
*/
|
||||||
|
export interface MappedTransaction {
|
||||||
|
externalId: string;
|
||||||
|
externalSource: 'sap_b1';
|
||||||
|
type: 'income' | 'expense' | 'transfer';
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
exchangeRate?: number;
|
||||||
|
description: string;
|
||||||
|
date: Date;
|
||||||
|
reference?: string;
|
||||||
|
contactExternalId?: string;
|
||||||
|
cfdiUuid?: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alerta generada desde SAP
|
||||||
|
*/
|
||||||
|
export interface SAPAlert {
|
||||||
|
type: 'payment_due' | 'low_stock' | 'credit_exceeded' | 'aging' | 'sync_error';
|
||||||
|
severity: 'info' | 'warning' | 'critical';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SAP Sync Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio de sincronizacion SAP B1 a Horux
|
||||||
|
*/
|
||||||
|
export class SAPSyncService {
|
||||||
|
private client: SAPClient | null = null;
|
||||||
|
private sales: SalesConnector | null = null;
|
||||||
|
private purchasing: PurchasingConnector | null = null;
|
||||||
|
private financials: FinancialsConnector | null = null;
|
||||||
|
private inventory: InventoryConnector | null = null;
|
||||||
|
private banking: BankingConnector | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicializa la conexion y conectores
|
||||||
|
*/
|
||||||
|
private async initialize(config: SAPConfig): Promise<void> {
|
||||||
|
this.client = await createConnectedSAPClient(config);
|
||||||
|
this.sales = new SalesConnector(this.client);
|
||||||
|
this.purchasing = new PurchasingConnector(this.client);
|
||||||
|
this.financials = new FinancialsConnector(this.client);
|
||||||
|
this.inventory = new InventoryConnector(this.client);
|
||||||
|
this.banking = new BankingConnector(this.client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cierra la conexion
|
||||||
|
*/
|
||||||
|
private async cleanup(): Promise<void> {
|
||||||
|
if (this.client) {
|
||||||
|
await this.client.logout();
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
this.sales = null;
|
||||||
|
this.purchasing = null;
|
||||||
|
this.financials = null;
|
||||||
|
this.inventory = null;
|
||||||
|
this.banking = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta la sincronizacion completa
|
||||||
|
*/
|
||||||
|
async syncToHorux(options: SyncOptions): Promise<SAPSyncResult> {
|
||||||
|
const syncStartedAt = new Date();
|
||||||
|
const result: SAPSyncResult = {
|
||||||
|
success: false,
|
||||||
|
tenantId: options.tenantId,
|
||||||
|
source: 'sap_b1',
|
||||||
|
syncStartedAt,
|
||||||
|
recordsProcessed: 0,
|
||||||
|
recordsSaved: 0,
|
||||||
|
recordsFailed: 0,
|
||||||
|
errors: [],
|
||||||
|
summary: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('SAP Sync: Iniciando sincronizacion', {
|
||||||
|
tenantId: options.tenantId,
|
||||||
|
period: options.period,
|
||||||
|
entities: options.entities,
|
||||||
|
mode: options.mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
auditLog(
|
||||||
|
'SAP_SYNC_STARTED',
|
||||||
|
null,
|
||||||
|
options.tenantId,
|
||||||
|
{ period: options.period, entities: options.entities },
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Inicializar conexion
|
||||||
|
await this.initialize(options.config);
|
||||||
|
|
||||||
|
// Determinar entidades a sincronizar
|
||||||
|
const entities = options.entities || [
|
||||||
|
'invoices',
|
||||||
|
'creditNotes',
|
||||||
|
'purchaseInvoices',
|
||||||
|
'incomingPayments',
|
||||||
|
'outgoingPayments',
|
||||||
|
'journalEntries',
|
||||||
|
'customers',
|
||||||
|
'vendors',
|
||||||
|
'items',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sincronizar cada entidad
|
||||||
|
for (const entity of entities) {
|
||||||
|
try {
|
||||||
|
options.onProgress?.({
|
||||||
|
entity,
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
percentage: 0,
|
||||||
|
status: 'processing',
|
||||||
|
});
|
||||||
|
|
||||||
|
const entityResult = await this.syncEntity(entity, options);
|
||||||
|
|
||||||
|
result.recordsProcessed += entityResult.processed;
|
||||||
|
result.recordsSaved += entityResult.saved;
|
||||||
|
result.recordsFailed += entityResult.failed;
|
||||||
|
result.errors.push(...entityResult.errors);
|
||||||
|
result.summary[entity] = entityResult.saved;
|
||||||
|
|
||||||
|
options.onProgress?.({
|
||||||
|
entity,
|
||||||
|
current: entityResult.processed,
|
||||||
|
total: entityResult.processed,
|
||||||
|
percentage: 100,
|
||||||
|
status: entityResult.failed > 0 ? 'error' : 'completed',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
||||||
|
result.errors.push({
|
||||||
|
entity,
|
||||||
|
error: errorMessage,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
options.onProgress?.({
|
||||||
|
entity,
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
percentage: 0,
|
||||||
|
status: 'error',
|
||||||
|
message: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar alertas si se solicita
|
||||||
|
if (options.generateAlerts) {
|
||||||
|
try {
|
||||||
|
const alerts = await this.generateAlerts(options);
|
||||||
|
logger.info('SAP Sync: Alertas generadas', { count: alerts.length });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('SAP Sync: Error generando alertas', { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success = result.recordsFailed === 0;
|
||||||
|
result.syncCompletedAt = new Date();
|
||||||
|
|
||||||
|
logger.info('SAP Sync: Sincronizacion completada', {
|
||||||
|
success: result.success,
|
||||||
|
processed: result.recordsProcessed,
|
||||||
|
saved: result.recordsSaved,
|
||||||
|
failed: result.recordsFailed,
|
||||||
|
});
|
||||||
|
|
||||||
|
auditLog(
|
||||||
|
'SAP_SYNC_COMPLETED',
|
||||||
|
null,
|
||||||
|
options.tenantId,
|
||||||
|
{
|
||||||
|
success: result.success,
|
||||||
|
recordsProcessed: result.recordsProcessed,
|
||||||
|
recordsSaved: result.recordsSaved,
|
||||||
|
duration: result.syncCompletedAt.getTime() - syncStartedAt.getTime(),
|
||||||
|
},
|
||||||
|
result.success
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
||||||
|
result.errors.push({
|
||||||
|
entity: 'connection',
|
||||||
|
error: errorMessage,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.error('SAP Sync: Error en sincronizacion', { error });
|
||||||
|
|
||||||
|
auditLog(
|
||||||
|
'SAP_SYNC_FAILED',
|
||||||
|
null,
|
||||||
|
options.tenantId,
|
||||||
|
{ error: errorMessage },
|
||||||
|
false
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await this.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza una entidad especifica
|
||||||
|
*/
|
||||||
|
private async syncEntity(
|
||||||
|
entity: SyncEntityType,
|
||||||
|
options: SyncOptions
|
||||||
|
): Promise<{
|
||||||
|
processed: number;
|
||||||
|
saved: number;
|
||||||
|
failed: number;
|
||||||
|
errors: SAPSyncError[];
|
||||||
|
}> {
|
||||||
|
const errors: SAPSyncError[] = [];
|
||||||
|
let processed = 0;
|
||||||
|
let saved = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
logger.info(`SAP Sync: Sincronizando ${entity}`, { period: options.period });
|
||||||
|
|
||||||
|
switch (entity) {
|
||||||
|
case 'invoices': {
|
||||||
|
const invoices = await this.sales!.getInvoices(options.period);
|
||||||
|
processed = invoices.length;
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
try {
|
||||||
|
const mapped = this.mapInvoiceToTransaction(invoice, options);
|
||||||
|
// TODO: Guardar en base de datos Horux
|
||||||
|
// await this.saveTransaction(options.tenantId, mapped);
|
||||||
|
saved++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
errors.push({
|
||||||
|
entity: 'invoice',
|
||||||
|
identifier: String(invoice.DocNum),
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'creditNotes': {
|
||||||
|
const creditNotes = await this.sales!.getCreditNotes(options.period);
|
||||||
|
processed = creditNotes.length;
|
||||||
|
|
||||||
|
for (const cn of creditNotes) {
|
||||||
|
try {
|
||||||
|
const mapped = this.mapCreditNoteToTransaction(cn, options);
|
||||||
|
saved++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
errors.push({
|
||||||
|
entity: 'creditNote',
|
||||||
|
identifier: String(cn.DocNum),
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'purchaseInvoices': {
|
||||||
|
const purchaseInvoices = await this.purchasing!.getPurchaseInvoices(options.period);
|
||||||
|
processed = purchaseInvoices.length;
|
||||||
|
|
||||||
|
for (const pi of purchaseInvoices) {
|
||||||
|
try {
|
||||||
|
const mapped = this.mapPurchaseInvoiceToTransaction(pi, options);
|
||||||
|
saved++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
errors.push({
|
||||||
|
entity: 'purchaseInvoice',
|
||||||
|
identifier: String(pi.DocNum),
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'incomingPayments': {
|
||||||
|
const payments = await this.banking!.getIncomingPayments(options.period);
|
||||||
|
processed = payments.length;
|
||||||
|
|
||||||
|
for (const payment of payments) {
|
||||||
|
try {
|
||||||
|
const mapped = this.mapIncomingPaymentToTransaction(payment, options);
|
||||||
|
saved++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
errors.push({
|
||||||
|
entity: 'incomingPayment',
|
||||||
|
identifier: String(payment.DocNum),
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'outgoingPayments': {
|
||||||
|
const payments = await this.banking!.getOutgoingPayments(options.period);
|
||||||
|
processed = payments.length;
|
||||||
|
|
||||||
|
for (const payment of payments) {
|
||||||
|
try {
|
||||||
|
const mapped = this.mapOutgoingPaymentToTransaction(payment, options);
|
||||||
|
saved++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
errors.push({
|
||||||
|
entity: 'outgoingPayment',
|
||||||
|
identifier: String(payment.DocNum),
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'journalEntries': {
|
||||||
|
const entries = await this.financials!.getJournalEntries(options.period);
|
||||||
|
processed = entries.length;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
try {
|
||||||
|
// Los asientos se usan para referencia, no se mapean directamente
|
||||||
|
saved++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
errors.push({
|
||||||
|
entity: 'journalEntry',
|
||||||
|
identifier: String(entry.JdtNum),
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'customers': {
|
||||||
|
const customers = await this.sales!.getCustomers({ activeOnly: true });
|
||||||
|
processed = customers.length;
|
||||||
|
|
||||||
|
for (const customer of customers) {
|
||||||
|
try {
|
||||||
|
const mapped = this.mapBusinessPartnerToContact(customer, 'customer');
|
||||||
|
saved++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
errors.push({
|
||||||
|
entity: 'customer',
|
||||||
|
identifier: customer.CardCode,
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'vendors': {
|
||||||
|
const vendors = await this.purchasing!.getVendors({ activeOnly: true });
|
||||||
|
processed = vendors.length;
|
||||||
|
|
||||||
|
for (const vendor of vendors) {
|
||||||
|
try {
|
||||||
|
const mapped = this.mapBusinessPartnerToContact(vendor, 'vendor');
|
||||||
|
saved++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
errors.push({
|
||||||
|
entity: 'vendor',
|
||||||
|
identifier: vendor.CardCode,
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'items': {
|
||||||
|
const items = await this.inventory!.getItems({ activeOnly: true });
|
||||||
|
processed = items.length;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
// Los items se sincronizan para referencia
|
||||||
|
saved++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
errors.push({
|
||||||
|
entity: 'item',
|
||||||
|
identifier: item.ItemCode,
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processed, saved, failed, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Mapping Functions
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea una factura de venta SAP a transaccion Horux
|
||||||
|
*/
|
||||||
|
mapInvoiceToTransaction(invoice: Invoice, options: SyncOptions): MappedTransaction {
|
||||||
|
return {
|
||||||
|
externalId: `SAP_INV_${invoice.DocEntry}`,
|
||||||
|
externalSource: 'sap_b1',
|
||||||
|
type: 'income',
|
||||||
|
amount: invoice.DocTotal,
|
||||||
|
currency: invoice.DocCurrency || 'MXN',
|
||||||
|
exchangeRate: invoice.DocRate,
|
||||||
|
description: `Factura ${invoice.DocNum} - ${invoice.CardName || invoice.CardCode}`,
|
||||||
|
date: new Date(invoice.DocDate),
|
||||||
|
reference: invoice.NumAtCard,
|
||||||
|
contactExternalId: invoice.CardCode,
|
||||||
|
cfdiUuid: options.useUDFs ? invoice.U_FolioFiscalUUID : undefined,
|
||||||
|
metadata: {
|
||||||
|
sapDocEntry: invoice.DocEntry,
|
||||||
|
sapDocNum: invoice.DocNum,
|
||||||
|
sapDocType: 'Invoice',
|
||||||
|
sapSeries: invoice.Series,
|
||||||
|
sapStatus: invoice.DocumentStatus,
|
||||||
|
vatSum: invoice.VatSum,
|
||||||
|
discSum: invoice.DiscSum,
|
||||||
|
paidToDate: invoice.PaidToDate,
|
||||||
|
...(options.useUDFs && {
|
||||||
|
serieCFDI: invoice.U_SerieCFDI,
|
||||||
|
folioCFDI: invoice.U_FolioCFDI,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea una nota de credito SAP a transaccion Horux
|
||||||
|
*/
|
||||||
|
mapCreditNoteToTransaction(creditNote: CreditNote, options: SyncOptions): MappedTransaction {
|
||||||
|
return {
|
||||||
|
externalId: `SAP_CN_${creditNote.DocEntry}`,
|
||||||
|
externalSource: 'sap_b1',
|
||||||
|
type: 'expense', // Las notas de credito reducen ingresos
|
||||||
|
amount: creditNote.DocTotal,
|
||||||
|
currency: creditNote.DocCurrency || 'MXN',
|
||||||
|
exchangeRate: creditNote.DocRate,
|
||||||
|
description: `Nota de Credito ${creditNote.DocNum} - ${creditNote.CardName || creditNote.CardCode}`,
|
||||||
|
date: new Date(creditNote.DocDate),
|
||||||
|
reference: creditNote.NumAtCard,
|
||||||
|
contactExternalId: creditNote.CardCode,
|
||||||
|
cfdiUuid: options.useUDFs ? creditNote.U_FolioFiscalUUID : undefined,
|
||||||
|
metadata: {
|
||||||
|
sapDocEntry: creditNote.DocEntry,
|
||||||
|
sapDocNum: creditNote.DocNum,
|
||||||
|
sapDocType: 'CreditNote',
|
||||||
|
sapSeries: creditNote.Series,
|
||||||
|
sapStatus: creditNote.DocumentStatus,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea una factura de compra SAP a transaccion Horux
|
||||||
|
*/
|
||||||
|
mapPurchaseInvoiceToTransaction(
|
||||||
|
purchaseInvoice: PurchaseInvoice,
|
||||||
|
options: SyncOptions
|
||||||
|
): MappedTransaction {
|
||||||
|
return {
|
||||||
|
externalId: `SAP_PI_${purchaseInvoice.DocEntry}`,
|
||||||
|
externalSource: 'sap_b1',
|
||||||
|
type: 'expense',
|
||||||
|
amount: purchaseInvoice.DocTotal,
|
||||||
|
currency: purchaseInvoice.DocCurrency || 'MXN',
|
||||||
|
exchangeRate: purchaseInvoice.DocRate,
|
||||||
|
description: `Factura Compra ${purchaseInvoice.DocNum} - ${purchaseInvoice.CardName || purchaseInvoice.CardCode}`,
|
||||||
|
date: new Date(purchaseInvoice.DocDate),
|
||||||
|
reference: purchaseInvoice.NumAtCard,
|
||||||
|
contactExternalId: purchaseInvoice.CardCode,
|
||||||
|
cfdiUuid: options.useUDFs ? purchaseInvoice.U_FolioFiscalUUID : undefined,
|
||||||
|
metadata: {
|
||||||
|
sapDocEntry: purchaseInvoice.DocEntry,
|
||||||
|
sapDocNum: purchaseInvoice.DocNum,
|
||||||
|
sapDocType: 'PurchaseInvoice',
|
||||||
|
sapSeries: purchaseInvoice.Series,
|
||||||
|
sapStatus: purchaseInvoice.DocumentStatus,
|
||||||
|
vatSum: purchaseInvoice.VatSum,
|
||||||
|
paidToDate: purchaseInvoice.PaidToDate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea un pago recibido SAP a transaccion Horux
|
||||||
|
*/
|
||||||
|
mapIncomingPaymentToTransaction(
|
||||||
|
payment: IncomingPayment,
|
||||||
|
options: SyncOptions
|
||||||
|
): MappedTransaction {
|
||||||
|
const totalAmount = this.calculatePaymentTotal(payment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId: `SAP_IP_${payment.DocEntry}`,
|
||||||
|
externalSource: 'sap_b1',
|
||||||
|
type: 'income',
|
||||||
|
amount: totalAmount,
|
||||||
|
currency: payment.DocCurrency || 'MXN',
|
||||||
|
exchangeRate: payment.DocRate,
|
||||||
|
description: `Pago Recibido ${payment.DocNum} - ${payment.CardName || payment.CardCode || 'Cuenta'}`,
|
||||||
|
date: new Date(payment.DocDate),
|
||||||
|
reference: payment.TransferReference,
|
||||||
|
contactExternalId: payment.CardCode,
|
||||||
|
metadata: {
|
||||||
|
sapDocEntry: payment.DocEntry,
|
||||||
|
sapDocNum: payment.DocNum,
|
||||||
|
sapDocType: 'IncomingPayment',
|
||||||
|
sapSeries: payment.Series,
|
||||||
|
cashSum: payment.CashSum,
|
||||||
|
transferSum: payment.TransferSum,
|
||||||
|
checkCount: payment.PaymentChecks?.length || 0,
|
||||||
|
creditCardCount: payment.PaymentCreditCards?.length || 0,
|
||||||
|
invoicesCount: payment.PaymentInvoices?.length || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea un pago emitido SAP a transaccion Horux
|
||||||
|
*/
|
||||||
|
mapOutgoingPaymentToTransaction(
|
||||||
|
payment: OutgoingPayment,
|
||||||
|
options: SyncOptions
|
||||||
|
): MappedTransaction {
|
||||||
|
const totalAmount = this.calculatePaymentTotal(payment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId: `SAP_OP_${payment.DocEntry}`,
|
||||||
|
externalSource: 'sap_b1',
|
||||||
|
type: 'expense',
|
||||||
|
amount: totalAmount,
|
||||||
|
currency: payment.DocCurrency || 'MXN',
|
||||||
|
exchangeRate: payment.DocRate,
|
||||||
|
description: `Pago Emitido ${payment.DocNum} - ${payment.CardName || payment.CardCode || 'Cuenta'}`,
|
||||||
|
date: new Date(payment.DocDate),
|
||||||
|
reference: payment.TransferReference,
|
||||||
|
contactExternalId: payment.CardCode,
|
||||||
|
metadata: {
|
||||||
|
sapDocEntry: payment.DocEntry,
|
||||||
|
sapDocNum: payment.DocNum,
|
||||||
|
sapDocType: 'OutgoingPayment',
|
||||||
|
sapSeries: payment.Series,
|
||||||
|
cashSum: payment.CashSum,
|
||||||
|
transferSum: payment.TransferSum,
|
||||||
|
checkCount: payment.PaymentChecks?.length || 0,
|
||||||
|
invoicesCount: payment.PaymentInvoices?.length || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea un socio de negocios SAP a contacto Horux
|
||||||
|
*/
|
||||||
|
mapBusinessPartnerToContact(
|
||||||
|
bp: BusinessPartner,
|
||||||
|
type: 'customer' | 'vendor'
|
||||||
|
): {
|
||||||
|
externalId: string;
|
||||||
|
externalSource: 'sap_b1';
|
||||||
|
type: 'customer' | 'supplier';
|
||||||
|
name: string;
|
||||||
|
rfc?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
address?: string;
|
||||||
|
creditLimit?: number;
|
||||||
|
balance?: number;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
externalId: `SAP_BP_${bp.CardCode}`,
|
||||||
|
externalSource: 'sap_b1',
|
||||||
|
type: type === 'customer' ? 'customer' : 'supplier',
|
||||||
|
name: bp.CardName,
|
||||||
|
rfc: bp.FederalTaxID,
|
||||||
|
email: bp.EmailAddress,
|
||||||
|
phone: bp.Phone1 || bp.Cellular,
|
||||||
|
address: bp.Address,
|
||||||
|
creditLimit: bp.CreditLimit,
|
||||||
|
balance: bp.Balance,
|
||||||
|
metadata: {
|
||||||
|
sapCardCode: bp.CardCode,
|
||||||
|
sapCardType: bp.CardType,
|
||||||
|
sapGroupCode: bp.GroupCode,
|
||||||
|
sapPayTerms: bp.PayTermsGrpCode,
|
||||||
|
sapSalesPerson: bp.SalesPersonCode,
|
||||||
|
sapCurrency: bp.Currency,
|
||||||
|
sapPriceList: bp.PriceListNum,
|
||||||
|
frozen: bp.Frozen === 'tYES',
|
||||||
|
valid: bp.Valid === 'tYES',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el total de un pago
|
||||||
|
*/
|
||||||
|
private calculatePaymentTotal(payment: IncomingPayment | OutgoingPayment): number {
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
total += payment.CashSum || 0;
|
||||||
|
total += payment.TransferSum || 0;
|
||||||
|
|
||||||
|
if (payment.PaymentChecks) {
|
||||||
|
total += payment.PaymentChecks.reduce((sum, c) => sum + (c.CheckSum || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.PaymentCreditCards) {
|
||||||
|
total += payment.PaymentCreditCards.reduce((sum, c) => sum + (c.CreditSum || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Alerts Generation
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera alertas basadas en datos de SAP
|
||||||
|
*/
|
||||||
|
async generateAlerts(options: SyncOptions): Promise<SAPAlert[]> {
|
||||||
|
const alerts: SAPAlert[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Alertas de facturas por vencer
|
||||||
|
const openInvoices = await this.sales!.getOpenInvoices();
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
for (const invoice of openInvoices) {
|
||||||
|
const dueDate = new Date(invoice.DocDueDate);
|
||||||
|
const daysUntilDue = Math.floor(
|
||||||
|
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (daysUntilDue <= 0) {
|
||||||
|
alerts.push({
|
||||||
|
type: 'payment_due',
|
||||||
|
severity: 'critical',
|
||||||
|
title: 'Factura Vencida',
|
||||||
|
message: `Factura ${invoice.DocNum} de ${invoice.CardName} vencida hace ${Math.abs(daysUntilDue)} dias. Saldo: $${(invoice.DocTotal - (invoice.PaidToDate || 0)).toFixed(2)}`,
|
||||||
|
entityType: 'invoice',
|
||||||
|
entityId: String(invoice.DocEntry),
|
||||||
|
metadata: {
|
||||||
|
cardCode: invoice.CardCode,
|
||||||
|
docNum: invoice.DocNum,
|
||||||
|
dueDate: invoice.DocDueDate,
|
||||||
|
balance: invoice.DocTotal - (invoice.PaidToDate || 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (daysUntilDue <= 7) {
|
||||||
|
alerts.push({
|
||||||
|
type: 'payment_due',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Factura Proxima a Vencer',
|
||||||
|
message: `Factura ${invoice.DocNum} de ${invoice.CardName} vence en ${daysUntilDue} dias. Saldo: $${(invoice.DocTotal - (invoice.PaidToDate || 0)).toFixed(2)}`,
|
||||||
|
entityType: 'invoice',
|
||||||
|
entityId: String(invoice.DocEntry),
|
||||||
|
metadata: {
|
||||||
|
cardCode: invoice.CardCode,
|
||||||
|
docNum: invoice.DocNum,
|
||||||
|
dueDate: invoice.DocDueDate,
|
||||||
|
balance: invoice.DocTotal - (invoice.PaidToDate || 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alertas de stock bajo
|
||||||
|
const lowStockItems = await this.inventory!.getLowStockItems();
|
||||||
|
for (const item of lowStockItems) {
|
||||||
|
alerts.push({
|
||||||
|
type: 'low_stock',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Stock Bajo',
|
||||||
|
message: `El articulo ${item.ItemCode} - ${item.ItemName} tiene stock bajo (${item.QuantityOnStock} unidades)`,
|
||||||
|
entityType: 'item',
|
||||||
|
entityId: item.ItemCode,
|
||||||
|
metadata: {
|
||||||
|
itemCode: item.ItemCode,
|
||||||
|
itemName: item.ItemName,
|
||||||
|
currentStock: item.QuantityOnStock,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alertas de clientes con credito excedido
|
||||||
|
const customers = await this.sales!.getCustomers({ withBalance: true });
|
||||||
|
for (const customer of customers) {
|
||||||
|
if (
|
||||||
|
customer.CreditLimit &&
|
||||||
|
customer.CreditLimit > 0 &&
|
||||||
|
(customer.Balance || 0) > customer.CreditLimit
|
||||||
|
) {
|
||||||
|
alerts.push({
|
||||||
|
type: 'credit_exceeded',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Limite de Credito Excedido',
|
||||||
|
message: `${customer.CardName} ha excedido su limite de credito. Balance: $${customer.Balance?.toFixed(2)}, Limite: $${customer.CreditLimit.toFixed(2)}`,
|
||||||
|
entityType: 'customer',
|
||||||
|
entityId: customer.CardCode,
|
||||||
|
metadata: {
|
||||||
|
cardCode: customer.CardCode,
|
||||||
|
cardName: customer.CardName,
|
||||||
|
balance: customer.Balance,
|
||||||
|
creditLimit: customer.CreditLimit,
|
||||||
|
exceeded: (customer.Balance || 0) - customer.CreditLimit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('SAP Sync: Error generando alertas', { error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del servicio de sincronizacion
|
||||||
|
*/
|
||||||
|
export function createSAPSyncService(): SAPSyncService {
|
||||||
|
return new SAPSyncService();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta sincronizacion SAP a Horux
|
||||||
|
*/
|
||||||
|
export async function syncSAPToHorux(options: SyncOptions): Promise<SAPSyncResult> {
|
||||||
|
const service = new SAPSyncService();
|
||||||
|
return service.syncToHorux(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SAPSyncService;
|
||||||
1150
apps/api/src/services/integrations/sap/sap.types.ts
Normal file
1150
apps/api/src/services/integrations/sap/sap.types.ts
Normal file
File diff suppressed because it is too large
Load Diff
1025
apps/api/src/services/integrations/sync.scheduler.ts
Normal file
1025
apps/api/src/services/integrations/sync.scheduler.ts
Normal file
File diff suppressed because it is too large
Load Diff
30
apps/api/src/services/reports/index.ts
Normal file
30
apps/api/src/services/reports/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Reports Service
|
||||||
|
*
|
||||||
|
* Executive report generation system for Horux Strategy.
|
||||||
|
* Combines financial metrics with AI-generated narratives to produce
|
||||||
|
* CFO-level reports in PDF format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './report.types';
|
||||||
|
|
||||||
|
// Templates and formatting
|
||||||
|
export * from './report.templates';
|
||||||
|
|
||||||
|
// AI Prompts
|
||||||
|
export * from './report.prompts';
|
||||||
|
|
||||||
|
// PDF Generator
|
||||||
|
export {
|
||||||
|
PDFGenerator,
|
||||||
|
getPDFGenerator,
|
||||||
|
createPDFGenerator,
|
||||||
|
} from './pdf.generator';
|
||||||
|
|
||||||
|
// Main Report Generator
|
||||||
|
export {
|
||||||
|
ReportGenerator,
|
||||||
|
getReportGenerator,
|
||||||
|
createReportGenerator,
|
||||||
|
} from './report.generator';
|
||||||
879
apps/api/src/services/reports/pdf.generator.ts
Normal file
879
apps/api/src/services/reports/pdf.generator.ts
Normal file
@@ -0,0 +1,879 @@
|
|||||||
|
/**
|
||||||
|
* PDF Generator
|
||||||
|
*
|
||||||
|
* Generates professional PDF reports using PDFKit.
|
||||||
|
* Includes corporate branding, tables, charts (as placeholders), and executive narratives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
import { Client as MinioClient } from 'minio';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import {
|
||||||
|
GeneratedReport,
|
||||||
|
ReportSection,
|
||||||
|
PDFGenerationOptions,
|
||||||
|
PDFGenerationResult,
|
||||||
|
DEFAULT_PDF_OPTIONS,
|
||||||
|
ChartData,
|
||||||
|
TableData,
|
||||||
|
KPISummary,
|
||||||
|
MONTH_NAMES_ES,
|
||||||
|
} from './report.types';
|
||||||
|
import {
|
||||||
|
formatCurrencyMX,
|
||||||
|
formatPercentageMX,
|
||||||
|
formatNumberMX,
|
||||||
|
formatPeriod,
|
||||||
|
formatPeriodRange,
|
||||||
|
generateChartPlaceholder,
|
||||||
|
} from './report.templates';
|
||||||
|
import { config } from '../../config';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PDF Generator Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class PDFGenerator {
|
||||||
|
private options: PDFGenerationOptions;
|
||||||
|
private minioClient: MinioClient | null;
|
||||||
|
private bucketName: string;
|
||||||
|
|
||||||
|
constructor(options?: Partial<PDFGenerationOptions>) {
|
||||||
|
this.options = { ...DEFAULT_PDF_OPTIONS, ...options };
|
||||||
|
this.bucketName = config.minio.bucket;
|
||||||
|
|
||||||
|
// Initialize MinIO client
|
||||||
|
try {
|
||||||
|
this.minioClient = new MinioClient({
|
||||||
|
endPoint: config.minio.endpoint,
|
||||||
|
port: config.minio.port,
|
||||||
|
useSSL: config.minio.useSSL,
|
||||||
|
accessKey: config.minio.accessKey,
|
||||||
|
secretKey: config.minio.secretKey,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('MinIO client initialization failed, PDF storage will be disabled:', error);
|
||||||
|
this.minioClient = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate PDF from report
|
||||||
|
*/
|
||||||
|
async generatePDF(report: GeneratedReport): Promise<PDFGenerationResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Create PDF document
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: this.options.pageSize,
|
||||||
|
layout: this.options.orientation,
|
||||||
|
margins: this.options.margins,
|
||||||
|
bufferPages: true,
|
||||||
|
info: {
|
||||||
|
Title: report.title,
|
||||||
|
Author: 'Horux Strategy - CFO Digital',
|
||||||
|
Subject: `Reporte Financiero ${formatPeriod(report.period)}`,
|
||||||
|
Creator: 'Horux Strategy',
|
||||||
|
Producer: 'PDFKit',
|
||||||
|
CreationDate: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect chunks
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
doc.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
|
||||||
|
// Generate content
|
||||||
|
await this.generateContent(doc, report);
|
||||||
|
|
||||||
|
// Add page numbers
|
||||||
|
this.addPageNumbers(doc);
|
||||||
|
|
||||||
|
// Finalize document
|
||||||
|
doc.end();
|
||||||
|
|
||||||
|
// Wait for PDF generation to complete
|
||||||
|
const buffer = await new Promise<Buffer>((resolve) => {
|
||||||
|
doc.on('end', () => {
|
||||||
|
resolve(Buffer.concat(chunks));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageCount = doc.bufferedPageRange().count;
|
||||||
|
const generationTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
fileName: this.generateFileName(report),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
pageCount,
|
||||||
|
fileSize: buffer.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate PDF and upload to MinIO
|
||||||
|
*/
|
||||||
|
async generateAndStore(report: GeneratedReport): Promise<{
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
pageCount: number;
|
||||||
|
}> {
|
||||||
|
const result = await this.generatePDF(report);
|
||||||
|
|
||||||
|
if (!this.minioClient) {
|
||||||
|
throw new Error('MinIO client not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure bucket exists
|
||||||
|
const bucketExists = await this.minioClient.bucketExists(this.bucketName);
|
||||||
|
if (!bucketExists) {
|
||||||
|
await this.minioClient.makeBucket(this.bucketName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate storage path
|
||||||
|
const objectName = `reports/${report.tenantId}/${report.config.type}/${result.fileName}`;
|
||||||
|
|
||||||
|
// Upload to MinIO
|
||||||
|
await this.minioClient.putObject(
|
||||||
|
this.bucketName,
|
||||||
|
objectName,
|
||||||
|
result.buffer,
|
||||||
|
result.fileSize,
|
||||||
|
{
|
||||||
|
'Content-Type': result.mimeType,
|
||||||
|
'x-amz-meta-report-id': report.id,
|
||||||
|
'x-amz-meta-tenant-id': report.tenantId,
|
||||||
|
'x-amz-meta-report-type': report.config.type,
|
||||||
|
'x-amz-meta-generated-at': report.generatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate presigned URL (valid for 7 days)
|
||||||
|
const url = await this.minioClient.presignedGetObject(
|
||||||
|
this.bucketName,
|
||||||
|
objectName,
|
||||||
|
7 * 24 * 60 * 60
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
size: result.fileSize,
|
||||||
|
pageCount: result.pageCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Content Generation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private async generateContent(doc: PDFKit.PDFDocument, report: GeneratedReport): Promise<void> {
|
||||||
|
// Cover page
|
||||||
|
this.generateCoverPage(doc, report);
|
||||||
|
|
||||||
|
// Table of contents
|
||||||
|
doc.addPage();
|
||||||
|
this.generateTableOfContents(doc, report);
|
||||||
|
|
||||||
|
// KPI Summary page
|
||||||
|
doc.addPage();
|
||||||
|
this.generateKPISummaryPage(doc, report);
|
||||||
|
|
||||||
|
// Generate each section
|
||||||
|
for (const section of report.sections) {
|
||||||
|
doc.addPage();
|
||||||
|
this.generateSection(doc, section, report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cover Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private generateCoverPage(doc: PDFKit.PDFDocument, report: GeneratedReport): void {
|
||||||
|
const { colors, branding } = this.options;
|
||||||
|
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
|
||||||
|
const centerX = this.options.margins.left + pageWidth / 2;
|
||||||
|
|
||||||
|
// Background header band
|
||||||
|
doc.rect(0, 0, doc.page.width, 200)
|
||||||
|
.fill(colors.primary);
|
||||||
|
|
||||||
|
// Company name / Logo area
|
||||||
|
doc.fontSize(14)
|
||||||
|
.fillColor('#FFFFFF')
|
||||||
|
.text(branding.companyName.toUpperCase(), this.options.margins.left, 60, {
|
||||||
|
width: pageWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Report title
|
||||||
|
doc.fontSize(32)
|
||||||
|
.fillColor('#FFFFFF')
|
||||||
|
.text(report.title, this.options.margins.left, 100, {
|
||||||
|
width: pageWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
doc.fontSize(14)
|
||||||
|
.fillColor('#FFFFFF')
|
||||||
|
.text(report.subtitle, this.options.margins.left, 150, {
|
||||||
|
width: pageWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Period information
|
||||||
|
const periodText = formatPeriodRange(report.dateRange.dateFrom, report.dateRange.dateTo);
|
||||||
|
doc.fontSize(16)
|
||||||
|
.fillColor(colors.text)
|
||||||
|
.text('Periodo del Reporte', this.options.margins.left, 280, {
|
||||||
|
width: pageWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.fontSize(24)
|
||||||
|
.fillColor(colors.primary)
|
||||||
|
.text(periodText, this.options.margins.left, 310, {
|
||||||
|
width: pageWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generation info
|
||||||
|
const generatedDate = report.generatedAt.toLocaleDateString('es-MX', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.fontSize(12)
|
||||||
|
.fillColor(colors.textLight)
|
||||||
|
.text(`Generado el ${generatedDate}`, this.options.margins.left, 400, {
|
||||||
|
width: pageWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Divider line
|
||||||
|
doc.moveTo(centerX - 100, 450)
|
||||||
|
.lineTo(centerX + 100, 450)
|
||||||
|
.strokeColor(colors.border)
|
||||||
|
.lineWidth(2)
|
||||||
|
.stroke();
|
||||||
|
|
||||||
|
// Report summary stats
|
||||||
|
const statsY = 500;
|
||||||
|
const statWidth = pageWidth / 3;
|
||||||
|
|
||||||
|
// Sections count
|
||||||
|
doc.fontSize(36)
|
||||||
|
.fillColor(colors.primary)
|
||||||
|
.text(report.sections.length.toString(), this.options.margins.left, statsY, {
|
||||||
|
width: statWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
doc.fontSize(12)
|
||||||
|
.fillColor(colors.textLight)
|
||||||
|
.text('Secciones', this.options.margins.left, statsY + 45, {
|
||||||
|
width: statWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Charts count
|
||||||
|
const chartsCount = report.sections.reduce((sum, s) => sum + (s.charts?.length || 0), 0);
|
||||||
|
doc.fontSize(36)
|
||||||
|
.fillColor(colors.primary)
|
||||||
|
.text(chartsCount.toString(), this.options.margins.left + statWidth, statsY, {
|
||||||
|
width: statWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
doc.fontSize(12)
|
||||||
|
.fillColor(colors.textLight)
|
||||||
|
.text('Graficas', this.options.margins.left + statWidth, statsY + 45, {
|
||||||
|
width: statWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recommendations count
|
||||||
|
const recsCount = report.aiContent?.recommendations?.length || 0;
|
||||||
|
doc.fontSize(36)
|
||||||
|
.fillColor(colors.primary)
|
||||||
|
.text(recsCount.toString(), this.options.margins.left + statWidth * 2, statsY, {
|
||||||
|
width: statWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
doc.fontSize(12)
|
||||||
|
.fillColor(colors.textLight)
|
||||||
|
.text('Recomendaciones', this.options.margins.left + statWidth * 2, statsY + 45, {
|
||||||
|
width: statWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
doc.fontSize(10)
|
||||||
|
.fillColor(colors.textLight)
|
||||||
|
.text(
|
||||||
|
branding.footerText || 'Generado por Horux Strategy - Tu CFO Digital',
|
||||||
|
this.options.margins.left,
|
||||||
|
doc.page.height - this.options.margins.bottom - 20,
|
||||||
|
{ width: pageWidth, align: 'center' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Table of Contents
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private generateTableOfContents(doc: PDFKit.PDFDocument, report: GeneratedReport): void {
|
||||||
|
const { colors } = this.options;
|
||||||
|
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
doc.fontSize(24)
|
||||||
|
.fillColor(colors.primary)
|
||||||
|
.text('Contenido', this.options.margins.left, this.options.margins.top);
|
||||||
|
|
||||||
|
doc.moveDown(1);
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
doc.moveTo(this.options.margins.left, doc.y)
|
||||||
|
.lineTo(this.options.margins.left + pageWidth, doc.y)
|
||||||
|
.strokeColor(colors.border)
|
||||||
|
.lineWidth(1)
|
||||||
|
.stroke();
|
||||||
|
|
||||||
|
doc.moveDown(1);
|
||||||
|
|
||||||
|
// TOC entries
|
||||||
|
let pageNumber = 3; // Start after cover and TOC
|
||||||
|
|
||||||
|
// KPI Summary
|
||||||
|
this.addTOCEntry(doc, 'Resumen de KPIs', pageNumber, pageWidth);
|
||||||
|
pageNumber++;
|
||||||
|
|
||||||
|
// Sections
|
||||||
|
for (const section of report.sections) {
|
||||||
|
this.addTOCEntry(doc, section.title, pageNumber, pageWidth);
|
||||||
|
pageNumber++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addTOCEntry(
|
||||||
|
doc: PDFKit.PDFDocument,
|
||||||
|
title: string,
|
||||||
|
pageNumber: number,
|
||||||
|
pageWidth: number
|
||||||
|
): void {
|
||||||
|
const { colors } = this.options;
|
||||||
|
const startX = this.options.margins.left;
|
||||||
|
const startY = doc.y;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
doc.fontSize(12)
|
||||||
|
.fillColor(colors.text)
|
||||||
|
.text(title, startX, startY, { continued: true });
|
||||||
|
|
||||||
|
// Dots
|
||||||
|
const titleWidth = doc.widthOfString(title);
|
||||||
|
const pageNumWidth = doc.widthOfString(pageNumber.toString());
|
||||||
|
const dotsWidth = pageWidth - titleWidth - pageNumWidth - 20;
|
||||||
|
const dots = '.'.repeat(Math.floor(dotsWidth / doc.widthOfString('.')));
|
||||||
|
|
||||||
|
doc.fillColor(colors.textLight)
|
||||||
|
.text(' ' + dots + ' ', { continued: true });
|
||||||
|
|
||||||
|
// Page number
|
||||||
|
doc.fillColor(colors.text)
|
||||||
|
.text(pageNumber.toString());
|
||||||
|
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// KPI Summary Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private generateKPISummaryPage(doc: PDFKit.PDFDocument, report: GeneratedReport): void {
|
||||||
|
const { colors } = this.options;
|
||||||
|
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
this.addSectionHeader(doc, 'Indicadores Clave (KPIs)');
|
||||||
|
|
||||||
|
const kpis = report.kpiSummary;
|
||||||
|
const currency = report.config.currency || 'MXN';
|
||||||
|
|
||||||
|
// Create KPI cards grid (2 columns)
|
||||||
|
const cardWidth = (pageWidth - 20) / 2;
|
||||||
|
const cardHeight = 80;
|
||||||
|
let currentY = doc.y + 20;
|
||||||
|
let column = 0;
|
||||||
|
|
||||||
|
const kpiList = [
|
||||||
|
{ label: 'Ingresos', kpi: kpis.revenue },
|
||||||
|
{ label: 'Gastos', kpi: kpis.expenses },
|
||||||
|
{ label: 'Utilidad Neta', kpi: kpis.netProfit },
|
||||||
|
{ label: 'Margen de Utilidad', kpi: kpis.profitMargin },
|
||||||
|
{ label: 'Flujo de Efectivo', kpi: kpis.cashFlow },
|
||||||
|
{ label: 'Cuentas por Cobrar', kpi: kpis.accountsReceivable },
|
||||||
|
{ label: 'Cuentas por Pagar', kpi: kpis.accountsPayable },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add startup metrics if available
|
||||||
|
if (kpis.mrr) kpiList.push({ label: 'MRR', kpi: kpis.mrr });
|
||||||
|
if (kpis.arr) kpiList.push({ label: 'ARR', kpi: kpis.arr });
|
||||||
|
if (kpis.churnRate) kpiList.push({ label: 'Churn Rate', kpi: kpis.churnRate });
|
||||||
|
if (kpis.runway) kpiList.push({ label: 'Runway', kpi: kpis.runway });
|
||||||
|
|
||||||
|
// Add enterprise metrics if available
|
||||||
|
if (kpis.ebitda) kpiList.push({ label: 'EBITDA', kpi: kpis.ebitda });
|
||||||
|
if (kpis.currentRatio) kpiList.push({ label: 'Ratio Corriente', kpi: kpis.currentRatio });
|
||||||
|
if (kpis.quickRatio) kpiList.push({ label: 'Prueba Acida', kpi: kpis.quickRatio });
|
||||||
|
|
||||||
|
for (const { label, kpi } of kpiList) {
|
||||||
|
const x = this.options.margins.left + column * (cardWidth + 20);
|
||||||
|
|
||||||
|
this.drawKPICard(doc, x, currentY, cardWidth, cardHeight, label, kpi);
|
||||||
|
|
||||||
|
column++;
|
||||||
|
if (column >= 2) {
|
||||||
|
column = 0;
|
||||||
|
currentY += cardHeight + 15;
|
||||||
|
|
||||||
|
// Check for page break
|
||||||
|
if (currentY + cardHeight > doc.page.height - this.options.margins.bottom - 50) {
|
||||||
|
doc.addPage();
|
||||||
|
currentY = this.options.margins.top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawKPICard(
|
||||||
|
doc: PDFKit.PDFDocument,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
label: string,
|
||||||
|
kpi: {
|
||||||
|
formatted: string;
|
||||||
|
formattedChange?: string;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
status: 'good' | 'warning' | 'critical' | 'neutral';
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
const { colors } = this.options;
|
||||||
|
|
||||||
|
// Card background
|
||||||
|
doc.roundedRect(x, y, width, height, 5)
|
||||||
|
.fillColor('#F9FAFB')
|
||||||
|
.fill();
|
||||||
|
|
||||||
|
// Status indicator
|
||||||
|
const statusColors = {
|
||||||
|
good: colors.success,
|
||||||
|
warning: colors.warning,
|
||||||
|
critical: colors.danger,
|
||||||
|
neutral: colors.textLight,
|
||||||
|
};
|
||||||
|
|
||||||
|
doc.roundedRect(x, y, 4, height, 2)
|
||||||
|
.fillColor(statusColors[kpi.status])
|
||||||
|
.fill();
|
||||||
|
|
||||||
|
// Label
|
||||||
|
doc.fontSize(10)
|
||||||
|
.fillColor(colors.textLight)
|
||||||
|
.text(label, x + 15, y + 12);
|
||||||
|
|
||||||
|
// Value
|
||||||
|
doc.fontSize(20)
|
||||||
|
.fillColor(colors.text)
|
||||||
|
.text(kpi.formatted + (kpi.unit ? ` ${kpi.unit}` : ''), x + 15, y + 30);
|
||||||
|
|
||||||
|
// Change indicator
|
||||||
|
if (kpi.formattedChange) {
|
||||||
|
const trendColor = kpi.trend === 'up' ? colors.success :
|
||||||
|
kpi.trend === 'down' ? colors.danger : colors.textLight;
|
||||||
|
const trendArrow = kpi.trend === 'up' ? '+' :
|
||||||
|
kpi.trend === 'down' ? '' : '=';
|
||||||
|
|
||||||
|
doc.fontSize(11)
|
||||||
|
.fillColor(trendColor)
|
||||||
|
.text(`${trendArrow}${kpi.formattedChange}`, x + 15, y + 55);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Section Generation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private generateSection(
|
||||||
|
doc: PDFKit.PDFDocument,
|
||||||
|
section: ReportSection,
|
||||||
|
report: GeneratedReport
|
||||||
|
): void {
|
||||||
|
// Section header
|
||||||
|
this.addSectionHeader(doc, section.title);
|
||||||
|
|
||||||
|
// Narrative text
|
||||||
|
if (section.narrative) {
|
||||||
|
this.addNarrative(doc, section.narrative);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
if (section.tables && section.tables.length > 0) {
|
||||||
|
for (const table of section.tables) {
|
||||||
|
this.addTable(doc, table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charts (as placeholders)
|
||||||
|
if (section.charts && section.charts.length > 0) {
|
||||||
|
for (const chart of section.charts) {
|
||||||
|
this.addChartPlaceholder(doc, chart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
|
||||||
|
const { colors } = this.options;
|
||||||
|
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
|
||||||
|
|
||||||
|
// Title with accent bar
|
||||||
|
doc.rect(this.options.margins.left, this.options.margins.top, 4, 28)
|
||||||
|
.fillColor(colors.primary)
|
||||||
|
.fill();
|
||||||
|
|
||||||
|
doc.fontSize(20)
|
||||||
|
.fillColor(colors.text)
|
||||||
|
.text(title, this.options.margins.left + 15, this.options.margins.top + 5);
|
||||||
|
|
||||||
|
// Divider line
|
||||||
|
doc.moveTo(this.options.margins.left, this.options.margins.top + 40)
|
||||||
|
.lineTo(this.options.margins.left + pageWidth, this.options.margins.top + 40)
|
||||||
|
.strokeColor(colors.border)
|
||||||
|
.lineWidth(0.5)
|
||||||
|
.stroke();
|
||||||
|
|
||||||
|
doc.y = this.options.margins.top + 55;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addNarrative(doc: PDFKit.PDFDocument, text: string): void {
|
||||||
|
const { colors } = this.options;
|
||||||
|
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
|
||||||
|
|
||||||
|
// Check for page break
|
||||||
|
this.checkPageBreak(doc, 100);
|
||||||
|
|
||||||
|
doc.fontSize(11)
|
||||||
|
.fillColor(colors.text)
|
||||||
|
.text(text, this.options.margins.left, doc.y, {
|
||||||
|
width: pageWidth,
|
||||||
|
align: 'justify',
|
||||||
|
lineGap: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.moveDown(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Table Generation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private addTable(doc: PDFKit.PDFDocument, table: TableData): void {
|
||||||
|
const { colors } = this.options;
|
||||||
|
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
|
||||||
|
|
||||||
|
if (table.headers.length === 0) return;
|
||||||
|
|
||||||
|
// Table title
|
||||||
|
if (table.title) {
|
||||||
|
this.checkPageBreak(doc, 50);
|
||||||
|
doc.fontSize(14)
|
||||||
|
.fillColor(colors.text)
|
||||||
|
.text(table.title, this.options.margins.left, doc.y);
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnCount = table.headers.length;
|
||||||
|
const columnWidth = pageWidth / columnCount;
|
||||||
|
const rowHeight = 25;
|
||||||
|
const headerHeight = 30;
|
||||||
|
|
||||||
|
// Estimate table height
|
||||||
|
const tableHeight = headerHeight + (table.rows.length + (table.totals ? 1 : 0)) * rowHeight;
|
||||||
|
this.checkPageBreak(doc, tableHeight);
|
||||||
|
|
||||||
|
let currentY = doc.y;
|
||||||
|
|
||||||
|
// Header background
|
||||||
|
doc.rect(this.options.margins.left, currentY, pageWidth, headerHeight)
|
||||||
|
.fillColor(colors.primary)
|
||||||
|
.fill();
|
||||||
|
|
||||||
|
// Header text
|
||||||
|
doc.fontSize(10)
|
||||||
|
.fillColor('#FFFFFF');
|
||||||
|
|
||||||
|
for (let i = 0; i < table.headers.length; i++) {
|
||||||
|
const x = this.options.margins.left + i * columnWidth + 5;
|
||||||
|
doc.text(table.headers[i], x, currentY + 9, {
|
||||||
|
width: columnWidth - 10,
|
||||||
|
align: i === 0 ? 'left' : 'right',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY += headerHeight;
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
for (let rowIdx = 0; rowIdx < table.rows.length; rowIdx++) {
|
||||||
|
const row = table.rows[rowIdx];
|
||||||
|
|
||||||
|
// Alternating row background
|
||||||
|
if (rowIdx % 2 === 1) {
|
||||||
|
doc.rect(this.options.margins.left, currentY, pageWidth, rowHeight)
|
||||||
|
.fillColor('#F9FAFB')
|
||||||
|
.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlighted row
|
||||||
|
if (row.isHighlighted) {
|
||||||
|
doc.rect(this.options.margins.left, currentY, pageWidth, rowHeight)
|
||||||
|
.fillColor('#EFF6FF')
|
||||||
|
.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cell content
|
||||||
|
doc.fontSize(10);
|
||||||
|
for (let colIdx = 0; colIdx < row.cells.length; colIdx++) {
|
||||||
|
const cell = row.cells[colIdx];
|
||||||
|
const x = this.options.margins.left + colIdx * columnWidth + 5;
|
||||||
|
|
||||||
|
doc.fillColor(cell.color || colors.text);
|
||||||
|
if (cell.isBold) {
|
||||||
|
doc.font('Helvetica-Bold');
|
||||||
|
} else {
|
||||||
|
doc.font('Helvetica');
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.text(cell.formatted, x, currentY + 7, {
|
||||||
|
width: columnWidth - 10,
|
||||||
|
align: cell.alignment || (colIdx === 0 ? 'left' : 'right'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY += rowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals row
|
||||||
|
if (table.totals) {
|
||||||
|
// Totals background
|
||||||
|
doc.rect(this.options.margins.left, currentY, pageWidth, rowHeight)
|
||||||
|
.fillColor(colors.primary)
|
||||||
|
.fill();
|
||||||
|
|
||||||
|
doc.fontSize(10)
|
||||||
|
.font('Helvetica-Bold')
|
||||||
|
.fillColor('#FFFFFF');
|
||||||
|
|
||||||
|
for (let colIdx = 0; colIdx < table.totals.cells.length; colIdx++) {
|
||||||
|
const cell = table.totals.cells[colIdx];
|
||||||
|
const x = this.options.margins.left + colIdx * columnWidth + 5;
|
||||||
|
|
||||||
|
doc.text(cell.formatted, x, currentY + 7, {
|
||||||
|
width: columnWidth - 10,
|
||||||
|
align: cell.alignment || (colIdx === 0 ? 'left' : 'right'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY += rowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset font
|
||||||
|
doc.font('Helvetica');
|
||||||
|
|
||||||
|
// Footnotes
|
||||||
|
if (table.footnotes && table.footnotes.length > 0) {
|
||||||
|
doc.fontSize(9)
|
||||||
|
.fillColor(colors.textLight);
|
||||||
|
|
||||||
|
for (const footnote of table.footnotes) {
|
||||||
|
doc.text(footnote, this.options.margins.left, currentY + 5);
|
||||||
|
currentY += 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.y = currentY + 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Chart Placeholder
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private addChartPlaceholder(doc: PDFKit.PDFDocument, chart: ChartData): void {
|
||||||
|
const { colors } = this.options;
|
||||||
|
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
|
||||||
|
const placeholder = generateChartPlaceholder(chart);
|
||||||
|
|
||||||
|
// Estimate height
|
||||||
|
const height = 60 + placeholder.dataPoints.length * 25;
|
||||||
|
this.checkPageBreak(doc, height);
|
||||||
|
|
||||||
|
const startY = doc.y;
|
||||||
|
|
||||||
|
// Chart container
|
||||||
|
doc.roundedRect(this.options.margins.left, startY, pageWidth, height, 5)
|
||||||
|
.strokeColor(colors.border)
|
||||||
|
.lineWidth(1)
|
||||||
|
.stroke();
|
||||||
|
|
||||||
|
// Title
|
||||||
|
doc.fontSize(12)
|
||||||
|
.fillColor(colors.text)
|
||||||
|
.text(placeholder.title, this.options.margins.left + 15, startY + 15);
|
||||||
|
|
||||||
|
// Chart type indicator
|
||||||
|
doc.fontSize(9)
|
||||||
|
.fillColor(colors.textLight)
|
||||||
|
.text(`[Grafica tipo: ${placeholder.type}]`, this.options.margins.left + 15, startY + 32);
|
||||||
|
|
||||||
|
// Data representation
|
||||||
|
let dataY = startY + 55;
|
||||||
|
const barMaxWidth = pageWidth - 200;
|
||||||
|
|
||||||
|
// Find max value for scaling
|
||||||
|
const maxValue = Math.max(...placeholder.dataPoints.map(d => {
|
||||||
|
const num = parseFloat(d.value.replace(/[^0-9.-]/g, ''));
|
||||||
|
return isNaN(num) ? 0 : Math.abs(num);
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const point of placeholder.dataPoints.slice(0, 8)) {
|
||||||
|
// Label
|
||||||
|
doc.fontSize(9)
|
||||||
|
.fillColor(colors.text)
|
||||||
|
.text(point.label, this.options.margins.left + 15, dataY, {
|
||||||
|
width: 120,
|
||||||
|
align: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Value bar
|
||||||
|
const numValue = parseFloat(point.value.replace(/[^0-9.-]/g, ''));
|
||||||
|
const barWidth = maxValue > 0 ? (Math.abs(numValue) / maxValue) * barMaxWidth : 0;
|
||||||
|
|
||||||
|
doc.rect(this.options.margins.left + 140, dataY, Math.max(barWidth, 2), 12)
|
||||||
|
.fillColor(numValue >= 0 ? colors.primary : colors.danger)
|
||||||
|
.fill();
|
||||||
|
|
||||||
|
// Value text
|
||||||
|
doc.fontSize(9)
|
||||||
|
.fillColor(colors.text)
|
||||||
|
.text(point.value, this.options.margins.left + 145 + barWidth, dataY);
|
||||||
|
|
||||||
|
if (point.percentage) {
|
||||||
|
doc.fillColor(colors.textLight)
|
||||||
|
.text(` (${point.percentage})`, { continued: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
dataY += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total if available
|
||||||
|
if (placeholder.total) {
|
||||||
|
doc.fontSize(10)
|
||||||
|
.fillColor(colors.text)
|
||||||
|
.text(`Total: ${placeholder.total}`, this.options.margins.left + 15, startY + height - 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.y = startY + height + 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Page Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private checkPageBreak(doc: PDFKit.PDFDocument, requiredSpace: number): void {
|
||||||
|
const availableSpace = doc.page.height - doc.y - this.options.margins.bottom - this.options.footerHeight;
|
||||||
|
|
||||||
|
if (availableSpace < requiredSpace) {
|
||||||
|
doc.addPage();
|
||||||
|
doc.y = this.options.margins.top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addPageNumbers(doc: PDFKit.PDFDocument): void {
|
||||||
|
const { colors } = this.options;
|
||||||
|
const range = doc.bufferedPageRange();
|
||||||
|
|
||||||
|
for (let i = range.start; i < range.start + range.count; i++) {
|
||||||
|
doc.switchToPage(i);
|
||||||
|
|
||||||
|
// Skip cover page
|
||||||
|
if (i === 0) continue;
|
||||||
|
|
||||||
|
const pageNum = i + 1;
|
||||||
|
const totalPages = range.count;
|
||||||
|
|
||||||
|
// Footer line
|
||||||
|
doc.moveTo(this.options.margins.left, doc.page.height - this.options.margins.bottom)
|
||||||
|
.lineTo(doc.page.width - this.options.margins.right, doc.page.height - this.options.margins.bottom)
|
||||||
|
.strokeColor(colors.border)
|
||||||
|
.lineWidth(0.5)
|
||||||
|
.stroke();
|
||||||
|
|
||||||
|
// Page number
|
||||||
|
doc.fontSize(9)
|
||||||
|
.fillColor(colors.textLight)
|
||||||
|
.text(
|
||||||
|
`Pagina ${pageNum} de ${totalPages}`,
|
||||||
|
this.options.margins.left,
|
||||||
|
doc.page.height - this.options.margins.bottom + 10,
|
||||||
|
{
|
||||||
|
width: doc.page.width - this.options.margins.left - this.options.margins.right,
|
||||||
|
align: 'center',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Footer text
|
||||||
|
doc.fontSize(8)
|
||||||
|
.text(
|
||||||
|
this.options.branding.footerText || 'Horux Strategy - Tu CFO Digital',
|
||||||
|
this.options.margins.left,
|
||||||
|
doc.page.height - this.options.margins.bottom + 25,
|
||||||
|
{
|
||||||
|
width: doc.page.width - this.options.margins.left - this.options.margins.right,
|
||||||
|
align: 'center',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private generateFileName(report: GeneratedReport): string {
|
||||||
|
const date = new Date().toISOString().split('T')[0];
|
||||||
|
const periodStr = formatPeriod(report.period).replace(/\s+/g, '-').toLowerCase();
|
||||||
|
return `reporte-${report.config.type}-${periodStr}-${date}.pdf`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let pdfGeneratorInstance: PDFGenerator | null = null;
|
||||||
|
|
||||||
|
export function getPDFGenerator(options?: Partial<PDFGenerationOptions>): PDFGenerator {
|
||||||
|
if (!pdfGeneratorInstance) {
|
||||||
|
pdfGeneratorInstance = new PDFGenerator(options);
|
||||||
|
}
|
||||||
|
return pdfGeneratorInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPDFGenerator(options?: Partial<PDFGenerationOptions>): PDFGenerator {
|
||||||
|
return new PDFGenerator(options);
|
||||||
|
}
|
||||||
1014
apps/api/src/services/reports/report.generator.ts
Normal file
1014
apps/api/src/services/reports/report.generator.ts
Normal file
File diff suppressed because it is too large
Load Diff
472
apps/api/src/services/reports/report.prompts.ts
Normal file
472
apps/api/src/services/reports/report.prompts.ts
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
/**
|
||||||
|
* Report Prompts for DeepSeek AI
|
||||||
|
*
|
||||||
|
* Optimized prompts for generating executive financial narratives.
|
||||||
|
* All prompts are designed for a Mexican CFO digital context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// System Context
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const SYSTEM_CONTEXT = `Eres un CFO digital experto en finanzas corporativas mexicanas.
|
||||||
|
Tu rol es analizar metricas financieras y generar narrativas ejecutivas claras, profesionales y accionables.
|
||||||
|
|
||||||
|
Caracteristicas de tu comunicacion:
|
||||||
|
- Lenguaje profesional pero accesible
|
||||||
|
- Enfocado en insights accionables
|
||||||
|
- Contexto mexicano (pesos, regulaciones SAT, practicas locales)
|
||||||
|
- Directo y conciso, sin relleno
|
||||||
|
- Siempre basado en datos, nunca especulacion
|
||||||
|
- Usa formato de numeros mexicano (comas para miles, punto para decimales)
|
||||||
|
|
||||||
|
Formato de respuesta:
|
||||||
|
- Parrafos cortos y faciles de escanear
|
||||||
|
- Puntos clave resaltados
|
||||||
|
- Recomendaciones concretas con pasos de accion
|
||||||
|
- Sin emojis ni elementos informales`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Executive Summary Prompt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const EXECUTIVE_SUMMARY_PROMPT = `Genera un resumen ejecutivo para el reporte financiero del periodo {{period}}.
|
||||||
|
|
||||||
|
DATOS DEL PERIODO:
|
||||||
|
- Ingresos: {{revenue}} ({{revenueChange}} vs periodo anterior)
|
||||||
|
- Gastos: {{expenses}} ({{expenseChange}} vs periodo anterior)
|
||||||
|
- Utilidad Neta: {{netProfit}} ({{profitChange}} vs periodo anterior)
|
||||||
|
- Margen de Utilidad: {{profitMargin}}
|
||||||
|
- Flujo de Efectivo Neto: {{cashFlow}}
|
||||||
|
- Cuentas por Cobrar: {{accountsReceivable}}
|
||||||
|
- Cuentas por Pagar: {{accountsPayable}}
|
||||||
|
{{#if hasStartupMetrics}}
|
||||||
|
- MRR: {{mrr}}
|
||||||
|
- ARR: {{arr}}
|
||||||
|
- Churn Rate: {{churnRate}}
|
||||||
|
- Runway: {{runway}} meses
|
||||||
|
{{/if}}
|
||||||
|
{{#if hasEnterpriseMetrics}}
|
||||||
|
- EBITDA: {{ebitda}}
|
||||||
|
- Ratio Corriente: {{currentRatio}}
|
||||||
|
- Prueba Acida: {{quickRatio}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
ANOMALIAS DETECTADAS: {{anomaliesCount}}
|
||||||
|
{{#each anomalies}}
|
||||||
|
- {{this.description}} (Severidad: {{this.severity}})
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
INSTRUCCIONES:
|
||||||
|
1. Resume la salud financiera general de la empresa en 2-3 oraciones
|
||||||
|
2. Destaca los 3 puntos mas importantes del periodo (positivos o negativos)
|
||||||
|
3. Menciona brevemente las areas que requieren atencion
|
||||||
|
4. Incluye una perspectiva de corto plazo
|
||||||
|
|
||||||
|
El resumen debe ser de maximo 250 palabras, profesional y orientado a la toma de decisiones.`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cash Flow Analysis Prompt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CASH_FLOW_ANALYSIS_PROMPT = `Analiza el flujo de efectivo del periodo {{period}}.
|
||||||
|
|
||||||
|
DATOS DE FLUJO DE EFECTIVO:
|
||||||
|
- Flujo Neto: {{netCashFlow}}
|
||||||
|
- Actividades Operativas: {{operatingActivities}}
|
||||||
|
- Actividades de Inversion: {{investingActivities}}
|
||||||
|
- Actividades de Financiamiento: {{financingActivities}}
|
||||||
|
- Saldo Inicial: {{openingBalance}}
|
||||||
|
- Saldo Final: {{closingBalance}}
|
||||||
|
|
||||||
|
DESGLOSE DIARIO/SEMANAL:
|
||||||
|
{{#each breakdown}}
|
||||||
|
- {{this.date}}: Entradas {{this.inflow}}, Salidas {{this.outflow}}, Neto {{this.netFlow}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
COMPARACION CON PERIODO ANTERIOR:
|
||||||
|
- Cambio en flujo neto: {{cashFlowChange}} ({{cashFlowChangePercent}})
|
||||||
|
|
||||||
|
INSTRUCCIONES:
|
||||||
|
1. Evalua la salud del flujo de efectivo
|
||||||
|
2. Identifica patrones en las entradas y salidas
|
||||||
|
3. Analiza la composicion del flujo (operativo vs inversion vs financiamiento)
|
||||||
|
4. Identifica periodos de estres de liquidez si los hay
|
||||||
|
5. Proporciona 2-3 recomendaciones para optimizar el flujo
|
||||||
|
|
||||||
|
El analisis debe ser de 150-200 palabras, enfocado en insights accionables.`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Revenue Analysis Prompt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const REVENUE_ANALYSIS_PROMPT = `Analiza los ingresos del periodo {{period}}.
|
||||||
|
|
||||||
|
DATOS DE INGRESOS:
|
||||||
|
- Ingresos Totales: {{totalRevenue}}
|
||||||
|
- Numero de Facturas: {{invoiceCount}}
|
||||||
|
- Valor Promedio de Factura: {{averageInvoiceValue}}
|
||||||
|
- Cambio vs Periodo Anterior: {{revenueChange}} ({{revenueChangePercent}})
|
||||||
|
|
||||||
|
DESGLOSE POR CATEGORIA:
|
||||||
|
{{#each byCategory}}
|
||||||
|
- {{this.categoryName}}: {{this.amount}} ({{this.percentage}}%)
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
{{#if hasProducts}}
|
||||||
|
TOP PRODUCTOS/SERVICIOS:
|
||||||
|
{{#each byProduct}}
|
||||||
|
- {{this.productName}}: {{this.amount}} ({{this.quantity}} unidades)
|
||||||
|
{{/each}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
INSTRUCCIONES:
|
||||||
|
1. Evalua el desempeno de ingresos vs periodo anterior
|
||||||
|
2. Identifica las categorias o productos que mas contribuyen
|
||||||
|
3. Detecta tendencias o cambios significativos
|
||||||
|
4. Analiza la concentracion de ingresos (dependencia de pocos clientes/productos)
|
||||||
|
5. Proporciona 2-3 recomendaciones para crecimiento
|
||||||
|
|
||||||
|
El analisis debe ser de 150-200 palabras, orientado a estrategia comercial.`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Expense Analysis Prompt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const EXPENSE_ANALYSIS_PROMPT = `Analiza los gastos del periodo {{period}}.
|
||||||
|
|
||||||
|
DATOS DE GASTOS:
|
||||||
|
- Gastos Totales: {{totalExpenses}}
|
||||||
|
- Gastos Fijos: {{fixedExpenses}} ({{fixedPercentage}}%)
|
||||||
|
- Gastos Variables: {{variableExpenses}} ({{variablePercentage}}%)
|
||||||
|
- Numero de Transacciones: {{expenseCount}}
|
||||||
|
- Cambio vs Periodo Anterior: {{expenseChange}} ({{expenseChangePercent}})
|
||||||
|
|
||||||
|
DESGLOSE POR CATEGORIA:
|
||||||
|
{{#each byCategory}}
|
||||||
|
- {{this.categoryName}}: {{this.amount}} ({{this.percentage}}%)
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
RATIO GASTOS/INGRESOS: {{expenseToRevenueRatio}}%
|
||||||
|
|
||||||
|
INSTRUCCIONES:
|
||||||
|
1. Evalua la estructura de gastos (fijos vs variables)
|
||||||
|
2. Identifica categorias con mayor crecimiento o que requieren atencion
|
||||||
|
3. Compara con benchmarks de la industria si es relevante
|
||||||
|
4. Analiza la eficiencia operativa
|
||||||
|
5. Proporciona 2-3 recomendaciones de optimizacion
|
||||||
|
|
||||||
|
El analisis debe ser de 150-200 palabras, enfocado en control de costos.`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profit Analysis Prompt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const PROFIT_ANALYSIS_PROMPT = `Analiza la rentabilidad del periodo {{period}}.
|
||||||
|
|
||||||
|
DATOS DE RENTABILIDAD:
|
||||||
|
- Ingresos: {{revenue}}
|
||||||
|
- Costo de Ventas: {{cogs}}
|
||||||
|
- Utilidad Bruta: {{grossProfit}} (Margen: {{grossMargin}}%)
|
||||||
|
- Gastos Operativos: {{operatingExpenses}}
|
||||||
|
- Utilidad Neta: {{netProfit}} (Margen: {{netMargin}}%)
|
||||||
|
- Cambio en Utilidad Neta: {{profitChange}} ({{profitChangePercent}})
|
||||||
|
|
||||||
|
TENDENCIA DE MARGENES (ultimos periodos):
|
||||||
|
{{#each marginTrend}}
|
||||||
|
- {{this.period}}: Margen Bruto {{this.grossMargin}}%, Margen Neto {{this.netMargin}}%
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
INSTRUCCIONES:
|
||||||
|
1. Evalua la salud de los margenes de rentabilidad
|
||||||
|
2. Identifica factores que impactan positiva o negativamente la utilidad
|
||||||
|
3. Compara margenes bruto vs neto para entender eficiencia operativa
|
||||||
|
4. Analiza la tendencia de rentabilidad
|
||||||
|
5. Proporciona 2-3 recomendaciones para mejorar margenes
|
||||||
|
|
||||||
|
El analisis debe ser de 150-200 palabras, enfocado en maximizar rentabilidad.`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Recommendations Prompt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const RECOMMENDATIONS_PROMPT = `Genera recomendaciones estrategicas basadas en el analisis financiero del periodo {{period}}.
|
||||||
|
|
||||||
|
RESUMEN DE METRICAS CLAVE:
|
||||||
|
- Ingresos: {{revenue}} ({{revenueChange}})
|
||||||
|
- Utilidad Neta: {{netProfit}} ({{profitChange}})
|
||||||
|
- Flujo de Efectivo: {{cashFlow}} ({{cashFlowChange}})
|
||||||
|
- Margen de Utilidad: {{profitMargin}}
|
||||||
|
{{#if hasStartupMetrics}}
|
||||||
|
- Runway: {{runway}} meses
|
||||||
|
- Burn Rate: {{burnRate}}
|
||||||
|
- Churn Rate: {{churnRate}}
|
||||||
|
{{/if}}
|
||||||
|
{{#if hasEnterpriseMetrics}}
|
||||||
|
- EBITDA: {{ebitda}}
|
||||||
|
- Ratio Corriente: {{currentRatio}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
ANOMALIAS DETECTADAS:
|
||||||
|
{{#each anomalies}}
|
||||||
|
- {{this.description}} ({{this.severity}})
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
FORTALEZAS IDENTIFICADAS:
|
||||||
|
{{#each strengths}}
|
||||||
|
- {{this}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
AREAS DE MEJORA:
|
||||||
|
{{#each weaknesses}}
|
||||||
|
- {{this}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
INSTRUCCIONES:
|
||||||
|
Genera exactamente 5 recomendaciones priorizadas con el siguiente formato:
|
||||||
|
1. [PRIORIDAD ALTA/MEDIA/BAJA] Titulo de la recomendacion
|
||||||
|
- Descripcion breve del problema u oportunidad
|
||||||
|
- Accion especifica recomendada
|
||||||
|
- Impacto esperado
|
||||||
|
- Plazo sugerido de implementacion
|
||||||
|
|
||||||
|
Las recomendaciones deben ser:
|
||||||
|
- Especificas y accionables
|
||||||
|
- Basadas en los datos proporcionados
|
||||||
|
- Contextualizadas para el mercado mexicano
|
||||||
|
- Priorizadas por impacto y urgencia`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Anomaly Explanation Prompt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ANOMALY_EXPLANATION_PROMPT = `Explica la siguiente anomalia financiera detectada.
|
||||||
|
|
||||||
|
ANOMALIA:
|
||||||
|
- Metrica: {{metric}}
|
||||||
|
- Tipo: {{type}}
|
||||||
|
- Severidad: {{severity}}
|
||||||
|
- Valor Actual: {{currentValue}}
|
||||||
|
- Valor Esperado: {{expectedValue}}
|
||||||
|
- Desviacion: {{deviation}} ({{deviationPercent}}%)
|
||||||
|
- Periodo: {{period}}
|
||||||
|
|
||||||
|
CONTEXTO ADICIONAL:
|
||||||
|
- Tendencia historica: {{historicalTrend}}
|
||||||
|
- Factores externos conocidos: {{externalFactors}}
|
||||||
|
|
||||||
|
INSTRUCCIONES:
|
||||||
|
1. Explica en terminos simples que significa esta anomalia
|
||||||
|
2. Proporciona 2-3 posibles causas (basadas en patrones tipicos)
|
||||||
|
3. Evalua el nivel de riesgo o impacto
|
||||||
|
4. Sugiere 2-3 acciones inmediatas a considerar
|
||||||
|
|
||||||
|
La explicacion debe ser de 100-150 palabras, clara y sin jerga tecnica excesiva.`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Startup Metrics Analysis Prompt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const STARTUP_METRICS_PROMPT = `Analiza las metricas SaaS/Startup del periodo {{period}}.
|
||||||
|
|
||||||
|
METRICAS DE INGRESOS RECURRENTES:
|
||||||
|
- MRR: {{mrr}}
|
||||||
|
- ARR: {{arr}}
|
||||||
|
- Net New MRR: {{netNewMRR}}
|
||||||
|
- MRR de Nuevos Clientes: {{newMRR}}
|
||||||
|
- MRR de Expansion: {{expansionMRR}}
|
||||||
|
- MRR Perdido (Churn): {{churnedMRR}}
|
||||||
|
- ARPU: {{arpu}}
|
||||||
|
|
||||||
|
METRICAS DE CLIENTES:
|
||||||
|
- Clientes Totales: {{customerCount}}
|
||||||
|
- Churn Rate: {{churnRate}}
|
||||||
|
- Clientes Perdidos: {{churnedCustomers}}
|
||||||
|
|
||||||
|
METRICAS DE EFICIENCIA:
|
||||||
|
- CAC: {{cac}}
|
||||||
|
- LTV: {{ltv}}
|
||||||
|
- Ratio LTV/CAC: {{ltvCacRatio}}
|
||||||
|
- Payback Period: {{paybackPeriod}} meses
|
||||||
|
|
||||||
|
RUNWAY Y BURN:
|
||||||
|
- Cash Actual: {{currentCash}}
|
||||||
|
- Burn Rate Mensual: {{burnRate}}
|
||||||
|
- Runway: {{runway}} meses
|
||||||
|
- Fecha Proyectada Sin Cash: {{zeroDate}}
|
||||||
|
|
||||||
|
INSTRUCCIONES:
|
||||||
|
1. Evalua la salud general de las metricas SaaS
|
||||||
|
2. Analiza la eficiencia de adquisicion de clientes (CAC, LTV/CAC)
|
||||||
|
3. Evalua la retencion y el churn
|
||||||
|
4. Analiza el runway y la sostenibilidad
|
||||||
|
5. Proporciona 3 recomendaciones especificas para startups
|
||||||
|
|
||||||
|
El analisis debe ser de 200-250 palabras, usando terminologia SaaS apropiada.`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enterprise Metrics Analysis Prompt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ENTERPRISE_METRICS_PROMPT = `Analiza las metricas financieras enterprise del periodo {{period}}.
|
||||||
|
|
||||||
|
METRICAS DE RENTABILIDAD:
|
||||||
|
- EBITDA: {{ebitda}} (Margen: {{ebitdaMargin}}%)
|
||||||
|
- Ingreso Operativo: {{operatingIncome}}
|
||||||
|
- Depreciacion: {{depreciation}}
|
||||||
|
- Amortizacion: {{amortization}}
|
||||||
|
|
||||||
|
RETORNOS:
|
||||||
|
- ROI: {{roi}}%
|
||||||
|
- ROE: {{roe}}%
|
||||||
|
- Inversion Total: {{totalInvestment}}
|
||||||
|
- Capital Contable: {{shareholdersEquity}}
|
||||||
|
|
||||||
|
RATIOS DE LIQUIDEZ:
|
||||||
|
- Ratio Corriente: {{currentRatio}} (Activos: {{currentAssets}}, Pasivos: {{currentLiabilities}})
|
||||||
|
- Prueba Acida: {{quickRatio}} (Sin inventario: {{quickAssets}})
|
||||||
|
- Interpretacion: {{liquidityInterpretation}}
|
||||||
|
|
||||||
|
RATIO DE DEUDA:
|
||||||
|
- Ratio de Deuda: {{debtRatio}}%
|
||||||
|
- Deuda Total: {{totalDebt}}
|
||||||
|
- Activos Totales: {{totalAssets}}
|
||||||
|
- Interpretacion: {{debtInterpretation}}
|
||||||
|
|
||||||
|
INSTRUCCIONES:
|
||||||
|
1. Evalua la salud financiera desde perspectiva enterprise
|
||||||
|
2. Analiza la rentabilidad operativa (EBITDA y margenes)
|
||||||
|
3. Evalua la eficiencia de retornos (ROI, ROE)
|
||||||
|
4. Analiza la posicion de liquidez
|
||||||
|
5. Evalua el apalancamiento y riesgo de deuda
|
||||||
|
6. Proporciona 3 recomendaciones para optimizacion
|
||||||
|
|
||||||
|
El analisis debe ser de 200-250 palabras, con enfoque en gobernanza corporativa.`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Forecast Prompt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const FORECAST_PROMPT = `Genera un pronostico financiero basado en las tendencias observadas.
|
||||||
|
|
||||||
|
DATOS HISTORICOS (ultimos 6 periodos):
|
||||||
|
{{#each historicalData}}
|
||||||
|
- {{this.period}}: Ingresos {{this.revenue}}, Gastos {{this.expenses}}, Utilidad {{this.profit}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
TENDENCIAS IDENTIFICADAS:
|
||||||
|
- Tendencia de Ingresos: {{revenueTrend}} ({{revenueTrendPercent}}% promedio)
|
||||||
|
- Tendencia de Gastos: {{expenseTrend}} ({{expenseTrendPercent}}% promedio)
|
||||||
|
- Estacionalidad Detectada: {{seasonality}}
|
||||||
|
|
||||||
|
FACTORES CONOCIDOS:
|
||||||
|
{{#each knownFactors}}
|
||||||
|
- {{this}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
INSTRUCCIONES:
|
||||||
|
1. Proyecta los ingresos para los proximos 3 periodos
|
||||||
|
2. Proyecta los gastos para los proximos 3 periodos
|
||||||
|
3. Estima la utilidad proyectada
|
||||||
|
4. Identifica riesgos para el pronostico
|
||||||
|
5. Sugiere escenarios optimista/pesimista
|
||||||
|
|
||||||
|
El pronostico debe incluir:
|
||||||
|
- Proyecciones numericas especificas
|
||||||
|
- Nivel de confianza de las proyecciones
|
||||||
|
- Supuestos clave
|
||||||
|
- Factores de riesgo
|
||||||
|
|
||||||
|
Usa lenguaje cauteloso apropiado para proyecciones financieras (150-200 palabras).`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Prompt Builder Helper
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PromptVariables {
|
||||||
|
[key: string]: string | number | boolean | unknown[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a prompt by replacing variables in a template
|
||||||
|
*/
|
||||||
|
export function buildPrompt(template: string, variables: PromptVariables): string {
|
||||||
|
let result = template;
|
||||||
|
|
||||||
|
// Handle conditional blocks {{#if variable}}...{{/if}}
|
||||||
|
result = result.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (_, varName, content) => {
|
||||||
|
const value = variables[varName];
|
||||||
|
if (value && (typeof value === 'boolean' ? value : true)) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle each blocks {{#each array}}...{{/each}}
|
||||||
|
result = result.replace(/\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g, (_, varName, content) => {
|
||||||
|
const array = variables[varName];
|
||||||
|
if (!Array.isArray(array)) return '';
|
||||||
|
|
||||||
|
return array
|
||||||
|
.map((item) => {
|
||||||
|
let itemContent = content;
|
||||||
|
if (typeof item === 'object' && item !== null) {
|
||||||
|
// Replace {{this.property}} with actual values
|
||||||
|
Object.entries(item).forEach(([key, value]) => {
|
||||||
|
itemContent = itemContent.replace(
|
||||||
|
new RegExp(`\\{\\{this\\.${key}\\}\\}`, 'g'),
|
||||||
|
String(value ?? '')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
itemContent = itemContent.replace(/\{\{this\}\}/g, String(item));
|
||||||
|
}
|
||||||
|
return itemContent;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle simple variable replacements {{variable}}
|
||||||
|
result = result.replace(/\{\{(\w+)\}\}/g, (_, varName) => {
|
||||||
|
const value = variables[varName];
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up extra whitespace
|
||||||
|
result = result.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats currency for prompts (Mexican peso format)
|
||||||
|
*/
|
||||||
|
export function formatCurrencyForPrompt(amount: number, currency: string = 'MXN'): string {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats percentage for prompts
|
||||||
|
*/
|
||||||
|
export function formatPercentageForPrompt(value: number): string {
|
||||||
|
const sign = value > 0 ? '+' : '';
|
||||||
|
return `${sign}${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats change description for prompts
|
||||||
|
*/
|
||||||
|
export function formatChangeForPrompt(change: number, changePercent: number): string {
|
||||||
|
if (Math.abs(changePercent) < 1) {
|
||||||
|
return 'sin cambio significativo';
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = change > 0 ? 'aumento' : 'disminucion';
|
||||||
|
return `${direction} de ${formatPercentageForPrompt(Math.abs(changePercent))}`;
|
||||||
|
}
|
||||||
885
apps/api/src/services/reports/report.templates.ts
Normal file
885
apps/api/src/services/reports/report.templates.ts
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
/**
|
||||||
|
* Report Templates
|
||||||
|
*
|
||||||
|
* Templates and formatting utilities for executive reports.
|
||||||
|
* Supports PYME, Startup, and Enterprise company types with Mexican localization.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ReportSection,
|
||||||
|
ReportSectionType,
|
||||||
|
CompanyType,
|
||||||
|
ChartData,
|
||||||
|
ChartConfig,
|
||||||
|
TableData,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
KPISummary,
|
||||||
|
KPIValue,
|
||||||
|
ReportMetricsData,
|
||||||
|
MONTH_NAMES_ES,
|
||||||
|
QUARTER_NAMES_ES,
|
||||||
|
DEFAULT_REPORT_SECTIONS,
|
||||||
|
} from './report.types';
|
||||||
|
import {
|
||||||
|
MetricPeriod,
|
||||||
|
formatCurrency,
|
||||||
|
formatPercentage,
|
||||||
|
formatNumber,
|
||||||
|
} from '../metrics/metrics.types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Number Formatting (Spanish Mexican)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format number in Mexican Spanish locale
|
||||||
|
*/
|
||||||
|
export function formatNumberMX(value: number, decimals: number = 0): string {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format currency in Mexican Pesos
|
||||||
|
*/
|
||||||
|
export function formatCurrencyMX(amount: number, currency: string = 'MXN'): string {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format currency with decimals
|
||||||
|
*/
|
||||||
|
export function formatCurrencyMXDecimals(amount: number, currency: string = 'MXN'): string {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format percentage in Mexican format
|
||||||
|
*/
|
||||||
|
export function formatPercentageMX(value: number, decimals: number = 1): string {
|
||||||
|
return `${formatNumberMX(value, decimals)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format percentage with sign
|
||||||
|
*/
|
||||||
|
export function formatPercentageWithSign(value: number, decimals: number = 1): string {
|
||||||
|
const sign = value > 0 ? '+' : '';
|
||||||
|
return `${sign}${formatPercentageMX(value, decimals)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format large numbers in compact form (K, M, B)
|
||||||
|
*/
|
||||||
|
export function formatCompactNumber(value: number): string {
|
||||||
|
if (Math.abs(value) >= 1_000_000_000) {
|
||||||
|
return `${formatNumberMX(value / 1_000_000_000, 1)}B`;
|
||||||
|
}
|
||||||
|
if (Math.abs(value) >= 1_000_000) {
|
||||||
|
return `${formatNumberMX(value / 1_000_000, 1)}M`;
|
||||||
|
}
|
||||||
|
if (Math.abs(value) >= 1_000) {
|
||||||
|
return `${formatNumberMX(value / 1_000, 1)}K`;
|
||||||
|
}
|
||||||
|
return formatNumberMX(value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Period Formatting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format period for display
|
||||||
|
*/
|
||||||
|
export function formatPeriod(period: MetricPeriod): string {
|
||||||
|
switch (period.type) {
|
||||||
|
case 'daily':
|
||||||
|
return `${period.day} de ${MONTH_NAMES_ES.long[(period.month || 1) - 1]} ${period.year}`;
|
||||||
|
case 'weekly':
|
||||||
|
return `Semana ${period.week} de ${period.year}`;
|
||||||
|
case 'monthly':
|
||||||
|
return `${MONTH_NAMES_ES.long[(period.month || 1) - 1]} ${period.year}`;
|
||||||
|
case 'quarterly':
|
||||||
|
return `${QUARTER_NAMES_ES[(period.quarter || 1) - 1]} ${period.year}`;
|
||||||
|
case 'yearly':
|
||||||
|
return `Ano Fiscal ${period.year}`;
|
||||||
|
default:
|
||||||
|
return `${period.year}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format period range for display
|
||||||
|
*/
|
||||||
|
export function formatPeriodRange(dateFrom: Date, dateTo: Date): string {
|
||||||
|
const fromDay = dateFrom.getDate();
|
||||||
|
const fromMonth = MONTH_NAMES_ES.long[dateFrom.getMonth()];
|
||||||
|
const fromYear = dateFrom.getFullYear();
|
||||||
|
const toDay = dateTo.getDate();
|
||||||
|
const toMonth = MONTH_NAMES_ES.long[dateTo.getMonth()];
|
||||||
|
const toYear = dateTo.getFullYear();
|
||||||
|
|
||||||
|
if (fromYear === toYear && fromMonth === toMonth) {
|
||||||
|
return `${fromDay} al ${toDay} de ${fromMonth} ${fromYear}`;
|
||||||
|
}
|
||||||
|
if (fromYear === toYear) {
|
||||||
|
return `${fromDay} de ${fromMonth} al ${toDay} de ${toMonth} ${fromYear}`;
|
||||||
|
}
|
||||||
|
return `${fromDay} de ${fromMonth} ${fromYear} al ${toDay} de ${toMonth} ${toYear}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get report title based on type and period
|
||||||
|
*/
|
||||||
|
export function getReportTitle(type: string, period: MetricPeriod): string {
|
||||||
|
const periodStr = formatPeriod(period);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'monthly':
|
||||||
|
return `Reporte Financiero Mensual - ${periodStr}`;
|
||||||
|
case 'quarterly':
|
||||||
|
return `Reporte Financiero Trimestral - ${periodStr}`;
|
||||||
|
case 'annual':
|
||||||
|
return `Reporte Financiero Anual - ${periodStr}`;
|
||||||
|
case 'custom':
|
||||||
|
return `Reporte Financiero Personalizado`;
|
||||||
|
default:
|
||||||
|
return `Reporte Financiero - ${periodStr}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Section Templates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get section title and order
|
||||||
|
*/
|
||||||
|
export function getSectionConfig(sectionType: ReportSectionType): {
|
||||||
|
title: string;
|
||||||
|
order: number;
|
||||||
|
} {
|
||||||
|
const configs: Record<ReportSectionType, { title: string; order: number }> = {
|
||||||
|
executive_summary: { title: 'Resumen Ejecutivo', order: 1 },
|
||||||
|
kpis_overview: { title: 'Indicadores Clave (KPIs)', order: 2 },
|
||||||
|
revenue_analysis: { title: 'Analisis de Ingresos', order: 3 },
|
||||||
|
expense_analysis: { title: 'Analisis de Gastos', order: 4 },
|
||||||
|
profit_analysis: { title: 'Analisis de Rentabilidad', order: 5 },
|
||||||
|
cash_flow_analysis: { title: 'Analisis de Flujo de Efectivo', order: 6 },
|
||||||
|
accounts_receivable: { title: 'Cuentas por Cobrar', order: 7 },
|
||||||
|
accounts_payable: { title: 'Cuentas por Pagar', order: 8 },
|
||||||
|
startup_metrics: { title: 'Metricas Startup/SaaS', order: 9 },
|
||||||
|
enterprise_metrics: { title: 'Metricas Corporativas', order: 10 },
|
||||||
|
anomalies: { title: 'Alertas y Anomalias', order: 11 },
|
||||||
|
recommendations: { title: 'Recomendaciones', order: 12 },
|
||||||
|
forecast: { title: 'Proyecciones', order: 13 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return configs[sectionType] || { title: sectionType, order: 99 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sections for company type
|
||||||
|
*/
|
||||||
|
export function getSectionsForCompanyType(companyType: CompanyType): ReportSectionType[] {
|
||||||
|
return DEFAULT_REPORT_SECTIONS[companyType];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// KPI Templates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine KPI status based on value and trend
|
||||||
|
*/
|
||||||
|
export function getKPIStatus(
|
||||||
|
value: number,
|
||||||
|
change: number,
|
||||||
|
metricType: string
|
||||||
|
): 'good' | 'warning' | 'critical' | 'neutral' {
|
||||||
|
// Metrics where higher is better
|
||||||
|
const higherIsBetter = ['revenue', 'netProfit', 'profitMargin', 'cashFlow', 'mrr', 'arr', 'ebitda', 'currentRatio', 'quickRatio'];
|
||||||
|
|
||||||
|
// Metrics where lower is better
|
||||||
|
const lowerIsBetter = ['expenses', 'churnRate', 'burnRate', 'accountsPayable'];
|
||||||
|
|
||||||
|
// Metrics that are neutral (context-dependent)
|
||||||
|
const neutral = ['accountsReceivable'];
|
||||||
|
|
||||||
|
if (neutral.includes(metricType)) {
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (higherIsBetter.includes(metricType)) {
|
||||||
|
if (change > 5) return 'good';
|
||||||
|
if (change < -10) return 'critical';
|
||||||
|
if (change < -5) return 'warning';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerIsBetter.includes(metricType)) {
|
||||||
|
if (change < -5) return 'good';
|
||||||
|
if (change > 10) return 'critical';
|
||||||
|
if (change > 5) return 'warning';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create KPI value object
|
||||||
|
*/
|
||||||
|
export function createKPIValue(
|
||||||
|
current: number,
|
||||||
|
previous: number | undefined,
|
||||||
|
metricType: string,
|
||||||
|
options: { currency?: string; unit?: string; isPercentage?: boolean } = {}
|
||||||
|
): KPIValue {
|
||||||
|
const change = previous !== undefined ? current - previous : undefined;
|
||||||
|
const changePercentage = previous !== undefined && previous !== 0
|
||||||
|
? ((current - previous) / Math.abs(previous)) * 100
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let trend: 'up' | 'down' | 'stable' = 'stable';
|
||||||
|
if (change !== undefined) {
|
||||||
|
if (change > 0) trend = 'up';
|
||||||
|
else if (change < 0) trend = 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatted: string;
|
||||||
|
let formattedChange: string | undefined;
|
||||||
|
|
||||||
|
if (options.currency) {
|
||||||
|
formatted = formatCurrencyMX(current, options.currency);
|
||||||
|
if (change !== undefined) {
|
||||||
|
formattedChange = formatCurrencyMX(change, options.currency);
|
||||||
|
}
|
||||||
|
} else if (options.isPercentage) {
|
||||||
|
formatted = formatPercentageMX(current);
|
||||||
|
if (changePercentage !== undefined) {
|
||||||
|
formattedChange = formatPercentageWithSign(changePercentage);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formatted = formatNumberMX(current, 1);
|
||||||
|
if (change !== undefined) {
|
||||||
|
formattedChange = `${change > 0 ? '+' : ''}${formatNumberMX(change, 1)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
change,
|
||||||
|
changePercentage,
|
||||||
|
trend,
|
||||||
|
formatted,
|
||||||
|
formattedChange,
|
||||||
|
unit: options.unit,
|
||||||
|
status: getKPIStatus(current, changePercentage || 0, metricType),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build KPI summary from metrics data
|
||||||
|
*/
|
||||||
|
export function buildKPISummary(data: ReportMetricsData): KPISummary {
|
||||||
|
const currency = data.currency;
|
||||||
|
|
||||||
|
const summary: KPISummary = {
|
||||||
|
revenue: createKPIValue(
|
||||||
|
data.revenue.totalRevenue.amount,
|
||||||
|
data.comparisons.revenue.previous,
|
||||||
|
'revenue',
|
||||||
|
{ currency }
|
||||||
|
),
|
||||||
|
expenses: createKPIValue(
|
||||||
|
data.expenses.totalExpenses.amount,
|
||||||
|
data.comparisons.expenses.previous,
|
||||||
|
'expenses',
|
||||||
|
{ currency }
|
||||||
|
),
|
||||||
|
netProfit: createKPIValue(
|
||||||
|
data.netProfit.profit.amount,
|
||||||
|
data.comparisons.netProfit.previous,
|
||||||
|
'netProfit',
|
||||||
|
{ currency }
|
||||||
|
),
|
||||||
|
profitMargin: createKPIValue(
|
||||||
|
data.netProfit.margin.value * 100,
|
||||||
|
undefined,
|
||||||
|
'profitMargin',
|
||||||
|
{ isPercentage: true }
|
||||||
|
),
|
||||||
|
cashFlow: createKPIValue(
|
||||||
|
data.cashFlow.netCashFlow.amount,
|
||||||
|
data.comparisons.cashFlow.previous,
|
||||||
|
'cashFlow',
|
||||||
|
{ currency }
|
||||||
|
),
|
||||||
|
accountsReceivable: createKPIValue(
|
||||||
|
data.accountsReceivable.totalReceivable.amount,
|
||||||
|
undefined,
|
||||||
|
'accountsReceivable',
|
||||||
|
{ currency }
|
||||||
|
),
|
||||||
|
accountsPayable: createKPIValue(
|
||||||
|
data.accountsPayable.totalPayable.amount,
|
||||||
|
undefined,
|
||||||
|
'accountsPayable',
|
||||||
|
{ currency }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add startup metrics if available
|
||||||
|
if (data.startup) {
|
||||||
|
summary.mrr = createKPIValue(
|
||||||
|
data.startup.mrr.mrr.amount,
|
||||||
|
undefined,
|
||||||
|
'mrr',
|
||||||
|
{ currency }
|
||||||
|
);
|
||||||
|
summary.arr = createKPIValue(
|
||||||
|
data.startup.arr.arr.amount,
|
||||||
|
undefined,
|
||||||
|
'arr',
|
||||||
|
{ currency }
|
||||||
|
);
|
||||||
|
summary.churnRate = createKPIValue(
|
||||||
|
data.startup.churnRate.churnRate.value * 100,
|
||||||
|
undefined,
|
||||||
|
'churnRate',
|
||||||
|
{ isPercentage: true }
|
||||||
|
);
|
||||||
|
summary.runway = createKPIValue(
|
||||||
|
data.startup.runway.runwayMonths,
|
||||||
|
undefined,
|
||||||
|
'runway',
|
||||||
|
{ unit: 'meses' }
|
||||||
|
);
|
||||||
|
summary.burnRate = createKPIValue(
|
||||||
|
data.startup.burnRate.netBurnRate.amount,
|
||||||
|
undefined,
|
||||||
|
'burnRate',
|
||||||
|
{ currency }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enterprise metrics if available
|
||||||
|
if (data.enterprise) {
|
||||||
|
summary.ebitda = createKPIValue(
|
||||||
|
data.enterprise.ebitda.ebitda.amount,
|
||||||
|
undefined,
|
||||||
|
'ebitda',
|
||||||
|
{ currency }
|
||||||
|
);
|
||||||
|
summary.currentRatio = createKPIValue(
|
||||||
|
data.enterprise.currentRatio.ratio.value,
|
||||||
|
undefined,
|
||||||
|
'currentRatio',
|
||||||
|
{ unit: 'x' }
|
||||||
|
);
|
||||||
|
summary.quickRatio = createKPIValue(
|
||||||
|
data.enterprise.quickRatio.ratio.value,
|
||||||
|
undefined,
|
||||||
|
'quickRatio',
|
||||||
|
{ unit: 'x' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Chart Templates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default chart colors for Horux
|
||||||
|
*/
|
||||||
|
export const CHART_COLORS = {
|
||||||
|
primary: ['#2563EB', '#3B82F6', '#60A5FA', '#93C5FD', '#BFDBFE'],
|
||||||
|
secondary: ['#1E40AF', '#1D4ED8', '#2563EB', '#3B82F6', '#60A5FA'],
|
||||||
|
success: ['#059669', '#10B981', '#34D399', '#6EE7B7', '#A7F3D0'],
|
||||||
|
warning: ['#D97706', '#F59E0B', '#FBBF24', '#FCD34D', '#FDE68A'],
|
||||||
|
danger: ['#DC2626', '#EF4444', '#F87171', '#FCA5A5', '#FECACA'],
|
||||||
|
neutral: ['#4B5563', '#6B7280', '#9CA3AF', '#D1D5DB', '#E5E7EB'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create revenue by category chart
|
||||||
|
*/
|
||||||
|
export function createRevenueByCategoryChart(data: ReportMetricsData): ChartData {
|
||||||
|
const chartData = data.revenue.byCategory.slice(0, 6).map((cat, index) => ({
|
||||||
|
label: cat.categoryName,
|
||||||
|
value: cat.amount.amount,
|
||||||
|
color: CHART_COLORS.primary[index % CHART_COLORS.primary.length],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'revenue-by-category',
|
||||||
|
type: 'pie',
|
||||||
|
title: 'Ingresos por Categoria',
|
||||||
|
data: chartData,
|
||||||
|
config: {
|
||||||
|
showLegend: true,
|
||||||
|
showValues: true,
|
||||||
|
formatAsCurrency: true,
|
||||||
|
colors: CHART_COLORS.primary,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create expense by category chart
|
||||||
|
*/
|
||||||
|
export function createExpenseByCategoryChart(data: ReportMetricsData): ChartData {
|
||||||
|
const chartData = data.expenses.byCategory.slice(0, 6).map((cat, index) => ({
|
||||||
|
label: cat.categoryName,
|
||||||
|
value: cat.amount.amount,
|
||||||
|
color: CHART_COLORS.warning[index % CHART_COLORS.warning.length],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'expenses-by-category',
|
||||||
|
type: 'donut',
|
||||||
|
title: 'Gastos por Categoria',
|
||||||
|
data: chartData,
|
||||||
|
config: {
|
||||||
|
showLegend: true,
|
||||||
|
showValues: true,
|
||||||
|
formatAsCurrency: true,
|
||||||
|
colors: CHART_COLORS.warning,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create cash flow waterfall chart
|
||||||
|
*/
|
||||||
|
export function createCashFlowWaterfallChart(data: ReportMetricsData): ChartData {
|
||||||
|
const cf = data.cashFlow;
|
||||||
|
const chartData = [
|
||||||
|
{ label: 'Saldo Inicial', value: cf.openingBalance.amount, color: CHART_COLORS.neutral[0] },
|
||||||
|
{ label: 'Operaciones', value: cf.operatingActivities.amount, color: cf.operatingActivities.amount >= 0 ? CHART_COLORS.success[0] : CHART_COLORS.danger[0] },
|
||||||
|
{ label: 'Inversiones', value: cf.investingActivities.amount, color: cf.investingActivities.amount >= 0 ? CHART_COLORS.success[1] : CHART_COLORS.danger[1] },
|
||||||
|
{ label: 'Financiamiento', value: cf.financingActivities.amount, color: cf.financingActivities.amount >= 0 ? CHART_COLORS.success[2] : CHART_COLORS.danger[2] },
|
||||||
|
{ label: 'Saldo Final', value: cf.closingBalance.amount, color: CHART_COLORS.primary[0] },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'cash-flow-waterfall',
|
||||||
|
type: 'waterfall',
|
||||||
|
title: 'Flujo de Efectivo',
|
||||||
|
data: chartData,
|
||||||
|
config: {
|
||||||
|
showLegend: false,
|
||||||
|
showValues: true,
|
||||||
|
formatAsCurrency: true,
|
||||||
|
colors: CHART_COLORS.primary,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create cash flow trend chart (daily breakdown)
|
||||||
|
*/
|
||||||
|
export function createCashFlowTrendChart(data: ReportMetricsData): ChartData {
|
||||||
|
const chartData = data.cashFlow.breakdown.slice(-30).map((day) => ({
|
||||||
|
label: new Date(day.date).toLocaleDateString('es-MX', { day: '2-digit', month: 'short' }),
|
||||||
|
value: day.balance,
|
||||||
|
secondaryValue: day.netFlow,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'cash-flow-trend',
|
||||||
|
type: 'area',
|
||||||
|
title: 'Tendencia de Saldo de Efectivo',
|
||||||
|
data: chartData,
|
||||||
|
config: {
|
||||||
|
xAxisLabel: 'Fecha',
|
||||||
|
yAxisLabel: 'Saldo (MXN)',
|
||||||
|
showLegend: true,
|
||||||
|
showValues: false,
|
||||||
|
formatAsCurrency: true,
|
||||||
|
colors: [CHART_COLORS.primary[0], CHART_COLORS.secondary[0]],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create profit breakdown chart
|
||||||
|
*/
|
||||||
|
export function createProfitBreakdownChart(data: ReportMetricsData): ChartData {
|
||||||
|
const revenue = data.revenue.totalRevenue.amount;
|
||||||
|
const cogs = data.grossProfit.costs.amount;
|
||||||
|
const grossProfit = data.grossProfit.profit.amount;
|
||||||
|
const opex = data.expenses.totalExpenses.amount - cogs;
|
||||||
|
const netProfit = data.netProfit.profit.amount;
|
||||||
|
|
||||||
|
const chartData = [
|
||||||
|
{ label: 'Ingresos', value: revenue, color: CHART_COLORS.success[0] },
|
||||||
|
{ label: 'Costo de Ventas', value: -cogs, color: CHART_COLORS.danger[0] },
|
||||||
|
{ label: 'Utilidad Bruta', value: grossProfit, color: CHART_COLORS.primary[0] },
|
||||||
|
{ label: 'Gastos Operativos', value: -opex, color: CHART_COLORS.warning[0] },
|
||||||
|
{ label: 'Utilidad Neta', value: netProfit, color: netProfit >= 0 ? CHART_COLORS.success[1] : CHART_COLORS.danger[1] },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'profit-breakdown',
|
||||||
|
type: 'waterfall',
|
||||||
|
title: 'Desglose de Rentabilidad',
|
||||||
|
data: chartData,
|
||||||
|
config: {
|
||||||
|
showLegend: false,
|
||||||
|
showValues: true,
|
||||||
|
formatAsCurrency: true,
|
||||||
|
colors: CHART_COLORS.primary,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create aging buckets chart
|
||||||
|
*/
|
||||||
|
export function createAgingChart(data: ReportMetricsData, type: 'receivable' | 'payable'): ChartData {
|
||||||
|
const agingData = type === 'receivable' ? data.agingReceivable : data.agingPayable;
|
||||||
|
|
||||||
|
if (!agingData) {
|
||||||
|
return {
|
||||||
|
id: `aging-${type}`,
|
||||||
|
type: 'bar',
|
||||||
|
title: type === 'receivable' ? 'Antiguedad de Cuentas por Cobrar' : 'Antiguedad de Cuentas por Pagar',
|
||||||
|
data: [],
|
||||||
|
config: {
|
||||||
|
showLegend: false,
|
||||||
|
showValues: true,
|
||||||
|
formatAsCurrency: true,
|
||||||
|
colors: CHART_COLORS.primary,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
CHART_COLORS.success[0], // Current - green
|
||||||
|
CHART_COLORS.primary[0], // 1-30
|
||||||
|
CHART_COLORS.warning[0], // 31-60
|
||||||
|
CHART_COLORS.warning[1], // 61-90
|
||||||
|
CHART_COLORS.danger[0], // 90+
|
||||||
|
];
|
||||||
|
|
||||||
|
const chartData = agingData.buckets.map((bucket, index) => ({
|
||||||
|
label: bucket.label,
|
||||||
|
value: bucket.amount.amount,
|
||||||
|
color: colors[index],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `aging-${type}`,
|
||||||
|
type: 'bar',
|
||||||
|
title: type === 'receivable' ? 'Antiguedad de Cuentas por Cobrar' : 'Antiguedad de Cuentas por Pagar',
|
||||||
|
data: chartData,
|
||||||
|
config: {
|
||||||
|
xAxisLabel: 'Dias de Antiguedad',
|
||||||
|
yAxisLabel: 'Monto (MXN)',
|
||||||
|
showLegend: false,
|
||||||
|
showValues: true,
|
||||||
|
formatAsCurrency: true,
|
||||||
|
colors,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Table Templates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create cell helper
|
||||||
|
*/
|
||||||
|
function createCell(
|
||||||
|
value: string | number,
|
||||||
|
options: {
|
||||||
|
alignment?: 'left' | 'center' | 'right';
|
||||||
|
isBold?: boolean;
|
||||||
|
color?: string;
|
||||||
|
formatAsCurrency?: boolean;
|
||||||
|
formatAsPercentage?: boolean;
|
||||||
|
currency?: string;
|
||||||
|
} = {}
|
||||||
|
): TableCell {
|
||||||
|
let formatted: string;
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (options.formatAsCurrency) {
|
||||||
|
formatted = formatCurrencyMX(value, options.currency || 'MXN');
|
||||||
|
} else if (options.formatAsPercentage) {
|
||||||
|
formatted = formatPercentageMX(value);
|
||||||
|
} else {
|
||||||
|
formatted = formatNumberMX(value, 2);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formatted = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
formatted,
|
||||||
|
alignment: options.alignment || (typeof value === 'number' ? 'right' : 'left'),
|
||||||
|
isBold: options.isBold,
|
||||||
|
color: options.color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create revenue summary table
|
||||||
|
*/
|
||||||
|
export function createRevenueSummaryTable(data: ReportMetricsData): TableData {
|
||||||
|
const currency = data.currency;
|
||||||
|
|
||||||
|
const rows: TableRow[] = data.revenue.byCategory.slice(0, 10).map((cat) => ({
|
||||||
|
cells: [
|
||||||
|
createCell(cat.categoryName),
|
||||||
|
createCell(cat.amount.amount, { formatAsCurrency: true, currency }),
|
||||||
|
createCell(cat.percentage, { formatAsPercentage: true }),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalsRow: TableRow = {
|
||||||
|
cells: [
|
||||||
|
createCell('Total', { isBold: true }),
|
||||||
|
createCell(data.revenue.totalRevenue.amount, { formatAsCurrency: true, currency, isBold: true }),
|
||||||
|
createCell(100, { formatAsPercentage: true, isBold: true }),
|
||||||
|
],
|
||||||
|
isHighlighted: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'revenue-summary',
|
||||||
|
title: 'Resumen de Ingresos por Categoria',
|
||||||
|
headers: ['Categoria', 'Monto', '% del Total'],
|
||||||
|
rows,
|
||||||
|
totals: totalsRow,
|
||||||
|
footnotes: [
|
||||||
|
`Numero de facturas: ${formatNumberMX(data.revenue.invoiceCount, 0)}`,
|
||||||
|
`Valor promedio por factura: ${formatCurrencyMX(data.revenue.averageInvoiceValue.amount, currency)}`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create expense summary table
|
||||||
|
*/
|
||||||
|
export function createExpenseSummaryTable(data: ReportMetricsData): TableData {
|
||||||
|
const currency = data.currency;
|
||||||
|
|
||||||
|
const rows: TableRow[] = data.expenses.byCategory.slice(0, 10).map((cat) => ({
|
||||||
|
cells: [
|
||||||
|
createCell(cat.categoryName),
|
||||||
|
createCell(cat.amount.amount, { formatAsCurrency: true, currency }),
|
||||||
|
createCell(cat.percentage, { formatAsPercentage: true }),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalsRow: TableRow = {
|
||||||
|
cells: [
|
||||||
|
createCell('Total', { isBold: true }),
|
||||||
|
createCell(data.expenses.totalExpenses.amount, { formatAsCurrency: true, currency, isBold: true }),
|
||||||
|
createCell(100, { formatAsPercentage: true, isBold: true }),
|
||||||
|
],
|
||||||
|
isHighlighted: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixedPct = (data.expenses.fixedExpenses.amount / data.expenses.totalExpenses.amount) * 100;
|
||||||
|
const variablePct = 100 - fixedPct;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'expense-summary',
|
||||||
|
title: 'Resumen de Gastos por Categoria',
|
||||||
|
headers: ['Categoria', 'Monto', '% del Total'],
|
||||||
|
rows,
|
||||||
|
totals: totalsRow,
|
||||||
|
footnotes: [
|
||||||
|
`Gastos fijos: ${formatCurrencyMX(data.expenses.fixedExpenses.amount, currency)} (${formatPercentageMX(fixedPct)})`,
|
||||||
|
`Gastos variables: ${formatCurrencyMX(data.expenses.variableExpenses.amount, currency)} (${formatPercentageMX(variablePct)})`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create KPI comparison table
|
||||||
|
*/
|
||||||
|
export function createKPIComparisonTable(summary: KPISummary, currency: string): TableData {
|
||||||
|
const rows: TableRow[] = [
|
||||||
|
{
|
||||||
|
cells: [
|
||||||
|
createCell('Ingresos'),
|
||||||
|
createCell(summary.revenue.current, { formatAsCurrency: true, currency }),
|
||||||
|
createCell(summary.revenue.previous || 0, { formatAsCurrency: true, currency }),
|
||||||
|
createCell(summary.revenue.changePercentage || 0, {
|
||||||
|
formatAsPercentage: true,
|
||||||
|
color: (summary.revenue.changePercentage || 0) >= 0 ? '#10B981' : '#EF4444',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cells: [
|
||||||
|
createCell('Gastos'),
|
||||||
|
createCell(summary.expenses.current, { formatAsCurrency: true, currency }),
|
||||||
|
createCell(summary.expenses.previous || 0, { formatAsCurrency: true, currency }),
|
||||||
|
createCell(summary.expenses.changePercentage || 0, {
|
||||||
|
formatAsPercentage: true,
|
||||||
|
color: (summary.expenses.changePercentage || 0) <= 0 ? '#10B981' : '#EF4444',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cells: [
|
||||||
|
createCell('Utilidad Neta'),
|
||||||
|
createCell(summary.netProfit.current, { formatAsCurrency: true, currency }),
|
||||||
|
createCell(summary.netProfit.previous || 0, { formatAsCurrency: true, currency }),
|
||||||
|
createCell(summary.netProfit.changePercentage || 0, {
|
||||||
|
formatAsPercentage: true,
|
||||||
|
color: (summary.netProfit.changePercentage || 0) >= 0 ? '#10B981' : '#EF4444',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
isHighlighted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cells: [
|
||||||
|
createCell('Flujo de Efectivo'),
|
||||||
|
createCell(summary.cashFlow.current, { formatAsCurrency: true, currency }),
|
||||||
|
createCell(summary.cashFlow.previous || 0, { formatAsCurrency: true, currency }),
|
||||||
|
createCell(summary.cashFlow.changePercentage || 0, {
|
||||||
|
formatAsPercentage: true,
|
||||||
|
color: (summary.cashFlow.changePercentage || 0) >= 0 ? '#10B981' : '#EF4444',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'kpi-comparison',
|
||||||
|
title: 'Comparacion de KPIs con Periodo Anterior',
|
||||||
|
headers: ['Metrica', 'Actual', 'Anterior', 'Cambio %'],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create accounts aging table
|
||||||
|
*/
|
||||||
|
export function createAgingTable(data: ReportMetricsData, type: 'receivable' | 'payable'): TableData {
|
||||||
|
const agingData = type === 'receivable' ? data.agingReceivable : data.agingPayable;
|
||||||
|
const currency = data.currency;
|
||||||
|
|
||||||
|
if (!agingData) {
|
||||||
|
return {
|
||||||
|
id: `aging-table-${type}`,
|
||||||
|
title: type === 'receivable' ? 'Antiguedad de Cuentas por Cobrar' : 'Antiguedad de Cuentas por Pagar',
|
||||||
|
headers: [],
|
||||||
|
rows: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: TableRow[] = agingData.buckets.map((bucket) => ({
|
||||||
|
cells: [
|
||||||
|
createCell(bucket.label),
|
||||||
|
createCell(bucket.amount.amount, { formatAsCurrency: true, currency }),
|
||||||
|
createCell(bucket.count),
|
||||||
|
createCell(bucket.percentage, { formatAsPercentage: true }),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalsRow: TableRow = {
|
||||||
|
cells: [
|
||||||
|
createCell('Total', { isBold: true }),
|
||||||
|
createCell(agingData.totalAmount.amount, { formatAsCurrency: true, currency, isBold: true }),
|
||||||
|
createCell(agingData.buckets.reduce((sum, b) => sum + b.count, 0), { isBold: true }),
|
||||||
|
createCell(100, { formatAsPercentage: true, isBold: true }),
|
||||||
|
],
|
||||||
|
isHighlighted: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `aging-table-${type}`,
|
||||||
|
title: type === 'receivable' ? 'Antiguedad de Cuentas por Cobrar' : 'Antiguedad de Cuentas por Pagar',
|
||||||
|
headers: ['Antiguedad', 'Monto', 'Documentos', '% del Total'],
|
||||||
|
rows,
|
||||||
|
totals: totalsRow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create anomalies table
|
||||||
|
*/
|
||||||
|
export function createAnomaliesTable(data: ReportMetricsData): TableData {
|
||||||
|
const severityColors: Record<string, string> = {
|
||||||
|
critical: '#DC2626',
|
||||||
|
high: '#EA580C',
|
||||||
|
medium: '#D97706',
|
||||||
|
low: '#65A30D',
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: TableRow[] = data.anomalies.slice(0, 10).map((anomaly) => ({
|
||||||
|
cells: [
|
||||||
|
createCell(anomaly.description),
|
||||||
|
createCell(anomaly.severity.toUpperCase(), { color: severityColors[anomaly.severity] }),
|
||||||
|
createCell(formatPercentageMX(anomaly.deviationPercentage)),
|
||||||
|
createCell(anomaly.recommendation),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'anomalies',
|
||||||
|
title: 'Alertas y Anomalias Detectadas',
|
||||||
|
headers: ['Descripcion', 'Severidad', 'Desviacion', 'Recomendacion'],
|
||||||
|
rows,
|
||||||
|
footnotes: rows.length === 0 ? ['No se detectaron anomalias en este periodo.'] : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Chart Placeholder for PDF
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a placeholder representation for charts in PDF
|
||||||
|
* This creates a simple text representation that can be rendered in PDFKit
|
||||||
|
*/
|
||||||
|
export function generateChartPlaceholder(chart: ChartData): {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
dataPoints: { label: string; value: string; percentage?: string }[];
|
||||||
|
total?: string;
|
||||||
|
} {
|
||||||
|
const total = chart.data.reduce((sum, d) => sum + d.value, 0);
|
||||||
|
|
||||||
|
const dataPoints = chart.data.map((d) => ({
|
||||||
|
label: d.label,
|
||||||
|
value: chart.config.formatAsCurrency
|
||||||
|
? formatCurrencyMX(d.value)
|
||||||
|
: formatNumberMX(d.value, 0),
|
||||||
|
percentage: total > 0 ? formatPercentageMX((d.value / total) * 100) : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: chart.type,
|
||||||
|
title: chart.title,
|
||||||
|
dataPoints,
|
||||||
|
total: chart.config.formatAsCurrency ? formatCurrencyMX(total) : formatNumberMX(total, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
516
apps/api/src/services/reports/report.types.ts
Normal file
516
apps/api/src/services/reports/report.types.ts
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
/**
|
||||||
|
* Report Types
|
||||||
|
*
|
||||||
|
* Type definitions for the Horux Strategy executive reporting system.
|
||||||
|
* Supports monthly, quarterly, annual, and custom reports with AI-generated narratives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
MetricPeriod,
|
||||||
|
DateRange,
|
||||||
|
RevenueResult,
|
||||||
|
ExpensesResult,
|
||||||
|
ProfitResult,
|
||||||
|
CashFlowResult,
|
||||||
|
AccountsReceivableResult,
|
||||||
|
AccountsPayableResult,
|
||||||
|
AgingReportResult,
|
||||||
|
VATPositionResult,
|
||||||
|
MRRResult,
|
||||||
|
ARRResult,
|
||||||
|
ChurnRateResult,
|
||||||
|
RunwayResult,
|
||||||
|
BurnRateResult,
|
||||||
|
EBITDAResult,
|
||||||
|
CurrentRatioResult,
|
||||||
|
QuickRatioResult,
|
||||||
|
Anomaly,
|
||||||
|
MetricComparison,
|
||||||
|
} from '../metrics/metrics.types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Types and Enums
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ReportType = 'monthly' | 'quarterly' | 'annual' | 'custom';
|
||||||
|
|
||||||
|
export type ReportFormat = 'pdf' | 'json' | 'html';
|
||||||
|
|
||||||
|
export type CompanyType = 'pyme' | 'startup' | 'enterprise';
|
||||||
|
|
||||||
|
export type ReportSectionType =
|
||||||
|
| 'executive_summary'
|
||||||
|
| 'kpis_overview'
|
||||||
|
| 'revenue_analysis'
|
||||||
|
| 'expense_analysis'
|
||||||
|
| 'profit_analysis'
|
||||||
|
| 'cash_flow_analysis'
|
||||||
|
| 'accounts_receivable'
|
||||||
|
| 'accounts_payable'
|
||||||
|
| 'startup_metrics'
|
||||||
|
| 'enterprise_metrics'
|
||||||
|
| 'anomalies'
|
||||||
|
| 'recommendations'
|
||||||
|
| 'forecast';
|
||||||
|
|
||||||
|
export type ReportStatus = 'pending' | 'generating' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Configuration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ReportConfig {
|
||||||
|
tenantId: string;
|
||||||
|
type: ReportType;
|
||||||
|
companyType: CompanyType;
|
||||||
|
period: MetricPeriod;
|
||||||
|
dateRange: DateRange;
|
||||||
|
sections: ReportSectionType[];
|
||||||
|
format: ReportFormat;
|
||||||
|
language: 'es-MX' | 'en-US';
|
||||||
|
currency: string;
|
||||||
|
includeCharts: boolean;
|
||||||
|
includeAIAnalysis: boolean;
|
||||||
|
includeRecommendations: boolean;
|
||||||
|
compareWithPreviousPeriod: boolean;
|
||||||
|
recipientEmail?: string;
|
||||||
|
customBranding?: ReportBranding;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportBranding {
|
||||||
|
companyName: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
primaryColor: string;
|
||||||
|
secondaryColor: string;
|
||||||
|
footerText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Sections
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ReportSection {
|
||||||
|
id: string;
|
||||||
|
type: ReportSectionType;
|
||||||
|
title: string;
|
||||||
|
order: number;
|
||||||
|
data: unknown;
|
||||||
|
narrative?: string;
|
||||||
|
charts?: ChartData[];
|
||||||
|
tables?: TableData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartData {
|
||||||
|
id: string;
|
||||||
|
type: 'bar' | 'line' | 'pie' | 'donut' | 'area' | 'waterfall';
|
||||||
|
title: string;
|
||||||
|
data: ChartDataPoint[];
|
||||||
|
config: ChartConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartDataPoint {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color?: string;
|
||||||
|
secondaryValue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartConfig {
|
||||||
|
xAxisLabel?: string;
|
||||||
|
yAxisLabel?: string;
|
||||||
|
showLegend: boolean;
|
||||||
|
showValues: boolean;
|
||||||
|
formatAsCurrency: boolean;
|
||||||
|
colors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
headers: string[];
|
||||||
|
rows: TableRow[];
|
||||||
|
totals?: TableRow;
|
||||||
|
footnotes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableRow {
|
||||||
|
cells: TableCell[];
|
||||||
|
isHighlighted?: boolean;
|
||||||
|
highlightColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableCell {
|
||||||
|
value: string | number;
|
||||||
|
formatted: string;
|
||||||
|
alignment?: 'left' | 'center' | 'right';
|
||||||
|
isBold?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// KPI Summary
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface KPISummary {
|
||||||
|
revenue: KPIValue;
|
||||||
|
expenses: KPIValue;
|
||||||
|
netProfit: KPIValue;
|
||||||
|
profitMargin: KPIValue;
|
||||||
|
cashFlow: KPIValue;
|
||||||
|
accountsReceivable: KPIValue;
|
||||||
|
accountsPayable: KPIValue;
|
||||||
|
// Startup KPIs (optional)
|
||||||
|
mrr?: KPIValue;
|
||||||
|
arr?: KPIValue;
|
||||||
|
churnRate?: KPIValue;
|
||||||
|
runway?: KPIValue;
|
||||||
|
burnRate?: KPIValue;
|
||||||
|
// Enterprise KPIs (optional)
|
||||||
|
ebitda?: KPIValue;
|
||||||
|
currentRatio?: KPIValue;
|
||||||
|
quickRatio?: KPIValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KPIValue {
|
||||||
|
current: number;
|
||||||
|
previous?: number;
|
||||||
|
change?: number;
|
||||||
|
changePercentage?: number;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
formatted: string;
|
||||||
|
formattedChange?: string;
|
||||||
|
unit?: string;
|
||||||
|
status: 'good' | 'warning' | 'critical' | 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Metrics Data for Report
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ReportMetricsData {
|
||||||
|
period: MetricPeriod;
|
||||||
|
currency: string;
|
||||||
|
generatedAt: Date;
|
||||||
|
|
||||||
|
// Core metrics
|
||||||
|
revenue: RevenueResult;
|
||||||
|
expenses: ExpensesResult;
|
||||||
|
grossProfit: ProfitResult;
|
||||||
|
netProfit: ProfitResult;
|
||||||
|
cashFlow: CashFlowResult;
|
||||||
|
accountsReceivable: AccountsReceivableResult;
|
||||||
|
accountsPayable: AccountsPayableResult;
|
||||||
|
agingReceivable?: AgingReportResult;
|
||||||
|
agingPayable?: AgingReportResult;
|
||||||
|
vatPosition?: VATPositionResult;
|
||||||
|
|
||||||
|
// Comparisons
|
||||||
|
comparisons: {
|
||||||
|
revenue: MetricComparison;
|
||||||
|
expenses: MetricComparison;
|
||||||
|
netProfit: MetricComparison;
|
||||||
|
cashFlow: MetricComparison;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Startup metrics (optional)
|
||||||
|
startup?: {
|
||||||
|
mrr: MRRResult;
|
||||||
|
arr: ARRResult;
|
||||||
|
churnRate: ChurnRateResult;
|
||||||
|
runway: RunwayResult;
|
||||||
|
burnRate: BurnRateResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enterprise metrics (optional)
|
||||||
|
enterprise?: {
|
||||||
|
ebitda: EBITDAResult;
|
||||||
|
currentRatio: CurrentRatioResult;
|
||||||
|
quickRatio: QuickRatioResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Anomalies detected
|
||||||
|
anomalies: Anomaly[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI Generated Content
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface AIGeneratedContent {
|
||||||
|
executiveSummary: string;
|
||||||
|
revenueAnalysis: string;
|
||||||
|
expenseAnalysis: string;
|
||||||
|
cashFlowAnalysis: string;
|
||||||
|
profitAnalysis: string;
|
||||||
|
recommendations: string[];
|
||||||
|
anomalyExplanations: AnomalyExplanation[];
|
||||||
|
forecast?: string;
|
||||||
|
riskAssessment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnomalyExplanation {
|
||||||
|
anomalyId: string;
|
||||||
|
explanation: string;
|
||||||
|
suggestedActions: string[];
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Generated Report
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface GeneratedReport {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
config: ReportConfig;
|
||||||
|
status: ReportStatus;
|
||||||
|
|
||||||
|
// Report content
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
period: MetricPeriod;
|
||||||
|
dateRange: DateRange;
|
||||||
|
generatedAt: Date;
|
||||||
|
|
||||||
|
// Sections
|
||||||
|
sections: ReportSection[];
|
||||||
|
|
||||||
|
// KPIs
|
||||||
|
kpiSummary: KPISummary;
|
||||||
|
|
||||||
|
// AI content
|
||||||
|
aiContent?: AIGeneratedContent;
|
||||||
|
|
||||||
|
// File references
|
||||||
|
pdfUrl?: string;
|
||||||
|
pdfSize?: number;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
metadata: ReportMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportMetadata {
|
||||||
|
version: string;
|
||||||
|
generationTimeMs: number;
|
||||||
|
metricsVersion: string;
|
||||||
|
aiModel?: string;
|
||||||
|
tokensUsed?: number;
|
||||||
|
sectionsGenerated: number;
|
||||||
|
chartsGenerated: number;
|
||||||
|
tablesGenerated: number;
|
||||||
|
warningsCount: number;
|
||||||
|
errorsCount: number;
|
||||||
|
warnings: string[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Storage
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface StoredReport {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: ReportType;
|
||||||
|
period: MetricPeriod;
|
||||||
|
title: string;
|
||||||
|
status: ReportStatus;
|
||||||
|
pdfUrl?: string;
|
||||||
|
pdfSize?: number;
|
||||||
|
generatedAt: Date;
|
||||||
|
expiresAt?: Date;
|
||||||
|
metadata: Partial<ReportMetadata>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Generation Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ReportGenerationEvent {
|
||||||
|
reportId: string;
|
||||||
|
tenantId: string;
|
||||||
|
type: 'started' | 'section_completed' | 'ai_generated' | 'pdf_generated' | 'completed' | 'failed';
|
||||||
|
timestamp: Date;
|
||||||
|
section?: ReportSectionType;
|
||||||
|
progress?: number;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportEventCallback = (event: ReportGenerationEvent) => void;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Request/Response
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface GenerateReportRequest {
|
||||||
|
type: ReportType;
|
||||||
|
companyType?: CompanyType;
|
||||||
|
month?: number; // 1-12 for monthly reports
|
||||||
|
quarter?: number; // 1-4 for quarterly reports
|
||||||
|
year: number;
|
||||||
|
dateRange?: DateRange;
|
||||||
|
sections?: ReportSectionType[];
|
||||||
|
format?: ReportFormat;
|
||||||
|
includeAIAnalysis?: boolean;
|
||||||
|
recipientEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateReportResponse {
|
||||||
|
reportId: string;
|
||||||
|
status: ReportStatus;
|
||||||
|
estimatedCompletionTime?: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetReportResponse {
|
||||||
|
report: GeneratedReport | null;
|
||||||
|
status: ReportStatus;
|
||||||
|
progress?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PDF Generation Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PDFGenerationOptions {
|
||||||
|
pageSize: 'letter' | 'A4';
|
||||||
|
orientation: 'portrait' | 'landscape';
|
||||||
|
margins: {
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
};
|
||||||
|
branding: ReportBranding;
|
||||||
|
headerHeight: number;
|
||||||
|
footerHeight: number;
|
||||||
|
fonts: {
|
||||||
|
title: string;
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
monospace: string;
|
||||||
|
};
|
||||||
|
colors: {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
accent: string;
|
||||||
|
text: string;
|
||||||
|
textLight: string;
|
||||||
|
background: string;
|
||||||
|
success: string;
|
||||||
|
warning: string;
|
||||||
|
danger: string;
|
||||||
|
border: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PDFGenerationResult {
|
||||||
|
buffer: Buffer;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
pageCount: number;
|
||||||
|
fileSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MonthNames {
|
||||||
|
short: string[];
|
||||||
|
long: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MONTH_NAMES_ES: MonthNames = {
|
||||||
|
short: ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'],
|
||||||
|
long: [
|
||||||
|
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QUARTER_NAMES_ES = ['Q1 (Ene-Mar)', 'Q2 (Abr-Jun)', 'Q3 (Jul-Sep)', 'Q4 (Oct-Dic)'];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Configurations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const DEFAULT_REPORT_SECTIONS: Record<CompanyType, ReportSectionType[]> = {
|
||||||
|
pyme: [
|
||||||
|
'executive_summary',
|
||||||
|
'kpis_overview',
|
||||||
|
'revenue_analysis',
|
||||||
|
'expense_analysis',
|
||||||
|
'profit_analysis',
|
||||||
|
'cash_flow_analysis',
|
||||||
|
'accounts_receivable',
|
||||||
|
'accounts_payable',
|
||||||
|
'anomalies',
|
||||||
|
'recommendations',
|
||||||
|
],
|
||||||
|
startup: [
|
||||||
|
'executive_summary',
|
||||||
|
'kpis_overview',
|
||||||
|
'revenue_analysis',
|
||||||
|
'expense_analysis',
|
||||||
|
'startup_metrics',
|
||||||
|
'cash_flow_analysis',
|
||||||
|
'anomalies',
|
||||||
|
'recommendations',
|
||||||
|
'forecast',
|
||||||
|
],
|
||||||
|
enterprise: [
|
||||||
|
'executive_summary',
|
||||||
|
'kpis_overview',
|
||||||
|
'revenue_analysis',
|
||||||
|
'expense_analysis',
|
||||||
|
'profit_analysis',
|
||||||
|
'enterprise_metrics',
|
||||||
|
'cash_flow_analysis',
|
||||||
|
'accounts_receivable',
|
||||||
|
'accounts_payable',
|
||||||
|
'anomalies',
|
||||||
|
'recommendations',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_BRANDING: ReportBranding = {
|
||||||
|
companyName: 'Horux Strategy',
|
||||||
|
primaryColor: '#2563EB',
|
||||||
|
secondaryColor: '#1E40AF',
|
||||||
|
footerText: 'Generado por Horux Strategy - Tu CFO Digital',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PDF_OPTIONS: PDFGenerationOptions = {
|
||||||
|
pageSize: 'letter',
|
||||||
|
orientation: 'portrait',
|
||||||
|
margins: {
|
||||||
|
top: 72,
|
||||||
|
bottom: 72,
|
||||||
|
left: 72,
|
||||||
|
right: 72,
|
||||||
|
},
|
||||||
|
branding: DEFAULT_BRANDING,
|
||||||
|
headerHeight: 60,
|
||||||
|
footerHeight: 40,
|
||||||
|
fonts: {
|
||||||
|
title: 'Helvetica-Bold',
|
||||||
|
heading: 'Helvetica-Bold',
|
||||||
|
body: 'Helvetica',
|
||||||
|
monospace: 'Courier',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: '#2563EB',
|
||||||
|
secondary: '#1E40AF',
|
||||||
|
accent: '#3B82F6',
|
||||||
|
text: '#1F2937',
|
||||||
|
textLight: '#6B7280',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
success: '#10B981',
|
||||||
|
warning: '#F59E0B',
|
||||||
|
danger: '#EF4444',
|
||||||
|
border: '#E5E7EB',
|
||||||
|
},
|
||||||
|
};
|
||||||
455
apps/web/src/app/(dashboard)/asistente/page.tsx
Normal file
455
apps/web/src/app/(dashboard)/asistente/page.tsx
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { cn, formatCurrency, formatPercentage } from '@/lib/utils';
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
ChatInterface,
|
||||||
|
SuggestedQuestions,
|
||||||
|
defaultSuggestedQuestions,
|
||||||
|
AIInsightCard,
|
||||||
|
InsightsList,
|
||||||
|
AIInsight,
|
||||||
|
SuggestedQuestion,
|
||||||
|
} from '@/components/ai';
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
Lightbulb,
|
||||||
|
BarChart3,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
ArrowRight,
|
||||||
|
MessageSquare,
|
||||||
|
History,
|
||||||
|
Settings,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Data
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const mockInsights: AIInsight[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'positive',
|
||||||
|
priority: 'high',
|
||||||
|
title: 'Crecimiento de MRR sostenido',
|
||||||
|
description: 'Tu MRR ha crecido un 5.9% este mes, manteniendose por encima del promedio de la industria.',
|
||||||
|
metric: {
|
||||||
|
label: 'MRR Actual',
|
||||||
|
value: 125000,
|
||||||
|
change: 5.9,
|
||||||
|
format: 'currency',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
label: 'Ver detalle de metricas',
|
||||||
|
onClick: () => console.log('Ver metricas'),
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'warning',
|
||||||
|
priority: 'high',
|
||||||
|
title: 'Facturas vencidas requieren atencion',
|
||||||
|
description: 'Tienes 3 facturas vencidas por un total de $15,750. Considera enviar recordatorios.',
|
||||||
|
metric: {
|
||||||
|
label: 'Monto Vencido',
|
||||||
|
value: 15750,
|
||||||
|
format: 'currency',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
label: 'Ver cuentas por cobrar',
|
||||||
|
onClick: () => console.log('Ver cuentas'),
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: 'suggestion',
|
||||||
|
priority: 'medium',
|
||||||
|
title: 'Oportunidad de ahorro en gastos',
|
||||||
|
description: 'Identificamos suscripciones de software duplicadas que podrian consolidarse.',
|
||||||
|
metric: {
|
||||||
|
label: 'Ahorro Potencial',
|
||||||
|
value: 2500,
|
||||||
|
format: 'currency',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
label: 'Revisar gastos',
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
type: 'positive',
|
||||||
|
priority: 'low',
|
||||||
|
title: 'LTV/CAC ratio excelente',
|
||||||
|
description: 'Tu ratio LTV/CAC de 6.0x indica una excelente eficiencia en adquisicion de clientes.',
|
||||||
|
metric: {
|
||||||
|
label: 'LTV/CAC',
|
||||||
|
value: '6.0x',
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
type: 'info',
|
||||||
|
priority: 'medium',
|
||||||
|
title: 'Proyeccion de flujo de caja',
|
||||||
|
description: 'Basado en tendencias actuales, tu flujo de caja proyectado para el proximo mes es positivo.',
|
||||||
|
metric: {
|
||||||
|
label: 'Flujo Proyectado',
|
||||||
|
value: 52000,
|
||||||
|
change: 12.5,
|
||||||
|
format: 'currency',
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const recentConversations = [
|
||||||
|
{ id: '1', title: 'Analisis de flujo de caja', date: 'Hace 2 horas', messages: 5 },
|
||||||
|
{ id: '2', title: 'Proyeccion Q4 2024', date: 'Ayer', messages: 12 },
|
||||||
|
{ id: '3', title: 'Optimizacion de gastos', date: 'Hace 2 dias', messages: 8 },
|
||||||
|
{ id: '4', title: 'Metricas SaaS', date: 'Hace 3 dias', messages: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickMetrics = [
|
||||||
|
{ label: 'MRR', value: 125000, change: 5.9, format: 'currency' as const },
|
||||||
|
{ label: 'Churn', value: 2.5, change: -0.7, format: 'percentage' as const, inverse: true },
|
||||||
|
{ label: 'Clientes', value: 342, change: 8.5, format: 'number' as const },
|
||||||
|
{ label: 'LTV/CAC', value: 6.0, change: 25, format: 'number' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Quick Metrics Bar
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function QuickMetricsBar() {
|
||||||
|
const formatValue = (value: number, format: 'currency' | 'percentage' | 'number') => {
|
||||||
|
switch (format) {
|
||||||
|
case 'currency':
|
||||||
|
return formatCurrency(value);
|
||||||
|
case 'percentage':
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
default:
|
||||||
|
return value.toLocaleString('es-ES');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{quickMetrics.map((metric) => {
|
||||||
|
const isPositive = metric.inverse ? metric.change <= 0 : metric.change >= 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={metric.label}
|
||||||
|
className="p-3 rounded-lg bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||||
|
{metric.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-0.5 text-xs font-medium',
|
||||||
|
isPositive
|
||||||
|
? 'text-success-600 dark:text-success-400'
|
||||||
|
: 'text-error-600 dark:text-error-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
{formatPercentage(Math.abs(metric.change))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-slate-900 dark:text-white mt-1">
|
||||||
|
{formatValue(metric.value, metric.format)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Recent Conversations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface RecentConversationsProps {
|
||||||
|
conversations: typeof recentConversations;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentConversations({ conversations, onSelect }: RecentConversationsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{conversations.map((conv) => (
|
||||||
|
<button
|
||||||
|
key={conv.id}
|
||||||
|
onClick={() => onSelect(conv.id)}
|
||||||
|
className="w-full flex items-center justify-between p-3 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-left transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-700 group-hover:bg-slate-200 dark:group-hover:bg-slate-600">
|
||||||
|
<MessageSquare className="w-4 h-4 text-slate-500 dark:text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
{conv.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{conv.date} - {conv.messages} mensajes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Page Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function AsistentePage() {
|
||||||
|
const [isFullChat, setIsFullChat] = useState(false);
|
||||||
|
const [insights, setInsights] = useState<AIInsight[]>(mockInsights);
|
||||||
|
|
||||||
|
const handleSuggestedQuestion = (question: SuggestedQuestion) => {
|
||||||
|
setIsFullChat(true);
|
||||||
|
// El componente ChatInterface manejara la pregunta
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInsightAction = (insight: AIInsight) => {
|
||||||
|
console.log('Insight action:', insight.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectConversation = (id: string) => {
|
||||||
|
console.log('Selected conversation:', id);
|
||||||
|
setIsFullChat(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vista de chat completo
|
||||||
|
if (isFullChat) {
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-8rem)]">
|
||||||
|
<ChatInterface
|
||||||
|
suggestedQuestions={defaultSuggestedQuestions}
|
||||||
|
className="h-full rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vista de dashboard del asistente
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
|
||||||
|
<Bot className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
CFO Digital
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400">
|
||||||
|
Tu asistente financiero impulsado por IA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<History className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Historial
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<MessageSquare className="w-4 h-4" />}
|
||||||
|
onClick={() => setIsFullChat(true)}
|
||||||
|
>
|
||||||
|
Nueva Conversacion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Metrics */}
|
||||||
|
<QuickMetricsBar />
|
||||||
|
|
||||||
|
{/* Main Content Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Chat Preview & Suggestions */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Quick Chat Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Pregunta al CFO Digital"
|
||||||
|
subtitle="Obten respuestas instantaneas sobre tus finanzas"
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
rightIcon={<ArrowRight className="w-4 h-4" />}
|
||||||
|
onClick={() => setIsFullChat(true)}
|
||||||
|
>
|
||||||
|
Chat completo
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
{/* Quick Input */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Escribe tu pregunta aqui..."
|
||||||
|
onClick={() => setIsFullChat(true)}
|
||||||
|
readOnly
|
||||||
|
className={cn(
|
||||||
|
'w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-700',
|
||||||
|
'bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white',
|
||||||
|
'cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 transition-colors',
|
||||||
|
'placeholder:text-slate-400 dark:placeholder:text-slate-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Sparkles className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggested Questions */}
|
||||||
|
<SuggestedQuestions
|
||||||
|
questions={defaultSuggestedQuestions.slice(0, 4)}
|
||||||
|
onSelect={handleSuggestedQuestion}
|
||||||
|
title="Preguntas frecuentes:"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* AI Insights */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Insights de IA"
|
||||||
|
subtitle="Recomendaciones basadas en tus datos"
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-1 text-xs text-violet-600 dark:text-violet-400">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Actualizado hace 5 min
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{insights.slice(0, 4).map((insight) => (
|
||||||
|
<AIInsightCard
|
||||||
|
key={insight.id}
|
||||||
|
insight={insight}
|
||||||
|
compact
|
||||||
|
onAction={() => handleInsightAction(insight)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{insights.length > 4 && (
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
Ver todos los insights ({insights.length})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Recent Conversations */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Conversaciones Recientes"
|
||||||
|
action={
|
||||||
|
<Button variant="ghost" size="xs">
|
||||||
|
Ver todas
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<RecentConversations
|
||||||
|
conversations={recentConversations}
|
||||||
|
onSelect={handleSelectConversation}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Capabilities */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Que puedo hacer" />
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ icon: <BarChart3 className="w-4 h-4" />, label: 'Analizar metricas financieras' },
|
||||||
|
{ icon: <TrendingUp className="w-4 h-4" />, label: 'Proyectar ingresos y gastos' },
|
||||||
|
{ icon: <DollarSign className="w-4 h-4" />, label: 'Optimizar flujo de caja' },
|
||||||
|
{ icon: <Users className="w-4 h-4" />, label: 'Gestionar cuentas por cobrar' },
|
||||||
|
{ icon: <AlertTriangle className="w-4 h-4" />, label: 'Detectar alertas financieras' },
|
||||||
|
{ icon: <Lightbulb className="w-4 h-4" />, label: 'Sugerir optimizaciones' },
|
||||||
|
].map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg text-sm text-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<span className="p-1.5 rounded-md bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tips Card */}
|
||||||
|
<Card className="bg-gradient-to-br from-violet-500 to-purple-600 border-0 text-white">
|
||||||
|
<CardContent>
|
||||||
|
<Sparkles className="w-8 h-8 mb-3 opacity-80" />
|
||||||
|
<h3 className="font-semibold text-lg mb-2">
|
||||||
|
Tip del dia
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm opacity-90 mb-4">
|
||||||
|
Pregunta por tus metricas SaaS para obtener un analisis completo
|
||||||
|
de la salud de tu negocio incluyendo MRR, Churn y LTV/CAC.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsFullChat(true);
|
||||||
|
}}
|
||||||
|
className="bg-white/20 hover:bg-white/30 border-0 text-white"
|
||||||
|
>
|
||||||
|
Probar ahora
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
719
apps/web/src/app/(dashboard)/integraciones/page.tsx
Normal file
719
apps/web/src/app/(dashboard)/integraciones/page.tsx
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardHeader, CardContent, CardFooter } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de integracion
|
||||||
|
*/
|
||||||
|
interface IntegrationProvider {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: 'erp' | 'accounting' | 'fiscal' | 'bank' | 'payments' | 'custom';
|
||||||
|
logoUrl?: string;
|
||||||
|
supportedEntities: string[];
|
||||||
|
supportedDirections: string[];
|
||||||
|
supportsRealtime: boolean;
|
||||||
|
supportsWebhooks: boolean;
|
||||||
|
isAvailable: boolean;
|
||||||
|
isBeta: boolean;
|
||||||
|
regions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfiguredIntegration {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
status: 'pending' | 'active' | 'inactive' | 'error' | 'expired';
|
||||||
|
isActive: boolean;
|
||||||
|
lastSyncAt?: string;
|
||||||
|
lastSyncStatus?: string;
|
||||||
|
healthStatus?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
|
||||||
|
provider?: {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncStatus {
|
||||||
|
integration: {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
isActive: boolean;
|
||||||
|
health: string;
|
||||||
|
};
|
||||||
|
lastSync?: {
|
||||||
|
jobId: string;
|
||||||
|
status: string;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
totalRecords: number;
|
||||||
|
createdRecords: number;
|
||||||
|
updatedRecords: number;
|
||||||
|
failedRecords: number;
|
||||||
|
errorCount: number;
|
||||||
|
lastError?: string;
|
||||||
|
};
|
||||||
|
nextSyncAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iconos para categorias
|
||||||
|
*/
|
||||||
|
const CategoryIcons: Record<string, React.ReactNode> = {
|
||||||
|
erp: (
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
accounting: (
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
fiscal: (
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
bank: (
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
payments: (
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
custom: (
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de salud badge
|
||||||
|
*/
|
||||||
|
const HealthBadge: React.FC<{ status?: string }> = ({ status }) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
healthy: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
degraded: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
unhealthy: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
unknown: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
healthy: 'Saludable',
|
||||||
|
degraded: 'Degradado',
|
||||||
|
unhealthy: 'Error',
|
||||||
|
unknown: 'Desconocido',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status || 'unknown']}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
|
||||||
|
status === 'healthy' ? 'bg-green-500' :
|
||||||
|
status === 'degraded' ? 'bg-yellow-500' :
|
||||||
|
status === 'unhealthy' ? 'bg-red-500' : 'bg-gray-500'
|
||||||
|
}`} />
|
||||||
|
{labels[status || 'unknown']}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de integracion badge
|
||||||
|
*/
|
||||||
|
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
inactive: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
||||||
|
error: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
expired: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
active: 'Activa',
|
||||||
|
pending: 'Pendiente',
|
||||||
|
inactive: 'Inactiva',
|
||||||
|
error: 'Error',
|
||||||
|
expired: 'Expirada',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || styles.inactive}`}>
|
||||||
|
{labels[status] || status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagina de Integraciones
|
||||||
|
*/
|
||||||
|
export default function IntegracionesPage() {
|
||||||
|
const [providers, setProviders] = useState<IntegrationProvider[]>([]);
|
||||||
|
const [configured, setConfigured] = useState<ConfiguredIntegration[]>([]);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [selectedIntegration, setSelectedIntegration] = useState<ConfiguredIntegration | null>(null);
|
||||||
|
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||||
|
const [showConfigModal, setShowConfigModal] = useState(false);
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState<IntegrationProvider | null>(null);
|
||||||
|
|
||||||
|
// Simular carga de datos
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Simular API call - en produccion seria fetch real
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Datos de ejemplo
|
||||||
|
setProviders([
|
||||||
|
{
|
||||||
|
type: 'contpaqi',
|
||||||
|
name: 'CONTPAQi',
|
||||||
|
description: 'Integracion con sistemas CONTPAQi (Comercial, Contabilidad, Nominas)',
|
||||||
|
category: 'erp',
|
||||||
|
logoUrl: '/integrations/contpaqi-logo.svg',
|
||||||
|
supportedEntities: ['transactions', 'invoices', 'contacts', 'products', 'accounts'],
|
||||||
|
supportedDirections: ['import', 'export', 'bidirectional'],
|
||||||
|
supportsRealtime: false,
|
||||||
|
supportsWebhooks: false,
|
||||||
|
isAvailable: true,
|
||||||
|
isBeta: false,
|
||||||
|
regions: ['MX'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'aspel',
|
||||||
|
name: 'Aspel',
|
||||||
|
description: 'Integracion con sistemas Aspel (SAE, COI, NOI, BANCO)',
|
||||||
|
category: 'erp',
|
||||||
|
supportedEntities: ['transactions', 'invoices', 'contacts', 'products'],
|
||||||
|
supportedDirections: ['import', 'export'],
|
||||||
|
supportsRealtime: false,
|
||||||
|
supportsWebhooks: false,
|
||||||
|
isAvailable: true,
|
||||||
|
isBeta: false,
|
||||||
|
regions: ['MX'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'odoo',
|
||||||
|
name: 'Odoo',
|
||||||
|
description: 'Integracion con Odoo ERP (on-premise y cloud)',
|
||||||
|
category: 'erp',
|
||||||
|
supportedEntities: ['transactions', 'invoices', 'contacts', 'products', 'accounts', 'payments'],
|
||||||
|
supportedDirections: ['import', 'export', 'bidirectional'],
|
||||||
|
supportsRealtime: true,
|
||||||
|
supportsWebhooks: true,
|
||||||
|
isAvailable: true,
|
||||||
|
isBeta: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'alegra',
|
||||||
|
name: 'Alegra',
|
||||||
|
description: 'Integracion con Alegra Contabilidad',
|
||||||
|
category: 'accounting',
|
||||||
|
supportedEntities: ['invoices', 'contacts', 'products', 'payments'],
|
||||||
|
supportedDirections: ['import', 'export', 'bidirectional'],
|
||||||
|
supportsRealtime: true,
|
||||||
|
supportsWebhooks: true,
|
||||||
|
isAvailable: true,
|
||||||
|
isBeta: false,
|
||||||
|
regions: ['MX', 'CO', 'PE', 'AR', 'CL'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sap',
|
||||||
|
name: 'SAP',
|
||||||
|
description: 'Integracion con SAP ERP y SAP Business One',
|
||||||
|
category: 'erp',
|
||||||
|
supportedEntities: ['transactions', 'invoices', 'contacts', 'products', 'accounts', 'journal_entries'],
|
||||||
|
supportedDirections: ['import', 'export', 'bidirectional'],
|
||||||
|
supportsRealtime: true,
|
||||||
|
supportsWebhooks: true,
|
||||||
|
isAvailable: true,
|
||||||
|
isBeta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sat',
|
||||||
|
name: 'SAT',
|
||||||
|
description: 'Servicio de Administracion Tributaria - Descarga de CFDIs',
|
||||||
|
category: 'fiscal',
|
||||||
|
supportedEntities: ['cfdis'],
|
||||||
|
supportedDirections: ['import'],
|
||||||
|
supportsRealtime: false,
|
||||||
|
supportsWebhooks: false,
|
||||||
|
isAvailable: true,
|
||||||
|
isBeta: false,
|
||||||
|
regions: ['MX'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bank_bbva',
|
||||||
|
name: 'BBVA Mexico',
|
||||||
|
description: 'Conexion bancaria BBVA para estados de cuenta',
|
||||||
|
category: 'bank',
|
||||||
|
supportedEntities: ['bank_statements'],
|
||||||
|
supportedDirections: ['import'],
|
||||||
|
supportsRealtime: false,
|
||||||
|
supportsWebhooks: false,
|
||||||
|
isAvailable: true,
|
||||||
|
isBeta: false,
|
||||||
|
regions: ['MX'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'payments_stripe',
|
||||||
|
name: 'Stripe',
|
||||||
|
description: 'Procesador de pagos Stripe',
|
||||||
|
category: 'payments',
|
||||||
|
supportedEntities: ['payments', 'invoices'],
|
||||||
|
supportedDirections: ['import', 'export'],
|
||||||
|
supportsRealtime: true,
|
||||||
|
supportsWebhooks: true,
|
||||||
|
isAvailable: true,
|
||||||
|
isBeta: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'webhook',
|
||||||
|
name: 'Webhook',
|
||||||
|
description: 'Webhook personalizado para integraciones custom',
|
||||||
|
category: 'custom',
|
||||||
|
supportedEntities: ['transactions', 'invoices', 'contacts'],
|
||||||
|
supportedDirections: ['import', 'export'],
|
||||||
|
supportsRealtime: true,
|
||||||
|
supportsWebhooks: true,
|
||||||
|
isAvailable: true,
|
||||||
|
isBeta: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
setConfigured([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'sat',
|
||||||
|
name: 'SAT - ABC123456789',
|
||||||
|
status: 'active',
|
||||||
|
isActive: true,
|
||||||
|
lastSyncAt: '2026-01-31T10:30:00Z',
|
||||||
|
lastSyncStatus: 'completed',
|
||||||
|
healthStatus: 'healthy',
|
||||||
|
provider: {
|
||||||
|
name: 'SAT',
|
||||||
|
category: 'fiscal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'contpaqi',
|
||||||
|
name: 'CONTPAQi Comercial',
|
||||||
|
status: 'active',
|
||||||
|
isActive: true,
|
||||||
|
lastSyncAt: '2026-01-31T08:00:00Z',
|
||||||
|
lastSyncStatus: 'completed',
|
||||||
|
healthStatus: 'healthy',
|
||||||
|
provider: {
|
||||||
|
name: 'CONTPAQi',
|
||||||
|
category: 'erp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filtrar providers por categoria
|
||||||
|
const filteredProviders = selectedCategory === 'all'
|
||||||
|
? providers
|
||||||
|
: providers.filter(p => p.category === selectedCategory);
|
||||||
|
|
||||||
|
// Categorias unicas
|
||||||
|
const categories = ['all', ...new Set(providers.map(p => p.category))];
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, string> = {
|
||||||
|
all: 'Todas',
|
||||||
|
erp: 'ERP',
|
||||||
|
accounting: 'Contabilidad',
|
||||||
|
fiscal: 'Fiscal',
|
||||||
|
bank: 'Bancos',
|
||||||
|
payments: 'Pagos',
|
||||||
|
custom: 'Personalizado',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formatear fecha
|
||||||
|
const formatDate = (dateString?: string) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
return new Date(dateString).toLocaleString('es-MX', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
Integraciones
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Conecta tus sistemas externos para sincronizar datos automaticamente
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowConfigModal(true)}
|
||||||
|
leftIcon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Nueva Integracion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Integraciones configuradas */}
|
||||||
|
{configured.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||||
|
Integraciones Configuradas
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{configured.map((integration) => (
|
||||||
|
<Card
|
||||||
|
key={integration.id}
|
||||||
|
variant="default"
|
||||||
|
hoverable
|
||||||
|
clickable
|
||||||
|
onClick={() => setSelectedIntegration(integration)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">
|
||||||
|
{CategoryIcons[integration.provider?.category || 'custom']}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white truncate">
|
||||||
|
{integration.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{integration.provider?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<StatusBadge status={integration.status} />
|
||||||
|
<HealthBadge status={integration.healthStatus} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
<p>Ultima sync: {formatDate(integration.lastSyncAt)}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Trigger sync
|
||||||
|
}}
|
||||||
|
leftIcon={
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Sincronizar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Open settings
|
||||||
|
}}
|
||||||
|
leftIcon={
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Configurar
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Integraciones disponibles */}
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
Integraciones Disponibles
|
||||||
|
</h2>
|
||||||
|
{/* Filtros de categoria */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setSelectedCategory(cat)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||||
|
selectedCategory === cat
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{categoryLabels[cat] || cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<Card key={i} variant="default" className="animate-pulse">
|
||||||
|
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-4" />
|
||||||
|
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-full mb-2" />
|
||||||
|
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-2/3" />
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredProviders.map((provider) => {
|
||||||
|
const isConfigured = configured.some(c => c.type === provider.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={provider.type}
|
||||||
|
variant="default"
|
||||||
|
hoverable
|
||||||
|
className={isConfigured ? 'opacity-60' : ''}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300">
|
||||||
|
{CategoryIcons[provider.category]}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{provider.name}
|
||||||
|
</h3>
|
||||||
|
{provider.isBeta && (
|
||||||
|
<span className="px-1.5 py-0.5 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400 rounded">
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 capitalize">
|
||||||
|
{categoryLabels[provider.category]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-300 mb-3">
|
||||||
|
{provider.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{provider.supportsRealtime && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||||
|
Tiempo real
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{provider.supportsWebhooks && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||||
|
Webhooks
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{provider.regions && provider.regions.length > 0 && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
|
||||||
|
{provider.regions.join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant={isConfigured ? 'secondary' : 'primary'}
|
||||||
|
disabled={isConfigured || !provider.isAvailable}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProvider(provider);
|
||||||
|
setShowConfigModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isConfigured ? 'Ya configurada' : 'Configurar'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de configuracion - Placeholder */}
|
||||||
|
{showConfigModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<Card className="w-full max-w-lg mx-4">
|
||||||
|
<CardHeader
|
||||||
|
title={selectedProvider ? `Configurar ${selectedProvider.name}` : 'Nueva Integracion'}
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowConfigModal(false);
|
||||||
|
setSelectedProvider(null);
|
||||||
|
}}
|
||||||
|
className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">
|
||||||
|
{selectedProvider
|
||||||
|
? `Configure los parametros de conexion para ${selectedProvider.name}.`
|
||||||
|
: 'Seleccione una integracion de la lista para configurarla.'}
|
||||||
|
</p>
|
||||||
|
{selectedProvider && (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Formulario de configuracion especifico para {selectedProvider.type} proximamente...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowConfigModal(false);
|
||||||
|
setSelectedProvider(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!selectedProvider}>
|
||||||
|
Guardar Configuracion
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal de detalles de integracion */}
|
||||||
|
{selectedIntegration && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<Card className="w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<CardHeader
|
||||||
|
title={selectedIntegration.name}
|
||||||
|
subtitle={`Tipo: ${selectedIntegration.type}`}
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedIntegration(null)}
|
||||||
|
className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Estado */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<StatusBadge status={selectedIntegration.status} />
|
||||||
|
<HealthBadge status={selectedIntegration.healthStatus} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ultima sincronizacion */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Ultima Sincronizacion
|
||||||
|
</h4>
|
||||||
|
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">Fecha:</span>
|
||||||
|
<span className="ml-2 text-slate-900 dark:text-white">
|
||||||
|
{formatDate(selectedIntegration.lastSyncAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">Estado:</span>
|
||||||
|
<span className="ml-2 text-slate-900 dark:text-white capitalize">
|
||||||
|
{selectedIntegration.lastSyncStatus || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Acciones */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
leftIcon={
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Sincronizar Ahora
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline">
|
||||||
|
Ver Historial
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost">
|
||||||
|
Configurar Schedule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
leftIcon={
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setSelectedIntegration(null)}
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
704
apps/web/src/app/(dashboard)/reportes/[id]/page.tsx
Normal file
704
apps/web/src/app/(dashboard)/reportes/[id]/page.tsx
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
Share2,
|
||||||
|
Printer,
|
||||||
|
Calendar,
|
||||||
|
FileText,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
DollarSign,
|
||||||
|
PieChart,
|
||||||
|
BarChart3,
|
||||||
|
Users,
|
||||||
|
Receipt,
|
||||||
|
Mail,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn, formatDate, formatCurrency, formatPercentage, formatNumber } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import {
|
||||||
|
ReportSection,
|
||||||
|
ReportSubSection,
|
||||||
|
ReportDataRow,
|
||||||
|
ReportAlert,
|
||||||
|
ReportChart,
|
||||||
|
Report,
|
||||||
|
ReportType,
|
||||||
|
} from '@/components/reports';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Data
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const generateMockReportData = (id: string): Report & { fullData: ReportFullData } => {
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setMonth(startDate.getMonth() - 1, 1);
|
||||||
|
const endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
const ingresos = 245000;
|
||||||
|
const egresos = 180000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: `Reporte ${startDate.toLocaleString('es', { month: 'long' })} ${startDate.getFullYear()}`,
|
||||||
|
type: 'mensual' as ReportType,
|
||||||
|
status: 'completado',
|
||||||
|
period: {
|
||||||
|
start: startDate.toISOString(),
|
||||||
|
end: endDate.toISOString(),
|
||||||
|
},
|
||||||
|
sections: ['Resumen Ejecutivo', 'Ingresos', 'Egresos', 'Flujo de Caja', 'Metricas SaaS', 'Graficas'],
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
fileSize: '2.4MB',
|
||||||
|
summary: {
|
||||||
|
ingresos,
|
||||||
|
egresos,
|
||||||
|
utilidad: ingresos - egresos,
|
||||||
|
},
|
||||||
|
fullData: {
|
||||||
|
resumenEjecutivo: {
|
||||||
|
highlights: [
|
||||||
|
{ label: 'Ingresos Totales', value: ingresos, change: 12.5, format: 'currency' },
|
||||||
|
{ label: 'Gastos Totales', value: egresos, change: 5.2, format: 'currency' },
|
||||||
|
{ label: 'Utilidad Neta', value: ingresos - egresos, change: 28.3, format: 'currency' },
|
||||||
|
{ label: 'Margen Bruto', value: 72.5, change: 3.2, format: 'percentage' },
|
||||||
|
],
|
||||||
|
alerts: [
|
||||||
|
{ type: 'success', title: 'Crecimiento sostenido', message: 'Los ingresos han crecido por 5to mes consecutivo' },
|
||||||
|
{ type: 'warning', title: 'Cuentas por cobrar', message: '3 facturas vencidas por $15,750 requieren atencion' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ingresos: {
|
||||||
|
total: ingresos,
|
||||||
|
byCategory: [
|
||||||
|
{ name: 'Suscripciones', value: 125000, percentage: 51 },
|
||||||
|
{ name: 'Servicios', value: 75000, percentage: 31 },
|
||||||
|
{ name: 'Licencias', value: 35000, percentage: 14 },
|
||||||
|
{ name: 'Otros', value: 10000, percentage: 4 },
|
||||||
|
],
|
||||||
|
trend: [
|
||||||
|
{ name: 'Ene', ingresos: 180000 },
|
||||||
|
{ name: 'Feb', ingresos: 195000 },
|
||||||
|
{ name: 'Mar', ingresos: 210000 },
|
||||||
|
{ name: 'Abr', ingresos: 225000 },
|
||||||
|
{ name: 'May', ingresos: 235000 },
|
||||||
|
{ name: 'Jun', ingresos: 245000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
egresos: {
|
||||||
|
total: egresos,
|
||||||
|
byCategory: [
|
||||||
|
{ name: 'Nomina', value: 85000, percentage: 47 },
|
||||||
|
{ name: 'Operaciones', value: 45000, percentage: 25 },
|
||||||
|
{ name: 'Marketing', value: 25000, percentage: 14 },
|
||||||
|
{ name: 'Tecnologia', value: 15000, percentage: 8 },
|
||||||
|
{ name: 'Otros', value: 10000, percentage: 6 },
|
||||||
|
],
|
||||||
|
trend: [
|
||||||
|
{ name: 'Ene', egresos: 150000 },
|
||||||
|
{ name: 'Feb', egresos: 155000 },
|
||||||
|
{ name: 'Mar', egresos: 165000 },
|
||||||
|
{ name: 'Abr', egresos: 170000 },
|
||||||
|
{ name: 'May', egresos: 175000 },
|
||||||
|
{ name: 'Jun', egresos: 180000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
flujoCaja: {
|
||||||
|
saldoInicial: 350000,
|
||||||
|
entradas: 245000,
|
||||||
|
salidas: 180000,
|
||||||
|
saldoFinal: 415000,
|
||||||
|
trend: [
|
||||||
|
{ name: 'Ene', flujo: 320000 },
|
||||||
|
{ name: 'Feb', flujo: 345000 },
|
||||||
|
{ name: 'Mar', flujo: 365000 },
|
||||||
|
{ name: 'Abr', flujo: 380000 },
|
||||||
|
{ name: 'May', flujo: 395000 },
|
||||||
|
{ name: 'Jun', flujo: 415000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
metricasSaas: {
|
||||||
|
mrr: 125000,
|
||||||
|
arr: 1500000,
|
||||||
|
churn: 2.5,
|
||||||
|
ltv: 15000,
|
||||||
|
cac: 2500,
|
||||||
|
ltvCac: 6.0,
|
||||||
|
nrr: 115,
|
||||||
|
activeCustomers: 342,
|
||||||
|
newCustomers: 28,
|
||||||
|
churnedCustomers: 9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ReportFullData {
|
||||||
|
resumenEjecutivo: {
|
||||||
|
highlights: Array<{ label: string; value: number; change: number; format: 'currency' | 'percentage' | 'number' }>;
|
||||||
|
alerts: Array<{ type: 'info' | 'success' | 'warning' | 'error'; title: string; message: string }>;
|
||||||
|
};
|
||||||
|
ingresos: {
|
||||||
|
total: number;
|
||||||
|
byCategory: Array<{ name: string; value: number; percentage: number }>;
|
||||||
|
trend: Array<{ name: string; ingresos: number }>;
|
||||||
|
};
|
||||||
|
egresos: {
|
||||||
|
total: number;
|
||||||
|
byCategory: Array<{ name: string; value: number; percentage: number }>;
|
||||||
|
trend: Array<{ name: string; egresos: number }>;
|
||||||
|
};
|
||||||
|
flujoCaja: {
|
||||||
|
saldoInicial: number;
|
||||||
|
entradas: number;
|
||||||
|
salidas: number;
|
||||||
|
saldoFinal: number;
|
||||||
|
trend: Array<{ name: string; flujo: number }>;
|
||||||
|
};
|
||||||
|
metricasSaas: {
|
||||||
|
mrr: number;
|
||||||
|
arr: number;
|
||||||
|
churn: number;
|
||||||
|
ltv: number;
|
||||||
|
cac: number;
|
||||||
|
ltvCac: number;
|
||||||
|
nrr: number;
|
||||||
|
activeCustomers: number;
|
||||||
|
newCustomers: number;
|
||||||
|
churnedCustomers: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Loading Skeleton
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function ReportDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-lg" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-6 bg-slate-200 dark:bg-slate-700 rounded w-64 mb-2" />
|
||||||
|
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
||||||
|
<div className="h-5 bg-slate-200 dark:bg-slate-700 rounded w-48 mb-4" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded" />
|
||||||
|
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Share Modal
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface ShareModalProps {
|
||||||
|
report: Report;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareModal({ report, onClose }: ShareModalProps) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const shareUrl = `https://app.horux.io/reportes/${report.id}`;
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
navigator.clipboard.writeText(shareUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendEmail = () => {
|
||||||
|
console.log('Sending email to:', email);
|
||||||
|
alert(`Reporte enviado a: ${email}`);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-md w-full">
|
||||||
|
<div className="p-5 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
Compartir Reporte
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{/* Copy Link */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
|
Enlace del reporte
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={shareUrl}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-3 py-2 bg-slate-50 dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
leftIcon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{copied ? 'Copiado' : 'Copiar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Send Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
|
Enviar por email
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="correo@ejemplo.com"
|
||||||
|
className="flex-1 px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSendEmail}
|
||||||
|
disabled={!email}
|
||||||
|
leftIcon={<Mail className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Enviar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 border-t border-slate-200 dark:border-slate-700 flex justify-end">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Page Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function ReporteDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const reportId = params.id as string;
|
||||||
|
|
||||||
|
const [report, setReport] = useState<(Report & { fullData: ReportFullData }) | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showShareModal, setShowShareModal] = useState(false);
|
||||||
|
|
||||||
|
const fetchReport = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||||
|
setReport(generateMockReportData(reportId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching report:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [reportId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReport();
|
||||||
|
}, [fetchReport]);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
console.log('Downloading report:', reportId);
|
||||||
|
alert('Descargando reporte...');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: number, format: 'currency' | 'percentage' | 'number') => {
|
||||||
|
switch (format) {
|
||||||
|
case 'currency':
|
||||||
|
return formatCurrency(value);
|
||||||
|
case 'percentage':
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
default:
|
||||||
|
return formatNumber(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <ReportDetailSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<FileText className="w-12 h-12 mx-auto text-slate-300 dark:text-slate-600 mb-4" />
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
|
||||||
|
Reporte no encontrado
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mb-4">
|
||||||
|
El reporte que buscas no existe o ha sido eliminado.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => router.push('/reportes')}>
|
||||||
|
Volver a Reportes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullData } = report;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 print:space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 print:hidden">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/reportes')}
|
||||||
|
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{report.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{formatDate(report.period.start)} - {formatDate(report.period.end)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Printer className="w-4 h-4" />}
|
||||||
|
onClick={handlePrint}
|
||||||
|
>
|
||||||
|
Imprimir
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Share2 className="w-4 h-4" />}
|
||||||
|
onClick={() => setShowShareModal(true)}
|
||||||
|
>
|
||||||
|
Compartir
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Download className="w-4 h-4" />}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
Descargar PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Print Header */}
|
||||||
|
<div className="hidden print:block">
|
||||||
|
<h1 className="text-2xl font-bold">{report.title}</h1>
|
||||||
|
<p className="text-slate-500">
|
||||||
|
Periodo: {formatDate(report.period.start)} - {formatDate(report.period.end)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resumen Ejecutivo */}
|
||||||
|
<ReportSection
|
||||||
|
title="Resumen Ejecutivo"
|
||||||
|
icon={<FileText className="w-5 h-5" />}
|
||||||
|
badge="Destacados"
|
||||||
|
>
|
||||||
|
{/* KPIs */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
{fullData.resumenEjecutivo.highlights.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{formatValue(item.value, item.format)}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-sm flex items-center gap-1 mt-1',
|
||||||
|
item.change >= 0
|
||||||
|
? 'text-success-600 dark:text-success-400'
|
||||||
|
: 'text-error-600 dark:text-error-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.change >= 0 ? (
|
||||||
|
<TrendingUp className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
{formatPercentage(Math.abs(item.change))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{fullData.resumenEjecutivo.alerts.map((alert, index) => (
|
||||||
|
<ReportAlert
|
||||||
|
key={index}
|
||||||
|
type={alert.type}
|
||||||
|
title={alert.title}
|
||||||
|
message={alert.message}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ReportSection>
|
||||||
|
|
||||||
|
{/* Ingresos */}
|
||||||
|
<ReportSection
|
||||||
|
title="Analisis de Ingresos"
|
||||||
|
icon={<TrendingUp className="w-5 h-5" />}
|
||||||
|
badge={formatCurrency(fullData.ingresos.total)}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* By Category */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-4">
|
||||||
|
Desglose por Categoria
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{fullData.ingresos.byCategory.map((cat) => (
|
||||||
|
<div key={cat.name}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-slate-700 dark:text-slate-300">{cat.name}</span>
|
||||||
|
<span className="font-medium text-slate-900 dark:text-white">
|
||||||
|
{formatCurrency(cat.value)} ({cat.percentage}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-success-500 rounded-full"
|
||||||
|
style={{ width: `${cat.percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Chart */}
|
||||||
|
<ReportChart
|
||||||
|
title="Tendencia de Ingresos"
|
||||||
|
type="area"
|
||||||
|
data={fullData.ingresos.trend}
|
||||||
|
series={[{ dataKey: 'ingresos', name: 'Ingresos', color: '#10b981' }]}
|
||||||
|
formatTooltip="currency"
|
||||||
|
height={250}
|
||||||
|
showLegend={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ReportSection>
|
||||||
|
|
||||||
|
{/* Egresos */}
|
||||||
|
<ReportSection
|
||||||
|
title="Analisis de Egresos"
|
||||||
|
icon={<Receipt className="w-5 h-5" />}
|
||||||
|
badge={formatCurrency(fullData.egresos.total)}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* By Category */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-4">
|
||||||
|
Desglose por Categoria
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{fullData.egresos.byCategory.map((cat) => (
|
||||||
|
<div key={cat.name}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-slate-700 dark:text-slate-300">{cat.name}</span>
|
||||||
|
<span className="font-medium text-slate-900 dark:text-white">
|
||||||
|
{formatCurrency(cat.value)} ({cat.percentage}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-error-500 rounded-full"
|
||||||
|
style={{ width: `${cat.percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Chart */}
|
||||||
|
<ReportChart
|
||||||
|
title="Tendencia de Egresos"
|
||||||
|
type="bar"
|
||||||
|
data={fullData.egresos.trend}
|
||||||
|
series={[{ dataKey: 'egresos', name: 'Egresos', color: '#ef4444' }]}
|
||||||
|
formatTooltip="currency"
|
||||||
|
height={250}
|
||||||
|
showLegend={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ReportSection>
|
||||||
|
|
||||||
|
{/* Flujo de Caja */}
|
||||||
|
<ReportSection
|
||||||
|
title="Flujo de Caja"
|
||||||
|
icon={<DollarSign className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50 text-center">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Saldo Inicial</p>
|
||||||
|
<p className="text-xl font-bold text-slate-900 dark:text-white mt-1">
|
||||||
|
{formatCurrency(fullData.flujoCaja.saldoInicial)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-success-50 dark:bg-success-900/20 text-center">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Entradas</p>
|
||||||
|
<p className="text-xl font-bold text-success-600 dark:text-success-400 mt-1">
|
||||||
|
+{formatCurrency(fullData.flujoCaja.entradas)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-error-50 dark:bg-error-900/20 text-center">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Salidas</p>
|
||||||
|
<p className="text-xl font-bold text-error-600 dark:text-error-400 mt-1">
|
||||||
|
-{formatCurrency(fullData.flujoCaja.salidas)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 text-center">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Saldo Final</p>
|
||||||
|
<p className="text-xl font-bold text-primary-600 dark:text-primary-400 mt-1">
|
||||||
|
{formatCurrency(fullData.flujoCaja.saldoFinal)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReportChart
|
||||||
|
title="Evolucion del Flujo de Caja"
|
||||||
|
type="line"
|
||||||
|
data={fullData.flujoCaja.trend}
|
||||||
|
series={[{ dataKey: 'flujo', name: 'Saldo', color: '#0c8ce8' }]}
|
||||||
|
formatTooltip="currency"
|
||||||
|
height={250}
|
||||||
|
/>
|
||||||
|
</ReportSection>
|
||||||
|
|
||||||
|
{/* Metricas SaaS */}
|
||||||
|
<ReportSection
|
||||||
|
title="Metricas SaaS"
|
||||||
|
icon={<BarChart3 className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">MRR</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
|
||||||
|
{formatCurrency(fullData.metricasSaas.mrr)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">ARR</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
|
||||||
|
{formatCurrency(fullData.metricasSaas.arr)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Churn Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
|
||||||
|
{fullData.metricasSaas.churn}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">NRR</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
|
||||||
|
{fullData.metricasSaas.nrr}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">LTV</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
|
||||||
|
{formatCurrency(fullData.metricasSaas.ltv)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">CAC</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
|
||||||
|
{formatCurrency(fullData.metricasSaas.cac)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">LTV/CAC</p>
|
||||||
|
<p className="text-2xl font-bold text-success-600 dark:text-success-400 mt-1">
|
||||||
|
{fullData.metricasSaas.ltvCac}x
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Clientes Activos</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
|
||||||
|
{fullData.metricasSaas.activeCustomers}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReportSubSection title="Movimiento de Clientes">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 rounded-lg bg-success-50 dark:bg-success-900/20 text-center">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Nuevos</p>
|
||||||
|
<p className="text-2xl font-bold text-success-600 dark:text-success-400">
|
||||||
|
+{fullData.metricasSaas.newCustomers}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-error-50 dark:bg-error-900/20 text-center">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Cancelados</p>
|
||||||
|
<p className="text-2xl font-bold text-error-600 dark:text-error-400">
|
||||||
|
-{fullData.metricasSaas.churnedCustomers}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 text-center">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Crecimiento Neto</p>
|
||||||
|
<p className="text-2xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
+{fullData.metricasSaas.newCustomers - fullData.metricasSaas.churnedCustomers}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ReportSubSection>
|
||||||
|
</ReportSection>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="text-center py-6 text-sm text-slate-500 dark:text-slate-400 print:hidden">
|
||||||
|
<p>Generado el {formatDate(report.generatedAt || new Date().toISOString())}</p>
|
||||||
|
<p className="mt-1">Horux Strategy - CFO Digital</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Share Modal */}
|
||||||
|
{showShareModal && (
|
||||||
|
<ShareModal report={report} onClose={() => setShowShareModal(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/web/src/app/(dashboard)/reportes/nuevo/page.tsx
Normal file
126
apps/web/src/app/(dashboard)/reportes/nuevo/page.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, FileText, CheckCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { GenerateReportWizard, ReportConfig } from '@/components/reports';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagina para generar un nuevo reporte
|
||||||
|
*
|
||||||
|
* Presenta un wizard de 3 pasos para configurar y generar
|
||||||
|
* un nuevo reporte financiero.
|
||||||
|
*/
|
||||||
|
export default function NuevoReportePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
|
const [generatedReportId, setGeneratedReportId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleComplete = async (config: ReportConfig) => {
|
||||||
|
console.log('Generating report with config:', config);
|
||||||
|
|
||||||
|
// Simular generacion del reporte
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// Simular ID del reporte generado
|
||||||
|
const reportId = `report-${Date.now()}`;
|
||||||
|
setGeneratedReportId(reportId);
|
||||||
|
setIsComplete(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.push('/reportes');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewReport = () => {
|
||||||
|
if (generatedReportId) {
|
||||||
|
router.push(`/reportes/${generatedReportId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateAnother = () => {
|
||||||
|
setIsComplete(false);
|
||||||
|
setGeneratedReportId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/reportes')}
|
||||||
|
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
Generar Nuevo Reporte
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400">
|
||||||
|
Configura y genera un reporte financiero personalizado
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isComplete ? (
|
||||||
|
// Success State
|
||||||
|
<Card className="text-center py-12">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-10 h-10 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
||||||
|
Reporte Generado Exitosamente
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 max-w-md mx-auto mb-8">
|
||||||
|
Tu reporte ha sido generado y esta listo para visualizarse.
|
||||||
|
Puedes verlo ahora o generar otro reporte.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<Button variant="outline" onClick={handleGenerateAnother}>
|
||||||
|
Generar Otro Reporte
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleViewReport} leftIcon={<FileText className="w-4 h-4" />}>
|
||||||
|
Ver Reporte
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
// Wizard
|
||||||
|
<GenerateReportWizard
|
||||||
|
onComplete={handleComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
className="border border-slate-200 dark:border-slate-700 shadow-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tips Section */}
|
||||||
|
{!isComplete && (
|
||||||
|
<Card className="bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="p-2 rounded-lg bg-primary-100 dark:bg-primary-900/40">
|
||||||
|
<FileText className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-primary-900 dark:text-primary-100 mb-1">
|
||||||
|
Consejos para generar reportes
|
||||||
|
</h3>
|
||||||
|
<ul className="text-sm text-primary-700 dark:text-primary-300 space-y-1">
|
||||||
|
<li>- Selecciona el tipo de reporte que mejor se ajuste a tus necesidades de analisis</li>
|
||||||
|
<li>- Los reportes mensuales son ideales para seguimiento operativo</li>
|
||||||
|
<li>- Los reportes trimestrales ofrecen mejor perspectiva de tendencias</li>
|
||||||
|
<li>- Incluye las secciones de metricas SaaS si tienes un modelo de suscripcion</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
685
apps/web/src/app/(dashboard)/reportes/page.tsx
Normal file
685
apps/web/src/app/(dashboard)/reportes/page.tsx
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Download,
|
||||||
|
RefreshCw,
|
||||||
|
Calendar,
|
||||||
|
Grid3X3,
|
||||||
|
List,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn, formatDate, formatCurrency } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { ReportCard, Report, ReportType, ReportStatus } from '@/components/reports';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
search: string;
|
||||||
|
type: ReportType | 'all';
|
||||||
|
status: ReportStatus | 'all';
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'grid' | 'list';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Data
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const generateMockReports = (): Report[] => {
|
||||||
|
const reports: Report[] = [];
|
||||||
|
const types: ReportType[] = ['mensual', 'trimestral', 'anual', 'custom'];
|
||||||
|
const statuses: ReportStatus[] = ['completado', 'completado', 'completado', 'generando', 'pendiente'];
|
||||||
|
const sectionOptions = [
|
||||||
|
'Resumen Ejecutivo',
|
||||||
|
'Ingresos',
|
||||||
|
'Egresos',
|
||||||
|
'Flujo de Caja',
|
||||||
|
'Metricas SaaS',
|
||||||
|
'Cuentas por Cobrar',
|
||||||
|
'Cuentas por Pagar',
|
||||||
|
'Graficas',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const type = types[i % types.length];
|
||||||
|
const status = statuses[Math.floor(Math.random() * statuses.length)];
|
||||||
|
const date = new Date();
|
||||||
|
date.setMonth(date.getMonth() - i);
|
||||||
|
|
||||||
|
const startDate = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||||
|
let endDate: Date;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'trimestral':
|
||||||
|
endDate = new Date(date.getFullYear(), date.getMonth() + 3, 0);
|
||||||
|
break;
|
||||||
|
case 'anual':
|
||||||
|
endDate = new Date(date.getFullYear(), 11, 31);
|
||||||
|
startDate.setMonth(0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
endDate = new Date(date.getFullYear(), date.getMonth() + 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ingresos = Math.floor(Math.random() * 500000) + 100000;
|
||||||
|
const egresos = Math.floor(ingresos * (0.5 + Math.random() * 0.3));
|
||||||
|
|
||||||
|
reports.push({
|
||||||
|
id: `report-${i + 1}`,
|
||||||
|
title: type === 'anual'
|
||||||
|
? `Reporte Anual ${startDate.getFullYear()}`
|
||||||
|
: type === 'trimestral'
|
||||||
|
? `Reporte Q${Math.ceil((startDate.getMonth() + 1) / 3)} ${startDate.getFullYear()}`
|
||||||
|
: type === 'custom'
|
||||||
|
? `Reporte Personalizado ${i + 1}`
|
||||||
|
: `Reporte ${startDate.toLocaleString('es', { month: 'long' })} ${startDate.getFullYear()}`,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
period: {
|
||||||
|
start: startDate.toISOString(),
|
||||||
|
end: endDate.toISOString(),
|
||||||
|
},
|
||||||
|
sections: sectionOptions.slice(0, Math.floor(Math.random() * 4) + 4),
|
||||||
|
generatedAt: status === 'completado' ? date.toISOString() : undefined,
|
||||||
|
fileSize: status === 'completado' ? `${Math.floor(Math.random() * 5) + 1}.${Math.floor(Math.random() * 9)}MB` : undefined,
|
||||||
|
summary: status === 'completado' ? {
|
||||||
|
ingresos,
|
||||||
|
egresos,
|
||||||
|
utilidad: ingresos - egresos,
|
||||||
|
} : undefined,
|
||||||
|
progress: status === 'generando' ? Math.floor(Math.random() * 80) + 10 : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reports;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Loading Skeleton
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function ReportsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-8 bg-slate-200 dark:bg-slate-700 rounded w-48" />
|
||||||
|
<div className="h-10 bg-slate-200 dark:bg-slate-700 rounded w-40" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<div key={i} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-lg" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2" />
|
||||||
|
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded" />
|
||||||
|
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-2/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Filter Panel
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
filters: Filters;
|
||||||
|
onChange: (filters: Filters) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterPanel({ filters, onChange, onClose, isOpen }: FilterPanelProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white">Filtros</h3>
|
||||||
|
<button onClick={onClose} className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
|
Tipo de Reporte
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.type}
|
||||||
|
onChange={(e) => onChange({ ...filters, type: e.target.value as Filters['type'] })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
<option value="mensual">Mensual</option>
|
||||||
|
<option value="trimestral">Trimestral</option>
|
||||||
|
<option value="anual">Anual</option>
|
||||||
|
<option value="custom">Personalizado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
|
Estado
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => onChange({ ...filters, status: e.target.value as Filters['status'] })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
<option value="completado">Completado</option>
|
||||||
|
<option value="generando">Generando</option>
|
||||||
|
<option value="pendiente">Pendiente</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
|
Desde
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateFrom}
|
||||||
|
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
|
Hasta
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo}
|
||||||
|
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<button
|
||||||
|
onClick={() => onChange({ search: '', type: 'all', status: 'all', dateFrom: '', dateTo: '' })}
|
||||||
|
className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
<Button size="sm" onClick={onClose}>
|
||||||
|
Aplicar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Preview Modal
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface ReportPreviewProps {
|
||||||
|
report: Report;
|
||||||
|
onClose: () => void;
|
||||||
|
onView: () => void;
|
||||||
|
onDownload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportPreview({ report, onClose, onView, onDownload }: ReportPreviewProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-lg bg-primary-100 dark:bg-primary-900/30">
|
||||||
|
<FileText className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900 dark:text-white">{report.title}</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{formatDate(report.period.start)} - {formatDate(report.period.end)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-5 space-y-6 max-h-[60vh] overflow-y-auto">
|
||||||
|
{/* Summary */}
|
||||||
|
{report.summary && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
|
||||||
|
Resumen Financiero
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 rounded-lg bg-success-50 dark:bg-success-900/20 text-center">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">Ingresos</p>
|
||||||
|
<p className="text-xl font-bold text-success-600 dark:text-success-400">
|
||||||
|
{formatCurrency(report.summary.ingresos)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-error-50 dark:bg-error-900/20 text-center">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">Egresos</p>
|
||||||
|
<p className="text-xl font-bold text-error-600 dark:text-error-400">
|
||||||
|
{formatCurrency(report.summary.egresos)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 text-center">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">Utilidad</p>
|
||||||
|
<p className="text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
{formatCurrency(report.summary.utilidad)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sections */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
|
||||||
|
Secciones Incluidas
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{report.sections.map((section) => (
|
||||||
|
<span
|
||||||
|
key={section}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{section}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
|
||||||
|
Detalles
|
||||||
|
</h3>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-700">
|
||||||
|
<dt className="text-sm text-slate-500 dark:text-slate-400">Tipo</dt>
|
||||||
|
<dd className="text-sm font-medium text-slate-900 dark:text-white capitalize">{report.type}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-700">
|
||||||
|
<dt className="text-sm text-slate-500 dark:text-slate-400">Estado</dt>
|
||||||
|
<dd className="text-sm font-medium text-slate-900 dark:text-white capitalize">{report.status}</dd>
|
||||||
|
</div>
|
||||||
|
{report.generatedAt && (
|
||||||
|
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-700">
|
||||||
|
<dt className="text-sm text-slate-500 dark:text-slate-400">Generado</dt>
|
||||||
|
<dd className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
{formatDate(report.generatedAt)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{report.fileSize && (
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<dt className="text-sm text-slate-500 dark:text-slate-400">Tamano</dt>
|
||||||
|
<dd className="text-sm font-medium text-slate-900 dark:text-white">{report.fileSize}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end gap-3 p-5 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
{report.status === 'completado' && (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" leftIcon={<Download className="w-4 h-4" />} onClick={onDownload}>
|
||||||
|
Descargar PDF
|
||||||
|
</Button>
|
||||||
|
<Button leftIcon={<Eye className="w-4 h-4" />} onClick={onView}>
|
||||||
|
Ver Completo
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Page Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function ReportesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
|
const [previewReport, setPreviewReport] = useState<Report | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filters, setFilters] = useState<Filters>({
|
||||||
|
search: '',
|
||||||
|
type: 'all',
|
||||||
|
status: 'all',
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const limit = 9;
|
||||||
|
|
||||||
|
const fetchReports = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||||
|
setReports(generateMockReports());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching reports:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReports();
|
||||||
|
}, [fetchReports]);
|
||||||
|
|
||||||
|
// Filter reports
|
||||||
|
const filteredReports = useMemo(() => {
|
||||||
|
return reports.filter((report) => {
|
||||||
|
if (filters.search) {
|
||||||
|
const searchLower = filters.search.toLowerCase();
|
||||||
|
if (!report.title.toLowerCase().includes(searchLower)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.type !== 'all' && report.type !== filters.type) return false;
|
||||||
|
if (filters.status !== 'all' && report.status !== filters.status) return false;
|
||||||
|
if (filters.dateFrom && report.period.start < filters.dateFrom) return false;
|
||||||
|
if (filters.dateTo && report.period.end > filters.dateTo) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [reports, filters]);
|
||||||
|
|
||||||
|
const paginatedReports = useMemo(() => {
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
return filteredReports.slice(start, start + limit);
|
||||||
|
}, [filteredReports, page]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredReports.length / limit);
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
total: reports.length,
|
||||||
|
completados: reports.filter(r => r.status === 'completado').length,
|
||||||
|
generando: reports.filter(r => r.status === 'generando').length,
|
||||||
|
pendientes: reports.filter(r => r.status === 'pendiente').length,
|
||||||
|
}), [reports]);
|
||||||
|
|
||||||
|
const handleViewReport = (report: Report) => {
|
||||||
|
router.push(`/reportes/${report.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadReport = (report: Report) => {
|
||||||
|
// Simular descarga
|
||||||
|
console.log('Downloading report:', report.id);
|
||||||
|
alert(`Descargando: ${report.title}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ReportsSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
Reportes
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||||
|
Gestiona y genera reportes financieros
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||||
|
onClick={fetchReports}
|
||||||
|
>
|
||||||
|
Actualizar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Plus className="w-4 h-4" />}
|
||||||
|
onClick={() => router.push('/reportes/nuevo')}
|
||||||
|
>
|
||||||
|
Generar Reporte
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-lg bg-slate-100 dark:bg-slate-700">
|
||||||
|
<FileText className="w-5 h-5 text-slate-600 dark:text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Total</p>
|
||||||
|
<p className="text-xl font-bold text-slate-900 dark:text-white">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-lg bg-success-100 dark:bg-success-900/30">
|
||||||
|
<CheckCircle className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Completados</p>
|
||||||
|
<p className="text-xl font-bold text-success-600 dark:text-success-400">{stats.completados}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-lg bg-primary-100 dark:bg-primary-900/30">
|
||||||
|
<Clock className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Generando</p>
|
||||||
|
<p className="text-xl font-bold text-primary-600 dark:text-primary-400">{stats.generando}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-lg bg-warning-100 dark:bg-warning-900/30">
|
||||||
|
<Calendar className="w-5 h-5 text-warning-600 dark:text-warning-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Pendientes</p>
|
||||||
|
<p className="text-xl font-bold text-warning-600 dark:text-warning-400">{stats.pendientes}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar reportes..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2.5 border rounded-lg transition-colors',
|
||||||
|
showFilters
|
||||||
|
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-400'
|
||||||
|
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Filtros</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={cn(
|
||||||
|
'p-2.5',
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={cn(
|
||||||
|
'p-2.5',
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
{showFilters && (
|
||||||
|
<FilterPanel
|
||||||
|
filters={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
onClose={() => setShowFilters(false)}
|
||||||
|
isOpen={showFilters}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reports Grid/List */}
|
||||||
|
{paginatedReports.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
|
||||||
|
: 'space-y-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{paginatedReports.map((report) => (
|
||||||
|
<ReportCard
|
||||||
|
key={report.id}
|
||||||
|
report={report}
|
||||||
|
onView={(r) => setPreviewReport(r)}
|
||||||
|
onDownload={handleDownloadReport}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="text-center py-12">
|
||||||
|
<FileText className="w-12 h-12 mx-auto text-slate-300 dark:text-slate-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
|
||||||
|
No se encontraron reportes
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mb-4">
|
||||||
|
Ajusta los filtros o genera un nuevo reporte
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => router.push('/reportes/nuevo')}>
|
||||||
|
Generar Reporte
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredReports.length)} de {filteredReports.length} reportes
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="p-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Pagina {page} de {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="p-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview Modal */}
|
||||||
|
{previewReport && (
|
||||||
|
<ReportPreview
|
||||||
|
report={previewReport}
|
||||||
|
onClose={() => setPreviewReport(null)}
|
||||||
|
onView={() => {
|
||||||
|
setPreviewReport(null);
|
||||||
|
handleViewReport(previewReport);
|
||||||
|
}}
|
||||||
|
onDownload={() => handleDownloadReport(previewReport)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
338
apps/web/src/components/ai/AIInsightCard.tsx
Normal file
338
apps/web/src/components/ai/AIInsightCard.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cn, formatCurrency, formatPercentage } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Info,
|
||||||
|
Lightbulb,
|
||||||
|
ArrowRight,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de insight
|
||||||
|
*/
|
||||||
|
export type InsightType = 'positive' | 'negative' | 'warning' | 'info' | 'suggestion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prioridad del insight
|
||||||
|
*/
|
||||||
|
export type InsightPriority = 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface del insight
|
||||||
|
*/
|
||||||
|
export interface AIInsight {
|
||||||
|
id: string;
|
||||||
|
type: InsightType;
|
||||||
|
priority: InsightPriority;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
metric?: {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
change?: number;
|
||||||
|
format?: 'currency' | 'percentage' | 'number';
|
||||||
|
};
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props del componente AIInsightCard
|
||||||
|
*/
|
||||||
|
interface AIInsightCardProps {
|
||||||
|
insight: AIInsight;
|
||||||
|
onAction?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos por tipo de insight
|
||||||
|
*/
|
||||||
|
const typeStyles: Record<InsightType, {
|
||||||
|
bg: string;
|
||||||
|
border: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
iconBg: string;
|
||||||
|
iconColor: string;
|
||||||
|
}> = {
|
||||||
|
positive: {
|
||||||
|
bg: 'bg-success-50 dark:bg-success-900/20',
|
||||||
|
border: 'border-success-200 dark:border-success-800',
|
||||||
|
icon: <TrendingUp className="w-5 h-5" />,
|
||||||
|
iconBg: 'bg-success-100 dark:bg-success-900/40',
|
||||||
|
iconColor: 'text-success-600 dark:text-success-400',
|
||||||
|
},
|
||||||
|
negative: {
|
||||||
|
bg: 'bg-error-50 dark:bg-error-900/20',
|
||||||
|
border: 'border-error-200 dark:border-error-800',
|
||||||
|
icon: <TrendingDown className="w-5 h-5" />,
|
||||||
|
iconBg: 'bg-error-100 dark:bg-error-900/40',
|
||||||
|
iconColor: 'text-error-600 dark:text-error-400',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: 'bg-warning-50 dark:bg-warning-900/20',
|
||||||
|
border: 'border-warning-200 dark:border-warning-800',
|
||||||
|
icon: <AlertTriangle className="w-5 h-5" />,
|
||||||
|
iconBg: 'bg-warning-100 dark:bg-warning-900/40',
|
||||||
|
iconColor: 'text-warning-600 dark:text-warning-400',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
bg: 'bg-primary-50 dark:bg-primary-900/20',
|
||||||
|
border: 'border-primary-200 dark:border-primary-800',
|
||||||
|
icon: <Info className="w-5 h-5" />,
|
||||||
|
iconBg: 'bg-primary-100 dark:bg-primary-900/40',
|
||||||
|
iconColor: 'text-primary-600 dark:text-primary-400',
|
||||||
|
},
|
||||||
|
suggestion: {
|
||||||
|
bg: 'bg-violet-50 dark:bg-violet-900/20',
|
||||||
|
border: 'border-violet-200 dark:border-violet-800',
|
||||||
|
icon: <Lightbulb className="w-5 h-5" />,
|
||||||
|
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
|
||||||
|
iconColor: 'text-violet-600 dark:text-violet-400',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge de prioridad
|
||||||
|
*/
|
||||||
|
const priorityBadge: Record<InsightPriority, { label: string; className: string }> = {
|
||||||
|
high: {
|
||||||
|
label: 'Alta prioridad',
|
||||||
|
className: 'bg-error-100 text-error-700 dark:bg-error-900/40 dark:text-error-400',
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
label: 'Media',
|
||||||
|
className: 'bg-warning-100 text-warning-700 dark:bg-warning-900/40 dark:text-warning-400',
|
||||||
|
},
|
||||||
|
low: {
|
||||||
|
label: 'Baja',
|
||||||
|
className: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-400',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatear valor de metrica
|
||||||
|
*/
|
||||||
|
const formatMetricValue = (value: number | string, format?: 'currency' | 'percentage' | 'number'): string => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'currency':
|
||||||
|
return formatCurrency(value);
|
||||||
|
case 'percentage':
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
case 'number':
|
||||||
|
default:
|
||||||
|
return value.toLocaleString('es-ES');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente AIInsightCard
|
||||||
|
*
|
||||||
|
* Tarjeta que muestra un insight generado por IA con metricas
|
||||||
|
* relevantes y acciones sugeridas.
|
||||||
|
*/
|
||||||
|
export const AIInsightCard: React.FC<AIInsightCardProps> = ({
|
||||||
|
insight,
|
||||||
|
onAction,
|
||||||
|
onDismiss,
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const styles = typeStyles[insight.type];
|
||||||
|
const priority = priorityBadge[insight.priority];
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 p-3 rounded-lg border',
|
||||||
|
styles.bg,
|
||||||
|
styles.border,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('flex-shrink-0 p-1.5 rounded-md', styles.iconBg, styles.iconColor)}>
|
||||||
|
{styles.icon}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-white line-clamp-1">
|
||||||
|
{insight.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 line-clamp-2 mt-0.5">
|
||||||
|
{insight.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{insight.action && (
|
||||||
|
<button
|
||||||
|
onClick={insight.action.onClick || onAction}
|
||||||
|
className={cn('flex-shrink-0 p-1 rounded', styles.iconColor, 'hover:bg-white/50 dark:hover:bg-slate-800/50')}
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border overflow-hidden',
|
||||||
|
styles.bg,
|
||||||
|
styles.border,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 flex items-start gap-3">
|
||||||
|
<span className={cn('flex-shrink-0 p-2.5 rounded-lg', styles.iconBg, styles.iconColor)}>
|
||||||
|
{styles.icon}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{insight.title}
|
||||||
|
</h3>
|
||||||
|
<span className={cn('text-xs font-medium px-2 py-0.5 rounded-full', priority.className)}>
|
||||||
|
{priority.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{insight.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Sparkles className="w-4 h-4 text-violet-500" />
|
||||||
|
<span className="text-xs text-slate-400 dark:text-slate-500">AI</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metric */}
|
||||||
|
{insight.metric && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-white/50 dark:bg-slate-800/50">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{insight.metric.label}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-bold text-slate-900 dark:text-white">
|
||||||
|
{formatMetricValue(insight.metric.value, insight.metric.format)}
|
||||||
|
</span>
|
||||||
|
{insight.metric.change !== undefined && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-0.5 text-sm font-medium',
|
||||||
|
insight.metric.change >= 0
|
||||||
|
? 'text-success-600 dark:text-success-400'
|
||||||
|
: 'text-error-600 dark:text-error-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{insight.metric.change >= 0 ? (
|
||||||
|
<TrendingUp className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
{formatPercentage(Math.abs(insight.metric.change))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
{insight.action && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<button
|
||||||
|
onClick={insight.action.onClick || onAction}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg',
|
||||||
|
'text-sm font-medium transition-colors',
|
||||||
|
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
|
||||||
|
'border border-slate-200 dark:border-slate-700',
|
||||||
|
'hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{insight.action.label}
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente InsightsList para mostrar multiples insights
|
||||||
|
*/
|
||||||
|
interface InsightsListProps {
|
||||||
|
insights: AIInsight[];
|
||||||
|
onInsightAction?: (insight: AIInsight) => void;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
maxVisible?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InsightsList: React.FC<InsightsListProps> = ({
|
||||||
|
insights,
|
||||||
|
onInsightAction,
|
||||||
|
className,
|
||||||
|
title = 'Insights de IA',
|
||||||
|
maxVisible = 5,
|
||||||
|
}) => {
|
||||||
|
const [showAll, setShowAll] = React.useState(false);
|
||||||
|
const visibleInsights = showAll ? insights : insights.slice(0, maxVisible);
|
||||||
|
const hasMore = insights.length > maxVisible;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-violet-500" />
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{insights.length} insights
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{visibleInsights.map((insight) => (
|
||||||
|
<AIInsightCard
|
||||||
|
key={insight.id}
|
||||||
|
insight={insight}
|
||||||
|
compact
|
||||||
|
onAction={() => onInsightAction?.(insight)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
className="w-full py-2 text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
|
||||||
|
>
|
||||||
|
{showAll ? 'Mostrar menos' : `Ver ${insights.length - maxVisible} mas`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIInsightCard;
|
||||||
458
apps/web/src/components/ai/ChatInterface.tsx
Normal file
458
apps/web/src/components/ai/ChatInterface.tsx
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { ChatMessage, Message, MessageRole } from './ChatMessage';
|
||||||
|
import { SuggestedQuestions, defaultSuggestedQuestions, QuickActions, SuggestedQuestion } from './SuggestedQuestions';
|
||||||
|
import {
|
||||||
|
Send,
|
||||||
|
Mic,
|
||||||
|
Paperclip,
|
||||||
|
Settings,
|
||||||
|
Trash2,
|
||||||
|
Bot,
|
||||||
|
Sparkles,
|
||||||
|
StopCircle,
|
||||||
|
Menu,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props del componente ChatInterface
|
||||||
|
*/
|
||||||
|
interface ChatInterfaceProps {
|
||||||
|
onSendMessage?: (message: string) => Promise<void>;
|
||||||
|
initialMessages?: Message[];
|
||||||
|
suggestedQuestions?: SuggestedQuestion[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
showSidebar?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar ID unico
|
||||||
|
*/
|
||||||
|
const generateId = () => Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente ChatInterface
|
||||||
|
*
|
||||||
|
* Interface de chat completa para el asistente CFO Digital
|
||||||
|
* con soporte para streaming, sugerencias y contenido enriquecido.
|
||||||
|
*/
|
||||||
|
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
||||||
|
onSendMessage,
|
||||||
|
initialMessages = [],
|
||||||
|
suggestedQuestions = defaultSuggestedQuestions,
|
||||||
|
isLoading = false,
|
||||||
|
className,
|
||||||
|
showSidebar = true,
|
||||||
|
}) => {
|
||||||
|
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(showSidebar);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll al final de los mensajes
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages, scrollToBottom]);
|
||||||
|
|
||||||
|
// Auto-resize del textarea
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
e.target.style.height = 'auto';
|
||||||
|
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enviar mensaje
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
const trimmedInput = inputValue.trim();
|
||||||
|
if (!trimmedInput || isLoading || isStreaming) return;
|
||||||
|
|
||||||
|
// Agregar mensaje del usuario
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: generateId(),
|
||||||
|
role: 'user',
|
||||||
|
content: trimmedInput,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
setInputValue('');
|
||||||
|
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.style.height = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simular respuesta del asistente
|
||||||
|
setIsStreaming(true);
|
||||||
|
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: generateId(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
timestamp: new Date(),
|
||||||
|
isStreaming: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, assistantMessage]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Si hay un handler externo, usarlo
|
||||||
|
if (onSendMessage) {
|
||||||
|
await onSendMessage(trimmedInput);
|
||||||
|
} else {
|
||||||
|
// Simular respuesta con streaming
|
||||||
|
await simulateStreaming(assistantMessage.id, trimmedInput);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message:', error);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((msg) =>
|
||||||
|
msg.id === assistantMessage.id
|
||||||
|
? { ...msg, content: 'Lo siento, hubo un error procesando tu consulta. Por favor intenta de nuevo.', isStreaming: false, isError: true }
|
||||||
|
: msg
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simular streaming de respuesta
|
||||||
|
const simulateStreaming = async (messageId: string, userQuery: string) => {
|
||||||
|
const responses: Record<string, { content: string; inlineContent?: Message['inlineContent'] }> = {
|
||||||
|
'flujo de caja': {
|
||||||
|
content: 'Analizando tu flujo de caja para este mes...\n\n**Resumen del Flujo de Caja:**\n\nTu flujo de caja neto es positivo este mes. Los ingresos operativos superan los gastos, lo cual es una excelente senal.\n\n**Recomendacion:** Considera destinar el excedente a una reserva de emergencia o a prepagar deuda de alto interes.',
|
||||||
|
inlineContent: [
|
||||||
|
{
|
||||||
|
type: 'metric',
|
||||||
|
data: { label: 'Flujo de Caja Neto', value: '$45,230', change: '+12.5% vs mes anterior' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
data: {
|
||||||
|
headers: ['Concepto', 'Monto'],
|
||||||
|
rows: [
|
||||||
|
['Ingresos Operativos', '$125,000'],
|
||||||
|
['Gastos Operativos', '$79,770'],
|
||||||
|
['Flujo Neto', '$45,230'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'metricas': {
|
||||||
|
content: 'Aqui estan tus **metricas SaaS clave** actualizadas:\n\n- **MRR (Monthly Recurring Revenue):** Crecimiento sostenido del 5.9% mensual\n- **ARR (Annual Recurring Revenue):** Proyeccion solida para el ano\n- **Churn Rate:** Por debajo del promedio de la industria\n- **LTV/CAC Ratio:** Excelente eficiencia en adquisicion\n\nTu negocio muestra indicadores saludables de crecimiento sostenible.',
|
||||||
|
inlineContent: [
|
||||||
|
{
|
||||||
|
type: 'metric',
|
||||||
|
data: { label: 'MRR', value: '$125,000', change: '+5.9% vs mes anterior' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'metric',
|
||||||
|
data: { label: 'LTV/CAC', value: '6.0x', change: '+25% YoY' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'facturas vencidas': {
|
||||||
|
content: 'Analizando las cuentas por cobrar...\n\nTienes **3 clientes** con facturas vencidas por un total de **$15,750**.\n\n**Accion recomendada:** Te sugiero enviar recordatorios automaticos y considerar ofrecer un pequeno descuento por pronto pago.',
|
||||||
|
inlineContent: [
|
||||||
|
{
|
||||||
|
type: 'alert',
|
||||||
|
data: { type: 'warning', title: 'Atencion', message: '3 facturas vencidas requieren seguimiento inmediato' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
data: {
|
||||||
|
headers: ['Cliente', 'Monto', 'Dias Vencido'],
|
||||||
|
rows: [
|
||||||
|
['Tech Solutions', '$8,500', '15'],
|
||||||
|
['Servicios Beta', '$4,250', '8'],
|
||||||
|
['Cliente Uno', '$3,000', '5'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'default': {
|
||||||
|
content: 'Entiendo tu consulta. Dejame analizar la informacion disponible...\n\nBasandome en los datos de tu empresa, puedo darte las siguientes recomendaciones:\n\n1. **Optimizacion de costos:** Revisa los gastos recurrentes\n2. **Mejora de cobranza:** Implementa recordatorios automaticos\n3. **Diversificacion de ingresos:** Explora nuevas lineas de productos\n\nQuieres que profundice en alguno de estos puntos?',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determinar respuesta basada en query
|
||||||
|
let responseData = responses['default'];
|
||||||
|
const queryLower = userQuery.toLowerCase();
|
||||||
|
|
||||||
|
if (queryLower.includes('flujo') || queryLower.includes('caja')) {
|
||||||
|
responseData = responses['flujo de caja'];
|
||||||
|
} else if (queryLower.includes('metrica') || queryLower.includes('mrr') || queryLower.includes('saas')) {
|
||||||
|
responseData = responses['metricas'];
|
||||||
|
} else if (queryLower.includes('vencid') || queryLower.includes('factura') || queryLower.includes('cobrar')) {
|
||||||
|
responseData = responses['facturas vencidas'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simular streaming caracter por caracter
|
||||||
|
const fullContent = responseData.content;
|
||||||
|
let currentContent = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < fullContent.length; i++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15 + Math.random() * 10));
|
||||||
|
currentContent += fullContent[i];
|
||||||
|
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((msg) =>
|
||||||
|
msg.id === messageId
|
||||||
|
? { ...msg, content: currentContent }
|
||||||
|
: msg
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar contenido inline al final
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((msg) =>
|
||||||
|
msg.id === messageId
|
||||||
|
? { ...msg, isStreaming: false, inlineContent: responseData.inlineContent }
|
||||||
|
: msg
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Manejar seleccion de pregunta sugerida
|
||||||
|
const handleSuggestedQuestion = (question: SuggestedQuestion) => {
|
||||||
|
setInputValue(question.text);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Manejar accion rapida
|
||||||
|
const handleQuickAction = (action: string) => {
|
||||||
|
const actionQueries: Record<string, string> = {
|
||||||
|
resumen_diario: 'Dame un resumen ejecutivo del dia de hoy',
|
||||||
|
metricas_clave: 'Cuales son mis metricas clave actuales?',
|
||||||
|
alertas_activas: 'Hay alguna alerta financiera activa?',
|
||||||
|
proyeccion_mes: 'Cual es la proyeccion para este mes?',
|
||||||
|
};
|
||||||
|
setInputValue(actionQueries[action] || '');
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limpiar chat
|
||||||
|
const handleClearChat = () => {
|
||||||
|
setMessages([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copiar mensaje
|
||||||
|
const handleCopyMessage = (content: string) => {
|
||||||
|
navigator.clipboard.writeText(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tecla Enter para enviar
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMessages = messages.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex h-full bg-slate-50 dark:bg-slate-900', className)}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="hidden lg:flex flex-col w-80 border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
|
||||||
|
{/* Sidebar Header */}
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
|
||||||
|
<Bot className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900 dark:text-white">CFO Digital</h2>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Asistente financiero IA</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
||||||
|
Acciones Rapidas
|
||||||
|
</h3>
|
||||||
|
<QuickActions onAction={handleQuickAction} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggested Questions */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<SuggestedQuestions
|
||||||
|
questions={suggestedQuestions.slice(0, 6)}
|
||||||
|
onSelect={handleSuggestedQuestion}
|
||||||
|
title="Sugerencias"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Footer */}
|
||||||
|
<div className="p-4 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<button
|
||||||
|
onClick={handleClearChat}
|
||||||
|
disabled={!hasMessages}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg',
|
||||||
|
'text-sm font-medium transition-colors',
|
||||||
|
'text-slate-600 dark:text-slate-400',
|
||||||
|
hasMessages
|
||||||
|
? 'hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||||
|
: 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Limpiar conversacion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Chat Area */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Chat Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
className="lg:hidden p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5 text-slate-600 dark:text-slate-400" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-violet-500" />
|
||||||
|
<span className="font-medium text-slate-900 dark:text-white">
|
||||||
|
Asistente CFO Digital
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400">
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages Area */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||||
|
{/* Empty State */}
|
||||||
|
{!hasMessages && (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center p-6">
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center mb-6">
|
||||||
|
<Bot className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
||||||
|
Hola! Soy tu CFO Digital
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 max-w-md mb-8">
|
||||||
|
Estoy aqui para ayudarte a entender mejor la salud financiera de tu empresa.
|
||||||
|
Preguntame sobre metricas, proyecciones, o cualquier duda financiera.
|
||||||
|
</p>
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
<SuggestedQuestions
|
||||||
|
questions={suggestedQuestions.slice(0, 4)}
|
||||||
|
onSelect={handleSuggestedQuestion}
|
||||||
|
title="Comienza con una pregunta:"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{messages.map((message) => (
|
||||||
|
<ChatMessage
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
onCopy={handleCopyMessage}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggested questions (when chat started) */}
|
||||||
|
{hasMessages && !isStreaming && (
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<SuggestedQuestions
|
||||||
|
questions={suggestedQuestions.slice(0, 3)}
|
||||||
|
onSelect={handleSuggestedQuestion}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className="p-4 border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
{/* Attach Button */}
|
||||||
|
<button className="flex-shrink-0 p-2.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
<Paperclip className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Escribe tu pregunta..."
|
||||||
|
rows={1}
|
||||||
|
className={cn(
|
||||||
|
'w-full resize-none rounded-xl border border-slate-300 dark:border-slate-600',
|
||||||
|
'bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white',
|
||||||
|
'px-4 py-3 pr-12',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
||||||
|
'placeholder:text-slate-400 dark:placeholder:text-slate-500',
|
||||||
|
'max-h-[200px]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voice Button */}
|
||||||
|
<button className="flex-shrink-0 p-2.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
<Mic className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Send/Stop Button */}
|
||||||
|
{isStreaming ? (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
onClick={() => setIsStreaming(false)}
|
||||||
|
leftIcon={<StopCircle className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
Detener
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!inputValue.trim() || isLoading}
|
||||||
|
leftIcon={<Send className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
Enviar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 text-center mt-2">
|
||||||
|
El CFO Digital usa IA para proporcionar insights. Siempre verifica la informacion importante.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatInterface;
|
||||||
281
apps/web/src/components/ai/ChatMessage.tsx
Normal file
281
apps/web/src/components/ai/ChatMessage.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cn, formatDate } from '@/lib/utils';
|
||||||
|
import { Bot, User, Copy, Check, RefreshCw, ThumbsUp, ThumbsDown } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de mensaje
|
||||||
|
*/
|
||||||
|
export type MessageRole = 'user' | 'assistant' | 'system';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contenido inline del mensaje (metricas, graficas, etc.)
|
||||||
|
*/
|
||||||
|
export interface MessageInlineContent {
|
||||||
|
type: 'metric' | 'chart' | 'table' | 'alert';
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface del mensaje
|
||||||
|
*/
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
role: MessageRole;
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
inlineContent?: MessageInlineContent[];
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props del componente ChatMessage
|
||||||
|
*/
|
||||||
|
interface ChatMessageProps {
|
||||||
|
message: Message;
|
||||||
|
onCopy?: (content: string) => void;
|
||||||
|
onRetry?: (message: Message) => void;
|
||||||
|
onFeedback?: (message: Message, isPositive: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderizar contenido inline
|
||||||
|
*/
|
||||||
|
const InlineContentRenderer: React.FC<{ content: MessageInlineContent }> = ({ content }) => {
|
||||||
|
switch (content.type) {
|
||||||
|
case 'metric':
|
||||||
|
return (
|
||||||
|
<div className="my-3 p-3 rounded-lg bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-primary-700 dark:text-primary-300">
|
||||||
|
{content.data.label as string}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold text-primary-900 dark:text-primary-100">
|
||||||
|
{content.data.value as string}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{content.data.change && (
|
||||||
|
<p className="text-xs text-primary-600 dark:text-primary-400 mt-1">
|
||||||
|
{content.data.change as string}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'alert':
|
||||||
|
const alertType = (content.data.type as string) || 'info';
|
||||||
|
const alertStyles = {
|
||||||
|
info: 'bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800 text-primary-700 dark:text-primary-300',
|
||||||
|
success: 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800 text-success-700 dark:text-success-300',
|
||||||
|
warning: 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 text-warning-700 dark:text-warning-300',
|
||||||
|
error: 'bg-error-50 dark:bg-error-900/20 border-error-200 dark:border-error-800 text-error-700 dark:text-error-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('my-3 p-3 rounded-lg border', alertStyles[alertType as keyof typeof alertStyles] || alertStyles.info)}>
|
||||||
|
<p className="font-medium text-sm">{content.data.title as string}</p>
|
||||||
|
{content.data.message && (
|
||||||
|
<p className="text-sm mt-1 opacity-80">{content.data.message as string}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'table':
|
||||||
|
const headers = (content.data.headers || []) as string[];
|
||||||
|
const rows = (content.data.rows || []) as string[][];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-3 overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||||
|
<tr>
|
||||||
|
{headers.map((header, i) => (
|
||||||
|
<th
|
||||||
|
key={i}
|
||||||
|
className="px-4 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-slate-900 divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<tr key={rowIndex}>
|
||||||
|
{row.map((cell, cellIndex) => (
|
||||||
|
<td
|
||||||
|
key={cellIndex}
|
||||||
|
className="px-4 py-2 whitespace-nowrap text-sm text-slate-900 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente ChatMessage
|
||||||
|
*
|
||||||
|
* Muestra un mensaje individual del chat con soporte para
|
||||||
|
* contenido enriquecido, streaming y acciones.
|
||||||
|
*/
|
||||||
|
export const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||||
|
message,
|
||||||
|
onCopy,
|
||||||
|
onRetry,
|
||||||
|
onFeedback,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
const isAssistant = message.role === 'assistant';
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (onCopy) {
|
||||||
|
onCopy(message.content);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Procesar contenido para markdown basico
|
||||||
|
const processContent = (content: string) => {
|
||||||
|
// Convertir **texto** a negrita
|
||||||
|
let processed = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
// Convertir *texto* a italica
|
||||||
|
processed = processed.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||||
|
// Convertir `codigo` a codigo inline
|
||||||
|
processed = processed.replace(/`(.*?)`/g, '<code class="px-1 py-0.5 rounded bg-slate-100 dark:bg-slate-700 text-sm">$1</code>');
|
||||||
|
// Convertir lineas nuevas
|
||||||
|
processed = processed.replace(/\n/g, '<br />');
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex gap-3',
|
||||||
|
isUser ? 'flex-row-reverse' : 'flex-row',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
|
||||||
|
isUser
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isUser ? (
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Content */}
|
||||||
|
<div className={cn('flex-1 max-w-[85%]', isUser && 'flex flex-col items-end')}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={cn('flex items-center gap-2 mb-1', isUser && 'flex-row-reverse')}>
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
{isUser ? 'Tu' : 'CFO Digital'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
{formatDate(message.timestamp, { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bubble */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative px-4 py-3 rounded-2xl',
|
||||||
|
isUser
|
||||||
|
? 'bg-primary-500 text-white rounded-tr-none'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-white rounded-tl-none',
|
||||||
|
message.isError && 'bg-error-100 dark:bg-error-900/30 border border-error-300 dark:border-error-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-sm leading-relaxed',
|
||||||
|
message.isStreaming && 'animate-pulse'
|
||||||
|
)}
|
||||||
|
dangerouslySetInnerHTML={{ __html: processContent(message.content) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Streaming indicator */}
|
||||||
|
{message.isStreaming && (
|
||||||
|
<span className="inline-block w-1.5 h-4 ml-1 bg-current animate-pulse rounded" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline Content */}
|
||||||
|
{message.inlineContent?.map((content, index) => (
|
||||||
|
<InlineContentRenderer key={index} content={content} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions for assistant messages */}
|
||||||
|
{isAssistant && !message.isStreaming && (
|
||||||
|
<div className="flex items-center gap-1 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
title="Copiar respuesta"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-success-500" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRetry(message)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
title="Regenerar respuesta"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onFeedback && (
|
||||||
|
<>
|
||||||
|
<div className="w-px h-4 bg-slate-200 dark:bg-slate-600 mx-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => onFeedback(message, true)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-success-600 hover:bg-success-50 dark:hover:text-success-400 dark:hover:bg-success-900/30 transition-colors"
|
||||||
|
title="Respuesta util"
|
||||||
|
>
|
||||||
|
<ThumbsUp className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onFeedback(message, false)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-error-600 hover:bg-error-50 dark:hover:text-error-400 dark:hover:bg-error-900/30 transition-colors"
|
||||||
|
title="Respuesta no util"
|
||||||
|
>
|
||||||
|
<ThumbsDown className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatMessage;
|
||||||
265
apps/web/src/components/ai/SuggestedQuestions.tsx
Normal file
265
apps/web/src/components/ai/SuggestedQuestions.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
PieChart,
|
||||||
|
Users,
|
||||||
|
AlertTriangle,
|
||||||
|
Target,
|
||||||
|
Calendar,
|
||||||
|
BarChart3,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categoria de pregunta sugerida
|
||||||
|
*/
|
||||||
|
type QuestionCategory = 'financiero' | 'metricas' | 'clientes' | 'predicciones' | 'alertas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pregunta sugerida
|
||||||
|
*/
|
||||||
|
export interface SuggestedQuestion {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
category: QuestionCategory;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props del componente SuggestedQuestions
|
||||||
|
*/
|
||||||
|
interface SuggestedQuestionsProps {
|
||||||
|
questions: SuggestedQuestion[];
|
||||||
|
onSelect: (question: SuggestedQuestion) => void;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icono por defecto segun categoria
|
||||||
|
*/
|
||||||
|
const defaultIcons: Record<QuestionCategory, React.ReactNode> = {
|
||||||
|
financiero: <DollarSign className="w-4 h-4" />,
|
||||||
|
metricas: <BarChart3 className="w-4 h-4" />,
|
||||||
|
clientes: <Users className="w-4 h-4" />,
|
||||||
|
predicciones: <TrendingUp className="w-4 h-4" />,
|
||||||
|
alertas: <AlertTriangle className="w-4 h-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estilos por categoria
|
||||||
|
*/
|
||||||
|
const categoryStyles: Record<QuestionCategory, { bg: string; text: string; border: string; hover: string }> = {
|
||||||
|
financiero: {
|
||||||
|
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||||
|
text: 'text-emerald-700 dark:text-emerald-400',
|
||||||
|
border: 'border-emerald-200 dark:border-emerald-800',
|
||||||
|
hover: 'hover:bg-emerald-100 dark:hover:bg-emerald-900/40',
|
||||||
|
},
|
||||||
|
metricas: {
|
||||||
|
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||||
|
text: 'text-blue-700 dark:text-blue-400',
|
||||||
|
border: 'border-blue-200 dark:border-blue-800',
|
||||||
|
hover: 'hover:bg-blue-100 dark:hover:bg-blue-900/40',
|
||||||
|
},
|
||||||
|
clientes: {
|
||||||
|
bg: 'bg-purple-50 dark:bg-purple-900/20',
|
||||||
|
text: 'text-purple-700 dark:text-purple-400',
|
||||||
|
border: 'border-purple-200 dark:border-purple-800',
|
||||||
|
hover: 'hover:bg-purple-100 dark:hover:bg-purple-900/40',
|
||||||
|
},
|
||||||
|
predicciones: {
|
||||||
|
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||||
|
text: 'text-amber-700 dark:text-amber-400',
|
||||||
|
border: 'border-amber-200 dark:border-amber-800',
|
||||||
|
hover: 'hover:bg-amber-100 dark:hover:bg-amber-900/40',
|
||||||
|
},
|
||||||
|
alertas: {
|
||||||
|
bg: 'bg-red-50 dark:bg-red-900/20',
|
||||||
|
text: 'text-red-700 dark:text-red-400',
|
||||||
|
border: 'border-red-200 dark:border-red-800',
|
||||||
|
hover: 'hover:bg-red-100 dark:hover:bg-red-900/40',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preguntas sugeridas por defecto
|
||||||
|
*/
|
||||||
|
export const defaultSuggestedQuestions: SuggestedQuestion[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
text: 'Como esta mi flujo de caja este mes?',
|
||||||
|
category: 'financiero',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
text: 'Cuales son mis metricas SaaS clave?',
|
||||||
|
category: 'metricas',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
text: 'Que clientes tienen facturas vencidas?',
|
||||||
|
category: 'clientes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
text: 'Proyecta mis ingresos para el proximo trimestre',
|
||||||
|
category: 'predicciones',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
text: 'Hay alguna alerta financiera que deba revisar?',
|
||||||
|
category: 'alertas',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
text: 'Cual es mi margen bruto actual?',
|
||||||
|
category: 'financiero',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
text: 'Como ha evolucionado mi MRR?',
|
||||||
|
category: 'metricas',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
text: 'Cual es mi tasa de churn actual?',
|
||||||
|
category: 'clientes',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente SuggestedQuestions
|
||||||
|
*
|
||||||
|
* Muestra una lista de preguntas sugeridas que el usuario puede
|
||||||
|
* seleccionar para iniciar una conversacion con el CFO Digital.
|
||||||
|
*/
|
||||||
|
export const SuggestedQuestions: React.FC<SuggestedQuestionsProps> = ({
|
||||||
|
questions,
|
||||||
|
onSelect,
|
||||||
|
title = 'Preguntas sugeridas',
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-wrap gap-2', className)}>
|
||||||
|
{questions.map((question) => {
|
||||||
|
const styles = categoryStyles[question.category];
|
||||||
|
const icon = question.icon || defaultIcons[question.category];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={question.id}
|
||||||
|
onClick={() => onSelect(question)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 px-3 py-1.5 rounded-full',
|
||||||
|
'text-sm font-medium border transition-all',
|
||||||
|
styles.bg,
|
||||||
|
styles.text,
|
||||||
|
styles.border,
|
||||||
|
styles.hover
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="max-w-[200px] truncate">{question.text}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{questions.map((question) => {
|
||||||
|
const styles = categoryStyles[question.category];
|
||||||
|
const icon = question.icon || defaultIcons[question.category];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={question.id}
|
||||||
|
onClick={() => onSelect(question)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 p-3 rounded-lg text-left',
|
||||||
|
'border transition-all group',
|
||||||
|
styles.bg,
|
||||||
|
styles.border,
|
||||||
|
styles.hover
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 p-1.5 rounded-md',
|
||||||
|
styles.text,
|
||||||
|
'bg-white/50 dark:bg-slate-900/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-snug',
|
||||||
|
styles.text,
|
||||||
|
'group-hover:underline'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{question.text}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente QuickActions para accesos rapidos
|
||||||
|
*/
|
||||||
|
interface QuickActionsProps {
|
||||||
|
onAction: (action: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickActions: React.FC<QuickActionsProps> = ({ onAction, className }) => {
|
||||||
|
const actions = [
|
||||||
|
{ id: 'resumen_diario', label: 'Resumen del dia', icon: <Calendar className="w-4 h-4" /> },
|
||||||
|
{ id: 'metricas_clave', label: 'Metricas clave', icon: <BarChart3 className="w-4 h-4" /> },
|
||||||
|
{ id: 'alertas_activas', label: 'Alertas activas', icon: <AlertTriangle className="w-4 h-4" /> },
|
||||||
|
{ id: 'proyeccion_mes', label: 'Proyeccion del mes', icon: <Target className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-wrap gap-2', className)}>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
onClick={() => onAction(action.id)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 px-3 py-2 rounded-lg',
|
||||||
|
'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300',
|
||||||
|
'hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors',
|
||||||
|
'text-sm font-medium'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SuggestedQuestions;
|
||||||
10
apps/web/src/components/ai/index.ts
Normal file
10
apps/web/src/components/ai/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { ChatMessage } from './ChatMessage';
|
||||||
|
export type { Message, MessageRole, MessageInlineContent } from './ChatMessage';
|
||||||
|
|
||||||
|
export { SuggestedQuestions, QuickActions, defaultSuggestedQuestions } from './SuggestedQuestions';
|
||||||
|
export type { SuggestedQuestion } from './SuggestedQuestions';
|
||||||
|
|
||||||
|
export { AIInsightCard, InsightsList } from './AIInsightCard';
|
||||||
|
export type { AIInsight, InsightType, InsightPriority } from './AIInsightCard';
|
||||||
|
|
||||||
|
export { ChatInterface } from './ChatInterface';
|
||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Shield,
|
Shield,
|
||||||
Bell,
|
Bell,
|
||||||
|
Plug,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,6 +95,21 @@ const navigation: NavGroup[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Datos',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Integraciones',
|
||||||
|
href: '/integraciones',
|
||||||
|
icon: <Plug className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reportes',
|
||||||
|
href: '/reportes',
|
||||||
|
icon: <FileText className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Sistema',
|
label: 'Sistema',
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
645
apps/web/src/components/reports/GenerateReportWizard.tsx
Normal file
645
apps/web/src/components/reports/GenerateReportWizard.tsx
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
CalendarRange,
|
||||||
|
BarChart3,
|
||||||
|
PieChart,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Receipt,
|
||||||
|
DollarSign,
|
||||||
|
FileBarChart,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { ReportType } from './ReportCard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seccion de reporte disponible
|
||||||
|
*/
|
||||||
|
interface ReportSectionOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props del componente GenerateReportWizard
|
||||||
|
*/
|
||||||
|
interface GenerateReportWizardProps {
|
||||||
|
onComplete: (config: ReportConfig) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuracion del reporte a generar
|
||||||
|
*/
|
||||||
|
export interface ReportConfig {
|
||||||
|
type: ReportType;
|
||||||
|
period: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
sections: string[];
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones de tipo de reporte
|
||||||
|
*/
|
||||||
|
const reportTypes: Array<{
|
||||||
|
type: ReportType;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
type: 'mensual',
|
||||||
|
name: 'Reporte Mensual',
|
||||||
|
description: 'Resumen completo del mes seleccionado',
|
||||||
|
icon: <Calendar className="w-6 h-6" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'trimestral',
|
||||||
|
name: 'Reporte Trimestral',
|
||||||
|
description: 'Analisis de 3 meses consecutivos',
|
||||||
|
icon: <CalendarRange className="w-6 h-6" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'anual',
|
||||||
|
name: 'Reporte Anual',
|
||||||
|
description: 'Vision general del ano fiscal completo',
|
||||||
|
icon: <FileBarChart className="w-6 h-6" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
name: 'Reporte Personalizado',
|
||||||
|
description: 'Define tu propio rango de fechas',
|
||||||
|
icon: <FileText className="w-6 h-6" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secciones disponibles para incluir
|
||||||
|
*/
|
||||||
|
const availableSections: ReportSectionOption[] = [
|
||||||
|
{
|
||||||
|
id: 'resumen_ejecutivo',
|
||||||
|
name: 'Resumen Ejecutivo',
|
||||||
|
description: 'Vision general de metricas clave',
|
||||||
|
icon: <FileText className="w-5 h-5" />,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ingresos',
|
||||||
|
name: 'Analisis de Ingresos',
|
||||||
|
description: 'Desglose completo de ingresos por categoria',
|
||||||
|
icon: <TrendingUp className="w-5 h-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'egresos',
|
||||||
|
name: 'Analisis de Egresos',
|
||||||
|
description: 'Control de gastos y costos operativos',
|
||||||
|
icon: <Receipt className="w-5 h-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'flujo_caja',
|
||||||
|
name: 'Flujo de Caja',
|
||||||
|
description: 'Movimientos de efectivo y proyecciones',
|
||||||
|
icon: <DollarSign className="w-5 h-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cuentas_cobrar',
|
||||||
|
name: 'Cuentas por Cobrar',
|
||||||
|
description: 'Estado de facturas pendientes de cobro',
|
||||||
|
icon: <Users className="w-5 h-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cuentas_pagar',
|
||||||
|
name: 'Cuentas por Pagar',
|
||||||
|
description: 'Obligaciones pendientes con proveedores',
|
||||||
|
icon: <Receipt className="w-5 h-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'metricas_saas',
|
||||||
|
name: 'Metricas SaaS',
|
||||||
|
description: 'MRR, ARR, Churn, LTV/CAC y mas',
|
||||||
|
icon: <BarChart3 className="w-5 h-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'graficas',
|
||||||
|
name: 'Graficas y Visualizaciones',
|
||||||
|
description: 'Representaciones visuales de los datos',
|
||||||
|
icon: <PieChart className="w-5 h-5" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente GenerateReportWizard
|
||||||
|
*
|
||||||
|
* Wizard de 3 pasos para configurar y generar un reporte.
|
||||||
|
*/
|
||||||
|
export const GenerateReportWizard: React.FC<GenerateReportWizardProps> = ({
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [generationProgress, setGenerationProgress] = useState(0);
|
||||||
|
|
||||||
|
// Estado del formulario
|
||||||
|
const [selectedType, setSelectedType] = useState<ReportType | null>(null);
|
||||||
|
const [period, setPeriod] = useState({
|
||||||
|
start: '',
|
||||||
|
end: '',
|
||||||
|
});
|
||||||
|
const [selectedSections, setSelectedSections] = useState<string[]>(['resumen_ejecutivo']);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
|
||||||
|
// Calcular fechas basadas en el tipo
|
||||||
|
const calculatePeriod = useCallback((type: ReportType) => {
|
||||||
|
const now = new Date();
|
||||||
|
let start: Date;
|
||||||
|
let end: Date = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'mensual':
|
||||||
|
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
break;
|
||||||
|
case 'trimestral':
|
||||||
|
const quarter = Math.floor(now.getMonth() / 3);
|
||||||
|
start = new Date(now.getFullYear(), quarter * 3, 1);
|
||||||
|
end = new Date(now.getFullYear(), quarter * 3 + 3, 0);
|
||||||
|
break;
|
||||||
|
case 'anual':
|
||||||
|
start = new Date(now.getFullYear(), 0, 1);
|
||||||
|
end = new Date(now.getFullYear(), 11, 31);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPeriod({
|
||||||
|
start: start.toISOString().split('T')[0],
|
||||||
|
end: end.toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Manejar seleccion de tipo
|
||||||
|
const handleTypeSelect = (type: ReportType) => {
|
||||||
|
setSelectedType(type);
|
||||||
|
if (type !== 'custom') {
|
||||||
|
calculatePeriod(type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Manejar toggle de seccion
|
||||||
|
const toggleSection = (sectionId: string) => {
|
||||||
|
const section = availableSections.find(s => s.id === sectionId);
|
||||||
|
if (section?.required) return;
|
||||||
|
|
||||||
|
setSelectedSections(prev =>
|
||||||
|
prev.includes(sectionId)
|
||||||
|
? prev.filter(id => id !== sectionId)
|
||||||
|
: [...prev, sectionId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generar titulo automatico
|
||||||
|
const generateTitle = useCallback(() => {
|
||||||
|
if (!selectedType || !period.start) return '';
|
||||||
|
|
||||||
|
const startDate = new Date(period.start);
|
||||||
|
const monthNames = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||||
|
|
||||||
|
switch (selectedType) {
|
||||||
|
case 'mensual':
|
||||||
|
return `Reporte ${monthNames[startDate.getMonth()]} ${startDate.getFullYear()}`;
|
||||||
|
case 'trimestral':
|
||||||
|
const quarter = Math.floor(startDate.getMonth() / 3) + 1;
|
||||||
|
return `Reporte Q${quarter} ${startDate.getFullYear()}`;
|
||||||
|
case 'anual':
|
||||||
|
return `Reporte Anual ${startDate.getFullYear()}`;
|
||||||
|
default:
|
||||||
|
return `Reporte Personalizado`;
|
||||||
|
}
|
||||||
|
}, [selectedType, period.start]);
|
||||||
|
|
||||||
|
// Simular generacion del reporte
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!selectedType || !period.start || !period.end) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
setGenerationProgress(0);
|
||||||
|
|
||||||
|
const config: ReportConfig = {
|
||||||
|
type: selectedType,
|
||||||
|
period,
|
||||||
|
sections: selectedSections,
|
||||||
|
title: title || generateTitle(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simular progreso
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
setGenerationProgress(prev => {
|
||||||
|
if (prev >= 95) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return prev + Math.random() * 10;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onComplete(config);
|
||||||
|
setGenerationProgress(100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generando reporte:', error);
|
||||||
|
} finally {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validacion por paso
|
||||||
|
const canProceed = () => {
|
||||||
|
switch (step) {
|
||||||
|
case 1:
|
||||||
|
return selectedType !== null;
|
||||||
|
case 2:
|
||||||
|
return period.start && period.end && selectedSections.length > 0;
|
||||||
|
case 3:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('bg-white dark:bg-slate-800 rounded-xl', className)}>
|
||||||
|
{/* Progress Steps */}
|
||||||
|
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{[1, 2, 3].map((s) => (
|
||||||
|
<React.Fragment key={s}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors',
|
||||||
|
step > s
|
||||||
|
? 'bg-success-500 text-white'
|
||||||
|
: step === s
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-400 dark:text-slate-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step > s ? <Check className="w-4 h-4" /> : s}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium hidden sm:inline',
|
||||||
|
step >= s
|
||||||
|
? 'text-slate-900 dark:text-white'
|
||||||
|
: 'text-slate-400 dark:text-slate-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s === 1 ? 'Tipo' : s === 2 ? 'Configurar' : 'Confirmar'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s < 3 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-1 h-0.5 mx-4 transition-colors',
|
||||||
|
step > s
|
||||||
|
? 'bg-success-500'
|
||||||
|
: 'bg-slate-200 dark:bg-slate-700'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Step 1: Seleccionar Tipo */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
Selecciona el tipo de reporte
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Elige el formato que mejor se ajuste a tus necesidades
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||||
|
{reportTypes.map((rt) => (
|
||||||
|
<button
|
||||||
|
key={rt.type}
|
||||||
|
onClick={() => handleTypeSelect(rt.type)}
|
||||||
|
className={cn(
|
||||||
|
'p-4 rounded-xl border-2 text-left transition-all',
|
||||||
|
selectedType === rt.type
|
||||||
|
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||||
|
: 'border-slate-200 dark:border-slate-700 hover:border-primary-300 dark:hover:border-primary-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-2.5 rounded-lg',
|
||||||
|
selectedType === rt.type
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rt.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{rt.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
{rt.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectedType === rt.type && (
|
||||||
|
<CheckCircle className="w-5 h-5 text-primary-500 ml-auto flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Configurar Periodo y Secciones */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
Configura el reporte
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Define el periodo y las secciones a incluir
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Periodo */}
|
||||||
|
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50 space-y-4">
|
||||||
|
<h3 className="font-medium text-slate-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5 text-primary-500" />
|
||||||
|
Periodo del reporte
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
|
Fecha inicio
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={period.start}
|
||||||
|
onChange={(e) => setPeriod({ ...period, start: e.target.value })}
|
||||||
|
disabled={selectedType !== 'custom'}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600',
|
||||||
|
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
|
||||||
|
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
||||||
|
'disabled:opacity-60 disabled:cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
|
Fecha fin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={period.end}
|
||||||
|
onChange={(e) => setPeriod({ ...period, end: e.target.value })}
|
||||||
|
disabled={selectedType !== 'custom'}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600',
|
||||||
|
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
|
||||||
|
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
||||||
|
'disabled:opacity-60 disabled:cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secciones */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-slate-900 dark:text-white mb-3">
|
||||||
|
Secciones a incluir
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{availableSections.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
onClick={() => toggleSection(section.id)}
|
||||||
|
disabled={section.required}
|
||||||
|
className={cn(
|
||||||
|
'p-3 rounded-lg border text-left transition-all',
|
||||||
|
selectedSections.includes(section.id)
|
||||||
|
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||||
|
: 'border-slate-200 dark:border-slate-700 hover:border-primary-300 dark:hover:border-primary-600',
|
||||||
|
section.required && 'opacity-80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-1.5 rounded-md',
|
||||||
|
selectedSections.includes(section.id)
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{section.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-medium text-slate-900 dark:text-white text-sm">
|
||||||
|
{section.name}
|
||||||
|
</h4>
|
||||||
|
{section.required && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-slate-200 dark:bg-slate-700 text-slate-500 dark:text-slate-400">
|
||||||
|
Requerido
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
{section.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectedSections.includes(section.id) && (
|
||||||
|
<CheckCircle className="w-4 h-4 text-primary-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Confirmar y Generar */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
Confirmar y generar
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Revisa la configuracion antes de generar el reporte
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Titulo del reporte */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
|
Titulo del reporte
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title || generateTitle()}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Titulo del reporte..."
|
||||||
|
className={cn(
|
||||||
|
'w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600',
|
||||||
|
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
|
||||||
|
'focus:ring-2 focus:ring-primary-500 focus:border-transparent'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resumen de configuracion */}
|
||||||
|
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50 space-y-4">
|
||||||
|
<h3 className="font-medium text-slate-900 dark:text-white">
|
||||||
|
Resumen de configuracion
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<span className="text-sm text-slate-500 dark:text-slate-400">Tipo de reporte</span>
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
{reportTypes.find(t => t.type === selectedType)?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<span className="text-sm text-slate-500 dark:text-slate-400">Periodo</span>
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
{period.start} a {period.end}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<span className="text-sm text-slate-500 dark:text-slate-400">Secciones</span>
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
{selectedSections.length} seleccionadas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{selectedSections.map((sectionId) => {
|
||||||
|
const section = availableSections.find(s => s.id === sectionId);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={sectionId}
|
||||||
|
className="text-xs px-2 py-1 rounded-md bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400"
|
||||||
|
>
|
||||||
|
{section?.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress durante generacion */}
|
||||||
|
{isGenerating && (
|
||||||
|
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Loader2 className="w-5 h-5 text-primary-600 dark:text-primary-400 animate-spin" />
|
||||||
|
<span className="font-medium text-primary-700 dark:text-primary-300">
|
||||||
|
Generando reporte...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-primary-100 dark:bg-primary-900/50 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary-500 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${generationProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-primary-600 dark:text-primary-400 mt-2">
|
||||||
|
{generationProgress < 30 && 'Recopilando datos...'}
|
||||||
|
{generationProgress >= 30 && generationProgress < 60 && 'Procesando metricas...'}
|
||||||
|
{generationProgress >= 60 && generationProgress < 90 && 'Generando graficas...'}
|
||||||
|
{generationProgress >= 90 && 'Finalizando reporte...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{step > 1 && !isGenerating && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setStep(step - 1)}
|
||||||
|
leftIcon={<ArrowLeft className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={onCancel} disabled={isGenerating}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
{step < 3 ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => setStep(step + 1)}
|
||||||
|
disabled={!canProceed()}
|
||||||
|
rightIcon={<ArrowRight className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating}
|
||||||
|
isLoading={isGenerating}
|
||||||
|
leftIcon={!isGenerating ? <FileText className="w-4 h-4" /> : undefined}
|
||||||
|
>
|
||||||
|
{isGenerating ? 'Generando...' : 'Generar Reporte'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenerateReportWizard;
|
||||||
271
apps/web/src/components/reports/ReportCard.tsx
Normal file
271
apps/web/src/components/reports/ReportCard.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cn, formatDate, formatCurrency } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Calendar,
|
||||||
|
TrendingUp,
|
||||||
|
BarChart3,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de reporte
|
||||||
|
*/
|
||||||
|
export type ReportType = 'mensual' | 'trimestral' | 'anual' | 'custom';
|
||||||
|
export type ReportStatus = 'generando' | 'completado' | 'error' | 'pendiente';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface del reporte
|
||||||
|
*/
|
||||||
|
export interface Report {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: ReportType;
|
||||||
|
status: ReportStatus;
|
||||||
|
period: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
sections: string[];
|
||||||
|
generatedAt?: string;
|
||||||
|
fileSize?: string;
|
||||||
|
summary?: {
|
||||||
|
ingresos: number;
|
||||||
|
egresos: number;
|
||||||
|
utilidad: number;
|
||||||
|
};
|
||||||
|
progress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props del componente ReportCard
|
||||||
|
*/
|
||||||
|
interface ReportCardProps {
|
||||||
|
report: Report;
|
||||||
|
onView?: (report: Report) => void;
|
||||||
|
onDownload?: (report: Report) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuracion visual por tipo de reporte
|
||||||
|
*/
|
||||||
|
const typeConfig: Record<ReportType, { label: string; color: string; bgColor: string }> = {
|
||||||
|
mensual: {
|
||||||
|
label: 'Mensual',
|
||||||
|
color: 'text-blue-600 dark:text-blue-400',
|
||||||
|
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
|
||||||
|
},
|
||||||
|
trimestral: {
|
||||||
|
label: 'Trimestral',
|
||||||
|
color: 'text-purple-600 dark:text-purple-400',
|
||||||
|
bgColor: 'bg-purple-100 dark:bg-purple-900/30',
|
||||||
|
},
|
||||||
|
anual: {
|
||||||
|
label: 'Anual',
|
||||||
|
color: 'text-amber-600 dark:text-amber-400',
|
||||||
|
bgColor: 'bg-amber-100 dark:bg-amber-900/30',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
label: 'Personalizado',
|
||||||
|
color: 'text-slate-600 dark:text-slate-400',
|
||||||
|
bgColor: 'bg-slate-100 dark:bg-slate-700',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuracion visual por estado
|
||||||
|
*/
|
||||||
|
const statusConfig: Record<ReportStatus, { label: string; icon: React.ReactNode; color: string; bgColor: string }> = {
|
||||||
|
completado: {
|
||||||
|
label: 'Completado',
|
||||||
|
icon: <CheckCircle className="w-4 h-4" />,
|
||||||
|
color: 'text-success-600 dark:text-success-400',
|
||||||
|
bgColor: 'bg-success-100 dark:bg-success-900/30',
|
||||||
|
},
|
||||||
|
generando: {
|
||||||
|
label: 'Generando',
|
||||||
|
icon: <Clock className="w-4 h-4 animate-spin" />,
|
||||||
|
color: 'text-primary-600 dark:text-primary-400',
|
||||||
|
bgColor: 'bg-primary-100 dark:bg-primary-900/30',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
label: 'Error',
|
||||||
|
icon: <AlertCircle className="w-4 h-4" />,
|
||||||
|
color: 'text-error-600 dark:text-error-400',
|
||||||
|
bgColor: 'bg-error-100 dark:bg-error-900/30',
|
||||||
|
},
|
||||||
|
pendiente: {
|
||||||
|
label: 'Pendiente',
|
||||||
|
icon: <Clock className="w-4 h-4" />,
|
||||||
|
color: 'text-slate-600 dark:text-slate-400',
|
||||||
|
bgColor: 'bg-slate-100 dark:bg-slate-700',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente ReportCard
|
||||||
|
*
|
||||||
|
* Muestra una tarjeta con informacion resumida de un reporte.
|
||||||
|
*/
|
||||||
|
export const ReportCard: React.FC<ReportCardProps> = ({
|
||||||
|
report,
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const typeStyle = typeConfig[report.type];
|
||||||
|
const statusStyle = statusConfig[report.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700',
|
||||||
|
'hover:shadow-lg hover:border-primary-300 dark:hover:border-primary-600',
|
||||||
|
'transition-all duration-200 cursor-pointer group',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={() => onView?.(report)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-5 border-b border-slate-100 dark:border-slate-700">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn('p-2.5 rounded-lg', typeStyle.bgColor)}>
|
||||||
|
<FileText className={cn('w-5 h-5', typeStyle.color)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||||
|
{report.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className={cn('text-xs font-medium px-2 py-0.5 rounded-full', typeStyle.bgColor, typeStyle.color)}>
|
||||||
|
{typeStyle.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn('flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full', statusStyle.bgColor, statusStyle.color)}>
|
||||||
|
{statusStyle.icon}
|
||||||
|
{statusStyle.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{/* Periodo */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{formatDate(report.period.start)} - {formatDate(report.period.end)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar si esta generando */}
|
||||||
|
{report.status === 'generando' && report.progress !== undefined && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">Progreso</span>
|
||||||
|
<span className="font-medium text-slate-700 dark:text-slate-300">{report.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${report.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resumen financiero */}
|
||||||
|
{report.status === 'completado' && report.summary && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="text-center p-2.5 rounded-lg bg-success-50 dark:bg-success-900/20">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Ingresos</p>
|
||||||
|
<p className="text-sm font-semibold text-success-600 dark:text-success-400">
|
||||||
|
{formatCurrency(report.summary.ingresos)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2.5 rounded-lg bg-error-50 dark:bg-error-900/20">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Egresos</p>
|
||||||
|
<p className="text-sm font-semibold text-error-600 dark:text-error-400">
|
||||||
|
{formatCurrency(report.summary.egresos)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2.5 rounded-lg bg-primary-50 dark:bg-primary-900/20">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Utilidad</p>
|
||||||
|
<p className="text-sm font-semibold text-primary-600 dark:text-primary-400">
|
||||||
|
{formatCurrency(report.summary.utilidad)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Secciones */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">Secciones incluidas:</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{report.sections.slice(0, 4).map((section) => (
|
||||||
|
<span
|
||||||
|
key={section}
|
||||||
|
className="text-xs px-2 py-1 rounded-md bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{section}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{report.sections.length > 4 && (
|
||||||
|
<span className="text-xs px-2 py-1 rounded-md bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400">
|
||||||
|
+{report.sections.length - 4} mas
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-5 py-3 border-t border-slate-100 dark:border-slate-700 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{report.generatedAt && (
|
||||||
|
<span>Generado: {formatDate(report.generatedAt)}</span>
|
||||||
|
)}
|
||||||
|
{report.fileSize && (
|
||||||
|
<span className="ml-2">({report.fileSize})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onView?.(report);
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-lg text-slate-500 hover:text-primary-600 hover:bg-primary-50 dark:hover:text-primary-400 dark:hover:bg-primary-900/30 transition-colors"
|
||||||
|
title="Ver reporte"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{report.status === 'completado' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDownload?.(report);
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-lg text-slate-500 hover:text-success-600 hover:bg-success-50 dark:hover:text-success-400 dark:hover:bg-success-900/30 transition-colors"
|
||||||
|
title="Descargar PDF"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportCard;
|
||||||
334
apps/web/src/components/reports/ReportChart.tsx
Normal file
334
apps/web/src/components/reports/ReportChart.tsx
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { cn, formatCurrency, formatNumber } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de grafica
|
||||||
|
*/
|
||||||
|
type ChartType = 'line' | 'area' | 'bar' | 'pie';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datos de la grafica
|
||||||
|
*/
|
||||||
|
interface ChartDataPoint {
|
||||||
|
name: string;
|
||||||
|
[key: string]: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuracion de serie
|
||||||
|
*/
|
||||||
|
interface ChartSeries {
|
||||||
|
dataKey: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
type?: 'monotone' | 'linear' | 'step';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props del componente ReportChart
|
||||||
|
*/
|
||||||
|
interface ReportChartProps {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
type: ChartType;
|
||||||
|
data: ChartDataPoint[];
|
||||||
|
series?: ChartSeries[];
|
||||||
|
height?: number;
|
||||||
|
showLegend?: boolean;
|
||||||
|
showGrid?: boolean;
|
||||||
|
formatTooltip?: 'currency' | 'number' | 'percentage';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colores por defecto para las series
|
||||||
|
*/
|
||||||
|
const defaultColors = [
|
||||||
|
'#0c8ce8', // primary
|
||||||
|
'#10b981', // success
|
||||||
|
'#f59e0b', // warning
|
||||||
|
'#ef4444', // error
|
||||||
|
'#8b5cf6', // purple
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#f97316', // orange
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente de Tooltip personalizado
|
||||||
|
*/
|
||||||
|
interface CustomTooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
dataKey: string;
|
||||||
|
}>;
|
||||||
|
label?: string;
|
||||||
|
format?: 'currency' | 'number' | 'percentage';
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip: React.FC<CustomTooltipProps> = ({ active, payload, label, format }) => {
|
||||||
|
if (!active || !payload) return null;
|
||||||
|
|
||||||
|
const formatValue = (value: number) => {
|
||||||
|
switch (format) {
|
||||||
|
case 'currency':
|
||||||
|
return formatCurrency(value);
|
||||||
|
case 'percentage':
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
case 'number':
|
||||||
|
default:
|
||||||
|
return formatNumber(value, value % 1 === 0 ? 0 : 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 dark:bg-slate-800 px-4 py-3 rounded-lg shadow-lg border border-slate-700">
|
||||||
|
<p className="text-sm font-medium text-white mb-2">{label}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{payload.map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400">{item.name}:</span>
|
||||||
|
<span className="text-xs font-semibold text-white">
|
||||||
|
{formatValue(item.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente ReportChart
|
||||||
|
*
|
||||||
|
* Grafica interactiva para reportes con soporte para multiples tipos.
|
||||||
|
*/
|
||||||
|
export const ReportChart: React.FC<ReportChartProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
series,
|
||||||
|
height = 300,
|
||||||
|
showLegend = true,
|
||||||
|
showGrid = true,
|
||||||
|
formatTooltip = 'number',
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
// Generar series por defecto si no se proporcionan
|
||||||
|
const chartSeries: ChartSeries[] = series || (data.length > 0
|
||||||
|
? Object.keys(data[0])
|
||||||
|
.filter((key) => key !== 'name' && typeof data[0][key] === 'number')
|
||||||
|
.map((key, index) => ({
|
||||||
|
dataKey: key,
|
||||||
|
name: key.charAt(0).toUpperCase() + key.slice(1),
|
||||||
|
color: defaultColors[index % defaultColors.length],
|
||||||
|
}))
|
||||||
|
: []);
|
||||||
|
|
||||||
|
const renderChart = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'line':
|
||||||
|
return (
|
||||||
|
<LineChart data={data}>
|
||||||
|
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />}
|
||||||
|
<XAxis dataKey="name" stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
|
||||||
|
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
|
||||||
|
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
|
||||||
|
{showLegend && <Legend />}
|
||||||
|
{chartSeries.map((s) => (
|
||||||
|
<Line
|
||||||
|
key={s.dataKey}
|
||||||
|
type={s.type || 'monotone'}
|
||||||
|
dataKey={s.dataKey}
|
||||||
|
name={s.name}
|
||||||
|
stroke={s.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 4, fill: s.color }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'area':
|
||||||
|
return (
|
||||||
|
<AreaChart data={data}>
|
||||||
|
<defs>
|
||||||
|
{chartSeries.map((s) => (
|
||||||
|
<linearGradient key={`gradient-${s.dataKey}`} id={`gradient-${s.dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={s.color} stopOpacity={0.4} />
|
||||||
|
<stop offset="100%" stopColor={s.color} stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />}
|
||||||
|
<XAxis dataKey="name" stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
|
||||||
|
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
|
||||||
|
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
|
||||||
|
{showLegend && <Legend />}
|
||||||
|
{chartSeries.map((s) => (
|
||||||
|
<Area
|
||||||
|
key={s.dataKey}
|
||||||
|
type={s.type || 'monotone'}
|
||||||
|
dataKey={s.dataKey}
|
||||||
|
name={s.name}
|
||||||
|
stroke={s.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={`url(#gradient-${s.dataKey})`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'bar':
|
||||||
|
return (
|
||||||
|
<BarChart data={data}>
|
||||||
|
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />}
|
||||||
|
<XAxis dataKey="name" stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
|
||||||
|
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
|
||||||
|
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
|
||||||
|
{showLegend && <Legend />}
|
||||||
|
{chartSeries.map((s) => (
|
||||||
|
<Bar
|
||||||
|
key={s.dataKey}
|
||||||
|
dataKey={s.dataKey}
|
||||||
|
name={s.name}
|
||||||
|
fill={s.color}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'pie':
|
||||||
|
const pieData = data.map((d, index) => ({
|
||||||
|
...d,
|
||||||
|
fill: defaultColors[index % defaultColors.length],
|
||||||
|
}));
|
||||||
|
const pieDataKey = chartSeries[0]?.dataKey || 'value';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
dataKey={pieDataKey}
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={100}
|
||||||
|
innerRadius={60}
|
||||||
|
paddingAngle={2}
|
||||||
|
label={({ name, percent }) => `${name} (${(percent * 100).toFixed(0)}%)`}
|
||||||
|
labelLine={{ stroke: '#6b7280' }}
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
|
||||||
|
{showLegend && <Legend />}
|
||||||
|
</PieChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('bg-white dark:bg-slate-800 rounded-xl p-5', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
{(title || subtitle) && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div style={{ height }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{renderChart()}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente de grafica mini para previews
|
||||||
|
*/
|
||||||
|
interface MiniChartProps {
|
||||||
|
data: number[];
|
||||||
|
color?: string;
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MiniChart: React.FC<MiniChartProps> = ({
|
||||||
|
data,
|
||||||
|
color = '#0c8ce8',
|
||||||
|
height = 40,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const chartData = data.map((value, index) => ({ value, name: index.toString() }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className)} style={{ height }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="miniGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#miniGradient)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportChart;
|
||||||
211
apps/web/src/components/reports/ReportSection.tsx
Normal file
211
apps/web/src/components/reports/ReportSection.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props del componente ReportSection
|
||||||
|
*/
|
||||||
|
interface ReportSectionProps {
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
className?: string;
|
||||||
|
headerAction?: React.ReactNode;
|
||||||
|
badge?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente ReportSection
|
||||||
|
*
|
||||||
|
* Seccion colapsable para mostrar partes del reporte.
|
||||||
|
*/
|
||||||
|
export const ReportSection: React.FC<ReportSectionProps> = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
defaultExpanded = true,
|
||||||
|
className,
|
||||||
|
headerAction,
|
||||||
|
badge,
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700',
|
||||||
|
'overflow-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between p-4',
|
||||||
|
'hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors',
|
||||||
|
'text-left'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Chevron */}
|
||||||
|
<span className="text-slate-400 dark:text-slate-500">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
{icon && (
|
||||||
|
<span className="text-primary-600 dark:text-primary-400">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Badge */}
|
||||||
|
{badge !== undefined && (
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header Action */}
|
||||||
|
{headerAction && (
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
{headerAction}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden transition-all duration-300 ease-in-out',
|
||||||
|
isExpanded ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-4 pt-0 border-t border-slate-100 dark:border-slate-700">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-seccion para contenido anidado
|
||||||
|
*/
|
||||||
|
interface ReportSubSectionProps {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReportSubSection: React.FC<ReportSubSectionProps> = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={cn('mt-4', className)}>
|
||||||
|
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<div className="pl-4 border-l-2 border-slate-200 dark:border-slate-600">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fila de datos para tablas del reporte
|
||||||
|
*/
|
||||||
|
interface ReportDataRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number | React.ReactNode;
|
||||||
|
subLabel?: string;
|
||||||
|
trend?: 'up' | 'down' | 'neutral';
|
||||||
|
trendValue?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReportDataRow: React.FC<ReportDataRowProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
subLabel,
|
||||||
|
trend,
|
||||||
|
trendValue,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const trendColors = {
|
||||||
|
up: 'text-success-600 dark:text-success-400',
|
||||||
|
down: 'text-error-600 dark:text-error-400',
|
||||||
|
neutral: 'text-slate-500 dark:text-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700 last:border-0', className)}>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">{label}</p>
|
||||||
|
{subLabel && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{subLabel}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{trend && trendValue && (
|
||||||
|
<span className={cn('text-xs font-medium', trendColors[trend])}>
|
||||||
|
{trendValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alerta o nota dentro del reporte
|
||||||
|
*/
|
||||||
|
interface ReportAlertProps {
|
||||||
|
type: 'info' | 'warning' | 'success' | 'error';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReportAlert: React.FC<ReportAlertProps> = ({
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const styles = {
|
||||||
|
info: 'bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800 text-primary-800 dark:text-primary-300',
|
||||||
|
warning: 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 text-warning-800 dark:text-warning-300',
|
||||||
|
success: 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800 text-success-800 dark:text-success-300',
|
||||||
|
error: 'bg-error-50 dark:bg-error-900/20 border-error-200 dark:border-error-800 text-error-800 dark:text-error-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('p-4 rounded-lg border', styles[type], className)}>
|
||||||
|
<p className="font-medium text-sm">{title}</p>
|
||||||
|
<p className="text-sm mt-1 opacity-80">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportSection;
|
||||||
9
apps/web/src/components/reports/index.ts
Normal file
9
apps/web/src/components/reports/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { ReportCard } from './ReportCard';
|
||||||
|
export type { Report, ReportType, ReportStatus } from './ReportCard';
|
||||||
|
|
||||||
|
export { ReportSection, ReportSubSection, ReportDataRow, ReportAlert } from './ReportSection';
|
||||||
|
|
||||||
|
export { ReportChart, MiniChart } from './ReportChart';
|
||||||
|
|
||||||
|
export { GenerateReportWizard } from './GenerateReportWizard';
|
||||||
|
export type { ReportConfig } from './GenerateReportWizard';
|
||||||
2160
package-lock.json
generated
Normal file
2160
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
642
packages/database/src/migrations/003_integrations.sql
Normal file
642
packages/database/src/migrations/003_integrations.sql
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Horux Strategy - Integrations Schema
|
||||||
|
-- Version: 003
|
||||||
|
-- Description: Tables for external integrations management
|
||||||
|
-- Note: ${SCHEMA_NAME} will be replaced with the actual schema name
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ENUM TYPES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Integration type
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'integration_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE integration_type AS ENUM (
|
||||||
|
'contpaqi',
|
||||||
|
'aspel',
|
||||||
|
'odoo',
|
||||||
|
'alegra',
|
||||||
|
'sap',
|
||||||
|
'manual',
|
||||||
|
'sat',
|
||||||
|
'bank_bbva',
|
||||||
|
'bank_banamex',
|
||||||
|
'bank_santander',
|
||||||
|
'bank_banorte',
|
||||||
|
'bank_hsbc',
|
||||||
|
'payments_stripe',
|
||||||
|
'payments_openpay',
|
||||||
|
'webhook',
|
||||||
|
'api_custom'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Integration status
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'integration_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE integration_status AS ENUM (
|
||||||
|
'pending',
|
||||||
|
'active',
|
||||||
|
'inactive',
|
||||||
|
'error',
|
||||||
|
'expired',
|
||||||
|
'configuring'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Sync status
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sync_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE sync_status AS ENUM (
|
||||||
|
'pending',
|
||||||
|
'queued',
|
||||||
|
'running',
|
||||||
|
'completed',
|
||||||
|
'failed',
|
||||||
|
'cancelled',
|
||||||
|
'partial'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Sync direction
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sync_direction' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE sync_direction AS ENUM (
|
||||||
|
'import',
|
||||||
|
'export',
|
||||||
|
'bidirectional'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Sync entity type
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sync_entity_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
|
||||||
|
CREATE TYPE sync_entity_type AS ENUM (
|
||||||
|
'transactions',
|
||||||
|
'invoices',
|
||||||
|
'contacts',
|
||||||
|
'products',
|
||||||
|
'accounts',
|
||||||
|
'categories',
|
||||||
|
'journal_entries',
|
||||||
|
'payments',
|
||||||
|
'cfdis',
|
||||||
|
'bank_statements'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INTEGRATIONS TABLE
|
||||||
|
-- Main table for integration configurations
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS integrations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
|
||||||
|
-- Integration info
|
||||||
|
type integration_type NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status integration_status NOT NULL DEFAULT 'pending',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Configuration (encrypted JSON)
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Connection health
|
||||||
|
last_health_check_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
health_status VARCHAR(20), -- healthy, degraded, unhealthy, unknown
|
||||||
|
health_message TEXT,
|
||||||
|
|
||||||
|
-- Last sync info
|
||||||
|
last_sync_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_sync_status sync_status,
|
||||||
|
last_sync_error TEXT,
|
||||||
|
next_sync_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Statistics
|
||||||
|
total_syncs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
successful_syncs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_syncs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT integrations_unique_type UNIQUE (type) WHERE type NOT IN ('webhook', 'api_custom') AND deleted_at IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for integrations
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_integrations_type ON integrations(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_integrations_status ON integrations(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_integrations_active ON integrations(is_active) WHERE is_active = true;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_integrations_next_sync ON integrations(next_sync_at) WHERE next_sync_at IS NOT NULL AND is_active = true;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_integrations_deleted ON integrations(deleted_at) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SYNC_JOBS TABLE
|
||||||
|
-- Individual sync job executions
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_jobs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Job info
|
||||||
|
job_type VARCHAR(100) NOT NULL,
|
||||||
|
status sync_status NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
-- Timing
|
||||||
|
started_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
completed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Progress
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0, -- 0-100
|
||||||
|
|
||||||
|
-- Results
|
||||||
|
records_processed INTEGER DEFAULT 0,
|
||||||
|
records_created INTEGER DEFAULT 0,
|
||||||
|
records_updated INTEGER DEFAULT 0,
|
||||||
|
records_failed INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Parameters
|
||||||
|
parameters JSONB,
|
||||||
|
result_summary JSONB,
|
||||||
|
|
||||||
|
-- Error handling
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
max_retries INTEGER DEFAULT 3,
|
||||||
|
next_retry_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_by UUID,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for sync_jobs
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_jobs_integration ON sync_jobs(integration_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_jobs_status ON sync_jobs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_jobs_created ON sync_jobs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_jobs_active ON sync_jobs(integration_id, status) WHERE status IN ('pending', 'queued', 'running');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_jobs_retry ON sync_jobs(next_retry_at) WHERE status = 'failed' AND retry_count < max_retries;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INTEGRATION_SYNC_LOGS TABLE
|
||||||
|
-- Detailed sync history and metrics
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS integration_sync_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||||
|
job_id UUID REFERENCES sync_jobs(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Sync details
|
||||||
|
entity_type sync_entity_type,
|
||||||
|
direction sync_direction NOT NULL DEFAULT 'import',
|
||||||
|
status sync_status NOT NULL,
|
||||||
|
|
||||||
|
-- Timing
|
||||||
|
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
completed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
|
||||||
|
-- Results
|
||||||
|
total_records INTEGER DEFAULT 0,
|
||||||
|
created_records INTEGER DEFAULT 0,
|
||||||
|
updated_records INTEGER DEFAULT 0,
|
||||||
|
skipped_records INTEGER DEFAULT 0,
|
||||||
|
failed_records INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Errors
|
||||||
|
error_count INTEGER DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
|
||||||
|
-- Trigger info
|
||||||
|
triggered_by VARCHAR(20) NOT NULL DEFAULT 'manual', -- schedule, manual, webhook, system
|
||||||
|
triggered_by_user_id UUID,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for integration_sync_logs
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_logs_integration ON integration_sync_logs(integration_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_logs_job ON integration_sync_logs(job_id) WHERE job_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_logs_created ON integration_sync_logs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_logs_status ON integration_sync_logs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_logs_entity ON integration_sync_logs(entity_type) WHERE entity_type IS NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INTEGRATION_LOGS TABLE
|
||||||
|
-- General integration event logs (connections, tests, etc.)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS integration_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Event info
|
||||||
|
integration_type integration_type NOT NULL,
|
||||||
|
integration_id UUID REFERENCES integrations(id) ON DELETE SET NULL,
|
||||||
|
event_type VARCHAR(50) NOT NULL, -- connection_test, config_change, error, etc.
|
||||||
|
status VARCHAR(20) NOT NULL, -- success, failed, warning
|
||||||
|
|
||||||
|
-- Details
|
||||||
|
message TEXT,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
metadata JSONB,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
user_id UUID,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for integration_logs
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_integration_logs_type ON integration_logs(integration_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_integration_logs_integration ON integration_logs(integration_id) WHERE integration_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_integration_logs_created ON integration_logs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_integration_logs_event ON integration_logs(event_type);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INTEGRATION_SCHEDULES TABLE
|
||||||
|
-- Scheduled sync configurations
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS integration_schedules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Schedule config
|
||||||
|
entity_type sync_entity_type,
|
||||||
|
direction sync_direction NOT NULL DEFAULT 'import',
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Cron expression
|
||||||
|
cron_expression VARCHAR(100) NOT NULL,
|
||||||
|
timezone VARCHAR(50) NOT NULL DEFAULT 'America/Mexico_City',
|
||||||
|
|
||||||
|
-- Execution window
|
||||||
|
start_time VARCHAR(5), -- HH:mm
|
||||||
|
end_time VARCHAR(5), -- HH:mm
|
||||||
|
days_of_week JSONB, -- [0,1,2,3,4,5,6]
|
||||||
|
|
||||||
|
-- Execution info
|
||||||
|
last_run_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
next_run_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_status sync_status,
|
||||||
|
|
||||||
|
-- Options
|
||||||
|
priority VARCHAR(10) NOT NULL DEFAULT 'normal', -- low, normal, high
|
||||||
|
timeout_ms INTEGER NOT NULL DEFAULT 300000, -- 5 minutes
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for integration_schedules
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_schedules_integration ON integration_schedules(integration_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON integration_schedules(is_enabled) WHERE is_enabled = true;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_schedules_next_run ON integration_schedules(next_run_at) WHERE is_enabled = true;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SYNC_MAPPINGS TABLE
|
||||||
|
-- Field mappings between external systems and Horux
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_mappings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Mapping info
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
entity_type sync_entity_type NOT NULL,
|
||||||
|
source_entity VARCHAR(100) NOT NULL, -- Entity name in external system
|
||||||
|
target_entity VARCHAR(100) NOT NULL, -- Entity name in Horux
|
||||||
|
|
||||||
|
-- Field mappings
|
||||||
|
field_mappings JSONB NOT NULL DEFAULT '[]',
|
||||||
|
/*
|
||||||
|
Example:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"sourceField": "CLAVECTE",
|
||||||
|
"targetField": "external_id",
|
||||||
|
"transformationType": "direct",
|
||||||
|
"isRequired": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"sourceField": "NOMCTE",
|
||||||
|
"targetField": "name",
|
||||||
|
"transformationType": "direct",
|
||||||
|
"isRequired": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"sourceField": "RFC",
|
||||||
|
"targetField": "rfc",
|
||||||
|
"transformationType": "formula",
|
||||||
|
"transformationValue": "UPPER(TRIM(value))",
|
||||||
|
"isRequired": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Filters
|
||||||
|
source_filters JSONB, -- Filters applied when fetching from source
|
||||||
|
target_filters JSONB, -- Filters applied before saving to target
|
||||||
|
|
||||||
|
-- Options
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
skip_duplicates BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
update_existing BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_by UUID,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for sync_mappings
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mappings_integration ON sync_mappings(integration_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mappings_entity ON sync_mappings(entity_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mappings_active ON sync_mappings(is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SYNC_VALUE_MAPPINGS TABLE
|
||||||
|
-- Value lookups for field transformations
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_value_mappings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
mapping_id UUID NOT NULL REFERENCES sync_mappings(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Mapping
|
||||||
|
field_name VARCHAR(100) NOT NULL,
|
||||||
|
source_value VARCHAR(500) NOT NULL,
|
||||||
|
target_value VARCHAR(500) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Unique constraint
|
||||||
|
UNIQUE(mapping_id, field_name, source_value)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for sync_value_mappings
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_value_mappings_mapping ON sync_value_mappings(mapping_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_value_mappings_field ON sync_value_mappings(field_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_value_mappings_lookup ON sync_value_mappings(mapping_id, field_name, source_value) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SYNC_ERRORS TABLE
|
||||||
|
-- Detailed error tracking for sync failures
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_errors (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
job_id UUID NOT NULL REFERENCES sync_jobs(id) ON DELETE CASCADE,
|
||||||
|
log_id UUID REFERENCES integration_sync_logs(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Error details
|
||||||
|
error_code VARCHAR(50) NOT NULL,
|
||||||
|
error_message TEXT NOT NULL,
|
||||||
|
source_id VARCHAR(255), -- ID in external system
|
||||||
|
target_id UUID, -- ID in Horux
|
||||||
|
field_name VARCHAR(100),
|
||||||
|
|
||||||
|
-- Context
|
||||||
|
source_data JSONB,
|
||||||
|
stack_trace TEXT,
|
||||||
|
|
||||||
|
-- Resolution
|
||||||
|
is_resolved BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
resolved_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
resolved_by UUID,
|
||||||
|
resolution_notes TEXT,
|
||||||
|
|
||||||
|
-- Retry info
|
||||||
|
is_retryable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for sync_errors
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_errors_job ON sync_errors(job_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_errors_code ON sync_errors(error_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_errors_unresolved ON sync_errors(is_resolved, created_at DESC) WHERE is_resolved = false;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_errors_source ON sync_errors(source_id) WHERE source_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- WEBHOOK_EVENTS TABLE
|
||||||
|
-- Incoming webhook events from external systems
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webhook_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Event info
|
||||||
|
event_type VARCHAR(100) NOT NULL,
|
||||||
|
event_id VARCHAR(255), -- External event ID
|
||||||
|
|
||||||
|
-- Payload
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
headers JSONB,
|
||||||
|
|
||||||
|
-- Processing
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed
|
||||||
|
processed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Validation
|
||||||
|
signature_valid BOOLEAN,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
ip_address INET
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for webhook_events
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_events_integration ON webhook_events(integration_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_events_type ON webhook_events(event_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_events_status ON webhook_events(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_events_pending ON webhook_events(integration_id, status) WHERE status = 'pending';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_events_received ON webhook_events(received_at DESC);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- EXTERNAL_ID_MAPPINGS TABLE
|
||||||
|
-- Track relationships between Horux IDs and external system IDs
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS external_id_mappings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Entity info
|
||||||
|
entity_type sync_entity_type NOT NULL,
|
||||||
|
horux_id UUID NOT NULL,
|
||||||
|
external_id VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
external_data JSONB, -- Store additional external info
|
||||||
|
last_synced_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
sync_hash VARCHAR(64), -- Hash of last synced data for change detection
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Unique constraint
|
||||||
|
UNIQUE(integration_id, entity_type, horux_id),
|
||||||
|
UNIQUE(integration_id, entity_type, external_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for external_id_mappings
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_mappings_integration ON external_id_mappings(integration_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_mappings_entity ON external_id_mappings(entity_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_mappings_horux ON external_id_mappings(horux_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_mappings_external ON external_id_mappings(external_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_mappings_lookup ON external_id_mappings(integration_id, entity_type, external_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Update integrations updated_at
|
||||||
|
CREATE TRIGGER update_integrations_updated_at BEFORE UPDATE ON integrations
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Update sync_jobs updated_at
|
||||||
|
CREATE TRIGGER update_sync_jobs_updated_at BEFORE UPDATE ON sync_jobs
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Update integration_schedules updated_at
|
||||||
|
CREATE TRIGGER update_schedules_updated_at BEFORE UPDATE ON integration_schedules
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Update sync_mappings updated_at
|
||||||
|
CREATE TRIGGER update_mappings_updated_at BEFORE UPDATE ON sync_mappings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Update external_id_mappings updated_at
|
||||||
|
CREATE TRIGGER update_ext_mappings_updated_at BEFORE UPDATE ON external_id_mappings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- FUNCTIONS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Function to update integration stats after sync
|
||||||
|
CREATE OR REPLACE FUNCTION update_integration_stats()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'completed' THEN
|
||||||
|
UPDATE integrations
|
||||||
|
SET
|
||||||
|
last_sync_at = NEW.completed_at,
|
||||||
|
last_sync_status = NEW.status,
|
||||||
|
last_sync_error = NULL,
|
||||||
|
total_syncs = total_syncs + 1,
|
||||||
|
successful_syncs = successful_syncs + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = NEW.integration_id;
|
||||||
|
ELSIF NEW.status = 'failed' THEN
|
||||||
|
UPDATE integrations
|
||||||
|
SET
|
||||||
|
last_sync_at = COALESCE(NEW.completed_at, NOW()),
|
||||||
|
last_sync_status = NEW.status,
|
||||||
|
last_sync_error = NEW.error_message,
|
||||||
|
total_syncs = total_syncs + 1,
|
||||||
|
failed_syncs = failed_syncs + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = NEW.integration_id;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to update integration stats
|
||||||
|
DROP TRIGGER IF EXISTS trigger_update_integration_stats ON sync_jobs;
|
||||||
|
CREATE TRIGGER trigger_update_integration_stats
|
||||||
|
AFTER UPDATE OF status ON sync_jobs
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (OLD.status IS DISTINCT FROM NEW.status AND NEW.status IN ('completed', 'failed'))
|
||||||
|
EXECUTE FUNCTION update_integration_stats();
|
||||||
|
|
||||||
|
-- Function to clean old sync logs
|
||||||
|
CREATE OR REPLACE FUNCTION cleanup_old_sync_logs(days_to_keep INTEGER DEFAULT 90)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM integration_sync_logs
|
||||||
|
WHERE created_at < NOW() - (days_to_keep || ' days')::INTERVAL;
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Function to clean old webhook events
|
||||||
|
CREATE OR REPLACE FUNCTION cleanup_old_webhook_events(days_to_keep INTEGER DEFAULT 30)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM webhook_events
|
||||||
|
WHERE received_at < NOW() - (days_to_keep || ' days')::INTERVAL
|
||||||
|
AND status IN ('completed', 'failed');
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- COMMENTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
COMMENT ON TABLE integrations IS 'External system integration configurations';
|
||||||
|
COMMENT ON TABLE sync_jobs IS 'Individual sync job executions';
|
||||||
|
COMMENT ON TABLE integration_sync_logs IS 'Detailed sync history and metrics';
|
||||||
|
COMMENT ON TABLE integration_logs IS 'General integration event logs';
|
||||||
|
COMMENT ON TABLE integration_schedules IS 'Scheduled sync configurations';
|
||||||
|
COMMENT ON TABLE sync_mappings IS 'Field mappings between external systems and Horux';
|
||||||
|
COMMENT ON TABLE sync_value_mappings IS 'Value lookups for field transformations';
|
||||||
|
COMMENT ON TABLE sync_errors IS 'Detailed error tracking for sync failures';
|
||||||
|
COMMENT ON TABLE webhook_events IS 'Incoming webhook events from external systems';
|
||||||
|
COMMENT ON TABLE external_id_mappings IS 'Track relationships between Horux and external IDs';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- DEFAULT DATA
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Insert default sync mappings templates (can be customized per tenant)
|
||||||
|
-- These would typically be inserted when a new integration is created
|
||||||
Reference in New Issue
Block a user