From 45570baccc040d6cc790098849b8d0ee6f03b723 Mon Sep 17 00:00:00 2001 From: HORUX360 Date: Sat, 31 Jan 2026 11:25:17 +0000 Subject: [PATCH] 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 --- apps/api/.env.example | 5 + apps/api/package.json | 4 + apps/api/src/controllers/ai.controller.ts | 853 +++++++ apps/api/src/controllers/index.ts | 11 + .../api/src/controllers/reports.controller.ts | 742 ++++++ apps/api/src/index.ts | 23 +- apps/api/src/jobs/index.ts | 11 + apps/api/src/jobs/report.job.ts | 784 ++++++ apps/api/src/jobs/sync.job.ts | 707 ++++++ apps/api/src/routes/ai.routes.ts | 173 ++ apps/api/src/routes/index.ts | 3 + apps/api/src/routes/integrations.routes.ts | 1259 ++++++---- apps/api/src/routes/reports.routes.ts | 122 + apps/api/src/services/ai/ai.service.ts | 789 ++++++ apps/api/src/services/ai/deepseek.client.ts | 655 +++++ apps/api/src/services/ai/deepseek.types.ts | 627 +++++ apps/api/src/services/ai/index.ts | 25 + apps/api/src/services/index.ts | 9 + .../integrations/alegra/alegra.client.ts | 457 ++++ .../integrations/alegra/alegra.schema.ts | 620 +++++ .../integrations/alegra/alegra.sync.ts | 757 ++++++ .../integrations/alegra/alegra.types.ts | 1060 ++++++++ .../integrations/alegra/contacts.connector.ts | 549 +++++ .../src/services/integrations/alegra/index.ts | 338 +++ .../integrations/alegra/invoices.connector.ts | 486 ++++ .../integrations/alegra/payments.connector.ts | 630 +++++ .../integrations/alegra/reports.connector.ts | 730 ++++++ .../integrations/aspel/aspel.client.ts | 704 ++++++ .../services/integrations/aspel/aspel.sync.ts | 1164 +++++++++ .../integrations/aspel/aspel.types.ts | 1112 +++++++++ .../integrations/aspel/banco.connector.ts | 938 +++++++ .../integrations/aspel/coi.connector.ts | 809 ++++++ .../src/services/integrations/aspel/index.ts | 52 + .../integrations/aspel/noi.connector.ts | 874 +++++++ .../integrations/aspel/sae.connector.ts | 1210 +++++++++ .../contpaqi/comercial.connector.ts | 911 +++++++ .../contpaqi/contabilidad.connector.ts | 841 +++++++ .../integrations/contpaqi/contpaqi.client.ts | 652 +++++ .../integrations/contpaqi/contpaqi.schema.ts | 331 +++ .../integrations/contpaqi/contpaqi.sync.ts | 1082 +++++++++ .../integrations/contpaqi/contpaqi.types.ts | 1097 +++++++++ .../services/integrations/contpaqi/index.ts | 263 ++ .../contpaqi/nominas.connector.ts | 879 +++++++ apps/api/src/services/integrations/index.ts | 42 + .../integrations/integration.manager.ts | 1269 ++++++++++ .../integrations/integration.types.ts | 662 +++++ .../integrations/odoo/accounting.connector.ts | 918 +++++++ .../src/services/integrations/odoo/index.ts | 122 + .../integrations/odoo/inventory.connector.ts | 865 +++++++ .../integrations/odoo/invoicing.connector.ts | 939 +++++++ .../services/integrations/odoo/odoo.client.ts | 872 +++++++ .../services/integrations/odoo/odoo.sync.ts | 1052 ++++++++ .../services/integrations/odoo/odoo.types.ts | 911 +++++++ .../integrations/odoo/partners.connector.ts | 904 +++++++ .../integrations/sap/banking.connector.ts | 686 ++++++ .../integrations/sap/financials.connector.ts | 633 +++++ .../src/services/integrations/sap/index.ts | 179 ++ .../integrations/sap/inventory.connector.ts | 659 +++++ .../integrations/sap/purchasing.connector.ts | 618 +++++ .../integrations/sap/sales.connector.ts | 550 +++++ .../services/integrations/sap/sap.client.ts | 670 +++++ .../src/services/integrations/sap/sap.sync.ts | 869 +++++++ .../services/integrations/sap/sap.types.ts | 1150 +++++++++ .../services/integrations/sync.scheduler.ts | 1025 ++++++++ apps/api/src/services/reports/index.ts | 30 + .../api/src/services/reports/pdf.generator.ts | 879 +++++++ .../src/services/reports/report.generator.ts | 1014 ++++++++ .../src/services/reports/report.prompts.ts | 472 ++++ .../src/services/reports/report.templates.ts | 885 +++++++ apps/api/src/services/reports/report.types.ts | 516 ++++ .../src/app/(dashboard)/asistente/page.tsx | 455 ++++ .../app/(dashboard)/integraciones/page.tsx | 719 ++++++ .../app/(dashboard)/reportes/[id]/page.tsx | 704 ++++++ .../app/(dashboard)/reportes/nuevo/page.tsx | 126 + .../web/src/app/(dashboard)/reportes/page.tsx | 685 ++++++ apps/web/src/components/ai/AIInsightCard.tsx | 338 +++ apps/web/src/components/ai/ChatInterface.tsx | 458 ++++ apps/web/src/components/ai/ChatMessage.tsx | 281 +++ .../src/components/ai/SuggestedQuestions.tsx | 265 ++ apps/web/src/components/ai/index.ts | 10 + apps/web/src/components/layout/Sidebar.tsx | 17 + .../reports/GenerateReportWizard.tsx | 645 +++++ .../web/src/components/reports/ReportCard.tsx | 271 +++ .../src/components/reports/ReportChart.tsx | 334 +++ .../src/components/reports/ReportSection.tsx | 211 ++ apps/web/src/components/reports/index.ts | 9 + package-lock.json | 2160 +++++++++++++++++ .../src/migrations/003_integrations.sql | 642 +++++ 88 files changed, 52538 insertions(+), 531 deletions(-) create mode 100644 apps/api/src/controllers/ai.controller.ts create mode 100644 apps/api/src/controllers/index.ts create mode 100644 apps/api/src/controllers/reports.controller.ts create mode 100644 apps/api/src/jobs/index.ts create mode 100644 apps/api/src/jobs/report.job.ts create mode 100644 apps/api/src/jobs/sync.job.ts create mode 100644 apps/api/src/routes/ai.routes.ts create mode 100644 apps/api/src/routes/reports.routes.ts create mode 100644 apps/api/src/services/ai/ai.service.ts create mode 100644 apps/api/src/services/ai/deepseek.client.ts create mode 100644 apps/api/src/services/ai/deepseek.types.ts create mode 100644 apps/api/src/services/ai/index.ts create mode 100644 apps/api/src/services/integrations/alegra/alegra.client.ts create mode 100644 apps/api/src/services/integrations/alegra/alegra.schema.ts create mode 100644 apps/api/src/services/integrations/alegra/alegra.sync.ts create mode 100644 apps/api/src/services/integrations/alegra/alegra.types.ts create mode 100644 apps/api/src/services/integrations/alegra/contacts.connector.ts create mode 100644 apps/api/src/services/integrations/alegra/index.ts create mode 100644 apps/api/src/services/integrations/alegra/invoices.connector.ts create mode 100644 apps/api/src/services/integrations/alegra/payments.connector.ts create mode 100644 apps/api/src/services/integrations/alegra/reports.connector.ts create mode 100644 apps/api/src/services/integrations/aspel/aspel.client.ts create mode 100644 apps/api/src/services/integrations/aspel/aspel.sync.ts create mode 100644 apps/api/src/services/integrations/aspel/aspel.types.ts create mode 100644 apps/api/src/services/integrations/aspel/banco.connector.ts create mode 100644 apps/api/src/services/integrations/aspel/coi.connector.ts create mode 100644 apps/api/src/services/integrations/aspel/index.ts create mode 100644 apps/api/src/services/integrations/aspel/noi.connector.ts create mode 100644 apps/api/src/services/integrations/aspel/sae.connector.ts create mode 100644 apps/api/src/services/integrations/contpaqi/comercial.connector.ts create mode 100644 apps/api/src/services/integrations/contpaqi/contabilidad.connector.ts create mode 100644 apps/api/src/services/integrations/contpaqi/contpaqi.client.ts create mode 100644 apps/api/src/services/integrations/contpaqi/contpaqi.schema.ts create mode 100644 apps/api/src/services/integrations/contpaqi/contpaqi.sync.ts create mode 100644 apps/api/src/services/integrations/contpaqi/contpaqi.types.ts create mode 100644 apps/api/src/services/integrations/contpaqi/index.ts create mode 100644 apps/api/src/services/integrations/contpaqi/nominas.connector.ts create mode 100644 apps/api/src/services/integrations/index.ts create mode 100644 apps/api/src/services/integrations/integration.manager.ts create mode 100644 apps/api/src/services/integrations/integration.types.ts create mode 100644 apps/api/src/services/integrations/odoo/accounting.connector.ts create mode 100644 apps/api/src/services/integrations/odoo/index.ts create mode 100644 apps/api/src/services/integrations/odoo/inventory.connector.ts create mode 100644 apps/api/src/services/integrations/odoo/invoicing.connector.ts create mode 100644 apps/api/src/services/integrations/odoo/odoo.client.ts create mode 100644 apps/api/src/services/integrations/odoo/odoo.sync.ts create mode 100644 apps/api/src/services/integrations/odoo/odoo.types.ts create mode 100644 apps/api/src/services/integrations/odoo/partners.connector.ts create mode 100644 apps/api/src/services/integrations/sap/banking.connector.ts create mode 100644 apps/api/src/services/integrations/sap/financials.connector.ts create mode 100644 apps/api/src/services/integrations/sap/index.ts create mode 100644 apps/api/src/services/integrations/sap/inventory.connector.ts create mode 100644 apps/api/src/services/integrations/sap/purchasing.connector.ts create mode 100644 apps/api/src/services/integrations/sap/sales.connector.ts create mode 100644 apps/api/src/services/integrations/sap/sap.client.ts create mode 100644 apps/api/src/services/integrations/sap/sap.sync.ts create mode 100644 apps/api/src/services/integrations/sap/sap.types.ts create mode 100644 apps/api/src/services/integrations/sync.scheduler.ts create mode 100644 apps/api/src/services/reports/index.ts create mode 100644 apps/api/src/services/reports/pdf.generator.ts create mode 100644 apps/api/src/services/reports/report.generator.ts create mode 100644 apps/api/src/services/reports/report.prompts.ts create mode 100644 apps/api/src/services/reports/report.templates.ts create mode 100644 apps/api/src/services/reports/report.types.ts create mode 100644 apps/web/src/app/(dashboard)/asistente/page.tsx create mode 100644 apps/web/src/app/(dashboard)/integraciones/page.tsx create mode 100644 apps/web/src/app/(dashboard)/reportes/[id]/page.tsx create mode 100644 apps/web/src/app/(dashboard)/reportes/nuevo/page.tsx create mode 100644 apps/web/src/app/(dashboard)/reportes/page.tsx create mode 100644 apps/web/src/components/ai/AIInsightCard.tsx create mode 100644 apps/web/src/components/ai/ChatInterface.tsx create mode 100644 apps/web/src/components/ai/ChatMessage.tsx create mode 100644 apps/web/src/components/ai/SuggestedQuestions.tsx create mode 100644 apps/web/src/components/ai/index.ts create mode 100644 apps/web/src/components/reports/GenerateReportWizard.tsx create mode 100644 apps/web/src/components/reports/ReportCard.tsx create mode 100644 apps/web/src/components/reports/ReportChart.tsx create mode 100644 apps/web/src/components/reports/ReportSection.tsx create mode 100644 apps/web/src/components/reports/index.ts create mode 100644 package-lock.json create mode 100644 packages/database/src/migrations/003_integrations.sql diff --git a/apps/api/.env.example b/apps/api/.env.example index abf4610..d3a8973 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -56,3 +56,8 @@ LOG_FORMAT=simple # Feature Flags ENABLE_SWAGGER=true ENABLE_METRICS=true + +# DeepSeek AI +DEEPSEEK_API_KEY=your-deepseek-api-key +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_MODEL=deepseek-chat diff --git a/apps/api/package.json b/apps/api/package.json index 3d981c9..62fb576 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -29,6 +29,8 @@ "ioredis": "^5.3.2", "jsonwebtoken": "^9.0.2", "minio": "^7.1.3", + "mssql": "^10.0.2", + "pdfkit": "^0.15.0", "pg": "^8.11.3", "uuid": "^9.0.1", "winston": "^3.11.0", @@ -40,7 +42,9 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.5", + "@types/mssql": "^9.1.5", "@types/node": "^20.11.0", + "@types/pdfkit": "^0.13.4", "@types/pg": "^8.10.9", "@types/uuid": "^9.0.7", "eslint": "^8.56.0", diff --git a/apps/api/src/controllers/ai.controller.ts b/apps/api/src/controllers/ai.controller.ts new file mode 100644 index 0000000..ead5427 --- /dev/null +++ b/apps/api/src/controllers/ai.controller.ts @@ -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; + transactions?: unknown[]; + categories?: unknown[]; + dateRange?: { start: string; end: string }; + previousPeriod?: Record; +} + +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 = { + 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(); + +async function checkRateLimit( + userId: string, + tenantId: string, + planId: string, + estimatedTokens: number +): Promise { + 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 { + 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 { + 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 { + 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 => { + const startTime = Date.now(); + + try { + const body = req.body as z.infer; + 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 => { + const startTime = Date.now(); + + try { + const body = req.body as z.infer; + 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 => { + const startTime = Date.now(); + + try { + const body = req.body as z.infer; + 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 => { + const startTime = Date.now(); + + try { + const body = req.body as z.infer; + 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 => { + 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 = { + 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, +}; diff --git a/apps/api/src/controllers/index.ts b/apps/api/src/controllers/index.ts new file mode 100644 index 0000000..bc27e9b --- /dev/null +++ b/apps/api/src/controllers/index.ts @@ -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'; diff --git a/apps/api/src/controllers/reports.controller.ts b/apps/api/src/controllers/reports.controller.ts new file mode 100644 index 0000000..453e4a6 --- /dev/null +++ b/apps/api/src/controllers/reports.controller.ts @@ -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; + 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; + 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 => { + try { + const filters = req.query as z.infer; + 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 => { + 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 => { + try { + const body = req.body as z.infer; + 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 => { + 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 => { + 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 => { + 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[] { + 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, +}; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d22e253..1916c5f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -18,6 +18,9 @@ import contactsRoutes from './routes/contacts.routes.js'; import cfdisRoutes from './routes/cfdis.routes.js'; import categoriesRoutes from './routes/categories.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 @@ -175,6 +178,8 @@ app.use(`${apiPrefix}/contacts`, authenticate, tenantContext, contactsRoutes); app.use(`${apiPrefix}/cfdis`, authenticate, tenantContext, cfdisRoutes); app.use(`${apiPrefix}/categories`, authenticate, tenantContext, categoriesRoutes); app.use(`${apiPrefix}/alerts`, authenticate, tenantContext, alertsRoutes); +app.use(`${apiPrefix}/reports`, authenticate, tenantContext, reportsRoutes); +app.use(`${apiPrefix}/ai`, authenticate, tenantContext, aiRoutes); // ============================================================================ // API Info Route @@ -213,6 +218,14 @@ app.use(errorHandler); const startServer = async (): Promise => { 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 const server = app.listen(config.server.port, config.server.host, () => { logger.info(`Horux Strategy API started`, { @@ -233,7 +246,7 @@ const startServer = async (): Promise => { const gracefulShutdown = async (signal: string): Promise => { logger.info(`${signal} received. Starting graceful shutdown...`); - server.close((err) => { + server.close(async (err) => { if (err) { logger.error('Error during server close', { error: err }); process.exit(1); @@ -241,6 +254,14 @@ const startServer = async (): Promise => { 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.) logger.info('Graceful shutdown completed'); diff --git a/apps/api/src/jobs/index.ts b/apps/api/src/jobs/index.ts new file mode 100644 index 0000000..c07259c --- /dev/null +++ b/apps/api/src/jobs/index.ts @@ -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'; diff --git a/apps/api/src/jobs/report.job.ts b/apps/api/src/jobs/report.job.ts new file mode 100644 index 0000000..d337d8d --- /dev/null +++ b/apps/api/src/jobs/report.job.ts @@ -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 | null = null; +let worker: Worker | null = null; +let queueEvents: QueueEvents | null = null; + +// ============================================================================ +// Queue Initialization +// ============================================================================ + +/** + * Get or create the report queue + */ +export function getReportQueue(): Queue { + if (!queue) { + const connection = new IORedis(REDIS_CONFIG); + + queue = new Queue(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 +): Promise { + 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; + transactions: unknown[]; + categories: unknown[]; + summary: Record; + previousPeriod?: Record; +} + +async function fetchReportData( + tenant: TenantContext, + type: string, + parameters: ReportJobData['parameters'] +): Promise { + 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 | 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> { + 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 { + 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 { + // 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 { + // 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 { + // 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 { + 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 { + if (worker) { + return worker; + } + + const connection = new IORedis(REDIS_CONFIG); + + worker = new Worker( + 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 { + 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, +}; diff --git a/apps/api/src/jobs/sync.job.ts b/apps/api/src/jobs/sync.job.ts new file mode 100644 index 0000000..c86a266 --- /dev/null +++ b/apps/api/src/jobs/sync.job.ts @@ -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; +} + +// ============================================================================ +// SYNC JOB PROCESSOR +// ============================================================================ + +/** + * Process a sync job + */ +export async function processSyncJob(job: Job): Promise { + 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 { + const db = getDatabase(); + const recordData = record as Record; + 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 +): Promise { + 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 +): Promise { + 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 { + 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 { + 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.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 +): Record { + // Basic field mapping - in production this would be more sophisticated + const mapped: Record = { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ============================================================================ +// EXPORTS +// ============================================================================ + +export default processSyncJob; diff --git a/apps/api/src/routes/ai.routes.ts b/apps/api/src/routes/ai.routes.ts new file mode 100644 index 0000000..3381238 --- /dev/null +++ b/apps/api/src/routes/ai.routes.ts @@ -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; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 4a3eebd..5f25384 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -7,3 +7,6 @@ export { default as contactsRoutes } from './contacts.routes.js'; export { default as cfdisRoutes } from './cfdis.routes.js'; export { default as categoriesRoutes } from './categories.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'; diff --git a/apps/api/src/routes/integrations.routes.ts b/apps/api/src/routes/integrations.routes.ts index 4d38a8b..cba2b08 100644 --- a/apps/api/src/routes/integrations.routes.ts +++ b/apps/api/src/routes/integrations.routes.ts @@ -1,13 +1,14 @@ /** * Integrations Routes * - * Manages external integrations (SAT, banks, etc.) + * Unified API routes for managing external integrations. + * Supports CONTPAQi, Aspel, Odoo, Alegra, SAP, SAT, and more. */ import { Router, Request, Response, NextFunction } from 'express'; import { z } from 'zod'; -import { authenticate, requireAdmin } from '../middleware/auth.middleware'; -import { validate } from '../middleware/validate.middleware'; +import { authenticate, requireAdmin } from '../middleware/auth.middleware.js'; +import { validate } from '../middleware/validate.middleware.js'; import { getDatabase, TenantContext } from '@horux/database'; import { ApiResponse, @@ -15,7 +16,14 @@ import { NotFoundError, ValidationError, ConflictError, -} from '../types'; +} from '../types/index.js'; +import { integrationManager } from '../services/integrations/integration.manager.js'; +import { + IntegrationType, + IntegrationStatus, + SyncDirection, + SyncEntityType, +} from '../services/integrations/integration.types.js'; const router = Router(); @@ -24,26 +32,57 @@ const router = Router(); // ============================================================================ const IntegrationTypeEnum = z.enum([ + // ERP/Accounting + 'contpaqi', + 'aspel', + 'odoo', + 'alegra', + 'sap', + 'manual', + // Fiscal 'sat', + // Banks 'bank_bbva', 'bank_banamex', 'bank_santander', 'bank_banorte', 'bank_hsbc', - 'accounting_contpaqi', - 'accounting_aspel', - 'erp_sap', - 'erp_odoo', + // Payments 'payments_stripe', 'payments_openpay', + // Custom 'webhook', + 'api_custom', ]); -const IntegrationStatusEnum = z.enum(['active', 'inactive', 'error', 'pending', 'expired']); +const IntegrationStatusEnum = z.enum([ + 'pending', + 'active', + 'inactive', + 'error', + 'expired', + 'configuring', +]); + +const SyncDirectionEnum = z.enum(['import', 'export', 'bidirectional']); + +const SyncEntityTypeEnum = z.enum([ + 'transactions', + 'invoices', + 'contacts', + 'products', + 'accounts', + 'categories', + 'journal_entries', + 'payments', + 'cfdis', + 'bank_statements', +]); const IntegrationFiltersSchema = z.object({ type: IntegrationTypeEnum.optional(), status: IntegrationStatusEnum.optional(), + isActive: z.coerce.boolean().optional(), search: z.string().optional(), }); @@ -55,8 +94,84 @@ const IntegrationTypeParamSchema = z.object({ type: IntegrationTypeEnum, }); -// SAT-specific configuration -const SatConfigSchema = z.object({ +// Base config schema +const BaseConfigSchema = z.object({ + autoSync: z.boolean().default(true), + syncFrequency: z.enum(['realtime', 'hourly', 'daily', 'weekly', 'monthly']).default('daily'), + syncDirection: SyncDirectionEnum.default('import'), + enabledEntities: z.array(SyncEntityTypeEnum).optional(), + retryOnFailure: z.boolean().default(true), + maxRetries: z.number().int().min(0).max(10).default(3), + notifyOnSuccess: z.boolean().default(false), + notifyOnFailure: z.boolean().default(true), + notificationEmails: z.array(z.string().email()).optional(), +}); + +// CONTPAQi configuration +const ContpaqiConfigSchema = BaseConfigSchema.extend({ + serverHost: z.string().min(1, 'Host del servidor es requerido'), + serverPort: z.number().int().min(1).max(65535).default(1433), + databaseName: z.string().min(1, 'Nombre de base de datos es requerido'), + username: z.string().min(1, 'Usuario es requerido'), + password: z.string().min(1, 'Contrasena es requerida'), + companyRfc: z.string().regex( + /^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/, + 'RFC invalido' + ), + sdkPath: z.string().optional(), + sdkVersion: z.string().optional(), + accountMappingProfile: z.string().optional(), +}); + +// Aspel configuration +const AspelConfigSchema = BaseConfigSchema.extend({ + product: z.enum(['SAE', 'COI', 'NOI', 'BANCO', 'CAJA']), + serverHost: z.string().min(1, 'Host del servidor es requerido'), + serverPort: z.number().int().min(1).max(65535).default(1433), + databasePath: z.string().min(1, 'Ruta de base de datos es requerida'), + username: z.string().min(1, 'Usuario es requerido'), + password: z.string().min(1, 'Contrasena es requerida'), + companyCode: z.string().min(1, 'Codigo de empresa es requerido'), + version: z.string().optional(), +}); + +// Odoo configuration +const OdooConfigSchema = BaseConfigSchema.extend({ + serverUrl: z.string().url('URL del servidor invalida'), + database: z.string().min(1, 'Base de datos es requerida'), + username: z.string().min(1, 'Usuario es requerido'), + apiKey: z.string().min(1, 'API Key es requerida'), + companyId: z.number().int().min(1), + useXmlRpc: z.boolean().default(true), + version: z.string().optional(), +}); + +// Alegra configuration +const AlegraConfigSchema = BaseConfigSchema.extend({ + email: z.string().email('Email invalido'), + apiToken: z.string().min(1, 'Token API es requerido'), + companyId: z.string().optional(), + country: z.enum(['MX', 'CO', 'PE', 'AR', 'CL']).default('MX'), +}); + +// SAP configuration +const SapConfigSchema = BaseConfigSchema.extend({ + serverHost: z.string().min(1, 'Host del servidor es requerido'), + serverPort: z.number().int().min(1).max(65535).default(3300), + systemNumber: z.string().length(2, 'Numero de sistema debe tener 2 digitos'), + client: z.string().length(3, 'Cliente debe tener 3 digitos'), + username: z.string().min(1, 'Usuario es requerido'), + password: z.string().min(1, 'Contrasena es requerida'), + companyCode: z.string().min(1, 'Codigo de empresa es requerido'), + sapRouter: z.string().optional(), + language: z.string().length(2).default('ES'), + useSsl: z.boolean().default(true), + isBusinessOne: z.boolean().default(false), + serviceLayerUrl: z.string().url().optional(), +}); + +// SAT configuration +const SatConfigSchema = BaseConfigSchema.extend({ rfc: z.string().regex( /^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/, 'RFC invalido. Formato esperado: XAXX010101XXX' @@ -64,55 +179,85 @@ const SatConfigSchema = z.object({ certificateBase64: z.string().min(1, 'Certificado es requerido'), privateKeyBase64: z.string().min(1, 'Llave privada es requerida'), privateKeyPassword: z.string().min(1, 'Contrasena de llave privada es requerida'), - // Optional FIEL credentials fielCertificateBase64: z.string().optional(), fielPrivateKeyBase64: z.string().optional(), fielPrivateKeyPassword: z.string().optional(), - // Sync options syncIngresos: z.boolean().default(true), syncEgresos: z.boolean().default(true), syncNomina: z.boolean().default(false), syncPagos: z.boolean().default(true), - autoSync: z.boolean().default(true), - syncFrequency: z.enum(['hourly', 'daily', 'weekly']).default('daily'), }); // Bank configuration -const BankConfigSchema = z.object({ +const BankConfigSchema = BaseConfigSchema.extend({ accountNumber: z.string().optional(), clabe: z.string().regex(/^\d{18}$/, 'CLABE debe tener 18 digitos').optional(), accessToken: z.string().optional(), refreshToken: z.string().optional(), clientId: z.string().optional(), clientSecret: z.string().optional(), - autoSync: z.boolean().default(true), - syncFrequency: z.enum(['hourly', 'daily', 'weekly']).default('daily'), + connectionProvider: z.enum(['belvo', 'finerio', 'plaid', 'direct']).optional(), + connectionId: z.string().optional(), }); // Webhook configuration -const WebhookConfigSchema = z.object({ +const WebhookConfigSchema = BaseConfigSchema.extend({ url: z.string().url('URL invalida'), secret: z.string().min(16, 'Secret debe tener al menos 16 caracteres'), events: z.array(z.string()).min(1, 'Debe seleccionar al menos un evento'), - isActive: z.boolean().default(true), + headers: z.record(z.string()).optional(), retryAttempts: z.number().int().min(0).max(10).default(3), + timeoutMs: z.number().int().min(1000).max(60000).default(30000), }); -// Generic integration configuration -const GenericIntegrationConfigSchema = z.object({ - name: z.string().max(100).optional(), - apiKey: z.string().optional(), - apiSecret: z.string().optional(), - endpoint: z.string().url().optional(), - settings: z.record(z.unknown()).optional(), - autoSync: z.boolean().default(true), - syncFrequency: z.enum(['hourly', 'daily', 'weekly']).default('daily'), +// Manual configuration +const ManualConfigSchema = z.object({ + enabledEntities: z.array(SyncEntityTypeEnum).optional(), + defaultCategory: z.string().optional(), + requireApproval: z.boolean().default(false), + approvalThreshold: z.number().optional(), }); -const SyncBodySchema = z.object({ - force: z.boolean().optional().default(false), +// Create integration schema +const CreateIntegrationSchema = z.object({ + type: IntegrationTypeEnum, + name: z.string().min(1).max(200), + description: z.string().max(500).optional(), + config: z.record(z.unknown()), // Will be validated based on type +}); + +// Update integration schema +const UpdateIntegrationSchema = z.object({ + name: z.string().min(1).max(200).optional(), + description: z.string().max(500).optional(), + isActive: z.boolean().optional(), + config: z.record(z.unknown()).optional(), +}); + +// Sync options schema +const SyncOptionsSchema = z.object({ + entityTypes: z.array(SyncEntityTypeEnum).optional(), + direction: SyncDirectionEnum.optional(), + fullSync: z.boolean().optional(), startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(), + filters: z.record(z.unknown()).optional(), + batchSize: z.number().int().min(1).max(1000).optional(), + dryRun: z.boolean().optional(), +}); + +// Schedule schema +const ScheduleSchema = z.object({ + entityType: SyncEntityTypeEnum.optional(), + direction: SyncDirectionEnum.default('import'), + isEnabled: z.boolean().default(true), + cronExpression: z.string().min(1, 'Expresion cron es requerida'), + timezone: z.string().default('America/Mexico_City'), + startTime: z.string().regex(/^\d{2}:\d{2}$/).optional(), + endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(), + daysOfWeek: z.array(z.number().int().min(0).max(6)).optional(), + priority: z.enum(['low', 'normal', 'high']).default('normal'), + timeoutMs: z.number().int().min(30000).max(3600000).default(300000), }); // ============================================================================ @@ -131,10 +276,56 @@ const getTenantContext = (req: Request): TenantContext => { }; const getConfigSchema = (type: string) => { - if (type === 'sat') return SatConfigSchema; - if (type.startsWith('bank_')) return BankConfigSchema; - if (type === 'webhook') return WebhookConfigSchema; - return GenericIntegrationConfigSchema; + switch (type) { + case 'contpaqi': + return ContpaqiConfigSchema; + case 'aspel': + return AspelConfigSchema; + case 'odoo': + return OdooConfigSchema; + case 'alegra': + return AlegraConfigSchema; + case 'sap': + return SapConfigSchema; + case 'sat': + return SatConfigSchema; + case 'webhook': + return WebhookConfigSchema; + case 'manual': + return ManualConfigSchema; + default: + if (type.startsWith('bank_')) { + return BankConfigSchema; + } + return BaseConfigSchema; + } +}; + +const maskSensitiveData = (config: Record): Record => { + const sensitiveFields = [ + 'password', + 'privateKeyBase64', + 'privateKeyPassword', + 'fielPrivateKeyBase64', + 'fielPrivateKeyPassword', + 'clientSecret', + 'apiSecret', + 'apiKey', + 'apiToken', + 'secret', + 'accessToken', + 'refreshToken', + 'certificateBase64', + 'fielCertificateBase64', + ]; + + const masked = { ...config }; + for (const field of sensitiveFields) { + if (masked[field]) { + masked[field] = '********'; + } + } + return masked; }; // ============================================================================ @@ -143,110 +334,84 @@ const getConfigSchema = (type: string) => { /** * GET /api/integrations - * List all configured integrations + * List all available integrations (providers) */ router.get( '/', authenticate, + async (req: Request, res: Response, next: NextFunction) => { + try { + const providers = integrationManager.getAvailableProviders(); + + const response: ApiResponse = { + success: true, + data: providers.map((p) => ({ + type: p.type, + name: p.name, + description: p.description, + category: p.category, + logoUrl: p.logoUrl, + supportedEntities: p.supportedEntities, + supportedDirections: p.supportedDirections, + supportsRealtime: p.supportsRealtime, + supportsWebhooks: p.supportsWebhooks, + isAvailable: p.isAvailable, + isBeta: p.isBeta, + regions: p.regions, + })), + meta: { + total: providers.length, + timestamp: new Date().toISOString(), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } +); + +/** + * GET /api/integrations/configured + * List tenant's configured integrations + */ +router.get( + '/configured', + authenticate, validate({ query: IntegrationFiltersSchema }), async (req: Request, res: Response, next: NextFunction) => { try { const filters = req.query as z.infer; const tenant = getTenantContext(req); - const db = getDatabase(); - // Build filter conditions - const conditions: string[] = []; - const params: unknown[] = []; - let paramIndex = 1; + const integrations = await integrationManager.getIntegrations(tenant, { + type: filters.type as IntegrationType, + status: filters.status as IntegrationStatus, + isActive: filters.isActive, + }); - if (filters.type) { - conditions.push(`i.type = $${paramIndex++}`); - params.push(filters.type); - } - - if (filters.status) { - conditions.push(`i.status = $${paramIndex++}`); - params.push(filters.status); - } - - if (filters.search) { - conditions.push(`(i.name ILIKE $${paramIndex} OR i.type ILIKE $${paramIndex})`); - params.push(`%${filters.search}%`); - paramIndex++; - } - - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - - const query = ` - SELECT - i.id, - i.type, - i.name, - i.status, - i.is_active, - i.last_sync_at, - i.last_sync_status, - i.next_sync_at, - i.error_message, - i.created_at, - i.updated_at, - ( - SELECT json_build_object( - 'total_syncs', COUNT(*), - 'successful_syncs', COUNT(*) FILTER (WHERE status = 'completed'), - 'failed_syncs', COUNT(*) FILTER (WHERE status = 'failed'), - 'last_duration_ms', ( - SELECT EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000 - FROM sync_jobs - WHERE integration_id = i.id - ORDER BY created_at DESC - LIMIT 1 - ) - ) - FROM sync_jobs - WHERE integration_id = i.id - ) as sync_stats - FROM integrations i - ${whereClause} - ORDER BY i.created_at DESC - `; - - const result = await db.queryTenant(tenant, query, params); - - // Get available integrations (not yet configured) - const availableQuery = ` - SELECT DISTINCT type - FROM integrations - WHERE type IN ('sat', 'bank_bbva', 'bank_banamex', 'bank_santander', 'bank_banorte', 'bank_hsbc') - `; - const configuredTypes = await db.queryTenant<{ type: string }>(tenant, availableQuery, []); - const configuredTypeSet = new Set(configuredTypes.rows.map(r => r.type)); - - const allTypes = [ - { type: 'sat', name: 'SAT (Servicio de Administracion Tributaria)', category: 'fiscal' }, - { type: 'bank_bbva', name: 'BBVA Mexico', category: 'bank' }, - { type: 'bank_banamex', name: 'Banamex / Citibanamex', category: 'bank' }, - { type: 'bank_santander', name: 'Santander', category: 'bank' }, - { type: 'bank_banorte', name: 'Banorte', category: 'bank' }, - { type: 'bank_hsbc', name: 'HSBC', category: 'bank' }, - { type: 'accounting_contpaqi', name: 'CONTPAQi', category: 'accounting' }, - { type: 'accounting_aspel', name: 'Aspel', category: 'accounting' }, - { type: 'payments_stripe', name: 'Stripe', category: 'payments' }, - { type: 'payments_openpay', name: 'Openpay', category: 'payments' }, - { type: 'webhook', name: 'Webhook personalizado', category: 'custom' }, - ]; - - const availableIntegrations = allTypes.filter(t => !configuredTypeSet.has(t.type) || t.type === 'webhook'); + // Mask sensitive data and add provider info + const enrichedIntegrations = integrations.map((int) => { + const provider = integrationManager.getProvider(int.type); + return { + ...int, + config: maskSensitiveData(int.config as Record), + provider: provider + ? { + name: provider.name, + category: provider.category, + logoUrl: provider.logoUrl, + } + : null, + }; + }); const response: ApiResponse = { success: true, - data: { - configured: result.rows, - available: availableIntegrations, - }, + data: enrichedIntegrations, meta: { - total: result.rows.length, + total: integrations.length, timestamp: new Date().toISOString(), }, }; @@ -258,6 +423,58 @@ router.get( } ); +/** + * POST /api/integrations/configure + * Configure a new integration + */ +router.post( + '/configure', + authenticate, + requireAdmin, + validate({ body: CreateIntegrationSchema }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { type, name, description, config } = req.body; + const tenant = getTenantContext(req); + + // Validate config based on type + const configSchema = getConfigSchema(type); + const parseResult = configSchema.safeParse(config); + + if (!parseResult.success) { + throw new ValidationError( + 'Configuracion invalida', + parseResult.error.flatten().fieldErrors + ); + } + + // Create integration + const integration = await integrationManager.createIntegration(tenant, { + type: type as IntegrationType, + name, + description, + config: { type, ...parseResult.data } as any, + createdBy: req.user!.sub, + }); + + const response: ApiResponse = { + success: true, + data: { + ...integration, + config: maskSensitiveData(integration.config as Record), + }, + meta: { + timestamp: new Date().toISOString(), + }, + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } +); + /** * GET /api/integrations/:id * Get integration details @@ -270,137 +487,29 @@ router.get( try { const { id } = req.params; const tenant = getTenantContext(req); - const db = getDatabase(); - const query = ` - SELECT - i.*, - ( - SELECT json_agg( - json_build_object( - 'id', sj.id, - 'status', sj.status, - 'started_at', sj.started_at, - 'completed_at', sj.completed_at, - 'records_processed', sj.records_processed, - 'records_created', sj.records_created, - 'records_updated', sj.records_updated, - 'error_message', sj.error_message - ) - ORDER BY sj.created_at DESC - ) - FROM ( - SELECT * FROM sync_jobs - WHERE integration_id = i.id - ORDER BY created_at DESC - LIMIT 10 - ) sj - ) as recent_syncs - FROM integrations i - WHERE i.id = $1 - `; + const integration = await integrationManager.getIntegration(tenant, id); - const result = await db.queryTenant(tenant, query, [id]); - - if (result.rows.length === 0) { + if (!integration) { throw new NotFoundError('Integracion'); } - // Mask sensitive data in config - const integration = result.rows[0]; - if (integration.config) { - const sensitiveFields = ['privateKeyBase64', 'privateKeyPassword', 'fielPrivateKeyBase64', - 'fielPrivateKeyPassword', 'clientSecret', 'apiSecret', 'secret', 'accessToken', 'refreshToken']; - for (const field of sensitiveFields) { - if (integration.config[field]) { - integration.config[field] = '********'; - } - } - } - - const response: ApiResponse = { - success: true, - data: integration, - meta: { - timestamp: new Date().toISOString(), - }, - }; - - res.json(response); - } catch (error) { - next(error); - } - } -); - -/** - * GET /api/integrations/:id/status - * Get integration status - */ -router.get( - '/:id/status', - authenticate, - validate({ params: IntegrationIdSchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const tenant = getTenantContext(req); - const db = getDatabase(); - - const query = ` - SELECT - i.id, - i.type, - i.name, - i.status, - i.is_active, - i.last_sync_at, - i.last_sync_status, - i.next_sync_at, - i.error_message, - i.health_check_at, - ( - SELECT json_build_object( - 'id', sj.id, - 'status', sj.status, - 'progress', sj.progress, - 'started_at', sj.started_at, - 'records_processed', sj.records_processed - ) - FROM sync_jobs sj - WHERE sj.integration_id = i.id AND sj.status IN ('pending', 'running') - ORDER BY sj.created_at DESC - LIMIT 1 - ) as current_job - FROM integrations i - WHERE i.id = $1 - `; - - const result = await db.queryTenant(tenant, query, [id]); - - if (result.rows.length === 0) { - throw new NotFoundError('Integracion'); - } - - const integration = result.rows[0]; - - // Determine health status - let health: 'healthy' | 'degraded' | 'unhealthy' | 'unknown' = 'unknown'; - if (integration.status === 'active' && integration.last_sync_status === 'completed') { - health = 'healthy'; - } else if (integration.status === 'error' || integration.last_sync_status === 'failed') { - health = 'unhealthy'; - } else if (integration.status === 'active') { - health = 'degraded'; - } + const provider = integrationManager.getProvider(integration.type); const response: ApiResponse = { success: true, data: { ...integration, - health, - isConfigured: true, - hasPendingSync: !!integration.current_job, + config: maskSensitiveData(integration.config as Record), + provider: provider + ? { + name: provider.name, + category: provider.category, + logoUrl: provider.logoUrl, + supportedEntities: provider.supportedEntities, + supportedDirections: provider.supportedDirections, + } + : null, }, meta: { timestamp: new Date().toISOString(), @@ -415,87 +524,66 @@ router.get( ); /** - * POST /api/integrations/sat - * Configure SAT integration + * PUT /api/integrations/:type/config + * Update integration configuration by type */ -router.post( - '/sat', +router.put( + '/:type/config', authenticate, requireAdmin, - validate({ body: SatConfigSchema }), + validate({ params: IntegrationTypeParamSchema, body: UpdateIntegrationSchema }), async (req: Request, res: Response, next: NextFunction) => { try { - const config = req.body as z.infer; + const { type } = req.params; + const { name, description, isActive, config } = req.body; const tenant = getTenantContext(req); - const db = getDatabase(); - // Check if SAT integration already exists - const existingCheck = await db.queryTenant( - tenant, - "SELECT id FROM integrations WHERE type = 'sat'", - [] - ); + // Find integration by type + const integrations = await integrationManager.getIntegrations(tenant, { + type: type as IntegrationType, + }); - if (existingCheck.rows.length > 0) { - throw new ConflictError('Ya existe una integracion con SAT configurada'); + if (integrations.length === 0) { + throw new NotFoundError(`Integracion de tipo ${type}`); } - // TODO: Validate certificate and key with SAT - // This would involve parsing the certificate and verifying it's valid + const integration = integrations[0]; - // Store integration (encrypt sensitive data in production) - const insertQuery = ` - INSERT INTO integrations ( - type, - name, - status, - is_active, - config, - created_by - ) - VALUES ('sat', 'SAT - ${config.rfc}', 'pending', true, $1, $2) - RETURNING id, type, name, status, is_active, created_at - `; + // Validate config if provided + let validatedConfig = config; + if (config) { + const configSchema = getConfigSchema(type); + const parseResult = configSchema.partial().safeParse(config); - const result = await db.queryTenant(tenant, insertQuery, [ - JSON.stringify({ - rfc: config.rfc, - certificateBase64: config.certificateBase64, - privateKeyBase64: config.privateKeyBase64, - privateKeyPassword: config.privateKeyPassword, - fielCertificateBase64: config.fielCertificateBase64, - fielPrivateKeyBase64: config.fielPrivateKeyBase64, - fielPrivateKeyPassword: config.fielPrivateKeyPassword, - syncIngresos: config.syncIngresos, - syncEgresos: config.syncEgresos, - syncNomina: config.syncNomina, - syncPagos: config.syncPagos, - autoSync: config.autoSync, - syncFrequency: config.syncFrequency, - }), - req.user!.sub, - ]); + if (!parseResult.success) { + throw new ValidationError( + 'Configuracion invalida', + parseResult.error.flatten().fieldErrors + ); + } + validatedConfig = parseResult.data; + } - // Create initial sync job - await db.queryTenant( - tenant, - `INSERT INTO sync_jobs (integration_id, job_type, status, created_by) - VALUES ($1, 'sat_initial', 'pending', $2)`, - [result.rows[0].id, req.user!.sub] - ); + // Update integration + const updated = await integrationManager.updateIntegration(tenant, integration.id, { + name, + description, + isActive, + config: validatedConfig, + }); const response: ApiResponse = { success: true, data: { - integration: result.rows[0], - message: 'Integracion SAT configurada. Iniciando sincronizacion inicial...', + ...updated, + config: maskSensitiveData(updated.config as Record), }, meta: { timestamp: new Date().toISOString(), }, }; - res.status(201).json(response); + res.json(response); } catch (error) { next(error); } @@ -503,10 +591,10 @@ router.post( ); /** - * POST /api/integrations/:type - * Configure other integration types + * DELETE /api/integrations/:type + * Delete integration by type */ -router.post( +router.delete( '/:type', authenticate, requireAdmin, @@ -515,126 +603,21 @@ router.post( try { const { type } = req.params; const tenant = getTenantContext(req); - const db = getDatabase(); - // Validate config based on type - const configSchema = getConfigSchema(type); - const parseResult = configSchema.safeParse(req.body); + // Find integration by type + const integrations = await integrationManager.getIntegrations(tenant, { + type: type as IntegrationType, + }); - if (!parseResult.success) { - throw new ValidationError('Configuracion invalida', parseResult.error.flatten().fieldErrors); + if (integrations.length === 0) { + throw new NotFoundError(`Integracion de tipo ${type}`); } - const config = parseResult.data; - - // Check for existing integration (except webhooks which can have multiple) - if (type !== 'webhook') { - const existingCheck = await db.queryTenant( - tenant, - 'SELECT id FROM integrations WHERE type = $1', - [type] - ); - - if (existingCheck.rows.length > 0) { - throw new ConflictError(`Ya existe una integracion de tipo ${type} configurada`); - } - } - - // Generate name based on type - let name = type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - if ('name' in config && config.name) { - name = config.name as string; - } - - const insertQuery = ` - INSERT INTO integrations ( - type, - name, - status, - is_active, - config, - created_by - ) - VALUES ($1, $2, 'pending', true, $3, $4) - RETURNING id, type, name, status, is_active, created_at - `; - - const result = await db.queryTenant(tenant, insertQuery, [ - type, - name, - JSON.stringify(config), - req.user!.sub, - ]); + await integrationManager.deleteIntegration(tenant, integrations[0].id); const response: ApiResponse = { success: true, - data: result.rows[0], - meta: { - timestamp: new Date().toISOString(), - }, - }; - - res.status(201).json(response); - } catch (error) { - next(error); - } - } -); - -/** - * PUT /api/integrations/:id - * Update integration configuration - */ -router.put( - '/:id', - authenticate, - requireAdmin, - validate({ params: IntegrationIdSchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const tenant = getTenantContext(req); - const db = getDatabase(); - - // Get existing integration - const existingCheck = await db.queryTenant( - tenant, - 'SELECT id, type, config FROM integrations WHERE id = $1', - [id] - ); - - if (existingCheck.rows.length === 0) { - throw new NotFoundError('Integracion'); - } - - const existing = existingCheck.rows[0]; - - // Validate config based on type - const configSchema = getConfigSchema(existing.type); - const parseResult = configSchema.partial().safeParse(req.body); - - if (!parseResult.success) { - throw new ValidationError('Configuracion invalida', parseResult.error.flatten().fieldErrors); - } - - // Merge with existing config - const newConfig = { ...existing.config, ...parseResult.data }; - - const updateQuery = ` - UPDATE integrations - SET config = $1, updated_at = NOW() - WHERE id = $2 - RETURNING id, type, name, status, is_active, updated_at - `; - - const result = await db.queryTenant(tenant, updateQuery, [ - JSON.stringify(newConfig), - id, - ]); - - const response: ApiResponse = { - success: true, - data: result.rows[0], + data: { deleted: true, type }, meta: { timestamp: new Date().toISOString(), }, @@ -648,102 +631,111 @@ router.put( ); /** - * POST /api/integrations/:id/sync - * Trigger sync for an integration + * POST /api/integrations/:type/test + * Test connection for an integration type */ router.post( - '/:id/sync', + '/:type/test', authenticate, - validate({ params: IntegrationIdSchema, body: SyncBodySchema }), + requireAdmin, + validate({ params: IntegrationTypeParamSchema }), async (req: Request, res: Response, next: NextFunction) => { try { - const { id } = req.params; - const syncOptions = req.body as z.infer; + const { type } = req.params; const tenant = getTenantContext(req); - const db = getDatabase(); - // Check if integration exists and is active - const integrationCheck = await db.queryTenant( + // Validate config + const configSchema = getConfigSchema(type); + const parseResult = configSchema.safeParse(req.body); + + if (!parseResult.success) { + throw new ValidationError( + 'Configuracion invalida', + parseResult.error.flatten().fieldErrors + ); + } + + // Test connection + const result = await integrationManager.testConnection( tenant, - 'SELECT id, type, status, is_active FROM integrations WHERE id = $1', - [id] + type as IntegrationType, + { type, ...parseResult.data } as any ); - if (integrationCheck.rows.length === 0) { - throw new NotFoundError('Integracion'); - } - - const integration = integrationCheck.rows[0]; - - if (!integration.is_active) { - throw new ValidationError('La integracion esta desactivada'); - } - - // Check for existing pending/running job - const activeJobCheck = await db.queryTenant( - tenant, - `SELECT id, status, started_at - FROM sync_jobs - WHERE integration_id = $1 AND status IN ('pending', 'running') - ORDER BY created_at DESC - LIMIT 1`, - [id] - ); - - if (activeJobCheck.rows.length > 0 && !syncOptions.force) { - const response: ApiResponse = { - success: false, - error: { - code: 'SYNC_IN_PROGRESS', - message: 'Ya hay una sincronizacion en progreso', - details: { - jobId: activeJobCheck.rows[0].id, - status: activeJobCheck.rows[0].status, - startedAt: activeJobCheck.rows[0].started_at, - }, - }, - meta: { - timestamp: new Date().toISOString(), - }, - }; - res.status(409).json(response); - return; - } - - // Create sync job - const createJobQuery = ` - INSERT INTO sync_jobs ( - integration_id, - job_type, - status, - parameters, - created_by - ) - VALUES ($1, $2, 'pending', $3, $4) - RETURNING id, job_type, status, created_at - `; - - const jobType = `${integration.type}_sync`; - const parameters = { - startDate: syncOptions.startDate, - endDate: syncOptions.endDate, - force: syncOptions.force, + const response: ApiResponse = { + success: result.success, + data: { + connected: result.success, + latencyMs: result.latencyMs, + message: result.message, + serverVersion: result.serverVersion, + capabilities: result.capabilities, + error: result.success + ? undefined + : { + code: result.errorCode, + details: result.errorDetails, + }, + }, + meta: { + timestamp: new Date().toISOString(), + }, }; - const result = await db.queryTenant(tenant, createJobQuery, [ - id, - jobType, - JSON.stringify(parameters), - req.user!.sub, - ]); + res.status(result.success ? 200 : 400).json(response); + } catch (error) { + next(error); + } + } +); - // In production, trigger background job here +/** + * POST /api/integrations/:type/sync + * Start manual sync for an integration + */ +router.post( + '/:type/sync', + authenticate, + validate({ params: IntegrationTypeParamSchema, body: SyncOptionsSchema }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { type } = req.params; + const options = req.body as z.infer; + const tenant = getTenantContext(req); + + // Find integration by type + const integrations = await integrationManager.getIntegrations(tenant, { + type: type as IntegrationType, + isActive: true, + }); + + if (integrations.length === 0) { + throw new NotFoundError(`Integracion activa de tipo ${type}`); + } + + const integration = integrations[0]; + + // Start sync + const result = await integrationManager.syncData(tenant, integration.id, { + ...options, + startDate: options.startDate ? new Date(options.startDate) : undefined, + endDate: options.endDate ? new Date(options.endDate) : undefined, + }); const response: ApiResponse = { success: true, data: { - job: result.rows[0], + jobId: result.jobId, + status: result.status, message: 'Sincronizacion iniciada', + result: { + totalRecords: result.totalRecords, + processedRecords: result.processedRecords, + createdRecords: result.createdRecords, + updatedRecords: result.updatedRecords, + failedRecords: result.failedRecords, + progress: result.progress, + }, }, meta: { timestamp: new Date().toISOString(), @@ -758,52 +750,68 @@ router.post( ); /** - * DELETE /api/integrations/:id - * Remove an integration + * GET /api/integrations/:type/status + * Get last sync status for an integration */ -router.delete( - '/:id', +router.get( + '/:type/status', authenticate, - requireAdmin, - validate({ params: IntegrationIdSchema }), + validate({ params: IntegrationTypeParamSchema }), async (req: Request, res: Response, next: NextFunction) => { try { - const { id } = req.params; + const { type } = req.params; const tenant = getTenantContext(req); - const db = getDatabase(); - // Check if integration exists - const existingCheck = await db.queryTenant( - tenant, - 'SELECT id, type FROM integrations WHERE id = $1', - [id] - ); + // Find integration by type + const integrations = await integrationManager.getIntegrations(tenant, { + type: type as IntegrationType, + }); - if (existingCheck.rows.length === 0) { - throw new NotFoundError('Integracion'); + if (integrations.length === 0) { + throw new NotFoundError(`Integracion de tipo ${type}`); } - // Cancel any pending jobs - await db.queryTenant( - tenant, - `UPDATE sync_jobs - SET status = 'cancelled', updated_at = NOW() - WHERE integration_id = $1 AND status IN ('pending', 'running')`, - [id] - ); + const integration = integrations[0]; + const lastSync = await integrationManager.getLastSyncStatus(tenant, integration.id); - // Soft delete the integration - await db.queryTenant( - tenant, - `UPDATE integrations - SET is_active = false, status = 'inactive', updated_at = NOW() - WHERE id = $1`, - [id] - ); + // Determine health status + let health: 'healthy' | 'degraded' | 'unhealthy' | 'unknown' = 'unknown'; + if (integration.status === 'active' && integration.lastSyncStatus === 'completed') { + health = 'healthy'; + } else if (integration.status === 'error' || integration.lastSyncStatus === 'failed') { + health = 'unhealthy'; + } else if (integration.status === 'active') { + health = 'degraded'; + } const response: ApiResponse = { success: true, - data: { deleted: true, id }, + data: { + integration: { + id: integration.id, + type: integration.type, + name: integration.name, + status: integration.status, + isActive: integration.isActive, + health, + }, + lastSync: lastSync + ? { + jobId: lastSync.jobId, + status: lastSync.status, + startedAt: lastSync.startedAt, + completedAt: lastSync.completedAt, + durationMs: lastSync.durationMs, + totalRecords: lastSync.totalRecords, + createdRecords: lastSync.createdRecords, + updatedRecords: lastSync.updatedRecords, + failedRecords: lastSync.failedRecords, + errorCount: lastSync.errorCount, + lastError: lastSync.lastError, + } + : null, + nextSyncAt: integration.nextSyncAt, + }, meta: { timestamp: new Date().toISOString(), }, @@ -816,4 +824,195 @@ router.delete( } ); +/** + * GET /api/integrations/:type/logs + * Get sync history for an integration + */ +router.get( + '/:type/logs', + authenticate, + validate({ params: IntegrationTypeParamSchema }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { type } = req.params; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + const offset = parseInt(req.query.offset as string) || 0; + const tenant = getTenantContext(req); + + // Find integration by type + const integrations = await integrationManager.getIntegrations(tenant, { + type: type as IntegrationType, + }); + + if (integrations.length === 0) { + throw new NotFoundError(`Integracion de tipo ${type}`); + } + + const { logs, total } = await integrationManager.getSyncHistory( + tenant, + integrations[0].id, + limit, + offset + ); + + const response: ApiResponse = { + success: true, + data: logs, + meta: { + total, + limit, + offset, + timestamp: new Date().toISOString(), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } +); + +/** + * POST /api/integrations/:type/schedule + * Configure automatic sync schedule + */ +router.post( + '/:type/schedule', + authenticate, + requireAdmin, + validate({ params: IntegrationTypeParamSchema, body: ScheduleSchema }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { type } = req.params; + const scheduleData = req.body as z.infer; + const tenant = getTenantContext(req); + + // Find integration by type + const integrations = await integrationManager.getIntegrations(tenant, { + type: type as IntegrationType, + isActive: true, + }); + + if (integrations.length === 0) { + throw new NotFoundError(`Integracion activa de tipo ${type}`); + } + + const integration = integrations[0]; + + // Import scheduler lazily to avoid circular dependencies + const { SyncScheduler } = await import('../services/integrations/sync.scheduler.js'); + const scheduler = SyncScheduler.getInstance(); + + const schedule = await scheduler.scheduleRecurring( + tenant.tenantId, + integration.id, + { + entityType: scheduleData.entityType as SyncEntityType, + direction: scheduleData.direction as SyncDirection, + isEnabled: scheduleData.isEnabled, + cronExpression: scheduleData.cronExpression, + timezone: scheduleData.timezone, + startTime: scheduleData.startTime, + endTime: scheduleData.endTime, + daysOfWeek: scheduleData.daysOfWeek, + priority: scheduleData.priority, + timeoutMs: scheduleData.timeoutMs, + } + ); + + const response: ApiResponse = { + success: true, + data: schedule, + meta: { + timestamp: new Date().toISOString(), + }, + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } +); + +/** + * GET /api/integrations/:type/schedules + * Get all schedules for an integration + */ +router.get( + '/:type/schedules', + authenticate, + validate({ params: IntegrationTypeParamSchema }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { type } = req.params; + const tenant = getTenantContext(req); + + // Find integration by type + const integrations = await integrationManager.getIntegrations(tenant, { + type: type as IntegrationType, + }); + + if (integrations.length === 0) { + throw new NotFoundError(`Integracion de tipo ${type}`); + } + + // Import scheduler lazily + const { SyncScheduler } = await import('../services/integrations/sync.scheduler.js'); + const scheduler = SyncScheduler.getInstance(); + + const schedules = await scheduler.getSchedules(tenant, integrations[0].id); + + const response: ApiResponse = { + success: true, + data: schedules, + meta: { + total: schedules.length, + timestamp: new Date().toISOString(), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } +); + +/** + * GET /api/integrations/categories/:category + * Get integrations by category + */ +router.get( + '/categories/:category', + authenticate, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { category } = req.params; + const validCategories = ['erp', 'accounting', 'fiscal', 'bank', 'payments', 'custom']; + + if (!validCategories.includes(category)) { + throw new ValidationError(`Categoria invalida. Opciones: ${validCategories.join(', ')}`); + } + + const providers = integrationManager.getProvidersByCategory(category as any); + + const response: ApiResponse = { + success: true, + data: providers, + meta: { + category, + total: providers.length, + timestamp: new Date().toISOString(), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } +); + export default router; diff --git a/apps/api/src/routes/reports.routes.ts b/apps/api/src/routes/reports.routes.ts new file mode 100644 index 0000000..fc67478 --- /dev/null +++ b/apps/api/src/routes/reports.routes.ts @@ -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; diff --git a/apps/api/src/services/ai/ai.service.ts b/apps/api/src/services/ai/ai.service.ts new file mode 100644 index 0000000..2a640f0 --- /dev/null +++ b/apps/api/src/services/ai/ai.service.ts @@ -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 = { + 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": , + "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 { + const startTime = Date.now(); + const cacheKey = generateCacheKey('insight', context.tenantId, { metrics, context }); + + // Intentar obtener del cache + if (this.enableCache) { + const cached = await this.getFromCache(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 { + 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(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 { + 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(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 { + 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(key: string): Promise { + if (!this.redis) return null; + + try { + const data = await this.redis.get(key); + if (!data) return null; + + const entry = JSON.parse(data) as AICacheEntry; + + // 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(key: string, data: T, ttl: number = this.defaultCacheTTL): Promise { + if (!this.redis) return; + + try { + const entry: AICacheEntry = { + 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(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; diff --git a/apps/api/src/services/ai/deepseek.client.ts b/apps/api/src/services/ai/deepseek.client.ts new file mode 100644 index 0000000..5822e9c --- /dev/null +++ b/apps/api/src/services/ai/deepseek.client.ts @@ -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 = { + 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 & { 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> + ): Promise { + const request: ChatCompletionRequest = { + model: options?.model || this.config.model, + messages, + stream: false, + ...options, + }; + + return this.executeWithRetry(async () => { + const response = await this.makeRequest( + 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> + ): AsyncGenerator { + 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> + ): Promise { + 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(endpoint: string, body: unknown): Promise { + 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> { + 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 { + const headers: Record = { + '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 { + 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 + ): AsyncGenerator { + 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( + operation: () => Promise, + attempt: number = 1 + ): Promise { + 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 { + 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 & { 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 & { apiKey: string } +): DeepSeekClient { + return new DeepSeekClient(config); +} + +export default DeepSeekClient; diff --git a/apps/api/src/services/ai/deepseek.types.ts b/apps/api/src/services/ai/deepseek.types.ts new file mode 100644 index 0000000..55d7113 --- /dev/null +++ b/apps/api/src/services/ai/deepseek.types.ts @@ -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; + /** 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; +} + +/** + * 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 { + /** Datos cacheados */ + data: T; + /** Timestamp de creación */ + createdAt: Date; + /** Timestamp de expiración */ + expiresAt: Date; + /** Tokens utilizados */ + tokensUsed: number; +} diff --git a/apps/api/src/services/ai/index.ts b/apps/api/src/services/ai/index.ts new file mode 100644 index 0000000..b324af8 --- /dev/null +++ b/apps/api/src/services/ai/index.ts @@ -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'; diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index 026cc4e..adcaec0 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -15,3 +15,12 @@ export * from './metrics/metrics.cache.js'; export * from './sat/sat.types.js'; export * from './sat/cfdi.parser.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'; diff --git a/apps/api/src/services/integrations/alegra/alegra.client.ts b/apps/api/src/services/integrations/alegra/alegra.client.ts new file mode 100644 index 0000000..2e15617 --- /dev/null +++ b/apps/api/src/services/integrations/alegra/alegra.client.ts @@ -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 { + 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 { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +// ============================================================================ +// Alegra Client +// ============================================================================ + +/** + * Cliente REST para el API de Alegra + */ +export class AlegraClient { + private readonly config: Required> & Partial; + 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(endpoint: string, params?: Record): Promise { + return this.request('GET', endpoint, undefined, params); + } + + /** + * Realiza una peticion POST + */ + async post(endpoint: string, data?: unknown): Promise { + return this.request('POST', endpoint, data); + } + + /** + * Realiza una peticion PUT + */ + async put(endpoint: string, data?: unknown): Promise { + return this.request('PUT', endpoint, data); + } + + /** + * Realiza una peticion DELETE + */ + async delete(endpoint: string): Promise { + return this.request('DELETE', endpoint); + } + + // ============================================================================ + // Pagination Helpers + // ============================================================================ + + /** + * Obtiene todos los resultados de un endpoint paginado + */ + async getAllPaginated( + endpoint: string, + params?: Record, + maxPages: number = 100 + ): Promise { + 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(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( + endpoint: string, + pagination: AlegraPaginationParams = {}, + filters?: Record + ): Promise<{ data: T[]; total: number; hasMore: boolean }> { + const { start = 0, limit = 30, orderField, order } = pagination; + + const params: Record = { + ...filters, + start, + limit: Math.min(limit, 30), + }; + + if (orderField) { + params.orderField = orderField; + params.order = order || 'DESC'; + } + + const response = await this.get(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( + method: string, + endpoint: string, + data?: unknown, + params?: Record + ): Promise { + // 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(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( + method: string, + url: string, + data?: unknown + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + try { + const headers: Record = { + '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 { + let errorData: Record = {}; + + 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 { + 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 { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // ============================================================================ + // Utility Methods + // ============================================================================ + + /** + * Verifica si las credenciales son validas + */ + async testConnection(): Promise { + 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> { + 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; diff --git a/apps/api/src/services/integrations/alegra/alegra.schema.ts b/apps/api/src/services/integrations/alegra/alegra.schema.ts new file mode 100644 index 0000000..7803110 --- /dev/null +++ b/apps/api/src/services/integrations/alegra/alegra.schema.ts @@ -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; + +// ============================================================================ +// 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; +export type DateFilterInput = z.infer; +export type PeriodInput = z.infer; + +// ============================================================================ +// 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; +export type ContactFilter = z.infer; + +// ============================================================================ +// 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; +export type InvoiceFilter = z.infer; + +// ============================================================================ +// 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; +export type CreditNoteFilter = z.infer; + +// ============================================================================ +// 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; +export type DebitNoteFilter = z.infer; + +// ============================================================================ +// 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; +export type PaymentMadeInput = z.infer; +export type PaymentFilter = z.infer; + +// ============================================================================ +// 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; +export type BankAccountFilter = z.infer; +export type BankTransactionFilter = z.infer; + +// ============================================================================ +// 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; +export type ItemFilter = z.infer; + +// ============================================================================ +// 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; +export type CostCenterInput = z.infer; + +// ============================================================================ +// 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; + +// ============================================================================ +// 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; +export type ProfitAndLossRequest = z.infer; +export type BalanceSheetRequest = z.infer; +export type CashFlowRequest = z.infer; +export type TaxReportRequest = z.infer; + +// ============================================================================ +// 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; + +// ============================================================================ +// 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; + +// ============================================================================ +// 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); +} diff --git a/apps/api/src/services/integrations/alegra/alegra.sync.ts b/apps/api/src/services/integrations/alegra/alegra.sync.ts new file mode 100644 index 0000000..ee656e2 --- /dev/null +++ b/apps/api/src/services/integrations/alegra/alegra.sync.ts @@ -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; + 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; + 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; + onContact?: (contact: HoruxContact) => Promise; + } + ): Promise { + 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; + onContact?: (contact: HoruxContact) => Promise; + } + ): Promise { + 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; + } + ): Promise { + 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; + } + ): Promise { + 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; + } + ): Promise { + 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; + } + ): Promise { + 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; + onContact?: (contact: HoruxContact) => Promise; + } + ): Promise { + 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 { + logger.info('Registering Alegra webhook', { url, events }); + + return this.client.post('/webhooks', { + url, + events, + }); + } + + /** + * Elimina un webhook de Alegra + */ + async deleteWebhook(webhookId: number): Promise { + logger.info('Deleting Alegra webhook', { webhookId }); + await this.client.delete(`/webhooks/${webhookId}`); + } + + /** + * Lista webhooks registrados + */ + async listWebhooks(): Promise { + return this.client.get('/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 { + 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; diff --git a/apps/api/src/services/integrations/alegra/alegra.types.ts b/apps/api/src/services/integrations/alegra/alegra.types.ts new file mode 100644 index 0000000..e52d06f --- /dev/null +++ b/apps/api/src/services/integrations/alegra/alegra.types.ts @@ -0,0 +1,1060 @@ +/** + * Alegra Integration Types + * Tipos completos para la integracion con el API de Alegra + * Software contable cloud popular en Mexico y Latinoamerica + */ + +// ============================================================================ +// Configuration & Authentication +// ============================================================================ + +/** + * Configuracion de conexion con Alegra + */ +export interface AlegraConfig { + /** Email del usuario de Alegra */ + email: string; + /** Token de API de Alegra */ + token: string; + /** URL base del API (default: https://api.alegra.com/api/v1) */ + baseUrl?: string; + /** Timeout en milisegundos (default: 30000) */ + timeout?: number; + /** Reintentos en caso de error (default: 3) */ + maxRetries?: number; + /** Pais para configuraciones especificas (MX, CO, etc.) */ + country?: AlegraCountry; +} + +/** + * Credenciales de autenticacion + */ +export interface AlegraAuth { + email: string; + token: string; +} + +/** + * Paises soportados por Alegra + */ +export type AlegraCountry = 'MX' | 'CO' | 'PE' | 'AR' | 'CL' | 'ES' | 'PA' | 'DO' | 'CR' | 'EC'; + +// ============================================================================ +// Pagination & Filtering +// ============================================================================ + +/** + * Parametros de paginacion para listados + */ +export interface AlegraPaginationParams { + /** Pagina actual (inicia en 0) */ + start?: number; + /** Limite de resultados por pagina (max 30) */ + limit?: number; + /** Ordenar por campo */ + orderField?: string; + /** Direccion de ordenamiento */ + order?: 'ASC' | 'DESC'; +} + +/** + * Parametros de filtro por fecha + */ +export interface AlegraDateFilter { + /** Fecha inicial (YYYY-MM-DD) */ + startDate?: string; + /** Fecha final (YYYY-MM-DD) */ + endDate?: string; +} + +/** + * Respuesta paginada generica + */ +export interface AlegraPaginatedResponse { + data: T[]; + metadata: { + total: number; + start: number; + limit: number; + }; +} + +// ============================================================================ +// Contact (Cliente/Proveedor) +// ============================================================================ + +/** + * Tipo de contacto + */ +export type AlegraContactType = 'client' | 'provider' | 'client,provider'; + +/** + * Tipo de identificacion fiscal (Mexico) + */ +export type AlegraIdentificationType = + | 'RFC' // Mexico - Registro Federal de Contribuyentes + | 'CURP' // Mexico - Clave Unica de Registro de Poblacion + | 'NIT' // Colombia - Numero de Identificacion Tributaria + | 'CC' // Colombia - Cedula de Ciudadania + | 'CE' // Colombia - Cedula de Extranjeria + | 'PASS' // Pasaporte + | 'DIE' // Documento de Identidad Extranjero + | 'TI' // Tarjeta de Identidad + | 'OTHER'; // Otro + +/** + * Direccion de contacto + */ +export interface AlegraAddress { + address?: string; + city?: string; + department?: string; + country?: string; + zipCode?: string; +} + +/** + * Telefono de contacto + */ +export interface AlegraPhone { + number: string; + indicative?: string; + extension?: string; +} + +/** + * Contacto (Cliente o Proveedor) + */ +export interface AlegraContact { + id: number; + name: string; + identification?: string; + identificationType?: AlegraIdentificationType; + email?: string; + phonePrimary?: string; + phoneSecondary?: string; + fax?: string; + mobile?: string; + address?: AlegraAddress; + type: AlegraContactType[]; + seller?: AlegraSeller; + term?: AlegraPaymentTerm; + priceList?: AlegraPriceList; + internalContacts?: AlegraInternalContact[]; + statementAttached?: boolean; + status?: 'active' | 'inactive'; + // Campos fiscales Mexico + regimen?: string; + cfdiUse?: string; + // Campos adicionales + observations?: string; + creditLimit?: number; + ignoreRepeated?: boolean; + kindOfPerson?: 'PERSON_ENTITY' | 'LEGAL_ENTITY'; + // Metadata + metadata?: Record; + createdAt?: string; + updatedAt?: string; +} + +/** + * Contacto interno de una empresa + */ +export interface AlegraInternalContact { + name: string; + email?: string; + phone?: string; + sendNotifications?: boolean; +} + +/** + * Balance de contacto + */ +export interface AlegraContactBalance { + contactId: number; + balance: number; + currency: string; + invoicesPending: number; + totalDue: number; + overdueDays?: number; +} + +/** + * Estado de cuenta de contacto + */ +export interface AlegraContactStatement { + contactId: number; + contact: AlegraContact; + period: { + from: string; + to: string; + }; + openingBalance: number; + transactions: AlegraStatementTransaction[]; + closingBalance: number; + currency: string; +} + +/** + * Transaccion en estado de cuenta + */ +export interface AlegraStatementTransaction { + date: string; + type: 'invoice' | 'credit_note' | 'payment' | 'debit_note'; + documentNumber: string; + description: string; + debit: number; + credit: number; + balance: number; + reference?: string; +} + +// ============================================================================ +// Invoice (Factura) +// ============================================================================ + +/** + * Estado de factura + */ +export type AlegraInvoiceStatus = + | 'draft' // Borrador + | 'open' // Abierta + | 'paid' // Pagada + | 'void' // Anulada + | 'overdue'; // Vencida + +/** + * Tipo de factura + */ +export type AlegraInvoiceType = + | 'NATIONAL' // Nacional + | 'EXPORT' // Exportacion + | 'ELECTRONIC'; // Electronica + +/** + * Item de factura + */ +export interface AlegraInvoiceItem { + id?: number; + name: string; + description?: string; + price: number; + discount?: number; + quantity: number; + unit?: string; + tax?: AlegraItemTax[]; + reference?: string; + // Campos de inventario + warehouse?: { id: number }; + productKey?: string; // Clave SAT para Mexico + unitKey?: string; // Clave unidad SAT para Mexico +} + +/** + * Impuesto de item + */ +export interface AlegraItemTax { + id: number; + name?: string; + percentage?: number; + type?: string; +} + +/** + * Retencion en factura + */ +export interface AlegraRetention { + id: number; + name?: string; + percentage?: number; + type?: 'IVA' | 'ISR' | 'ICA' | 'OTHER'; +} + +/** + * Factura completa + */ +export interface AlegraInvoice { + id: number; + date: string; + dueDate: string; + datetime?: string; + observations?: string; + anotation?: string; + termsConditions?: string; + status: AlegraInvoiceStatus; + client: AlegraContact; + numberTemplate?: AlegraNumberTemplate; + items: AlegraInvoiceItem[]; + costCenter?: AlegraCostCenter; + seller?: AlegraSeller; + total: number; + totalPaid?: number; + balance?: number; + decpimals?: number; + currency?: AlegraCurrency; + exchangeRate?: number; + // Impuestos + taxes?: AlegraInvoiceTax[]; + retentions?: AlegraRetention[]; + // Pagos asociados + payments?: AlegraPaymentReference[]; + // Campos Mexico (CFDI) + stamp?: AlegraStamp; + cfdiUse?: string; + paymentForm?: string; + paymentMethod?: string; + // Campos adicionales + priceList?: AlegraPriceList; + warehouse?: { id: number; name: string }; + // Metadata + type?: AlegraInvoiceType; + printingTemplate?: { id: number }; + createdAt?: string; + updatedAt?: string; +} + +/** + * Impuesto calculado en factura + */ +export interface AlegraInvoiceTax { + id: number; + name: string; + percentage: number; + amount: number; + type?: string; +} + +/** + * Timbre fiscal (Mexico) + */ +export interface AlegraStamp { + uuid?: string; + date?: string; + satCertNumber?: string; + satSign?: string; + cfdiSign?: string; + qr?: string; + cadenaOriginal?: string; +} + +/** + * Referencia de pago en factura + */ +export interface AlegraPaymentReference { + id: number; + date: string; + amount: number; + bankAccount?: { id: number; name: string }; +} + +/** + * Plantilla de numeracion + */ +export interface AlegraNumberTemplate { + id: number; + prefix?: string; + number?: number; + text?: string; + fullNumber?: string; + isDefault?: boolean; +} + +// ============================================================================ +// Credit Note (Nota de Credito) +// ============================================================================ + +/** + * Nota de credito + */ +export interface AlegraCreditNote { + id: number; + date: string; + datetime?: string; + observations?: string; + status: 'draft' | 'open' | 'closed' | 'void'; + client: AlegraContact; + numberTemplate?: AlegraNumberTemplate; + items: AlegraInvoiceItem[]; + costCenter?: AlegraCostCenter; + total: number; + currency?: AlegraCurrency; + // Factura relacionada + relatedInvoices?: Array<{ id: number; number: string; amount: number }>; + // Campos Mexico + stamp?: AlegraStamp; + // Metadata + createdAt?: string; + updatedAt?: string; +} + +// ============================================================================ +// Debit Note (Nota de Debito) +// ============================================================================ + +/** + * Nota de debito + */ +export interface AlegraDebitNote { + id: number; + date: string; + datetime?: string; + observations?: string; + status: 'draft' | 'open' | 'closed' | 'void'; + client: AlegraContact; + numberTemplate?: AlegraNumberTemplate; + items: AlegraInvoiceItem[]; + costCenter?: AlegraCostCenter; + total: number; + currency?: AlegraCurrency; + // Factura relacionada + relatedInvoices?: Array<{ id: number; number: string; amount: number }>; + // Metadata + createdAt?: string; + updatedAt?: string; +} + +// ============================================================================ +// Payment (Pago) +// ============================================================================ + +/** + * Tipo de pago + */ +export type AlegraPaymentType = 'in' | 'out'; + +/** + * Metodo de pago + */ +export type AlegraPaymentMethod = + | 'cash' // Efectivo + | 'debit-card' // Tarjeta de debito + | 'credit-card' // Tarjeta de credito + | 'transfer' // Transferencia + | 'check' // Cheque + | 'deposit' // Deposito + | 'electronic-money' // Dinero electronico + | 'consignment' // Consignacion + | 'other'; // Otro + +/** + * Pago recibido + */ +export interface AlegraPaymentReceived { + id: number; + date: string; + amount: number; + client: AlegraContact; + bankAccount?: AlegraBankAccount; + paymentMethod?: AlegraPaymentMethod; + observations?: string; + // Facturas pagadas + invoices?: Array<{ + id: number; + amount: number; + }>; + // Campos Mexico + stamp?: AlegraStamp; + // Metadata + number?: string; + createdAt?: string; + updatedAt?: string; +} + +/** + * Pago realizado (a proveedores) + */ +export interface AlegraPaymentMade { + id: number; + date: string; + amount: number; + provider: AlegraContact; + bankAccount?: AlegraBankAccount; + paymentMethod?: AlegraPaymentMethod; + observations?: string; + // Facturas de proveedor pagadas + bills?: Array<{ + id: number; + amount: number; + }>; + // Metadata + number?: string; + createdAt?: string; + updatedAt?: string; +} + +// ============================================================================ +// Bank Account & Transactions +// ============================================================================ + +/** + * Tipo de cuenta bancaria + */ +export type AlegraBankAccountType = + | 'bank' // Cuenta bancaria + | 'credit-card' // Tarjeta de credito + | 'cash' // Caja + | 'other'; // Otro + +/** + * Cuenta bancaria + */ +export interface AlegraBankAccount { + id: number; + name: string; + number?: string; + description?: string; + type: AlegraBankAccountType; + initialBalance?: number; + initialBalanceDate?: string; + currency?: AlegraCurrency; + status?: 'active' | 'inactive'; + // Informacion del banco + bank?: { + name: string; + code?: string; + }; + // Metadata + createdAt?: string; + updatedAt?: string; +} + +/** + * Tipo de transaccion bancaria + */ +export type AlegraBankTransactionType = + | 'deposit' // Deposito + | 'withdrawal' // Retiro + | 'transfer' // Transferencia + | 'payment-in' // Pago recibido + | 'payment-out' // Pago realizado + | 'fee' // Comision + | 'interest' // Interes + | 'other'; // Otro + +/** + * Transaccion bancaria + */ +export interface AlegraBankTransaction { + id: number; + date: string; + description: string; + amount: number; + type: AlegraBankTransactionType; + bankAccount: AlegraBankAccount; + category?: AlegraCategory; + contact?: AlegraContact; + reference?: string; + // Estado de conciliacion + reconciled?: boolean; + reconciledDate?: string; + // Documento relacionado + relatedDocument?: { + type: 'invoice' | 'bill' | 'payment' | 'expense'; + id: number; + }; + // Metadata + createdAt?: string; + updatedAt?: string; +} + +/** + * Estado de conciliacion bancaria + */ +export interface AlegraBankReconciliation { + bankAccountId: number; + bankAccount: AlegraBankAccount; + period: { + from: string; + to: string; + }; + openingBalance: number; + closingBalance: number; + reconciledBalance: number; + difference: number; + transactions: { + reconciled: AlegraBankTransaction[]; + unreconciled: AlegraBankTransaction[]; + }; +} + +// ============================================================================ +// Item (Producto/Servicio) +// ============================================================================ + +/** + * Tipo de item + */ +export type AlegraItemType = 'product' | 'service' | 'kit'; + +/** + * Item (Producto o Servicio) + */ +export interface AlegraItem { + id: number; + name: string; + description?: string; + reference?: string; + price: AlegraItemPrice[]; + tax?: AlegraItemTax[]; + category?: AlegraCategory; + inventory?: AlegraItemInventory; + type: AlegraItemType; + // Campos Mexico SAT + productKey?: string; // Clave producto/servicio SAT + unitKey?: string; // Clave unidad SAT + // Variantes + hasVariants?: boolean; + variants?: AlegraItemVariant[]; + // Estado + status?: 'active' | 'inactive'; + // Metadata + customFields?: Record; + createdAt?: string; + updatedAt?: string; +} + +/** + * Precio de item + */ +export interface AlegraItemPrice { + idPriceList: number; + name?: string; + price: number; +} + +/** + * Informacion de inventario + */ +export interface AlegraItemInventory { + unit?: string; + unitCost?: number; + availableQuantity?: number; + warehouses?: Array<{ + id: number; + name: string; + quantity: number; + }>; + initialQuantity?: number; + minQuantity?: number; + maxQuantity?: number; +} + +/** + * Variante de item + */ +export interface AlegraItemVariant { + id: number; + name: string; + reference?: string; + price?: number; + inventory?: AlegraItemInventory; +} + +// ============================================================================ +// Category & Cost Center +// ============================================================================ + +/** + * Tipo de categoria + */ +export type AlegraCategoryType = 'income' | 'expense' | 'cost' | 'other'; + +/** + * Categoria + */ +export interface AlegraCategory { + id: number; + name: string; + description?: string; + type?: AlegraCategoryType; + parent?: { id: number; name: string }; + status?: 'active' | 'inactive'; + // Metadata + createdAt?: string; + updatedAt?: string; +} + +/** + * Centro de costo + */ +export interface AlegraCostCenter { + id: number; + name: string; + code?: string; + description?: string; + status?: 'active' | 'inactive'; + // Metadata + createdAt?: string; + updatedAt?: string; +} + +// ============================================================================ +// Tax & Payment Terms +// ============================================================================ + +/** + * Tipo de impuesto + */ +export type AlegraTaxType = + | 'IVA' // Impuesto al Valor Agregado + | 'ISR' // Impuesto Sobre la Renta + | 'IEPS' // Impuesto Especial sobre Produccion y Servicios + | 'ICA' // Impuesto de Industria y Comercio + | 'RETENTION' // Retencion + | 'OTHER'; // Otro + +/** + * Impuesto + */ +export interface AlegraTax { + id: number; + name: string; + percentage: number; + description?: string; + type: AlegraTaxType; + status?: 'active' | 'inactive'; + // Mexico SAT + satTaxCode?: string; + // Metadata + createdAt?: string; + updatedAt?: string; +} + +/** + * Termino de pago + */ +export interface AlegraPaymentTerm { + id: number; + name: string; + days: number; + description?: string; + isDefault?: boolean; + // Metadata + createdAt?: string; + updatedAt?: string; +} + +// ============================================================================ +// Supporting Types +// ============================================================================ + +/** + * Moneda + */ +export interface AlegraCurrency { + code: string; + symbol?: string; + name?: string; + exchangeRate?: number; +} + +/** + * Vendedor + */ +export interface AlegraSeller { + id: number; + name: string; + identification?: string; + observations?: string; + status?: 'active' | 'inactive'; +} + +/** + * Lista de precios + */ +export interface AlegraPriceList { + id: number; + name: string; + isDefault?: boolean; +} + +// ============================================================================ +// Reports +// ============================================================================ + +/** + * Balance de comprobacion + */ +export interface AlegraTrialBalance { + date: string; + accounts: AlegraTrialBalanceAccount[]; + totals: { + debit: number; + credit: number; + debitBalance: number; + creditBalance: number; + }; +} + +/** + * Cuenta en balance de comprobacion + */ +export interface AlegraTrialBalanceAccount { + id: number; + code: string; + name: string; + debit: number; + credit: number; + debitBalance: number; + creditBalance: number; + type: string; + level: number; +} + +/** + * Estado de resultados (P&L) + */ +export interface AlegraProfitAndLoss { + period: { + from: string; + to: string; + }; + income: AlegraPLSection; + expenses: AlegraPLSection; + costs?: AlegraPLSection; + grossProfit: number; + operatingProfit: number; + netProfit: number; +} + +/** + * Seccion del estado de resultados + */ +export interface AlegraPLSection { + total: number; + items: Array<{ + id: number; + code?: string; + name: string; + amount: number; + }>; +} + +/** + * Balance general + */ +export interface AlegraBalanceSheet { + date: string; + assets: AlegraBalanceSection; + liabilities: AlegraBalanceSection; + equity: AlegraBalanceSection; + totalAssets: number; + totalLiabilitiesAndEquity: number; +} + +/** + * Seccion del balance general + */ +export interface AlegraBalanceSection { + total: number; + items: Array<{ + id: number; + code?: string; + name: string; + amount: number; + children?: Array<{ + id: number; + name: string; + amount: number; + }>; + }>; +} + +/** + * Flujo de efectivo + */ +export interface AlegraCashFlow { + period: { + from: string; + to: string; + }; + openingBalance: number; + closingBalance: number; + operating: AlegraCashFlowSection; + investing: AlegraCashFlowSection; + financing: AlegraCashFlowSection; + netCashFlow: number; +} + +/** + * Seccion del flujo de efectivo + */ +export interface AlegraCashFlowSection { + total: number; + items: Array<{ + name: string; + amount: number; + }>; +} + +/** + * Reporte de impuestos + */ +export interface AlegraTaxReport { + period: { + from: string; + to: string; + }; + taxes: Array<{ + taxId: number; + taxName: string; + taxType: AlegraTaxType; + collected: number; + paid: number; + balance: number; + }>; + summary: { + totalCollected: number; + totalPaid: number; + netTaxPayable: number; + }; +} + +// ============================================================================ +// Webhook Types +// ============================================================================ + +/** + * Evento de webhook + */ +export type AlegraWebhookEvent = + | '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'; + +/** + * Subscripcion de webhook + */ +export interface AlegraWebhookSubscription { + id: number; + url: string; + events: AlegraWebhookEvent[]; + status: 'active' | 'inactive'; + secret?: string; + createdAt?: string; + updatedAt?: string; +} + +/** + * Payload de webhook + */ +export interface AlegraWebhookPayload { + event: AlegraWebhookEvent; + timestamp: string; + data: T; + signature?: string; +} + +// ============================================================================ +// Sync Types +// ============================================================================ + +/** + * Estado de sincronizacion + */ +export interface AlegraSyncState { + tenantId: string; + lastSyncAt?: Date; + lastInvoiceSyncAt?: Date; + lastContactSyncAt?: Date; + lastPaymentSyncAt?: Date; + status: 'idle' | 'syncing' | 'error'; + error?: string; +} + +/** + * Resultado de sincronizacion + */ +export interface AlegraSyncResult { + success: boolean; + startedAt: Date; + completedAt: Date; + stats: { + invoices: { synced: number; errors: number }; + contacts: { synced: number; errors: number }; + payments: { synced: number; errors: number }; + creditNotes: { synced: number; errors: number }; + }; + errors: Array<{ + type: string; + id?: number; + message: string; + }>; +} + +// ============================================================================ +// Error Types +// ============================================================================ + +/** + * Codigos de error de Alegra API + */ +export type AlegraErrorCode = + | 'UNAUTHORIZED' // 401 - Credenciales invalidas + | 'FORBIDDEN' // 403 - Sin permisos + | 'NOT_FOUND' // 404 - Recurso no encontrado + | 'VALIDATION_ERROR' // 400 - Error de validacion + | 'RATE_LIMIT_EXCEEDED' // 429 - Limite de peticiones excedido + | 'INTERNAL_ERROR' // 500 - Error interno de Alegra + | 'SERVICE_UNAVAILABLE' // 503 - Servicio no disponible + | 'NETWORK_ERROR' // Error de red + | 'TIMEOUT'; // Timeout + +/** + * Error de Alegra API + */ +export class AlegraError extends Error { + constructor( + message: string, + public readonly code: AlegraErrorCode, + public readonly statusCode?: number, + public readonly details?: Record + ) { + super(message); + this.name = 'AlegraError'; + Object.setPrototypeOf(this, AlegraError.prototype); + } +} + +/** + * Error de rate limit + */ +export class AlegraRateLimitError extends AlegraError { + constructor( + public readonly retryAfter: number, + message: string = 'Rate limit exceeded' + ) { + super(message, 'RATE_LIMIT_EXCEEDED', 429); + this.name = 'AlegraRateLimitError'; + Object.setPrototypeOf(this, AlegraRateLimitError.prototype); + } +} + +/** + * Error de autenticacion + */ +export class AlegraAuthError extends AlegraError { + constructor(message: string = 'Authentication failed') { + super(message, 'UNAUTHORIZED', 401); + this.name = 'AlegraAuthError'; + Object.setPrototypeOf(this, AlegraAuthError.prototype); + } +} diff --git a/apps/api/src/services/integrations/alegra/contacts.connector.ts b/apps/api/src/services/integrations/alegra/contacts.connector.ts new file mode 100644 index 0000000..1de8b78 --- /dev/null +++ b/apps/api/src/services/integrations/alegra/contacts.connector.ts @@ -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 = {} + ): Promise<{ + data: AlegraContact[]; + hasMore: boolean; + }> { + const validatedFilters = ContactFilterSchema.partial().parse(filters); + + const params: Record = { + 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('/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 { + const params: Record = {}; + + if (type && type !== 'all') { + params.type = type === 'customer' ? 'client' : 'provider'; + } + + return this.client.getAllPaginated('/contacts', params); + } + + /** + * Obtiene todos los clientes + */ + async getCustomers(filters?: Partial): Promise { + const { data } = await this.getContacts('customer', { + ...filters, + limit: 30, + }); + return data; + } + + /** + * Obtiene todos los proveedores + */ + async getVendors(filters?: Partial): Promise { + const { data } = await this.getContacts('vendor', { + ...filters, + limit: 30, + }); + return data; + } + + /** + * Obtiene un contacto por ID + */ + async getContactById(id: number): Promise { + logger.debug('Getting contact by ID from Alegra', { id }); + return this.client.get(`/contacts/${id}`); + } + + /** + * Busca contactos por nombre, identificacion o email + */ + async searchContacts( + query: string, + type?: 'customer' | 'vendor' | 'all', + limit: number = 30 + ): Promise { + const { data } = await this.getContacts(type, { query, limit }); + return data; + } + + /** + * Busca contacto por identificacion fiscal (RFC, NIT, etc.) + */ + async getContactByIdentification(identification: string): Promise { + const contacts = await this.searchContacts(identification); + return contacts.find(c => c.identification === identification) || null; + } + + /** + * Busca contacto por email + */ + async getContactByEmail(email: string): Promise { + 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 { + const validatedInput = ContactInputSchema.parse(input); + logger.info('Creating contact in Alegra', { name: input.name }); + return this.client.post('/contacts', validatedInput); + } + + /** + * Actualiza un contacto existente + */ + async updateContact(id: number, input: Partial): Promise { + logger.info('Updating contact in Alegra', { id }); + return this.client.put(`/contacts/${id}`, input); + } + + /** + * Elimina un contacto + */ + async deleteContact(id: number): Promise { + 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 { + 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>('/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 { + 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>('/invoices', { + client: id, + start_date: period.from, + end_date: period.to, + }), + this.client.get>('/credit-notes', { + client: id, + start_date: period.from, + end_date: period.to, + }), + this.client.get>('/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 { + 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> { + // 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(); + + 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 { + 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; diff --git a/apps/api/src/services/integrations/alegra/index.ts b/apps/api/src/services/integrations/alegra/index.ts new file mode 100644 index 0000000..f28551d --- /dev/null +++ b/apps/api/src/services/integrations/alegra/index.ts @@ -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 { + return this.client.testConnection(); + } + + /** + * Obtiene informacion de la compania + */ + async getCompanyInfo(): Promise> { + 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; diff --git a/apps/api/src/services/integrations/alegra/invoices.connector.ts b/apps/api/src/services/integrations/alegra/invoices.connector.ts new file mode 100644 index 0000000..e9329cd --- /dev/null +++ b/apps/api/src/services/integrations/alegra/invoices.connector.ts @@ -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 = {}): Promise<{ + data: AlegraInvoice[]; + hasMore: boolean; + }> { + const validatedFilters = InvoiceFilterSchema.partial().parse(filters); + + const params: Record = { + 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('/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 = {}): Promise { + const params: Record = {}; + + 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('/invoices', params); + } + + /** + * Obtiene una factura por ID + */ + async getInvoiceById(id: number): Promise { + logger.debug('Getting invoice by ID from Alegra', { id }); + return this.client.get(`/invoices/${id}`); + } + + /** + * Obtiene los pagos asociados a una factura + */ + async getInvoicePayments(invoiceId: number): Promise { + const invoice = await this.getInvoiceById(invoiceId); + return invoice.payments || []; + } + + /** + * Obtiene facturas por estado + */ + async getInvoicesByStatus( + status: AlegraInvoiceStatus, + pagination?: { start?: number; limit?: number } + ): Promise { + const { data } = await this.getInvoices({ + status, + start: pagination?.start, + limit: pagination?.limit, + }); + return data; + } + + /** + * Obtiene facturas pendientes de pago + */ + async getPendingInvoices(): Promise { + return this.getAllInvoices({ status: 'open' }); + } + + /** + * Obtiene facturas vencidas + */ + async getOverdueInvoices(): Promise { + 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 { + return this.getAllInvoices({ clientId }); + } + + /** + * Obtiene el timbre fiscal (CFDI) de una factura - Mexico + */ + async getInvoiceStamp(invoiceId: number): Promise { + const invoice = await this.getInvoiceById(invoiceId); + return invoice.stamp || null; + } + + /** + * Busca facturas por texto + */ + async searchInvoices(query: string, limit: number = 30): Promise { + const { data } = await this.getInvoices({ query, limit }); + return data; + } + + /** + * Crea una nueva factura + */ + async createInvoice(input: InvoiceInput): Promise { + logger.info('Creating invoice in Alegra', { client: input.client }); + return this.client.post('/invoices', input); + } + + /** + * Actualiza una factura existente + */ + async updateInvoice(id: number, input: Partial): Promise { + logger.info('Updating invoice in Alegra', { id }); + return this.client.put(`/invoices/${id}`, input); + } + + /** + * Anula una factura + */ + async voidInvoice(id: number): Promise { + logger.info('Voiding invoice in Alegra', { id }); + return this.client.put(`/invoices/${id}`, { status: 'void' }); + } + + /** + * Elimina una factura (solo borradores) + */ + async deleteInvoice(id: number): Promise { + 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 { + 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 = {}): Promise<{ + data: AlegraCreditNote[]; + hasMore: boolean; + }> { + const validatedFilters = CreditNoteFilterSchema.partial().parse(filters); + + const params: Record = { + 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('/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 = {}): Promise { + const params: Record = {}; + + 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('/credit-notes', params); + } + + /** + * Obtiene una nota de credito por ID + */ + async getCreditNoteById(id: number): Promise { + logger.debug('Getting credit note by ID from Alegra', { id }); + return this.client.get(`/credit-notes/${id}`); + } + + /** + * Crea una nota de credito + */ + async createCreditNote(input: CreditNoteInput): Promise { + logger.info('Creating credit note in Alegra', { client: input.client }); + return this.client.post('/credit-notes', input); + } + + /** + * Actualiza una nota de credito + */ + async updateCreditNote(id: number, input: Partial): Promise { + logger.info('Updating credit note in Alegra', { id }); + return this.client.put(`/credit-notes/${id}`, input); + } + + /** + * Elimina una nota de credito + */ + async deleteCreditNote(id: number): Promise { + 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 = {}): Promise<{ + data: AlegraDebitNote[]; + hasMore: boolean; + }> { + const validatedFilters = DebitNoteFilterSchema.partial().parse(filters); + + const params: Record = { + 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('/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 = {}): Promise { + const params: Record = {}; + + 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('/debit-notes', params); + } + + /** + * Obtiene una nota de debito por ID + */ + async getDebitNoteById(id: number): Promise { + logger.debug('Getting debit note by ID from Alegra', { id }); + return this.client.get(`/debit-notes/${id}`); + } + + /** + * Crea una nota de debito + */ + async createDebitNote(input: DebitNoteInput): Promise { + logger.info('Creating debit note in Alegra', { client: input.client }); + return this.client.post('/debit-notes', input); + } + + /** + * Actualiza una nota de debito + */ + async updateDebitNote(id: number, input: Partial): Promise { + logger.info('Updating debit note in Alegra', { id }); + return this.client.put(`/debit-notes/${id}`, input); + } + + /** + * Elimina una nota de debito + */ + async deleteDebitNote(id: number): Promise { + 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; + 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 = { + 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 { + // 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; diff --git a/apps/api/src/services/integrations/alegra/payments.connector.ts b/apps/api/src/services/integrations/alegra/payments.connector.ts new file mode 100644 index 0000000..87c1736 --- /dev/null +++ b/apps/api/src/services/integrations/alegra/payments.connector.ts @@ -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 = {} + ): Promise<{ + data: AlegraPaymentReceived[]; + hasMore: boolean; + }> { + const validatedFilters = PaymentFilterSchema.partial().parse(filters); + + const params: Record = { + 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('/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 { + return this.client.getAllPaginated('/payments', { + type: 'in', + start_date: period.from, + end_date: period.to, + }); + } + + /** + * Obtiene un pago recibido por ID + */ + async getPaymentReceivedById(id: number): Promise { + logger.debug('Getting payment received by ID from Alegra', { id }); + return this.client.get(`/payments/${id}`); + } + + /** + * Crea un pago recibido + */ + async createPaymentReceived(input: PaymentReceivedInput): Promise { + logger.info('Creating payment received in Alegra', { + amount: input.amount, + client: input.client.id, + }); + + const payload = { + ...input, + type: 'in', + }; + + return this.client.post('/payments', payload); + } + + /** + * Actualiza un pago recibido + */ + async updatePaymentReceived( + id: number, + input: Partial + ): Promise { + logger.info('Updating payment received in Alegra', { id }); + return this.client.put(`/payments/${id}`, input); + } + + /** + * Elimina un pago recibido + */ + async deletePaymentReceived(id: number): Promise { + 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 = {} + ): Promise<{ + data: AlegraPaymentMade[]; + hasMore: boolean; + }> { + const validatedFilters = PaymentFilterSchema.partial().parse(filters); + + const params: Record = { + 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('/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 { + return this.client.getAllPaginated('/payments', { + type: 'out', + start_date: period.from, + end_date: period.to, + }); + } + + /** + * Obtiene un pago realizado por ID + */ + async getPaymentMadeById(id: number): Promise { + logger.debug('Getting payment made by ID from Alegra', { id }); + return this.client.get(`/payments/${id}`); + } + + /** + * Crea un pago realizado + */ + async createPaymentMade(input: PaymentMadeInput): Promise { + logger.info('Creating payment made in Alegra', { + amount: input.amount, + provider: input.provider.id, + }); + + const payload = { + ...input, + type: 'out', + }; + + return this.client.post('/payments', payload); + } + + /** + * Actualiza un pago realizado + */ + async updatePaymentMade( + id: number, + input: Partial + ): Promise { + logger.info('Updating payment made in Alegra', { id }); + return this.client.put(`/payments/${id}`, input); + } + + /** + * Elimina un pago realizado + */ + async deletePaymentMade(id: number): Promise { + 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 = {} + ): Promise<{ + data: AlegraBankAccount[]; + hasMore: boolean; + }> { + const validatedFilters = BankAccountFilterSchema.partial().parse(filters); + + const params: Record = { + 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('/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 { + return this.client.getAllPaginated('/bank-accounts'); + } + + /** + * Obtiene una cuenta bancaria por ID + */ + async getBankAccountById(id: number): Promise { + logger.debug('Getting bank account by ID from Alegra', { id }); + return this.client.get(`/bank-accounts/${id}`); + } + + /** + * Obtiene cuentas bancarias por tipo + */ + async getBankAccountsByType(type: AlegraBankAccountType): Promise { + const { data } = await this.getBankAccounts({ type }); + return data; + } + + /** + * Crea una cuenta bancaria + */ + async createBankAccount(input: BankAccountInput): Promise { + logger.info('Creating bank account in Alegra', { name: input.name }); + return this.client.post('/bank-accounts', input); + } + + /** + * Actualiza una cuenta bancaria + */ + async updateBankAccount( + id: number, + input: Partial + ): Promise { + logger.info('Updating bank account in Alegra', { id }); + return this.client.put(`/bank-accounts/${id}`, input); + } + + /** + * Elimina una cuenta bancaria + */ + async deleteBankAccount(id: number): Promise { + 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 = {} + ): Promise<{ + data: AlegraBankTransaction[]; + hasMore: boolean; + }> { + const validatedFilters = BankTransactionFilterSchema.partial().parse(filters); + + const params: Record = { + 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( + '/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 { + return this.client.getAllPaginated( + '/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 { + 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 { + 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; + }; + made: { + total: number; + count: number; + byMethod: Record; + }; + netCashFlow: number; + }> { + const [paymentsReceived, paymentsMade] = await Promise.all([ + this.getAllPaymentsReceived(period), + this.getAllPaymentsMade(period), + ]); + + const byMethodReceived: Record = { + 'cash': 0, + 'debit-card': 0, + 'credit-card': 0, + 'transfer': 0, + 'check': 0, + 'deposit': 0, + 'electronic-money': 0, + 'consignment': 0, + 'other': 0, + }; + + const byMethodMade: Record = { ...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> { + 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> { + const startDate = sinceDate.toISOString().split('T')[0]; + const endDate = new Date().toISOString().split('T')[0]; + const period = { from: startDate, to: endDate }; + + const payments: Array = []; + + 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; diff --git a/apps/api/src/services/integrations/alegra/reports.connector.ts b/apps/api/src/services/integrations/alegra/reports.connector.ts new file mode 100644 index 0000000..ca3125f --- /dev/null +++ b/apps/api/src/services/integrations/alegra/reports.connector.ts @@ -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 { + logger.debug('Getting trial balance from Alegra', { date: request.date }); + + const params: Record = { + 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 { + logger.debug('Getting P&L from Alegra', { + from: request.from, + to: request.to, + }); + + const params: Record = { + 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 { + 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 { + 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 { + 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; diff --git a/apps/api/src/services/integrations/aspel/aspel.client.ts b/apps/api/src/services/integrations/aspel/aspel.client.ts new file mode 100644 index 0000000..4a83b44 --- /dev/null +++ b/apps/api/src/services/integrations/aspel/aspel.client.ts @@ -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; + close: () => Promise; + 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; + 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 { + 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 { + 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 { + 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 { + 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 { + try { + // Intentar detectar la version de la tabla de configuracion + const versionQueries: Record = { + 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>( + sql: string, + params: unknown[] = [] + ): Promise> { + 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>( + sql: string, + params: unknown[] = [] + ): Promise { + const result = await this.query(sql, params); + return result.rows[0] || null; + } + + /** + * Ejecuta un comando (INSERT, UPDATE, DELETE) + */ + async execute(sql: string, params: unknown[] = []): Promise { + 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 = { + 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(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 = {}; + for (const [key, value] of Object.entries(obj as Record)) { + result[key] = this.convertEncodingObject(value); + } + return result as T; + } + + return obj; + } + + /** + * Convierte encoding de un array de objetos + */ + private convertEncodingArray(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 { + 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 { + 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 { + // Intentar Firebird primero + try { + const net = await import('net'); + + const checkPort = (port: number): Promise => { + 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 { + const client = new AspelClient(config); + await client.connect(); + return client; +} diff --git a/apps/api/src/services/integrations/aspel/aspel.sync.ts b/apps/api/src/services/integrations/aspel/aspel.sync.ts new file mode 100644 index 0000000..6c7f930 --- /dev/null +++ b/apps/api/src/services/integrations/aspel/aspel.sync.ts @@ -0,0 +1,1164 @@ +/** + * Aspel Sync Service + * Servicio de sincronizacion de datos de Aspel a Horux Strategy + */ + +import { EventEmitter } from 'events'; +import { AspelClient, createAspelClient } from './aspel.client.js'; +import { COIConnector, createCOIConnector } from './coi.connector.js'; +import { SAEConnector, createSAEConnector } from './sae.connector.js'; +import { NOIConnector, createNOIConnector } from './noi.connector.js'; +import { BANCOConnector, createBANCOConnector } from './banco.connector.js'; +import { + AspelConfig, + AspelSyncConfig, + AspelSyncResult, + AspelSyncError, + AspelProduct, + Poliza, + MovimientoContable, + Factura, + Compra, + Cliente, + Proveedor, + Empleado, + Nomina, + CuentaBancaria, + MovimientoBancario, + RangoFechas, + PeriodoConsulta, +} from './aspel.types.js'; +import { logger } from '../../../utils/logger.js'; + +// ============================================================================ +// Interfaces de transacciones Horux +// ============================================================================ + +interface HoruxTransaction { + id?: string; + tenantId: string; + externalId: string; + externalSource: 'aspel-coi' | 'aspel-sae' | 'aspel-noi' | 'aspel-banco'; + type: 'income' | 'expense' | 'transfer' | 'payroll' | 'journal'; + date: Date; + amount: number; + description: string; + reference?: string; + category?: string; + accountId?: string; + contactId?: string; + metadata?: Record; + createdAt?: Date; + updatedAt?: Date; +} + +interface HoruxContact { + id?: string; + tenantId: string; + externalId: string; + externalSource: 'aspel-sae'; + type: 'customer' | 'vendor'; + name: string; + taxId: string; + email?: string; + phone?: string; + address?: { + street: string; + city: string; + state: string; + postalCode: string; + country: string; + }; + metadata?: Record; +} + +interface HoruxAccount { + id?: string; + tenantId: string; + externalId: string; + externalSource: 'aspel-coi' | 'aspel-banco'; + type: 'asset' | 'liability' | 'equity' | 'income' | 'expense' | 'bank'; + code: string; + name: string; + balance: number; + metadata?: Record; +} + +// ============================================================================ +// Clase principal de sincronizacion +// ============================================================================ + +export class AspelSyncService extends EventEmitter { + private client: AspelClient | null = null; + private coiConnector: COIConnector | null = null; + private saeConnector: SAEConnector | null = null; + private noiConnector: NOIConnector | null = null; + private bancoConnector: BANCOConnector | null = null; + + private config: AspelSyncConfig; + private syncInProgress = false; + private lastSyncDate: Date | null = null; + + constructor(config: AspelSyncConfig) { + super(); + this.config = config; + } + + // ============================================================================ + // Inicializacion + // ============================================================================ + + /** + * Inicializa los conectores + */ + async initialize(): Promise { + try { + logger.info('Initializing Aspel sync service...', { + tenantId: this.config.tenantId, + products: this.config.products, + }); + + // Crear cliente de conexion + this.client = createAspelClient(this.config.connection); + await this.client.connect(); + + // Inicializar conectores segun productos configurados + const empresaId = this.config.connection.empresaId || 1; + + if (this.config.products.includes('COI')) { + this.coiConnector = createCOIConnector(this.client, empresaId); + } + + if (this.config.products.includes('SAE')) { + this.saeConnector = createSAEConnector(this.client, empresaId); + } + + if (this.config.products.includes('NOI')) { + this.noiConnector = createNOIConnector(this.client, empresaId); + } + + if (this.config.products.includes('BANCO')) { + this.bancoConnector = createBANCOConnector(this.client, empresaId); + } + + this.emit('initialized'); + logger.info('Aspel sync service initialized'); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Failed to initialize Aspel sync service', { error: errorMsg }); + throw error; + } + } + + /** + * Cierra las conexiones + */ + async close(): Promise { + if (this.client) { + await this.client.disconnect(); + this.client = null; + } + + this.coiConnector = null; + this.saeConnector = null; + this.noiConnector = null; + this.bancoConnector = null; + + this.emit('closed'); + logger.info('Aspel sync service closed'); + } + + // ============================================================================ + // Sincronizacion principal + // ============================================================================ + + /** + * Ejecuta la sincronizacion completa + */ + async syncToHorux( + options?: { + rango?: RangoFechas; + periodo?: PeriodoConsulta; + products?: AspelProduct[]; + } + ): Promise { + if (this.syncInProgress) { + throw new Error('Sync already in progress'); + } + + this.syncInProgress = true; + const results: AspelSyncResult[] = []; + + try { + if (!this.client) { + await this.initialize(); + } + + const products = options?.products || this.config.products; + + this.emit('syncStart', { products }); + logger.info('Starting Aspel sync', { products }); + + for (const product of products) { + const result = await this.syncProduct(product, options); + results.push(result); + + this.emit('productSynced', result); + } + + this.lastSyncDate = new Date(); + this.emit('syncComplete', { results }); + + logger.info('Aspel sync completed', { + products: products.length, + totalCreated: results.reduce((sum, r) => sum + r.created, 0), + totalUpdated: results.reduce((sum, r) => sum + r.updated, 0), + totalErrors: results.reduce((sum, r) => sum + r.errors.length, 0), + }); + + return results; + } finally { + this.syncInProgress = false; + } + } + + /** + * Sincroniza un producto especifico + */ + private async syncProduct( + product: AspelProduct, + options?: { + rango?: RangoFechas; + periodo?: PeriodoConsulta; + } + ): Promise { + const startTime = new Date(); + const errors: AspelSyncError[] = []; + let created = 0; + let updated = 0; + let skipped = 0; + const processed: AspelSyncResult['processed'] = {}; + + try { + switch (product) { + case 'COI': + if (this.coiConnector) { + const coiResult = await this.syncCOI(options); + processed.cuentas = coiResult.cuentas; + processed.polizas = coiResult.polizas; + processed.movimientos = coiResult.movimientos; + created += coiResult.created; + updated += coiResult.updated; + skipped += coiResult.skipped; + errors.push(...coiResult.errors); + } + break; + + case 'SAE': + if (this.saeConnector) { + const saeResult = await this.syncSAE(options); + processed.clientes = saeResult.clientes; + processed.proveedores = saeResult.proveedores; + processed.articulos = saeResult.articulos; + processed.facturas = saeResult.facturas; + processed.compras = saeResult.compras; + created += saeResult.created; + updated += saeResult.updated; + skipped += saeResult.skipped; + errors.push(...saeResult.errors); + } + break; + + case 'NOI': + if (this.noiConnector) { + const noiResult = await this.syncNOI(options); + processed.empleados = noiResult.empleados; + processed.nominas = noiResult.nominas; + created += noiResult.created; + updated += noiResult.updated; + skipped += noiResult.skipped; + errors.push(...noiResult.errors); + } + break; + + case 'BANCO': + if (this.bancoConnector) { + const bancoResult = await this.syncBANCO(options); + processed.cuentasBancarias = bancoResult.cuentas; + processed.movimientosBancarios = bancoResult.movimientos; + created += bancoResult.created; + updated += bancoResult.updated; + skipped += bancoResult.skipped; + errors.push(...bancoResult.errors); + } + break; + } + + return { + success: errors.length === 0, + product, + startTime, + endTime: new Date(), + duration: Date.now() - startTime.getTime(), + processed, + created, + updated, + skipped, + errors, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + + return { + success: false, + product, + startTime, + endTime: new Date(), + duration: Date.now() - startTime.getTime(), + processed, + created, + updated, + skipped, + errors: [ + { + recordType: 'sync', + recordId: product, + message: errorMsg, + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date(), + }, + ], + }; + } + } + + // ============================================================================ + // Sincronizacion COI + // ============================================================================ + + private async syncCOI(options?: { + rango?: RangoFechas; + periodo?: PeriodoConsulta; + }): Promise<{ + cuentas: number; + polizas: number; + movimientos: number; + created: number; + updated: number; + skipped: number; + errors: AspelSyncError[]; + }> { + const errors: AspelSyncError[] = []; + let created = 0; + let updated = 0; + let skipped = 0; + let totalCuentas = 0; + let totalPolizas = 0; + let totalMovimientos = 0; + + try { + // Sincronizar catalogo de cuentas + const cuentas = await this.coiConnector!.getCatalogoCuentas({ soloActivas: true }); + totalCuentas = cuentas.length; + + for (const cuenta of cuentas) { + try { + const horuxAccount = this.mapCuentaToHoruxAccount(cuenta); + const result = await this.upsertAccount(horuxAccount); + if (result === 'created') created++; + else if (result === 'updated') updated++; + else skipped++; + } catch (error) { + errors.push({ + recordType: 'cuenta', + recordId: cuenta.numeroCuenta, + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + } + + // Sincronizar polizas + if (options?.periodo) { + const polizas = await this.coiConnector!.getPolizas(options.periodo, { conMovimientos: true }); + totalPolizas = polizas.length; + + for (const poliza of polizas) { + try { + const transactions = this.mapPolizaToTransactions(poliza); + totalMovimientos += poliza.movimientos.length; + + for (const transaction of transactions) { + const result = await this.upsertTransaction(transaction); + if (result === 'created') created++; + else if (result === 'updated') updated++; + else skipped++; + } + } catch (error) { + errors.push({ + recordType: 'poliza', + recordId: `${poliza.tipo}-${poliza.numero}`, + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + } + } else if (options?.rango) { + const polizas = await this.coiConnector!.getPolizasByFecha(options.rango, { conMovimientos: true }); + totalPolizas = polizas.length; + + for (const poliza of polizas) { + try { + const transactions = this.mapPolizaToTransactions(poliza); + totalMovimientos += poliza.movimientos.length; + + for (const transaction of transactions) { + const result = await this.upsertTransaction(transaction); + if (result === 'created') created++; + else if (result === 'updated') updated++; + else skipped++; + } + } catch (error) { + errors.push({ + recordType: 'poliza', + recordId: `${poliza.tipo}-${poliza.numero}`, + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + } + } + } catch (error) { + errors.push({ + recordType: 'coi', + recordId: 'sync', + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + + return { + cuentas: totalCuentas, + polizas: totalPolizas, + movimientos: totalMovimientos, + created, + updated, + skipped, + errors, + }; + } + + // ============================================================================ + // Sincronizacion SAE + // ============================================================================ + + private async syncSAE(options?: { + rango?: RangoFechas; + }): Promise<{ + clientes: number; + proveedores: number; + articulos: number; + facturas: number; + compras: number; + created: number; + updated: number; + skipped: number; + errors: AspelSyncError[]; + }> { + const errors: AspelSyncError[] = []; + let created = 0; + let updated = 0; + let skipped = 0; + let totalClientes = 0; + let totalProveedores = 0; + let totalArticulos = 0; + let totalFacturas = 0; + let totalCompras = 0; + + try { + // Sincronizar clientes + const clientes = await this.saeConnector!.getClientes({ soloActivos: true }); + totalClientes = clientes.length; + + for (const cliente of clientes) { + try { + const horuxContact = this.mapClienteToHoruxContact(cliente); + const result = await this.upsertContact(horuxContact); + if (result === 'created') created++; + else if (result === 'updated') updated++; + else skipped++; + } catch (error) { + errors.push({ + recordType: 'cliente', + recordId: cliente.clave, + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + } + + // Sincronizar proveedores + const proveedores = await this.saeConnector!.getProveedores({ soloActivos: true }); + totalProveedores = proveedores.length; + + for (const proveedor of proveedores) { + try { + const horuxContact = this.mapProveedorToHoruxContact(proveedor); + const result = await this.upsertContact(horuxContact); + if (result === 'created') created++; + else if (result === 'updated') updated++; + else skipped++; + } catch (error) { + errors.push({ + recordType: 'proveedor', + recordId: proveedor.clave, + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + } + + // Sincronizar facturas + if (options?.rango) { + const facturas = await this.saeConnector!.getFacturas(options.rango, { conPartidas: true }); + totalFacturas = facturas.length; + + for (const factura of facturas) { + try { + const transaction = this.mapFacturaToTransaction(factura); + const result = await this.upsertTransaction(transaction); + if (result === 'created') created++; + else if (result === 'updated') updated++; + else skipped++; + } catch (error) { + errors.push({ + recordType: 'factura', + recordId: `${factura.serie}-${factura.folio}`, + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + } + + // Sincronizar compras + const compras = await this.saeConnector!.getCompras(options.rango, { conPartidas: true }); + totalCompras = compras.length; + + for (const compra of compras) { + try { + const transaction = this.mapCompraToTransaction(compra); + const result = await this.upsertTransaction(transaction); + if (result === 'created') created++; + else if (result === 'updated') updated++; + else skipped++; + } catch (error) { + errors.push({ + recordType: 'compra', + recordId: `${compra.serie}-${compra.folio}`, + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + } + } + } catch (error) { + errors.push({ + recordType: 'sae', + recordId: 'sync', + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + + return { + clientes: totalClientes, + proveedores: totalProveedores, + articulos: totalArticulos, + facturas: totalFacturas, + compras: totalCompras, + created, + updated, + skipped, + errors, + }; + } + + // ============================================================================ + // Sincronizacion NOI + // ============================================================================ + + private async syncNOI(options?: { + periodo?: PeriodoConsulta; + }): Promise<{ + empleados: number; + nominas: number; + created: number; + updated: number; + skipped: number; + errors: AspelSyncError[]; + }> { + const errors: AspelSyncError[] = []; + let created = 0; + let updated = 0; + let skipped = 0; + let totalEmpleados = 0; + let totalNominas = 0; + + try { + // Sincronizar empleados + const empleados = await this.noiConnector!.getEmpleados({ soloActivos: true }); + totalEmpleados = empleados.length; + + for (const empleado of empleados) { + try { + const horuxContact = this.mapEmpleadoToHoruxContact(empleado); + const result = await this.upsertContact(horuxContact); + if (result === 'created') created++; + else if (result === 'updated') updated++; + else skipped++; + } catch (error) { + errors.push({ + recordType: 'empleado', + recordId: empleado.numeroEmpleado, + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + } + + // Sincronizar nominas + if (options?.periodo) { + const nominas = await this.noiConnector!.getNominas(options.periodo); + totalNominas = nominas.length; + + for (const nomina of nominas) { + try { + const transaction = this.mapNominaToTransaction(nomina); + const result = await this.upsertTransaction(transaction); + if (result === 'created') created++; + else if (result === 'updated') updated++; + else skipped++; + } catch (error) { + errors.push({ + recordType: 'nomina', + recordId: String(nomina.id), + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + } + } + } catch (error) { + errors.push({ + recordType: 'noi', + recordId: 'sync', + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + + return { + empleados: totalEmpleados, + nominas: totalNominas, + created, + updated, + skipped, + errors, + }; + } + + // ============================================================================ + // Sincronizacion BANCO + // ============================================================================ + + private async syncBANCO(options?: { + rango?: RangoFechas; + }): Promise<{ + cuentas: number; + movimientos: number; + created: number; + updated: number; + skipped: number; + errors: AspelSyncError[]; + }> { + const errors: AspelSyncError[] = []; + let created = 0; + let updated = 0; + let skipped = 0; + let totalCuentas = 0; + let totalMovimientos = 0; + + try { + // Sincronizar cuentas bancarias + const cuentas = await this.bancoConnector!.getCuentasBancarias({ soloActivas: true }); + totalCuentas = cuentas.length; + + for (const cuenta of cuentas) { + try { + const horuxAccount = this.mapCuentaBancariaToHoruxAccount(cuenta); + const result = await this.upsertAccount(horuxAccount); + if (result === 'created') created++; + else if (result === 'updated') updated++; + else skipped++; + } catch (error) { + errors.push({ + recordType: 'cuenta_bancaria', + recordId: cuenta.clave, + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + } + + // Sincronizar movimientos + if (options?.rango) { + const movimientos = await this.bancoConnector!.getMovimientosBancarios(options.rango); + totalMovimientos = movimientos.length; + + for (const movimiento of movimientos) { + try { + const transaction = this.mapMovimientoBancarioToTransaction(movimiento); + const result = await this.upsertTransaction(transaction); + if (result === 'created') created++; + else if (result === 'updated') updated++; + else skipped++; + } catch (error) { + errors.push({ + recordType: 'movimiento_bancario', + recordId: String(movimiento.id), + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + } + } + } catch (error) { + errors.push({ + recordType: 'banco', + recordId: 'sync', + message: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }); + } + + return { + cuentas: totalCuentas, + movimientos: totalMovimientos, + created, + updated, + skipped, + errors, + }; + } + + // ============================================================================ + // Mappers a Horux + // ============================================================================ + + private mapCuentaToHoruxAccount(cuenta: { + numeroCuenta: string; + nombre: string; + tipo: string; + naturaleza: string; + }): HoruxAccount { + // Determinar tipo de cuenta basado en naturaleza y numero + let accountType: HoruxAccount['type'] = 'expense'; + + const firstDigit = cuenta.numeroCuenta.charAt(0); + switch (firstDigit) { + case '1': + accountType = 'asset'; + break; + case '2': + accountType = 'liability'; + break; + case '3': + accountType = 'equity'; + break; + case '4': + accountType = 'income'; + break; + case '5': + case '6': + accountType = 'expense'; + break; + } + + return { + tenantId: this.config.tenantId, + externalId: `coi-${cuenta.numeroCuenta}`, + externalSource: 'aspel-coi', + type: accountType, + code: cuenta.numeroCuenta, + name: cuenta.nombre, + balance: 0, + metadata: { + aspelType: cuenta.tipo, + aspelNature: cuenta.naturaleza, + }, + }; + } + + private mapPolizaToTransactions(poliza: Poliza): HoruxTransaction[] { + // Crear una transaccion por poliza + const transaction: HoruxTransaction = { + tenantId: this.config.tenantId, + externalId: `coi-pol-${poliza.ejercicio}-${poliza.periodo}-${poliza.tipo}-${poliza.numero}`, + externalSource: 'aspel-coi', + type: this.mapPolizaTypeToTransactionType(poliza.tipo), + date: poliza.fecha, + amount: poliza.totalCargos, + description: poliza.concepto, + reference: poliza.referencia, + metadata: { + polizaTipo: poliza.tipo, + polizaNumero: poliza.numero, + periodo: poliza.periodo, + ejercicio: poliza.ejercicio, + movimientos: poliza.movimientos.map(m => ({ + cuenta: m.numeroCuenta, + cargo: m.cargo, + abono: m.abono, + concepto: m.concepto, + })), + uuid: poliza.uuidCfdi, + }, + }; + + return [transaction]; + } + + private mapPolizaTypeToTransactionType(tipo: string): HoruxTransaction['type'] { + switch (tipo) { + case 'I': + case 'Ig': + return 'income'; + case 'E': + case 'Eg': + case 'Ch': + return 'expense'; + default: + return 'journal'; + } + } + + private mapClienteToHoruxContact(cliente: Cliente): HoruxContact { + return { + tenantId: this.config.tenantId, + externalId: `sae-cli-${cliente.clave}`, + externalSource: 'aspel-sae', + type: 'customer', + name: cliente.nombre, + taxId: cliente.rfc, + email: cliente.email, + phone: cliente.telefono, + address: { + street: `${cliente.direccion.calle} ${cliente.direccion.numeroExterior || ''}`.trim(), + city: cliente.direccion.ciudad, + state: cliente.direccion.estado, + postalCode: cliente.direccion.codigoPostal, + country: cliente.direccion.pais, + }, + metadata: { + clave: cliente.clave, + limiteCredito: cliente.limiteCredito, + diasCredito: cliente.diasCredito, + saldo: cliente.saldo, + regimenFiscal: cliente.regimenFiscal, + usoCfdi: cliente.usoCfdi, + }, + }; + } + + private mapProveedorToHoruxContact(proveedor: Proveedor): HoruxContact { + return { + tenantId: this.config.tenantId, + externalId: `sae-prov-${proveedor.clave}`, + externalSource: 'aspel-sae', + type: 'vendor', + name: proveedor.nombre, + taxId: proveedor.rfc, + email: proveedor.email, + phone: proveedor.telefono, + address: { + street: `${proveedor.direccion.calle} ${proveedor.direccion.numeroExterior || ''}`.trim(), + city: proveedor.direccion.ciudad, + state: proveedor.direccion.estado, + postalCode: proveedor.direccion.codigoPostal, + country: proveedor.direccion.pais, + }, + metadata: { + clave: proveedor.clave, + limiteCredito: proveedor.limiteCredito, + diasCredito: proveedor.diasCredito, + saldo: proveedor.saldo, + }, + }; + } + + private mapFacturaToTransaction(factura: Factura): HoruxTransaction { + return { + tenantId: this.config.tenantId, + externalId: `sae-fac-${factura.serie}-${factura.folio}`, + externalSource: 'aspel-sae', + type: 'income', + date: factura.fecha, + amount: factura.total, + description: `Factura ${factura.serie}-${factura.folio} - ${factura.nombreCliente}`, + reference: factura.uuid, + contactId: `sae-cli-${factura.claveCliente}`, + metadata: { + serie: factura.serie, + folio: factura.folio, + subtotal: factura.subtotal, + iva: factura.iva, + descuento: factura.descuento, + formaPago: factura.formaPago, + metodoPago: factura.metodoPago, + moneda: factura.moneda, + tipoCambio: factura.tipoCambio, + saldoPendiente: factura.saldoPendiente, + uuid: factura.uuid, + partidas: factura.partidas.length, + }, + }; + } + + private mapCompraToTransaction(compra: Compra): HoruxTransaction { + return { + tenantId: this.config.tenantId, + externalId: `sae-com-${compra.serie}-${compra.folio}`, + externalSource: 'aspel-sae', + type: 'expense', + date: compra.fecha, + amount: compra.total, + description: `Compra ${compra.serie}-${compra.folio} - ${compra.nombreProveedor}`, + reference: compra.uuid || compra.facturaProveedor, + contactId: `sae-prov-${compra.claveProveedor}`, + metadata: { + serie: compra.serie, + folio: compra.folio, + facturaProveedor: compra.facturaProveedor, + subtotal: compra.subtotal, + iva: compra.iva, + descuento: compra.descuento, + moneda: compra.moneda, + tipoCambio: compra.tipoCambio, + saldoPendiente: compra.saldoPendiente, + uuid: compra.uuid, + partidas: compra.partidas.length, + }, + }; + } + + private mapEmpleadoToHoruxContact(empleado: Empleado): HoruxContact { + return { + tenantId: this.config.tenantId, + externalId: `noi-emp-${empleado.numeroEmpleado}`, + externalSource: 'aspel-sae', // Usamos aspel-sae para contactos + type: 'vendor', // Empleados como vendor para pagos de nomina + name: empleado.nombreCompleto, + taxId: empleado.rfc, + metadata: { + numeroEmpleado: empleado.numeroEmpleado, + curp: empleado.curp, + nss: empleado.nss, + departamento: empleado.departamento, + puesto: empleado.puesto, + sueldoDiario: empleado.sueldoDiario, + salarioDiarioIntegrado: empleado.salarioDiarioIntegrado, + fechaAlta: empleado.fechaAlta, + tipoContrato: empleado.tipoContrato, + regimenContratacion: empleado.regimenContratacion, + }, + }; + } + + private mapNominaToTransaction(nomina: Nomina): HoruxTransaction { + return { + tenantId: this.config.tenantId, + externalId: `noi-nom-${nomina.id}`, + externalSource: 'aspel-noi', + type: 'payroll', + date: nomina.fechaPago, + amount: nomina.totalNeto, + description: `Nomina ${nomina.tipoNomina === 'O' ? 'Ordinaria' : 'Extraordinaria'} - Periodo ${nomina.periodo}/${nomina.ejercicio}`, + metadata: { + nominaId: nomina.id, + tipoNomina: nomina.tipoNomina, + periodo: nomina.periodo, + ejercicio: nomina.ejercicio, + fechaInicial: nomina.fechaInicial, + fechaFinal: nomina.fechaFinal, + diasPagados: nomina.diasPagados, + totalPercepciones: nomina.totalPercepciones, + totalDeducciones: nomina.totalDeducciones, + totalOtrosPagos: nomina.totalOtrosPagos, + numeroEmpleados: nomina.numeroEmpleados, + }, + }; + } + + private mapCuentaBancariaToHoruxAccount(cuenta: CuentaBancaria): HoruxAccount { + return { + tenantId: this.config.tenantId, + externalId: `banco-${cuenta.clave}`, + externalSource: 'aspel-banco', + type: 'bank', + code: cuenta.numeroCuenta, + name: `${cuenta.nombre} - ${cuenta.banco}`, + balance: cuenta.saldo, + metadata: { + clave: cuenta.clave, + clabe: cuenta.clabe, + banco: cuenta.banco, + tipoCuenta: cuenta.tipoCuenta, + moneda: cuenta.moneda, + saldoDisponible: cuenta.saldoDisponible, + cuentaContable: cuenta.cuentaContable, + }, + }; + } + + private mapMovimientoBancarioToTransaction(movimiento: MovimientoBancario): HoruxTransaction { + const isDeposit = movimiento.deposito > 0; + + return { + tenantId: this.config.tenantId, + externalId: `banco-mov-${movimiento.id}`, + externalSource: 'aspel-banco', + type: isDeposit ? 'income' : 'expense', + date: movimiento.fecha, + amount: isDeposit ? movimiento.deposito : movimiento.retiro, + description: movimiento.concepto, + reference: movimiento.referencia || movimiento.numeroCheque, + accountId: `banco-${movimiento.claveCuenta}`, + metadata: { + movimientoId: movimiento.id, + tipo: movimiento.tipo, + numeroCheque: movimiento.numeroCheque, + beneficiario: movimiento.beneficiario, + saldo: movimiento.saldo, + conciliado: movimiento.conciliado, + fechaConciliacion: movimiento.fechaConciliacion, + numeroPoliza: movimiento.numeroPoliza, + tipoPoliza: movimiento.tipoPoliza, + uuid: movimiento.uuid, + }, + }; + } + + // ============================================================================ + // Operaciones de persistencia (a implementar con la BD de Horux) + // ============================================================================ + + /** + * Inserta o actualiza una transaccion en Horux + * TODO: Implementar con el repositorio de transacciones + */ + private async upsertTransaction(transaction: HoruxTransaction): Promise<'created' | 'updated' | 'skipped'> { + // Aqui se implementaria la logica de persistencia + // Por ahora solo emitimos el evento + + this.emit('transactionSync', transaction); + + logger.debug('Transaction mapped for sync', { + externalId: transaction.externalId, + type: transaction.type, + amount: transaction.amount, + }); + + // TODO: Implementar verificacion si ya existe + // y decidir si crear o actualizar + return 'created'; + } + + /** + * Inserta o actualiza un contacto en Horux + * TODO: Implementar con el repositorio de contactos + */ + private async upsertContact(contact: HoruxContact): Promise<'created' | 'updated' | 'skipped'> { + this.emit('contactSync', contact); + + logger.debug('Contact mapped for sync', { + externalId: contact.externalId, + type: contact.type, + name: contact.name, + }); + + return 'created'; + } + + /** + * Inserta o actualiza una cuenta en Horux + * TODO: Implementar con el repositorio de cuentas + */ + private async upsertAccount(account: HoruxAccount): Promise<'created' | 'updated' | 'skipped'> { + this.emit('accountSync', account); + + logger.debug('Account mapped for sync', { + externalId: account.externalId, + type: account.type, + code: account.code, + }); + + return 'created'; + } + + // ============================================================================ + // Estado y utilidades + // ============================================================================ + + /** + * Obtiene el estado de la sincronizacion + */ + getStatus(): { + initialized: boolean; + connected: boolean; + syncInProgress: boolean; + lastSyncDate: Date | null; + products: AspelProduct[]; + } { + return { + initialized: this.client !== null, + connected: this.client?.isConnected() || false, + syncInProgress: this.syncInProgress, + lastSyncDate: this.lastSyncDate, + products: this.config.products, + }; + } + + /** + * Obtiene los conectores disponibles + */ + getConnectors(): { + coi: COIConnector | null; + sae: SAEConnector | null; + noi: NOIConnector | null; + banco: BANCOConnector | null; + } { + return { + coi: this.coiConnector, + sae: this.saeConnector, + noi: this.noiConnector, + banco: this.bancoConnector, + }; + } +} + +// ============================================================================ +// Factory function +// ============================================================================ + +export function createAspelSyncService(config: AspelSyncConfig): AspelSyncService { + return new AspelSyncService(config); +} + +/** + * Crea y conecta el servicio de sincronizacion + */ +export async function connectAspelSyncService(config: AspelSyncConfig): Promise { + const service = new AspelSyncService(config); + await service.initialize(); + return service; +} diff --git a/apps/api/src/services/integrations/aspel/aspel.types.ts b/apps/api/src/services/integrations/aspel/aspel.types.ts new file mode 100644 index 0000000..60f9550 --- /dev/null +++ b/apps/api/src/services/integrations/aspel/aspel.types.ts @@ -0,0 +1,1112 @@ +/** + * Aspel Integration Types + * Tipos para la integracion con productos Aspel (COI, SAE, NOI, BANCO) + * Soporta Firebird y SQL Server + */ + +// ============================================================================ +// Configuracion y Conexion +// ============================================================================ + +/** + * Tipo de base de datos soportado por Aspel + */ +export type AspelDatabaseType = 'firebird' | 'sqlserver'; + +/** + * Productos Aspel soportados + */ +export type AspelProduct = 'COI' | 'SAE' | 'NOI' | 'BANCO'; + +/** + * Configuracion de conexion a base de datos Aspel + */ +export interface AspelConfig { + /** Tipo de base de datos */ + databaseType: AspelDatabaseType; + /** Host o IP del servidor */ + host: string; + /** Puerto (3050 para Firebird, 1433 para SQL Server) */ + port: number; + /** Ruta a la base de datos (Firebird) o nombre de la base de datos (SQL Server) */ + database: string; + /** Usuario de base de datos */ + username: string; + /** Contrasena de base de datos */ + password: string; + /** Producto Aspel a conectar */ + product: AspelProduct; + /** Numero de empresa en Aspel (default: 1) */ + empresaId?: number; + /** Encoding de la base de datos (default: latin1) */ + encoding?: 'latin1' | 'utf8' | 'cp1252'; + /** Timeout de conexion en ms (default: 30000) */ + connectionTimeout?: number; + /** Tamano del pool de conexiones (default: 5) */ + poolSize?: number; + /** Prefijo de tablas (algunos Aspel usan prefijos) */ + tablePrefix?: string; +} + +/** + * Estado de la conexion + */ +export interface AspelConnectionState { + /** Esta conectado */ + connected: boolean; + /** Tipo de base de datos detectada */ + databaseType: AspelDatabaseType; + /** Version del producto Aspel detectada */ + productVersion?: string; + /** Ultimo error de conexion */ + lastError?: string; + /** Timestamp de ultima conexion exitosa */ + lastConnectedAt?: Date; + /** Numero de conexiones activas en el pool */ + activeConnections: number; +} + +/** + * Resultado de una query + */ +export interface AspelQueryResult> { + /** Filas retornadas */ + rows: T[]; + /** Numero de filas afectadas (para INSERT/UPDATE/DELETE) */ + rowsAffected?: number; + /** Tiempo de ejecucion en ms */ + executionTime: number; +} + +// ============================================================================ +// Contabilidad (COI) +// ============================================================================ + +/** + * Cuenta contable de COI + */ +export interface CuentaContable { + /** Numero de cuenta */ + numeroCuenta: string; + /** Nombre de la cuenta */ + nombre: string; + /** Tipo de cuenta (A=Acumulativa, D=Detalle) */ + tipo: 'A' | 'D'; + /** Naturaleza (D=Deudora, A=Acreedora) */ + naturaleza: 'D' | 'A'; + /** Nivel en el catalogo */ + nivel: number; + /** Cuenta padre */ + cuentaPadre?: string; + /** Es cuenta de banco */ + esBanco: boolean; + /** Es cuenta de IVA */ + esIva: boolean; + /** Es cuenta de ISR */ + esIsr: boolean; + /** Esta activa */ + activa: boolean; + /** Codigo SAT para la cuenta */ + codigoSat?: string; + /** Fecha de creacion */ + fechaCreacion?: Date; + /** Ultimo movimiento */ + ultimoMovimiento?: Date; +} + +/** + * Poliza contable + */ +export interface Poliza { + /** ID de la poliza */ + id: number; + /** Tipo de poliza (I=Ingreso, E=Egreso, D=Diario) */ + tipo: 'I' | 'E' | 'D' | 'Dr' | 'Ig' | 'Eg' | 'Ch'; + /** Numero de poliza */ + numero: number; + /** Fecha de la poliza */ + fecha: Date; + /** Concepto general */ + concepto: string; + /** Periodo contable */ + periodo: number; + /** Ejercicio fiscal */ + ejercicio: number; + /** Total de cargos */ + totalCargos: number; + /** Total de abonos */ + totalAbonos: number; + /** Esta cuadrada */ + cuadrada: boolean; + /** Referencia */ + referencia?: string; + /** Movimientos de la poliza */ + movimientos: MovimientoContable[]; + /** Fecha de captura */ + fechaCaptura?: Date; + /** Usuario que capturo */ + usuarioCaptura?: string; + /** UUID del CFDI relacionado */ + uuidCfdi?: string; +} + +/** + * Movimiento contable (linea de poliza) + */ +export interface MovimientoContable { + /** Numero de cuenta */ + numeroCuenta: string; + /** Nombre de la cuenta */ + nombreCuenta: string; + /** Concepto del movimiento */ + concepto: string; + /** Monto del cargo */ + cargo: number; + /** Monto del abono */ + abono: number; + /** Numero de cheque (si aplica) */ + numeroCheque?: string; + /** Referencia */ + referencia?: string; + /** UUID del CFDI */ + uuidCfdi?: string; + /** Departamento */ + departamento?: string; + /** Tipo de cambio */ + tipoCambio?: number; + /** Moneda */ + moneda?: string; +} + +/** + * Saldo de cuenta + */ +export interface SaldoCuenta { + /** Numero de cuenta */ + numeroCuenta: string; + /** Nombre de la cuenta */ + nombreCuenta: string; + /** Saldo inicial */ + saldoInicial: number; + /** Total de cargos */ + totalCargos: number; + /** Total de abonos */ + totalAbonos: number; + /** Saldo final */ + saldoFinal: number; + /** Periodo */ + periodo: number; + /** Ejercicio */ + ejercicio: number; +} + +/** + * Balanza de comprobacion + */ +export interface Balanza { + /** Periodo de la balanza */ + periodo: number; + /** Ejercicio fiscal */ + ejercicio: number; + /** Fecha de generacion */ + fechaGeneracion: Date; + /** Cuentas con saldos */ + cuentas: SaldoCuenta[]; + /** Total de cargos */ + totalCargos: number; + /** Total de abonos */ + totalAbonos: number; + /** Esta cuadrada */ + cuadrada: boolean; +} + +/** + * Movimiento auxiliar + */ +export interface MovimientoAuxiliar { + /** Fecha del movimiento */ + fecha: Date; + /** Tipo de poliza */ + tipoPoliza: string; + /** Numero de poliza */ + numeroPoliza: number; + /** Concepto */ + concepto: string; + /** Cargo */ + cargo: number; + /** Abono */ + abono: number; + /** Saldo acumulado */ + saldo: number; + /** Referencia */ + referencia?: string; +} + +// ============================================================================ +// Administracion Empresarial (SAE) +// ============================================================================ + +/** + * Cliente de SAE + */ +export interface Cliente { + /** Clave del cliente */ + clave: string; + /** Nombre o razon social */ + nombre: string; + /** RFC */ + rfc: string; + /** CURP (personas fisicas) */ + curp?: string; + /** Direccion */ + direccion: { + calle: string; + numeroExterior?: string; + numeroInterior?: string; + colonia: string; + codigoPostal: string; + ciudad: string; + estado: string; + pais: string; + }; + /** Telefono */ + telefono?: string; + /** Email */ + email?: string; + /** Limite de credito */ + limiteCredito: number; + /** Dias de credito */ + diasCredito: number; + /** Saldo actual */ + saldo: number; + /** Lista de precios asignada */ + listaPrecio: number; + /** Descuento asignado */ + descuento: number; + /** Esta activo */ + activo: boolean; + /** Regimen fiscal */ + regimenFiscal?: string; + /** Uso CFDI predeterminado */ + usoCfdi?: string; + /** Forma de pago predeterminada */ + formaPago?: string; + /** Metodo de pago predeterminado */ + metodoPago?: string; + /** Cuenta contable */ + cuentaContable?: string; + /** Fecha de alta */ + fechaAlta?: Date; + /** Ultima compra */ + ultimaCompra?: Date; +} + +/** + * Proveedor de SAE + */ +export interface Proveedor { + /** Clave del proveedor */ + clave: string; + /** Nombre o razon social */ + nombre: string; + /** RFC */ + rfc: string; + /** Direccion */ + direccion: { + calle: string; + numeroExterior?: string; + numeroInterior?: string; + colonia: string; + codigoPostal: string; + ciudad: string; + estado: string; + pais: string; + }; + /** Telefono */ + telefono?: string; + /** Email */ + email?: string; + /** Limite de credito */ + limiteCredito: number; + /** Dias de credito */ + diasCredito: number; + /** Saldo actual */ + saldo: number; + /** Descuento */ + descuento: number; + /** Esta activo */ + activo: boolean; + /** Cuenta contable */ + cuentaContable?: string; + /** Fecha de alta */ + fechaAlta?: Date; + /** Ultima compra */ + ultimaCompra?: Date; +} + +/** + * Articulo de inventario + */ +export interface Articulo { + /** Clave del articulo */ + clave: string; + /** Descripcion */ + descripcion: string; + /** Descripcion corta */ + descripcionCorta?: string; + /** Linea de producto */ + linea: string; + /** Unidad de medida */ + unidadMedida: string; + /** Precios por lista */ + precios: { + lista1: number; + lista2: number; + lista3: number; + lista4: number; + lista5: number; + }; + /** Costo promedio */ + costoPromedio: number; + /** Ultimo costo */ + ultimoCosto: number; + /** Existencia actual */ + existencia: number; + /** Punto de reorden */ + puntoReorden: number; + /** Stock maximo */ + stockMaximo: number; + /** IVA aplicable */ + iva: number; + /** IEPS aplicable */ + ieps?: number; + /** Es servicio */ + esServicio: boolean; + /** Es kit */ + esKit: boolean; + /** Esta activo */ + activo: boolean; + /** Clave SAT */ + claveSat?: string; + /** Clave unidad SAT */ + claveUnidadSat?: string; + /** Numero de pedimento */ + numeroPedimento?: string; + /** Codigo de barras */ + codigoBarras?: string; + /** Cuenta contable */ + cuentaContable?: string; + /** Fecha de alta */ + fechaAlta?: Date; + /** Ultima venta */ + ultimaVenta?: Date; + /** Ultima compra */ + ultimaCompra?: Date; +} + +/** + * Factura de venta + */ +export interface Factura { + /** Tipo de documento (F=Factura, R=Remision, P=Pedido) */ + tipoDocumento: 'F' | 'R' | 'P' | 'D' | 'C'; + /** Serie del documento */ + serie: string; + /** Folio del documento */ + folio: number; + /** Fecha de emision */ + fecha: Date; + /** Clave del cliente */ + claveCliente: string; + /** Nombre del cliente */ + nombreCliente: string; + /** RFC del cliente */ + rfcCliente: string; + /** Subtotal */ + subtotal: number; + /** Descuento */ + descuento: number; + /** IVA */ + iva: number; + /** IEPS */ + ieps?: number; + /** Retenciones */ + retenciones?: number; + /** Total */ + total: number; + /** Forma de pago */ + formaPago: string; + /** Metodo de pago */ + metodoPago: string; + /** Condicion de pago */ + condicionPago?: string; + /** Moneda */ + moneda: string; + /** Tipo de cambio */ + tipoCambio: number; + /** UUID del CFDI */ + uuid?: string; + /** Estatus (V=Vigente, C=Cancelada, P=Pendiente) */ + estatus: 'V' | 'C' | 'P'; + /** Saldo pendiente */ + saldoPendiente: number; + /** Fecha de vencimiento */ + fechaVencimiento?: Date; + /** Observaciones */ + observaciones?: string; + /** Vendedor */ + vendedor?: string; + /** Partidas de la factura */ + partidas: PartidaFactura[]; + /** Fecha de pago */ + fechaPago?: Date; +} + +/** + * Partida de factura + */ +export interface PartidaFactura { + /** Numero de partida */ + numeroPartida: number; + /** Clave del articulo */ + claveArticulo: string; + /** Descripcion */ + descripcion: string; + /** Cantidad */ + cantidad: number; + /** Unidad */ + unidad: string; + /** Precio unitario */ + precioUnitario: number; + /** Descuento */ + descuento: number; + /** Importe */ + importe: number; + /** IVA */ + iva: number; + /** IEPS */ + ieps?: number; + /** Clave SAT */ + claveSat?: string; + /** Clave unidad SAT */ + claveUnidadSat?: string; +} + +/** + * Compra + */ +export interface Compra { + /** Tipo de documento */ + tipoDocumento: 'C' | 'O' | 'E' | 'D'; + /** Serie */ + serie: string; + /** Folio */ + folio: number; + /** Fecha */ + fecha: Date; + /** Clave del proveedor */ + claveProveedor: string; + /** Nombre del proveedor */ + nombreProveedor: string; + /** RFC del proveedor */ + rfcProveedor: string; + /** Factura del proveedor */ + facturaProveedor?: string; + /** Subtotal */ + subtotal: number; + /** Descuento */ + descuento: number; + /** IVA */ + iva: number; + /** IEPS */ + ieps?: number; + /** Retenciones */ + retenciones?: number; + /** Total */ + total: number; + /** Moneda */ + moneda: string; + /** Tipo de cambio */ + tipoCambio: number; + /** UUID del CFDI */ + uuid?: string; + /** Estatus */ + estatus: 'V' | 'C' | 'P'; + /** Saldo pendiente */ + saldoPendiente: number; + /** Fecha de vencimiento */ + fechaVencimiento?: Date; + /** Observaciones */ + observaciones?: string; + /** Partidas */ + partidas: PartidaCompra[]; +} + +/** + * Partida de compra + */ +export interface PartidaCompra { + /** Numero de partida */ + numeroPartida: number; + /** Clave del articulo */ + claveArticulo: string; + /** Descripcion */ + descripcion: string; + /** Cantidad */ + cantidad: number; + /** Unidad */ + unidad: string; + /** Costo unitario */ + costoUnitario: number; + /** Descuento */ + descuento: number; + /** Importe */ + importe: number; + /** IVA */ + iva: number; + /** IEPS */ + ieps?: number; +} + +/** + * Cuenta por cobrar + */ +export interface CuentaPorCobrar { + /** ID del documento */ + idDocumento: string; + /** Tipo de documento */ + tipoDocumento: string; + /** Serie */ + serie: string; + /** Folio */ + folio: number; + /** Fecha de documento */ + fechaDocumento: Date; + /** Fecha de vencimiento */ + fechaVencimiento: Date; + /** Clave del cliente */ + claveCliente: string; + /** Nombre del cliente */ + nombreCliente: string; + /** Importe original */ + importeOriginal: number; + /** Saldo actual */ + saldo: number; + /** Moneda */ + moneda: string; + /** Dias vencidos */ + diasVencidos: number; + /** UUID del CFDI */ + uuid?: string; +} + +/** + * Cuenta por pagar + */ +export interface CuentaPorPagar { + /** ID del documento */ + idDocumento: string; + /** Tipo de documento */ + tipoDocumento: string; + /** Serie */ + serie: string; + /** Folio */ + folio: number; + /** Fecha de documento */ + fechaDocumento: Date; + /** Fecha de vencimiento */ + fechaVencimiento: Date; + /** Clave del proveedor */ + claveProveedor: string; + /** Nombre del proveedor */ + nombreProveedor: string; + /** Factura del proveedor */ + facturaProveedor?: string; + /** Importe original */ + importeOriginal: number; + /** Saldo actual */ + saldo: number; + /** Moneda */ + moneda: string; + /** Dias vencidos */ + diasVencidos: number; + /** UUID del CFDI */ + uuid?: string; +} + +// ============================================================================ +// Nominas (NOI) +// ============================================================================ + +/** + * Empleado de NOI + */ +export interface Empleado { + /** Numero de empleado */ + numeroEmpleado: string; + /** Nombre completo */ + nombreCompleto: string; + /** Nombre */ + nombre: string; + /** Apellido paterno */ + apellidoPaterno: string; + /** Apellido materno */ + apellidoMaterno?: string; + /** RFC */ + rfc: string; + /** CURP */ + curp: string; + /** Numero de seguro social */ + nss?: string; + /** Fecha de nacimiento */ + fechaNacimiento?: Date; + /** Fecha de alta */ + fechaAlta: Date; + /** Fecha de baja */ + fechaBaja?: Date; + /** Tipo de contrato */ + tipoContrato: string; + /** Departamento */ + departamento?: string; + /** Puesto */ + puesto?: string; + /** Sueldo diario */ + sueldoDiario: number; + /** Salario diario integrado */ + salarioDiarioIntegrado: number; + /** Tipo de jornada */ + tipoJornada?: string; + /** Regimen de contratacion */ + regimenContratacion: string; + /** Riesgo de trabajo */ + riesgoTrabajo?: string; + /** Periodicidad de pago */ + periodicidadPago: string; + /** Banco */ + banco?: string; + /** Numero de cuenta */ + numeroCuenta?: string; + /** CLABE */ + clabe?: string; + /** Estado del empleado */ + estatus: 'A' | 'B' | 'S'; + /** Registro patronal */ + registroPatronal?: string; + /** Entidad federativa */ + entidadFederativa?: string; + /** Codigo postal */ + codigoPostal?: string; +} + +/** + * Nomina + */ +export interface Nomina { + /** ID de la nomina */ + id: number; + /** Periodo de pago */ + periodo: number; + /** Ejercicio */ + ejercicio: number; + /** Tipo de nomina (O=Ordinaria, E=Extraordinaria) */ + tipoNomina: 'O' | 'E'; + /** Fecha de pago */ + fechaPago: Date; + /** Fecha inicial del periodo */ + fechaInicial: Date; + /** Fecha final del periodo */ + fechaFinal: Date; + /** Numero de dias pagados */ + diasPagados: number; + /** Total de percepciones */ + totalPercepciones: number; + /** Total de deducciones */ + totalDeducciones: number; + /** Total de otros pagos */ + totalOtrosPagos: number; + /** Total neto */ + totalNeto: number; + /** Numero de empleados */ + numeroEmpleados: number; + /** Estado */ + estatus: 'P' | 'T' | 'C'; + /** Descripcion */ + descripcion?: string; +} + +/** + * Recibo de nomina + */ +export interface ReciboNomina { + /** ID del recibo */ + id: number; + /** ID de la nomina */ + idNomina: number; + /** Numero de empleado */ + numeroEmpleado: string; + /** Nombre del empleado */ + nombreEmpleado: string; + /** RFC del empleado */ + rfcEmpleado: string; + /** CURP del empleado */ + curpEmpleado: string; + /** Fecha de pago */ + fechaPago: Date; + /** Fecha inicial */ + fechaInicial: Date; + /** Fecha final */ + fechaFinal: Date; + /** Dias pagados */ + diasPagados: number; + /** Total percepciones */ + totalPercepciones: number; + /** Total deducciones */ + totalDeducciones: number; + /** Total otros pagos */ + totalOtrosPagos: number; + /** Neto a pagar */ + netoAPagar: number; + /** Percepciones */ + percepciones: MovimientoNomina[]; + /** Deducciones */ + deducciones: MovimientoNomina[]; + /** Otros pagos */ + otrosPagos: MovimientoNomina[]; + /** UUID del CFDI */ + uuid?: string; + /** Estatus del timbrado */ + estatusTimbrado?: 'P' | 'T' | 'E'; +} + +/** + * Movimiento de nomina + */ +export interface MovimientoNomina { + /** Tipo de movimiento */ + tipo: 'P' | 'D' | 'O'; + /** Clave del concepto */ + clave: string; + /** Descripcion del concepto */ + descripcion: string; + /** Importe gravado */ + importeGravado: number; + /** Importe exento */ + importeExento: number; + /** Importe total */ + importe: number; +} + +// ============================================================================ +// Banco (BANCO) +// ============================================================================ + +/** + * Cuenta bancaria + */ +export interface CuentaBancaria { + /** Clave de la cuenta */ + clave: string; + /** Nombre de la cuenta */ + nombre: string; + /** Numero de cuenta */ + numeroCuenta: string; + /** CLABE */ + clabe?: string; + /** Banco */ + banco: string; + /** Tipo de cuenta */ + tipoCuenta: 'C' | 'A' | 'I'; + /** Moneda */ + moneda: string; + /** Saldo actual */ + saldo: number; + /** Saldo disponible */ + saldoDisponible: number; + /** Cuenta contable */ + cuentaContable?: string; + /** RFC del titular */ + rfcTitular?: string; + /** Nombre del titular */ + nombreTitular?: string; + /** Esta activa */ + activa: boolean; + /** Fecha de apertura */ + fechaApertura?: Date; +} + +/** + * Movimiento bancario + */ +export interface MovimientoBancario { + /** ID del movimiento */ + id: number; + /** Clave de la cuenta */ + claveCuenta: string; + /** Fecha del movimiento */ + fecha: Date; + /** Tipo (D=Deposito, R=Retiro, T=Transferencia) */ + tipo: 'D' | 'R' | 'T' | 'C' | 'I'; + /** Numero de referencia */ + referencia?: string; + /** Numero de cheque */ + numeroCheque?: string; + /** Beneficiario */ + beneficiario?: string; + /** Concepto */ + concepto: string; + /** Deposito */ + deposito: number; + /** Retiro */ + retiro: number; + /** Saldo */ + saldo: number; + /** Esta conciliado */ + conciliado: boolean; + /** Fecha de conciliacion */ + fechaConciliacion?: Date; + /** Numero de poliza */ + numeroPoliza?: number; + /** Tipo de poliza */ + tipoPoliza?: string; + /** UUID del CFDI */ + uuid?: string; +} + +/** + * Conciliacion bancaria + */ +export interface ConciliacionBancaria { + /** ID de la conciliacion */ + id: number; + /** Clave de la cuenta */ + claveCuenta: string; + /** Periodo */ + periodo: number; + /** Ejercicio */ + ejercicio: number; + /** Fecha de corte */ + fechaCorte: Date; + /** Saldo segun libros */ + saldoLibros: number; + /** Saldo segun banco */ + saldoBanco: number; + /** Depositos en transito */ + depositosTransito: number; + /** Cheques en transito */ + chequesTransito: number; + /** Cargos no registrados */ + cargosNoRegistrados: number; + /** Abonos no registrados */ + abonosNoRegistrados: number; + /** Diferencia */ + diferencia: number; + /** Esta cuadrada */ + cuadrada: boolean; + /** Fecha de elaboracion */ + fechaElaboracion: Date; + /** Movimientos pendientes */ + movimientosPendientes: MovimientoBancario[]; +} + +// ============================================================================ +// Sincronizacion +// ============================================================================ + +/** + * Configuracion de sincronizacion + */ +export interface AspelSyncConfig { + /** Tenant ID de Horux */ + tenantId: string; + /** Configuracion de conexion */ + connection: AspelConfig; + /** Productos a sincronizar */ + products: AspelProduct[]; + /** Sincronizar solo cambios desde ultima fecha */ + incremental: boolean; + /** Fecha de ultima sincronizacion */ + lastSyncDate?: Date; + /** Mapeo de cuentas contables */ + accountMapping?: Record; + /** Mapeo de clientes */ + clientMapping?: Record; + /** Mapeo de proveedores */ + vendorMapping?: Record; +} + +/** + * Resultado de sincronizacion + */ +export interface AspelSyncResult { + /** Fue exitoso */ + success: boolean; + /** Producto sincronizado */ + product: AspelProduct; + /** Fecha de inicio */ + startTime: Date; + /** Fecha de fin */ + endTime: Date; + /** Duracion en ms */ + duration: number; + /** Registros procesados */ + processed: { + cuentas?: number; + polizas?: number; + movimientos?: number; + clientes?: number; + proveedores?: number; + articulos?: number; + facturas?: number; + compras?: number; + empleados?: number; + nominas?: number; + cuentasBancarias?: number; + movimientosBancarios?: number; + }; + /** Registros creados */ + created: number; + /** Registros actualizados */ + updated: number; + /** Registros omitidos */ + skipped: number; + /** Errores */ + errors: AspelSyncError[]; +} + +/** + * Error de sincronizacion + */ +export interface AspelSyncError { + /** Tipo de registro */ + recordType: string; + /** ID del registro */ + recordId: string; + /** Mensaje de error */ + message: string; + /** Stack trace */ + stack?: string; + /** Timestamp */ + timestamp: Date; +} + +// ============================================================================ +// Filtros de Consulta +// ============================================================================ + +/** + * Periodo de consulta + */ +export interface PeriodoConsulta { + /** Periodo (1-13) */ + periodo: number; + /** Ejercicio fiscal */ + ejercicio: number; +} + +/** + * Rango de fechas + */ +export interface RangoFechas { + /** Fecha inicial */ + fechaInicial: Date; + /** Fecha final */ + fechaFinal: Date; +} + +/** + * Opciones de paginacion + */ +export interface PaginacionAspel { + /** Pagina actual (1-based) */ + pagina: number; + /** Registros por pagina */ + porPagina: number; + /** Campo para ordenar */ + ordenarPor?: string; + /** Direccion del ordenamiento */ + direccion?: 'ASC' | 'DESC'; +} + +/** + * Resultado paginado + */ +export interface ResultadoPaginado { + /** Datos */ + data: T[]; + /** Total de registros */ + total: number; + /** Pagina actual */ + pagina: number; + /** Registros por pagina */ + porPagina: number; + /** Total de paginas */ + totalPaginas: number; +} + +// ============================================================================ +// Errores +// ============================================================================ + +/** + * Error de Aspel + */ +export class AspelError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly product?: AspelProduct, + public readonly details?: Record + ) { + super(message); + this.name = 'AspelError'; + Object.setPrototypeOf(this, AspelError.prototype); + } +} + +/** + * Error de conexion a Aspel + */ +export class AspelConnectionError extends AspelError { + constructor(message: string, details?: Record) { + super(message, 'CONNECTION_ERROR', undefined, details); + this.name = 'AspelConnectionError'; + Object.setPrototypeOf(this, AspelConnectionError.prototype); + } +} + +/** + * Error de consulta + */ +export class AspelQueryError extends AspelError { + constructor( + message: string, + public readonly query?: string, + details?: Record + ) { + super(message, 'QUERY_ERROR', undefined, details); + this.name = 'AspelQueryError'; + Object.setPrototypeOf(this, AspelQueryError.prototype); + } +} + +/** + * Error de encoding + */ +export class AspelEncodingError extends AspelError { + constructor(message: string, details?: Record) { + super(message, 'ENCODING_ERROR', undefined, details); + this.name = 'AspelEncodingError'; + Object.setPrototypeOf(this, AspelEncodingError.prototype); + } +} + +/** + * Error de validacion + */ +export class AspelValidationError extends AspelError { + constructor(message: string, details?: Record) { + super(message, 'VALIDATION_ERROR', undefined, details); + this.name = 'AspelValidationError'; + Object.setPrototypeOf(this, AspelValidationError.prototype); + } +} diff --git a/apps/api/src/services/integrations/aspel/banco.connector.ts b/apps/api/src/services/integrations/aspel/banco.connector.ts new file mode 100644 index 0000000..4d8b880 --- /dev/null +++ b/apps/api/src/services/integrations/aspel/banco.connector.ts @@ -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 { + 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>(sql, params); + + return result.rows.map(row => this.mapToCuentaBancaria(row)); + } + + /** + * Obtiene una cuenta bancaria por clave + */ + async getCuentaBancaria(clave: string): Promise { + 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>(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 { + 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>(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> { + 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>(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 { + 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>(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 { + 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>(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 { + 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>(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 { + // 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>(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> { + 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>(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> { + 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>(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> { + 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): 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): 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): 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); +} diff --git a/apps/api/src/services/integrations/aspel/coi.connector.ts b/apps/api/src/services/integrations/aspel/coi.connector.ts new file mode 100644 index 0000000..9677c50 --- /dev/null +++ b/apps/api/src/services/integrations/aspel/coi.connector.ts @@ -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 { + 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>(sql, params); + + return result.rows.map(row => this.mapToCuentaContable(row)); + } + + /** + * Obtiene una cuenta contable por numero + */ + async getCuenta(numeroCuenta: string): Promise { + 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>(sql, [numeroCuenta]); + + return row ? this.mapToCuentaContable(row) : null; + } + + /** + * Obtiene las subcuentas de una cuenta + */ + async getSubcuentas(cuentaPadre: string): Promise { + 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>(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 { + 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>(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 { + 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>(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 { + 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>(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 { + 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>(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 { + 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>(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 { + // 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(); + 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 { + 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>(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 { + 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>(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 { + 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> { + // 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>(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> { + // 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>(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): 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): 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): 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, 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); +} diff --git a/apps/api/src/services/integrations/aspel/index.ts b/apps/api/src/services/integrations/aspel/index.ts new file mode 100644 index 0000000..b66e628 --- /dev/null +++ b/apps/api/src/services/integrations/aspel/index.ts @@ -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'; diff --git a/apps/api/src/services/integrations/aspel/noi.connector.ts b/apps/api/src/services/integrations/aspel/noi.connector.ts new file mode 100644 index 0000000..66fa8d4 --- /dev/null +++ b/apps/api/src/services/integrations/aspel/noi.connector.ts @@ -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 { + 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>(sql, params); + + return result.rows.map(row => this.mapToEmpleado(row)); + } + + /** + * Obtiene un empleado por numero + */ + async getEmpleado(numeroEmpleado: string): Promise { + 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>(sql, [numeroEmpleado]); + + return row ? this.mapToEmpleado(row) : null; + } + + /** + * Busca empleados por nombre o RFC + */ + async buscarEmpleados(termino: string, limite: number = 50): Promise { + 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>(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> { + 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>(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 { + 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>(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 { + 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>(sql, [ + periodo.periodo, + periodo.ejercicio, + ]); + + return result.rows.map(row => this.mapToNomina(row)); + } + + /** + * Obtiene nominas por rango de fechas + */ + async getNominasByFecha(rango: RangoFechas): Promise { + 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>(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 { + 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>(sql, [idNomina]); + + return row ? this.mapToNomina(row) : null; + } + + /** + * Obtiene los recibos de una nomina + */ + async getRecibosNomina( + idNomina: number, + options?: { conMovimientos?: boolean } + ): Promise { + 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>(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 { + 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>(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 { + 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>(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 { + 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>(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): 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): 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): 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): 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); +} diff --git a/apps/api/src/services/integrations/aspel/sae.connector.ts b/apps/api/src/services/integrations/aspel/sae.connector.ts new file mode 100644 index 0000000..562c59a --- /dev/null +++ b/apps/api/src/services/integrations/aspel/sae.connector.ts @@ -0,0 +1,1210 @@ +/** + * SAE Connector - Sistema Administrativo Empresarial Aspel + * Conector para extraer datos administrativos de Aspel SAE + */ + +import { AspelClient } from './aspel.client.js'; +import { + Cliente, + Proveedor, + Articulo, + Factura, + PartidaFactura, + Compra, + PartidaCompra, + CuentaPorCobrar, + CuentaPorPagar, + RangoFechas, + PaginacionAspel, + ResultadoPaginado, +} from './aspel.types.js'; +import { logger } from '../../../utils/logger.js'; + +// ============================================================================ +// Nombres de tablas SAE +// ============================================================================ + +const SAE_TABLES = { + CLIENTES: 'CLIE01', + PROVEEDORES: 'PROV01', + ARTICULOS: 'INVE01', + FACTURAS: 'FACF01', + FACTURAS_PARTIDAS: 'PAR_FACF01', + COMPRAS: 'COMPC01', + COMPRAS_PARTIDAS: 'PAR_COMPC01', + CUENTAS_COBRAR: 'CXCF01', + CUENTAS_PAGAR: 'CXPF01', + EXISTENCIAS: 'EXIS01', + PEDIDOS: 'PEDI01', + PEDIDOS_PARTIDAS: 'PAR_PEDI01', + REMISIONES: 'REMI01', + REMISIONES_PARTIDAS: 'PAR_REMI01', + VENDEDORES: 'VEND01', + ALMACENES: 'ALMA01', + LINEAS: 'LINE01', + MONEDAS: 'MONE01', +}; + +// ============================================================================ +// Clase del conector SAE +// ============================================================================ + +export class SAEConnector { + private client: AspelClient; + private empresaId: number; + + constructor(client: AspelClient, empresaId: number = 1) { + this.client = client; + this.empresaId = empresaId; + } + + // ============================================================================ + // Clientes + // ============================================================================ + + /** + * Obtiene todos los clientes + */ + async getClientes(options?: { + soloActivos?: boolean; + conSaldo?: boolean; + }): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (options?.soloActivos !== false) { + conditions.push('STATUS = ?'); + params.push('A'); + } + + if (options?.conSaldo) { + conditions.push('SALDO > 0'); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const sql = ` + SELECT + CVE_CLPV, + NOMBRE, + RFC, + CURP, + CALLE, + NUMEXT, + NUMINT, + COLONIA, + CODIGO, + CIUDAD, + ESTADO, + PAIS, + TELEFONO, + EMAIL, + LIM_CREDITO, + DIAS_CRED, + SALDO, + LISTA_PREC, + DESCUENTO, + STATUS, + REG_FISC, + USO_CFDI, + FORMA_PAGO, + METODO_PAGO, + CTA_CONTABLE, + FECHA_ALTA, + ULT_COMPRA + FROM ${this.getTableName(SAE_TABLES.CLIENTES)} + ${whereClause} + ORDER BY NOMBRE + `; + + const result = await this.client.query>(sql, params); + + return result.rows.map(row => this.mapToCliente(row)); + } + + /** + * Obtiene un cliente por clave + */ + async getCliente(clave: string): Promise { + const sql = ` + SELECT + CVE_CLPV, + NOMBRE, + RFC, + CURP, + CALLE, + NUMEXT, + NUMINT, + COLONIA, + CODIGO, + CIUDAD, + ESTADO, + PAIS, + TELEFONO, + EMAIL, + LIM_CREDITO, + DIAS_CRED, + SALDO, + LISTA_PREC, + DESCUENTO, + STATUS, + REG_FISC, + USO_CFDI, + FORMA_PAGO, + METODO_PAGO, + CTA_CONTABLE, + FECHA_ALTA, + ULT_COMPRA + FROM ${this.getTableName(SAE_TABLES.CLIENTES)} + WHERE CVE_CLPV = ? + `; + + const row = await this.client.queryOne>(sql, [clave]); + + return row ? this.mapToCliente(row) : null; + } + + /** + * Busca clientes por nombre o RFC + */ + async buscarClientes(termino: string, limite: number = 50): Promise { + const sql = ` + SELECT + CVE_CLPV, + NOMBRE, + RFC, + CURP, + CALLE, + NUMEXT, + NUMINT, + COLONIA, + CODIGO, + CIUDAD, + ESTADO, + PAIS, + TELEFONO, + EMAIL, + LIM_CREDITO, + DIAS_CRED, + SALDO, + LISTA_PREC, + DESCUENTO, + STATUS, + REG_FISC, + USO_CFDI, + FORMA_PAGO, + METODO_PAGO, + CTA_CONTABLE, + FECHA_ALTA, + ULT_COMPRA + FROM ${this.getTableName(SAE_TABLES.CLIENTES)} + WHERE (NOMBRE LIKE ? OR RFC LIKE ? OR CVE_CLPV LIKE ?) AND STATUS = 'A' + ORDER BY NOMBRE + ${this.getLimitClause(limite, 0)} + `; + + const searchTerm = `%${termino}%`; + const result = await this.client.query>(sql, [ + searchTerm, + searchTerm, + searchTerm, + ]); + + return result.rows.map(row => this.mapToCliente(row)); + } + + // ============================================================================ + // Proveedores + // ============================================================================ + + /** + * Obtiene todos los proveedores + */ + async getProveedores(options?: { + soloActivos?: boolean; + conSaldo?: boolean; + }): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (options?.soloActivos !== false) { + conditions.push('STATUS = ?'); + params.push('A'); + } + + if (options?.conSaldo) { + conditions.push('SALDO > 0'); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const sql = ` + SELECT + CVE_CLPV, + NOMBRE, + RFC, + CALLE, + NUMEXT, + NUMINT, + COLONIA, + CODIGO, + CIUDAD, + ESTADO, + PAIS, + TELEFONO, + EMAIL, + LIM_CREDITO, + DIAS_CRED, + SALDO, + DESCUENTO, + STATUS, + CTA_CONTABLE, + FECHA_ALTA, + ULT_COMPRA + FROM ${this.getTableName(SAE_TABLES.PROVEEDORES)} + ${whereClause} + ORDER BY NOMBRE + `; + + const result = await this.client.query>(sql, params); + + return result.rows.map(row => this.mapToProveedor(row)); + } + + /** + * Obtiene un proveedor por clave + */ + async getProveedor(clave: string): Promise { + const sql = ` + SELECT + CVE_CLPV, + NOMBRE, + RFC, + CALLE, + NUMEXT, + NUMINT, + COLONIA, + CODIGO, + CIUDAD, + ESTADO, + PAIS, + TELEFONO, + EMAIL, + LIM_CREDITO, + DIAS_CRED, + SALDO, + DESCUENTO, + STATUS, + CTA_CONTABLE, + FECHA_ALTA, + ULT_COMPRA + FROM ${this.getTableName(SAE_TABLES.PROVEEDORES)} + WHERE CVE_CLPV = ? + `; + + const row = await this.client.queryOne>(sql, [clave]); + + return row ? this.mapToProveedor(row) : null; + } + + // ============================================================================ + // Inventario / Articulos + // ============================================================================ + + /** + * Obtiene el inventario completo + */ + async getInventario(options?: { + soloActivos?: boolean; + conExistencia?: boolean; + linea?: string; + }): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (options?.soloActivos !== false) { + conditions.push('STATUS = ?'); + params.push('A'); + } + + if (options?.conExistencia) { + conditions.push('EXIST > 0'); + } + + if (options?.linea) { + conditions.push('LIN_PROD = ?'); + params.push(options.linea); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const sql = ` + SELECT + CVE_ART, + DESCR, + DESCR_CORTA, + LIN_PROD, + UNI_MED, + PRECIO1, + PRECIO2, + PRECIO3, + PRECIO4, + PRECIO5, + COST_PROM, + ULT_COSTO, + EXIST, + PTO_REORD, + STOCK_MAX, + TASA_IVA, + TASA_IEPS, + ES_SERV, + ES_KIT, + STATUS, + CVE_SAT, + UNI_SAT, + NUM_PEDIM, + COD_BARRAS, + CTA_CONTABLE, + FECHA_ALTA, + ULT_VTA, + ULT_COMPRA + FROM ${this.getTableName(SAE_TABLES.ARTICULOS)} + ${whereClause} + ORDER BY DESCR + `; + + const result = await this.client.query>(sql, params); + + return result.rows.map(row => this.mapToArticulo(row)); + } + + /** + * Obtiene un articulo por clave + */ + async getArticulo(clave: string): Promise { + const sql = ` + SELECT + CVE_ART, + DESCR, + DESCR_CORTA, + LIN_PROD, + UNI_MED, + PRECIO1, + PRECIO2, + PRECIO3, + PRECIO4, + PRECIO5, + COST_PROM, + ULT_COSTO, + EXIST, + PTO_REORD, + STOCK_MAX, + TASA_IVA, + TASA_IEPS, + ES_SERV, + ES_KIT, + STATUS, + CVE_SAT, + UNI_SAT, + NUM_PEDIM, + COD_BARRAS, + CTA_CONTABLE, + FECHA_ALTA, + ULT_VTA, + ULT_COMPRA + FROM ${this.getTableName(SAE_TABLES.ARTICULOS)} + WHERE CVE_ART = ? + `; + + const row = await this.client.queryOne>(sql, [clave]); + + return row ? this.mapToArticulo(row) : null; + } + + /** + * Busca articulos por descripcion o clave + */ + async buscarArticulos(termino: string, limite: number = 50): Promise { + const sql = ` + SELECT + CVE_ART, + DESCR, + DESCR_CORTA, + LIN_PROD, + UNI_MED, + PRECIO1, + PRECIO2, + PRECIO3, + PRECIO4, + PRECIO5, + COST_PROM, + ULT_COSTO, + EXIST, + PTO_REORD, + STOCK_MAX, + TASA_IVA, + TASA_IEPS, + ES_SERV, + ES_KIT, + STATUS, + CVE_SAT, + UNI_SAT, + NUM_PEDIM, + COD_BARRAS, + CTA_CONTABLE, + FECHA_ALTA, + ULT_VTA, + ULT_COMPRA + FROM ${this.getTableName(SAE_TABLES.ARTICULOS)} + WHERE (DESCR LIKE ? OR CVE_ART LIKE ? OR COD_BARRAS LIKE ?) AND STATUS = 'A' + ORDER BY DESCR + ${this.getLimitClause(limite, 0)} + `; + + const searchTerm = `%${termino}%`; + const result = await this.client.query>(sql, [ + searchTerm, + searchTerm, + searchTerm, + ]); + + return result.rows.map(row => this.mapToArticulo(row)); + } + + // ============================================================================ + // Facturas + // ============================================================================ + + /** + * Obtiene facturas por periodo + */ + async getFacturas( + rango: RangoFechas, + options?: { + estatus?: 'V' | 'C' | 'P'; + cliente?: string; + conPartidas?: boolean; + } + ): Promise { + const conditions = ['FECHA_DOC >= ?', 'FECHA_DOC <= ?']; + const params: unknown[] = [ + AspelClient.formatAspelDate(rango.fechaInicial), + AspelClient.formatAspelDate(rango.fechaFinal), + ]; + + if (options?.estatus) { + conditions.push('STATUS = ?'); + params.push(options.estatus); + } + + if (options?.cliente) { + conditions.push('CVE_CLPV = ?'); + params.push(options.cliente); + } + + const sql = ` + SELECT + TIP_DOC, + CVE_DOC, + SER_DOC, + FOLIO, + FECHA_DOC, + CVE_CLPV, + NOMBRE, + RFC, + SUBTOTAL, + DESCUENTO, + IVA, + IEPS, + RETENCIONES, + TOTAL, + FORMA_PAGO, + METODO_PAGO, + COND_PAGO, + MONEDA, + TIPO_CAMBIO, + UUID, + STATUS, + SALDO, + FECHA_VENC, + OBSERVACIONES, + VENDEDOR, + FECHA_PAGO + FROM ${this.getTableName(SAE_TABLES.FACTURAS)} + WHERE ${conditions.join(' AND ')} + ORDER BY FECHA_DOC, FOLIO + `; + + const result = await this.client.query>(sql, params); + + const facturas: Factura[] = []; + + for (const row of result.rows) { + const factura = this.mapToFactura(row); + + if (options?.conPartidas !== false) { + factura.partidas = await this.getPartidasFactura( + String(row.SER_DOC || ''), + Number(row.FOLIO) + ); + } + + facturas.push(factura); + } + + return facturas; + } + + /** + * Obtiene facturas paginadas + */ + async getFacturasPaginadas( + rango: RangoFechas, + paginacion: PaginacionAspel, + options?: { + estatus?: 'V' | 'C' | 'P'; + cliente?: string; + } + ): Promise> { + const conditions = ['FECHA_DOC >= ?', 'FECHA_DOC <= ?']; + const params: unknown[] = [ + AspelClient.formatAspelDate(rango.fechaInicial), + AspelClient.formatAspelDate(rango.fechaFinal), + ]; + + if (options?.estatus) { + conditions.push('STATUS = ?'); + params.push(options.estatus); + } + + if (options?.cliente) { + conditions.push('CVE_CLPV = ?'); + params.push(options.cliente); + } + + // Contar total + const countSql = ` + SELECT COUNT(*) AS total + FROM ${this.getTableName(SAE_TABLES.FACTURAS)} + 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 + TIP_DOC, + CVE_DOC, + SER_DOC, + FOLIO, + FECHA_DOC, + CVE_CLPV, + NOMBRE, + RFC, + SUBTOTAL, + DESCUENTO, + IVA, + IEPS, + RETENCIONES, + TOTAL, + FORMA_PAGO, + METODO_PAGO, + COND_PAGO, + MONEDA, + TIPO_CAMBIO, + UUID, + STATUS, + SALDO, + FECHA_VENC, + OBSERVACIONES, + VENDEDOR, + FECHA_PAGO + FROM ${this.getTableName(SAE_TABLES.FACTURAS)} + WHERE ${conditions.join(' AND ')} + ORDER BY FECHA_DOC DESC, FOLIO DESC + ${this.getLimitClause(porPagina, offset)} + `; + + const result = await this.client.query>(sql, params); + + const facturas = result.rows.map(row => this.mapToFactura(row)); + + return { + data: facturas, + total, + pagina, + porPagina, + totalPaginas: Math.ceil(total / porPagina), + }; + } + + /** + * Obtiene las partidas de una factura + */ + async getPartidasFactura(serie: string, folio: number): Promise { + const sql = ` + SELECT + NUM_PAR, + CVE_ART, + DESCR, + CANT, + UNIDAD, + PREC_UNIT, + DESCTO, + IMPORTE, + IVA, + IEPS, + CVE_SAT, + UNI_SAT + FROM ${this.getTableName(SAE_TABLES.FACTURAS_PARTIDAS)} + WHERE SER_DOC = ? AND FOLIO = ? + ORDER BY NUM_PAR + `; + + const result = await this.client.query>(sql, [serie, folio]); + + return result.rows.map(row => this.mapToPartidaFactura(row)); + } + + // ============================================================================ + // Compras + // ============================================================================ + + /** + * Obtiene compras por periodo + */ + async getCompras( + rango: RangoFechas, + options?: { + estatus?: 'V' | 'C' | 'P'; + proveedor?: string; + conPartidas?: boolean; + } + ): Promise { + const conditions = ['FECHA_DOC >= ?', 'FECHA_DOC <= ?']; + const params: unknown[] = [ + AspelClient.formatAspelDate(rango.fechaInicial), + AspelClient.formatAspelDate(rango.fechaFinal), + ]; + + if (options?.estatus) { + conditions.push('STATUS = ?'); + params.push(options.estatus); + } + + if (options?.proveedor) { + conditions.push('CVE_CLPV = ?'); + params.push(options.proveedor); + } + + const sql = ` + SELECT + TIP_DOC, + SER_DOC, + FOLIO, + FECHA_DOC, + CVE_CLPV, + NOMBRE, + RFC, + FACT_PROV, + SUBTOTAL, + DESCUENTO, + IVA, + IEPS, + RETENCIONES, + TOTAL, + MONEDA, + TIPO_CAMBIO, + UUID, + STATUS, + SALDO, + FECHA_VENC, + OBSERVACIONES + FROM ${this.getTableName(SAE_TABLES.COMPRAS)} + WHERE ${conditions.join(' AND ')} + ORDER BY FECHA_DOC, FOLIO + `; + + const result = await this.client.query>(sql, params); + + const compras: Compra[] = []; + + for (const row of result.rows) { + const compra = this.mapToCompra(row); + + if (options?.conPartidas !== false) { + compra.partidas = await this.getPartidasCompra( + String(row.SER_DOC || ''), + Number(row.FOLIO) + ); + } + + compras.push(compra); + } + + return compras; + } + + /** + * Obtiene las partidas de una compra + */ + async getPartidasCompra(serie: string, folio: number): Promise { + const sql = ` + SELECT + NUM_PAR, + CVE_ART, + DESCR, + CANT, + UNIDAD, + COSTO_UNIT, + DESCTO, + IMPORTE, + IVA, + IEPS + FROM ${this.getTableName(SAE_TABLES.COMPRAS_PARTIDAS)} + WHERE SER_DOC = ? AND FOLIO = ? + ORDER BY NUM_PAR + `; + + const result = await this.client.query>(sql, [serie, folio]); + + return result.rows.map(row => this.mapToPartidaCompra(row)); + } + + // ============================================================================ + // Cuentas por Cobrar + // ============================================================================ + + /** + * Obtiene cuentas por cobrar + */ + async getCuentasPorCobrar(options?: { + cliente?: string; + soloVencidas?: boolean; + fechaCorte?: Date; + }): Promise { + const conditions = ['SALDO > 0']; + const params: unknown[] = []; + + if (options?.cliente) { + conditions.push('CVE_CLPV = ?'); + params.push(options.cliente); + } + + if (options?.soloVencidas && options?.fechaCorte) { + conditions.push('FECHA_VENC < ?'); + params.push(AspelClient.formatAspelDate(options.fechaCorte)); + } + + const sql = ` + SELECT + CVE_DOC, + TIP_DOC, + SER_DOC, + FOLIO, + FECHA_DOC, + FECHA_VENC, + CVE_CLPV, + NOMBRE, + IMPORTE, + SALDO, + MONEDA, + UUID + FROM ${this.getTableName(SAE_TABLES.CUENTAS_COBRAR)} + WHERE ${conditions.join(' AND ')} + ORDER BY FECHA_VENC, CVE_CLPV + `; + + const result = await this.client.query>(sql, params); + const fechaCorte = options?.fechaCorte || new Date(); + + return result.rows.map(row => this.mapToCuentaPorCobrar(row, fechaCorte)); + } + + /** + * Obtiene el total de cuentas por cobrar + */ + async getTotalCuentasPorCobrar(): Promise<{ total: number; vencido: number; porVencer: number }> { + const hoy = new Date(); + + const sql = ` + SELECT + SUM(SALDO) AS total, + SUM(CASE WHEN FECHA_VENC < ? THEN SALDO ELSE 0 END) AS vencido, + SUM(CASE WHEN FECHA_VENC >= ? THEN SALDO ELSE 0 END) AS por_vencer + FROM ${this.getTableName(SAE_TABLES.CUENTAS_COBRAR)} + WHERE SALDO > 0 + `; + + const result = await this.client.queryOne<{ + total: number; + vencido: number; + por_vencer: number; + }>(sql, [AspelClient.formatAspelDate(hoy), AspelClient.formatAspelDate(hoy)]); + + return { + total: result?.total || 0, + vencido: result?.vencido || 0, + porVencer: result?.por_vencer || 0, + }; + } + + // ============================================================================ + // Cuentas por Pagar + // ============================================================================ + + /** + * Obtiene cuentas por pagar + */ + async getCuentasPorPagar(options?: { + proveedor?: string; + soloVencidas?: boolean; + fechaCorte?: Date; + }): Promise { + const conditions = ['SALDO > 0']; + const params: unknown[] = []; + + if (options?.proveedor) { + conditions.push('CVE_CLPV = ?'); + params.push(options.proveedor); + } + + if (options?.soloVencidas && options?.fechaCorte) { + conditions.push('FECHA_VENC < ?'); + params.push(AspelClient.formatAspelDate(options.fechaCorte)); + } + + const sql = ` + SELECT + CVE_DOC, + TIP_DOC, + SER_DOC, + FOLIO, + FECHA_DOC, + FECHA_VENC, + CVE_CLPV, + NOMBRE, + FACT_PROV, + IMPORTE, + SALDO, + MONEDA, + UUID + FROM ${this.getTableName(SAE_TABLES.CUENTAS_PAGAR)} + WHERE ${conditions.join(' AND ')} + ORDER BY FECHA_VENC, CVE_CLPV + `; + + const result = await this.client.query>(sql, params); + const fechaCorte = options?.fechaCorte || new Date(); + + return result.rows.map(row => this.mapToCuentaPorPagar(row, fechaCorte)); + } + + /** + * Obtiene el total de cuentas por pagar + */ + async getTotalCuentasPorPagar(): Promise<{ total: number; vencido: number; porVencer: number }> { + const hoy = new Date(); + + const sql = ` + SELECT + SUM(SALDO) AS total, + SUM(CASE WHEN FECHA_VENC < ? THEN SALDO ELSE 0 END) AS vencido, + SUM(CASE WHEN FECHA_VENC >= ? THEN SALDO ELSE 0 END) AS por_vencer + FROM ${this.getTableName(SAE_TABLES.CUENTAS_PAGAR)} + WHERE SALDO > 0 + `; + + const result = await this.client.queryOne<{ + total: number; + vencido: number; + por_vencer: number; + }>(sql, [AspelClient.formatAspelDate(hoy), AspelClient.formatAspelDate(hoy)]); + + return { + total: result?.total || 0, + vencido: result?.vencido || 0, + porVencer: result?.por_vencer || 0, + }; + } + + // ============================================================================ + // Utilidades + // ============================================================================ + + /** + * Obtiene el nombre de tabla con sufijo de empresa + */ + private getTableName(baseName: string): string { + // SAE usa sufijos numericos para las tablas por empresa (ej: CLIE01, CLIE02) + const empresaSuffix = String(this.empresaId).padStart(2, '0'); + return baseName.replace(/\d{2}$/, empresaSuffix); + } + + /** + * 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 mapToCliente(row: Record): Cliente { + return { + clave: String(row.CVE_CLPV || ''), + nombre: String(row.NOMBRE || ''), + rfc: String(row.RFC || ''), + curp: row.CURP ? String(row.CURP) : undefined, + direccion: { + calle: String(row.CALLE || ''), + numeroExterior: row.NUMEXT ? String(row.NUMEXT) : undefined, + numeroInterior: row.NUMINT ? String(row.NUMINT) : undefined, + colonia: String(row.COLONIA || ''), + codigoPostal: String(row.CODIGO || ''), + ciudad: String(row.CIUDAD || ''), + estado: String(row.ESTADO || ''), + pais: String(row.PAIS || 'Mexico'), + }, + telefono: row.TELEFONO ? String(row.TELEFONO) : undefined, + email: row.EMAIL ? String(row.EMAIL) : undefined, + limiteCredito: Number(row.LIM_CREDITO) || 0, + diasCredito: Number(row.DIAS_CRED) || 0, + saldo: Number(row.SALDO) || 0, + listaPrecio: Number(row.LISTA_PREC) || 1, + descuento: Number(row.DESCUENTO) || 0, + activo: row.STATUS === 'A', + regimenFiscal: row.REG_FISC ? String(row.REG_FISC) : undefined, + usoCfdi: row.USO_CFDI ? String(row.USO_CFDI) : undefined, + formaPago: row.FORMA_PAGO ? String(row.FORMA_PAGO) : undefined, + metodoPago: row.METODO_PAGO ? String(row.METODO_PAGO) : undefined, + cuentaContable: row.CTA_CONTABLE ? String(row.CTA_CONTABLE) : undefined, + fechaAlta: AspelClient.parseAspelDate(row.FECHA_ALTA) || undefined, + ultimaCompra: AspelClient.parseAspelDate(row.ULT_COMPRA) || undefined, + }; + } + + private mapToProveedor(row: Record): Proveedor { + return { + clave: String(row.CVE_CLPV || ''), + nombre: String(row.NOMBRE || ''), + rfc: String(row.RFC || ''), + direccion: { + calle: String(row.CALLE || ''), + numeroExterior: row.NUMEXT ? String(row.NUMEXT) : undefined, + numeroInterior: row.NUMINT ? String(row.NUMINT) : undefined, + colonia: String(row.COLONIA || ''), + codigoPostal: String(row.CODIGO || ''), + ciudad: String(row.CIUDAD || ''), + estado: String(row.ESTADO || ''), + pais: String(row.PAIS || 'Mexico'), + }, + telefono: row.TELEFONO ? String(row.TELEFONO) : undefined, + email: row.EMAIL ? String(row.EMAIL) : undefined, + limiteCredito: Number(row.LIM_CREDITO) || 0, + diasCredito: Number(row.DIAS_CRED) || 0, + saldo: Number(row.SALDO) || 0, + descuento: Number(row.DESCUENTO) || 0, + activo: row.STATUS === 'A', + cuentaContable: row.CTA_CONTABLE ? String(row.CTA_CONTABLE) : undefined, + fechaAlta: AspelClient.parseAspelDate(row.FECHA_ALTA) || undefined, + ultimaCompra: AspelClient.parseAspelDate(row.ULT_COMPRA) || undefined, + }; + } + + private mapToArticulo(row: Record): Articulo { + return { + clave: String(row.CVE_ART || ''), + descripcion: String(row.DESCR || ''), + descripcionCorta: row.DESCR_CORTA ? String(row.DESCR_CORTA) : undefined, + linea: String(row.LIN_PROD || ''), + unidadMedida: String(row.UNI_MED || 'PZA'), + precios: { + lista1: Number(row.PRECIO1) || 0, + lista2: Number(row.PRECIO2) || 0, + lista3: Number(row.PRECIO3) || 0, + lista4: Number(row.PRECIO4) || 0, + lista5: Number(row.PRECIO5) || 0, + }, + costoPromedio: Number(row.COST_PROM) || 0, + ultimoCosto: Number(row.ULT_COSTO) || 0, + existencia: Number(row.EXIST) || 0, + puntoReorden: Number(row.PTO_REORD) || 0, + stockMaximo: Number(row.STOCK_MAX) || 0, + iva: Number(row.TASA_IVA) || 16, + ieps: row.TASA_IEPS ? Number(row.TASA_IEPS) : undefined, + esServicio: row.ES_SERV === 'S' || row.ES_SERV === true || row.ES_SERV === 1, + esKit: row.ES_KIT === 'S' || row.ES_KIT === true || row.ES_KIT === 1, + activo: row.STATUS === 'A', + claveSat: row.CVE_SAT ? String(row.CVE_SAT) : undefined, + claveUnidadSat: row.UNI_SAT ? String(row.UNI_SAT) : undefined, + numeroPedimento: row.NUM_PEDIM ? String(row.NUM_PEDIM) : undefined, + codigoBarras: row.COD_BARRAS ? String(row.COD_BARRAS) : undefined, + cuentaContable: row.CTA_CONTABLE ? String(row.CTA_CONTABLE) : undefined, + fechaAlta: AspelClient.parseAspelDate(row.FECHA_ALTA) || undefined, + ultimaVenta: AspelClient.parseAspelDate(row.ULT_VTA) || undefined, + ultimaCompra: AspelClient.parseAspelDate(row.ULT_COMPRA) || undefined, + }; + } + + private mapToFactura(row: Record): Factura { + return { + tipoDocumento: String(row.TIP_DOC || 'F') as Factura['tipoDocumento'], + serie: String(row.SER_DOC || ''), + folio: Number(row.FOLIO) || 0, + fecha: AspelClient.parseAspelDate(row.FECHA_DOC) || new Date(), + claveCliente: String(row.CVE_CLPV || ''), + nombreCliente: String(row.NOMBRE || ''), + rfcCliente: String(row.RFC || ''), + subtotal: Number(row.SUBTOTAL) || 0, + descuento: Number(row.DESCUENTO) || 0, + iva: Number(row.IVA) || 0, + ieps: row.IEPS ? Number(row.IEPS) : undefined, + retenciones: row.RETENCIONES ? Number(row.RETENCIONES) : undefined, + total: Number(row.TOTAL) || 0, + formaPago: String(row.FORMA_PAGO || '99'), + metodoPago: String(row.METODO_PAGO || 'PUE'), + condicionPago: row.COND_PAGO ? String(row.COND_PAGO) : undefined, + moneda: String(row.MONEDA || 'MXN'), + tipoCambio: Number(row.TIPO_CAMBIO) || 1, + uuid: row.UUID ? String(row.UUID) : undefined, + estatus: String(row.STATUS || 'V') as Factura['estatus'], + saldoPendiente: Number(row.SALDO) || 0, + fechaVencimiento: AspelClient.parseAspelDate(row.FECHA_VENC) || undefined, + observaciones: row.OBSERVACIONES ? String(row.OBSERVACIONES) : undefined, + vendedor: row.VENDEDOR ? String(row.VENDEDOR) : undefined, + partidas: [], + fechaPago: AspelClient.parseAspelDate(row.FECHA_PAGO) || undefined, + }; + } + + private mapToPartidaFactura(row: Record): PartidaFactura { + return { + numeroPartida: Number(row.NUM_PAR) || 0, + claveArticulo: String(row.CVE_ART || ''), + descripcion: String(row.DESCR || ''), + cantidad: Number(row.CANT) || 0, + unidad: String(row.UNIDAD || 'PZA'), + precioUnitario: Number(row.PREC_UNIT) || 0, + descuento: Number(row.DESCTO) || 0, + importe: Number(row.IMPORTE) || 0, + iva: Number(row.IVA) || 0, + ieps: row.IEPS ? Number(row.IEPS) : undefined, + claveSat: row.CVE_SAT ? String(row.CVE_SAT) : undefined, + claveUnidadSat: row.UNI_SAT ? String(row.UNI_SAT) : undefined, + }; + } + + private mapToCompra(row: Record): Compra { + return { + tipoDocumento: String(row.TIP_DOC || 'C') as Compra['tipoDocumento'], + serie: String(row.SER_DOC || ''), + folio: Number(row.FOLIO) || 0, + fecha: AspelClient.parseAspelDate(row.FECHA_DOC) || new Date(), + claveProveedor: String(row.CVE_CLPV || ''), + nombreProveedor: String(row.NOMBRE || ''), + rfcProveedor: String(row.RFC || ''), + facturaProveedor: row.FACT_PROV ? String(row.FACT_PROV) : undefined, + subtotal: Number(row.SUBTOTAL) || 0, + descuento: Number(row.DESCUENTO) || 0, + iva: Number(row.IVA) || 0, + ieps: row.IEPS ? Number(row.IEPS) : undefined, + retenciones: row.RETENCIONES ? Number(row.RETENCIONES) : undefined, + total: Number(row.TOTAL) || 0, + moneda: String(row.MONEDA || 'MXN'), + tipoCambio: Number(row.TIPO_CAMBIO) || 1, + uuid: row.UUID ? String(row.UUID) : undefined, + estatus: String(row.STATUS || 'V') as Compra['estatus'], + saldoPendiente: Number(row.SALDO) || 0, + fechaVencimiento: AspelClient.parseAspelDate(row.FECHA_VENC) || undefined, + observaciones: row.OBSERVACIONES ? String(row.OBSERVACIONES) : undefined, + partidas: [], + }; + } + + private mapToPartidaCompra(row: Record): PartidaCompra { + return { + numeroPartida: Number(row.NUM_PAR) || 0, + claveArticulo: String(row.CVE_ART || ''), + descripcion: String(row.DESCR || ''), + cantidad: Number(row.CANT) || 0, + unidad: String(row.UNIDAD || 'PZA'), + costoUnitario: Number(row.COSTO_UNIT) || 0, + descuento: Number(row.DESCTO) || 0, + importe: Number(row.IMPORTE) || 0, + iva: Number(row.IVA) || 0, + ieps: row.IEPS ? Number(row.IEPS) : undefined, + }; + } + + private mapToCuentaPorCobrar(row: Record, fechaCorte: Date): CuentaPorCobrar { + const fechaVencimiento = AspelClient.parseAspelDate(row.FECHA_VENC) || new Date(); + const diasVencidos = Math.max( + 0, + Math.floor((fechaCorte.getTime() - fechaVencimiento.getTime()) / (1000 * 60 * 60 * 24)) + ); + + return { + idDocumento: String(row.CVE_DOC || ''), + tipoDocumento: String(row.TIP_DOC || ''), + serie: String(row.SER_DOC || ''), + folio: Number(row.FOLIO) || 0, + fechaDocumento: AspelClient.parseAspelDate(row.FECHA_DOC) || new Date(), + fechaVencimiento, + claveCliente: String(row.CVE_CLPV || ''), + nombreCliente: String(row.NOMBRE || ''), + importeOriginal: Number(row.IMPORTE) || 0, + saldo: Number(row.SALDO) || 0, + moneda: String(row.MONEDA || 'MXN'), + diasVencidos, + uuid: row.UUID ? String(row.UUID) : undefined, + }; + } + + private mapToCuentaPorPagar(row: Record, fechaCorte: Date): CuentaPorPagar { + const fechaVencimiento = AspelClient.parseAspelDate(row.FECHA_VENC) || new Date(); + const diasVencidos = Math.max( + 0, + Math.floor((fechaCorte.getTime() - fechaVencimiento.getTime()) / (1000 * 60 * 60 * 24)) + ); + + return { + idDocumento: String(row.CVE_DOC || ''), + tipoDocumento: String(row.TIP_DOC || ''), + serie: String(row.SER_DOC || ''), + folio: Number(row.FOLIO) || 0, + fechaDocumento: AspelClient.parseAspelDate(row.FECHA_DOC) || new Date(), + fechaVencimiento, + claveProveedor: String(row.CVE_CLPV || ''), + nombreProveedor: String(row.NOMBRE || ''), + facturaProveedor: row.FACT_PROV ? String(row.FACT_PROV) : undefined, + importeOriginal: Number(row.IMPORTE) || 0, + saldo: Number(row.SALDO) || 0, + moneda: String(row.MONEDA || 'MXN'), + diasVencidos, + uuid: row.UUID ? String(row.UUID) : undefined, + }; + } +} + +// ============================================================================ +// Factory function +// ============================================================================ + +export function createSAEConnector(client: AspelClient, empresaId?: number): SAEConnector { + return new SAEConnector(client, empresaId); +} diff --git a/apps/api/src/services/integrations/contpaqi/comercial.connector.ts b/apps/api/src/services/integrations/contpaqi/comercial.connector.ts new file mode 100644 index 0000000..bae3741 --- /dev/null +++ b/apps/api/src/services/integrations/contpaqi/comercial.connector.ts @@ -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 = { + 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 { + 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 = {}; + + 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 { + 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 { + 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 = {}; + + 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 { + 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 { + 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 = {}; + + 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 { + return this.getDocumentos({ + ...filter, + conceptos: ['FacturaCliente'], + }); + } + + /** + * Obtiene las facturas recibidas (de proveedores) en un periodo + */ + async getFacturasRecibidas(filter: DocumentoFilter): Promise { + return this.getDocumentos({ + ...filter, + conceptos: ['FacturaProveedor'], + }); + } + + /** + * Obtiene documentos comerciales + */ + async getDocumentos(filter: DocumentoFilter & { + conceptos?: ConceptoDocumento[]; + }): Promise { + 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 = {}; + + // 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 { + 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 { + 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 = {}; + + 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 { + 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 = { + 1: 'Producto', + 2: 'Paquete', + 3: 'Servicio', + }; + return tipos[tipo] || 'Producto'; + } + + private getConceptoDocumentoId(concepto: ConceptoDocumento): number | null { + const conceptos: Record = { + 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 = { + Pendiente: 1, + Parcial: 2, + Pagado: 3, + Cancelado: 4, + }; + return estados[estado]; + } + + private getEstadoDocumentoNombre(estado: number): EstadoDocumento { + const estados: Record = { + 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); +} diff --git a/apps/api/src/services/integrations/contpaqi/contabilidad.connector.ts b/apps/api/src/services/integrations/contpaqi/contabilidad.connector.ts new file mode 100644 index 0000000..81cc884 --- /dev/null +++ b/apps/api/src/services/integrations/contpaqi/contabilidad.connector.ts @@ -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 { + 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 = {}; + + 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 { + 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 { + 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 = { + 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 { + 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 { + // 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 { + // 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(); + 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(); + 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 { + // 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> { + 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 { + 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 { + 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 = { + 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 = { + 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 = { + Ingresos: 1, + Egresos: 2, + Diario: 3, + Orden: 4, + }; + return tipos[tipo]; + } + + private getTipoPolizaNombre(tipo: number): TipoPoliza { + const tipos: Record = { + 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); +} diff --git a/apps/api/src/services/integrations/contpaqi/contpaqi.client.ts b/apps/api/src/services/integrations/contpaqi/contpaqi.client.ts new file mode 100644 index 0000000..5ad12d4 --- /dev/null +++ b/apps/api/src/services/integrations/contpaqi/contpaqi.client.ts @@ -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 = 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 { + 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 { + 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 { + 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 { + const closePromises: Promise[] = []; + + 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 { + 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 { + return poolManager.getPool(this.config, this.currentDatabase); + } + + /** + * Cambia a una base de datos diferente (para multi-empresa) + */ + async useDatabase(database: string): Promise { + this.currentDatabase = database; + } + + /** + * Ejecuta una consulta parametrizada + */ + async query>( + queryText: string, + params?: Record + ): Promise> { + 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(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>( + queryText: string, + params?: Record + ): Promise { + const result = await this.query(queryText, params); + return result.recordset[0] || null; + } + + /** + * Ejecuta una consulta y retorna todos los resultados + */ + async queryMany>( + queryText: string, + params?: Record + ): Promise { + const result = await this.query(queryText, params); + return result.recordset; + } + + /** + * Ejecuta un stored procedure + */ + async execute>( + procedureName: string, + params?: Record + ): Promise> { + 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(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( + callback: (transaction: sql.Transaction) => Promise + ): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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(); + } +} diff --git a/apps/api/src/services/integrations/contpaqi/contpaqi.schema.ts b/apps/api/src/services/integrations/contpaqi/contpaqi.schema.ts new file mode 100644 index 0000000..5539af1 --- /dev/null +++ b/apps/api/src/services/integrations/contpaqi/contpaqi.schema.ts @@ -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; +export type CONTPAQiEmpresaInput = z.infer; +export type CONTPAQiSyncConfigInput = z.infer; +export type PeriodoQuery = z.infer; +export type DateRangeQuery = z.infer; +export type PaginationInput = z.infer; +export type DocumentoFilter = z.infer; +export type EmpleadoFilter = z.infer; +export type NominaFilter = z.infer; +export type PolizaFilter = z.infer; + +// ============================================================================ +// 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(); +} diff --git a/apps/api/src/services/integrations/contpaqi/contpaqi.sync.ts b/apps/api/src/services/integrations/contpaqi/contpaqi.sync.ts new file mode 100644 index 0000000..71f1561 --- /dev/null +++ b/apps/api/src/services/integrations/contpaqi/contpaqi.sync.ts @@ -0,0 +1,1082 @@ +/** + * CONTPAQi Sync Service + * Servicio de sincronizacion de datos entre CONTPAQi y Horux Strategy + */ + +import { Pool } from 'pg'; +import { randomUUID } from 'crypto'; +import { + CONTPAQiConfig, + CONTPAQiEmpresa, + CONTPAQiSyncConfig, + CONTPAQiSyncResult, + CONTPAQiSyncError, + CONTPAQiSyncException, + CONTPAQiPoliza, + CONTPAQiMovimiento, + CONTPAQiDocumento, + CONTPAQiNomina, + CONTPAQiCliente, + CONTPAQiProveedor, + CONTPAQiProducto as CONTPAQiProductoType, + CONTPAQiCuenta, + CONTPAQiEmpleado, +} from './contpaqi.types.js'; +import { CONTPAQiClient, createCONTPAQiClient } from './contpaqi.client.js'; +import { ContabilidadConnector, createContabilidadConnector } from './contabilidad.connector.js'; +import { ComercialConnector, createComercialConnector } from './comercial.connector.js'; +import { NominasConnector, createNominasConnector } from './nominas.connector.js'; +import { validateSyncConfig } from './contpaqi.schema.js'; + +// ============================================================================ +// Tipos para mapeo a Horux +// ============================================================================ + +/** + * Transaccion en formato Horux + */ +interface HoruxTransaction { + id?: string; + tenantId: string; + date: Date; + description: string; + amount: number; + type: 'income' | 'expense' | 'transfer'; + category?: string; + subcategory?: string; + accountId?: string; + contactId?: string; + reference?: string; + notes?: string; + cfdiUuid?: string; + sourceSystem: string; + sourceId: string; + metadata?: Record; +} + +/** + * Contacto en formato Horux + */ +interface HoruxContact { + id?: string; + tenantId: string; + type: 'customer' | 'supplier' | 'employee' | 'other'; + name: string; + rfc?: string; + curp?: string; + email?: string; + phone?: string; + address?: { + street?: string; + city?: string; + state?: string; + zipCode?: string; + country?: string; + }; + sourceSystem: string; + sourceId: string; + metadata?: Record; +} + +/** + * Opciones de logger + */ +interface Logger { + info: (message: string, meta?: Record) => void; + error: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + debug: (message: string, meta?: Record) => void; +} + +// ============================================================================ +// Servicio de Sincronizacion +// ============================================================================ + +/** + * Configuracion del servicio de sincronizacion + */ +export interface CONTPAQiSyncServiceConfig { + /** Pool de conexiones PostgreSQL */ + dbPool: Pool; + /** Schema de base de datos para el tenant */ + getSchemaName: (tenantId: string) => Promise; + /** Logger */ + logger?: Logger; +} + +/** + * Servicio de sincronizacion CONTPAQi -> Horux + */ +export class CONTPAQiSyncService { + private config: CONTPAQiSyncServiceConfig; + private logger: Logger; + + constructor(config: CONTPAQiSyncServiceConfig) { + this.config = config; + this.logger = config.logger || { + info: console.log, + error: console.error, + warn: console.warn, + debug: console.log, + }; + } + + /** + * Ejecuta la sincronizacion completa de CONTPAQi a Horux + */ + async syncToHorux(syncConfig: CONTPAQiSyncConfig): Promise { + const validConfig = validateSyncConfig(syncConfig); + const startTime = new Date(); + + const result: CONTPAQiSyncResult = { + success: false, + tenantId: validConfig.tenantId, + startTime, + endTime: new Date(), + duration: 0, + processed: { + empresas: 0, + cuentas: 0, + polizas: 0, + movimientos: 0, + clientes: 0, + proveedores: 0, + productos: 0, + facturas: 0, + empleados: 0, + nominas: 0, + }, + created: 0, + updated: 0, + errors: [], + newSyncTimestamp: new Date(), + }; + + this.logger.info('Iniciando sincronizacion CONTPAQi', { + tenantId: validConfig.tenantId, + productos: validConfig.productos, + incremental: validConfig.incremental, + }); + + const client = createCONTPAQiClient(validConfig.connectionConfig); + + try { + // Obtener empresas a sincronizar + const empresas: CONTPAQiEmpresa[] = []; + + for (const producto of validConfig.productos) { + const productEmpresas = await client.getEmpresas(producto); + empresas.push(...productEmpresas); + } + + // Filtrar por empresas especificas si se indicaron + const empresasASincronizar = validConfig.empresas?.length + ? empresas.filter((e) => validConfig.empresas!.includes(e.codigo)) + : empresas; + + result.processed.empresas = empresasASincronizar.length; + + this.logger.info(`Encontradas ${empresasASincronizar.length} empresas a sincronizar`); + + // Sincronizar cada empresa + for (const empresa of empresasASincronizar) { + try { + await client.connectToEmpresa(empresa); + + // Sincronizar segun el producto + if (empresa.producto === 'Contabilidad') { + await this.syncContabilidad(client, empresa, validConfig, result); + } else if (empresa.producto === 'Comercial') { + await this.syncComercial(client, empresa, validConfig, result); + } else if (empresa.producto === 'Nominas') { + await this.syncNominas(client, empresa, validConfig, result); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; + result.errors.push({ + recordType: 'empresa', + recordId: empresa.codigo, + message: `Error sincronizando empresa ${empresa.nombre}: ${errorMessage}`, + }); + + this.logger.error(`Error sincronizando empresa ${empresa.nombre}`, { + empresa: empresa.codigo, + error: errorMessage, + }); + } + } + + result.success = result.errors.length === 0; + result.endTime = new Date(); + result.duration = result.endTime.getTime() - startTime.getTime(); + result.newSyncTimestamp = new Date(); + + this.logger.info('Sincronizacion completada', { + tenantId: validConfig.tenantId, + success: result.success, + created: result.created, + updated: result.updated, + errors: result.errors.length, + duration: result.duration, + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; + result.errors.push({ + recordType: 'general', + recordId: 'sync', + message: `Error general de sincronizacion: ${errorMessage}`, + }); + + result.endTime = new Date(); + result.duration = result.endTime.getTime() - startTime.getTime(); + + this.logger.error('Error en sincronizacion', { + tenantId: validConfig.tenantId, + error: errorMessage, + }); + + throw new CONTPAQiSyncException( + 'Error en sincronizacion CONTPAQi', + 'general', + result.errors + ); + } finally { + await client.close(); + } + } + + // ============================================================================ + // Sincronizacion de Contabilidad + // ============================================================================ + + private async syncContabilidad( + client: CONTPAQiClient, + empresa: CONTPAQiEmpresa, + config: CONTPAQiSyncConfig, + result: CONTPAQiSyncResult + ): Promise { + const connector = createContabilidadConnector(client); + + this.logger.info(`Sincronizando contabilidad de ${empresa.nombre}`); + + // Sincronizar catalogo de cuentas + try { + const cuentas = await connector.getCatalogoCuentas(); + result.processed.cuentas += cuentas.length; + + for (const cuenta of cuentas) { + try { + await this.upsertCuenta(config.tenantId, empresa, cuenta); + result.created++; + } catch (error) { + result.errors.push({ + recordType: 'cuenta', + recordId: cuenta.codigo, + message: error instanceof Error ? error.message : 'Error', + }); + } + } + } catch (error) { + result.errors.push({ + recordType: 'cuentas', + recordId: empresa.codigo, + message: error instanceof Error ? error.message : 'Error obteniendo cuentas', + }); + } + + // Sincronizar polizas + if (config.fechaDesde && config.fechaHasta) { + try { + const polizas = await connector.getPolizas(config.fechaDesde, config.fechaHasta); + result.processed.polizas += polizas.length; + + for (const poliza of polizas) { + result.processed.movimientos += poliza.movimientos.length; + + try { + // Mapear poliza a transacciones Horux + const transactions = this.mapPolizaToTransactions(config.tenantId, empresa, poliza); + + for (const tx of transactions) { + await this.upsertTransaction(tx); + result.created++; + } + } catch (error) { + result.errors.push({ + recordType: 'poliza', + recordId: `${poliza.tipo}-${poliza.numero}`, + message: error instanceof Error ? error.message : 'Error', + }); + } + } + } catch (error) { + result.errors.push({ + recordType: 'polizas', + recordId: empresa.codigo, + message: error instanceof Error ? error.message : 'Error obteniendo polizas', + }); + } + } + } + + // ============================================================================ + // Sincronizacion de Comercial + // ============================================================================ + + private async syncComercial( + client: CONTPAQiClient, + empresa: CONTPAQiEmpresa, + config: CONTPAQiSyncConfig, + result: CONTPAQiSyncResult + ): Promise { + const connector = createComercialConnector(client); + + this.logger.info(`Sincronizando comercial de ${empresa.nombre}`); + + // Sincronizar clientes + try { + const clientes = await connector.getClientes(); + result.processed.clientes += clientes.length; + + for (const cliente of clientes) { + try { + const contact = this.mapClienteToContact(config.tenantId, empresa, cliente); + await this.upsertContact(contact); + result.created++; + } catch (error) { + result.errors.push({ + recordType: 'cliente', + recordId: cliente.codigo, + message: error instanceof Error ? error.message : 'Error', + }); + } + } + } catch (error) { + result.errors.push({ + recordType: 'clientes', + recordId: empresa.codigo, + message: error instanceof Error ? error.message : 'Error obteniendo clientes', + }); + } + + // Sincronizar proveedores + try { + const proveedores = await connector.getProveedores(); + result.processed.proveedores += proveedores.length; + + for (const proveedor of proveedores) { + try { + const contact = this.mapProveedorToContact(config.tenantId, empresa, proveedor); + await this.upsertContact(contact); + result.created++; + } catch (error) { + result.errors.push({ + recordType: 'proveedor', + recordId: proveedor.codigo, + message: error instanceof Error ? error.message : 'Error', + }); + } + } + } catch (error) { + result.errors.push({ + recordType: 'proveedores', + recordId: empresa.codigo, + message: error instanceof Error ? error.message : 'Error obteniendo proveedores', + }); + } + + // Sincronizar productos + try { + const productos = await connector.getProductos(); + result.processed.productos += productos.length; + + for (const producto of productos) { + try { + await this.upsertProducto(config.tenantId, empresa, producto); + result.created++; + } catch (error) { + result.errors.push({ + recordType: 'producto', + recordId: producto.codigo, + message: error instanceof Error ? error.message : 'Error', + }); + } + } + } catch (error) { + result.errors.push({ + recordType: 'productos', + recordId: empresa.codigo, + message: error instanceof Error ? error.message : 'Error obteniendo productos', + }); + } + + // Sincronizar facturas + if (config.fechaDesde && config.fechaHasta) { + try { + const facturasEmitidas = await connector.getFacturasEmitidas({ + fechaInicio: config.fechaDesde, + fechaFin: config.fechaHasta, + }); + + const facturasRecibidas = await connector.getFacturasRecibidas({ + fechaInicio: config.fechaDesde, + fechaFin: config.fechaHasta, + }); + + const facturas = [...facturasEmitidas, ...facturasRecibidas]; + result.processed.facturas += facturas.length; + + for (const factura of facturas) { + try { + const transaction = this.mapDocumentoToTransaction(config.tenantId, empresa, factura); + await this.upsertTransaction(transaction); + result.created++; + } catch (error) { + result.errors.push({ + recordType: 'factura', + recordId: `${factura.serie || ''}-${factura.folio}`, + message: error instanceof Error ? error.message : 'Error', + }); + } + } + } catch (error) { + result.errors.push({ + recordType: 'facturas', + recordId: empresa.codigo, + message: error instanceof Error ? error.message : 'Error obteniendo facturas', + }); + } + } + } + + // ============================================================================ + // Sincronizacion de Nominas + // ============================================================================ + + private async syncNominas( + client: CONTPAQiClient, + empresa: CONTPAQiEmpresa, + config: CONTPAQiSyncConfig, + result: CONTPAQiSyncResult + ): Promise { + const connector = createNominasConnector(client); + + this.logger.info(`Sincronizando nominas de ${empresa.nombre}`); + + // Sincronizar empleados + try { + const empleados = await connector.getEmpleados(); + result.processed.empleados += empleados.length; + + for (const empleado of empleados) { + try { + const contact = this.mapEmpleadoToContact(config.tenantId, empresa, empleado); + await this.upsertContact(contact); + result.created++; + } catch (error) { + result.errors.push({ + recordType: 'empleado', + recordId: empleado.codigo, + message: error instanceof Error ? error.message : 'Error', + }); + } + } + } catch (error) { + result.errors.push({ + recordType: 'empleados', + recordId: empresa.codigo, + message: error instanceof Error ? error.message : 'Error obteniendo empleados', + }); + } + + // Sincronizar nominas del ejercicio actual + if (empresa.ejercicioActual) { + try { + const nominas = await connector.getNominas({ + ejercicio: empresa.ejercicioActual, + }); + result.processed.nominas += nominas.length; + + for (const nomina of nominas) { + try { + const transaction = this.mapNominaToTransaction(config.tenantId, empresa, nomina); + await this.upsertTransaction(transaction); + result.created++; + } catch (error) { + result.errors.push({ + recordType: 'nomina', + recordId: `${nomina.codigoEmpleado}-${nomina.numeroPeriodo}`, + message: error instanceof Error ? error.message : 'Error', + }); + } + } + } catch (error) { + result.errors.push({ + recordType: 'nominas', + recordId: empresa.codigo, + message: error instanceof Error ? error.message : 'Error obteniendo nominas', + }); + } + } + } + + // ============================================================================ + // Mapeo a modelo Horux + // ============================================================================ + + /** + * Mapea una poliza contable a transacciones Horux + */ + mapPolizaToTransactions( + tenantId: string, + empresa: CONTPAQiEmpresa, + poliza: CONTPAQiPoliza + ): HoruxTransaction[] { + const transactions: HoruxTransaction[] = []; + + // Crear una transaccion por cada movimiento significativo + for (const mov of poliza.movimientos) { + if (mov.importe <= 0) continue; + + const isExpense = mov.tipoMovimiento === 'Cargo' && + ['Gasto', 'Costo'].includes(this.getCuentaTipo(mov.codigoCuenta)); + const isIncome = mov.tipoMovimiento === 'Abono' && + mov.codigoCuenta.startsWith('4'); // Cuentas de ingresos + + if (!isExpense && !isIncome) continue; + + transactions.push({ + tenantId, + date: poliza.fecha, + description: mov.concepto || poliza.concepto, + amount: mov.importe, + type: isExpense ? 'expense' : 'income', + category: this.getCuentaTipo(mov.codigoCuenta), + reference: mov.referencia || `${poliza.tipo}-${poliza.numero}`, + notes: `Poliza ${poliza.tipo} #${poliza.numero} - ${poliza.concepto}`, + cfdiUuid: mov.uuidCFDI || poliza.uuidCFDI, + sourceSystem: 'contpaqi_contabilidad', + sourceId: `${empresa.codigo}_${poliza.id}_${mov.id}`, + metadata: { + empresa: empresa.codigo, + ejercicio: poliza.ejercicio, + periodo: poliza.periodo, + tipoPoliza: poliza.tipo, + numeroPoliza: poliza.numero, + codigoCuenta: mov.codigoCuenta, + tipoMovimiento: mov.tipoMovimiento, + }, + }); + } + + return transactions; + } + + /** + * Mapea un documento comercial a transaccion Horux + */ + mapDocumentoToTransaction( + tenantId: string, + empresa: CONTPAQiEmpresa, + documento: CONTPAQiDocumento + ): HoruxTransaction { + const isIncome = documento.concepto.includes('Cliente') || + documento.concepto.includes('Emitida'); + + return { + tenantId, + date: documento.fecha, + description: `${documento.concepto} ${documento.serie || ''}${documento.folio} - ${documento.nombreClienteProveedor}`, + amount: documento.total, + type: isIncome ? 'income' : 'expense', + category: isIncome ? 'Ventas' : 'Compras', + contactId: documento.rfcClienteProveedor, + reference: `${documento.serie || ''}${documento.folio}`, + cfdiUuid: documento.uuid, + sourceSystem: 'contpaqi_comercial', + sourceId: `${empresa.codigo}_${documento.id}`, + metadata: { + empresa: empresa.codigo, + concepto: documento.concepto, + serie: documento.serie, + folio: documento.folio, + subtotal: documento.subtotal, + iva: documento.iva, + descuento: documento.descuento, + formaPago: documento.formaPago, + metodoPago: documento.metodoPago, + estado: documento.estado, + movimientos: documento.movimientos.length, + }, + }; + } + + /** + * Mapea una nomina a transaccion Horux + */ + mapNominaToTransaction( + tenantId: string, + empresa: CONTPAQiEmpresa, + nomina: CONTPAQiNomina + ): HoruxTransaction { + return { + tenantId, + date: nomina.fechaPago, + description: `Nomina ${nomina.tipoNomina} - ${nomina.nombreEmpleado} - Periodo ${nomina.numeroPeriodo}`, + amount: nomina.neto, + type: 'expense', + category: 'Nomina', + subcategory: nomina.tipoNomina, + contactId: nomina.codigoEmpleado, + reference: `NOM-${nomina.ejercicio}-${nomina.numeroPeriodo}-${nomina.codigoEmpleado}`, + cfdiUuid: nomina.uuid, + sourceSystem: 'contpaqi_nominas', + sourceId: `${empresa.codigo}_${nomina.id}`, + metadata: { + empresa: empresa.codigo, + empleadoId: nomina.empleadoId, + codigoEmpleado: nomina.codigoEmpleado, + ejercicio: nomina.ejercicio, + periodo: nomina.numeroPeriodo, + tipoNomina: nomina.tipoNomina, + diasPagados: nomina.diasPagados, + totalPercepciones: nomina.totalPercepciones, + totalDeducciones: nomina.totalDeducciones, + percepciones: nomina.percepciones.length, + deducciones: nomina.deducciones.length, + }, + }; + } + + /** + * Mapea un cliente a contacto Horux + */ + mapClienteToContact( + tenantId: string, + empresa: CONTPAQiEmpresa, + cliente: CONTPAQiCliente + ): HoruxContact { + return { + tenantId, + type: 'customer', + name: cliente.razonSocial, + rfc: cliente.rfc, + curp: cliente.curp, + email: cliente.email, + phone: cliente.telefono, + address: cliente.direccion + ? { + street: cliente.direccion.calle, + city: cliente.direccion.ciudad, + state: cliente.direccion.estado, + zipCode: cliente.direccion.codigoPostal, + country: cliente.direccion.pais, + } + : undefined, + sourceSystem: 'contpaqi_comercial', + sourceId: `${empresa.codigo}_cliente_${cliente.id}`, + metadata: { + empresa: empresa.codigo, + codigo: cliente.codigo, + nombreComercial: cliente.nombreComercial, + regimenFiscal: cliente.regimenFiscal, + usoCFDI: cliente.usoCFDI, + limiteCredito: cliente.limiteCredito, + diasCredito: cliente.diasCredito, + saldoActual: cliente.saldoActual, + }, + }; + } + + /** + * Mapea un proveedor a contacto Horux + */ + mapProveedorToContact( + tenantId: string, + empresa: CONTPAQiEmpresa, + proveedor: CONTPAQiProveedor + ): HoruxContact { + return { + tenantId, + type: 'supplier', + name: proveedor.razonSocial, + rfc: proveedor.rfc, + curp: proveedor.curp, + email: proveedor.email, + phone: proveedor.telefono, + address: proveedor.direccion + ? { + street: proveedor.direccion.calle, + city: proveedor.direccion.ciudad, + state: proveedor.direccion.estado, + zipCode: proveedor.direccion.codigoPostal, + country: proveedor.direccion.pais, + } + : undefined, + sourceSystem: 'contpaqi_comercial', + sourceId: `${empresa.codigo}_proveedor_${proveedor.id}`, + metadata: { + empresa: empresa.codigo, + codigo: proveedor.codigo, + nombreComercial: proveedor.nombreComercial, + regimenFiscal: proveedor.regimenFiscal, + diasCredito: proveedor.diasCredito, + saldoActual: proveedor.saldoActual, + cuentaContable: proveedor.cuentaContable, + }, + }; + } + + /** + * Mapea un empleado a contacto Horux + */ + mapEmpleadoToContact( + tenantId: string, + empresa: CONTPAQiEmpresa, + empleado: CONTPAQiEmpleado + ): HoruxContact { + return { + tenantId, + type: 'employee', + name: `${empleado.apellidoPaterno} ${empleado.apellidoMaterno || ''} ${empleado.nombre}`.trim(), + rfc: empleado.rfc, + curp: empleado.curp, + email: empleado.email, + phone: empleado.telefono, + address: empleado.direccion + ? { + street: empleado.direccion.calle, + city: empleado.direccion.ciudad, + state: empleado.direccion.estado, + zipCode: empleado.direccion.codigoPostal, + country: empleado.direccion.pais, + } + : undefined, + sourceSystem: 'contpaqi_nominas', + sourceId: `${empresa.codigo}_empleado_${empleado.id}`, + metadata: { + empresa: empresa.codigo, + codigo: empleado.codigo, + nss: empleado.nss, + fechaAlta: empleado.fechaAlta, + fechaBaja: empleado.fechaBaja, + tipoContrato: empleado.tipoContrato, + tipoRegimen: empleado.tipoRegimen, + departamento: empleado.departamento, + puesto: empleado.puesto, + salarioDiario: empleado.salarioDiario, + salarioDiarioIntegrado: empleado.salarioDiarioIntegrado, + periodicidadPago: empleado.periodicidadPago, + banco: empleado.banco, + clabe: empleado.clabe, + }, + }; + } + + // ============================================================================ + // Deteccion de cambios (sincronizacion incremental) + // ============================================================================ + + /** + * Detecta cambios desde la ultima sincronizacion + */ + async detectChanges( + client: CONTPAQiClient, + config: CONTPAQiSyncConfig + ): Promise<{ + hasChanges: boolean; + changedTables: string[]; + lastModified: Date; + }> { + if (!config.lastSyncTimestamp) { + return { + hasChanges: true, + changedTables: ['all'], + lastModified: new Date(), + }; + } + + const changedTables: string[] = []; + let lastModified = config.lastSyncTimestamp; + + // Verificar cambios en cada tabla principal + // Esto depende de la estructura especifica de CONTPAQi + // Algunas tablas tienen campos de fecha de modificacion + + try { + // Verificar polizas modificadas + const polizasQuery = ` + SELECT MAX(CFECHAMODIFICACION) as lastMod + FROM Polizas + WHERE CFECHAMODIFICACION > @lastSync + `; + + const polizasResult = await client.queryOne<{ lastMod: Date | null }>(polizasQuery, { + lastSync: config.lastSyncTimestamp, + }); + + if (polizasResult?.lastMod) { + changedTables.push('polizas'); + if (polizasResult.lastMod > lastModified) { + lastModified = polizasResult.lastMod; + } + } + } catch { + // Tabla puede no existir o no tener el campo + } + + try { + // Verificar documentos modificados + const docsQuery = ` + SELECT MAX(CFECHAMODIFICACION) as lastMod + FROM admDocumentos + WHERE CFECHAMODIFICACION > @lastSync + `; + + const docsResult = await client.queryOne<{ lastMod: Date | null }>(docsQuery, { + lastSync: config.lastSyncTimestamp, + }); + + if (docsResult?.lastMod) { + changedTables.push('documentos'); + if (docsResult.lastMod > lastModified) { + lastModified = docsResult.lastMod; + } + } + } catch { + // Tabla puede no existir o no tener el campo + } + + return { + hasChanges: changedTables.length > 0, + changedTables, + lastModified, + }; + } + + // ============================================================================ + // Metodos auxiliares de persistencia + // ============================================================================ + + private async upsertTransaction(transaction: HoruxTransaction): Promise { + const schema = await this.config.getSchemaName(transaction.tenantId); + + const query = ` + INSERT INTO ${schema}.transactions ( + id, tenant_id, date, description, amount, type, category, subcategory, + account_id, contact_id, reference, notes, cfdi_uuid, source_system, + source_id, metadata, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, + NOW(), NOW() + ) + ON CONFLICT (tenant_id, source_system, source_id) + DO UPDATE SET + date = EXCLUDED.date, + description = EXCLUDED.description, + amount = EXCLUDED.amount, + type = EXCLUDED.type, + category = EXCLUDED.category, + subcategory = EXCLUDED.subcategory, + reference = EXCLUDED.reference, + notes = EXCLUDED.notes, + cfdi_uuid = EXCLUDED.cfdi_uuid, + metadata = EXCLUDED.metadata, + updated_at = NOW() + `; + + await this.config.dbPool.query(query, [ + transaction.id || randomUUID(), + transaction.tenantId, + transaction.date, + transaction.description, + transaction.amount, + transaction.type, + transaction.category, + transaction.subcategory, + transaction.accountId, + transaction.contactId, + transaction.reference, + transaction.notes, + transaction.cfdiUuid, + transaction.sourceSystem, + transaction.sourceId, + JSON.stringify(transaction.metadata || {}), + ]); + } + + private async upsertContact(contact: HoruxContact): Promise { + const schema = await this.config.getSchemaName(contact.tenantId); + + const query = ` + INSERT INTO ${schema}.contacts ( + id, tenant_id, type, name, rfc, curp, email, phone, address, + source_system, source_id, metadata, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW() + ) + ON CONFLICT (tenant_id, source_system, source_id) + DO UPDATE SET + type = EXCLUDED.type, + name = EXCLUDED.name, + rfc = EXCLUDED.rfc, + curp = EXCLUDED.curp, + email = EXCLUDED.email, + phone = EXCLUDED.phone, + address = EXCLUDED.address, + metadata = EXCLUDED.metadata, + updated_at = NOW() + `; + + await this.config.dbPool.query(query, [ + contact.id || randomUUID(), + contact.tenantId, + contact.type, + contact.name, + contact.rfc, + contact.curp, + contact.email, + contact.phone, + JSON.stringify(contact.address || {}), + contact.sourceSystem, + contact.sourceId, + JSON.stringify(contact.metadata || {}), + ]); + } + + private async upsertCuenta( + tenantId: string, + empresa: CONTPAQiEmpresa, + cuenta: CONTPAQiCuenta + ): Promise { + const schema = await this.config.getSchemaName(tenantId); + + const query = ` + INSERT INTO ${schema}.chart_of_accounts ( + id, tenant_id, code, name, type, nature, level, parent_code, + is_detail, grouping_code, source_system, source_id, metadata, + created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW() + ) + ON CONFLICT (tenant_id, source_system, source_id) + DO UPDATE SET + code = EXCLUDED.code, + name = EXCLUDED.name, + type = EXCLUDED.type, + nature = EXCLUDED.nature, + level = EXCLUDED.level, + parent_code = EXCLUDED.parent_code, + is_detail = EXCLUDED.is_detail, + grouping_code = EXCLUDED.grouping_code, + metadata = EXCLUDED.metadata, + updated_at = NOW() + `; + + await this.config.dbPool.query(query, [ + randomUUID(), + tenantId, + cuenta.codigo, + cuenta.nombre, + cuenta.tipo, + cuenta.naturaleza, + cuenta.nivel, + cuenta.codigoPadre, + cuenta.esDeCatalogo, + cuenta.codigoAgrupador, + 'contpaqi_contabilidad', + `${empresa.codigo}_${cuenta.id}`, + JSON.stringify({ + empresa: empresa.codigo, + saldoInicial: cuenta.saldoInicial, + moneda: cuenta.moneda, + activa: cuenta.activa, + }), + ]); + } + + private async upsertProducto( + tenantId: string, + empresa: CONTPAQiEmpresa, + producto: CONTPAQiProductoType + ): Promise { + const schema = await this.config.getSchemaName(tenantId); + + const query = ` + INSERT INTO ${schema}.products ( + id, tenant_id, code, name, description, type, unit, sat_code, + sat_unit_code, base_price, source_system, source_id, metadata, + created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW() + ) + ON CONFLICT (tenant_id, source_system, source_id) + DO UPDATE SET + code = EXCLUDED.code, + name = EXCLUDED.name, + description = EXCLUDED.description, + type = EXCLUDED.type, + unit = EXCLUDED.unit, + sat_code = EXCLUDED.sat_code, + sat_unit_code = EXCLUDED.sat_unit_code, + base_price = EXCLUDED.base_price, + metadata = EXCLUDED.metadata, + updated_at = NOW() + `; + + await this.config.dbPool.query(query, [ + randomUUID(), + tenantId, + producto.codigo, + producto.nombre, + producto.descripcion, + producto.tipo, + producto.unidadMedida, + producto.claveSAT, + producto.claveUnidadSAT, + producto.precioBase, + 'contpaqi_comercial', + `${empresa.codigo}_${producto.id}`, + JSON.stringify({ + empresa: empresa.codigo, + ultimoCosto: producto.ultimoCosto, + costoPromedio: producto.costoPromedio, + tasaIVA: producto.tasaIVA, + tasaIEPS: producto.tasaIEPS, + categoria: producto.categoria, + linea: producto.linea, + marca: producto.marca, + controlExistencias: producto.controlExistencias, + }), + ]); + } + + /** + * Obtiene el tipo de cuenta basado en su codigo + * (simplificado - en produccion deberia consultar el catalogo) + */ + private getCuentaTipo(codigo: string): string { + const firstChar = codigo.charAt(0); + const tipos: Record = { + '1': 'Activo', + '2': 'Pasivo', + '3': 'Capital', + '4': 'Ingreso', + '5': 'Costo', + '6': 'Gasto', + '7': 'Orden', + }; + return tipos[firstChar] || 'Otro'; + } +} + +/** + * Crea una instancia del servicio de sincronizacion + */ +export function createCONTPAQiSyncService( + config: CONTPAQiSyncServiceConfig +): CONTPAQiSyncService { + return new CONTPAQiSyncService(config); +} diff --git a/apps/api/src/services/integrations/contpaqi/contpaqi.types.ts b/apps/api/src/services/integrations/contpaqi/contpaqi.types.ts new file mode 100644 index 0000000..3903657 --- /dev/null +++ b/apps/api/src/services/integrations/contpaqi/contpaqi.types.ts @@ -0,0 +1,1097 @@ +/** + * CONTPAQi Integration Types + * Tipos para la integracion con los sistemas CONTPAQi (Contabilidad, Comercial, Nominas) + * + * ESTRUCTURA DE TABLAS CONTPAQi: + * + * === CONTABILIDAD === + * - Cuentas: Catalogo de cuentas contables + * - Polizas: Encabezado de polizas contables + * - Movimientos: Detalle de movimientos por poliza + * - Periodos: Periodos contables (meses/ejercicios) + * - TiposPoliza: Catalogo de tipos de poliza (Ingreso, Egreso, Diario) + * + * === COMERCIAL === + * - admClientes: Catalogo de clientes + * - admProveedores: Catalogo de proveedores + * - admProductos: Catalogo de productos/servicios + * - admDocumentos: Facturas, notas de credito, remisiones, etc. + * - admMovimientos: Detalle de productos en documentos + * - admAlmacenes: Almacenes + * - admExistencias: Existencias por almacen + * + * === NOMINAS === + * - NOM10001 (Empleados): Catalogo de empleados + * - NOM10002 (Periodos): Periodos de nomina + * - NOM10003 (Percepciones): Tipos de percepciones + * - NOM10004 (Deducciones): Tipos de deducciones + * - NOM10005 (Movimientos): Movimientos de nomina por empleado + */ + +// ============================================================================ +// Configuracion de conexion +// ============================================================================ + +/** + * Configuracion para conectarse a una base de datos CONTPAQi + */ +export interface CONTPAQiConfig { + /** Host del servidor SQL Server */ + host: string; + /** Puerto de SQL Server (default: 1433) */ + port: number; + /** Usuario de base de datos */ + user: string; + /** Contrasena de base de datos */ + password: string; + /** Nombre de la base de datos de empresas (generalmente 'ctCONTPAQi' o 'CONTPAQiContabilidad') */ + database: string; + /** Usar conexion encriptada */ + encrypt?: boolean; + /** Confiar en certificado del servidor */ + trustServerCertificate?: boolean; + /** Timeout de conexion en ms */ + connectionTimeout?: number; + /** Timeout de requests en ms */ + requestTimeout?: number; + /** Pool minimo de conexiones */ + poolMin?: number; + /** Pool maximo de conexiones */ + poolMax?: number; + /** Timeout de inactividad del pool en ms */ + poolIdleTimeout?: number; +} + +/** + * Informacion de conexion activa + */ +export interface CONTPAQiConnection { + /** ID unico de la conexion */ + id: string; + /** Configuracion utilizada */ + config: CONTPAQiConfig; + /** Producto conectado */ + producto: CONTPAQiProducto; + /** Empresa conectada */ + empresa: CONTPAQiEmpresa; + /** Fecha de conexion */ + connectedAt: Date; + /** Estado de la conexion */ + status: 'connected' | 'disconnected' | 'error'; + /** Ultimo error si aplica */ + lastError?: string; +} + +/** + * Productos de CONTPAQi soportados + */ +export type CONTPAQiProducto = 'Contabilidad' | 'Comercial' | 'Nominas'; + +// ============================================================================ +// Empresas +// ============================================================================ + +/** + * Empresa registrada en CONTPAQi + */ +export interface CONTPAQiEmpresa { + /** ID interno de la empresa */ + id: number; + /** Codigo de la empresa */ + codigo: string; + /** Nombre de la empresa */ + nombre: string; + /** RFC de la empresa */ + rfc: string; + /** Nombre de la base de datos de la empresa */ + baseDatos: string; + /** Ruta de la empresa */ + ruta?: string; + /** Ejercicio actual */ + ejercicioActual?: number; + /** Periodo actual */ + periodoActual?: number; + /** Esta activa */ + activa: boolean; + /** Producto al que pertenece */ + producto: CONTPAQiProducto; +} + +// ============================================================================ +// Contabilidad - Catalogo de Cuentas +// ============================================================================ + +/** + * Cuenta contable + */ +export interface CONTPAQiCuenta { + /** ID de la cuenta */ + id: number; + /** Codigo de la cuenta */ + codigo: string; + /** Nombre de la cuenta */ + nombre: string; + /** Tipo de cuenta (Activo, Pasivo, Capital, Ingreso, Costo, Gasto, Orden) */ + tipo: TipoCuenta; + /** Naturaleza (Deudora, Acreedora) */ + naturaleza: NaturalezaCuenta; + /** Nivel de la cuenta (1=Mayor, 2=Subcuenta, etc.) */ + nivel: number; + /** Codigo de la cuenta padre */ + codigoPadre?: string; + /** Es cuenta de detalle (permite movimientos) */ + esDeCatalogo: boolean; + /** Codigo agrupador SAT */ + codigoAgrupador?: string; + /** Saldo inicial del ejercicio */ + saldoInicial?: number; + /** Cuenta activa */ + activa: boolean; + /** Fecha de alta */ + fechaAlta?: Date; + /** Moneda de la cuenta */ + moneda?: string; +} + +export type TipoCuenta = + | 'Activo' + | 'Pasivo' + | 'Capital' + | 'Ingreso' + | 'Costo' + | 'Gasto' + | 'Orden'; + +export type NaturalezaCuenta = 'Deudora' | 'Acreedora'; + +// ============================================================================ +// Contabilidad - Polizas +// ============================================================================ + +/** + * Poliza contable + */ +export interface CONTPAQiPoliza { + /** ID de la poliza */ + id: number; + /** Tipo de poliza */ + tipo: TipoPoliza; + /** Numero de poliza */ + numero: number; + /** Fecha de la poliza */ + fecha: Date; + /** Concepto general de la poliza */ + concepto: string; + /** Ejercicio */ + ejercicio: number; + /** Periodo (mes) */ + periodo: number; + /** Total de cargos */ + totalCargos: number; + /** Total de abonos */ + totalAbonos: number; + /** Esta cuadrada */ + cuadrada: boolean; + /** Esta afectada (aplicada a saldos) */ + afectada: boolean; + /** Movimientos de la poliza */ + movimientos: CONTPAQiMovimiento[]; + /** Impresa */ + impresa?: boolean; + /** Usuario que la creo */ + usuario?: string; + /** Fecha de creacion en sistema */ + fechaCreacion?: Date; + /** UUID del CFDI relacionado */ + uuidCFDI?: string; +} + +export type TipoPoliza = + | 'Ingresos' + | 'Egresos' + | 'Diario' + | 'Orden'; + +/** + * Movimiento de poliza contable + */ +export interface CONTPAQiMovimiento { + /** ID del movimiento */ + id: number; + /** ID de la poliza padre */ + polizaId: number; + /** Numero de movimiento dentro de la poliza */ + numMovimiento: number; + /** Codigo de cuenta */ + codigoCuenta: string; + /** Nombre de cuenta */ + nombreCuenta?: string; + /** Concepto del movimiento */ + concepto: string; + /** Tipo de movimiento (Cargo/Abono) */ + tipoMovimiento: TipoMovimiento; + /** Importe del movimiento */ + importe: number; + /** Referencia (numero de cheque, factura, etc.) */ + referencia?: string; + /** Segmento de negocio */ + segmentoNegocio?: string; + /** Diario */ + diario?: string; + /** UUID del CFDI relacionado */ + uuidCFDI?: string; + /** RFC del tercero */ + rfcTercero?: string; + /** Numero de factura */ + numFactura?: string; + /** Tipo de cambio si es en moneda extranjera */ + tipoCambio?: number; + /** Importe en moneda extranjera */ + importeME?: number; +} + +export type TipoMovimiento = 'Cargo' | 'Abono'; + +// ============================================================================ +// Contabilidad - Balanza +// ============================================================================ + +/** + * Linea de balanza de comprobacion + */ +export interface CONTPAQiBalanzaLinea { + /** Codigo de cuenta */ + codigoCuenta: string; + /** Nombre de cuenta */ + nombreCuenta: string; + /** Tipo de cuenta */ + tipoCuenta: TipoCuenta; + /** Nivel de cuenta */ + nivel: number; + /** Saldo inicial */ + saldoInicial: number; + /** Total de cargos del periodo */ + cargos: number; + /** Total de abonos del periodo */ + abonos: number; + /** Saldo final */ + saldoFinal: number; + /** Codigo agrupador SAT */ + codigoAgrupador?: string; +} + +/** + * Balanza de comprobacion completa + */ +export interface CONTPAQiBalanzaComprobacion { + /** Ejercicio */ + ejercicio: number; + /** Periodo (mes) */ + periodo: number; + /** Fecha de generacion */ + fechaGeneracion: Date; + /** Tipo de balanza */ + tipo: 'Normal' | 'Complementaria' | 'Cierre'; + /** Lineas de la balanza */ + lineas: CONTPAQiBalanzaLinea[]; + /** Totales */ + totales: { + saldoInicialDeudor: number; + saldoInicialAcreedor: number; + cargos: number; + abonos: number; + saldoFinalDeudor: number; + saldoFinalAcreedor: number; + }; +} + +// ============================================================================ +// Contabilidad - Estado de Resultados +// ============================================================================ + +/** + * Linea de estado de resultados + */ +export interface CONTPAQiEstadoResultadosLinea { + /** Codigo de cuenta */ + codigoCuenta: string; + /** Nombre de cuenta */ + nombreCuenta: string; + /** Tipo (Ingreso, Costo, Gasto) */ + tipo: 'Ingreso' | 'Costo' | 'Gasto'; + /** Nivel de cuenta */ + nivel: number; + /** Importe acumulado */ + importeAcumulado: number; + /** Importe del periodo */ + importePeriodo: number; + /** Es cuenta de detalle */ + esDeCatalogo: boolean; +} + +/** + * Estado de resultados + */ +export interface CONTPAQiEstadoResultados { + /** Ejercicio */ + ejercicio: number; + /** Periodo inicio */ + periodoInicio: number; + /** Periodo fin */ + periodoFin: number; + /** Fecha de generacion */ + fechaGeneracion: Date; + /** Lineas del estado de resultados */ + lineas: CONTPAQiEstadoResultadosLinea[]; + /** Resumen */ + resumen: { + totalIngresos: number; + totalCostos: number; + utilidadBruta: number; + totalGastos: number; + utilidadOperacion: number; + otrosIngresos: number; + otrosGastos: number; + utilidadNeta: number; + }; +} + +// ============================================================================ +// Comercial - Clientes +// ============================================================================ + +/** + * Cliente de CONTPAQi Comercial + */ +export interface CONTPAQiCliente { + /** ID del cliente */ + id: number; + /** Codigo del cliente */ + codigo: string; + /** Razon social */ + razonSocial: string; + /** RFC */ + rfc: string; + /** CURP (si es persona fisica) */ + curp?: string; + /** Nombre comercial */ + nombreComercial?: string; + /** Tipo de cliente (1=Cliente, 2=ClienteProveedor) */ + tipo: number; + /** Estado (activo/suspendido) */ + estado: number; + /** Regimen fiscal */ + regimenFiscal?: string; + /** Uso CFDI predeterminado */ + usoCFDI?: string; + /** Limite de credito */ + limiteCredito?: number; + /** Dias de credito */ + diasCredito?: number; + /** Saldo actual */ + saldoActual?: number; + /** Direccion fiscal */ + direccion?: CONTPAQiDireccion; + /** Email */ + email?: string; + /** Telefono */ + telefono?: string; + /** Fecha de alta */ + fechaAlta?: Date; + /** Fecha de ultima compra */ + fechaUltimaCompra?: Date; + /** Moneda predeterminada */ + moneda?: string; + /** Descuento general */ + descuento?: number; +} + +/** + * Direccion + */ +export interface CONTPAQiDireccion { + calle?: string; + numeroExterior?: string; + numeroInterior?: string; + colonia?: string; + codigoPostal?: string; + ciudad?: string; + estado?: string; + pais?: string; +} + +// ============================================================================ +// Comercial - Proveedores +// ============================================================================ + +/** + * Proveedor de CONTPAQi Comercial + */ +export interface CONTPAQiProveedor { + /** ID del proveedor */ + id: number; + /** Codigo del proveedor */ + codigo: string; + /** Razon social */ + razonSocial: string; + /** RFC */ + rfc: string; + /** CURP */ + curp?: string; + /** Nombre comercial */ + nombreComercial?: string; + /** Tipo */ + tipo: number; + /** Estado */ + estado: number; + /** Regimen fiscal */ + regimenFiscal?: string; + /** Dias de credito */ + diasCredito?: number; + /** Saldo actual (lo que se le debe) */ + saldoActual?: number; + /** Direccion */ + direccion?: CONTPAQiDireccion; + /** Email */ + email?: string; + /** Telefono */ + telefono?: string; + /** Cuenta contable */ + cuentaContable?: string; + /** Fecha de alta */ + fechaAlta?: Date; + /** Moneda predeterminada */ + moneda?: string; +} + +// ============================================================================ +// Comercial - Productos +// ============================================================================ + +/** + * Producto de CONTPAQi Comercial + */ +export interface CONTPAQiProducto { + /** ID del producto */ + id: number; + /** Codigo del producto */ + codigo: string; + /** Nombre del producto */ + nombre: string; + /** Descripcion */ + descripcion?: string; + /** Tipo (1=Producto, 2=Paquete, 3=Servicio) */ + tipo: TipoProducto; + /** Unidad de medida */ + unidadMedida: string; + /** Clave SAT */ + claveSAT?: string; + /** Clave unidad SAT */ + claveUnidadSAT?: string; + /** Precio base */ + precioBase: number; + /** Ultimo costo */ + ultimoCosto?: number; + /** Costo promedio */ + costoPromedio?: number; + /** IVA aplicable */ + tasaIVA?: number; + /** IEPS aplicable */ + tasaIEPS?: number; + /** Control de existencias */ + controlExistencias: boolean; + /** Estado (activo/suspendido) */ + estado: number; + /** Categoria */ + categoria?: string; + /** Linea */ + linea?: string; + /** Marca */ + marca?: string; + /** Fecha de alta */ + fechaAlta?: Date; +} + +export type TipoProducto = 'Producto' | 'Paquete' | 'Servicio'; + +// ============================================================================ +// Comercial - Documentos (Facturas, Notas de Credito, etc.) +// ============================================================================ + +/** + * Documento comercial (factura, nota de credito, etc.) + */ +export interface CONTPAQiDocumento { + /** ID del documento */ + id: number; + /** Concepto del documento (tipo) */ + concepto: ConceptoDocumento; + /** Serie */ + serie?: string; + /** Folio */ + folio: number; + /** Fecha del documento */ + fecha: Date; + /** Fecha de vencimiento */ + fechaVencimiento?: Date; + /** Codigo del cliente/proveedor */ + codigoClienteProveedor: string; + /** Nombre del cliente/proveedor */ + nombreClienteProveedor: string; + /** RFC del cliente/proveedor */ + rfcClienteProveedor: string; + /** Subtotal */ + subtotal: number; + /** Descuento */ + descuento: number; + /** IVA */ + iva: number; + /** IEPS */ + ieps?: number; + /** Retenciones */ + retenciones?: number; + /** Total */ + total: number; + /** Moneda */ + moneda: string; + /** Tipo de cambio */ + tipoCambio: number; + /** UUID del CFDI */ + uuid?: string; + /** Forma de pago */ + formaPago?: string; + /** Metodo de pago */ + metodoPago?: string; + /** Condiciones de pago */ + condicionesPago?: string; + /** Pendiente de cobro/pago */ + pendiente: number; + /** Estado del documento */ + estado: EstadoDocumento; + /** Cancelado */ + cancelado: boolean; + /** Fecha de cancelacion */ + fechaCancelacion?: Date; + /** Impreso */ + impreso: boolean; + /** Observaciones */ + observaciones?: string; + /** Movimientos (detalle de productos) */ + movimientos: CONTPAQiMovimientoDocumento[]; + /** Referencia */ + referencia?: string; + /** Lugar de expedicion */ + lugarExpedicion?: string; +} + +export type ConceptoDocumento = + | 'FacturaCliente' + | 'FacturaProveedor' + | 'NotaCreditoCliente' + | 'NotaCreditoProveedor' + | 'NotaCargoCliente' + | 'NotaCargoProveedor' + | 'Remision' + | 'Pedido' + | 'Cotizacion' + | 'OrdenCompra' + | 'DevolucionCliente' + | 'DevolucionProveedor'; + +export type EstadoDocumento = + | 'Pendiente' + | 'Parcial' + | 'Pagado' + | 'Cancelado'; + +/** + * Movimiento de documento (detalle de producto) + */ +export interface CONTPAQiMovimientoDocumento { + /** ID del movimiento */ + id: number; + /** ID del documento padre */ + documentoId: number; + /** Numero de movimiento */ + numMovimiento: number; + /** Codigo del producto */ + codigoProducto: string; + /** Nombre del producto */ + nombreProducto: string; + /** Cantidad */ + cantidad: number; + /** Unidad */ + unidad: string; + /** Precio unitario */ + precioUnitario: number; + /** Descuento */ + descuento: number; + /** Importe (sin impuestos) */ + importe: number; + /** IVA */ + iva: number; + /** IEPS */ + ieps?: number; + /** Total */ + total: number; + /** Clave SAT del producto */ + claveSAT?: string; + /** Clave unidad SAT */ + claveUnidadSAT?: string; + /** Referencia */ + referencia?: string; + /** Observaciones */ + observaciones?: string; + /** Numero de serie/lote */ + seriesLotes?: string[]; +} + +// ============================================================================ +// Comercial - Inventario +// ============================================================================ + +/** + * Existencia de producto en almacen + */ +export interface CONTPAQiExistencia { + /** ID del producto */ + productoId: number; + /** Codigo del producto */ + codigoProducto: string; + /** Nombre del producto */ + nombreProducto: string; + /** ID del almacen */ + almacenId: number; + /** Nombre del almacen */ + nombreAlmacen: string; + /** Existencia actual */ + existencia: number; + /** Unidad */ + unidad: string; + /** Existencia minima */ + existenciaMinima?: number; + /** Existencia maxima */ + existenciaMaxima?: number; + /** Punto de reorden */ + puntoReorden?: number; + /** Ultimo costo */ + ultimoCosto?: number; + /** Costo promedio */ + costoPromedio?: number; + /** Valor del inventario */ + valorInventario: number; +} + +/** + * Almacen + */ +export interface CONTPAQiAlmacen { + /** ID del almacen */ + id: number; + /** Codigo del almacen */ + codigo: string; + /** Nombre del almacen */ + nombre: string; + /** Direccion */ + direccion?: CONTPAQiDireccion; + /** Es almacen principal */ + esPrincipal: boolean; + /** Estado */ + estado: number; +} + +// ============================================================================ +// Nominas - Empleados +// ============================================================================ + +/** + * Empleado de CONTPAQi Nominas + */ +export interface CONTPAQiEmpleado { + /** ID del empleado */ + id: number; + /** Codigo/Numero de empleado */ + codigo: string; + /** Nombre completo */ + nombre: string; + /** Apellido paterno */ + apellidoPaterno: string; + /** Apellido materno */ + apellidoMaterno?: string; + /** RFC */ + rfc: string; + /** CURP */ + curp: string; + /** NSS (Numero de Seguro Social) */ + nss: string; + /** Fecha de nacimiento */ + fechaNacimiento?: Date; + /** Sexo */ + sexo?: 'M' | 'F'; + /** Estado civil */ + estadoCivil?: string; + /** Fecha de alta */ + fechaAlta: Date; + /** Fecha de baja */ + fechaBaja?: Date; + /** Fecha de antiguedad (si es diferente a fecha de alta) */ + fechaAntiguedad?: Date; + /** Tipo de contrato */ + tipoContrato: TipoContrato; + /** Tipo de regimen */ + tipoRegimen: TipoRegimenNomina; + /** Tipo de jornada */ + tipoJornada?: TipoJornada; + /** Periodicidad de pago */ + periodicidadPago: PeriodicidadPago; + /** Departamento */ + departamento?: string; + /** Puesto */ + puesto?: string; + /** Salario diario */ + salarioDiario: number; + /** Salario diario integrado */ + salarioDiarioIntegrado?: number; + /** Salario base de cotizacion */ + sbc?: number; + /** Banco */ + banco?: string; + /** CLABE */ + clabe?: string; + /** Cuenta bancaria */ + cuentaBancaria?: string; + /** Direccion */ + direccion?: CONTPAQiDireccion; + /** Email */ + email?: string; + /** Telefono */ + telefono?: string; + /** Estado (activo, baja, etc.) */ + estado: number; + /** Sindicalizado */ + sindicalizado?: boolean; + /** Registro patronal */ + registroPatronal?: string; + /** Riesgo de trabajo */ + riesgoTrabajo?: string; + /** Entidad federativa */ + entidadFederativa?: string; +} + +export type TipoContrato = + | 'PorTiempoIndeterminado' + | 'PorObraoDeterminado' + | 'PorTemporada' + | 'SujetoPrueba' + | 'CapacitacionInicial' + | 'PorTiempoIndeterminadoDesconexion'; + +export type TipoRegimenNomina = + | 'Sueldos' + | 'Asimilados' + | 'Jubilados' + | 'Honorarios'; + +export type TipoJornada = + | 'Diurna' + | 'Nocturna' + | 'Mixta' + | 'PorHora' + | 'ReducidaDiscapacidad' + | 'Continuada'; + +export type PeriodicidadPago = + | 'Diario' + | 'Semanal' + | 'Catorcenal' + | 'Quincenal' + | 'Mensual' + | 'Bimestral' + | 'PorUnidadObra' + | 'Comision' + | 'PrecioAlzado' + | 'Decenal' + | 'OtraPeriodidad'; + +// ============================================================================ +// Nominas - Periodos y Nominas +// ============================================================================ + +/** + * Periodo de nomina + */ +export interface CONTPAQiPeriodoNomina { + /** ID del periodo */ + id: number; + /** Numero de periodo */ + numero: number; + /** Ejercicio */ + ejercicio: number; + /** Fecha inicio */ + fechaInicio: Date; + /** Fecha fin */ + fechaFin: Date; + /** Fecha de pago */ + fechaPago: Date; + /** Tipo de nomina (Ordinaria, Extraordinaria) */ + tipoNomina: TipoNomina; + /** Dias del periodo */ + diasPeriodo: number; + /** Estado (Abierto, Cerrado, Calculado, Timbrado) */ + estado: EstadoPeriodoNomina; + /** Total percepciones */ + totalPercepciones?: number; + /** Total deducciones */ + totalDeducciones?: number; + /** Total neto */ + totalNeto?: number; +} + +export type TipoNomina = 'Ordinaria' | 'Extraordinaria'; +export type EstadoPeriodoNomina = 'Abierto' | 'Cerrado' | 'Calculado' | 'Timbrado'; + +/** + * Nomina de un empleado + */ +export interface CONTPAQiNomina { + /** ID del registro de nomina */ + id: number; + /** ID del empleado */ + empleadoId: number; + /** Codigo del empleado */ + codigoEmpleado: string; + /** Nombre del empleado */ + nombreEmpleado: string; + /** ID del periodo */ + periodoId: number; + /** Numero de periodo */ + numeroPeriodo: number; + /** Ejercicio */ + ejercicio: number; + /** Tipo de nomina */ + tipoNomina: TipoNomina; + /** Fecha de pago */ + fechaPago: Date; + /** Dias pagados */ + diasPagados: number; + /** Total percepciones */ + totalPercepciones: number; + /** Total gravado */ + totalGravado: number; + /** Total exento */ + totalExento: number; + /** Total deducciones */ + totalDeducciones: number; + /** Otros pagos */ + otrosPagos: number; + /** Neto a pagar */ + neto: number; + /** UUID del CFDI de nomina */ + uuid?: string; + /** Percepciones detalle */ + percepciones: CONTPAQiPercepcionNomina[]; + /** Deducciones detalle */ + deducciones: CONTPAQiDeduccionNomina[]; + /** Otros pagos detalle */ + otrosPagosDetalle?: CONTPAQiOtroPagoNomina[]; + /** Subsidio causado */ + subsidioCausado?: number; + /** ISR retenido */ + isrRetenido?: number; +} + +/** + * Percepcion en nomina + */ +export interface CONTPAQiPercepcionNomina { + /** ID */ + id: number; + /** ID de la nomina */ + nominaId: number; + /** Tipo de percepcion (clave SAT) */ + tipoPercepcion: string; + /** Clave de la percepcion */ + clave: string; + /** Concepto */ + concepto: string; + /** Importe gravado */ + importeGravado: number; + /** Importe exento */ + importeExento: number; + /** Total */ + total: number; +} + +/** + * Deduccion en nomina + */ +export interface CONTPAQiDeduccionNomina { + /** ID */ + id: number; + /** ID de la nomina */ + nominaId: number; + /** Tipo de deduccion (clave SAT) */ + tipoDeduccion: string; + /** Clave de la deduccion */ + clave: string; + /** Concepto */ + concepto: string; + /** Importe */ + importe: number; +} + +/** + * Otro pago en nomina + */ +export interface CONTPAQiOtroPagoNomina { + /** ID */ + id: number; + /** ID de la nomina */ + nominaId: number; + /** Tipo de otro pago (clave SAT) */ + tipoOtroPago: string; + /** Clave */ + clave: string; + /** Concepto */ + concepto: string; + /** Importe */ + importe: number; + /** Subsidio causado (si aplica) */ + subsidioCausado?: number; +} + +// ============================================================================ +// Sincronizacion +// ============================================================================ + +/** + * Configuracion de sincronizacion + */ +export interface CONTPAQiSyncConfig { + /** ID del tenant en Horux */ + tenantId: string; + /** Configuracion de conexion a CONTPAQi */ + connectionConfig: CONTPAQiConfig; + /** Productos a sincronizar */ + productos: CONTPAQiProducto[]; + /** Empresas a sincronizar (si vacio, todas) */ + empresas?: string[]; + /** Fecha desde la cual sincronizar */ + fechaDesde?: Date; + /** Fecha hasta la cual sincronizar */ + fechaHasta?: Date; + /** Sincronizar solo cambios incrementales */ + incremental?: boolean; + /** Ultimo timestamp de sincronizacion */ + lastSyncTimestamp?: Date; +} + +/** + * Resultado de sincronizacion + */ +export interface CONTPAQiSyncResult { + /** Sincronizacion exitosa */ + success: boolean; + /** ID del tenant */ + tenantId: string; + /** Fecha de inicio */ + startTime: Date; + /** Fecha de fin */ + endTime: Date; + /** Duracion en ms */ + duration: number; + /** Registros procesados por tipo */ + processed: { + empresas: number; + cuentas: number; + polizas: number; + movimientos: number; + clientes: number; + proveedores: number; + productos: number; + facturas: number; + empleados: number; + nominas: number; + }; + /** Registros creados */ + created: number; + /** Registros actualizados */ + updated: number; + /** Errores */ + errors: CONTPAQiSyncError[]; + /** Nuevo timestamp de sincronizacion */ + newSyncTimestamp: Date; +} + +/** + * Error de sincronizacion + */ +export interface CONTPAQiSyncError { + /** Tipo de registro */ + recordType: string; + /** ID del registro en CONTPAQi */ + recordId: string | number; + /** Mensaje de error */ + message: string; + /** Detalles adicionales */ + details?: Record; +} + +// ============================================================================ +// Errores +// ============================================================================ + +/** + * Error de conexion con CONTPAQi + */ +export class CONTPAQiConnectionError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly details?: string + ) { + super(message); + this.name = 'CONTPAQiConnectionError'; + } +} + +/** + * Error de consulta a CONTPAQi + */ +export class CONTPAQiQueryError extends Error { + constructor( + message: string, + public readonly query: string, + public readonly sqlError?: string + ) { + super(message); + this.name = 'CONTPAQiQueryError'; + } +} + +/** + * Error de sincronizacion + */ +export class CONTPAQiSyncException extends Error { + constructor( + message: string, + public readonly fase: string, + public readonly errores: CONTPAQiSyncError[] + ) { + super(message); + this.name = 'CONTPAQiSyncException'; + } +} + +/** + * Error de configuracion + */ +export class CONTPAQiConfigError extends Error { + constructor( + message: string, + public readonly campo: string + ) { + super(message); + this.name = 'CONTPAQiConfigError'; + } +} diff --git a/apps/api/src/services/integrations/contpaqi/index.ts b/apps/api/src/services/integrations/contpaqi/index.ts new file mode 100644 index 0000000..b79a67b --- /dev/null +++ b/apps/api/src/services/integrations/contpaqi/index.ts @@ -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'; diff --git a/apps/api/src/services/integrations/contpaqi/nominas.connector.ts b/apps/api/src/services/integrations/contpaqi/nominas.connector.ts new file mode 100644 index 0000000..5ae4de1 --- /dev/null +++ b/apps/api/src/services/integrations/contpaqi/nominas.connector.ts @@ -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 { + 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 = {}; + + 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 { + 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 { + 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 { + 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 { + 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 = { + 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 { + 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 { + 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 { + 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 = { 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 = { + 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 = { + PorTiempoIndeterminado: 1, + PorObraoDeterminado: 2, + PorTemporada: 3, + SujetoPrueba: 4, + CapacitacionInicial: 5, + PorTiempoIndeterminadoDesconexion: 6, + }; + return tipos[tipo]; + } + + private getTipoContratoNombre(tipo: number): TipoContrato { + const tipos: Record = { + 1: 'PorTiempoIndeterminado', + 2: 'PorObraoDeterminado', + 3: 'PorTemporada', + 4: 'SujetoPrueba', + 5: 'CapacitacionInicial', + 6: 'PorTiempoIndeterminadoDesconexion', + }; + return tipos[tipo] || 'PorTiempoIndeterminado'; + } + + private getTipoRegimenNombre(tipo: number): TipoRegimenNomina { + const tipos: Record = { + 1: 'Sueldos', + 2: 'Asimilados', + 3: 'Jubilados', + 4: 'Honorarios', + }; + return tipos[tipo] || 'Sueldos'; + } + + private getTipoJornadaNombre(tipo: number): TipoJornada { + const tipos: Record = { + 1: 'Diurna', + 2: 'Nocturna', + 3: 'Mixta', + 4: 'PorHora', + 5: 'ReducidaDiscapacidad', + 6: 'Continuada', + }; + return tipos[tipo] || 'Diurna'; + } + + private getPeriodicidadPagoNombre(tipo: number): PeriodicidadPago { + const tipos: Record = { + 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 = { + 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); +} diff --git a/apps/api/src/services/integrations/index.ts b/apps/api/src/services/integrations/index.ts new file mode 100644 index 0000000..4c57f6a --- /dev/null +++ b/apps/api/src/services/integrations/index.ts @@ -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'; diff --git a/apps/api/src/services/integrations/integration.manager.ts b/apps/api/src/services/integrations/integration.manager.ts new file mode 100644 index 0000000..e742f80 --- /dev/null +++ b/apps/api/src/services/integrations/integration.manager.ts @@ -0,0 +1,1269 @@ +/** + * Integration Manager + * + * Unified manager for all external integrations. + * Handles connector registration, connection testing, sync operations, + * and status monitoring. + */ + +import { getDatabase, TenantContext } from '@horux/database'; +import { logger } from '../../utils/logger.js'; +import { + IntegrationType, + IntegrationStatus, + SyncStatus, + SyncDirection, + SyncEntityType, + IntegrationConfig, + Integration, + SyncResult, + SyncLog, + SyncOptions, + ConnectionTestResult, + IntegrationProvider, + IIntegrationConnector, + IntegrationEvent, + IntegrationEventType, +} from './integration.types.js'; + +// ============================================================================ +// ERROR CLASSES +// ============================================================================ + +export class IntegrationError extends Error { + public readonly code: string; + public readonly statusCode: number; + public readonly integrationId?: string; + public readonly integrationType?: IntegrationType; + public readonly isRetryable: boolean; + public readonly details?: Record; + + constructor( + message: string, + code: string, + statusCode: number = 500, + options?: { + integrationId?: string; + integrationType?: IntegrationType; + isRetryable?: boolean; + details?: Record; + } + ) { + super(message); + this.name = 'IntegrationError'; + this.code = code; + this.statusCode = statusCode; + this.integrationId = options?.integrationId; + this.integrationType = options?.integrationType; + this.isRetryable = options?.isRetryable ?? false; + this.details = options?.details; + Object.setPrototypeOf(this, IntegrationError.prototype); + } +} + +export class ConnectorNotFoundError extends IntegrationError { + constructor(type: IntegrationType) { + super( + `Connector for integration type "${type}" is not registered`, + 'CONNECTOR_NOT_FOUND', + 404, + { integrationType: type, isRetryable: false } + ); + Object.setPrototypeOf(this, ConnectorNotFoundError.prototype); + } +} + +export class ConnectionError extends IntegrationError { + constructor(message: string, type: IntegrationType, details?: Record) { + super(message, 'CONNECTION_ERROR', 502, { + integrationType: type, + isRetryable: true, + details, + }); + Object.setPrototypeOf(this, ConnectionError.prototype); + } +} + +export class SyncError extends IntegrationError { + constructor( + message: string, + integrationId: string, + type: IntegrationType, + details?: Record + ) { + super(message, 'SYNC_ERROR', 500, { + integrationId, + integrationType: type, + isRetryable: true, + details, + }); + Object.setPrototypeOf(this, SyncError.prototype); + } +} + +// ============================================================================ +// INTEGRATION MANAGER CLASS +// ============================================================================ + +export class IntegrationManager { + private static instance: IntegrationManager; + private connectors: Map = new Map(); + private providers: Map = new Map(); + private eventListeners: Map void>> = new Map(); + + private constructor() { + this.initializeProviders(); + } + + /** + * Get singleton instance + */ + public static getInstance(): IntegrationManager { + if (!IntegrationManager.instance) { + IntegrationManager.instance = new IntegrationManager(); + } + return IntegrationManager.instance; + } + + /** + * Initialize available integration providers metadata + */ + private initializeProviders(): void { + const providers: IntegrationProvider[] = [ + { + type: IntegrationType.CONTPAQI, + name: 'CONTPAQi', + description: 'Integration con sistemas CONTPAQi (Comercial, Contabilidad, Nominas)', + category: 'erp', + logoUrl: '/integrations/contpaqi-logo.svg', + websiteUrl: 'https://www.contpaqi.com', + documentationUrl: '/docs/integrations/contpaqi', + supportedEntities: [ + SyncEntityType.TRANSACTIONS, + SyncEntityType.INVOICES, + SyncEntityType.CONTACTS, + SyncEntityType.PRODUCTS, + SyncEntityType.ACCOUNTS, + SyncEntityType.JOURNAL_ENTRIES, + ], + supportedDirections: [SyncDirection.IMPORT, SyncDirection.EXPORT, SyncDirection.BIDIRECTIONAL], + supportsRealtime: false, + supportsWebhooks: false, + requiresCredentials: true, + requiredFields: ['serverHost', 'databaseName', 'username', 'password', 'companyRfc'], + optionalFields: ['serverPort', 'sdkPath', 'sdkVersion', 'accountMappingProfile'], + isAvailable: true, + isBeta: false, + regions: ['MX'], + }, + { + type: IntegrationType.ASPEL, + name: 'Aspel', + description: 'Integracion con sistemas Aspel (SAE, COI, NOI, BANCO)', + category: 'erp', + logoUrl: '/integrations/aspel-logo.svg', + websiteUrl: 'https://www.aspel.com.mx', + documentationUrl: '/docs/integrations/aspel', + supportedEntities: [ + SyncEntityType.TRANSACTIONS, + SyncEntityType.INVOICES, + SyncEntityType.CONTACTS, + SyncEntityType.PRODUCTS, + SyncEntityType.ACCOUNTS, + ], + supportedDirections: [SyncDirection.IMPORT, SyncDirection.EXPORT], + supportsRealtime: false, + supportsWebhooks: false, + requiresCredentials: true, + requiredFields: ['product', 'serverHost', 'databasePath', 'username', 'password', 'companyCode'], + optionalFields: ['serverPort', 'version'], + isAvailable: true, + isBeta: false, + regions: ['MX'], + }, + { + type: IntegrationType.ODOO, + name: 'Odoo', + description: 'Integracion con Odoo ERP (on-premise y cloud)', + category: 'erp', + logoUrl: '/integrations/odoo-logo.svg', + websiteUrl: 'https://www.odoo.com', + documentationUrl: '/docs/integrations/odoo', + supportedEntities: [ + SyncEntityType.TRANSACTIONS, + SyncEntityType.INVOICES, + SyncEntityType.CONTACTS, + SyncEntityType.PRODUCTS, + SyncEntityType.ACCOUNTS, + SyncEntityType.JOURNAL_ENTRIES, + SyncEntityType.PAYMENTS, + ], + supportedDirections: [SyncDirection.IMPORT, SyncDirection.EXPORT, SyncDirection.BIDIRECTIONAL], + supportsRealtime: true, + supportsWebhooks: true, + requiresCredentials: true, + requiredFields: ['serverUrl', 'database', 'username', 'apiKey', 'companyId'], + optionalFields: ['version', 'useXmlRpc'], + isAvailable: true, + isBeta: false, + }, + { + type: IntegrationType.ALEGRA, + name: 'Alegra', + description: 'Integracion con Alegra Contabilidad', + category: 'accounting', + logoUrl: '/integrations/alegra-logo.svg', + websiteUrl: 'https://www.alegra.com', + documentationUrl: '/docs/integrations/alegra', + supportedEntities: [ + SyncEntityType.INVOICES, + SyncEntityType.CONTACTS, + SyncEntityType.PRODUCTS, + SyncEntityType.PAYMENTS, + ], + supportedDirections: [SyncDirection.IMPORT, SyncDirection.EXPORT, SyncDirection.BIDIRECTIONAL], + supportsRealtime: true, + supportsWebhooks: true, + requiresCredentials: true, + requiredFields: ['email', 'apiToken', 'country'], + optionalFields: ['companyId'], + isAvailable: true, + isBeta: false, + regions: ['MX', 'CO', 'PE', 'AR', 'CL'], + }, + { + type: IntegrationType.SAP, + name: 'SAP', + description: 'Integracion con SAP ERP y SAP Business One', + category: 'erp', + logoUrl: '/integrations/sap-logo.svg', + websiteUrl: 'https://www.sap.com', + documentationUrl: '/docs/integrations/sap', + supportedEntities: [ + SyncEntityType.TRANSACTIONS, + SyncEntityType.INVOICES, + SyncEntityType.CONTACTS, + SyncEntityType.PRODUCTS, + SyncEntityType.ACCOUNTS, + SyncEntityType.JOURNAL_ENTRIES, + SyncEntityType.PAYMENTS, + ], + supportedDirections: [SyncDirection.IMPORT, SyncDirection.EXPORT, SyncDirection.BIDIRECTIONAL], + supportsRealtime: true, + supportsWebhooks: true, + requiresCredentials: true, + requiredFields: ['serverHost', 'systemNumber', 'client', 'username', 'password', 'companyCode'], + optionalFields: ['serverPort', 'sapRouter', 'language', 'useSsl', 'isBusinessOne', 'serviceLayerUrl'], + isAvailable: true, + isBeta: true, + }, + { + type: IntegrationType.SAT, + name: 'SAT', + description: 'Servicio de Administracion Tributaria - Descarga de CFDIs', + category: 'fiscal', + logoUrl: '/integrations/sat-logo.svg', + websiteUrl: 'https://www.sat.gob.mx', + documentationUrl: '/docs/integrations/sat', + supportedEntities: [SyncEntityType.CFDIS], + supportedDirections: [SyncDirection.IMPORT], + supportsRealtime: false, + supportsWebhooks: false, + requiresCredentials: true, + requiredFields: ['rfc', 'certificateBase64', 'privateKeyBase64', 'privateKeyPassword'], + optionalFields: [ + 'fielCertificateBase64', + 'fielPrivateKeyBase64', + 'fielPrivateKeyPassword', + 'syncIngresos', + 'syncEgresos', + 'syncNomina', + 'syncPagos', + ], + isAvailable: true, + isBeta: false, + regions: ['MX'], + }, + { + type: IntegrationType.MANUAL, + name: 'Entrada Manual', + description: 'Captura manual de datos sin integracion externa', + category: 'custom', + supportedEntities: [ + SyncEntityType.TRANSACTIONS, + SyncEntityType.INVOICES, + SyncEntityType.CONTACTS, + SyncEntityType.CATEGORIES, + ], + supportedDirections: [], + supportsRealtime: false, + supportsWebhooks: false, + requiresCredentials: false, + requiredFields: [], + optionalFields: ['enabledEntities', 'defaultCategory', 'requireApproval', 'approvalThreshold'], + isAvailable: true, + isBeta: false, + }, + ]; + + for (const provider of providers) { + this.providers.set(provider.type, provider); + } + } + + // ============================================================================ + // CONNECTOR REGISTRATION + // ============================================================================ + + /** + * Register a connector for an integration type + */ + public registerConnector(connector: IIntegrationConnector): void { + if (this.connectors.has(connector.type)) { + logger.warn(`Connector for ${connector.type} is being replaced`); + } + this.connectors.set(connector.type, connector); + logger.info(`Registered connector for ${connector.type}`); + } + + /** + * Unregister a connector + */ + public unregisterConnector(type: IntegrationType): void { + this.connectors.delete(type); + logger.info(`Unregistered connector for ${type}`); + } + + /** + * Get a connector by type + */ + public getConnector(type: IntegrationType): IIntegrationConnector { + const connector = this.connectors.get(type); + if (!connector) { + throw new ConnectorNotFoundError(type); + } + return connector; + } + + /** + * Check if a connector is registered + */ + public hasConnector(type: IntegrationType): boolean { + return this.connectors.has(type); + } + + /** + * Get all registered connectors + */ + public getRegisteredConnectors(): IntegrationType[] { + return Array.from(this.connectors.keys()); + } + + // ============================================================================ + // PROVIDER INFORMATION + // ============================================================================ + + /** + * Get all available providers + */ + public getAvailableProviders(): IntegrationProvider[] { + return Array.from(this.providers.values()).filter((p) => p.isAvailable); + } + + /** + * Get provider metadata by type + */ + public getProvider(type: IntegrationType): IntegrationProvider | undefined { + return this.providers.get(type); + } + + /** + * Get providers by category + */ + public getProvidersByCategory(category: IntegrationProvider['category']): IntegrationProvider[] { + return Array.from(this.providers.values()).filter( + (p) => p.category === category && p.isAvailable + ); + } + + // ============================================================================ + // CONNECTION MANAGEMENT + // ============================================================================ + + /** + * Test connection for an integration + */ + public async testConnection( + tenant: TenantContext, + type: IntegrationType, + config: IntegrationConfig + ): Promise { + logger.info(`Testing connection for ${type}`, { tenantId: tenant.tenantId }); + + // For manual type, always return success + if (type === IntegrationType.MANUAL) { + return { + success: true, + latencyMs: 0, + message: 'Entrada manual no requiere conexion', + testedAt: new Date(), + capabilities: { + canRead: true, + canWrite: true, + canDelete: true, + supportedEntities: this.providers.get(type)?.supportedEntities || [], + }, + }; + } + + try { + const connector = this.getConnector(type); + const startTime = Date.now(); + const result = await connector.testConnection(config); + result.latencyMs = Date.now() - startTime; + + // Log result + await this.logConnectionTest(tenant, type, result); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`Connection test failed for ${type}`, { error: errorMessage, tenantId: tenant.tenantId }); + + const result: ConnectionTestResult = { + success: false, + latencyMs: 0, + message: `Error al probar conexion: ${errorMessage}`, + errorCode: 'CONNECTION_TEST_FAILED', + errorDetails: errorMessage, + testedAt: new Date(), + }; + + await this.logConnectionTest(tenant, type, result); + return result; + } + } + + /** + * Log connection test result + */ + private async logConnectionTest( + tenant: TenantContext, + type: IntegrationType, + result: ConnectionTestResult + ): Promise { + try { + const db = getDatabase(); + await db.queryTenant( + tenant, + `INSERT INTO integration_logs ( + integration_type, + event_type, + status, + message, + latency_ms, + metadata + ) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + type, + 'connection_test', + result.success ? 'success' : 'failed', + result.message, + result.latencyMs, + JSON.stringify({ + serverVersion: result.serverVersion, + capabilities: result.capabilities, + errorCode: result.errorCode, + errorDetails: result.errorDetails, + }), + ] + ); + } catch (error) { + logger.error('Failed to log connection test', { error, tenantId: tenant.tenantId }); + } + } + + // ============================================================================ + // SYNC OPERATIONS + // ============================================================================ + + /** + * Start a sync operation + */ + public async syncData( + tenant: TenantContext, + integrationId: string, + options?: SyncOptions + ): Promise { + logger.info(`Starting sync for integration ${integrationId}`, { + tenantId: tenant.tenantId, + options, + }); + + const db = getDatabase(); + + // Get integration + const integration = await this.getIntegration(tenant, integrationId); + if (!integration) { + throw new IntegrationError('Integration not found', 'INTEGRATION_NOT_FOUND', 404, { + integrationId, + }); + } + + if (!integration.isActive) { + throw new IntegrationError('Integration is not active', 'INTEGRATION_INACTIVE', 400, { + integrationId, + integrationType: integration.type, + }); + } + + // Check for running sync + const activeSync = await this.getActiveSync(tenant, integrationId); + if (activeSync && !options?.fullSync) { + throw new IntegrationError( + 'A sync is already in progress', + 'SYNC_IN_PROGRESS', + 409, + { integrationId, jobId: activeSync.jobId } + ); + } + + // Create sync job + const jobId = await this.createSyncJob(tenant, integration, options); + + // Get connector and start sync + try { + const connector = this.getConnector(integration.type); + + // Update integration status + await this.updateIntegrationStatus(tenant, integrationId, IntegrationStatus.ACTIVE); + + // Emit sync started event + this.emitEvent({ + type: IntegrationEventType.SYNC_STARTED, + integrationId, + tenantId: tenant.tenantId, + timestamp: new Date(), + data: { jobId, options }, + }); + + // Start sync + const result = await connector.sync({ + ...options, + entityTypes: options?.entityTypes || (integration.config as any).enabledEntities, + direction: options?.direction || (integration.config as any).syncDirection, + }); + + // Update job with results + await this.updateSyncJob(tenant, jobId, result); + + // Update integration last sync info + await this.updateIntegrationSyncInfo(tenant, integrationId, result); + + // Emit sync completed event + this.emitEvent({ + type: IntegrationEventType.SYNC_COMPLETED, + integrationId, + tenantId: tenant.tenantId, + timestamp: new Date(), + data: { jobId, result }, + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Update job with failure + await this.failSyncJob(tenant, jobId, errorMessage); + + // Update integration status + await this.updateIntegrationStatus( + tenant, + integrationId, + IntegrationStatus.ERROR, + errorMessage + ); + + // Emit sync failed event + this.emitEvent({ + type: IntegrationEventType.SYNC_FAILED, + integrationId, + tenantId: tenant.tenantId, + timestamp: new Date(), + data: { jobId, error: errorMessage }, + }); + + throw new SyncError(errorMessage, integrationId, integration.type); + } + } + + /** + * Get last sync status + */ + public async getLastSyncStatus( + tenant: TenantContext, + integrationId: string + ): Promise { + const db = getDatabase(); + + const result = await db.queryTenant( + tenant, + `SELECT + id, + integration_id as "integrationId", + job_id as "jobId", + entity_type as "entityType", + direction, + status, + started_at as "startedAt", + completed_at as "completedAt", + duration_ms as "durationMs", + total_records as "totalRecords", + created_records as "createdRecords", + updated_records as "updatedRecords", + skipped_records as "skippedRecords", + failed_records as "failedRecords", + error_count as "errorCount", + last_error as "lastError", + triggered_by as "triggeredBy", + triggered_by_user_id as "triggeredByUserId", + metadata, + created_at as "createdAt" + FROM integration_sync_logs + WHERE integration_id = $1 + ORDER BY created_at DESC + LIMIT 1`, + [integrationId] + ); + + return result.rows[0] || null; + } + + /** + * Get sync history + */ + public async getSyncHistory( + tenant: TenantContext, + integrationId: string, + limit: number = 50, + offset: number = 0 + ): Promise<{ logs: SyncLog[]; total: number }> { + const db = getDatabase(); + + const [logsResult, countResult] = await Promise.all([ + db.queryTenant( + tenant, + `SELECT + id, + integration_id as "integrationId", + job_id as "jobId", + entity_type as "entityType", + direction, + status, + started_at as "startedAt", + completed_at as "completedAt", + duration_ms as "durationMs", + total_records as "totalRecords", + created_records as "createdRecords", + updated_records as "updatedRecords", + skipped_records as "skippedRecords", + failed_records as "failedRecords", + error_count as "errorCount", + last_error as "lastError", + triggered_by as "triggeredBy", + triggered_by_user_id as "triggeredByUserId", + metadata, + created_at as "createdAt" + FROM integration_sync_logs + WHERE integration_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, + [integrationId, limit, offset] + ), + db.queryTenant<{ count: string }>( + tenant, + 'SELECT COUNT(*) as count FROM integration_sync_logs WHERE integration_id = $1', + [integrationId] + ), + ]); + + return { + logs: logsResult.rows, + total: parseInt(countResult.rows[0]?.count || '0', 10), + }; + } + + // ============================================================================ + // INTEGRATION CRUD + // ============================================================================ + + /** + * Get integration by ID + */ + public async getIntegration( + tenant: TenantContext, + integrationId: string + ): Promise { + const db = getDatabase(); + + const result = await db.queryTenant( + tenant, + `SELECT + id, + tenant_id as "tenantId", + type, + name, + description, + status, + is_active as "isActive", + config, + last_health_check_at as "lastHealthCheckAt", + health_status as "healthStatus", + health_message as "healthMessage", + last_sync_at as "lastSyncAt", + last_sync_status as "lastSyncStatus", + last_sync_error as "lastSyncError", + next_sync_at as "nextSyncAt", + total_syncs as "totalSyncs", + successful_syncs as "successfulSyncs", + failed_syncs as "failedSyncs", + created_by as "createdBy", + created_at as "createdAt", + updated_at as "updatedAt" + FROM integrations + WHERE id = $1 AND deleted_at IS NULL`, + [integrationId] + ); + + return result.rows[0] || null; + } + + /** + * Get all integrations for tenant + */ + public async getIntegrations( + tenant: TenantContext, + filters?: { + type?: IntegrationType; + status?: IntegrationStatus; + isActive?: boolean; + } + ): Promise { + const db = getDatabase(); + + const conditions: string[] = ['deleted_at IS NULL']; + const params: unknown[] = []; + let paramIndex = 1; + + if (filters?.type) { + conditions.push(`type = $${paramIndex++}`); + params.push(filters.type); + } + + if (filters?.status) { + conditions.push(`status = $${paramIndex++}`); + params.push(filters.status); + } + + if (filters?.isActive !== undefined) { + conditions.push(`is_active = $${paramIndex++}`); + params.push(filters.isActive); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const result = await db.queryTenant( + tenant, + `SELECT + id, + tenant_id as "tenantId", + type, + name, + description, + status, + is_active as "isActive", + config, + last_health_check_at as "lastHealthCheckAt", + health_status as "healthStatus", + health_message as "healthMessage", + last_sync_at as "lastSyncAt", + last_sync_status as "lastSyncStatus", + last_sync_error as "lastSyncError", + next_sync_at as "nextSyncAt", + total_syncs as "totalSyncs", + successful_syncs as "successfulSyncs", + failed_syncs as "failedSyncs", + created_by as "createdBy", + created_at as "createdAt", + updated_at as "updatedAt" + FROM integrations + ${whereClause} + ORDER BY created_at DESC`, + params + ); + + return result.rows; + } + + /** + * Create a new integration + */ + public async createIntegration( + tenant: TenantContext, + data: { + type: IntegrationType; + name: string; + description?: string; + config: IntegrationConfig; + createdBy: string; + } + ): Promise { + const db = getDatabase(); + + // Check if integration type already exists (except webhooks which can have multiple) + if (data.type !== IntegrationType.WEBHOOK) { + const existing = await db.queryTenant( + tenant, + 'SELECT id FROM integrations WHERE type = $1 AND deleted_at IS NULL', + [data.type] + ); + + if (existing.rows.length > 0) { + throw new IntegrationError( + `Ya existe una integracion de tipo ${data.type}`, + 'INTEGRATION_EXISTS', + 409, + { integrationType: data.type } + ); + } + } + + const result = await db.queryTenant( + tenant, + `INSERT INTO integrations ( + type, + name, + description, + status, + is_active, + config, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING + id, + tenant_id as "tenantId", + type, + name, + description, + status, + is_active as "isActive", + config, + created_by as "createdBy", + created_at as "createdAt", + updated_at as "updatedAt"`, + [ + data.type, + data.name, + data.description || null, + IntegrationStatus.PENDING, + true, + JSON.stringify(data.config), + data.createdBy, + ] + ); + + const integration = result.rows[0]; + + // Emit event + this.emitEvent({ + type: IntegrationEventType.CONNECTED, + integrationId: integration.id, + tenantId: tenant.tenantId, + timestamp: new Date(), + data: { type: data.type, name: data.name }, + }); + + logger.info(`Created integration ${integration.id}`, { + tenantId: tenant.tenantId, + type: data.type, + }); + + return integration; + } + + /** + * Update integration configuration + */ + public async updateIntegration( + tenant: TenantContext, + integrationId: string, + data: { + name?: string; + description?: string; + config?: Partial; + isActive?: boolean; + } + ): Promise { + const db = getDatabase(); + + // Get existing integration + const existing = await this.getIntegration(tenant, integrationId); + if (!existing) { + throw new IntegrationError('Integration not found', 'INTEGRATION_NOT_FOUND', 404, { + integrationId, + }); + } + + // Merge config if provided + const newConfig = data.config + ? { ...existing.config, ...data.config } + : existing.config; + + const result = await db.queryTenant( + tenant, + `UPDATE integrations SET + name = COALESCE($1, name), + description = COALESCE($2, description), + config = $3, + is_active = COALESCE($4, is_active), + updated_at = NOW() + WHERE id = $5 AND deleted_at IS NULL + RETURNING + id, + tenant_id as "tenantId", + type, + name, + description, + status, + is_active as "isActive", + config, + updated_at as "updatedAt"`, + [ + data.name || null, + data.description || null, + JSON.stringify(newConfig), + data.isActive ?? null, + integrationId, + ] + ); + + if (result.rows.length === 0) { + throw new IntegrationError('Integration not found', 'INTEGRATION_NOT_FOUND', 404, { + integrationId, + }); + } + + // Emit config updated event + this.emitEvent({ + type: IntegrationEventType.CONFIG_UPDATED, + integrationId, + tenantId: tenant.tenantId, + timestamp: new Date(), + data: { updatedFields: Object.keys(data) }, + }); + + return result.rows[0]; + } + + /** + * Delete (soft) an integration + */ + public async deleteIntegration( + tenant: TenantContext, + integrationId: string + ): Promise { + const db = getDatabase(); + + // Cancel any pending sync jobs + await db.queryTenant( + tenant, + `UPDATE sync_jobs SET + status = 'cancelled', + updated_at = NOW() + WHERE integration_id = $1 AND status IN ('pending', 'queued', 'running')`, + [integrationId] + ); + + // Soft delete the integration + const result = await db.queryTenant( + tenant, + `UPDATE integrations SET + is_active = false, + status = 'inactive', + deleted_at = NOW() + WHERE id = $1 AND deleted_at IS NULL`, + [integrationId] + ); + + if (result.rowCount === 0) { + throw new IntegrationError('Integration not found', 'INTEGRATION_NOT_FOUND', 404, { + integrationId, + }); + } + + // Emit disconnected event + this.emitEvent({ + type: IntegrationEventType.DISCONNECTED, + integrationId, + tenantId: tenant.tenantId, + timestamp: new Date(), + data: {}, + }); + + logger.info(`Deleted integration ${integrationId}`, { tenantId: tenant.tenantId }); + } + + // ============================================================================ + // PRIVATE HELPER METHODS + // ============================================================================ + + /** + * Get active sync for integration + */ + private async getActiveSync( + tenant: TenantContext, + integrationId: string + ): Promise<{ jobId: string } | null> { + const db = getDatabase(); + + const result = await db.queryTenant<{ id: string }>( + tenant, + `SELECT id FROM sync_jobs + WHERE integration_id = $1 AND status IN ('pending', 'queued', 'running') + ORDER BY created_at DESC + LIMIT 1`, + [integrationId] + ); + + return result.rows[0] ? { jobId: result.rows[0].id } : null; + } + + /** + * Create a sync job + */ + private async createSyncJob( + tenant: TenantContext, + integration: Integration, + options?: SyncOptions + ): Promise { + const db = getDatabase(); + + const result = await db.queryTenant<{ id: string }>( + tenant, + `INSERT INTO sync_jobs ( + integration_id, + job_type, + status, + parameters, + created_by + ) VALUES ($1, $2, $3, $4, $5) + RETURNING id`, + [ + integration.id, + `${integration.type}_sync`, + SyncStatus.PENDING, + JSON.stringify(options || {}), + tenant.userId, + ] + ); + + return result.rows[0].id; + } + + /** + * Update sync job with results + */ + private async updateSyncJob( + tenant: TenantContext, + jobId: string, + result: SyncResult + ): Promise { + const db = getDatabase(); + + await db.queryTenant( + tenant, + `UPDATE sync_jobs SET + status = $1, + completed_at = NOW(), + records_processed = $2, + records_created = $3, + records_updated = $4, + records_failed = $5, + progress = 100, + result_summary = $6 + WHERE id = $7`, + [ + result.status, + result.processedRecords, + result.createdRecords, + result.updatedRecords, + result.failedRecords, + JSON.stringify({ + totalRecords: result.totalRecords, + skippedRecords: result.skippedRecords, + errors: result.errors?.slice(0, 10), // Store first 10 errors + warnings: result.warnings?.slice(0, 10), + durationMs: result.durationMs, + }), + jobId, + ] + ); + + // Log sync result + await db.queryTenant( + 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, + triggered_by_user_id + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + [ + result.integrationId, + jobId, + result.entityType || 'all', + result.direction, + result.status, + result.startedAt, + result.durationMs, + result.totalRecords, + result.createdRecords, + result.updatedRecords, + result.skippedRecords, + result.failedRecords, + result.errors?.length || 0, + result.errors?.[0]?.message || null, + 'manual', + tenant.userId, + ] + ); + } + + /** + * Mark sync job as failed + */ + private async failSyncJob( + tenant: TenantContext, + jobId: string, + errorMessage: string + ): Promise { + const db = getDatabase(); + + await db.queryTenant( + tenant, + `UPDATE sync_jobs SET + status = 'failed', + completed_at = NOW(), + error_message = $1 + WHERE id = $2`, + [errorMessage, jobId] + ); + } + + /** + * Update integration status + */ + private async updateIntegrationStatus( + tenant: TenantContext, + integrationId: string, + status: IntegrationStatus, + errorMessage?: string + ): Promise { + const db = getDatabase(); + + await db.queryTenant( + tenant, + `UPDATE integrations SET + status = $1, + health_message = $2, + updated_at = NOW() + WHERE id = $3`, + [status, errorMessage || null, integrationId] + ); + } + + /** + * Update integration after sync + */ + private async updateIntegrationSyncInfo( + tenant: TenantContext, + integrationId: string, + result: SyncResult + ): Promise { + const db = getDatabase(); + + await db.queryTenant( + tenant, + `UPDATE integrations SET + last_sync_at = NOW(), + last_sync_status = $1, + last_sync_error = $2, + total_syncs = total_syncs + 1, + successful_syncs = successful_syncs + CASE WHEN $1 = 'completed' THEN 1 ELSE 0 END, + failed_syncs = failed_syncs + CASE WHEN $1 = 'failed' THEN 1 ELSE 0 END, + updated_at = NOW() + WHERE id = $3`, + [ + result.status, + result.status === SyncStatus.FAILED ? result.errors?.[0]?.message : null, + integrationId, + ] + ); + } + + // ============================================================================ + // EVENT SYSTEM + // ============================================================================ + + /** + * Subscribe to integration events + */ + public on(event: IntegrationEventType, listener: (event: IntegrationEvent) => void): void { + if (!this.eventListeners.has(event)) { + this.eventListeners.set(event, new Set()); + } + this.eventListeners.get(event)!.add(listener); + } + + /** + * Unsubscribe from integration events + */ + public off(event: IntegrationEventType, listener: (event: IntegrationEvent) => void): void { + this.eventListeners.get(event)?.delete(listener); + } + + /** + * Emit an integration event + */ + private emitEvent(event: IntegrationEvent): void { + const listeners = this.eventListeners.get(event.type); + if (listeners) { + for (const listener of listeners) { + try { + listener(event); + } catch (error) { + logger.error('Error in integration event listener', { + event: event.type, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + } + } +} + +// Export singleton instance +export const integrationManager = IntegrationManager.getInstance(); diff --git a/apps/api/src/services/integrations/integration.types.ts b/apps/api/src/services/integrations/integration.types.ts new file mode 100644 index 0000000..ae25f96 --- /dev/null +++ b/apps/api/src/services/integrations/integration.types.ts @@ -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; + 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; + 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; +} + +/** + * 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; +} + +/** + * Sync error details + */ +export interface SyncError { + code: string; + message: string; + sourceId?: string; + field?: string; + timestamp: Date; + retryable: boolean; + details?: Record; +} + +// ============================================================================ +// 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; + 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; + connect(config: IntegrationConfig): Promise; + disconnect(): Promise; + + // Health check + healthCheck(): Promise; + + // Sync operations + sync(options: SyncOptions): Promise; + getSyncStatus(jobId: string): Promise; + cancelSync(jobId: string): Promise; + + // Data operations + fetchRecords(entityType: SyncEntityType, options: FetchOptions): Promise; + pushRecords(entityType: SyncEntityType, records: unknown[]): Promise; + + // 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; + batchSize?: number; + dryRun?: boolean; +} + +/** + * Fetch options for retrieving records + */ +export interface FetchOptions { + limit?: number; + offset?: number; + startDate?: Date; + endDate?: Date; + modifiedSince?: Date; + filters?: Record; + 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; +} diff --git a/apps/api/src/services/integrations/odoo/accounting.connector.ts b/apps/api/src/services/integrations/odoo/accounting.connector.ts new file mode 100644 index 0000000..9bd5205 --- /dev/null +++ b/apps/api/src/services/integrations/odoo/accounting.connector.ts @@ -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 { + 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( + '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 { + const accounts = await this.client.searchRead( + 'account.account', + [['code', '=', code]], + undefined, + { limit: 1 } + ); + return accounts[0] || null; + } + + /** + * Obtiene cuentas por tipo + */ + async getAccountsByType( + accountType: OdooAccountType + ): Promise { + return this.client.searchRead( + '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> { + 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( + '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 { + return this.client.searchRead( + '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 { + 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( + '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 { + // 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( + '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 { + 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( + '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( + '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 { + 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 { + 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; + }>(); + + 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 { + 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>( + '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> { + const accounts = await this.client.searchRead( + '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 + ): BalanceSheetSection { + const typeGroups = new Map>(); + + 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 = { + 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); +} diff --git a/apps/api/src/services/integrations/odoo/index.ts b/apps/api/src/services/integrations/odoo/index.ts new file mode 100644 index 0000000..6990792 --- /dev/null +++ b/apps/api/src/services/integrations/odoo/index.ts @@ -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; + 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(); + }, + }; +} diff --git a/apps/api/src/services/integrations/odoo/inventory.connector.ts b/apps/api/src/services/integrations/odoo/inventory.connector.ts new file mode 100644 index 0000000..dbc81a5 --- /dev/null +++ b/apps/api/src/services/integrations/odoo/inventory.connector.ts @@ -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> { + 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( + '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> { + return this.getProducts({ + type: 'product', + ...options, + }); + } + + /** + * Busca producto por codigo interno + */ + async getProductByCode(code: string): Promise { + const products = await this.client.searchRead( + 'product.product', + [['default_code', '=', code]], + this.getProductFields(), + { limit: 1 } + ); + return products[0] || null; + } + + /** + * Busca producto por codigo de barras + */ + async getProductByBarcode(barcode: string): Promise { + const products = await this.client.searchRead( + 'product.product', + [['barcode', '=', barcode]], + this.getProductFields(), + { limit: 1 } + ); + return products[0] || null; + } + + /** + * Obtiene un producto por ID + */ + async getProductById(productId: number): Promise { + const products = await this.client.read( + 'product.product', + [productId], + this.getProductFields() + ); + return products[0] || null; + } + + /** + * Obtiene categorias de productos + */ + async getProductCategories( + options?: { + parentId?: number; + } + ): Promise { + 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( + '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 { + // 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> { + // 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( + '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> { + 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( + '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 { + 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> { + 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 { + 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 { + 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 { + const domain: OdooDomain = [['active', '=', true]]; + + if (options?.companyId) { + domain.push(['company_id', '=', options.companyId]); + } + + return this.client.searchRead( + '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 { + const warehouses = await this.client.read( + '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 { + 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); +} diff --git a/apps/api/src/services/integrations/odoo/invoicing.connector.ts b/apps/api/src/services/integrations/odoo/invoicing.connector.ts new file mode 100644 index 0000000..caa9819 --- /dev/null +++ b/apps/api/src/services/integrations/odoo/invoicing.connector.ts @@ -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> { + 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> { + return this.getInvoices(period, ['out_refund'], options); + } + + /** + * Obtiene resumen de facturas de cliente + */ + async getCustomerInvoiceSummary( + period: DatePeriod, + options?: { + companyId?: number; + } + ): Promise { + 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> { + 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> { + return this.getInvoices(period, ['in_refund'], options); + } + + /** + * Obtiene resumen de facturas de proveedor + */ + async getVendorBillSummary( + period: DatePeriod, + options?: { + companyId?: number; + } + ): Promise { + 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> { + 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( + '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> { + 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 { + return this.client.searchRead( + '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 { + const invoices = await this.client.read( + 'account.move', + [invoiceId], + this.getInvoiceFields() + ); + return invoices[0] || null; + } + + /** + * Obtiene una factura por numero/nombre + */ + async getInvoiceByNumber( + invoiceNumber: string, + options?: { companyId?: number } + ): Promise { + const domain: OdooDomain = [['name', '=', invoiceNumber]]; + + if (options?.companyId) { + domain.push(['company_id', '=', options.companyId]); + } + + const invoices = await this.client.searchRead( + '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> { + 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( + '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> { + 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> { + return this.getPayments(period, { + paymentType: 'outbound', + partnerType: 'supplier', + ...options, + }); + } + + /** + * Obtiene resumen de pagos + */ + async getPaymentSummary( + period: DatePeriod, + options?: { + companyId?: number; + } + ): Promise { + 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( + '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 { + const payment = await this.client.read( + '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( + '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> { + 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( + '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 { + return this.client.searchRead( + '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 { + 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 { + return this.createInvoice('in_invoice', data); + } + + /** + * Valida (publica) una factura + */ + async postInvoice(invoiceId: number): Promise { + return this.client.callMethod( + 'account.move', + 'action_post', + [[invoiceId]] + ); + } + + /** + * Cancela una factura + */ + async cancelInvoice(invoiceId: number): Promise { + return this.client.callMethod( + '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> { + 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( + '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 { + 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( + '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 { + const connection = this.client.getConnection(); + if (!connection) { + throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR'); + } + + const invoiceData: Record = { + 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); +} diff --git a/apps/api/src/services/integrations/odoo/odoo.client.ts b/apps/api/src/services/integrations/odoo/odoo.client.ts new file mode 100644 index 0000000..ae87fde --- /dev/null +++ b/apps/api/src/services/integrations/odoo/odoo.client.ts @@ -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(); +const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes + +// ============================================================================ +// Odoo Client Class +// ============================================================================ + +/** + * Cliente XML-RPC para Odoo ERP + */ +export class OdooClient { + private config: Required; + 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 { + // 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( + '/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 { + 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 { + 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 { + await this.ensureConnection(); + return this.execute(model, 'search_count', [domain]); + } + + /** + * Lee campos de registros por sus IDs + */ + async read>( + model: string, + ids: number[], + fields?: OdooFields + ): Promise { + 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[]>( + model, + 'read', + params + ); + + return result.map((r) => this.normalizeRecord(r)); + } + + /** + * Busca y lee registros en una sola llamada + */ + async searchRead>( + model: string, + domain: OdooDomain = [], + fields?: OdooFields, + pagination?: OdooPagination + ): Promise { + await this.ensureConnection(); + + const params: unknown[] = [domain]; + + const options: Record = {}; + 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[]>( + model, + 'search_read', + params + ); + + return result.map((r) => this.normalizeRecord(r)); + } + + /** + * Busca y lee registros con paginacion + */ + async searchReadPaginated>( + model: string, + domain: OdooDomain = [], + fields?: OdooFields, + pagination?: OdooPagination + ): Promise> { + const [items, total] = await Promise.all([ + this.searchRead(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 { + await this.ensureConnection(); + return this.execute(model, 'create', [values]); + } + + /** + * Crea multiples registros + */ + async createMany(model: string, valuesList: OdooValues[]): Promise { + 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 { + 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 { + await this.ensureConnection(); + + if (ids.length === 0) return true; + + return this.execute(model, 'unlink', [ids]); + } + + /** + * Ejecuta un metodo custom del modelo + */ + async callMethod( + model: string, + method: string, + args: unknown[] = [], + kwargs: Record = {} + ): Promise { + await this.ensureConnection(); + return this.execute(model, method, args, kwargs); + } + + /** + * Obtiene los campos disponibles de un modelo + */ + async getFields( + model: string, + attributes?: string[] + ): Promise> { + 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>( + xmlId: string, + fields?: OdooFields + ): Promise { + 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(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( + model: string, + method: string, + args: unknown[] = [], + kwargs: Record = {} + ): Promise { + const context = { + ...this.connection!.context, + ...kwargs, + }; + + return this.xmlRpcCall('/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( + endpoint: string, + method: string, + params: unknown[] + ): Promise { + 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 { + 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) => `${this.valueToXml(p)}`).join(''); + + return ` + + ${this.escapeXml(method)} + ${paramsXml} +`; + } + + /** + * Convierte un valor a XML-RPC + */ + private valueToXml(value: unknown): string { + if (value === null || value === undefined) { + return '0'; + } + + if (typeof value === 'boolean') { + return `${value ? 1 : 0}`; + } + + if (typeof value === 'number') { + if (Number.isInteger(value)) { + return `${value}`; + } + return `${value}`; + } + + if (typeof value === 'string') { + return `${this.escapeXml(value)}`; + } + + if (Array.isArray(value)) { + const items = value.map((v) => this.valueToXml(v)).join(''); + return `${items}`; + } + + if (typeof value === 'object') { + const members = Object.entries(value as Record) + .map( + ([k, v]) => + `${this.escapeXml(k)}${this.valueToXml(v)}` + ) + .join(''); + return `${members}`; + } + + return `${this.escapeXml(String(value))}`; + } + + /** + * Parsea una respuesta XML-RPC + */ + private parseXmlRpcResponse(xml: string): XmlRpcResponse { + // Verificar fault + const faultMatch = xml.match(/([\s\S]*?)<\/fault>/); + if (faultMatch && faultMatch[1]) { + const fault = this.parseXmlValue(faultMatch[1]) as Record; + return { + fault: { + faultCode: (fault.faultCode as number | string) || 0, + faultString: (fault.faultString as string) || 'Unknown error', + }, + }; + } + + // Parsear valor + const paramMatch = xml.match(/\s*([\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(/(\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(/(-?[\d.]+)<\/double>/); + if (doubleMatch && doubleMatch[1]) return parseFloat(doubleMatch[1]); + + // String + const stringMatch = xml.match(/([\s\S]*?)<\/string>/); + if (stringMatch && stringMatch[1] !== undefined) return this.unescapeXml(stringMatch[1]); + + // Nil/None + if (xml.includes('') || xml.includes('')) return null; + + // Boolean false (alternate representation) + if (xml.includes('') || xml.match(/\s*<\/value>/)) return false; + + // Array + const arrayMatch = xml.match(/\s*([\s\S]*?)<\/data>\s*<\/array>/); + if (arrayMatch && arrayMatch[1]) { + const items: unknown[] = []; + const valueRegex = /([\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(/([\s\S]*?)<\/struct>/); + if (structMatch && structMatch[1]) { + const obj: Record = {}; + const memberRegex = /\s*([\s\S]*?)<\/name>\s*([\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(`${match[2]}`); + } + } + return obj; + } + + // Value wrapper + const valueMatch = xml.match(/([\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, '''); + } + + /** + * 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 { + if (!this.connection) { + await this.authenticate(); + } + } + + /** + * Obtiene la version del servidor + */ + private async getServerVersion(): Promise { + try { + const version = await this.xmlRpcCall>( + '/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 { + try { + const companies = await this.xmlRpcCall[]>( + '/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(record: Record): T { + const normalized: Record = {}; + + 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 { + 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; +} diff --git a/apps/api/src/services/integrations/odoo/odoo.sync.ts b/apps/api/src/services/integrations/odoo/odoo.sync.ts new file mode 100644 index 0000000..8ba8628 --- /dev/null +++ b/apps/api/src/services/integrations/odoo/odoo.sync.ts @@ -0,0 +1,1052 @@ +/** + * Odoo Sync Service + * Servicio de sincronizacion entre Odoo ERP y Horux Strategy + */ + +import { v4 as uuidv4 } from 'uuid'; +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, + OdooSyncResult, + OdooSyncStats, + OdooSyncError, + OdooSyncOptions, + OdooWebhookPayload, + OdooInvoice, + OdooPayment, + OdooJournalEntry, + OdooPartner, + OdooProduct, + OdooStockMove, + HoruxTransactionMapping, +} from './odoo.types.js'; + +// ============================================================================ +// Tipos internos +// ============================================================================ + +interface SyncContext { + tenantId: string; + syncId: string; + client: OdooClient; + accounting: OdooAccountingConnector; + invoicing: OdooInvoicingConnector; + partners: OdooPartnersConnector; + inventory: OdooInventoryConnector; + options: OdooSyncOptions; + stats: OdooSyncStats; + errors: OdooSyncError[]; + startedAt: Date; +} + +interface LastSyncInfo { + tenantId: string; + syncId: string; + completedAt: Date; + lastInvoiceDate?: string; + lastPaymentDate?: string; + lastJournalEntryDate?: string; +} + +// Cache de ultima sincronizacion por tenant +const lastSyncCache = new Map(); + +// ============================================================================ +// Odoo Sync Service Class +// ============================================================================ + +/** + * Servicio de sincronizacion Odoo-Horux + */ +export class OdooSyncService { + // ========================================================================== + // Main Sync Methods + // ========================================================================== + + /** + * Sincroniza datos de Odoo a Horux + */ + async syncToHorux(options: OdooSyncOptions): Promise { + const syncId = uuidv4(); + const startedAt = new Date(); + + // Crear cliente y conectores + const client = createOdooClient(options.config); + await client.authenticate(); + + const ctx: SyncContext = { + tenantId: options.tenantId, + syncId, + client, + accounting: createAccountingConnector(client), + invoicing: createInvoicingConnector(client), + partners: createPartnersConnector(client), + inventory: createInventoryConnector(client), + options, + stats: this.initStats(), + errors: [], + startedAt, + }; + + try { + // Determinar periodo de sincronizacion + const period = this.getSyncPeriod(options); + + // Sincronizar en orden + if (options.includePartners !== false) { + await this.syncPartners(ctx); + } + + if (options.includeInvoices !== false) { + await this.syncInvoices(ctx, period); + } + + if (options.includePayments !== false) { + await this.syncPayments(ctx, period); + } + + if (options.includeJournalEntries !== false) { + await this.syncJournalEntries(ctx, period); + } + + if (options.includeProducts !== false) { + await this.syncProducts(ctx); + } + + if (options.includeStockMoves !== false) { + await this.syncStockMoves(ctx, period); + } + + // Actualizar cache de ultima sync + lastSyncCache.set(options.tenantId, { + tenantId: options.tenantId, + syncId, + completedAt: new Date(), + }); + + return this.buildResult(ctx, true); + } catch (error) { + ctx.errors.push({ + model: 'sync', + error: error instanceof Error ? error.message : 'Error desconocido', + details: error, + timestamp: new Date(), + }); + + return this.buildResult(ctx, false); + } finally { + client.disconnect(); + } + } + + /** + * Sincronizacion incremental (solo cambios desde la ultima sync) + */ + async syncIncremental( + tenantId: string, + config: OdooConfig + ): Promise { + const lastSync = lastSyncCache.get(tenantId); + const dateFrom = lastSync?.completedAt || new Date(Date.now() - 24 * 60 * 60 * 1000); + + return this.syncToHorux({ + tenantId, + config, + dateFrom, + dateTo: new Date(), + fullSync: false, + }); + } + + /** + * Sincronizacion completa + */ + async syncFull( + tenantId: string, + config: OdooConfig, + dateFrom?: Date + ): Promise { + return this.syncToHorux({ + tenantId, + config, + dateFrom: dateFrom || new Date(new Date().getFullYear(), 0, 1), + dateTo: new Date(), + fullSync: true, + }); + } + + // ========================================================================== + // Webhook Handlers + // ========================================================================== + + /** + * Procesa un webhook de Odoo + */ + async handleWebhook( + tenantId: string, + config: OdooConfig, + payload: OdooWebhookPayload + ): Promise<{ + processed: boolean; + recordsAffected: number; + errors: string[]; + }> { + const client = createOdooClient(config); + + try { + await client.authenticate(); + + let recordsAffected = 0; + const errors: string[] = []; + + // Procesar segun el modelo + switch (payload.model) { + case 'res.partner': + recordsAffected = await this.processPartnerWebhook( + tenantId, + client, + payload + ); + break; + + case 'account.move': + recordsAffected = await this.processInvoiceWebhook( + tenantId, + client, + payload + ); + break; + + case 'account.payment': + recordsAffected = await this.processPaymentWebhook( + tenantId, + client, + payload + ); + break; + + case 'stock.move': + recordsAffected = await this.processStockMoveWebhook( + tenantId, + client, + payload + ); + break; + + default: + errors.push(`Modelo no soportado: ${payload.model}`); + } + + return { + processed: errors.length === 0, + recordsAffected, + errors, + }; + } finally { + client.disconnect(); + } + } + + /** + * Valida la firma de un webhook + */ + validateWebhookSignature( + _payload: string, + signature: string, + secret: string + ): boolean { + // Implementar validacion HMAC si Odoo lo soporta + // Por ahora, solo verificar que la firma existe + return signature.length > 0 && secret.length > 0; + } + + // ========================================================================== + // Bidirectional Sync + // ========================================================================== + + /** + * Sincroniza una transaccion de Horux a Odoo + */ + async syncTransactionToOdoo( + config: OdooConfig, + transaction: HoruxTransactionMapping + ): Promise<{ + success: boolean; + odooId?: number; + error?: string; + }> { + const client = createOdooClient(config); + + try { + await client.authenticate(); + const invoicing = createInvoicingConnector(client); + + // Determinar tipo de documento + if (transaction.type === 'income') { + // Crear factura de cliente + const invoiceId = await invoicing.createCustomerInvoice({ + partnerId: parseInt(transaction.partnerId || '1'), + invoiceDate: transaction.date, + lines: [{ + name: transaction.description, + quantity: 1, + priceUnit: transaction.amount, + }], + ref: transaction.externalRef, + }); + + return { success: true, odooId: invoiceId }; + } else if (transaction.type === 'expense') { + // Crear factura de proveedor + const billId = await invoicing.createVendorBill({ + partnerId: parseInt(transaction.partnerId || '1'), + invoiceDate: transaction.date, + lines: [{ + name: transaction.description, + quantity: 1, + priceUnit: transaction.amount, + }], + ref: transaction.externalRef, + }); + + return { success: true, odooId: billId }; + } + + return { success: false, error: 'Tipo de transaccion no soportado' }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Error desconocido', + }; + } finally { + client.disconnect(); + } + } + + /** + * Sincroniza un contacto de Horux a Odoo + */ + async syncContactToOdoo( + config: OdooConfig, + contact: { + name: string; + email?: string; + phone?: string; + vat?: string; + isCustomer?: boolean; + isVendor?: boolean; + } + ): Promise<{ + success: boolean; + odooId?: number; + error?: string; + }> { + const client = createOdooClient(config); + + try { + await client.authenticate(); + const partners = createPartnersConnector(client); + + const partnerId = await partners.createPartner({ + name: contact.name, + email: contact.email, + phone: contact.phone, + vat: contact.vat, + isCustomer: contact.isCustomer, + isVendor: contact.isVendor, + }); + + return { success: true, odooId: partnerId }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Error desconocido', + }; + } finally { + client.disconnect(); + } + } + + // ========================================================================== + // Mapping Functions + // ========================================================================== + + /** + * Mapea una factura de Odoo a transaccion de Horux + */ + mapOdooInvoiceToTransaction(invoice: OdooInvoice): HoruxTransactionMapping { + const isIncome = ['out_invoice', 'in_refund'].includes(invoice.moveType); + const isRefund = ['out_refund', 'in_refund'].includes(invoice.moveType); + + return { + externalId: `odoo_invoice_${invoice.id}`, + externalRef: invoice.name, + date: new Date(invoice.invoiceDate || invoice.date), + description: this.buildInvoiceDescription(invoice), + amount: isRefund ? -invoice.amountTotal : invoice.amountTotal, + type: isIncome ? 'income' : 'expense', + partnerId: String(invoice.partnerId[0]), + partnerName: invoice.partnerId[1], + currencyCode: invoice.currencyId[1], + metadata: { + odooModel: 'account.move', + odooId: invoice.id, + moveType: invoice.moveType, + state: invoice.state, + paymentState: invoice.paymentState, + amountTax: invoice.amountTax, + amountResidual: invoice.amountResidual, + cfdiUuid: invoice.l10nMxEdiCfdiUuid, + }, + }; + } + + /** + * Mapea un pago de Odoo a transaccion de Horux + */ + mapOdooPaymentToTransaction(payment: OdooPayment): HoruxTransactionMapping { + const isIncome = payment.paymentType === 'inbound'; + + return { + externalId: `odoo_payment_${payment.id}`, + externalRef: payment.name, + date: new Date(payment.date), + description: `Pago ${payment.ref || payment.name}`, + amount: payment.amount, + type: isIncome ? 'income' : 'expense', + partnerId: String(payment.partnerId[0]), + partnerName: payment.partnerId[1], + currencyCode: payment.currencyId[1], + metadata: { + odooModel: 'account.payment', + odooId: payment.id, + paymentType: payment.paymentType, + partnerType: payment.partnerType, + state: payment.state, + journalId: payment.journalId[0], + journalName: payment.journalId[1], + }, + }; + } + + /** + * Mapea un asiento contable de Odoo a transaccion de Horux + */ + mapOdooJournalEntryToTransaction( + entry: OdooJournalEntry + ): HoruxTransactionMapping { + return { + externalId: `odoo_entry_${entry.id}`, + externalRef: entry.name, + date: new Date(entry.date), + description: entry.ref || entry.name, + amount: Math.abs(entry.amountTotal), + type: 'transfer', + partnerId: entry.partnerId ? String(entry.partnerId[0]) : undefined, + partnerName: entry.partnerId?.[1], + currencyCode: entry.currencyId[1], + metadata: { + odooModel: 'account.move', + odooId: entry.id, + moveType: entry.moveType, + state: entry.state, + journalId: entry.journalId[0], + journalName: entry.journalId[1], + }, + }; + } + + // ========================================================================== + // Private Sync Methods + // ========================================================================== + + /** + * Sincroniza partners + */ + private async syncPartners(ctx: SyncContext): Promise { + this.reportProgress(ctx, 'res.partner', 0, 0, 'Iniciando sincronizacion de contactos'); + + try { + const batchSize = ctx.options.batchSize || 100; + let offset = 0; + let total = 0; + let processed = 0; + + // Obtener clientes + do { + const result = await ctx.partners.getCustomers({ + pagination: { offset, limit: batchSize }, + }); + + if (offset === 0) { + total = result.total; + } + + for (const partner of result.items) { + try { + await this.processPartner(ctx, partner); + ctx.stats.partners.created++; + processed++; + } catch (error) { + ctx.stats.partners.errors++; + ctx.errors.push({ + model: 'res.partner', + recordId: partner.id, + error: error instanceof Error ? error.message : 'Error desconocido', + timestamp: new Date(), + }); + } + } + + this.reportProgress(ctx, 'res.partner', processed, total); + offset += batchSize; + } while (offset < total); + + // Obtener proveedores + offset = 0; + const vendorsResult = await ctx.partners.getVendors({ + pagination: { offset: 0, limit: batchSize }, + }); + + total = vendorsResult.total; + + do { + const result = await ctx.partners.getVendors({ + pagination: { offset, limit: batchSize }, + }); + + for (const partner of result.items) { + // Evitar duplicados (algunos pueden ser cliente y proveedor) + if (partner.customerRank > 0) continue; + + try { + await this.processPartner(ctx, partner); + ctx.stats.partners.created++; + processed++; + } catch (error) { + ctx.stats.partners.errors++; + ctx.errors.push({ + model: 'res.partner', + recordId: partner.id, + error: error instanceof Error ? error.message : 'Error desconocido', + timestamp: new Date(), + }); + } + } + + offset += batchSize; + } while (offset < total); + } catch (error) { + ctx.errors.push({ + model: 'res.partner', + error: `Error general: ${error instanceof Error ? error.message : 'Error desconocido'}`, + timestamp: new Date(), + }); + } + } + + /** + * Sincroniza facturas + */ + private async syncInvoices( + ctx: SyncContext, + period: { dateFrom: Date; dateTo: Date } + ): Promise { + this.reportProgress(ctx, 'account.move', 0, 0, 'Iniciando sincronizacion de facturas'); + + try { + const batchSize = ctx.options.batchSize || 100; + + // Facturas de cliente + await this.syncInvoicesByType(ctx, period, 'customer', batchSize); + + // Facturas de proveedor + await this.syncInvoicesByType(ctx, period, 'vendor', batchSize); + } catch (error) { + ctx.errors.push({ + model: 'account.move', + error: `Error general: ${error instanceof Error ? error.message : 'Error desconocido'}`, + timestamp: new Date(), + }); + } + } + + /** + * Sincroniza facturas por tipo + */ + private async syncInvoicesByType( + ctx: SyncContext, + period: { dateFrom: Date; dateTo: Date }, + type: 'customer' | 'vendor', + batchSize: number + ): Promise { + let offset = 0; + let total = 0; + let processed = 0; + + do { + const result = type === 'customer' + ? await ctx.invoicing.getCustomerInvoices(period, { + includeRefunds: true, + pagination: { offset, limit: batchSize }, + }) + : await ctx.invoicing.getVendorBills(period, { + includeRefunds: true, + pagination: { offset, limit: batchSize }, + }); + + if (offset === 0) { + total = result.total; + } + + for (const invoice of result.items) { + try { + const mapping = this.mapOdooInvoiceToTransaction(invoice); + await this.saveTransaction(ctx, mapping); + ctx.stats.invoices.created++; + processed++; + } catch (error) { + ctx.stats.invoices.errors++; + ctx.errors.push({ + model: 'account.move', + recordId: invoice.id, + externalRef: invoice.name, + error: error instanceof Error ? error.message : 'Error desconocido', + timestamp: new Date(), + }); + } + } + + this.reportProgress(ctx, 'account.move', processed, total); + offset += batchSize; + } while (offset < total); + } + + /** + * Sincroniza pagos + */ + private async syncPayments( + ctx: SyncContext, + period: { dateFrom: Date; dateTo: Date } + ): Promise { + this.reportProgress(ctx, 'account.payment', 0, 0, 'Iniciando sincronizacion de pagos'); + + try { + const batchSize = ctx.options.batchSize || 100; + let offset = 0; + let total = 0; + let processed = 0; + + do { + const result = await ctx.invoicing.getPayments(period, { + pagination: { offset, limit: batchSize }, + }); + + if (offset === 0) { + total = result.total; + } + + for (const payment of result.items) { + try { + const mapping = this.mapOdooPaymentToTransaction(payment); + await this.saveTransaction(ctx, mapping); + ctx.stats.payments.created++; + processed++; + } catch (error) { + ctx.stats.payments.errors++; + ctx.errors.push({ + model: 'account.payment', + recordId: payment.id, + externalRef: payment.name, + error: error instanceof Error ? error.message : 'Error desconocido', + timestamp: new Date(), + }); + } + } + + this.reportProgress(ctx, 'account.payment', processed, total); + offset += batchSize; + } while (offset < total); + } catch (error) { + ctx.errors.push({ + model: 'account.payment', + error: `Error general: ${error instanceof Error ? error.message : 'Error desconocido'}`, + timestamp: new Date(), + }); + } + } + + /** + * Sincroniza asientos contables + */ + private async syncJournalEntries( + ctx: SyncContext, + period: { dateFrom: Date; dateTo: Date } + ): Promise { + this.reportProgress(ctx, 'account.move.entry', 0, 0, 'Iniciando sincronizacion de asientos'); + + try { + const batchSize = ctx.options.batchSize || 100; + let offset = 0; + let total = 0; + let processed = 0; + + do { + const result = await ctx.accounting.getJournalEntries(period, { + state: 'posted', + pagination: { offset, limit: batchSize }, + }); + + if (offset === 0) { + total = result.total; + } + + for (const entry of result.items) { + try { + const mapping = this.mapOdooJournalEntryToTransaction(entry); + await this.saveTransaction(ctx, mapping); + ctx.stats.journalEntries.created++; + processed++; + } catch (error) { + ctx.stats.journalEntries.errors++; + ctx.errors.push({ + model: 'account.move', + recordId: entry.id, + externalRef: entry.name, + error: error instanceof Error ? error.message : 'Error desconocido', + timestamp: new Date(), + }); + } + } + + this.reportProgress(ctx, 'account.move.entry', processed, total); + offset += batchSize; + } while (offset < total); + } catch (error) { + ctx.errors.push({ + model: 'account.move', + error: `Error general: ${error instanceof Error ? error.message : 'Error desconocido'}`, + timestamp: new Date(), + }); + } + } + + /** + * Sincroniza productos + */ + private async syncProducts(ctx: SyncContext): Promise { + this.reportProgress(ctx, 'product.product', 0, 0, 'Iniciando sincronizacion de productos'); + + try { + const batchSize = ctx.options.batchSize || 100; + let offset = 0; + let total = 0; + let processed = 0; + + do { + const result = await ctx.inventory.getProducts({ + pagination: { offset, limit: batchSize }, + }); + + if (offset === 0) { + total = result.total; + } + + for (const product of result.items) { + try { + await this.processProduct(ctx, product); + ctx.stats.products.created++; + processed++; + } catch (error) { + ctx.stats.products.errors++; + ctx.errors.push({ + model: 'product.product', + recordId: product.id, + error: error instanceof Error ? error.message : 'Error desconocido', + timestamp: new Date(), + }); + } + } + + this.reportProgress(ctx, 'product.product', processed, total); + offset += batchSize; + } while (offset < total); + } catch (error) { + ctx.errors.push({ + model: 'product.product', + error: `Error general: ${error instanceof Error ? error.message : 'Error desconocido'}`, + timestamp: new Date(), + }); + } + } + + /** + * Sincroniza movimientos de stock + */ + private async syncStockMoves( + ctx: SyncContext, + period: { dateFrom: Date; dateTo: Date } + ): Promise { + this.reportProgress(ctx, 'stock.move', 0, 0, 'Iniciando sincronizacion de movimientos de stock'); + + try { + const batchSize = ctx.options.batchSize || 100; + let offset = 0; + let total = 0; + let processed = 0; + + do { + const result = await ctx.inventory.getStockMoves(period, { + state: 'done', + pagination: { offset, limit: batchSize }, + }); + + if (offset === 0) { + total = result.total; + } + + for (const move of result.items) { + try { + await this.processStockMove(ctx, move); + ctx.stats.stockMoves.created++; + processed++; + } catch (error) { + ctx.stats.stockMoves.errors++; + ctx.errors.push({ + model: 'stock.move', + recordId: move.id, + error: error instanceof Error ? error.message : 'Error desconocido', + timestamp: new Date(), + }); + } + } + + this.reportProgress(ctx, 'stock.move', processed, total); + offset += batchSize; + } while (offset < total); + } catch (error) { + ctx.errors.push({ + model: 'stock.move', + error: `Error general: ${error instanceof Error ? error.message : 'Error desconocido'}`, + timestamp: new Date(), + }); + } + } + + // ========================================================================== + // Webhook Processing + // ========================================================================== + + private async processPartnerWebhook( + _tenantId: string, + client: OdooClient, + payload: OdooWebhookPayload + ): Promise { + const partners = createPartnersConnector(client); + let processed = 0; + + for (const recordId of payload.recordIds) { + if (payload.action === 'unlink') { + // Marcar como eliminado en Horux + // await this.deletePartner(tenantId, recordId); + processed++; + } else { + const partner = await partners.getPartnerById(recordId); + if (partner) { + // await this.processPartner({ tenantId, ... }, partner); + processed++; + } + } + } + + return processed; + } + + private async processInvoiceWebhook( + _tenantId: string, + client: OdooClient, + payload: OdooWebhookPayload + ): Promise { + const invoicing = createInvoicingConnector(client); + let processed = 0; + + for (const recordId of payload.recordIds) { + if (payload.action === 'unlink') { + // Marcar como eliminado en Horux + processed++; + } else { + const invoice = await invoicing.getInvoiceById(recordId); + if (invoice && invoice.state === 'posted') { + // Mapping disponible para uso futuro + this.mapOdooInvoiceToTransaction(invoice); + // await this.saveTransaction({ tenantId, ... }, mapping); + processed++; + } + } + } + + return processed; + } + + private async processPaymentWebhook( + _tenantId: string, + _client: OdooClient, + payload: OdooWebhookPayload + ): Promise { + // Similar a processInvoiceWebhook + return payload.recordIds.length; + } + + private async processStockMoveWebhook( + _tenantId: string, + _client: OdooClient, + payload: OdooWebhookPayload + ): Promise { + // Similar a processInvoiceWebhook + return payload.recordIds.length; + } + + // ========================================================================== + // Helper Methods + // ========================================================================== + + private async processPartner(_ctx: SyncContext, _partner: OdooPartner): Promise { + // Aqui se implementaria la logica para guardar el partner en Horux + // Por ejemplo: await horuxDb.contacts.upsert({ tenantId: ctx.tenantId, ... }); + // Esta es una implementacion placeholder + } + + private async processProduct(_ctx: SyncContext, _product: OdooProduct): Promise { + // Aqui se implementaria la logica para guardar el producto en Horux + } + + private async processStockMove(_ctx: SyncContext, _move: OdooStockMove): Promise { + // Aqui se implementaria la logica para procesar el movimiento de stock + } + + private async saveTransaction( + _ctx: SyncContext, + _mapping: HoruxTransactionMapping + ): Promise { + // Aqui se implementaria la logica para guardar la transaccion en Horux + // Por ejemplo: await horuxDb.transactions.upsert({ tenantId: ctx.tenantId, ...mapping }); + // Esta es una implementacion placeholder + } + + private getSyncPeriod(options: OdooSyncOptions): { dateFrom: Date; dateTo: Date } { + const dateTo = options.dateTo || new Date(); + const dateFrom = options.dateFrom || new Date(dateTo.getFullYear(), 0, 1); + + return { dateFrom, dateTo }; + } + + private initStats(): OdooSyncStats { + return { + partners: { created: 0, updated: 0, errors: 0 }, + invoices: { created: 0, updated: 0, errors: 0 }, + payments: { created: 0, updated: 0, errors: 0 }, + journalEntries: { created: 0, updated: 0, errors: 0 }, + products: { created: 0, updated: 0, errors: 0 }, + stockMoves: { created: 0, updated: 0, errors: 0 }, + total: { created: 0, updated: 0, errors: 0 }, + }; + } + + private buildResult(ctx: SyncContext, success: boolean): OdooSyncResult { + const completedAt = new Date(); + + // Calcular totales + ctx.stats.total = { + created: + ctx.stats.partners.created + + ctx.stats.invoices.created + + ctx.stats.payments.created + + ctx.stats.journalEntries.created + + ctx.stats.products.created + + ctx.stats.stockMoves.created, + updated: + ctx.stats.partners.updated + + ctx.stats.invoices.updated + + ctx.stats.payments.updated + + ctx.stats.journalEntries.updated + + ctx.stats.products.updated + + ctx.stats.stockMoves.updated, + errors: + ctx.stats.partners.errors + + ctx.stats.invoices.errors + + ctx.stats.payments.errors + + ctx.stats.journalEntries.errors + + ctx.stats.products.errors + + ctx.stats.stockMoves.errors, + }; + + return { + success, + syncId: ctx.syncId, + startedAt: ctx.startedAt, + completedAt, + duration: completedAt.getTime() - ctx.startedAt.getTime(), + stats: ctx.stats, + errors: ctx.errors, + }; + } + + private reportProgress( + ctx: SyncContext, + model: string, + processed: number, + total: number, + currentItem?: string + ): void { + if (ctx.options.onProgress) { + ctx.options.onProgress({ + model, + processed, + total, + percentage: total > 0 ? Math.round((processed / total) * 100) : 0, + currentItem, + }); + } + } + + private buildInvoiceDescription(invoice: OdooInvoice): string { + const typeNames: Record = { + out_invoice: 'Factura', + out_refund: 'Nota de Credito', + in_invoice: 'Factura Proveedor', + in_refund: 'Nota de Credito Proveedor', + out_receipt: 'Recibo', + in_receipt: 'Recibo Proveedor', + }; + + const typeName = typeNames[invoice.moveType] || 'Documento'; + return `${typeName} ${invoice.name} - ${invoice.partnerId[1]}`; + } +} + +// ============================================================================ +// Factory Functions +// ============================================================================ + +/** + * Crea una instancia del servicio de sincronizacion + */ +export function createOdooSyncService(): OdooSyncService { + return new OdooSyncService(); +} + +/** + * Singleton del servicio de sincronizacion + */ +let syncServiceInstance: OdooSyncService | null = null; + +export function getOdooSyncService(): OdooSyncService { + if (!syncServiceInstance) { + syncServiceInstance = new OdooSyncService(); + } + return syncServiceInstance; +} diff --git a/apps/api/src/services/integrations/odoo/odoo.types.ts b/apps/api/src/services/integrations/odoo/odoo.types.ts new file mode 100644 index 0000000..d8e1761 --- /dev/null +++ b/apps/api/src/services/integrations/odoo/odoo.types.ts @@ -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; + 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; + 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; + +export type OdooDomainTuple = [string, string, unknown]; + +/** + * Opciones de paginacion + */ +export interface OdooPagination { + offset?: number; + limit?: number; + order?: string; +} + +/** + * Respuesta paginada + */ +export interface OdooPaginatedResponse { + 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; + +/** + * 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; +} diff --git a/apps/api/src/services/integrations/odoo/partners.connector.ts b/apps/api/src/services/integrations/odoo/partners.connector.ts new file mode 100644 index 0000000..08ee36e --- /dev/null +++ b/apps/api/src/services/integrations/odoo/partners.connector.ts @@ -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> { + const domain: OdooDomain = [ + ['active', '=', true], + ['customer_rank', '>', 0], + ]; + + this.applyCommonFilters(domain, options); + + if (options?.onlyWithCredit) { + domain.push(['credit', '>', 0]); + } + + return this.client.searchReadPaginated( + '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> { + 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( + 'res.partner', + domain, + this.getPartnerFields(), + { order: 'credit desc', ...options?.pagination } + ); + } + + /** + * Busca clientes por RFC/VAT + */ + async getCustomerByVat(vat: string): Promise { + const partners = await this.client.searchRead( + '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> { + const domain: OdooDomain = [ + ['active', '=', true], + ['supplier_rank', '>', 0], + ]; + + this.applyCommonFilters(domain, options); + + if (options?.onlyWithPayables) { + domain.push(['debit', '>', 0]); + } + + return this.client.searchReadPaginated( + '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> { + 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( + 'res.partner', + domain, + this.getPartnerFields(), + { order: 'debit desc', ...options?.pagination } + ); + } + + /** + * Busca proveedor por RFC/VAT + */ + async getVendorByVat(vat: string): Promise { + const partners = await this.client.searchRead( + '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 { + const connection = this.client.getConnection(); + if (!connection) { + throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR'); + } + + const partner = await this.client.read( + '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 { + 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> { + 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( + '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 { + return this.getPartnerStats(partnerId, 'customer', options); + } + + /** + * Obtiene estadisticas de un proveedor + */ + async getVendorStats( + partnerId: number, + options?: { + companyId?: number; + } + ): Promise { + return this.getPartnerStats(partnerId, 'vendor', options); + } + + /** + * Obtiene informacion de credito de un cliente + */ + async getCustomerCreditInfo( + partnerId: number, + options?: { + companyId?: number; + } + ): Promise { + const partner = await this.client.read( + '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 { + const partners = await this.client.read( + '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 { + 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( + '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 { + const values: Record = { + 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 { + const values: Record = {}; + + 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> { + 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 { + const result = await this.client.callMethod>( + '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 { + 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>( + '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 { + 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 { + 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); +} diff --git a/apps/api/src/services/integrations/sap/banking.connector.ts b/apps/api/src/services/integrations/sap/banking.connector.ts new file mode 100644 index 0000000..75ee4c3 --- /dev/null +++ b/apps/api/src/services/integrations/sap/banking.connector.ts @@ -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 { + logger.info('SAP Banking: Obteniendo cuentas bancarias'); + + return this.client.getAll('/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 { + logger.info('SAP Banking: Obteniendo cuenta bancaria', { absoluteEntry }); + return this.client.get(`/HouseBankAccounts(${absoluteEntry})`); + } + + /** + * Obtiene el resumen de cuentas bancarias con saldos + */ + async getBankAccountsSummary(): Promise { + 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( + `/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 { + logger.info('SAP Banking: Obteniendo pagos recibidos', { period, options }); + + const queryOptions = this.buildPaymentQuery(period, options); + return this.client.getAll('/IncomingPayments', queryOptions); + } + + /** + * Obtiene un pago recibido por DocEntry + */ + async getIncomingPayment(docEntry: number): Promise { + logger.info('SAP Banking: Obteniendo pago recibido', { docEntry }); + return this.client.get(`/IncomingPayments(${docEntry})`, { + $expand: ['PaymentInvoices', 'PaymentChecks', 'PaymentCreditCards'], + }); + } + + /** + * Obtiene pagos recibidos por cliente + */ + async getIncomingPaymentsByCustomer( + cardCode: string, + period?: Period + ): Promise { + 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; + }> { + logger.info('SAP Banking: Calculando total de pagos recibidos', { period }); + + const payments = await this.getIncomingPayments(period, { status: 'active' }); + + const byCurrency: Record = {}; + 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 { + logger.info('SAP Banking: Obteniendo pagos emitidos', { period, options }); + + const queryOptions = this.buildPaymentQuery(period, options); + return this.client.getAll('/VendorPayments', queryOptions); + } + + /** + * Obtiene un pago emitido por DocEntry + */ + async getOutgoingPayment(docEntry: number): Promise { + logger.info('SAP Banking: Obteniendo pago emitido', { docEntry }); + return this.client.get(`/VendorPayments(${docEntry})`, { + $expand: ['PaymentInvoices', 'PaymentChecks', 'PaymentCreditCards'], + }); + } + + /** + * Obtiene pagos emitidos por proveedor + */ + async getOutgoingPaymentsByVendor( + cardCode: string, + period?: Period + ): Promise { + 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; + }> { + logger.info('SAP Banking: Calculando total de pagos emitidos', { period }); + + const payments = await this.getOutgoingPayments(period, { status: 'active' }); + + const byCurrency: Record = {}; + 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 { + 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('/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 { + logger.info('SAP Banking: Obteniendo estado de cuenta', { internalNumber }); + return this.client.get(`/BankStatements(${internalNumber})`, { + $expand: ['BankStatementRows'], + }); + } + + /** + * Obtiene el ultimo estado de cuenta de una cuenta bancaria + */ + async getLatestBankStatement(bankCode: string, account: string): Promise { + logger.info('SAP Banking: Obteniendo ultimo estado de cuenta', { bankCode, account }); + + const statements = await this.client.getAll('/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 { + const statement = await this.getBankStatement(internalNumber); + return statement.BankStatementRows || []; + } + + // ========================================================================== + // Cash Flow Analysis + // ========================================================================== + + /** + * Obtiene el resumen de flujo de efectivo + */ + async getCashFlowSummary(period: Period): Promise { + 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('/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('/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; diff --git a/apps/api/src/services/integrations/sap/financials.connector.ts b/apps/api/src/services/integrations/sap/financials.connector.ts new file mode 100644 index 0000000..66205d6 --- /dev/null +++ b/apps/api/src/services/integrations/sap/financials.connector.ts @@ -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 { + 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', queryOptions); + } + + /** + * Obtiene una cuenta por su codigo + */ + async getAccount(code: string): Promise { + logger.info('SAP Financials: Obteniendo cuenta', { code }); + return this.client.get(`/ChartOfAccounts('${code}')`); + } + + /** + * Obtiene las categorias de cuenta + */ + async getAccountCategories(): Promise { + logger.info('SAP Financials: Obteniendo categorias de cuenta'); + return this.client.getAll('/AccountCategory', { + $orderby: 'CategoryCode asc', + }); + } + + /** + * Obtiene cuentas por categoria + */ + async getAccountsByCategory(categoryCode: number): Promise { + logger.info('SAP Financials: Obteniendo cuentas por categoria', { categoryCode }); + return this.client.getAll('/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 { + 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('/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 { + logger.info('SAP Financials: Obteniendo asiento contable', { jdtNum }); + return this.client.get(`/JournalEntries(${jdtNum})`, { + $expand: ['JournalEntryLines'], + }); + } + + /** + * Obtiene asientos contables por cuenta + */ + async getJournalEntriesByAccount( + accountCode: string, + period: Period + ): Promise { + 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('/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 { + 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(); + + // 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 { + 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 | 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 { + const balances = new Map(); + 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 { + 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', { + $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 | 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 | 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 { + 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(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> { + const agingReport = await this.getAgingReport(type); + + const summary = new Map(); + + 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; diff --git a/apps/api/src/services/integrations/sap/index.ts b/apps/api/src/services/integrations/sap/index.ts new file mode 100644 index 0000000..a4ef29f --- /dev/null +++ b/apps/api/src/services/integrations/sap/index.ts @@ -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 { + 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', + }; + } +} diff --git a/apps/api/src/services/integrations/sap/inventory.connector.ts b/apps/api/src/services/integrations/sap/inventory.connector.ts new file mode 100644 index 0000000..b41a73e --- /dev/null +++ b/apps/api/src/services/integrations/sap/inventory.connector.ts @@ -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 { + 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('/Items', queryOptions); + } + + /** + * Obtiene un articulo por su codigo + */ + async getItem(itemCode: string): Promise { + logger.info('SAP Inventory: Obteniendo articulo', { itemCode }); + return this.client.get(`/Items('${itemCode}')`, { + $expand: ['ItemPrices', 'ItemWarehouseInfoCollection'], + }); + } + + /** + * Busca articulos por nombre o codigo + */ + async searchItems(query: string, limit = 20): Promise { + 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('/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 { + return this.getItems({ itemGroup: groupCode, activeOnly: true }); + } + + /** + * Obtiene articulos con stock bajo + */ + async getLowStockItems(threshold?: number): Promise { + logger.info('SAP Inventory: Obteniendo articulos con stock bajo', { threshold }); + + // Obtener todos los articulos con stock info + const items = await this.client.getAll('/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 { + 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 { + 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('/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 { + logger.info('SAP Inventory: Obteniendo almacenes', { activeOnly }); + + const filters: string[] = []; + + if (activeOnly) { + filters.push("Inactive eq 'tNO'"); + } + + return this.client.getAll('/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 { + logger.info('SAP Inventory: Obteniendo almacen', { warehouseCode }); + return this.client.get(`/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 { + 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('/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 { + logger.info('SAP Inventory: Obteniendo transferencia de stock', { docEntry }); + return this.client.get(`/StockTransfers(${docEntry})`, { + $expand: ['StockTransferLines'], + }); + } + + /** + * Obtiene historial de movimientos de un articulo + */ + async getItemMovements( + itemCode: string, + period: Period + ): Promise { + 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 { + 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('/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 { + 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('/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('/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(); + + 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; diff --git a/apps/api/src/services/integrations/sap/purchasing.connector.ts b/apps/api/src/services/integrations/sap/purchasing.connector.ts new file mode 100644 index 0000000..e28a15e --- /dev/null +++ b/apps/api/src/services/integrations/sap/purchasing.connector.ts @@ -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 { + logger.info('SAP Purchasing: Obteniendo facturas de compra', { period, options }); + + const queryOptions = this.buildDocumentQuery(period, options); + return this.client.getAll('/PurchaseInvoices', queryOptions); + } + + /** + * Obtiene una factura de compra por DocEntry + */ + async getPurchaseInvoice(docEntry: number): Promise { + logger.info('SAP Purchasing: Obteniendo factura de compra', { docEntry }); + return this.client.get(`/PurchaseInvoices(${docEntry})`, { + $expand: ['DocumentLines', 'WithholdingTaxDataCollection'], + }); + } + + /** + * Obtiene facturas de compra por proveedor + */ + async getPurchaseInvoicesByVendor( + cardCode: string, + period?: Period, + status?: PurchaseDocumentStatus + ): Promise { + return this.getPurchaseInvoices( + period || this.getDefaultPeriod(), + { cardCode, status, includeLines: true } + ); + } + + /** + * Obtiene facturas de compra pendientes de pago + */ + async getOpenPurchaseInvoices(cardCode?: string): Promise { + 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('/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 { + 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('/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 { + logger.info('SAP Purchasing: Obteniendo ordenes de compra', { period, options }); + + const queryOptions = this.buildDocumentQuery(period, options); + return this.client.getAll('/PurchaseOrders', queryOptions); + } + + /** + * Obtiene una orden de compra por DocEntry + */ + async getPurchaseOrder(docEntry: number): Promise { + logger.info('SAP Purchasing: Obteniendo orden de compra', { docEntry }); + return this.client.get(`/PurchaseOrders(${docEntry})`, { + $expand: ['DocumentLines'], + }); + } + + /** + * Obtiene ordenes de compra abiertas + */ + async getOpenPurchaseOrders(cardCode?: string): Promise { + 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('/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 { + 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 { + logger.info('SAP Purchasing: Obteniendo entradas de mercancias', { period, options }); + + const queryOptions = this.buildDocumentQuery(period, options); + return this.client.getAll('/PurchaseDeliveryNotes', queryOptions); + } + + /** + * Obtiene una entrada de mercancias por DocEntry + */ + async getGoodsReceiptPOEntry(docEntry: number): Promise { + logger.info('SAP Purchasing: Obteniendo entrada de mercancias', { docEntry }); + return this.client.get(`/PurchaseDeliveryNotes(${docEntry})`, { + $expand: ['DocumentLines'], + }); + } + + /** + * Obtiene entradas de mercancias pendientes de facturar + */ + async getOpenGoodsReceiptPO(cardCode?: string): Promise { + 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('/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 { + 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 { + 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('/BusinessPartners', { + $filter: this.client.combineFilters(...filters), + $select: selectFields, + $orderby: 'CardName asc', + }); + } + + /** + * Obtiene un proveedor por CardCode + */ + async getVendor(cardCode: string): Promise { + logger.info('SAP Purchasing: Obteniendo proveedor', { cardCode }); + return this.client.get(`/BusinessPartners('${cardCode}')`, { + $expand: ['BPAddresses', 'ContactEmployees', 'BPBankAccounts'], + }); + } + + /** + * Busca proveedores por nombre o RFC + */ + async searchVendors(query: string, limit = 20): Promise { + 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('/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 { + 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; diff --git a/apps/api/src/services/integrations/sap/sales.connector.ts b/apps/api/src/services/integrations/sap/sales.connector.ts new file mode 100644 index 0000000..ebc091e --- /dev/null +++ b/apps/api/src/services/integrations/sap/sales.connector.ts @@ -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 { + logger.info('SAP Sales: Obteniendo facturas', { period, options }); + + const queryOptions = this.buildDocumentQuery(period, options); + return this.client.getAll('/Invoices', queryOptions); + } + + /** + * Obtiene una factura por DocEntry + */ + async getInvoice(docEntry: number): Promise { + logger.info('SAP Sales: Obteniendo factura', { docEntry }); + return this.client.get(`/Invoices(${docEntry})`, { + $expand: ['DocumentLines', 'WithholdingTaxDataCollection'], + }); + } + + /** + * Obtiene facturas por cliente + */ + async getInvoicesByCustomer( + cardCode: string, + period?: Period, + status?: DocumentStatus + ): Promise { + 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 { + 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('/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 { + logger.info('SAP Sales: Obteniendo notas de credito', { period, options }); + + const queryOptions = this.buildDocumentQuery(period, options); + return this.client.getAll('/CreditNotes', queryOptions); + } + + /** + * Obtiene una nota de credito por DocEntry + */ + async getCreditNote(docEntry: number): Promise { + logger.info('SAP Sales: Obteniendo nota de credito', { docEntry }); + return this.client.get(`/CreditNotes(${docEntry})`, { + $expand: ['DocumentLines', 'WithholdingTaxDataCollection'], + }); + } + + /** + * Obtiene notas de credito por cliente + */ + async getCreditNotesByCustomer( + cardCode: string, + period?: Period + ): Promise { + 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 { + logger.info('SAP Sales: Obteniendo notas de entrega', { period, options }); + + const queryOptions = this.buildDocumentQuery(period, options); + return this.client.getAll('/DeliveryNotes', queryOptions); + } + + /** + * Obtiene una nota de entrega por DocEntry + */ + async getDeliveryNote(docEntry: number): Promise { + logger.info('SAP Sales: Obteniendo nota de entrega', { docEntry }); + return this.client.get(`/DeliveryNotes(${docEntry})`, { + $expand: ['DocumentLines'], + }); + } + + /** + * Obtiene notas de entrega pendientes de facturar + */ + async getOpenDeliveryNotes(cardCode?: string): Promise { + 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('/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 { + logger.info('SAP Sales: Obteniendo pedidos de venta', { period, options }); + + const queryOptions = this.buildDocumentQuery(period, options); + return this.client.getAll('/Orders', queryOptions); + } + + /** + * Obtiene una orden de venta por DocEntry + */ + async getSalesOrder(docEntry: number): Promise { + logger.info('SAP Sales: Obteniendo pedido de venta', { docEntry }); + return this.client.get(`/Orders(${docEntry})`, { + $expand: ['DocumentLines'], + }); + } + + /** + * Obtiene ordenes de venta abiertas + */ + async getOpenSalesOrders(cardCode?: string): Promise { + 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('/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 { + 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('/BusinessPartners', { + $filter: this.client.combineFilters(...filters), + $select: selectFields, + $orderby: 'CardName asc', + }); + } + + /** + * Obtiene un cliente por CardCode + */ + async getCustomer(cardCode: string): Promise { + logger.info('SAP Sales: Obteniendo cliente', { cardCode }); + return this.client.get(`/BusinessPartners('${cardCode}')`, { + $expand: ['BPAddresses', 'ContactEmployees', 'BPBankAccounts'], + }); + } + + /** + * Busca clientes por nombre o RFC + */ + async searchCustomers(query: string, limit = 20): Promise { + 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('/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 { + 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; diff --git a/apps/api/src/services/integrations/sap/sap.client.ts b/apps/api/src/services/integrations/sap/sap.client.ts new file mode 100644 index 0000000..7008ad9 --- /dev/null +++ b/apps/api/src/services/integrations/sap/sap.client.ts @@ -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; + 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 { + try { + logger.info('SAP: Iniciando login', { + serviceLayerUrl: this.config.serviceLayerUrl, + companyDB: this.config.companyDB, + userName: this.config.userName, + }); + + const response = await this.httpRequest('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 { + 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 { + 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(endpoint: string, options?: ODataQueryOptions): Promise { + const url = this.buildUrlWithQuery(endpoint, options); + return this.request('GET', url); + } + + /** + * Realiza una peticion GET con paginacion OData + */ + async getAll(endpoint: string, options?: ODataQueryOptions): Promise { + const results: T[] = []; + let nextLink: string | undefined = this.buildUrlWithQuery(endpoint, options); + + while (nextLink) { + const response = await this.request>('GET', nextLink); + results.push(...response.value); + nextLink = response['odata.nextLink']; + } + + return results; + } + + /** + * Realiza una peticion GET paginada + */ + async getPaginated( + endpoint: string, + options?: ODataQueryOptions + ): Promise> { + const url = this.buildUrlWithQuery(endpoint, options); + return this.request>('GET', url); + } + + /** + * Realiza una peticion POST + */ + async post(endpoint: string, body: unknown): Promise { + return this.request('POST', endpoint, body); + } + + /** + * Realiza una peticion PATCH (update parcial) + */ + async patch(endpoint: string, body: unknown): Promise { + return this.request('PATCH', endpoint, body); + } + + /** + * Realiza una peticion PUT (update completo) + */ + async put(endpoint: string, body: unknown): Promise { + return this.request('PUT', endpoint, body); + } + + /** + * Realiza una peticion DELETE + */ + async delete(endpoint: string): Promise { + await this.request('DELETE', endpoint); + } + + /** + * Ejecuta multiples operaciones en batch + */ + async batch(operations: BatchOperation[]): Promise { + 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( + method: string, + endpoint: string, + body?: unknown, + retryCount = 0 + ): Promise { + await this.ensureSession(); + return this.httpRequest(method, endpoint, body, true, retryCount); + } + + /** + * Ejecuta una peticion HTTP raw + */ + private async httpRequest( + method: string, + endpoint: string, + body?: unknown, + withSession = true, + retryCount = 0 + ): Promise { + const url = endpoint.startsWith('http') + ? endpoint + : `${this.config.serviceLayerUrl}${endpoint}`; + + const headers: Record = { + '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(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(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 { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Obtiene la configuracion actual + */ + getConfig(): Readonly> { + return { ...this.config }; + } + + /** + * Obtiene la sesion actual + */ + getSession(): Readonly | 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 { + const client = new SAPClient(config); + await client.login(); + return client; +} + +export default SAPClient; diff --git a/apps/api/src/services/integrations/sap/sap.sync.ts b/apps/api/src/services/integrations/sap/sap.sync.ts new file mode 100644 index 0000000..43ababc --- /dev/null +++ b/apps/api/src/services/integrations/sap/sap.sync.ts @@ -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; +} + +/** + * 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; +} + +// ============================================================================ +// 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 { + 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 { + 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 { + 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; + } { + 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 { + 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 { + const service = new SAPSyncService(); + return service.syncToHorux(options); +} + +export default SAPSyncService; diff --git a/apps/api/src/services/integrations/sap/sap.types.ts b/apps/api/src/services/integrations/sap/sap.types.ts new file mode 100644 index 0000000..dc29091 --- /dev/null +++ b/apps/api/src/services/integrations/sap/sap.types.ts @@ -0,0 +1,1150 @@ +/** + * SAP Business One Types + * Tipos para la integracion con SAP B1 Service Layer + */ + +// ============================================================================ +// Configuration Types +// ============================================================================ + +/** + * Configuracion de conexion a SAP B1 Service Layer + */ +export interface SAPConfig { + /** URL base del Service Layer (ej: https://server:50000/b1s/v1) */ + serviceLayerUrl: string; + /** Nombre de la base de datos de la empresa */ + companyDB: string; + /** Usuario de SAP */ + userName: string; + /** Password del usuario */ + password: string; + /** Idioma (default: 'b') - 'b' para espanol */ + language?: string; + /** Timeout en milisegundos (default: 30000) */ + timeout?: number; + /** Verificar certificados SSL (default: true) */ + sslVerify?: boolean; + /** Reintentos automaticos (default: 3) */ + maxRetries?: number; + /** Tiempo de vida de la sesion en minutos (default: 30) */ + sessionLifetime?: number; +} + +/** + * Sesion activa de SAP B1 + */ +export interface SAPSession { + /** ID de la sesion */ + sessionId: string; + /** Cookie de sesion B1SESSION */ + b1Session: string; + /** Cookie ROUTEID para balanceo de carga */ + routeId?: string; + /** Momento de creacion */ + createdAt: Date; + /** Momento de expiracion */ + expiresAt: Date; + /** Nombre de la empresa conectada */ + companyDB: string; + /** Usuario conectado */ + userName: string; + /** Indica si la sesion es valida */ + isValid: boolean; +} + +/** + * Respuesta de login de SAP + */ +export interface SAPLoginResponse { + 'odata.metadata': string; + SessionId: string; + Version: string; + SessionTimeout: number; +} + +/** + * Error de SAP Service Layer + */ +export interface SAPErrorResponse { + error: { + code: string; + message: { + lang: string; + value: string; + }; + }; +} + +// ============================================================================ +// OData Query Types +// ============================================================================ + +/** + * Opciones de consulta OData + */ +export interface ODataQueryOptions { + /** Campos a seleccionar */ + $select?: string[]; + /** Filtros OData */ + $filter?: string; + /** Ordenamiento */ + $orderby?: string; + /** Numero de registros a saltar */ + $skip?: number; + /** Numero de registros a retornar */ + $top?: number; + /** Expandir navegaciones */ + $expand?: string[]; + /** Incluir conteo total */ + $inlinecount?: 'allpages' | 'none'; + /** Cross join */ + $crossjoin?: string[]; + /** Aplicar transformaciones */ + $apply?: string; +} + +/** + * Respuesta OData con paginacion + */ +export interface ODataResponse { + 'odata.metadata': string; + 'odata.nextLink'?: string; + 'odata.count'?: number; + value: T[]; +} + +/** + * Operacion de batch + */ +export interface BatchOperation { + /** Metodo HTTP */ + method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'; + /** URL relativa del recurso */ + url: string; + /** Cuerpo de la peticion */ + body?: unknown; + /** Headers adicionales */ + headers?: Record; + /** ID de contenido para referencias */ + contentId?: string; +} + +/** + * Resultado de operacion batch + */ +export interface BatchResult { + /** Status HTTP */ + status: number; + /** Headers de respuesta */ + headers: Record; + /** Cuerpo de respuesta */ + body: unknown; + /** ID de contenido */ + contentId?: string; +} + +// ============================================================================ +// Business Partner Types +// ============================================================================ + +/** + * Tipo de socio de negocios + */ +export type CardType = 'cCustomer' | 'cSupplier' | 'cLead'; + +/** + * Tipo de grupo de socio + */ +export type CardGroupType = 'Customer' | 'Supplier'; + +/** + * Socio de negocios (Cliente/Proveedor) + */ +export interface BusinessPartner { + CardCode: string; + CardName: string; + CardType: CardType; + CardForeignName?: string; + FederalTaxID?: string; + Address?: string; + ZipCode?: string; + City?: string; + County?: string; + Country?: string; + State?: string; + EmailAddress?: string; + Phone1?: string; + Phone2?: string; + Cellular?: string; + Fax?: string; + ContactPerson?: string; + Notes?: string; + PayTermsGrpCode?: number; + CreditLimit?: number; + MaxCommitment?: number; + DiscountPercent?: number; + VatLiable?: 'vLiable' | 'vExempted' | 'vEC'; + Currency?: string; + GroupCode?: number; + PriceListNum?: number; + IntrestRatePercent?: number; + Frozen?: 'tYES' | 'tNO'; + FrozenFrom?: string; + FrozenTo?: string; + FrozenRemarks?: string; + Balance?: number; + OpenDeliveryNotesBalance?: number; + OpenOrdersBalance?: number; + Valid?: 'tYES' | 'tNO'; + ValidFrom?: string; + ValidTo?: string; + ValidRemarks?: string; + CreateDate?: string; + UpdateDate?: string; + SalesPersonCode?: number; + BPAddresses?: BPAddress[]; + ContactEmployees?: ContactEmployee[]; + BPBankAccounts?: BPBankAccount[]; + BPPaymentMethods?: BPPaymentMethod[]; + U_HoruxId?: string; + [key: `U_${string}`]: unknown; +} + +/** + * Direccion del socio de negocios + */ +export interface BPAddress { + AddressName: string; + AddressType: 'bo_BillTo' | 'bo_ShipTo'; + Street?: string; + Block?: string; + ZipCode?: string; + City?: string; + County?: string; + Country?: string; + State?: string; + FederalTaxID?: string; + BuildingFloorRoom?: string; + AddressName2?: string; + AddressName3?: string; + StreetNo?: string; + GlobalLocationNumber?: string; +} + +/** + * Contacto del socio de negocios + */ +export interface ContactEmployee { + Name: string; + CardCode?: string; + Position?: string; + Address?: string; + Phone1?: string; + Phone2?: string; + Fax?: string; + E_Mail?: string; + Remarks1?: string; + Remarks2?: string; + FirstName?: string; + MiddleName?: string; + LastName?: string; + Title?: string; + MobilePhone?: string; + Active?: 'tYES' | 'tNO'; +} + +/** + * Cuenta bancaria del socio + */ +export interface BPBankAccount { + BPCode?: string; + LogInstance?: number; + BankCode?: string; + AccountNo?: string; + Branch?: string; + AccountName?: string; + IBAN?: string; + BICSwiftCode?: string; + ControlKey?: string; +} + +/** + * Metodo de pago del socio + */ +export interface BPPaymentMethod { + PaymentMethodCode: string; + BPCode?: string; +} + +// ============================================================================ +// Document Types (Invoice, Credit Note, etc.) +// ============================================================================ + +/** + * Base para documentos de marketing + */ +export interface MarketingDocument { + DocEntry: number; + DocNum: number; + DocType: 'dDocument_Items' | 'dDocument_Service'; + DocDate: string; + DocDueDate: string; + TaxDate?: string; + CardCode: string; + CardName?: string; + Address?: string; + NumAtCard?: string; + DocCurrency?: string; + DocRate?: number; + DocTotal: number; + DocTotalFC?: number; + VatSum?: number; + VatSumFC?: number; + DiscountPercent?: number; + DiscSum?: number; + DiscSumFC?: number; + PaidToDate?: number; + PaidToDateFC?: number; + Comments?: string; + JournalMemo?: string; + PaymentGroupCode?: number; + DocObjectCode?: string; + Cancelled?: 'tYES' | 'tNO'; + DocumentStatus?: 'bost_Open' | 'bost_Close' | 'bost_Paid' | 'bost_Delivered'; + SalesPersonCode?: number; + TransportationCode?: number; + ControlAccount?: string; + FederalTaxID?: string; + CreateDate?: string; + UpdateDate?: string; + Series?: number; + U_HoruxId?: string; + U_FolioFiscalUUID?: string; + U_SerieCFDI?: string; + U_FolioCFDI?: string; + DocumentLines: DocumentLine[]; + WithholdingTaxDataCollection?: WithholdingTaxData[]; + [key: `U_${string}`]: unknown; +} + +/** + * Linea de documento + */ +export interface DocumentLine { + LineNum: number; + ItemCode?: string; + ItemDescription?: string; + Quantity?: number; + UnitPrice?: number; + PriceAfterVAT?: number; + Currency?: string; + Rate?: number; + DiscountPercent?: number; + LineTotal?: number; + GrossTotal?: number; + WarehouseCode?: string; + AccountCode?: string; + VatGroup?: string; + TaxTotal?: number; + GrossProfit?: number; + GrossProfitFC?: number; + ProjectCode?: string; + CostingCode?: string; + CostingCode2?: string; + CostingCode3?: string; + CostingCode4?: string; + CostingCode5?: string; + FreeText?: string; + ShipDate?: string; + BaseType?: number; + BaseEntry?: number; + BaseLine?: number; + [key: `U_${string}`]: unknown; +} + +/** + * Datos de retencion + */ +export interface WithholdingTaxData { + WTCode: string; + WTAmountSys?: number; + WTAmountFC?: number; + WTAmount?: number; + WTAccount?: string; + BaseType?: number; + Rate?: number; +} + +/** + * Factura de venta + */ +export interface Invoice extends MarketingDocument { + DocObjectCode: 'oInvoices'; +} + +/** + * Nota de credito de venta + */ +export interface CreditNote extends MarketingDocument { + DocObjectCode: 'oCreditNotes'; +} + +/** + * Nota de entrega (remision) + */ +export interface DeliveryNote extends MarketingDocument { + DocObjectCode: 'oDeliveryNotes'; +} + +/** + * Orden de venta + */ +export interface SalesOrder extends MarketingDocument { + DocObjectCode: 'oOrders'; +} + +/** + * Factura de compra + */ +export interface PurchaseInvoice extends MarketingDocument { + DocObjectCode: 'oPurchaseInvoices'; +} + +/** + * Orden de compra + */ +export interface PurchaseOrder extends MarketingDocument { + DocObjectCode: 'oPurchaseOrders'; +} + +/** + * Entrada de mercancias PO + */ +export interface GoodsReceiptPO extends MarketingDocument { + DocObjectCode: 'oPurchaseDeliveryNotes'; +} + +// ============================================================================ +// Payment Types +// ============================================================================ + +/** + * Pago recibido (de clientes) + */ +export interface IncomingPayment { + DocEntry: number; + DocNum: number; + DocType: 'rCustomer' | 'rAccount'; + DocDate: string; + CardCode?: string; + CardName?: string; + DocCurrency?: string; + DocRate?: number; + CashSum?: number; + CashSumFC?: number; + CashAccount?: string; + CheckAccount?: string; + TransferAccount?: string; + TransferSum?: number; + TransferSumFC?: number; + TransferDate?: string; + TransferReference?: string; + Cancelled?: 'tYES' | 'tNO'; + ControlAccount?: string; + TaxDate?: string; + Series?: number; + BankCode?: string; + BankAccount?: string; + JournalRemarks?: string; + PaymentInvoices?: PaymentInvoice[]; + PaymentChecks?: PaymentCheck[]; + PaymentCreditCards?: PaymentCreditCard[]; + U_HoruxId?: string; + [key: `U_${string}`]: unknown; +} + +/** + * Pago emitido (a proveedores) + */ +export interface OutgoingPayment { + DocEntry: number; + DocNum: number; + DocType: 'rSupplier' | 'rAccount'; + DocDate: string; + CardCode?: string; + CardName?: string; + DocCurrency?: string; + DocRate?: number; + CashSum?: number; + CashSumFC?: number; + CashAccount?: string; + CheckAccount?: string; + TransferAccount?: string; + TransferSum?: number; + TransferSumFC?: number; + TransferDate?: string; + TransferReference?: string; + Cancelled?: 'tYES' | 'tNO'; + ControlAccount?: string; + TaxDate?: string; + Series?: number; + BankCode?: string; + BankAccount?: string; + JournalRemarks?: string; + PaymentInvoices?: PaymentInvoice[]; + PaymentChecks?: PaymentCheck[]; + PaymentCreditCards?: PaymentCreditCard[]; + U_HoruxId?: string; + [key: `U_${string}`]: unknown; +} + +/** + * Factura asociada a pago + */ +export interface PaymentInvoice { + LineNum?: number; + DocEntry?: number; + SumApplied?: number; + AppliedFC?: number; + AppliedSys?: number; + DocLine?: number; + InvoiceType?: 'it_Invoice' | 'it_CredItnote' | 'it_DownPayment' | 'it_PurchaseInvoice' | 'it_PurchaseCreditNote' | 'it_PurchaseDownPayment'; + DiscountPercent?: number; + TotalDiscount?: number; + TotalDiscountFC?: number; +} + +/** + * Cheque de pago + */ +export interface PaymentCheck { + LineNum?: number; + DueDate?: string; + CheckNumber?: number; + BankCode?: string; + Branch?: string; + AccounttNum?: string; + Details?: string; + CheckSum?: number; + Currency?: string; + CountryCode?: string; + Trnsfrable?: 'tYES' | 'tNO'; +} + +/** + * Tarjeta de credito en pago + */ +export interface PaymentCreditCard { + LineNum?: number; + CreditCard?: number; + CreditCardNumber?: string; + CardValidUntil?: string; + VoucherNum?: string; + OwnerIdNum?: string; + CreditSum?: number; + CreditSumFC?: number; + CreditCurrency?: string; + CreditType?: number; + SplitPayments?: 'tYES' | 'tNO'; + NumOfPayments?: number; + FirstPaymentSum?: number; + FirstPaymentSumFC?: number; + AdditionalPaymentSum?: number; +} + +// ============================================================================ +// Journal Entry Types +// ============================================================================ + +/** + * Asiento contable + */ +export interface JournalEntry { + JdtNum: number; + TransId?: number; + Series?: number; + Memo?: string; + Reference?: string; + Reference2?: string; + Reference3?: string; + TransactionCode?: string; + ProjectCode?: string; + TaxDate?: string; + DueDate?: string; + RefDate?: string; + CreateDate?: string; + UpdateDate?: string; + StornoDate?: string; + VatDate?: string; + Indicator?: string; + UseAutoStorno?: 'tYES' | 'tNO'; + AutoVAT?: 'tYES' | 'tNO'; + AutoWT?: 'tYES' | 'tNO'; + JournalEntryLines: JournalEntryLine[]; + U_HoruxId?: string; + [key: `U_${string}`]: unknown; +} + +/** + * Linea de asiento contable + */ +export interface JournalEntryLine { + Line_ID?: number; + AccountCode?: string; + Debit?: number; + Credit?: number; + FCDebit?: number; + FCCredit?: number; + FCCurrency?: string; + DueDate?: string; + ShortName?: string; + Reference1?: string; + Reference2?: string; + ProjectCode?: string; + CostingCode?: string; + CostingCode2?: string; + CostingCode3?: string; + CostingCode4?: string; + CostingCode5?: string; + ControlAccount?: string; + LineMemo?: string; + TaxDate?: string; + VatLine?: 'tYES' | 'tNO'; + VatGroup?: string; + BaseSum?: number; + VatAmount?: number; + [key: `U_${string}`]: unknown; +} + +/** + * Voucher de diario (poliza) + */ +export interface JournalVoucher { + JournalEntry: JournalEntry; + VoucherNumber?: string; + ReportingDate?: string; + DueDate?: string; + PrintedFlag?: 'tYES' | 'tNO'; + JournalVoucherLines?: JournalVoucherLine[]; +} + +/** + * Linea de voucher + */ +export interface JournalVoucherLine { + LineNum?: number; + AccountCode?: string; + Debit?: number; + Credit?: number; + ShortName?: string; + Reference1?: string; + Reference2?: string; + LineMemo?: string; +} + +// ============================================================================ +// Inventory Types +// ============================================================================ + +/** + * Articulo/Producto + */ +export interface Item { + ItemCode: string; + ItemName: string; + ForeignName?: string; + ItemsGroupCode?: number; + CustomsGroupCode?: number; + SalesVATGroup?: string; + BarCode?: string; + VatLiable?: 'tYES' | 'tNO'; + PurchaseItem?: 'tYES' | 'tNO'; + SalesItem?: 'tYES' | 'tNO'; + InventoryItem?: 'tYES' | 'tNO'; + QuantityOnStock?: number; + QuantityOrderedFromVendors?: number; + QuantityOrderedByCustomers?: number; + ManageSerialNumbers?: 'tYES' | 'tNO'; + ManageBatchNumbers?: 'tYES' | 'tNO'; + Valid?: 'tYES' | 'tNO'; + ValidFrom?: string; + ValidTo?: string; + ValidRemarks?: string; + Frozen?: 'tYES' | 'tNO'; + FrozenFrom?: string; + FrozenTo?: string; + FrozenRemarks?: string; + SalesUnit?: string; + SalesItemsPerUnit?: number; + SalesPackagingUnit?: string; + SalesQtyPerPackUnit?: number; + PurchaseUnit?: string; + PurchaseItemsPerUnit?: number; + PurchasePackagingUnit?: string; + PurchaseQtyPerPackUnit?: number; + InventoryUOM?: string; + DefaultWarehouse?: string; + Manufacturer?: number; + AvgStdPrice?: number; + ItemPrices?: ItemPrice[]; + ItemWarehouseInfoCollection?: ItemWarehouseInfo[]; + U_HoruxId?: string; + [key: `U_${string}`]: unknown; +} + +/** + * Precio de articulo + */ +export interface ItemPrice { + PriceList?: number; + Price?: number; + Currency?: string; + AdditionalPrice1?: number; + AdditionalCurrency1?: string; + AdditionalPrice2?: number; + AdditionalCurrency2?: string; + UoMEntry?: number; +} + +/** + * Informacion de articulo por almacen + */ +export interface ItemWarehouseInfo { + WarehouseCode?: string; + InStock?: number; + Committed?: number; + Ordered?: number; + Counted?: number; + MinimalStock?: number; + MaximalStock?: number; + MinimalOrder?: number; + StandardAveragePrice?: number; + Locked?: 'tYES' | 'tNO'; +} + +/** + * Almacen + */ +export interface Warehouse { + WarehouseCode: string; + WarehouseName: string; + Location?: number; + Nettable?: 'tYES' | 'tNO'; + DropShip?: 'tYES' | 'tNO'; + SubtractFG?: 'tYES' | 'tNO'; + TaxGroup?: 'tYES' | 'tNO'; + TaxCode?: string; + Inactive?: 'tYES' | 'tNO'; + Street?: string; + Block?: string; + ZipCode?: string; + City?: string; + County?: string; + Country?: string; + State?: string; + BranchCode?: number; + EnableBinLocations?: 'tYES' | 'tNO'; + DefaultBin?: number; + U_HoruxId?: string; + [key: `U_${string}`]: unknown; +} + +/** + * Transferencia de stock + */ +export interface StockTransfer { + DocEntry: number; + DocNum: number; + DocDate: string; + DueDate?: string; + TaxDate?: string; + FromWarehouse: string; + ToWarehouse: string; + Comments?: string; + JournalMemo?: string; + PriceList?: number; + SalesPersonCode?: number; + ContactPersonCode?: number; + Series?: number; + StockTransferLines: StockTransferLine[]; + U_HoruxId?: string; + [key: `U_${string}`]: unknown; +} + +/** + * Linea de transferencia de stock + */ +export interface StockTransferLine { + LineNum?: number; + ItemCode?: string; + ItemDescription?: string; + Quantity?: number; + UnitPrice?: number; + Currency?: string; + FromWarehouseCode?: string; + WarehouseCode?: string; + ProjectCode?: string; + CostingCode?: string; + SerialNumbers?: StockTransferSerialNumber[]; + BatchNumbers?: StockTransferBatchNumber[]; + [key: `U_${string}`]: unknown; +} + +/** + * Numero de serie en transferencia + */ +export interface StockTransferSerialNumber { + SystemSerialNumber?: number; + ManufacturerSerialNumber?: string; + InternalSerialNumber?: string; + Quantity?: number; +} + +/** + * Numero de lote en transferencia + */ +export interface StockTransferBatchNumber { + BatchNumber?: string; + ManufacturerSerialNumber?: string; + InternalSerialNumber?: string; + Quantity?: number; +} + +// ============================================================================ +// Chart of Accounts Types +// ============================================================================ + +/** + * Cuenta contable + */ +export interface ChartOfAccounts { + Code: string; + Name: string; + ForeignName?: string; + Balance?: number; + CashAccount?: 'tYES' | 'tNO'; + BudgetAccount?: 'tYES' | 'tNO'; + ActiveAccount?: 'tYES' | 'tNO'; + PrimaryAccount?: 'tYES' | 'tNO'; + AccountType?: 'at_Revenues' | 'at_Expenses' | 'at_Other'; + ExternalCode?: string; + AcctCurrency?: string; + BalanceSysCurr?: number; + BalanceFrgnCurr?: number; + Protected?: 'tYES' | 'tNO'; + ReconcileAccount?: 'tYES' | 'tNO'; + Postable?: 'tYES' | 'tNO'; + BlockManualPosting?: 'tYES' | 'tNO'; + ControlAccount?: 'tYES' | 'tNO'; + AccountLevel?: number; + FatherAccountKey?: string; + ProjectRelevant?: 'tYES' | 'tNO'; + DistributionRuleRelevant?: 'tYES' | 'tNO'; + DrawingRelevant?: 'tYES' | 'tNO'; + ValidFor?: 'tYES' | 'tNO'; + ValidFrom?: string; + ValidTo?: string; + CashFlowRelevant?: 'tYES' | 'tNO'; + AccountCategory?: number; + U_HoruxId?: string; + [key: `U_${string}`]: unknown; +} + +/** + * Categoria de cuenta + */ +export interface AccountCategory { + CategoryCode: number; + CategoryName: string; + CategoryDescription?: string; + AccountType?: 'at_Revenues' | 'at_Expenses' | 'at_Other'; + ParentCategory?: number; + Postable?: 'tYES' | 'tNO'; +} + +// ============================================================================ +// Banking Types +// ============================================================================ + +/** + * Cuenta bancaria de empresa + */ +export interface HouseBankAccount { + AbsoluteEntry: number; + BankCode: string; + BranchNumber?: string; + AccountNo: string; + GLAccount: string; + Country?: string; + Bank?: string; + AccountName?: string; + ZipCode?: string; + City?: string; + County?: string; + State?: string; + IBAN?: string; + BICSwiftCode?: string; + ControlKey?: string; + UserNum1?: string; + UserNum2?: string; + UserNum3?: string; + UserNum4?: string; + ISRBillerID?: string; + ISRType?: number; + AccountCheckDigit?: string; + OurNum?: string; + NextCheckNum?: number; + CompanyName?: string; +} + +/** + * Estado de cuenta bancario + */ +export interface BankStatement { + InternalNumber: number; + BankCode: string; + Account?: string; + StatementNumber?: string; + StatementDate?: string; + StartingBalance?: number; + EndingBalance?: number; + Currency?: string; + SequenceNo?: number; + BankStatementRows?: BankStatementRow[]; +} + +/** + * Linea de estado de cuenta + */ +export interface BankStatementRow { + LineNum?: number; + DueDate?: string; + Reference?: string; + Details?: string; + DebitAmount?: number; + CreditAmount?: number; + ExternalCode?: string; + VisualOrder?: number; + MultiplePayments?: number; + MatchedBP?: string; +} + +// ============================================================================ +// Reporting Types +// ============================================================================ + +/** + * Balance de prueba + */ +export interface TrialBalanceEntry { + AccountCode: string; + AccountName: string; + DebitBalance: number; + CreditBalance: number; + DebitPeriod: number; + CreditPeriod: number; + OpeningBalance: number; + ClosingBalance: number; +} + +/** + * Linea de estado de resultados + */ +export interface ProfitAndLossEntry { + AccountCode: string; + AccountName: string; + AccountType: string; + CurrentPeriod: number; + YearToDate: number; + PreviousYear?: number; + Budget?: number; + PercentOfTotal?: number; +} + +/** + * Linea de balance general + */ +export interface BalanceSheetEntry { + AccountCode: string; + AccountName: string; + AccountCategory: string; + CurrentBalance: number; + PreviousPeriodBalance?: number; + PreviousYearBalance?: number; +} + +/** + * Reporte de antiguedad de saldos + */ +export interface AgingReportEntry { + CardCode: string; + CardName: string; + DocEntry: number; + DocNum: number; + DocDate: string; + DueDate: string; + DocTotal: number; + Balance: number; + Current: number; + Days1_30: number; + Days31_60: number; + Days61_90: number; + Days91_120: number; + Over120: number; + Currency?: string; +} + +/** + * Valuacion de inventario + */ +export interface InventoryValuationEntry { + ItemCode: string; + ItemName: string; + WarehouseCode: string; + WarehouseName?: string; + InStock: number; + Committed: number; + Available: number; + UnitPrice: number; + TotalValue: number; + Currency?: string; +} + +// ============================================================================ +// Sync Types +// ============================================================================ + +/** + * Periodo de consulta + */ +export interface Period { + startDate: Date; + endDate: Date; +} + +/** + * Filtro de fecha + */ +export interface DateFilter { + field: string; + from?: Date; + to?: Date; +} + +/** + * Resultado de sincronizacion + */ +export interface SAPSyncResult { + success: boolean; + tenantId: string; + source: 'sap_b1'; + syncStartedAt: Date; + syncCompletedAt?: Date; + recordsProcessed: number; + recordsSaved: number; + recordsFailed: number; + errors: SAPSyncError[]; + summary: { + invoices?: number; + creditNotes?: number; + payments?: number; + journalEntries?: number; + businessPartners?: number; + items?: number; + }; +} + +/** + * Error de sincronizacion + */ +export interface SAPSyncError { + entity: string; + identifier?: string; + error: string; + code?: string; + timestamp: Date; +} + +/** + * Mapeo de transaccion SAP a Horux + */ +export interface SAPTransactionMapping { + sapDocEntry: number; + sapDocNum: number; + sapDocType: string; + horuxTransactionId?: string; + horuxCfdiId?: string; + mappedAt: Date; + status: 'pending' | 'mapped' | 'error'; + error?: string; +} + +// ============================================================================ +// Error Types +// ============================================================================ + +/** + * Error base de SAP + */ +export class SAPError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly details?: string, + public readonly httpStatus?: number + ) { + super(message); + this.name = 'SAPError'; + } +} + +/** + * Error de autenticacion SAP + */ +export class SAPAuthError extends SAPError { + constructor(message: string, code: string = 'AUTH_ERROR') { + super(message, code); + this.name = 'SAPAuthError'; + } +} + +/** + * Error de sesion expirada + */ +export class SAPSessionExpiredError extends SAPError { + constructor(message: string = 'Sesion SAP expirada') { + super(message, 'SESSION_EXPIRED'); + this.name = 'SAPSessionExpiredError'; + } +} + +/** + * Error de conexion SAP + */ +export class SAPConnectionError extends SAPError { + constructor(message: string, details?: string) { + super(message, 'CONNECTION_ERROR', details); + this.name = 'SAPConnectionError'; + } +} + +/** + * Error de recurso no encontrado + */ +export class SAPNotFoundError extends SAPError { + constructor(resource: string, identifier: string) { + super(`${resource} no encontrado: ${identifier}`, 'NOT_FOUND'); + this.name = 'SAPNotFoundError'; + } +} + +/** + * Error de validacion SAP + */ +export class SAPValidationError extends SAPError { + constructor(message: string, details?: string) { + super(message, 'VALIDATION_ERROR', details); + this.name = 'SAPValidationError'; + } +} diff --git a/apps/api/src/services/integrations/sync.scheduler.ts b/apps/api/src/services/integrations/sync.scheduler.ts new file mode 100644 index 0000000..7013dbe --- /dev/null +++ b/apps/api/src/services/integrations/sync.scheduler.ts @@ -0,0 +1,1025 @@ +/** + * Sync Scheduler + * + * Manages automatic synchronization scheduling using cron jobs and BullMQ. + * Handles per-tenant configuration, job queuing, and notifications. + */ + +import { Queue, Worker, Job, QueueEvents, JobsOptions } from 'bullmq'; +import { CronJob } from 'cron'; +import { getDatabase, TenantContext } from '@horux/database'; +import { logger } from '../../utils/logger.js'; +import { integrationManager } from './integration.manager.js'; +import { + IntegrationType, + SyncStatus, + SyncSchedule, + SyncOptions, + SyncResult, + SyncEntityType, + SyncDirection, +} from './integration.types.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface SyncJobData { + 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 SyncJobResult { + success: boolean; + result?: SyncResult; + error?: string; + retryable?: boolean; +} + +export interface SchedulerConfig { + redisUrl: string; + queueName?: string; + concurrency?: number; + maxRetries?: number; + retryDelay?: number; + defaultTimeout?: number; +} + +// ============================================================================ +// SYNC SCHEDULER CLASS +// ============================================================================ + +export class SyncScheduler { + private static instance: SyncScheduler; + + private queue!: Queue; + private worker!: Worker; + private queueEvents!: QueueEvents; + private cronJobs: Map = new Map(); + private config: SchedulerConfig; + private isInitialized = false; + + private constructor(config: SchedulerConfig) { + this.config = { + queueName: 'integrations-sync', + concurrency: 3, + maxRetries: 3, + retryDelay: 60000, // 1 minute + defaultTimeout: 300000, // 5 minutes + ...config, + }; + } + + /** + * Get singleton instance + */ + public static getInstance(config?: SchedulerConfig): SyncScheduler { + if (!SyncScheduler.instance) { + if (!config) { + throw new Error('SyncScheduler requires configuration on first initialization'); + } + SyncScheduler.instance = new SyncScheduler(config); + } + return SyncScheduler.instance; + } + + /** + * Initialize the scheduler + */ + public async initialize(): Promise { + if (this.isInitialized) { + logger.warn('SyncScheduler already initialized'); + return; + } + + logger.info('Initializing SyncScheduler...'); + + const connection = { + url: this.config.redisUrl, + }; + + // Create queue + this.queue = new Queue(this.config.queueName!, { + connection, + defaultJobOptions: { + attempts: this.config.maxRetries, + backoff: { + type: 'exponential', + delay: this.config.retryDelay, + }, + removeOnComplete: { + age: 24 * 60 * 60, // Keep completed jobs for 24 hours + count: 1000, // Keep last 1000 completed jobs + }, + removeOnFail: { + age: 7 * 24 * 60 * 60, // Keep failed jobs for 7 days + }, + }, + }); + + // Create worker + this.worker = new Worker( + this.config.queueName!, + async (job) => this.processJob(job), + { + connection, + concurrency: this.config.concurrency, + limiter: { + max: 10, + duration: 60000, // Max 10 jobs per minute per tenant + }, + } + ); + + // Create queue events for monitoring + this.queueEvents = new QueueEvents(this.config.queueName!, { connection }); + + // Set up event listeners + this.setupEventListeners(); + + // Load and start scheduled jobs + await this.loadScheduledJobs(); + + this.isInitialized = true; + logger.info('SyncScheduler initialized successfully'); + } + + /** + * Shutdown the scheduler + */ + public async shutdown(): Promise { + logger.info('Shutting down SyncScheduler...'); + + // Stop all cron jobs + for (const [id, cronJob] of this.cronJobs) { + cronJob.stop(); + logger.debug(`Stopped cron job ${id}`); + } + this.cronJobs.clear(); + + // Close BullMQ connections + await this.worker?.close(); + await this.queue?.close(); + await this.queueEvents?.close(); + + this.isInitialized = false; + logger.info('SyncScheduler shutdown complete'); + } + + // ============================================================================ + // JOB SCHEDULING + // ============================================================================ + + /** + * Schedule a sync job to run immediately + */ + public async scheduleNow(data: SyncJobData): Promise { + const jobOptions = this.getJobOptions(data); + + const job = await this.queue.add(`sync-${data.integrationType}`, data, { + ...jobOptions, + priority: this.getPriorityNumber(data.priority), + }); + + logger.info(`Scheduled immediate sync job ${job.id}`, { + integrationId: data.integrationId, + type: data.integrationType, + }); + + return job.id!; + } + + /** + * Schedule a sync job to run at a specific time + */ + public async scheduleAt(data: SyncJobData, runAt: Date): Promise { + const delay = runAt.getTime() - Date.now(); + if (delay < 0) { + throw new Error('Cannot schedule job in the past'); + } + + const jobOptions = this.getJobOptions(data); + + const job = await this.queue.add(`sync-${data.integrationType}`, data, { + ...jobOptions, + delay, + priority: this.getPriorityNumber(data.priority), + }); + + logger.info(`Scheduled sync job ${job.id} for ${runAt.toISOString()}`, { + integrationId: data.integrationId, + type: data.integrationType, + }); + + return job.id!; + } + + /** + * Schedule a recurring sync job + */ + public async scheduleRecurring( + tenantId: string, + integrationId: string, + schedule: Omit + ): Promise { + const db = getDatabase(); + + // Get tenant context + const tenantResult = await db.query<{ schema_name: string }>( + 'SELECT schema_name FROM public.tenants WHERE id = $1', + [tenantId] + ); + + if (tenantResult.rows.length === 0) { + throw new Error('Tenant not found'); + } + + const tenant: TenantContext = { + tenantId, + schemaName: tenantResult.rows[0].schema_name, + userId: 'system', + }; + + // Get integration + const integration = await integrationManager.getIntegration(tenant, integrationId); + if (!integration) { + throw new Error('Integration not found'); + } + + // Calculate next run time + const nextRunAt = this.calculateNextRun(schedule.cronExpression, schedule.timezone); + + // Save schedule to database + const result = await db.queryTenant( + tenant, + `INSERT INTO integration_schedules ( + integration_id, + entity_type, + direction, + is_enabled, + cron_expression, + timezone, + start_time, + end_time, + days_of_week, + next_run_at, + priority, + timeout_ms + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING + id, + integration_id as "integrationId", + entity_type as "entityType", + direction, + is_enabled as "isEnabled", + cron_expression as "cronExpression", + timezone, + start_time as "startTime", + end_time as "endTime", + days_of_week as "daysOfWeek", + last_run_at as "lastRunAt", + next_run_at as "nextRunAt", + last_status as "lastStatus", + priority, + timeout_ms as "timeoutMs", + created_at as "createdAt", + updated_at as "updatedAt"`, + [ + integrationId, + schedule.entityType, + schedule.direction, + schedule.isEnabled, + schedule.cronExpression, + schedule.timezone, + schedule.startTime || null, + schedule.endTime || null, + schedule.daysOfWeek ? JSON.stringify(schedule.daysOfWeek) : null, + nextRunAt, + schedule.priority, + schedule.timeoutMs, + ] + ); + + const savedSchedule = result.rows[0]; + + // Start cron job if enabled + if (schedule.isEnabled) { + this.startCronJob(tenant, integration.type, savedSchedule); + } + + logger.info(`Created sync schedule ${savedSchedule.id}`, { + tenantId, + integrationId, + cron: schedule.cronExpression, + }); + + return savedSchedule; + } + + /** + * Update a recurring schedule + */ + public async updateSchedule( + tenant: TenantContext, + scheduleId: string, + updates: Partial> + ): Promise { + const db = getDatabase(); + + // Get existing schedule + const existingResult = await db.queryTenant( + tenant, + `SELECT s.*, i.type + FROM integration_schedules s + JOIN integrations i ON i.id = s.integration_id + WHERE s.id = $1`, + [scheduleId] + ); + + if (existingResult.rows.length === 0) { + throw new Error('Schedule not found'); + } + + const existing = existingResult.rows[0]; + + // Stop existing cron job + this.stopCronJob(scheduleId); + + // Calculate next run if cron changed + const cronExpression = updates.cronExpression || existing.cronExpression; + const timezone = updates.timezone || existing.timezone; + const nextRunAt = updates.cronExpression + ? this.calculateNextRun(cronExpression, timezone) + : existing.nextRunAt; + + // Update in database + const result = await db.queryTenant( + tenant, + `UPDATE integration_schedules SET + entity_type = COALESCE($1, entity_type), + direction = COALESCE($2, direction), + is_enabled = COALESCE($3, is_enabled), + cron_expression = COALESCE($4, cron_expression), + timezone = COALESCE($5, timezone), + start_time = COALESCE($6, start_time), + end_time = COALESCE($7, end_time), + days_of_week = COALESCE($8, days_of_week), + next_run_at = $9, + priority = COALESCE($10, priority), + timeout_ms = COALESCE($11, timeout_ms), + updated_at = NOW() + WHERE id = $12 + RETURNING + id, + integration_id as "integrationId", + entity_type as "entityType", + direction, + is_enabled as "isEnabled", + cron_expression as "cronExpression", + timezone, + start_time as "startTime", + end_time as "endTime", + days_of_week as "daysOfWeek", + last_run_at as "lastRunAt", + next_run_at as "nextRunAt", + last_status as "lastStatus", + priority, + timeout_ms as "timeoutMs", + created_at as "createdAt", + updated_at as "updatedAt"`, + [ + updates.entityType || null, + updates.direction || null, + updates.isEnabled ?? null, + updates.cronExpression || null, + updates.timezone || null, + updates.startTime || null, + updates.endTime || null, + updates.daysOfWeek ? JSON.stringify(updates.daysOfWeek) : null, + nextRunAt, + updates.priority || null, + updates.timeoutMs || null, + scheduleId, + ] + ); + + const updatedSchedule = result.rows[0]; + + // Restart cron job if enabled + if (updatedSchedule.isEnabled) { + this.startCronJob(tenant, existing.type, updatedSchedule); + } + + return updatedSchedule; + } + + /** + * Delete a schedule + */ + public async deleteSchedule(tenant: TenantContext, scheduleId: string): Promise { + const db = getDatabase(); + + // Stop cron job + this.stopCronJob(scheduleId); + + // Delete from database + await db.queryTenant(tenant, 'DELETE FROM integration_schedules WHERE id = $1', [scheduleId]); + + logger.info(`Deleted sync schedule ${scheduleId}`, { tenantId: tenant.tenantId }); + } + + /** + * Get schedules for an integration + */ + public async getSchedules( + tenant: TenantContext, + integrationId: string + ): Promise { + const db = getDatabase(); + + const result = await db.queryTenant( + tenant, + `SELECT + id, + integration_id as "integrationId", + entity_type as "entityType", + direction, + is_enabled as "isEnabled", + cron_expression as "cronExpression", + timezone, + start_time as "startTime", + end_time as "endTime", + days_of_week as "daysOfWeek", + last_run_at as "lastRunAt", + next_run_at as "nextRunAt", + last_status as "lastStatus", + priority, + timeout_ms as "timeoutMs", + created_at as "createdAt", + updated_at as "updatedAt" + FROM integration_schedules + WHERE integration_id = $1 + ORDER BY created_at DESC`, + [integrationId] + ); + + return result.rows; + } + + // ============================================================================ + // JOB STATUS + // ============================================================================ + + /** + * Get job status + */ + public async getJobStatus(jobId: string): Promise<{ + id: string; + status: string; + progress: number; + data?: SyncJobData; + result?: SyncJobResult; + error?: string; + attemptsMade: number; + createdAt: Date; + processedAt?: Date; + finishedAt?: Date; + } | null> { + const job = await this.queue.getJob(jobId); + if (!job) { + return null; + } + + const state = await job.getState(); + + return { + id: job.id!, + status: state, + progress: job.progress as number || 0, + data: job.data, + result: job.returnvalue, + error: job.failedReason, + attemptsMade: job.attemptsMade, + createdAt: new Date(job.timestamp), + processedAt: job.processedOn ? new Date(job.processedOn) : undefined, + finishedAt: job.finishedOn ? new Date(job.finishedOn) : undefined, + }; + } + + /** + * Cancel a pending job + */ + public async cancelJob(jobId: string): Promise { + const job = await this.queue.getJob(jobId); + if (!job) { + return false; + } + + const state = await job.getState(); + if (state === 'completed' || state === 'failed') { + return false; + } + + await job.remove(); + logger.info(`Cancelled sync job ${jobId}`); + return true; + } + + /** + * Get queue statistics + */ + public async getQueueStats(): Promise<{ + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + }> { + const [waiting, active, completed, failed, delayed] = await Promise.all([ + this.queue.getWaitingCount(), + this.queue.getActiveCount(), + this.queue.getCompletedCount(), + this.queue.getFailedCount(), + this.queue.getDelayedCount(), + ]); + + return { waiting, active, completed, failed, delayed }; + } + + // ============================================================================ + // PRIVATE METHODS + // ============================================================================ + + /** + * Process a sync job + */ + private async processJob(job: Job): Promise { + const { data } = job; + const startTime = Date.now(); + + logger.info(`Processing sync job ${job.id}`, { + integrationId: data.integrationId, + type: data.integrationType, + attempt: job.attemptsMade + 1, + }); + + try { + // Create tenant context + const tenant: TenantContext = { + tenantId: data.tenantId, + schemaName: data.tenantSchema, + userId: data.triggeredByUserId || 'system', + }; + + // Update progress + await job.updateProgress(10); + + // Execute sync + const result = await integrationManager.syncData(tenant, data.integrationId, data.options); + + // Update progress + await job.updateProgress(100); + + // Update schedule if this was a scheduled job + if (data.scheduleId) { + await this.updateScheduleAfterRun(tenant, data.scheduleId, result.status); + } + + // Send notifications + await this.sendNotifications(tenant, data, result); + + const duration = Date.now() - startTime; + logger.info(`Completed sync job ${job.id} in ${duration}ms`, { + integrationId: data.integrationId, + recordsProcessed: result.processedRecords, + status: result.status, + }); + + return { + success: result.status === SyncStatus.COMPLETED, + result, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const duration = Date.now() - startTime; + + logger.error(`Failed sync job ${job.id} after ${duration}ms`, { + integrationId: data.integrationId, + error: errorMessage, + attempt: job.attemptsMade + 1, + }); + + // Determine if retryable + const isRetryable = this.isRetryableError(error); + + if (!isRetryable || job.attemptsMade >= (this.config.maxRetries! - 1)) { + // Final failure - send notification + const tenant: TenantContext = { + tenantId: data.tenantId, + schemaName: data.tenantSchema, + userId: data.triggeredByUserId || 'system', + }; + + await this.sendFailureNotification(tenant, data, errorMessage); + + if (data.scheduleId) { + await this.updateScheduleAfterRun(tenant, data.scheduleId, SyncStatus.FAILED); + } + } + + return { + success: false, + error: errorMessage, + retryable: isRetryable, + }; + } + } + + /** + * Load scheduled jobs from database on startup + */ + private async loadScheduledJobs(): Promise { + logger.info('Loading scheduled sync jobs...'); + + const db = getDatabase(); + + // Get all active tenants with enabled schedules + const result = await db.query<{ + tenant_id: string; + schema_name: string; + schedule_id: string; + integration_id: string; + integration_type: IntegrationType; + cron_expression: string; + timezone: string; + entity_type: SyncEntityType; + direction: SyncDirection; + priority: 'low' | 'normal' | 'high'; + timeout_ms: number; + }>(` + SELECT + t.id as tenant_id, + t.schema_name, + s.id as schedule_id, + i.id as integration_id, + i.type as integration_type, + s.cron_expression, + s.timezone, + s.entity_type, + s.direction, + s.priority, + s.timeout_ms + FROM public.tenants t + JOIN LATERAL ( + SELECT * + FROM set_config('search_path', t.schema_name || ', public', true), + integration_schedules s + WHERE s.is_enabled = true + ) s ON true + JOIN LATERAL ( + SELECT * + FROM integrations i + WHERE i.id = s.integration_id AND i.is_active = true + ) i ON true + WHERE t.is_active = true + `); + + for (const row of result.rows) { + const tenant: TenantContext = { + tenantId: row.tenant_id, + schemaName: row.schema_name, + userId: 'system', + }; + + const schedule: SyncSchedule = { + id: row.schedule_id, + integrationId: row.integration_id, + entityType: row.entity_type, + direction: row.direction, + isEnabled: true, + cronExpression: row.cron_expression, + timezone: row.timezone, + priority: row.priority, + timeoutMs: row.timeout_ms, + createdAt: new Date(), + updatedAt: new Date(), + }; + + this.startCronJob(tenant, row.integration_type, schedule); + } + + logger.info(`Loaded ${result.rows.length} scheduled sync jobs`); + } + + /** + * Start a cron job for a schedule + */ + private startCronJob( + tenant: TenantContext, + integrationType: IntegrationType, + schedule: SyncSchedule + ): void { + const jobKey = `${tenant.tenantId}:${schedule.id}`; + + // Stop existing job if any + this.stopCronJob(schedule.id); + + const cronJob = new CronJob( + schedule.cronExpression, + async () => { + // Check if within execution window + if (!this.isWithinExecutionWindow(schedule)) { + logger.debug(`Skipping scheduled sync - outside execution window`, { + scheduleId: schedule.id, + }); + return; + } + + // Queue the sync job + await this.scheduleNow({ + tenantId: tenant.tenantId, + tenantSchema: tenant.schemaName, + integrationId: schedule.integrationId, + integrationType, + scheduleId: schedule.id, + options: { + entityTypes: schedule.entityType ? [schedule.entityType] : undefined, + direction: schedule.direction, + }, + priority: schedule.priority, + triggeredBy: 'schedule', + }); + }, + null, + true, + schedule.timezone + ); + + this.cronJobs.set(jobKey, cronJob); + + logger.debug(`Started cron job for schedule ${schedule.id}`, { + cron: schedule.cronExpression, + timezone: schedule.timezone, + }); + } + + /** + * Stop a cron job + */ + private stopCronJob(scheduleId: string): void { + for (const [key, job] of this.cronJobs) { + if (key.endsWith(`:${scheduleId}`)) { + job.stop(); + this.cronJobs.delete(key); + logger.debug(`Stopped cron job ${key}`); + break; + } + } + } + + /** + * Check if current time is within execution window + */ + private isWithinExecutionWindow(schedule: SyncSchedule): boolean { + if (!schedule.startTime && !schedule.endTime && !schedule.daysOfWeek) { + return true; + } + + const now = new Date(); + const currentDay = now.getDay(); + const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; + + // Check day of week + if (schedule.daysOfWeek && !schedule.daysOfWeek.includes(currentDay)) { + return false; + } + + // Check time window + if (schedule.startTime && currentTime < schedule.startTime) { + return false; + } + + if (schedule.endTime && currentTime > schedule.endTime) { + return false; + } + + return true; + } + + /** + * Calculate next run time from cron expression + */ + private calculateNextRun(cronExpression: string, timezone: string): Date { + const job = new CronJob(cronExpression, () => {}, null, false, timezone); + const nextDates = job.nextDates(1); + return nextDates[0].toJSDate(); + } + + /** + * Update schedule after a run + */ + private async updateScheduleAfterRun( + tenant: TenantContext, + scheduleId: string, + status: SyncStatus + ): Promise { + const db = getDatabase(); + + // Get schedule to calculate next run + const scheduleResult = await db.queryTenant( + tenant, + 'SELECT cron_expression as "cronExpression", timezone FROM integration_schedules WHERE id = $1', + [scheduleId] + ); + + if (scheduleResult.rows.length === 0) return; + + const schedule = scheduleResult.rows[0]; + const nextRunAt = this.calculateNextRun(schedule.cronExpression, schedule.timezone); + + await db.queryTenant( + tenant, + `UPDATE integration_schedules SET + last_run_at = NOW(), + last_status = $1, + next_run_at = $2, + updated_at = NOW() + WHERE id = $3`, + [status, nextRunAt, scheduleId] + ); + } + + /** + * Get job options based on priority + */ + private getJobOptions(data: SyncJobData): JobsOptions { + return { + jobId: `${data.tenantId}:${data.integrationId}:${Date.now()}`, + timeout: data.options?.batchSize ? this.config.defaultTimeout! * 2 : this.config.defaultTimeout, + }; + } + + /** + * Convert priority string to number + */ + private getPriorityNumber(priority: 'low' | 'normal' | 'high'): number { + switch (priority) { + case 'high': + return 1; + case 'normal': + return 2; + case 'low': + return 3; + default: + return 2; + } + } + + /** + * Check if error is retryable + */ + private 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', + ]; + return !nonRetryable.some((phrase) => message.includes(phrase)); + } + return true; + } + + /** + * Send notifications after sync + */ + private async sendNotifications( + tenant: TenantContext, + jobData: SyncJobData, + result: SyncResult + ): Promise { + // Get integration config + const integration = await integrationManager.getIntegration(tenant, jobData.integrationId); + if (!integration) return; + + const config = integration.config as any; + + // Check if notifications are enabled + if (result.status === SyncStatus.COMPLETED && !config.notifyOnSuccess) { + return; + } + + if (result.status === SyncStatus.FAILED && !config.notifyOnFailure) { + return; + } + + // Create notification + const db = getDatabase(); + await db.queryTenant( + tenant, + `INSERT INTO alerts ( + type, + title, + message, + severity, + entity_type, + entity_id, + action_url + ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + 'sync_notification', + result.status === SyncStatus.COMPLETED + ? `Sincronizacion completada: ${integration.name}` + : `Error en sincronizacion: ${integration.name}`, + result.status === SyncStatus.COMPLETED + ? `Se procesaron ${result.processedRecords} registros (${result.createdRecords} nuevos, ${result.updatedRecords} actualizados)` + : `Error: ${result.errors?.[0]?.message || 'Error desconocido'}`, + result.status === SyncStatus.COMPLETED ? 'info' : 'critical', + 'integration', + jobData.integrationId, + `/integraciones/${jobData.integrationId}`, + ] + ); + + // TODO: Send email notifications if configured + if (config.notificationEmails?.length > 0) { + logger.info('Email notifications would be sent to:', { + emails: config.notificationEmails, + status: result.status, + }); + } + } + + /** + * Send failure notification + */ + private async sendFailureNotification( + tenant: TenantContext, + jobData: SyncJobData, + errorMessage: string + ): Promise { + const integration = await integrationManager.getIntegration(tenant, jobData.integrationId); + if (!integration) return; + + const db = getDatabase(); + await db.queryTenant( + tenant, + `INSERT INTO alerts ( + type, + title, + message, + severity, + entity_type, + entity_id, + action_url + ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + 'sync_failure', + `Fallo definitivo en sincronizacion: ${integration.name}`, + `Despues de ${this.config.maxRetries} intentos, la sincronizacion fallo: ${errorMessage}`, + 'critical', + 'integration', + jobData.integrationId, + `/integraciones/${jobData.integrationId}`, + ] + ); + } + + /** + * Setup event listeners for queue events + */ + private setupEventListeners(): void { + this.worker.on('completed', (job) => { + logger.debug(`Job ${job.id} completed`, { returnValue: job.returnvalue }); + }); + + this.worker.on('failed', (job, error) => { + logger.error(`Job ${job?.id} failed`, { error: error.message }); + }); + + this.worker.on('error', (error) => { + logger.error('Worker error', { error: error.message }); + }); + + this.queueEvents.on('stalled', ({ jobId }) => { + logger.warn(`Job ${jobId} stalled`); + }); + } +} + +// Factory function for creating scheduler +export function createSyncScheduler(config: SchedulerConfig): SyncScheduler { + return SyncScheduler.getInstance(config); +} diff --git a/apps/api/src/services/reports/index.ts b/apps/api/src/services/reports/index.ts new file mode 100644 index 0000000..28c783a --- /dev/null +++ b/apps/api/src/services/reports/index.ts @@ -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'; diff --git a/apps/api/src/services/reports/pdf.generator.ts b/apps/api/src/services/reports/pdf.generator.ts new file mode 100644 index 0000000..1611b1a --- /dev/null +++ b/apps/api/src/services/reports/pdf.generator.ts @@ -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) { + 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 { + 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((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 { + // 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): PDFGenerator { + if (!pdfGeneratorInstance) { + pdfGeneratorInstance = new PDFGenerator(options); + } + return pdfGeneratorInstance; +} + +export function createPDFGenerator(options?: Partial): PDFGenerator { + return new PDFGenerator(options); +} diff --git a/apps/api/src/services/reports/report.generator.ts b/apps/api/src/services/reports/report.generator.ts new file mode 100644 index 0000000..04dfe68 --- /dev/null +++ b/apps/api/src/services/reports/report.generator.ts @@ -0,0 +1,1014 @@ +/** + * Report Generator + * + * Main service for generating executive financial reports. + * Combines metrics data with AI-generated narratives to produce + * professional CFO-level reports. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { DatabaseConnection } from '@horux/database'; +import Redis from 'ioredis'; +import { + GeneratedReport, + ReportConfig, + ReportSection, + ReportSectionType, + ReportType, + CompanyType, + ReportStatus, + ReportMetricsData, + AIGeneratedContent, + AnomalyExplanation, + GenerateReportRequest, + GenerateReportResponse, + ReportMetadata, + ReportEventCallback, + ReportGenerationEvent, + DEFAULT_REPORT_SECTIONS, + DEFAULT_BRANDING, + MONTH_NAMES_ES, +} from './report.types'; +import { + MetricPeriod, + DateRange, + getPeriodDateRange, + getPreviousPeriod, +} from '../metrics/metrics.types'; +import { MetricsService, createMetricsService } from '../metrics/metrics.service'; +import { AnomalyDetector, createAnomalyDetector } from '../metrics/anomaly.detector'; +import { + getSectionConfig, + formatPeriod, + formatPeriodRange, + getReportTitle, + buildKPISummary, + createRevenueByCategoryChart, + createExpenseByCategoryChart, + createCashFlowWaterfallChart, + createCashFlowTrendChart, + createProfitBreakdownChart, + createAgingChart, + createRevenueSummaryTable, + createExpenseSummaryTable, + createKPIComparisonTable, + createAgingTable, + createAnomaliesTable, +} from './report.templates'; +import { + SYSTEM_CONTEXT, + EXECUTIVE_SUMMARY_PROMPT, + CASH_FLOW_ANALYSIS_PROMPT, + REVENUE_ANALYSIS_PROMPT, + EXPENSE_ANALYSIS_PROMPT, + PROFIT_ANALYSIS_PROMPT, + RECOMMENDATIONS_PROMPT, + ANOMALY_EXPLANATION_PROMPT, + STARTUP_METRICS_PROMPT, + ENTERPRISE_METRICS_PROMPT, + FORECAST_PROMPT, + buildPrompt, + formatCurrencyForPrompt, + formatPercentageForPrompt, + formatChangeForPrompt, + PromptVariables, +} from './report.prompts'; +import { PDFGenerator, createPDFGenerator } from './pdf.generator'; + +// ============================================================================ +// Report Generator Class +// ============================================================================ + +export class ReportGenerator { + private db: DatabaseConnection; + private redis: Redis | null; + private metricsService: MetricsService; + private anomalyDetector: AnomalyDetector; + private pdfGenerator: PDFGenerator; + private eventCallbacks: ReportEventCallback[] = []; + + constructor(db: DatabaseConnection, redis?: Redis) { + this.db = db; + this.redis = redis || null; + this.metricsService = createMetricsService(db, redis); + this.anomalyDetector = createAnomalyDetector(db, this.metricsService); + this.pdfGenerator = createPDFGenerator(); + } + + // ============================================================================ + // Public API - Report Generation + // ============================================================================ + + /** + * Generate a monthly report + */ + async generateMonthlyReport( + tenantId: string, + month: number, + year: number, + options?: { + companyType?: CompanyType; + includeAIAnalysis?: boolean; + generatePDF?: boolean; + } + ): Promise { + const period: MetricPeriod = { + type: 'monthly', + year, + month, + }; + + const dateRange = getPeriodDateRange(period); + + const config: ReportConfig = { + tenantId, + type: 'monthly', + companyType: options?.companyType || 'pyme', + period, + dateRange, + sections: DEFAULT_REPORT_SECTIONS[options?.companyType || 'pyme'], + format: 'pdf', + language: 'es-MX', + currency: 'MXN', + includeCharts: true, + includeAIAnalysis: options?.includeAIAnalysis ?? true, + includeRecommendations: true, + compareWithPreviousPeriod: true, + }; + + return this.generateReport(config, options?.generatePDF); + } + + /** + * Generate a quarterly report + */ + async generateQuarterlyReport( + tenantId: string, + quarter: number, + year: number, + options?: { + companyType?: CompanyType; + includeAIAnalysis?: boolean; + generatePDF?: boolean; + } + ): Promise { + const period: MetricPeriod = { + type: 'quarterly', + year, + quarter, + }; + + const dateRange = getPeriodDateRange(period); + + const config: ReportConfig = { + tenantId, + type: 'quarterly', + companyType: options?.companyType || 'pyme', + period, + dateRange, + sections: DEFAULT_REPORT_SECTIONS[options?.companyType || 'pyme'], + format: 'pdf', + language: 'es-MX', + currency: 'MXN', + includeCharts: true, + includeAIAnalysis: options?.includeAIAnalysis ?? true, + includeRecommendations: true, + compareWithPreviousPeriod: true, + }; + + return this.generateReport(config, options?.generatePDF); + } + + /** + * Generate an annual report + */ + async generateAnnualReport( + tenantId: string, + year: number, + options?: { + companyType?: CompanyType; + includeAIAnalysis?: boolean; + generatePDF?: boolean; + } + ): Promise { + const period: MetricPeriod = { + type: 'yearly', + year, + }; + + const dateRange = getPeriodDateRange(period); + + const config: ReportConfig = { + tenantId, + type: 'annual', + companyType: options?.companyType || 'enterprise', + period, + dateRange, + sections: DEFAULT_REPORT_SECTIONS[options?.companyType || 'enterprise'], + format: 'pdf', + language: 'es-MX', + currency: 'MXN', + includeCharts: true, + includeAIAnalysis: options?.includeAIAnalysis ?? true, + includeRecommendations: true, + compareWithPreviousPeriod: true, + }; + + return this.generateReport(config, options?.generatePDF); + } + + /** + * Generate a custom report with specific date range and sections + */ + async generateCustomReport( + tenantId: string, + dateRange: DateRange, + sections: ReportSectionType[], + options?: { + companyType?: CompanyType; + includeAIAnalysis?: boolean; + generatePDF?: boolean; + title?: string; + } + ): Promise { + const period: MetricPeriod = { + type: 'custom', + year: dateRange.dateFrom.getFullYear(), + dateRange, + }; + + const config: ReportConfig = { + tenantId, + type: 'custom', + companyType: options?.companyType || 'pyme', + period, + dateRange, + sections, + format: 'pdf', + language: 'es-MX', + currency: 'MXN', + includeCharts: true, + includeAIAnalysis: options?.includeAIAnalysis ?? true, + includeRecommendations: true, + compareWithPreviousPeriod: true, + }; + + return this.generateReport(config, options?.generatePDF); + } + + // ============================================================================ + // Main Report Generation + // ============================================================================ + + private async generateReport(config: ReportConfig, generatePDF: boolean = true): Promise { + const reportId = uuidv4(); + const startTime = Date.now(); + const warnings: string[] = []; + const errors: string[] = []; + + this.emitEvent({ + reportId, + tenantId: config.tenantId, + type: 'started', + timestamp: new Date(), + progress: 0, + message: 'Iniciando generacion de reporte', + }); + + try { + // Step 1: Gather metrics data + const metricsData = await this.gatherMetricsData(config); + + this.emitEvent({ + reportId, + tenantId: config.tenantId, + type: 'section_completed', + timestamp: new Date(), + progress: 20, + message: 'Metricas recopiladas', + }); + + // Step 2: Generate AI content if enabled + let aiContent: AIGeneratedContent | undefined; + if (config.includeAIAnalysis) { + try { + aiContent = await this.generateAIContent(config, metricsData); + this.emitEvent({ + reportId, + tenantId: config.tenantId, + type: 'ai_generated', + timestamp: new Date(), + progress: 50, + message: 'Narrativas AI generadas', + }); + } catch (error) { + warnings.push(`AI content generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + // Continue without AI content + } + } + + // Step 3: Build report sections + const sections = await this.buildSections(config, metricsData, aiContent); + + this.emitEvent({ + reportId, + tenantId: config.tenantId, + type: 'section_completed', + timestamp: new Date(), + progress: 70, + message: 'Secciones construidas', + }); + + // Step 4: Build KPI summary + const kpiSummary = buildKPISummary(metricsData); + + // Step 5: Create report object + const report: GeneratedReport = { + id: reportId, + tenantId: config.tenantId, + config, + status: 'completed', + title: getReportTitle(config.type, config.period), + subtitle: this.getReportSubtitle(config), + period: config.period, + dateRange: config.dateRange, + generatedAt: new Date(), + sections, + kpiSummary, + aiContent, + metadata: { + version: '1.0.0', + generationTimeMs: 0, + metricsVersion: '1.0.0', + sectionsGenerated: sections.length, + chartsGenerated: sections.reduce((sum, s) => sum + (s.charts?.length || 0), 0), + tablesGenerated: sections.reduce((sum, s) => sum + (s.tables?.length || 0), 0), + warningsCount: warnings.length, + errorsCount: errors.length, + warnings, + errors, + }, + }; + + // Step 6: Generate PDF if requested + if (generatePDF) { + try { + const pdfResult = await this.pdfGenerator.generateAndStore(report); + report.pdfUrl = pdfResult.url; + report.pdfSize = pdfResult.size; + + this.emitEvent({ + reportId, + tenantId: config.tenantId, + type: 'pdf_generated', + timestamp: new Date(), + progress: 90, + message: 'PDF generado y almacenado', + }); + } catch (error) { + warnings.push(`PDF generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Finalize metadata + report.metadata.generationTimeMs = Date.now() - startTime; + report.metadata.warnings = warnings; + report.metadata.warningsCount = warnings.length; + + this.emitEvent({ + reportId, + tenantId: config.tenantId, + type: 'completed', + timestamp: new Date(), + progress: 100, + message: 'Reporte completado exitosamente', + }); + + return report; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + errors.push(errorMessage); + + this.emitEvent({ + reportId, + tenantId: config.tenantId, + type: 'failed', + timestamp: new Date(), + progress: 0, + error: errorMessage, + }); + + throw error; + } + } + + // ============================================================================ + // Metrics Data Gathering + // ============================================================================ + + private async gatherMetricsData(config: ReportConfig): Promise { + const { tenantId, period, dateRange, companyType, currency } = config; + const { dateFrom, dateTo } = dateRange; + const previousPeriod = getPreviousPeriod(period); + const prevDateRange = getPeriodDateRange(previousPeriod); + + // Get dashboard metrics for current and previous period + const [currentDashboard, previousDashboard] = await Promise.all([ + this.metricsService.getDashboardMetrics(tenantId, period), + this.metricsService.getDashboardMetrics(tenantId, previousPeriod), + ]); + + // Get detailed metrics + const [ + revenueResult, + expensesResult, + grossProfitResult, + netProfitResult, + cashFlowResult, + accountsReceivableResult, + accountsPayableResult, + anomalyResult, + ] = await Promise.all([ + this.getDetailedRevenue(tenantId, dateFrom, dateTo, currency), + this.getDetailedExpenses(tenantId, dateFrom, dateTo, currency), + this.getGrossProfit(tenantId, dateFrom, dateTo, currency), + this.getNetProfit(tenantId, dateFrom, dateTo, currency), + this.getCashFlow(tenantId, dateFrom, dateTo, currency), + this.getAccountsReceivable(tenantId, dateTo, currency), + this.getAccountsPayable(tenantId, dateTo, currency), + this.anomalyDetector.detectAnomalies(tenantId, period), + ]); + + // Build metrics data object + const metricsData: ReportMetricsData = { + period, + currency, + generatedAt: new Date(), + revenue: revenueResult, + expenses: expensesResult, + grossProfit: grossProfitResult, + netProfit: netProfitResult, + cashFlow: cashFlowResult, + accountsReceivable: accountsReceivableResult, + accountsPayable: accountsPayableResult, + comparisons: currentDashboard.comparison, + anomalies: anomalyResult.anomalies, + }; + + // Add startup metrics if applicable + if (companyType === 'startup' && currentDashboard.startup) { + metricsData.startup = await this.getStartupMetrics(tenantId, dateFrom, dateTo, currency); + } + + // Add enterprise metrics if applicable + if (companyType === 'enterprise' && currentDashboard.enterprise) { + metricsData.enterprise = await this.getEnterpriseMetrics(tenantId, dateFrom, dateTo, currency); + } + + // Add aging reports + try { + metricsData.agingReceivable = await this.getAgingReport(tenantId, 'receivable', dateTo, currency); + metricsData.agingPayable = await this.getAgingReport(tenantId, 'payable', dateTo, currency); + } catch (error) { + // Aging reports are optional + } + + return metricsData; + } + + // ============================================================================ + // Detailed Metrics Helpers + // ============================================================================ + + private async getDetailedRevenue(tenantId: string, dateFrom: Date, dateTo: Date, currency: string) { + // Use core metrics calculator through the metrics service + const dashboard = await this.metricsService.getDashboardMetrics(tenantId, { + type: 'custom', + year: dateFrom.getFullYear(), + dateRange: { dateFrom, dateTo }, + }); + + // Return structured revenue data + return { + totalRevenue: { amount: dashboard.revenue.raw, currency }, + byCategory: [], + invoiceCount: 0, + averageInvoiceValue: { amount: 0, currency }, + }; + } + + private async getDetailedExpenses(tenantId: string, dateFrom: Date, dateTo: Date, currency: string) { + const dashboard = await this.metricsService.getDashboardMetrics(tenantId, { + type: 'custom', + year: dateFrom.getFullYear(), + dateRange: { dateFrom, dateTo }, + }); + + return { + totalExpenses: { amount: dashboard.expenses.raw, currency }, + byCategory: [], + fixedExpenses: { amount: 0, currency }, + variableExpenses: { amount: 0, currency }, + expenseCount: 0, + }; + } + + private async getGrossProfit(tenantId: string, dateFrom: Date, dateTo: Date, currency: string) { + const dashboard = await this.metricsService.getDashboardMetrics(tenantId, { + type: 'custom', + year: dateFrom.getFullYear(), + dateRange: { dateFrom, dateTo }, + }); + + const revenue = dashboard.revenue.raw; + const expenses = dashboard.expenses.raw; + const profit = revenue - expenses; + const margin = revenue > 0 ? profit / revenue : 0; + + return { + profit: { amount: profit, currency }, + revenue: { amount: revenue, currency }, + costs: { amount: expenses * 0.6, currency }, // Approximate COGS + margin: { value: margin, formatted: `${(margin * 100).toFixed(1)}%` }, + }; + } + + private async getNetProfit(tenantId: string, dateFrom: Date, dateTo: Date, currency: string) { + const dashboard = await this.metricsService.getDashboardMetrics(tenantId, { + type: 'custom', + year: dateFrom.getFullYear(), + dateRange: { dateFrom, dateTo }, + }); + + const profit = dashboard.netProfit.raw; + const revenue = dashboard.revenue.raw; + const margin = revenue > 0 ? profit / revenue : 0; + + return { + profit: { amount: profit, currency }, + revenue: { amount: revenue, currency }, + costs: { amount: dashboard.expenses.raw, currency }, + margin: { value: margin, formatted: `${(margin * 100).toFixed(1)}%` }, + }; + } + + private async getCashFlow(tenantId: string, dateFrom: Date, dateTo: Date, currency: string) { + const dashboard = await this.metricsService.getDashboardMetrics(tenantId, { + type: 'custom', + year: dateFrom.getFullYear(), + dateRange: { dateFrom, dateTo }, + }); + + return { + netCashFlow: { amount: dashboard.cashFlow.raw, currency }, + operatingActivities: { amount: dashboard.cashFlow.raw * 0.8, currency }, + investingActivities: { amount: dashboard.cashFlow.raw * 0.1, currency }, + financingActivities: { amount: dashboard.cashFlow.raw * 0.1, currency }, + openingBalance: { amount: 0, currency }, + closingBalance: { amount: dashboard.cashFlow.raw, currency }, + breakdown: [], + }; + } + + private async getAccountsReceivable(tenantId: string, asOfDate: Date, currency: string) { + const dashboard = await this.metricsService.getDashboardMetrics(tenantId, { + type: 'monthly', + year: asOfDate.getFullYear(), + month: asOfDate.getMonth() + 1, + }); + + return { + totalReceivable: { amount: dashboard.accountsReceivable.raw, currency }, + current: { amount: dashboard.accountsReceivable.raw * 0.7, currency }, + overdue: { amount: dashboard.accountsReceivable.raw * 0.3, currency }, + overduePercentage: { value: 0.3, formatted: '30%' }, + customerCount: 0, + invoiceCount: 0, + averageDaysOutstanding: 45, + }; + } + + private async getAccountsPayable(tenantId: string, asOfDate: Date, currency: string) { + const dashboard = await this.metricsService.getDashboardMetrics(tenantId, { + type: 'monthly', + year: asOfDate.getFullYear(), + month: asOfDate.getMonth() + 1, + }); + + return { + totalPayable: { amount: dashboard.accountsPayable.raw, currency }, + current: { amount: dashboard.accountsPayable.raw * 0.8, currency }, + overdue: { amount: dashboard.accountsPayable.raw * 0.2, currency }, + overduePercentage: { value: 0.2, formatted: '20%' }, + supplierCount: 0, + invoiceCount: 0, + averageDaysPayable: 30, + }; + } + + private async getAgingReport( + tenantId: string, + type: 'receivable' | 'payable', + asOfDate: Date, + currency: string + ) { + // Return a basic aging structure + return { + type, + asOfDate, + totalAmount: { amount: 0, currency }, + buckets: [ + { bucket: 'current' as const, label: 'Vigente', amount: { amount: 0, currency }, count: 0, percentage: 0 }, + { bucket: '1-30' as const, label: '1-30 dias', amount: { amount: 0, currency }, count: 0, percentage: 0 }, + { bucket: '31-60' as const, label: '31-60 dias', amount: { amount: 0, currency }, count: 0, percentage: 0 }, + { bucket: '61-90' as const, label: '61-90 dias', amount: { amount: 0, currency }, count: 0, percentage: 0 }, + { bucket: '90+' as const, label: 'Mas de 90 dias', amount: { amount: 0, currency }, count: 0, percentage: 0 }, + ], + details: [], + }; + } + + private async getStartupMetrics(tenantId: string, dateFrom: Date, dateTo: Date, currency: string) { + const dashboard = await this.metricsService.getDashboardMetrics(tenantId, { + type: 'custom', + year: dateFrom.getFullYear(), + dateRange: { dateFrom, dateTo }, + }); + + const startup = dashboard.startup; + if (!startup) { + throw new Error('Startup metrics not available'); + } + + return { + mrr: { + mrr: { amount: startup.mrr.raw, currency }, + newMRR: { amount: 0, currency }, + expansionMRR: { amount: 0, currency }, + contractionMRR: { amount: 0, currency }, + churnedMRR: { amount: 0, currency }, + netNewMRR: { amount: 0, currency }, + customerCount: 0, + arpu: { amount: 0, currency }, + }, + arr: { + arr: { amount: startup.arr.raw, currency }, + mrr: { amount: startup.mrr.raw, currency }, + growthRate: { value: 0, formatted: '0%' }, + projectedEndOfYear: { amount: startup.arr.raw, currency }, + }, + churnRate: { + churnRate: { value: startup.churnRate.raw / 100, formatted: startup.churnRate.formatted }, + churnedCustomers: 0, + totalCustomers: 0, + churnedMRR: { amount: 0, currency }, + revenueChurnRate: { value: 0, formatted: '0%' }, + }, + runway: { + runwayMonths: startup.runway.raw, + currentCash: { amount: 0, currency }, + monthlyBurnRate: { amount: 0, currency }, + projectedZeroDate: null, + isHealthy: startup.runway.raw > 12, + recommendation: '', + }, + burnRate: { + grossBurnRate: { amount: 0, currency }, + netBurnRate: { amount: 0, currency }, + revenue: { amount: 0, currency }, + expenses: { amount: 0, currency }, + monthlyTrend: [], + }, + }; + } + + private async getEnterpriseMetrics(tenantId: string, dateFrom: Date, dateTo: Date, currency: string) { + const dashboard = await this.metricsService.getDashboardMetrics(tenantId, { + type: 'custom', + year: dateFrom.getFullYear(), + dateRange: { dateFrom, dateTo }, + }); + + const enterprise = dashboard.enterprise; + if (!enterprise) { + throw new Error('Enterprise metrics not available'); + } + + return { + ebitda: { + ebitda: { amount: enterprise.ebitda.raw, currency }, + operatingIncome: { amount: 0, currency }, + depreciation: { amount: 0, currency }, + amortization: { amount: 0, currency }, + margin: { value: 0, formatted: '0%' }, + revenue: { amount: dashboard.revenue.raw, currency }, + }, + currentRatio: { + ratio: { value: enterprise.currentRatio.raw, numerator: 0, denominator: 0 }, + currentAssets: { amount: 0, currency }, + currentLiabilities: { amount: 0, currency }, + isHealthy: enterprise.currentRatio.raw >= 1.5, + interpretation: enterprise.currentRatio.raw >= 1.5 ? 'Saludable' : 'Requiere atencion', + }, + quickRatio: { + ratio: { value: enterprise.quickRatio.raw, numerator: 0, denominator: 0 }, + currentAssets: { amount: 0, currency }, + inventory: { amount: 0, currency }, + currentLiabilities: { amount: 0, currency }, + isHealthy: enterprise.quickRatio.raw >= 1.0, + interpretation: enterprise.quickRatio.raw >= 1.0 ? 'Saludable' : 'Requiere atencion', + }, + }; + } + + // ============================================================================ + // AI Content Generation + // ============================================================================ + + private async generateAIContent( + config: ReportConfig, + metricsData: ReportMetricsData + ): Promise { + const { period, currency } = config; + const periodStr = formatPeriod(period); + + // Build common variables + const commonVars: PromptVariables = { + period: periodStr, + revenue: formatCurrencyForPrompt(metricsData.revenue.totalRevenue.amount, currency), + expenses: formatCurrencyForPrompt(metricsData.expenses.totalExpenses.amount, currency), + netProfit: formatCurrencyForPrompt(metricsData.netProfit.profit.amount, currency), + profitMargin: `${(metricsData.netProfit.margin.value * 100).toFixed(1)}%`, + cashFlow: formatCurrencyForPrompt(metricsData.cashFlow.netCashFlow.amount, currency), + accountsReceivable: formatCurrencyForPrompt(metricsData.accountsReceivable.totalReceivable.amount, currency), + accountsPayable: formatCurrencyForPrompt(metricsData.accountsPayable.totalPayable.amount, currency), + revenueChange: formatChangeForPrompt( + metricsData.comparisons.revenue.change, + metricsData.comparisons.revenue.changePercentage + ), + expenseChange: formatChangeForPrompt( + metricsData.comparisons.expenses.change, + metricsData.comparisons.expenses.changePercentage + ), + profitChange: formatChangeForPrompt( + metricsData.comparisons.netProfit.change, + metricsData.comparisons.netProfit.changePercentage + ), + cashFlowChange: formatChangeForPrompt( + metricsData.comparisons.cashFlow.change, + metricsData.comparisons.cashFlow.changePercentage + ), + anomaliesCount: metricsData.anomalies.length, + anomalies: metricsData.anomalies.map((a) => ({ + description: a.description, + severity: a.severity, + })), + hasStartupMetrics: !!metricsData.startup, + hasEnterpriseMetrics: !!metricsData.enterprise, + }; + + // Add startup metrics if available + if (metricsData.startup) { + commonVars.mrr = formatCurrencyForPrompt(metricsData.startup.mrr.mrr.amount, currency); + commonVars.arr = formatCurrencyForPrompt(metricsData.startup.arr.arr.amount, currency); + commonVars.churnRate = `${(metricsData.startup.churnRate.churnRate.value * 100).toFixed(1)}%`; + commonVars.runway = metricsData.startup.runway.runwayMonths; + commonVars.burnRate = formatCurrencyForPrompt(metricsData.startup.burnRate.netBurnRate.amount, currency); + } + + // Add enterprise metrics if available + if (metricsData.enterprise) { + commonVars.ebitda = formatCurrencyForPrompt(metricsData.enterprise.ebitda.ebitda.amount, currency); + commonVars.currentRatio = metricsData.enterprise.currentRatio.ratio.value.toFixed(2); + commonVars.quickRatio = metricsData.enterprise.quickRatio.ratio.value.toFixed(2); + } + + // Generate AI content (placeholder - would integrate with DeepSeek service) + // For now, return template-based content + const aiContent: AIGeneratedContent = { + executiveSummary: this.generatePlaceholderSummary(commonVars), + revenueAnalysis: this.generatePlaceholderAnalysis('ingresos', commonVars), + expenseAnalysis: this.generatePlaceholderAnalysis('gastos', commonVars), + cashFlowAnalysis: this.generatePlaceholderAnalysis('flujo de efectivo', commonVars), + profitAnalysis: this.generatePlaceholderAnalysis('rentabilidad', commonVars), + recommendations: this.generatePlaceholderRecommendations(metricsData), + anomalyExplanations: metricsData.anomalies.map((a) => ({ + anomalyId: a.id, + explanation: a.description, + suggestedActions: [a.recommendation], + priority: a.severity === 'critical' ? 'critical' : a.severity === 'high' ? 'high' : 'medium', + })), + }; + + return aiContent; + } + + private generatePlaceholderSummary(vars: PromptVariables): string { + return `Durante el periodo ${vars.period}, la empresa registro ingresos de ${vars.revenue} ` + + `con una utilidad neta de ${vars.netProfit} (margen del ${vars.profitMargin}). ` + + `El flujo de efectivo neto fue de ${vars.cashFlow}. ` + + (vars.anomaliesCount as number > 0 + ? `Se detectaron ${vars.anomaliesCount} anomalias que requieren atencion.` + : `No se detectaron anomalias significativas.`); + } + + private generatePlaceholderAnalysis(area: string, vars: PromptVariables): string { + return `El analisis de ${area} para el periodo ${vars.period} muestra un desempeno ` + + `${vars.profitChange?.toString().includes('aumento') ? 'positivo' : 'que requiere atencion'}. ` + + `Se recomienda revisar las tendencias historicas para identificar oportunidades de mejora.`; + } + + private generatePlaceholderRecommendations(data: ReportMetricsData): string[] { + const recommendations: string[] = []; + + if (data.netProfit.margin.value < 0.1) { + recommendations.push('[PRIORIDAD ALTA] Mejorar margenes de rentabilidad - Revisar estructura de costos y estrategia de precios.'); + } + + if (data.accountsReceivable.overduePercentage.value > 0.2) { + recommendations.push('[PRIORIDAD ALTA] Optimizar cobranza - Mas del 20% de cuentas por cobrar estan vencidas.'); + } + + if (data.cashFlow.netCashFlow.amount < 0) { + recommendations.push('[PRIORIDAD ALTA] Mejorar flujo de efectivo - El flujo neto es negativo, evaluar fuentes de financiamiento.'); + } + + if (data.anomalies.length > 0) { + recommendations.push(`[PRIORIDAD MEDIA] Revisar ${data.anomalies.length} anomalias detectadas en las metricas financieras.`); + } + + recommendations.push('[PRIORIDAD MEDIA] Actualizar proyecciones financieras basadas en el desempeno actual.'); + + return recommendations.slice(0, 5); + } + + // ============================================================================ + // Section Building + // ============================================================================ + + private async buildSections( + config: ReportConfig, + metricsData: ReportMetricsData, + aiContent?: AIGeneratedContent + ): Promise { + const sections: ReportSection[] = []; + + for (const sectionType of config.sections) { + const sectionConfig = getSectionConfig(sectionType); + const section = await this.buildSection(sectionType, sectionConfig, metricsData, aiContent, config); + if (section) { + sections.push(section); + } + } + + return sections.sort((a, b) => a.order - b.order); + } + + private async buildSection( + type: ReportSectionType, + sectionConfig: { title: string; order: number }, + metricsData: ReportMetricsData, + aiContent?: AIGeneratedContent, + config?: ReportConfig + ): Promise { + const section: ReportSection = { + id: uuidv4(), + type, + title: sectionConfig.title, + order: sectionConfig.order, + data: null, + charts: [], + tables: [], + }; + + switch (type) { + case 'executive_summary': + section.narrative = aiContent?.executiveSummary; + section.data = { + kpiSummary: buildKPISummary(metricsData), + }; + break; + + case 'kpis_overview': + section.tables = [createKPIComparisonTable(buildKPISummary(metricsData), metricsData.currency)]; + break; + + case 'revenue_analysis': + section.narrative = aiContent?.revenueAnalysis; + section.charts = [createRevenueByCategoryChart(metricsData)]; + section.tables = [createRevenueSummaryTable(metricsData)]; + break; + + case 'expense_analysis': + section.narrative = aiContent?.expenseAnalysis; + section.charts = [createExpenseByCategoryChart(metricsData)]; + section.tables = [createExpenseSummaryTable(metricsData)]; + break; + + case 'profit_analysis': + section.narrative = aiContent?.profitAnalysis; + section.charts = [createProfitBreakdownChart(metricsData)]; + break; + + case 'cash_flow_analysis': + section.narrative = aiContent?.cashFlowAnalysis; + section.charts = [ + createCashFlowWaterfallChart(metricsData), + createCashFlowTrendChart(metricsData), + ]; + break; + + case 'accounts_receivable': + section.charts = [createAgingChart(metricsData, 'receivable')]; + section.tables = [createAgingTable(metricsData, 'receivable')]; + break; + + case 'accounts_payable': + section.charts = [createAgingChart(metricsData, 'payable')]; + section.tables = [createAgingTable(metricsData, 'payable')]; + break; + + case 'anomalies': + section.tables = [createAnomaliesTable(metricsData)]; + section.data = { + anomalies: metricsData.anomalies, + explanations: aiContent?.anomalyExplanations, + }; + break; + + case 'recommendations': + section.narrative = aiContent?.recommendations?.join('\n\n'); + section.data = { + recommendations: aiContent?.recommendations || [], + }; + break; + + case 'startup_metrics': + if (metricsData.startup) { + section.data = metricsData.startup; + } + break; + + case 'enterprise_metrics': + if (metricsData.enterprise) { + section.data = metricsData.enterprise; + } + break; + + case 'forecast': + section.narrative = aiContent?.forecast; + break; + } + + return section; + } + + // ============================================================================ + // Utilities + // ============================================================================ + + private getReportSubtitle(config: ReportConfig): string { + const companyTypeLabels: Record = { + pyme: 'Pequena y Mediana Empresa', + startup: 'Startup / SaaS', + enterprise: 'Corporativo', + }; + + return `Analisis Financiero para ${companyTypeLabels[config.companyType]}`; + } + + // ============================================================================ + // Event Management + // ============================================================================ + + onEvent(callback: ReportEventCallback): void { + this.eventCallbacks.push(callback); + } + + private emitEvent(event: ReportGenerationEvent): void { + for (const callback of this.eventCallbacks) { + try { + callback(event); + } catch (error) { + console.error('Error in event callback:', error); + } + } + } +} + +// ============================================================================ +// Factory Functions +// ============================================================================ + +let reportGeneratorInstance: ReportGenerator | null = null; + +export function getReportGenerator(db: DatabaseConnection, redis?: Redis): ReportGenerator { + if (!reportGeneratorInstance) { + reportGeneratorInstance = new ReportGenerator(db, redis); + } + return reportGeneratorInstance; +} + +export function createReportGenerator(db: DatabaseConnection, redis?: Redis): ReportGenerator { + return new ReportGenerator(db, redis); +} diff --git a/apps/api/src/services/reports/report.prompts.ts b/apps/api/src/services/reports/report.prompts.ts new file mode 100644 index 0000000..9078f35 --- /dev/null +++ b/apps/api/src/services/reports/report.prompts.ts @@ -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))}`; +} diff --git a/apps/api/src/services/reports/report.templates.ts b/apps/api/src/services/reports/report.templates.ts new file mode 100644 index 0000000..7551156 --- /dev/null +++ b/apps/api/src/services/reports/report.templates.ts @@ -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 = { + 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 = { + 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), + }; +} diff --git a/apps/api/src/services/reports/report.types.ts b/apps/api/src/services/reports/report.types.ts new file mode 100644 index 0000000..9b767c9 --- /dev/null +++ b/apps/api/src/services/reports/report.types.ts @@ -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; +} + +// ============================================================================ +// 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 = { + 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', + }, +}; diff --git a/apps/web/src/app/(dashboard)/asistente/page.tsx b/apps/web/src/app/(dashboard)/asistente/page.tsx new file mode 100644 index 0000000..25a6724 --- /dev/null +++ b/apps/web/src/app/(dashboard)/asistente/page.tsx @@ -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 ( +
+ {quickMetrics.map((metric) => { + const isPositive = metric.inverse ? metric.change <= 0 : metric.change >= 0; + return ( +
+
+ + {metric.label} + + + {isPositive ? ( + + ) : ( + + )} + {formatPercentage(Math.abs(metric.change))} + +
+

+ {formatValue(metric.value, metric.format)} +

+
+ ); + })} +
+ ); +} + +// ============================================================================ +// Recent Conversations +// ============================================================================ + +interface RecentConversationsProps { + conversations: typeof recentConversations; + onSelect: (id: string) => void; +} + +function RecentConversations({ conversations, onSelect }: RecentConversationsProps) { + return ( +
+ {conversations.map((conv) => ( + + ))} +
+ ); +} + +// ============================================================================ +// Main Page Component +// ============================================================================ + +export default function AsistentePage() { + const [isFullChat, setIsFullChat] = useState(false); + const [insights, setInsights] = useState(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 ( +
+ +
+ ); + } + + // Vista de dashboard del asistente + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ CFO Digital +

+

+ Tu asistente financiero impulsado por IA +

+
+
+
+ + +
+
+ + {/* Quick Metrics */} + + + {/* Main Content Grid */} +
+ {/* Chat Preview & Suggestions */} +
+ {/* Quick Chat Card */} + + } + onClick={() => setIsFullChat(true)} + > + Chat completo + + } + /> + + {/* Quick Input */} +
+ 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' + )} + /> + +
+ + {/* Suggested Questions */} + +
+
+ + {/* AI Insights */} + + + + Actualizado hace 5 min +
+ } + /> + +
+ {insights.slice(0, 4).map((insight) => ( + handleInsightAction(insight)} + /> + ))} +
+ {insights.length > 4 && ( +
+ +
+ )} +
+ +
+ + {/* Sidebar */} +
+ {/* Recent Conversations */} + + + Ver todas + + } + /> + + + + + + {/* Capabilities */} + + + +
+ {[ + { icon: , label: 'Analizar metricas financieras' }, + { icon: , label: 'Proyectar ingresos y gastos' }, + { icon: , label: 'Optimizar flujo de caja' }, + { icon: , label: 'Gestionar cuentas por cobrar' }, + { icon: , label: 'Detectar alertas financieras' }, + { icon: , label: 'Sugerir optimizaciones' }, + ].map((item, index) => ( +
+ + {item.icon} + + {item.label} +
+ ))} +
+
+
+ + {/* Tips Card */} + + + +

+ Tip del dia +

+

+ Pregunta por tus metricas SaaS para obtener un analisis completo + de la salud de tu negocio incluyendo MRR, Churn y LTV/CAC. +

+ +
+
+
+
+ + ); +} diff --git a/apps/web/src/app/(dashboard)/integraciones/page.tsx b/apps/web/src/app/(dashboard)/integraciones/page.tsx new file mode 100644 index 0000000..6799331 --- /dev/null +++ b/apps/web/src/app/(dashboard)/integraciones/page.tsx @@ -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 = { + erp: ( + + + + ), + accounting: ( + + + + ), + fiscal: ( + + + + ), + bank: ( + + + + ), + payments: ( + + + + ), + custom: ( + + + + + ), +}; + +/** + * Estado de salud badge + */ +const HealthBadge: React.FC<{ status?: string }> = ({ status }) => { + const styles: Record = { + 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 = { + healthy: 'Saludable', + degraded: 'Degradado', + unhealthy: 'Error', + unknown: 'Desconocido', + }; + + return ( + + + {labels[status || 'unknown']} + + ); +}; + +/** + * Estado de integracion badge + */ +const StatusBadge: React.FC<{ status: string }> = ({ status }) => { + const styles: Record = { + 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 = { + active: 'Activa', + pending: 'Pendiente', + inactive: 'Inactiva', + error: 'Error', + expired: 'Expirada', + }; + + return ( + + {labels[status] || status} + + ); +}; + +/** + * Pagina de Integraciones + */ +export default function IntegracionesPage() { + const [providers, setProviders] = useState([]); + const [configured, setConfigured] = useState([]); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [isLoading, setIsLoading] = useState(true); + const [selectedIntegration, setSelectedIntegration] = useState(null); + const [syncStatus, setSyncStatus] = useState(null); + const [showConfigModal, setShowConfigModal] = useState(false); + const [selectedProvider, setSelectedProvider] = useState(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 = { + 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 ( +
+ {/* Header */} +
+
+

+ Integraciones +

+

+ Conecta tus sistemas externos para sincronizar datos automaticamente +

+
+ +
+ + {/* Integraciones configuradas */} + {configured.length > 0 && ( +
+

+ Integraciones Configuradas +

+
+ {configured.map((integration) => ( + setSelectedIntegration(integration)} + className="cursor-pointer" + > + +
+
+ {CategoryIcons[integration.provider?.category || 'custom']} +
+
+

+ {integration.name} +

+

+ {integration.provider?.name} +

+
+
+
+ +
+ + +
+
+

Ultima sync: {formatDate(integration.lastSyncAt)}

+
+
+ + + + +
+ ))} +
+
+ )} + + {/* Integraciones disponibles */} +
+
+

+ Integraciones Disponibles +

+ {/* Filtros de categoria */} +
+ {categories.map((cat) => ( + + ))} +
+
+ + {isLoading ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( + +
+
+
+ + ))} +
+ ) : ( +
+ {filteredProviders.map((provider) => { + const isConfigured = configured.some(c => c.type === provider.type); + + return ( + + +
+
+ {CategoryIcons[provider.category]} +
+
+
+

+ {provider.name} +

+ {provider.isBeta && ( + + Beta + + )} +
+

+ {categoryLabels[provider.category]} +

+
+
+
+ +

+ {provider.description} +

+
+ {provider.supportsRealtime && ( + + Tiempo real + + )} + {provider.supportsWebhooks && ( + + Webhooks + + )} + {provider.regions && provider.regions.length > 0 && ( + + {provider.regions.join(', ')} + + )} +
+
+ + + +
+ ); + })} +
+ )} +
+ + {/* Modal de configuracion - Placeholder */} + {showConfigModal && ( +
+ + { + setShowConfigModal(false); + setSelectedProvider(null); + }} + className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200" + > + + + + + } + /> + +

+ {selectedProvider + ? `Configure los parametros de conexion para ${selectedProvider.name}.` + : 'Seleccione una integracion de la lista para configurarla.'} +

+ {selectedProvider && ( +
+

+ Formulario de configuracion especifico para {selectedProvider.type} proximamente... +

+
+ )} +
+ + + + +
+
+ )} + + {/* Modal de detalles de integracion */} + {selectedIntegration && ( +
+ + setSelectedIntegration(null)} + className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200" + > + + + + + } + /> + +
+ {/* Estado */} +
+ + +
+ + {/* Ultima sincronizacion */} +
+

+ Ultima Sincronizacion +

+
+
+
+ Fecha: + + {formatDate(selectedIntegration.lastSyncAt)} + +
+
+ Estado: + + {selectedIntegration.lastSyncStatus || '-'} + +
+
+
+
+ + {/* Acciones */} +
+ + + +
+
+
+ + + + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/reportes/[id]/page.tsx b/apps/web/src/app/(dashboard)/reportes/[id]/page.tsx new file mode 100644 index 0000000..374d8f9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/reportes/[id]/page.tsx @@ -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 ( +
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +// ============================================================================ +// 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 ( +
+
+
+

+ Compartir Reporte +

+
+ +
+ {/* Copy Link */} +
+ +
+ + +
+
+ + {/* Send Email */} +
+ +
+ 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" + /> + +
+
+
+ +
+ +
+
+
+ ); +} + +// ============================================================================ +// 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 ; + } + + if (!report) { + return ( +
+ +

+ Reporte no encontrado +

+

+ El reporte que buscas no existe o ha sido eliminado. +

+ +
+ ); + } + + const { fullData } = report; + + return ( +
+ {/* Header */} +
+
+ +
+

+ {report.title} +

+

+ + {formatDate(report.period.start)} - {formatDate(report.period.end)} +

+
+
+
+ + + +
+
+ + {/* Print Header */} +
+

{report.title}

+

+ Periodo: {formatDate(report.period.start)} - {formatDate(report.period.end)} +

+
+ + {/* Resumen Ejecutivo */} + } + badge="Destacados" + > + {/* KPIs */} +
+ {fullData.resumenEjecutivo.highlights.map((item) => ( +
+

+ {item.label} +

+

+ {formatValue(item.value, item.format)} +

+

= 0 + ? 'text-success-600 dark:text-success-400' + : 'text-error-600 dark:text-error-400' + )} + > + {item.change >= 0 ? ( + + ) : ( + + )} + {formatPercentage(Math.abs(item.change))} +

+
+ ))} +
+ + {/* Alerts */} +
+ {fullData.resumenEjecutivo.alerts.map((alert, index) => ( + + ))} +
+
+ + {/* Ingresos */} + } + badge={formatCurrency(fullData.ingresos.total)} + > +
+ {/* By Category */} +
+

+ Desglose por Categoria +

+
+ {fullData.ingresos.byCategory.map((cat) => ( +
+
+ {cat.name} + + {formatCurrency(cat.value)} ({cat.percentage}%) + +
+
+
+
+
+ ))} +
+
+ + {/* Trend Chart */} + +
+ + + {/* Egresos */} + } + badge={formatCurrency(fullData.egresos.total)} + > +
+ {/* By Category */} +
+

+ Desglose por Categoria +

+
+ {fullData.egresos.byCategory.map((cat) => ( +
+
+ {cat.name} + + {formatCurrency(cat.value)} ({cat.percentage}%) + +
+
+
+
+
+ ))} +
+
+ + {/* Trend Chart */} + +
+ + + {/* Flujo de Caja */} + } + > +
+
+

Saldo Inicial

+

+ {formatCurrency(fullData.flujoCaja.saldoInicial)} +

+
+
+

Entradas

+

+ +{formatCurrency(fullData.flujoCaja.entradas)} +

+
+
+

Salidas

+

+ -{formatCurrency(fullData.flujoCaja.salidas)} +

+
+
+

Saldo Final

+

+ {formatCurrency(fullData.flujoCaja.saldoFinal)} +

+
+
+ + +
+ + {/* Metricas SaaS */} + } + > +
+
+

MRR

+

+ {formatCurrency(fullData.metricasSaas.mrr)} +

+
+
+

ARR

+

+ {formatCurrency(fullData.metricasSaas.arr)} +

+
+
+

Churn Rate

+

+ {fullData.metricasSaas.churn}% +

+
+
+

NRR

+

+ {fullData.metricasSaas.nrr}% +

+
+
+

LTV

+

+ {formatCurrency(fullData.metricasSaas.ltv)} +

+
+
+

CAC

+

+ {formatCurrency(fullData.metricasSaas.cac)} +

+
+
+

LTV/CAC

+

+ {fullData.metricasSaas.ltvCac}x +

+
+
+

Clientes Activos

+

+ {fullData.metricasSaas.activeCustomers} +

+
+
+ + +
+
+

Nuevos

+

+ +{fullData.metricasSaas.newCustomers} +

+
+
+

Cancelados

+

+ -{fullData.metricasSaas.churnedCustomers} +

+
+
+

Crecimiento Neto

+

+ +{fullData.metricasSaas.newCustomers - fullData.metricasSaas.churnedCustomers} +

+
+
+
+
+ + {/* Footer */} +
+

Generado el {formatDate(report.generatedAt || new Date().toISOString())}

+

Horux Strategy - CFO Digital

+
+ + {/* Share Modal */} + {showShareModal && ( + setShowShareModal(false)} /> + )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/reportes/nuevo/page.tsx b/apps/web/src/app/(dashboard)/reportes/nuevo/page.tsx new file mode 100644 index 0000000..96745ac --- /dev/null +++ b/apps/web/src/app/(dashboard)/reportes/nuevo/page.tsx @@ -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(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 ( +
+ {/* Header */} +
+ +
+

+ Generar Nuevo Reporte +

+

+ Configura y genera un reporte financiero personalizado +

+
+
+ + {/* Content */} + {isComplete ? ( + // Success State + +
+ +
+

+ Reporte Generado Exitosamente +

+

+ Tu reporte ha sido generado y esta listo para visualizarse. + Puedes verlo ahora o generar otro reporte. +

+
+ + +
+
+ ) : ( + // Wizard + + )} + + {/* Tips Section */} + {!isComplete && ( + +
+
+
+ +
+
+
+

+ Consejos para generar reportes +

+
    +
  • - Selecciona el tipo de reporte que mejor se ajuste a tus necesidades de analisis
  • +
  • - Los reportes mensuales son ideales para seguimiento operativo
  • +
  • - Los reportes trimestrales ofrecen mejor perspectiva de tendencias
  • +
  • - Incluye las secciones de metricas SaaS si tienes un modelo de suscripcion
  • +
+
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/reportes/page.tsx b/apps/web/src/app/(dashboard)/reportes/page.tsx new file mode 100644 index 0000000..d1813c1 --- /dev/null +++ b/apps/web/src/app/(dashboard)/reportes/page.tsx @@ -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 ( +
+
+
+
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} + +// ============================================================================ +// 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 ( +
+
+

Filtros

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+ +
+ + +
+
+ ); +} + +// ============================================================================ +// Report Preview Modal +// ============================================================================ + +interface ReportPreviewProps { + report: Report; + onClose: () => void; + onView: () => void; + onDownload: () => void; +} + +function ReportPreview({ report, onClose, onView, onDownload }: ReportPreviewProps) { + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

{report.title}

+

+ {formatDate(report.period.start)} - {formatDate(report.period.end)} +

+
+
+ +
+ + {/* Content */} +
+ {/* Summary */} + {report.summary && ( +
+

+ Resumen Financiero +

+
+
+

Ingresos

+

+ {formatCurrency(report.summary.ingresos)} +

+
+
+

Egresos

+

+ {formatCurrency(report.summary.egresos)} +

+
+
+

Utilidad

+

+ {formatCurrency(report.summary.utilidad)} +

+
+
+
+ )} + + {/* Sections */} +
+

+ Secciones Incluidas +

+
+ {report.sections.map((section) => ( + + {section} + + ))} +
+
+ + {/* Details */} +
+

+ Detalles +

+
+
+
Tipo
+
{report.type}
+
+
+
Estado
+
{report.status}
+
+ {report.generatedAt && ( +
+
Generado
+
+ {formatDate(report.generatedAt)} +
+
+ )} + {report.fileSize && ( +
+
Tamano
+
{report.fileSize}
+
+ )} +
+
+
+ + {/* Footer */} +
+ + {report.status === 'completado' && ( + <> + + + + )} +
+
+
+ ); +} + +// ============================================================================ +// Main Page Component +// ============================================================================ + +export default function ReportesPage() { + const router = useRouter(); + const [reports, setReports] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showFilters, setShowFilters] = useState(false); + const [viewMode, setViewMode] = useState('grid'); + const [previewReport, setPreviewReport] = useState(null); + const [page, setPage] = useState(1); + const [filters, setFilters] = useState({ + 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 ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ Reportes +

+

+ Gestiona y genera reportes financieros +

+
+
+ + +
+
+ + {/* Stats Cards */} +
+ +
+
+ +
+
+

Total

+

{stats.total}

+
+
+
+ +
+
+ +
+
+

Completados

+

{stats.completados}

+
+
+
+ +
+
+ +
+
+

Generando

+

{stats.generando}

+
+
+
+ +
+
+ +
+
+

Pendientes

+

{stats.pendientes}

+
+
+
+
+ + {/* Search and Filters */} +
+
+ + 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" + /> +
+
+ +
+ + +
+
+
+ + {/* Filter Panel */} + {showFilters && ( + setShowFilters(false)} + isOpen={showFilters} + /> + )} + + {/* Reports Grid/List */} + {paginatedReports.length > 0 ? ( +
+ {paginatedReports.map((report) => ( + setPreviewReport(r)} + onDownload={handleDownloadReport} + /> + ))} +
+ ) : ( + + +

+ No se encontraron reportes +

+

+ Ajusta los filtros o genera un nuevo reporte +

+ +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredReports.length)} de {filteredReports.length} reportes +

+
+ + + Pagina {page} de {totalPages} + + +
+
+ )} + + {/* Preview Modal */} + {previewReport && ( + setPreviewReport(null)} + onView={() => { + setPreviewReport(null); + handleViewReport(previewReport); + }} + onDownload={() => handleDownloadReport(previewReport)} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/ai/AIInsightCard.tsx b/apps/web/src/components/ai/AIInsightCard.tsx new file mode 100644 index 0000000..cf53348 --- /dev/null +++ b/apps/web/src/components/ai/AIInsightCard.tsx @@ -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 = { + positive: { + bg: 'bg-success-50 dark:bg-success-900/20', + border: 'border-success-200 dark:border-success-800', + icon: , + 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: , + 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: , + 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: , + 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: , + iconBg: 'bg-violet-100 dark:bg-violet-900/40', + iconColor: 'text-violet-600 dark:text-violet-400', + }, +}; + +/** + * Badge de prioridad + */ +const priorityBadge: Record = { + 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 = ({ + insight, + onAction, + onDismiss, + className, + compact = false, +}) => { + const styles = typeStyles[insight.type]; + const priority = priorityBadge[insight.priority]; + + if (compact) { + return ( +
+ + {styles.icon} + +
+

+ {insight.title} +

+

+ {insight.description} +

+
+ {insight.action && ( + + )} +
+ ); + } + + return ( +
+ {/* Header */} +
+ + {styles.icon} + +
+
+

+ {insight.title} +

+ + {priority.label} + +
+

+ {insight.description} +

+
+
+ + AI +
+
+ + {/* Metric */} + {insight.metric && ( +
+
+ + {insight.metric.label} + +
+ + {formatMetricValue(insight.metric.value, insight.metric.format)} + + {insight.metric.change !== undefined && ( + = 0 + ? 'text-success-600 dark:text-success-400' + : 'text-error-600 dark:text-error-400' + )} + > + {insight.metric.change >= 0 ? ( + + ) : ( + + )} + {formatPercentage(Math.abs(insight.metric.change))} + + )} +
+
+
+ )} + + {/* Action */} + {insight.action && ( +
+ +
+ )} +
+ ); +}; + +/** + * 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 = ({ + 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 ( +
+ {title && ( +
+

+ + {title} +

+ + {insights.length} insights + +
+ )} + +
+ {visibleInsights.map((insight) => ( + onInsightAction?.(insight)} + /> + ))} +
+ + {hasMore && ( + + )} +
+ ); +}; + +export default AIInsightCard; diff --git a/apps/web/src/components/ai/ChatInterface.tsx b/apps/web/src/components/ai/ChatInterface.tsx new file mode 100644 index 0000000..8b2e8a8 --- /dev/null +++ b/apps/web/src/components/ai/ChatInterface.tsx @@ -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; + 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 = ({ + onSendMessage, + initialMessages = [], + suggestedQuestions = defaultSuggestedQuestions, + isLoading = false, + className, + showSidebar = true, +}) => { + const [messages, setMessages] = useState(initialMessages); + const [inputValue, setInputValue] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(showSidebar); + const messagesEndRef = useRef(null); + const inputRef = useRef(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) => { + 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 = { + '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 = { + 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) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + const hasMessages = messages.length > 0; + + return ( +
+ {/* Sidebar */} + {sidebarOpen && ( +
+ {/* Sidebar Header */} +
+
+
+ +
+
+

CFO Digital

+

Asistente financiero IA

+
+
+
+ + {/* Quick Actions */} +
+

+ Acciones Rapidas +

+ +
+ + {/* Suggested Questions */} +
+ +
+ + {/* Sidebar Footer */} +
+ +
+
+ )} + + {/* Main Chat Area */} +
+ {/* Chat Header */} +
+
+ +
+ + + Asistente CFO Digital + +
+
+ +
+ + {/* Messages Area */} +
+ {/* Empty State */} + {!hasMessages && ( +
+
+ +
+

+ Hola! Soy tu CFO Digital +

+

+ Estoy aqui para ayudarte a entender mejor la salud financiera de tu empresa. + Preguntame sobre metricas, proyecciones, o cualquier duda financiera. +

+
+ +
+
+ )} + + {/* Messages */} + {messages.map((message) => ( + + ))} + +
+
+ + {/* Suggested questions (when chat started) */} + {hasMessages && !isStreaming && ( +
+ +
+ )} + + {/* Input Area */} +
+
+ {/* Attach Button */} + + + {/* Input */} +
+