feat: Implement Phase 3 & 4 - AI Reports & ERP Integrations

## Phase 3: DeepSeek AI Integration

### AI Services (apps/api/src/services/ai/)
- DeepSeek API client with streaming, rate limiting, retries
- AI service for financial analysis, executive summaries, recommendations
- Optimized prompts for Mexican CFO digital assistant
- Redis caching for AI responses

### Report Generation (apps/api/src/services/reports/)
- ReportGenerator: monthly, quarterly, annual, custom reports
- PDF generator with corporate branding (PDFKit)
- Report templates for PYME, Startup, Enterprise
- Spanish prompts for financial narratives
- MinIO storage for generated PDFs

### AI & Reports API Routes
- POST /api/ai/analyze - Financial metrics analysis
- POST /api/ai/chat - CFO digital chat with streaming
- POST /api/reports/generate - Async report generation
- GET /api/reports/:id/download - PDF download
- BullMQ jobs for background processing

### Frontend Pages
- /reportes - Report listing with filters
- /reportes/nuevo - 3-step wizard for report generation
- /reportes/[id] - Full report viewer with charts
- /asistente - CFO Digital chat interface
- Components: ChatInterface, ReportCard, AIInsightCard

## Phase 4: ERP Integrations

### CONTPAQi Connector (SQL Server)
- Contabilidad: catalog, polizas, balanza, estado de resultados
- Comercial: clientes, proveedores, facturas, inventario
- Nominas: empleados, nominas, percepciones/deducciones

### Aspel Connector (Firebird/SQL Server)
- COI: accounting, polizas, balanza, auxiliares
- SAE: sales, purchases, inventory, A/R, A/P
- NOI: payroll, employees, receipts
- BANCO: bank accounts, movements, reconciliation
- Latin1 to UTF-8 encoding handling

### Odoo Connector (XML-RPC)
- Accounting: chart of accounts, journal entries, reports
- Invoicing: customer/vendor invoices, payments
- Partners: customers, vendors, statements
- Inventory: products, stock levels, valuations
- Multi-company and version 14-17 support

### Alegra Connector (REST API)
- Invoices, credit/debit notes with CFDI support
- Contacts with Mexican fiscal fields (RFC, regimen)
- Payments and bank reconciliation
- Financial reports: trial balance, P&L, balance sheet
- Webhook support for real-time sync

### SAP Business One Connector (OData/Service Layer)
- Session management with auto-refresh
- Financials: chart of accounts, journal entries, reports
- Sales: invoices, credit notes, delivery notes, orders
- Purchasing: bills, POs, goods receipts
- Inventory: items, stock, transfers, valuation
- Banking: accounts, payments, cash flow

### Integration Manager
- Unified interface for all connectors
- BullMQ scheduler for automated sync
- Webhook handling for real-time updates
- Migration 003_integrations.sql with sync tables
- Frontend page /integraciones for configuration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 11:25:17 +00:00
parent a9b1994c48
commit 45570baccc
88 changed files with 52538 additions and 531 deletions

View File

@@ -56,3 +56,8 @@ LOG_FORMAT=simple
# Feature Flags # Feature Flags
ENABLE_SWAGGER=true ENABLE_SWAGGER=true
ENABLE_METRICS=true ENABLE_METRICS=true
# DeepSeek AI
DEEPSEEK_API_KEY=your-deepseek-api-key
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat

View File

@@ -29,6 +29,8 @@
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"minio": "^7.1.3", "minio": "^7.1.3",
"mssql": "^10.0.2",
"pdfkit": "^0.15.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"winston": "^3.11.0", "winston": "^3.11.0",
@@ -40,7 +42,9 @@
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5", "@types/jsonwebtoken": "^9.0.5",
"@types/mssql": "^9.1.5",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/pdfkit": "^0.13.4",
"@types/pg": "^8.10.9", "@types/pg": "^8.10.9",
"@types/uuid": "^9.0.7", "@types/uuid": "^9.0.7",
"eslint": "^8.56.0", "eslint": "^8.56.0",

View File

@@ -0,0 +1,853 @@
/**
* AI Controller
*
* Handles AI-powered analysis, explanations, recommendations, and chat
*/
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { getDatabase, TenantContext } from '@horux/database';
import {
ApiResponse,
AppError,
RateLimitError,
ValidationError,
} from '../types/index.js';
import { logger } from '../utils/logger.js';
import { config } from '../config/index.js';
// ============================================================================
// Types
// ============================================================================
export interface AIUsage {
userId: string;
tenantId: string;
tokensUsed: number;
requestCount: number;
lastRequestAt: Date;
periodStart: Date;
periodEnd: Date;
}
export interface AIContext {
metrics?: Record<string, unknown>;
transactions?: unknown[];
categories?: unknown[];
dateRange?: { start: string; end: string };
previousPeriod?: Record<string, unknown>;
}
export interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
timestamp?: Date;
}
// ============================================================================
// Validation Schemas
// ============================================================================
export const AnalyzeRequestSchema = z.object({
metrics: z.array(z.string()).min(1, 'Debes especificar al menos una metrica'),
dateRange: z.object({
start: z.string().datetime(),
end: z.string().datetime(),
}),
compareWith: z.enum(['previous_period', 'year_ago', 'none']).optional().default('previous_period'),
depth: z.enum(['quick', 'standard', 'detailed']).optional().default('standard'),
focusAreas: z.array(z.string()).optional(),
});
export const ExplainRequestSchema = z.object({
type: z.enum(['metric', 'anomaly', 'trend', 'transaction', 'forecast']),
entityId: z.string().optional(),
context: z.object({
metricCode: z.string().optional(),
value: z.number().optional(),
previousValue: z.number().optional(),
changePercent: z.number().optional(),
dateRange: z.object({
start: z.string().datetime(),
end: z.string().datetime(),
}).optional(),
}),
language: z.enum(['es', 'en']).optional().default('es'),
});
export const RecommendRequestSchema = z.object({
area: z.enum([
'cost_reduction',
'revenue_growth',
'cash_flow',
'tax_optimization',
'budget_planning',
'general',
]),
dateRange: z.object({
start: z.string().datetime(),
end: z.string().datetime(),
}),
constraints: z.object({
maxBudget: z.number().optional(),
riskTolerance: z.enum(['low', 'medium', 'high']).optional().default('medium'),
timeHorizon: z.enum(['short', 'medium', 'long']).optional().default('medium'),
}).optional(),
excludeCategories: z.array(z.string().uuid()).optional(),
});
export const ChatRequestSchema = z.object({
message: z.string().min(1, 'El mensaje no puede estar vacio').max(2000),
conversationId: z.string().uuid().optional(),
context: z.object({
currentView: z.string().optional(),
selectedMetrics: z.array(z.string()).optional(),
dateRange: z.object({
start: z.string().datetime(),
end: z.string().datetime(),
}).optional(),
}).optional(),
});
// ============================================================================
// Rate Limiting
// ============================================================================
interface RateLimitConfig {
maxRequestsPerMinute: number;
maxRequestsPerDay: number;
maxTokensPerDay: number;
}
const AI_RATE_LIMITS: Record<string, RateLimitConfig> = {
free: {
maxRequestsPerMinute: 5,
maxRequestsPerDay: 50,
maxTokensPerDay: 50000,
},
starter: {
maxRequestsPerMinute: 10,
maxRequestsPerDay: 200,
maxTokensPerDay: 200000,
},
growth: {
maxRequestsPerMinute: 20,
maxRequestsPerDay: 500,
maxTokensPerDay: 500000,
},
enterprise: {
maxRequestsPerMinute: 50,
maxRequestsPerDay: 2000,
maxTokensPerDay: 2000000,
},
};
// In-memory rate limit tracking (should use Redis in production)
const rateLimitCache = new Map<string, {
minuteCount: number;
minuteStart: number;
dayCount: number;
dayStart: number;
tokensUsed: number;
}>();
async function checkRateLimit(
userId: string,
tenantId: string,
planId: string,
estimatedTokens: number
): Promise<void> {
const limits = AI_RATE_LIMITS[planId] || AI_RATE_LIMITS.free;
const cacheKey = `${tenantId}:${userId}`;
const now = Date.now();
const minuteWindow = 60 * 1000;
const dayWindow = 24 * 60 * 60 * 1000;
let usage = rateLimitCache.get(cacheKey);
if (!usage) {
usage = {
minuteCount: 0,
minuteStart: now,
dayCount: 0,
dayStart: now,
tokensUsed: 0,
};
rateLimitCache.set(cacheKey, usage);
}
// Reset minute window if needed
if (now - usage.minuteStart > minuteWindow) {
usage.minuteCount = 0;
usage.minuteStart = now;
}
// Reset day window if needed
if (now - usage.dayStart > dayWindow) {
usage.dayCount = 0;
usage.dayStart = now;
usage.tokensUsed = 0;
}
// Check limits
if (usage.minuteCount >= limits.maxRequestsPerMinute) {
const waitTime = Math.ceil((minuteWindow - (now - usage.minuteStart)) / 1000);
throw new RateLimitError(
`Has excedido el limite de ${limits.maxRequestsPerMinute} solicitudes por minuto. Espera ${waitTime} segundos.`
);
}
if (usage.dayCount >= limits.maxRequestsPerDay) {
throw new RateLimitError(
`Has excedido el limite de ${limits.maxRequestsPerDay} solicitudes diarias. Intenta manana.`
);
}
if (usage.tokensUsed + estimatedTokens > limits.maxTokensPerDay) {
throw new RateLimitError(
`Has excedido el limite de tokens diarios. Intenta manana o mejora tu plan.`
);
}
// Update usage
usage.minuteCount++;
usage.dayCount++;
usage.tokensUsed += estimatedTokens;
}
// ============================================================================
// Helper Functions
// ============================================================================
const getTenantContext = (req: Request): TenantContext => {
if (!req.user || !req.tenantSchema) {
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
}
return {
tenantId: req.user.tenant_id,
schemaName: req.tenantSchema,
userId: req.user.sub,
};
};
async function getUserPlan(tenantId: string): Promise<string> {
const db = getDatabase();
const result = await db.queryPublic<{ plan_id: string }>(
'SELECT plan_id FROM public.tenants WHERE id = $1',
[tenantId]
);
return result.rows[0]?.plan_id || 'free';
}
async function getContextData(
tenant: TenantContext,
dateRange: { start: string; end: string }
): Promise<AIContext> {
const db = getDatabase();
// Get key metrics
const metricsQuery = `
SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense,
COUNT(*) as transaction_count,
COUNT(DISTINCT contact_id) as unique_contacts
FROM transactions
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
`;
const metricsResult = await db.queryTenant(tenant, metricsQuery, [dateRange.start, dateRange.end]);
// Get top categories
const categoriesQuery = `
SELECT
c.name,
c.type,
SUM(t.amount) as total,
COUNT(*) as count
FROM transactions t
JOIN categories c ON t.category_id = c.id
WHERE t.date >= $1 AND t.date <= $2 AND t.status != 'cancelled'
GROUP BY c.id, c.name, c.type
ORDER BY SUM(t.amount) DESC
LIMIT 10
`;
const categoriesResult = await db.queryTenant(tenant, categoriesQuery, [dateRange.start, dateRange.end]);
return {
metrics: metricsResult.rows[0],
categories: categoriesResult.rows,
dateRange,
};
}
async function logAIRequest(
tenant: TenantContext,
requestType: string,
tokensUsed: number,
durationMs: number
): Promise<void> {
const db = getDatabase();
try {
await db.queryTenant(
tenant,
`INSERT INTO ai_usage_logs (user_id, request_type, tokens_used, duration_ms)
VALUES ($1, $2, $3, $4)`,
[tenant.userId, requestType, tokensUsed, durationMs]
);
} catch (error) {
// Log error but don't fail the request
logger.warn('Failed to log AI request', { error, tenantId: tenant.tenantId });
}
}
// ============================================================================
// Controller Methods
// ============================================================================
/**
* Analyze metrics with AI
*/
export const analyzeMetrics = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const startTime = Date.now();
try {
const body = req.body as z.infer<typeof AnalyzeRequestSchema>;
const tenant = getTenantContext(req);
const planId = await getUserPlan(tenant.tenantId);
// Estimate tokens (rough estimate based on depth)
const estimatedTokens = body.depth === 'detailed' ? 2000 : body.depth === 'standard' ? 1000 : 500;
// Check rate limits
await checkRateLimit(req.user!.sub, tenant.tenantId, planId, estimatedTokens);
// Get context data
const context = await getContextData(tenant, body.dateRange);
// TODO: Call actual AI service (OpenAI/Claude)
// For now, return a mock response
const analysis = {
summary: 'Analisis de metricas financieras',
insights: [
{
type: 'observation',
title: 'Tendencia de ingresos',
description: 'Los ingresos muestran una tendencia estable durante el periodo analizado.',
confidence: 0.85,
importance: 'medium',
},
{
type: 'warning',
title: 'Gastos operativos',
description: 'Los gastos operativos representan un porcentaje alto de los ingresos.',
confidence: 0.90,
importance: 'high',
recommendation: 'Considera revisar los gastos de las categorias principales.',
},
],
metrics: body.metrics.map(m => ({
code: m,
analysis: `Analisis detallado de ${m}`,
trend: 'stable',
healthScore: 75,
})),
generatedAt: new Date().toISOString(),
tokensUsed: estimatedTokens,
};
const durationMs = Date.now() - startTime;
// Log the request
await logAIRequest(tenant, 'analyze', estimatedTokens, durationMs);
logger.info('AI analysis completed', {
userId: req.user!.sub,
depth: body.depth,
metricsCount: body.metrics.length,
durationMs,
});
const response: ApiResponse = {
success: true,
data: analysis,
meta: {
timestamp: new Date().toISOString(),
tokensUsed: estimatedTokens,
durationMs,
},
};
res.json(response);
} catch (error) {
next(error);
}
};
/**
* Explain a metric, anomaly, or trend
*/
export const explainEntity = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const startTime = Date.now();
try {
const body = req.body as z.infer<typeof ExplainRequestSchema>;
const tenant = getTenantContext(req);
const planId = await getUserPlan(tenant.tenantId);
const estimatedTokens = 500;
await checkRateLimit(req.user!.sub, tenant.tenantId, planId, estimatedTokens);
// TODO: Call actual AI service
const explanation = {
type: body.type,
title: getExplanationTitle(body.type, body.context),
explanation: getExplanationText(body.type, body.context, body.language),
keyPoints: [
'Punto clave 1 sobre la metrica o anomalia',
'Punto clave 2 con contexto relevante',
'Punto clave 3 con implicaciones',
],
relatedMetrics: ['cash_flow', 'profit_margin'],
suggestedActions: [
{
action: 'Revisar transacciones del periodo',
priority: 'high',
effort: 'low',
},
],
generatedAt: new Date().toISOString(),
};
const durationMs = Date.now() - startTime;
await logAIRequest(tenant, 'explain', estimatedTokens, durationMs);
const response: ApiResponse = {
success: true,
data: explanation,
meta: {
timestamp: new Date().toISOString(),
tokensUsed: estimatedTokens,
durationMs,
},
};
res.json(response);
} catch (error) {
next(error);
}
};
/**
* Get AI-powered recommendations
*/
export const getRecommendations = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const startTime = Date.now();
try {
const body = req.body as z.infer<typeof RecommendRequestSchema>;
const tenant = getTenantContext(req);
const planId = await getUserPlan(tenant.tenantId);
const estimatedTokens = 1500;
await checkRateLimit(req.user!.sub, tenant.tenantId, planId, estimatedTokens);
// Get context data
const context = await getContextData(tenant, body.dateRange);
// TODO: Call actual AI service
const recommendations = {
area: body.area,
summary: `Recomendaciones para ${getAreaLabel(body.area)}`,
recommendations: [
{
id: '1',
title: 'Optimizar gastos recurrentes',
description: 'Identifica suscripciones y servicios que pueden reducirse o eliminarse.',
impact: 'high',
effort: 'medium',
estimatedSavings: 5000,
timeToImplement: '1-2 semanas',
steps: [
'Revisar lista de gastos recurrentes',
'Identificar servicios subutilizados',
'Negociar mejores tarifas o cancelar',
],
},
{
id: '2',
title: 'Mejorar terminos de pago',
description: 'Negocia mejores terminos con proveedores para mejorar flujo de efectivo.',
impact: 'medium',
effort: 'low',
estimatedSavings: 2000,
timeToImplement: '2-4 semanas',
steps: [
'Identificar top 5 proveedores por volumen',
'Preparar propuesta de terminos',
'Negociar con cada proveedor',
],
},
],
constraints: body.constraints,
contextUsed: {
totalIncome: context.metrics?.total_income,
totalExpense: context.metrics?.total_expense,
topCategories: (context.categories as unknown[])?.slice(0, 3),
},
generatedAt: new Date().toISOString(),
};
const durationMs = Date.now() - startTime;
await logAIRequest(tenant, 'recommend', estimatedTokens, durationMs);
const response: ApiResponse = {
success: true,
data: recommendations,
meta: {
timestamp: new Date().toISOString(),
tokensUsed: estimatedTokens,
durationMs,
},
};
res.json(response);
} catch (error) {
next(error);
}
};
/**
* Chat with CFO Digital
* Supports streaming responses
*/
export const chat = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const startTime = Date.now();
try {
const body = req.body as z.infer<typeof ChatRequestSchema>;
const tenant = getTenantContext(req);
const planId = await getUserPlan(tenant.tenantId);
const db = getDatabase();
const estimatedTokens = 800;
await checkRateLimit(req.user!.sub, tenant.tenantId, planId, estimatedTokens);
// Get or create conversation
let conversationId = body.conversationId;
let messages: ChatMessage[] = [];
if (conversationId) {
// Fetch existing conversation
const convResult = await db.queryTenant<{ messages: ChatMessage[] }>(
tenant,
'SELECT messages FROM ai_conversations WHERE id = $1 AND user_id = $2',
[conversationId, req.user!.sub]
);
if (convResult.rows.length > 0) {
messages = convResult.rows[0].messages || [];
} else {
conversationId = undefined; // Create new if not found
}
}
// Add user message
messages.push({
role: 'user',
content: body.message,
timestamp: new Date(),
});
// Get relevant context if provided
let contextData: AIContext | undefined;
if (body.context?.dateRange) {
contextData = await getContextData(tenant, body.context.dateRange);
}
// TODO: Call actual AI service with streaming
// For now, return a mock response
const assistantMessage = generateChatResponse(body.message, contextData);
messages.push({
role: 'assistant',
content: assistantMessage,
timestamp: new Date(),
});
// Save or create conversation
if (conversationId) {
await db.queryTenant(
tenant,
`UPDATE ai_conversations
SET messages = $2, updated_at = NOW()
WHERE id = $1`,
[conversationId, JSON.stringify(messages)]
);
} else {
const createResult = await db.queryTenant<{ id: string }>(
tenant,
`INSERT INTO ai_conversations (user_id, title, messages)
VALUES ($1, $2, $3)
RETURNING id`,
[
req.user!.sub,
body.message.slice(0, 50) + (body.message.length > 50 ? '...' : ''),
JSON.stringify(messages),
]
);
conversationId = createResult.rows[0]?.id;
}
const durationMs = Date.now() - startTime;
await logAIRequest(tenant, 'chat', estimatedTokens, durationMs);
// Check if client wants streaming
const wantsStream = req.headers.accept?.includes('text/event-stream');
if (wantsStream) {
// SSE streaming response
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Simulate streaming by sending chunks
const words = assistantMessage.split(' ');
for (let i = 0; i < words.length; i += 3) {
const chunk = words.slice(i, i + 3).join(' ');
res.write(`data: ${JSON.stringify({ type: 'chunk', content: chunk + ' ' })}\n\n`);
await new Promise(resolve => setTimeout(resolve, 50));
}
res.write(`data: ${JSON.stringify({ type: 'done', conversationId })}\n\n`);
res.end();
} else {
// Regular JSON response
const response: ApiResponse = {
success: true,
data: {
conversationId,
message: {
role: 'assistant',
content: assistantMessage,
},
messagesCount: messages.length,
},
meta: {
timestamp: new Date().toISOString(),
tokensUsed: estimatedTokens,
durationMs,
},
};
res.json(response);
}
} catch (error) {
next(error);
}
};
/**
* Get AI usage statistics
*/
export const getUsage = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const tenant = getTenantContext(req);
const db = getDatabase();
const planId = await getUserPlan(tenant.tenantId);
const limits = AI_RATE_LIMITS[planId] || AI_RATE_LIMITS.free;
// Get current day's usage
const today = new Date();
today.setHours(0, 0, 0, 0);
const usageQuery = `
SELECT
COUNT(*) as request_count,
COALESCE(SUM(tokens_used), 0) as tokens_used,
MAX(created_at) as last_request
FROM ai_usage_logs
WHERE user_id = $1 AND created_at >= $2
`;
const usageResult = await db.queryTenant<{
request_count: string;
tokens_used: string;
last_request: Date;
}>(tenant, usageQuery, [req.user!.sub, today.toISOString()]);
const usage = usageResult.rows[0];
// Get usage by type
const byTypeQuery = `
SELECT
request_type,
COUNT(*) as count,
SUM(tokens_used) as tokens
FROM ai_usage_logs
WHERE user_id = $1 AND created_at >= $2
GROUP BY request_type
`;
const byTypeResult = await db.queryTenant(tenant, byTypeQuery, [
req.user!.sub,
today.toISOString(),
]);
// Get weekly trend
const trendQuery = `
SELECT
DATE(created_at) as date,
COUNT(*) as requests,
SUM(tokens_used) as tokens
FROM ai_usage_logs
WHERE user_id = $1 AND created_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE(created_at)
ORDER BY date
`;
const trendResult = await db.queryTenant(tenant, trendQuery, [req.user!.sub]);
const response: ApiResponse = {
success: true,
data: {
today: {
requestCount: parseInt(usage?.request_count || '0', 10),
tokensUsed: parseInt(usage?.tokens_used || '0', 10),
lastRequest: usage?.last_request,
},
limits: {
maxRequestsPerDay: limits.maxRequestsPerDay,
maxTokensPerDay: limits.maxTokensPerDay,
requestsRemaining: Math.max(0, limits.maxRequestsPerDay - parseInt(usage?.request_count || '0', 10)),
tokensRemaining: Math.max(0, limits.maxTokensPerDay - parseInt(usage?.tokens_used || '0', 10)),
},
byType: byTypeResult.rows,
weeklyTrend: trendResult.rows,
plan: planId,
resetsAt: new Date(today.getTime() + 24 * 60 * 60 * 1000).toISOString(),
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
};
// ============================================================================
// Helper Functions
// ============================================================================
function getExplanationTitle(type: string, context?: { metricCode?: string }): string {
switch (type) {
case 'metric':
return `Explicacion de ${context?.metricCode || 'metrica'}`;
case 'anomaly':
return 'Deteccion de anomalia';
case 'trend':
return 'Analisis de tendencia';
case 'transaction':
return 'Analisis de transaccion';
case 'forecast':
return 'Explicacion de proyeccion';
default:
return 'Explicacion';
}
}
function getExplanationText(
type: string,
context?: { value?: number; previousValue?: number; changePercent?: number },
language?: string
): string {
const isSpanish = language !== 'en';
if (context?.changePercent !== undefined) {
const direction = context.changePercent > 0 ? 'aumento' : 'disminucion';
return isSpanish
? `Se observa un ${direction} del ${Math.abs(context.changePercent).toFixed(1)}% respecto al periodo anterior. Este cambio puede atribuirse a varios factores que se detallan a continuacion.`
: `A ${context.changePercent > 0 ? 'increase' : 'decrease'} of ${Math.abs(context.changePercent).toFixed(1)}% is observed compared to the previous period.`;
}
return isSpanish
? 'Esta metrica refleja el estado actual de tu negocio basado en los datos disponibles.'
: 'This metric reflects the current state of your business based on available data.';
}
function getAreaLabel(area: string): string {
const labels: Record<string, string> = {
cost_reduction: 'reduccion de costos',
revenue_growth: 'crecimiento de ingresos',
cash_flow: 'flujo de efectivo',
tax_optimization: 'optimizacion fiscal',
budget_planning: 'planificacion de presupuesto',
general: 'mejora general',
};
return labels[area] || area;
}
function generateChatResponse(message: string, context?: AIContext): string {
// TODO: Replace with actual AI call
const lowerMessage = message.toLowerCase();
if (lowerMessage.includes('hola') || lowerMessage.includes('hello')) {
return 'Hola! Soy tu CFO Digital. Estoy aqui para ayudarte a entender tus finanzas y tomar mejores decisiones. Puedes preguntarme sobre tus metricas, pedirme analisis o recomendaciones.';
}
if (lowerMessage.includes('ingreso') || lowerMessage.includes('revenue')) {
const income = context?.metrics?.total_income || 'no disponible';
return `Segun los datos del periodo, tus ingresos totales son de $${income} MXN. Te puedo ayudar a analizar las tendencias o identificar oportunidades de crecimiento.`;
}
if (lowerMessage.includes('gasto') || lowerMessage.includes('expense')) {
const expense = context?.metrics?.total_expense || 'no disponible';
return `Tus gastos totales en el periodo son de $${expense} MXN. Si deseas, puedo analizar las categorias de mayor gasto y sugerirte formas de optimizar.`;
}
if (lowerMessage.includes('ayuda') || lowerMessage.includes('help')) {
return 'Puedo ayudarte con:\n\n1. Analisis de metricas financieras\n2. Explicaciones de anomalias\n3. Recomendaciones para mejorar\n4. Proyecciones futuras\n5. Responder preguntas sobre tus finanzas\n\nSimplemente preguntame lo que necesites saber!';
}
return 'Entiendo tu consulta. Basandome en los datos disponibles, puedo decirte que tu situacion financiera muestra patrones interesantes. Seria util que me dieras mas contexto o que me preguntes algo especifico para darte una respuesta mas detallada.';
}
export default {
analyzeMetrics,
explainEntity,
getRecommendations,
chat,
getUsage,
};

View File

@@ -0,0 +1,11 @@
/**
* Controllers Index
*
* Export all controllers for easy importing
*/
export * from './reports.controller.js';
export * from './ai.controller.js';
export { default as reportsController } from './reports.controller.js';
export { default as aiController } from './ai.controller.js';

View File

@@ -0,0 +1,742 @@
/**
* Reports Controller
*
* Handles report generation, listing, and download operations
*/
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { getDatabase, TenantContext } from '@horux/database';
import {
ApiResponse,
AppError,
NotFoundError,
ValidationError,
} from '../types/index.js';
import { logger } from '../utils/logger.js';
import { getReportQueue } from '../jobs/report.job.js';
// ============================================================================
// Types
// ============================================================================
export interface Report {
id: string;
tenant_id: string;
name: string;
type: ReportType;
status: ReportStatus;
template_id: string | null;
parameters: Record<string, unknown>;
file_path: string | null;
file_size: number | null;
generated_at: Date | null;
expires_at: Date | null;
created_by: string;
created_at: Date;
updated_at: Date;
error_message: string | null;
}
export type ReportType =
| 'financial_summary'
| 'income_statement'
| 'balance_sheet'
| 'cash_flow'
| 'tax_report'
| 'expense_analysis'
| 'customer_analysis'
| 'budget_vs_actual'
| 'forecast'
| 'custom';
export type ReportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'expired';
export interface ReportTemplate {
id: string;
name: string;
description: string;
type: ReportType;
default_parameters: Record<string, unknown>;
is_active: boolean;
is_premium: boolean;
}
// ============================================================================
// Validation Schemas
// ============================================================================
export const ReportTypeEnum = z.enum([
'financial_summary',
'income_statement',
'balance_sheet',
'cash_flow',
'tax_report',
'expense_analysis',
'customer_analysis',
'budget_vs_actual',
'forecast',
'custom',
]);
export const ReportStatusEnum = z.enum([
'pending',
'processing',
'completed',
'failed',
'expired',
]);
export const ReportFiltersSchema = z.object({
page: z.string().optional().transform((v) => (v ? parseInt(v, 10) : 1)),
limit: z.string().optional().transform((v) => (v ? Math.min(parseInt(v, 10), 100) : 20)),
type: ReportTypeEnum.optional(),
status: ReportStatusEnum.optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
search: z.string().optional(),
});
export const ReportIdSchema = z.object({
id: z.string().uuid('ID de reporte invalido'),
});
export const GenerateReportSchema = z.object({
name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres').max(100),
type: ReportTypeEnum,
templateId: z.string().uuid().optional(),
parameters: z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
categories: z.array(z.string().uuid()).optional(),
contacts: z.array(z.string().uuid()).optional(),
includeCharts: z.boolean().optional().default(true),
includeDetails: z.boolean().optional().default(true),
currency: z.string().length(3).optional().default('MXN'),
language: z.enum(['es', 'en']).optional().default('es'),
format: z.enum(['pdf', 'xlsx', 'csv']).optional().default('pdf'),
comparisonPeriod: z.enum(['previous', 'year_ago', 'none']).optional().default('none'),
}),
priority: z.enum(['low', 'normal', 'high']).optional().default('normal'),
});
// ============================================================================
// Helper Functions
// ============================================================================
const getTenantContext = (req: Request): TenantContext => {
if (!req.user || !req.tenantSchema) {
throw new AppError('Contexto de tenant no disponible', 'TENANT_CONTEXT_ERROR', 500);
}
return {
tenantId: req.user.tenant_id,
schemaName: req.tenantSchema,
userId: req.user.sub,
};
};
// ============================================================================
// Controller Methods
// ============================================================================
/**
* List reports with filters and pagination
*/
export const listReports = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const filters = req.query as z.infer<typeof ReportFiltersSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Build filter conditions
const conditions: string[] = ['1 = 1'];
const params: unknown[] = [];
let paramIndex = 1;
if (filters.type) {
conditions.push(`r.type = $${paramIndex++}`);
params.push(filters.type);
}
if (filters.status) {
conditions.push(`r.status = $${paramIndex++}`);
params.push(filters.status);
}
if (filters.startDate) {
conditions.push(`r.created_at >= $${paramIndex++}`);
params.push(filters.startDate);
}
if (filters.endDate) {
conditions.push(`r.created_at <= $${paramIndex++}`);
params.push(filters.endDate);
}
if (filters.search) {
conditions.push(`r.name ILIKE $${paramIndex++}`);
params.push(`%${filters.search}%`);
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const offset = (filters.page - 1) * filters.limit;
// Get total count
const countQuery = `SELECT COUNT(*) as total FROM reports r ${whereClause}`;
const countResult = await db.queryTenant<{ total: string }>(tenant, countQuery, params);
const total = parseInt(countResult.rows[0]?.total || '0', 10);
// Get reports
const dataQuery = `
SELECT
r.id,
r.name,
r.type,
r.status,
r.template_id,
r.parameters,
r.file_path,
r.file_size,
r.generated_at,
r.expires_at,
r.error_message,
r.created_at,
r.updated_at,
u.email as created_by_email,
u.first_name as created_by_first_name,
u.last_name as created_by_last_name
FROM reports r
LEFT JOIN public.users u ON r.created_by = u.id
${whereClause}
ORDER BY r.created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const dataResult = await db.queryTenant(tenant, dataQuery, [...params, filters.limit, offset]);
const response: ApiResponse = {
success: true,
data: dataResult.rows,
meta: {
page: filters.page,
limit: filters.limit,
total,
totalPages: Math.ceil(total / filters.limit),
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
};
/**
* Get a single report by ID
*/
export const getReport = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
const query = `
SELECT
r.*,
u.email as created_by_email,
u.first_name as created_by_first_name,
u.last_name as created_by_last_name
FROM reports r
LEFT JOIN public.users u ON r.created_by = u.id
WHERE r.id = $1
`;
const result = await db.queryTenant(tenant, query, [id]);
if (result.rows.length === 0) {
throw new NotFoundError('Reporte');
}
const response: ApiResponse = {
success: true,
data: result.rows[0],
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
};
/**
* Generate a new report
*/
export const generateReport = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const body = req.body as z.infer<typeof GenerateReportSchema>;
const tenant = getTenantContext(req);
const db = getDatabase();
// Validate date range
const startDate = new Date(body.parameters.startDate);
const endDate = new Date(body.parameters.endDate);
if (startDate >= endDate) {
throw new ValidationError('La fecha de inicio debe ser anterior a la fecha de fin');
}
// Check for reasonable date range (max 5 years)
const maxRangeDays = 365 * 5;
const daysDiff = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
if (daysDiff > maxRangeDays) {
throw new ValidationError('El rango de fechas no puede ser mayor a 5 anos');
}
// Check concurrent report limit (max 3 pending reports per user)
const pendingCheck = await db.queryTenant<{ count: string }>(
tenant,
`SELECT COUNT(*) as count FROM reports WHERE created_by = $1 AND status IN ('pending', 'processing')`,
[req.user!.sub]
);
const pendingCount = parseInt(pendingCheck.rows[0]?.count || '0', 10);
if (pendingCount >= 3) {
throw new ValidationError('Ya tienes 3 reportes en proceso. Espera a que terminen antes de generar mas.');
}
// Create report record
const insertQuery = `
INSERT INTO reports (
name,
type,
status,
template_id,
parameters,
created_by
) VALUES ($1, $2, 'pending', $3, $4, $5)
RETURNING *
`;
const insertResult = await db.queryTenant(tenant, insertQuery, [
body.name,
body.type,
body.templateId || null,
JSON.stringify(body.parameters),
req.user!.sub,
]);
const report = insertResult.rows[0];
// Add job to queue
const queue = getReportQueue();
const priority = body.priority === 'high' ? 1 : body.priority === 'low' ? 10 : 5;
const job = await queue.add(
'generate-report',
{
reportId: report.id,
tenantId: tenant.tenantId,
schemaName: tenant.schemaName,
userId: req.user!.sub,
type: body.type,
parameters: body.parameters,
},
{
priority,
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: {
age: 24 * 3600, // Keep completed jobs for 24 hours
count: 100,
},
removeOnFail: {
age: 7 * 24 * 3600, // Keep failed jobs for 7 days
},
}
);
logger.info('Report generation job queued', {
reportId: report.id,
jobId: job.id,
type: body.type,
userId: req.user!.sub,
});
const response: ApiResponse = {
success: true,
data: {
...report,
jobId: job.id,
estimatedTime: getEstimatedTime(body.type, daysDiff),
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.status(202).json(response);
} catch (error) {
next(error);
}
};
/**
* Download a report PDF
*/
export const downloadReport = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
// Get report
const query = `
SELECT id, name, status, file_path, file_size, parameters, expires_at
FROM reports
WHERE id = $1
`;
const result = await db.queryTenant(tenant, query, [id]);
if (result.rows.length === 0) {
throw new NotFoundError('Reporte');
}
const report = result.rows[0];
// Check status
if (report.status !== 'completed') {
throw new ValidationError(
report.status === 'processing'
? 'El reporte aun se esta generando'
: report.status === 'failed'
? 'El reporte fallo al generarse'
: 'El reporte no esta disponible'
);
}
// Check expiration
if (report.expires_at && new Date(report.expires_at) < new Date()) {
throw new ValidationError('El reporte ha expirado');
}
// Check file exists
if (!report.file_path) {
throw new AppError('Archivo de reporte no encontrado', 'FILE_NOT_FOUND', 404);
}
// Get file from storage (MinIO)
// For now, return a signed URL or stream the file
// This would integrate with a storage service
const format = (report.parameters as { format?: string })?.format || 'pdf';
const contentType = format === 'pdf'
? 'application/pdf'
: format === 'xlsx'
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'text/csv';
const response: ApiResponse = {
success: true,
data: {
downloadUrl: `/api/v1/reports/${id}/file`,
fileName: `${report.name}.${format}`,
fileSize: report.file_size,
contentType,
expiresAt: report.expires_at,
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
};
/**
* Delete a report
*/
export const deleteReport = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const { id } = req.params;
const tenant = getTenantContext(req);
const db = getDatabase();
// Get report
const existingResult = await db.queryTenant(
tenant,
'SELECT id, status, file_path, created_by FROM reports WHERE id = $1',
[id]
);
if (existingResult.rows.length === 0) {
throw new NotFoundError('Reporte');
}
const report = existingResult.rows[0];
// Only allow deletion by owner or admin
if (report.created_by !== req.user!.sub && req.user!.role !== 'owner' && req.user!.role !== 'admin') {
throw new AppError('No tienes permisos para eliminar este reporte', 'FORBIDDEN', 403);
}
// If report is processing, cancel the job
if (report.status === 'pending' || report.status === 'processing') {
const queue = getReportQueue();
const jobs = await queue.getJobs(['waiting', 'active', 'delayed']);
const reportJob = jobs.find(j => j.data?.reportId === id);
if (reportJob) {
await reportJob.remove();
logger.info('Cancelled pending report job', { reportId: id, jobId: reportJob.id });
}
}
// TODO: Delete file from storage if exists
if (report.file_path) {
// await storageService.deleteFile(report.file_path);
}
// Delete report record
await db.queryTenant(tenant, 'DELETE FROM reports WHERE id = $1', [id]);
logger.info('Report deleted', { reportId: id, userId: req.user!.sub });
const response: ApiResponse = {
success: true,
data: {
id,
deleted: true,
},
meta: {
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
};
/**
* List available report templates
*/
export const listTemplates = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const tenant = getTenantContext(req);
const db = getDatabase();
// Get user's plan to filter premium templates
const planResult = await db.queryPublic<{ plan_id: string }>(
'SELECT plan_id FROM public.tenants WHERE id = $1',
[tenant.tenantId]
);
const planId = planResult.rows[0]?.plan_id || 'free';
const isPremium = planId !== 'free';
const query = `
SELECT
id,
name,
description,
type,
default_parameters,
is_premium,
preview_image_url,
category
FROM report_templates
WHERE is_active = true
ORDER BY
CASE WHEN is_premium AND $1 = false THEN 1 ELSE 0 END,
category,
name
`;
const result = await db.queryTenant(tenant, query, [isPremium]);
// If no templates in tenant schema, return default templates
const templates = result.rows.length > 0 ? result.rows : getDefaultTemplates();
const response: ApiResponse = {
success: true,
data: templates.map(t => ({
...t,
available: isPremium || !t.is_premium,
})),
meta: {
isPremiumPlan: isPremium,
timestamp: new Date().toISOString(),
},
};
res.json(response);
} catch (error) {
next(error);
}
};
// ============================================================================
// Helper Functions
// ============================================================================
function getEstimatedTime(type: ReportType, daysDiff: number): string {
// Estimate based on report type and date range
let baseMinutes = 1;
switch (type) {
case 'financial_summary':
baseMinutes = 2;
break;
case 'income_statement':
case 'balance_sheet':
case 'cash_flow':
baseMinutes = 3;
break;
case 'tax_report':
baseMinutes = 5;
break;
case 'expense_analysis':
case 'customer_analysis':
baseMinutes = 4;
break;
case 'budget_vs_actual':
case 'forecast':
baseMinutes = 6;
break;
case 'custom':
baseMinutes = 5;
break;
}
// Add time based on date range
if (daysDiff > 365) {
baseMinutes *= 2;
} else if (daysDiff > 180) {
baseMinutes *= 1.5;
}
return `${Math.ceil(baseMinutes)} minutos`;
}
function getDefaultTemplates(): Partial<ReportTemplate>[] {
return [
{
id: 'tpl-financial-summary',
name: 'Resumen Financiero',
description: 'Resumen ejecutivo de la situacion financiera con metricas clave',
type: 'financial_summary',
is_active: true,
is_premium: false,
},
{
id: 'tpl-income-statement',
name: 'Estado de Resultados',
description: 'Detalle de ingresos, gastos y utilidad neta',
type: 'income_statement',
is_active: true,
is_premium: false,
},
{
id: 'tpl-balance-sheet',
name: 'Balance General',
description: 'Activos, pasivos y capital contable',
type: 'balance_sheet',
is_active: true,
is_premium: false,
},
{
id: 'tpl-cash-flow',
name: 'Flujo de Efectivo',
description: 'Movimientos de entrada y salida de efectivo',
type: 'cash_flow',
is_active: true,
is_premium: false,
},
{
id: 'tpl-tax-report',
name: 'Reporte Fiscal',
description: 'Resumen de obligaciones fiscales y CFDIs',
type: 'tax_report',
is_active: true,
is_premium: true,
},
{
id: 'tpl-expense-analysis',
name: 'Analisis de Gastos',
description: 'Desglose detallado de gastos por categoria',
type: 'expense_analysis',
is_active: true,
is_premium: false,
},
{
id: 'tpl-customer-analysis',
name: 'Analisis de Clientes',
description: 'Metricas de clientes, ingresos por cliente y tendencias',
type: 'customer_analysis',
is_active: true,
is_premium: true,
},
{
id: 'tpl-budget-vs-actual',
name: 'Presupuesto vs Real',
description: 'Comparacion de presupuesto planificado contra resultados reales',
type: 'budget_vs_actual',
is_active: true,
is_premium: true,
},
{
id: 'tpl-forecast',
name: 'Proyeccion Financiera',
description: 'Proyecciones basadas en datos historicos e IA',
type: 'forecast',
is_active: true,
is_premium: true,
},
];
}
export default {
listReports,
getReport,
generateReport,
downloadReport,
deleteReport,
listTemplates,
};

View File

@@ -18,6 +18,9 @@ import contactsRoutes from './routes/contacts.routes.js';
import cfdisRoutes from './routes/cfdis.routes.js'; import cfdisRoutes from './routes/cfdis.routes.js';
import categoriesRoutes from './routes/categories.routes.js'; import categoriesRoutes from './routes/categories.routes.js';
import alertsRoutes from './routes/alerts.routes.js'; import alertsRoutes from './routes/alerts.routes.js';
import reportsRoutes from './routes/reports.routes.js';
import aiRoutes from './routes/ai.routes.js';
import { startReportWorker, stopReportWorker } from './jobs/report.job.js';
// ============================================================================ // ============================================================================
// Application Setup // Application Setup
@@ -175,6 +178,8 @@ app.use(`${apiPrefix}/contacts`, authenticate, tenantContext, contactsRoutes);
app.use(`${apiPrefix}/cfdis`, authenticate, tenantContext, cfdisRoutes); app.use(`${apiPrefix}/cfdis`, authenticate, tenantContext, cfdisRoutes);
app.use(`${apiPrefix}/categories`, authenticate, tenantContext, categoriesRoutes); app.use(`${apiPrefix}/categories`, authenticate, tenantContext, categoriesRoutes);
app.use(`${apiPrefix}/alerts`, authenticate, tenantContext, alertsRoutes); app.use(`${apiPrefix}/alerts`, authenticate, tenantContext, alertsRoutes);
app.use(`${apiPrefix}/reports`, authenticate, tenantContext, reportsRoutes);
app.use(`${apiPrefix}/ai`, authenticate, tenantContext, aiRoutes);
// ============================================================================ // ============================================================================
// API Info Route // API Info Route
@@ -213,6 +218,14 @@ app.use(errorHandler);
const startServer = async (): Promise<void> => { const startServer = async (): Promise<void> => {
try { try {
// Start report generation worker
try {
startReportWorker();
logger.info('Report worker started');
} catch (error) {
logger.warn('Failed to start report worker (Redis may not be available)', { error });
}
// Start listening // Start listening
const server = app.listen(config.server.port, config.server.host, () => { const server = app.listen(config.server.port, config.server.host, () => {
logger.info(`Horux Strategy API started`, { logger.info(`Horux Strategy API started`, {
@@ -233,7 +246,7 @@ const startServer = async (): Promise<void> => {
const gracefulShutdown = async (signal: string): Promise<void> => { const gracefulShutdown = async (signal: string): Promise<void> => {
logger.info(`${signal} received. Starting graceful shutdown...`); logger.info(`${signal} received. Starting graceful shutdown...`);
server.close((err) => { server.close(async (err) => {
if (err) { if (err) {
logger.error('Error during server close', { error: err }); logger.error('Error during server close', { error: err });
process.exit(1); process.exit(1);
@@ -241,6 +254,14 @@ const startServer = async (): Promise<void> => {
logger.info('Server closed. Cleaning up...'); logger.info('Server closed. Cleaning up...');
// Stop report worker
try {
await stopReportWorker();
logger.info('Report worker stopped');
} catch (error) {
logger.warn('Error stopping report worker', { error });
}
// Add any cleanup logic here (close database connections, etc.) // Add any cleanup logic here (close database connections, etc.)
logger.info('Graceful shutdown completed'); logger.info('Graceful shutdown completed');

View File

@@ -0,0 +1,11 @@
/**
* Jobs Index
*
* Export all background job processors
*/
export * from './report.job.js';
export { default as reportJob } from './report.job.js';
// Integration sync jobs
export { processSyncJob, type SyncJobPayload, type SyncJobContext } from './sync.job.js';

View File

@@ -0,0 +1,784 @@
/**
* Report Generation Job
*
* BullMQ job processor for generating reports in background
*/
import { Queue, Worker, Job, QueueEvents } from 'bullmq';
import IORedis from 'ioredis';
import { getDatabase, TenantContext } from '@horux/database';
import { logger } from '../utils/logger.js';
import { config } from '../config/index.js';
// ============================================================================
// Types
// ============================================================================
export interface ReportJobData {
reportId: string;
tenantId: string;
schemaName: string;
userId: string;
type: string;
parameters: {
startDate: string;
endDate: string;
categories?: string[];
contacts?: string[];
includeCharts?: boolean;
includeDetails?: boolean;
currency?: string;
language?: string;
format?: 'pdf' | 'xlsx' | 'csv';
comparisonPeriod?: 'previous' | 'year_ago' | 'none';
};
}
export interface ReportJobResult {
reportId: string;
filePath: string;
fileSize: number;
generatedAt: Date;
expiresAt: Date;
pageCount?: number;
}
// ============================================================================
// Queue Configuration
// ============================================================================
const QUEUE_NAME = 'report-generation';
const REDIS_CONFIG = {
host: new URL(config.redis.url).hostname || 'localhost',
port: parseInt(new URL(config.redis.url).port || '6379', 10),
maxRetriesPerRequest: null,
};
let queue: Queue<ReportJobData, ReportJobResult> | null = null;
let worker: Worker<ReportJobData, ReportJobResult> | null = null;
let queueEvents: QueueEvents | null = null;
// ============================================================================
// Queue Initialization
// ============================================================================
/**
* Get or create the report queue
*/
export function getReportQueue(): Queue<ReportJobData, ReportJobResult> {
if (!queue) {
const connection = new IORedis(REDIS_CONFIG);
queue = new Queue<ReportJobData, ReportJobResult>(QUEUE_NAME, {
connection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: {
age: 24 * 3600, // 24 hours
count: 1000,
},
removeOnFail: {
age: 7 * 24 * 3600, // 7 days
},
},
});
queue.on('error', (error) => {
logger.error('Report queue error', { error: error.message });
});
logger.info('Report queue initialized');
}
return queue;
}
/**
* Initialize the queue events listener
*/
export function getQueueEvents(): QueueEvents {
if (!queueEvents) {
const connection = new IORedis(REDIS_CONFIG);
queueEvents = new QueueEvents(QUEUE_NAME, { connection });
queueEvents.on('completed', ({ jobId, returnvalue }) => {
logger.info('Report job completed', { jobId, returnvalue });
});
queueEvents.on('failed', ({ jobId, failedReason }) => {
logger.error('Report job failed', { jobId, failedReason });
});
queueEvents.on('progress', ({ jobId, data }) => {
logger.debug('Report job progress', { jobId, progress: data });
});
}
return queueEvents;
}
// ============================================================================
// Job Processor
// ============================================================================
/**
* Process a report generation job
*/
async function processReportJob(
job: Job<ReportJobData, ReportJobResult>
): Promise<ReportJobResult> {
const { reportId, tenantId, schemaName, userId, type, parameters } = job.data;
const db = getDatabase();
const tenant: TenantContext = {
tenantId,
schemaName,
userId,
};
logger.info('Starting report generation', {
jobId: job.id,
reportId,
type,
tenantId,
});
try {
// Update status to processing
await db.queryTenant(
tenant,
`UPDATE reports SET status = 'processing', updated_at = NOW() WHERE id = $1`,
[reportId]
);
await job.updateProgress(10);
// Fetch data based on report type
const reportData = await fetchReportData(tenant, type, parameters);
await job.updateProgress(40);
// Generate the report document
const document = await generateReportDocument(type, reportData, parameters);
await job.updateProgress(70);
// Save to storage
const { filePath, fileSize } = await saveReportToStorage(
reportId,
tenantId,
document,
parameters.format || 'pdf'
);
await job.updateProgress(90);
// Calculate expiration (30 days from now)
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
// Update report record
await db.queryTenant(
tenant,
`UPDATE reports
SET status = 'completed',
file_path = $2,
file_size = $3,
generated_at = NOW(),
expires_at = $4,
updated_at = NOW()
WHERE id = $1`,
[reportId, filePath, fileSize, expiresAt.toISOString()]
);
await job.updateProgress(100);
// Send notification to user
await notifyUser(tenant, userId, {
type: 'report_ready',
reportId,
title: `Tu reporte esta listo`,
message: `El reporte "${type}" ha sido generado exitosamente.`,
});
logger.info('Report generation completed', {
jobId: job.id,
reportId,
filePath,
fileSize,
});
return {
reportId,
filePath,
fileSize,
generatedAt: new Date(),
expiresAt,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
logger.error('Report generation failed', {
jobId: job.id,
reportId,
error: errorMessage,
});
// Update report status to failed
await db.queryTenant(
tenant,
`UPDATE reports
SET status = 'failed',
error_message = $2,
updated_at = NOW()
WHERE id = $1`,
[reportId, errorMessage]
);
// Notify user of failure
await notifyUser(tenant, userId, {
type: 'report_failed',
reportId,
title: 'Error al generar reporte',
message: `No se pudo generar el reporte. Por favor intenta de nuevo.`,
});
throw error;
}
}
// ============================================================================
// Report Data Fetching
// ============================================================================
interface ReportData {
metrics: Record<string, unknown>;
transactions: unknown[];
categories: unknown[];
summary: Record<string, unknown>;
previousPeriod?: Record<string, unknown>;
}
async function fetchReportData(
tenant: TenantContext,
type: string,
parameters: ReportJobData['parameters']
): Promise<ReportData> {
const db = getDatabase();
const { startDate, endDate, categories, contacts } = parameters;
// Build category filter
const categoryFilter = categories?.length
? `AND t.category_id IN (${categories.map((_, i) => `$${i + 3}`).join(',')})`
: '';
// Build contact filter
const contactFilter = contacts?.length
? `AND t.contact_id IN (${contacts.map((_, i) => `$${i + 3 + (categories?.length || 0)}`).join(',')})`
: '';
const filterParams = [
startDate,
endDate,
...(categories || []),
...(contacts || []),
];
// Get summary metrics
const metricsQuery = `
SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as net_profit,
COUNT(*) as transaction_count,
COUNT(DISTINCT contact_id) as unique_contacts,
AVG(amount) as average_transaction
FROM transactions t
WHERE date >= $1 AND date <= $2 AND status != 'cancelled'
${categoryFilter}
${contactFilter}
`;
const metricsResult = await db.queryTenant(tenant, metricsQuery, filterParams);
// Get transactions with details
const transactionsQuery = `
SELECT
t.id,
t.date,
t.type,
t.amount,
t.description,
t.reference,
t.status,
c.name as category_name,
c.color as category_color,
ct.name as contact_name
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN contacts ct ON t.contact_id = ct.id
WHERE t.date >= $1 AND t.date <= $2 AND t.status != 'cancelled'
${categoryFilter}
${contactFilter}
ORDER BY t.date DESC
LIMIT 1000
`;
const transactionsResult = await db.queryTenant(tenant, transactionsQuery, filterParams);
// Get category breakdown
const categoriesQuery = `
SELECT
c.id,
c.name,
c.type,
c.color,
c.icon,
COUNT(*) as transaction_count,
SUM(t.amount) as total_amount,
AVG(t.amount) as average_amount
FROM transactions t
JOIN categories c ON t.category_id = c.id
WHERE t.date >= $1 AND t.date <= $2 AND t.status != 'cancelled'
${categoryFilter}
${contactFilter}
GROUP BY c.id, c.name, c.type, c.color, c.icon
ORDER BY SUM(t.amount) DESC
`;
const categoriesResult = await db.queryTenant(tenant, categoriesQuery, filterParams);
// Get previous period data if comparison requested
let previousPeriod: Record<string, unknown> | undefined;
if (parameters.comparisonPeriod && parameters.comparisonPeriod !== 'none') {
const start = new Date(startDate);
const end = new Date(endDate);
const duration = end.getTime() - start.getTime();
let prevStart: Date;
let prevEnd: Date;
if (parameters.comparisonPeriod === 'year_ago') {
prevStart = new Date(start);
prevStart.setFullYear(prevStart.getFullYear() - 1);
prevEnd = new Date(end);
prevEnd.setFullYear(prevEnd.getFullYear() - 1);
} else {
prevEnd = new Date(start);
prevStart = new Date(prevEnd.getTime() - duration);
}
const prevResult = await db.queryTenant(tenant, metricsQuery, [
prevStart.toISOString(),
prevEnd.toISOString(),
...(categories || []),
...(contacts || []),
]);
previousPeriod = prevResult.rows[0];
}
// Type-specific data
const typeSpecificData = await fetchTypeSpecificData(tenant, type, parameters);
return {
metrics: metricsResult.rows[0],
transactions: transactionsResult.rows,
categories: categoriesResult.rows,
summary: typeSpecificData,
previousPeriod,
};
}
async function fetchTypeSpecificData(
tenant: TenantContext,
type: string,
parameters: ReportJobData['parameters']
): Promise<Record<string, unknown>> {
const db = getDatabase();
const { startDate, endDate } = parameters;
switch (type) {
case 'tax_report': {
// Get CFDI summary
const cfdiQuery = `
SELECT
tipo_comprobante,
COUNT(*) as count,
SUM(total) as total,
SUM(subtotal) as subtotal,
SUM(total_impuestos_trasladados) as taxes_transferred,
SUM(total_impuestos_retenidos) as taxes_withheld
FROM cfdis
WHERE fecha_emision >= $1 AND fecha_emision <= $2 AND status = 'vigente'
GROUP BY tipo_comprobante
`;
const cfdiResult = await db.queryTenant(tenant, cfdiQuery, [startDate, endDate]);
return {
cfdiSummary: cfdiResult.rows,
};
}
case 'cash_flow': {
// Get daily cash flow
const cashFlowQuery = `
SELECT
DATE(date) as day,
SUM(CASE WHEN type = 'income' AND status = 'completed' THEN amount ELSE 0 END) as inflow,
SUM(CASE WHEN type = 'expense' AND status = 'completed' THEN amount ELSE 0 END) as outflow,
SUM(CASE WHEN type = 'income' AND status = 'completed' THEN amount ELSE 0 END) -
SUM(CASE WHEN type = 'expense' AND status = 'completed' THEN amount ELSE 0 END) as net
FROM transactions
WHERE date >= $1 AND date <= $2
GROUP BY DATE(date)
ORDER BY day
`;
const cashFlowResult = await db.queryTenant(tenant, cashFlowQuery, [startDate, endDate]);
return {
dailyCashFlow: cashFlowResult.rows,
};
}
case 'customer_analysis': {
// Get customer metrics
const customerQuery = `
SELECT
c.id,
c.name,
c.email,
c.type,
COUNT(t.id) as transaction_count,
SUM(t.amount) as total_revenue,
AVG(t.amount) as average_transaction,
MIN(t.date) as first_transaction,
MAX(t.date) as last_transaction
FROM contacts c
LEFT JOIN transactions t ON c.id = t.contact_id
AND t.type = 'income'
AND t.date >= $1 AND t.date <= $2
AND t.status != 'cancelled'
WHERE c.type = 'customer'
GROUP BY c.id, c.name, c.email, c.type
HAVING COUNT(t.id) > 0
ORDER BY SUM(t.amount) DESC
LIMIT 50
`;
const customerResult = await db.queryTenant(tenant, customerQuery, [startDate, endDate]);
return {
customers: customerResult.rows,
};
}
default:
return {};
}
}
// ============================================================================
// Report Document Generation
// ============================================================================
interface ReportDocument {
content: Buffer;
mimeType: string;
extension: string;
}
async function generateReportDocument(
type: string,
data: ReportData,
parameters: ReportJobData['parameters']
): Promise<ReportDocument> {
const format = parameters.format || 'pdf';
// TODO: Integrate with actual PDF/Excel generation library
// For now, create a placeholder
switch (format) {
case 'pdf':
return generatePdfReport(type, data, parameters);
case 'xlsx':
return generateExcelReport(type, data, parameters);
case 'csv':
return generateCsvReport(type, data, parameters);
default:
return generatePdfReport(type, data, parameters);
}
}
async function generatePdfReport(
type: string,
data: ReportData,
parameters: ReportJobData['parameters']
): Promise<ReportDocument> {
// TODO: Use PDFKit or similar library to generate actual PDF
// This is a placeholder implementation
const content = Buffer.from(JSON.stringify({
reportType: type,
generatedAt: new Date().toISOString(),
parameters,
summary: data.metrics,
transactionCount: data.transactions.length,
categoryCount: data.categories.length,
note: 'PDF generation placeholder - implement with PDFKit',
}, null, 2));
return {
content,
mimeType: 'application/pdf',
extension: 'pdf',
};
}
async function generateExcelReport(
type: string,
data: ReportData,
_parameters: ReportJobData['parameters']
): Promise<ReportDocument> {
// TODO: Use exceljs or similar library
const content = Buffer.from(JSON.stringify({
reportType: type,
generatedAt: new Date().toISOString(),
transactions: data.transactions,
categories: data.categories,
note: 'Excel generation placeholder - implement with exceljs',
}, null, 2));
return {
content,
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
extension: 'xlsx',
};
}
async function generateCsvReport(
_type: string,
data: ReportData,
_parameters: ReportJobData['parameters']
): Promise<ReportDocument> {
// Generate CSV from transactions
const headers = ['Date', 'Type', 'Amount', 'Description', 'Category', 'Contact', 'Status'];
const rows = (data.transactions as Array<{
date: string;
type: string;
amount: number;
description: string;
category_name: string;
contact_name: string;
status: string;
}>).map(t => [
t.date,
t.type,
t.amount,
`"${(t.description || '').replace(/"/g, '""')}"`,
t.category_name || '',
t.contact_name || '',
t.status,
].join(','));
const csvContent = [headers.join(','), ...rows].join('\n');
return {
content: Buffer.from(csvContent, 'utf-8'),
mimeType: 'text/csv',
extension: 'csv',
};
}
// ============================================================================
// Storage
// ============================================================================
async function saveReportToStorage(
reportId: string,
tenantId: string,
document: ReportDocument,
_format: string
): Promise<{ filePath: string; fileSize: number }> {
// TODO: Integrate with MinIO storage service
// For now, return a placeholder path
const filePath = `reports/${tenantId}/${reportId}.${document.extension}`;
const fileSize = document.content.length;
// In production:
// await minioClient.putObject(bucket, filePath, document.content, {
// 'Content-Type': document.mimeType,
// });
logger.debug('Report saved to storage', { filePath, fileSize });
return { filePath, fileSize };
}
// ============================================================================
// Notifications
// ============================================================================
interface NotificationData {
type: string;
reportId: string;
title: string;
message: string;
}
async function notifyUser(
tenant: TenantContext,
userId: string,
notification: NotificationData
): Promise<void> {
const db = getDatabase();
try {
// Create in-app notification
await db.queryTenant(
tenant,
`INSERT INTO alerts (
user_id,
type,
priority,
title,
message,
action_url,
action_label,
metadata
) VALUES ($1, 'info', 'medium', $2, $3, $4, $5, $6)`,
[
userId,
notification.title,
notification.message,
`/reports/${notification.reportId}`,
notification.type === 'report_ready' ? 'Ver reporte' : 'Reintentar',
JSON.stringify({ reportId: notification.reportId }),
]
);
// TODO: Send email notification if configured
// TODO: Send push notification if mobile app
logger.debug('User notified', { userId, type: notification.type });
} catch (error) {
logger.warn('Failed to notify user', { userId, error });
}
}
// ============================================================================
// Worker Initialization
// ============================================================================
/**
* Start the report worker
*/
export function startReportWorker(): Worker<ReportJobData, ReportJobResult> {
if (worker) {
return worker;
}
const connection = new IORedis(REDIS_CONFIG);
worker = new Worker<ReportJobData, ReportJobResult>(
QUEUE_NAME,
processReportJob,
{
connection,
concurrency: 3, // Process up to 3 reports concurrently
limiter: {
max: 10,
duration: 60000, // 10 jobs per minute max
},
}
);
worker.on('completed', (job, result) => {
logger.info('Report worker: job completed', {
jobId: job.id,
reportId: result.reportId,
});
});
worker.on('failed', (job, error) => {
logger.error('Report worker: job failed', {
jobId: job?.id,
error: error.message,
attempts: job?.attemptsMade,
});
});
worker.on('error', (error) => {
logger.error('Report worker error', { error: error.message });
});
worker.on('stalled', (jobId) => {
logger.warn('Report worker: job stalled', { jobId });
});
logger.info('Report worker started', { concurrency: 3 });
return worker;
}
/**
* Stop the report worker gracefully
*/
export async function stopReportWorker(): Promise<void> {
if (worker) {
await worker.close();
worker = null;
logger.info('Report worker stopped');
}
if (queueEvents) {
await queueEvents.close();
queueEvents = null;
}
if (queue) {
await queue.close();
queue = null;
}
}
/**
* Get queue statistics
*/
export async function getQueueStats(): Promise<{
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
}> {
const q = getReportQueue();
const [waiting, active, completed, failed, delayed] = await Promise.all([
q.getWaitingCount(),
q.getActiveCount(),
q.getCompletedCount(),
q.getFailedCount(),
q.getDelayedCount(),
]);
return { waiting, active, completed, failed, delayed };
}
export default {
getReportQueue,
getQueueEvents,
startReportWorker,
stopReportWorker,
getQueueStats,
};

View File

@@ -0,0 +1,707 @@
/**
* Sync Job
*
* Background job processor for integration synchronizations.
* Handles progress tracking, logging, and retries with exponential backoff.
*/
import { Job } from 'bullmq';
import { getDatabase, TenantContext } from '@horux/database';
import { logger, createContextLogger } from '../utils/logger.js';
import { integrationManager } from '../services/integrations/integration.manager.js';
import {
IntegrationType,
SyncStatus,
SyncResult,
SyncError,
SyncRecordResult,
SyncEntityType,
SyncDirection,
IIntegrationConnector,
SyncOptions,
FetchOptions,
} from '../services/integrations/integration.types.js';
// ============================================================================
// TYPES
// ============================================================================
export interface SyncJobPayload {
tenantId: string;
tenantSchema: string;
integrationId: string;
integrationType: IntegrationType;
scheduleId?: string;
options: SyncOptions;
priority: 'low' | 'normal' | 'high';
triggeredBy: 'schedule' | 'manual' | 'webhook' | 'system';
triggeredByUserId?: string;
retryCount?: number;
}
export interface SyncJobContext {
tenant: TenantContext;
integrationId: string;
integrationType: IntegrationType;
jobId: string;
logger: ReturnType<typeof createContextLogger>;
}
// ============================================================================
// SYNC JOB PROCESSOR
// ============================================================================
/**
* Process a sync job
*/
export async function processSyncJob(job: Job<SyncJobPayload>): Promise<SyncResult> {
const { data } = job;
const startTime = Date.now();
// Create context logger
const contextLogger = createContextLogger({
jobId: job.id,
integrationId: data.integrationId,
integrationType: data.integrationType,
tenantId: data.tenantId,
action: 'sync',
});
contextLogger.info('Starting sync job');
// Create tenant context
const tenant: TenantContext = {
tenantId: data.tenantId,
schemaName: data.tenantSchema,
userId: data.triggeredByUserId || 'system',
};
// Create job context
const context: SyncJobContext = {
tenant,
integrationId: data.integrationId,
integrationType: data.integrationType,
jobId: job.id!,
logger: contextLogger,
};
// Initialize result
const result: SyncResult = {
jobId: job.id!,
integrationId: data.integrationId,
integrationType: data.integrationType,
status: SyncStatus.RUNNING,
direction: data.options.direction || SyncDirection.IMPORT,
entityType: data.options.entityTypes?.[0] || SyncEntityType.TRANSACTIONS,
startedAt: new Date(),
totalRecords: 0,
processedRecords: 0,
createdRecords: 0,
updatedRecords: 0,
skippedRecords: 0,
failedRecords: 0,
progress: 0,
errors: [],
warnings: [],
};
try {
// Update job progress
await job.updateProgress(5);
// Get integration details
const integration = await integrationManager.getIntegration(tenant, data.integrationId);
if (!integration) {
throw new Error('Integration not found');
}
if (!integration.isActive) {
throw new Error('Integration is not active');
}
// Update progress
await job.updateProgress(10);
// Get connector
const connector = integrationManager.getConnector(data.integrationType);
// Connect to external system
contextLogger.info('Connecting to external system');
await connector.connect(integration.config);
await job.updateProgress(15);
// Determine entities to sync
const entityTypes = data.options.entityTypes ||
(integration.config as any).enabledEntities ||
connector.getSupportedEntities();
// Calculate progress increments
const progressPerEntity = 80 / entityTypes.length;
let currentProgress = 15;
// Sync each entity type
for (const entityType of entityTypes) {
contextLogger.info(`Syncing entity type: ${entityType}`);
try {
const entityResult = await syncEntityType(
context,
connector,
entityType,
data.options,
job,
currentProgress,
progressPerEntity
);
// Aggregate results
result.totalRecords += entityResult.totalRecords;
result.processedRecords += entityResult.processedRecords;
result.createdRecords += entityResult.createdRecords;
result.updatedRecords += entityResult.updatedRecords;
result.skippedRecords += entityResult.skippedRecords;
result.failedRecords += entityResult.failedRecords;
result.errors.push(...entityResult.errors);
result.warnings.push(...entityResult.warnings);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
contextLogger.error(`Failed to sync ${entityType}`, { error: errorMessage });
result.errors.push({
code: 'ENTITY_SYNC_FAILED',
message: `Failed to sync ${entityType}: ${errorMessage}`,
timestamp: new Date(),
retryable: true,
});
}
currentProgress += progressPerEntity;
await job.updateProgress(Math.min(currentProgress, 95));
}
// Disconnect from external system
await connector.disconnect();
// Determine final status
if (result.errors.length === 0) {
result.status = SyncStatus.COMPLETED;
} else if (result.processedRecords > 0) {
result.status = SyncStatus.PARTIAL;
} else {
result.status = SyncStatus.FAILED;
}
result.completedAt = new Date();
result.durationMs = Date.now() - startTime;
result.progress = 100;
await job.updateProgress(100);
// Log final results
await logSyncResult(context, result);
contextLogger.info('Sync job completed', {
status: result.status,
totalRecords: result.totalRecords,
processedRecords: result.processedRecords,
createdRecords: result.createdRecords,
updatedRecords: result.updatedRecords,
failedRecords: result.failedRecords,
durationMs: result.durationMs,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
result.status = SyncStatus.FAILED;
result.completedAt = new Date();
result.durationMs = Date.now() - startTime;
result.errors.push({
code: 'SYNC_FAILED',
message: errorMessage,
timestamp: new Date(),
retryable: isRetryableError(error),
});
// Log failure
await logSyncResult(context, result);
contextLogger.error('Sync job failed', { error: errorMessage });
throw error;
}
}
// ============================================================================
// ENTITY SYNC
// ============================================================================
/**
* Sync a single entity type
*/
async function syncEntityType(
context: SyncJobContext,
connector: IIntegrationConnector,
entityType: SyncEntityType,
options: SyncOptions,
job: Job,
startProgress: number,
progressRange: number
): Promise<{
totalRecords: number;
processedRecords: number;
createdRecords: number;
updatedRecords: number;
skippedRecords: number;
failedRecords: number;
errors: SyncError[];
warnings: string[];
}> {
const result = {
totalRecords: 0,
processedRecords: 0,
createdRecords: 0,
updatedRecords: 0,
skippedRecords: 0,
failedRecords: 0,
errors: [] as SyncError[],
warnings: [] as string[],
};
const batchSize = options.batchSize || 100;
let offset = 0;
let hasMore = true;
while (hasMore) {
// Fetch batch of records
const fetchOptions: FetchOptions = {
limit: batchSize,
offset,
startDate: options.startDate,
endDate: options.endDate,
modifiedSince: options.fullSync ? undefined : await getLastSyncDate(context, entityType),
filters: options.filters,
};
context.logger.debug(`Fetching ${entityType} records`, { offset, limit: batchSize });
const records = await connector.fetchRecords(entityType, fetchOptions);
if (records.length === 0) {
hasMore = false;
break;
}
result.totalRecords += records.length;
// Process records in batch
if (options.dryRun) {
// Dry run - just count
result.processedRecords += records.length;
result.skippedRecords += records.length;
} else {
// Actually process records
const batchResults = await processBatch(context, entityType, records, options);
result.processedRecords += batchResults.processedRecords;
result.createdRecords += batchResults.createdRecords;
result.updatedRecords += batchResults.updatedRecords;
result.skippedRecords += batchResults.skippedRecords;
result.failedRecords += batchResults.failedRecords;
result.errors.push(...batchResults.errors);
}
// Update progress
const batchProgress = (offset / (result.totalRecords || 1)) * progressRange;
await job.updateProgress(Math.min(startProgress + batchProgress, startProgress + progressRange));
// Check if we got a full batch (more records might exist)
if (records.length < batchSize) {
hasMore = false;
} else {
offset += batchSize;
}
// Add small delay between batches to avoid overwhelming the external system
await delay(100);
}
return result;
}
/**
* Process a batch of records
*/
async function processBatch(
context: SyncJobContext,
entityType: SyncEntityType,
records: unknown[],
options: SyncOptions
): Promise<{
processedRecords: number;
createdRecords: number;
updatedRecords: number;
skippedRecords: number;
failedRecords: number;
errors: SyncError[];
}> {
const result = {
processedRecords: 0,
createdRecords: 0,
updatedRecords: 0,
skippedRecords: 0,
failedRecords: 0,
errors: [] as SyncError[],
};
const db = getDatabase();
for (const record of records) {
try {
const recordResult = await processRecord(context, entityType, record);
result.processedRecords++;
switch (recordResult.action) {
case 'created':
result.createdRecords++;
break;
case 'updated':
result.updatedRecords++;
break;
case 'skipped':
result.skippedRecords++;
break;
case 'failed':
result.failedRecords++;
if (recordResult.errorMessage) {
result.errors.push({
code: recordResult.errorCode || 'RECORD_FAILED',
message: recordResult.errorMessage,
sourceId: recordResult.sourceId,
timestamp: new Date(),
retryable: true,
});
}
break;
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
result.failedRecords++;
result.errors.push({
code: 'RECORD_ERROR',
message: errorMessage,
sourceId: (record as any).id || 'unknown',
timestamp: new Date(),
retryable: true,
});
}
}
return result;
}
/**
* Process a single record
*/
async function processRecord(
context: SyncJobContext,
entityType: SyncEntityType,
record: unknown
): Promise<SyncRecordResult> {
const db = getDatabase();
const recordData = record as Record<string, unknown>;
const sourceId = String(recordData.id || recordData.uuid || recordData.code || '');
// Check if record already exists
const existingRecord = await findExistingRecord(context, entityType, sourceId);
if (existingRecord) {
// Update existing record
await updateRecord(context, entityType, existingRecord.id, recordData);
return {
sourceId,
targetId: existingRecord.id,
success: true,
action: 'updated',
};
} else {
// Create new record
const targetId = await createRecord(context, entityType, recordData);
return {
sourceId,
targetId,
success: true,
action: 'created',
};
}
}
/**
* Find existing record by source ID
*/
async function findExistingRecord(
context: SyncJobContext,
entityType: SyncEntityType,
sourceId: string
): Promise<{ id: string } | null> {
const db = getDatabase();
const tableName = getTableName(entityType);
const result = await db.queryTenant<{ id: string }>(
context.tenant,
`SELECT id FROM ${tableName} WHERE external_id = $1 OR id::text = $1 LIMIT 1`,
[sourceId]
);
return result.rows[0] || null;
}
/**
* Create a new record
*/
async function createRecord(
context: SyncJobContext,
entityType: SyncEntityType,
data: Record<string, unknown>
): Promise<string> {
const db = getDatabase();
const tableName = getTableName(entityType);
// Map fields based on entity type
const mappedData = mapRecordFields(entityType, data);
const columns = Object.keys(mappedData);
const values = Object.values(mappedData);
const placeholders = columns.map((_, i) => `$${i + 1}`);
const result = await db.queryTenant<{ id: string }>(
context.tenant,
`INSERT INTO ${tableName} (${columns.join(', ')})
VALUES (${placeholders.join(', ')})
RETURNING id`,
values
);
return result.rows[0].id;
}
/**
* Update an existing record
*/
async function updateRecord(
context: SyncJobContext,
entityType: SyncEntityType,
id: string,
data: Record<string, unknown>
): Promise<void> {
const db = getDatabase();
const tableName = getTableName(entityType);
// Map fields based on entity type
const mappedData = mapRecordFields(entityType, data);
const setClause = Object.keys(mappedData)
.map((col, i) => `${col} = $${i + 2}`)
.join(', ');
const values = [id, ...Object.values(mappedData)];
await db.queryTenant(
context.tenant,
`UPDATE ${tableName} SET ${setClause}, updated_at = NOW() WHERE id = $1`,
values
);
}
/**
* Get last sync date for incremental sync
*/
async function getLastSyncDate(
context: SyncJobContext,
entityType: SyncEntityType
): Promise<Date | undefined> {
const db = getDatabase();
const result = await db.queryTenant<{ last_sync_at: Date }>(
context.tenant,
`SELECT last_sync_at FROM integration_sync_logs
WHERE integration_id = $1 AND entity_type = $2 AND status = 'completed'
ORDER BY completed_at DESC
LIMIT 1`,
[context.integrationId, entityType]
);
return result.rows[0]?.last_sync_at;
}
/**
* Log sync result to database
*/
async function logSyncResult(context: SyncJobContext, result: SyncResult): Promise<void> {
const db = getDatabase();
await db.queryTenant(
context.tenant,
`INSERT INTO integration_sync_logs (
integration_id,
job_id,
entity_type,
direction,
status,
started_at,
completed_at,
duration_ms,
total_records,
created_records,
updated_records,
skipped_records,
failed_records,
error_count,
last_error,
triggered_by,
metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[
context.integrationId,
result.jobId,
result.entityType,
result.direction,
result.status,
result.startedAt,
result.completedAt,
result.durationMs,
result.totalRecords,
result.createdRecords,
result.updatedRecords,
result.skippedRecords,
result.failedRecords,
result.errors.length,
result.errors[0]?.message || null,
'manual',
JSON.stringify({
warnings: result.warnings,
errors: result.errors.slice(0, 20),
}),
]
);
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Get table name for entity type
*/
function getTableName(entityType: SyncEntityType): string {
const tableMap: Record<SyncEntityType, string> = {
[SyncEntityType.TRANSACTIONS]: 'transactions',
[SyncEntityType.INVOICES]: 'cfdis',
[SyncEntityType.CONTACTS]: 'contacts',
[SyncEntityType.PRODUCTS]: 'products',
[SyncEntityType.ACCOUNTS]: 'accounts',
[SyncEntityType.CATEGORIES]: 'categories',
[SyncEntityType.JOURNAL_ENTRIES]: 'journal_entries',
[SyncEntityType.PAYMENTS]: 'payments',
[SyncEntityType.CFDIS]: 'cfdis',
[SyncEntityType.BANK_STATEMENTS]: 'bank_transactions',
};
return tableMap[entityType] || entityType.toLowerCase();
}
/**
* Map record fields based on entity type
*/
function mapRecordFields(
entityType: SyncEntityType,
data: Record<string, unknown>
): Record<string, unknown> {
// Basic field mapping - in production this would be more sophisticated
const mapped: Record<string, unknown> = {
external_id: data.id || data.uuid || data.code,
source: 'integration',
};
switch (entityType) {
case SyncEntityType.CONTACTS:
mapped.name = data.name || data.razon_social || data.nombre;
mapped.rfc = data.rfc || data.tax_id;
mapped.email = data.email || data.correo;
mapped.phone = data.phone || data.telefono;
mapped.type = data.type || 'customer';
break;
case SyncEntityType.TRANSACTIONS:
mapped.amount = data.amount || data.monto || data.total;
mapped.type = data.type || data.tipo || 'expense';
mapped.description = data.description || data.descripcion || data.concepto;
mapped.transaction_date = data.date || data.fecha;
break;
case SyncEntityType.INVOICES:
case SyncEntityType.CFDIS:
mapped.uuid_fiscal = data.uuid || data.uuid_fiscal;
mapped.total = data.total || data.monto;
mapped.subtotal = data.subtotal || data.total;
mapped.fecha_emision = data.date || data.fecha || data.fecha_emision;
mapped.emisor_rfc = data.emisor_rfc || data.rfc_emisor;
mapped.emisor_nombre = data.emisor_nombre || data.nombre_emisor;
mapped.receptor_rfc = data.receptor_rfc || data.rfc_receptor;
mapped.receptor_nombre = data.receptor_nombre || data.nombre_receptor;
mapped.tipo_comprobante = data.tipo || data.tipo_comprobante || 'I';
break;
case SyncEntityType.ACCOUNTS:
mapped.code = data.code || data.codigo || data.numero;
mapped.name = data.name || data.nombre || data.descripcion;
mapped.type = data.type || data.tipo || data.naturaleza;
break;
case SyncEntityType.CATEGORIES:
mapped.code = data.code || data.codigo;
mapped.name = data.name || data.nombre;
mapped.type = data.type || data.tipo || 'expense';
break;
default:
// Copy all fields as-is
Object.assign(mapped, data);
}
return mapped;
}
/**
* Check if error is retryable
*/
function isRetryableError(error: unknown): boolean {
if (error instanceof Error) {
const message = error.message.toLowerCase();
const nonRetryable = [
'authentication failed',
'invalid credentials',
'unauthorized',
'forbidden',
'not found',
'invalid configuration',
'validation error',
];
return !nonRetryable.some((phrase) => message.includes(phrase));
}
return true;
}
/**
* Delay helper
*/
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ============================================================================
// EXPORTS
// ============================================================================
export default processSyncJob;

View File

@@ -0,0 +1,173 @@
/**
* AI Routes
*
* Routes for AI-powered analysis, explanations, recommendations, and chat
*/
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { authenticate } from '../middleware/auth.middleware.js';
import { validate } from '../middleware/validate.middleware.js';
import {
analyzeMetrics,
explainEntity,
getRecommendations,
chat,
getUsage,
AnalyzeRequestSchema,
ExplainRequestSchema,
RecommendRequestSchema,
ChatRequestSchema,
} from '../controllers/ai.controller.js';
const router = Router();
// ============================================================================
// AI-Specific Rate Limiting
// ============================================================================
/**
* Stricter rate limiter for AI endpoints
* This is in addition to the plan-based limits in the controller
*/
const aiRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per minute max (across all AI endpoints)
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: {
code: 'AI_RATE_LIMIT_EXCEEDED',
message: 'Demasiadas solicitudes de AI. Por favor espera un momento.',
},
},
keyGenerator: (req) => {
// Rate limit by tenant + user
return `ai:${req.user?.tenant_id || 'anonymous'}:${req.user?.sub || req.ip}`;
},
});
/**
* More restrictive limiter for chat endpoint (streaming consumes more resources)
*/
const chatRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // 10 chat messages per minute
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: {
code: 'CHAT_RATE_LIMIT_EXCEEDED',
message: 'Demasiados mensajes de chat. Por favor espera un momento.',
},
},
keyGenerator: (req) => {
return `chat:${req.user?.tenant_id || 'anonymous'}:${req.user?.sub || req.ip}`;
},
});
// ============================================================================
// Routes
// ============================================================================
/**
* POST /api/ai/analyze
* Perform AI analysis on metrics
*
* Body:
* - metrics: Array of metric codes to analyze
* - dateRange: { start, end } date range
* - compareWith: Comparison period (previous_period, year_ago, none)
* - depth: Analysis depth (quick, standard, detailed)
* - focusAreas: Optional areas to focus analysis on
*
* Returns structured insights and observations
*/
router.post(
'/analyze',
authenticate,
aiRateLimiter,
validate({ body: AnalyzeRequestSchema }),
analyzeMetrics
);
/**
* POST /api/ai/explain
* Get AI explanation for a metric, anomaly, or trend
*
* Body:
* - type: Type of entity to explain (metric, anomaly, trend, transaction, forecast)
* - entityId: Optional ID of the specific entity
* - context: Context data for the explanation
* - language: Response language (es, en)
*
* Returns human-readable explanation with key points
*/
router.post(
'/explain',
authenticate,
aiRateLimiter,
validate({ body: ExplainRequestSchema }),
explainEntity
);
/**
* POST /api/ai/recommend
* Get AI-powered recommendations
*
* Body:
* - area: Focus area (cost_reduction, revenue_growth, cash_flow, etc.)
* - dateRange: { start, end } date range to analyze
* - constraints: Optional constraints (maxBudget, riskTolerance, timeHorizon)
* - excludeCategories: Categories to exclude from recommendations
*
* Returns prioritized list of actionable recommendations
*/
router.post(
'/recommend',
authenticate,
aiRateLimiter,
validate({ body: RecommendRequestSchema }),
getRecommendations
);
/**
* POST /api/ai/chat
* Chat with CFO Digital (AI assistant)
*
* Body:
* - message: User's message
* - conversationId: Optional ID to continue existing conversation
* - context: Optional context about current view, selected metrics, etc.
*
* Supports SSE streaming with Accept: text/event-stream header
* Otherwise returns complete response as JSON
*/
router.post(
'/chat',
authenticate,
chatRateLimiter,
validate({ body: ChatRequestSchema }),
chat
);
/**
* GET /api/ai/usage
* Get AI API usage statistics
*
* Returns:
* - Today's usage (requests, tokens)
* - Plan limits
* - Usage by type (analyze, explain, recommend, chat)
* - Weekly trend
* - Reset time
*/
router.get(
'/usage',
authenticate,
getUsage
);
export default router;

View File

@@ -7,3 +7,6 @@ export { default as contactsRoutes } from './contacts.routes.js';
export { default as cfdisRoutes } from './cfdis.routes.js'; export { default as cfdisRoutes } from './cfdis.routes.js';
export { default as categoriesRoutes } from './categories.routes.js'; export { default as categoriesRoutes } from './categories.routes.js';
export { default as alertsRoutes } from './alerts.routes.js'; export { default as alertsRoutes } from './alerts.routes.js';
export { default as reportsRoutes } from './reports.routes.js';
export { default as aiRoutes } from './ai.routes.js';
export { default as integrationsRoutes } from './integrations.routes.js';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
/**
* Reports Routes
*
* Routes for report generation, listing, and download
*/
import { Router } from 'express';
import { authenticate } from '../middleware/auth.middleware.js';
import { validate } from '../middleware/validate.middleware.js';
import {
listReports,
getReport,
generateReport,
downloadReport,
deleteReport,
listTemplates,
ReportFiltersSchema,
ReportIdSchema,
GenerateReportSchema,
} from '../controllers/reports.controller.js';
const router = Router();
// ============================================================================
// Routes
// ============================================================================
/**
* GET /api/reports
* List all reports with pagination and filters
*
* Query params:
* - page: Page number (default: 1)
* - limit: Items per page (default: 20, max: 100)
* - type: Filter by report type
* - status: Filter by status
* - startDate: Filter by creation date start
* - endDate: Filter by creation date end
* - search: Search by name
*/
router.get(
'/',
authenticate,
validate({ query: ReportFiltersSchema }),
listReports
);
/**
* GET /api/reports/templates
* List available report templates
*
* Returns templates filtered by user's plan (free vs premium)
*/
router.get(
'/templates',
authenticate,
listTemplates
);
/**
* GET /api/reports/:id
* Get a specific report by ID
*
* Returns full report details including generation status
*/
router.get(
'/:id',
authenticate,
validate({ params: ReportIdSchema }),
getReport
);
/**
* POST /api/reports/generate
* Generate a new report
*
* Body:
* - name: Report name
* - type: Report type (financial_summary, income_statement, etc.)
* - templateId: Optional template ID to use
* - parameters: Report parameters including date range, filters
* - priority: Job priority (low, normal, high)
*
* Returns immediately with job ID and estimated time
* Report is generated in background via BullMQ
*/
router.post(
'/generate',
authenticate,
validate({ body: GenerateReportSchema }),
generateReport
);
/**
* GET /api/reports/:id/download
* Get download URL for a completed report
*
* Returns signed URL or streams the file
* Only available for completed reports
*/
router.get(
'/:id/download',
authenticate,
validate({ params: ReportIdSchema }),
downloadReport
);
/**
* DELETE /api/reports/:id
* Delete a report
*
* Also cancels pending/processing jobs and removes files from storage
* Only the report creator or admins can delete
*/
router.delete(
'/:id',
authenticate,
validate({ params: ReportIdSchema }),
deleteReport
);
export default router;

View File

@@ -0,0 +1,789 @@
/**
* AI Service
*
* Servicio de alto nivel para análisis financiero con DeepSeek AI.
* Proporciona:
* - Generación de insights financieros
* - Resúmenes ejecutivos
* - Recomendaciones estratégicas
* - Cache de respuestas con Redis
* - Prompts optimizados para análisis financiero en español
*/
import crypto from 'crypto';
import Redis from 'ioredis';
import {
DeepSeekClient,
DeepSeekError,
getDeepSeekClient,
DeepSeekRateLimitError,
} from './deepseek.client.js';
import {
DeepSeekMessage,
FinancialAnalysisContext,
FinancialMetricsInput,
FinancialTrends,
FinancialInsightResult,
ExecutiveSummaryResult,
RecommendationsResult,
Recommendation,
FinancialAlert,
AICacheEntry,
Usage,
RateLimitInfo,
} from './deepseek.types.js';
import { logger, auditLog } from '../../utils/logger.js';
import { ExternalServiceError, RateLimitError } from '../../types/index.js';
// ============================================================================
// Constants
// ============================================================================
const CACHE_PREFIX = 'horux:ai:';
const DEFAULT_CACHE_TTL = 3600; // 1 hora
// TTL específicos por tipo de operación
const OPERATION_CACHE_TTL: Record<string, number> = {
insight: 1800, // 30 minutos
summary: 3600, // 1 hora
recommendations: 7200, // 2 horas
};
// ============================================================================
// System Prompts
// ============================================================================
const SYSTEM_PROMPTS = {
financialAnalyst: `Eres un analista financiero experto especializado en empresas mexicanas y latinoamericanas.
Tu rol es proporcionar análisis financieros precisos, accionables y en español.
Principios guía:
- Usa terminología financiera estándar en español de México
- Sé conciso pero completo
- Prioriza información accionable
- Identifica patrones y tendencias
- Destaca riesgos y oportunidades
- Contextualiza los números con el entorno económico mexicano
- Considera las particularidades fiscales y regulatorias de México (SAT, CFDI, etc.)
Formato de respuestas:
- Usa formato JSON cuando se solicite
- Estructura clara con secciones definidas
- Incluye métricas específicas cuando sea relevante
- Evita jerga innecesaria`,
executiveSummary: `Eres un analista financiero senior preparando un resumen ejecutivo para la alta dirección.
Tu objetivo es comunicar de manera clara y concisa el estado financiero de la empresa.
Características del resumen:
- Lenguaje ejecutivo y profesional en español
- Enfoque en resultados y tendencias clave
- Destaca logros y áreas de preocupación
- Incluye comparativas con período anterior
- Termina con recomendaciones de alto nivel
- Máximo 3-4 párrafos para el resumen general`,
strategicAdvisor: `Eres un consultor estratégico financiero para empresas en México.
Tu objetivo es proporcionar recomendaciones prácticas y priorizadas.
Enfoque de las recomendaciones:
- Basadas en datos, no en suposiciones
- Priorizadas por impacto y urgencia
- Considerando el contexto mexicano (fiscal, económico, regulatorio)
- Con plazos realistas de implementación
- Identificando recursos necesarios
- Mencionando riesgos de no actuar`,
};
// ============================================================================
// Prompt Templates
// ============================================================================
/**
* Genera el prompt para análisis de métricas financieras
*/
function buildFinancialInsightPrompt(
metrics: FinancialMetricsInput,
context: FinancialAnalysisContext
): string {
const previousComparison = metrics.previousPeriod
? `
Comparación con período anterior:
- Ingresos anteriores: ${formatCurrency(metrics.previousPeriod.revenue, context.currency)}
- Cambio en ingresos: ${calculateChange(metrics.revenue, metrics.previousPeriod.revenue)}%
- Gastos anteriores: ${formatCurrency(metrics.previousPeriod.expenses, context.currency)}
- Cambio en gastos: ${calculateChange(metrics.expenses, metrics.previousPeriod.expenses)}%
- Utilidad anterior: ${formatCurrency(metrics.previousPeriod.netProfit, context.currency)}
- Cambio en utilidad: ${calculateChange(metrics.netProfit, metrics.previousPeriod.netProfit)}%`
: '';
const additionalMetrics = metrics.additional
? `
Métricas adicionales:
${metrics.additional.mrr ? `- MRR: ${formatCurrency(metrics.additional.mrr, context.currency)}` : ''}
${metrics.additional.arr ? `- ARR: ${formatCurrency(metrics.additional.arr, context.currency)}` : ''}
${metrics.additional.burnRate ? `- Burn Rate: ${formatCurrency(metrics.additional.burnRate, context.currency)}/mes` : ''}
${metrics.additional.runway ? `- Runway: ${metrics.additional.runway} meses` : ''}
${metrics.additional.churnRate ? `- Churn Rate: ${(metrics.additional.churnRate * 100).toFixed(2)}%` : ''}
${metrics.additional.ebitda ? `- EBITDA: ${formatCurrency(metrics.additional.ebitda, context.currency)}` : ''}
${metrics.additional.currentRatio ? `- Ratio de Liquidez: ${metrics.additional.currentRatio.toFixed(2)}` : ''}
${metrics.additional.quickRatio ? `- Prueba Ácida: ${metrics.additional.quickRatio.toFixed(2)}` : ''}`
: '';
return `Analiza las siguientes métricas financieras y proporciona insights accionables.
Empresa: ${context.companyName || 'No especificada'}
Industria: ${context.industry || 'No especificada'}
Período: ${context.period.type} (${formatDate(context.period.from)} - ${formatDate(context.period.to)})
Moneda: ${context.currency}
Métricas principales:
- Ingresos: ${formatCurrency(metrics.revenue, context.currency)}
- Gastos: ${formatCurrency(metrics.expenses, context.currency)}
- Utilidad Neta: ${formatCurrency(metrics.netProfit, context.currency)}
- Margen de Utilidad: ${(metrics.profitMargin * 100).toFixed(2)}%
- Flujo de Caja: ${formatCurrency(metrics.cashFlow, context.currency)}
- Cuentas por Cobrar: ${formatCurrency(metrics.accountsReceivable, context.currency)}
- Cuentas por Pagar: ${formatCurrency(metrics.accountsPayable, context.currency)}
${previousComparison}
${additionalMetrics}
Responde en formato JSON con la siguiente estructura:
{
"summary": "Resumen ejecutivo de 2-3 oraciones",
"keyPoints": ["punto clave 1", "punto clave 2", "punto clave 3"],
"healthScore": <número del 0 al 100>,
"alerts": [
{
"type": "warning|critical|info",
"title": "título de la alerta",
"description": "descripción detallada",
"metric": "métrica afectada"
}
]
}`;
}
/**
* Genera el prompt para resumen ejecutivo
*/
function buildExecutiveSummaryPrompt(
metrics: FinancialMetricsInput,
context: FinancialAnalysisContext,
trends?: FinancialTrends
): string {
const trendAnalysis = trends && trends.revenueTrend && trends.expensesTrend
? `
Tendencias (últimos períodos):
- Tendencia de ingresos: ${describeTrend(trends.revenueTrend)}
- Tendencia de gastos: ${describeTrend(trends.expensesTrend)}
- Tendencia de utilidad: ${describeTrend(trends.profitTrend)}
- Tendencia de flujo de caja: ${describeTrend(trends.cashFlowTrend)}`
: '';
return `Prepara un resumen ejecutivo para la alta dirección basado en los siguientes datos.
Empresa: ${context.companyName || 'La empresa'}
Período: ${context.period.type} (${formatDate(context.period.from)} - ${formatDate(context.period.to)})
Datos financieros:
- Ingresos: ${formatCurrency(metrics.revenue, context.currency)}
- Gastos: ${formatCurrency(metrics.expenses, context.currency)}
- Utilidad Neta: ${formatCurrency(metrics.netProfit, context.currency)}
- Margen: ${(metrics.profitMargin * 100).toFixed(2)}%
- Flujo de Caja: ${formatCurrency(metrics.cashFlow, context.currency)}
${
metrics.previousPeriod
? `
Cambios vs período anterior:
- Ingresos: ${calculateChange(metrics.revenue, metrics.previousPeriod.revenue)}%
- Gastos: ${calculateChange(metrics.expenses, metrics.previousPeriod.expenses)}%
- Utilidad: ${calculateChange(metrics.netProfit, metrics.previousPeriod.netProfit)}%`
: ''
}
${trendAnalysis}
Responde en formato JSON con la siguiente estructura:
{
"title": "Título del resumen (incluir período)",
"overview": "Resumen general de 3-4 oraciones",
"achievements": ["logro 1", "logro 2"],
"challenges": ["desafío 1", "desafío 2"],
"nextSteps": ["acción recomendada 1", "acción recomendada 2"]
}`;
}
/**
* Genera el prompt para recomendaciones
*/
function buildRecommendationsPrompt(
metrics: FinancialMetricsInput,
context: FinancialAnalysisContext,
_trends?: FinancialTrends
): string {
return `Genera recomendaciones estratégicas basadas en el siguiente análisis financiero.
Empresa: ${context.companyName || 'La empresa'}
Industria: ${context.industry || 'No especificada'}
Período: ${formatDate(context.period.from)} - ${formatDate(context.period.to)}
Estado financiero actual:
- Ingresos: ${formatCurrency(metrics.revenue, context.currency)}
- Gastos: ${formatCurrency(metrics.expenses, context.currency)}
- Utilidad Neta: ${formatCurrency(metrics.netProfit, context.currency)} (margen: ${(metrics.profitMargin * 100).toFixed(2)}%)
- Flujo de Caja: ${formatCurrency(metrics.cashFlow, context.currency)}
- Cuentas por Cobrar: ${formatCurrency(metrics.accountsReceivable, context.currency)}
- Cuentas por Pagar: ${formatCurrency(metrics.accountsPayable, context.currency)}
${
metrics.previousPeriod
? `
Variaciones vs período anterior:
- Ingresos: ${calculateChange(metrics.revenue, metrics.previousPeriod.revenue)}%
- Gastos: ${calculateChange(metrics.expenses, metrics.previousPeriod.expenses)}%
- Utilidad: ${calculateChange(metrics.netProfit, metrics.previousPeriod.netProfit)}%
- Flujo de Caja: ${calculateChange(metrics.cashFlow, metrics.previousPeriod.cashFlow)}%`
: ''
}
${
metrics.additional
? `
Indicadores adicionales:
${metrics.additional.currentRatio ? `- Ratio de Liquidez: ${metrics.additional.currentRatio.toFixed(2)}` : ''}
${metrics.additional.runway ? `- Runway: ${metrics.additional.runway} meses` : ''}
${metrics.additional.burnRate ? `- Burn Rate: ${formatCurrency(metrics.additional.burnRate, context.currency)}/mes` : ''}`
: ''
}
Responde en formato JSON con la siguiente estructura:
{
"recommendations": [
{
"priority": "high|medium|low",
"category": "cost_reduction|revenue_growth|cash_flow|risk_management|operational",
"title": "Título de la recomendación",
"description": "Descripción detallada de qué hacer y por qué",
"expectedImpact": "Impacto esperado cuantificado si es posible",
"timeframe": "immediate|short_term|medium_term|long_term"
}
],
"opportunities": ["oportunidad identificada 1", "oportunidad 2"],
"risks": ["riesgo a mitigar 1", "riesgo 2"]
}`;
}
// ============================================================================
// Helper Functions
// ============================================================================
function formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
function formatDate(date: Date): string {
return new Intl.DateTimeFormat('es-MX', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
}
function calculateChange(current: number, previous: number): string {
if (previous === 0) return current > 0 ? '+100.00' : '0.00';
const change = ((current - previous) / Math.abs(previous)) * 100;
return change > 0 ? `+${change.toFixed(2)}` : change.toFixed(2);
}
function describeTrend(trendData: { period: string; value: number; changePercent?: number }[]): string {
if (!trendData || trendData.length < 2) return 'Datos insuficientes';
const lastChange = trendData[trendData.length - 1]?.changePercent;
if (lastChange === undefined) return 'Sin cambio';
if (lastChange > 5) return `Creciente (+${lastChange.toFixed(1)}%)`;
if (lastChange < -5) return `Decreciente (${lastChange.toFixed(1)}%)`;
return 'Estable';
}
function generateCacheKey(operation: string, tenantId: string, input: unknown): string {
const inputHash = crypto
.createHash('sha256')
.update(JSON.stringify(input))
.digest('hex')
.substring(0, 16);
return `${CACHE_PREFIX}${operation}:${tenantId}:${inputHash}`;
}
// ============================================================================
// AI Service Class
// ============================================================================
export interface AIServiceOptions {
/** Cliente Redis para cache */
redis?: Redis;
/** Habilitar cache (por defecto: true) */
enableCache?: boolean;
/** Cliente DeepSeek personalizado */
deepSeekClient?: DeepSeekClient;
/** TTL de cache por defecto en segundos */
defaultCacheTTL?: number;
}
/**
* Servicio de IA para análisis financiero
*/
export class AIService {
private client: DeepSeekClient;
private redis: Redis | null;
private enableCache: boolean;
private defaultCacheTTL: number;
private totalTokensUsed: number = 0;
constructor(options: AIServiceOptions = {}) {
this.client = options.deepSeekClient || getDeepSeekClient();
this.redis = options.redis || null;
this.enableCache = options.enableCache !== false;
this.defaultCacheTTL = options.defaultCacheTTL || DEFAULT_CACHE_TTL;
// Escuchar eventos de uso
this.client.on('usage', (usage: Usage) => {
this.totalTokensUsed += usage.total_tokens;
logger.debug('AI tokens used', { usage, totalTokensUsed: this.totalTokensUsed });
});
// Escuchar advertencias de rate limit
this.client.on('rateLimitWarning', (info: RateLimitInfo) => {
logger.warn('DeepSeek rate limit warning', { info });
});
logger.info('AIService initialized', {
cacheEnabled: this.enableCache,
model: this.client.getModel(),
});
}
// ============================================================================
// Public Methods
// ============================================================================
/**
* Genera insights financieros basados en métricas
*/
async generateFinancialInsight(
metrics: FinancialMetricsInput,
context: FinancialAnalysisContext
): Promise<FinancialInsightResult> {
const startTime = Date.now();
const cacheKey = generateCacheKey('insight', context.tenantId, { metrics, context });
// Intentar obtener del cache
if (this.enableCache) {
const cached = await this.getFromCache<FinancialInsightResult>(cacheKey);
if (cached) {
logger.debug('Financial insight retrieved from cache', { tenantId: context.tenantId });
return cached;
}
}
try {
const messages: DeepSeekMessage[] = [
{ role: 'system', content: SYSTEM_PROMPTS.financialAnalyst },
{ role: 'user', content: buildFinancialInsightPrompt(metrics, context) },
];
const response = await this.client.chat(messages, {
temperature: 0.3,
max_tokens: 1500,
response_format: { type: 'json_object' },
});
const content = response.choices[0]?.message?.content || '{}';
const parsed = this.parseJSONResponse<{
summary: string;
keyPoints: string[];
healthScore: number;
alerts: FinancialAlert[];
}>(content);
const result: FinancialInsightResult = {
summary: parsed.summary || 'No se pudo generar el análisis.',
keyPoints: parsed.keyPoints || [],
healthScore: Math.min(100, Math.max(0, parsed.healthScore || 50)),
alerts: parsed.alerts || [],
tokensUsed: response.usage?.total_tokens || 0,
generationTime: Date.now() - startTime,
};
// Guardar en cache
if (this.enableCache) {
await this.saveToCache(cacheKey, result, OPERATION_CACHE_TTL.insight);
}
auditLog('AI_INSIGHT_GENERATED', null, context.tenantId, {
tokensUsed: result.tokensUsed,
generationTime: result.generationTime,
healthScore: result.healthScore,
});
return result;
} catch (error) {
return this.handleAIError(error, 'generateFinancialInsight', context.tenantId, startTime);
}
}
/**
* Genera un resumen ejecutivo del período
*/
async generateExecutiveSummary(
metrics: FinancialMetricsInput,
context: FinancialAnalysisContext,
trends?: FinancialTrends
): Promise<ExecutiveSummaryResult> {
const startTime = Date.now();
const cacheKey = generateCacheKey('summary', context.tenantId, { metrics, context, trends });
// Intentar obtener del cache
if (this.enableCache) {
const cached = await this.getFromCache<ExecutiveSummaryResult>(cacheKey);
if (cached) {
logger.debug('Executive summary retrieved from cache', { tenantId: context.tenantId });
return cached;
}
}
try {
const messages: DeepSeekMessage[] = [
{ role: 'system', content: SYSTEM_PROMPTS.executiveSummary },
{ role: 'user', content: buildExecutiveSummaryPrompt(metrics, context, trends) },
];
const response = await this.client.chat(messages, {
temperature: 0.4,
max_tokens: 2000,
response_format: { type: 'json_object' },
});
const content = response.choices[0]?.message?.content || '{}';
const parsed = this.parseJSONResponse<{
title: string;
overview: string;
achievements: string[];
challenges: string[];
nextSteps: string[];
}>(content);
const result: ExecutiveSummaryResult = {
title: parsed.title || `Resumen Ejecutivo - ${context.period.type}`,
overview: parsed.overview || 'No se pudo generar el resumen.',
achievements: parsed.achievements || [],
challenges: parsed.challenges || [],
nextSteps: parsed.nextSteps || [],
tokensUsed: response.usage?.total_tokens || 0,
generationTime: Date.now() - startTime,
};
// Guardar en cache
if (this.enableCache) {
await this.saveToCache(cacheKey, result, OPERATION_CACHE_TTL.summary);
}
auditLog('AI_SUMMARY_GENERATED', null, context.tenantId, {
tokensUsed: result.tokensUsed,
generationTime: result.generationTime,
});
return result;
} catch (error) {
return this.handleAIError(error, 'generateExecutiveSummary', context.tenantId, startTime);
}
}
/**
* Genera recomendaciones estratégicas
*/
async generateRecommendations(
metrics: FinancialMetricsInput,
context: FinancialAnalysisContext,
trends?: FinancialTrends
): Promise<RecommendationsResult> {
const startTime = Date.now();
const cacheKey = generateCacheKey('recommendations', context.tenantId, { metrics, context, trends });
// Intentar obtener del cache
if (this.enableCache) {
const cached = await this.getFromCache<RecommendationsResult>(cacheKey);
if (cached) {
logger.debug('Recommendations retrieved from cache', { tenantId: context.tenantId });
return cached;
}
}
try {
const messages: DeepSeekMessage[] = [
{ role: 'system', content: SYSTEM_PROMPTS.strategicAdvisor },
{ role: 'user', content: buildRecommendationsPrompt(metrics, context, trends) },
];
const response = await this.client.chat(messages, {
temperature: 0.5,
max_tokens: 2500,
response_format: { type: 'json_object' },
});
const content = response.choices[0]?.message?.content || '{}';
const parsed = this.parseJSONResponse<{
recommendations: Recommendation[];
opportunities: string[];
risks: string[];
}>(content);
const result: RecommendationsResult = {
recommendations: parsed.recommendations || [],
opportunities: parsed.opportunities || [],
risks: parsed.risks || [],
tokensUsed: response.usage?.total_tokens || 0,
generationTime: Date.now() - startTime,
};
// Ordenar recomendaciones por prioridad
result.recommendations.sort((a, b) => {
const priorityOrder = { high: 0, medium: 1, low: 2 };
return (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2);
});
// Guardar en cache
if (this.enableCache) {
await this.saveToCache(cacheKey, result, OPERATION_CACHE_TTL.recommendations);
}
auditLog('AI_RECOMMENDATIONS_GENERATED', null, context.tenantId, {
tokensUsed: result.tokensUsed,
generationTime: result.generationTime,
recommendationCount: result.recommendations.length,
});
return result;
} catch (error) {
return this.handleAIError(error, 'generateRecommendations', context.tenantId, startTime);
}
}
/**
* Genera una respuesta de chat personalizada
*/
async chat(
prompt: string,
context: FinancialAnalysisContext,
conversationHistory?: DeepSeekMessage[]
): Promise<{ response: string; tokensUsed: number }> {
const startTime = Date.now();
try {
const messages: DeepSeekMessage[] = [
{ role: 'system', content: SYSTEM_PROMPTS.financialAnalyst },
...(conversationHistory || []),
{ role: 'user', content: prompt },
];
const response = await this.client.chat(messages, {
temperature: 0.7,
max_tokens: 2000,
});
const result = {
response: response.choices[0]?.message?.content || '',
tokensUsed: response.usage?.total_tokens || 0,
};
auditLog('AI_CHAT', null, context.tenantId, {
tokensUsed: result.tokensUsed,
generationTime: Date.now() - startTime,
});
return result;
} catch (error) {
logger.error('AI chat failed', { error, tenantId: context.tenantId });
throw this.transformError(error);
}
}
/**
* Obtiene estadísticas de uso del servicio
*/
getUsageStats(): { totalTokensUsed: number; rateLimitInfo: unknown } {
return {
totalTokensUsed: this.totalTokensUsed,
rateLimitInfo: this.client.getRateLimitInfo(),
};
}
/**
* Invalida el cache para un tenant
*/
async invalidateCache(tenantId: string): Promise<number> {
if (!this.redis) return 0;
try {
const pattern = `${CACHE_PREFIX}*:${tenantId}:*`;
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
logger.info('AI cache invalidated', { tenantId, keysDeleted: keys.length });
}
return keys.length;
} catch (error) {
logger.error('Failed to invalidate AI cache', { error, tenantId });
return 0;
}
}
// ============================================================================
// Private Methods - Cache
// ============================================================================
/**
* Obtiene un valor del cache
*/
private async getFromCache<T>(key: string): Promise<T | null> {
if (!this.redis) return null;
try {
const data = await this.redis.get(key);
if (!data) return null;
const entry = JSON.parse(data) as AICacheEntry<T>;
// Verificar expiración
if (new Date(entry.expiresAt) < new Date()) {
await this.redis.del(key);
return null;
}
return entry.data;
} catch (error) {
logger.warn('Cache get error', { key, error });
return null;
}
}
/**
* Guarda un valor en el cache
*/
private async saveToCache<T>(key: string, data: T, ttl: number = this.defaultCacheTTL): Promise<void> {
if (!this.redis) return;
try {
const entry: AICacheEntry<T> = {
data,
createdAt: new Date(),
expiresAt: new Date(Date.now() + ttl * 1000),
tokensUsed: (data as any).tokensUsed || 0,
};
await this.redis.setex(key, ttl, JSON.stringify(entry));
} catch (error) {
logger.warn('Cache set error', { key, error });
}
}
// ============================================================================
// Private Methods - Error Handling
// ============================================================================
/**
* Maneja errores de la API de AI
*/
private handleAIError(
error: unknown,
operation: string,
tenantId: string,
startTime: number
): never {
const generationTime = Date.now() - startTime;
logger.error('AI operation failed', {
operation,
tenantId,
generationTime,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw this.transformError(error);
}
/**
* Transforma errores de DeepSeek a errores de la aplicación
*/
private transformError(error: unknown): Error {
if (error instanceof DeepSeekRateLimitError) {
return new RateLimitError(
`DeepSeek rate limit excedido. Reintentar en ${Math.ceil(error.retryAfter / 1000)} segundos.`
);
}
if (error instanceof DeepSeekError) {
return new ExternalServiceError('DeepSeek AI', error.message);
}
if (error instanceof Error) {
return new ExternalServiceError('DeepSeek AI', error.message);
}
return new ExternalServiceError('DeepSeek AI', 'Error desconocido');
}
/**
* Parsea una respuesta JSON de manera segura
*/
private parseJSONResponse<T>(content: string): T {
try {
return JSON.parse(content) as T;
} catch (error) {
logger.warn('Failed to parse AI JSON response', { content: content.substring(0, 200) });
// Intentar extraer JSON del contenido
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
return JSON.parse(jsonMatch[0]) as T;
} catch {
// Falló el segundo intento
}
}
return {} as T;
}
}
}
// ============================================================================
// Factory Functions
// ============================================================================
let aiServiceInstance: AIService | null = null;
/**
* Obtiene una instancia singleton del servicio de AI
*/
export function getAIService(options?: AIServiceOptions): AIService {
if (!aiServiceInstance) {
aiServiceInstance = new AIService(options);
}
return aiServiceInstance;
}
/**
* Crea una nueva instancia del servicio de AI
*/
export function createAIService(options?: AIServiceOptions): AIService {
return new AIService(options);
}
export default AIService;

View File

@@ -0,0 +1,655 @@
/**
* DeepSeek AI Client
*
* Cliente HTTP para la API de DeepSeek con soporte para:
* - Chat completions (normal y streaming)
* - Rate limiting con backoff exponencial
* - Reintentos automáticos
* - Tipado completo de request/response
*/
import { EventEmitter } from 'events';
import {
DeepSeekConfig,
DeepSeekModel,
DeepSeekMessage,
ChatCompletionRequest,
ChatCompletionResponse,
StreamChunk,
DeepSeekAPIError,
DeepSeekErrorResponse,
RateLimitInfo,
Usage,
} from './deepseek.types.js';
import { logger } from '../../utils/logger.js';
import { ExternalServiceError } from '../../types/index.js';
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_CONFIG: Partial<DeepSeekConfig> = {
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
timeout: 30000,
maxRetries: 3,
retryDelay: 1000,
};
const API_ENDPOINTS = {
chatCompletions: '/v1/chat/completions',
models: '/v1/models',
} as const;
// HTTP status codes that should trigger a retry
const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504];
// ============================================================================
// Error Classes
// ============================================================================
/**
* Error específico de DeepSeek
*/
export class DeepSeekError extends Error {
constructor(
message: string,
public readonly type: string,
public readonly statusCode: number,
public readonly param?: string | null,
public readonly code?: string | null,
public readonly retryable: boolean = false
) {
super(message);
this.name = 'DeepSeekError';
Object.setPrototypeOf(this, DeepSeekError.prototype);
}
static fromAPIError(error: DeepSeekAPIError, statusCode: number): DeepSeekError {
const retryable = statusCode === 429 || statusCode >= 500;
return new DeepSeekError(
error.message,
error.type,
statusCode,
error.param,
error.code,
retryable
);
}
}
/**
* Error de rate limiting de DeepSeek
*/
export class DeepSeekRateLimitError extends DeepSeekError {
constructor(
message: string,
public readonly retryAfter: number,
public readonly rateLimitInfo?: RateLimitInfo
) {
super(message, 'rate_limit_error', 429, null, 'rate_limit_exceeded', true);
this.name = 'DeepSeekRateLimitError';
Object.setPrototypeOf(this, DeepSeekRateLimitError.prototype);
}
}
// ============================================================================
// DeepSeek Client
// ============================================================================
/**
* Cliente para la API de DeepSeek
*/
export class DeepSeekClient extends EventEmitter {
private readonly config: DeepSeekConfig;
private rateLimitInfo: RateLimitInfo | null = null;
constructor(config: Partial<DeepSeekConfig> & { apiKey: string }) {
super();
if (!config.apiKey) {
throw new Error('DeepSeek API key is required');
}
this.config = {
...DEFAULT_CONFIG,
...config,
baseUrl: (config.baseUrl || DEFAULT_CONFIG.baseUrl!).replace(/\/$/, ''),
} as DeepSeekConfig;
logger.debug('DeepSeekClient initialized', {
baseUrl: this.config.baseUrl,
model: this.config.model,
timeout: this.config.timeout,
});
}
// ============================================================================
// Public Methods
// ============================================================================
/**
* Realiza una solicitud de chat completion
*/
async chat(
messages: DeepSeekMessage[],
options?: Partial<Omit<ChatCompletionRequest, 'messages' | 'stream'>>
): Promise<ChatCompletionResponse> {
const request: ChatCompletionRequest = {
model: options?.model || this.config.model,
messages,
stream: false,
...options,
};
return this.executeWithRetry<ChatCompletionResponse>(async () => {
const response = await this.makeRequest<ChatCompletionResponse>(
API_ENDPOINTS.chatCompletions,
request
);
this.emit('usage', response.usage);
logger.debug('Chat completion successful', {
model: response.model,
usage: response.usage,
finishReason: response.choices[0]?.finish_reason,
});
return response;
});
}
/**
* Realiza una solicitud de chat completion con streaming
*/
async *streamChat(
messages: DeepSeekMessage[],
options?: Partial<Omit<ChatCompletionRequest, 'messages' | 'stream'>>
): AsyncGenerator<StreamChunk, void, unknown> {
const request: ChatCompletionRequest = {
model: options?.model || this.config.model,
messages,
stream: true,
...options,
};
const response = await this.makeStreamRequest(
API_ENDPOINTS.chatCompletions,
request
);
let totalUsage: Usage | null = null;
for await (const chunk of this.parseSSEStream(response)) {
if (chunk.usage) {
totalUsage = chunk.usage;
}
yield chunk;
}
if (totalUsage) {
this.emit('usage', totalUsage);
}
}
/**
* Método de conveniencia para obtener una respuesta simple de texto
*/
async getTextResponse(
prompt: string,
systemPrompt?: string,
options?: Partial<Omit<ChatCompletionRequest, 'messages' | 'stream'>>
): Promise<string> {
const messages: DeepSeekMessage[] = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: prompt });
const response = await this.chat(messages, options);
return response.choices[0]?.message?.content || '';
}
/**
* Obtiene información de rate limiting actual
*/
getRateLimitInfo(): RateLimitInfo | null {
return this.rateLimitInfo;
}
/**
* Cambia el modelo por defecto
*/
setModel(model: DeepSeekModel): void {
this.config.model = model;
logger.debug('Model changed', { model });
}
/**
* Obtiene el modelo actual
*/
getModel(): DeepSeekModel {
return this.config.model;
}
// ============================================================================
// Private Methods - HTTP
// ============================================================================
/**
* Realiza una solicitud HTTP a la API
*/
private async makeRequest<T>(endpoint: string, body: unknown): Promise<T> {
const url = `${this.config.baseUrl}${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
try {
const response = await fetch(url, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(body),
signal: controller.signal,
});
this.updateRateLimitInfo(response.headers);
if (!response.ok) {
await this.handleErrorResponse(response);
}
const data = await response.json() as T;
return data;
} catch (error) {
if (error instanceof DeepSeekError) {
throw error;
}
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new DeepSeekError(
`Request timeout after ${this.config.timeout}ms`,
'timeout_error',
408,
null,
'timeout',
true
);
}
throw new ExternalServiceError('DeepSeek', error.message);
}
throw new ExternalServiceError('DeepSeek', 'Unknown error occurred');
} finally {
clearTimeout(timeoutId);
}
}
/**
* Realiza una solicitud HTTP con streaming
*/
private async makeStreamRequest(
endpoint: string,
body: unknown
): Promise<ReadableStream<Uint8Array>> {
const url = `${this.config.baseUrl}${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout || 60000);
try {
const response = await fetch(url, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);
this.updateRateLimitInfo(response.headers);
if (!response.ok) {
await this.handleErrorResponse(response);
}
if (!response.body) {
throw new DeepSeekError(
'No response body for streaming request',
'invalid_response',
500
);
}
return response.body;
} catch (error) {
if (error instanceof DeepSeekError) {
throw error;
}
if (error instanceof Error) {
throw new ExternalServiceError('DeepSeek', error.message);
}
throw new ExternalServiceError('DeepSeek', 'Unknown error occurred');
}
}
/**
* Obtiene los headers para las solicitudes
*/
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
'Accept': 'application/json',
};
if (this.config.organization) {
headers['OpenAI-Organization'] = this.config.organization;
}
return headers;
}
/**
* Maneja respuestas de error de la API
*/
private async handleErrorResponse(response: Response): Promise<never> {
let errorData: DeepSeekErrorResponse | null = null;
try {
errorData = await response.json() as DeepSeekErrorResponse;
} catch {
// Si no podemos parsear el JSON, usamos un error genérico
}
// Manejar rate limiting
if (response.status === 429) {
const retryAfter = this.parseRetryAfter(response.headers);
throw new DeepSeekRateLimitError(
errorData?.error?.message || 'Rate limit exceeded',
retryAfter,
this.rateLimitInfo || undefined
);
}
if (errorData?.error) {
throw DeepSeekError.fromAPIError(errorData.error, response.status);
}
throw new DeepSeekError(
`HTTP ${response.status}: ${response.statusText}`,
'http_error',
response.status,
null,
null,
RETRYABLE_STATUS_CODES.includes(response.status)
);
}
// ============================================================================
// Private Methods - Streaming
// ============================================================================
/**
* Parsea el stream SSE y genera chunks
*/
private async *parseSSEStream(
stream: ReadableStream<Uint8Array>
): AsyncGenerator<StreamChunk, void, unknown> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith(':')) {
continue;
}
if (trimmedLine === 'data: [DONE]') {
return;
}
if (trimmedLine.startsWith('data: ')) {
const jsonStr = trimmedLine.slice(6);
try {
const chunk = JSON.parse(jsonStr) as StreamChunk;
yield chunk;
} catch (parseError) {
logger.warn('Failed to parse SSE chunk', { jsonStr, error: parseError });
}
}
}
}
// Procesar cualquier dato restante en el buffer
if (buffer.trim() && buffer.startsWith('data: ') && buffer !== 'data: [DONE]') {
try {
const chunk = JSON.parse(buffer.slice(6)) as StreamChunk;
yield chunk;
} catch {
// Ignorar errores de parseo del buffer final
}
}
} finally {
reader.releaseLock();
}
}
// ============================================================================
// Private Methods - Retry Logic
// ============================================================================
/**
* Ejecuta una operación con reintentos
*/
private async executeWithRetry<T>(
operation: () => Promise<T>,
attempt: number = 1
): Promise<T> {
try {
return await operation();
} catch (error) {
const shouldRetry = this.shouldRetry(error, attempt);
if (!shouldRetry) {
throw error;
}
const delay = this.calculateRetryDelay(error, attempt);
logger.warn('Retrying DeepSeek request', {
attempt,
maxRetries: this.config.maxRetries,
delayMs: delay,
error: error instanceof Error ? error.message : 'Unknown error',
});
await this.sleep(delay);
return this.executeWithRetry(operation, attempt + 1);
}
}
/**
* Determina si se debe reintentar la operación
*/
private shouldRetry(error: unknown, attempt: number): boolean {
if (attempt >= (this.config.maxRetries || 3)) {
return false;
}
if (error instanceof DeepSeekError) {
return error.retryable;
}
return false;
}
/**
* Calcula el delay para el siguiente reintento
*/
private calculateRetryDelay(error: unknown, attempt: number): number {
const baseDelay = this.config.retryDelay || 1000;
// Si es rate limit, usar el retry-after header si está disponible
if (error instanceof DeepSeekRateLimitError && error.retryAfter > 0) {
return error.retryAfter;
}
// Backoff exponencial con jitter
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
const jitter = Math.random() * 1000;
return Math.min(exponentialDelay + jitter, 60000); // Max 60 segundos
}
// ============================================================================
// Private Methods - Rate Limiting
// ============================================================================
/**
* Actualiza la información de rate limiting desde los headers
*/
private updateRateLimitInfo(headers: Headers): void {
const limitRequests = headers.get('x-ratelimit-limit-requests');
const limitTokens = headers.get('x-ratelimit-limit-tokens');
const remainingRequests = headers.get('x-ratelimit-remaining-requests');
const remainingTokens = headers.get('x-ratelimit-remaining-tokens');
const resetRequests = headers.get('x-ratelimit-reset-requests');
const resetTokens = headers.get('x-ratelimit-reset-tokens');
if (limitRequests || limitTokens) {
this.rateLimitInfo = {
limitRequests: parseInt(limitRequests || '0', 10),
limitTokens: parseInt(limitTokens || '0', 10),
remainingRequests: parseInt(remainingRequests || '0', 10),
remainingTokens: parseInt(remainingTokens || '0', 10),
resetRequests: this.parseResetTime(resetRequests),
resetTokens: this.parseResetTime(resetTokens),
};
// Emitir evento si estamos cerca del límite
if (this.rateLimitInfo.remainingRequests < 10) {
this.emit('rateLimitWarning', this.rateLimitInfo);
}
}
}
/**
* Parsea el tiempo de reset del rate limit
*/
private parseResetTime(value: string | null): number {
if (!value) return 0;
// Puede ser un número de segundos o una duración como "1m30s"
const numericValue = parseInt(value, 10);
if (!isNaN(numericValue)) {
return numericValue * 1000;
}
// Parsear formato de duración
const match = value.match(/(?:(\d+)m)?(?:(\d+)s)?/);
if (match) {
const minutes = parseInt(match[1] || '0', 10);
const seconds = parseInt(match[2] || '0', 10);
return (minutes * 60 + seconds) * 1000;
}
return 0;
}
/**
* Parsea el header Retry-After
*/
private parseRetryAfter(headers: Headers): number {
const retryAfter = headers.get('retry-after');
if (!retryAfter) {
return 5000; // Default 5 segundos
}
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) {
return seconds * 1000;
}
// Puede ser una fecha HTTP
const date = new Date(retryAfter);
if (!isNaN(date.getTime())) {
return Math.max(0, date.getTime() - Date.now());
}
return 5000;
}
// ============================================================================
// Private Methods - Utilities
// ============================================================================
/**
* Espera un tiempo determinado
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
// ============================================================================
// Factory Function
// ============================================================================
let deepSeekClientInstance: DeepSeekClient | null = null;
/**
* Obtiene una instancia singleton del cliente DeepSeek
*/
export function getDeepSeekClient(
config?: Partial<DeepSeekConfig> & { apiKey: string }
): DeepSeekClient {
if (!deepSeekClientInstance) {
if (!config?.apiKey) {
const apiKey = process.env.DEEPSEEK_API_KEY;
if (!apiKey) {
throw new Error('DEEPSEEK_API_KEY environment variable is not set');
}
deepSeekClientInstance = new DeepSeekClient({
apiKey,
baseUrl: process.env.DEEPSEEK_BASE_URL || DEFAULT_CONFIG.baseUrl,
model: (process.env.DEEPSEEK_MODEL as DeepSeekModel) || DEFAULT_CONFIG.model,
...config,
});
} else {
deepSeekClientInstance = new DeepSeekClient(config);
}
}
return deepSeekClientInstance;
}
/**
* Crea una nueva instancia del cliente DeepSeek
*/
export function createDeepSeekClient(
config: Partial<DeepSeekConfig> & { apiKey: string }
): DeepSeekClient {
return new DeepSeekClient(config);
}
export default DeepSeekClient;

View File

@@ -0,0 +1,627 @@
/**
* DeepSeek AI Types
*
* Definiciones de tipos completas para la integración con DeepSeek API.
* Soporta los modelos deepseek-chat y deepseek-coder.
*/
// ============================================================================
// Configuration Types
// ============================================================================
/**
* Configuración del cliente DeepSeek
*/
export interface DeepSeekConfig {
/** API Key de DeepSeek */
apiKey: string;
/** URL base de la API (por defecto: https://api.deepseek.com) */
baseUrl: string;
/** Modelo a utilizar */
model: DeepSeekModel;
/** Timeout en milisegundos (por defecto: 30000) */
timeout?: number;
/** Número máximo de reintentos (por defecto: 3) */
maxRetries?: number;
/** Delay base entre reintentos en ms (por defecto: 1000) */
retryDelay?: number;
/** Organización (opcional) */
organization?: string;
}
/**
* Modelos disponibles de DeepSeek
*/
export type DeepSeekModel =
| 'deepseek-chat'
| 'deepseek-coder'
| 'deepseek-reasoner';
// ============================================================================
// Message Types
// ============================================================================
/**
* Roles de mensaje soportados
*/
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
/**
* Mensaje base de DeepSeek
*/
export interface DeepSeekMessage {
/** Rol del mensaje */
role: MessageRole;
/** Contenido del mensaje */
content: string;
/** Nombre del participante (opcional) */
name?: string;
/** Llamadas a herramientas (solo para assistant) */
tool_calls?: ToolCall[];
/** ID de la llamada a herramienta (solo para tool) */
tool_call_id?: string;
}
/**
* Mensaje de sistema
*/
export interface SystemMessage extends DeepSeekMessage {
role: 'system';
}
/**
* Mensaje de usuario
*/
export interface UserMessage extends DeepSeekMessage {
role: 'user';
}
/**
* Mensaje de asistente
*/
export interface AssistantMessage extends DeepSeekMessage {
role: 'assistant';
}
/**
* Llamada a herramienta
*/
export interface ToolCall {
/** ID único de la llamada */
id: string;
/** Tipo de herramienta */
type: 'function';
/** Función llamada */
function: {
/** Nombre de la función */
name: string;
/** Argumentos en JSON */
arguments: string;
};
}
// ============================================================================
// Request Types
// ============================================================================
/**
* Solicitud de chat completion
*/
export interface ChatCompletionRequest {
/** Modelo a utilizar */
model: DeepSeekModel;
/** Mensajes de la conversación */
messages: DeepSeekMessage[];
/** Temperatura de muestreo (0-2, por defecto: 1) */
temperature?: number;
/** Top-p sampling (0-1, por defecto: 1) */
top_p?: number;
/** Número de completaciones a generar */
n?: number;
/** Si debe ser streaming */
stream?: boolean;
/** Secuencias de parada */
stop?: string | string[];
/** Máximo de tokens a generar */
max_tokens?: number;
/** Penalización de presencia (-2 a 2) */
presence_penalty?: number;
/** Penalización de frecuencia (-2 a 2) */
frequency_penalty?: number;
/** Modificar probabilidad de tokens específicos */
logit_bias?: Record<string, number>;
/** ID de usuario para tracking */
user?: string;
/** Herramientas disponibles */
tools?: Tool[];
/** Elección de herramienta */
tool_choice?: 'none' | 'auto' | { type: 'function'; function: { name: string } };
/** Formato de respuesta */
response_format?: ResponseFormat;
/** Semilla para reproducibilidad */
seed?: number;
}
/**
* Definición de herramienta
*/
export interface Tool {
/** Tipo de herramienta */
type: 'function';
/** Definición de la función */
function: FunctionDefinition;
}
/**
* Definición de función
*/
export interface FunctionDefinition {
/** Nombre de la función */
name: string;
/** Descripción de la función */
description?: string;
/** Parámetros de la función (JSON Schema) */
parameters?: Record<string, unknown>;
}
/**
* Formato de respuesta
*/
export interface ResponseFormat {
/** Tipo de formato */
type: 'text' | 'json_object';
}
// ============================================================================
// Response Types
// ============================================================================
/**
* Respuesta de chat completion
*/
export interface ChatCompletionResponse {
/** ID único de la respuesta */
id: string;
/** Tipo de objeto */
object: 'chat.completion';
/** Timestamp de creación (Unix) */
created: number;
/** Modelo utilizado */
model: string;
/** Elecciones generadas */
choices: ChatCompletionChoice[];
/** Estadísticas de uso */
usage: Usage;
/** Fingerprint del sistema */
system_fingerprint?: string;
}
/**
* Elección de chat completion
*/
export interface ChatCompletionChoice {
/** Índice de la elección */
index: number;
/** Mensaje generado */
message: AssistantMessage;
/** Probabilidades de tokens (si se solicitó) */
logprobs?: LogProbs | null;
/** Razón de finalización */
finish_reason: FinishReason;
}
/**
* Razones de finalización
*/
export type FinishReason =
| 'stop' // Parada natural o por secuencia de stop
| 'length' // Límite de tokens alcanzado
| 'tool_calls' // Modelo quiere llamar herramientas
| 'content_filter' // Contenido filtrado
| 'function_call'; // Deprecated: llamada a función
/**
* Probabilidades de tokens
*/
export interface LogProbs {
/** Contenido con probabilidades */
content: LogProbContent[] | null;
}
/**
* Contenido con probabilidades
*/
export interface LogProbContent {
/** Token */
token: string;
/** Log probability */
logprob: number;
/** Bytes del token */
bytes: number[] | null;
/** Top logprobs */
top_logprobs: TopLogProb[];
}
/**
* Top log probabilities
*/
export interface TopLogProb {
/** Token */
token: string;
/** Log probability */
logprob: number;
/** Bytes del token */
bytes: number[] | null;
}
// ============================================================================
// Streaming Types
// ============================================================================
/**
* Chunk de streaming
*/
export interface StreamChunk {
/** ID único */
id: string;
/** Tipo de objeto */
object: 'chat.completion.chunk';
/** Timestamp de creación */
created: number;
/** Modelo utilizado */
model: string;
/** Fingerprint del sistema */
system_fingerprint?: string;
/** Elecciones delta */
choices: StreamChoice[];
/** Uso (solo en último chunk con stream_options) */
usage?: Usage | null;
}
/**
* Elección de streaming
*/
export interface StreamChoice {
/** Índice de la elección */
index: number;
/** Delta del mensaje */
delta: StreamDelta;
/** Probabilidades de tokens */
logprobs?: LogProbs | null;
/** Razón de finalización */
finish_reason: FinishReason | null;
}
/**
* Delta de streaming
*/
export interface StreamDelta {
/** Rol (solo en primer chunk) */
role?: MessageRole;
/** Contenido incremental */
content?: string;
/** Llamadas a herramientas incrementales */
tool_calls?: StreamToolCall[];
}
/**
* Llamada a herramienta en streaming
*/
export interface StreamToolCall {
/** Índice de la llamada */
index: number;
/** ID (solo en primer chunk de la llamada) */
id?: string;
/** Tipo */
type?: 'function';
/** Función */
function?: {
/** Nombre (solo en primer chunk) */
name?: string;
/** Argumentos incrementales */
arguments?: string;
};
}
// ============================================================================
// Usage & Statistics Types
// ============================================================================
/**
* Estadísticas de uso
*/
export interface Usage {
/** Tokens en el prompt */
prompt_tokens: number;
/** Tokens en la respuesta */
completion_tokens: number;
/** Tokens totales */
total_tokens: number;
/** Detalles del prompt (opcional) */
prompt_tokens_details?: PromptTokensDetails;
/** Detalles de la respuesta (opcional) */
completion_tokens_details?: CompletionTokensDetails;
}
/**
* Detalles de tokens del prompt
*/
export interface PromptTokensDetails {
/** Tokens cacheados */
cached_tokens?: number;
}
/**
* Detalles de tokens de la respuesta
*/
export interface CompletionTokensDetails {
/** Tokens de razonamiento */
reasoning_tokens?: number;
}
// ============================================================================
// Error Types
// ============================================================================
/**
* Error de la API de DeepSeek
*/
export interface DeepSeekAPIError {
/** Mensaje de error */
message: string;
/** Tipo de error */
type: DeepSeekErrorType;
/** Parámetro que causó el error (si aplica) */
param?: string | null;
/** Código de error */
code?: string | null;
}
/**
* Tipos de error de DeepSeek
*/
export type DeepSeekErrorType =
| 'invalid_request_error'
| 'authentication_error'
| 'permission_error'
| 'not_found_error'
| 'rate_limit_error'
| 'server_error'
| 'service_unavailable_error';
/**
* Respuesta de error de la API
*/
export interface DeepSeekErrorResponse {
error: DeepSeekAPIError;
}
// ============================================================================
// Rate Limiting Types
// ============================================================================
/**
* Información de rate limiting
*/
export interface RateLimitInfo {
/** Límite de requests por minuto */
limitRequests: number;
/** Límite de tokens por minuto */
limitTokens: number;
/** Requests restantes */
remainingRequests: number;
/** Tokens restantes */
remainingTokens: number;
/** Tiempo hasta reset de requests (ms) */
resetRequests: number;
/** Tiempo hasta reset de tokens (ms) */
resetTokens: number;
}
// ============================================================================
// Financial Analysis Types (for AI Service)
// ============================================================================
/**
* Contexto para análisis financiero
*/
export interface FinancialAnalysisContext {
/** ID del tenant */
tenantId: string;
/** Nombre de la empresa */
companyName?: string;
/** Industria */
industry?: string;
/** Período del análisis */
period: {
from: Date;
to: Date;
type: 'monthly' | 'quarterly' | 'yearly';
};
/** Moneda */
currency: string;
/** Idioma preferido */
language: 'es' | 'en';
}
/**
* Métricas financieras para análisis
*/
export interface FinancialMetricsInput {
/** Ingresos */
revenue: number;
/** Gastos */
expenses: number;
/** Utilidad neta */
netProfit: number;
/** Margen de utilidad */
profitMargin: number;
/** Flujo de caja */
cashFlow: number;
/** Cuentas por cobrar */
accountsReceivable: number;
/** Cuentas por pagar */
accountsPayable: number;
/** Comparación con período anterior */
previousPeriod?: {
revenue: number;
expenses: number;
netProfit: number;
cashFlow: number;
};
/** Métricas adicionales (startup/enterprise) */
additional?: {
mrr?: number;
arr?: number;
burnRate?: number;
runway?: number;
churnRate?: number;
ebitda?: number;
currentRatio?: number;
quickRatio?: number;
};
}
/**
* Tendencias financieras
*/
export interface FinancialTrends {
/** Tendencia de ingresos */
revenueTrend: TrendData[];
/** Tendencia de gastos */
expensesTrend: TrendData[];
/** Tendencia de utilidad */
profitTrend: TrendData[];
/** Tendencia de flujo de caja */
cashFlowTrend: TrendData[];
}
/**
* Punto de datos de tendencia
*/
export interface TrendData {
/** Período */
period: string;
/** Valor */
value: number;
/** Cambio porcentual */
changePercent?: number;
}
/**
* Resultado de insight financiero
*/
export interface FinancialInsightResult {
/** Resumen ejecutivo */
summary: string;
/** Puntos clave */
keyPoints: string[];
/** Nivel de salud financiera */
healthScore: number;
/** Indicadores de alerta */
alerts: FinancialAlert[];
/** Tokens utilizados */
tokensUsed: number;
/** Tiempo de generación (ms) */
generationTime: number;
}
/**
* Alerta financiera
*/
export interface FinancialAlert {
/** Tipo de alerta */
type: 'warning' | 'critical' | 'info';
/** Título */
title: string;
/** Descripción */
description: string;
/** Métrica afectada */
metric?: string;
}
/**
* Resultado de resumen ejecutivo
*/
export interface ExecutiveSummaryResult {
/** Título del resumen */
title: string;
/** Resumen general */
overview: string;
/** Logros del período */
achievements: string[];
/** Desafíos identificados */
challenges: string[];
/** Próximos pasos sugeridos */
nextSteps: string[];
/** Tokens utilizados */
tokensUsed: number;
/** Tiempo de generación (ms) */
generationTime: number;
}
/**
* Resultado de recomendaciones
*/
export interface RecommendationsResult {
/** Recomendaciones prioritarias */
recommendations: Recommendation[];
/** Oportunidades identificadas */
opportunities: string[];
/** Riesgos a mitigar */
risks: string[];
/** Tokens utilizados */
tokensUsed: number;
/** Tiempo de generación (ms) */
generationTime: number;
}
/**
* Recomendación individual
*/
export interface Recommendation {
/** Prioridad */
priority: 'high' | 'medium' | 'low';
/** Categoría */
category: 'cost_reduction' | 'revenue_growth' | 'cash_flow' | 'risk_management' | 'operational';
/** Título */
title: string;
/** Descripción detallada */
description: string;
/** Impacto esperado */
expectedImpact: string;
/** Plazo de implementación */
timeframe: 'immediate' | 'short_term' | 'medium_term' | 'long_term';
}
// ============================================================================
// Cache Types
// ============================================================================
/**
* Clave de cache para respuestas de AI
*/
export interface AICacheKey {
/** Tipo de operación */
operation: 'insight' | 'summary' | 'recommendations';
/** ID del tenant */
tenantId: string;
/** Hash del input */
inputHash: string;
/** Período */
period: string;
}
/**
* Entrada de cache
*/
export interface AICacheEntry<T> {
/** Datos cacheados */
data: T;
/** Timestamp de creación */
createdAt: Date;
/** Timestamp de expiración */
expiresAt: Date;
/** Tokens utilizados */
tokensUsed: number;
}

View File

@@ -0,0 +1,25 @@
/**
* AI Services Index
*
* Exporta todos los módulos relacionados con integración de AI.
*/
// Types
export * from './deepseek.types.js';
// Client
export {
DeepSeekClient,
DeepSeekError,
DeepSeekRateLimitError,
getDeepSeekClient,
createDeepSeekClient,
} from './deepseek.client.js';
// Service
export {
AIService,
AIServiceOptions,
getAIService,
createAIService,
} from './ai.service.js';

View File

@@ -15,3 +15,12 @@ export * from './metrics/metrics.cache.js';
export * from './sat/sat.types.js'; export * from './sat/sat.types.js';
export * from './sat/cfdi.parser.js'; export * from './sat/cfdi.parser.js';
export * from './sat/fiel.service.js'; export * from './sat/fiel.service.js';
// AI
export * from './ai/index.js';
// Reports
export * from './reports/index.js';
// Integrations
export * from './integrations/index.js';

View File

@@ -0,0 +1,457 @@
/**
* Alegra REST API Client
* Cliente HTTP para comunicarse con el API de Alegra
*/
import {
AlegraConfig,
AlegraError,
AlegraRateLimitError,
AlegraAuthError,
AlegraErrorCode,
AlegraPaginationParams,
} from './alegra.types.js';
import { logger } from '../../utils/logger.js';
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_BASE_URL = 'https://api.alegra.com/api/v1';
const DEFAULT_TIMEOUT = 30000;
const DEFAULT_MAX_RETRIES = 3;
const RATE_LIMIT_DELAY = 1000; // 1 segundo entre requests
const MAX_REQUESTS_PER_MINUTE = 60; // Limite conservador
// ============================================================================
// Rate Limiter
// ============================================================================
/**
* Simple rate limiter para respetar los limites de Alegra
*/
class RateLimiter {
private tokens: number;
private lastRefill: number;
private readonly maxTokens: number;
private readonly refillRate: number; // tokens por segundo
constructor(maxTokens: number = MAX_REQUESTS_PER_MINUTE, refillRate: number = 1) {
this.maxTokens = maxTokens;
this.tokens = maxTokens;
this.lastRefill = Date.now();
this.refillRate = refillRate;
}
async acquire(): Promise<void> {
this.refill();
if (this.tokens < 1) {
const waitTime = Math.ceil((1 - this.tokens) / this.refillRate) * 1000;
await this.sleep(waitTime);
this.refill();
}
this.tokens -= 1;
}
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// ============================================================================
// Alegra Client
// ============================================================================
/**
* Cliente REST para el API de Alegra
*/
export class AlegraClient {
private readonly config: Required<Pick<AlegraConfig, 'email' | 'token' | 'baseUrl' | 'timeout' | 'maxRetries'>> & Partial<AlegraConfig>;
private readonly authHeader: string;
private readonly rateLimiter: RateLimiter;
constructor(config: AlegraConfig) {
this.config = {
...config,
baseUrl: config.baseUrl || DEFAULT_BASE_URL,
timeout: config.timeout || DEFAULT_TIMEOUT,
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
};
// Crear header de autenticacion Basic
const credentials = Buffer.from(`${this.config.email}:${this.config.token}`).toString('base64');
this.authHeader = `Basic ${credentials}`;
// Inicializar rate limiter
this.rateLimiter = new RateLimiter();
logger.info('AlegraClient initialized', { baseUrl: this.config.baseUrl });
}
// ============================================================================
// Core HTTP Methods
// ============================================================================
/**
* Realiza una peticion GET
*/
async get<T>(endpoint: string, params?: Record<string, unknown>): Promise<T> {
return this.request<T>('GET', endpoint, undefined, params);
}
/**
* Realiza una peticion POST
*/
async post<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>('POST', endpoint, data);
}
/**
* Realiza una peticion PUT
*/
async put<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>('PUT', endpoint, data);
}
/**
* Realiza una peticion DELETE
*/
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>('DELETE', endpoint);
}
// ============================================================================
// Pagination Helpers
// ============================================================================
/**
* Obtiene todos los resultados de un endpoint paginado
*/
async getAllPaginated<T>(
endpoint: string,
params?: Record<string, unknown>,
maxPages: number = 100
): Promise<T[]> {
const allResults: T[] = [];
let start = 0;
const limit = 30; // Maximo permitido por Alegra
let hasMore = true;
let pageCount = 0;
while (hasMore && pageCount < maxPages) {
const response = await this.get<T[]>(endpoint, {
...params,
start,
limit,
});
if (Array.isArray(response)) {
allResults.push(...response);
hasMore = response.length === limit;
start += limit;
pageCount++;
} else {
hasMore = false;
}
}
return allResults;
}
/**
* Obtiene resultados paginados con metadata
*/
async getPaginated<T>(
endpoint: string,
pagination: AlegraPaginationParams = {},
filters?: Record<string, unknown>
): Promise<{ data: T[]; total: number; hasMore: boolean }> {
const { start = 0, limit = 30, orderField, order } = pagination;
const params: Record<string, unknown> = {
...filters,
start,
limit: Math.min(limit, 30),
};
if (orderField) {
params.orderField = orderField;
params.order = order || 'DESC';
}
const response = await this.get<T[]>(endpoint, params);
const data = Array.isArray(response) ? response : [];
return {
data,
total: data.length, // Alegra no devuelve total, estimamos
hasMore: data.length === params.limit,
};
}
// ============================================================================
// Private Methods
// ============================================================================
/**
* Realiza una peticion HTTP con reintentos y rate limiting
*/
private async request<T>(
method: string,
endpoint: string,
data?: unknown,
params?: Record<string, unknown>
): Promise<T> {
// Aplicar rate limiting
await this.rateLimiter.acquire();
const url = this.buildUrl(endpoint, params);
let lastError: Error | null = null;
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
try {
const response = await this.executeRequest<T>(method, url, data);
return response;
} catch (error) {
lastError = error as Error;
if (error instanceof AlegraRateLimitError) {
// Esperar antes de reintentar en caso de rate limit
const waitTime = error.retryAfter * 1000 || RATE_LIMIT_DELAY * (attempt + 1);
logger.warn('Rate limit hit, waiting before retry', {
attempt,
waitTime,
endpoint
});
await this.sleep(waitTime);
continue;
}
if (error instanceof AlegraAuthError) {
// No reintentar errores de autenticacion
throw error;
}
if (this.shouldRetry(error) && attempt < this.config.maxRetries) {
const delay = this.calculateBackoff(attempt);
logger.warn('Request failed, retrying', {
attempt,
delay,
endpoint,
error: (error as Error).message
});
await this.sleep(delay);
continue;
}
throw error;
}
}
throw lastError || new AlegraError('Max retries exceeded', 'NETWORK_ERROR');
}
/**
* Ejecuta la peticion HTTP usando fetch nativo
*/
private async executeRequest<T>(
method: string,
url: string,
data?: unknown
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
try {
const headers: Record<string, string> = {
'Authorization': this.authHeader,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
const options: RequestInit = {
method,
headers,
signal: controller.signal,
};
if (data && (method === 'POST' || method === 'PUT')) {
options.body = JSON.stringify(data);
}
logger.debug('Alegra API request', { method, url });
const response = await fetch(url, options);
// Manejar errores HTTP
if (!response.ok) {
await this.handleErrorResponse(response);
}
// Parsear respuesta
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return await response.json() as T;
}
return {} as T;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Maneja respuestas de error
*/
private async handleErrorResponse(response: Response): Promise<never> {
let errorData: Record<string, unknown> = {};
try {
errorData = await response.json();
} catch {
// No se pudo parsear el error
}
const message = (errorData.message as string) ||
(errorData.error as string) ||
`HTTP ${response.status}: ${response.statusText}`;
switch (response.status) {
case 401:
throw new AlegraAuthError(message);
case 403:
throw new AlegraError(message, 'FORBIDDEN', 403, errorData);
case 404:
throw new AlegraError(message, 'NOT_FOUND', 404, errorData);
case 400:
case 422:
throw new AlegraError(message, 'VALIDATION_ERROR', response.status, errorData);
case 429:
const retryAfter = parseInt(response.headers.get('Retry-After') || '60', 10);
throw new AlegraRateLimitError(retryAfter, message);
case 500:
case 502:
case 503:
const code: AlegraErrorCode = response.status === 503 ? 'SERVICE_UNAVAILABLE' : 'INTERNAL_ERROR';
throw new AlegraError(message, code, response.status, errorData);
default:
throw new AlegraError(message, 'INTERNAL_ERROR', response.status, errorData);
}
}
/**
* Construye la URL con parametros
*/
private buildUrl(endpoint: string, params?: Record<string, unknown>): string {
const baseUrl = this.config.baseUrl.replace(/\/$/, '');
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const url = new URL(`${baseUrl}${path}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
/**
* Determina si se debe reintentar basado en el error
*/
private shouldRetry(error: unknown): boolean {
if (error instanceof AlegraError) {
return ['NETWORK_ERROR', 'TIMEOUT', 'INTERNAL_ERROR', 'SERVICE_UNAVAILABLE'].includes(error.code);
}
return error instanceof Error && error.name === 'AbortError';
}
/**
* Calcula el delay de backoff exponencial
*/
private calculateBackoff(attempt: number): number {
const baseDelay = RATE_LIMIT_DELAY;
const maxDelay = 30000; // 30 segundos max
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
// Agregar jitter
return delay + Math.random() * 1000;
}
/**
* Espera un tiempo determinado
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ============================================================================
// Utility Methods
// ============================================================================
/**
* Verifica si las credenciales son validas
*/
async testConnection(): Promise<boolean> {
try {
// Intentar obtener la lista de impuestos (endpoint ligero)
await this.get('/taxes', { limit: 1 });
return true;
} catch (error) {
if (error instanceof AlegraAuthError) {
return false;
}
throw error;
}
}
/**
* Obtiene informacion de la compania
*/
async getCompanyInfo(): Promise<Record<string, unknown>> {
return this.get('/company');
}
/**
* Obtiene el pais configurado
*/
getCountry(): string | undefined {
return this.config.country;
}
/**
* Verifica si es una cuenta de Mexico
*/
isMexicoAccount(): boolean {
return this.config.country === 'MX';
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea una instancia del cliente de Alegra
*/
export function createAlegraClient(config: AlegraConfig): AlegraClient {
return new AlegraClient(config);
}
export default AlegraClient;

View File

@@ -0,0 +1,620 @@
/**
* Alegra Validation Schemas
* Schemas Zod para validacion de datos de Alegra
*/
import { z } from 'zod';
// ============================================================================
// Configuration Schemas
// ============================================================================
/**
* Schema para configuracion de Alegra
*/
export const AlegraConfigSchema = z.object({
email: z.string().email('Email invalido'),
token: z.string().min(1, 'Token es requerido'),
baseUrl: z.string().url().optional().default('https://api.alegra.com/api/v1'),
timeout: z.number().int().positive().optional().default(30000),
maxRetries: z.number().int().min(0).max(10).optional().default(3),
country: z.enum(['MX', 'CO', 'PE', 'AR', 'CL', 'ES', 'PA', 'DO', 'CR', 'EC']).optional(),
});
export type AlegraConfigInput = z.infer<typeof AlegraConfigSchema>;
// ============================================================================
// Pagination & Filter Schemas
// ============================================================================
/**
* Schema de paginacion
*/
export const PaginationSchema = z.object({
start: z.number().int().min(0).optional().default(0),
limit: z.number().int().min(1).max(30).optional().default(30),
orderField: z.string().optional(),
order: z.enum(['ASC', 'DESC']).optional().default('DESC'),
});
/**
* Schema de filtro por fecha
*/
export const DateFilterSchema = z.object({
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato de fecha invalido (YYYY-MM-DD)').optional(),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato de fecha invalido (YYYY-MM-DD)').optional(),
});
/**
* Schema de periodo
*/
export const PeriodSchema = z.object({
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato de fecha invalido (YYYY-MM-DD)'),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato de fecha invalido (YYYY-MM-DD)'),
}).refine(
(data) => new Date(data.from) <= new Date(data.to),
{ message: 'La fecha inicial debe ser menor o igual a la fecha final' }
);
export type PaginationInput = z.infer<typeof PaginationSchema>;
export type DateFilterInput = z.infer<typeof DateFilterSchema>;
export type PeriodInput = z.infer<typeof PeriodSchema>;
// ============================================================================
// Contact Schemas
// ============================================================================
/**
* Tipos de identificacion
*/
export const IdentificationTypeSchema = z.enum([
'RFC', 'CURP', 'NIT', 'CC', 'CE', 'PASS', 'DIE', 'TI', 'OTHER',
]);
/**
* Tipos de contacto
*/
export const ContactTypeSchema = z.enum(['client', 'provider', 'client,provider']);
/**
* Schema de direccion
*/
export const AddressSchema = z.object({
address: z.string().max(500).optional(),
city: z.string().max(100).optional(),
department: z.string().max(100).optional(),
country: z.string().max(100).optional(),
zipCode: z.string().max(20).optional(),
});
/**
* Schema de contacto interno
*/
export const InternalContactSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email().optional(),
phone: z.string().max(50).optional(),
sendNotifications: z.boolean().optional(),
});
/**
* Schema de contacto (creacion/actualizacion)
*/
export const ContactInputSchema = z.object({
name: z.string().min(1, 'Nombre es requerido').max(300),
identification: z.string().max(50).optional(),
identificationType: IdentificationTypeSchema.optional(),
email: z.string().email('Email invalido').optional(),
phonePrimary: z.string().max(50).optional(),
phoneSecondary: z.string().max(50).optional(),
fax: z.string().max(50).optional(),
mobile: z.string().max(50).optional(),
address: AddressSchema.optional(),
type: z.array(ContactTypeSchema).min(1),
seller: z.object({ id: z.number() }).optional(),
term: z.object({ id: z.number() }).optional(),
priceList: z.object({ id: z.number() }).optional(),
internalContacts: z.array(InternalContactSchema).optional(),
statementAttached: z.boolean().optional(),
observations: z.string().max(1000).optional(),
creditLimit: z.number().min(0).optional(),
ignoreRepeated: z.boolean().optional(),
kindOfPerson: z.enum(['PERSON_ENTITY', 'LEGAL_ENTITY']).optional(),
// Mexico
regimen: z.string().optional(),
cfdiUse: z.string().optional(),
});
/**
* Schema de filtro de contactos
*/
export const ContactFilterSchema = PaginationSchema.extend({
type: ContactTypeSchema.optional(),
query: z.string().optional(),
status: z.enum(['active', 'inactive']).optional(),
});
export type ContactInput = z.infer<typeof ContactInputSchema>;
export type ContactFilter = z.infer<typeof ContactFilterSchema>;
// ============================================================================
// Invoice Schemas
// ============================================================================
/**
* Estados de factura
*/
export const InvoiceStatusSchema = z.enum(['draft', 'open', 'paid', 'void', 'overdue']);
/**
* Schema de item de factura
*/
export const InvoiceItemSchema = z.object({
id: z.number().optional(),
name: z.string().min(1).max(500),
description: z.string().max(1000).optional(),
price: z.number().min(0),
discount: z.number().min(0).max(100).optional(),
quantity: z.number().min(0.01),
unit: z.string().max(50).optional(),
tax: z.array(z.object({ id: z.number() })).optional(),
reference: z.string().max(100).optional(),
warehouse: z.object({ id: z.number() }).optional(),
productKey: z.string().max(20).optional(),
unitKey: z.string().max(20).optional(),
});
/**
* Schema de pago en factura
*/
export const InvoicePaymentSchema = z.object({
id: z.number(),
amount: z.number().positive(),
});
/**
* Schema de factura (creacion)
*/
export const InvoiceInputSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
client: z.union([
z.object({ id: z.number() }),
z.object({ name: z.string() }),
]),
items: z.array(InvoiceItemSchema).min(1, 'Se requiere al menos un item'),
numberTemplate: z.object({ id: z.number() }).optional(),
costCenter: z.object({ id: z.number() }).optional(),
seller: z.object({ id: z.number() }).optional(),
currency: z.object({ code: z.string() }).optional(),
exchangeRate: z.number().positive().optional(),
observations: z.string().max(2000).optional(),
anotation: z.string().max(2000).optional(),
termsConditions: z.string().max(2000).optional(),
status: InvoiceStatusSchema.optional(),
payments: z.array(InvoicePaymentSchema).optional(),
priceList: z.object({ id: z.number() }).optional(),
warehouse: z.object({ id: z.number() }).optional(),
// Mexico CFDI
cfdiUse: z.string().optional(),
paymentForm: z.string().optional(),
paymentMethod: z.string().optional(),
});
/**
* Schema de filtro de facturas
*/
export const InvoiceFilterSchema = PaginationSchema.extend({
...DateFilterSchema.shape,
status: InvoiceStatusSchema.optional(),
clientId: z.number().optional(),
numberTemplateId: z.number().optional(),
query: z.string().optional(),
});
export type InvoiceInput = z.infer<typeof InvoiceInputSchema>;
export type InvoiceFilter = z.infer<typeof InvoiceFilterSchema>;
// ============================================================================
// Credit Note Schemas
// ============================================================================
/**
* Schema de nota de credito (creacion)
*/
export const CreditNoteInputSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
client: z.union([
z.object({ id: z.number() }),
z.object({ name: z.string() }),
]),
items: z.array(InvoiceItemSchema).min(1, 'Se requiere al menos un item'),
numberTemplate: z.object({ id: z.number() }).optional(),
costCenter: z.object({ id: z.number() }).optional(),
currency: z.object({ code: z.string() }).optional(),
observations: z.string().max(2000).optional(),
relatedInvoices: z.array(z.object({
id: z.number(),
amount: z.number().positive().optional(),
})).optional(),
});
/**
* Schema de filtro de notas de credito
*/
export const CreditNoteFilterSchema = PaginationSchema.extend({
...DateFilterSchema.shape,
status: z.enum(['draft', 'open', 'closed', 'void']).optional(),
clientId: z.number().optional(),
});
export type CreditNoteInput = z.infer<typeof CreditNoteInputSchema>;
export type CreditNoteFilter = z.infer<typeof CreditNoteFilterSchema>;
// ============================================================================
// Debit Note Schemas
// ============================================================================
/**
* Schema de nota de debito (creacion)
*/
export const DebitNoteInputSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
client: z.union([
z.object({ id: z.number() }),
z.object({ name: z.string() }),
]),
items: z.array(InvoiceItemSchema).min(1, 'Se requiere al menos un item'),
numberTemplate: z.object({ id: z.number() }).optional(),
costCenter: z.object({ id: z.number() }).optional(),
currency: z.object({ code: z.string() }).optional(),
observations: z.string().max(2000).optional(),
relatedInvoices: z.array(z.object({
id: z.number(),
amount: z.number().positive().optional(),
})).optional(),
});
/**
* Schema de filtro de notas de debito
*/
export const DebitNoteFilterSchema = PaginationSchema.extend({
...DateFilterSchema.shape,
status: z.enum(['draft', 'open', 'closed', 'void']).optional(),
clientId: z.number().optional(),
});
export type DebitNoteInput = z.infer<typeof DebitNoteInputSchema>;
export type DebitNoteFilter = z.infer<typeof DebitNoteFilterSchema>;
// ============================================================================
// Payment Schemas
// ============================================================================
/**
* Metodos de pago
*/
export const PaymentMethodSchema = z.enum([
'cash', 'debit-card', 'credit-card', 'transfer', 'check',
'deposit', 'electronic-money', 'consignment', 'other',
]);
/**
* Schema de pago recibido (creacion)
*/
export const PaymentReceivedInputSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
amount: z.number().positive('El monto debe ser positivo'),
client: z.object({ id: z.number() }),
bankAccount: z.object({ id: z.number() }).optional(),
paymentMethod: PaymentMethodSchema.optional(),
observations: z.string().max(1000).optional(),
invoices: z.array(z.object({
id: z.number(),
amount: z.number().positive(),
})).optional(),
});
/**
* Schema de pago realizado (creacion)
*/
export const PaymentMadeInputSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
amount: z.number().positive('El monto debe ser positivo'),
provider: z.object({ id: z.number() }),
bankAccount: z.object({ id: z.number() }).optional(),
paymentMethod: PaymentMethodSchema.optional(),
observations: z.string().max(1000).optional(),
bills: z.array(z.object({
id: z.number(),
amount: z.number().positive(),
})).optional(),
});
/**
* Schema de filtro de pagos
*/
export const PaymentFilterSchema = PaginationSchema.extend({
...DateFilterSchema.shape,
bankAccountId: z.number().optional(),
contactId: z.number().optional(),
});
export type PaymentReceivedInput = z.infer<typeof PaymentReceivedInputSchema>;
export type PaymentMadeInput = z.infer<typeof PaymentMadeInputSchema>;
export type PaymentFilter = z.infer<typeof PaymentFilterSchema>;
// ============================================================================
// Bank Account Schemas
// ============================================================================
/**
* Tipos de cuenta bancaria
*/
export const BankAccountTypeSchema = z.enum(['bank', 'credit-card', 'cash', 'other']);
/**
* Schema de cuenta bancaria (creacion)
*/
export const BankAccountInputSchema = z.object({
name: z.string().min(1).max(200),
number: z.string().max(50).optional(),
description: z.string().max(500).optional(),
type: BankAccountTypeSchema,
initialBalance: z.number().optional().default(0),
initialBalanceDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
currency: z.object({ code: z.string() }).optional(),
bank: z.object({
name: z.string(),
code: z.string().optional(),
}).optional(),
});
/**
* Schema de filtro de cuentas bancarias
*/
export const BankAccountFilterSchema = PaginationSchema.extend({
type: BankAccountTypeSchema.optional(),
status: z.enum(['active', 'inactive']).optional(),
});
/**
* Schema de filtro de transacciones bancarias
*/
export const BankTransactionFilterSchema = PaginationSchema.extend({
...DateFilterSchema.shape,
type: z.enum([
'deposit', 'withdrawal', 'transfer', 'payment-in',
'payment-out', 'fee', 'interest', 'other',
]).optional(),
reconciled: z.boolean().optional(),
});
export type BankAccountInput = z.infer<typeof BankAccountInputSchema>;
export type BankAccountFilter = z.infer<typeof BankAccountFilterSchema>;
export type BankTransactionFilter = z.infer<typeof BankTransactionFilterSchema>;
// ============================================================================
// Item Schemas
// ============================================================================
/**
* Tipos de item
*/
export const ItemTypeSchema = z.enum(['product', 'service', 'kit']);
/**
* Schema de precio de item
*/
export const ItemPriceSchema = z.object({
idPriceList: z.number(),
price: z.number().min(0),
});
/**
* Schema de inventario de item
*/
export const ItemInventorySchema = z.object({
unit: z.string().max(50).optional(),
unitCost: z.number().min(0).optional(),
initialQuantity: z.number().min(0).optional(),
minQuantity: z.number().min(0).optional(),
maxQuantity: z.number().min(0).optional(),
warehouses: z.array(z.object({
id: z.number(),
initialQuantity: z.number().optional(),
})).optional(),
});
/**
* Schema de item (creacion)
*/
export const ItemInputSchema = z.object({
name: z.string().min(1).max(500),
description: z.string().max(1000).optional(),
reference: z.string().max(100).optional(),
price: z.array(ItemPriceSchema).min(1),
tax: z.array(z.object({ id: z.number() })).optional(),
category: z.object({ id: z.number() }).optional(),
inventory: ItemInventorySchema.optional(),
type: ItemTypeSchema,
productKey: z.string().max(20).optional(),
unitKey: z.string().max(20).optional(),
});
/**
* Schema de filtro de items
*/
export const ItemFilterSchema = PaginationSchema.extend({
type: ItemTypeSchema.optional(),
categoryId: z.number().optional(),
query: z.string().optional(),
status: z.enum(['active', 'inactive']).optional(),
});
export type ItemInput = z.infer<typeof ItemInputSchema>;
export type ItemFilter = z.infer<typeof ItemFilterSchema>;
// ============================================================================
// Category & Cost Center Schemas
// ============================================================================
/**
* Schema de categoria
*/
export const CategoryInputSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(500).optional(),
type: z.enum(['income', 'expense', 'cost', 'other']).optional(),
parent: z.object({ id: z.number() }).optional(),
});
/**
* Schema de centro de costo
*/
export const CostCenterInputSchema = z.object({
name: z.string().min(1).max(200),
code: z.string().max(50).optional(),
description: z.string().max(500).optional(),
});
export type CategoryInput = z.infer<typeof CategoryInputSchema>;
export type CostCenterInput = z.infer<typeof CostCenterInputSchema>;
// ============================================================================
// Tax Schemas
// ============================================================================
/**
* Schema de impuesto
*/
export const TaxInputSchema = z.object({
name: z.string().min(1).max(200),
percentage: z.number().min(0).max(100),
description: z.string().max(500).optional(),
type: z.enum(['IVA', 'ISR', 'IEPS', 'ICA', 'RETENTION', 'OTHER']),
satTaxCode: z.string().optional(),
});
export type TaxInput = z.infer<typeof TaxInputSchema>;
// ============================================================================
// Report Schemas
// ============================================================================
/**
* Schema de solicitud de balance de comprobacion
*/
export const TrialBalanceRequestSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
level: z.number().int().min(1).max(10).optional(),
});
/**
* Schema de solicitud de estado de resultados
*/
export const ProfitAndLossRequestSchema = PeriodSchema.extend({
costCenterId: z.number().optional(),
comparePreviousPeriod: z.boolean().optional(),
});
/**
* Schema de solicitud de balance general
*/
export const BalanceSheetRequestSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato invalido'),
comparePreviousYear: z.boolean().optional(),
});
/**
* Schema de solicitud de flujo de efectivo
*/
export const CashFlowRequestSchema = PeriodSchema.extend({
method: z.enum(['direct', 'indirect']).optional().default('indirect'),
});
/**
* Schema de solicitud de reporte de impuestos
*/
export const TaxReportRequestSchema = PeriodSchema.extend({
taxType: z.enum(['IVA', 'ISR', 'IEPS', 'ALL']).optional().default('ALL'),
});
export type TrialBalanceRequest = z.infer<typeof TrialBalanceRequestSchema>;
export type ProfitAndLossRequest = z.infer<typeof ProfitAndLossRequestSchema>;
export type BalanceSheetRequest = z.infer<typeof BalanceSheetRequestSchema>;
export type CashFlowRequest = z.infer<typeof CashFlowRequestSchema>;
export type TaxReportRequest = z.infer<typeof TaxReportRequestSchema>;
// ============================================================================
// Webhook Schemas
// ============================================================================
/**
* Eventos de webhook
*/
export const WebhookEventSchema = z.enum([
'invoice.created', 'invoice.updated', 'invoice.deleted', 'invoice.voided', 'invoice.paid',
'contact.created', 'contact.updated', 'contact.deleted',
'payment.created', 'payment.updated', 'payment.deleted',
'item.created', 'item.updated', 'item.deleted',
'credit-note.created', 'credit-note.updated',
'debit-note.created', 'debit-note.updated',
'bill.created', 'bill.updated', 'bill.paid',
]);
/**
* Schema de subscripcion de webhook
*/
export const WebhookSubscriptionInputSchema = z.object({
url: z.string().url('URL invalida'),
events: z.array(WebhookEventSchema).min(1, 'Se requiere al menos un evento'),
});
export type WebhookSubscriptionInput = z.infer<typeof WebhookSubscriptionInputSchema>;
// ============================================================================
// Sync Schemas
// ============================================================================
/**
* Schema de configuracion de sincronizacion
*/
export const SyncConfigSchema = z.object({
tenantId: z.string().uuid('Tenant ID invalido'),
alegraConfig: AlegraConfigSchema,
syncInvoices: z.boolean().optional().default(true),
syncContacts: z.boolean().optional().default(true),
syncPayments: z.boolean().optional().default(true),
syncCreditNotes: z.boolean().optional().default(true),
syncItems: z.boolean().optional().default(false),
incrementalSync: z.boolean().optional().default(true),
period: PeriodSchema.optional(),
});
export type SyncConfig = z.infer<typeof SyncConfigSchema>;
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Valida la configuracion de Alegra
*/
export function validateAlegraConfig(config: unknown): AlegraConfigInput {
return AlegraConfigSchema.parse(config);
}
/**
* Valida un periodo de fechas
*/
export function validatePeriod(period: unknown): PeriodInput {
return PeriodSchema.parse(period);
}
/**
* Valida paginacion
*/
export function validatePagination(params: unknown): PaginationInput {
return PaginationSchema.parse(params);
}

View File

@@ -0,0 +1,757 @@
/**
* Alegra Sync Service
* Servicio de sincronizacion de datos entre Alegra y Horux Strategy
*/
import { AlegraClient, createAlegraClient } from './alegra.client.js';
import { AlegraInvoicesConnector, createInvoicesConnector } from './invoices.connector.js';
import { AlegraContactsConnector, createContactsConnector } from './contacts.connector.js';
import { AlegraPaymentsConnector, createPaymentsConnector } from './payments.connector.js';
import {
AlegraConfig,
AlegraInvoice,
AlegraContact,
AlegraPaymentReceived,
AlegraCreditNote,
AlegraSyncResult,
AlegraSyncState,
AlegraWebhookEvent,
AlegraWebhookPayload,
AlegraWebhookSubscription,
} from './alegra.types.js';
import { SyncConfig, SyncConfigSchema } from './alegra.schema.js';
import { logger } from '../../utils/logger.js';
// ============================================================================
// Types for Horux Integration
// ============================================================================
/**
* Transaccion de Horux Strategy
*/
interface HoruxTransaction {
id?: string;
tenantId: string;
externalId: string;
externalSource: 'alegra';
type: 'invoice' | 'credit_note' | 'payment_received' | 'payment_made';
date: Date;
amount: number;
currency: string;
contactId?: string;
contactName?: string;
description: string;
status: string;
metadata: Record<string, unknown>;
syncedAt: Date;
}
/**
* Contacto de Horux Strategy
*/
interface HoruxContact {
id?: string;
tenantId: string;
externalId: string;
externalSource: 'alegra';
name: string;
email?: string;
phone?: string;
identification?: string;
type: 'customer' | 'vendor';
metadata: Record<string, unknown>;
syncedAt: Date;
}
// ============================================================================
// Sync Service Class
// ============================================================================
/**
* Servicio de sincronizacion con Alegra
*/
export class AlegraSyncService {
private client: AlegraClient;
private invoicesConnector: AlegraInvoicesConnector;
private contactsConnector: AlegraContactsConnector;
private paymentsConnector: AlegraPaymentsConnector;
constructor(config: AlegraConfig) {
this.client = createAlegraClient(config);
this.invoicesConnector = createInvoicesConnector(this.client);
this.contactsConnector = createContactsConnector(this.client);
this.paymentsConnector = createPaymentsConnector(this.client);
}
// ============================================================================
// Main Sync Methods
// ============================================================================
/**
* Sincroniza todos los datos de Alegra a Horux
*/
async syncToHorux(
tenantId: string,
config: SyncConfig,
callbacks?: {
onProgress?: (progress: { type: string; current: number; total: number }) => void;
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
onContact?: (contact: HoruxContact) => Promise<void>;
}
): Promise<AlegraSyncResult> {
const startedAt = new Date();
const validatedConfig = SyncConfigSchema.parse(config);
logger.info('Starting Alegra sync', {
tenantId,
syncInvoices: validatedConfig.syncInvoices,
syncContacts: validatedConfig.syncContacts,
syncPayments: validatedConfig.syncPayments,
incrementalSync: validatedConfig.incrementalSync,
});
const result: AlegraSyncResult = {
success: true,
startedAt,
completedAt: new Date(),
stats: {
invoices: { synced: 0, errors: 0 },
contacts: { synced: 0, errors: 0 },
payments: { synced: 0, errors: 0 },
creditNotes: { synced: 0, errors: 0 },
},
errors: [],
};
try {
// Determinar periodo de sincronizacion
const period = validatedConfig.period || this.getDefaultPeriod();
// Sincronizar contactos primero (para tener referencias)
if (validatedConfig.syncContacts) {
await this.syncContacts(tenantId, result, callbacks);
}
// Sincronizar facturas
if (validatedConfig.syncInvoices) {
await this.syncInvoices(tenantId, period, result, callbacks);
}
// Sincronizar notas de credito
if (validatedConfig.syncCreditNotes) {
await this.syncCreditNotes(tenantId, period, result, callbacks);
}
// Sincronizar pagos
if (validatedConfig.syncPayments) {
await this.syncPayments(tenantId, period, result, callbacks);
}
result.completedAt = new Date();
result.success = result.errors.length === 0;
logger.info('Alegra sync completed', {
tenantId,
duration: result.completedAt.getTime() - startedAt.getTime(),
stats: result.stats,
errorsCount: result.errors.length,
});
} catch (error) {
result.success = false;
result.errors.push({
type: 'sync',
message: error instanceof Error ? error.message : 'Unknown error',
});
logger.error('Alegra sync failed', {
tenantId,
error: (error as Error).message,
});
}
return result;
}
/**
* Sincronizacion incremental desde la ultima fecha conocida
*/
async syncIncremental(
tenantId: string,
lastSyncAt: Date,
callbacks?: {
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
onContact?: (contact: HoruxContact) => Promise<void>;
}
): Promise<AlegraSyncResult> {
logger.info('Starting incremental Alegra sync', { tenantId, lastSyncAt });
const startedAt = new Date();
const result: AlegraSyncResult = {
success: true,
startedAt,
completedAt: new Date(),
stats: {
invoices: { synced: 0, errors: 0 },
contacts: { synced: 0, errors: 0 },
payments: { synced: 0, errors: 0 },
creditNotes: { synced: 0, errors: 0 },
},
errors: [],
};
try {
// Obtener elementos modificados desde la ultima sincronizacion
const [invoices, contacts, payments] = await Promise.all([
this.invoicesConnector.getInvoicesModifiedSince(lastSyncAt),
this.contactsConnector.getContactsModifiedSince(lastSyncAt),
this.paymentsConnector.getPaymentsModifiedSince(lastSyncAt, 'all'),
]);
// Sincronizar contactos modificados
for (const contact of contacts) {
try {
const horuxContact = this.mapAlegraContactToHorux(tenantId, contact);
if (callbacks?.onContact) {
await callbacks.onContact(horuxContact);
}
result.stats.contacts.synced++;
} catch (error) {
result.stats.contacts.errors++;
result.errors.push({
type: 'contact',
id: contact.id,
message: (error as Error).message,
});
}
}
// Sincronizar facturas modificadas
for (const invoice of invoices) {
try {
const transaction = this.mapAlegraInvoiceToTransaction(tenantId, invoice);
if (callbacks?.onTransaction) {
await callbacks.onTransaction(transaction);
}
result.stats.invoices.synced++;
} catch (error) {
result.stats.invoices.errors++;
result.errors.push({
type: 'invoice',
id: invoice.id,
message: (error as Error).message,
});
}
}
// Sincronizar pagos modificados
for (const payment of payments) {
try {
const transaction = this.mapAlegraPaymentToTransaction(
tenantId,
payment as AlegraPaymentReceived
);
if (callbacks?.onTransaction) {
await callbacks.onTransaction(transaction);
}
result.stats.payments.synced++;
} catch (error) {
result.stats.payments.errors++;
result.errors.push({
type: 'payment',
id: (payment as AlegraPaymentReceived).id,
message: (error as Error).message,
});
}
}
result.completedAt = new Date();
result.success = result.errors.length === 0;
} catch (error) {
result.success = false;
result.errors.push({
type: 'sync',
message: (error as Error).message,
});
}
return result;
}
// ============================================================================
// Individual Sync Methods
// ============================================================================
/**
* Sincroniza contactos
*/
private async syncContacts(
tenantId: string,
result: AlegraSyncResult,
callbacks?: {
onProgress?: (progress: { type: string; current: number; total: number }) => void;
onContact?: (contact: HoruxContact) => Promise<void>;
}
): Promise<void> {
logger.debug('Syncing contacts from Alegra');
const contacts = await this.contactsConnector.getAllContacts('all');
const total = contacts.length;
for (let i = 0; i < contacts.length; i++) {
const contact = contacts[i];
try {
const horuxContact = this.mapAlegraContactToHorux(tenantId, contact);
if (callbacks?.onContact) {
await callbacks.onContact(horuxContact);
}
result.stats.contacts.synced++;
callbacks?.onProgress?.({
type: 'contacts',
current: i + 1,
total,
});
} catch (error) {
result.stats.contacts.errors++;
result.errors.push({
type: 'contact',
id: contact.id,
message: (error as Error).message,
});
}
}
}
/**
* Sincroniza facturas
*/
private async syncInvoices(
tenantId: string,
period: { from: string; to: string },
result: AlegraSyncResult,
callbacks?: {
onProgress?: (progress: { type: string; current: number; total: number }) => void;
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
}
): Promise<void> {
logger.debug('Syncing invoices from Alegra', { period });
const invoices = await this.invoicesConnector.getAllInvoices({
startDate: period.from,
endDate: period.to,
});
const total = invoices.length;
for (let i = 0; i < invoices.length; i++) {
const invoice = invoices[i];
try {
const transaction = this.mapAlegraInvoiceToTransaction(tenantId, invoice);
if (callbacks?.onTransaction) {
await callbacks.onTransaction(transaction);
}
result.stats.invoices.synced++;
callbacks?.onProgress?.({
type: 'invoices',
current: i + 1,
total,
});
} catch (error) {
result.stats.invoices.errors++;
result.errors.push({
type: 'invoice',
id: invoice.id,
message: (error as Error).message,
});
}
}
}
/**
* Sincroniza notas de credito
*/
private async syncCreditNotes(
tenantId: string,
period: { from: string; to: string },
result: AlegraSyncResult,
callbacks?: {
onProgress?: (progress: { type: string; current: number; total: number }) => void;
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
}
): Promise<void> {
logger.debug('Syncing credit notes from Alegra', { period });
const creditNotes = await this.invoicesConnector.getAllCreditNotes({
startDate: period.from,
endDate: period.to,
});
const total = creditNotes.length;
for (let i = 0; i < creditNotes.length; i++) {
const creditNote = creditNotes[i];
try {
const transaction = this.mapAlegraCreditNoteToTransaction(tenantId, creditNote);
if (callbacks?.onTransaction) {
await callbacks.onTransaction(transaction);
}
result.stats.creditNotes.synced++;
callbacks?.onProgress?.({
type: 'creditNotes',
current: i + 1,
total,
});
} catch (error) {
result.stats.creditNotes.errors++;
result.errors.push({
type: 'credit_note',
id: creditNote.id,
message: (error as Error).message,
});
}
}
}
/**
* Sincroniza pagos
*/
private async syncPayments(
tenantId: string,
period: { from: string; to: string },
result: AlegraSyncResult,
callbacks?: {
onProgress?: (progress: { type: string; current: number; total: number }) => void;
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
}
): Promise<void> {
logger.debug('Syncing payments from Alegra', { period });
const paymentsReceived = await this.paymentsConnector.getAllPaymentsReceived(period);
const total = paymentsReceived.length;
for (let i = 0; i < paymentsReceived.length; i++) {
const payment = paymentsReceived[i];
try {
const transaction = this.mapAlegraPaymentToTransaction(tenantId, payment);
if (callbacks?.onTransaction) {
await callbacks.onTransaction(transaction);
}
result.stats.payments.synced++;
callbacks?.onProgress?.({
type: 'payments',
current: i + 1,
total,
});
} catch (error) {
result.stats.payments.errors++;
result.errors.push({
type: 'payment',
id: payment.id,
message: (error as Error).message,
});
}
}
}
// ============================================================================
// Mapping Methods
// ============================================================================
/**
* Mapea una factura de Alegra a transaccion de Horux
*/
mapAlegraInvoiceToTransaction(tenantId: string, invoice: AlegraInvoice): HoruxTransaction {
return {
tenantId,
externalId: `alegra-invoice-${invoice.id}`,
externalSource: 'alegra',
type: 'invoice',
date: new Date(invoice.date),
amount: invoice.total,
currency: invoice.currency?.code || 'MXN',
contactId: invoice.client ? `alegra-contact-${invoice.client.id}` : undefined,
contactName: invoice.client?.name,
description: `Factura ${invoice.numberTemplate?.fullNumber || invoice.id}`,
status: invoice.status,
metadata: {
alegraId: invoice.id,
numberTemplate: invoice.numberTemplate,
dueDate: invoice.dueDate,
totalPaid: invoice.totalPaid,
balance: invoice.balance,
items: invoice.items?.map(item => ({
name: item.name,
quantity: item.quantity,
price: item.price,
})),
stamp: invoice.stamp,
costCenter: invoice.costCenter,
seller: invoice.seller,
},
syncedAt: new Date(),
};
}
/**
* Mapea una nota de credito de Alegra a transaccion de Horux
*/
mapAlegraCreditNoteToTransaction(tenantId: string, creditNote: AlegraCreditNote): HoruxTransaction {
return {
tenantId,
externalId: `alegra-creditnote-${creditNote.id}`,
externalSource: 'alegra',
type: 'credit_note',
date: new Date(creditNote.date),
amount: -creditNote.total, // Negativo porque es un credito
currency: creditNote.currency?.code || 'MXN',
contactId: creditNote.client ? `alegra-contact-${creditNote.client.id}` : undefined,
contactName: creditNote.client?.name,
description: `Nota de Credito ${creditNote.numberTemplate?.fullNumber || creditNote.id}`,
status: creditNote.status,
metadata: {
alegraId: creditNote.id,
numberTemplate: creditNote.numberTemplate,
relatedInvoices: creditNote.relatedInvoices,
items: creditNote.items?.map(item => ({
name: item.name,
quantity: item.quantity,
price: item.price,
})),
stamp: creditNote.stamp,
},
syncedAt: new Date(),
};
}
/**
* Mapea un pago de Alegra a transaccion de Horux
*/
mapAlegraPaymentToTransaction(
tenantId: string,
payment: AlegraPaymentReceived
): HoruxTransaction {
return {
tenantId,
externalId: `alegra-payment-${payment.id}`,
externalSource: 'alegra',
type: 'payment_received',
date: new Date(payment.date),
amount: payment.amount,
currency: 'MXN', // Default
contactId: payment.client ? `alegra-contact-${payment.client.id}` : undefined,
contactName: payment.client?.name,
description: `Pago ${payment.number || payment.id}`,
status: 'completed',
metadata: {
alegraId: payment.id,
paymentMethod: payment.paymentMethod,
bankAccount: payment.bankAccount,
invoices: payment.invoices,
observations: payment.observations,
},
syncedAt: new Date(),
};
}
/**
* Mapea un contacto de Alegra a contacto de Horux
*/
mapAlegraContactToHorux(tenantId: string, contact: AlegraContact): HoruxContact {
const isCustomer = contact.type.includes('client');
return {
tenantId,
externalId: `alegra-contact-${contact.id}`,
externalSource: 'alegra',
name: contact.name,
email: contact.email,
phone: contact.phonePrimary || contact.mobile,
identification: contact.identification,
type: isCustomer ? 'customer' : 'vendor',
metadata: {
alegraId: contact.id,
identificationType: contact.identificationType,
address: contact.address,
type: contact.type,
seller: contact.seller,
term: contact.term,
regimen: contact.regimen,
cfdiUse: contact.cfdiUse,
creditLimit: contact.creditLimit,
},
syncedAt: new Date(),
};
}
// ============================================================================
// Webhook Handling
// ============================================================================
/**
* Procesa un evento de webhook de Alegra
*/
async handleWebhook(
tenantId: string,
payload: AlegraWebhookPayload,
callbacks?: {
onTransaction?: (transaction: HoruxTransaction) => Promise<void>;
onContact?: (contact: HoruxContact) => Promise<void>;
}
): Promise<void> {
logger.info('Processing Alegra webhook', {
event: payload.event,
timestamp: payload.timestamp,
});
const event = payload.event;
const data = payload.data;
switch (event) {
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
if (callbacks?.onTransaction) {
const transaction = this.mapAlegraInvoiceToTransaction(tenantId, data as AlegraInvoice);
await callbacks.onTransaction(transaction);
}
break;
case 'invoice.voided':
case 'invoice.deleted':
// Marcar como anulado/eliminado
if (callbacks?.onTransaction) {
const invoice = data as AlegraInvoice;
const transaction = this.mapAlegraInvoiceToTransaction(tenantId, {
...invoice,
status: 'void',
});
await callbacks.onTransaction(transaction);
}
break;
case 'contact.created':
case 'contact.updated':
if (callbacks?.onContact) {
const contact = this.mapAlegraContactToHorux(tenantId, data as AlegraContact);
await callbacks.onContact(contact);
}
break;
case 'payment.created':
case 'payment.updated':
if (callbacks?.onTransaction) {
const transaction = this.mapAlegraPaymentToTransaction(tenantId, data as AlegraPaymentReceived);
await callbacks.onTransaction(transaction);
}
break;
case 'credit-note.created':
case 'credit-note.updated':
if (callbacks?.onTransaction) {
const transaction = this.mapAlegraCreditNoteToTransaction(tenantId, data as AlegraCreditNote);
await callbacks.onTransaction(transaction);
}
break;
default:
logger.warn('Unhandled Alegra webhook event', { event });
}
}
/**
* Registra un webhook en Alegra
*/
async registerWebhook(
url: string,
events: AlegraWebhookEvent[]
): Promise<AlegraWebhookSubscription> {
logger.info('Registering Alegra webhook', { url, events });
return this.client.post<AlegraWebhookSubscription>('/webhooks', {
url,
events,
});
}
/**
* Elimina un webhook de Alegra
*/
async deleteWebhook(webhookId: number): Promise<void> {
logger.info('Deleting Alegra webhook', { webhookId });
await this.client.delete(`/webhooks/${webhookId}`);
}
/**
* Lista webhooks registrados
*/
async listWebhooks(): Promise<AlegraWebhookSubscription[]> {
return this.client.get<AlegraWebhookSubscription[]>('/webhooks');
}
// ============================================================================
// Utility Methods
// ============================================================================
/**
* Obtiene el periodo por defecto (ultimo mes)
*/
private getDefaultPeriod(): { from: string; to: string } {
const to = new Date();
const from = new Date();
from.setMonth(from.getMonth() - 1);
return {
from: from.toISOString().split('T')[0],
to: to.toISOString().split('T')[0],
};
}
/**
* Verifica la conexion con Alegra
*/
async testConnection(): Promise<boolean> {
return this.client.testConnection();
}
/**
* Obtiene el estado de sincronizacion
*/
getSyncState(tenantId: string): AlegraSyncState {
// Esto deberia obtenerse de la base de datos
return {
tenantId,
status: 'idle',
};
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea un servicio de sincronizacion de Alegra
*/
export function createAlegraSyncService(config: AlegraConfig): AlegraSyncService {
return new AlegraSyncService(config);
}
export default AlegraSyncService;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,549 @@
/**
* Alegra Contacts Connector
* Conector para gestion de contactos: clientes y proveedores
*/
import { AlegraClient } from './alegra.client.js';
import {
AlegraContact,
AlegraContactType,
AlegraContactBalance,
AlegraContactStatement,
AlegraStatementTransaction,
} from './alegra.types.js';
import {
ContactFilter,
ContactInput,
ContactFilterSchema,
ContactInputSchema,
PeriodInput,
} from './alegra.schema.js';
import { logger } from '../../utils/logger.js';
// ============================================================================
// Contacts Connector Class
// ============================================================================
/**
* Conector para operaciones de contactos en Alegra
*/
export class AlegraContactsConnector {
constructor(private readonly client: AlegraClient) {}
// ============================================================================
// Contact CRUD Methods
// ============================================================================
/**
* Obtiene listado de contactos con filtros opcionales
*/
async getContacts(
type?: 'customer' | 'vendor' | 'all',
filters: Partial<ContactFilter> = {}
): Promise<{
data: AlegraContact[];
hasMore: boolean;
}> {
const validatedFilters = ContactFilterSchema.partial().parse(filters);
const params: Record<string, unknown> = {
start: validatedFilters.start || 0,
limit: validatedFilters.limit || 30,
};
// Mapear tipo de contacto
if (type && type !== 'all') {
params.type = type === 'customer' ? 'client' : 'provider';
} else if (validatedFilters.type) {
params.type = validatedFilters.type;
}
if (validatedFilters.query) {
params.query = validatedFilters.query;
}
if (validatedFilters.status) {
params.status = validatedFilters.status;
}
if (validatedFilters.orderField) {
params.orderField = validatedFilters.orderField;
params.order = validatedFilters.order || 'ASC';
}
logger.debug('Getting contacts from Alegra', { type, filters: params });
const response = await this.client.get<AlegraContact[]>('/contacts', params);
const data = Array.isArray(response) ? response : [];
return {
data,
hasMore: data.length === (validatedFilters.limit || 30),
};
}
/**
* Obtiene todos los contactos (paginacion automatica)
*/
async getAllContacts(type?: 'customer' | 'vendor' | 'all'): Promise<AlegraContact[]> {
const params: Record<string, unknown> = {};
if (type && type !== 'all') {
params.type = type === 'customer' ? 'client' : 'provider';
}
return this.client.getAllPaginated<AlegraContact>('/contacts', params);
}
/**
* Obtiene todos los clientes
*/
async getCustomers(filters?: Partial<ContactFilter>): Promise<AlegraContact[]> {
const { data } = await this.getContacts('customer', {
...filters,
limit: 30,
});
return data;
}
/**
* Obtiene todos los proveedores
*/
async getVendors(filters?: Partial<ContactFilter>): Promise<AlegraContact[]> {
const { data } = await this.getContacts('vendor', {
...filters,
limit: 30,
});
return data;
}
/**
* Obtiene un contacto por ID
*/
async getContactById(id: number): Promise<AlegraContact> {
logger.debug('Getting contact by ID from Alegra', { id });
return this.client.get<AlegraContact>(`/contacts/${id}`);
}
/**
* Busca contactos por nombre, identificacion o email
*/
async searchContacts(
query: string,
type?: 'customer' | 'vendor' | 'all',
limit: number = 30
): Promise<AlegraContact[]> {
const { data } = await this.getContacts(type, { query, limit });
return data;
}
/**
* Busca contacto por identificacion fiscal (RFC, NIT, etc.)
*/
async getContactByIdentification(identification: string): Promise<AlegraContact | null> {
const contacts = await this.searchContacts(identification);
return contacts.find(c => c.identification === identification) || null;
}
/**
* Busca contacto por email
*/
async getContactByEmail(email: string): Promise<AlegraContact | null> {
const contacts = await this.searchContacts(email);
return contacts.find(c => c.email?.toLowerCase() === email.toLowerCase()) || null;
}
/**
* Crea un nuevo contacto
*/
async createContact(input: ContactInput): Promise<AlegraContact> {
const validatedInput = ContactInputSchema.parse(input);
logger.info('Creating contact in Alegra', { name: input.name });
return this.client.post<AlegraContact>('/contacts', validatedInput);
}
/**
* Actualiza un contacto existente
*/
async updateContact(id: number, input: Partial<ContactInput>): Promise<AlegraContact> {
logger.info('Updating contact in Alegra', { id });
return this.client.put<AlegraContact>(`/contacts/${id}`, input);
}
/**
* Elimina un contacto
*/
async deleteContact(id: number): Promise<void> {
logger.info('Deleting contact in Alegra', { id });
await this.client.delete(`/contacts/${id}`);
}
// ============================================================================
// Balance & Statement Methods
// ============================================================================
/**
* Obtiene el balance de un contacto
*/
async getContactBalance(id: number): Promise<AlegraContactBalance> {
logger.debug('Getting contact balance from Alegra', { id });
// Alegra no tiene endpoint directo de balance, lo calculamos
const contact = await this.getContactById(id);
// Obtener facturas pendientes del cliente
const pendingInvoices = await this.client.get<Array<{
id: number;
total: number;
totalPaid?: number;
balance?: number;
status: string;
dueDate: string;
}>>('/invoices', {
client: id,
status: 'open',
});
let totalDue = 0;
let invoicesPending = 0;
let maxOverdueDays = 0;
const today = new Date();
for (const invoice of pendingInvoices) {
const balance = invoice.balance ?? (invoice.total - (invoice.totalPaid || 0));
totalDue += balance;
invoicesPending++;
const dueDate = new Date(invoice.dueDate);
if (dueDate < today) {
const overdueDays = Math.floor((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24));
maxOverdueDays = Math.max(maxOverdueDays, overdueDays);
}
}
return {
contactId: id,
balance: totalDue,
currency: 'MXN', // Default, podria obtenerse de la config
invoicesPending,
totalDue,
overdueDays: maxOverdueDays > 0 ? maxOverdueDays : undefined,
};
}
/**
* Obtiene el estado de cuenta de un contacto en un periodo
*/
async getContactStatement(
id: number,
period: PeriodInput
): Promise<AlegraContactStatement> {
logger.debug('Getting contact statement from Alegra', { id, period });
const contact = await this.getContactById(id);
// Obtener todas las transacciones del periodo
const [invoices, creditNotes, payments] = await Promise.all([
this.client.get<Array<{
id: number;
date: string;
numberTemplate?: { fullNumber?: string };
total: number;
status: string;
}>>('/invoices', {
client: id,
start_date: period.from,
end_date: period.to,
}),
this.client.get<Array<{
id: number;
date: string;
numberTemplate?: { fullNumber?: string };
total: number;
}>>('/credit-notes', {
client: id,
start_date: period.from,
end_date: period.to,
}),
this.client.get<Array<{
id: number;
date: string;
number?: string;
amount: number;
}>>('/payments', {
client: id,
start_date: period.from,
end_date: period.to,
}),
]);
// Construir transacciones
const transactions: AlegraStatementTransaction[] = [];
// Agregar facturas
for (const invoice of invoices) {
if (invoice.status !== 'void') {
transactions.push({
date: invoice.date,
type: 'invoice',
documentNumber: invoice.numberTemplate?.fullNumber || `INV-${invoice.id}`,
description: `Factura`,
debit: invoice.total,
credit: 0,
balance: 0, // Se calculara despues
});
}
}
// Agregar notas de credito
for (const creditNote of creditNotes) {
transactions.push({
date: creditNote.date,
type: 'credit_note',
documentNumber: creditNote.numberTemplate?.fullNumber || `NC-${creditNote.id}`,
description: `Nota de credito`,
debit: 0,
credit: creditNote.total,
balance: 0,
});
}
// Agregar pagos
for (const payment of payments) {
transactions.push({
date: payment.date,
type: 'payment',
documentNumber: payment.number || `PAY-${payment.id}`,
description: `Pago recibido`,
debit: 0,
credit: payment.amount,
balance: 0,
});
}
// Ordenar por fecha
transactions.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
// Calcular balances
// Obtener balance inicial (simplificado - balance al inicio del periodo)
const openingBalance = 0; // Idealmente se calcularia con historico
let runningBalance = openingBalance;
for (const transaction of transactions) {
runningBalance += transaction.debit - transaction.credit;
transaction.balance = runningBalance;
}
return {
contactId: id,
contact,
period: {
from: period.from,
to: period.to,
},
openingBalance,
transactions,
closingBalance: runningBalance,
currency: 'MXN',
};
}
// ============================================================================
// Bulk Operations
// ============================================================================
/**
* Crea o actualiza un contacto (upsert por identificacion)
*/
async upsertContact(input: ContactInput): Promise<AlegraContact> {
if (input.identification) {
const existing = await this.getContactByIdentification(input.identification);
if (existing) {
return this.updateContact(existing.id, input);
}
}
return this.createContact(input);
}
/**
* Importa multiples contactos
*/
async importContacts(
contacts: ContactInput[],
options?: {
skipErrors?: boolean;
upsert?: boolean;
}
): Promise<{
created: AlegraContact[];
updated: AlegraContact[];
errors: Array<{ input: ContactInput; error: string }>;
}> {
const created: AlegraContact[] = [];
const updated: AlegraContact[] = [];
const errors: Array<{ input: ContactInput; error: string }> = [];
for (const input of contacts) {
try {
if (options?.upsert) {
const contact = await this.upsertContact(input);
if (input.identification) {
const existing = await this.getContactByIdentification(input.identification);
if (existing && existing.id === contact.id) {
updated.push(contact);
} else {
created.push(contact);
}
} else {
created.push(contact);
}
} else {
const contact = await this.createContact(input);
created.push(contact);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (options?.skipErrors) {
errors.push({ input, error: errorMessage });
} else {
throw error;
}
}
}
return { created, updated, errors };
}
// ============================================================================
// Statistics & Analytics
// ============================================================================
/**
* Obtiene estadisticas de contactos
*/
async getContactStatistics(): Promise<{
totalCustomers: number;
totalVendors: number;
activeCustomers: number;
activeVendors: number;
customersWithBalance: number;
totalReceivables: number;
}> {
const [customers, vendors] = await Promise.all([
this.getAllContacts('customer'),
this.getAllContacts('vendor'),
]);
let customersWithBalance = 0;
let totalReceivables = 0;
// Para cada cliente, verificar si tiene balance pendiente
// Nota: Esto puede ser lento con muchos clientes
const balancePromises = customers.slice(0, 100).map(async customer => {
try {
const balance = await this.getContactBalance(customer.id);
return balance;
} catch {
return null;
}
});
const balances = await Promise.all(balancePromises);
for (const balance of balances) {
if (balance && balance.balance > 0) {
customersWithBalance++;
totalReceivables += balance.balance;
}
}
return {
totalCustomers: customers.length,
totalVendors: vendors.length,
activeCustomers: customers.filter(c => c.status !== 'inactive').length,
activeVendors: vendors.filter(v => v.status !== 'inactive').length,
customersWithBalance,
totalReceivables,
};
}
/**
* Obtiene los mejores clientes por facturacion
*/
async getTopCustomers(
period: PeriodInput,
limit: number = 10
): Promise<Array<{
contact: AlegraContact;
totalInvoiced: number;
invoiceCount: number;
}>> {
// Obtener todas las facturas del periodo
const invoices = await this.client.getAllPaginated<{
id: number;
client: { id: number };
total: number;
}>('/invoices', {
start_date: period.from,
end_date: period.to,
status: 'paid,open',
});
// Agrupar por cliente
const customerStats = new Map<number, { total: number; count: number }>();
for (const invoice of invoices) {
const clientId = invoice.client.id;
const current = customerStats.get(clientId) || { total: 0, count: 0 };
current.total += invoice.total;
current.count++;
customerStats.set(clientId, current);
}
// Ordenar por total facturado
const sorted = Array.from(customerStats.entries())
.sort(([, a], [, b]) => b.total - a.total)
.slice(0, limit);
// Obtener detalles de los contactos
const results = await Promise.all(
sorted.map(async ([clientId, stats]) => {
const contact = await this.getContactById(clientId);
return {
contact,
totalInvoiced: stats.total,
invoiceCount: stats.count,
};
})
);
return results;
}
/**
* Obtiene contactos modificados desde una fecha (para sync incremental)
*/
async getContactsModifiedSince(sinceDate: Date): Promise<AlegraContact[]> {
const allContacts = await this.getAllContacts();
return allContacts.filter(contact => {
if (contact.updatedAt) {
return new Date(contact.updatedAt) >= sinceDate;
}
if (contact.createdAt) {
return new Date(contact.createdAt) >= sinceDate;
}
return false;
});
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea un conector de contactos de Alegra
*/
export function createContactsConnector(client: AlegraClient): AlegraContactsConnector {
return new AlegraContactsConnector(client);
}
export default AlegraContactsConnector;

View File

@@ -0,0 +1,338 @@
/**
* Alegra Integration Module
* Modulo completo de integracion con Alegra - Software contable cloud
*
* Exporta todos los componentes necesarios para integrar Horux Strategy con Alegra:
* - Cliente REST API con rate limiting y reintentos
* - Conectores para facturas, contactos, pagos y reportes
* - Servicio de sincronizacion bidireccional
* - Tipos y schemas de validacion
*
* Uso basico:
* ```typescript
* import { createAlegraClient, createAlegraSyncService } from './services/integrations/alegra';
*
* const client = createAlegraClient({
* email: 'usuario@ejemplo.com',
* token: 'tu-api-token',
* country: 'MX',
* });
*
* // Usar conectores directamente
* const invoices = await client.get('/invoices');
*
* // O usar el servicio de sincronizacion
* const syncService = createAlegraSyncService(config);
* await syncService.syncToHorux(tenantId, syncConfig);
* ```
*/
// ============================================================================
// Client
// ============================================================================
export { AlegraClient, createAlegraClient } from './alegra.client.js';
// ============================================================================
// Connectors
// ============================================================================
export {
AlegraInvoicesConnector,
createInvoicesConnector,
} from './invoices.connector.js';
export {
AlegraContactsConnector,
createContactsConnector,
} from './contacts.connector.js';
export {
AlegraPaymentsConnector,
createPaymentsConnector,
} from './payments.connector.js';
export {
AlegraReportsConnector,
createReportsConnector,
} from './reports.connector.js';
// ============================================================================
// Sync Service
// ============================================================================
export {
AlegraSyncService,
createAlegraSyncService,
} from './alegra.sync.js';
// ============================================================================
// Types
// ============================================================================
export type {
// Config & Auth
AlegraConfig,
AlegraAuth,
AlegraCountry,
// Pagination
AlegraPaginationParams,
AlegraDateFilter,
AlegraPaginatedResponse,
// Contacts
AlegraContact,
AlegraContactType,
AlegraIdentificationType,
AlegraAddress,
AlegraPhone,
AlegraInternalContact,
AlegraContactBalance,
AlegraContactStatement,
AlegraStatementTransaction,
// Invoices
AlegraInvoice,
AlegraInvoiceStatus,
AlegraInvoiceType,
AlegraInvoiceItem,
AlegraItemTax,
AlegraRetention,
AlegraInvoiceTax,
AlegraStamp,
AlegraPaymentReference,
AlegraNumberTemplate,
// Credit & Debit Notes
AlegraCreditNote,
AlegraDebitNote,
// Payments
AlegraPaymentReceived,
AlegraPaymentMade,
AlegraPaymentType,
AlegraPaymentMethod,
// Bank
AlegraBankAccount,
AlegraBankAccountType,
AlegraBankTransaction,
AlegraBankTransactionType,
AlegraBankReconciliation,
// Items
AlegraItem,
AlegraItemType,
AlegraItemPrice,
AlegraItemInventory,
AlegraItemVariant,
// Categories & Cost Centers
AlegraCategory,
AlegraCategoryType,
AlegraCostCenter,
// Tax & Payment Terms
AlegraTax,
AlegraTaxType,
AlegraPaymentTerm,
// Currency & Seller
AlegraCurrency,
AlegraSeller,
AlegraPriceList,
// Reports
AlegraTrialBalance,
AlegraTrialBalanceAccount,
AlegraProfitAndLoss,
AlegraPLSection,
AlegraBalanceSheet,
AlegraBalanceSection,
AlegraCashFlow,
AlegraCashFlowSection,
AlegraTaxReport,
// Webhooks
AlegraWebhookEvent,
AlegraWebhookSubscription,
AlegraWebhookPayload,
// Sync
AlegraSyncState,
AlegraSyncResult,
// Errors
AlegraErrorCode,
} from './alegra.types.js';
export {
AlegraError,
AlegraRateLimitError,
AlegraAuthError,
} from './alegra.types.js';
// ============================================================================
// Schemas
// ============================================================================
export {
// Config
AlegraConfigSchema,
type AlegraConfigInput,
// Pagination & Filters
PaginationSchema,
DateFilterSchema,
PeriodSchema,
type PaginationInput,
type DateFilterInput,
type PeriodInput,
// Contacts
ContactInputSchema,
ContactFilterSchema,
IdentificationTypeSchema,
ContactTypeSchema,
AddressSchema,
type ContactInput,
type ContactFilter,
// Invoices
InvoiceInputSchema,
InvoiceFilterSchema,
InvoiceItemSchema,
InvoiceStatusSchema,
type InvoiceInput,
type InvoiceFilter,
// Credit Notes
CreditNoteInputSchema,
CreditNoteFilterSchema,
type CreditNoteInput,
type CreditNoteFilter,
// Debit Notes
DebitNoteInputSchema,
DebitNoteFilterSchema,
type DebitNoteInput,
type DebitNoteFilter,
// Payments
PaymentReceivedInputSchema,
PaymentMadeInputSchema,
PaymentFilterSchema,
PaymentMethodSchema,
type PaymentReceivedInput,
type PaymentMadeInput,
type PaymentFilter,
// Bank Accounts
BankAccountInputSchema,
BankAccountFilterSchema,
BankTransactionFilterSchema,
BankAccountTypeSchema,
type BankAccountInput,
type BankAccountFilter,
type BankTransactionFilter,
// Items
ItemInputSchema,
ItemFilterSchema,
ItemTypeSchema,
type ItemInput,
type ItemFilter,
// Categories & Cost Centers
CategoryInputSchema,
CostCenterInputSchema,
type CategoryInput,
type CostCenterInput,
// Tax
TaxInputSchema,
type TaxInput,
// Reports
TrialBalanceRequestSchema,
ProfitAndLossRequestSchema,
BalanceSheetRequestSchema,
CashFlowRequestSchema,
TaxReportRequestSchema,
type TrialBalanceRequest,
type ProfitAndLossRequest,
type BalanceSheetRequest,
type CashFlowRequest,
type TaxReportRequest,
// Webhooks
WebhookEventSchema,
WebhookSubscriptionInputSchema,
type WebhookSubscriptionInput,
// Sync
SyncConfigSchema,
type SyncConfig,
// Utilities
validateAlegraConfig,
validatePeriod,
validatePagination,
} from './alegra.schema.js';
// ============================================================================
// Facade Class for Easy Usage
// ============================================================================
import { AlegraClient, createAlegraClient } from './alegra.client.js';
import { AlegraInvoicesConnector, createInvoicesConnector } from './invoices.connector.js';
import { AlegraContactsConnector, createContactsConnector } from './contacts.connector.js';
import { AlegraPaymentsConnector, createPaymentsConnector } from './payments.connector.js';
import { AlegraReportsConnector, createReportsConnector } from './reports.connector.js';
import { AlegraSyncService, createAlegraSyncService } from './alegra.sync.js';
import type { AlegraConfig } from './alegra.types.js';
/**
* Clase de fachada que proporciona acceso unificado a toda la funcionalidad de Alegra
*/
export class Alegra {
public readonly client: AlegraClient;
public readonly invoices: AlegraInvoicesConnector;
public readonly contacts: AlegraContactsConnector;
public readonly payments: AlegraPaymentsConnector;
public readonly reports: AlegraReportsConnector;
public readonly sync: AlegraSyncService;
constructor(config: AlegraConfig) {
this.client = createAlegraClient(config);
this.invoices = createInvoicesConnector(this.client);
this.contacts = createContactsConnector(this.client);
this.payments = createPaymentsConnector(this.client);
this.reports = createReportsConnector(this.client);
this.sync = createAlegraSyncService(config);
}
/**
* Verifica si las credenciales son validas
*/
async testConnection(): Promise<boolean> {
return this.client.testConnection();
}
/**
* Obtiene informacion de la compania
*/
async getCompanyInfo(): Promise<Record<string, unknown>> {
return this.client.getCompanyInfo();
}
}
/**
* Crea una instancia de Alegra con todos los conectores
*/
export function createAlegra(config: AlegraConfig): Alegra {
return new Alegra(config);
}
export default Alegra;

View File

@@ -0,0 +1,486 @@
/**
* Alegra Invoices Connector
* Conector para facturacion: facturas, notas de credito y notas de debito
*/
import { AlegraClient } from './alegra.client.js';
import {
AlegraInvoice,
AlegraCreditNote,
AlegraDebitNote,
AlegraPaymentReference,
AlegraInvoiceStatus,
AlegraStamp,
} from './alegra.types.js';
import {
InvoiceFilter,
InvoiceInput,
CreditNoteFilter,
CreditNoteInput,
DebitNoteFilter,
DebitNoteInput,
InvoiceFilterSchema,
CreditNoteFilterSchema,
DebitNoteFilterSchema,
} from './alegra.schema.js';
import { logger } from '../../utils/logger.js';
// ============================================================================
// Invoices Connector Class
// ============================================================================
/**
* Conector para operaciones de facturacion en Alegra
*/
export class AlegraInvoicesConnector {
constructor(private readonly client: AlegraClient) {}
// ============================================================================
// Invoice Methods
// ============================================================================
/**
* Obtiene listado de facturas con filtros opcionales
*/
async getInvoices(filters: Partial<InvoiceFilter> = {}): Promise<{
data: AlegraInvoice[];
hasMore: boolean;
}> {
const validatedFilters = InvoiceFilterSchema.partial().parse(filters);
const params: Record<string, unknown> = {
start: validatedFilters.start || 0,
limit: validatedFilters.limit || 30,
};
// Aplicar filtros opcionales
if (validatedFilters.startDate) {
params.start_date = validatedFilters.startDate;
}
if (validatedFilters.endDate) {
params.end_date = validatedFilters.endDate;
}
if (validatedFilters.status) {
params.status = validatedFilters.status;
}
if (validatedFilters.clientId) {
params.client = validatedFilters.clientId;
}
if (validatedFilters.numberTemplateId) {
params.numberTemplate = validatedFilters.numberTemplateId;
}
if (validatedFilters.query) {
params.query = validatedFilters.query;
}
if (validatedFilters.orderField) {
params.orderField = validatedFilters.orderField;
params.order = validatedFilters.order || 'DESC';
}
logger.debug('Getting invoices from Alegra', { filters: params });
const response = await this.client.get<AlegraInvoice[]>('/invoices', params);
const data = Array.isArray(response) ? response : [];
return {
data,
hasMore: data.length === (validatedFilters.limit || 30),
};
}
/**
* Obtiene todas las facturas de un periodo (paginacion automatica)
*/
async getAllInvoices(filters: Partial<InvoiceFilter> = {}): Promise<AlegraInvoice[]> {
const params: Record<string, unknown> = {};
if (filters.startDate) params.start_date = filters.startDate;
if (filters.endDate) params.end_date = filters.endDate;
if (filters.status) params.status = filters.status;
if (filters.clientId) params.client = filters.clientId;
return this.client.getAllPaginated<AlegraInvoice>('/invoices', params);
}
/**
* Obtiene una factura por ID
*/
async getInvoiceById(id: number): Promise<AlegraInvoice> {
logger.debug('Getting invoice by ID from Alegra', { id });
return this.client.get<AlegraInvoice>(`/invoices/${id}`);
}
/**
* Obtiene los pagos asociados a una factura
*/
async getInvoicePayments(invoiceId: number): Promise<AlegraPaymentReference[]> {
const invoice = await this.getInvoiceById(invoiceId);
return invoice.payments || [];
}
/**
* Obtiene facturas por estado
*/
async getInvoicesByStatus(
status: AlegraInvoiceStatus,
pagination?: { start?: number; limit?: number }
): Promise<AlegraInvoice[]> {
const { data } = await this.getInvoices({
status,
start: pagination?.start,
limit: pagination?.limit,
});
return data;
}
/**
* Obtiene facturas pendientes de pago
*/
async getPendingInvoices(): Promise<AlegraInvoice[]> {
return this.getAllInvoices({ status: 'open' });
}
/**
* Obtiene facturas vencidas
*/
async getOverdueInvoices(): Promise<AlegraInvoice[]> {
const openInvoices = await this.getAllInvoices({ status: 'open' });
const today = new Date();
return openInvoices.filter(invoice => {
const dueDate = new Date(invoice.dueDate);
return dueDate < today;
});
}
/**
* Obtiene facturas de un cliente
*/
async getInvoicesByClient(clientId: number): Promise<AlegraInvoice[]> {
return this.getAllInvoices({ clientId });
}
/**
* Obtiene el timbre fiscal (CFDI) de una factura - Mexico
*/
async getInvoiceStamp(invoiceId: number): Promise<AlegraStamp | null> {
const invoice = await this.getInvoiceById(invoiceId);
return invoice.stamp || null;
}
/**
* Busca facturas por texto
*/
async searchInvoices(query: string, limit: number = 30): Promise<AlegraInvoice[]> {
const { data } = await this.getInvoices({ query, limit });
return data;
}
/**
* Crea una nueva factura
*/
async createInvoice(input: InvoiceInput): Promise<AlegraInvoice> {
logger.info('Creating invoice in Alegra', { client: input.client });
return this.client.post<AlegraInvoice>('/invoices', input);
}
/**
* Actualiza una factura existente
*/
async updateInvoice(id: number, input: Partial<InvoiceInput>): Promise<AlegraInvoice> {
logger.info('Updating invoice in Alegra', { id });
return this.client.put<AlegraInvoice>(`/invoices/${id}`, input);
}
/**
* Anula una factura
*/
async voidInvoice(id: number): Promise<AlegraInvoice> {
logger.info('Voiding invoice in Alegra', { id });
return this.client.put<AlegraInvoice>(`/invoices/${id}`, { status: 'void' });
}
/**
* Elimina una factura (solo borradores)
*/
async deleteInvoice(id: number): Promise<void> {
logger.info('Deleting invoice in Alegra', { id });
await this.client.delete(`/invoices/${id}`);
}
/**
* Envia factura por email
*/
async sendInvoiceByEmail(
invoiceId: number,
emails: string[],
options?: { subject?: string; message?: string }
): Promise<void> {
logger.info('Sending invoice by email', { invoiceId, emails });
await this.client.post(`/invoices/${invoiceId}/email`, {
emails,
...options,
});
}
// ============================================================================
// Credit Note Methods
// ============================================================================
/**
* Obtiene listado de notas de credito
*/
async getCreditNotes(filters: Partial<CreditNoteFilter> = {}): Promise<{
data: AlegraCreditNote[];
hasMore: boolean;
}> {
const validatedFilters = CreditNoteFilterSchema.partial().parse(filters);
const params: Record<string, unknown> = {
start: validatedFilters.start || 0,
limit: validatedFilters.limit || 30,
};
if (validatedFilters.startDate) params.start_date = validatedFilters.startDate;
if (validatedFilters.endDate) params.end_date = validatedFilters.endDate;
if (validatedFilters.status) params.status = validatedFilters.status;
if (validatedFilters.clientId) params.client = validatedFilters.clientId;
logger.debug('Getting credit notes from Alegra', { filters: params });
const response = await this.client.get<AlegraCreditNote[]>('/credit-notes', params);
const data = Array.isArray(response) ? response : [];
return {
data,
hasMore: data.length === (validatedFilters.limit || 30),
};
}
/**
* Obtiene todas las notas de credito de un periodo
*/
async getAllCreditNotes(filters: Partial<CreditNoteFilter> = {}): Promise<AlegraCreditNote[]> {
const params: Record<string, unknown> = {};
if (filters.startDate) params.start_date = filters.startDate;
if (filters.endDate) params.end_date = filters.endDate;
if (filters.status) params.status = filters.status;
if (filters.clientId) params.client = filters.clientId;
return this.client.getAllPaginated<AlegraCreditNote>('/credit-notes', params);
}
/**
* Obtiene una nota de credito por ID
*/
async getCreditNoteById(id: number): Promise<AlegraCreditNote> {
logger.debug('Getting credit note by ID from Alegra', { id });
return this.client.get<AlegraCreditNote>(`/credit-notes/${id}`);
}
/**
* Crea una nota de credito
*/
async createCreditNote(input: CreditNoteInput): Promise<AlegraCreditNote> {
logger.info('Creating credit note in Alegra', { client: input.client });
return this.client.post<AlegraCreditNote>('/credit-notes', input);
}
/**
* Actualiza una nota de credito
*/
async updateCreditNote(id: number, input: Partial<CreditNoteInput>): Promise<AlegraCreditNote> {
logger.info('Updating credit note in Alegra', { id });
return this.client.put<AlegraCreditNote>(`/credit-notes/${id}`, input);
}
/**
* Elimina una nota de credito
*/
async deleteCreditNote(id: number): Promise<void> {
logger.info('Deleting credit note in Alegra', { id });
await this.client.delete(`/credit-notes/${id}`);
}
// ============================================================================
// Debit Note Methods
// ============================================================================
/**
* Obtiene listado de notas de debito
*/
async getDebitNotes(filters: Partial<DebitNoteFilter> = {}): Promise<{
data: AlegraDebitNote[];
hasMore: boolean;
}> {
const validatedFilters = DebitNoteFilterSchema.partial().parse(filters);
const params: Record<string, unknown> = {
start: validatedFilters.start || 0,
limit: validatedFilters.limit || 30,
};
if (validatedFilters.startDate) params.start_date = validatedFilters.startDate;
if (validatedFilters.endDate) params.end_date = validatedFilters.endDate;
if (validatedFilters.status) params.status = validatedFilters.status;
if (validatedFilters.clientId) params.client = validatedFilters.clientId;
logger.debug('Getting debit notes from Alegra', { filters: params });
const response = await this.client.get<AlegraDebitNote[]>('/debit-notes', params);
const data = Array.isArray(response) ? response : [];
return {
data,
hasMore: data.length === (validatedFilters.limit || 30),
};
}
/**
* Obtiene todas las notas de debito de un periodo
*/
async getAllDebitNotes(filters: Partial<DebitNoteFilter> = {}): Promise<AlegraDebitNote[]> {
const params: Record<string, unknown> = {};
if (filters.startDate) params.start_date = filters.startDate;
if (filters.endDate) params.end_date = filters.endDate;
if (filters.status) params.status = filters.status;
if (filters.clientId) params.client = filters.clientId;
return this.client.getAllPaginated<AlegraDebitNote>('/debit-notes', params);
}
/**
* Obtiene una nota de debito por ID
*/
async getDebitNoteById(id: number): Promise<AlegraDebitNote> {
logger.debug('Getting debit note by ID from Alegra', { id });
return this.client.get<AlegraDebitNote>(`/debit-notes/${id}`);
}
/**
* Crea una nota de debito
*/
async createDebitNote(input: DebitNoteInput): Promise<AlegraDebitNote> {
logger.info('Creating debit note in Alegra', { client: input.client });
return this.client.post<AlegraDebitNote>('/debit-notes', input);
}
/**
* Actualiza una nota de debito
*/
async updateDebitNote(id: number, input: Partial<DebitNoteInput>): Promise<AlegraDebitNote> {
logger.info('Updating debit note in Alegra', { id });
return this.client.put<AlegraDebitNote>(`/debit-notes/${id}`, input);
}
/**
* Elimina una nota de debito
*/
async deleteDebitNote(id: number): Promise<void> {
logger.info('Deleting debit note in Alegra', { id });
await this.client.delete(`/debit-notes/${id}`);
}
// ============================================================================
// Aggregate Methods
// ============================================================================
/**
* Obtiene resumen de facturacion de un periodo
*/
async getInvoicingSummary(startDate: string, endDate: string): Promise<{
totalInvoices: number;
totalAmount: number;
paidAmount: number;
pendingAmount: number;
invoicesByStatus: Record<AlegraInvoiceStatus, number>;
creditNotesTotal: number;
debitNotesTotal: number;
}> {
const [invoices, creditNotes, debitNotes] = await Promise.all([
this.getAllInvoices({ startDate, endDate }),
this.getAllCreditNotes({ startDate, endDate }),
this.getAllDebitNotes({ startDate, endDate }),
]);
const invoicesByStatus: Record<AlegraInvoiceStatus, number> = {
draft: 0,
open: 0,
paid: 0,
void: 0,
overdue: 0,
};
let totalAmount = 0;
let paidAmount = 0;
for (const invoice of invoices) {
invoicesByStatus[invoice.status]++;
totalAmount += invoice.total;
if (invoice.status === 'paid') {
paidAmount += invoice.total;
} else if (invoice.totalPaid) {
paidAmount += invoice.totalPaid;
}
}
const creditNotesTotal = creditNotes.reduce((sum, cn) => sum + cn.total, 0);
const debitNotesTotal = debitNotes.reduce((sum, dn) => sum + dn.total, 0);
return {
totalInvoices: invoices.length,
totalAmount,
paidAmount,
pendingAmount: totalAmount - paidAmount,
invoicesByStatus,
creditNotesTotal,
debitNotesTotal,
};
}
/**
* Obtiene facturas modificadas desde una fecha (para sync incremental)
*/
async getInvoicesModifiedSince(
sinceDate: Date,
pagination?: { start?: number; limit?: number }
): Promise<AlegraInvoice[]> {
// Alegra no tiene filtro de fecha de modificacion directamente
// Obtenemos por fecha de creacion y filtramos
const startDate = sinceDate.toISOString().split('T')[0];
const endDate = new Date().toISOString().split('T')[0];
const { data } = await this.getInvoices({
startDate,
endDate,
start: pagination?.start,
limit: pagination?.limit,
});
// Filtrar por updatedAt si esta disponible
return data.filter(invoice => {
if (invoice.updatedAt) {
return new Date(invoice.updatedAt) >= sinceDate;
}
if (invoice.createdAt) {
return new Date(invoice.createdAt) >= sinceDate;
}
return true;
});
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea un conector de facturacion de Alegra
*/
export function createInvoicesConnector(client: AlegraClient): AlegraInvoicesConnector {
return new AlegraInvoicesConnector(client);
}
export default AlegraInvoicesConnector;

View File

@@ -0,0 +1,630 @@
/**
* Alegra Payments Connector
* Conector para pagos recibidos, pagos realizados, cuentas bancarias y transacciones
*/
import { AlegraClient } from './alegra.client.js';
import {
AlegraPaymentReceived,
AlegraPaymentMade,
AlegraBankAccount,
AlegraBankTransaction,
AlegraBankReconciliation,
AlegraBankAccountType,
AlegraPaymentMethod,
} from './alegra.types.js';
import {
PaymentFilter,
PaymentReceivedInput,
PaymentMadeInput,
BankAccountFilter,
BankAccountInput,
BankTransactionFilter,
PaymentFilterSchema,
BankAccountFilterSchema,
BankTransactionFilterSchema,
PeriodInput,
} from './alegra.schema.js';
import { logger } from '../../utils/logger.js';
// ============================================================================
// Payments Connector Class
// ============================================================================
/**
* Conector para operaciones de pagos y bancos en Alegra
*/
export class AlegraPaymentsConnector {
constructor(private readonly client: AlegraClient) {}
// ============================================================================
// Payments Received (Cobros)
// ============================================================================
/**
* Obtiene listado de pagos recibidos
*/
async getPaymentsReceived(
period?: PeriodInput,
filters: Partial<PaymentFilter> = {}
): Promise<{
data: AlegraPaymentReceived[];
hasMore: boolean;
}> {
const validatedFilters = PaymentFilterSchema.partial().parse(filters);
const params: Record<string, unknown> = {
start: validatedFilters.start || 0,
limit: validatedFilters.limit || 30,
type: 'in', // Pagos recibidos (ingresos)
};
if (period) {
params.start_date = period.from;
params.end_date = period.to;
} else if (validatedFilters.startDate) {
params.start_date = validatedFilters.startDate;
if (validatedFilters.endDate) {
params.end_date = validatedFilters.endDate;
}
}
if (validatedFilters.bankAccountId) {
params.bankAccount = validatedFilters.bankAccountId;
}
if (validatedFilters.contactId) {
params.client = validatedFilters.contactId;
}
logger.debug('Getting payments received from Alegra', { filters: params });
const response = await this.client.get<AlegraPaymentReceived[]>('/payments', params);
const data = Array.isArray(response) ? response : [];
return {
data,
hasMore: data.length === (validatedFilters.limit || 30),
};
}
/**
* Obtiene todos los pagos recibidos de un periodo
*/
async getAllPaymentsReceived(period: PeriodInput): Promise<AlegraPaymentReceived[]> {
return this.client.getAllPaginated<AlegraPaymentReceived>('/payments', {
type: 'in',
start_date: period.from,
end_date: period.to,
});
}
/**
* Obtiene un pago recibido por ID
*/
async getPaymentReceivedById(id: number): Promise<AlegraPaymentReceived> {
logger.debug('Getting payment received by ID from Alegra', { id });
return this.client.get<AlegraPaymentReceived>(`/payments/${id}`);
}
/**
* Crea un pago recibido
*/
async createPaymentReceived(input: PaymentReceivedInput): Promise<AlegraPaymentReceived> {
logger.info('Creating payment received in Alegra', {
amount: input.amount,
client: input.client.id,
});
const payload = {
...input,
type: 'in',
};
return this.client.post<AlegraPaymentReceived>('/payments', payload);
}
/**
* Actualiza un pago recibido
*/
async updatePaymentReceived(
id: number,
input: Partial<PaymentReceivedInput>
): Promise<AlegraPaymentReceived> {
logger.info('Updating payment received in Alegra', { id });
return this.client.put<AlegraPaymentReceived>(`/payments/${id}`, input);
}
/**
* Elimina un pago recibido
*/
async deletePaymentReceived(id: number): Promise<void> {
logger.info('Deleting payment received in Alegra', { id });
await this.client.delete(`/payments/${id}`);
}
// ============================================================================
// Payments Made (Pagos a Proveedores)
// ============================================================================
/**
* Obtiene listado de pagos realizados
*/
async getPaymentsMade(
period?: PeriodInput,
filters: Partial<PaymentFilter> = {}
): Promise<{
data: AlegraPaymentMade[];
hasMore: boolean;
}> {
const validatedFilters = PaymentFilterSchema.partial().parse(filters);
const params: Record<string, unknown> = {
start: validatedFilters.start || 0,
limit: validatedFilters.limit || 30,
type: 'out', // Pagos realizados (egresos)
};
if (period) {
params.start_date = period.from;
params.end_date = period.to;
} else if (validatedFilters.startDate) {
params.start_date = validatedFilters.startDate;
if (validatedFilters.endDate) {
params.end_date = validatedFilters.endDate;
}
}
if (validatedFilters.bankAccountId) {
params.bankAccount = validatedFilters.bankAccountId;
}
if (validatedFilters.contactId) {
params.provider = validatedFilters.contactId;
}
logger.debug('Getting payments made from Alegra', { filters: params });
const response = await this.client.get<AlegraPaymentMade[]>('/payments', params);
const data = Array.isArray(response) ? response : [];
return {
data,
hasMore: data.length === (validatedFilters.limit || 30),
};
}
/**
* Obtiene todos los pagos realizados de un periodo
*/
async getAllPaymentsMade(period: PeriodInput): Promise<AlegraPaymentMade[]> {
return this.client.getAllPaginated<AlegraPaymentMade>('/payments', {
type: 'out',
start_date: period.from,
end_date: period.to,
});
}
/**
* Obtiene un pago realizado por ID
*/
async getPaymentMadeById(id: number): Promise<AlegraPaymentMade> {
logger.debug('Getting payment made by ID from Alegra', { id });
return this.client.get<AlegraPaymentMade>(`/payments/${id}`);
}
/**
* Crea un pago realizado
*/
async createPaymentMade(input: PaymentMadeInput): Promise<AlegraPaymentMade> {
logger.info('Creating payment made in Alegra', {
amount: input.amount,
provider: input.provider.id,
});
const payload = {
...input,
type: 'out',
};
return this.client.post<AlegraPaymentMade>('/payments', payload);
}
/**
* Actualiza un pago realizado
*/
async updatePaymentMade(
id: number,
input: Partial<PaymentMadeInput>
): Promise<AlegraPaymentMade> {
logger.info('Updating payment made in Alegra', { id });
return this.client.put<AlegraPaymentMade>(`/payments/${id}`, input);
}
/**
* Elimina un pago realizado
*/
async deletePaymentMade(id: number): Promise<void> {
logger.info('Deleting payment made in Alegra', { id });
await this.client.delete(`/payments/${id}`);
}
// ============================================================================
// Bank Accounts
// ============================================================================
/**
* Obtiene listado de cuentas bancarias
*/
async getBankAccounts(
filters: Partial<BankAccountFilter> = {}
): Promise<{
data: AlegraBankAccount[];
hasMore: boolean;
}> {
const validatedFilters = BankAccountFilterSchema.partial().parse(filters);
const params: Record<string, unknown> = {
start: validatedFilters.start || 0,
limit: validatedFilters.limit || 30,
};
if (validatedFilters.type) {
params.type = validatedFilters.type;
}
if (validatedFilters.status) {
params.status = validatedFilters.status;
}
logger.debug('Getting bank accounts from Alegra', { filters: params });
const response = await this.client.get<AlegraBankAccount[]>('/bank-accounts', params);
const data = Array.isArray(response) ? response : [];
return {
data,
hasMore: data.length === (validatedFilters.limit || 30),
};
}
/**
* Obtiene todas las cuentas bancarias
*/
async getAllBankAccounts(): Promise<AlegraBankAccount[]> {
return this.client.getAllPaginated<AlegraBankAccount>('/bank-accounts');
}
/**
* Obtiene una cuenta bancaria por ID
*/
async getBankAccountById(id: number): Promise<AlegraBankAccount> {
logger.debug('Getting bank account by ID from Alegra', { id });
return this.client.get<AlegraBankAccount>(`/bank-accounts/${id}`);
}
/**
* Obtiene cuentas bancarias por tipo
*/
async getBankAccountsByType(type: AlegraBankAccountType): Promise<AlegraBankAccount[]> {
const { data } = await this.getBankAccounts({ type });
return data;
}
/**
* Crea una cuenta bancaria
*/
async createBankAccount(input: BankAccountInput): Promise<AlegraBankAccount> {
logger.info('Creating bank account in Alegra', { name: input.name });
return this.client.post<AlegraBankAccount>('/bank-accounts', input);
}
/**
* Actualiza una cuenta bancaria
*/
async updateBankAccount(
id: number,
input: Partial<BankAccountInput>
): Promise<AlegraBankAccount> {
logger.info('Updating bank account in Alegra', { id });
return this.client.put<AlegraBankAccount>(`/bank-accounts/${id}`, input);
}
/**
* Elimina una cuenta bancaria
*/
async deleteBankAccount(id: number): Promise<void> {
logger.info('Deleting bank account in Alegra', { id });
await this.client.delete(`/bank-accounts/${id}`);
}
// ============================================================================
// Bank Transactions
// ============================================================================
/**
* Obtiene transacciones bancarias de una cuenta
*/
async getBankTransactions(
accountId: number,
period?: PeriodInput,
filters: Partial<BankTransactionFilter> = {}
): Promise<{
data: AlegraBankTransaction[];
hasMore: boolean;
}> {
const validatedFilters = BankTransactionFilterSchema.partial().parse(filters);
const params: Record<string, unknown> = {
start: validatedFilters.start || 0,
limit: validatedFilters.limit || 30,
bankAccount: accountId,
};
if (period) {
params.start_date = period.from;
params.end_date = period.to;
} else if (validatedFilters.startDate) {
params.start_date = validatedFilters.startDate;
if (validatedFilters.endDate) {
params.end_date = validatedFilters.endDate;
}
}
if (validatedFilters.type) {
params.type = validatedFilters.type;
}
if (validatedFilters.reconciled !== undefined) {
params.reconciled = validatedFilters.reconciled;
}
logger.debug('Getting bank transactions from Alegra', { accountId, filters: params });
// Alegra puede no tener endpoint directo de transacciones bancarias
// Usamos el endpoint de movimientos o combinamos pagos
const response = await this.client.get<AlegraBankTransaction[]>(
'/bank-accounts/transactions',
params
);
const data = Array.isArray(response) ? response : [];
return {
data,
hasMore: data.length === (validatedFilters.limit || 30),
};
}
/**
* Obtiene todas las transacciones bancarias de un periodo
*/
async getAllBankTransactions(
accountId: number,
period: PeriodInput
): Promise<AlegraBankTransaction[]> {
return this.client.getAllPaginated<AlegraBankTransaction>(
'/bank-accounts/transactions',
{
bankAccount: accountId,
start_date: period.from,
end_date: period.to,
}
);
}
// ============================================================================
// Bank Reconciliation
// ============================================================================
/**
* Obtiene el estado de conciliacion bancaria
*/
async getBankReconciliation(
accountId: number,
period?: PeriodInput
): Promise<AlegraBankReconciliation> {
logger.debug('Getting bank reconciliation from Alegra', { accountId, period });
const bankAccount = await this.getBankAccountById(accountId);
// Determinar periodo (ultimo mes si no se especifica)
const endDate = period?.to || new Date().toISOString().split('T')[0];
const startDate = period?.from || (() => {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return d.toISOString().split('T')[0];
})();
// Obtener transacciones del periodo
const transactions = await this.getAllBankTransactions(accountId, {
from: startDate,
to: endDate,
});
// Separar conciliadas y no conciliadas
const reconciled = transactions.filter(t => t.reconciled);
const unreconciled = transactions.filter(t => !t.reconciled);
// Calcular balances
const openingBalance = bankAccount.initialBalance || 0;
let closingBalance = openingBalance;
let reconciledBalance = openingBalance;
for (const t of transactions) {
const amount = t.type === 'deposit' || t.type === 'payment-in'
? t.amount
: -t.amount;
closingBalance += amount;
if (t.reconciled) {
reconciledBalance += amount;
}
}
return {
bankAccountId: accountId,
bankAccount,
period: {
from: startDate,
to: endDate,
},
openingBalance,
closingBalance,
reconciledBalance,
difference: closingBalance - reconciledBalance,
transactions: {
reconciled,
unreconciled,
},
};
}
/**
* Marca transacciones como conciliadas
*/
async reconcileTransactions(transactionIds: number[]): Promise<void> {
logger.info('Reconciling transactions in Alegra', { count: transactionIds.length });
for (const id of transactionIds) {
await this.client.put(`/bank-accounts/transactions/${id}`, {
reconciled: true,
reconciledDate: new Date().toISOString().split('T')[0],
});
}
}
// ============================================================================
// Summary & Statistics
// ============================================================================
/**
* Obtiene resumen de pagos de un periodo
*/
async getPaymentsSummary(period: PeriodInput): Promise<{
received: {
total: number;
count: number;
byMethod: Record<AlegraPaymentMethod, number>;
};
made: {
total: number;
count: number;
byMethod: Record<AlegraPaymentMethod, number>;
};
netCashFlow: number;
}> {
const [paymentsReceived, paymentsMade] = await Promise.all([
this.getAllPaymentsReceived(period),
this.getAllPaymentsMade(period),
]);
const byMethodReceived: Record<AlegraPaymentMethod, number> = {
'cash': 0,
'debit-card': 0,
'credit-card': 0,
'transfer': 0,
'check': 0,
'deposit': 0,
'electronic-money': 0,
'consignment': 0,
'other': 0,
};
const byMethodMade: Record<AlegraPaymentMethod, number> = { ...byMethodReceived };
let totalReceived = 0;
for (const payment of paymentsReceived) {
totalReceived += payment.amount;
const method = payment.paymentMethod || 'other';
byMethodReceived[method] = (byMethodReceived[method] || 0) + payment.amount;
}
let totalMade = 0;
for (const payment of paymentsMade) {
totalMade += payment.amount;
const method = payment.paymentMethod || 'other';
byMethodMade[method] = (byMethodMade[method] || 0) + payment.amount;
}
return {
received: {
total: totalReceived,
count: paymentsReceived.length,
byMethod: byMethodReceived,
},
made: {
total: totalMade,
count: paymentsMade.length,
byMethod: byMethodMade,
},
netCashFlow: totalReceived - totalMade,
};
}
/**
* Obtiene balance de cuentas bancarias
*/
async getBankAccountsBalance(): Promise<Array<{
account: AlegraBankAccount;
currentBalance: number;
}>> {
const accounts = await this.getAllBankAccounts();
const results = await Promise.all(
accounts.map(async account => {
// Obtener transacciones para calcular balance actual
// Simplificado: usamos solo el balance inicial
const currentBalance = account.initialBalance || 0;
return {
account,
currentBalance,
};
})
);
return results;
}
/**
* Obtiene pagos modificados desde una fecha (para sync incremental)
*/
async getPaymentsModifiedSince(
sinceDate: Date,
type: 'received' | 'made' | 'all' = 'all'
): Promise<Array<AlegraPaymentReceived | AlegraPaymentMade>> {
const startDate = sinceDate.toISOString().split('T')[0];
const endDate = new Date().toISOString().split('T')[0];
const period = { from: startDate, to: endDate };
const payments: Array<AlegraPaymentReceived | AlegraPaymentMade> = [];
if (type === 'received' || type === 'all') {
const received = await this.getAllPaymentsReceived(period);
payments.push(...received.filter(p => {
if (p.updatedAt) return new Date(p.updatedAt) >= sinceDate;
if (p.createdAt) return new Date(p.createdAt) >= sinceDate;
return true;
}));
}
if (type === 'made' || type === 'all') {
const made = await this.getAllPaymentsMade(period);
payments.push(...made.filter(p => {
if (p.updatedAt) return new Date(p.updatedAt) >= sinceDate;
if (p.createdAt) return new Date(p.createdAt) >= sinceDate;
return true;
}));
}
return payments;
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea un conector de pagos de Alegra
*/
export function createPaymentsConnector(client: AlegraClient): AlegraPaymentsConnector {
return new AlegraPaymentsConnector(client);
}
export default AlegraPaymentsConnector;

View File

@@ -0,0 +1,730 @@
/**
* Alegra Reports Connector
* Conector para reportes financieros: balance de comprobacion, P&L, balance general, flujo de caja
*/
import { AlegraClient } from './alegra.client.js';
import {
AlegraTrialBalance,
AlegraTrialBalanceAccount,
AlegraProfitAndLoss,
AlegraBalanceSheet,
AlegraCashFlow,
AlegraTaxReport,
AlegraTaxType,
} from './alegra.types.js';
import {
TrialBalanceRequest,
ProfitAndLossRequest,
BalanceSheetRequest,
CashFlowRequest,
TaxReportRequest,
PeriodInput,
} from './alegra.schema.js';
import { logger } from '../../utils/logger.js';
// ============================================================================
// Reports Connector Class
// ============================================================================
/**
* Conector para operaciones de reportes financieros en Alegra
*/
export class AlegraReportsConnector {
constructor(private readonly client: AlegraClient) {}
// ============================================================================
// Trial Balance (Balance de Comprobacion)
// ============================================================================
/**
* Obtiene el balance de comprobacion a una fecha
*/
async getTrialBalance(request: TrialBalanceRequest): Promise<AlegraTrialBalance> {
logger.debug('Getting trial balance from Alegra', { date: request.date });
const params: Record<string, unknown> = {
date: request.date,
};
if (request.level) {
params.level = request.level;
}
try {
const response = await this.client.get<{
accounts?: AlegraTrialBalanceAccount[];
totals?: {
debit: number;
credit: number;
debitBalance: number;
creditBalance: number;
};
}>('/reports/trial-balance', params);
return {
date: request.date,
accounts: response.accounts || [],
totals: response.totals || {
debit: 0,
credit: 0,
debitBalance: 0,
creditBalance: 0,
},
};
} catch (error) {
logger.error('Error getting trial balance from Alegra', {
error: (error as Error).message,
date: request.date,
});
// Retornar estructura vacia en caso de error
return {
date: request.date,
accounts: [],
totals: {
debit: 0,
credit: 0,
debitBalance: 0,
creditBalance: 0,
},
};
}
}
/**
* Obtiene balance de comprobacion comparativo (dos fechas)
*/
async getTrialBalanceComparative(
date1: string,
date2: string
): Promise<{
current: AlegraTrialBalance;
previous: AlegraTrialBalance;
variance: {
accounts: Array<{
id: number;
code: string;
name: string;
currentBalance: number;
previousBalance: number;
variance: number;
variancePercent: number;
}>;
};
}> {
const [current, previous] = await Promise.all([
this.getTrialBalance({ date: date1 }),
this.getTrialBalance({ date: date2 }),
]);
// Crear mapa de cuentas del periodo anterior
const previousMap = new Map(
previous.accounts.map(a => [a.id, a])
);
// Calcular variaciones
const varianceAccounts = current.accounts.map(account => {
const prevAccount = previousMap.get(account.id);
const currentBalance = account.debitBalance - account.creditBalance;
const previousBalance = prevAccount
? prevAccount.debitBalance - prevAccount.creditBalance
: 0;
const variance = currentBalance - previousBalance;
const variancePercent = previousBalance !== 0
? (variance / Math.abs(previousBalance)) * 100
: currentBalance !== 0 ? 100 : 0;
return {
id: account.id,
code: account.code,
name: account.name,
currentBalance,
previousBalance,
variance,
variancePercent,
};
});
return {
current,
previous,
variance: {
accounts: varianceAccounts,
},
};
}
// ============================================================================
// Profit & Loss (Estado de Resultados)
// ============================================================================
/**
* Obtiene el estado de resultados de un periodo
*/
async getProfitAndLoss(request: ProfitAndLossRequest): Promise<AlegraProfitAndLoss> {
logger.debug('Getting P&L from Alegra', {
from: request.from,
to: request.to,
});
const params: Record<string, unknown> = {
start_date: request.from,
end_date: request.to,
};
if (request.costCenterId) {
params.costCenter = request.costCenterId;
}
try {
const response = await this.client.get<{
income?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number }> };
expenses?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number }> };
costs?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number }> };
grossProfit?: number;
operatingProfit?: number;
netProfit?: number;
}>('/reports/profit-and-loss', params);
const income = response.income || { total: 0, items: [] };
const expenses = response.expenses || { total: 0, items: [] };
const costs = response.costs;
return {
period: { from: request.from, to: request.to },
income,
expenses,
costs,
grossProfit: response.grossProfit ?? (income.total - (costs?.total || 0)),
operatingProfit: response.operatingProfit ?? (income.total - (costs?.total || 0) - expenses.total),
netProfit: response.netProfit ?? (income.total - (costs?.total || 0) - expenses.total),
};
} catch (error) {
logger.error('Error getting P&L from Alegra', {
error: (error as Error).message,
period: request,
});
return {
period: { from: request.from, to: request.to },
income: { total: 0, items: [] },
expenses: { total: 0, items: [] },
grossProfit: 0,
operatingProfit: 0,
netProfit: 0,
};
}
}
/**
* Obtiene P&L comparativo con periodo anterior
*/
async getProfitAndLossComparative(
currentPeriod: PeriodInput,
previousPeriod: PeriodInput
): Promise<{
current: AlegraProfitAndLoss;
previous: AlegraProfitAndLoss;
variance: {
incomeVariance: number;
incomeVariancePercent: number;
expensesVariance: number;
expensesVariancePercent: number;
netProfitVariance: number;
netProfitVariancePercent: number;
};
}> {
const [current, previous] = await Promise.all([
this.getProfitAndLoss(currentPeriod),
this.getProfitAndLoss(previousPeriod),
]);
const calcVariance = (curr: number, prev: number) => ({
variance: curr - prev,
percent: prev !== 0 ? ((curr - prev) / Math.abs(prev)) * 100 : curr !== 0 ? 100 : 0,
});
const incomeVar = calcVariance(current.income.total, previous.income.total);
const expensesVar = calcVariance(current.expenses.total, previous.expenses.total);
const netProfitVar = calcVariance(current.netProfit, previous.netProfit);
return {
current,
previous,
variance: {
incomeVariance: incomeVar.variance,
incomeVariancePercent: incomeVar.percent,
expensesVariance: expensesVar.variance,
expensesVariancePercent: expensesVar.percent,
netProfitVariance: netProfitVar.variance,
netProfitVariancePercent: netProfitVar.percent,
},
};
}
// ============================================================================
// Balance Sheet (Balance General)
// ============================================================================
/**
* Obtiene el balance general a una fecha
*/
async getBalanceSheet(request: BalanceSheetRequest): Promise<AlegraBalanceSheet> {
logger.debug('Getting balance sheet from Alegra', { date: request.date });
try {
const response = await this.client.get<{
assets?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number; children?: Array<{ id: number; name: string; amount: number }> }> };
liabilities?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number; children?: Array<{ id: number; name: string; amount: number }> }> };
equity?: { total: number; items: Array<{ id: number; code?: string; name: string; amount: number; children?: Array<{ id: number; name: string; amount: number }> }> };
}>('/reports/balance-sheet', { date: request.date });
const assets = response.assets || { total: 0, items: [] };
const liabilities = response.liabilities || { total: 0, items: [] };
const equity = response.equity || { total: 0, items: [] };
return {
date: request.date,
assets,
liabilities,
equity,
totalAssets: assets.total,
totalLiabilitiesAndEquity: liabilities.total + equity.total,
};
} catch (error) {
logger.error('Error getting balance sheet from Alegra', {
error: (error as Error).message,
date: request.date,
});
return {
date: request.date,
assets: { total: 0, items: [] },
liabilities: { total: 0, items: [] },
equity: { total: 0, items: [] },
totalAssets: 0,
totalLiabilitiesAndEquity: 0,
};
}
}
/**
* Obtiene balance general comparativo
*/
async getBalanceSheetComparative(
currentDate: string,
previousDate: string
): Promise<{
current: AlegraBalanceSheet;
previous: AlegraBalanceSheet;
variance: {
assetsVariance: number;
assetsVariancePercent: number;
liabilitiesVariance: number;
liabilitiesVariancePercent: number;
equityVariance: number;
equityVariancePercent: number;
};
}> {
const [current, previous] = await Promise.all([
this.getBalanceSheet({ date: currentDate }),
this.getBalanceSheet({ date: previousDate }),
]);
const calcVariance = (curr: number, prev: number) => ({
variance: curr - prev,
percent: prev !== 0 ? ((curr - prev) / Math.abs(prev)) * 100 : curr !== 0 ? 100 : 0,
});
const assetsVar = calcVariance(current.assets.total, previous.assets.total);
const liabilitiesVar = calcVariance(current.liabilities.total, previous.liabilities.total);
const equityVar = calcVariance(current.equity.total, previous.equity.total);
return {
current,
previous,
variance: {
assetsVariance: assetsVar.variance,
assetsVariancePercent: assetsVar.percent,
liabilitiesVariance: liabilitiesVar.variance,
liabilitiesVariancePercent: liabilitiesVar.percent,
equityVariance: equityVar.variance,
equityVariancePercent: equityVar.percent,
},
};
}
// ============================================================================
// Cash Flow (Flujo de Efectivo)
// ============================================================================
/**
* Obtiene el flujo de efectivo de un periodo
*/
async getCashFlow(request: CashFlowRequest): Promise<AlegraCashFlow> {
logger.debug('Getting cash flow from Alegra', {
from: request.from,
to: request.to,
});
try {
const response = await this.client.get<{
openingBalance?: number;
closingBalance?: number;
operating?: { total: number; items: Array<{ name: string; amount: number }> };
investing?: { total: number; items: Array<{ name: string; amount: number }> };
financing?: { total: number; items: Array<{ name: string; amount: number }> };
}>('/reports/cash-flow', {
start_date: request.from,
end_date: request.to,
method: request.method,
});
const operating = response.operating || { total: 0, items: [] };
const investing = response.investing || { total: 0, items: [] };
const financing = response.financing || { total: 0, items: [] };
return {
period: { from: request.from, to: request.to },
openingBalance: response.openingBalance || 0,
closingBalance: response.closingBalance || 0,
operating,
investing,
financing,
netCashFlow: operating.total + investing.total + financing.total,
};
} catch (error) {
logger.error('Error getting cash flow from Alegra', {
error: (error as Error).message,
period: request,
});
return {
period: { from: request.from, to: request.to },
openingBalance: 0,
closingBalance: 0,
operating: { total: 0, items: [] },
investing: { total: 0, items: [] },
financing: { total: 0, items: [] },
netCashFlow: 0,
};
}
}
// ============================================================================
// Tax Reports (Reportes de Impuestos)
// ============================================================================
/**
* Obtiene el reporte de impuestos de un periodo
*/
async getTaxReport(request: TaxReportRequest): Promise<AlegraTaxReport> {
logger.debug('Getting tax report from Alegra', {
from: request.from,
to: request.to,
taxType: request.taxType,
});
try {
const response = await this.client.get<{
taxes?: Array<{
taxId: number;
taxName: string;
taxType: AlegraTaxType;
collected: number;
paid: number;
}>;
}>('/reports/taxes', {
start_date: request.from,
end_date: request.to,
tax_type: request.taxType === 'ALL' ? undefined : request.taxType,
});
const taxes = (response.taxes || []).map(tax => ({
...tax,
balance: tax.collected - tax.paid,
}));
const totalCollected = taxes.reduce((sum, t) => sum + t.collected, 0);
const totalPaid = taxes.reduce((sum, t) => sum + t.paid, 0);
return {
period: { from: request.from, to: request.to },
taxes,
summary: {
totalCollected,
totalPaid,
netTaxPayable: totalCollected - totalPaid,
},
};
} catch (error) {
logger.error('Error getting tax report from Alegra', {
error: (error as Error).message,
period: request,
});
return {
period: { from: request.from, to: request.to },
taxes: [],
summary: {
totalCollected: 0,
totalPaid: 0,
netTaxPayable: 0,
},
};
}
}
/**
* Obtiene reporte de IVA para Mexico
*/
async getIVAReport(period: PeriodInput): Promise<{
period: PeriodInput;
ivaCollected: number;
ivaPaid: number;
ivaBalance: number;
details: {
sales: Array<{ rate: number; base: number; iva: number }>;
purchases: Array<{ rate: number; base: number; iva: number }>;
};
}> {
const taxReport = await this.getTaxReport({
...period,
taxType: 'IVA',
});
const ivaTax = taxReport.taxes.find(t => t.taxType === 'IVA');
return {
period,
ivaCollected: ivaTax?.collected || 0,
ivaPaid: ivaTax?.paid || 0,
ivaBalance: ivaTax?.balance || 0,
details: {
sales: [
{ rate: 16, base: 0, iva: ivaTax?.collected || 0 },
{ rate: 8, base: 0, iva: 0 },
{ rate: 0, base: 0, iva: 0 },
],
purchases: [
{ rate: 16, base: 0, iva: ivaTax?.paid || 0 },
{ rate: 8, base: 0, iva: 0 },
{ rate: 0, base: 0, iva: 0 },
],
},
};
}
// ============================================================================
// Account Reports
// ============================================================================
/**
* Obtiene movimientos de una cuenta contable
*/
async getAccountMovements(
accountId: number,
period: PeriodInput
): Promise<{
accountId: number;
accountName: string;
period: PeriodInput;
openingBalance: number;
movements: Array<{
date: string;
description: string;
reference?: string;
debit: number;
credit: number;
balance: number;
}>;
closingBalance: number;
}> {
logger.debug('Getting account movements from Alegra', { accountId, period });
try {
const response = await this.client.get<{
accountName?: string;
openingBalance?: number;
movements?: Array<{
date: string;
description: string;
reference?: string;
debit: number;
credit: number;
}>;
}>(`/reports/accounts/${accountId}/movements`, {
start_date: period.from,
end_date: period.to,
});
const movements = response.movements || [];
let runningBalance = response.openingBalance || 0;
const movementsWithBalance = movements.map(m => {
runningBalance += m.debit - m.credit;
return {
...m,
balance: runningBalance,
};
});
return {
accountId,
accountName: response.accountName || '',
period,
openingBalance: response.openingBalance || 0,
movements: movementsWithBalance,
closingBalance: runningBalance,
};
} catch (error) {
logger.error('Error getting account movements from Alegra', {
error: (error as Error).message,
accountId,
period,
});
return {
accountId,
accountName: '',
period,
openingBalance: 0,
movements: [],
closingBalance: 0,
};
}
}
// ============================================================================
// Analytics & KPIs
// ============================================================================
/**
* Obtiene KPIs financieros
*/
async getFinancialKPIs(period: PeriodInput): Promise<{
profitMargin: number;
grossMargin: number;
currentRatio: number;
quickRatio: number;
debtToEquity: number;
returnOnAssets: number;
returnOnEquity: number;
}> {
const [pl, bs] = await Promise.all([
this.getProfitAndLoss(period),
this.getBalanceSheet({ date: period.to }),
]);
// Calcular ratios
const profitMargin = pl.income.total > 0
? (pl.netProfit / pl.income.total) * 100
: 0;
const grossMargin = pl.income.total > 0
? (pl.grossProfit / pl.income.total) * 100
: 0;
// Simplificado - idealmente se separarian activos/pasivos corrientes
const currentRatio = bs.liabilities.total > 0
? bs.assets.total / bs.liabilities.total
: 0;
const quickRatio = currentRatio * 0.8; // Aproximacion
const debtToEquity = bs.equity.total > 0
? bs.liabilities.total / bs.equity.total
: 0;
const returnOnAssets = bs.assets.total > 0
? (pl.netProfit / bs.assets.total) * 100
: 0;
const returnOnEquity = bs.equity.total > 0
? (pl.netProfit / bs.equity.total) * 100
: 0;
return {
profitMargin,
grossMargin,
currentRatio,
quickRatio,
debtToEquity,
returnOnAssets,
returnOnEquity,
};
}
/**
* Obtiene resumen financiero completo
*/
async getFinancialSummary(period: PeriodInput): Promise<{
period: PeriodInput;
profitAndLoss: {
revenue: number;
expenses: number;
netIncome: number;
};
balanceSheet: {
assets: number;
liabilities: number;
equity: number;
};
cashFlow: {
operating: number;
investing: number;
financing: number;
netChange: number;
};
kpis: {
profitMargin: number;
currentRatio: number;
debtToEquity: number;
};
}> {
const [pl, bs, cf] = await Promise.all([
this.getProfitAndLoss(period),
this.getBalanceSheet({ date: period.to }),
this.getCashFlow(period),
]);
return {
period,
profitAndLoss: {
revenue: pl.income.total,
expenses: pl.expenses.total,
netIncome: pl.netProfit,
},
balanceSheet: {
assets: bs.assets.total,
liabilities: bs.liabilities.total,
equity: bs.equity.total,
},
cashFlow: {
operating: cf.operating.total,
investing: cf.investing.total,
financing: cf.financing.total,
netChange: cf.netCashFlow,
},
kpis: {
profitMargin: pl.income.total > 0 ? (pl.netProfit / pl.income.total) * 100 : 0,
currentRatio: bs.liabilities.total > 0 ? bs.assets.total / bs.liabilities.total : 0,
debtToEquity: bs.equity.total > 0 ? bs.liabilities.total / bs.equity.total : 0,
},
};
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea un conector de reportes de Alegra
*/
export function createReportsConnector(client: AlegraClient): AlegraReportsConnector {
return new AlegraReportsConnector(client);
}
export default AlegraReportsConnector;

View File

@@ -0,0 +1,704 @@
/**
* Aspel Database Client
* Cliente de conexion para bases de datos Aspel (Firebird y SQL Server)
* Maneja pool de conexiones, encoding Latin1 y deteccion automatica de BD
*/
import { EventEmitter } from 'events';
import { logger } from '../../../utils/logger.js';
import {
AspelConfig,
AspelConnectionState,
AspelQueryResult,
AspelDatabaseType,
AspelError,
AspelConnectionError,
AspelQueryError,
AspelEncodingError,
} from './aspel.types.js';
// ============================================================================
// Interfaces para drivers de BD (dynamic import)
// ============================================================================
interface FirebirdConnection {
query: (sql: string, params: unknown[], callback: (err: Error | null, result: unknown[]) => void) => void;
detach: (callback: (err: Error | null) => void) => void;
}
interface FirebirdPool {
get: (callback: (err: Error | null, connection: FirebirdConnection) => void) => void;
destroy: () => void;
}
interface SqlServerPool {
connect: () => Promise<void>;
close: () => Promise<void>;
request: () => SqlServerRequest;
connected: boolean;
}
interface SqlServerRequest {
query: (sql: string) => Promise<{ recordset: unknown[]; rowsAffected: number[] }>;
input: (name: string, value: unknown) => SqlServerRequest;
}
// ============================================================================
// Clase principal del cliente Aspel
// ============================================================================
export class AspelClient extends EventEmitter {
private config: Required<AspelConfig>;
private firebirdPool: FirebirdPool | null = null;
private sqlServerPool: SqlServerPool | null = null;
private state: AspelConnectionState;
private reconnectTimer: NodeJS.Timeout | null = null;
private isInitialized = false;
constructor(config: AspelConfig) {
super();
// Configuracion con valores por defecto
this.config = {
databaseType: config.databaseType,
host: config.host,
port: config.port || (config.databaseType === 'firebird' ? 3050 : 1433),
database: config.database,
username: config.username,
password: config.password,
product: config.product,
empresaId: config.empresaId ?? 1,
encoding: config.encoding ?? 'latin1',
connectionTimeout: config.connectionTimeout ?? 30000,
poolSize: config.poolSize ?? 5,
tablePrefix: config.tablePrefix ?? '',
};
this.state = {
connected: false,
databaseType: this.config.databaseType,
activeConnections: 0,
};
logger.info('AspelClient created', {
product: this.config.product,
databaseType: this.config.databaseType,
host: this.config.host,
});
}
// ============================================================================
// Metodos de conexion
// ============================================================================
/**
* Inicializa la conexion al pool de base de datos
*/
async connect(): Promise<void> {
if (this.isInitialized) {
return;
}
try {
logger.info('Connecting to Aspel database...', {
type: this.config.databaseType,
host: this.config.host,
database: this.config.database,
});
if (this.config.databaseType === 'firebird') {
await this.connectFirebird();
} else {
await this.connectSqlServer();
}
this.isInitialized = true;
this.state.connected = true;
this.state.lastConnectedAt = new Date();
// Detectar version del producto
await this.detectProductVersion();
this.emit('connected', this.state);
logger.info('Connected to Aspel database', {
product: this.config.product,
version: this.state.productVersion,
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.state.lastError = errorMsg;
this.state.connected = false;
logger.error('Failed to connect to Aspel database', { error: errorMsg });
throw new AspelConnectionError(`Failed to connect: ${errorMsg}`, {
host: this.config.host,
database: this.config.database,
});
}
}
/**
* Conecta a Firebird
*/
private async connectFirebird(): Promise<void> {
try {
// Dynamic import para node-firebird
const Firebird = await import('node-firebird').catch(() => null);
if (!Firebird) {
throw new Error('node-firebird package not installed. Run: npm install node-firebird');
}
const options = {
host: this.config.host,
port: this.config.port,
database: this.config.database,
user: this.config.username,
password: this.config.password,
lowercase_keys: false,
role: null,
pageSize: 4096,
retryConnectionInterval: 1000,
blobAsText: true,
encoding: this.mapEncodingToFirebird(this.config.encoding),
};
return new Promise((resolve, reject) => {
Firebird.pool(this.config.poolSize, options, (err: Error | null, pool: FirebirdPool) => {
if (err) {
reject(err);
return;
}
this.firebirdPool = pool;
resolve();
});
});
} catch (error) {
throw new AspelConnectionError(
`Firebird connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Conecta a SQL Server
*/
private async connectSqlServer(): Promise<void> {
try {
// Dynamic import para mssql
const sql = await import('mssql').catch(() => null);
if (!sql) {
throw new Error('mssql package not installed. Run: npm install mssql');
}
const config = {
server: this.config.host,
port: this.config.port,
database: this.config.database,
user: this.config.username,
password: this.config.password,
options: {
encrypt: false,
trustServerCertificate: true,
enableArithAbort: true,
connectTimeout: this.config.connectionTimeout,
},
pool: {
max: this.config.poolSize,
min: 1,
idleTimeoutMillis: 30000,
},
};
this.sqlServerPool = new sql.ConnectionPool(config) as unknown as SqlServerPool;
await this.sqlServerPool.connect();
} catch (error) {
throw new AspelConnectionError(
`SQL Server connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Cierra las conexiones
*/
async disconnect(): Promise<void> {
try {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.firebirdPool) {
this.firebirdPool.destroy();
this.firebirdPool = null;
}
if (this.sqlServerPool) {
await this.sqlServerPool.close();
this.sqlServerPool = null;
}
this.isInitialized = false;
this.state.connected = false;
this.state.activeConnections = 0;
this.emit('disconnected');
logger.info('Disconnected from Aspel database');
} catch (error) {
logger.error('Error disconnecting from Aspel', { error });
}
}
/**
* Detecta la version del producto Aspel
*/
private async detectProductVersion(): Promise<void> {
try {
// Intentar detectar la version de la tabla de configuracion
const versionQueries: Record<string, string> = {
COI: "SELECT VALOR FROM CONFIGURACION WHERE LLAVE = 'VERSION'",
SAE: "SELECT VALOR FROM CONFIGURACION WHERE LLAVE = 'VERSION'",
NOI: "SELECT VALOR FROM CONFIGURACION WHERE LLAVE = 'VERSION'",
BANCO: "SELECT VALOR FROM CONFIGURACION WHERE LLAVE = 'VERSION'",
};
const query = versionQueries[this.config.product];
if (query) {
const result = await this.query<{ VALOR: string }>(query);
if (result.rows.length > 0 && result.rows[0]) {
this.state.productVersion = result.rows[0].VALOR;
}
}
} catch {
// Si no se puede detectar la version, continuar sin ella
logger.warn('Could not detect Aspel product version');
}
}
// ============================================================================
// Metodos de consulta
// ============================================================================
/**
* Ejecuta una consulta SQL
*/
async query<T = Record<string, unknown>>(
sql: string,
params: unknown[] = []
): Promise<AspelQueryResult<T>> {
if (!this.isInitialized) {
await this.connect();
}
const startTime = Date.now();
try {
let rows: T[];
let rowsAffected: number | undefined;
if (this.config.databaseType === 'firebird') {
const result = await this.queryFirebird(sql, params);
rows = result.rows as T[];
} else {
const result = await this.querySqlServer(sql, params);
rows = result.rows as T[];
rowsAffected = result.rowsAffected;
}
// Convertir encoding de Latin1 a UTF-8
rows = this.convertEncodingArray(rows);
const executionTime = Date.now() - startTime;
return {
rows,
rowsAffected,
executionTime,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
logger.error('Aspel query error', { sql, error: errorMsg });
throw new AspelQueryError(errorMsg, sql);
}
}
/**
* Ejecuta query en Firebird
*/
private async queryFirebird(sql: string, params: unknown[]): Promise<{ rows: unknown[] }> {
if (!this.firebirdPool) {
throw new AspelConnectionError('Firebird pool not initialized');
}
return new Promise((resolve, reject) => {
this.firebirdPool!.get((err, connection) => {
if (err) {
reject(err);
return;
}
this.state.activeConnections++;
connection.query(sql, params, (queryErr, result) => {
this.state.activeConnections--;
connection.detach((detachErr) => {
if (detachErr) {
logger.warn('Error detaching Firebird connection', { error: detachErr });
}
});
if (queryErr) {
reject(queryErr);
return;
}
resolve({ rows: result || [] });
});
});
});
}
/**
* Ejecuta query en SQL Server
*/
private async querySqlServer(
sql: string,
params: unknown[]
): Promise<{ rows: unknown[]; rowsAffected?: number }> {
if (!this.sqlServerPool || !this.sqlServerPool.connected) {
throw new AspelConnectionError('SQL Server pool not connected');
}
this.state.activeConnections++;
try {
const request = this.sqlServerPool.request();
// Agregar parametros
params.forEach((param, index) => {
request.input(`p${index}`, param);
});
// Reemplazar ? por @pN para SQL Server
let paramIndex = 0;
const sqlWithParams = sql.replace(/\?/g, () => `@p${paramIndex++}`);
const result = await request.query(sqlWithParams);
return {
rows: result.recordset || [],
rowsAffected: result.rowsAffected?.[0],
};
} finally {
this.state.activeConnections--;
}
}
/**
* Ejecuta una consulta y retorna el primer resultado
*/
async queryOne<T = Record<string, unknown>>(
sql: string,
params: unknown[] = []
): Promise<T | null> {
const result = await this.query<T>(sql, params);
return result.rows[0] || null;
}
/**
* Ejecuta un comando (INSERT, UPDATE, DELETE)
*/
async execute(sql: string, params: unknown[] = []): Promise<number> {
const result = await this.query(sql, params);
return result.rowsAffected || 0;
}
// ============================================================================
// Manejo de Encoding
// ============================================================================
/**
* Mapea el encoding de configuracion a Firebird
*/
private mapEncodingToFirebird(encoding: string): string {
const encodingMap: Record<string, string> = {
latin1: 'ISO8859_1',
utf8: 'UTF8',
cp1252: 'WIN1252',
};
return encodingMap[encoding] || 'ISO8859_1';
}
/**
* Convierte una cadena de Latin1 a UTF-8
*/
private convertEncoding(value: unknown): unknown {
if (typeof value !== 'string') {
return value;
}
try {
if (this.config.encoding === 'latin1' || this.config.encoding === 'cp1252') {
// Crear buffer desde Latin1 y convertir a UTF-8
const buffer = Buffer.from(value, 'latin1');
return buffer.toString('utf8');
}
return value;
} catch (error) {
logger.warn('Encoding conversion error', { value, error });
return value;
}
}
/**
* Convierte encoding de un objeto
*/
private convertEncodingObject<T>(obj: T): T {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj === 'string') {
return this.convertEncoding(obj) as T;
}
if (Array.isArray(obj)) {
return obj.map(item => this.convertEncodingObject(item)) as T;
}
if (typeof obj === 'object') {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[key] = this.convertEncodingObject(value);
}
return result as T;
}
return obj;
}
/**
* Convierte encoding de un array de objetos
*/
private convertEncodingArray<T>(rows: T[]): T[] {
return rows.map(row => this.convertEncodingObject(row));
}
// ============================================================================
// Utilidades para fechas de Aspel
// ============================================================================
/**
* Convierte una fecha de Aspel a Date de JavaScript
* Aspel almacena fechas en formato YYYYMMDD como numero o YYYY-MM-DD
*/
static parseAspelDate(value: unknown): Date | null {
if (!value) return null;
try {
// Si es numero en formato YYYYMMDD
if (typeof value === 'number') {
const str = value.toString();
if (str.length === 8) {
const year = parseInt(str.substring(0, 4));
const month = parseInt(str.substring(4, 6)) - 1;
const day = parseInt(str.substring(6, 8));
return new Date(year, month, day);
}
}
// Si es string
if (typeof value === 'string') {
// Formato YYYYMMDD
if (/^\d{8}$/.test(value)) {
const year = parseInt(value.substring(0, 4));
const month = parseInt(value.substring(4, 6)) - 1;
const day = parseInt(value.substring(6, 8));
return new Date(year, month, day);
}
// Formato YYYY-MM-DD o ISO
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date;
}
}
// Si ya es Date
if (value instanceof Date) {
return value;
}
return null;
} catch {
return null;
}
}
/**
* Convierte una fecha de JavaScript a formato Aspel
*/
static formatAspelDate(date: Date): number {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return parseInt(`${year}${month}${day}`);
}
/**
* Convierte fecha/hora de Aspel
*/
static parseAspelDateTime(dateValue: unknown, timeValue?: unknown): Date | null {
const date = AspelClient.parseAspelDate(dateValue);
if (!date) return null;
if (timeValue) {
// Tiempo en formato HHMMSS
const timeStr = typeof timeValue === 'number'
? timeValue.toString().padStart(6, '0')
: String(timeValue);
if (timeStr.length >= 4) {
const hours = parseInt(timeStr.substring(0, 2));
const minutes = parseInt(timeStr.substring(2, 4));
const seconds = timeStr.length >= 6 ? parseInt(timeStr.substring(4, 6)) : 0;
date.setHours(hours, minutes, seconds);
}
}
return date;
}
// ============================================================================
// Metodos de utilidad
// ============================================================================
/**
* Obtiene el estado actual de la conexion
*/
getState(): AspelConnectionState {
return { ...this.state };
}
/**
* Obtiene la configuracion (sin password)
*/
getConfig(): Omit<AspelConfig, 'password'> {
const { password, ...configWithoutPassword } = this.config;
return configWithoutPassword;
}
/**
* Verifica si esta conectado
*/
isConnected(): boolean {
return this.state.connected;
}
/**
* Obtiene el nombre de tabla con prefijo
*/
getTableName(tableName: string): string {
return this.config.tablePrefix + tableName;
}
/**
* Escapa un identificador SQL
*/
escapeIdentifier(identifier: string): string {
if (this.config.databaseType === 'firebird') {
return `"${identifier.replace(/"/g, '""')}"`;
}
return `[${identifier.replace(/\]/g, ']]')}]`;
}
/**
* Test de conexion
*/
async testConnection(): Promise<boolean> {
try {
await this.connect();
// Query simple para verificar conexion
const testQuery = this.config.databaseType === 'firebird'
? 'SELECT 1 FROM RDB$DATABASE'
: 'SELECT 1 AS test';
await this.query(testQuery);
return true;
} catch (error) {
logger.error('Aspel connection test failed', { error });
return false;
}
}
/**
* Detecta automaticamente el tipo de base de datos
*/
static async detectDatabaseType(host: string, firebirdPort = 3050, sqlServerPort = 1433): Promise<AspelDatabaseType | null> {
// Intentar Firebird primero
try {
const net = await import('net');
const checkPort = (port: number): Promise<boolean> => {
return new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(3000);
socket.on('connect', () => {
socket.destroy();
resolve(true);
});
socket.on('error', () => {
socket.destroy();
resolve(false);
});
socket.on('timeout', () => {
socket.destroy();
resolve(false);
});
socket.connect(port, host);
});
};
const [firebirdAvailable, sqlServerAvailable] = await Promise.all([
checkPort(firebirdPort),
checkPort(sqlServerPort),
]);
if (firebirdAvailable) return 'firebird';
if (sqlServerAvailable) return 'sqlserver';
return null;
} catch {
return null;
}
}
}
// ============================================================================
// Factory function
// ============================================================================
/**
* Crea una instancia del cliente Aspel
*/
export function createAspelClient(config: AspelConfig): AspelClient {
return new AspelClient(config);
}
/**
* Crea y conecta una instancia del cliente Aspel
*/
export async function connectAspel(config: AspelConfig): Promise<AspelClient> {
const client = new AspelClient(config);
await client.connect();
return client;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,938 @@
/**
* BANCO Connector - Control Bancario Aspel
* Conector para extraer datos bancarios de Aspel BANCO
*/
import { AspelClient } from './aspel.client.js';
import {
CuentaBancaria,
MovimientoBancario,
ConciliacionBancaria,
PeriodoConsulta,
RangoFechas,
PaginacionAspel,
ResultadoPaginado,
} from './aspel.types.js';
import { logger } from '../../../utils/logger.js';
// ============================================================================
// Nombres de tablas BANCO
// ============================================================================
const BANCO_TABLES = {
CUENTAS: 'CTAS_BANCO',
MOVIMIENTOS: 'MOVS_BANCO',
CONCILIACIONES: 'CONC_BANCO',
CONCILIACION_DET: 'CONC_BANCO_DET',
BANCOS: 'BANCOS',
BENEFICIARIOS: 'BENEFICIARIOS',
CHEQUES: 'CHEQUES',
TRASPASOS: 'TRASPASOS',
CONFIGURACION: 'CONFIG_BANCO',
};
// ============================================================================
// Clase del conector BANCO
// ============================================================================
export class BANCOConnector {
private client: AspelClient;
private empresaId: number;
constructor(client: AspelClient, empresaId: number = 1) {
this.client = client;
this.empresaId = empresaId;
}
// ============================================================================
// Cuentas Bancarias
// ============================================================================
/**
* Obtiene todas las cuentas bancarias
*/
async getCuentasBancarias(options?: {
soloActivas?: boolean;
banco?: string;
moneda?: string;
}): Promise<CuentaBancaria[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (options?.soloActivas !== false) {
conditions.push('ESTATUS = ?');
params.push('A');
}
if (options?.banco) {
conditions.push('CVE_BANCO = ?');
params.push(options.banco);
}
if (options?.moneda) {
conditions.push('MONEDA = ?');
params.push(options.moneda);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const sql = `
SELECT
c.CVE_CTA,
c.NOMBRE,
c.NUM_CTA,
c.CLABE,
b.NOMBRE AS NOMBRE_BANCO,
c.TIPO_CTA,
c.MONEDA,
c.SALDO,
c.SALDO_DISP,
c.CTA_CONTABLE,
c.RFC_TITULAR,
c.NOMBRE_TIT,
c.ESTATUS,
c.FECHA_APERT
FROM ${BANCO_TABLES.CUENTAS} c
LEFT JOIN ${BANCO_TABLES.BANCOS} b ON c.CVE_BANCO = b.CVE_BANCO
${whereClause}
ORDER BY c.NOMBRE
`;
const result = await this.client.query<Record<string, unknown>>(sql, params);
return result.rows.map(row => this.mapToCuentaBancaria(row));
}
/**
* Obtiene una cuenta bancaria por clave
*/
async getCuentaBancaria(clave: string): Promise<CuentaBancaria | null> {
const sql = `
SELECT
c.CVE_CTA,
c.NOMBRE,
c.NUM_CTA,
c.CLABE,
b.NOMBRE AS NOMBRE_BANCO,
c.TIPO_CTA,
c.MONEDA,
c.SALDO,
c.SALDO_DISP,
c.CTA_CONTABLE,
c.RFC_TITULAR,
c.NOMBRE_TIT,
c.ESTATUS,
c.FECHA_APERT
FROM ${BANCO_TABLES.CUENTAS} c
LEFT JOIN ${BANCO_TABLES.BANCOS} b ON c.CVE_BANCO = b.CVE_BANCO
WHERE c.CVE_CTA = ?
`;
const row = await this.client.queryOne<Record<string, unknown>>(sql, [clave]);
return row ? this.mapToCuentaBancaria(row) : null;
}
/**
* Obtiene el saldo total de todas las cuentas
*/
async getSaldosTotales(): Promise<{
totalMXN: number;
totalUSD: number;
totalEUR: number;
porCuenta: Array<{
clave: string;
nombre: string;
moneda: string;
saldo: number;
}>;
}> {
const sql = `
SELECT
CVE_CTA,
NOMBRE,
MONEDA,
SALDO
FROM ${BANCO_TABLES.CUENTAS}
WHERE ESTATUS = 'A'
ORDER BY MONEDA, NOMBRE
`;
const result = await this.client.query<{
CVE_CTA: string;
NOMBRE: string;
MONEDA: string;
SALDO: number;
}>(sql);
let totalMXN = 0;
let totalUSD = 0;
let totalEUR = 0;
const porCuenta = result.rows.map(row => {
const saldo = Number(row.SALDO) || 0;
const moneda = String(row.MONEDA || 'MXN').toUpperCase();
if (moneda === 'MXN' || moneda === 'PESOS') {
totalMXN += saldo;
} else if (moneda === 'USD' || moneda === 'DOLARES') {
totalUSD += saldo;
} else if (moneda === 'EUR' || moneda === 'EUROS') {
totalEUR += saldo;
}
return {
clave: String(row.CVE_CTA),
nombre: String(row.NOMBRE),
moneda,
saldo,
};
});
return { totalMXN, totalUSD, totalEUR, porCuenta };
}
// ============================================================================
// Movimientos Bancarios
// ============================================================================
/**
* Obtiene movimientos bancarios por periodo
*/
async getMovimientosBancarios(
rango: RangoFechas,
options?: {
cuenta?: string;
tipo?: 'D' | 'R' | 'T' | 'C' | 'I';
soloConciliados?: boolean;
soloNoConciliados?: boolean;
}
): Promise<MovimientoBancario[]> {
const conditions = ['FECHA >= ?', 'FECHA <= ?'];
const params: unknown[] = [
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
];
if (options?.cuenta) {
conditions.push('CVE_CTA = ?');
params.push(options.cuenta);
}
if (options?.tipo) {
conditions.push('TIPO_MOV = ?');
params.push(options.tipo);
}
if (options?.soloConciliados) {
conditions.push('CONCILIADO = ?');
params.push('S');
}
if (options?.soloNoConciliados) {
conditions.push('(CONCILIADO = ? OR CONCILIADO IS NULL)');
params.push('N');
}
const sql = `
SELECT
ID_MOV,
CVE_CTA,
FECHA,
TIPO_MOV,
REFERENCIA,
NUM_CHEQUE,
BENEFICIARIO,
CONCEPTO,
DEPOSITO,
RETIRO,
SALDO,
CONCILIADO,
FECHA_CONC,
NUM_POLIZA,
TIPO_POLIZA,
UUID
FROM ${BANCO_TABLES.MOVIMIENTOS}
WHERE ${conditions.join(' AND ')}
ORDER BY FECHA, ID_MOV
`;
const result = await this.client.query<Record<string, unknown>>(sql, params);
return result.rows.map(row => this.mapToMovimientoBancario(row));
}
/**
* Obtiene movimientos bancarios paginados
*/
async getMovimientosBancariosPaginados(
rango: RangoFechas,
paginacion: PaginacionAspel,
options?: {
cuenta?: string;
tipo?: 'D' | 'R' | 'T';
}
): Promise<ResultadoPaginado<MovimientoBancario>> {
const conditions = ['FECHA >= ?', 'FECHA <= ?'];
const params: unknown[] = [
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
];
if (options?.cuenta) {
conditions.push('CVE_CTA = ?');
params.push(options.cuenta);
}
if (options?.tipo) {
conditions.push('TIPO_MOV = ?');
params.push(options.tipo);
}
// Contar total
const countSql = `
SELECT COUNT(*) AS total
FROM ${BANCO_TABLES.MOVIMIENTOS}
WHERE ${conditions.join(' AND ')}
`;
const countResult = await this.client.queryOne<{ total: number }>(countSql, params);
const total = countResult?.total || 0;
// Obtener pagina
const pagina = paginacion.pagina || 1;
const porPagina = paginacion.porPagina || 50;
const offset = (pagina - 1) * porPagina;
const sql = `
SELECT
ID_MOV,
CVE_CTA,
FECHA,
TIPO_MOV,
REFERENCIA,
NUM_CHEQUE,
BENEFICIARIO,
CONCEPTO,
DEPOSITO,
RETIRO,
SALDO,
CONCILIADO,
FECHA_CONC,
NUM_POLIZA,
TIPO_POLIZA,
UUID
FROM ${BANCO_TABLES.MOVIMIENTOS}
WHERE ${conditions.join(' AND ')}
ORDER BY FECHA DESC, ID_MOV DESC
${this.getLimitClause(porPagina, offset)}
`;
const result = await this.client.query<Record<string, unknown>>(sql, params);
return {
data: result.rows.map(row => this.mapToMovimientoBancario(row)),
total,
pagina,
porPagina,
totalPaginas: Math.ceil(total / porPagina),
};
}
/**
* Obtiene un movimiento bancario por ID
*/
async getMovimientoBancario(id: number): Promise<MovimientoBancario | null> {
const sql = `
SELECT
ID_MOV,
CVE_CTA,
FECHA,
TIPO_MOV,
REFERENCIA,
NUM_CHEQUE,
BENEFICIARIO,
CONCEPTO,
DEPOSITO,
RETIRO,
SALDO,
CONCILIADO,
FECHA_CONC,
NUM_POLIZA,
TIPO_POLIZA,
UUID
FROM ${BANCO_TABLES.MOVIMIENTOS}
WHERE ID_MOV = ?
`;
const row = await this.client.queryOne<Record<string, unknown>>(sql, [id]);
return row ? this.mapToMovimientoBancario(row) : null;
}
/**
* Obtiene resumen de movimientos por cuenta
*/
async getResumenMovimientos(
rango: RangoFechas,
cuenta: string
): Promise<{
saldoInicial: number;
depositos: number;
retiros: number;
saldoFinal: number;
movimientos: number;
}> {
// Obtener saldo inicial (ultimo saldo antes del periodo)
const saldoIniSql = `
SELECT SALDO
FROM ${BANCO_TABLES.MOVIMIENTOS}
WHERE CVE_CTA = ? AND FECHA < ?
ORDER BY FECHA DESC, ID_MOV DESC
${this.getLimitClause(1, 0)}
`;
const saldoIniResult = await this.client.queryOne<{ SALDO: number }>(saldoIniSql, [
cuenta,
AspelClient.formatAspelDate(rango.fechaInicial),
]);
// Obtener resumen del periodo
const resumenSql = `
SELECT
COALESCE(SUM(DEPOSITO), 0) AS DEPOSITOS,
COALESCE(SUM(RETIRO), 0) AS RETIROS,
COUNT(*) AS MOVIMIENTOS
FROM ${BANCO_TABLES.MOVIMIENTOS}
WHERE CVE_CTA = ? AND FECHA >= ? AND FECHA <= ?
`;
const resumenResult = await this.client.queryOne<{
DEPOSITOS: number;
RETIROS: number;
MOVIMIENTOS: number;
}>(resumenSql, [
cuenta,
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
]);
const saldoInicial = saldoIniResult?.SALDO || 0;
const depositos = resumenResult?.DEPOSITOS || 0;
const retiros = resumenResult?.RETIROS || 0;
return {
saldoInicial,
depositos,
retiros,
saldoFinal: saldoInicial + depositos - retiros,
movimientos: resumenResult?.MOVIMIENTOS || 0,
};
}
// ============================================================================
// Conciliaciones Bancarias
// ============================================================================
/**
* Obtiene las conciliaciones bancarias
*/
async getConciliaciones(options?: {
cuenta?: string;
ejercicio?: number;
periodo?: number;
}): Promise<ConciliacionBancaria[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (options?.cuenta) {
conditions.push('CVE_CTA = ?');
params.push(options.cuenta);
}
if (options?.ejercicio) {
conditions.push('EJERCICIO = ?');
params.push(options.ejercicio);
}
if (options?.periodo) {
conditions.push('PERIODO = ?');
params.push(options.periodo);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const sql = `
SELECT
ID_CONC,
CVE_CTA,
PERIODO,
EJERCICIO,
FECHA_CORTE,
SALDO_LIBROS,
SALDO_BANCO,
DEP_TRANSITO,
CHQ_TRANSITO,
CARGOS_NO_REG,
ABONOS_NO_REG,
DIFERENCIA,
CUADRADA,
FECHA_ELAB
FROM ${BANCO_TABLES.CONCILIACIONES}
${whereClause}
ORDER BY EJERCICIO DESC, PERIODO DESC
`;
const result = await this.client.query<Record<string, unknown>>(sql, params);
const conciliaciones: ConciliacionBancaria[] = [];
for (const row of result.rows) {
const conciliacion = this.mapToConciliacionBancaria(row);
// Obtener movimientos pendientes
conciliacion.movimientosPendientes = await this.getMovimientosPendientesConciliacion(
conciliacion.claveCuenta,
conciliacion.periodo,
conciliacion.ejercicio
);
conciliaciones.push(conciliacion);
}
return conciliaciones;
}
/**
* Obtiene una conciliacion especifica
*/
async getConciliacion(
cuenta: string,
periodo: number,
ejercicio: number
): Promise<ConciliacionBancaria | null> {
const sql = `
SELECT
ID_CONC,
CVE_CTA,
PERIODO,
EJERCICIO,
FECHA_CORTE,
SALDO_LIBROS,
SALDO_BANCO,
DEP_TRANSITO,
CHQ_TRANSITO,
CARGOS_NO_REG,
ABONOS_NO_REG,
DIFERENCIA,
CUADRADA,
FECHA_ELAB
FROM ${BANCO_TABLES.CONCILIACIONES}
WHERE CVE_CTA = ? AND PERIODO = ? AND EJERCICIO = ?
`;
const row = await this.client.queryOne<Record<string, unknown>>(sql, [
cuenta,
periodo,
ejercicio,
]);
if (!row) return null;
const conciliacion = this.mapToConciliacionBancaria(row);
// Obtener movimientos pendientes
conciliacion.movimientosPendientes = await this.getMovimientosPendientesConciliacion(
cuenta,
periodo,
ejercicio
);
return conciliacion;
}
/**
* Obtiene movimientos pendientes de conciliar
*/
async getMovimientosPendientesConciliacion(
cuenta: string,
periodo: number,
ejercicio: number
): Promise<MovimientoBancario[]> {
// Calcular rango de fechas del periodo
const fechaInicio = new Date(ejercicio, periodo - 1, 1);
const fechaFin = new Date(ejercicio, periodo, 0); // Ultimo dia del mes
const sql = `
SELECT
ID_MOV,
CVE_CTA,
FECHA,
TIPO_MOV,
REFERENCIA,
NUM_CHEQUE,
BENEFICIARIO,
CONCEPTO,
DEPOSITO,
RETIRO,
SALDO,
CONCILIADO,
FECHA_CONC,
NUM_POLIZA,
TIPO_POLIZA,
UUID
FROM ${BANCO_TABLES.MOVIMIENTOS}
WHERE CVE_CTA = ?
AND FECHA <= ?
AND (CONCILIADO = 'N' OR CONCILIADO IS NULL)
ORDER BY FECHA, ID_MOV
`;
const result = await this.client.query<Record<string, unknown>>(sql, [
cuenta,
AspelClient.formatAspelDate(fechaFin),
]);
return result.rows.map(row => this.mapToMovimientoBancario(row));
}
/**
* Obtiene el estado de conciliacion de una cuenta
*/
async getEstadoConciliacion(cuenta: string): Promise<{
movimientosPendientes: number;
depositosPendientes: number;
retirosPendientes: number;
totalPendiente: number;
ultimaConciliacion?: Date;
}> {
// Movimientos pendientes
const pendientesSql = `
SELECT
COUNT(*) AS NUM_MOVS,
COALESCE(SUM(DEPOSITO), 0) AS DEPOSITOS,
COALESCE(SUM(RETIRO), 0) AS RETIROS
FROM ${BANCO_TABLES.MOVIMIENTOS}
WHERE CVE_CTA = ? AND (CONCILIADO = 'N' OR CONCILIADO IS NULL)
`;
const pendientesResult = await this.client.queryOne<{
NUM_MOVS: number;
DEPOSITOS: number;
RETIROS: number;
}>(pendientesSql, [cuenta]);
// Ultima conciliacion
const ultimaConcSql = `
SELECT MAX(FECHA_ELAB) AS ULTIMA_CONC
FROM ${BANCO_TABLES.CONCILIACIONES}
WHERE CVE_CTA = ?
`;
const ultimaConcResult = await this.client.queryOne<{ ULTIMA_CONC: unknown }>(
ultimaConcSql,
[cuenta]
);
const depositos = pendientesResult?.DEPOSITOS || 0;
const retiros = pendientesResult?.RETIROS || 0;
return {
movimientosPendientes: pendientesResult?.NUM_MOVS || 0,
depositosPendientes: depositos,
retirosPendientes: retiros,
totalPendiente: depositos - retiros,
ultimaConciliacion: AspelClient.parseAspelDate(ultimaConcResult?.ULTIMA_CONC) || undefined,
};
}
// ============================================================================
// Cheques
// ============================================================================
/**
* Obtiene cheques emitidos
*/
async getCheques(
rango: RangoFechas,
options?: {
cuenta?: string;
estatus?: 'E' | 'C' | 'P'; // Emitido, Cancelado, Pendiente
}
): Promise<Array<{
numero: string;
cuenta: string;
fecha: Date;
beneficiario: string;
importe: number;
concepto: string;
estatus: string;
fechaCobro?: Date;
}>> {
const conditions = ['FECHA >= ?', 'FECHA <= ?'];
const params: unknown[] = [
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
];
if (options?.cuenta) {
conditions.push('CVE_CTA = ?');
params.push(options.cuenta);
}
if (options?.estatus) {
conditions.push('ESTATUS = ?');
params.push(options.estatus);
}
const sql = `
SELECT
NUM_CHEQUE,
CVE_CTA,
FECHA,
BENEFICIARIO,
IMPORTE,
CONCEPTO,
ESTATUS,
FECHA_COBRO
FROM ${BANCO_TABLES.CHEQUES}
WHERE ${conditions.join(' AND ')}
ORDER BY FECHA, NUM_CHEQUE
`;
const result = await this.client.query<Record<string, unknown>>(sql, params);
return result.rows.map(row => ({
numero: String(row.NUM_CHEQUE || ''),
cuenta: String(row.CVE_CTA || ''),
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
beneficiario: String(row.BENEFICIARIO || ''),
importe: Number(row.IMPORTE) || 0,
concepto: String(row.CONCEPTO || ''),
estatus: String(row.ESTATUS || 'E'),
fechaCobro: AspelClient.parseAspelDate(row.FECHA_COBRO) || undefined,
}));
}
// ============================================================================
// Traspasos
// ============================================================================
/**
* Obtiene traspasos entre cuentas
*/
async getTraspasos(rango: RangoFechas): Promise<Array<{
id: number;
fecha: Date;
cuentaOrigen: string;
cuentaDestino: string;
importe: number;
concepto: string;
referencia?: string;
}>> {
const sql = `
SELECT
ID_TRASPASO,
FECHA,
CTA_ORIGEN,
CTA_DESTINO,
IMPORTE,
CONCEPTO,
REFERENCIA
FROM ${BANCO_TABLES.TRASPASOS}
WHERE FECHA >= ? AND FECHA <= ?
ORDER BY FECHA
`;
const result = await this.client.query<Record<string, unknown>>(sql, [
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
]);
return result.rows.map(row => ({
id: Number(row.ID_TRASPASO) || 0,
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
cuentaOrigen: String(row.CTA_ORIGEN || ''),
cuentaDestino: String(row.CTA_DESTINO || ''),
importe: Number(row.IMPORTE) || 0,
concepto: String(row.CONCEPTO || ''),
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
}));
}
// ============================================================================
// Reportes
// ============================================================================
/**
* Obtiene el flujo de efectivo por periodo
*/
async getFlujoEfectivo(
rango: RangoFechas,
agrupacion: 'dia' | 'semana' | 'mes' = 'dia'
): Promise<Array<{
fecha: Date;
depositos: number;
retiros: number;
neto: number;
saldo: number;
}>> {
let dateFormat: string;
const state = this.client.getState();
if (state.databaseType === 'firebird') {
switch (agrupacion) {
case 'semana':
dateFormat = "EXTRACT(YEAR FROM FECHA) || '-' || LPAD(EXTRACT(WEEK FROM FECHA), 2, '0')";
break;
case 'mes':
dateFormat = "EXTRACT(YEAR FROM FECHA) || '-' || LPAD(EXTRACT(MONTH FROM FECHA), 2, '0')";
break;
default:
dateFormat = 'FECHA';
}
} else {
switch (agrupacion) {
case 'semana':
dateFormat = "FORMAT(FECHA, 'yyyy-ww')";
break;
case 'mes':
dateFormat = "FORMAT(FECHA, 'yyyy-MM')";
break;
default:
dateFormat = 'CAST(FECHA AS DATE)';
}
}
const sql = `
SELECT
${agrupacion === 'dia' ? 'FECHA' : `MIN(FECHA) AS FECHA`},
SUM(DEPOSITO) AS DEPOSITOS,
SUM(RETIRO) AS RETIROS
FROM ${BANCO_TABLES.MOVIMIENTOS}
WHERE FECHA >= ? AND FECHA <= ?
${agrupacion !== 'dia' ? `GROUP BY ${dateFormat}` : ''}
ORDER BY FECHA
`;
const result = await this.client.query<{
FECHA: unknown;
DEPOSITOS: number;
RETIROS: number;
}>(sql, [
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
]);
// Calcular saldos acumulados
let saldoAcumulado = 0;
return result.rows.map(row => {
const depositos = Number(row.DEPOSITOS) || 0;
const retiros = Number(row.RETIROS) || 0;
const neto = depositos - retiros;
saldoAcumulado += neto;
return {
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
depositos,
retiros,
neto,
saldo: saldoAcumulado,
};
});
}
// ============================================================================
// Utilidades
// ============================================================================
/**
* Obtiene clausula LIMIT/OFFSET segun tipo de BD
*/
private getLimitClause(limit: number, offset: number): string {
const state = this.client.getState();
if (state.databaseType === 'firebird') {
return `ROWS ${offset + 1} TO ${offset + limit}`;
}
return `OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`;
}
// ============================================================================
// Mappers
// ============================================================================
private mapToCuentaBancaria(row: Record<string, unknown>): CuentaBancaria {
return {
clave: String(row.CVE_CTA || ''),
nombre: String(row.NOMBRE || ''),
numeroCuenta: String(row.NUM_CTA || ''),
clabe: row.CLABE ? String(row.CLABE) : undefined,
banco: String(row.NOMBRE_BANCO || ''),
tipoCuenta: String(row.TIPO_CTA || 'C') as CuentaBancaria['tipoCuenta'],
moneda: String(row.MONEDA || 'MXN'),
saldo: Number(row.SALDO) || 0,
saldoDisponible: Number(row.SALDO_DISP) || 0,
cuentaContable: row.CTA_CONTABLE ? String(row.CTA_CONTABLE) : undefined,
rfcTitular: row.RFC_TITULAR ? String(row.RFC_TITULAR) : undefined,
nombreTitular: row.NOMBRE_TIT ? String(row.NOMBRE_TIT) : undefined,
activa: row.ESTATUS === 'A',
fechaApertura: AspelClient.parseAspelDate(row.FECHA_APERT) || undefined,
};
}
private mapToMovimientoBancario(row: Record<string, unknown>): MovimientoBancario {
return {
id: Number(row.ID_MOV) || 0,
claveCuenta: String(row.CVE_CTA || ''),
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
tipo: String(row.TIPO_MOV || 'D') as MovimientoBancario['tipo'],
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
numeroCheque: row.NUM_CHEQUE ? String(row.NUM_CHEQUE) : undefined,
beneficiario: row.BENEFICIARIO ? String(row.BENEFICIARIO) : undefined,
concepto: String(row.CONCEPTO || ''),
deposito: Number(row.DEPOSITO) || 0,
retiro: Number(row.RETIRO) || 0,
saldo: Number(row.SALDO) || 0,
conciliado: row.CONCILIADO === 'S',
fechaConciliacion: AspelClient.parseAspelDate(row.FECHA_CONC) || undefined,
numeroPoliza: row.NUM_POLIZA ? Number(row.NUM_POLIZA) : undefined,
tipoPoliza: row.TIPO_POLIZA ? String(row.TIPO_POLIZA) : undefined,
uuid: row.UUID ? String(row.UUID) : undefined,
};
}
private mapToConciliacionBancaria(row: Record<string, unknown>): ConciliacionBancaria {
const saldoLibros = Number(row.SALDO_LIBROS) || 0;
const saldoBanco = Number(row.SALDO_BANCO) || 0;
const diferencia = Number(row.DIFERENCIA) || 0;
return {
id: Number(row.ID_CONC) || 0,
claveCuenta: String(row.CVE_CTA || ''),
periodo: Number(row.PERIODO) || 0,
ejercicio: Number(row.EJERCICIO) || 0,
fechaCorte: AspelClient.parseAspelDate(row.FECHA_CORTE) || new Date(),
saldoLibros,
saldoBanco,
depositosTransito: Number(row.DEP_TRANSITO) || 0,
chequesTransito: Number(row.CHQ_TRANSITO) || 0,
cargosNoRegistrados: Number(row.CARGOS_NO_REG) || 0,
abonosNoRegistrados: Number(row.ABONOS_NO_REG) || 0,
diferencia,
cuadrada: row.CUADRADA === 'S' || Math.abs(diferencia) < 0.01,
fechaElaboracion: AspelClient.parseAspelDate(row.FECHA_ELAB) || new Date(),
movimientosPendientes: [],
};
}
}
// ============================================================================
// Factory function
// ============================================================================
export function createBANCOConnector(client: AspelClient, empresaId?: number): BANCOConnector {
return new BANCOConnector(client, empresaId);
}

View File

@@ -0,0 +1,809 @@
/**
* COI Connector - Contabilidad Integral Aspel
* Conector para extraer datos contables de Aspel COI
*/
import { AspelClient } from './aspel.client.js';
import {
CuentaContable,
Poliza,
MovimientoContable,
Balanza,
SaldoCuenta,
MovimientoAuxiliar,
PeriodoConsulta,
RangoFechas,
PaginacionAspel,
ResultadoPaginado,
AspelQueryError,
} from './aspel.types.js';
import { logger } from '../../../utils/logger.js';
// ============================================================================
// Nombres de tablas COI
// ============================================================================
const COI_TABLES = {
CUENTAS: 'CUENTAS',
POLIZAS: 'POLIZAS',
PARTIDAS: 'PARTIDAS',
SALDOS: 'SALDOS',
PERIODOS: 'PERIODOS',
CONFIGURACION: 'CONFIGURACION',
DEPARTAMENTOS: 'DEPARTAMENTOS',
CUENTAS_SAT: 'CUENTAS_SAT',
};
// ============================================================================
// Clase del conector COI
// ============================================================================
export class COIConnector {
private client: AspelClient;
private empresaId: number;
constructor(client: AspelClient, empresaId: number = 1) {
this.client = client;
this.empresaId = empresaId;
}
// ============================================================================
// Catalogo de Cuentas
// ============================================================================
/**
* Obtiene el catalogo completo de cuentas contables
*/
async getCatalogoCuentas(options?: {
soloActivas?: boolean;
nivel?: number;
tipoCuenta?: 'A' | 'D';
}): Promise<CuentaContable[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (options?.soloActivas) {
conditions.push('ESTATUS = ?');
params.push('A');
}
if (options?.nivel !== undefined) {
conditions.push('NIVEL = ?');
params.push(options.nivel);
}
if (options?.tipoCuenta) {
conditions.push('TIPO_CTA = ?');
params.push(options.tipoCuenta);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const sql = `
SELECT
NUM_CTA,
NOMBRE,
TIPO_CTA,
NATURALEZA,
NIVEL,
CTA_PADRE,
ES_BANCO,
ES_IVA,
ES_ISR,
ESTATUS,
CODIGO_SAT,
FECHA_ALTA,
FECHA_ULT_MOV
FROM ${COI_TABLES.CUENTAS}
${whereClause}
ORDER BY NUM_CTA
`;
const result = await this.client.query<Record<string, unknown>>(sql, params);
return result.rows.map(row => this.mapToCuentaContable(row));
}
/**
* Obtiene una cuenta contable por numero
*/
async getCuenta(numeroCuenta: string): Promise<CuentaContable | null> {
const sql = `
SELECT
NUM_CTA,
NOMBRE,
TIPO_CTA,
NATURALEZA,
NIVEL,
CTA_PADRE,
ES_BANCO,
ES_IVA,
ES_ISR,
ESTATUS,
CODIGO_SAT,
FECHA_ALTA,
FECHA_ULT_MOV
FROM ${COI_TABLES.CUENTAS}
WHERE NUM_CTA = ?
`;
const row = await this.client.queryOne<Record<string, unknown>>(sql, [numeroCuenta]);
return row ? this.mapToCuentaContable(row) : null;
}
/**
* Obtiene las subcuentas de una cuenta
*/
async getSubcuentas(cuentaPadre: string): Promise<CuentaContable[]> {
const sql = `
SELECT
NUM_CTA,
NOMBRE,
TIPO_CTA,
NATURALEZA,
NIVEL,
CTA_PADRE,
ES_BANCO,
ES_IVA,
ES_ISR,
ESTATUS,
CODIGO_SAT,
FECHA_ALTA,
FECHA_ULT_MOV
FROM ${COI_TABLES.CUENTAS}
WHERE CTA_PADRE = ?
ORDER BY NUM_CTA
`;
const result = await this.client.query<Record<string, unknown>>(sql, [cuentaPadre]);
return result.rows.map(row => this.mapToCuentaContable(row));
}
// ============================================================================
// Polizas
// ============================================================================
/**
* Obtiene polizas de un periodo
*/
async getPolizas(
periodo: PeriodoConsulta,
options?: {
tipo?: string;
conMovimientos?: boolean;
}
): Promise<Poliza[]> {
const conditions = ['PERIODO = ?', 'EJERCICIO = ?'];
const params: unknown[] = [periodo.periodo, periodo.ejercicio];
if (options?.tipo) {
conditions.push('TIPO_POLI = ?');
params.push(options.tipo);
}
const sql = `
SELECT
ID_POLIZA,
TIPO_POLI,
NUM_POLIZ,
FECHA,
CONCEPTO,
PERIODO,
EJERCICIO,
TOTAL_CARGOS,
TOTAL_ABONOS,
REFERENCIA,
FECHA_CAPTURA,
USUARIO,
UUID_CFDI
FROM ${COI_TABLES.POLIZAS}
WHERE ${conditions.join(' AND ')}
ORDER BY TIPO_POLI, NUM_POLIZ
`;
const result = await this.client.query<Record<string, unknown>>(sql, params);
const polizas: Poliza[] = [];
for (const row of result.rows) {
const poliza = this.mapToPoliza(row);
if (options?.conMovimientos) {
poliza.movimientos = await this.getMovimientosPoliza(poliza.id);
}
polizas.push(poliza);
}
return polizas;
}
/**
* Obtiene polizas por rango de fechas
*/
async getPolizasByFecha(
rango: RangoFechas,
options?: {
tipo?: string;
conMovimientos?: boolean;
}
): Promise<Poliza[]> {
const conditions = ['FECHA >= ?', 'FECHA <= ?'];
const params: unknown[] = [
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
];
if (options?.tipo) {
conditions.push('TIPO_POLI = ?');
params.push(options.tipo);
}
const sql = `
SELECT
ID_POLIZA,
TIPO_POLI,
NUM_POLIZ,
FECHA,
CONCEPTO,
PERIODO,
EJERCICIO,
TOTAL_CARGOS,
TOTAL_ABONOS,
REFERENCIA,
FECHA_CAPTURA,
USUARIO,
UUID_CFDI
FROM ${COI_TABLES.POLIZAS}
WHERE ${conditions.join(' AND ')}
ORDER BY FECHA, TIPO_POLI, NUM_POLIZ
`;
const result = await this.client.query<Record<string, unknown>>(sql, params);
const polizas: Poliza[] = [];
for (const row of result.rows) {
const poliza = this.mapToPoliza(row);
if (options?.conMovimientos) {
poliza.movimientos = await this.getMovimientosPoliza(poliza.id);
}
polizas.push(poliza);
}
return polizas;
}
/**
* Obtiene una poliza especifica
*/
async getPoliza(
tipo: string,
numero: number,
periodo: PeriodoConsulta
): Promise<Poliza | null> {
const sql = `
SELECT
ID_POLIZA,
TIPO_POLI,
NUM_POLIZ,
FECHA,
CONCEPTO,
PERIODO,
EJERCICIO,
TOTAL_CARGOS,
TOTAL_ABONOS,
REFERENCIA,
FECHA_CAPTURA,
USUARIO,
UUID_CFDI
FROM ${COI_TABLES.POLIZAS}
WHERE TIPO_POLI = ? AND NUM_POLIZ = ? AND PERIODO = ? AND EJERCICIO = ?
`;
const row = await this.client.queryOne<Record<string, unknown>>(sql, [
tipo,
numero,
periodo.periodo,
periodo.ejercicio,
]);
if (!row) return null;
const poliza = this.mapToPoliza(row);
poliza.movimientos = await this.getMovimientosPoliza(poliza.id);
return poliza;
}
/**
* Obtiene los movimientos de una poliza
*/
async getMovimientosPoliza(idPoliza: number): Promise<MovimientoContable[]> {
const sql = `
SELECT
p.NUM_CTA,
c.NOMBRE AS NOMBRE_CTA,
p.CONCEPTO,
p.CARGO,
p.ABONO,
p.NUM_CHEQUE,
p.REFERENCIA,
p.UUID_CFDI,
p.DEPARTAMENTO,
p.TIPO_CAMBIO,
p.MONEDA
FROM ${COI_TABLES.PARTIDAS} p
LEFT JOIN ${COI_TABLES.CUENTAS} c ON p.NUM_CTA = c.NUM_CTA
WHERE p.ID_POLIZA = ?
ORDER BY p.NUM_PARTIDA
`;
const result = await this.client.query<Record<string, unknown>>(sql, [idPoliza]);
return result.rows.map(row => this.mapToMovimientoContable(row));
}
// ============================================================================
// Balanza de Comprobacion
// ============================================================================
/**
* Obtiene la balanza de comprobacion de un periodo
*/
async getBalanza(periodo: PeriodoConsulta): Promise<Balanza> {
const sql = `
SELECT
s.NUM_CTA,
c.NOMBRE,
s.SALDO_INI,
s.CARGOS,
s.ABONOS,
s.SALDO_FIN
FROM ${COI_TABLES.SALDOS} s
JOIN ${COI_TABLES.CUENTAS} c ON s.NUM_CTA = c.NUM_CTA
WHERE s.PERIODO = ? AND s.EJERCICIO = ? AND c.TIPO_CTA = 'D'
ORDER BY s.NUM_CTA
`;
const result = await this.client.query<Record<string, unknown>>(sql, [
periodo.periodo,
periodo.ejercicio,
]);
const cuentas = result.rows.map(row => this.mapToSaldoCuenta(row, periodo));
const totalCargos = cuentas.reduce((sum, c) => sum + c.totalCargos, 0);
const totalAbonos = cuentas.reduce((sum, c) => sum + c.totalAbonos, 0);
return {
periodo: periodo.periodo,
ejercicio: periodo.ejercicio,
fechaGeneracion: new Date(),
cuentas,
totalCargos,
totalAbonos,
cuadrada: Math.abs(totalCargos - totalAbonos) < 0.01,
};
}
/**
* Obtiene balanza de comprobacion con saldos iniciales y acumulados
*/
async getBalanzaCompleta(periodo: PeriodoConsulta): Promise<Balanza> {
// Obtener saldos iniciales (acumulado hasta periodo anterior)
const sqlSaldosIni = `
SELECT
NUM_CTA,
SUM(SALDO_FIN) AS SALDO_ACUM
FROM ${COI_TABLES.SALDOS}
WHERE EJERCICIO = ? AND PERIODO < ?
GROUP BY NUM_CTA
`;
const saldosIniResult = await this.client.query<{
NUM_CTA: string;
SALDO_ACUM: number;
}>(sqlSaldosIni, [periodo.ejercicio, periodo.periodo]);
const saldosIniMap = new Map<string, number>();
for (const row of saldosIniResult.rows) {
saldosIniMap.set(row.NUM_CTA, row.SALDO_ACUM);
}
// Obtener movimientos del periodo
const balanza = await this.getBalanza(periodo);
// Ajustar saldos iniciales
for (const cuenta of balanza.cuentas) {
const saldoAcumulado = saldosIniMap.get(cuenta.numeroCuenta) || 0;
cuenta.saldoInicial += saldoAcumulado;
cuenta.saldoFinal = cuenta.saldoInicial + cuenta.totalCargos - cuenta.totalAbonos;
}
return balanza;
}
// ============================================================================
// Auxiliares
// ============================================================================
/**
* Obtiene el auxiliar de una cuenta
*/
async getAuxiliares(
numeroCuenta: string,
periodo: PeriodoConsulta
): Promise<MovimientoAuxiliar[]> {
const sql = `
SELECT
pol.FECHA,
pol.TIPO_POLI,
pol.NUM_POLIZ,
par.CONCEPTO,
par.CARGO,
par.ABONO,
par.REFERENCIA
FROM ${COI_TABLES.PARTIDAS} par
JOIN ${COI_TABLES.POLIZAS} pol ON par.ID_POLIZA = pol.ID_POLIZA
WHERE par.NUM_CTA = ? AND pol.PERIODO = ? AND pol.EJERCICIO = ?
ORDER BY pol.FECHA, pol.TIPO_POLI, pol.NUM_POLIZ
`;
const result = await this.client.query<Record<string, unknown>>(sql, [
numeroCuenta,
periodo.periodo,
periodo.ejercicio,
]);
// Calcular saldo acumulado
let saldo = 0;
// Obtener saldo inicial
const saldoIni = await this.getSaldoInicial(numeroCuenta, periodo);
saldo = saldoIni;
return result.rows.map(row => {
const cargo = Number(row.CARGO) || 0;
const abono = Number(row.ABONO) || 0;
saldo = saldo + cargo - abono;
return {
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
tipoPoliza: String(row.TIPO_POLI || ''),
numeroPoliza: Number(row.NUM_POLIZ) || 0,
concepto: String(row.CONCEPTO || ''),
cargo,
abono,
saldo,
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
};
});
}
/**
* Obtiene el auxiliar por rango de fechas
*/
async getAuxiliaresByFecha(
numeroCuenta: string,
rango: RangoFechas
): Promise<MovimientoAuxiliar[]> {
const sql = `
SELECT
pol.FECHA,
pol.TIPO_POLI,
pol.NUM_POLIZ,
par.CONCEPTO,
par.CARGO,
par.ABONO,
par.REFERENCIA
FROM ${COI_TABLES.PARTIDAS} par
JOIN ${COI_TABLES.POLIZAS} pol ON par.ID_POLIZA = pol.ID_POLIZA
WHERE par.NUM_CTA = ? AND pol.FECHA >= ? AND pol.FECHA <= ?
ORDER BY pol.FECHA, pol.TIPO_POLI, pol.NUM_POLIZ
`;
const result = await this.client.query<Record<string, unknown>>(sql, [
numeroCuenta,
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
]);
let saldo = 0;
return result.rows.map(row => {
const cargo = Number(row.CARGO) || 0;
const abono = Number(row.ABONO) || 0;
saldo = saldo + cargo - abono;
return {
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
tipoPoliza: String(row.TIPO_POLI || ''),
numeroPoliza: Number(row.NUM_POLIZ) || 0,
concepto: String(row.CONCEPTO || ''),
cargo,
abono,
saldo,
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
};
});
}
/**
* Obtiene el saldo inicial de una cuenta para un periodo
*/
private async getSaldoInicial(
numeroCuenta: string,
periodo: PeriodoConsulta
): Promise<number> {
const sql = `
SELECT COALESCE(SUM(SALDO_FIN), 0) AS SALDO
FROM ${COI_TABLES.SALDOS}
WHERE NUM_CTA = ? AND EJERCICIO = ? AND PERIODO < ?
`;
const result = await this.client.queryOne<{ SALDO: number }>(sql, [
numeroCuenta,
periodo.ejercicio,
periodo.periodo,
]);
return result?.SALDO || 0;
}
// ============================================================================
// Diario General
// ============================================================================
/**
* Obtiene el diario general de un periodo
*/
async getDiarioGeneral(
periodo: PeriodoConsulta,
paginacion?: PaginacionAspel
): Promise<ResultadoPaginado<Poliza>> {
// Contar total de polizas
const countSql = `
SELECT COUNT(*) AS total
FROM ${COI_TABLES.POLIZAS}
WHERE PERIODO = ? AND EJERCICIO = ?
`;
const countResult = await this.client.queryOne<{ total: number }>(countSql, [
periodo.periodo,
periodo.ejercicio,
]);
const total = countResult?.total || 0;
// Obtener polizas con paginacion
const pagina = paginacion?.pagina || 1;
const porPagina = paginacion?.porPagina || 50;
const offset = (pagina - 1) * porPagina;
const orderBy = paginacion?.ordenarPor || 'FECHA';
const direccion = paginacion?.direccion || 'ASC';
const sql = `
SELECT
ID_POLIZA,
TIPO_POLI,
NUM_POLIZ,
FECHA,
CONCEPTO,
PERIODO,
EJERCICIO,
TOTAL_CARGOS,
TOTAL_ABONOS,
REFERENCIA,
FECHA_CAPTURA,
USUARIO,
UUID_CFDI
FROM ${COI_TABLES.POLIZAS}
WHERE PERIODO = ? AND EJERCICIO = ?
ORDER BY ${orderBy} ${direccion}
${this.getLimitClause(porPagina, offset)}
`;
const result = await this.client.query<Record<string, unknown>>(sql, [
periodo.periodo,
periodo.ejercicio,
]);
const polizas: Poliza[] = [];
for (const row of result.rows) {
const poliza = this.mapToPoliza(row);
poliza.movimientos = await this.getMovimientosPoliza(poliza.id);
polizas.push(poliza);
}
return {
data: polizas,
total,
pagina,
porPagina,
totalPaginas: Math.ceil(total / porPagina),
};
}
/**
* Obtiene diario general por rango de fechas
*/
async getDiarioGeneralByFecha(
rango: RangoFechas,
paginacion?: PaginacionAspel
): Promise<ResultadoPaginado<Poliza>> {
// Contar total de polizas
const countSql = `
SELECT COUNT(*) AS total
FROM ${COI_TABLES.POLIZAS}
WHERE FECHA >= ? AND FECHA <= ?
`;
const countResult = await this.client.queryOne<{ total: number }>(countSql, [
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
]);
const total = countResult?.total || 0;
// Obtener polizas con paginacion
const pagina = paginacion?.pagina || 1;
const porPagina = paginacion?.porPagina || 50;
const offset = (pagina - 1) * porPagina;
const sql = `
SELECT
ID_POLIZA,
TIPO_POLI,
NUM_POLIZ,
FECHA,
CONCEPTO,
PERIODO,
EJERCICIO,
TOTAL_CARGOS,
TOTAL_ABONOS,
REFERENCIA,
FECHA_CAPTURA,
USUARIO,
UUID_CFDI
FROM ${COI_TABLES.POLIZAS}
WHERE FECHA >= ? AND FECHA <= ?
ORDER BY FECHA, TIPO_POLI, NUM_POLIZ
${this.getLimitClause(porPagina, offset)}
`;
const result = await this.client.query<Record<string, unknown>>(sql, [
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
]);
const polizas: Poliza[] = [];
for (const row of result.rows) {
const poliza = this.mapToPoliza(row);
poliza.movimientos = await this.getMovimientosPoliza(poliza.id);
polizas.push(poliza);
}
return {
data: polizas,
total,
pagina,
porPagina,
totalPaginas: Math.ceil(total / porPagina),
};
}
// ============================================================================
// Utilidades
// ============================================================================
/**
* Obtiene clausula LIMIT/OFFSET segun tipo de BD
*/
private getLimitClause(limit: number, offset: number): string {
const state = this.client.getState();
if (state.databaseType === 'firebird') {
return `ROWS ${offset + 1} TO ${offset + limit}`;
}
return `OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`;
}
/**
* Mapea fila de BD a CuentaContable
*/
private mapToCuentaContable(row: Record<string, unknown>): CuentaContable {
return {
numeroCuenta: String(row.NUM_CTA || ''),
nombre: String(row.NOMBRE || ''),
tipo: (String(row.TIPO_CTA || 'D') as 'A' | 'D'),
naturaleza: (String(row.NATURALEZA || 'D') as 'D' | 'A'),
nivel: Number(row.NIVEL) || 1,
cuentaPadre: row.CTA_PADRE ? String(row.CTA_PADRE) : undefined,
esBanco: row.ES_BANCO === 'S' || row.ES_BANCO === true || row.ES_BANCO === 1,
esIva: row.ES_IVA === 'S' || row.ES_IVA === true || row.ES_IVA === 1,
esIsr: row.ES_ISR === 'S' || row.ES_ISR === true || row.ES_ISR === 1,
activa: row.ESTATUS === 'A' || row.ESTATUS === true || row.ESTATUS === 1,
codigoSat: row.CODIGO_SAT ? String(row.CODIGO_SAT) : undefined,
fechaCreacion: AspelClient.parseAspelDate(row.FECHA_ALTA) || undefined,
ultimoMovimiento: AspelClient.parseAspelDate(row.FECHA_ULT_MOV) || undefined,
};
}
/**
* Mapea fila de BD a Poliza
*/
private mapToPoliza(row: Record<string, unknown>): Poliza {
const totalCargos = Number(row.TOTAL_CARGOS) || 0;
const totalAbonos = Number(row.TOTAL_ABONOS) || 0;
return {
id: Number(row.ID_POLIZA) || 0,
tipo: String(row.TIPO_POLI || 'D') as Poliza['tipo'],
numero: Number(row.NUM_POLIZ) || 0,
fecha: AspelClient.parseAspelDate(row.FECHA) || new Date(),
concepto: String(row.CONCEPTO || ''),
periodo: Number(row.PERIODO) || 0,
ejercicio: Number(row.EJERCICIO) || 0,
totalCargos,
totalAbonos,
cuadrada: Math.abs(totalCargos - totalAbonos) < 0.01,
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
movimientos: [],
fechaCaptura: AspelClient.parseAspelDate(row.FECHA_CAPTURA) || undefined,
usuarioCaptura: row.USUARIO ? String(row.USUARIO) : undefined,
uuidCfdi: row.UUID_CFDI ? String(row.UUID_CFDI) : undefined,
};
}
/**
* Mapea fila de BD a MovimientoContable
*/
private mapToMovimientoContable(row: Record<string, unknown>): MovimientoContable {
return {
numeroCuenta: String(row.NUM_CTA || ''),
nombreCuenta: String(row.NOMBRE_CTA || ''),
concepto: String(row.CONCEPTO || ''),
cargo: Number(row.CARGO) || 0,
abono: Number(row.ABONO) || 0,
numeroCheque: row.NUM_CHEQUE ? String(row.NUM_CHEQUE) : undefined,
referencia: row.REFERENCIA ? String(row.REFERENCIA) : undefined,
uuidCfdi: row.UUID_CFDI ? String(row.UUID_CFDI) : undefined,
departamento: row.DEPARTAMENTO ? String(row.DEPARTAMENTO) : undefined,
tipoCambio: row.TIPO_CAMBIO ? Number(row.TIPO_CAMBIO) : undefined,
moneda: row.MONEDA ? String(row.MONEDA) : undefined,
};
}
/**
* Mapea fila de BD a SaldoCuenta
*/
private mapToSaldoCuenta(row: Record<string, unknown>, periodo: PeriodoConsulta): SaldoCuenta {
return {
numeroCuenta: String(row.NUM_CTA || ''),
nombreCuenta: String(row.NOMBRE || ''),
saldoInicial: Number(row.SALDO_INI) || 0,
totalCargos: Number(row.CARGOS) || 0,
totalAbonos: Number(row.ABONOS) || 0,
saldoFinal: Number(row.SALDO_FIN) || 0,
periodo: periodo.periodo,
ejercicio: periodo.ejercicio,
};
}
}
// ============================================================================
// Factory function
// ============================================================================
export function createCOIConnector(client: AspelClient, empresaId?: number): COIConnector {
return new COIConnector(client, empresaId);
}

View File

@@ -0,0 +1,52 @@
/**
* Aspel Integration Module
* Exportaciones del conector de integracion con productos Aspel
*
* Productos soportados:
* - COI (Contabilidad Integral)
* - SAE (Sistema Administrativo Empresarial)
* - NOI (Nominas Integral)
* - BANCO (Control Bancario)
*
* Bases de datos soportadas:
* - Firebird (versiones clasicas de Aspel)
* - SQL Server (versiones modernas de Aspel)
*/
// Types
export * from './aspel.types.js';
// Client
export {
AspelClient,
createAspelClient,
connectAspel,
} from './aspel.client.js';
// Connectors
export {
COIConnector,
createCOIConnector,
} from './coi.connector.js';
export {
SAEConnector,
createSAEConnector,
} from './sae.connector.js';
export {
NOIConnector,
createNOIConnector,
} from './noi.connector.js';
export {
BANCOConnector,
createBANCOConnector,
} from './banco.connector.js';
// Sync Service
export {
AspelSyncService,
createAspelSyncService,
connectAspelSyncService,
} from './aspel.sync.js';

View File

@@ -0,0 +1,874 @@
/**
* NOI Connector - Nominas Integral Aspel
* Conector para extraer datos de nominas de Aspel NOI
*/
import { AspelClient } from './aspel.client.js';
import {
Empleado,
Nomina,
ReciboNomina,
MovimientoNomina,
PeriodoConsulta,
RangoFechas,
PaginacionAspel,
ResultadoPaginado,
} from './aspel.types.js';
import { logger } from '../../../utils/logger.js';
// ============================================================================
// Nombres de tablas NOI
// ============================================================================
const NOI_TABLES = {
EMPLEADOS: 'NOM10001',
NOMINAS: 'NOM10010',
RECIBOS: 'NOM10011',
MOVIMIENTOS: 'NOM10012',
DEPARTAMENTOS: 'NOM10002',
PUESTOS: 'NOM10003',
CONCEPTOS: 'NOM10004',
BANCOS: 'NOM10005',
PERIODOS: 'NOM10006',
CONFIGURACION: 'NOM10000',
};
// ============================================================================
// Clase del conector NOI
// ============================================================================
export class NOIConnector {
private client: AspelClient;
private empresaId: number;
constructor(client: AspelClient, empresaId: number = 1) {
this.client = client;
this.empresaId = empresaId;
}
// ============================================================================
// Empleados
// ============================================================================
/**
* Obtiene todos los empleados
*/
async getEmpleados(options?: {
soloActivos?: boolean;
departamento?: string;
puesto?: string;
}): Promise<Empleado[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (options?.soloActivos !== false) {
conditions.push('ESTATUS = ?');
params.push('A');
}
if (options?.departamento) {
conditions.push('DEPARTAMENTO = ?');
params.push(options.departamento);
}
if (options?.puesto) {
conditions.push('PUESTO = ?');
params.push(options.puesto);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const sql = `
SELECT
NUM_EMP,
NOMBRE_COMP,
NOMBRE,
AP_PATERNO,
AP_MATERNO,
RFC,
CURP,
NSS,
FECHA_NAC,
FECHA_ALTA,
FECHA_BAJA,
TIPO_CONTRATO,
DEPARTAMENTO,
PUESTO,
SUELDO_DIARIO,
SDI,
TIPO_JORNADA,
REG_CONTRAT,
RIESGO_TRAB,
PERIOD_PAGO,
BANCO,
NUM_CUENTA,
CLABE,
ESTATUS,
REG_PATRONAL,
ENTIDAD_FED,
CODIGO_POSTAL
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
${whereClause}
ORDER BY NOMBRE_COMP
`;
const result = await this.client.query<Record<string, unknown>>(sql, params);
return result.rows.map(row => this.mapToEmpleado(row));
}
/**
* Obtiene un empleado por numero
*/
async getEmpleado(numeroEmpleado: string): Promise<Empleado | null> {
const sql = `
SELECT
NUM_EMP,
NOMBRE_COMP,
NOMBRE,
AP_PATERNO,
AP_MATERNO,
RFC,
CURP,
NSS,
FECHA_NAC,
FECHA_ALTA,
FECHA_BAJA,
TIPO_CONTRATO,
DEPARTAMENTO,
PUESTO,
SUELDO_DIARIO,
SDI,
TIPO_JORNADA,
REG_CONTRAT,
RIESGO_TRAB,
PERIOD_PAGO,
BANCO,
NUM_CUENTA,
CLABE,
ESTATUS,
REG_PATRONAL,
ENTIDAD_FED,
CODIGO_POSTAL
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
WHERE NUM_EMP = ?
`;
const row = await this.client.queryOne<Record<string, unknown>>(sql, [numeroEmpleado]);
return row ? this.mapToEmpleado(row) : null;
}
/**
* Busca empleados por nombre o RFC
*/
async buscarEmpleados(termino: string, limite: number = 50): Promise<Empleado[]> {
const sql = `
SELECT
NUM_EMP,
NOMBRE_COMP,
NOMBRE,
AP_PATERNO,
AP_MATERNO,
RFC,
CURP,
NSS,
FECHA_NAC,
FECHA_ALTA,
FECHA_BAJA,
TIPO_CONTRATO,
DEPARTAMENTO,
PUESTO,
SUELDO_DIARIO,
SDI,
TIPO_JORNADA,
REG_CONTRAT,
RIESGO_TRAB,
PERIOD_PAGO,
BANCO,
NUM_CUENTA,
CLABE,
ESTATUS,
REG_PATRONAL,
ENTIDAD_FED,
CODIGO_POSTAL
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
WHERE (NOMBRE_COMP LIKE ? OR RFC LIKE ? OR NUM_EMP LIKE ? OR CURP LIKE ?)
AND ESTATUS = 'A'
ORDER BY NOMBRE_COMP
${this.getLimitClause(limite, 0)}
`;
const searchTerm = `%${termino}%`;
const result = await this.client.query<Record<string, unknown>>(sql, [
searchTerm,
searchTerm,
searchTerm,
searchTerm,
]);
return result.rows.map(row => this.mapToEmpleado(row));
}
/**
* Obtiene empleados paginados
*/
async getEmpleadosPaginados(
paginacion: PaginacionAspel,
options?: {
soloActivos?: boolean;
departamento?: string;
}
): Promise<ResultadoPaginado<Empleado>> {
const conditions: string[] = [];
const params: unknown[] = [];
if (options?.soloActivos !== false) {
conditions.push('ESTATUS = ?');
params.push('A');
}
if (options?.departamento) {
conditions.push('DEPARTAMENTO = ?');
params.push(options.departamento);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Contar total
const countSql = `
SELECT COUNT(*) AS total
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
${whereClause}
`;
const countResult = await this.client.queryOne<{ total: number }>(countSql, params);
const total = countResult?.total || 0;
// Obtener pagina
const pagina = paginacion.pagina || 1;
const porPagina = paginacion.porPagina || 50;
const offset = (pagina - 1) * porPagina;
const sql = `
SELECT
NUM_EMP,
NOMBRE_COMP,
NOMBRE,
AP_PATERNO,
AP_MATERNO,
RFC,
CURP,
NSS,
FECHA_NAC,
FECHA_ALTA,
FECHA_BAJA,
TIPO_CONTRATO,
DEPARTAMENTO,
PUESTO,
SUELDO_DIARIO,
SDI,
TIPO_JORNADA,
REG_CONTRAT,
RIESGO_TRAB,
PERIOD_PAGO,
BANCO,
NUM_CUENTA,
CLABE,
ESTATUS,
REG_PATRONAL,
ENTIDAD_FED,
CODIGO_POSTAL
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
${whereClause}
ORDER BY NOMBRE_COMP
${this.getLimitClause(porPagina, offset)}
`;
const result = await this.client.query<Record<string, unknown>>(sql, params);
return {
data: result.rows.map(row => this.mapToEmpleado(row)),
total,
pagina,
porPagina,
totalPaginas: Math.ceil(total / porPagina),
};
}
/**
* Obtiene empleados dados de baja en un periodo
*/
async getEmpleadosBaja(rango: RangoFechas): Promise<Empleado[]> {
const sql = `
SELECT
NUM_EMP,
NOMBRE_COMP,
NOMBRE,
AP_PATERNO,
AP_MATERNO,
RFC,
CURP,
NSS,
FECHA_NAC,
FECHA_ALTA,
FECHA_BAJA,
TIPO_CONTRATO,
DEPARTAMENTO,
PUESTO,
SUELDO_DIARIO,
SDI,
TIPO_JORNADA,
REG_CONTRAT,
RIESGO_TRAB,
PERIOD_PAGO,
BANCO,
NUM_CUENTA,
CLABE,
ESTATUS,
REG_PATRONAL,
ENTIDAD_FED,
CODIGO_POSTAL
FROM ${this.getTableName(NOI_TABLES.EMPLEADOS)}
WHERE ESTATUS = 'B'
AND FECHA_BAJA >= ?
AND FECHA_BAJA <= ?
ORDER BY FECHA_BAJA
`;
const result = await this.client.query<Record<string, unknown>>(sql, [
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
]);
return result.rows.map(row => this.mapToEmpleado(row));
}
// ============================================================================
// Nominas
// ============================================================================
/**
* Obtiene nominas por periodo
*/
async getNominas(periodo: PeriodoConsulta): Promise<Nomina[]> {
const sql = `
SELECT
ID_NOMINA,
PERIODO,
EJERCICIO,
TIPO_NOMINA,
FECHA_PAGO,
FECHA_INI,
FECHA_FIN,
DIAS_PAGADOS,
TOTAL_PERC,
TOTAL_DEDUC,
TOTAL_OTROS,
TOTAL_NETO,
NUM_EMPLEADOS,
ESTATUS,
DESCRIPCION
FROM ${this.getTableName(NOI_TABLES.NOMINAS)}
WHERE PERIODO = ? AND EJERCICIO = ?
ORDER BY FECHA_PAGO
`;
const result = await this.client.query<Record<string, unknown>>(sql, [
periodo.periodo,
periodo.ejercicio,
]);
return result.rows.map(row => this.mapToNomina(row));
}
/**
* Obtiene nominas por rango de fechas
*/
async getNominasByFecha(rango: RangoFechas): Promise<Nomina[]> {
const sql = `
SELECT
ID_NOMINA,
PERIODO,
EJERCICIO,
TIPO_NOMINA,
FECHA_PAGO,
FECHA_INI,
FECHA_FIN,
DIAS_PAGADOS,
TOTAL_PERC,
TOTAL_DEDUC,
TOTAL_OTROS,
TOTAL_NETO,
NUM_EMPLEADOS,
ESTATUS,
DESCRIPCION
FROM ${this.getTableName(NOI_TABLES.NOMINAS)}
WHERE FECHA_PAGO >= ? AND FECHA_PAGO <= ?
ORDER BY FECHA_PAGO
`;
const result = await this.client.query<Record<string, unknown>>(sql, [
AspelClient.formatAspelDate(rango.fechaInicial),
AspelClient.formatAspelDate(rango.fechaFinal),
]);
return result.rows.map(row => this.mapToNomina(row));
}
/**
* Obtiene una nomina especifica
*/
async getNomina(idNomina: number): Promise<Nomina | null> {
const sql = `
SELECT
ID_NOMINA,
PERIODO,
EJERCICIO,
TIPO_NOMINA,
FECHA_PAGO,
FECHA_INI,
FECHA_FIN,
DIAS_PAGADOS,
TOTAL_PERC,
TOTAL_DEDUC,
TOTAL_OTROS,
TOTAL_NETO,
NUM_EMPLEADOS,
ESTATUS,
DESCRIPCION
FROM ${this.getTableName(NOI_TABLES.NOMINAS)}
WHERE ID_NOMINA = ?
`;
const row = await this.client.queryOne<Record<string, unknown>>(sql, [idNomina]);
return row ? this.mapToNomina(row) : null;
}
/**
* Obtiene los recibos de una nomina
*/
async getRecibosNomina(
idNomina: number,
options?: { conMovimientos?: boolean }
): Promise<ReciboNomina[]> {
const sql = `
SELECT
r.ID_RECIBO,
r.ID_NOMINA,
r.NUM_EMP,
e.NOMBRE_COMP,
e.RFC,
e.CURP,
r.FECHA_PAGO,
r.FECHA_INI,
r.FECHA_FIN,
r.DIAS_PAGADOS,
r.TOTAL_PERC,
r.TOTAL_DEDUC,
r.TOTAL_OTROS,
r.NETO_PAGAR,
r.UUID,
r.ESTATUS_TIMB
FROM ${this.getTableName(NOI_TABLES.RECIBOS)} r
JOIN ${this.getTableName(NOI_TABLES.EMPLEADOS)} e ON r.NUM_EMP = e.NUM_EMP
WHERE r.ID_NOMINA = ?
ORDER BY e.NOMBRE_COMP
`;
const result = await this.client.query<Record<string, unknown>>(sql, [idNomina]);
const recibos: ReciboNomina[] = [];
for (const row of result.rows) {
const recibo = this.mapToReciboNomina(row);
if (options?.conMovimientos !== false) {
const movimientos = await this.getMovimientosRecibo(recibo.id);
recibo.percepciones = movimientos.filter(m => m.tipo === 'P');
recibo.deducciones = movimientos.filter(m => m.tipo === 'D');
recibo.otrosPagos = movimientos.filter(m => m.tipo === 'O');
}
recibos.push(recibo);
}
return recibos;
}
/**
* Obtiene el recibo de un empleado
*/
async getReciboEmpleado(
idNomina: number,
numeroEmpleado: string
): Promise<ReciboNomina | null> {
const sql = `
SELECT
r.ID_RECIBO,
r.ID_NOMINA,
r.NUM_EMP,
e.NOMBRE_COMP,
e.RFC,
e.CURP,
r.FECHA_PAGO,
r.FECHA_INI,
r.FECHA_FIN,
r.DIAS_PAGADOS,
r.TOTAL_PERC,
r.TOTAL_DEDUC,
r.TOTAL_OTROS,
r.NETO_PAGAR,
r.UUID,
r.ESTATUS_TIMB
FROM ${this.getTableName(NOI_TABLES.RECIBOS)} r
JOIN ${this.getTableName(NOI_TABLES.EMPLEADOS)} e ON r.NUM_EMP = e.NUM_EMP
WHERE r.ID_NOMINA = ? AND r.NUM_EMP = ?
`;
const row = await this.client.queryOne<Record<string, unknown>>(sql, [
idNomina,
numeroEmpleado,
]);
if (!row) return null;
const recibo = this.mapToReciboNomina(row);
// Obtener movimientos
const movimientos = await this.getMovimientosRecibo(recibo.id);
recibo.percepciones = movimientos.filter(m => m.tipo === 'P');
recibo.deducciones = movimientos.filter(m => m.tipo === 'D');
recibo.otrosPagos = movimientos.filter(m => m.tipo === 'O');
return recibo;
}
// ============================================================================
// Movimientos de Nomina
// ============================================================================
/**
* Obtiene los movimientos de un recibo
*/
async getMovimientosRecibo(idRecibo: number): Promise<MovimientoNomina[]> {
const sql = `
SELECT
m.TIPO_MOV,
m.CVE_CONCEPTO,
c.DESCRIPCION,
m.IMP_GRAVADO,
m.IMP_EXENTO,
m.IMPORTE
FROM ${this.getTableName(NOI_TABLES.MOVIMIENTOS)} m
LEFT JOIN ${this.getTableName(NOI_TABLES.CONCEPTOS)} c ON m.CVE_CONCEPTO = c.CVE_CONCEPTO
WHERE m.ID_RECIBO = ?
ORDER BY m.TIPO_MOV, m.CVE_CONCEPTO
`;
const result = await this.client.query<Record<string, unknown>>(sql, [idRecibo]);
return result.rows.map(row => this.mapToMovimientoNomina(row));
}
/**
* Obtiene movimientos de nomina por periodo
*/
async getMovimientosNomina(
periodo: PeriodoConsulta,
options?: {
tipoMovimiento?: 'P' | 'D' | 'O';
concepto?: string;
}
): Promise<MovimientoNomina[]> {
const conditions = ['n.PERIODO = ?', 'n.EJERCICIO = ?'];
const params: unknown[] = [periodo.periodo, periodo.ejercicio];
if (options?.tipoMovimiento) {
conditions.push('m.TIPO_MOV = ?');
params.push(options.tipoMovimiento);
}
if (options?.concepto) {
conditions.push('m.CVE_CONCEPTO = ?');
params.push(options.concepto);
}
const sql = `
SELECT
m.TIPO_MOV,
m.CVE_CONCEPTO,
c.DESCRIPCION,
SUM(m.IMP_GRAVADO) AS IMP_GRAVADO,
SUM(m.IMP_EXENTO) AS IMP_EXENTO,
SUM(m.IMPORTE) AS IMPORTE
FROM ${this.getTableName(NOI_TABLES.MOVIMIENTOS)} m
JOIN ${this.getTableName(NOI_TABLES.RECIBOS)} r ON m.ID_RECIBO = r.ID_RECIBO
JOIN ${this.getTableName(NOI_TABLES.NOMINAS)} n ON r.ID_NOMINA = n.ID_NOMINA
LEFT JOIN ${this.getTableName(NOI_TABLES.CONCEPTOS)} c ON m.CVE_CONCEPTO = c.CVE_CONCEPTO
WHERE ${conditions.join(' AND ')}
GROUP BY m.TIPO_MOV, m.CVE_CONCEPTO, c.DESCRIPCION
ORDER BY m.TIPO_MOV, m.CVE_CONCEPTO
`;
const result = await this.client.query<Record<string, unknown>>(sql, params);
return result.rows.map(row => this.mapToMovimientoNomina(row));
}
/**
* Obtiene resumen de percepciones y deducciones por concepto
*/
async getResumenConceptos(periodo: PeriodoConsulta): Promise<{
percepciones: MovimientoNomina[];
deducciones: MovimientoNomina[];
otrosPagos: MovimientoNomina[];
totales: {
percepciones: number;
deducciones: number;
otrosPagos: number;
neto: number;
};
}> {
const movimientos = await this.getMovimientosNomina(periodo);
const percepciones = movimientos.filter(m => m.tipo === 'P');
const deducciones = movimientos.filter(m => m.tipo === 'D');
const otrosPagos = movimientos.filter(m => m.tipo === 'O');
const totalPercepciones = percepciones.reduce((sum, m) => sum + m.importe, 0);
const totalDeducciones = deducciones.reduce((sum, m) => sum + m.importe, 0);
const totalOtrosPagos = otrosPagos.reduce((sum, m) => sum + m.importe, 0);
return {
percepciones,
deducciones,
otrosPagos,
totales: {
percepciones: totalPercepciones,
deducciones: totalDeducciones,
otrosPagos: totalOtrosPagos,
neto: totalPercepciones - totalDeducciones + totalOtrosPagos,
},
};
}
// ============================================================================
// Reportes
// ============================================================================
/**
* Obtiene el acumulado de nomina por empleado
*/
async getAcumuladoEmpleado(
numeroEmpleado: string,
ejercicio: number
): Promise<{
empleado: Empleado | null;
totalPercepciones: number;
totalDeducciones: number;
totalOtrosPagos: number;
totalNeto: number;
recibos: number;
}> {
const empleado = await this.getEmpleado(numeroEmpleado);
const sql = `
SELECT
SUM(r.TOTAL_PERC) AS TOTAL_PERC,
SUM(r.TOTAL_DEDUC) AS TOTAL_DEDUC,
SUM(r.TOTAL_OTROS) AS TOTAL_OTROS,
SUM(r.NETO_PAGAR) AS TOTAL_NETO,
COUNT(*) AS NUM_RECIBOS
FROM ${this.getTableName(NOI_TABLES.RECIBOS)} r
JOIN ${this.getTableName(NOI_TABLES.NOMINAS)} n ON r.ID_NOMINA = n.ID_NOMINA
WHERE r.NUM_EMP = ? AND n.EJERCICIO = ?
`;
const result = await this.client.queryOne<{
TOTAL_PERC: number;
TOTAL_DEDUC: number;
TOTAL_OTROS: number;
TOTAL_NETO: number;
NUM_RECIBOS: number;
}>(sql, [numeroEmpleado, ejercicio]);
return {
empleado,
totalPercepciones: result?.TOTAL_PERC || 0,
totalDeducciones: result?.TOTAL_DEDUC || 0,
totalOtrosPagos: result?.TOTAL_OTROS || 0,
totalNeto: result?.TOTAL_NETO || 0,
recibos: result?.NUM_RECIBOS || 0,
};
}
/**
* Obtiene estadisticas de nomina
*/
async getEstadisticasNomina(ejercicio: number): Promise<{
totalNominas: number;
totalEmpleados: number;
totalPercepciones: number;
totalDeducciones: number;
totalNeto: number;
promedioNeto: number;
}> {
const sql = `
SELECT
COUNT(DISTINCT n.ID_NOMINA) AS TOTAL_NOMINAS,
COUNT(DISTINCT r.NUM_EMP) AS TOTAL_EMPLEADOS,
SUM(r.TOTAL_PERC) AS TOTAL_PERC,
SUM(r.TOTAL_DEDUC) AS TOTAL_DEDUC,
SUM(r.NETO_PAGAR) AS TOTAL_NETO,
AVG(r.NETO_PAGAR) AS PROMEDIO_NETO
FROM ${this.getTableName(NOI_TABLES.RECIBOS)} r
JOIN ${this.getTableName(NOI_TABLES.NOMINAS)} n ON r.ID_NOMINA = n.ID_NOMINA
WHERE n.EJERCICIO = ?
`;
const result = await this.client.queryOne<{
TOTAL_NOMINAS: number;
TOTAL_EMPLEADOS: number;
TOTAL_PERC: number;
TOTAL_DEDUC: number;
TOTAL_NETO: number;
PROMEDIO_NETO: number;
}>(sql, [ejercicio]);
return {
totalNominas: result?.TOTAL_NOMINAS || 0,
totalEmpleados: result?.TOTAL_EMPLEADOS || 0,
totalPercepciones: result?.TOTAL_PERC || 0,
totalDeducciones: result?.TOTAL_DEDUC || 0,
totalNeto: result?.TOTAL_NETO || 0,
promedioNeto: result?.PROMEDIO_NETO || 0,
};
}
// ============================================================================
// Utilidades
// ============================================================================
/**
* Obtiene el nombre de tabla con prefijo de empresa
*/
private getTableName(baseName: string): string {
// NOI usa prefijos numericos para las tablas por empresa (ej: NOM10001 -> NOM20001 para empresa 2)
const empresaPrefix = String(this.empresaId);
return baseName.replace(/^NOM1/, `NOM${empresaPrefix}`);
}
/**
* Obtiene clausula LIMIT/OFFSET segun tipo de BD
*/
private getLimitClause(limit: number, offset: number): string {
const state = this.client.getState();
if (state.databaseType === 'firebird') {
return `ROWS ${offset + 1} TO ${offset + limit}`;
}
return `OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`;
}
// ============================================================================
// Mappers
// ============================================================================
private mapToEmpleado(row: Record<string, unknown>): Empleado {
return {
numeroEmpleado: String(row.NUM_EMP || ''),
nombreCompleto: String(row.NOMBRE_COMP || ''),
nombre: String(row.NOMBRE || ''),
apellidoPaterno: String(row.AP_PATERNO || ''),
apellidoMaterno: row.AP_MATERNO ? String(row.AP_MATERNO) : undefined,
rfc: String(row.RFC || ''),
curp: String(row.CURP || ''),
nss: row.NSS ? String(row.NSS) : undefined,
fechaNacimiento: AspelClient.parseAspelDate(row.FECHA_NAC) || undefined,
fechaAlta: AspelClient.parseAspelDate(row.FECHA_ALTA) || new Date(),
fechaBaja: AspelClient.parseAspelDate(row.FECHA_BAJA) || undefined,
tipoContrato: String(row.TIPO_CONTRATO || '01'),
departamento: row.DEPARTAMENTO ? String(row.DEPARTAMENTO) : undefined,
puesto: row.PUESTO ? String(row.PUESTO) : undefined,
sueldoDiario: Number(row.SUELDO_DIARIO) || 0,
salarioDiarioIntegrado: Number(row.SDI) || 0,
tipoJornada: row.TIPO_JORNADA ? String(row.TIPO_JORNADA) : undefined,
regimenContratacion: String(row.REG_CONTRAT || '02'),
riesgoTrabajo: row.RIESGO_TRAB ? String(row.RIESGO_TRAB) : undefined,
periodicidadPago: String(row.PERIOD_PAGO || '02'),
banco: row.BANCO ? String(row.BANCO) : undefined,
numeroCuenta: row.NUM_CUENTA ? String(row.NUM_CUENTA) : undefined,
clabe: row.CLABE ? String(row.CLABE) : undefined,
estatus: String(row.ESTATUS || 'A') as Empleado['estatus'],
registroPatronal: row.REG_PATRONAL ? String(row.REG_PATRONAL) : undefined,
entidadFederativa: row.ENTIDAD_FED ? String(row.ENTIDAD_FED) : undefined,
codigoPostal: row.CODIGO_POSTAL ? String(row.CODIGO_POSTAL) : undefined,
};
}
private mapToNomina(row: Record<string, unknown>): Nomina {
return {
id: Number(row.ID_NOMINA) || 0,
periodo: Number(row.PERIODO) || 0,
ejercicio: Number(row.EJERCICIO) || 0,
tipoNomina: String(row.TIPO_NOMINA || 'O') as Nomina['tipoNomina'],
fechaPago: AspelClient.parseAspelDate(row.FECHA_PAGO) || new Date(),
fechaInicial: AspelClient.parseAspelDate(row.FECHA_INI) || new Date(),
fechaFinal: AspelClient.parseAspelDate(row.FECHA_FIN) || new Date(),
diasPagados: Number(row.DIAS_PAGADOS) || 0,
totalPercepciones: Number(row.TOTAL_PERC) || 0,
totalDeducciones: Number(row.TOTAL_DEDUC) || 0,
totalOtrosPagos: Number(row.TOTAL_OTROS) || 0,
totalNeto: Number(row.TOTAL_NETO) || 0,
numeroEmpleados: Number(row.NUM_EMPLEADOS) || 0,
estatus: String(row.ESTATUS || 'P') as Nomina['estatus'],
descripcion: row.DESCRIPCION ? String(row.DESCRIPCION) : undefined,
};
}
private mapToReciboNomina(row: Record<string, unknown>): ReciboNomina {
return {
id: Number(row.ID_RECIBO) || 0,
idNomina: Number(row.ID_NOMINA) || 0,
numeroEmpleado: String(row.NUM_EMP || ''),
nombreEmpleado: String(row.NOMBRE_COMP || ''),
rfcEmpleado: String(row.RFC || ''),
curpEmpleado: String(row.CURP || ''),
fechaPago: AspelClient.parseAspelDate(row.FECHA_PAGO) || new Date(),
fechaInicial: AspelClient.parseAspelDate(row.FECHA_INI) || new Date(),
fechaFinal: AspelClient.parseAspelDate(row.FECHA_FIN) || new Date(),
diasPagados: Number(row.DIAS_PAGADOS) || 0,
totalPercepciones: Number(row.TOTAL_PERC) || 0,
totalDeducciones: Number(row.TOTAL_DEDUC) || 0,
totalOtrosPagos: Number(row.TOTAL_OTROS) || 0,
netoAPagar: Number(row.NETO_PAGAR) || 0,
percepciones: [],
deducciones: [],
otrosPagos: [],
uuid: row.UUID ? String(row.UUID) : undefined,
estatusTimbrado: row.ESTATUS_TIMB ? String(row.ESTATUS_TIMB) as ReciboNomina['estatusTimbrado'] : undefined,
};
}
private mapToMovimientoNomina(row: Record<string, unknown>): MovimientoNomina {
return {
tipo: String(row.TIPO_MOV || 'P') as MovimientoNomina['tipo'],
clave: String(row.CVE_CONCEPTO || ''),
descripcion: String(row.DESCRIPCION || ''),
importeGravado: Number(row.IMP_GRAVADO) || 0,
importeExento: Number(row.IMP_EXENTO) || 0,
importe: Number(row.IMPORTE) || 0,
};
}
}
// ============================================================================
// Factory function
// ============================================================================
export function createNOIConnector(client: AspelClient, empresaId?: number): NOIConnector {
return new NOIConnector(client, empresaId);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,911 @@
/**
* CONTPAQi Comercial Connector
* Conector para el modulo Comercial de CONTPAQi (ventas/compras)
*
* TABLAS PRINCIPALES DE CONTPAQi COMERCIAL:
*
* CLIENTES Y PROVEEDORES:
* - admClientes: Catalogo de clientes y proveedores (unificado)
* - CIDCLIENTEPROVEEDOR: ID unico
* - CCODIGOCLIENTE: Codigo del cliente/proveedor
* - CRAZONSOCIAL: Razon social
* - CRFC: RFC
* - CTIPOCLIENTE: 1=Cliente, 2=ClienteProveedor, 3=Proveedor
*
* - admDomicilios: Direcciones de clientes/proveedores
*
* PRODUCTOS:
* - admProductos: Catalogo de productos y servicios
* - CIDPRODUCTO: ID del producto
* - CCODIGOPRODUCTO: Codigo del producto
* - CNOMBREPRODUCTO: Nombre
* - CTIPOPRODUCTO: 1=Producto, 2=Paquete, 3=Servicio
*
* DOCUMENTOS:
* - admDocumentos: Encabezado de documentos (facturas, notas, etc.)
* - CIDDOCUMENTO: ID del documento
* - CIDCONCEPTODOCUMENTO: Tipo de documento
* - CSERIEFASCICULO: Serie
* - CFOLIO: Folio
* - CFECHA: Fecha
* - CIDCLIENTEPROVEEDOR: Cliente/Proveedor
*
* - admMovimientos: Detalle de productos en documentos
* - CIDMOVIMIENTO: ID del movimiento
* - CIDDOCUMENTO: ID del documento padre
* - CIDPRODUCTO: Producto
* - CUNIDADES: Cantidad
* - CPRECIO: Precio unitario
*
* INVENTARIOS:
* - admAlmacenes: Almacenes
* - admExistencias: Existencias por almacen
* - CIDPRODUCTO: Producto
* - CIDALMACEN: Almacen
* - CEXISTENCIA: Cantidad
*/
import { CONTPAQiClient } from './contpaqi.client.js';
import {
CONTPAQiCliente,
CONTPAQiProveedor,
CONTPAQiProducto,
CONTPAQiDocumento,
CONTPAQiMovimientoDocumento,
CONTPAQiExistencia,
CONTPAQiAlmacen,
CONTPAQiDireccion,
ConceptoDocumento,
EstadoDocumento,
TipoProducto,
} from './contpaqi.types.js';
import { DocumentoFilter, PaginationInput } from './contpaqi.schema.js';
/**
* Mapeo de conceptos de documento de CONTPAQi a tipos internos
* Estos IDs varian segun la configuracion de cada empresa
* Valores tipicos:
*/
const CONCEPTO_DOC_MAP: Record<number, ConceptoDocumento> = {
1: 'Cotizacion',
2: 'Pedido',
3: 'Remision',
4: 'FacturaCliente',
5: 'NotaCreditoCliente',
6: 'NotaCargoCliente',
7: 'DevolucionCliente',
17: 'OrdenCompra',
18: 'FacturaProveedor',
19: 'NotaCreditoProveedor',
20: 'NotaCargoProveedor',
21: 'DevolucionProveedor',
};
/**
* Conector para CONTPAQi Comercial
*/
export class ComercialConnector {
constructor(private client: CONTPAQiClient) {}
// ============================================================================
// Clientes
// ============================================================================
/**
* Obtiene el catalogo de clientes
*/
async getClientes(options?: PaginationInput & {
activos?: boolean;
busqueda?: string;
}): Promise<CONTPAQiCliente[]> {
let query = `
SELECT
cp.CIDCLIENTEPROVEEDOR as id,
cp.CCODIGOCLIENTE as codigo,
cp.CRAZONSOCIAL as razonSocial,
cp.CRFC as rfc,
cp.CCURP as curp,
cp.CNOMBRECOMERCIAL as nombreComercial,
cp.CTIPOCLIENTE as tipo,
cp.CESTATUS as estado,
cp.CREGIMENFISCAL as regimenFiscal,
cp.CUSOCFDI as usoCFDI,
cp.CLIMITECREDITO as limiteCredito,
cp.CDIASCREDITO as diasCredito,
cp.CSALDOACTUAL as saldoActual,
cp.CEMAIL1 as email,
cp.CTELEFONO1 as telefono,
cp.CFECHAALTA as fechaAlta,
cp.CFECHAULTIMACOMPRA as fechaUltimaCompra,
cp.CMONEDA as moneda,
cp.CDESCUENTOGENERAL as descuento
FROM admClientes cp
WHERE cp.CTIPOCLIENTE IN (1, 2)
`;
const params: Record<string, unknown> = {};
if (options?.activos !== false) {
query += ' AND cp.CESTATUS = 1';
}
if (options?.busqueda) {
query += ` AND (
cp.CCODIGOCLIENTE LIKE @busqueda
OR cp.CRAZONSOCIAL LIKE @busqueda
OR cp.CRFC LIKE @busqueda
)`;
params.busqueda = `%${options.busqueda}%`;
}
query += ' ORDER BY cp.CRAZONSOCIAL';
// Paginacion
if (options?.limit) {
const offset = options.page ? (options.page - 1) * options.limit : 0;
query += ` OFFSET ${offset} ROWS FETCH NEXT ${options.limit} ROWS ONLY`;
}
const result = await this.client.queryMany<{
id: number;
codigo: string;
razonSocial: string;
rfc: string;
curp: string | null;
nombreComercial: string | null;
tipo: number;
estado: number;
regimenFiscal: string | null;
usoCFDI: string | null;
limiteCredito: number | null;
diasCredito: number | null;
saldoActual: number | null;
email: string | null;
telefono: string | null;
fechaAlta: Date | null;
fechaUltimaCompra: Date | null;
moneda: string | null;
descuento: number | null;
}>(query, params);
// Obtener direcciones
const clientes: CONTPAQiCliente[] = [];
for (const row of result) {
const direccion = await this.getDireccionCliente(row.id);
clientes.push({
id: row.id,
codigo: row.codigo?.trim() || '',
razonSocial: row.razonSocial?.trim() || '',
rfc: row.rfc?.trim() || '',
curp: row.curp?.trim() || undefined,
nombreComercial: row.nombreComercial?.trim() || undefined,
tipo: row.tipo,
estado: row.estado,
regimenFiscal: row.regimenFiscal?.trim() || undefined,
usoCFDI: row.usoCFDI?.trim() || undefined,
limiteCredito: row.limiteCredito || undefined,
diasCredito: row.diasCredito || undefined,
saldoActual: row.saldoActual || undefined,
direccion,
email: row.email?.trim() || undefined,
telefono: row.telefono?.trim() || undefined,
fechaAlta: row.fechaAlta || undefined,
fechaUltimaCompra: row.fechaUltimaCompra || undefined,
moneda: row.moneda?.trim() || undefined,
descuento: row.descuento || undefined,
});
}
return clientes;
}
/**
* Obtiene un cliente por su codigo
*/
async getClienteByCodigo(codigo: string): Promise<CONTPAQiCliente | null> {
const clientes = await this.getClientes({ activos: false, busqueda: codigo });
return clientes.find((c) => c.codigo === codigo) || null;
}
// ============================================================================
// Proveedores
// ============================================================================
/**
* Obtiene el catalogo de proveedores
*/
async getProveedores(options?: PaginationInput & {
activos?: boolean;
busqueda?: string;
}): Promise<CONTPAQiProveedor[]> {
let query = `
SELECT
cp.CIDCLIENTEPROVEEDOR as id,
cp.CCODIGOCLIENTE as codigo,
cp.CRAZONSOCIAL as razonSocial,
cp.CRFC as rfc,
cp.CCURP as curp,
cp.CNOMBRECOMERCIAL as nombreComercial,
cp.CTIPOCLIENTE as tipo,
cp.CESTATUS as estado,
cp.CREGIMENFISCAL as regimenFiscal,
cp.CDIASCREDITO as diasCredito,
cp.CSALDOACTUAL as saldoActual,
cp.CEMAIL1 as email,
cp.CTELEFONO1 as telefono,
cp.CCUENTACONTABLE as cuentaContable,
cp.CFECHAALTA as fechaAlta,
cp.CMONEDA as moneda
FROM admClientes cp
WHERE cp.CTIPOCLIENTE IN (2, 3)
`;
const params: Record<string, unknown> = {};
if (options?.activos !== false) {
query += ' AND cp.CESTATUS = 1';
}
if (options?.busqueda) {
query += ` AND (
cp.CCODIGOCLIENTE LIKE @busqueda
OR cp.CRAZONSOCIAL LIKE @busqueda
OR cp.CRFC LIKE @busqueda
)`;
params.busqueda = `%${options.busqueda}%`;
}
query += ' ORDER BY cp.CRAZONSOCIAL';
if (options?.limit) {
const offset = options.page ? (options.page - 1) * options.limit : 0;
query += ` OFFSET ${offset} ROWS FETCH NEXT ${options.limit} ROWS ONLY`;
}
const result = await this.client.queryMany<{
id: number;
codigo: string;
razonSocial: string;
rfc: string;
curp: string | null;
nombreComercial: string | null;
tipo: number;
estado: number;
regimenFiscal: string | null;
diasCredito: number | null;
saldoActual: number | null;
email: string | null;
telefono: string | null;
cuentaContable: string | null;
fechaAlta: Date | null;
moneda: string | null;
}>(query, params);
const proveedores: CONTPAQiProveedor[] = [];
for (const row of result) {
const direccion = await this.getDireccionCliente(row.id);
proveedores.push({
id: row.id,
codigo: row.codigo?.trim() || '',
razonSocial: row.razonSocial?.trim() || '',
rfc: row.rfc?.trim() || '',
curp: row.curp?.trim() || undefined,
nombreComercial: row.nombreComercial?.trim() || undefined,
tipo: row.tipo,
estado: row.estado,
regimenFiscal: row.regimenFiscal?.trim() || undefined,
diasCredito: row.diasCredito || undefined,
saldoActual: row.saldoActual || undefined,
direccion,
email: row.email?.trim() || undefined,
telefono: row.telefono?.trim() || undefined,
cuentaContable: row.cuentaContable?.trim() || undefined,
fechaAlta: row.fechaAlta || undefined,
moneda: row.moneda?.trim() || undefined,
});
}
return proveedores;
}
/**
* Obtiene la direccion fiscal de un cliente/proveedor
*/
private async getDireccionCliente(clienteId: number): Promise<CONTPAQiDireccion | undefined> {
const query = `
SELECT TOP 1
d.CCALLE as calle,
d.CNUMEROEXTERIOR as numeroExterior,
d.CNUMEROINTERIOR as numeroInterior,
d.CCOLONIA as colonia,
d.CCODIGOPOSTAL as codigoPostal,
d.CCIUDAD as ciudad,
d.CESTADO as estado,
d.CPAIS as pais
FROM admDomicilios d
WHERE d.CIDCLIENTEPROVEEDOR = @clienteId
AND d.CTIPO = 1
ORDER BY d.CIDDOMICILIO
`;
const result = await this.client.queryOne<{
calle: string | null;
numeroExterior: string | null;
numeroInterior: string | null;
colonia: string | null;
codigoPostal: string | null;
ciudad: string | null;
estado: string | null;
pais: string | null;
}>(query, { clienteId });
if (!result) return undefined;
return {
calle: result.calle?.trim() || undefined,
numeroExterior: result.numeroExterior?.trim() || undefined,
numeroInterior: result.numeroInterior?.trim() || undefined,
colonia: result.colonia?.trim() || undefined,
codigoPostal: result.codigoPostal?.trim() || undefined,
ciudad: result.ciudad?.trim() || undefined,
estado: result.estado?.trim() || undefined,
pais: result.pais?.trim() || undefined,
};
}
// ============================================================================
// Productos
// ============================================================================
/**
* Obtiene el catalogo de productos
*/
async getProductos(options?: PaginationInput & {
activos?: boolean;
tipo?: TipoProducto;
busqueda?: string;
categoria?: string;
}): Promise<CONTPAQiProducto[]> {
let query = `
SELECT
p.CIDPRODUCTO as id,
p.CCODIGOPRODUCTO as codigo,
p.CNOMBREPRODUCTO as nombre,
p.CDESCRIPCIONPRODUCTO as descripcion,
p.CTIPOPRODUCTO as tipoNum,
p.CUNIDADMEDIDA as unidadMedida,
p.CCLAVESAT as claveSAT,
p.CCLAVEUNIDADSAT as claveUnidadSAT,
p.CPRECIOBASE as precioBase,
p.CULTIMOCOSTO as ultimoCosto,
p.CCOSTOPROMEDIO as costoPromedio,
p.CTASAIVA as tasaIVA,
p.CTASAIEPS as tasaIEPS,
p.CCONTROLEXISTENCIAS as controlExistencias,
p.CESTATUS as estado,
p.CCATEGORIA as categoria,
p.CLINEA as linea,
p.CMARCA as marca,
p.CFECHAALTA as fechaAlta
FROM admProductos p
WHERE 1=1
`;
const params: Record<string, unknown> = {};
if (options?.activos !== false) {
query += ' AND p.CESTATUS = 1';
}
if (options?.tipo) {
const tipoNum = options.tipo === 'Producto' ? 1 : options.tipo === 'Paquete' ? 2 : 3;
query += ' AND p.CTIPOPRODUCTO = @tipoNum';
params.tipoNum = tipoNum;
}
if (options?.busqueda) {
query += ` AND (
p.CCODIGOPRODUCTO LIKE @busqueda
OR p.CNOMBREPRODUCTO LIKE @busqueda
OR p.CDESCRIPCIONPRODUCTO LIKE @busqueda
)`;
params.busqueda = `%${options.busqueda}%`;
}
if (options?.categoria) {
query += ' AND p.CCATEGORIA = @categoria';
params.categoria = options.categoria;
}
query += ' ORDER BY p.CNOMBREPRODUCTO';
if (options?.limit) {
const offset = options.page ? (options.page - 1) * options.limit : 0;
query += ` OFFSET ${offset} ROWS FETCH NEXT ${options.limit} ROWS ONLY`;
}
const result = await this.client.queryMany<{
id: number;
codigo: string;
nombre: string;
descripcion: string | null;
tipoNum: number;
unidadMedida: string;
claveSAT: string | null;
claveUnidadSAT: string | null;
precioBase: number;
ultimoCosto: number | null;
costoPromedio: number | null;
tasaIVA: number | null;
tasaIEPS: number | null;
controlExistencias: number;
estado: number;
categoria: string | null;
linea: string | null;
marca: string | null;
fechaAlta: Date | null;
}>(query, params);
return result.map((row) => ({
id: row.id,
codigo: row.codigo?.trim() || '',
nombre: row.nombre?.trim() || '',
descripcion: row.descripcion?.trim() || undefined,
tipo: this.getTipoProductoNombre(row.tipoNum),
unidadMedida: row.unidadMedida?.trim() || '',
claveSAT: row.claveSAT?.trim() || undefined,
claveUnidadSAT: row.claveUnidadSAT?.trim() || undefined,
precioBase: row.precioBase || 0,
ultimoCosto: row.ultimoCosto || undefined,
costoPromedio: row.costoPromedio || undefined,
tasaIVA: row.tasaIVA || undefined,
tasaIEPS: row.tasaIEPS || undefined,
controlExistencias: row.controlExistencias === 1,
estado: row.estado,
categoria: row.categoria?.trim() || undefined,
linea: row.linea?.trim() || undefined,
marca: row.marca?.trim() || undefined,
fechaAlta: row.fechaAlta || undefined,
}));
}
// ============================================================================
// Documentos (Facturas, Notas de Credito, etc.)
// ============================================================================
/**
* Obtiene las facturas emitidas (a clientes) en un periodo
*/
async getFacturasEmitidas(filter: DocumentoFilter): Promise<CONTPAQiDocumento[]> {
return this.getDocumentos({
...filter,
conceptos: ['FacturaCliente'],
});
}
/**
* Obtiene las facturas recibidas (de proveedores) en un periodo
*/
async getFacturasRecibidas(filter: DocumentoFilter): Promise<CONTPAQiDocumento[]> {
return this.getDocumentos({
...filter,
conceptos: ['FacturaProveedor'],
});
}
/**
* Obtiene documentos comerciales
*/
async getDocumentos(filter: DocumentoFilter & {
conceptos?: ConceptoDocumento[];
}): Promise<CONTPAQiDocumento[]> {
let query = `
SELECT
d.CIDDOCUMENTO as id,
d.CIDCONCEPTODOCUMENTO as conceptoId,
d.CSERIEFASCICULO as serie,
d.CFOLIO as folio,
d.CFECHA as fecha,
d.CFECHAVENCIMIENTO as fechaVencimiento,
cp.CCODIGOCLIENTE as codigoClienteProveedor,
cp.CRAZONSOCIAL as nombreClienteProveedor,
cp.CRFC as rfcClienteProveedor,
d.CSUBTOTAL as subtotal,
d.CDESCUENTO as descuento,
d.CIVA as iva,
d.CIEPS as ieps,
d.CRETENCIONES as retenciones,
d.CTOTAL as total,
d.CMONEDA as moneda,
d.CTIPOCAMBIO as tipoCambio,
d.CUUID as uuid,
d.CFORMAPAGO as formaPago,
d.CMETODOPAGO as metodoPago,
d.CCONDICIONESPAGO as condicionesPago,
d.CPENDIENTE as pendiente,
d.CESTATUS as estadoNum,
d.CCANCELADO as cancelado,
d.CFECHACANCELACION as fechaCancelacion,
d.CIMPRESO as impreso,
d.COBSERVACIONES as observaciones,
d.CREFERENCIA as referencia,
d.CLUGAREXPEDICION as lugarExpedicion
FROM admDocumentos d
INNER JOIN admClientes cp ON d.CIDCLIENTEPROVEEDOR = cp.CIDCLIENTEPROVEEDOR
WHERE 1=1
`;
const params: Record<string, unknown> = {};
// Filtrar por conceptos de documento
if (filter.conceptos && filter.conceptos.length > 0) {
const conceptoIds = filter.conceptos
.map((c) => this.getConceptoDocumentoId(c))
.filter((id) => id !== null);
if (conceptoIds.length > 0) {
query += ` AND d.CIDCONCEPTODOCUMENTO IN (${conceptoIds.join(',')})`;
}
}
if (filter.fechaInicio) {
query += ' AND d.CFECHA >= @fechaInicio';
params.fechaInicio = new Date(filter.fechaInicio);
}
if (filter.fechaFin) {
query += ' AND d.CFECHA <= @fechaFin';
params.fechaFin = new Date(filter.fechaFin);
}
if (filter.clienteId) {
query += ' AND d.CIDCLIENTEPROVEEDOR = @clienteId';
params.clienteId = filter.clienteId;
}
if (filter.uuid) {
query += ' AND d.CUUID = @uuid';
params.uuid = filter.uuid;
}
if (filter.estado) {
query += ' AND d.CESTATUS = @estado';
params.estado = this.getEstadoDocumentoNumero(filter.estado);
}
if (!filter.incluyeCancelados) {
query += ' AND ISNULL(d.CCANCELADO, 0) = 0';
}
query += ' ORDER BY d.CFECHA DESC, d.CFOLIO DESC';
if (filter.limit) {
const offset = filter.page ? (filter.page - 1) * filter.limit : 0;
query += ` OFFSET ${offset} ROWS FETCH NEXT ${filter.limit} ROWS ONLY`;
}
const result = await this.client.queryMany<{
id: number;
conceptoId: number;
serie: string | null;
folio: number;
fecha: Date;
fechaVencimiento: Date | null;
codigoClienteProveedor: string;
nombreClienteProveedor: string;
rfcClienteProveedor: string;
subtotal: number;
descuento: number;
iva: number;
ieps: number | null;
retenciones: number | null;
total: number;
moneda: string;
tipoCambio: number;
uuid: string | null;
formaPago: string | null;
metodoPago: string | null;
condicionesPago: string | null;
pendiente: number;
estadoNum: number;
cancelado: number;
fechaCancelacion: Date | null;
impreso: number;
observaciones: string | null;
referencia: string | null;
lugarExpedicion: string | null;
}>(query, params);
const documentos: CONTPAQiDocumento[] = [];
for (const row of result) {
const movimientos = await this.getMovimientosDocumento(row.id);
documentos.push({
id: row.id,
concepto: this.getConceptoDocumentoNombre(row.conceptoId),
serie: row.serie?.trim() || undefined,
folio: row.folio,
fecha: row.fecha,
fechaVencimiento: row.fechaVencimiento || undefined,
codigoClienteProveedor: row.codigoClienteProveedor?.trim() || '',
nombreClienteProveedor: row.nombreClienteProveedor?.trim() || '',
rfcClienteProveedor: row.rfcClienteProveedor?.trim() || '',
subtotal: row.subtotal || 0,
descuento: row.descuento || 0,
iva: row.iva || 0,
ieps: row.ieps || undefined,
retenciones: row.retenciones || undefined,
total: row.total || 0,
moneda: row.moneda?.trim() || 'MXN',
tipoCambio: row.tipoCambio || 1,
uuid: row.uuid?.trim() || undefined,
formaPago: row.formaPago?.trim() || undefined,
metodoPago: row.metodoPago?.trim() || undefined,
condicionesPago: row.condicionesPago?.trim() || undefined,
pendiente: row.pendiente || 0,
estado: this.getEstadoDocumentoNombre(row.estadoNum),
cancelado: row.cancelado === 1,
fechaCancelacion: row.fechaCancelacion || undefined,
impreso: row.impreso === 1,
observaciones: row.observaciones?.trim() || undefined,
movimientos,
referencia: row.referencia?.trim() || undefined,
lugarExpedicion: row.lugarExpedicion?.trim() || undefined,
});
}
return documentos;
}
/**
* Obtiene los movimientos (detalle de productos) de un documento
*/
async getMovimientosDocumento(documentoId: number): Promise<CONTPAQiMovimientoDocumento[]> {
const query = `
SELECT
m.CIDMOVIMIENTO as id,
m.CIDDOCUMENTO as documentoId,
m.CNUMEROMOVIMIENTO as numMovimiento,
p.CCODIGOPRODUCTO as codigoProducto,
p.CNOMBREPRODUCTO as nombreProducto,
m.CUNIDADES as cantidad,
m.CUNIDAD as unidad,
m.CPRECIO as precioUnitario,
m.CDESCUENTO as descuento,
m.CIMPORTE as importe,
m.CIVA as iva,
m.CIEPS as ieps,
m.CTOTAL as total,
p.CCLAVESAT as claveSAT,
p.CCLAVEUNIDADSAT as claveUnidadSAT,
m.CREFERENCIA as referencia,
m.COBSERVACIONES as observaciones
FROM admMovimientos m
INNER JOIN admProductos p ON m.CIDPRODUCTO = p.CIDPRODUCTO
WHERE m.CIDDOCUMENTO = @documentoId
ORDER BY m.CNUMEROMOVIMIENTO
`;
const result = await this.client.queryMany<{
id: number;
documentoId: number;
numMovimiento: number;
codigoProducto: string;
nombreProducto: string;
cantidad: number;
unidad: string;
precioUnitario: number;
descuento: number;
importe: number;
iva: number;
ieps: number | null;
total: number;
claveSAT: string | null;
claveUnidadSAT: string | null;
referencia: string | null;
observaciones: string | null;
}>(query, { documentoId });
return result.map((row) => ({
id: row.id,
documentoId: row.documentoId,
numMovimiento: row.numMovimiento,
codigoProducto: row.codigoProducto?.trim() || '',
nombreProducto: row.nombreProducto?.trim() || '',
cantidad: row.cantidad || 0,
unidad: row.unidad?.trim() || '',
precioUnitario: row.precioUnitario || 0,
descuento: row.descuento || 0,
importe: row.importe || 0,
iva: row.iva || 0,
ieps: row.ieps || undefined,
total: row.total || 0,
claveSAT: row.claveSAT?.trim() || undefined,
claveUnidadSAT: row.claveUnidadSAT?.trim() || undefined,
referencia: row.referencia?.trim() || undefined,
observaciones: row.observaciones?.trim() || undefined,
}));
}
// ============================================================================
// Inventario
// ============================================================================
/**
* Obtiene las existencias de productos
*/
async getInventario(options?: {
almacenId?: number;
productoId?: number;
soloConExistencia?: boolean;
}): Promise<CONTPAQiExistencia[]> {
let query = `
SELECT
e.CIDPRODUCTO as productoId,
p.CCODIGOPRODUCTO as codigoProducto,
p.CNOMBREPRODUCTO as nombreProducto,
e.CIDALMACEN as almacenId,
a.CNOMBREALMACEN as nombreAlmacen,
e.CEXISTENCIA as existencia,
p.CUNIDADMEDIDA as unidad,
e.CEXISTENCIAMINIMA as existenciaMinima,
e.CEXISTENCIAMAXIMA as existenciaMaxima,
e.CPUNTOREORDEN as puntoReorden,
p.CULTIMOCOSTO as ultimoCosto,
p.CCOSTOPROMEDIO as costoPromedio
FROM admExistencias e
INNER JOIN admProductos p ON e.CIDPRODUCTO = p.CIDPRODUCTO
INNER JOIN admAlmacenes a ON e.CIDALMACEN = a.CIDALMACEN
WHERE p.CCONTROLEXISTENCIAS = 1
`;
const params: Record<string, unknown> = {};
if (options?.almacenId) {
query += ' AND e.CIDALMACEN = @almacenId';
params.almacenId = options.almacenId;
}
if (options?.productoId) {
query += ' AND e.CIDPRODUCTO = @productoId';
params.productoId = options.productoId;
}
if (options?.soloConExistencia) {
query += ' AND e.CEXISTENCIA > 0';
}
query += ' ORDER BY p.CNOMBREPRODUCTO, a.CNOMBREALMACEN';
const result = await this.client.queryMany<{
productoId: number;
codigoProducto: string;
nombreProducto: string;
almacenId: number;
nombreAlmacen: string;
existencia: number;
unidad: string;
existenciaMinima: number | null;
existenciaMaxima: number | null;
puntoReorden: number | null;
ultimoCosto: number | null;
costoPromedio: number | null;
}>(query, params);
return result.map((row) => ({
productoId: row.productoId,
codigoProducto: row.codigoProducto?.trim() || '',
nombreProducto: row.nombreProducto?.trim() || '',
almacenId: row.almacenId,
nombreAlmacen: row.nombreAlmacen?.trim() || '',
existencia: row.existencia || 0,
unidad: row.unidad?.trim() || '',
existenciaMinima: row.existenciaMinima || undefined,
existenciaMaxima: row.existenciaMaxima || undefined,
puntoReorden: row.puntoReorden || undefined,
ultimoCosto: row.ultimoCosto || undefined,
costoPromedio: row.costoPromedio || undefined,
valorInventario: (row.existencia || 0) * (row.costoPromedio || row.ultimoCosto || 0),
}));
}
/**
* Obtiene el catalogo de almacenes
*/
async getAlmacenes(): Promise<CONTPAQiAlmacen[]> {
const query = `
SELECT
a.CIDALMACEN as id,
a.CCODIGOALMACEN as codigo,
a.CNOMBREALMACEN as nombre,
a.CESPRINCIPAL as esPrincipal,
a.CESTATUS as estado
FROM admAlmacenes a
WHERE a.CESTATUS = 1
ORDER BY a.CNOMBREALMACEN
`;
const result = await this.client.queryMany<{
id: number;
codigo: string;
nombre: string;
esPrincipal: number;
estado: number;
}>(query);
return result.map((row) => ({
id: row.id,
codigo: row.codigo?.trim() || '',
nombre: row.nombre?.trim() || '',
esPrincipal: row.esPrincipal === 1,
estado: row.estado,
}));
}
// ============================================================================
// Helpers
// ============================================================================
private getTipoProductoNombre(tipo: number): TipoProducto {
const tipos: Record<number, TipoProducto> = {
1: 'Producto',
2: 'Paquete',
3: 'Servicio',
};
return tipos[tipo] || 'Producto';
}
private getConceptoDocumentoId(concepto: ConceptoDocumento): number | null {
const conceptos: Record<ConceptoDocumento, number> = {
Cotizacion: 1,
Pedido: 2,
Remision: 3,
FacturaCliente: 4,
NotaCreditoCliente: 5,
NotaCargoCliente: 6,
DevolucionCliente: 7,
OrdenCompra: 17,
FacturaProveedor: 18,
NotaCreditoProveedor: 19,
NotaCargoProveedor: 20,
DevolucionProveedor: 21,
};
return conceptos[concepto] || null;
}
private getConceptoDocumentoNombre(id: number): ConceptoDocumento {
return CONCEPTO_DOC_MAP[id] || 'FacturaCliente';
}
private getEstadoDocumentoNumero(estado: EstadoDocumento): number {
const estados: Record<EstadoDocumento, number> = {
Pendiente: 1,
Parcial: 2,
Pagado: 3,
Cancelado: 4,
};
return estados[estado];
}
private getEstadoDocumentoNombre(estado: number): EstadoDocumento {
const estados: Record<number, EstadoDocumento> = {
1: 'Pendiente',
2: 'Parcial',
3: 'Pagado',
4: 'Cancelado',
};
return estados[estado] || 'Pendiente';
}
}
/**
* Crea una instancia del conector Comercial
*/
export function createComercialConnector(client: CONTPAQiClient): ComercialConnector {
return new ComercialConnector(client);
}

View File

@@ -0,0 +1,841 @@
/**
* CONTPAQi Contabilidad Connector
* Conector para el modulo de Contabilidad de CONTPAQi
*
* TABLAS PRINCIPALES DE CONTPAQi CONTABILIDAD:
* - Cuentas: Catalogo de cuentas contables
* - Polizas: Encabezado de polizas
* - MovPolizas: Movimientos de polizas
* - TiposPoliza: Tipos de poliza (Ingresos, Egresos, Diario)
* - Ejercicios: Ejercicios fiscales
* - Periodos: Periodos contables (meses)
* - SaldosCuentas: Saldos por periodo
*/
import { CONTPAQiClient } from './contpaqi.client.js';
import {
CONTPAQiCuenta,
CONTPAQiPoliza,
CONTPAQiMovimiento,
CONTPAQiBalanzaComprobacion,
CONTPAQiBalanzaLinea,
CONTPAQiEstadoResultados,
CONTPAQiEstadoResultadosLinea,
TipoCuenta,
NaturalezaCuenta,
TipoPoliza,
TipoMovimiento,
CONTPAQiQueryError,
} from './contpaqi.types.js';
import { PeriodoQuery, PolizaFilter, DateRangeQuery } from './contpaqi.schema.js';
/**
* Conector para CONTPAQi Contabilidad
*/
export class ContabilidadConnector {
constructor(private client: CONTPAQiClient) {}
// ============================================================================
// Catalogo de Cuentas
// ============================================================================
/**
* Obtiene el catalogo completo de cuentas contables
*
* Tabla: Cuentas
* Campos principales:
* - CIDCUENTA: ID de la cuenta
* - CCODIGOCUENTA: Codigo de la cuenta
* - CNOMBRECUENTA: Nombre de la cuenta
* - CTIPOCUENTA: Tipo (1=Activo, 2=Pasivo, 3=Capital, 4=Ingreso, 5=Costo, 6=Gasto, 7=Orden)
* - CNATURALEZA: Naturaleza (1=Deudora, 2=Acreedora)
* - CNIVEL: Nivel de la cuenta
* - CCUENTAPADRE: Codigo de cuenta padre
* - CESDECATALOGO: 1 si es cuenta de detalle
* - CCODIGOAGRUPADOR: Codigo agrupador SAT
*/
async getCatalogoCuentas(options?: {
activas?: boolean;
tipo?: TipoCuenta;
nivel?: number;
soloDetalle?: boolean;
}): Promise<CONTPAQiCuenta[]> {
let query = `
SELECT
CIDCUENTA as id,
CCODIGOCUENTA as codigo,
CNOMBRECUENTA as nombre,
CTIPOCUENTA as tipoNum,
CNATURALEZA as naturalezaNum,
CNIVEL as nivel,
CCUENTAPADRE as codigoPadre,
CESDECATALOGO as esDeCatalogoNum,
CCODIGOAGRUPADOR as codigoAgrupador,
CSALDOINICIAL as saldoInicial,
CESTATUS as activa,
CFECHAALTA as fechaAlta,
CMONEDA as moneda
FROM Cuentas
WHERE 1=1
`;
const params: Record<string, unknown> = {};
if (options?.activas !== false) {
query += ' AND CESTATUS = 1';
}
if (options?.tipo) {
query += ' AND CTIPOCUENTA = @tipo';
params.tipo = this.getTipoCuentaNumero(options.tipo);
}
if (options?.nivel !== undefined) {
query += ' AND CNIVEL = @nivel';
params.nivel = options.nivel;
}
if (options?.soloDetalle) {
query += ' AND CESDECATALOGO = 1';
}
query += ' ORDER BY CCODIGOCUENTA';
const result = await this.client.queryMany<{
id: number;
codigo: string;
nombre: string;
tipoNum: number;
naturalezaNum: number;
nivel: number;
codigoPadre: string | null;
esDeCatalogoNum: number;
codigoAgrupador: string | null;
saldoInicial: number | null;
activa: number;
fechaAlta: Date | null;
moneda: string | null;
}>(query, params);
return result.map((row) => ({
id: row.id,
codigo: row.codigo?.trim() || '',
nombre: row.nombre?.trim() || '',
tipo: this.getTipoCuentaNombre(row.tipoNum),
naturaleza: row.naturalezaNum === 1 ? 'Deudora' : 'Acreedora' as NaturalezaCuenta,
nivel: row.nivel,
codigoPadre: row.codigoPadre?.trim() || undefined,
esDeCatalogo: row.esDeCatalogoNum === 1,
codigoAgrupador: row.codigoAgrupador?.trim() || undefined,
saldoInicial: row.saldoInicial || 0,
activa: row.activa === 1,
fechaAlta: row.fechaAlta || undefined,
moneda: row.moneda?.trim() || undefined,
}));
}
/**
* Obtiene una cuenta por su codigo
*/
async getCuentaByCodigo(codigo: string): Promise<CONTPAQiCuenta | null> {
const cuentas = await this.getCatalogoCuentas({ activas: false });
return cuentas.find((c) => c.codigo === codigo) || null;
}
// ============================================================================
// Polizas
// ============================================================================
/**
* Obtiene polizas contables por rango de fechas
*
* Tablas:
* - Polizas: Encabezado de polizas
* - CIDPOLIZA: ID de la poliza
* - CTIPOPOLIZA: Tipo (1=Ingresos, 2=Egresos, 3=Diario, 4=Orden)
* - CNUMPOLIZA: Numero de poliza
* - CFECHA: Fecha
* - CCONCEPTO: Concepto
* - CIDEJERCICIO: Ejercicio
* - CIDPERIODO: Periodo
* - CAFECTADA: Si esta afectada a saldos
*
* - MovPolizas: Movimientos de poliza
* - CIDMOVPOLIZA: ID del movimiento
* - CIDPOLIZA: ID de poliza padre
* - CNUMEROPARTIDA: Numero de movimiento
* - CCODIGOCUENTA: Codigo de cuenta
* - CCONCEPTO: Concepto del movimiento
* - CCARGO: Importe cargo
* - CABONO: Importe abono
* - CREFERENCIA: Referencia
*/
async getPolizas(
fechaInicio: Date,
fechaFin: Date,
options?: PolizaFilter
): Promise<CONTPAQiPoliza[]> {
let query = `
SELECT
p.CIDPOLIZA as id,
p.CTIPOPOLIZA as tipoNum,
p.CNUMPOLIZA as numero,
p.CFECHA as fecha,
p.CCONCEPTO as concepto,
p.CIDEJERCICIO as ejercicio,
p.CIDPERIODO as periodo,
p.CAFECTADA as afectada,
p.CIMPRESA as impresa,
p.CUSUARIO as usuario,
p.CFECHACREACION as fechaCreacion,
p.CUUIDCFDI as uuidCFDI
FROM Polizas p
WHERE p.CFECHA >= @fechaInicio
AND p.CFECHA <= @fechaFin
`;
const params: Record<string, unknown> = {
fechaInicio,
fechaFin,
};
if (options?.tipoPoliza) {
query += ' AND p.CTIPOPOLIZA = @tipoPoliza';
params.tipoPoliza = this.getTipoPolizaNumero(options.tipoPoliza);
}
if (options?.ejercicio) {
query += ' AND p.CIDEJERCICIO = @ejercicio';
params.ejercicio = options.ejercicio;
}
if (options?.periodoInicio && options?.periodoFin) {
query += ' AND p.CIDPERIODO >= @periodoInicio AND p.CIDPERIODO <= @periodoFin';
params.periodoInicio = options.periodoInicio;
params.periodoFin = options.periodoFin;
}
if (options?.soloAfectadas !== false) {
query += ' AND p.CAFECTADA = 1';
}
query += ' ORDER BY p.CFECHA, p.CTIPOPOLIZA, p.CNUMPOLIZA';
// Aplicar paginacion
if (options?.limit) {
query += ` OFFSET ${options.page ? (options.page - 1) * options.limit : 0} ROWS`;
query += ` FETCH NEXT ${options.limit} ROWS ONLY`;
}
const polizasResult = await this.client.queryMany<{
id: number;
tipoNum: number;
numero: number;
fecha: Date;
concepto: string;
ejercicio: number;
periodo: number;
afectada: number;
impresa: number | null;
usuario: string | null;
fechaCreacion: Date | null;
uuidCFDI: string | null;
}>(query, params);
// Obtener movimientos de cada poliza
const polizas: CONTPAQiPoliza[] = [];
for (const polizaRow of polizasResult) {
const movimientos = await this.getMovimientosPoliza(polizaRow.id);
const totalCargos = movimientos.reduce(
(sum, m) => sum + (m.tipoMovimiento === 'Cargo' ? m.importe : 0),
0
);
const totalAbonos = movimientos.reduce(
(sum, m) => sum + (m.tipoMovimiento === 'Abono' ? m.importe : 0),
0
);
polizas.push({
id: polizaRow.id,
tipo: this.getTipoPolizaNombre(polizaRow.tipoNum),
numero: polizaRow.numero,
fecha: polizaRow.fecha,
concepto: polizaRow.concepto?.trim() || '',
ejercicio: polizaRow.ejercicio,
periodo: polizaRow.periodo,
totalCargos,
totalAbonos,
cuadrada: Math.abs(totalCargos - totalAbonos) < 0.01,
afectada: polizaRow.afectada === 1,
movimientos,
impresa: polizaRow.impresa === 1,
usuario: polizaRow.usuario?.trim() || undefined,
fechaCreacion: polizaRow.fechaCreacion || undefined,
uuidCFDI: polizaRow.uuidCFDI?.trim() || undefined,
});
}
return polizas;
}
/**
* Obtiene los movimientos de una poliza
*/
async getMovimientosPoliza(polizaId: number): Promise<CONTPAQiMovimiento[]> {
const query = `
SELECT
m.CIDMOVPOLIZA as id,
m.CIDPOLIZA as polizaId,
m.CNUMEROPARTIDA as numMovimiento,
m.CCODIGOCUENTA as codigoCuenta,
c.CNOMBRECUENTA as nombreCuenta,
m.CCONCEPTO as concepto,
m.CCARGO as cargo,
m.CABONO as abono,
m.CREFERENCIA as referencia,
m.CSEGMENTONEGOCIO as segmentoNegocio,
m.CDIARIO as diario,
m.CUUIDCFDI as uuidCFDI,
m.CRFCTERCERO as rfcTercero,
m.CNUMFACTURA as numFactura,
m.CTIPOCAMBIO as tipoCambio,
m.CIMPORTEME as importeME
FROM MovPolizas m
LEFT JOIN Cuentas c ON m.CCODIGOCUENTA = c.CCODIGOCUENTA
WHERE m.CIDPOLIZA = @polizaId
ORDER BY m.CNUMEROPARTIDA
`;
const result = await this.client.queryMany<{
id: number;
polizaId: number;
numMovimiento: number;
codigoCuenta: string;
nombreCuenta: string | null;
concepto: string;
cargo: number;
abono: number;
referencia: string | null;
segmentoNegocio: string | null;
diario: string | null;
uuidCFDI: string | null;
rfcTercero: string | null;
numFactura: string | null;
tipoCambio: number | null;
importeME: number | null;
}>(query, { polizaId });
return result.map((row) => {
const cargo = row.cargo || 0;
const abono = row.abono || 0;
return {
id: row.id,
polizaId: row.polizaId,
numMovimiento: row.numMovimiento,
codigoCuenta: row.codigoCuenta?.trim() || '',
nombreCuenta: row.nombreCuenta?.trim() || undefined,
concepto: row.concepto?.trim() || '',
tipoMovimiento: cargo > 0 ? 'Cargo' : 'Abono' as TipoMovimiento,
importe: cargo > 0 ? cargo : abono,
referencia: row.referencia?.trim() || undefined,
segmentoNegocio: row.segmentoNegocio?.trim() || undefined,
diario: row.diario?.trim() || undefined,
uuidCFDI: row.uuidCFDI?.trim() || undefined,
rfcTercero: row.rfcTercero?.trim() || undefined,
numFactura: row.numFactura?.trim() || undefined,
tipoCambio: row.tipoCambio || undefined,
importeME: row.importeME || undefined,
};
});
}
// ============================================================================
// Balanza de Comprobacion
// ============================================================================
/**
* Genera la balanza de comprobacion para un periodo
*
* Tabla: SaldosCuentas (o calculado de MovPolizas)
* - CIDCUENTA: ID de cuenta
* - CIDEJERCICIO: Ejercicio
* - CIDPERIODO: Periodo
* - CSALDOINICIAL: Saldo inicial
* - CCARGOS: Total cargos
* - CABONOS: Total abonos
* - CSALDOFINAL: Saldo final
*/
async getBalanzaComprobacion(periodo: PeriodoQuery): Promise<CONTPAQiBalanzaComprobacion> {
// Primero intentar obtener de la tabla de saldos
let query = `
SELECT
c.CCODIGOCUENTA as codigoCuenta,
c.CNOMBRECUENTA as nombreCuenta,
c.CTIPOCUENTA as tipoCuenta,
c.CNIVEL as nivel,
c.CCODIGOAGRUPADOR as codigoAgrupador,
ISNULL(s.CSALDOINICIAL, 0) as saldoInicial,
ISNULL(s.CCARGOS, 0) as cargos,
ISNULL(s.CABONOS, 0) as abonos,
ISNULL(s.CSALDOFINAL, 0) as saldoFinal
FROM Cuentas c
LEFT JOIN SaldosCuentas s ON c.CIDCUENTA = s.CIDCUENTA
AND s.CIDEJERCICIO = @ejercicio
AND s.CIDPERIODO = @periodo
WHERE c.CESTATUS = 1
AND c.CESDECATALOGO = 1
ORDER BY c.CCODIGOCUENTA
`;
try {
const result = await this.client.queryMany<{
codigoCuenta: string;
nombreCuenta: string;
tipoCuenta: number;
nivel: number;
codigoAgrupador: string | null;
saldoInicial: number;
cargos: number;
abonos: number;
saldoFinal: number;
}>(query, { ejercicio: periodo.ejercicio, periodo: periodo.periodo });
const lineas: CONTPAQiBalanzaLinea[] = result.map((row) => ({
codigoCuenta: row.codigoCuenta?.trim() || '',
nombreCuenta: row.nombreCuenta?.trim() || '',
tipoCuenta: this.getTipoCuentaNombre(row.tipoCuenta),
nivel: row.nivel,
saldoInicial: row.saldoInicial,
cargos: row.cargos,
abonos: row.abonos,
saldoFinal: row.saldoFinal,
codigoAgrupador: row.codigoAgrupador?.trim() || undefined,
}));
// Calcular totales
const totales = lineas.reduce(
(acc, linea) => {
if (linea.saldoInicial >= 0) {
acc.saldoInicialDeudor += linea.saldoInicial;
} else {
acc.saldoInicialAcreedor += Math.abs(linea.saldoInicial);
}
acc.cargos += linea.cargos;
acc.abonos += linea.abonos;
if (linea.saldoFinal >= 0) {
acc.saldoFinalDeudor += linea.saldoFinal;
} else {
acc.saldoFinalAcreedor += Math.abs(linea.saldoFinal);
}
return acc;
},
{
saldoInicialDeudor: 0,
saldoInicialAcreedor: 0,
cargos: 0,
abonos: 0,
saldoFinalDeudor: 0,
saldoFinalAcreedor: 0,
}
);
return {
ejercicio: periodo.ejercicio,
periodo: periodo.periodo,
fechaGeneracion: new Date(),
tipo: 'Normal',
lineas,
totales,
};
} catch (error) {
// Si no existe la tabla SaldosCuentas, calcular de movimientos
return this.calcularBalanzaDeMovimientos(periodo);
}
}
/**
* Calcula la balanza de comprobacion directamente de los movimientos
*/
private async calcularBalanzaDeMovimientos(
periodo: PeriodoQuery
): Promise<CONTPAQiBalanzaComprobacion> {
// Calcular saldos iniciales (acumulado de periodos anteriores)
const queryInicial = `
SELECT
m.CCODIGOCUENTA as codigoCuenta,
SUM(ISNULL(m.CCARGO, 0)) as cargos,
SUM(ISNULL(m.CABONO, 0)) as abonos
FROM MovPolizas m
INNER JOIN Polizas p ON m.CIDPOLIZA = p.CIDPOLIZA
WHERE p.CIDEJERCICIO = @ejercicio
AND p.CIDPERIODO < @periodo
AND p.CAFECTADA = 1
GROUP BY m.CCODIGOCUENTA
`;
const queryPeriodo = `
SELECT
m.CCODIGOCUENTA as codigoCuenta,
SUM(ISNULL(m.CCARGO, 0)) as cargos,
SUM(ISNULL(m.CABONO, 0)) as abonos
FROM MovPolizas m
INNER JOIN Polizas p ON m.CIDPOLIZA = p.CIDPOLIZA
WHERE p.CIDEJERCICIO = @ejercicio
AND p.CIDPERIODO = @periodo
AND p.CAFECTADA = 1
GROUP BY m.CCODIGOCUENTA
`;
const params = { ejercicio: periodo.ejercicio, periodo: periodo.periodo };
const [saldosIniciales, saldosPeriodo, cuentas] = await Promise.all([
this.client.queryMany<{
codigoCuenta: string;
cargos: number;
abonos: number;
}>(queryInicial, params),
this.client.queryMany<{
codigoCuenta: string;
cargos: number;
abonos: number;
}>(queryPeriodo, params),
this.getCatalogoCuentas({ activas: true, soloDetalle: true }),
]);
// Crear mapa de saldos iniciales
const inicialesMap = new Map<string, number>();
for (const s of saldosIniciales) {
const codigo = s.codigoCuenta?.trim() || '';
inicialesMap.set(codigo, s.cargos - s.abonos);
}
// Crear mapa de movimientos del periodo
const periodoMap = new Map<string, { cargos: number; abonos: number }>();
for (const s of saldosPeriodo) {
const codigo = s.codigoCuenta?.trim() || '';
periodoMap.set(codigo, { cargos: s.cargos, abonos: s.abonos });
}
// Generar lineas de balanza
const lineas: CONTPAQiBalanzaLinea[] = cuentas.map((cuenta) => {
const saldoInicial = inicialesMap.get(cuenta.codigo) || 0;
const movs = periodoMap.get(cuenta.codigo) || { cargos: 0, abonos: 0 };
const saldoFinal = saldoInicial + movs.cargos - movs.abonos;
return {
codigoCuenta: cuenta.codigo,
nombreCuenta: cuenta.nombre,
tipoCuenta: cuenta.tipo,
nivel: cuenta.nivel,
saldoInicial,
cargos: movs.cargos,
abonos: movs.abonos,
saldoFinal,
codigoAgrupador: cuenta.codigoAgrupador,
};
});
// Filtrar cuentas sin movimiento
const lineasConMovimiento = lineas.filter(
(l) => l.saldoInicial !== 0 || l.cargos !== 0 || l.abonos !== 0
);
// Calcular totales
const totales = lineasConMovimiento.reduce(
(acc, linea) => {
if (linea.saldoInicial >= 0) {
acc.saldoInicialDeudor += linea.saldoInicial;
} else {
acc.saldoInicialAcreedor += Math.abs(linea.saldoInicial);
}
acc.cargos += linea.cargos;
acc.abonos += linea.abonos;
if (linea.saldoFinal >= 0) {
acc.saldoFinalDeudor += linea.saldoFinal;
} else {
acc.saldoFinalAcreedor += Math.abs(linea.saldoFinal);
}
return acc;
},
{
saldoInicialDeudor: 0,
saldoInicialAcreedor: 0,
cargos: 0,
abonos: 0,
saldoFinalDeudor: 0,
saldoFinalAcreedor: 0,
}
);
return {
ejercicio: periodo.ejercicio,
periodo: periodo.periodo,
fechaGeneracion: new Date(),
tipo: 'Normal',
lineas: lineasConMovimiento,
totales,
};
}
// ============================================================================
// Estado de Resultados
// ============================================================================
/**
* Genera el estado de resultados para un periodo
*/
async getEstadoResultados(periodo: PeriodoQuery): Promise<CONTPAQiEstadoResultados> {
// Obtener balanza para el periodo
const balanza = await this.getBalanzaComprobacion(periodo);
// Filtrar solo cuentas de resultados (Ingresos, Costos, Gastos)
const lineasResultados = balanza.lineas.filter((l) =>
['Ingreso', 'Costo', 'Gasto'].includes(l.tipoCuenta)
);
const lineas: CONTPAQiEstadoResultadosLinea[] = lineasResultados.map((l) => ({
codigoCuenta: l.codigoCuenta,
nombreCuenta: l.nombreCuenta,
tipo: l.tipoCuenta as 'Ingreso' | 'Costo' | 'Gasto',
nivel: l.nivel,
importeAcumulado: Math.abs(l.saldoFinal),
importePeriodo: Math.abs(l.cargos - l.abonos),
esDeCatalogo: true,
}));
// Calcular resumen
const totalIngresos = lineas
.filter((l) => l.tipo === 'Ingreso')
.reduce((sum, l) => sum + l.importeAcumulado, 0);
const totalCostos = lineas
.filter((l) => l.tipo === 'Costo')
.reduce((sum, l) => sum + l.importeAcumulado, 0);
const totalGastos = lineas
.filter((l) => l.tipo === 'Gasto')
.reduce((sum, l) => sum + l.importeAcumulado, 0);
const utilidadBruta = totalIngresos - totalCostos;
const utilidadOperacion = utilidadBruta - totalGastos;
return {
ejercicio: periodo.ejercicio,
periodoInicio: 1,
periodoFin: periodo.periodo,
fechaGeneracion: new Date(),
lineas,
resumen: {
totalIngresos,
totalCostos,
utilidadBruta,
totalGastos,
utilidadOperacion,
otrosIngresos: 0,
otrosGastos: 0,
utilidadNeta: utilidadOperacion,
},
};
}
// ============================================================================
// Saldos de Cuentas
// ============================================================================
/**
* Obtiene los saldos de las cuentas a una fecha determinada
*/
async getSaldosCuentas(
fecha: Date
): Promise<Array<{
codigoCuenta: string;
nombreCuenta: string;
tipoCuenta: TipoCuenta;
saldo: number;
}>> {
const query = `
SELECT
c.CCODIGOCUENTA as codigoCuenta,
c.CNOMBRECUENTA as nombreCuenta,
c.CTIPOCUENTA as tipoCuenta,
ISNULL(SUM(ISNULL(m.CCARGO, 0) - ISNULL(m.CABONO, 0)), 0) as saldo
FROM Cuentas c
LEFT JOIN MovPolizas m ON c.CCODIGOCUENTA = m.CCODIGOCUENTA
LEFT JOIN Polizas p ON m.CIDPOLIZA = p.CIDPOLIZA
AND p.CFECHA <= @fecha
AND p.CAFECTADA = 1
WHERE c.CESTATUS = 1
AND c.CESDECATALOGO = 1
GROUP BY c.CCODIGOCUENTA, c.CNOMBRECUENTA, c.CTIPOCUENTA
HAVING ISNULL(SUM(ISNULL(m.CCARGO, 0) - ISNULL(m.CABONO, 0)), 0) <> 0
ORDER BY c.CCODIGOCUENTA
`;
const result = await this.client.queryMany<{
codigoCuenta: string;
nombreCuenta: string;
tipoCuenta: number;
saldo: number;
}>(query, { fecha });
return result.map((row) => ({
codigoCuenta: row.codigoCuenta?.trim() || '',
nombreCuenta: row.nombreCuenta?.trim() || '',
tipoCuenta: this.getTipoCuentaNombre(row.tipoCuenta),
saldo: row.saldo,
}));
}
/**
* Obtiene el saldo de una cuenta especifica
*/
async getSaldoCuenta(codigoCuenta: string, fecha: Date): Promise<number> {
const query = `
SELECT
ISNULL(SUM(ISNULL(m.CCARGO, 0) - ISNULL(m.CABONO, 0)), 0) as saldo
FROM MovPolizas m
INNER JOIN Polizas p ON m.CIDPOLIZA = p.CIDPOLIZA
WHERE m.CCODIGOCUENTA = @codigoCuenta
AND p.CFECHA <= @fecha
AND p.CAFECTADA = 1
`;
const result = await this.client.queryOne<{ saldo: number }>(query, {
codigoCuenta,
fecha,
});
return result?.saldo || 0;
}
// ============================================================================
// Auxiliares de Cuentas
// ============================================================================
/**
* Obtiene el auxiliar (movimientos) de una cuenta en un rango de fechas
*/
async getAuxiliarCuenta(
codigoCuenta: string,
fechaInicio: Date,
fechaFin: Date
): Promise<CONTPAQiMovimiento[]> {
const query = `
SELECT
m.CIDMOVPOLIZA as id,
m.CIDPOLIZA as polizaId,
m.CNUMEROPARTIDA as numMovimiento,
m.CCODIGOCUENTA as codigoCuenta,
p.CFECHA as fecha,
p.CTIPOPOLIZA as tipoPoliza,
p.CNUMPOLIZA as numPoliza,
m.CCONCEPTO as concepto,
m.CCARGO as cargo,
m.CABONO as abono,
m.CREFERENCIA as referencia,
m.CUUIDCFDI as uuidCFDI,
m.CRFCTERCERO as rfcTercero
FROM MovPolizas m
INNER JOIN Polizas p ON m.CIDPOLIZA = p.CIDPOLIZA
WHERE m.CCODIGOCUENTA = @codigoCuenta
AND p.CFECHA >= @fechaInicio
AND p.CFECHA <= @fechaFin
AND p.CAFECTADA = 1
ORDER BY p.CFECHA, p.CTIPOPOLIZA, p.CNUMPOLIZA, m.CNUMEROPARTIDA
`;
const result = await this.client.queryMany<{
id: number;
polizaId: number;
numMovimiento: number;
codigoCuenta: string;
fecha: Date;
tipoPoliza: number;
numPoliza: number;
concepto: string;
cargo: number;
abono: number;
referencia: string | null;
uuidCFDI: string | null;
rfcTercero: string | null;
}>(query, { codigoCuenta, fechaInicio, fechaFin });
return result.map((row) => {
const cargo = row.cargo || 0;
const abono = row.abono || 0;
return {
id: row.id,
polizaId: row.polizaId,
numMovimiento: row.numMovimiento,
codigoCuenta: row.codigoCuenta?.trim() || '',
concepto: row.concepto?.trim() || '',
tipoMovimiento: cargo > 0 ? 'Cargo' : 'Abono' as TipoMovimiento,
importe: cargo > 0 ? cargo : abono,
referencia: row.referencia?.trim() || undefined,
uuidCFDI: row.uuidCFDI?.trim() || undefined,
rfcTercero: row.rfcTercero?.trim() || undefined,
};
});
}
// ============================================================================
// Helpers
// ============================================================================
private getTipoCuentaNumero(tipo: TipoCuenta): number {
const tipos: Record<TipoCuenta, number> = {
Activo: 1,
Pasivo: 2,
Capital: 3,
Ingreso: 4,
Costo: 5,
Gasto: 6,
Orden: 7,
};
return tipos[tipo];
}
private getTipoCuentaNombre(tipo: number): TipoCuenta {
const tipos: Record<number, TipoCuenta> = {
1: 'Activo',
2: 'Pasivo',
3: 'Capital',
4: 'Ingreso',
5: 'Costo',
6: 'Gasto',
7: 'Orden',
};
return tipos[tipo] || 'Orden';
}
private getTipoPolizaNumero(tipo: TipoPoliza): number {
const tipos: Record<TipoPoliza, number> = {
Ingresos: 1,
Egresos: 2,
Diario: 3,
Orden: 4,
};
return tipos[tipo];
}
private getTipoPolizaNombre(tipo: number): TipoPoliza {
const tipos: Record<number, TipoPoliza> = {
1: 'Ingresos',
2: 'Egresos',
3: 'Diario',
4: 'Orden',
};
return tipos[tipo] || 'Diario';
}
}
/**
* Crea una instancia del conector de Contabilidad
*/
export function createContabilidadConnector(client: CONTPAQiClient): ContabilidadConnector {
return new ContabilidadConnector(client);
}

View File

@@ -0,0 +1,652 @@
/**
* CONTPAQi SQL Server Client
* Cliente de conexion a bases de datos SQL Server de CONTPAQi
*/
import * as sql from 'mssql';
import { randomUUID } from 'crypto';
import {
CONTPAQiConfig,
CONTPAQiConnection,
CONTPAQiEmpresa,
CONTPAQiProducto,
CONTPAQiConnectionError,
CONTPAQiQueryError,
} from './contpaqi.types.js';
import { validateConfig } from './contpaqi.schema.js';
// ============================================================================
// Pool Manager - Gestiona multiples conexiones a diferentes empresas
// ============================================================================
/**
* Pool de conexiones registrado
*/
interface RegisteredPool {
pool: sql.ConnectionPool;
config: CONTPAQiConfig;
empresa?: CONTPAQiEmpresa;
createdAt: Date;
lastUsed: Date;
connectionId: string;
}
/**
* Manager de pools de conexion para CONTPAQi
* Mantiene un pool por cada base de datos de empresa
*/
class CONTPAQiPoolManager {
private pools: Map<string, RegisteredPool> = new Map();
private cleanupInterval: NodeJS.Timeout | null = null;
private readonly maxIdleTime = 30 * 60 * 1000; // 30 minutos
constructor() {
// Limpieza automatica de pools inactivos
this.startCleanupInterval();
}
/**
* Genera una clave unica para identificar un pool
*/
private getPoolKey(config: CONTPAQiConfig, database?: string): string {
const db = database || config.database;
return `${config.host}:${config.port}:${db}:${config.user}`;
}
/**
* Obtiene o crea un pool de conexiones
*/
async getPool(config: CONTPAQiConfig, database?: string): Promise<sql.ConnectionPool> {
const key = this.getPoolKey(config, database);
// Verificar si ya existe el pool
if (this.pools.has(key)) {
const registered = this.pools.get(key)!;
registered.lastUsed = new Date();
// Verificar si el pool esta conectado
if (registered.pool.connected) {
return registered.pool;
}
// Si no esta conectado, intentar reconectar
try {
await registered.pool.connect();
return registered.pool;
} catch (error) {
// Si falla reconexion, eliminar y crear nuevo
this.pools.delete(key);
}
}
// Crear nuevo pool
const pool = await this.createPool(config, database);
const connectionId = randomUUID();
this.pools.set(key, {
pool,
config,
createdAt: new Date(),
lastUsed: new Date(),
connectionId,
});
return pool;
}
/**
* Crea un nuevo pool de conexiones
*/
private async createPool(config: CONTPAQiConfig, database?: string): Promise<sql.ConnectionPool> {
const validatedConfig = validateConfig(config);
const sqlConfig: sql.config = {
server: validatedConfig.host,
port: validatedConfig.port,
user: validatedConfig.user,
password: validatedConfig.password,
database: database || validatedConfig.database,
options: {
encrypt: validatedConfig.encrypt,
trustServerCertificate: validatedConfig.trustServerCertificate,
enableArithAbort: true,
// Importante para CONTPAQi que usa latin1_general_ci_as
useUTC: false,
},
connectionTimeout: validatedConfig.connectionTimeout,
requestTimeout: validatedConfig.requestTimeout,
pool: {
min: validatedConfig.poolMin,
max: validatedConfig.poolMax,
idleTimeoutMillis: validatedConfig.poolIdleTimeout,
},
};
try {
const pool = new sql.ConnectionPool(sqlConfig);
// Manejar errores del pool
pool.on('error', (err) => {
console.error(`[CONTPAQi] Error en pool ${database}:`, err.message);
});
await pool.connect();
return pool;
} catch (error) {
const message = error instanceof Error ? error.message : 'Error de conexion';
throw new CONTPAQiConnectionError(
`No se pudo conectar a la base de datos ${database || config.database}`,
'CONNECTION_FAILED',
message
);
}
}
/**
* Cierra un pool especifico
*/
async closePool(config: CONTPAQiConfig, database?: string): Promise<void> {
const key = this.getPoolKey(config, database);
if (this.pools.has(key)) {
const registered = this.pools.get(key)!;
try {
await registered.pool.close();
} catch (error) {
console.error(`[CONTPAQi] Error cerrando pool:`, error);
}
this.pools.delete(key);
}
}
/**
* Cierra todos los pools
*/
async closeAll(): Promise<void> {
const closePromises: Promise<void>[] = [];
for (const [key, registered] of this.pools) {
closePromises.push(
registered.pool.close().catch((error) => {
console.error(`[CONTPAQi] Error cerrando pool ${key}:`, error);
})
);
}
await Promise.all(closePromises);
this.pools.clear();
this.stopCleanupInterval();
}
/**
* Inicia el intervalo de limpieza de pools inactivos
*/
private startCleanupInterval(): void {
if (this.cleanupInterval) return;
this.cleanupInterval = setInterval(() => {
this.cleanupIdlePools();
}, 5 * 60 * 1000); // Cada 5 minutos
}
/**
* Detiene el intervalo de limpieza
*/
private stopCleanupInterval(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
}
/**
* Limpia pools que han estado inactivos demasiado tiempo
*/
private async cleanupIdlePools(): Promise<void> {
const now = Date.now();
for (const [key, registered] of this.pools) {
const idleTime = now - registered.lastUsed.getTime();
if (idleTime > this.maxIdleTime) {
try {
await registered.pool.close();
this.pools.delete(key);
console.log(`[CONTPAQi] Pool ${key} cerrado por inactividad`);
} catch (error) {
console.error(`[CONTPAQi] Error limpiando pool ${key}:`, error);
}
}
}
}
/**
* Obtiene estadisticas de los pools
*/
getStats(): {
totalPools: number;
pools: Array<{
key: string;
database: string;
connected: boolean;
createdAt: Date;
lastUsed: Date;
}>;
} {
const pools = Array.from(this.pools.entries()).map(([key, registered]) => ({
key,
database: registered.config.database,
connected: registered.pool.connected,
createdAt: registered.createdAt,
lastUsed: registered.lastUsed,
}));
return {
totalPools: this.pools.size,
pools,
};
}
}
// Instancia global del pool manager
const poolManager = new CONTPAQiPoolManager();
// ============================================================================
// Cliente CONTPAQi
// ============================================================================
/**
* Cliente para interactuar con bases de datos de CONTPAQi
*/
export class CONTPAQiClient {
private config: CONTPAQiConfig;
private currentDatabase: string;
private connectionId: string;
constructor(config: CONTPAQiConfig) {
this.config = validateConfig(config);
this.currentDatabase = config.database;
this.connectionId = randomUUID();
}
/**
* Obtiene el pool de conexiones actual
*/
private async getPool(): Promise<sql.ConnectionPool> {
return poolManager.getPool(this.config, this.currentDatabase);
}
/**
* Cambia a una base de datos diferente (para multi-empresa)
*/
async useDatabase(database: string): Promise<void> {
this.currentDatabase = database;
}
/**
* Ejecuta una consulta parametrizada
*/
async query<T = Record<string, unknown>>(
queryText: string,
params?: Record<string, unknown>
): Promise<sql.IResult<T>> {
const pool = await this.getPool();
try {
const request = pool.request();
// Agregar parametros de forma segura
if (params) {
for (const [key, value] of Object.entries(params)) {
request.input(key, this.getSqlType(value), value);
}
}
return await request.query<T>(queryText);
} catch (error) {
const message = error instanceof Error ? error.message : 'Error de consulta';
throw new CONTPAQiQueryError(
`Error ejecutando consulta: ${message}`,
queryText,
message
);
}
}
/**
* Ejecuta una consulta y retorna el primer resultado
*/
async queryOne<T = Record<string, unknown>>(
queryText: string,
params?: Record<string, unknown>
): Promise<T | null> {
const result = await this.query<T>(queryText, params);
return result.recordset[0] || null;
}
/**
* Ejecuta una consulta y retorna todos los resultados
*/
async queryMany<T = Record<string, unknown>>(
queryText: string,
params?: Record<string, unknown>
): Promise<T[]> {
const result = await this.query<T>(queryText, params);
return result.recordset;
}
/**
* Ejecuta un stored procedure
*/
async execute<T = Record<string, unknown>>(
procedureName: string,
params?: Record<string, unknown>
): Promise<sql.IProcedureResult<T>> {
const pool = await this.getPool();
try {
const request = pool.request();
if (params) {
for (const [key, value] of Object.entries(params)) {
request.input(key, this.getSqlType(value), value);
}
}
return await request.execute<T>(procedureName);
} catch (error) {
const message = error instanceof Error ? error.message : 'Error de ejecucion';
throw new CONTPAQiQueryError(
`Error ejecutando stored procedure ${procedureName}: ${message}`,
procedureName,
message
);
}
}
/**
* Ejecuta multiples consultas en una transaccion
*/
async transaction<T>(
callback: (transaction: sql.Transaction) => Promise<T>
): Promise<T> {
const pool = await this.getPool();
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
const result = await callback(transaction);
await transaction.commit();
return result;
} catch (error) {
await transaction.rollback();
throw error;
}
}
/**
* Verifica la conexion a la base de datos
*/
async testConnection(): Promise<boolean> {
try {
const result = await this.queryOne<{ test: number }>('SELECT 1 as test');
return result?.test === 1;
} catch {
return false;
}
}
/**
* Obtiene informacion de la conexion
*/
getConnectionInfo(): CONTPAQiConnection {
return {
id: this.connectionId,
config: this.config,
producto: 'Contabilidad', // Se actualiza segun el producto
empresa: {
id: 0,
codigo: '',
nombre: '',
rfc: '',
baseDatos: this.currentDatabase,
activa: true,
producto: 'Contabilidad',
},
connectedAt: new Date(),
status: 'connected',
};
}
/**
* Obtiene las empresas disponibles en CONTPAQi
*/
async getEmpresas(producto: CONTPAQiProducto): Promise<CONTPAQiEmpresa[]> {
// La estructura de tablas varia segun el producto
let query: string;
switch (producto) {
case 'Contabilidad':
// Tabla de empresas en CONTPAQi Contabilidad
query = `
SELECT
CIDEMPRESA as id,
CCODIGOEMPRESA as codigo,
CNOMBREEMPRESA as nombre,
CRFC as rfc,
CRUTADATOS as baseDatos,
CRUTAEMPRESA as ruta,
CIDEJERCICIO as ejercicioActual,
CIDPERIODO as periodoActual,
CASE WHEN CESTATUS = 1 THEN 1 ELSE 0 END as activa
FROM Empresas
WHERE CESTATUS = 1
ORDER BY CNOMBREEMPRESA
`;
break;
case 'Comercial':
// Tabla de empresas en CONTPAQi Comercial
query = `
SELECT
CIDEMPRESA as id,
CCODIGOEMPRESA as codigo,
CNOMBREEMPRESA as nombre,
CRFC as rfc,
CRUTADATOS as baseDatos,
CRUTAEMPRESA as ruta,
1 as ejercicioActual,
1 as periodoActual,
CASE WHEN CESTATUS = 1 THEN 1 ELSE 0 END as activa
FROM Empresas
WHERE CESTATUS = 1
ORDER BY CNOMBREEMPRESA
`;
break;
case 'Nominas':
// Tabla de empresas en CONTPAQi Nominas
query = `
SELECT
CIDEMPRESA as id,
CCODIGOEMPRESA as codigo,
CNOMBREEMPRESA as nombre,
CRFC as rfc,
CRUTADATOS as baseDatos,
CRUTAEMPRESA as ruta,
CEJERCICIO as ejercicioActual,
CPERIODO as periodoActual,
CASE WHEN CESTATUS = 1 THEN 1 ELSE 0 END as activa
FROM Empresas
WHERE CESTATUS = 1
ORDER BY CNOMBREEMPRESA
`;
break;
}
try {
const result = await this.queryMany<{
id: number;
codigo: string;
nombre: string;
rfc: string;
baseDatos: string;
ruta?: string;
ejercicioActual?: number;
periodoActual?: number;
activa: number;
}>(query);
return result.map((row) => ({
id: row.id,
codigo: row.codigo?.trim() || '',
nombre: row.nombre?.trim() || '',
rfc: row.rfc?.trim() || '',
baseDatos: row.baseDatos?.trim() || '',
ruta: row.ruta?.trim(),
ejercicioActual: row.ejercicioActual,
periodoActual: row.periodoActual,
activa: row.activa === 1,
producto,
}));
} catch (error) {
// Si falla, puede ser que la tabla no exista o tenga otro nombre
console.warn(`[CONTPAQi] Error obteniendo empresas de ${producto}:`, error);
return [];
}
}
/**
* Conecta a la base de datos de una empresa especifica
*/
async connectToEmpresa(empresa: CONTPAQiEmpresa): Promise<void> {
await this.useDatabase(empresa.baseDatos);
// Verificar conexion
const connected = await this.testConnection();
if (!connected) {
throw new CONTPAQiConnectionError(
`No se pudo conectar a la empresa ${empresa.nombre}`,
'EMPRESA_CONNECTION_FAILED',
`Base de datos: ${empresa.baseDatos}`
);
}
}
/**
* Cierra la conexion actual
*/
async close(): Promise<void> {
await poolManager.closePool(this.config, this.currentDatabase);
}
/**
* Determina el tipo SQL de un valor JavaScript
*/
private getSqlType(value: unknown): sql.ISqlType {
if (value === null || value === undefined) {
return sql.NVarChar;
}
if (typeof value === 'number') {
if (Number.isInteger(value)) {
return sql.Int;
}
return sql.Decimal(18, 6);
}
if (typeof value === 'boolean') {
return sql.Bit;
}
if (value instanceof Date) {
return sql.DateTime;
}
if (typeof value === 'string') {
if (value.length > 4000) {
return sql.NVarChar(sql.MAX);
}
return sql.NVarChar(4000);
}
if (Buffer.isBuffer(value)) {
return sql.VarBinary(sql.MAX);
}
return sql.NVarChar;
}
}
// ============================================================================
// Factory Functions
// ============================================================================
/**
* Crea una nueva instancia del cliente CONTPAQi
*/
export function createCONTPAQiClient(config: CONTPAQiConfig): CONTPAQiClient {
return new CONTPAQiClient(config);
}
/**
* Obtiene el pool manager global
*/
export function getCONTPAQiPoolManager(): CONTPAQiPoolManager {
return poolManager;
}
/**
* Cierra todos los pools de conexion (para cleanup)
*/
export async function closeCONTPAQiConnections(): Promise<void> {
await poolManager.closeAll();
}
/**
* Verifica la conexion a un servidor CONTPAQi
*/
export async function testCONTPAQiConnection(config: CONTPAQiConfig): Promise<{
success: boolean;
message: string;
empresas?: CONTPAQiEmpresa[];
}> {
const client = createCONTPAQiClient(config);
try {
const connected = await client.testConnection();
if (!connected) {
return {
success: false,
message: 'No se pudo establecer conexion con la base de datos',
};
}
// Intentar obtener empresas de cada producto
const empresas: CONTPAQiEmpresa[] = [];
for (const producto of ['Contabilidad', 'Comercial', 'Nominas'] as CONTPAQiProducto[]) {
try {
const productEmpresas = await client.getEmpresas(producto);
empresas.push(...productEmpresas);
} catch {
// Ignorar si no se pueden obtener empresas de un producto
}
}
return {
success: true,
message: `Conexion exitosa. ${empresas.length} empresa(s) encontrada(s).`,
empresas,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Error desconocido';
return {
success: false,
message: `Error de conexion: ${message}`,
};
} finally {
await client.close();
}
}

View File

@@ -0,0 +1,331 @@
/**
* CONTPAQi Validation Schemas (Zod)
* Schemas de validacion para la configuracion de conexion a CONTPAQi
*/
import { z } from 'zod';
// ============================================================================
// Schemas de Configuracion
// ============================================================================
/**
* Schema para la configuracion de conexion a SQL Server
*/
export const CONTPAQiConfigSchema = z.object({
host: z
.string()
.min(1, 'El host es requerido')
.describe('Host del servidor SQL Server'),
port: z
.number()
.int()
.min(1)
.max(65535)
.default(1433)
.describe('Puerto de SQL Server'),
user: z
.string()
.min(1, 'El usuario es requerido')
.describe('Usuario de base de datos'),
password: z
.string()
.min(1, 'La contrasena es requerida')
.describe('Contrasena de base de datos'),
database: z
.string()
.min(1, 'El nombre de la base de datos es requerido')
.describe('Nombre de la base de datos de empresas'),
encrypt: z
.boolean()
.default(false)
.describe('Usar conexion encriptada'),
trustServerCertificate: z
.boolean()
.default(true)
.describe('Confiar en certificado del servidor'),
connectionTimeout: z
.number()
.int()
.min(1000)
.max(60000)
.default(15000)
.describe('Timeout de conexion en ms'),
requestTimeout: z
.number()
.int()
.min(1000)
.max(300000)
.default(30000)
.describe('Timeout de requests en ms'),
poolMin: z
.number()
.int()
.min(0)
.max(100)
.default(0)
.describe('Pool minimo de conexiones'),
poolMax: z
.number()
.int()
.min(1)
.max(100)
.default(10)
.describe('Pool maximo de conexiones'),
poolIdleTimeout: z
.number()
.int()
.min(1000)
.max(3600000)
.default(30000)
.describe('Timeout de inactividad del pool en ms'),
});
/**
* Schema para validar una empresa de CONTPAQi
*/
export const CONTPAQiEmpresaSchema = z.object({
id: z.number().int().positive(),
codigo: z.string().min(1),
nombre: z.string().min(1),
rfc: z.string().regex(
/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/,
'RFC invalido'
),
baseDatos: z.string().min(1),
ruta: z.string().optional(),
ejercicioActual: z.number().int().positive().optional(),
periodoActual: z.number().int().min(1).max(13).optional(),
activa: z.boolean().default(true),
producto: z.enum(['Contabilidad', 'Comercial', 'Nominas']),
});
/**
* Schema para la configuracion de sincronizacion
*/
export const CONTPAQiSyncConfigSchema = z.object({
tenantId: z
.string()
.uuid('El tenantId debe ser un UUID valido'),
connectionConfig: CONTPAQiConfigSchema,
productos: z
.array(z.enum(['Contabilidad', 'Comercial', 'Nominas']))
.min(1, 'Debe seleccionar al menos un producto'),
empresas: z
.array(z.string())
.optional()
.describe('Codigos de empresas a sincronizar (vacio = todas)'),
fechaDesde: z
.date()
.or(z.string().datetime())
.optional()
.transform((val) => (val ? new Date(val) : undefined)),
fechaHasta: z
.date()
.or(z.string().datetime())
.optional()
.transform((val) => (val ? new Date(val) : undefined)),
incremental: z
.boolean()
.default(true)
.describe('Solo sincronizar cambios desde ultima sincronizacion'),
lastSyncTimestamp: z
.date()
.or(z.string().datetime())
.optional()
.transform((val) => (val ? new Date(val) : undefined)),
}).refine(
(data) => {
if (data.fechaDesde && data.fechaHasta) {
return data.fechaDesde <= data.fechaHasta;
}
return true;
},
{
message: 'La fecha desde debe ser menor o igual a la fecha hasta',
path: ['fechaDesde'],
}
);
// ============================================================================
// Schemas de Consultas
// ============================================================================
/**
* Schema para consulta de periodo
*/
export const PeriodoQuerySchema = z.object({
ejercicio: z
.number()
.int()
.min(2000)
.max(2100)
.describe('Ano del ejercicio fiscal'),
periodo: z
.number()
.int()
.min(1)
.max(13)
.describe('Mes del periodo (1-12, 13 para cierre)'),
});
/**
* Schema para consulta de rango de fechas
*/
export const DateRangeQuerySchema = z.object({
fechaInicio: z
.date()
.or(z.string().datetime())
.transform((val) => new Date(val)),
fechaFin: z
.date()
.or(z.string().datetime())
.transform((val) => new Date(val)),
}).refine(
(data) => data.fechaInicio <= data.fechaFin,
{
message: 'La fecha de inicio debe ser menor o igual a la fecha de fin',
path: ['fechaInicio'],
}
);
/**
* Schema para paginacion
*/
export const PaginationSchema = z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(1000).default(100),
orderBy: z.string().optional(),
orderDir: z.enum(['ASC', 'DESC']).default('ASC'),
});
/**
* Schema para filtro de documentos comerciales
*/
export const DocumentoFilterSchema = z.object({
concepto: z.enum([
'FacturaCliente',
'FacturaProveedor',
'NotaCreditoCliente',
'NotaCreditoProveedor',
'NotaCargoCliente',
'NotaCargoProveedor',
'Remision',
'Pedido',
'Cotizacion',
'OrdenCompra',
'DevolucionCliente',
'DevolucionProveedor',
]).optional(),
fechaInicio: z.date().or(z.string().datetime()).optional(),
fechaFin: z.date().or(z.string().datetime()).optional(),
clienteId: z.number().int().positive().optional(),
proveedorId: z.number().int().positive().optional(),
uuid: z.string().uuid().optional(),
estado: z.enum(['Pendiente', 'Parcial', 'Pagado', 'Cancelado']).optional(),
incluyeCancelados: z.boolean().default(false),
}).merge(PaginationSchema);
/**
* Schema para filtro de empleados
*/
export const EmpleadoFilterSchema = z.object({
estado: z.number().int().optional(),
departamento: z.string().optional(),
puesto: z.string().optional(),
tipoContrato: z.enum([
'PorTiempoIndeterminado',
'PorObraoDeterminado',
'PorTemporada',
'SujetoPrueba',
'CapacitacionInicial',
'PorTiempoIndeterminadoDesconexion',
]).optional(),
incluyeBajas: z.boolean().default(false),
}).merge(PaginationSchema);
/**
* Schema para filtro de nominas
*/
export const NominaFilterSchema = z.object({
ejercicio: z.number().int().min(2000).max(2100),
periodoInicio: z.number().int().min(1).max(53).optional(),
periodoFin: z.number().int().min(1).max(53).optional(),
tipoNomina: z.enum(['Ordinaria', 'Extraordinaria']).optional(),
empleadoId: z.number().int().positive().optional(),
}).merge(PaginationSchema);
/**
* Schema para filtro de polizas
*/
export const PolizaFilterSchema = z.object({
ejercicio: z.number().int().min(2000).max(2100),
periodoInicio: z.number().int().min(1).max(13).optional(),
periodoFin: z.number().int().min(1).max(13).optional(),
tipoPoliza: z.enum(['Ingresos', 'Egresos', 'Diario', 'Orden']).optional(),
fechaInicio: z.date().or(z.string().datetime()).optional(),
fechaFin: z.date().or(z.string().datetime()).optional(),
soloAfectadas: z.boolean().default(true),
}).merge(PaginationSchema);
// ============================================================================
// Tipos Inferidos
// ============================================================================
export type CONTPAQiConfigInput = z.infer<typeof CONTPAQiConfigSchema>;
export type CONTPAQiEmpresaInput = z.infer<typeof CONTPAQiEmpresaSchema>;
export type CONTPAQiSyncConfigInput = z.infer<typeof CONTPAQiSyncConfigSchema>;
export type PeriodoQuery = z.infer<typeof PeriodoQuerySchema>;
export type DateRangeQuery = z.infer<typeof DateRangeQuerySchema>;
export type PaginationInput = z.infer<typeof PaginationSchema>;
export type DocumentoFilter = z.infer<typeof DocumentoFilterSchema>;
export type EmpleadoFilter = z.infer<typeof EmpleadoFilterSchema>;
export type NominaFilter = z.infer<typeof NominaFilterSchema>;
export type PolizaFilter = z.infer<typeof PolizaFilterSchema>;
// ============================================================================
// Helpers de Validacion
// ============================================================================
/**
* Valida la configuracion de conexion
*/
export function validateConfig(config: unknown): CONTPAQiConfigInput {
return CONTPAQiConfigSchema.parse(config);
}
/**
* Valida la configuracion de sincronizacion
*/
export function validateSyncConfig(config: unknown): CONTPAQiSyncConfigInput {
return CONTPAQiSyncConfigSchema.parse(config);
}
/**
* Valida un rango de fechas
*/
export function validateDateRange(range: unknown): DateRangeQuery {
return DateRangeQuerySchema.parse(range);
}
/**
* Valida un periodo contable
*/
export function validatePeriodo(periodo: unknown): PeriodoQuery {
return PeriodoQuerySchema.parse(periodo);
}
/**
* Valida un RFC mexicano
*/
export function isValidRFC(rfc: string): boolean {
const rfcRegex = /^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/;
return rfcRegex.test(rfc.toUpperCase());
}
/**
* Formatea un RFC a mayusculas
*/
export function formatRFC(rfc: string): string {
return rfc.toUpperCase().trim();
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
/**
* CONTPAQi Integration Module
*
* Conector completo para integracion con los sistemas CONTPAQi:
* - CONTPAQi Contabilidad
* - CONTPAQi Comercial (ventas/compras)
* - CONTPAQi Nominas
*
* ESTRUCTURA DE TABLAS CONSULTADAS:
*
* ============================================================================
* CONTPAQi CONTABILIDAD
* ============================================================================
*
* Empresas
* - CIDEMPRESA: ID interno
* - CCODIGOEMPRESA: Codigo de empresa
* - CNOMBREEMPRESA: Nombre
* - CRFC: RFC
* - CRUTADATOS: Base de datos de la empresa
*
* Cuentas (Catalogo de cuentas contables)
* - CIDCUENTA: ID de cuenta
* - CCODIGOCUENTA: Codigo (ej: 1101-001)
* - CNOMBRECUENTA: Nombre
* - CTIPOCUENTA: 1=Activo, 2=Pasivo, 3=Capital, 4=Ingreso, 5=Costo, 6=Gasto, 7=Orden
* - CNATURALEZA: 1=Deudora, 2=Acreedora
* - CNIVEL: Nivel jerarquico
* - CCUENTAPADRE: Cuenta padre
* - CESDECATALOGO: 1=Cuenta de detalle
* - CCODIGOAGRUPADOR: Codigo agrupador SAT
*
* Polizas (Encabezado)
* - CIDPOLIZA: ID poliza
* - CTIPOPOLIZA: 1=Ingresos, 2=Egresos, 3=Diario, 4=Orden
* - CNUMPOLIZA: Numero
* - CFECHA: Fecha
* - CCONCEPTO: Descripcion
* - CIDEJERCICIO: Ano fiscal
* - CIDPERIODO: Mes (1-13)
* - CAFECTADA: Si esta aplicada a saldos
* - CUUIDCFDI: UUID del CFDI relacionado
*
* MovPolizas (Detalle de polizas)
* - CIDMOVPOLIZA: ID movimiento
* - CIDPOLIZA: Poliza padre
* - CNUMEROPARTIDA: Numero de linea
* - CCODIGOCUENTA: Cuenta contable
* - CCONCEPTO: Descripcion
* - CCARGO: Importe cargo
* - CABONO: Importe abono
* - CREFERENCIA: Referencia (num cheque, factura, etc)
* - CUUIDCFDI: UUID CFDI
* - CRFCTERCERO: RFC del tercero
*
* SaldosCuentas
* - CIDCUENTA: Cuenta
* - CIDEJERCICIO: Ejercicio
* - CIDPERIODO: Periodo
* - CSALDOINICIAL: Saldo inicial
* - CCARGOS: Total cargos
* - CABONOS: Total abonos
* - CSALDOFINAL: Saldo final
*
* ============================================================================
* CONTPAQi COMERCIAL
* ============================================================================
*
* admClientes (Clientes y Proveedores - tabla unificada)
* - CIDCLIENTEPROVEEDOR: ID
* - CCODIGOCLIENTE: Codigo
* - CRAZONSOCIAL: Razon social
* - CRFC: RFC
* - CTIPOCLIENTE: 1=Cliente, 2=ClienteProveedor, 3=Proveedor
* - CREGIMENFISCAL: Regimen fiscal
* - CUSOCFDI: Uso CFDI predeterminado
* - CLIMITECREDITO: Limite de credito
* - CDIASCREDITO: Dias de credito
* - CSALDOACTUAL: Saldo actual
*
* admDomicilios (Direcciones)
* - CIDDOMICILIO: ID
* - CIDCLIENTEPROVEEDOR: Cliente/Proveedor
* - CTIPO: Tipo de direccion
* - CCALLE, CNUMEROEXTERIOR, CCOLONIA, CCODIGOPOSTAL, etc.
*
* admProductos (Catalogo de productos)
* - CIDPRODUCTO: ID
* - CCODIGOPRODUCTO: Codigo
* - CNOMBREPRODUCTO: Nombre
* - CTIPOPRODUCTO: 1=Producto, 2=Paquete, 3=Servicio
* - CUNIDADMEDIDA: Unidad
* - CCLAVESAT: Clave SAT
* - CPRECIOBASE: Precio base
* - CULTIMOCOSTO: Ultimo costo
* - CCOSTOPROMEDIO: Costo promedio
* - CTASAIVA: Tasa de IVA
*
* admDocumentos (Facturas, Notas, Remisiones, etc.)
* - CIDDOCUMENTO: ID
* - CIDCONCEPTODOCUMENTO: Tipo de documento
* - CSERIEFASCICULO: Serie
* - CFOLIO: Folio
* - CFECHA: Fecha
* - CIDCLIENTEPROVEEDOR: Cliente/Proveedor
* - CSUBTOTAL, CDESCUENTO, CIVA, CTOTAL: Totales
* - CUUID: UUID del CFDI
* - CFORMAPAGO, CMETODOPAGO: Forma y metodo de pago
* - CPENDIENTE: Saldo pendiente
* - CCANCELADO: Si esta cancelado
*
* admMovimientos (Detalle de productos en documentos)
* - CIDMOVIMIENTO: ID
* - CIDDOCUMENTO: Documento padre
* - CIDPRODUCTO: Producto
* - CUNIDADES: Cantidad
* - CPRECIO: Precio unitario
* - CDESCUENTO: Descuento
* - CIMPORTE: Importe
* - CIVA: IVA
*
* admAlmacenes (Almacenes)
* - CIDALMACEN: ID
* - CCODIGOALMACEN: Codigo
* - CNOMBREALMACEN: Nombre
*
* admExistencias (Inventario por almacen)
* - CIDPRODUCTO: Producto
* - CIDALMACEN: Almacen
* - CEXISTENCIA: Cantidad
*
* ============================================================================
* CONTPAQi NOMINAS
* ============================================================================
*
* nomEmpleados (Catalogo de empleados)
* - CIDEMPLEAD: ID
* - CCODIGOEMPLEADO: Codigo/Numero
* - CNOMBRE, CAPELLIDOPATERNO, CAPELLIDOMATERNO: Nombre
* - CRFC, CCURP, CNSS: Identificadores
* - CFECHAALTA, CFECHABAJA: Fechas
* - CTIPOCONTRATO: Tipo de contrato
* - CTIPOREGIMEN: Regimen (Sueldos, Asimilados, etc.)
* - CPERIODICIDADPAGO: Periodicidad
* - CSALARIODIARIO: Salario diario
* - CSALARIODIAINTEGRADO: SDI
* - CDEPARTAMENTO, CPUESTO: Puesto
* - CBANCO, CCLABE: Datos bancarios
*
* nomPeriodos (Periodos de nomina)
* - CIDPERIODO: ID
* - CNUMEROPERIODO: Numero de periodo
* - CEJERCICIO: Ano
* - CFECHAINICIO, CFECHAFIN, CFECHAPAGO: Fechas
* - CTIPONOMINA: 1=Ordinaria, 2=Extraordinaria
*
* nomNominas (Nomina por empleado/periodo)
* - CIDNOMINA: ID
* - CIDEMPLEAD: Empleado
* - CIDPERIODO: Periodo
* - CTOTALPERCEPCIONES, CTOTALDEDUCCIONES: Totales
* - CNETO: Neto a pagar
* - CUUID: UUID del CFDI de nomina
*
* nomMovimientos (Percepciones y deducciones)
* - CIDMOVIMIENTO: ID
* - CIDNOMINA: Nomina
* - CTIPOMOVIMIENTO: 1=Percepcion, 2=Deduccion, 3=OtroPago
* - CIDTIPOMOVIMIENTO: ID del tipo
* - CIMPORTE: Importe
* - CIMPORTEGRAVADO, CIMPORTEEXENTO: Gravado/Exento
*
* nomTiposPercepcion (Catalogo de percepciones)
* - CIDTIPOPERCEPCION: ID
* - CCLAVE: Clave
* - CCONCEPTO: Descripcion
* - CTIPOPERCEPCIONSAT: Clave SAT
*
* nomTiposDeduccion (Catalogo de deducciones)
* - CIDTIPODEDUCCION: ID
* - CCLAVE: Clave
* - CCONCEPTO: Descripcion
* - CTIPODEDUCCIONSAT: Clave SAT
*
* ============================================================================
*
* @example
* ```typescript
* import {
* createCONTPAQiClient,
* createContabilidadConnector,
* createComercialConnector,
* createNominasConnector,
* createCONTPAQiSyncService,
* } from './services/integrations/contpaqi';
*
* // Crear cliente
* const client = createCONTPAQiClient({
* host: 'localhost',
* port: 1433,
* user: 'sa',
* password: 'password',
* database: 'ctCONTPAQi',
* });
*
* // Conectar a una empresa
* const empresas = await client.getEmpresas('Contabilidad');
* await client.connectToEmpresa(empresas[0]);
*
* // Usar conectores especificos
* const contabilidad = createContabilidadConnector(client);
* const cuentas = await contabilidad.getCatalogoCuentas();
* const balanza = await contabilidad.getBalanzaComprobacion({ ejercicio: 2024, periodo: 12 });
*
* // Sincronizar a Horux
* const syncService = createCONTPAQiSyncService({ dbPool, getSchemaName });
* const result = await syncService.syncToHorux({
* tenantId: 'xxx',
* connectionConfig: { ... },
* productos: ['Contabilidad', 'Comercial'],
* fechaDesde: new Date('2024-01-01'),
* fechaHasta: new Date('2024-12-31'),
* });
* ```
*/
// Types
export * from './contpaqi.types.js';
// Schemas
export * from './contpaqi.schema.js';
// Client
export {
CONTPAQiClient,
createCONTPAQiClient,
getCONTPAQiPoolManager,
closeCONTPAQiConnections,
testCONTPAQiConnection,
} from './contpaqi.client.js';
// Connectors
export {
ContabilidadConnector,
createContabilidadConnector,
} from './contabilidad.connector.js';
export {
ComercialConnector,
createComercialConnector,
} from './comercial.connector.js';
export {
NominasConnector,
createNominasConnector,
} from './nominas.connector.js';
// Sync Service
export {
CONTPAQiSyncService,
createCONTPAQiSyncService,
type CONTPAQiSyncServiceConfig,
} from './contpaqi.sync.js';

View File

@@ -0,0 +1,879 @@
/**
* CONTPAQi Nominas Connector
* Conector para el modulo de Nominas de CONTPAQi
*
* TABLAS PRINCIPALES DE CONTPAQi NOMINAS:
*
* EMPLEADOS:
* - NOM10001 (o nomEmpleados): Catalogo de empleados
* - CIDEMPLEAD: ID del empleado
* - CCODIGOEMPLEADO: Codigo/Numero de empleado
* - CNOMBRE, CAPELLIDOPATERNO, CAPELLIDOMATERNO: Nombre
* - CRFC, CCURP, CNSS: Identificadores fiscales
* - CFECHAALTA, CFECHABAJA: Fechas de alta/baja
* - CSALARIODIARIO: Salario diario
*
* PERIODOS:
* - NOM10002 (o nomPeriodos): Periodos de nomina
* - CIDPERIODO: ID del periodo
* - CNUMEROPERIODO: Numero de periodo
* - CFECHAINICIO, CFECHAFIN, CFECHAPAGO: Fechas
*
* MOVIMIENTOS DE NOMINA:
* - NOM10005 (o nomMovimientosNomina): Movimientos por empleado/periodo
* - CIDMOVIMIENTO: ID del movimiento
* - CIDEMPLEAD: Empleado
* - CIDPERIODO: Periodo
* - CIDTIPOMOVIMIENTO: Tipo (percepcion/deduccion)
* - CIMPORTE: Importe
*
* PERCEPCIONES Y DEDUCCIONES:
* - NOM10003 (o nomTiposPercepcion): Catalogo de percepciones
* - NOM10004 (o nomTiposDeduccion): Catalogo de deducciones
*/
import { CONTPAQiClient } from './contpaqi.client.js';
import {
CONTPAQiEmpleado,
CONTPAQiPeriodoNomina,
CONTPAQiNomina,
CONTPAQiPercepcionNomina,
CONTPAQiDeduccionNomina,
CONTPAQiOtroPagoNomina,
CONTPAQiDireccion,
TipoContrato,
TipoRegimenNomina,
TipoJornada,
PeriodicidadPago,
TipoNomina,
EstadoPeriodoNomina,
} from './contpaqi.types.js';
import { EmpleadoFilter, NominaFilter, PaginationInput } from './contpaqi.schema.js';
/**
* Conector para CONTPAQi Nominas
*/
export class NominasConnector {
constructor(private client: CONTPAQiClient) {}
// ============================================================================
// Empleados
// ============================================================================
/**
* Obtiene el catalogo de empleados
*/
async getEmpleados(filter?: EmpleadoFilter): Promise<CONTPAQiEmpleado[]> {
let query = `
SELECT
e.CIDEMPLEAD as id,
e.CCODIGOEMPLEADO as codigo,
e.CNOMBRE as nombre,
e.CAPELLIDOPATERNO as apellidoPaterno,
e.CAPELLIDOMATERNO as apellidoMaterno,
e.CRFC as rfc,
e.CCURP as curp,
e.CNSS as nss,
e.CFECHANACIMIENTO as fechaNacimiento,
e.CSEXO as sexo,
e.CESTADOCIVIL as estadoCivil,
e.CFECHAALTA as fechaAlta,
e.CFECHABAJA as fechaBaja,
e.CFECHAANTIGUEDAD as fechaAntiguedad,
e.CTIPOCONTRATO as tipoContratoNum,
e.CTIPOREGIMEN as tipoRegimenNum,
e.CTIPOJORNADA as tipoJornadaNum,
e.CPERIODICIDADPAGO as periodicidadPagoNum,
e.CDEPARTAMENTO as departamento,
e.CPUESTO as puesto,
e.CSALARIODIARIO as salarioDiario,
e.CSALARIODIAINTEGRADO as salarioDiarioIntegrado,
e.CSBC as sbc,
e.CBANCO as banco,
e.CCLABE as clabe,
e.CCUENTABANCARIA as cuentaBancaria,
e.CEMAIL as email,
e.CTELEFONO as telefono,
e.CESTATUS as estado,
e.CSINDICALIZADO as sindicalizado,
e.CREGISTROPATRONAL as registroPatronal,
e.CRIESGOTRABAJO as riesgoTrabajo,
e.CENTIDADFEDERATIVA as entidadFederativa
FROM nomEmpleados e
WHERE 1=1
`;
const params: Record<string, unknown> = {};
if (filter?.estado !== undefined) {
query += ' AND e.CESTATUS = @estado';
params.estado = filter.estado;
} else if (!filter?.incluyeBajas) {
query += ' AND e.CESTATUS = 1';
}
if (filter?.departamento) {
query += ' AND e.CDEPARTAMENTO = @departamento';
params.departamento = filter.departamento;
}
if (filter?.puesto) {
query += ' AND e.CPUESTO = @puesto';
params.puesto = filter.puesto;
}
if (filter?.tipoContrato) {
query += ' AND e.CTIPOCONTRATO = @tipoContrato';
params.tipoContrato = this.getTipoContratoNumero(filter.tipoContrato);
}
query += ' ORDER BY e.CAPELLIDOPATERNO, e.CAPELLIDOMATERNO, e.CNOMBRE';
if (filter?.limit) {
const offset = filter.page ? (filter.page - 1) * filter.limit : 0;
query += ` OFFSET ${offset} ROWS FETCH NEXT ${filter.limit} ROWS ONLY`;
}
const result = await this.client.queryMany<{
id: number;
codigo: string;
nombre: string;
apellidoPaterno: string;
apellidoMaterno: string | null;
rfc: string;
curp: string;
nss: string;
fechaNacimiento: Date | null;
sexo: string | null;
estadoCivil: string | null;
fechaAlta: Date;
fechaBaja: Date | null;
fechaAntiguedad: Date | null;
tipoContratoNum: number;
tipoRegimenNum: number;
tipoJornadaNum: number | null;
periodicidadPagoNum: number;
departamento: string | null;
puesto: string | null;
salarioDiario: number;
salarioDiarioIntegrado: number | null;
sbc: number | null;
banco: string | null;
clabe: string | null;
cuentaBancaria: string | null;
email: string | null;
telefono: string | null;
estado: number;
sindicalizado: number | null;
registroPatronal: string | null;
riesgoTrabajo: string | null;
entidadFederativa: string | null;
}>(query, params);
const empleados: CONTPAQiEmpleado[] = [];
for (const row of result) {
const direccion = await this.getDireccionEmpleado(row.id);
empleados.push({
id: row.id,
codigo: row.codigo?.trim() || '',
nombre: row.nombre?.trim() || '',
apellidoPaterno: row.apellidoPaterno?.trim() || '',
apellidoMaterno: row.apellidoMaterno?.trim() || undefined,
rfc: row.rfc?.trim() || '',
curp: row.curp?.trim() || '',
nss: row.nss?.trim() || '',
fechaNacimiento: row.fechaNacimiento || undefined,
sexo: (row.sexo?.trim() as 'M' | 'F') || undefined,
estadoCivil: row.estadoCivil?.trim() || undefined,
fechaAlta: row.fechaAlta,
fechaBaja: row.fechaBaja || undefined,
fechaAntiguedad: row.fechaAntiguedad || undefined,
tipoContrato: this.getTipoContratoNombre(row.tipoContratoNum),
tipoRegimen: this.getTipoRegimenNombre(row.tipoRegimenNum),
tipoJornada: row.tipoJornadaNum
? this.getTipoJornadaNombre(row.tipoJornadaNum)
: undefined,
periodicidadPago: this.getPeriodicidadPagoNombre(row.periodicidadPagoNum),
departamento: row.departamento?.trim() || undefined,
puesto: row.puesto?.trim() || undefined,
salarioDiario: row.salarioDiario || 0,
salarioDiarioIntegrado: row.salarioDiarioIntegrado || undefined,
sbc: row.sbc || undefined,
banco: row.banco?.trim() || undefined,
clabe: row.clabe?.trim() || undefined,
cuentaBancaria: row.cuentaBancaria?.trim() || undefined,
direccion,
email: row.email?.trim() || undefined,
telefono: row.telefono?.trim() || undefined,
estado: row.estado,
sindicalizado: row.sindicalizado === 1,
registroPatronal: row.registroPatronal?.trim() || undefined,
riesgoTrabajo: row.riesgoTrabajo?.trim() || undefined,
entidadFederativa: row.entidadFederativa?.trim() || undefined,
});
}
return empleados;
}
/**
* Obtiene un empleado por su codigo
*/
async getEmpleadoByCodigo(codigo: string): Promise<CONTPAQiEmpleado | null> {
const query = `
SELECT CIDEMPLEAD as id FROM nomEmpleados WHERE CCODIGOEMPLEADO = @codigo
`;
const result = await this.client.queryOne<{ id: number }>(query, { codigo });
if (!result) return null;
const empleados = await this.getEmpleados({ incluyeBajas: true });
return empleados.find((e) => e.id === result.id) || null;
}
/**
* Obtiene la direccion de un empleado
*/
private async getDireccionEmpleado(empleadoId: number): Promise<CONTPAQiDireccion | undefined> {
const query = `
SELECT TOP 1
CCALLE as calle,
CNUMEROEXTERIOR as numeroExterior,
CNUMEROINTERIOR as numeroInterior,
CCOLONIA as colonia,
CCODIGOPOSTAL as codigoPostal,
CCIUDAD as ciudad,
CESTADO as estado,
CPAIS as pais
FROM nomDomicilios
WHERE CIDEMPLEAD = @empleadoId
ORDER BY CIDDOMICILIO
`;
const result = await this.client.queryOne<{
calle: string | null;
numeroExterior: string | null;
numeroInterior: string | null;
colonia: string | null;
codigoPostal: string | null;
ciudad: string | null;
estado: string | null;
pais: string | null;
}>(query, { empleadoId });
if (!result) return undefined;
return {
calle: result.calle?.trim() || undefined,
numeroExterior: result.numeroExterior?.trim() || undefined,
numeroInterior: result.numeroInterior?.trim() || undefined,
colonia: result.colonia?.trim() || undefined,
codigoPostal: result.codigoPostal?.trim() || undefined,
ciudad: result.ciudad?.trim() || undefined,
estado: result.estado?.trim() || undefined,
pais: result.pais?.trim() || undefined,
};
}
// ============================================================================
// Periodos de Nomina
// ============================================================================
/**
* Obtiene los periodos de nomina
*/
async getPeriodos(ejercicio: number): Promise<CONTPAQiPeriodoNomina[]> {
const query = `
SELECT
p.CIDPERIODO as id,
p.CNUMEROPERIODO as numero,
p.CEJERCICIO as ejercicio,
p.CFECHAINICIO as fechaInicio,
p.CFECHAFIN as fechaFin,
p.CFECHAPAGO as fechaPago,
p.CTIPONOMINA as tipoNominaNum,
p.CDIASPERIODO as diasPeriodo,
p.CESTATUS as estadoNum,
ISNULL(SUM(m.CIMPORTE), 0) as totalPercepciones,
0 as totalDeducciones
FROM nomPeriodos p
LEFT JOIN nomMovimientos m ON p.CIDPERIODO = m.CIDPERIODO AND m.CTIPOMOVIMIENTO = 1
WHERE p.CEJERCICIO = @ejercicio
GROUP BY p.CIDPERIODO, p.CNUMEROPERIODO, p.CEJERCICIO, p.CFECHAINICIO,
p.CFECHAFIN, p.CFECHAPAGO, p.CTIPONOMINA, p.CDIASPERIODO, p.CESTATUS
ORDER BY p.CNUMEROPERIODO
`;
const result = await this.client.queryMany<{
id: number;
numero: number;
ejercicio: number;
fechaInicio: Date;
fechaFin: Date;
fechaPago: Date;
tipoNominaNum: number;
diasPeriodo: number;
estadoNum: number;
totalPercepciones: number;
totalDeducciones: number;
}>(query, { ejercicio });
return result.map((row) => ({
id: row.id,
numero: row.numero,
ejercicio: row.ejercicio,
fechaInicio: row.fechaInicio,
fechaFin: row.fechaFin,
fechaPago: row.fechaPago,
tipoNomina: row.tipoNominaNum === 1 ? 'Ordinaria' : 'Extraordinaria' as TipoNomina,
diasPeriodo: row.diasPeriodo,
estado: this.getEstadoPeriodoNombre(row.estadoNum),
totalPercepciones: row.totalPercepciones,
totalDeducciones: row.totalDeducciones,
}));
}
// ============================================================================
// Nominas
// ============================================================================
/**
* Obtiene las nominas de un periodo
*/
async getNominas(filter: NominaFilter): Promise<CONTPAQiNomina[]> {
let query = `
SELECT DISTINCT
n.CIDNOMINA as id,
n.CIDEMPLEAD as empleadoId,
e.CCODIGOEMPLEADO as codigoEmpleado,
CONCAT(e.CAPELLIDOPATERNO, ' ', ISNULL(e.CAPELLIDOMATERNO, ''), ' ', e.CNOMBRE) as nombreEmpleado,
n.CIDPERIODO as periodoId,
p.CNUMEROPERIODO as numeroPeriodo,
p.CEJERCICIO as ejercicio,
p.CTIPONOMINA as tipoNominaNum,
p.CFECHAPAGO as fechaPago,
n.CDIASPAGADOS as diasPagados,
n.CTOTALPERCEPCIONES as totalPercepciones,
n.CTOTALGRAVADO as totalGravado,
n.CTOTALEXENTO as totalExento,
n.CTOTALDEDUCCIONES as totalDeducciones,
n.COTROSPAGOS as otrosPagos,
n.CNETO as neto,
n.CUUID as uuid,
n.CSUBSIDIOCAUSADO as subsidioCausado,
n.CISRRETENIDO as isrRetenido
FROM nomNominas n
INNER JOIN nomEmpleados e ON n.CIDEMPLEAD = e.CIDEMPLEAD
INNER JOIN nomPeriodos p ON n.CIDPERIODO = p.CIDPERIODO
WHERE p.CEJERCICIO = @ejercicio
`;
const params: Record<string, unknown> = {
ejercicio: filter.ejercicio,
};
if (filter.periodoInicio !== undefined) {
query += ' AND p.CNUMEROPERIODO >= @periodoInicio';
params.periodoInicio = filter.periodoInicio;
}
if (filter.periodoFin !== undefined) {
query += ' AND p.CNUMEROPERIODO <= @periodoFin';
params.periodoFin = filter.periodoFin;
}
if (filter.tipoNomina) {
query += ' AND p.CTIPONOMINA = @tipoNomina';
params.tipoNomina = filter.tipoNomina === 'Ordinaria' ? 1 : 2;
}
if (filter.empleadoId) {
query += ' AND n.CIDEMPLEAD = @empleadoId';
params.empleadoId = filter.empleadoId;
}
query += ' ORDER BY p.CNUMEROPERIODO, e.CAPELLIDOPATERNO, e.CNOMBRE';
if (filter.limit) {
const offset = filter.page ? (filter.page - 1) * filter.limit : 0;
query += ` OFFSET ${offset} ROWS FETCH NEXT ${filter.limit} ROWS ONLY`;
}
const result = await this.client.queryMany<{
id: number;
empleadoId: number;
codigoEmpleado: string;
nombreEmpleado: string;
periodoId: number;
numeroPeriodo: number;
ejercicio: number;
tipoNominaNum: number;
fechaPago: Date;
diasPagados: number;
totalPercepciones: number;
totalGravado: number;
totalExento: number;
totalDeducciones: number;
otrosPagos: number;
neto: number;
uuid: string | null;
subsidioCausado: number | null;
isrRetenido: number | null;
}>(query, params);
const nominas: CONTPAQiNomina[] = [];
for (const row of result) {
const [percepciones, deducciones, otrosPagosDetalle] = await Promise.all([
this.getPercepcionesNomina(row.id),
this.getDeduccionesNomina(row.id),
this.getOtrosPagosNomina(row.id),
]);
nominas.push({
id: row.id,
empleadoId: row.empleadoId,
codigoEmpleado: row.codigoEmpleado?.trim() || '',
nombreEmpleado: row.nombreEmpleado?.trim() || '',
periodoId: row.periodoId,
numeroPeriodo: row.numeroPeriodo,
ejercicio: row.ejercicio,
tipoNomina: row.tipoNominaNum === 1 ? 'Ordinaria' : 'Extraordinaria',
fechaPago: row.fechaPago,
diasPagados: row.diasPagados || 0,
totalPercepciones: row.totalPercepciones || 0,
totalGravado: row.totalGravado || 0,
totalExento: row.totalExento || 0,
totalDeducciones: row.totalDeducciones || 0,
otrosPagos: row.otrosPagos || 0,
neto: row.neto || 0,
uuid: row.uuid?.trim() || undefined,
percepciones,
deducciones,
otrosPagosDetalle: otrosPagosDetalle.length > 0 ? otrosPagosDetalle : undefined,
subsidioCausado: row.subsidioCausado || undefined,
isrRetenido: row.isrRetenido || undefined,
});
}
return nominas;
}
/**
* Obtiene las percepciones de una nomina
*/
async getPercepcionesNomina(nominaId: number): Promise<CONTPAQiPercepcionNomina[]> {
const query = `
SELECT
m.CIDMOVIMIENTO as id,
m.CIDNOMINA as nominaId,
t.CTIPOPERCEPCIONSAT as tipoPercepcion,
t.CCLAVE as clave,
t.CCONCEPTO as concepto,
m.CIMPORTEGRAVADO as importeGravado,
m.CIMPORTEEXENTO as importeExento,
ISNULL(m.CIMPORTEGRAVADO, 0) + ISNULL(m.CIMPORTEEXENTO, 0) as total
FROM nomMovimientos m
INNER JOIN nomTiposPercepcion t ON m.CIDTIPOMOVIMIENTO = t.CIDTIPOPERCEPCION
WHERE m.CIDNOMINA = @nominaId
AND m.CTIPOMOVIMIENTO = 1
ORDER BY t.CORDEN
`;
const result = await this.client.queryMany<{
id: number;
nominaId: number;
tipoPercepcion: string;
clave: string;
concepto: string;
importeGravado: number;
importeExento: number;
total: number;
}>(query, { nominaId });
return result.map((row) => ({
id: row.id,
nominaId: row.nominaId,
tipoPercepcion: row.tipoPercepcion?.trim() || '',
clave: row.clave?.trim() || '',
concepto: row.concepto?.trim() || '',
importeGravado: row.importeGravado || 0,
importeExento: row.importeExento || 0,
total: row.total || 0,
}));
}
/**
* Obtiene las deducciones de una nomina
*/
async getDeduccionesNomina(nominaId: number): Promise<CONTPAQiDeduccionNomina[]> {
const query = `
SELECT
m.CIDMOVIMIENTO as id,
m.CIDNOMINA as nominaId,
t.CTIPODEDUCCIONSAT as tipoDeduccion,
t.CCLAVE as clave,
t.CCONCEPTO as concepto,
m.CIMPORTE as importe
FROM nomMovimientos m
INNER JOIN nomTiposDeduccion t ON m.CIDTIPOMOVIMIENTO = t.CIDTIPODEDUCCION
WHERE m.CIDNOMINA = @nominaId
AND m.CTIPOMOVIMIENTO = 2
ORDER BY t.CORDEN
`;
const result = await this.client.queryMany<{
id: number;
nominaId: number;
tipoDeduccion: string;
clave: string;
concepto: string;
importe: number;
}>(query, { nominaId });
return result.map((row) => ({
id: row.id,
nominaId: row.nominaId,
tipoDeduccion: row.tipoDeduccion?.trim() || '',
clave: row.clave?.trim() || '',
concepto: row.concepto?.trim() || '',
importe: row.importe || 0,
}));
}
/**
* Obtiene otros pagos de una nomina (subsidio, etc.)
*/
async getOtrosPagosNomina(nominaId: number): Promise<CONTPAQiOtroPagoNomina[]> {
const query = `
SELECT
m.CIDMOVIMIENTO as id,
m.CIDNOMINA as nominaId,
t.CTIPOOTROPAGOSAT as tipoOtroPago,
t.CCLAVE as clave,
t.CCONCEPTO as concepto,
m.CIMPORTE as importe,
m.CSUBSIDIOCAUSADO as subsidioCausado
FROM nomMovimientos m
INNER JOIN nomTiposOtroPago t ON m.CIDTIPOMOVIMIENTO = t.CIDTIPOOTROPAGO
WHERE m.CIDNOMINA = @nominaId
AND m.CTIPOMOVIMIENTO = 3
ORDER BY t.CORDEN
`;
try {
const result = await this.client.queryMany<{
id: number;
nominaId: number;
tipoOtroPago: string;
clave: string;
concepto: string;
importe: number;
subsidioCausado: number | null;
}>(query, { nominaId });
return result.map((row) => ({
id: row.id,
nominaId: row.nominaId,
tipoOtroPago: row.tipoOtroPago?.trim() || '',
clave: row.clave?.trim() || '',
concepto: row.concepto?.trim() || '',
importe: row.importe || 0,
subsidioCausado: row.subsidioCausado || undefined,
}));
} catch {
// La tabla de otros pagos puede no existir en versiones antiguas
return [];
}
}
/**
* Obtiene las percepciones y deducciones de un empleado en un periodo
*/
async getPercepcionesDeducciones(
empleadoId: number,
ejercicio?: number,
periodo?: number
): Promise<{
empleado: CONTPAQiEmpleado | null;
percepciones: CONTPAQiPercepcionNomina[];
deducciones: CONTPAQiDeduccionNomina[];
totales: {
percepciones: number;
deducciones: number;
neto: number;
};
}> {
// Obtener empleado
const empleados = await this.getEmpleados({ incluyeBajas: true });
const empleado = empleados.find((e) => e.id === empleadoId) || null;
// Construir query para movimientos
let query = `
SELECT
n.CIDNOMINA as nominaId
FROM nomNominas n
INNER JOIN nomPeriodos p ON n.CIDPERIODO = p.CIDPERIODO
WHERE n.CIDEMPLEAD = @empleadoId
`;
const params: Record<string, unknown> = { empleadoId };
if (ejercicio !== undefined) {
query += ' AND p.CEJERCICIO = @ejercicio';
params.ejercicio = ejercicio;
}
if (periodo !== undefined) {
query += ' AND p.CNUMEROPERIODO = @periodo';
params.periodo = periodo;
}
const nominasResult = await this.client.queryMany<{ nominaId: number }>(query, params);
const percepciones: CONTPAQiPercepcionNomina[] = [];
const deducciones: CONTPAQiDeduccionNomina[] = [];
for (const { nominaId } of nominasResult) {
const [percs, deds] = await Promise.all([
this.getPercepcionesNomina(nominaId),
this.getDeduccionesNomina(nominaId),
]);
percepciones.push(...percs);
deducciones.push(...deds);
}
const totalPercepciones = percepciones.reduce((sum, p) => sum + p.total, 0);
const totalDeducciones = deducciones.reduce((sum, d) => sum + d.importe, 0);
return {
empleado,
percepciones,
deducciones,
totales: {
percepciones: totalPercepciones,
deducciones: totalDeducciones,
neto: totalPercepciones - totalDeducciones,
},
};
}
// ============================================================================
// Reportes
// ============================================================================
/**
* Obtiene un resumen de nomina por periodo
*/
async getResumenNomina(
ejercicio: number,
periodoInicio?: number,
periodoFin?: number
): Promise<{
periodo: { inicio: number; fin: number };
empleadosActivos: number;
totalPercepciones: number;
totalDeducciones: number;
totalNeto: number;
desglosePorConcepto: Array<{
tipo: 'Percepcion' | 'Deduccion';
clave: string;
concepto: string;
total: number;
}>;
}> {
const params: Record<string, unknown> = {
ejercicio,
periodoInicio: periodoInicio || 1,
periodoFin: periodoFin || 24,
};
// Obtener totales generales
const totalesQuery = `
SELECT
COUNT(DISTINCT n.CIDEMPLEAD) as empleadosActivos,
SUM(n.CTOTALPERCEPCIONES) as totalPercepciones,
SUM(n.CTOTALDEDUCCIONES) as totalDeducciones,
SUM(n.CNETO) as totalNeto
FROM nomNominas n
INNER JOIN nomPeriodos p ON n.CIDPERIODO = p.CIDPERIODO
WHERE p.CEJERCICIO = @ejercicio
AND p.CNUMEROPERIODO >= @periodoInicio
AND p.CNUMEROPERIODO <= @periodoFin
`;
const totales = await this.client.queryOne<{
empleadosActivos: number;
totalPercepciones: number;
totalDeducciones: number;
totalNeto: number;
}>(totalesQuery, params);
// Obtener desglose por concepto de percepcion
const percepcionesQuery = `
SELECT
'Percepcion' as tipo,
t.CCLAVE as clave,
t.CCONCEPTO as concepto,
SUM(ISNULL(m.CIMPORTEGRAVADO, 0) + ISNULL(m.CIMPORTEEXENTO, 0)) as total
FROM nomMovimientos m
INNER JOIN nomNominas n ON m.CIDNOMINA = n.CIDNOMINA
INNER JOIN nomPeriodos p ON n.CIDPERIODO = p.CIDPERIODO
INNER JOIN nomTiposPercepcion t ON m.CIDTIPOMOVIMIENTO = t.CIDTIPOPERCEPCION
WHERE p.CEJERCICIO = @ejercicio
AND p.CNUMEROPERIODO >= @periodoInicio
AND p.CNUMEROPERIODO <= @periodoFin
AND m.CTIPOMOVIMIENTO = 1
GROUP BY t.CCLAVE, t.CCONCEPTO
ORDER BY t.CORDEN
`;
// Obtener desglose por concepto de deduccion
const deduccionesQuery = `
SELECT
'Deduccion' as tipo,
t.CCLAVE as clave,
t.CCONCEPTO as concepto,
SUM(m.CIMPORTE) as total
FROM nomMovimientos m
INNER JOIN nomNominas n ON m.CIDNOMINA = n.CIDNOMINA
INNER JOIN nomPeriodos p ON n.CIDPERIODO = p.CIDPERIODO
INNER JOIN nomTiposDeduccion t ON m.CIDTIPOMOVIMIENTO = t.CIDTIPODEDUCCION
WHERE p.CEJERCICIO = @ejercicio
AND p.CNUMEROPERIODO >= @periodoInicio
AND p.CNUMEROPERIODO <= @periodoFin
AND m.CTIPOMOVIMIENTO = 2
GROUP BY t.CCLAVE, t.CCONCEPTO
ORDER BY t.CORDEN
`;
const [percepcionesDesglose, deduccionesDesglose] = await Promise.all([
this.client.queryMany<{
tipo: string;
clave: string;
concepto: string;
total: number;
}>(percepcionesQuery, params),
this.client.queryMany<{
tipo: string;
clave: string;
concepto: string;
total: number;
}>(deduccionesQuery, params),
]);
const desglosePorConcepto = [
...percepcionesDesglose.map((r) => ({
tipo: 'Percepcion' as const,
clave: r.clave?.trim() || '',
concepto: r.concepto?.trim() || '',
total: r.total || 0,
})),
...deduccionesDesglose.map((r) => ({
tipo: 'Deduccion' as const,
clave: r.clave?.trim() || '',
concepto: r.concepto?.trim() || '',
total: r.total || 0,
})),
];
return {
periodo: {
inicio: periodoInicio || 1,
fin: periodoFin || 24,
},
empleadosActivos: totales?.empleadosActivos || 0,
totalPercepciones: totales?.totalPercepciones || 0,
totalDeducciones: totales?.totalDeducciones || 0,
totalNeto: totales?.totalNeto || 0,
desglosePorConcepto,
};
}
// ============================================================================
// Helpers
// ============================================================================
private getTipoContratoNumero(tipo: TipoContrato): number {
const tipos: Record<TipoContrato, number> = {
PorTiempoIndeterminado: 1,
PorObraoDeterminado: 2,
PorTemporada: 3,
SujetoPrueba: 4,
CapacitacionInicial: 5,
PorTiempoIndeterminadoDesconexion: 6,
};
return tipos[tipo];
}
private getTipoContratoNombre(tipo: number): TipoContrato {
const tipos: Record<number, TipoContrato> = {
1: 'PorTiempoIndeterminado',
2: 'PorObraoDeterminado',
3: 'PorTemporada',
4: 'SujetoPrueba',
5: 'CapacitacionInicial',
6: 'PorTiempoIndeterminadoDesconexion',
};
return tipos[tipo] || 'PorTiempoIndeterminado';
}
private getTipoRegimenNombre(tipo: number): TipoRegimenNomina {
const tipos: Record<number, TipoRegimenNomina> = {
1: 'Sueldos',
2: 'Asimilados',
3: 'Jubilados',
4: 'Honorarios',
};
return tipos[tipo] || 'Sueldos';
}
private getTipoJornadaNombre(tipo: number): TipoJornada {
const tipos: Record<number, TipoJornada> = {
1: 'Diurna',
2: 'Nocturna',
3: 'Mixta',
4: 'PorHora',
5: 'ReducidaDiscapacidad',
6: 'Continuada',
};
return tipos[tipo] || 'Diurna';
}
private getPeriodicidadPagoNombre(tipo: number): PeriodicidadPago {
const tipos: Record<number, PeriodicidadPago> = {
1: 'Diario',
2: 'Semanal',
3: 'Catorcenal',
4: 'Quincenal',
5: 'Mensual',
6: 'Bimestral',
7: 'PorUnidadObra',
8: 'Comision',
9: 'PrecioAlzado',
10: 'Decenal',
99: 'OtraPeriodidad',
};
return tipos[tipo] || 'Quincenal';
}
private getEstadoPeriodoNombre(estado: number): EstadoPeriodoNomina {
const estados: Record<number, EstadoPeriodoNomina> = {
1: 'Abierto',
2: 'Calculado',
3: 'Cerrado',
4: 'Timbrado',
};
return estados[estado] || 'Abierto';
}
}
/**
* Crea una instancia del conector de Nominas
*/
export function createNominasConnector(client: CONTPAQiClient): NominasConnector {
return new NominasConnector(client);
}

View File

@@ -0,0 +1,42 @@
/**
* Integrations Module Exports
*
* Unified exports for the integrations system.
*/
// Types
export * from './integration.types.js';
// Manager
export {
IntegrationManager,
integrationManager,
IntegrationError,
ConnectorNotFoundError,
ConnectionError,
SyncError,
} from './integration.manager.js';
// Scheduler
export {
SyncScheduler,
createSyncScheduler,
type SyncJobData,
type SyncJobResult,
type SchedulerConfig,
} from './sync.scheduler.js';
// SAP Business One
export * from './sap/index.js';
// Alegra - Software contable cloud (Mexico, Colombia, LATAM)
export * from './alegra/index.js';
// Odoo ERP (versions 14, 15, 16, 17)
export * from './odoo/index.js';
// CONTPAQi - Sistema ERP Mexicano (Contabilidad, Comercial, Nominas)
export * from './contpaqi/index.js';
// Aspel - Sistema ERP Mexicano (COI, SAE, NOI, BANCO)
export * from './aspel/index.js';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,662 @@
/**
* Integration Types
*
* Common types for the unified integration system.
* Supports CONTPAQi, Aspel, Odoo, Alegra, SAP, and manual data entry.
*/
// ============================================================================
// ENUMS
// ============================================================================
/**
* Supported integration types
*/
export enum IntegrationType {
// ERP/Accounting systems
CONTPAQI = 'contpaqi',
ASPEL = 'aspel',
ODOO = 'odoo',
ALEGRA = 'alegra',
SAP = 'sap',
// Manual entry (no external system)
MANUAL = 'manual',
// Fiscal
SAT = 'sat',
// Banks
BANK_BBVA = 'bank_bbva',
BANK_BANAMEX = 'bank_banamex',
BANK_SANTANDER = 'bank_santander',
BANK_BANORTE = 'bank_banorte',
BANK_HSBC = 'bank_hsbc',
// Payments
STRIPE = 'payments_stripe',
OPENPAY = 'payments_openpay',
// Custom
WEBHOOK = 'webhook',
API_CUSTOM = 'api_custom',
}
/**
* Integration status
*/
export enum IntegrationStatus {
PENDING = 'pending',
ACTIVE = 'active',
INACTIVE = 'inactive',
ERROR = 'error',
EXPIRED = 'expired',
CONFIGURING = 'configuring',
}
/**
* Sync job status
*/
export enum SyncStatus {
PENDING = 'pending',
QUEUED = 'queued',
RUNNING = 'running',
COMPLETED = 'completed',
FAILED = 'failed',
CANCELLED = 'cancelled',
PARTIAL = 'partial', // Some records synced, some failed
}
/**
* Sync direction
*/
export enum SyncDirection {
IMPORT = 'import', // External -> Horux
EXPORT = 'export', // Horux -> External
BIDIRECTIONAL = 'bidirectional',
}
/**
* Data entity types that can be synced
*/
export enum SyncEntityType {
TRANSACTIONS = 'transactions',
INVOICES = 'invoices',
CONTACTS = 'contacts',
PRODUCTS = 'products',
ACCOUNTS = 'accounts',
CATEGORIES = 'categories',
JOURNAL_ENTRIES = 'journal_entries',
PAYMENTS = 'payments',
CFDIS = 'cfdis',
BANK_STATEMENTS = 'bank_statements',
}
// ============================================================================
// CONFIGURATION TYPES
// ============================================================================
/**
* Base configuration for all integrations
*/
export interface BaseIntegrationConfig {
autoSync: boolean;
syncFrequency: 'realtime' | 'hourly' | 'daily' | 'weekly' | 'monthly';
syncDirection: SyncDirection;
enabledEntities: SyncEntityType[];
retryOnFailure: boolean;
maxRetries: number;
notifyOnSuccess: boolean;
notifyOnFailure: boolean;
notificationEmails?: string[];
}
/**
* CONTPAQi configuration
*/
export interface ContpaqiConfig extends BaseIntegrationConfig {
type: IntegrationType.CONTPAQI;
// Connection
serverHost: string;
serverPort: number;
databaseName: string;
username: string;
password: string;
companyRfc: string;
// SDK settings
sdkPath?: string;
sdkVersion?: string;
// Mappings
accountMappingProfile?: string;
}
/**
* Aspel configuration
*/
export interface AspelConfig extends BaseIntegrationConfig {
type: IntegrationType.ASPEL;
product: 'SAE' | 'COI' | 'NOI' | 'BANCO' | 'CAJA';
// Connection
serverHost: string;
serverPort: number;
databasePath: string;
username: string;
password: string;
companyCode: string;
// Version
version?: string;
}
/**
* Odoo configuration
*/
export interface OdooConfig extends BaseIntegrationConfig {
type: IntegrationType.ODOO;
// Connection
serverUrl: string;
database: string;
username: string;
apiKey: string;
// Company
companyId: number;
// Options
useXmlRpc: boolean;
version?: string;
}
/**
* Alegra configuration
*/
export interface AlegraConfig extends BaseIntegrationConfig {
type: IntegrationType.ALEGRA;
// API credentials
email: string;
apiToken: string;
// Company
companyId?: string;
// Region
country: 'MX' | 'CO' | 'PE' | 'AR' | 'CL';
}
/**
* SAP configuration
*/
export interface SapConfig extends BaseIntegrationConfig {
type: IntegrationType.SAP;
// Connection
serverHost: string;
serverPort: number;
systemNumber: string;
client: string;
username: string;
password: string;
// Company
companyCode: string;
// Options
sapRouter?: string;
language: string;
useSsl: boolean;
// B1 specific
isBusinessOne?: boolean;
serviceLayerUrl?: string;
}
/**
* SAT configuration
*/
export interface SatConfig extends BaseIntegrationConfig {
type: IntegrationType.SAT;
rfc: string;
certificateBase64: string;
privateKeyBase64: string;
privateKeyPassword: string;
fielCertificateBase64?: string;
fielPrivateKeyBase64?: string;
fielPrivateKeyPassword?: string;
syncIngresos: boolean;
syncEgresos: boolean;
syncNomina: boolean;
syncPagos: boolean;
}
/**
* Bank configuration
*/
export interface BankConfig extends BaseIntegrationConfig {
type: IntegrationType.BANK_BBVA | IntegrationType.BANK_BANAMEX |
IntegrationType.BANK_SANTANDER | IntegrationType.BANK_BANORTE |
IntegrationType.BANK_HSBC;
accountNumber?: string;
clabe?: string;
accessToken?: string;
refreshToken?: string;
clientId?: string;
clientSecret?: string;
connectionProvider?: 'belvo' | 'finerio' | 'plaid' | 'direct';
connectionId?: string;
}
/**
* Webhook configuration
*/
export interface WebhookConfig extends BaseIntegrationConfig {
type: IntegrationType.WEBHOOK;
url: string;
secret: string;
events: string[];
headers?: Record<string, string>;
retryAttempts: number;
timeoutMs: number;
}
/**
* Manual entry configuration
*/
export interface ManualConfig {
type: IntegrationType.MANUAL;
enabledEntities: SyncEntityType[];
defaultCategory?: string;
requireApproval: boolean;
approvalThreshold?: number;
}
/**
* Union type for all integration configs
*/
export type IntegrationConfig =
| ContpaqiConfig
| AspelConfig
| OdooConfig
| AlegraConfig
| SapConfig
| SatConfig
| BankConfig
| WebhookConfig
| ManualConfig;
// ============================================================================
// DATA MAPPING TYPES
// ============================================================================
/**
* Field mapping between external system and Horux
*/
export interface FieldMapping {
id: string;
sourceField: string;
targetField: string;
transformationType?: 'direct' | 'lookup' | 'formula' | 'constant';
transformationValue?: string;
isRequired: boolean;
defaultValue?: unknown;
}
/**
* Entity mapping configuration
*/
export interface EntityMapping {
id: string;
integrationId: string;
entityType: SyncEntityType;
sourceEntity: string;
targetEntity: string;
fieldMappings: FieldMapping[];
filters?: Record<string, unknown>;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* Value mapping for lookups (e.g., account codes)
*/
export interface ValueMapping {
id: string;
mappingId: string;
sourceValue: string;
targetValue: string;
description?: string;
}
// ============================================================================
// SYNC RESULT TYPES
// ============================================================================
/**
* Result of a single record sync
*/
export interface SyncRecordResult {
sourceId: string;
targetId?: string;
success: boolean;
action: 'created' | 'updated' | 'skipped' | 'deleted' | 'failed';
errorMessage?: string;
errorCode?: string;
metadata?: Record<string, unknown>;
}
/**
* Result of a sync job
*/
export interface SyncResult {
jobId: string;
integrationId: string;
integrationType: IntegrationType;
status: SyncStatus;
direction: SyncDirection;
entityType: SyncEntityType;
// Timing
startedAt: Date;
completedAt?: Date;
durationMs?: number;
// Counts
totalRecords: number;
processedRecords: number;
createdRecords: number;
updatedRecords: number;
skippedRecords: number;
failedRecords: number;
// Progress
progress: number; // 0-100
// Errors
errors: SyncError[];
warnings: string[];
// Details
recordResults?: SyncRecordResult[];
metadata?: Record<string, unknown>;
}
/**
* Sync error details
*/
export interface SyncError {
code: string;
message: string;
sourceId?: string;
field?: string;
timestamp: Date;
retryable: boolean;
details?: Record<string, unknown>;
}
// ============================================================================
// SYNC LOG TYPES
// ============================================================================
/**
* Sync job log entry
*/
export interface SyncLog {
id: string;
integrationId: string;
jobId: string;
entityType: SyncEntityType;
direction: SyncDirection;
status: SyncStatus;
// Timing
startedAt: Date;
completedAt?: Date;
durationMs?: number;
// Results
totalRecords: number;
createdRecords: number;
updatedRecords: number;
skippedRecords: number;
failedRecords: number;
// Error summary
errorCount: number;
lastError?: string;
// Trigger info
triggeredBy: 'schedule' | 'manual' | 'webhook' | 'system';
triggeredByUserId?: string;
// Metadata
metadata?: Record<string, unknown>;
createdAt: Date;
}
// ============================================================================
// SCHEDULE TYPES
// ============================================================================
/**
* Sync schedule configuration
*/
export interface SyncSchedule {
id: string;
integrationId: string;
entityType: SyncEntityType;
direction: SyncDirection;
// Schedule
isEnabled: boolean;
cronExpression: string;
timezone: string;
// Execution window
startTime?: string; // HH:mm
endTime?: string; // HH:mm
daysOfWeek?: number[]; // 0-6
// Last execution
lastRunAt?: Date;
nextRunAt?: Date;
lastStatus?: SyncStatus;
// Options
priority: 'low' | 'normal' | 'high';
timeoutMs: number;
createdAt: Date;
updatedAt: Date;
}
// ============================================================================
// CONNECTION TEST TYPES
// ============================================================================
/**
* Connection test result
*/
export interface ConnectionTestResult {
success: boolean;
latencyMs: number;
message: string;
// Connection details
serverVersion?: string;
serverTime?: Date;
permissions?: string[];
// Errors
errorCode?: string;
errorDetails?: string;
// Capabilities
capabilities?: {
canRead: boolean;
canWrite: boolean;
canDelete: boolean;
supportedEntities: SyncEntityType[];
};
testedAt: Date;
}
// ============================================================================
// INTEGRATION ENTITY
// ============================================================================
/**
* Full integration entity stored in database
*/
export interface Integration {
id: string;
tenantId: string;
type: IntegrationType;
name: string;
description?: string;
status: IntegrationStatus;
isActive: boolean;
// Configuration (encrypted in DB)
config: IntegrationConfig;
// Connection health
lastHealthCheckAt?: Date;
healthStatus?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
healthMessage?: string;
// Last sync info
lastSyncAt?: Date;
lastSyncStatus?: SyncStatus;
lastSyncError?: string;
nextSyncAt?: Date;
// Statistics
totalSyncs: number;
successfulSyncs: number;
failedSyncs: number;
// Audit
createdBy: string;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}
// ============================================================================
// CONNECTOR INTERFACE
// ============================================================================
/**
* Interface that all connectors must implement
*/
export interface IIntegrationConnector {
type: IntegrationType;
name: string;
description: string;
logoUrl?: string;
// Connection
testConnection(config: IntegrationConfig): Promise<ConnectionTestResult>;
connect(config: IntegrationConfig): Promise<void>;
disconnect(): Promise<void>;
// Health check
healthCheck(): Promise<ConnectionTestResult>;
// Sync operations
sync(options: SyncOptions): Promise<SyncResult>;
getSyncStatus(jobId: string): Promise<SyncResult>;
cancelSync(jobId: string): Promise<boolean>;
// Data operations
fetchRecords(entityType: SyncEntityType, options: FetchOptions): Promise<unknown[]>;
pushRecords(entityType: SyncEntityType, records: unknown[]): Promise<SyncRecordResult[]>;
// Supported capabilities
getSupportedEntities(): SyncEntityType[];
getSupportedDirections(): SyncDirection[];
}
/**
* Sync options
*/
export interface SyncOptions {
entityTypes?: SyncEntityType[];
direction?: SyncDirection;
fullSync?: boolean; // If true, sync all records, not just changes
startDate?: Date;
endDate?: Date;
filters?: Record<string, unknown>;
batchSize?: number;
dryRun?: boolean;
}
/**
* Fetch options for retrieving records
*/
export interface FetchOptions {
limit?: number;
offset?: number;
startDate?: Date;
endDate?: Date;
modifiedSince?: Date;
filters?: Record<string, unknown>;
orderBy?: string;
orderDirection?: 'asc' | 'desc';
}
// ============================================================================
// METADATA TYPES
// ============================================================================
/**
* Integration provider metadata
*/
export interface IntegrationProvider {
type: IntegrationType;
name: string;
description: string;
category: 'erp' | 'accounting' | 'fiscal' | 'bank' | 'payments' | 'custom';
logoUrl?: string;
websiteUrl?: string;
documentationUrl?: string;
// Features
supportedEntities: SyncEntityType[];
supportedDirections: SyncDirection[];
supportsRealtime: boolean;
supportsWebhooks: boolean;
// Requirements
requiresCredentials: boolean;
requiredFields: string[];
optionalFields: string[];
// Availability
isAvailable: boolean;
isBeta: boolean;
regions?: string[]; // ISO country codes
}
// ============================================================================
// EVENT TYPES
// ============================================================================
/**
* Integration event types
*/
export enum IntegrationEventType {
CONNECTED = 'integration.connected',
DISCONNECTED = 'integration.disconnected',
SYNC_STARTED = 'integration.sync.started',
SYNC_COMPLETED = 'integration.sync.completed',
SYNC_FAILED = 'integration.sync.failed',
SYNC_PROGRESS = 'integration.sync.progress',
HEALTH_CHECK_FAILED = 'integration.health.failed',
CONFIG_UPDATED = 'integration.config.updated',
}
/**
* Integration event payload
*/
export interface IntegrationEvent {
type: IntegrationEventType;
integrationId: string;
tenantId: string;
timestamp: Date;
data: Record<string, unknown>;
}

View File

@@ -0,0 +1,918 @@
/**
* Odoo Accounting Connector
* Conector para funciones contables de Odoo ERP
*/
import { OdooClient } from './odoo.client.js';
import {
OdooAccount,
OdooAccountType,
OdooJournal,
OdooJournalEntry,
OdooJournalEntryLine,
OdooDomain,
OdooPagination,
OdooPaginatedResponse,
ChartOfAccountsLine,
TrialBalanceLine,
ProfitAndLossReport,
ProfitAndLossLine,
BalanceSheetReport,
BalanceSheetSection,
BalanceSheetSubsection,
BalanceSheetLine,
TaxReport,
TaxReportLine,
OdooError,
} from './odoo.types.js';
// ============================================================================
// Tipos internos
// ============================================================================
interface DatePeriod {
dateFrom: Date;
dateTo: Date;
}
interface AccountBalance {
accountId: number;
accountCode: string;
accountName: string;
accountType: OdooAccountType;
debit: number;
credit: number;
balance: number;
}
// ============================================================================
// Accounting Connector Class
// ============================================================================
/**
* Conector de contabilidad para Odoo
*/
export class OdooAccountingConnector {
private client: OdooClient;
constructor(client: OdooClient) {
this.client = client;
}
// ==========================================================================
// Chart of Accounts
// ==========================================================================
/**
* Obtiene el catalogo de cuentas
*/
async getChartOfAccounts(
options?: {
includeDeprecated?: boolean;
accountTypes?: OdooAccountType[];
companyId?: number;
}
): Promise<ChartOfAccountsLine[]> {
const domain: OdooDomain = [];
if (!options?.includeDeprecated) {
domain.push(['deprecated', '=', false]);
}
if (options?.accountTypes && options.accountTypes.length > 0) {
domain.push(['account_type', 'in', options.accountTypes]);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
const accounts = await this.client.searchRead<OdooAccount>(
'account.account',
domain,
[
'id',
'code',
'name',
'account_type',
'group_id',
'root_id',
'reconcile',
'deprecated',
],
{ order: 'code' }
);
// Calcular balances de cada cuenta
const balances = await this.getAccountBalances(
accounts.map((a) => a.id),
undefined
);
const balanceMap = new Map(balances.map((b) => [b.accountId, b]));
return accounts.map((account) => {
const balance = balanceMap.get(account.id);
return {
id: account.id,
code: account.code,
name: account.name,
accountType: account.accountType,
level: this.getAccountLevel(account.code),
parentId: account.groupId?.[0],
debit: balance?.debit || 0,
credit: balance?.credit || 0,
balance: balance?.balance || 0,
};
});
}
/**
* Obtiene una cuenta por su codigo
*/
async getAccountByCode(code: string): Promise<OdooAccount | null> {
const accounts = await this.client.searchRead<OdooAccount>(
'account.account',
[['code', '=', code]],
undefined,
{ limit: 1 }
);
return accounts[0] || null;
}
/**
* Obtiene cuentas por tipo
*/
async getAccountsByType(
accountType: OdooAccountType
): Promise<OdooAccount[]> {
return this.client.searchRead<OdooAccount>(
'account.account',
[
['account_type', '=', accountType],
['deprecated', '=', false],
],
undefined,
{ order: 'code' }
);
}
// ==========================================================================
// Journal Entries
// ==========================================================================
/**
* Obtiene asientos contables de un periodo
*/
async getJournalEntries(
period: DatePeriod,
options?: {
state?: 'draft' | 'posted' | 'cancel';
journalId?: number;
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooJournalEntry>> {
const domain: OdooDomain = [
['date', '>=', this.formatDate(period.dateFrom)],
['date', '<=', this.formatDate(period.dateTo)],
['move_type', '=', 'entry'],
];
if (options?.state) {
domain.push(['state', '=', options.state]);
}
if (options?.journalId) {
domain.push(['journal_id', '=', options.journalId]);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
return this.client.searchReadPaginated<OdooJournalEntry>(
'account.move',
domain,
[
'id',
'name',
'ref',
'date',
'move_type',
'state',
'journal_id',
'company_id',
'currency_id',
'line_ids',
'partner_id',
'amount_total',
'amount_total_signed',
'narration',
'reversed_entry_id',
'create_date',
'write_date',
],
{ order: 'date desc, id desc', ...options?.pagination }
);
}
/**
* Obtiene las lineas de un asiento contable
*/
async getJournalEntryLines(
moveId: number
): Promise<OdooJournalEntryLine[]> {
return this.client.searchRead<OdooJournalEntryLine>(
'account.move.line',
[['move_id', '=', moveId]],
[
'id',
'move_id',
'move_name',
'sequence',
'name',
'ref',
'date',
'journal_id',
'company_id',
'account_id',
'partner_id',
'debit',
'credit',
'balance',
'amount_currency',
'currency_id',
'reconciled',
'full_reconcile_id',
'matching_number',
'analytic_distribution',
'tax_ids',
'tax_tag_ids',
'tax_line_id',
'display_type',
],
{ order: 'sequence, id' }
);
}
/**
* Obtiene los diarios contables
*/
async getJournals(
options?: {
type?: 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
companyId?: number;
}
): Promise<OdooJournal[]> {
const domain: OdooDomain = [['active', '=', true]];
if (options?.type) {
domain.push(['type', '=', options.type]);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
return this.client.searchRead<OdooJournal>(
'account.journal',
domain,
[
'id',
'name',
'code',
'type',
'company_id',
'currency_id',
'default_account_id',
'suspense_account_id',
'profit_account_id',
'loss_account_id',
'active',
'sequence',
],
{ order: 'sequence, id' }
);
}
// ==========================================================================
// Trial Balance
// ==========================================================================
/**
* Obtiene la balanza de comprobacion
*/
async getTrialBalance(
date: Date,
options?: {
fiscalYearStart?: Date;
companyId?: number;
accountTypes?: OdooAccountType[];
}
): Promise<TrialBalanceLine[]> {
// Determinar inicio del ejercicio fiscal
const fiscalYearStart = options?.fiscalYearStart ||
new Date(date.getFullYear(), 0, 1);
// Obtener cuentas
const domain: OdooDomain = [['deprecated', '=', false]];
if (options?.accountTypes && options.accountTypes.length > 0) {
domain.push(['account_type', 'in', options.accountTypes]);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
const accounts = await this.client.searchRead<OdooAccount>(
'account.account',
domain,
['id', 'code', 'name', 'account_type'],
{ order: 'code' }
);
if (accounts.length === 0) return [];
const accountIds = accounts.map((a) => a.id);
// Obtener saldos iniciales (antes del periodo)
const initialBalances = await this.getAccountBalances(
accountIds,
{ dateFrom: new Date('1900-01-01'), dateTo: new Date(fiscalYearStart.getTime() - 86400000) },
options?.companyId
);
// Obtener movimientos del periodo
const periodBalances = await this.getAccountBalances(
accountIds,
{ dateFrom: fiscalYearStart, dateTo: date },
options?.companyId
);
// Combinar datos
const initialMap = new Map(initialBalances.map((b) => [b.accountId, b]));
const periodMap = new Map(periodBalances.map((b) => [b.accountId, b]));
return accounts.map((account) => {
const initial = initialMap.get(account.id);
const period = periodMap.get(account.id);
const initialDebit = initial?.debit || 0;
const initialCredit = initial?.credit || 0;
const initialBalance = initial?.balance || 0;
const periodDebit = period?.debit || 0;
const periodCredit = period?.credit || 0;
const periodBalance = period?.balance || 0;
return {
accountId: account.id,
accountCode: account.code,
accountName: account.name,
accountType: account.accountType,
initialDebit,
initialCredit,
initialBalance,
periodDebit,
periodCredit,
periodBalance,
finalDebit: initialDebit + periodDebit,
finalCredit: initialCredit + periodCredit,
finalBalance: initialBalance + periodBalance,
};
});
}
// ==========================================================================
// Profit and Loss
// ==========================================================================
/**
* Obtiene el estado de resultados
*/
async getProfitAndLoss(
period: DatePeriod,
options?: {
companyId?: number;
}
): Promise<ProfitAndLossReport> {
const connection = this.client.getConnection();
if (!connection) {
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
}
const companyId = options?.companyId || connection.activeCompanyId;
const company = connection.companies.find((c) => c.id === companyId);
// Obtener cuentas de ingreso
const incomeAccounts = await this.client.searchRead<OdooAccount>(
'account.account',
[
['account_type', 'in', ['income', 'income_other']],
['deprecated', '=', false],
['company_id', '=', companyId],
],
['id', 'code', 'name', 'account_type']
);
// Obtener cuentas de gasto
const expenseAccounts = await this.client.searchRead<OdooAccount>(
'account.account',
[
['account_type', 'in', ['expense', 'expense_depreciation', 'expense_direct_cost']],
['deprecated', '=', false],
['company_id', '=', companyId],
],
['id', 'code', 'name', 'account_type']
);
// Obtener balances
const allAccountIds = [
...incomeAccounts.map((a) => a.id),
...expenseAccounts.map((a) => a.id),
];
const balances = await this.getAccountBalances(
allAccountIds,
period,
companyId
);
const balanceMap = new Map(balances.map((b) => [b.accountId, b]));
// Calcular ingresos
const incomeLines: ProfitAndLossLine[] = incomeAccounts.map((account) => {
const balance = balanceMap.get(account.id);
// Ingresos son creditos (balance negativo), invertir signo
const amount = -(balance?.balance || 0);
return {
accountId: account.id,
accountCode: account.code,
accountName: account.name,
amount,
percentage: 0,
};
});
const totalIncome = incomeLines.reduce((sum, l) => sum + l.amount, 0);
// Calcular porcentajes de ingreso
incomeLines.forEach((line) => {
line.percentage = totalIncome > 0 ? (line.amount / totalIncome) * 100 : 0;
});
// Calcular gastos
const expenseLines: ProfitAndLossLine[] = expenseAccounts.map((account) => {
const balance = balanceMap.get(account.id);
// Gastos son debitos (balance positivo)
const amount = balance?.balance || 0;
return {
accountId: account.id,
accountCode: account.code,
accountName: account.name,
amount,
percentage: 0,
};
});
const totalExpense = expenseLines.reduce((sum, l) => sum + l.amount, 0);
// Calcular porcentajes de gasto
expenseLines.forEach((line) => {
line.percentage = totalExpense > 0 ? (line.amount / totalExpense) * 100 : 0;
});
return {
dateFrom: this.formatDate(period.dateFrom),
dateTo: this.formatDate(period.dateTo),
companyId,
companyName: company?.name || 'Unknown',
currencyId: company?.currencyId || 0,
currencyCode: company?.currencyCode || 'MXN',
income: {
total: totalIncome,
lines: incomeLines.filter((l) => l.amount !== 0).sort((a, b) => b.amount - a.amount),
},
expense: {
total: totalExpense,
lines: expenseLines.filter((l) => l.amount !== 0).sort((a, b) => b.amount - a.amount),
},
netProfit: totalIncome - totalExpense,
};
}
// ==========================================================================
// Balance Sheet
// ==========================================================================
/**
* Obtiene el balance general
*/
async getBalanceSheet(
date: Date,
options?: {
companyId?: number;
}
): Promise<BalanceSheetReport> {
const connection = this.client.getConnection();
if (!connection) {
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
}
const companyId = options?.companyId || connection.activeCompanyId;
const company = connection.companies.find((c) => c.id === companyId);
// Tipos de cuenta por seccion
const assetTypes: OdooAccountType[] = [
'asset_receivable',
'asset_cash',
'asset_current',
'asset_non_current',
'asset_prepayments',
'asset_fixed',
];
const liabilityTypes: OdooAccountType[] = [
'liability_payable',
'liability_credit_card',
'liability_current',
'liability_non_current',
];
const equityTypes: OdooAccountType[] = [
'equity',
'equity_unaffected',
];
// Obtener cuentas por tipo
const [assetAccounts, liabilityAccounts, equityAccounts] = await Promise.all([
this.getAccountsWithBalances(assetTypes, date, companyId),
this.getAccountsWithBalances(liabilityTypes, date, companyId),
this.getAccountsWithBalances(equityTypes, date, companyId),
]);
// Agrupar por tipo de cuenta
const assets = this.groupAccountsByType(assetAccounts);
const liabilities = this.groupAccountsByType(liabilityAccounts);
const equity = this.groupAccountsByType(equityAccounts);
// Calcular resultado del ejercicio
const profitLoss = await this.getProfitAndLoss(
{ dateFrom: new Date(date.getFullYear(), 0, 1), dateTo: date },
{ companyId }
);
// Agregar resultado del ejercicio al capital
if (profitLoss.netProfit !== 0) {
equity.subsections.push({
name: 'Resultado del Ejercicio',
total: profitLoss.netProfit,
lines: [{
accountId: 0,
accountCode: 'RES',
accountName: 'Resultado del Periodo',
balance: profitLoss.netProfit,
}],
});
equity.total += profitLoss.netProfit;
}
return {
date: this.formatDate(date),
companyId,
companyName: company?.name || 'Unknown',
currencyId: company?.currencyId || 0,
currencyCode: company?.currencyCode || 'MXN',
assets,
liabilities,
equity,
totalAssets: assets.total,
totalLiabilitiesEquity: liabilities.total + equity.total,
};
}
// ==========================================================================
// Tax Report
// ==========================================================================
/**
* Obtiene el reporte de impuestos
*/
async getTaxReport(
period: DatePeriod,
options?: {
companyId?: number;
}
): Promise<TaxReport> {
const connection = this.client.getConnection();
if (!connection) {
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
}
const companyId = options?.companyId || connection.activeCompanyId;
const company = connection.companies.find((c) => c.id === companyId);
// Obtener impuestos
const taxes = await this.client.searchRead<{
id: number;
name: string;
typeTaxUse: 'sale' | 'purchase' | 'none';
amount: number;
amountType: string;
}>(
'account.tax',
[
['company_id', '=', companyId],
['active', '=', true],
],
['id', 'name', 'type_tax_use', 'amount', 'amount_type']
);
// Obtener lineas de movimiento con impuestos en el periodo
const taxLines = await this.client.searchRead<{
taxLineId: [number, string];
debit: number;
credit: number;
balance: number;
moveId: [number, string];
}>(
'account.move.line',
[
['date', '>=', this.formatDate(period.dateFrom)],
['date', '<=', this.formatDate(period.dateTo)],
['company_id', '=', companyId],
['tax_line_id', '!=', false],
['parent_state', '=', 'posted'],
],
['tax_line_id', 'debit', 'credit', 'balance', 'move_id']
);
// Agrupar por impuesto
const taxData = new Map<number, {
baseAmount: number;
taxAmount: number;
invoiceCount: Set<number>;
}>();
for (const line of taxLines) {
const taxId = line.taxLineId[0];
const moveId = line.moveId[0];
if (!taxData.has(taxId)) {
taxData.set(taxId, {
baseAmount: 0,
taxAmount: 0,
invoiceCount: new Set(),
});
}
const data = taxData.get(taxId)!;
data.taxAmount += line.balance;
data.invoiceCount.add(moveId);
}
// Construir lineas del reporte
const lines: TaxReportLine[] = taxes.map((tax) => {
const data = taxData.get(tax.id);
const taxAmount = data?.taxAmount || 0;
// Estimar base del impuesto
const baseAmount = tax.amount > 0 ? taxAmount / (tax.amount / 100) : 0;
return {
taxId: tax.id,
taxName: tax.name,
taxType: tax.typeTaxUse,
baseAmount: Math.abs(baseAmount),
taxAmount: Math.abs(taxAmount),
invoiceCount: data?.invoiceCount.size || 0,
};
});
// Calcular totales
const salesLines = lines.filter((l) => l.taxType === 'sale');
const purchaseLines = lines.filter((l) => l.taxType === 'purchase');
const totalSalesBase = salesLines.reduce((sum, l) => sum + l.baseAmount, 0);
const totalSalesTax = salesLines.reduce((sum, l) => sum + l.taxAmount, 0);
const totalPurchasesBase = purchaseLines.reduce((sum, l) => sum + l.baseAmount, 0);
const totalPurchasesTax = purchaseLines.reduce((sum, l) => sum + l.taxAmount, 0);
return {
dateFrom: this.formatDate(period.dateFrom),
dateTo: this.formatDate(period.dateTo),
companyId,
companyName: company?.name || 'Unknown',
lines: lines.filter((l) => l.taxAmount !== 0),
totalSalesBase,
totalSalesTax,
totalPurchasesBase,
totalPurchasesTax,
netTax: totalSalesTax - totalPurchasesTax,
};
}
// ==========================================================================
// Private Helpers
// ==========================================================================
/**
* Obtiene balances de cuentas
*/
private async getAccountBalances(
accountIds: number[],
period?: DatePeriod,
companyId?: number
): Promise<AccountBalance[]> {
if (accountIds.length === 0) return [];
const domain: OdooDomain = [
['account_id', 'in', accountIds],
['parent_state', '=', 'posted'],
];
if (period) {
domain.push(['date', '>=', this.formatDate(period.dateFrom)]);
domain.push(['date', '<=', this.formatDate(period.dateTo)]);
}
if (companyId) {
domain.push(['company_id', '=', companyId]);
}
// Usar read_group para agregar por cuenta
const result = await this.client.callMethod<Array<{
account_id: [number, string];
debit: number;
credit: number;
balance: number;
__count: number;
}>>(
'account.move.line',
'read_group',
[domain, ['account_id', 'debit', 'credit', 'balance'], ['account_id']],
{ lazy: false }
);
// Obtener informacion de cuentas
const accounts = await this.client.read<{
id: number;
code: string;
name: string;
accountType: OdooAccountType;
}>(
'account.account',
accountIds,
['id', 'code', 'name', 'account_type']
);
const accountMap = new Map(accounts.map((a) => [a.id, a]));
return result.map((r) => {
const account = accountMap.get(r.account_id[0]);
return {
accountId: r.account_id[0],
accountCode: account?.code || '',
accountName: account?.name || r.account_id[1],
accountType: account?.accountType || 'asset_current',
debit: r.debit || 0,
credit: r.credit || 0,
balance: r.balance || 0,
};
});
}
/**
* Obtiene cuentas con balances
*/
private async getAccountsWithBalances(
accountTypes: OdooAccountType[],
date: Date,
companyId: number
): Promise<Array<OdooAccount & { balance: number }>> {
const accounts = await this.client.searchRead<OdooAccount>(
'account.account',
[
['account_type', 'in', accountTypes],
['deprecated', '=', false],
['company_id', '=', companyId],
],
['id', 'code', 'name', 'account_type']
);
if (accounts.length === 0) return [];
const balances = await this.getAccountBalances(
accounts.map((a) => a.id),
{ dateFrom: new Date('1900-01-01'), dateTo: date },
companyId
);
const balanceMap = new Map(balances.map((b) => [b.accountId, b]));
return accounts.map((account) => ({
...account,
balance: balanceMap.get(account.id)?.balance || 0,
}));
}
/**
* Agrupa cuentas por tipo
*/
private groupAccountsByType(
accounts: Array<OdooAccount & { balance: number }>
): BalanceSheetSection {
const typeGroups = new Map<string, Array<OdooAccount & { balance: number }>>();
for (const account of accounts) {
const type = account.accountType;
if (!typeGroups.has(type)) {
typeGroups.set(type, []);
}
typeGroups.get(type)!.push(account);
}
const subsections: BalanceSheetSubsection[] = [];
for (const [type, accts] of typeGroups) {
const lines: BalanceSheetLine[] = accts
.filter((a) => a.balance !== 0)
.map((a) => ({
accountId: a.id,
accountCode: a.code,
accountName: a.name,
balance: a.balance,
}));
if (lines.length > 0) {
subsections.push({
name: this.getAccountTypeName(type as OdooAccountType),
total: lines.reduce((sum, l) => sum + l.balance, 0),
lines,
});
}
}
return {
total: subsections.reduce((sum, s) => sum + s.total, 0),
subsections,
};
}
/**
* Obtiene el nivel de una cuenta basado en su codigo
*/
private getAccountLevel(code: string): number {
const len = code.replace(/[^0-9]/g, '').length;
if (len <= 3) return 1;
if (len <= 4) return 2;
if (len <= 6) return 3;
return 4;
}
/**
* Obtiene el nombre legible de un tipo de cuenta
*/
private getAccountTypeName(type: OdooAccountType): string {
const names: Record<OdooAccountType, string> = {
asset_receivable: 'Cuentas por Cobrar',
asset_cash: 'Efectivo y Equivalentes',
asset_current: 'Activo Circulante',
asset_non_current: 'Activo No Circulante',
asset_prepayments: 'Pagos Anticipados',
asset_fixed: 'Activo Fijo',
liability_payable: 'Cuentas por Pagar',
liability_credit_card: 'Tarjetas de Credito',
liability_current: 'Pasivo Circulante',
liability_non_current: 'Pasivo No Circulante',
equity: 'Capital',
equity_unaffected: 'Resultados Acumulados',
income: 'Ingresos',
income_other: 'Otros Ingresos',
expense: 'Gastos',
expense_depreciation: 'Depreciacion',
expense_direct_cost: 'Costos Directos',
off_balance: 'Cuentas de Orden',
};
return names[type] || type;
}
/**
* Formatea una fecha a string YYYY-MM-DD
*/
private formatDate(date: Date): string {
return date.toISOString().split('T')[0] ?? '';
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea un conector de contabilidad
*/
export function createAccountingConnector(
client: OdooClient
): OdooAccountingConnector {
return new OdooAccountingConnector(client);
}

View File

@@ -0,0 +1,122 @@
/**
* Odoo ERP Integration
* Conector completo para integracion con Odoo ERP (versiones 14, 15, 16, 17)
*/
// Types
export * from './odoo.types.js';
// Client
export {
OdooClient,
createOdooClient,
clearSessionCache,
getSessionCacheSize,
} from './odoo.client.js';
// Connectors
export {
OdooAccountingConnector,
createAccountingConnector,
} from './accounting.connector.js';
export {
OdooInvoicingConnector,
createInvoicingConnector,
} from './invoicing.connector.js';
export {
OdooPartnersConnector,
createPartnersConnector,
} from './partners.connector.js';
export {
OdooInventoryConnector,
createInventoryConnector,
} from './inventory.connector.js';
// Sync Service
export {
OdooSyncService,
createOdooSyncService,
getOdooSyncService,
} from './odoo.sync.js';
// ============================================================================
// Convenience Factory
// ============================================================================
import { OdooClient, createOdooClient } from './odoo.client.js';
import { OdooAccountingConnector, createAccountingConnector } from './accounting.connector.js';
import { OdooInvoicingConnector, createInvoicingConnector } from './invoicing.connector.js';
import { OdooPartnersConnector, createPartnersConnector } from './partners.connector.js';
import { OdooInventoryConnector, createInventoryConnector } from './inventory.connector.js';
import { OdooConfig } from './odoo.types.js';
/**
* Instancia completa de Odoo con todos los conectores
*/
export interface OdooInstance {
client: OdooClient;
accounting: OdooAccountingConnector;
invoicing: OdooInvoicingConnector;
partners: OdooPartnersConnector;
inventory: OdooInventoryConnector;
connect: () => Promise<void>;
disconnect: () => void;
}
/**
* Crea una instancia completa de Odoo con todos los conectores
*
* @example
* ```typescript
* const odoo = createOdooInstance({
* url: 'https://mycompany.odoo.com',
* database: 'mydb',
* username: 'admin',
* password: 'admin123',
* version: 16,
* });
*
* await odoo.connect();
*
* // Obtener clientes
* const customers = await odoo.partners.getCustomers();
*
* // Obtener facturas del mes
* const invoices = await odoo.invoicing.getCustomerInvoices({
* dateFrom: new Date('2024-01-01'),
* dateTo: new Date('2024-01-31'),
* });
*
* // Obtener balance general
* const balanceSheet = await odoo.accounting.getBalanceSheet(new Date());
*
* // Obtener productos con stock
* const products = await odoo.inventory.getStockableProducts();
*
* odoo.disconnect();
* ```
*/
export function createOdooInstance(config: OdooConfig): OdooInstance {
const client = createOdooClient(config);
const accounting = createAccountingConnector(client);
const invoicing = createInvoicingConnector(client);
const partners = createPartnersConnector(client);
const inventory = createInventoryConnector(client);
return {
client,
accounting,
invoicing,
partners,
inventory,
connect: async () => {
await client.authenticate();
},
disconnect: () => {
client.disconnect();
},
};
}

View File

@@ -0,0 +1,865 @@
/**
* Odoo Inventory Connector
* Conector para funciones de inventario de Odoo ERP
*/
import { OdooClient } from './odoo.client.js';
import {
OdooProduct,
OdooProductType,
OdooStockLevel,
OdooStockMove,
OdooStockValuation,
OdooDomain,
OdooPagination,
OdooPaginatedResponse,
OdooError,
} from './odoo.types.js';
// ============================================================================
// Tipos internos
// ============================================================================
interface DatePeriod {
dateFrom: Date;
dateTo: Date;
}
interface StockLocation {
id: number;
name: string;
completeName: string;
locationType: 'supplier' | 'view' | 'internal' | 'customer' | 'inventory' | 'production' | 'transit';
companyId?: [number, string];
active: boolean;
}
interface StockWarehouse {
id: number;
name: string;
code: string;
companyId: [number, string];
lotStockId: [number, string];
viewLocationId: [number, string];
active: boolean;
}
interface ProductCategory {
id: number;
name: string;
completeName: string;
parentId?: [number, string];
productCount: number;
}
interface StockMoveSummary {
incoming: number;
outgoing: number;
internal: number;
incomingValue: number;
outgoingValue: number;
}
// ============================================================================
// Inventory Connector Class
// ============================================================================
/**
* Conector de inventario para Odoo
*/
export class OdooInventoryConnector {
private client: OdooClient;
constructor(client: OdooClient) {
this.client = client;
}
// ==========================================================================
// Products
// ==========================================================================
/**
* Obtiene productos
*/
async getProducts(
options?: {
search?: string;
type?: OdooProductType;
categoryId?: number;
saleOk?: boolean;
purchaseOk?: boolean;
includeInactive?: boolean;
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooProduct>> {
const domain: OdooDomain = [];
if (!options?.includeInactive) {
domain.push(['active', '=', true]);
}
if (options?.search) {
domain.push('|');
domain.push('|');
domain.push(['name', 'ilike', options.search]);
domain.push(['default_code', 'ilike', options.search]);
domain.push(['barcode', 'ilike', options.search]);
}
if (options?.type) {
domain.push(['type', '=', options.type]);
}
if (options?.categoryId) {
domain.push(['categ_id', 'child_of', options.categoryId]);
}
if (options?.saleOk !== undefined) {
domain.push(['sale_ok', '=', options.saleOk]);
}
if (options?.purchaseOk !== undefined) {
domain.push(['purchase_ok', '=', options.purchaseOk]);
}
if (options?.companyId) {
domain.push('|');
domain.push(['company_id', '=', options.companyId]);
domain.push(['company_id', '=', false]);
}
return this.client.searchReadPaginated<OdooProduct>(
'product.product',
domain,
this.getProductFields(),
{ order: 'name asc', ...options?.pagination }
);
}
/**
* Obtiene productos almacenables (con stock)
*/
async getStockableProducts(
options?: {
categoryId?: number;
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooProduct>> {
return this.getProducts({
type: 'product',
...options,
});
}
/**
* Busca producto por codigo interno
*/
async getProductByCode(code: string): Promise<OdooProduct | null> {
const products = await this.client.searchRead<OdooProduct>(
'product.product',
[['default_code', '=', code]],
this.getProductFields(),
{ limit: 1 }
);
return products[0] || null;
}
/**
* Busca producto por codigo de barras
*/
async getProductByBarcode(barcode: string): Promise<OdooProduct | null> {
const products = await this.client.searchRead<OdooProduct>(
'product.product',
[['barcode', '=', barcode]],
this.getProductFields(),
{ limit: 1 }
);
return products[0] || null;
}
/**
* Obtiene un producto por ID
*/
async getProductById(productId: number): Promise<OdooProduct | null> {
const products = await this.client.read<OdooProduct>(
'product.product',
[productId],
this.getProductFields()
);
return products[0] || null;
}
/**
* Obtiene categorias de productos
*/
async getProductCategories(
options?: {
parentId?: number;
}
): Promise<ProductCategory[]> {
const domain: OdooDomain = [];
if (options?.parentId !== undefined) {
if (options.parentId === 0) {
domain.push(['parent_id', '=', false]);
} else {
domain.push(['parent_id', '=', options.parentId]);
}
}
return this.client.searchRead<ProductCategory>(
'product.category',
domain,
['id', 'name', 'complete_name', 'parent_id', 'product_count'],
{ order: 'complete_name asc' }
);
}
// ==========================================================================
// Stock Levels
// ==========================================================================
/**
* Obtiene niveles de stock
*/
async getStockLevels(
options?: {
productIds?: number[];
locationId?: number;
warehouseId?: number;
companyId?: number;
onlyAvailable?: boolean;
}
): Promise<OdooStockLevel[]> {
// Primero, obtener las ubicaciones relevantes
let locationIds: number[] = [];
if (options?.locationId) {
locationIds = [options.locationId];
} else if (options?.warehouseId) {
const warehouse = await this.getWarehouse(options.warehouseId);
if (warehouse) {
locationIds = [warehouse.lotStockId[0]];
}
} else {
// Obtener todas las ubicaciones internas
const locations = await this.getStockLocations({
locationType: 'internal',
companyId: options?.companyId,
});
locationIds = locations.map((l) => l.id);
}
if (locationIds.length === 0) return [];
// Construir dominio para stock.quant
const domain: OdooDomain = [
['location_id', 'in', locationIds],
];
if (options?.productIds && options.productIds.length > 0) {
domain.push(['product_id', 'in', options.productIds]);
}
if (options?.onlyAvailable) {
domain.push(['available_quantity', '>', 0]);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
const quants = await this.client.searchRead<{
productId: [number, string];
locationId: [number, string];
lotId: [number, string] | false;
packageId: [number, string] | false;
quantity: number;
reservedQuantity: number;
availableQuantity: number;
productUomId: [number, string];
}>(
'stock.quant',
domain,
[
'product_id',
'location_id',
'lot_id',
'package_id',
'quantity',
'reserved_quantity',
'available_quantity',
'product_uom_id',
],
{ order: 'product_id, location_id' }
);
// Obtener informacion de productos
const productIds = [...new Set(quants.map((q) => q.productId[0]))];
const products = await this.client.read<{
id: number;
name: string;
defaultCode: string;
}>(
'product.product',
productIds,
['id', 'name', 'default_code']
);
const productMap = new Map(products.map((p) => [p.id, p]));
return quants.map((quant) => {
const product = productMap.get(quant.productId[0]);
return {
productId: quant.productId[0],
productName: product?.name || quant.productId[1],
productCode: product?.defaultCode,
locationId: quant.locationId[0],
locationName: quant.locationId[1],
lotId: quant.lotId ? quant.lotId[0] : undefined,
lotName: quant.lotId ? quant.lotId[1] : undefined,
packageId: quant.packageId ? quant.packageId[0] : undefined,
packageName: quant.packageId ? quant.packageId[1] : undefined,
quantity: quant.quantity,
reservedQuantity: quant.reservedQuantity,
availableQuantity: quant.availableQuantity,
uomId: quant.productUomId[0],
uomName: quant.productUomId[1],
};
});
}
/**
* Obtiene stock de un producto
*/
async getProductStock(
productId: number,
options?: {
locationId?: number;
warehouseId?: number;
}
): Promise<{
onHand: number;
reserved: number;
available: number;
incoming: number;
outgoing: number;
}> {
const levels = await this.getStockLevels({
productIds: [productId],
locationId: options?.locationId,
warehouseId: options?.warehouseId,
});
const onHand = levels.reduce((sum, l) => sum + l.quantity, 0);
const reserved = levels.reduce((sum, l) => sum + l.reservedQuantity, 0);
const available = levels.reduce((sum, l) => sum + l.availableQuantity, 0);
// Obtener movimientos pendientes
const [incoming, outgoing] = await Promise.all([
this.getPendingMovements(productId, 'incoming', options?.locationId),
this.getPendingMovements(productId, 'outgoing', options?.locationId),
]);
return {
onHand,
reserved,
available,
incoming,
outgoing,
};
}
/**
* Obtiene stock agrupado por producto
*/
async getStockByProduct(
options?: {
categoryId?: number;
warehouseId?: number;
companyId?: number;
onlyWithStock?: boolean;
}
): Promise<Array<{
productId: number;
productName: string;
productCode?: string;
onHand: number;
reserved: number;
available: number;
value: number;
}>> {
// Obtener productos
const productDomain: OdooDomain = [
['type', '=', 'product'],
['active', '=', true],
];
if (options?.categoryId) {
productDomain.push(['categ_id', 'child_of', options.categoryId]);
}
const products = await this.client.searchRead<OdooProduct>(
'product.product',
productDomain,
['id', 'name', 'default_code', 'standard_price', 'qty_available', 'virtual_available']
);
if (options?.onlyWithStock) {
return products
.filter((p) => (p.qtyAvailable || 0) > 0)
.map((p) => ({
productId: p.id,
productName: p.name,
productCode: p.defaultCode,
onHand: p.qtyAvailable || 0,
reserved: (p.qtyAvailable || 0) - (p.virtualAvailable || 0),
available: p.virtualAvailable || 0,
value: (p.qtyAvailable || 0) * p.standardPrice,
}));
}
return products.map((p) => ({
productId: p.id,
productName: p.name,
productCode: p.defaultCode,
onHand: p.qtyAvailable || 0,
reserved: Math.max(0, (p.qtyAvailable || 0) - (p.virtualAvailable || 0)),
available: p.virtualAvailable || 0,
value: (p.qtyAvailable || 0) * p.standardPrice,
}));
}
// ==========================================================================
// Stock Moves
// ==========================================================================
/**
* Obtiene movimientos de stock de un periodo
*/
async getStockMoves(
period: DatePeriod,
options?: {
productId?: number;
locationId?: number;
pickingCode?: 'incoming' | 'outgoing' | 'internal';
state?: 'draft' | 'waiting' | 'confirmed' | 'assigned' | 'done' | 'cancel';
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooStockMove>> {
const domain: OdooDomain = [
['date', '>=', this.formatDateTime(period.dateFrom)],
['date', '<=', this.formatDateTime(period.dateTo)],
];
if (options?.productId) {
domain.push(['product_id', '=', options.productId]);
}
if (options?.locationId) {
domain.push('|');
domain.push(['location_id', '=', options.locationId]);
domain.push(['location_dest_id', '=', options.locationId]);
}
if (options?.pickingCode) {
domain.push(['picking_code', '=', options.pickingCode]);
}
if (options?.state) {
domain.push(['state', '=', options.state]);
} else {
domain.push(['state', '=', 'done']);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
return this.client.searchReadPaginated<OdooStockMove>(
'stock.move',
domain,
[
'id',
'name',
'reference',
'date',
'product_id',
'product_uom_qty',
'product_uom',
'location_id',
'location_dest_id',
'state',
'picking_id',
'picking_code',
'origin_returned_move_id',
'price_unit',
'company_id',
'partner_id',
'origin',
'create_date',
'write_date',
],
{ order: 'date desc, id desc', ...options?.pagination }
);
}
/**
* Obtiene resumen de movimientos de stock
*/
async getStockMoveSummary(
period: DatePeriod,
options?: {
productId?: number;
companyId?: number;
}
): Promise<StockMoveSummary> {
const moves = await this.getStockMoves(period, {
productId: options?.productId,
companyId: options?.companyId,
state: 'done',
pagination: { limit: 10000 },
});
let incoming = 0;
let outgoing = 0;
let internal = 0;
let incomingValue = 0;
let outgoingValue = 0;
for (const move of moves.items) {
const value = move.productUomQty * move.priceUnit;
switch (move.pickingCode) {
case 'incoming':
incoming += move.productUomQty;
incomingValue += value;
break;
case 'outgoing':
outgoing += move.productUomQty;
outgoingValue += value;
break;
default:
internal += move.productUomQty;
}
}
return {
incoming,
outgoing,
internal,
incomingValue,
outgoingValue,
};
}
/**
* Obtiene historial de movimientos de un producto
*/
async getProductMovementHistory(
productId: number,
options?: {
dateFrom?: Date;
dateTo?: Date;
locationId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooStockMove>> {
const period: DatePeriod = {
dateFrom: options?.dateFrom || new Date('1900-01-01'),
dateTo: options?.dateTo || new Date(),
};
return this.getStockMoves(period, {
productId,
locationId: options?.locationId,
pagination: options?.pagination,
});
}
// ==========================================================================
// Stock Valuation
// ==========================================================================
/**
* Obtiene valoracion de inventario
*/
async getStockValuation(
options?: {
categoryId?: number;
warehouseId?: number;
companyId?: number;
date?: Date;
}
): Promise<OdooStockValuation[]> {
const connection = this.client.getConnection();
if (!connection) {
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
}
const companyId = options?.companyId || connection.activeCompanyId;
const company = connection.companies.find((c) => c.id === companyId);
// Obtener productos con stock
const stockByProduct = await this.getStockByProduct({
categoryId: options?.categoryId,
warehouseId: options?.warehouseId,
companyId,
onlyWithStock: true,
});
// Obtener informacion de costos de productos
const productIds = stockByProduct.map((s) => s.productId);
if (productIds.length === 0) return [];
const products = await this.client.read<{
id: number;
standardPrice: number;
costMethod: string;
}>(
'product.product',
productIds,
['id', 'standard_price', 'cost_method']
);
const productMap = new Map(products.map((p) => [p.id, p]));
return stockByProduct.map((stock) => {
const product = productMap.get(stock.productId);
const unitCost = product?.standardPrice || 0;
return {
productId: stock.productId,
productName: stock.productName,
productCode: stock.productCode,
quantity: stock.onHand,
unitCost,
totalValue: stock.onHand * unitCost,
currencyId: company?.currencyId || 0,
currencyCode: company?.currencyCode || 'MXN',
valuationMethod: product?.costMethod || 'standard',
};
});
}
/**
* Obtiene valor total del inventario
*/
async getTotalInventoryValue(
options?: {
categoryId?: number;
warehouseId?: number;
companyId?: number;
}
): Promise<{
totalValue: number;
totalProducts: number;
totalQuantity: number;
currencyCode: string;
}> {
const valuation = await this.getStockValuation(options);
return {
totalValue: valuation.reduce((sum, v) => sum + v.totalValue, 0),
totalProducts: valuation.length,
totalQuantity: valuation.reduce((sum, v) => sum + v.quantity, 0),
currencyCode: valuation[0]?.currencyCode || 'MXN',
};
}
// ==========================================================================
// Locations & Warehouses
// ==========================================================================
/**
* Obtiene ubicaciones de stock
*/
async getStockLocations(
options?: {
locationType?: 'supplier' | 'view' | 'internal' | 'customer' | 'inventory' | 'production' | 'transit';
warehouseId?: number;
companyId?: number;
includeInactive?: boolean;
}
): Promise<StockLocation[]> {
const domain: OdooDomain = [];
if (!options?.includeInactive) {
domain.push(['active', '=', true]);
}
if (options?.locationType) {
domain.push(['usage', '=', options.locationType]);
}
if (options?.warehouseId) {
domain.push(['warehouse_id', '=', options.warehouseId]);
}
if (options?.companyId) {
domain.push('|');
domain.push(['company_id', '=', options.companyId]);
domain.push(['company_id', '=', false]);
}
const locations = await this.client.searchRead<{
id: number;
name: string;
completeName: string;
usage: string;
companyId: [number, string] | false;
active: boolean;
}>(
'stock.location',
domain,
['id', 'name', 'complete_name', 'usage', 'company_id', 'active'],
{ order: 'complete_name asc' }
);
return locations.map((loc) => ({
id: loc.id,
name: loc.name,
completeName: loc.completeName,
locationType: loc.usage as StockLocation['locationType'],
companyId: loc.companyId ? [loc.companyId[0], loc.companyId[1]] : undefined,
active: loc.active,
}));
}
/**
* Obtiene almacenes
*/
async getWarehouses(
options?: {
companyId?: number;
}
): Promise<StockWarehouse[]> {
const domain: OdooDomain = [['active', '=', true]];
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
return this.client.searchRead<StockWarehouse>(
'stock.warehouse',
domain,
['id', 'name', 'code', 'company_id', 'lot_stock_id', 'view_location_id', 'active'],
{ order: 'name asc' }
);
}
/**
* Obtiene un almacen por ID
*/
async getWarehouse(warehouseId: number): Promise<StockWarehouse | null> {
const warehouses = await this.client.read<StockWarehouse>(
'stock.warehouse',
[warehouseId],
['id', 'name', 'code', 'company_id', 'lot_stock_id', 'view_location_id', 'active']
);
return warehouses[0] || null;
}
// ==========================================================================
// Private Helpers
// ==========================================================================
/**
* Obtiene movimientos pendientes (entrantes o salientes)
*/
private async getPendingMovements(
productId: number,
direction: 'incoming' | 'outgoing',
locationId?: number
): Promise<number> {
const domain: OdooDomain = [
['product_id', '=', productId],
['state', 'in', ['waiting', 'confirmed', 'assigned']],
];
if (direction === 'incoming') {
domain.push(['picking_code', '=', 'incoming']);
} else {
domain.push(['picking_code', '=', 'outgoing']);
}
if (locationId) {
const field = direction === 'incoming' ? 'location_dest_id' : 'location_id';
domain.push([field, '=', locationId]);
}
const moves = await this.client.searchRead<{
productUomQty: number;
}>(
'stock.move',
domain,
['product_uom_qty']
);
return moves.reduce((sum, m) => sum + m.productUomQty, 0);
}
/**
* Campos a leer de productos
*/
private getProductFields(): string[] {
return [
'id',
'name',
'display_name',
'default_code',
'barcode',
'type',
'categ_id',
'list_price',
'standard_price',
'sale_ok',
'purchase_ok',
'active',
'uom_id',
'uom_po_id',
'company_id',
'description',
'description_sale',
'description_purchase',
'weight',
'volume',
'tracking',
'property_account_income_id',
'property_account_expense_id',
'taxes_id',
'supplier_taxes_id',
'qty_available',
'virtual_available',
'incoming_qty',
'outgoing_qty',
'free_qty',
'create_date',
'write_date',
];
}
/**
* Formatea una fecha a string YYYY-MM-DD HH:MM:SS
*/
private formatDateTime(date: Date): string {
return date.toISOString().replace('T', ' ').split('.')[0] ?? '';
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea un conector de inventario
*/
export function createInventoryConnector(
client: OdooClient
): OdooInventoryConnector {
return new OdooInventoryConnector(client);
}

View File

@@ -0,0 +1,939 @@
/**
* Odoo Invoicing Connector
* Conector para funciones de facturacion de Odoo ERP
*/
import { OdooClient } from './odoo.client.js';
import {
OdooInvoice,
OdooInvoiceLine,
OdooInvoiceType,
OdooInvoiceState,
OdooPaymentState,
OdooPayment,
OdooBankStatement,
OdooBankStatementLine,
OdooDomain,
OdooPagination,
OdooPaginatedResponse,
OdooError,
} from './odoo.types.js';
// ============================================================================
// Tipos internos
// ============================================================================
interface DatePeriod {
dateFrom: Date;
dateTo: Date;
}
interface InvoiceSummary {
total: number;
totalPaid: number;
totalPending: number;
count: number;
countPaid: number;
countPending: number;
}
interface PaymentSummary {
totalInbound: number;
totalOutbound: number;
countInbound: number;
countOutbound: number;
}
// ============================================================================
// Invoicing Connector Class
// ============================================================================
/**
* Conector de facturacion para Odoo
*/
export class OdooInvoicingConnector {
private client: OdooClient;
constructor(client: OdooClient) {
this.client = client;
}
// ==========================================================================
// Customer Invoices
// ==========================================================================
/**
* Obtiene facturas de cliente de un periodo
*/
async getCustomerInvoices(
period: DatePeriod,
options?: {
state?: OdooInvoiceState;
paymentState?: OdooPaymentState;
partnerId?: number;
companyId?: number;
includeRefunds?: boolean;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooInvoice>> {
const invoiceTypes: OdooInvoiceType[] = ['out_invoice'];
if (options?.includeRefunds) {
invoiceTypes.push('out_refund');
}
return this.getInvoices(period, invoiceTypes, options);
}
/**
* Obtiene notas de credito de cliente
*/
async getCustomerRefunds(
period: DatePeriod,
options?: {
state?: OdooInvoiceState;
partnerId?: number;
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooInvoice>> {
return this.getInvoices(period, ['out_refund'], options);
}
/**
* Obtiene resumen de facturas de cliente
*/
async getCustomerInvoiceSummary(
period: DatePeriod,
options?: {
companyId?: number;
}
): Promise<InvoiceSummary> {
return this.getInvoiceSummary(period, ['out_invoice', 'out_refund'], options);
}
// ==========================================================================
// Vendor Bills
// ==========================================================================
/**
* Obtiene facturas de proveedor de un periodo
*/
async getVendorBills(
period: DatePeriod,
options?: {
state?: OdooInvoiceState;
paymentState?: OdooPaymentState;
partnerId?: number;
companyId?: number;
includeRefunds?: boolean;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooInvoice>> {
const invoiceTypes: OdooInvoiceType[] = ['in_invoice'];
if (options?.includeRefunds) {
invoiceTypes.push('in_refund');
}
return this.getInvoices(period, invoiceTypes, options);
}
/**
* Obtiene notas de credito de proveedor
*/
async getVendorRefunds(
period: DatePeriod,
options?: {
state?: OdooInvoiceState;
partnerId?: number;
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooInvoice>> {
return this.getInvoices(period, ['in_refund'], options);
}
/**
* Obtiene resumen de facturas de proveedor
*/
async getVendorBillSummary(
period: DatePeriod,
options?: {
companyId?: number;
}
): Promise<InvoiceSummary> {
return this.getInvoiceSummary(period, ['in_invoice', 'in_refund'], options);
}
// ==========================================================================
// Open Invoices
// ==========================================================================
/**
* Obtiene facturas abiertas (pendientes de pago)
*/
async getOpenInvoices(
options?: {
type?: 'customer' | 'vendor' | 'all';
partnerId?: number;
companyId?: number;
dueDateBefore?: Date;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooInvoice>> {
let invoiceTypes: OdooInvoiceType[];
switch (options?.type) {
case 'customer':
invoiceTypes = ['out_invoice', 'out_refund'];
break;
case 'vendor':
invoiceTypes = ['in_invoice', 'in_refund'];
break;
default:
invoiceTypes = ['out_invoice', 'out_refund', 'in_invoice', 'in_refund'];
}
const domain: OdooDomain = [
['move_type', 'in', invoiceTypes],
['state', '=', 'posted'],
['payment_state', 'in', ['not_paid', 'partial']],
];
if (options?.partnerId) {
domain.push(['partner_id', '=', options.partnerId]);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
if (options?.dueDateBefore) {
domain.push(['invoice_date_due', '<=', this.formatDate(options.dueDateBefore)]);
}
return this.client.searchReadPaginated<OdooInvoice>(
'account.move',
domain,
this.getInvoiceFields(),
{ order: 'invoice_date_due asc, id desc', ...options?.pagination }
);
}
/**
* Obtiene facturas vencidas
*/
async getOverdueInvoices(
options?: {
type?: 'customer' | 'vendor' | 'all';
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooInvoice>> {
return this.getOpenInvoices({
...options,
dueDateBefore: new Date(),
});
}
/**
* Obtiene el monto total de facturas abiertas
*/
async getOpenInvoicesTotal(
options?: {
type?: 'customer' | 'vendor' | 'all';
companyId?: number;
}
): Promise<{
totalAmount: number;
totalResidual: number;
count: number;
overdueAmount: number;
overdueCount: number;
}> {
const allOpen = await this.getOpenInvoices({
type: options?.type,
companyId: options?.companyId,
pagination: { limit: 10000 },
});
const now = new Date();
let overdueAmount = 0;
let overdueCount = 0;
for (const invoice of allOpen.items) {
if (invoice.invoiceDateDue && new Date(invoice.invoiceDateDue) < now) {
overdueAmount += invoice.amountResidual;
overdueCount++;
}
}
return {
totalAmount: allOpen.items.reduce((sum, i) => sum + i.amountTotal, 0),
totalResidual: allOpen.items.reduce((sum, i) => sum + i.amountResidual, 0),
count: allOpen.total,
overdueAmount,
overdueCount,
};
}
// ==========================================================================
// Invoice Lines
// ==========================================================================
/**
* Obtiene las lineas de una factura
*/
async getInvoiceLines(invoiceId: number): Promise<OdooInvoiceLine[]> {
return this.client.searchRead<OdooInvoiceLine>(
'account.move.line',
[
['move_id', '=', invoiceId],
['display_type', 'in', ['product', 'line_section', 'line_note']],
],
[
'id',
'move_id',
'sequence',
'name',
'product_id',
'product_uom_id',
'quantity',
'price_unit',
'discount',
'price_subtotal',
'price_total',
'tax_ids',
'account_id',
'analytic_distribution',
'debit',
'credit',
'balance',
'amount_currency',
'currency_id',
'display_type',
],
{ order: 'sequence, id' }
);
}
/**
* Obtiene una factura por ID
*/
async getInvoiceById(invoiceId: number): Promise<OdooInvoice | null> {
const invoices = await this.client.read<OdooInvoice>(
'account.move',
[invoiceId],
this.getInvoiceFields()
);
return invoices[0] || null;
}
/**
* Obtiene una factura por numero/nombre
*/
async getInvoiceByNumber(
invoiceNumber: string,
options?: { companyId?: number }
): Promise<OdooInvoice | null> {
const domain: OdooDomain = [['name', '=', invoiceNumber]];
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
const invoices = await this.client.searchRead<OdooInvoice>(
'account.move',
domain,
this.getInvoiceFields(),
{ limit: 1 }
);
return invoices[0] || null;
}
// ==========================================================================
// Payments
// ==========================================================================
/**
* Obtiene pagos de un periodo
*/
async getPayments(
period: DatePeriod,
options?: {
paymentType?: 'inbound' | 'outbound';
partnerType?: 'customer' | 'supplier';
partnerId?: number;
companyId?: number;
state?: 'draft' | 'posted' | 'cancel';
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooPayment>> {
const domain: OdooDomain = [
['date', '>=', this.formatDate(period.dateFrom)],
['date', '<=', this.formatDate(period.dateTo)],
];
if (options?.paymentType) {
domain.push(['payment_type', '=', options.paymentType]);
}
if (options?.partnerType) {
domain.push(['partner_type', '=', options.partnerType]);
}
if (options?.partnerId) {
domain.push(['partner_id', '=', options.partnerId]);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
if (options?.state) {
domain.push(['state', '=', options.state]);
} else {
domain.push(['state', '=', 'posted']);
}
return this.client.searchReadPaginated<OdooPayment>(
'account.payment',
domain,
[
'id',
'name',
'payment_type',
'partner_type',
'partner_id',
'amount',
'currency_id',
'date',
'journal_id',
'company_id',
'state',
'ref',
'payment_method_id',
'payment_method_line_id',
'destination_account_id',
'move_id',
'reconciled_invoice_ids',
'reconciled_bill_ids',
'is_reconciled',
'is_matched',
'create_date',
'write_date',
],
{ order: 'date desc, id desc', ...options?.pagination }
);
}
/**
* Obtiene pagos de cliente
*/
async getCustomerPayments(
period: DatePeriod,
options?: {
partnerId?: number;
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooPayment>> {
return this.getPayments(period, {
paymentType: 'inbound',
partnerType: 'customer',
...options,
});
}
/**
* Obtiene pagos a proveedor
*/
async getVendorPayments(
period: DatePeriod,
options?: {
partnerId?: number;
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooPayment>> {
return this.getPayments(period, {
paymentType: 'outbound',
partnerType: 'supplier',
...options,
});
}
/**
* Obtiene resumen de pagos
*/
async getPaymentSummary(
period: DatePeriod,
options?: {
companyId?: number;
}
): Promise<PaymentSummary> {
const domain: OdooDomain = [
['date', '>=', this.formatDate(period.dateFrom)],
['date', '<=', this.formatDate(period.dateTo)],
['state', '=', 'posted'],
];
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
const payments = await this.client.searchRead<OdooPayment>(
'account.payment',
domain,
['payment_type', 'amount'],
{ limit: 10000 }
);
let totalInbound = 0;
let totalOutbound = 0;
let countInbound = 0;
let countOutbound = 0;
for (const payment of payments) {
if (payment.paymentType === 'inbound') {
totalInbound += payment.amount;
countInbound++;
} else {
totalOutbound += payment.amount;
countOutbound++;
}
}
return {
totalInbound,
totalOutbound,
countInbound,
countOutbound,
};
}
/**
* Obtiene facturas relacionadas a un pago
*/
async getPaymentInvoices(paymentId: number): Promise<OdooInvoice[]> {
const payment = await this.client.read<OdooPayment>(
'account.payment',
[paymentId],
['reconciled_invoice_ids', 'reconciled_bill_ids']
);
if (!payment[0]) return [];
const invoiceIds = [
...(payment[0].reconciledInvoiceIds || []),
...(payment[0].reconciledBillIds || []),
];
if (invoiceIds.length === 0) return [];
return this.client.read<OdooInvoice>(
'account.move',
invoiceIds,
this.getInvoiceFields()
);
}
// ==========================================================================
// Bank Statements
// ==========================================================================
/**
* Obtiene estados de cuenta bancarios
*/
async getBankStatements(
period: DatePeriod,
options?: {
journalId?: number;
companyId?: number;
state?: 'open' | 'confirm';
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooBankStatement>> {
const domain: OdooDomain = [
['date', '>=', this.formatDate(period.dateFrom)],
['date', '<=', this.formatDate(period.dateTo)],
];
if (options?.journalId) {
domain.push(['journal_id', '=', options.journalId]);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
if (options?.state) {
domain.push(['state', '=', options.state]);
}
return this.client.searchReadPaginated<OdooBankStatement>(
'account.bank.statement',
domain,
[
'id',
'name',
'date',
'journal_id',
'company_id',
'balance_start',
'balance_end_real',
'balance_end',
'state',
'line_ids',
'create_date',
'write_date',
],
{ order: 'date desc, id desc', ...options?.pagination }
);
}
/**
* Obtiene lineas de un estado de cuenta
*/
async getBankStatementLines(
statementId: number
): Promise<OdooBankStatementLine[]> {
return this.client.searchRead<OdooBankStatementLine>(
'account.bank.statement.line',
[['statement_id', '=', statementId]],
[
'id',
'statement_id',
'sequence',
'date',
'name',
'ref',
'partner_id',
'amount',
'amount_currency',
'foreign_currency_id',
'payment_ref',
'transaction_type',
'is_reconciled',
'move_id',
],
{ order: 'sequence, id' }
);
}
// ==========================================================================
// Invoice Creation
// ==========================================================================
/**
* Crea una factura de cliente
*/
async createCustomerInvoice(data: {
partnerId: number;
invoiceDate?: Date;
invoiceDateDue?: Date;
lines: Array<{
productId?: number;
name: string;
quantity: number;
priceUnit: number;
taxIds?: number[];
accountId?: number;
}>;
ref?: string;
narration?: string;
journalId?: number;
companyId?: number;
}): Promise<number> {
return this.createInvoice('out_invoice', data);
}
/**
* Crea una factura de proveedor
*/
async createVendorBill(data: {
partnerId: number;
invoiceDate?: Date;
invoiceDateDue?: Date;
lines: Array<{
productId?: number;
name: string;
quantity: number;
priceUnit: number;
taxIds?: number[];
accountId?: number;
}>;
ref?: string;
narration?: string;
journalId?: number;
companyId?: number;
}): Promise<number> {
return this.createInvoice('in_invoice', data);
}
/**
* Valida (publica) una factura
*/
async postInvoice(invoiceId: number): Promise<boolean> {
return this.client.callMethod<boolean>(
'account.move',
'action_post',
[[invoiceId]]
);
}
/**
* Cancela una factura
*/
async cancelInvoice(invoiceId: number): Promise<boolean> {
return this.client.callMethod<boolean>(
'account.move',
'button_cancel',
[[invoiceId]]
);
}
// ==========================================================================
// Private Helpers
// ==========================================================================
/**
* Obtiene facturas con filtros genericos
*/
private async getInvoices(
period: DatePeriod,
invoiceTypes: OdooInvoiceType[],
options?: {
state?: OdooInvoiceState;
paymentState?: OdooPaymentState;
partnerId?: number;
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooInvoice>> {
const domain: OdooDomain = [
['move_type', 'in', invoiceTypes],
['invoice_date', '>=', this.formatDate(period.dateFrom)],
['invoice_date', '<=', this.formatDate(period.dateTo)],
];
if (options?.state) {
domain.push(['state', '=', options.state]);
} else {
domain.push(['state', '=', 'posted']);
}
if (options?.paymentState) {
domain.push(['payment_state', '=', options.paymentState]);
}
if (options?.partnerId) {
domain.push(['partner_id', '=', options.partnerId]);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
return this.client.searchReadPaginated<OdooInvoice>(
'account.move',
domain,
this.getInvoiceFields(),
{ order: 'invoice_date desc, id desc', ...options?.pagination }
);
}
/**
* Obtiene resumen de facturas
*/
private async getInvoiceSummary(
period: DatePeriod,
invoiceTypes: OdooInvoiceType[],
options?: {
companyId?: number;
}
): Promise<InvoiceSummary> {
const domain: OdooDomain = [
['move_type', 'in', invoiceTypes],
['invoice_date', '>=', this.formatDate(period.dateFrom)],
['invoice_date', '<=', this.formatDate(period.dateTo)],
['state', '=', 'posted'],
];
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
const invoices = await this.client.searchRead<OdooInvoice>(
'account.move',
domain,
['amount_total', 'amount_residual', 'payment_state'],
{ limit: 10000 }
);
let total = 0;
let totalPaid = 0;
let totalPending = 0;
let countPaid = 0;
let countPending = 0;
for (const invoice of invoices) {
total += invoice.amountTotal;
if (invoice.paymentState === 'paid') {
totalPaid += invoice.amountTotal;
countPaid++;
} else {
totalPending += invoice.amountResidual;
countPending++;
}
}
return {
total,
totalPaid,
totalPending,
count: invoices.length,
countPaid,
countPending,
};
}
/**
* Crea una factura
*/
private async createInvoice(
moveType: OdooInvoiceType,
data: {
partnerId: number;
invoiceDate?: Date;
invoiceDateDue?: Date;
lines: Array<{
productId?: number;
name: string;
quantity: number;
priceUnit: number;
taxIds?: number[];
accountId?: number;
}>;
ref?: string;
narration?: string;
journalId?: number;
companyId?: number;
}
): Promise<number> {
const connection = this.client.getConnection();
if (!connection) {
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
}
const invoiceData: Record<string, unknown> = {
move_type: moveType,
partner_id: data.partnerId,
invoice_date: data.invoiceDate
? this.formatDate(data.invoiceDate)
: this.formatDate(new Date()),
invoice_line_ids: data.lines.map((line) => [
0,
0,
{
product_id: line.productId || false,
name: line.name,
quantity: line.quantity,
price_unit: line.priceUnit,
tax_ids: line.taxIds ? [[6, 0, line.taxIds]] : false,
account_id: line.accountId || false,
},
]),
};
if (data.invoiceDateDue) {
invoiceData.invoice_date_due = this.formatDate(data.invoiceDateDue);
}
if (data.ref) {
invoiceData.ref = data.ref;
}
if (data.narration) {
invoiceData.narration = data.narration;
}
if (data.journalId) {
invoiceData.journal_id = data.journalId;
}
if (data.companyId) {
invoiceData.company_id = data.companyId;
}
return this.client.create('account.move', invoiceData);
}
/**
* Campos a leer de facturas
*/
private getInvoiceFields(): string[] {
return [
'id',
'name',
'ref',
'move_type',
'state',
'payment_state',
'partner_id',
'partner_shipping_id',
'commercial_partner_id',
'invoice_date',
'invoice_date_due',
'date',
'journal_id',
'company_id',
'currency_id',
'amount_untaxed',
'amount_tax',
'amount_total',
'amount_residual',
'amount_paid',
'invoice_payment_term_id',
'invoice_line_ids',
'narration',
'fiscal_position_id',
'invoice_origin',
'invoice_user_id',
'team_id',
'reversed',
'reversed_entry_id',
'amount_total_signed',
'amount_residual_signed',
'amount_untaxed_signed',
'create_date',
'write_date',
// Campos Mexico
'l10n_mx_edi_cfdi_uuid',
'l10n_mx_edi_usage',
'l10n_mx_edi_payment_method',
'l10n_mx_edi_payment_policy',
];
}
/**
* Formatea una fecha a string YYYY-MM-DD
*/
private formatDate(date: Date): string {
return date.toISOString().split('T')[0] ?? '';
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea un conector de facturacion
*/
export function createInvoicingConnector(
client: OdooClient
): OdooInvoicingConnector {
return new OdooInvoicingConnector(client);
}

View File

@@ -0,0 +1,872 @@
/**
* Odoo XML-RPC Client
* Cliente para comunicarse con Odoo via XML-RPC
* Soporta versiones 14, 15, 16, 17
*/
import * as https from 'https';
import * as http from 'http';
import { URL } from 'url';
import {
OdooConfig,
OdooConnection,
OdooContext,
OdooCompany,
OdooDomain,
OdooFields,
OdooValues,
OdooPagination,
OdooPaginatedResponse,
OdooError,
OdooAuthError,
OdooConnectionError,
} from './odoo.types.js';
// ============================================================================
// XML-RPC Types
// ============================================================================
interface XmlRpcResponse {
value?: unknown;
fault?: {
faultCode: number | string;
faultString: string;
};
}
// ============================================================================
// Session Cache
// ============================================================================
interface CachedSession {
connection: OdooConnection;
expiresAt: Date;
}
const sessionCache = new Map<string, CachedSession>();
const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
// ============================================================================
// Odoo Client Class
// ============================================================================
/**
* Cliente XML-RPC para Odoo ERP
*/
export class OdooClient {
private config: Required<OdooConfig>;
private connection: OdooConnection | null = null;
private cacheKey: string;
constructor(config: OdooConfig) {
this.config = {
url: config.url.replace(/\/+$/, ''),
database: config.database,
username: config.username,
password: config.password,
useApiKey: config.useApiKey ?? false,
version: config.version ?? 16,
companyId: config.companyId ?? 1,
allowedCompanyIds: config.allowedCompanyIds ?? [],
timeout: config.timeout ?? 30000,
maxRetries: config.maxRetries ?? 3,
};
this.cacheKey = `${this.config.url}:${this.config.database}:${this.config.username}`;
}
// ==========================================================================
// Public Methods - Connection
// ==========================================================================
/**
* Autentica con Odoo y obtiene la conexion
*/
async authenticate(): Promise<OdooConnection> {
// Verificar cache
const cached = sessionCache.get(this.cacheKey);
if (cached && cached.expiresAt > new Date()) {
this.connection = cached.connection;
return this.connection;
}
try {
// Llamar al metodo authenticate de XML-RPC
const uid = await this.xmlRpcCall<number>(
'/xmlrpc/2/common',
'authenticate',
[
this.config.database,
this.config.username,
this.config.password,
{},
]
);
if (!uid) {
throw new OdooAuthError('Credenciales invalidas');
}
// Obtener version del servidor
const serverVersion = await this.getServerVersion();
// Obtener companias
const companies = await this.getCompanies(uid);
// Determinar compania activa
const activeCompanyId =
this.config.companyId ||
(companies.length > 0 ? companies[0]!.id : 1);
// Crear contexto
const context: OdooContext = {
lang: 'es_MX',
tz: 'America/Mexico_City',
uid: uid,
allowed_company_ids:
this.config.allowedCompanyIds.length > 0
? this.config.allowedCompanyIds
: companies.map((c) => c.id),
company_id: activeCompanyId,
};
// Crear conexion
this.connection = {
uid,
context,
authenticatedAt: new Date(),
serverUrl: this.config.url,
database: this.config.database,
serverVersion,
companies,
activeCompanyId,
};
// Guardar en cache
sessionCache.set(this.cacheKey, {
connection: this.connection,
expiresAt: new Date(Date.now() + SESSION_TTL_MS),
});
return this.connection;
} catch (error) {
if (error instanceof OdooError) throw error;
throw new OdooAuthError(
`Error de autenticacion: ${error instanceof Error ? error.message : 'Error desconocido'}`,
error
);
}
}
/**
* Verifica si hay una conexion activa
*/
isConnected(): boolean {
return this.connection !== null;
}
/**
* Obtiene la conexion actual
*/
getConnection(): OdooConnection | null {
return this.connection;
}
/**
* Cierra la sesion y limpia el cache
*/
disconnect(): void {
this.connection = null;
sessionCache.delete(this.cacheKey);
}
/**
* Cambia la compania activa
*/
async setCompany(companyId: number): Promise<void> {
if (!this.connection) {
await this.authenticate();
}
const company = this.connection!.companies.find((c) => c.id === companyId);
if (!company) {
throw new OdooError(
`Compania ${companyId} no encontrada o sin acceso`,
'ACCESS_DENIED'
);
}
this.connection!.activeCompanyId = companyId;
this.connection!.context.company_id = companyId;
// Actualizar cache
const cached = sessionCache.get(this.cacheKey);
if (cached) {
cached.connection = this.connection!;
}
}
// ==========================================================================
// Public Methods - CRUD Operations
// ==========================================================================
/**
* Busca IDs de registros que cumplan el dominio
*/
async search(
model: string,
domain: OdooDomain = [],
pagination?: OdooPagination
): Promise<number[]> {
await this.ensureConnection();
const params: unknown[] = [domain];
if (pagination) {
params.push({
offset: pagination.offset ?? 0,
limit: pagination.limit,
order: pagination.order,
});
}
return this.execute(model, 'search', params);
}
/**
* Cuenta registros que cumplan el dominio
*/
async searchCount(model: string, domain: OdooDomain = []): Promise<number> {
await this.ensureConnection();
return this.execute(model, 'search_count', [domain]);
}
/**
* Lee campos de registros por sus IDs
*/
async read<T = Record<string, unknown>>(
model: string,
ids: number[],
fields?: OdooFields
): Promise<T[]> {
await this.ensureConnection();
if (ids.length === 0) return [];
const params: unknown[] = [ids];
if (fields && fields.length > 0) {
params.push(fields);
}
const result = await this.execute<Record<string, unknown>[]>(
model,
'read',
params
);
return result.map((r) => this.normalizeRecord<T>(r));
}
/**
* Busca y lee registros en una sola llamada
*/
async searchRead<T = Record<string, unknown>>(
model: string,
domain: OdooDomain = [],
fields?: OdooFields,
pagination?: OdooPagination
): Promise<T[]> {
await this.ensureConnection();
const params: unknown[] = [domain];
const options: Record<string, unknown> = {};
if (fields && fields.length > 0) {
options.fields = fields;
}
if (pagination) {
if (pagination.offset !== undefined) options.offset = pagination.offset;
if (pagination.limit !== undefined) options.limit = pagination.limit;
if (pagination.order) options.order = pagination.order;
}
params.push(options);
const result = await this.execute<Record<string, unknown>[]>(
model,
'search_read',
params
);
return result.map((r) => this.normalizeRecord<T>(r));
}
/**
* Busca y lee registros con paginacion
*/
async searchReadPaginated<T = Record<string, unknown>>(
model: string,
domain: OdooDomain = [],
fields?: OdooFields,
pagination?: OdooPagination
): Promise<OdooPaginatedResponse<T>> {
const [items, total] = await Promise.all([
this.searchRead<T>(model, domain, fields, pagination),
this.searchCount(model, domain),
]);
const offset = pagination?.offset ?? 0;
const limit = pagination?.limit ?? items.length;
return {
items,
total,
offset,
limit,
hasMore: offset + items.length < total,
};
}
/**
* Crea un nuevo registro
*/
async create(model: string, values: OdooValues): Promise<number> {
await this.ensureConnection();
return this.execute(model, 'create', [values]);
}
/**
* Crea multiples registros
*/
async createMany(model: string, valuesList: OdooValues[]): Promise<number[]> {
await this.ensureConnection();
const ids: number[] = [];
for (const values of valuesList) {
const id = await this.create(model, values);
ids.push(id);
}
return ids;
}
/**
* Actualiza registros existentes
*/
async write(
model: string,
ids: number[],
values: OdooValues
): Promise<boolean> {
await this.ensureConnection();
if (ids.length === 0) return true;
return this.execute(model, 'write', [ids, values]);
}
/**
* Elimina registros
*/
async unlink(model: string, ids: number[]): Promise<boolean> {
await this.ensureConnection();
if (ids.length === 0) return true;
return this.execute(model, 'unlink', [ids]);
}
/**
* Ejecuta un metodo custom del modelo
*/
async callMethod<T = unknown>(
model: string,
method: string,
args: unknown[] = [],
kwargs: Record<string, unknown> = {}
): Promise<T> {
await this.ensureConnection();
return this.execute<T>(model, method, args, kwargs);
}
/**
* Obtiene los campos disponibles de un modelo
*/
async getFields(
model: string,
attributes?: string[]
): Promise<Record<string, unknown>> {
await this.ensureConnection();
const params: unknown[] = [];
if (attributes && attributes.length > 0) {
params.push(attributes);
}
return this.execute(model, 'fields_get', params);
}
/**
* Lee un registro por ID externo (XML ID)
*/
async readByXmlId<T = Record<string, unknown>>(
xmlId: string,
fields?: OdooFields
): Promise<T | null> {
await this.ensureConnection();
try {
const result = await this.execute<[number, string]>(
'ir.model.data',
'xmlid_to_res_model_res_id',
[xmlId, true]
);
if (!result || result.length < 2) return null;
const [id] = result;
const records = await this.read<T>(xmlId.split('.')[0] || '', [id], fields);
return records[0] || null;
} catch {
return null;
}
}
// ==========================================================================
// Private Methods - XML-RPC
// ==========================================================================
/**
* Ejecuta una operacion en un modelo
*/
private async execute<T = unknown>(
model: string,
method: string,
args: unknown[] = [],
kwargs: Record<string, unknown> = {}
): Promise<T> {
const context = {
...this.connection!.context,
...kwargs,
};
return this.xmlRpcCall<T>('/xmlrpc/2/object', 'execute_kw', [
this.config.database,
this.connection!.uid,
this.config.password,
model,
method,
args,
context,
]);
}
/**
* Realiza una llamada XML-RPC
*/
private async xmlRpcCall<T>(
endpoint: string,
method: string,
params: unknown[]
): Promise<T> {
const url = new URL(endpoint, this.config.url);
const body = this.buildXmlRpcRequest(method, params);
let lastError: Error | null = null;
for (let attempt = 0; attempt < this.config.maxRetries; attempt++) {
try {
const response = await this.sendRequest(url, body);
const parsed = this.parseXmlRpcResponse(response);
if (parsed.fault) {
throw new OdooError(
parsed.fault.faultString,
'XML_RPC_ERROR',
parsed.fault,
String(parsed.fault.faultCode)
);
}
return parsed.value as T;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// No reintentar errores de autenticacion o validacion
if (
error instanceof OdooAuthError ||
(error instanceof OdooError &&
(error.code === 'AUTH_ERROR' || error.code === 'VALIDATION_ERROR'))
) {
throw error;
}
// Esperar antes de reintentar (backoff exponencial)
if (attempt < this.config.maxRetries - 1) {
await this.sleep(Math.pow(2, attempt) * 1000);
}
}
}
throw new OdooConnectionError(
`Error despues de ${this.config.maxRetries} intentos: ${lastError?.message}`,
lastError
);
}
/**
* Envia una solicitud HTTP
*/
private sendRequest(url: URL, body: string): Promise<string> {
return new Promise((resolve, reject) => {
const isHttps = url.protocol === 'https:';
const transport = isHttps ? https : http;
const options: https.RequestOptions = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset=utf-8',
'Content-Length': Buffer.byteLength(body, 'utf8'),
'User-Agent': 'HoruxStrategy-OdooConnector/1.0',
},
timeout: this.config.timeout,
};
const req = transport.request(options, (res: http.IncomingMessage) => {
let data = '';
res.on('data', (chunk: Buffer | string) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(data);
} else {
reject(
new OdooConnectionError(
`HTTP Error ${res.statusCode}: ${res.statusMessage}`,
{ statusCode: res.statusCode, body: data }
)
);
}
});
});
req.on('error', (error: Error) => {
reject(new OdooConnectionError(`Connection error: ${error.message}`, error));
});
req.on('timeout', () => {
req.destroy();
reject(new OdooError('Request timeout', 'TIMEOUT'));
});
req.write(body);
req.end();
});
}
/**
* Construye el XML de una solicitud XML-RPC
*/
private buildXmlRpcRequest(method: string, params: unknown[]): string {
const paramsXml = params.map((p) => `<param>${this.valueToXml(p)}</param>`).join('');
return `<?xml version="1.0"?>
<methodCall>
<methodName>${this.escapeXml(method)}</methodName>
<params>${paramsXml}</params>
</methodCall>`;
}
/**
* Convierte un valor a XML-RPC
*/
private valueToXml(value: unknown): string {
if (value === null || value === undefined) {
return '<value><boolean>0</boolean></value>';
}
if (typeof value === 'boolean') {
return `<value><boolean>${value ? 1 : 0}</boolean></value>`;
}
if (typeof value === 'number') {
if (Number.isInteger(value)) {
return `<value><int>${value}</int></value>`;
}
return `<value><double>${value}</double></value>`;
}
if (typeof value === 'string') {
return `<value><string>${this.escapeXml(value)}</string></value>`;
}
if (Array.isArray(value)) {
const items = value.map((v) => this.valueToXml(v)).join('');
return `<value><array><data>${items}</data></array></value>`;
}
if (typeof value === 'object') {
const members = Object.entries(value as Record<string, unknown>)
.map(
([k, v]) =>
`<member><name>${this.escapeXml(k)}</name>${this.valueToXml(v)}</member>`
)
.join('');
return `<value><struct>${members}</struct></value>`;
}
return `<value><string>${this.escapeXml(String(value))}</string></value>`;
}
/**
* Parsea una respuesta XML-RPC
*/
private parseXmlRpcResponse(xml: string): XmlRpcResponse {
// Verificar fault
const faultMatch = xml.match(/<fault>([\s\S]*?)<\/fault>/);
if (faultMatch && faultMatch[1]) {
const fault = this.parseXmlValue(faultMatch[1]) as Record<string, unknown>;
return {
fault: {
faultCode: (fault.faultCode as number | string) || 0,
faultString: (fault.faultString as string) || 'Unknown error',
},
};
}
// Parsear valor
const paramMatch = xml.match(/<params>\s*<param>([\s\S]*?)<\/param>\s*<\/params>/);
if (paramMatch && paramMatch[1]) {
return {
value: this.parseXmlValue(paramMatch[1]),
};
}
return { value: null };
}
/**
* Parsea un valor XML-RPC
*/
private parseXmlValue(xml: string): unknown {
// Boolean
const boolMatch = xml.match(/<boolean>(\d)<\/boolean>/);
if (boolMatch && boolMatch[1]) return boolMatch[1] === '1';
// Integer
const intMatch = xml.match(/<(?:int|i4)>(-?\d+)<\/(?:int|i4)>/);
if (intMatch && intMatch[1]) return parseInt(intMatch[1], 10);
// Double
const doubleMatch = xml.match(/<double>(-?[\d.]+)<\/double>/);
if (doubleMatch && doubleMatch[1]) return parseFloat(doubleMatch[1]);
// String
const stringMatch = xml.match(/<string>([\s\S]*?)<\/string>/);
if (stringMatch && stringMatch[1] !== undefined) return this.unescapeXml(stringMatch[1]);
// Nil/None
if (xml.includes('<nil/>') || xml.includes('<nil></nil>')) return null;
// Boolean false (alternate representation)
if (xml.includes('<value/>') || xml.match(/<value>\s*<\/value>/)) return false;
// Array
const arrayMatch = xml.match(/<array>\s*<data>([\s\S]*?)<\/data>\s*<\/array>/);
if (arrayMatch && arrayMatch[1]) {
const items: unknown[] = [];
const valueRegex = /<value>([\s\S]*?)<\/value>/g;
let match;
while ((match = valueRegex.exec(arrayMatch[1])) !== null) {
items.push(this.parseXmlValue(match[0]));
}
return items;
}
// Struct
const structMatch = xml.match(/<struct>([\s\S]*?)<\/struct>/);
if (structMatch && structMatch[1]) {
const obj: Record<string, unknown> = {};
const memberRegex = /<member>\s*<name>([\s\S]*?)<\/name>\s*<value>([\s\S]*?)<\/value>\s*<\/member>/g;
let match;
while ((match = memberRegex.exec(structMatch[1])) !== null) {
if (match[1] && match[2]) {
const key = this.unescapeXml(match[1]);
obj[key] = this.parseXmlValue(`<value>${match[2]}</value>`);
}
}
return obj;
}
// Value wrapper
const valueMatch = xml.match(/<value>([\s\S]*?)<\/value>/);
if (valueMatch && valueMatch[1] !== undefined) {
const inner = valueMatch[1].trim();
// Si es texto sin tipo, tratarlo como string
if (!inner.startsWith('<')) {
return inner;
}
return this.parseXmlValue(inner);
}
return xml.trim();
}
/**
* Escapa caracteres especiales para XML
*/
private escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Desescapa caracteres XML
*/
private unescapeXml(str: string): string {
return str
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
}
// ==========================================================================
// Private Methods - Helpers
// ==========================================================================
/**
* Asegura que hay una conexion activa
*/
private async ensureConnection(): Promise<void> {
if (!this.connection) {
await this.authenticate();
}
}
/**
* Obtiene la version del servidor
*/
private async getServerVersion(): Promise<string> {
try {
const version = await this.xmlRpcCall<Record<string, unknown>>(
'/xmlrpc/2/common',
'version',
[]
);
return String(version.server_version || 'unknown');
} catch {
return 'unknown';
}
}
/**
* Obtiene las companias disponibles para el usuario
*/
private async getCompanies(uid: number): Promise<OdooCompany[]> {
try {
const companies = await this.xmlRpcCall<Record<string, unknown>[]>(
'/xmlrpc/2/object',
'execute_kw',
[
this.config.database,
uid,
this.config.password,
'res.company',
'search_read',
[[]],
{
fields: [
'id',
'name',
'currency_id',
'vat',
'street',
'city',
'country_id',
],
},
]
);
return companies.map((c) => ({
id: c.id as number,
name: c.name as string,
currencyId: (c.currency_id as [number, string])?.[0] || 0,
currencyCode: (c.currency_id as [number, string])?.[1] || 'MXN',
vatNumber: c.vat as string | undefined,
street: c.street as string | undefined,
city: c.city as string | undefined,
countryId: (c.country_id as [number, string])?.[0],
countryCode: (c.country_id as [number, string])?.[1],
}));
} catch {
// Si falla, retornar compania por defecto
return [
{
id: 1,
name: 'Default Company',
currencyId: 1,
currencyCode: 'MXN',
},
];
}
}
/**
* Normaliza un registro de Odoo (convierte snake_case a camelCase)
*/
private normalizeRecord<T>(record: Record<string, unknown>): T {
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
const camelKey = key.replace(/_([a-z])/g, (_, letter) =>
letter.toUpperCase()
);
normalized[camelKey] = value;
}
return normalized as T;
}
/**
* Espera un tiempo determinado
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea una instancia del cliente Odoo
*/
export function createOdooClient(config: OdooConfig): OdooClient {
return new OdooClient(config);
}
/**
* Limpia el cache de sesiones
*/
export function clearSessionCache(): void {
sessionCache.clear();
}
/**
* Obtiene el tamano del cache de sesiones
*/
export function getSessionCacheSize(): number {
return sessionCache.size;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,911 @@
/**
* Odoo ERP Integration Types
* Tipos completos para la integracion con Odoo ERP (versiones 14, 15, 16, 17)
*/
// ============================================================================
// Configuration Types
// ============================================================================
/**
* Configuracion de conexion a Odoo
*/
export interface OdooConfig {
/** URL base del servidor Odoo (ej: https://mycompany.odoo.com) */
url: string;
/** Nombre de la base de datos Odoo */
database: string;
/** Usuario o email para autenticacion */
username: string;
/** Password o API key */
password: string;
/** Si es true, usa API key en lugar de password */
useApiKey?: boolean;
/** Version de Odoo (14, 15, 16, 17) */
version?: OdooVersion;
/** ID de la compania activa (para multi-company) */
companyId?: number;
/** IDs de companias permitidas */
allowedCompanyIds?: number[];
/** Timeout para requests en ms (default: 30000) */
timeout?: number;
/** Reintentos maximos para operaciones fallidas */
maxRetries?: number;
}
/**
* Versiones soportadas de Odoo
*/
export type OdooVersion = 14 | 15 | 16 | 17;
/**
* Conexion activa con Odoo
*/
export interface OdooConnection {
/** UID del usuario autenticado */
uid: number;
/** Contexto de sesion */
context: OdooContext;
/** Timestamp de autenticacion */
authenticatedAt: Date;
/** URL del servidor */
serverUrl: string;
/** Base de datos conectada */
database: string;
/** Version del servidor */
serverVersion: string;
/** Companias disponibles */
companies: OdooCompany[];
/** Compania activa */
activeCompanyId: number;
}
/**
* Contexto de sesion Odoo
*/
export interface OdooContext {
lang: string;
tz: string;
uid: number;
allowed_company_ids: number[];
company_id?: number;
[key: string]: unknown;
}
/**
* Compania en Odoo
*/
export interface OdooCompany {
id: number;
name: string;
currencyId: number;
currencyCode: string;
vatNumber?: string;
street?: string;
city?: string;
countryId?: number;
countryCode?: string;
}
// ============================================================================
// Partner Types (Customers/Vendors)
// ============================================================================
/**
* Partner de Odoo (cliente, proveedor o ambos)
*/
export interface OdooPartner {
id: number;
name: string;
displayName?: string;
ref?: string;
vat?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
street?: string;
street2?: string;
city?: string;
stateId?: [number, string];
zip?: string;
countryId?: [number, string];
isCompany: boolean;
parentId?: [number, string];
childIds: number[];
companyId?: [number, string];
userId?: [number, string];
isCustomer?: boolean;
isSupplier?: boolean;
customerRank: number;
supplierRank: number;
credit: number;
debit: number;
creditLimit: number;
propertyAccountReceivable?: [number, string];
propertyAccountPayable?: [number, string];
propertyPaymentTermId?: [number, string];
propertySupplierPaymentTermId?: [number, string];
lang?: string;
categoryId?: [number, string][];
active: boolean;
createDate: string;
writeDate: string;
}
/**
* Saldo de partner
*/
export interface PartnerBalance {
partnerId: number;
partnerName: string;
debit: number;
credit: number;
balance: number;
receivable: number;
payable: number;
currencyId: number;
currencyCode: string;
}
/**
* Transaccion de partner (para statement)
*/
export interface PartnerTransaction {
id: number;
date: string;
ref?: string;
name: string;
journalId: [number, string];
accountId: [number, string];
moveId: [number, string];
debit: number;
credit: number;
balance: number;
amountCurrency: number;
currencyId?: [number, string];
reconciled: boolean;
fullReconcileId?: [number, string];
}
// ============================================================================
// Invoice Types
// ============================================================================
/**
* Tipo de factura en Odoo
*/
export type OdooInvoiceType =
| 'out_invoice' // Factura de cliente
| 'out_refund' // Nota de credito cliente
| 'in_invoice' // Factura de proveedor
| 'in_refund' // Nota de credito proveedor
| 'out_receipt' // Recibo de cliente
| 'in_receipt'; // Recibo de proveedor
/**
* Estado de factura
*/
export type OdooInvoiceState =
| 'draft'
| 'posted'
| 'cancel';
/**
* Estado de pago de factura
*/
export type OdooPaymentState =
| 'not_paid'
| 'in_payment'
| 'paid'
| 'partial'
| 'reversed';
/**
* Factura de Odoo (account.move)
*/
export interface OdooInvoice {
id: number;
name: string;
ref?: string;
moveType: OdooInvoiceType;
state: OdooInvoiceState;
paymentState: OdooPaymentState;
partnerId: [number, string];
partnerShippingId?: [number, string];
commercialPartnerId?: [number, string];
invoiceDate?: string;
invoiceDateDue?: string;
date: string;
journalId: [number, string];
companyId: [number, string];
currencyId: [number, string];
amountUntaxed: number;
amountTax: number;
amountTotal: number;
amountResidual: number;
amountPaid: number;
invoicePaymentTermId?: [number, string];
invoiceLineIds: number[];
narration?: string;
fiscalPositionId?: [number, string];
invoiceOrigin?: string;
invoiceUserId?: [number, string];
teamId?: [number, string];
reversed: boolean;
reversedEntryId?: [number, string];
amountTotalSigned: number;
amountResidualSigned: number;
amountUntaxedSigned: number;
createDate: string;
writeDate: string;
// Campos especificos de Mexico (l10n_mx_edi)
l10nMxEdiCfdiUuid?: string;
l10nMxEdiUsage?: string;
l10nMxEdiPaymentMethod?: string;
l10nMxEdiPaymentPolicy?: string;
}
/**
* Linea de factura (account.move.line con producto)
*/
export interface OdooInvoiceLine {
id: number;
moveId: [number, string];
sequence: number;
name: string;
productId?: [number, string];
productUomId?: [number, string];
quantity: number;
priceUnit: number;
discount: number;
priceSubtotal: number;
priceTotal: number;
taxIds: [number, string][];
accountId: [number, string];
analyticDistribution?: Record<string, number>;
debit: number;
credit: number;
balance: number;
amountCurrency: number;
currencyId: [number, string];
displayType: 'product' | 'line_section' | 'line_note' | 'tax' | 'payment_term' | 'rounding';
}
// ============================================================================
// Payment Types
// ============================================================================
/**
* Tipo de pago
*/
export type OdooPaymentType = 'inbound' | 'outbound';
/**
* Estado del pago
*/
export type OdooPaymentStateType =
| 'draft'
| 'posted'
| 'cancel'
| 'reconciled';
/**
* Pago de Odoo (account.payment)
*/
export interface OdooPayment {
id: number;
name: string;
paymentType: OdooPaymentType;
partnerType: 'customer' | 'supplier';
partnerId: [number, string];
amount: number;
currencyId: [number, string];
date: string;
journalId: [number, string];
companyId: [number, string];
state: OdooPaymentStateType;
ref?: string;
paymentMethodId: [number, string];
paymentMethodLineId?: [number, string];
destinationAccountId?: [number, string];
moveId?: [number, string];
reconciledInvoiceIds: number[];
reconciledBillIds: number[];
isReconciled: boolean;
isMatched: boolean;
createDate: string;
writeDate: string;
}
/**
* Estado de cuenta bancario
*/
export interface OdooBankStatement {
id: number;
name: string;
date: string;
journalId: [number, string];
companyId: [number, string];
balanceStart: number;
balanceEndReal: number;
balanceEnd: number;
state: 'open' | 'confirm';
lineIds: number[];
createDate: string;
writeDate: string;
}
/**
* Linea de estado de cuenta bancario
*/
export interface OdooBankStatementLine {
id: number;
statementId: [number, string];
sequence: number;
date: string;
name: string;
ref?: string;
partnerId?: [number, string];
amount: number;
amountCurrency?: number;
foreignCurrencyId?: [number, string];
paymentRef?: string;
transactionType?: string;
isReconciled: boolean;
moveId?: [number, string];
}
// ============================================================================
// Account Types
// ============================================================================
/**
* Tipo de cuenta contable
*/
export type OdooAccountType =
| 'asset_receivable'
| 'asset_cash'
| 'asset_current'
| 'asset_non_current'
| 'asset_prepayments'
| 'asset_fixed'
| 'liability_payable'
| 'liability_credit_card'
| 'liability_current'
| 'liability_non_current'
| 'equity'
| 'equity_unaffected'
| 'income'
| 'income_other'
| 'expense'
| 'expense_depreciation'
| 'expense_direct_cost'
| 'off_balance';
/**
* Cuenta contable de Odoo
*/
export interface OdooAccount {
id: number;
code: string;
name: string;
accountType: OdooAccountType;
currencyId?: [number, string];
companyId: [number, string];
deprecated: boolean;
reconcile: boolean;
note?: string;
groupId?: [number, string];
rootId?: [number, string];
taxIds: [number, string][];
tagIds: [number, string][];
internalGroup?: string;
createDate: string;
writeDate: string;
}
/**
* Asiento contable (account.move)
*/
export interface OdooJournalEntry {
id: number;
name: string;
ref?: string;
date: string;
moveType: 'entry' | OdooInvoiceType;
state: 'draft' | 'posted' | 'cancel';
journalId: [number, string];
companyId: [number, string];
currencyId: [number, string];
lineIds: number[];
partnerId?: [number, string];
amountTotal: number;
amountTotalSigned: number;
autoPost?: 'no' | 'at_date' | 'monthly' | 'quarterly' | 'yearly';
toCheck: boolean;
narration?: string;
reversedEntryId?: [number, string];
createDate: string;
writeDate: string;
}
/**
* Linea de asiento contable
*/
export interface OdooJournalEntryLine {
id: number;
moveId: [number, string];
moveName?: string;
sequence: number;
name: string;
ref?: string;
date: string;
journalId: [number, string];
companyId: [number, string];
accountId: [number, string];
partnerId?: [number, string];
debit: number;
credit: number;
balance: number;
amountCurrency: number;
currencyId: [number, string];
reconciled: boolean;
fullReconcileId?: [number, string];
matchingNumber?: string;
analyticDistribution?: Record<string, number>;
taxIds: [number, string][];
taxTagIds: [number, string][];
taxLineId?: [number, string];
taxRepartitionLineId?: [number, string];
displayType: 'product' | 'cogs' | 'tax' | 'rounding' | 'payment_term' | 'line_section' | 'line_note' | 'epd';
}
/**
* Diario contable
*/
export interface OdooJournal {
id: number;
name: string;
code: string;
type: 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
companyId: [number, string];
currencyId?: [number, string];
defaultAccountId?: [number, string];
suspenseAccountId?: [number, string];
profitAccountId?: [number, string];
lossAccountId?: [number, string];
active: boolean;
sequence: number;
invoiceReferenceType?: string;
invoiceReferenceModel?: string;
}
// ============================================================================
// Product & Inventory Types
// ============================================================================
/**
* Tipo de producto
*/
export type OdooProductType = 'consu' | 'service' | 'product';
/**
* Producto de Odoo
*/
export interface OdooProduct {
id: number;
name: string;
displayName?: string;
defaultCode?: string;
barcode?: string;
type: OdooProductType;
categId: [number, string];
listPrice: number;
standardPrice: number;
saleOk: boolean;
purchaseOk: boolean;
active: boolean;
uomId: [number, string];
uomPoId: [number, string];
companyId?: [number, string];
description?: string;
descriptionSale?: string;
descriptionPurchase?: string;
weight?: number;
volume?: number;
tracking: 'none' | 'serial' | 'lot';
// Cuentas contables
propertyAccountIncomeId?: [number, string];
propertyAccountExpenseId?: [number, string];
// Impuestos
taxesId: [number, string][];
supplierTaxesId: [number, string][];
// Inventario
qtyAvailable?: number;
virtualAvailable?: number;
incomingQty?: number;
outgoingQty?: number;
freeQty?: number;
// Costos
valuationMethod?: 'standard' | 'fifo' | 'average';
costMethod?: 'standard' | 'fifo' | 'average';
createDate: string;
writeDate: string;
}
/**
* Nivel de stock por ubicacion
*/
export interface OdooStockLevel {
productId: number;
productName: string;
productCode?: string;
locationId: number;
locationName: string;
lotId?: number;
lotName?: string;
packageId?: number;
packageName?: string;
quantity: number;
reservedQuantity: number;
availableQuantity: number;
uomId: number;
uomName: string;
}
/**
* Movimiento de stock
*/
export interface OdooStockMove {
id: number;
name: string;
reference?: string;
date: string;
productId: [number, string];
productUomQty: number;
productUom: [number, string];
locationId: [number, string];
locationDestId: [number, string];
state: 'draft' | 'waiting' | 'confirmed' | 'assigned' | 'done' | 'cancel';
pickingId?: [number, string];
pickingCode?: string;
originReturnedMoveId?: [number, string];
priceUnit: number;
companyId: [number, string];
partnerId?: [number, string];
origin?: string;
createDate: string;
writeDate: string;
}
/**
* Valoracion de inventario
*/
export interface OdooStockValuation {
productId: number;
productName: string;
productCode?: string;
quantity: number;
unitCost: number;
totalValue: number;
currencyId: number;
currencyCode: string;
valuationMethod: string;
}
// ============================================================================
// Report Types
// ============================================================================
/**
* Linea del catalogo de cuentas
*/
export interface ChartOfAccountsLine {
id: number;
code: string;
name: string;
accountType: OdooAccountType;
level: number;
parentId?: number;
debit: number;
credit: number;
balance: number;
}
/**
* Linea de balanza de comprobacion
*/
export interface TrialBalanceLine {
accountId: number;
accountCode: string;
accountName: string;
accountType: OdooAccountType;
initialDebit: number;
initialCredit: number;
initialBalance: number;
periodDebit: number;
periodCredit: number;
periodBalance: number;
finalDebit: number;
finalCredit: number;
finalBalance: number;
}
/**
* Estado de resultados
*/
export interface ProfitAndLossReport {
dateFrom: string;
dateTo: string;
companyId: number;
companyName: string;
currencyId: number;
currencyCode: string;
income: ProfitAndLossSection;
expense: ProfitAndLossSection;
netProfit: number;
}
export interface ProfitAndLossSection {
total: number;
lines: ProfitAndLossLine[];
}
export interface ProfitAndLossLine {
accountId: number;
accountCode: string;
accountName: string;
amount: number;
percentage: number;
}
/**
* Balance general
*/
export interface BalanceSheetReport {
date: string;
companyId: number;
companyName: string;
currencyId: number;
currencyCode: string;
assets: BalanceSheetSection;
liabilities: BalanceSheetSection;
equity: BalanceSheetSection;
totalAssets: number;
totalLiabilitiesEquity: number;
}
export interface BalanceSheetSection {
total: number;
subsections: BalanceSheetSubsection[];
}
export interface BalanceSheetSubsection {
name: string;
total: number;
lines: BalanceSheetLine[];
}
export interface BalanceSheetLine {
accountId: number;
accountCode: string;
accountName: string;
balance: number;
}
/**
* Reporte de impuestos
*/
export interface TaxReport {
dateFrom: string;
dateTo: string;
companyId: number;
companyName: string;
lines: TaxReportLine[];
totalSalesBase: number;
totalSalesTax: number;
totalPurchasesBase: number;
totalPurchasesTax: number;
netTax: number;
}
export interface TaxReportLine {
taxId: number;
taxName: string;
taxType: 'sale' | 'purchase' | 'none';
baseAmount: number;
taxAmount: number;
invoiceCount: number;
}
// ============================================================================
// Sync Types
// ============================================================================
/**
* Resultado de sincronizacion
*/
export interface OdooSyncResult {
success: boolean;
syncId: string;
startedAt: Date;
completedAt?: Date;
duration?: number;
stats: OdooSyncStats;
errors: OdooSyncError[];
}
export interface OdooSyncStats {
partners: { created: number; updated: number; errors: number };
invoices: { created: number; updated: number; errors: number };
payments: { created: number; updated: number; errors: number };
journalEntries: { created: number; updated: number; errors: number };
products: { created: number; updated: number; errors: number };
stockMoves: { created: number; updated: number; errors: number };
total: { created: number; updated: number; errors: number };
}
export interface OdooSyncError {
model: string;
recordId?: number;
externalRef?: string;
error: string;
details?: unknown;
timestamp: Date;
}
/**
* Opciones de sincronizacion
*/
export interface OdooSyncOptions {
tenantId: string;
config: OdooConfig;
dateFrom?: Date;
dateTo?: Date;
fullSync?: boolean;
includePartners?: boolean;
includeInvoices?: boolean;
includePayments?: boolean;
includeJournalEntries?: boolean;
includeProducts?: boolean;
includeStockMoves?: boolean;
batchSize?: number;
onProgress?: (progress: OdooSyncProgress) => void;
}
export interface OdooSyncProgress {
model: string;
processed: number;
total: number;
percentage: number;
currentItem?: string;
}
/**
* Webhook de Odoo
*/
export interface OdooWebhookPayload {
model: string;
action: 'create' | 'write' | 'unlink';
recordIds: number[];
timestamp: string;
companyId: number;
userId: number;
}
// ============================================================================
// Error Types
// ============================================================================
/**
* Codigos de error de Odoo
*/
export type OdooErrorCode =
| 'CONNECTION_ERROR'
| 'AUTH_ERROR'
| 'ACCESS_DENIED'
| 'RECORD_NOT_FOUND'
| 'VALIDATION_ERROR'
| 'XML_RPC_ERROR'
| 'TIMEOUT'
| 'RATE_LIMIT'
| 'UNKNOWN_ERROR';
/**
* Error de Odoo
*/
export class OdooError extends Error {
constructor(
message: string,
public readonly code: OdooErrorCode,
public readonly details?: unknown,
public readonly odooFaultCode?: string
) {
super(message);
this.name = 'OdooError';
Object.setPrototypeOf(this, OdooError.prototype);
}
}
/**
* Error de autenticacion
*/
export class OdooAuthError extends OdooError {
constructor(message: string, details?: unknown) {
super(message, 'AUTH_ERROR', details);
this.name = 'OdooAuthError';
Object.setPrototypeOf(this, OdooAuthError.prototype);
}
}
/**
* Error de conexion
*/
export class OdooConnectionError extends OdooError {
constructor(message: string, details?: unknown) {
super(message, 'CONNECTION_ERROR', details);
this.name = 'OdooConnectionError';
Object.setPrototypeOf(this, OdooConnectionError.prototype);
}
}
// ============================================================================
// Utility Types
// ============================================================================
/**
* Dominio de busqueda de Odoo (usado en search/read)
*/
export type OdooDomain = Array<string | OdooDomainTuple | OdooDomain>;
export type OdooDomainTuple = [string, string, unknown];
/**
* Opciones de paginacion
*/
export interface OdooPagination {
offset?: number;
limit?: number;
order?: string;
}
/**
* Respuesta paginada
*/
export interface OdooPaginatedResponse<T> {
items: T[];
total: number;
offset: number;
limit: number;
hasMore: boolean;
}
/**
* Campos a leer
*/
export type OdooFields = string[];
/**
* Valores para crear/actualizar
*/
export type OdooValues = Record<string, unknown>;
/**
* Mapeo de transaccion de Horux
*/
export interface HoruxTransactionMapping {
externalId: string;
externalRef?: string;
date: Date;
description: string;
amount: number;
type: 'income' | 'expense' | 'transfer';
category?: string;
partnerId?: string;
partnerName?: string;
accountCode?: string;
accountName?: string;
currencyCode: string;
metadata: Record<string, unknown>;
}

View File

@@ -0,0 +1,904 @@
/**
* Odoo Partners Connector
* Conector para contactos (clientes y proveedores) de Odoo ERP
*/
import { OdooClient } from './odoo.client.js';
import {
OdooPartner,
PartnerBalance,
PartnerTransaction,
OdooDomain,
OdooPagination,
OdooPaginatedResponse,
OdooError,
} from './odoo.types.js';
// ============================================================================
// Tipos internos
// ============================================================================
interface PartnerStats {
totalInvoiced: number;
totalPaid: number;
totalPending: number;
invoiceCount: number;
lastInvoiceDate?: string;
averagePaymentDays?: number;
}
interface PartnerCreditInfo {
credit: number;
debit: number;
creditLimit: number;
availableCredit: number;
overdueAmount: number;
isBlocked: boolean;
}
// ============================================================================
// Partners Connector Class
// ============================================================================
/**
* Conector de contactos para Odoo
*/
export class OdooPartnersConnector {
private client: OdooClient;
constructor(client: OdooClient) {
this.client = client;
}
// ==========================================================================
// Customers
// ==========================================================================
/**
* Obtiene clientes
*/
async getCustomers(
options?: {
search?: string;
isCompany?: boolean;
categoryId?: number;
userId?: number;
companyId?: number;
onlyWithCredit?: boolean;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooPartner>> {
const domain: OdooDomain = [
['active', '=', true],
['customer_rank', '>', 0],
];
this.applyCommonFilters(domain, options);
if (options?.onlyWithCredit) {
domain.push(['credit', '>', 0]);
}
return this.client.searchReadPaginated<OdooPartner>(
'res.partner',
domain,
this.getPartnerFields(),
{ order: 'name asc', ...options?.pagination }
);
}
/**
* Obtiene clientes con saldo pendiente
*/
async getCustomersWithBalance(
options?: {
minBalance?: number;
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooPartner>> {
const domain: OdooDomain = [
['active', '=', true],
['customer_rank', '>', 0],
['credit', '>', options?.minBalance || 0],
];
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
return this.client.searchReadPaginated<OdooPartner>(
'res.partner',
domain,
this.getPartnerFields(),
{ order: 'credit desc', ...options?.pagination }
);
}
/**
* Busca clientes por RFC/VAT
*/
async getCustomerByVat(vat: string): Promise<OdooPartner | null> {
const partners = await this.client.searchRead<OdooPartner>(
'res.partner',
[
['vat', '=', vat],
['customer_rank', '>', 0],
],
this.getPartnerFields(),
{ limit: 1 }
);
return partners[0] || null;
}
// ==========================================================================
// Vendors
// ==========================================================================
/**
* Obtiene proveedores
*/
async getVendors(
options?: {
search?: string;
isCompany?: boolean;
categoryId?: number;
companyId?: number;
onlyWithPayables?: boolean;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooPartner>> {
const domain: OdooDomain = [
['active', '=', true],
['supplier_rank', '>', 0],
];
this.applyCommonFilters(domain, options);
if (options?.onlyWithPayables) {
domain.push(['debit', '>', 0]);
}
return this.client.searchReadPaginated<OdooPartner>(
'res.partner',
domain,
this.getPartnerFields(),
{ order: 'name asc', ...options?.pagination }
);
}
/**
* Obtiene proveedores con saldo pendiente
*/
async getVendorsWithBalance(
options?: {
minBalance?: number;
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<OdooPartner>> {
const domain: OdooDomain = [
['active', '=', true],
['supplier_rank', '>', 0],
['debit', '>', options?.minBalance || 0],
];
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
return this.client.searchReadPaginated<OdooPartner>(
'res.partner',
domain,
this.getPartnerFields(),
{ order: 'debit desc', ...options?.pagination }
);
}
/**
* Busca proveedor por RFC/VAT
*/
async getVendorByVat(vat: string): Promise<OdooPartner | null> {
const partners = await this.client.searchRead<OdooPartner>(
'res.partner',
[
['vat', '=', vat],
['supplier_rank', '>', 0],
],
this.getPartnerFields(),
{ limit: 1 }
);
return partners[0] || null;
}
// ==========================================================================
// Partner Balance
// ==========================================================================
/**
* Obtiene el balance de un partner
*/
async getPartnerBalance(
partnerId: number,
options?: {
companyId?: number;
}
): Promise<PartnerBalance> {
const connection = this.client.getConnection();
if (!connection) {
throw new OdooError('No hay conexion activa', 'CONNECTION_ERROR');
}
const partner = await this.client.read<OdooPartner>(
'res.partner',
[partnerId],
['name', 'credit', 'debit']
);
if (!partner[0]) {
throw new OdooError(`Partner ${partnerId} no encontrado`, 'RECORD_NOT_FOUND');
}
// Obtener cuentas por cobrar y pagar
const companyId = options?.companyId || connection.activeCompanyId;
const company = connection.companies.find((c) => c.id === companyId);
const receivableBalance = await this.getAccountBalance(
partnerId,
'asset_receivable',
companyId
);
const payableBalance = await this.getAccountBalance(
partnerId,
'liability_payable',
companyId
);
return {
partnerId,
partnerName: partner[0].name,
debit: partner[0].debit,
credit: partner[0].credit,
balance: partner[0].credit - partner[0].debit,
receivable: receivableBalance,
payable: Math.abs(payableBalance),
currencyId: company?.currencyId || 0,
currencyCode: company?.currencyCode || 'MXN',
};
}
/**
* Obtiene balances de multiples partners
*/
async getPartnersBalance(
partnerIds: number[],
options?: {
companyId?: number;
}
): Promise<PartnerBalance[]> {
if (partnerIds.length === 0) return [];
const results: PartnerBalance[] = [];
for (const partnerId of partnerIds) {
try {
const balance = await this.getPartnerBalance(partnerId, options);
results.push(balance);
} catch (error) {
// Continuar con el siguiente partner
}
}
return results;
}
// ==========================================================================
// Partner Transactions
// ==========================================================================
/**
* Obtiene transacciones de un partner
*/
async getPartnerTransactions(
partnerId: number,
options?: {
dateFrom?: Date;
dateTo?: Date;
accountType?: 'receivable' | 'payable' | 'all';
onlyUnreconciled?: boolean;
companyId?: number;
pagination?: OdooPagination;
}
): Promise<OdooPaginatedResponse<PartnerTransaction>> {
const domain: OdooDomain = [
['partner_id', '=', partnerId],
['parent_state', '=', 'posted'],
];
if (options?.dateFrom) {
domain.push(['date', '>=', this.formatDate(options.dateFrom)]);
}
if (options?.dateTo) {
domain.push(['date', '<=', this.formatDate(options.dateTo)]);
}
if (options?.accountType === 'receivable') {
domain.push(['account_type', '=', 'asset_receivable']);
} else if (options?.accountType === 'payable') {
domain.push(['account_type', '=', 'liability_payable']);
} else if (options?.accountType === 'all') {
domain.push(['account_type', 'in', ['asset_receivable', 'liability_payable']]);
}
if (options?.onlyUnreconciled) {
domain.push(['reconciled', '=', false]);
domain.push(['amount_residual', '!=', 0]);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
return this.client.searchReadPaginated<PartnerTransaction>(
'account.move.line',
domain,
[
'id',
'date',
'ref',
'name',
'journal_id',
'account_id',
'move_id',
'debit',
'credit',
'balance',
'amount_currency',
'currency_id',
'reconciled',
'full_reconcile_id',
],
{ order: 'date desc, id desc', ...options?.pagination }
);
}
/**
* Obtiene estado de cuenta de un partner
*/
async getPartnerStatement(
partnerId: number,
options?: {
dateFrom?: Date;
dateTo?: Date;
accountType?: 'receivable' | 'payable' | 'all';
companyId?: number;
}
): Promise<{
partner: OdooPartner;
openingBalance: number;
transactions: PartnerTransaction[];
closingBalance: number;
}> {
const partner = await this.getPartnerById(partnerId);
if (!partner) {
throw new OdooError(`Partner ${partnerId} no encontrado`, 'RECORD_NOT_FOUND');
}
const dateFrom = options?.dateFrom || new Date('1900-01-01');
const dateTo = options?.dateTo || new Date();
// Obtener balance inicial
const openingBalance = await this.getPartnerBalanceAtDate(
partnerId,
new Date(dateFrom.getTime() - 86400000),
options?.accountType,
options?.companyId
);
// Obtener transacciones del periodo
const transactionsResult = await this.getPartnerTransactions(partnerId, {
dateFrom,
dateTo,
accountType: options?.accountType,
companyId: options?.companyId,
pagination: { limit: 10000 },
});
// Calcular balance final
let runningBalance = openingBalance;
const transactions = transactionsResult.items.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
for (const tx of transactions) {
runningBalance += tx.balance;
}
return {
partner,
openingBalance,
transactions,
closingBalance: runningBalance,
};
}
// ==========================================================================
// Partner Statistics
// ==========================================================================
/**
* Obtiene estadisticas de un cliente
*/
async getCustomerStats(
partnerId: number,
options?: {
companyId?: number;
}
): Promise<PartnerStats> {
return this.getPartnerStats(partnerId, 'customer', options);
}
/**
* Obtiene estadisticas de un proveedor
*/
async getVendorStats(
partnerId: number,
options?: {
companyId?: number;
}
): Promise<PartnerStats> {
return this.getPartnerStats(partnerId, 'vendor', options);
}
/**
* Obtiene informacion de credito de un cliente
*/
async getCustomerCreditInfo(
partnerId: number,
options?: {
companyId?: number;
}
): Promise<PartnerCreditInfo> {
const partner = await this.client.read<OdooPartner>(
'res.partner',
[partnerId],
['credit', 'debit', 'credit_limit']
);
if (!partner[0]) {
throw new OdooError(`Partner ${partnerId} no encontrado`, 'RECORD_NOT_FOUND');
}
const credit = partner[0].credit;
const debit = partner[0].debit;
const creditLimit = partner[0].creditLimit;
// Obtener monto vencido
const overdueResult = await this.getOverdueAmount(partnerId, options?.companyId);
return {
credit,
debit,
creditLimit,
availableCredit: creditLimit - credit,
overdueAmount: overdueResult,
isBlocked: creditLimit > 0 && credit >= creditLimit,
};
}
// ==========================================================================
// Partner CRUD
// ==========================================================================
/**
* Obtiene un partner por ID
*/
async getPartnerById(partnerId: number): Promise<OdooPartner | null> {
const partners = await this.client.read<OdooPartner>(
'res.partner',
[partnerId],
this.getPartnerFields()
);
return partners[0] || null;
}
/**
* Busca partners
*/
async searchPartners(
query: string,
options?: {
type?: 'customer' | 'vendor' | 'all';
isCompany?: boolean;
limit?: number;
}
): Promise<OdooPartner[]> {
const domain: OdooDomain = [
['active', '=', true],
'|',
['name', 'ilike', query],
['vat', 'ilike', query],
];
if (options?.type === 'customer') {
domain.push(['customer_rank', '>', 0]);
} else if (options?.type === 'vendor') {
domain.push(['supplier_rank', '>', 0]);
}
if (options?.isCompany !== undefined) {
domain.push(['is_company', '=', options.isCompany]);
}
return this.client.searchRead<OdooPartner>(
'res.partner',
domain,
this.getPartnerFields(),
{ limit: options?.limit || 20, order: 'name asc' }
);
}
/**
* Crea un nuevo partner
*/
async createPartner(data: {
name: string;
email?: string;
phone?: string;
mobile?: string;
vat?: string;
street?: string;
street2?: string;
city?: string;
stateId?: number;
zip?: string;
countryId?: number;
isCompany?: boolean;
isCustomer?: boolean;
isVendor?: boolean;
companyId?: number;
parentId?: number;
}): Promise<number> {
const values: Record<string, unknown> = {
name: data.name,
is_company: data.isCompany ?? true,
customer_rank: data.isCustomer ? 1 : 0,
supplier_rank: data.isVendor ? 1 : 0,
};
if (data.email) values.email = data.email;
if (data.phone) values.phone = data.phone;
if (data.mobile) values.mobile = data.mobile;
if (data.vat) values.vat = data.vat;
if (data.street) values.street = data.street;
if (data.street2) values.street2 = data.street2;
if (data.city) values.city = data.city;
if (data.stateId) values.state_id = data.stateId;
if (data.zip) values.zip = data.zip;
if (data.countryId) values.country_id = data.countryId;
if (data.companyId) values.company_id = data.companyId;
if (data.parentId) values.parent_id = data.parentId;
return this.client.create('res.partner', values);
}
/**
* Actualiza un partner
*/
async updatePartner(
partnerId: number,
data: Partial<{
name: string;
email: string;
phone: string;
mobile: string;
vat: string;
street: string;
street2: string;
city: string;
stateId: number;
zip: string;
countryId: number;
isCompany: boolean;
creditLimit: number;
}>
): Promise<boolean> {
const values: Record<string, unknown> = {};
if (data.name !== undefined) values.name = data.name;
if (data.email !== undefined) values.email = data.email;
if (data.phone !== undefined) values.phone = data.phone;
if (data.mobile !== undefined) values.mobile = data.mobile;
if (data.vat !== undefined) values.vat = data.vat;
if (data.street !== undefined) values.street = data.street;
if (data.street2 !== undefined) values.street2 = data.street2;
if (data.city !== undefined) values.city = data.city;
if (data.stateId !== undefined) values.state_id = data.stateId;
if (data.zip !== undefined) values.zip = data.zip;
if (data.countryId !== undefined) values.country_id = data.countryId;
if (data.isCompany !== undefined) values.is_company = data.isCompany;
if (data.creditLimit !== undefined) values.credit_limit = data.creditLimit;
return this.client.write('res.partner', [partnerId], values);
}
/**
* Obtiene categorias de partner
*/
async getPartnerCategories(): Promise<Array<{ id: number; name: string }>> {
return this.client.searchRead<{ id: number; name: string }>(
'res.partner.category',
[],
['id', 'name'],
{ order: 'name asc' }
);
}
// ==========================================================================
// Private Helpers
// ==========================================================================
/**
* Aplica filtros comunes al dominio
*/
private applyCommonFilters(
domain: OdooDomain,
options?: {
search?: string;
isCompany?: boolean;
categoryId?: number;
userId?: number;
companyId?: number;
}
): void {
if (options?.search) {
domain.push('|');
domain.push(['name', 'ilike', options.search]);
domain.push(['vat', 'ilike', options.search]);
}
if (options?.isCompany !== undefined) {
domain.push(['is_company', '=', options.isCompany]);
}
if (options?.categoryId) {
domain.push(['category_id', 'in', [options.categoryId]]);
}
if (options?.userId) {
domain.push(['user_id', '=', options.userId]);
}
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
}
/**
* Obtiene balance de una cuenta para un partner
*/
private async getAccountBalance(
partnerId: number,
accountType: string,
companyId: number
): Promise<number> {
const result = await this.client.callMethod<Array<{
balance: number;
}>>(
'account.move.line',
'read_group',
[
[
['partner_id', '=', partnerId],
['account_type', '=', accountType],
['parent_state', '=', 'posted'],
['company_id', '=', companyId],
],
['balance'],
[],
],
{ lazy: false }
);
return result[0]?.balance || 0;
}
/**
* Obtiene balance de partner a una fecha
*/
private async getPartnerBalanceAtDate(
partnerId: number,
date: Date,
accountType?: 'receivable' | 'payable' | 'all',
companyId?: number
): Promise<number> {
const domain: OdooDomain = [
['partner_id', '=', partnerId],
['date', '<=', this.formatDate(date)],
['parent_state', '=', 'posted'],
];
if (accountType === 'receivable') {
domain.push(['account_type', '=', 'asset_receivable']);
} else if (accountType === 'payable') {
domain.push(['account_type', '=', 'liability_payable']);
} else {
domain.push(['account_type', 'in', ['asset_receivable', 'liability_payable']]);
}
if (companyId) {
domain.push(['company_id', '=', companyId]);
}
const result = await this.client.callMethod<Array<{
balance: number;
}>>(
'account.move.line',
'read_group',
[domain, ['balance'], []],
{ lazy: false }
);
return result[0]?.balance || 0;
}
/**
* Obtiene estadisticas de un partner
*/
private async getPartnerStats(
partnerId: number,
type: 'customer' | 'vendor',
options?: { companyId?: number }
): Promise<PartnerStats> {
const invoiceTypes = type === 'customer'
? ['out_invoice', 'out_refund']
: ['in_invoice', 'in_refund'];
const domain: OdooDomain = [
['partner_id', '=', partnerId],
['move_type', 'in', invoiceTypes],
['state', '=', 'posted'],
];
if (options?.companyId) {
domain.push(['company_id', '=', options.companyId]);
}
const invoices = await this.client.searchRead<{
amountTotal: number;
amountResidual: number;
paymentState: string;
invoiceDate: string;
}>(
'account.move',
domain,
['amount_total', 'amount_residual', 'payment_state', 'invoice_date'],
{ order: 'invoice_date desc', limit: 1000 }
);
let totalInvoiced = 0;
let totalPaid = 0;
let totalPending = 0;
let lastInvoiceDate: string | undefined;
const paymentDays: number[] = [];
for (const invoice of invoices) {
totalInvoiced += invoice.amountTotal;
if (invoice.paymentState === 'paid') {
totalPaid += invoice.amountTotal;
} else {
totalPending += invoice.amountResidual;
}
if (!lastInvoiceDate) {
lastInvoiceDate = invoice.invoiceDate;
}
}
return {
totalInvoiced,
totalPaid,
totalPending,
invoiceCount: invoices.length,
lastInvoiceDate,
averagePaymentDays: paymentDays.length > 0
? paymentDays.reduce((a, b) => a + b, 0) / paymentDays.length
: undefined,
};
}
/**
* Obtiene monto vencido de un partner
*/
private async getOverdueAmount(
partnerId: number,
companyId?: number
): Promise<number> {
const today = this.formatDate(new Date());
const domain: OdooDomain = [
['partner_id', '=', partnerId],
['move_type', 'in', ['out_invoice', 'out_refund']],
['state', '=', 'posted'],
['payment_state', 'in', ['not_paid', 'partial']],
['invoice_date_due', '<', today],
];
if (companyId) {
domain.push(['company_id', '=', companyId]);
}
const invoices = await this.client.searchRead<{
amountResidual: number;
}>(
'account.move',
domain,
['amount_residual']
);
return invoices.reduce((sum, inv) => sum + inv.amountResidual, 0);
}
/**
* Campos a leer de partners
*/
private getPartnerFields(): string[] {
return [
'id',
'name',
'display_name',
'ref',
'vat',
'email',
'phone',
'mobile',
'website',
'street',
'street2',
'city',
'state_id',
'zip',
'country_id',
'is_company',
'parent_id',
'child_ids',
'company_id',
'user_id',
'customer_rank',
'supplier_rank',
'credit',
'debit',
'credit_limit',
'property_account_receivable_id',
'property_account_payable_id',
'property_payment_term_id',
'property_supplier_payment_term_id',
'lang',
'category_id',
'active',
'create_date',
'write_date',
];
}
/**
* Formatea una fecha a string YYYY-MM-DD
*/
private formatDate(date: Date): string {
return date.toISOString().split('T')[0] ?? '';
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea un conector de contactos
*/
export function createPartnersConnector(
client: OdooClient
): OdooPartnersConnector {
return new OdooPartnersConnector(client);
}

View File

@@ -0,0 +1,686 @@
/**
* SAP Business One Banking Connector
* Conector para cuentas bancarias, pagos y estados de cuenta
*/
import { SAPClient } from './sap.client.js';
import {
HouseBankAccount,
BankStatement,
BankStatementRow,
IncomingPayment,
OutgoingPayment,
Period,
ODataQueryOptions,
} from './sap.types.js';
import { logger } from '../../../utils/logger.js';
// ============================================================================
// Types
// ============================================================================
/**
* Estado de pago
*/
export type PaymentStatus = 'all' | 'active' | 'cancelled';
/**
* Tipo de pago
*/
export type PaymentType = 'cash' | 'check' | 'transfer' | 'creditCard' | 'all';
/**
* Opciones de filtro para pagos
*/
export interface PaymentFilterOptions {
cardCode?: string;
status?: PaymentStatus;
paymentType?: PaymentType;
bankAccount?: string;
series?: number;
}
/**
* Resumen de cuenta bancaria
*/
export interface BankAccountSummary {
absoluteEntry: number;
bankCode: string;
accountNo: string;
accountName: string;
glAccount: string;
currentBalance: number;
currency: string;
iban?: string;
lastStatementDate?: string;
lastStatementBalance?: number;
}
/**
* Resumen de flujo de efectivo
*/
export interface CashFlowSummary {
period: Period;
openingBalance: number;
incomingPayments: number;
outgoingPayments: number;
netCashFlow: number;
closingBalance: number;
byPaymentType: {
cash: { incoming: number; outgoing: number };
check: { incoming: number; outgoing: number };
transfer: { incoming: number; outgoing: number };
creditCard: { incoming: number; outgoing: number };
};
}
// ============================================================================
// Banking Connector Class
// ============================================================================
/**
* Conector para datos bancarios de SAP B1
*/
export class BankingConnector {
constructor(private readonly client: SAPClient) {}
// ==========================================================================
// Bank Accounts (Cuentas Bancarias de Empresa)
// ==========================================================================
/**
* Obtiene todas las cuentas bancarias de la empresa
*/
async getBankAccounts(): Promise<HouseBankAccount[]> {
logger.info('SAP Banking: Obteniendo cuentas bancarias');
return this.client.getAll<HouseBankAccount>('/HouseBankAccounts', {
$select: [
'AbsoluteEntry',
'BankCode',
'BranchNumber',
'AccountNo',
'GLAccount',
'Country',
'Bank',
'AccountName',
'ZipCode',
'City',
'State',
'IBAN',
'BICSwiftCode',
'NextCheckNum',
'CompanyName',
],
$orderby: 'BankCode asc',
});
}
/**
* Obtiene una cuenta bancaria por su entrada
*/
async getBankAccount(absoluteEntry: number): Promise<HouseBankAccount> {
logger.info('SAP Banking: Obteniendo cuenta bancaria', { absoluteEntry });
return this.client.get<HouseBankAccount>(`/HouseBankAccounts(${absoluteEntry})`);
}
/**
* Obtiene el resumen de cuentas bancarias con saldos
*/
async getBankAccountsSummary(): Promise<BankAccountSummary[]> {
logger.info('SAP Banking: Obteniendo resumen de cuentas bancarias');
const accounts = await this.getBankAccounts();
const summaries: BankAccountSummary[] = [];
for (const account of accounts) {
// Obtener saldo de la cuenta GL asociada
let currentBalance = 0;
if (account.GLAccount) {
try {
interface GLAccount {
Balance?: number;
}
const glAccount = await this.client.get<GLAccount>(
`/ChartOfAccounts('${account.GLAccount}')`
);
currentBalance = glAccount.Balance || 0;
} catch {
// Si no se puede obtener el balance, dejarlo en 0
}
}
summaries.push({
absoluteEntry: account.AbsoluteEntry,
bankCode: account.BankCode,
accountNo: account.AccountNo,
accountName: account.AccountName || '',
glAccount: account.GLAccount,
currentBalance,
currency: 'MXN', // TODO: Obtener moneda de la cuenta GL
iban: account.IBAN,
});
}
return summaries;
}
// ==========================================================================
// Incoming Payments (Pagos Recibidos)
// ==========================================================================
/**
* Obtiene pagos recibidos de un periodo
*/
async getIncomingPayments(
period: Period,
options?: PaymentFilterOptions
): Promise<IncomingPayment[]> {
logger.info('SAP Banking: Obteniendo pagos recibidos', { period, options });
const queryOptions = this.buildPaymentQuery(period, options);
return this.client.getAll<IncomingPayment>('/IncomingPayments', queryOptions);
}
/**
* Obtiene un pago recibido por DocEntry
*/
async getIncomingPayment(docEntry: number): Promise<IncomingPayment> {
logger.info('SAP Banking: Obteniendo pago recibido', { docEntry });
return this.client.get<IncomingPayment>(`/IncomingPayments(${docEntry})`, {
$expand: ['PaymentInvoices', 'PaymentChecks', 'PaymentCreditCards'],
});
}
/**
* Obtiene pagos recibidos por cliente
*/
async getIncomingPaymentsByCustomer(
cardCode: string,
period?: Period
): Promise<IncomingPayment[]> {
return this.getIncomingPayments(
period || this.getDefaultPeriod(),
{ cardCode }
);
}
/**
* Obtiene el total de pagos recibidos en un periodo
*/
async getTotalIncomingPayments(period: Period): Promise<{
count: number;
total: number;
byCurrency: Record<string, number>;
}> {
logger.info('SAP Banking: Calculando total de pagos recibidos', { period });
const payments = await this.getIncomingPayments(period, { status: 'active' });
const byCurrency: Record<string, number> = {};
let total = 0;
for (const payment of payments) {
const amount = this.calculatePaymentTotal(payment);
const currency = payment.DocCurrency || 'MXN';
total += amount;
byCurrency[currency] = (byCurrency[currency] || 0) + amount;
}
return {
count: payments.length,
total,
byCurrency,
};
}
// ==========================================================================
// Outgoing Payments (Pagos Emitidos)
// ==========================================================================
/**
* Obtiene pagos emitidos de un periodo
*/
async getOutgoingPayments(
period: Period,
options?: PaymentFilterOptions
): Promise<OutgoingPayment[]> {
logger.info('SAP Banking: Obteniendo pagos emitidos', { period, options });
const queryOptions = this.buildPaymentQuery(period, options);
return this.client.getAll<OutgoingPayment>('/VendorPayments', queryOptions);
}
/**
* Obtiene un pago emitido por DocEntry
*/
async getOutgoingPayment(docEntry: number): Promise<OutgoingPayment> {
logger.info('SAP Banking: Obteniendo pago emitido', { docEntry });
return this.client.get<OutgoingPayment>(`/VendorPayments(${docEntry})`, {
$expand: ['PaymentInvoices', 'PaymentChecks', 'PaymentCreditCards'],
});
}
/**
* Obtiene pagos emitidos por proveedor
*/
async getOutgoingPaymentsByVendor(
cardCode: string,
period?: Period
): Promise<OutgoingPayment[]> {
return this.getOutgoingPayments(
period || this.getDefaultPeriod(),
{ cardCode }
);
}
/**
* Obtiene el total de pagos emitidos en un periodo
*/
async getTotalOutgoingPayments(period: Period): Promise<{
count: number;
total: number;
byCurrency: Record<string, number>;
}> {
logger.info('SAP Banking: Calculando total de pagos emitidos', { period });
const payments = await this.getOutgoingPayments(period, { status: 'active' });
const byCurrency: Record<string, number> = {};
let total = 0;
for (const payment of payments) {
const amount = this.calculatePaymentTotal(payment);
const currency = payment.DocCurrency || 'MXN';
total += amount;
byCurrency[currency] = (byCurrency[currency] || 0) + amount;
}
return {
count: payments.length,
total,
byCurrency,
};
}
// ==========================================================================
// Bank Statements (Estados de Cuenta)
// ==========================================================================
/**
* Obtiene estados de cuenta bancarios
*/
async getBankStatements(options?: {
bankCode?: string;
account?: string;
fromDate?: Date;
toDate?: Date;
}): Promise<BankStatement[]> {
logger.info('SAP Banking: Obteniendo estados de cuenta', { options });
const filters: string[] = [];
if (options?.bankCode) {
filters.push(`BankCode eq '${options.bankCode}'`);
}
if (options?.account) {
filters.push(`Account eq '${options.account}'`);
}
if (options?.fromDate) {
filters.push(`StatementDate ge '${this.client.formatDate(options.fromDate)}'`);
}
if (options?.toDate) {
filters.push(`StatementDate le '${this.client.formatDate(options.toDate)}'`);
}
return this.client.getAll<BankStatement>('/BankStatements', {
$filter: filters.length > 0 ? this.client.combineFilters(...filters) : undefined,
$expand: ['BankStatementRows'],
$orderby: 'StatementDate desc',
});
}
/**
* Obtiene un estado de cuenta por su numero interno
*/
async getBankStatement(internalNumber: number): Promise<BankStatement> {
logger.info('SAP Banking: Obteniendo estado de cuenta', { internalNumber });
return this.client.get<BankStatement>(`/BankStatements(${internalNumber})`, {
$expand: ['BankStatementRows'],
});
}
/**
* Obtiene el ultimo estado de cuenta de una cuenta bancaria
*/
async getLatestBankStatement(bankCode: string, account: string): Promise<BankStatement | null> {
logger.info('SAP Banking: Obteniendo ultimo estado de cuenta', { bankCode, account });
const statements = await this.client.getAll<BankStatement>('/BankStatements', {
$filter: `BankCode eq '${bankCode}' and Account eq '${account}'`,
$orderby: 'StatementDate desc',
$top: 1,
});
return statements[0] || null;
}
/**
* Obtiene movimientos de un estado de cuenta
*/
async getBankStatementRows(internalNumber: number): Promise<BankStatementRow[]> {
const statement = await this.getBankStatement(internalNumber);
return statement.BankStatementRows || [];
}
// ==========================================================================
// Cash Flow Analysis
// ==========================================================================
/**
* Obtiene el resumen de flujo de efectivo
*/
async getCashFlowSummary(period: Period): Promise<CashFlowSummary> {
logger.info('SAP Banking: Generando resumen de flujo de efectivo', { period });
const [incomingPayments, outgoingPayments] = await Promise.all([
this.getIncomingPayments(period, { status: 'active' }),
this.getOutgoingPayments(period, { status: 'active' }),
]);
// Calcular totales por tipo de pago
const byPaymentType = {
cash: { incoming: 0, outgoing: 0 },
check: { incoming: 0, outgoing: 0 },
transfer: { incoming: 0, outgoing: 0 },
creditCard: { incoming: 0, outgoing: 0 },
};
// Procesar pagos recibidos
for (const payment of incomingPayments) {
if (payment.CashSum && payment.CashSum > 0) {
byPaymentType.cash.incoming += payment.CashSum;
}
if (payment.PaymentChecks?.length) {
const checkTotal = payment.PaymentChecks.reduce((sum, c) => sum + (c.CheckSum || 0), 0);
byPaymentType.check.incoming += checkTotal;
}
if (payment.TransferSum && payment.TransferSum > 0) {
byPaymentType.transfer.incoming += payment.TransferSum;
}
if (payment.PaymentCreditCards?.length) {
const ccTotal = payment.PaymentCreditCards.reduce((sum, c) => sum + (c.CreditSum || 0), 0);
byPaymentType.creditCard.incoming += ccTotal;
}
}
// Procesar pagos emitidos
for (const payment of outgoingPayments) {
if (payment.CashSum && payment.CashSum > 0) {
byPaymentType.cash.outgoing += payment.CashSum;
}
if (payment.PaymentChecks?.length) {
const checkTotal = payment.PaymentChecks.reduce((sum, c) => sum + (c.CheckSum || 0), 0);
byPaymentType.check.outgoing += checkTotal;
}
if (payment.TransferSum && payment.TransferSum > 0) {
byPaymentType.transfer.outgoing += payment.TransferSum;
}
if (payment.PaymentCreditCards?.length) {
const ccTotal = payment.PaymentCreditCards.reduce((sum, c) => sum + (c.CreditSum || 0), 0);
byPaymentType.creditCard.outgoing += ccTotal;
}
}
const totalIncoming = Object.values(byPaymentType).reduce(
(sum, type) => sum + type.incoming,
0
);
const totalOutgoing = Object.values(byPaymentType).reduce(
(sum, type) => sum + type.outgoing,
0
);
return {
period,
openingBalance: 0, // TODO: Calcular desde estados de cuenta
incomingPayments: totalIncoming,
outgoingPayments: totalOutgoing,
netCashFlow: totalIncoming - totalOutgoing,
closingBalance: totalIncoming - totalOutgoing, // Simplificado
byPaymentType,
};
}
/**
* Obtiene proyeccion de flujo de efectivo
*/
async getCashFlowProjection(daysAhead = 30): Promise<{
expectedIncoming: number;
expectedOutgoing: number;
projectedBalance: number;
byWeek: Array<{
weekStart: Date;
incoming: number;
outgoing: number;
balance: number;
}>;
}> {
logger.info('SAP Banking: Generando proyeccion de flujo de efectivo', { daysAhead });
const today = new Date();
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + daysAhead);
// Obtener facturas de venta abiertas (ingresos esperados)
interface OpenInvoice {
DocDueDate: string;
DocTotal: number;
PaidToDate?: number;
}
const openARInvoices = await this.client.getAll<OpenInvoice>('/Invoices', {
$filter: this.client.combineFilters(
"DocumentStatus eq 'bost_Open'",
"Cancelled eq 'tNO'",
this.client.buildDateFilter('DocDueDate', today, futureDate)
),
$select: ['DocDueDate', 'DocTotal', 'PaidToDate'],
});
// Obtener facturas de compra abiertas (pagos esperados)
const openAPInvoices = await this.client.getAll<OpenInvoice>('/PurchaseInvoices', {
$filter: this.client.combineFilters(
"DocumentStatus eq 'bost_Open'",
"Cancelled eq 'tNO'",
this.client.buildDateFilter('DocDueDate', today, futureDate)
),
$select: ['DocDueDate', 'DocTotal', 'PaidToDate'],
});
// Calcular totales
const expectedIncoming = openARInvoices.reduce(
(sum, inv) => sum + (inv.DocTotal - (inv.PaidToDate || 0)),
0
);
const expectedOutgoing = openAPInvoices.reduce(
(sum, inv) => sum + (inv.DocTotal - (inv.PaidToDate || 0)),
0
);
// Agrupar por semana
const byWeek: Array<{
weekStart: Date;
incoming: number;
outgoing: number;
balance: number;
}> = [];
const numWeeks = Math.ceil(daysAhead / 7);
let runningBalance = 0;
for (let i = 0; i < numWeeks; i++) {
const weekStart = new Date(today);
weekStart.setDate(weekStart.getDate() + i * 7);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
const weekIncoming = openARInvoices
.filter((inv) => {
const dueDate = new Date(inv.DocDueDate);
return dueDate >= weekStart && dueDate <= weekEnd;
})
.reduce((sum, inv) => sum + (inv.DocTotal - (inv.PaidToDate || 0)), 0);
const weekOutgoing = openAPInvoices
.filter((inv) => {
const dueDate = new Date(inv.DocDueDate);
return dueDate >= weekStart && dueDate <= weekEnd;
})
.reduce((sum, inv) => sum + (inv.DocTotal - (inv.PaidToDate || 0)), 0);
runningBalance += weekIncoming - weekOutgoing;
byWeek.push({
weekStart,
incoming: weekIncoming,
outgoing: weekOutgoing,
balance: runningBalance,
});
}
return {
expectedIncoming,
expectedOutgoing,
projectedBalance: expectedIncoming - expectedOutgoing,
byWeek,
};
}
// ==========================================================================
// Helper Methods
// ==========================================================================
/**
* Construye las opciones de consulta para pagos
*/
private buildPaymentQuery(
period: Period,
options?: PaymentFilterOptions
): ODataQueryOptions {
const filters: string[] = [];
// Filtro de fecha
const dateFilter = this.client.buildDateFilter('DocDate', period.startDate, period.endDate);
if (dateFilter) {
filters.push(dateFilter);
}
// Filtro de socio de negocios
if (options?.cardCode) {
filters.push(`CardCode eq '${options.cardCode}'`);
}
// Filtro de estado
if (options?.status === 'active') {
filters.push("Cancelled eq 'tNO'");
} else if (options?.status === 'cancelled') {
filters.push("Cancelled eq 'tYES'");
}
// Filtro de tipo de pago
if (options?.paymentType && options.paymentType !== 'all') {
switch (options.paymentType) {
case 'cash':
filters.push('CashSum gt 0');
break;
case 'transfer':
filters.push('TransferSum gt 0');
break;
// check y creditCard se manejan en las lineas
}
}
// Filtro de serie
if (options?.series !== undefined) {
filters.push(`Series eq ${options.series}`);
}
return {
$filter: filters.length > 0 ? this.client.combineFilters(...filters) : undefined,
$select: [
'DocEntry',
'DocNum',
'DocType',
'DocDate',
'CardCode',
'CardName',
'DocCurrency',
'DocRate',
'CashSum',
'CashAccount',
'TransferAccount',
'TransferSum',
'TransferDate',
'TransferReference',
'Cancelled',
'ControlAccount',
'TaxDate',
'Series',
'BankCode',
'BankAccount',
'JournalRemarks',
],
$expand: ['PaymentInvoices'],
$orderby: 'DocDate desc, DocNum desc',
};
}
/**
* Calcula el total de un pago
*/
private calculatePaymentTotal(payment: IncomingPayment | OutgoingPayment): number {
let total = 0;
total += payment.CashSum || 0;
total += payment.TransferSum || 0;
if (payment.PaymentChecks) {
total += payment.PaymentChecks.reduce((sum, c) => sum + (c.CheckSum || 0), 0);
}
if (payment.PaymentCreditCards) {
total += payment.PaymentCreditCards.reduce((sum, c) => sum + (c.CreditSum || 0), 0);
}
return total;
}
/**
* Obtiene el periodo por defecto (ultimo mes)
*/
private getDefaultPeriod(): Period {
const endDate = new Date();
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1);
return { startDate, endDate };
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea una instancia del conector bancario
*/
export function createBankingConnector(client: SAPClient): BankingConnector {
return new BankingConnector(client);
}
export default BankingConnector;

View File

@@ -0,0 +1,633 @@
/**
* SAP Business One Financials Connector
* Conector para datos financieros y contables
*/
import { SAPClient } from './sap.client.js';
import {
ChartOfAccounts,
AccountCategory,
JournalEntry,
TrialBalanceEntry,
ProfitAndLossEntry,
BalanceSheetEntry,
AgingReportEntry,
Period,
ODataQueryOptions,
ODataResponse,
SAPError,
} from './sap.types.js';
import { logger } from '../../../utils/logger.js';
// ============================================================================
// Types
// ============================================================================
/**
* Tipo de reporte de antiguedad
*/
export type AgingType = 'AR' | 'AP';
/**
* Opciones para trial balance
*/
export interface TrialBalanceOptions {
accountCodes?: string[];
includeZeroBalance?: boolean;
groupByLevel?: number;
}
/**
* Opciones para P&L
*/
export interface ProfitAndLossOptions {
comparePreviousYear?: boolean;
compareBudget?: boolean;
accountLevel?: number;
}
/**
* Opciones para balance sheet
*/
export interface BalanceSheetOptions {
comparePreviousPeriod?: boolean;
comparePreviousYear?: boolean;
accountLevel?: number;
}
/**
* Opciones para aging report
*/
export interface AgingOptions {
cardCodes?: string[];
asOfDate?: Date;
agingPeriods?: number[];
}
// ============================================================================
// Financials Connector Class
// ============================================================================
/**
* Conector para datos financieros de SAP B1
*/
export class FinancialsConnector {
constructor(private readonly client: SAPClient) {}
// ==========================================================================
// Chart of Accounts
// ==========================================================================
/**
* Obtiene el catalogo de cuentas completo
*/
async getChartOfAccounts(options?: {
activeOnly?: boolean;
postableOnly?: boolean;
accountType?: 'at_Revenues' | 'at_Expenses' | 'at_Other';
}): Promise<ChartOfAccounts[]> {
logger.info('SAP Financials: Obteniendo catalogo de cuentas', { options });
const filters: string[] = [];
if (options?.activeOnly) {
filters.push("ActiveAccount eq 'tYES'");
}
if (options?.postableOnly) {
filters.push("Postable eq 'tYES'");
}
if (options?.accountType) {
filters.push(`AccountType eq '${options.accountType}'`);
}
const queryOptions: ODataQueryOptions = {
$select: [
'Code',
'Name',
'ForeignName',
'Balance',
'CashAccount',
'ActiveAccount',
'AccountType',
'ExternalCode',
'AcctCurrency',
'BalanceSysCurr',
'Protected',
'ReconcileAccount',
'Postable',
'BlockManualPosting',
'ControlAccount',
'AccountLevel',
'FatherAccountKey',
'ValidFor',
'ValidFrom',
'ValidTo',
'AccountCategory',
],
$orderby: 'Code asc',
};
if (filters.length > 0) {
queryOptions.$filter = this.client.combineFilters(...filters);
}
return this.client.getAll<ChartOfAccounts>('/ChartOfAccounts', queryOptions);
}
/**
* Obtiene una cuenta por su codigo
*/
async getAccount(code: string): Promise<ChartOfAccounts> {
logger.info('SAP Financials: Obteniendo cuenta', { code });
return this.client.get<ChartOfAccounts>(`/ChartOfAccounts('${code}')`);
}
/**
* Obtiene las categorias de cuenta
*/
async getAccountCategories(): Promise<AccountCategory[]> {
logger.info('SAP Financials: Obteniendo categorias de cuenta');
return this.client.getAll<AccountCategory>('/AccountCategory', {
$orderby: 'CategoryCode asc',
});
}
/**
* Obtiene cuentas por categoria
*/
async getAccountsByCategory(categoryCode: number): Promise<ChartOfAccounts[]> {
logger.info('SAP Financials: Obteniendo cuentas por categoria', { categoryCode });
return this.client.getAll<ChartOfAccounts>('/ChartOfAccounts', {
$filter: `AccountCategory eq ${categoryCode}`,
$orderby: 'Code asc',
});
}
// ==========================================================================
// Journal Entries
// ==========================================================================
/**
* Obtiene asientos contables de un periodo
*/
async getJournalEntries(period: Period, options?: {
transactionCode?: string;
projectCode?: string;
}): Promise<JournalEntry[]> {
logger.info('SAP Financials: Obteniendo asientos contables', { period, options });
const filters: string[] = [];
// Filtro de fecha
const dateFilter = this.client.buildDateFilter('RefDate', period.startDate, period.endDate);
if (dateFilter) {
filters.push(dateFilter);
}
if (options?.transactionCode) {
filters.push(`TransactionCode eq '${options.transactionCode}'`);
}
if (options?.projectCode) {
filters.push(`ProjectCode eq '${options.projectCode}'`);
}
return this.client.getAll<JournalEntry>('/JournalEntries', {
$filter: this.client.combineFilters(...filters),
$expand: ['JournalEntryLines'],
$orderby: 'RefDate desc, JdtNum desc',
});
}
/**
* Obtiene un asiento contable por su numero
*/
async getJournalEntry(jdtNum: number): Promise<JournalEntry> {
logger.info('SAP Financials: Obteniendo asiento contable', { jdtNum });
return this.client.get<JournalEntry>(`/JournalEntries(${jdtNum})`, {
$expand: ['JournalEntryLines'],
});
}
/**
* Obtiene asientos contables por cuenta
*/
async getJournalEntriesByAccount(
accountCode: string,
period: Period
): Promise<JournalEntry[]> {
logger.info('SAP Financials: Obteniendo asientos por cuenta', { accountCode, period });
const dateFilter = this.client.buildDateFilter('RefDate', period.startDate, period.endDate);
// Usamos una consulta especial porque necesitamos filtrar por lineas
const entries = await this.client.getAll<JournalEntry>('/JournalEntries', {
$filter: dateFilter,
$expand: ['JournalEntryLines'],
$orderby: 'RefDate desc',
});
// Filtrar localmente por cuenta (SAP no soporta filtro directo en expansion)
return entries.filter((entry) =>
entry.JournalEntryLines?.some((line) => line.AccountCode === accountCode)
);
}
// ==========================================================================
// Trial Balance
// ==========================================================================
/**
* Obtiene la balanza de comprobacion a una fecha
* Nota: SAP B1 no tiene un endpoint directo para trial balance,
* se calcula a partir de los saldos de cuentas
*/
async getTrialBalance(
asOfDate: Date,
options?: TrialBalanceOptions
): Promise<TrialBalanceEntry[]> {
logger.info('SAP Financials: Calculando balanza de comprobacion', { asOfDate, options });
// Obtener todas las cuentas postables
const accounts = await this.getChartOfAccounts({
postableOnly: true,
activeOnly: true,
});
// Obtener movimientos del periodo (desde inicio de anio fiscal)
const fiscalYearStart = new Date(asOfDate.getFullYear(), 0, 1);
const journalEntries = await this.getJournalEntries({
startDate: fiscalYearStart,
endDate: asOfDate,
});
// Calcular saldos
const balances = new Map<string, TrialBalanceEntry>();
// Inicializar con saldos de cuentas
for (const account of accounts) {
if (options?.accountCodes && !options.accountCodes.includes(account.Code)) {
continue;
}
balances.set(account.Code, {
AccountCode: account.Code,
AccountName: account.Name,
DebitBalance: 0,
CreditBalance: 0,
DebitPeriod: 0,
CreditPeriod: 0,
OpeningBalance: account.Balance || 0,
ClosingBalance: 0,
});
}
// Acumular movimientos
for (const entry of journalEntries) {
for (const line of entry.JournalEntryLines || []) {
const accountCode = line.AccountCode;
if (!accountCode || !balances.has(accountCode)) {
continue;
}
const balance = balances.get(accountCode)!;
balance.DebitPeriod += line.Debit || 0;
balance.CreditPeriod += line.Credit || 0;
}
}
// Calcular saldos finales
const result: TrialBalanceEntry[] = [];
for (const balance of Array.from(balances.values())) {
balance.DebitBalance = Math.max(0, balance.OpeningBalance) + balance.DebitPeriod;
balance.CreditBalance = Math.abs(Math.min(0, balance.OpeningBalance)) + balance.CreditPeriod;
balance.ClosingBalance = balance.OpeningBalance + balance.DebitPeriod - balance.CreditPeriod;
// Filtrar saldos en cero si no se solicitan
if (!options?.includeZeroBalance && balance.ClosingBalance === 0 && balance.DebitPeriod === 0 && balance.CreditPeriod === 0) {
continue;
}
result.push(balance);
}
// Ordenar por codigo de cuenta
return result.sort((a, b) => a.AccountCode.localeCompare(b.AccountCode));
}
// ==========================================================================
// Profit & Loss Report
// ==========================================================================
/**
* Obtiene el estado de resultados
*/
async getProfitAndLossReport(
period: Period,
options?: ProfitAndLossOptions
): Promise<ProfitAndLossEntry[]> {
logger.info('SAP Financials: Generando estado de resultados', { period, options });
// Obtener cuentas de ingresos y gastos
const [revenueAccounts, expenseAccounts] = await Promise.all([
this.getChartOfAccounts({ accountType: 'at_Revenues', postableOnly: true }),
this.getChartOfAccounts({ accountType: 'at_Expenses', postableOnly: true }),
]);
const allAccounts = [...revenueAccounts, ...expenseAccounts];
// Obtener movimientos del periodo actual
const currentEntries = await this.getJournalEntries(period);
// Calcular saldos del periodo actual
const currentPeriodBalances = this.calculatePeriodBalances(currentEntries, allAccounts);
// Calcular YTD
const yearStart = new Date(period.startDate.getFullYear(), 0, 1);
const ytdEntries = await this.getJournalEntries({
startDate: yearStart,
endDate: period.endDate,
});
const ytdBalances = this.calculatePeriodBalances(ytdEntries, allAccounts);
// Opcionalmente obtener periodo anterior
let previousYearBalances: Map<string, number> | undefined;
if (options?.comparePreviousYear) {
const prevYearStart = new Date(period.startDate.getFullYear() - 1, period.startDate.getMonth(), period.startDate.getDate());
const prevYearEnd = new Date(period.endDate.getFullYear() - 1, period.endDate.getMonth(), period.endDate.getDate());
const prevEntries = await this.getJournalEntries({
startDate: prevYearStart,
endDate: prevYearEnd,
});
previousYearBalances = this.calculatePeriodBalances(prevEntries, allAccounts);
}
// Construir resultado
const result: ProfitAndLossEntry[] = [];
let totalRevenue = 0;
let totalExpenses = 0;
for (const account of allAccounts) {
const currentPeriod = currentPeriodBalances.get(account.Code) || 0;
const yearToDate = ytdBalances.get(account.Code) || 0;
const previousYear = previousYearBalances?.get(account.Code);
if (account.AccountType === 'at_Revenues') {
totalRevenue += yearToDate;
} else {
totalExpenses += yearToDate;
}
result.push({
AccountCode: account.Code,
AccountName: account.Name,
AccountType: account.AccountType || '',
CurrentPeriod: currentPeriod,
YearToDate: yearToDate,
PreviousYear: previousYear,
});
}
// Calcular porcentajes
const totalNet = totalRevenue - totalExpenses;
for (const entry of result) {
entry.PercentOfTotal = totalNet !== 0
? (entry.YearToDate / Math.abs(totalNet)) * 100
: 0;
}
return result.sort((a, b) => a.AccountCode.localeCompare(b.AccountCode));
}
/**
* Calcula los saldos por cuenta para un periodo
*/
private calculatePeriodBalances(
entries: JournalEntry[],
accounts: ChartOfAccounts[]
): Map<string, number> {
const balances = new Map<string, number>();
const accountSet = new Set(accounts.map((a) => a.Code));
for (const entry of entries) {
for (const line of entry.JournalEntryLines || []) {
if (!line.AccountCode || !accountSet.has(line.AccountCode)) {
continue;
}
const current = balances.get(line.AccountCode) || 0;
const movement = (line.Credit || 0) - (line.Debit || 0);
balances.set(line.AccountCode, current + movement);
}
}
return balances;
}
// ==========================================================================
// Balance Sheet
// ==========================================================================
/**
* Obtiene el balance general
*/
async getBalanceSheet(
asOfDate: Date,
options?: BalanceSheetOptions
): Promise<BalanceSheetEntry[]> {
logger.info('SAP Financials: Generando balance general', { asOfDate, options });
// Obtener todas las cuentas de balance (no ingresos/gastos)
const accounts = await this.client.getAll<ChartOfAccounts>('/ChartOfAccounts', {
$filter: "AccountType eq 'at_Other' and Postable eq 'tYES' and ActiveAccount eq 'tYES'",
$orderby: 'Code asc',
});
// Obtener saldos actuales
const trialBalance = await this.getTrialBalance(asOfDate);
const currentBalances = new Map(
trialBalance.map((t) => [t.AccountCode, t.ClosingBalance])
);
// Obtener saldos del periodo anterior si se solicita
let previousPeriodBalances: Map<string, number> | undefined;
if (options?.comparePreviousPeriod) {
const prevMonth = new Date(asOfDate);
prevMonth.setMonth(prevMonth.getMonth() - 1);
const prevTrialBalance = await this.getTrialBalance(prevMonth);
previousPeriodBalances = new Map(
prevTrialBalance.map((t) => [t.AccountCode, t.ClosingBalance])
);
}
// Obtener saldos del ano anterior si se solicita
let previousYearBalances: Map<string, number> | undefined;
if (options?.comparePreviousYear) {
const prevYear = new Date(asOfDate);
prevYear.setFullYear(prevYear.getFullYear() - 1);
const prevYearTrialBalance = await this.getTrialBalance(prevYear);
previousYearBalances = new Map(
prevYearTrialBalance.map((t) => [t.AccountCode, t.ClosingBalance])
);
}
// Construir resultado
const result: BalanceSheetEntry[] = [];
for (const account of accounts) {
const currentBalance = currentBalances.get(account.Code) || 0;
result.push({
AccountCode: account.Code,
AccountName: account.Name,
AccountCategory: String(account.AccountCategory || ''),
CurrentBalance: currentBalance,
PreviousPeriodBalance: previousPeriodBalances?.get(account.Code),
PreviousYearBalance: previousYearBalances?.get(account.Code),
});
}
return result;
}
// ==========================================================================
// Aging Reports
// ==========================================================================
/**
* Obtiene el reporte de antiguedad de saldos
*/
async getAgingReport(
type: AgingType,
options?: AgingOptions
): Promise<AgingReportEntry[]> {
logger.info('SAP Financials: Generando reporte de antiguedad', { type, options });
const asOfDate = options?.asOfDate || new Date();
const agingPeriods = options?.agingPeriods || [30, 60, 90, 120];
// Obtener documentos abiertos segun el tipo
const endpoint = type === 'AR' ? '/Invoices' : '/PurchaseInvoices';
const filters: string[] = [
"DocumentStatus eq 'bost_Open'",
"Cancelled eq 'tNO'",
];
if (options?.cardCodes?.length) {
const cardCodesFilter = options.cardCodes.map((c) => `CardCode eq '${c}'`).join(' or ');
filters.push(`(${cardCodesFilter})`);
}
interface DocumentWithBalance {
DocEntry: number;
DocNum: number;
CardCode: string;
CardName: string;
DocDate: string;
DocDueDate: string;
DocTotal: number;
PaidToDate: number;
}
const documents = await this.client.getAll<DocumentWithBalance>(endpoint, {
$filter: this.client.combineFilters(...filters),
$select: ['DocEntry', 'DocNum', 'CardCode', 'CardName', 'DocDate', 'DocDueDate', 'DocTotal', 'PaidToDate'],
$orderby: 'DocDueDate asc',
});
// Calcular antiguedad
const result: AgingReportEntry[] = [];
for (const doc of documents) {
const balance = doc.DocTotal - (doc.PaidToDate || 0);
if (balance <= 0) {
continue;
}
const dueDate = new Date(doc.DocDueDate);
const daysPastDue = Math.floor(
(asOfDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24)
);
const entry: AgingReportEntry = {
CardCode: doc.CardCode,
CardName: doc.CardName || '',
DocEntry: doc.DocEntry,
DocNum: doc.DocNum,
DocDate: doc.DocDate,
DueDate: doc.DocDueDate,
DocTotal: doc.DocTotal,
Balance: balance,
Current: 0,
Days1_30: 0,
Days31_60: 0,
Days61_90: 0,
Days91_120: 0,
Over120: 0,
};
// Clasificar por antiguedad
if (daysPastDue <= 0) {
entry.Current = balance;
} else if (daysPastDue <= 30) {
entry.Days1_30 = balance;
} else if (daysPastDue <= 60) {
entry.Days31_60 = balance;
} else if (daysPastDue <= 90) {
entry.Days61_90 = balance;
} else if (daysPastDue <= 120) {
entry.Days91_120 = balance;
} else {
entry.Over120 = balance;
}
result.push(entry);
}
return result;
}
/**
* Obtiene el resumen de antiguedad por cliente/proveedor
*/
async getAgingSummary(type: AgingType): Promise<Map<string, AgingReportEntry>> {
const agingReport = await this.getAgingReport(type);
const summary = new Map<string, AgingReportEntry>();
for (const entry of agingReport) {
if (summary.has(entry.CardCode)) {
const existing = summary.get(entry.CardCode)!;
existing.Balance += entry.Balance;
existing.Current += entry.Current;
existing.Days1_30 += entry.Days1_30;
existing.Days31_60 += entry.Days31_60;
existing.Days61_90 += entry.Days61_90;
existing.Days91_120 += entry.Days91_120;
existing.Over120 += entry.Over120;
} else {
summary.set(entry.CardCode, { ...entry });
}
}
return summary;
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea una instancia del conector de finanzas
*/
export function createFinancialsConnector(client: SAPClient): FinancialsConnector {
return new FinancialsConnector(client);
}
export default FinancialsConnector;

View File

@@ -0,0 +1,179 @@
/**
* SAP Business One Integration for Horux Strategy
* Modulo completo para integracion con SAP B1 Service Layer
*/
// =============================================================================
// Types
// =============================================================================
export * from './sap.types.js';
// =============================================================================
// Client
// =============================================================================
export {
SAPClient,
createSAPClient,
createConnectedSAPClient,
} from './sap.client.js';
// =============================================================================
// Connectors
// =============================================================================
// Financials
export {
FinancialsConnector,
createFinancialsConnector,
} from './financials.connector.js';
export type {
AgingType,
TrialBalanceOptions,
ProfitAndLossOptions,
BalanceSheetOptions,
AgingOptions,
} from './financials.connector.js';
// Sales
export {
SalesConnector,
createSalesConnector,
} from './sales.connector.js';
export type {
DocumentStatus,
DocumentFilterOptions,
CustomerBalanceSummary,
} from './sales.connector.js';
// Purchasing
export {
PurchasingConnector,
createPurchasingConnector,
} from './purchasing.connector.js';
export type {
PurchaseDocumentStatus,
PurchaseDocumentFilterOptions,
VendorBalanceSummary,
} from './purchasing.connector.js';
// Inventory
export {
InventoryConnector,
createInventoryConnector,
} from './inventory.connector.js';
export type {
ItemFilterOptions,
ItemStockInfo,
InventoryMovement,
InventoryGroupSummary,
} from './inventory.connector.js';
// Banking
export {
BankingConnector,
createBankingConnector,
} from './banking.connector.js';
export type {
PaymentStatus,
PaymentType,
PaymentFilterOptions,
BankAccountSummary,
CashFlowSummary,
} from './banking.connector.js';
// =============================================================================
// Sync Service
// =============================================================================
export {
SAPSyncService,
createSAPSyncService,
syncSAPToHorux,
} from './sap.sync.js';
export type {
SyncOptions,
SyncEntityType,
SyncProgress,
MappedTransaction,
SAPAlert,
} from './sap.sync.js';
// =============================================================================
// Utility Functions
// =============================================================================
import { SAPConfig } from './sap.types.js';
import { SAPClient, createConnectedSAPClient } from './sap.client.js';
import { FinancialsConnector } from './financials.connector.js';
import { SalesConnector } from './sales.connector.js';
import { PurchasingConnector } from './purchasing.connector.js';
import { InventoryConnector } from './inventory.connector.js';
import { BankingConnector } from './banking.connector.js';
/**
* Conjunto completo de conectores SAP
*/
export interface SAPConnectors {
client: SAPClient;
financials: FinancialsConnector;
sales: SalesConnector;
purchasing: PurchasingConnector;
inventory: InventoryConnector;
banking: BankingConnector;
}
/**
* Crea todos los conectores SAP con una sola conexion
* @param config Configuracion de conexion SAP
* @returns Conjunto de conectores inicializados
*/
export async function createSAPConnectors(config: SAPConfig): Promise<SAPConnectors> {
const client = await createConnectedSAPClient(config);
return {
client,
financials: new FinancialsConnector(client),
sales: new SalesConnector(client),
purchasing: new PurchasingConnector(client),
inventory: new InventoryConnector(client),
banking: new BankingConnector(client),
};
}
/**
* Verifica la conexion a SAP Service Layer
* @param config Configuracion de conexion SAP
* @returns true si la conexion es exitosa
*/
export async function testSAPConnection(config: SAPConfig): Promise<{
success: boolean;
message: string;
sessionId?: string;
version?: string;
}> {
try {
const client = await createConnectedSAPClient(config);
const session = client.getSession();
await client.logout();
return {
success: true,
message: 'Conexion exitosa a SAP Business One Service Layer',
sessionId: session?.sessionId,
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Error de conexion desconocido',
};
}
}

View File

@@ -0,0 +1,659 @@
/**
* SAP Business One Inventory Connector
* Conector para articulos, almacenes y movimientos de inventario
*/
import { SAPClient } from './sap.client.js';
import {
Item,
ItemWarehouseInfo,
Warehouse,
StockTransfer,
Period,
InventoryValuationEntry,
ODataQueryOptions,
} from './sap.types.js';
import { logger } from '../../../utils/logger.js';
// ============================================================================
// Types
// ============================================================================
/**
* Opciones de filtro para articulos
*/
export interface ItemFilterOptions {
itemGroup?: number;
warehouseCode?: string;
activeOnly?: boolean;
inventoryOnly?: boolean;
salesOnly?: boolean;
purchaseOnly?: boolean;
withStock?: boolean;
}
/**
* Informacion de stock de un articulo
*/
export interface ItemStockInfo {
itemCode: string;
itemName: string;
totalInStock: number;
totalCommitted: number;
totalOrdered: number;
totalAvailable: number;
defaultWarehouse?: string;
warehouseStock: Array<{
warehouseCode: string;
warehouseName?: string;
inStock: number;
committed: number;
ordered: number;
available: number;
minStock?: number;
maxStock?: number;
}>;
}
/**
* Movimiento de inventario
*/
export interface InventoryMovement {
docEntry: number;
docNum: number;
docDate: string;
itemCode: string;
itemName?: string;
warehouseCode: string;
quantity: number;
direction: 'in' | 'out';
movementType: string;
reference?: string;
}
/**
* Resumen de inventario por grupo
*/
export interface InventoryGroupSummary {
groupCode: number;
groupName: string;
itemCount: number;
totalValue: number;
totalStock: number;
}
// ============================================================================
// Inventory Connector Class
// ============================================================================
/**
* Conector para datos de inventario de SAP B1
*/
export class InventoryConnector {
constructor(private readonly client: SAPClient) {}
// ==========================================================================
// Items (Articulos)
// ==========================================================================
/**
* Obtiene todos los articulos
*/
async getItems(options?: ItemFilterOptions): Promise<Item[]> {
logger.info('SAP Inventory: Obteniendo articulos', { options });
const filters: string[] = [];
if (options?.activeOnly) {
filters.push("Frozen eq 'tNO'");
filters.push("Valid eq 'tYES'");
}
if (options?.inventoryOnly) {
filters.push("InventoryItem eq 'tYES'");
}
if (options?.salesOnly) {
filters.push("SalesItem eq 'tYES'");
}
if (options?.purchaseOnly) {
filters.push("PurchaseItem eq 'tYES'");
}
if (options?.itemGroup !== undefined) {
filters.push(`ItemsGroupCode eq ${options.itemGroup}`);
}
if (options?.withStock) {
filters.push('QuantityOnStock gt 0');
}
const queryOptions: ODataQueryOptions = {
$select: [
'ItemCode',
'ItemName',
'ForeignName',
'ItemsGroupCode',
'SalesVATGroup',
'BarCode',
'VatLiable',
'PurchaseItem',
'SalesItem',
'InventoryItem',
'QuantityOnStock',
'QuantityOrderedFromVendors',
'QuantityOrderedByCustomers',
'ManageSerialNumbers',
'ManageBatchNumbers',
'Valid',
'Frozen',
'SalesUnit',
'PurchaseUnit',
'InventoryUOM',
'DefaultWarehouse',
'AvgStdPrice',
],
$orderby: 'ItemCode asc',
};
if (filters.length > 0) {
queryOptions.$filter = this.client.combineFilters(...filters);
}
return this.client.getAll<Item>('/Items', queryOptions);
}
/**
* Obtiene un articulo por su codigo
*/
async getItem(itemCode: string): Promise<Item> {
logger.info('SAP Inventory: Obteniendo articulo', { itemCode });
return this.client.get<Item>(`/Items('${itemCode}')`, {
$expand: ['ItemPrices', 'ItemWarehouseInfoCollection'],
});
}
/**
* Busca articulos por nombre o codigo
*/
async searchItems(query: string, limit = 20): Promise<Item[]> {
logger.info('SAP Inventory: Buscando articulos', { query, limit });
const searchFilter = `contains(ItemName,'${query}') or contains(ItemCode,'${query}') or contains(BarCode,'${query}')`;
return this.client.getAll<Item>('/Items', {
$filter: `(${searchFilter}) and Valid eq 'tYES'`,
$select: ['ItemCode', 'ItemName', 'BarCode', 'QuantityOnStock', 'DefaultWarehouse', 'AvgStdPrice'],
$top: limit,
$orderby: 'ItemName asc',
});
}
/**
* Obtiene articulos por grupo
*/
async getItemsByGroup(groupCode: number): Promise<Item[]> {
return this.getItems({ itemGroup: groupCode, activeOnly: true });
}
/**
* Obtiene articulos con stock bajo
*/
async getLowStockItems(threshold?: number): Promise<Item[]> {
logger.info('SAP Inventory: Obteniendo articulos con stock bajo', { threshold });
// Obtener todos los articulos con stock info
const items = await this.client.getAll<Item>('/Items', {
$filter: "InventoryItem eq 'tYES' and Valid eq 'tYES'",
$expand: ['ItemWarehouseInfoCollection'],
$select: [
'ItemCode',
'ItemName',
'QuantityOnStock',
'DefaultWarehouse',
],
});
// Filtrar localmente por stock minimo
return items.filter((item) => {
if (!item.ItemWarehouseInfoCollection) return false;
return item.ItemWarehouseInfoCollection.some((wh) => {
const minStock = wh.MinimalStock || threshold || 0;
const available = (wh.InStock || 0) - (wh.Committed || 0);
return available <= minStock;
});
});
}
// ==========================================================================
// Item Stock
// ==========================================================================
/**
* Obtiene el stock detallado de un articulo
*/
async getItemStock(itemCode: string): Promise<ItemStockInfo> {
logger.info('SAP Inventory: Obteniendo stock de articulo', { itemCode });
const item = await this.getItem(itemCode);
const warehouses = await this.getWarehouses();
const warehouseMap = new Map(warehouses.map((w) => [w.WarehouseCode, w.WarehouseName]));
const warehouseStock = (item.ItemWarehouseInfoCollection || []).map((wh) => ({
warehouseCode: wh.WarehouseCode || '',
warehouseName: warehouseMap.get(wh.WarehouseCode || ''),
inStock: wh.InStock || 0,
committed: wh.Committed || 0,
ordered: wh.Ordered || 0,
available: (wh.InStock || 0) - (wh.Committed || 0),
minStock: wh.MinimalStock,
maxStock: wh.MaximalStock,
}));
const totalInStock = warehouseStock.reduce((sum, wh) => sum + wh.inStock, 0);
const totalCommitted = warehouseStock.reduce((sum, wh) => sum + wh.committed, 0);
const totalOrdered = warehouseStock.reduce((sum, wh) => sum + wh.ordered, 0);
return {
itemCode: item.ItemCode,
itemName: item.ItemName,
totalInStock,
totalCommitted,
totalOrdered,
totalAvailable: totalInStock - totalCommitted,
defaultWarehouse: item.DefaultWarehouse,
warehouseStock,
};
}
/**
* Obtiene el stock de multiples articulos
*/
async getItemsStock(itemCodes: string[]): Promise<ItemStockInfo[]> {
logger.info('SAP Inventory: Obteniendo stock de articulos', { count: itemCodes.length });
const results: ItemStockInfo[] = [];
// Procesar en lotes para evitar timeouts
const batchSize = 50;
for (let i = 0; i < itemCodes.length; i += batchSize) {
const batch = itemCodes.slice(i, i + batchSize);
const stockPromises = batch.map((code) => this.getItemStock(code));
const batchResults = await Promise.all(stockPromises);
results.push(...batchResults);
}
return results;
}
/**
* Obtiene stock por almacen
*/
async getStockByWarehouse(warehouseCode: string): Promise<
Array<{
itemCode: string;
itemName: string;
inStock: number;
committed: number;
available: number;
value: number;
}>
> {
logger.info('SAP Inventory: Obteniendo stock por almacen', { warehouseCode });
const items = await this.client.getAll<Item>('/Items', {
$filter: "InventoryItem eq 'tYES' and Valid eq 'tYES'",
$expand: ['ItemWarehouseInfoCollection'],
$select: ['ItemCode', 'ItemName', 'AvgStdPrice'],
});
const result: Array<{
itemCode: string;
itemName: string;
inStock: number;
committed: number;
available: number;
value: number;
}> = [];
for (const item of items) {
const whInfo = item.ItemWarehouseInfoCollection?.find(
(wh) => wh.WarehouseCode === warehouseCode
);
if (whInfo && (whInfo.InStock || 0) > 0) {
const inStock = whInfo.InStock || 0;
const committed = whInfo.Committed || 0;
result.push({
itemCode: item.ItemCode,
itemName: item.ItemName,
inStock,
committed,
available: inStock - committed,
value: inStock * (item.AvgStdPrice || 0),
});
}
}
return result.sort((a, b) => b.value - a.value);
}
// ==========================================================================
// Warehouses (Almacenes)
// ==========================================================================
/**
* Obtiene todos los almacenes
*/
async getWarehouses(activeOnly = true): Promise<Warehouse[]> {
logger.info('SAP Inventory: Obteniendo almacenes', { activeOnly });
const filters: string[] = [];
if (activeOnly) {
filters.push("Inactive eq 'tNO'");
}
return this.client.getAll<Warehouse>('/Warehouses', {
$filter: filters.length > 0 ? this.client.combineFilters(...filters) : undefined,
$select: [
'WarehouseCode',
'WarehouseName',
'Location',
'Nettable',
'DropShip',
'Inactive',
'Street',
'City',
'Country',
'State',
'BranchCode',
'EnableBinLocations',
],
$orderby: 'WarehouseCode asc',
});
}
/**
* Obtiene un almacen por su codigo
*/
async getWarehouse(warehouseCode: string): Promise<Warehouse> {
logger.info('SAP Inventory: Obteniendo almacen', { warehouseCode });
return this.client.get<Warehouse>(`/Warehouses('${warehouseCode}')`);
}
// ==========================================================================
// Stock Transfers (Transferencias de Stock)
// ==========================================================================
/**
* Obtiene transferencias de stock de un periodo
*/
async getStockTransfers(
period: Period,
options?: {
fromWarehouse?: string;
toWarehouse?: string;
itemCode?: string;
}
): Promise<StockTransfer[]> {
logger.info('SAP Inventory: Obteniendo transferencias de stock', { period, options });
const filters: string[] = [];
// Filtro de fecha
const dateFilter = this.client.buildDateFilter('DocDate', period.startDate, period.endDate);
if (dateFilter) {
filters.push(dateFilter);
}
if (options?.fromWarehouse) {
filters.push(`FromWarehouse eq '${options.fromWarehouse}'`);
}
if (options?.toWarehouse) {
filters.push(`ToWarehouse eq '${options.toWarehouse}'`);
}
const transfers = await this.client.getAll<StockTransfer>('/StockTransfers', {
$filter: filters.length > 0 ? this.client.combineFilters(...filters) : undefined,
$expand: ['StockTransferLines'],
$orderby: 'DocDate desc',
});
// Filtrar por articulo si se especifica
if (options?.itemCode) {
return transfers.filter((transfer) =>
transfer.StockTransferLines?.some((line) => line.ItemCode === options.itemCode)
);
}
return transfers;
}
/**
* Obtiene una transferencia de stock por DocEntry
*/
async getStockTransfer(docEntry: number): Promise<StockTransfer> {
logger.info('SAP Inventory: Obteniendo transferencia de stock', { docEntry });
return this.client.get<StockTransfer>(`/StockTransfers(${docEntry})`, {
$expand: ['StockTransferLines'],
});
}
/**
* Obtiene historial de movimientos de un articulo
*/
async getItemMovements(
itemCode: string,
period: Period
): Promise<InventoryMovement[]> {
logger.info('SAP Inventory: Obteniendo movimientos de articulo', { itemCode, period });
const movements: InventoryMovement[] = [];
// Obtener transferencias
const transfers = await this.getStockTransfers(period, { itemCode });
for (const transfer of transfers) {
for (const line of transfer.StockTransferLines || []) {
if (line.ItemCode !== itemCode) continue;
// Movimiento de salida
movements.push({
docEntry: transfer.DocEntry,
docNum: transfer.DocNum,
docDate: transfer.DocDate,
itemCode: line.ItemCode,
itemName: line.ItemDescription,
warehouseCode: line.FromWarehouseCode || transfer.FromWarehouse,
quantity: line.Quantity || 0,
direction: 'out',
movementType: 'Transfer',
reference: `To: ${transfer.ToWarehouse}`,
});
// Movimiento de entrada
movements.push({
docEntry: transfer.DocEntry,
docNum: transfer.DocNum,
docDate: transfer.DocDate,
itemCode: line.ItemCode,
itemName: line.ItemDescription,
warehouseCode: line.WarehouseCode || transfer.ToWarehouse,
quantity: line.Quantity || 0,
direction: 'in',
movementType: 'Transfer',
reference: `From: ${transfer.FromWarehouse}`,
});
}
}
// Ordenar por fecha descendente
return movements.sort(
(a, b) => new Date(b.docDate).getTime() - new Date(a.docDate).getTime()
);
}
// ==========================================================================
// Inventory Valuation
// ==========================================================================
/**
* Obtiene la valuacion del inventario
*/
async getInventoryValuation(options?: {
warehouseCode?: string;
itemGroup?: number;
asOfDate?: Date;
}): Promise<InventoryValuationEntry[]> {
logger.info('SAP Inventory: Obteniendo valuacion de inventario', { options });
const filters: string[] = [
"InventoryItem eq 'tYES'",
"Valid eq 'tYES'",
'QuantityOnStock gt 0',
];
if (options?.itemGroup !== undefined) {
filters.push(`ItemsGroupCode eq ${options.itemGroup}`);
}
const items = await this.client.getAll<Item>('/Items', {
$filter: this.client.combineFilters(...filters),
$expand: ['ItemWarehouseInfoCollection'],
$select: [
'ItemCode',
'ItemName',
'QuantityOnStock',
'QuantityOrderedByCustomers',
'AvgStdPrice',
],
});
const warehouses = await this.getWarehouses();
const warehouseMap = new Map(warehouses.map((w) => [w.WarehouseCode, w.WarehouseName]));
const result: InventoryValuationEntry[] = [];
for (const item of items) {
const warehouseInfos = options?.warehouseCode
? item.ItemWarehouseInfoCollection?.filter(
(wh) => wh.WarehouseCode === options.warehouseCode
)
: item.ItemWarehouseInfoCollection;
for (const whInfo of warehouseInfos || []) {
const inStock = whInfo.InStock || 0;
if (inStock <= 0) continue;
const unitPrice = whInfo.StandardAveragePrice || item.AvgStdPrice || 0;
const committed = whInfo.Committed || 0;
result.push({
ItemCode: item.ItemCode,
ItemName: item.ItemName,
WarehouseCode: whInfo.WarehouseCode || '',
WarehouseName: warehouseMap.get(whInfo.WarehouseCode || ''),
InStock: inStock,
Committed: committed,
Available: inStock - committed,
UnitPrice: unitPrice,
TotalValue: inStock * unitPrice,
});
}
}
// Ordenar por valor total descendente
return result.sort((a, b) => b.TotalValue - a.TotalValue);
}
/**
* Obtiene resumen de valuacion por grupo de articulos
*/
async getInventoryValuationByGroup(): Promise<InventoryGroupSummary[]> {
logger.info('SAP Inventory: Obteniendo valuacion por grupo');
const valuation = await this.getInventoryValuation();
// Obtener grupos de articulos
interface ItemGroup {
Number: number;
GroupName: string;
}
const groups = await this.client.getAll<ItemGroup>('/ItemGroups', {
$select: ['Number', 'GroupName'],
});
const groupMap = new Map(groups.map((g) => [g.Number, g.GroupName]));
// Agrupar por grupo de articulo
const items = await this.client.getAll<Item>('/Items', {
$filter: "InventoryItem eq 'tYES' and QuantityOnStock gt 0",
$select: ['ItemCode', 'ItemsGroupCode'],
});
const itemGroupMap = new Map(items.map((i) => [i.ItemCode, i.ItemsGroupCode]));
const summary = new Map<number, InventoryGroupSummary>();
for (const entry of valuation) {
const groupCode = itemGroupMap.get(entry.ItemCode) || 0;
if (!summary.has(groupCode)) {
summary.set(groupCode, {
groupCode,
groupName: groupMap.get(groupCode) || 'Sin Grupo',
itemCount: 0,
totalValue: 0,
totalStock: 0,
});
}
const group = summary.get(groupCode)!;
group.itemCount += 1;
group.totalValue += entry.TotalValue;
group.totalStock += entry.InStock;
}
return Array.from(summary.values()).sort((a, b) => b.totalValue - a.totalValue);
}
/**
* Obtiene el valor total del inventario
*/
async getTotalInventoryValue(warehouseCode?: string): Promise<{
totalValue: number;
totalItems: number;
totalStock: number;
currency: string;
}> {
logger.info('SAP Inventory: Calculando valor total de inventario', { warehouseCode });
const valuation = await this.getInventoryValuation({ warehouseCode });
return {
totalValue: valuation.reduce((sum, v) => sum + v.TotalValue, 0),
totalItems: new Set(valuation.map((v) => v.ItemCode)).size,
totalStock: valuation.reduce((sum, v) => sum + v.InStock, 0),
currency: 'MXN', // Moneda base de la empresa
};
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea una instancia del conector de inventario
*/
export function createInventoryConnector(client: SAPClient): InventoryConnector {
return new InventoryConnector(client);
}
export default InventoryConnector;

View File

@@ -0,0 +1,618 @@
/**
* SAP Business One Purchasing Connector
* Conector para documentos de compra y proveedores
*/
import { SAPClient } from './sap.client.js';
import {
PurchaseInvoice,
PurchaseOrder,
GoodsReceiptPO,
BusinessPartner,
Period,
ODataQueryOptions,
} from './sap.types.js';
import { logger } from '../../../utils/logger.js';
// ============================================================================
// Types
// ============================================================================
/**
* Estado de documento de compra
*/
export type PurchaseDocumentStatus = 'open' | 'closed' | 'cancelled' | 'all';
/**
* Opciones de filtro para documentos de compra
*/
export interface PurchaseDocumentFilterOptions {
cardCode?: string;
status?: PurchaseDocumentStatus;
series?: number;
includeLines?: boolean;
warehouseCode?: string;
}
/**
* Resumen de balance de proveedor
*/
export interface VendorBalanceSummary {
cardCode: string;
cardName: string;
totalBalance: number;
openInvoicesBalance: number;
openOrdersBalance: number;
openGoodsReceiptBalance: number;
lastInvoiceDate?: string;
lastPaymentDate?: string;
currency: string;
}
// ============================================================================
// Purchasing Connector Class
// ============================================================================
/**
* Conector para datos de compras de SAP B1
*/
export class PurchasingConnector {
constructor(private readonly client: SAPClient) {}
// ==========================================================================
// Purchase Invoices (Facturas de Compra)
// ==========================================================================
/**
* Obtiene facturas de compra de un periodo
*/
async getPurchaseInvoices(
period: Period,
options?: PurchaseDocumentFilterOptions
): Promise<PurchaseInvoice[]> {
logger.info('SAP Purchasing: Obteniendo facturas de compra', { period, options });
const queryOptions = this.buildDocumentQuery(period, options);
return this.client.getAll<PurchaseInvoice>('/PurchaseInvoices', queryOptions);
}
/**
* Obtiene una factura de compra por DocEntry
*/
async getPurchaseInvoice(docEntry: number): Promise<PurchaseInvoice> {
logger.info('SAP Purchasing: Obteniendo factura de compra', { docEntry });
return this.client.get<PurchaseInvoice>(`/PurchaseInvoices(${docEntry})`, {
$expand: ['DocumentLines', 'WithholdingTaxDataCollection'],
});
}
/**
* Obtiene facturas de compra por proveedor
*/
async getPurchaseInvoicesByVendor(
cardCode: string,
period?: Period,
status?: PurchaseDocumentStatus
): Promise<PurchaseInvoice[]> {
return this.getPurchaseInvoices(
period || this.getDefaultPeriod(),
{ cardCode, status, includeLines: true }
);
}
/**
* Obtiene facturas de compra pendientes de pago
*/
async getOpenPurchaseInvoices(cardCode?: string): Promise<PurchaseInvoice[]> {
logger.info('SAP Purchasing: Obteniendo facturas de compra abiertas', { cardCode });
const filters: string[] = [
"DocumentStatus eq 'bost_Open'",
"Cancelled eq 'tNO'",
];
if (cardCode) {
filters.push(`CardCode eq '${cardCode}'`);
}
return this.client.getAll<PurchaseInvoice>('/PurchaseInvoices', {
$filter: this.client.combineFilters(...filters),
$select: this.getDocumentSelectFields(),
$expand: ['DocumentLines'],
$orderby: 'DocDueDate asc',
});
}
/**
* Obtiene facturas de compra por vencer
*/
async getUpcomingPurchaseInvoices(daysAhead = 30): Promise<PurchaseInvoice[]> {
logger.info('SAP Purchasing: Obteniendo facturas por vencer', { daysAhead });
const today = new Date();
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + daysAhead);
const dateFilter = this.client.buildDateFilter('DocDueDate', today, futureDate);
return this.client.getAll<PurchaseInvoice>('/PurchaseInvoices', {
$filter: this.client.combineFilters(
"DocumentStatus eq 'bost_Open'",
"Cancelled eq 'tNO'",
dateFilter
),
$select: this.getDocumentSelectFields(),
$orderby: 'DocDueDate asc',
});
}
// ==========================================================================
// Purchase Orders (Ordenes de Compra)
// ==========================================================================
/**
* Obtiene ordenes de compra de un periodo
*/
async getPurchaseOrders(
period: Period,
options?: PurchaseDocumentFilterOptions
): Promise<PurchaseOrder[]> {
logger.info('SAP Purchasing: Obteniendo ordenes de compra', { period, options });
const queryOptions = this.buildDocumentQuery(period, options);
return this.client.getAll<PurchaseOrder>('/PurchaseOrders', queryOptions);
}
/**
* Obtiene una orden de compra por DocEntry
*/
async getPurchaseOrder(docEntry: number): Promise<PurchaseOrder> {
logger.info('SAP Purchasing: Obteniendo orden de compra', { docEntry });
return this.client.get<PurchaseOrder>(`/PurchaseOrders(${docEntry})`, {
$expand: ['DocumentLines'],
});
}
/**
* Obtiene ordenes de compra abiertas
*/
async getOpenPurchaseOrders(cardCode?: string): Promise<PurchaseOrder[]> {
logger.info('SAP Purchasing: Obteniendo ordenes de compra abiertas', { cardCode });
const filters: string[] = [
"DocumentStatus eq 'bost_Open'",
"Cancelled eq 'tNO'",
];
if (cardCode) {
filters.push(`CardCode eq '${cardCode}'`);
}
return this.client.getAll<PurchaseOrder>('/PurchaseOrders', {
$filter: this.client.combineFilters(...filters),
$select: this.getDocumentSelectFields(),
$expand: ['DocumentLines'],
$orderby: 'DocDueDate asc',
});
}
/**
* Obtiene ordenes de compra por proveedor
*/
async getPurchaseOrdersByVendor(
cardCode: string,
period?: Period
): Promise<PurchaseOrder[]> {
return this.getPurchaseOrders(
period || this.getDefaultPeriod(),
{ cardCode, includeLines: true }
);
}
// ==========================================================================
// Goods Receipt PO (Entrada de Mercancias)
// ==========================================================================
/**
* Obtiene entradas de mercancias de un periodo
*/
async getGoodsReceiptPO(
period: Period,
options?: PurchaseDocumentFilterOptions
): Promise<GoodsReceiptPO[]> {
logger.info('SAP Purchasing: Obteniendo entradas de mercancias', { period, options });
const queryOptions = this.buildDocumentQuery(period, options);
return this.client.getAll<GoodsReceiptPO>('/PurchaseDeliveryNotes', queryOptions);
}
/**
* Obtiene una entrada de mercancias por DocEntry
*/
async getGoodsReceiptPOEntry(docEntry: number): Promise<GoodsReceiptPO> {
logger.info('SAP Purchasing: Obteniendo entrada de mercancias', { docEntry });
return this.client.get<GoodsReceiptPO>(`/PurchaseDeliveryNotes(${docEntry})`, {
$expand: ['DocumentLines'],
});
}
/**
* Obtiene entradas de mercancias pendientes de facturar
*/
async getOpenGoodsReceiptPO(cardCode?: string): Promise<GoodsReceiptPO[]> {
logger.info('SAP Purchasing: Obteniendo entradas pendientes de facturar', { cardCode });
const filters: string[] = [
"DocumentStatus eq 'bost_Open'",
"Cancelled eq 'tNO'",
];
if (cardCode) {
filters.push(`CardCode eq '${cardCode}'`);
}
return this.client.getAll<GoodsReceiptPO>('/PurchaseDeliveryNotes', {
$filter: this.client.combineFilters(...filters),
$select: this.getDocumentSelectFields(),
$orderby: 'DocDate desc',
});
}
/**
* Obtiene entradas de mercancias por almacen
*/
async getGoodsReceiptPOByWarehouse(
warehouseCode: string,
period: Period
): Promise<GoodsReceiptPO[]> {
logger.info('SAP Purchasing: Obteniendo entradas por almacen', { warehouseCode, period });
// SAP no permite filtrar directamente por lineas, obtenemos y filtramos
const receipts = await this.getGoodsReceiptPO(period, { includeLines: true });
return receipts.filter((receipt) =>
receipt.DocumentLines?.some((line) => line.WarehouseCode === warehouseCode)
);
}
// ==========================================================================
// Vendors (Proveedores)
// ==========================================================================
/**
* Obtiene todos los proveedores
*/
async getVendors(options?: {
activeOnly?: boolean;
groupCode?: number;
withBalance?: boolean;
}): Promise<BusinessPartner[]> {
logger.info('SAP Purchasing: Obteniendo proveedores', { options });
const filters: string[] = ["CardType eq 'cSupplier'"];
if (options?.activeOnly) {
filters.push("Frozen eq 'tNO'");
filters.push("Valid eq 'tYES'");
}
if (options?.groupCode !== undefined) {
filters.push(`GroupCode eq ${options.groupCode}`);
}
const selectFields = [
'CardCode',
'CardName',
'CardType',
'CardForeignName',
'FederalTaxID',
'Address',
'ZipCode',
'City',
'Country',
'State',
'EmailAddress',
'Phone1',
'Phone2',
'Cellular',
'ContactPerson',
'PayTermsGrpCode',
'Currency',
'GroupCode',
'Frozen',
'Valid',
'CreateDate',
'UpdateDate',
];
if (options?.withBalance) {
selectFields.push('Balance');
}
return this.client.getAll<BusinessPartner>('/BusinessPartners', {
$filter: this.client.combineFilters(...filters),
$select: selectFields,
$orderby: 'CardName asc',
});
}
/**
* Obtiene un proveedor por CardCode
*/
async getVendor(cardCode: string): Promise<BusinessPartner> {
logger.info('SAP Purchasing: Obteniendo proveedor', { cardCode });
return this.client.get<BusinessPartner>(`/BusinessPartners('${cardCode}')`, {
$expand: ['BPAddresses', 'ContactEmployees', 'BPBankAccounts'],
});
}
/**
* Busca proveedores por nombre o RFC
*/
async searchVendors(query: string, limit = 20): Promise<BusinessPartner[]> {
logger.info('SAP Purchasing: Buscando proveedores', { query, limit });
const searchFilter = `contains(CardName,'${query}') or contains(CardCode,'${query}') or contains(FederalTaxID,'${query}')`;
return this.client.getAll<BusinessPartner>('/BusinessPartners', {
$filter: `CardType eq 'cSupplier' and (${searchFilter})`,
$select: ['CardCode', 'CardName', 'FederalTaxID', 'EmailAddress', 'Phone1', 'Balance'],
$top: limit,
$orderby: 'CardName asc',
});
}
/**
* Obtiene el balance detallado de un proveedor
*/
async getVendorBalance(cardCode: string): Promise<VendorBalanceSummary> {
logger.info('SAP Purchasing: Obteniendo balance de proveedor', { cardCode });
// Obtener datos del proveedor
const vendor = await this.getVendor(cardCode);
// Obtener facturas abiertas
const openInvoices = await this.getOpenPurchaseInvoices(cardCode);
const openInvoicesBalance = openInvoices.reduce((sum, inv) => {
return sum + (inv.DocTotal - (inv.PaidToDate || 0));
}, 0);
// Obtener ordenes de compra abiertas
const openOrders = await this.getOpenPurchaseOrders(cardCode);
const openOrdersBalance = openOrders.reduce((sum, order) => sum + order.DocTotal, 0);
// Obtener entradas pendientes de facturar
const openReceipts = await this.getOpenGoodsReceiptPO(cardCode);
const openGoodsReceiptBalance = openReceipts.reduce((sum, receipt) => sum + receipt.DocTotal, 0);
// Encontrar ultima factura
const lastInvoice = openInvoices.length > 0
? openInvoices.sort((a, b) => new Date(b.DocDate).getTime() - new Date(a.DocDate).getTime())[0]
: null;
return {
cardCode: vendor.CardCode,
cardName: vendor.CardName,
totalBalance: vendor.Balance || 0,
openInvoicesBalance,
openOrdersBalance,
openGoodsReceiptBalance,
lastInvoiceDate: lastInvoice?.DocDate,
currency: vendor.Currency || 'MXN',
};
}
/**
* Obtiene el estado de cuenta de un proveedor
*/
async getVendorStatement(
cardCode: string,
period: Period
): Promise<{
vendor: BusinessPartner;
invoices: PurchaseInvoice[];
openBalance: number;
periodCharges: number;
closingBalance: number;
}> {
logger.info('SAP Purchasing: Generando estado de cuenta de proveedor', { cardCode, period });
const [vendor, invoices] = await Promise.all([
this.getVendor(cardCode),
this.getPurchaseInvoicesByVendor(cardCode, period),
]);
const periodCharges = invoices.reduce((sum, inv) => sum + inv.DocTotal, 0);
// Calcular balance de apertura (simplificado)
const openBalance = (vendor.Balance || 0) - periodCharges;
const closingBalance = openBalance + periodCharges;
return {
vendor,
invoices,
openBalance,
periodCharges,
closingBalance,
};
}
// ==========================================================================
// Analytics
// ==========================================================================
/**
* Obtiene resumen de compras por proveedor
*/
async getPurchaseSummaryByVendor(period: Period): Promise<
Array<{
cardCode: string;
cardName: string;
invoiceCount: number;
totalAmount: number;
paidAmount: number;
pendingAmount: number;
}>
> {
logger.info('SAP Purchasing: Generando resumen de compras por proveedor', { period });
const invoices = await this.getPurchaseInvoices(period, { status: 'all' });
const summary = new Map<
string,
{
cardCode: string;
cardName: string;
invoiceCount: number;
totalAmount: number;
paidAmount: number;
pendingAmount: number;
}
>();
for (const invoice of invoices) {
if (!summary.has(invoice.CardCode)) {
summary.set(invoice.CardCode, {
cardCode: invoice.CardCode,
cardName: invoice.CardName || '',
invoiceCount: 0,
totalAmount: 0,
paidAmount: 0,
pendingAmount: 0,
});
}
const entry = summary.get(invoice.CardCode)!;
entry.invoiceCount += 1;
entry.totalAmount += invoice.DocTotal;
entry.paidAmount += invoice.PaidToDate || 0;
entry.pendingAmount += invoice.DocTotal - (invoice.PaidToDate || 0);
}
return Array.from(summary.values()).sort((a, b) => b.totalAmount - a.totalAmount);
}
// ==========================================================================
// Helper Methods
// ==========================================================================
/**
* Construye las opciones de consulta para documentos
*/
private buildDocumentQuery(
period: Period,
options?: PurchaseDocumentFilterOptions
): ODataQueryOptions {
const filters: string[] = [];
// Filtro de fecha
const dateFilter = this.client.buildDateFilter('DocDate', period.startDate, period.endDate);
if (dateFilter) {
filters.push(dateFilter);
}
// Filtro de proveedor
if (options?.cardCode) {
filters.push(`CardCode eq '${options.cardCode}'`);
}
// Filtro de estado
if (options?.status && options.status !== 'all') {
switch (options.status) {
case 'open':
filters.push("DocumentStatus eq 'bost_Open'");
break;
case 'closed':
filters.push("DocumentStatus eq 'bost_Close'");
break;
case 'cancelled':
filters.push("Cancelled eq 'tYES'");
break;
}
}
// Excluir cancelados por defecto
if (!options?.status || options.status !== 'cancelled') {
filters.push("Cancelled eq 'tNO'");
}
// Filtro de serie
if (options?.series !== undefined) {
filters.push(`Series eq ${options.series}`);
}
const queryOptions: ODataQueryOptions = {
$filter: this.client.combineFilters(...filters),
$select: this.getDocumentSelectFields(),
$orderby: 'DocDate desc, DocNum desc',
};
if (options?.includeLines) {
queryOptions.$expand = ['DocumentLines'];
}
return queryOptions;
}
/**
* Obtiene los campos comunes a seleccionar en documentos
*/
private getDocumentSelectFields(): string[] {
return [
'DocEntry',
'DocNum',
'DocType',
'DocDate',
'DocDueDate',
'TaxDate',
'CardCode',
'CardName',
'Address',
'NumAtCard',
'DocCurrency',
'DocRate',
'DocTotal',
'DocTotalFC',
'VatSum',
'DiscountPercent',
'DiscSum',
'PaidToDate',
'PaidToDateFC',
'Comments',
'JournalMemo',
'DocumentStatus',
'Cancelled',
'Series',
'CreateDate',
'UpdateDate',
'U_FolioFiscalUUID',
'U_SerieCFDI',
'U_FolioCFDI',
];
}
/**
* Obtiene el periodo por defecto (ultimo mes)
*/
private getDefaultPeriod(): Period {
const endDate = new Date();
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1);
return { startDate, endDate };
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea una instancia del conector de compras
*/
export function createPurchasingConnector(client: SAPClient): PurchasingConnector {
return new PurchasingConnector(client);
}
export default PurchasingConnector;

View File

@@ -0,0 +1,550 @@
/**
* SAP Business One Sales Connector
* Conector para documentos de venta y clientes
*/
import { SAPClient } from './sap.client.js';
import {
Invoice,
CreditNote,
DeliveryNote,
SalesOrder,
BusinessPartner,
Period,
ODataQueryOptions,
SAPError,
} from './sap.types.js';
import { logger } from '../../../utils/logger.js';
// ============================================================================
// Types
// ============================================================================
/**
* Estado de documento
*/
export type DocumentStatus = 'open' | 'closed' | 'cancelled' | 'all';
/**
* Opciones de filtro para documentos
*/
export interface DocumentFilterOptions {
cardCode?: string;
status?: DocumentStatus;
series?: number;
salesPersonCode?: number;
includeLines?: boolean;
}
/**
* Resumen de balance de cliente
*/
export interface CustomerBalanceSummary {
cardCode: string;
cardName: string;
totalBalance: number;
openInvoicesBalance: number;
openOrdersBalance: number;
openDeliveryNotesBalance: number;
creditLimit: number;
availableCredit: number;
lastInvoiceDate?: string;
lastPaymentDate?: string;
currency: string;
}
// ============================================================================
// Sales Connector Class
// ============================================================================
/**
* Conector para datos de ventas de SAP B1
*/
export class SalesConnector {
constructor(private readonly client: SAPClient) {}
// ==========================================================================
// Invoices (Facturas de Venta)
// ==========================================================================
/**
* Obtiene facturas de venta de un periodo
*/
async getInvoices(period: Period, options?: DocumentFilterOptions): Promise<Invoice[]> {
logger.info('SAP Sales: Obteniendo facturas', { period, options });
const queryOptions = this.buildDocumentQuery(period, options);
return this.client.getAll<Invoice>('/Invoices', queryOptions);
}
/**
* Obtiene una factura por DocEntry
*/
async getInvoice(docEntry: number): Promise<Invoice> {
logger.info('SAP Sales: Obteniendo factura', { docEntry });
return this.client.get<Invoice>(`/Invoices(${docEntry})`, {
$expand: ['DocumentLines', 'WithholdingTaxDataCollection'],
});
}
/**
* Obtiene facturas por cliente
*/
async getInvoicesByCustomer(
cardCode: string,
period?: Period,
status?: DocumentStatus
): Promise<Invoice[]> {
logger.info('SAP Sales: Obteniendo facturas por cliente', { cardCode, period, status });
return this.getInvoices(
period || this.getDefaultPeriod(),
{ cardCode, status, includeLines: true }
);
}
/**
* Obtiene facturas pendientes de pago
*/
async getOpenInvoices(cardCode?: string): Promise<Invoice[]> {
logger.info('SAP Sales: Obteniendo facturas abiertas', { cardCode });
const filters: string[] = [
"DocumentStatus eq 'bost_Open'",
"Cancelled eq 'tNO'",
];
if (cardCode) {
filters.push(`CardCode eq '${cardCode}'`);
}
return this.client.getAll<Invoice>('/Invoices', {
$filter: this.client.combineFilters(...filters),
$select: this.getDocumentSelectFields(),
$expand: ['DocumentLines'],
$orderby: 'DocDueDate asc',
});
}
// ==========================================================================
// Credit Notes (Notas de Credito)
// ==========================================================================
/**
* Obtiene notas de credito de un periodo
*/
async getCreditNotes(period: Period, options?: DocumentFilterOptions): Promise<CreditNote[]> {
logger.info('SAP Sales: Obteniendo notas de credito', { period, options });
const queryOptions = this.buildDocumentQuery(period, options);
return this.client.getAll<CreditNote>('/CreditNotes', queryOptions);
}
/**
* Obtiene una nota de credito por DocEntry
*/
async getCreditNote(docEntry: number): Promise<CreditNote> {
logger.info('SAP Sales: Obteniendo nota de credito', { docEntry });
return this.client.get<CreditNote>(`/CreditNotes(${docEntry})`, {
$expand: ['DocumentLines', 'WithholdingTaxDataCollection'],
});
}
/**
* Obtiene notas de credito por cliente
*/
async getCreditNotesByCustomer(
cardCode: string,
period?: Period
): Promise<CreditNote[]> {
return this.getCreditNotes(period || this.getDefaultPeriod(), { cardCode, includeLines: true });
}
// ==========================================================================
// Delivery Notes (Notas de Entrega / Remisiones)
// ==========================================================================
/**
* Obtiene notas de entrega de un periodo
*/
async getDeliveryNotes(period: Period, options?: DocumentFilterOptions): Promise<DeliveryNote[]> {
logger.info('SAP Sales: Obteniendo notas de entrega', { period, options });
const queryOptions = this.buildDocumentQuery(period, options);
return this.client.getAll<DeliveryNote>('/DeliveryNotes', queryOptions);
}
/**
* Obtiene una nota de entrega por DocEntry
*/
async getDeliveryNote(docEntry: number): Promise<DeliveryNote> {
logger.info('SAP Sales: Obteniendo nota de entrega', { docEntry });
return this.client.get<DeliveryNote>(`/DeliveryNotes(${docEntry})`, {
$expand: ['DocumentLines'],
});
}
/**
* Obtiene notas de entrega pendientes de facturar
*/
async getOpenDeliveryNotes(cardCode?: string): Promise<DeliveryNote[]> {
logger.info('SAP Sales: Obteniendo remisiones abiertas', { cardCode });
const filters: string[] = [
"DocumentStatus eq 'bost_Open'",
"Cancelled eq 'tNO'",
];
if (cardCode) {
filters.push(`CardCode eq '${cardCode}'`);
}
return this.client.getAll<DeliveryNote>('/DeliveryNotes', {
$filter: this.client.combineFilters(...filters),
$select: this.getDocumentSelectFields(),
$orderby: 'DocDate desc',
});
}
// ==========================================================================
// Sales Orders (Pedidos de Venta)
// ==========================================================================
/**
* Obtiene ordenes de venta de un periodo
*/
async getSalesOrders(period: Period, options?: DocumentFilterOptions): Promise<SalesOrder[]> {
logger.info('SAP Sales: Obteniendo pedidos de venta', { period, options });
const queryOptions = this.buildDocumentQuery(period, options);
return this.client.getAll<SalesOrder>('/Orders', queryOptions);
}
/**
* Obtiene una orden de venta por DocEntry
*/
async getSalesOrder(docEntry: number): Promise<SalesOrder> {
logger.info('SAP Sales: Obteniendo pedido de venta', { docEntry });
return this.client.get<SalesOrder>(`/Orders(${docEntry})`, {
$expand: ['DocumentLines'],
});
}
/**
* Obtiene ordenes de venta abiertas
*/
async getOpenSalesOrders(cardCode?: string): Promise<SalesOrder[]> {
logger.info('SAP Sales: Obteniendo pedidos abiertos', { cardCode });
const filters: string[] = [
"DocumentStatus eq 'bost_Open'",
"Cancelled eq 'tNO'",
];
if (cardCode) {
filters.push(`CardCode eq '${cardCode}'`);
}
return this.client.getAll<SalesOrder>('/Orders', {
$filter: this.client.combineFilters(...filters),
$select: this.getDocumentSelectFields(),
$expand: ['DocumentLines'],
$orderby: 'DocDueDate asc',
});
}
// ==========================================================================
// Customers (Clientes)
// ==========================================================================
/**
* Obtiene todos los clientes
*/
async getCustomers(options?: {
activeOnly?: boolean;
groupCode?: number;
withBalance?: boolean;
}): Promise<BusinessPartner[]> {
logger.info('SAP Sales: Obteniendo clientes', { options });
const filters: string[] = ["CardType eq 'cCustomer'"];
if (options?.activeOnly) {
filters.push("Frozen eq 'tNO'");
filters.push("Valid eq 'tYES'");
}
if (options?.groupCode !== undefined) {
filters.push(`GroupCode eq ${options.groupCode}`);
}
const selectFields = [
'CardCode',
'CardName',
'CardType',
'CardForeignName',
'FederalTaxID',
'Address',
'ZipCode',
'City',
'Country',
'State',
'EmailAddress',
'Phone1',
'Phone2',
'Cellular',
'ContactPerson',
'PayTermsGrpCode',
'CreditLimit',
'Currency',
'GroupCode',
'PriceListNum',
'Frozen',
'Valid',
'CreateDate',
'UpdateDate',
'SalesPersonCode',
];
if (options?.withBalance) {
selectFields.push('Balance', 'OpenDeliveryNotesBalance', 'OpenOrdersBalance');
}
return this.client.getAll<BusinessPartner>('/BusinessPartners', {
$filter: this.client.combineFilters(...filters),
$select: selectFields,
$orderby: 'CardName asc',
});
}
/**
* Obtiene un cliente por CardCode
*/
async getCustomer(cardCode: string): Promise<BusinessPartner> {
logger.info('SAP Sales: Obteniendo cliente', { cardCode });
return this.client.get<BusinessPartner>(`/BusinessPartners('${cardCode}')`, {
$expand: ['BPAddresses', 'ContactEmployees', 'BPBankAccounts'],
});
}
/**
* Busca clientes por nombre o RFC
*/
async searchCustomers(query: string, limit = 20): Promise<BusinessPartner[]> {
logger.info('SAP Sales: Buscando clientes', { query, limit });
const searchFilter = `contains(CardName,'${query}') or contains(CardCode,'${query}') or contains(FederalTaxID,'${query}')`;
return this.client.getAll<BusinessPartner>('/BusinessPartners', {
$filter: `CardType eq 'cCustomer' and (${searchFilter})`,
$select: ['CardCode', 'CardName', 'FederalTaxID', 'EmailAddress', 'Phone1', 'Balance'],
$top: limit,
$orderby: 'CardName asc',
});
}
/**
* Obtiene el balance detallado de un cliente
*/
async getCustomerBalance(cardCode: string): Promise<CustomerBalanceSummary> {
logger.info('SAP Sales: Obteniendo balance de cliente', { cardCode });
// Obtener datos del cliente
const customer = await this.getCustomer(cardCode);
// Obtener facturas abiertas para calcular balance real
const openInvoices = await this.getOpenInvoices(cardCode);
const openInvoicesBalance = openInvoices.reduce((sum, inv) => {
return sum + (inv.DocTotal - (inv.PaidToDate || 0));
}, 0);
// Encontrar ultima factura y pago
const lastInvoice = openInvoices.length > 0
? openInvoices.sort((a, b) => new Date(b.DocDate).getTime() - new Date(a.DocDate).getTime())[0]
: null;
return {
cardCode: customer.CardCode,
cardName: customer.CardName,
totalBalance: customer.Balance || 0,
openInvoicesBalance,
openOrdersBalance: customer.OpenOrdersBalance || 0,
openDeliveryNotesBalance: customer.OpenDeliveryNotesBalance || 0,
creditLimit: customer.CreditLimit || 0,
availableCredit: (customer.CreditLimit || 0) - (customer.Balance || 0),
lastInvoiceDate: lastInvoice?.DocDate,
currency: customer.Currency || 'MXN',
};
}
/**
* Obtiene el estado de cuenta de un cliente
*/
async getCustomerStatement(
cardCode: string,
period: Period
): Promise<{
customer: BusinessPartner;
invoices: Invoice[];
creditNotes: CreditNote[];
openBalance: number;
periodDebits: number;
periodCredits: number;
closingBalance: number;
}> {
logger.info('SAP Sales: Generando estado de cuenta', { cardCode, period });
const [customer, invoices, creditNotes] = await Promise.all([
this.getCustomer(cardCode),
this.getInvoicesByCustomer(cardCode, period),
this.getCreditNotesByCustomer(cardCode, period),
]);
const periodDebits = invoices.reduce((sum, inv) => sum + inv.DocTotal, 0);
const periodCredits = creditNotes.reduce((sum, cn) => sum + cn.DocTotal, 0);
// Calcular balance de apertura (simplificado)
const openBalance = (customer.Balance || 0) - periodDebits + periodCredits;
const closingBalance = openBalance + periodDebits - periodCredits;
return {
customer,
invoices,
creditNotes,
openBalance,
periodDebits,
periodCredits,
closingBalance,
};
}
// ==========================================================================
// Helper Methods
// ==========================================================================
/**
* Construye las opciones de consulta para documentos
*/
private buildDocumentQuery(
period: Period,
options?: DocumentFilterOptions
): ODataQueryOptions {
const filters: string[] = [];
// Filtro de fecha
const dateFilter = this.client.buildDateFilter('DocDate', period.startDate, period.endDate);
if (dateFilter) {
filters.push(dateFilter);
}
// Filtro de cliente
if (options?.cardCode) {
filters.push(`CardCode eq '${options.cardCode}'`);
}
// Filtro de estado
if (options?.status && options.status !== 'all') {
switch (options.status) {
case 'open':
filters.push("DocumentStatus eq 'bost_Open'");
break;
case 'closed':
filters.push("DocumentStatus eq 'bost_Close'");
break;
case 'cancelled':
filters.push("Cancelled eq 'tYES'");
break;
}
}
// Excluir cancelados por defecto
if (!options?.status || options.status !== 'cancelled') {
filters.push("Cancelled eq 'tNO'");
}
// Filtro de serie
if (options?.series !== undefined) {
filters.push(`Series eq ${options.series}`);
}
// Filtro de vendedor
if (options?.salesPersonCode !== undefined) {
filters.push(`SalesPersonCode eq ${options.salesPersonCode}`);
}
const queryOptions: ODataQueryOptions = {
$filter: this.client.combineFilters(...filters),
$select: this.getDocumentSelectFields(),
$orderby: 'DocDate desc, DocNum desc',
};
if (options?.includeLines) {
queryOptions.$expand = ['DocumentLines'];
}
return queryOptions;
}
/**
* Obtiene los campos comunes a seleccionar en documentos
*/
private getDocumentSelectFields(): string[] {
return [
'DocEntry',
'DocNum',
'DocType',
'DocDate',
'DocDueDate',
'TaxDate',
'CardCode',
'CardName',
'Address',
'NumAtCard',
'DocCurrency',
'DocRate',
'DocTotal',
'DocTotalFC',
'VatSum',
'DiscountPercent',
'DiscSum',
'PaidToDate',
'PaidToDateFC',
'Comments',
'JournalMemo',
'DocumentStatus',
'Cancelled',
'SalesPersonCode',
'Series',
'CreateDate',
'UpdateDate',
'U_FolioFiscalUUID',
'U_SerieCFDI',
'U_FolioCFDI',
];
}
/**
* Obtiene el periodo por defecto (ultimo mes)
*/
private getDefaultPeriod(): Period {
const endDate = new Date();
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1);
return { startDate, endDate };
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea una instancia del conector de ventas
*/
export function createSalesConnector(client: SAPClient): SalesConnector {
return new SalesConnector(client);
}
export default SalesConnector;

View File

@@ -0,0 +1,670 @@
/**
* SAP Business One Service Layer Client
* Cliente HTTP para comunicarse con SAP B1 Service Layer (REST/OData)
*/
import {
SAPConfig,
SAPSession,
SAPLoginResponse,
SAPErrorResponse,
ODataQueryOptions,
ODataResponse,
BatchOperation,
BatchResult,
SAPError,
SAPAuthError,
SAPSessionExpiredError,
SAPConnectionError,
SAPNotFoundError,
} from './sap.types.js';
import { logger } from '../../../utils/logger.js';
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_TIMEOUT = 30000;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_SESSION_LIFETIME = 30; // minutos
const SESSION_REFRESH_THRESHOLD = 5; // minutos antes de expiracion
// ============================================================================
// SAP Service Layer Client
// ============================================================================
/**
* Cliente para SAP Business One Service Layer
* Maneja autenticacion, sesiones y comunicacion HTTP con el servicio
*/
export class SAPClient {
private config: Required<SAPConfig>;
private session: SAPSession | null = null;
private sessionRefreshTimer: NodeJS.Timeout | null = null;
constructor(config: SAPConfig) {
this.config = {
language: 'b', // Espanol
timeout: DEFAULT_TIMEOUT,
sslVerify: true,
maxRetries: DEFAULT_MAX_RETRIES,
sessionLifetime: DEFAULT_SESSION_LIFETIME,
...config,
};
}
// ==========================================================================
// Session Management
// ==========================================================================
/**
* Inicia sesion en SAP Service Layer
*/
async login(): Promise<SAPSession> {
try {
logger.info('SAP: Iniciando login', {
serviceLayerUrl: this.config.serviceLayerUrl,
companyDB: this.config.companyDB,
userName: this.config.userName,
});
const response = await this.httpRequest<SAPLoginResponse>('POST', '/Login', {
CompanyDB: this.config.companyDB,
UserName: this.config.userName,
Password: this.config.password,
Language: this.config.language,
}, false);
// Extraer cookies de sesion de los headers
const sessionId = response.SessionId;
const sessionTimeout = response.SessionTimeout || this.config.sessionLifetime;
const now = new Date();
this.session = {
sessionId,
b1Session: sessionId,
createdAt: now,
expiresAt: new Date(now.getTime() + sessionTimeout * 60 * 1000),
companyDB: this.config.companyDB,
userName: this.config.userName,
isValid: true,
};
// Configurar refresh automatico
this.setupSessionRefresh();
logger.info('SAP: Login exitoso', {
sessionId,
expiresAt: this.session.expiresAt,
});
return this.session;
} catch (error) {
logger.error('SAP: Error en login', { error });
throw new SAPAuthError(
`Error de autenticacion SAP: ${error instanceof Error ? error.message : 'Error desconocido'}`
);
}
}
/**
* Cierra la sesion actual
*/
async logout(): Promise<void> {
if (!this.session) {
return;
}
try {
await this.httpRequest('POST', '/Logout', undefined, true);
logger.info('SAP: Logout exitoso');
} catch (error) {
logger.warn('SAP: Error en logout (ignorado)', { error });
} finally {
this.clearSession();
}
}
/**
* Verifica si la sesion actual es valida
*/
isSessionValid(): boolean {
if (!this.session) {
return false;
}
const now = new Date();
const isNotExpired = this.session.expiresAt > now;
return this.session.isValid && isNotExpired;
}
/**
* Obtiene la sesion actual, haciendo login si es necesario
*/
async ensureSession(): Promise<SAPSession> {
if (!this.isSessionValid()) {
return this.login();
}
return this.session!;
}
/**
* Configura el refresh automatico de sesion
*/
private setupSessionRefresh(): void {
this.clearSessionRefreshTimer();
if (!this.session) {
return;
}
// Calcular tiempo hasta el refresh (5 minutos antes de expirar)
const refreshTime = this.session.expiresAt.getTime() - SESSION_REFRESH_THRESHOLD * 60 * 1000;
const now = Date.now();
const delay = Math.max(0, refreshTime - now);
this.sessionRefreshTimer = setTimeout(async () => {
try {
logger.info('SAP: Refrescando sesion');
await this.login();
} catch (error) {
logger.error('SAP: Error al refrescar sesion', { error });
this.clearSession();
}
}, delay);
}
/**
* Limpia el timer de refresh de sesion
*/
private clearSessionRefreshTimer(): void {
if (this.sessionRefreshTimer) {
clearTimeout(this.sessionRefreshTimer);
this.sessionRefreshTimer = null;
}
}
/**
* Limpia la sesion actual
*/
private clearSession(): void {
this.session = null;
this.clearSessionRefreshTimer();
}
// ==========================================================================
// HTTP Methods
// ==========================================================================
/**
* Realiza una peticion GET
*/
async get<T>(endpoint: string, options?: ODataQueryOptions): Promise<T> {
const url = this.buildUrlWithQuery(endpoint, options);
return this.request<T>('GET', url);
}
/**
* Realiza una peticion GET con paginacion OData
*/
async getAll<T>(endpoint: string, options?: ODataQueryOptions): Promise<T[]> {
const results: T[] = [];
let nextLink: string | undefined = this.buildUrlWithQuery(endpoint, options);
while (nextLink) {
const response = await this.request<ODataResponse<T>>('GET', nextLink);
results.push(...response.value);
nextLink = response['odata.nextLink'];
}
return results;
}
/**
* Realiza una peticion GET paginada
*/
async getPaginated<T>(
endpoint: string,
options?: ODataQueryOptions
): Promise<ODataResponse<T>> {
const url = this.buildUrlWithQuery(endpoint, options);
return this.request<ODataResponse<T>>('GET', url);
}
/**
* Realiza una peticion POST
*/
async post<T>(endpoint: string, body: unknown): Promise<T> {
return this.request<T>('POST', endpoint, body);
}
/**
* Realiza una peticion PATCH (update parcial)
*/
async patch<T>(endpoint: string, body: unknown): Promise<T> {
return this.request<T>('PATCH', endpoint, body);
}
/**
* Realiza una peticion PUT (update completo)
*/
async put<T>(endpoint: string, body: unknown): Promise<T> {
return this.request<T>('PUT', endpoint, body);
}
/**
* Realiza una peticion DELETE
*/
async delete(endpoint: string): Promise<void> {
await this.request<void>('DELETE', endpoint);
}
/**
* Ejecuta multiples operaciones en batch
*/
async batch(operations: BatchOperation[]): Promise<BatchResult[]> {
const boundary = `batch_${Date.now()}_${Math.random().toString(36).substring(7)}`;
const changesetBoundary = `changeset_${Date.now()}_${Math.random().toString(36).substring(7)}`;
// Construir el cuerpo del batch
const batchBody = this.buildBatchBody(operations, boundary, changesetBoundary);
await this.ensureSession();
const response = await fetch(`${this.config.serviceLayerUrl}/$batch`, {
method: 'POST',
headers: {
'Content-Type': `multipart/mixed; boundary=${boundary}`,
'Cookie': `B1SESSION=${this.session!.b1Session}`,
'Accept': 'application/json',
},
body: batchBody,
});
if (!response.ok) {
throw new SAPError(
`Error en batch: ${response.statusText}`,
'BATCH_ERROR',
await response.text(),
response.status
);
}
// Parsear la respuesta del batch
const responseText = await response.text();
return this.parseBatchResponse(responseText, operations);
}
// ==========================================================================
// OData Query Building
// ==========================================================================
/**
* Construye una URL con parametros OData
*/
private buildUrlWithQuery(endpoint: string, options?: ODataQueryOptions): string {
if (!options) {
return endpoint;
}
const params: string[] = [];
if (options.$select?.length) {
params.push(`$select=${options.$select.join(',')}`);
}
if (options.$filter) {
params.push(`$filter=${encodeURIComponent(options.$filter)}`);
}
if (options.$orderby) {
params.push(`$orderby=${encodeURIComponent(options.$orderby)}`);
}
if (options.$skip !== undefined) {
params.push(`$skip=${options.$skip}`);
}
if (options.$top !== undefined) {
params.push(`$top=${options.$top}`);
}
if (options.$expand?.length) {
params.push(`$expand=${options.$expand.join(',')}`);
}
if (options.$inlinecount) {
params.push(`$inlinecount=${options.$inlinecount}`);
}
if (options.$apply) {
params.push(`$apply=${encodeURIComponent(options.$apply)}`);
}
if (params.length === 0) {
return endpoint;
}
const separator = endpoint.includes('?') ? '&' : '?';
return `${endpoint}${separator}${params.join('&')}`;
}
/**
* Construye un filtro de fecha OData
*/
buildDateFilter(field: string, from?: Date, to?: Date): string {
const filters: string[] = [];
if (from) {
filters.push(`${field} ge '${this.formatDate(from)}'`);
}
if (to) {
filters.push(`${field} le '${this.formatDate(to)}'`);
}
return filters.join(' and ');
}
/**
* Combina multiples filtros OData
*/
combineFilters(...filters: (string | undefined)[]): string {
return filters
.filter((f): f is string => !!f && f.length > 0)
.join(' and ');
}
/**
* Formatea una fecha para OData
*/
formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
// ==========================================================================
// HTTP Request Implementation
// ==========================================================================
/**
* Ejecuta una peticion HTTP con manejo de sesion y reintentos
*/
private async request<T>(
method: string,
endpoint: string,
body?: unknown,
retryCount = 0
): Promise<T> {
await this.ensureSession();
return this.httpRequest<T>(method, endpoint, body, true, retryCount);
}
/**
* Ejecuta una peticion HTTP raw
*/
private async httpRequest<T>(
method: string,
endpoint: string,
body?: unknown,
withSession = true,
retryCount = 0
): Promise<T> {
const url = endpoint.startsWith('http')
? endpoint
: `${this.config.serviceLayerUrl}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (withSession && this.session) {
headers['Cookie'] = `B1SESSION=${this.session.b1Session}`;
if (this.session.routeId) {
headers['Cookie'] += `; ROUTEID=${this.session.routeId}`;
}
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
try {
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
// Manejar errores de sesion
if (response.status === 401) {
if (retryCount < this.config.maxRetries) {
logger.warn('SAP: Sesion expirada, reintentando login');
this.clearSession();
await this.login();
return this.httpRequest<T>(method, endpoint, body, withSession, retryCount + 1);
}
throw new SAPSessionExpiredError();
}
// Manejar 404
if (response.status === 404) {
throw new SAPNotFoundError('Recurso', endpoint);
}
// Manejar otros errores
if (!response.ok) {
const errorBody = await response.text();
let errorMessage = `Error HTTP ${response.status}: ${response.statusText}`;
let errorCode = String(response.status);
try {
const errorJson = JSON.parse(errorBody) as SAPErrorResponse;
if (errorJson.error) {
errorMessage = errorJson.error.message.value;
errorCode = errorJson.error.code;
}
} catch {
// No es JSON, usar el texto raw
}
// Reintentar en errores de conexion
if (response.status >= 500 && retryCount < this.config.maxRetries) {
logger.warn(`SAP: Error del servidor, reintentando (${retryCount + 1}/${this.config.maxRetries})`, {
status: response.status,
error: errorMessage,
});
await this.sleep(1000 * (retryCount + 1)); // Backoff exponencial
return this.httpRequest<T>(method, endpoint, body, withSession, retryCount + 1);
}
throw new SAPError(errorMessage, errorCode, errorBody, response.status);
}
// Manejar respuesta vacia
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return undefined as unknown as T;
}
const text = await response.text();
if (!text || text.length === 0) {
return undefined as unknown as T;
}
return JSON.parse(text) as T;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof SAPError) {
throw error;
}
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new SAPConnectionError('Timeout de conexion', `URL: ${url}`);
}
throw new SAPConnectionError(error.message, `URL: ${url}`);
}
throw new SAPConnectionError('Error de conexion desconocido', `URL: ${url}`);
}
}
// ==========================================================================
// Batch Processing
// ==========================================================================
/**
* Construye el cuerpo de una peticion batch
*/
private buildBatchBody(
operations: BatchOperation[],
boundary: string,
changesetBoundary: string
): string {
const parts: string[] = [];
// Separar operaciones de lectura y escritura
const readOps = operations.filter((op) => op.method === 'GET');
const writeOps = operations.filter((op) => op.method !== 'GET');
// Agregar operaciones de lectura
for (const op of readOps) {
parts.push(`--${boundary}`);
parts.push('Content-Type: application/http');
parts.push('Content-Transfer-Encoding: binary');
parts.push('');
parts.push(`${op.method} ${op.url} HTTP/1.1`);
parts.push('Accept: application/json');
parts.push('');
}
// Agregar changeset para operaciones de escritura
if (writeOps.length > 0) {
parts.push(`--${boundary}`);
parts.push(`Content-Type: multipart/mixed; boundary=${changesetBoundary}`);
parts.push('');
for (let i = 0; i < writeOps.length; i++) {
const op = writeOps[i];
parts.push(`--${changesetBoundary}`);
parts.push('Content-Type: application/http');
parts.push('Content-Transfer-Encoding: binary');
if (op.contentId) {
parts.push(`Content-ID: ${op.contentId}`);
}
parts.push('');
parts.push(`${op.method} ${op.url} HTTP/1.1`);
parts.push('Content-Type: application/json');
parts.push('Accept: application/json');
parts.push('');
if (op.body) {
parts.push(JSON.stringify(op.body));
}
parts.push('');
}
parts.push(`--${changesetBoundary}--`);
}
parts.push(`--${boundary}--`);
return parts.join('\r\n');
}
/**
* Parsea la respuesta de una peticion batch
*/
private parseBatchResponse(responseText: string, operations: BatchOperation[]): BatchResult[] {
const results: BatchResult[] = [];
// Extraer el boundary de la respuesta
const boundaryMatch = responseText.match(/--batchresponse_[a-z0-9-]+/);
if (!boundaryMatch) {
logger.warn('SAP: No se pudo parsear respuesta batch');
return results;
}
const boundary = boundaryMatch[0];
const parts = responseText.split(boundary).filter((p) => p.trim() && !p.trim().startsWith('--'));
for (let i = 0; i < parts.length && i < operations.length; i++) {
const part = parts[i];
const result: BatchResult = {
status: 200,
headers: {},
body: null,
contentId: operations[i].contentId,
};
// Extraer status
const statusMatch = part.match(/HTTP\/1\.1 (\d+)/);
if (statusMatch) {
result.status = parseInt(statusMatch[1], 10);
}
// Extraer body JSON
const jsonMatch = part.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
result.body = JSON.parse(jsonMatch[0]);
} catch {
result.body = jsonMatch[0];
}
}
results.push(result);
}
return results;
}
// ==========================================================================
// Utility Methods
// ==========================================================================
/**
* Espera un tiempo determinado
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Obtiene la configuracion actual
*/
getConfig(): Readonly<Required<SAPConfig>> {
return { ...this.config };
}
/**
* Obtiene la sesion actual
*/
getSession(): Readonly<SAPSession> | null {
return this.session ? { ...this.session } : null;
}
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Crea una instancia del cliente SAP B1
*/
export function createSAPClient(config: SAPConfig): SAPClient {
return new SAPClient(config);
}
/**
* Crea un cliente SAP y realiza login automatico
*/
export async function createConnectedSAPClient(config: SAPConfig): Promise<SAPClient> {
const client = new SAPClient(config);
await client.login();
return client;
}
export default SAPClient;

View File

@@ -0,0 +1,869 @@
/**
* SAP Business One Sync Service
* Servicio de sincronizacion de datos SAP B1 a Horux Strategy
*/
import { SAPClient, createConnectedSAPClient } from './sap.client.js';
import { SalesConnector } from './sales.connector.js';
import { PurchasingConnector } from './purchasing.connector.js';
import { FinancialsConnector } from './financials.connector.js';
import { InventoryConnector } from './inventory.connector.js';
import { BankingConnector } from './banking.connector.js';
import {
SAPConfig,
SAPSyncResult,
SAPSyncError,
SAPTransactionMapping,
Invoice,
CreditNote,
PurchaseInvoice,
IncomingPayment,
OutgoingPayment,
JournalEntry,
BusinessPartner,
Item,
Period,
SAPError,
} from './sap.types.js';
import { logger, auditLog } from '../../../utils/logger.js';
// ============================================================================
// Types
// ============================================================================
/**
* Opciones de sincronizacion
*/
export interface SyncOptions {
/** ID del tenant en Horux */
tenantId: string;
/** Configuracion de conexion SAP */
config: SAPConfig;
/** Periodo a sincronizar */
period: Period;
/** Entidades a sincronizar */
entities?: SyncEntityType[];
/** Modo de sincronizacion */
mode?: 'full' | 'incremental';
/** Callback de progreso */
onProgress?: (progress: SyncProgress) => void;
/** Generar alertas automaticas */
generateAlerts?: boolean;
/** Usar campos definidos por usuario */
useUDFs?: boolean;
}
/**
* Tipos de entidad a sincronizar
*/
export type SyncEntityType =
| 'invoices'
| 'creditNotes'
| 'purchaseInvoices'
| 'incomingPayments'
| 'outgoingPayments'
| 'journalEntries'
| 'customers'
| 'vendors'
| 'items';
/**
* Progreso de sincronizacion
*/
export interface SyncProgress {
entity: SyncEntityType;
current: number;
total: number;
percentage: number;
status: 'pending' | 'processing' | 'completed' | 'error';
message?: string;
}
/**
* Transaccion mapeada para Horux
*/
export interface MappedTransaction {
externalId: string;
externalSource: 'sap_b1';
type: 'income' | 'expense' | 'transfer';
amount: number;
currency: string;
exchangeRate?: number;
description: string;
date: Date;
reference?: string;
contactExternalId?: string;
cfdiUuid?: string;
metadata: Record<string, unknown>;
}
/**
* Alerta generada desde SAP
*/
export interface SAPAlert {
type: 'payment_due' | 'low_stock' | 'credit_exceeded' | 'aging' | 'sync_error';
severity: 'info' | 'warning' | 'critical';
title: string;
message: string;
entityType?: string;
entityId?: string;
metadata?: Record<string, unknown>;
}
// ============================================================================
// SAP Sync Service
// ============================================================================
/**
* Servicio de sincronizacion SAP B1 a Horux
*/
export class SAPSyncService {
private client: SAPClient | null = null;
private sales: SalesConnector | null = null;
private purchasing: PurchasingConnector | null = null;
private financials: FinancialsConnector | null = null;
private inventory: InventoryConnector | null = null;
private banking: BankingConnector | null = null;
/**
* Inicializa la conexion y conectores
*/
private async initialize(config: SAPConfig): Promise<void> {
this.client = await createConnectedSAPClient(config);
this.sales = new SalesConnector(this.client);
this.purchasing = new PurchasingConnector(this.client);
this.financials = new FinancialsConnector(this.client);
this.inventory = new InventoryConnector(this.client);
this.banking = new BankingConnector(this.client);
}
/**
* Cierra la conexion
*/
private async cleanup(): Promise<void> {
if (this.client) {
await this.client.logout();
this.client = null;
}
this.sales = null;
this.purchasing = null;
this.financials = null;
this.inventory = null;
this.banking = null;
}
/**
* Ejecuta la sincronizacion completa
*/
async syncToHorux(options: SyncOptions): Promise<SAPSyncResult> {
const syncStartedAt = new Date();
const result: SAPSyncResult = {
success: false,
tenantId: options.tenantId,
source: 'sap_b1',
syncStartedAt,
recordsProcessed: 0,
recordsSaved: 0,
recordsFailed: 0,
errors: [],
summary: {},
};
logger.info('SAP Sync: Iniciando sincronizacion', {
tenantId: options.tenantId,
period: options.period,
entities: options.entities,
mode: options.mode,
});
auditLog(
'SAP_SYNC_STARTED',
null,
options.tenantId,
{ period: options.period, entities: options.entities },
true
);
try {
// Inicializar conexion
await this.initialize(options.config);
// Determinar entidades a sincronizar
const entities = options.entities || [
'invoices',
'creditNotes',
'purchaseInvoices',
'incomingPayments',
'outgoingPayments',
'journalEntries',
'customers',
'vendors',
'items',
];
// Sincronizar cada entidad
for (const entity of entities) {
try {
options.onProgress?.({
entity,
current: 0,
total: 0,
percentage: 0,
status: 'processing',
});
const entityResult = await this.syncEntity(entity, options);
result.recordsProcessed += entityResult.processed;
result.recordsSaved += entityResult.saved;
result.recordsFailed += entityResult.failed;
result.errors.push(...entityResult.errors);
result.summary[entity] = entityResult.saved;
options.onProgress?.({
entity,
current: entityResult.processed,
total: entityResult.processed,
percentage: 100,
status: entityResult.failed > 0 ? 'error' : 'completed',
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
result.errors.push({
entity,
error: errorMessage,
timestamp: new Date(),
});
options.onProgress?.({
entity,
current: 0,
total: 0,
percentage: 0,
status: 'error',
message: errorMessage,
});
}
}
// Generar alertas si se solicita
if (options.generateAlerts) {
try {
const alerts = await this.generateAlerts(options);
logger.info('SAP Sync: Alertas generadas', { count: alerts.length });
} catch (error) {
logger.warn('SAP Sync: Error generando alertas', { error });
}
}
result.success = result.recordsFailed === 0;
result.syncCompletedAt = new Date();
logger.info('SAP Sync: Sincronizacion completada', {
success: result.success,
processed: result.recordsProcessed,
saved: result.recordsSaved,
failed: result.recordsFailed,
});
auditLog(
'SAP_SYNC_COMPLETED',
null,
options.tenantId,
{
success: result.success,
recordsProcessed: result.recordsProcessed,
recordsSaved: result.recordsSaved,
duration: result.syncCompletedAt.getTime() - syncStartedAt.getTime(),
},
result.success
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
result.errors.push({
entity: 'connection',
error: errorMessage,
timestamp: new Date(),
});
logger.error('SAP Sync: Error en sincronizacion', { error });
auditLog(
'SAP_SYNC_FAILED',
null,
options.tenantId,
{ error: errorMessage },
false
);
} finally {
await this.cleanup();
}
return result;
}
/**
* Sincroniza una entidad especifica
*/
private async syncEntity(
entity: SyncEntityType,
options: SyncOptions
): Promise<{
processed: number;
saved: number;
failed: number;
errors: SAPSyncError[];
}> {
const errors: SAPSyncError[] = [];
let processed = 0;
let saved = 0;
let failed = 0;
logger.info(`SAP Sync: Sincronizando ${entity}`, { period: options.period });
switch (entity) {
case 'invoices': {
const invoices = await this.sales!.getInvoices(options.period);
processed = invoices.length;
for (const invoice of invoices) {
try {
const mapped = this.mapInvoiceToTransaction(invoice, options);
// TODO: Guardar en base de datos Horux
// await this.saveTransaction(options.tenantId, mapped);
saved++;
} catch (error) {
failed++;
errors.push({
entity: 'invoice',
identifier: String(invoice.DocNum),
error: error instanceof Error ? error.message : 'Error desconocido',
timestamp: new Date(),
});
}
}
break;
}
case 'creditNotes': {
const creditNotes = await this.sales!.getCreditNotes(options.period);
processed = creditNotes.length;
for (const cn of creditNotes) {
try {
const mapped = this.mapCreditNoteToTransaction(cn, options);
saved++;
} catch (error) {
failed++;
errors.push({
entity: 'creditNote',
identifier: String(cn.DocNum),
error: error instanceof Error ? error.message : 'Error desconocido',
timestamp: new Date(),
});
}
}
break;
}
case 'purchaseInvoices': {
const purchaseInvoices = await this.purchasing!.getPurchaseInvoices(options.period);
processed = purchaseInvoices.length;
for (const pi of purchaseInvoices) {
try {
const mapped = this.mapPurchaseInvoiceToTransaction(pi, options);
saved++;
} catch (error) {
failed++;
errors.push({
entity: 'purchaseInvoice',
identifier: String(pi.DocNum),
error: error instanceof Error ? error.message : 'Error desconocido',
timestamp: new Date(),
});
}
}
break;
}
case 'incomingPayments': {
const payments = await this.banking!.getIncomingPayments(options.period);
processed = payments.length;
for (const payment of payments) {
try {
const mapped = this.mapIncomingPaymentToTransaction(payment, options);
saved++;
} catch (error) {
failed++;
errors.push({
entity: 'incomingPayment',
identifier: String(payment.DocNum),
error: error instanceof Error ? error.message : 'Error desconocido',
timestamp: new Date(),
});
}
}
break;
}
case 'outgoingPayments': {
const payments = await this.banking!.getOutgoingPayments(options.period);
processed = payments.length;
for (const payment of payments) {
try {
const mapped = this.mapOutgoingPaymentToTransaction(payment, options);
saved++;
} catch (error) {
failed++;
errors.push({
entity: 'outgoingPayment',
identifier: String(payment.DocNum),
error: error instanceof Error ? error.message : 'Error desconocido',
timestamp: new Date(),
});
}
}
break;
}
case 'journalEntries': {
const entries = await this.financials!.getJournalEntries(options.period);
processed = entries.length;
for (const entry of entries) {
try {
// Los asientos se usan para referencia, no se mapean directamente
saved++;
} catch (error) {
failed++;
errors.push({
entity: 'journalEntry',
identifier: String(entry.JdtNum),
error: error instanceof Error ? error.message : 'Error desconocido',
timestamp: new Date(),
});
}
}
break;
}
case 'customers': {
const customers = await this.sales!.getCustomers({ activeOnly: true });
processed = customers.length;
for (const customer of customers) {
try {
const mapped = this.mapBusinessPartnerToContact(customer, 'customer');
saved++;
} catch (error) {
failed++;
errors.push({
entity: 'customer',
identifier: customer.CardCode,
error: error instanceof Error ? error.message : 'Error desconocido',
timestamp: new Date(),
});
}
}
break;
}
case 'vendors': {
const vendors = await this.purchasing!.getVendors({ activeOnly: true });
processed = vendors.length;
for (const vendor of vendors) {
try {
const mapped = this.mapBusinessPartnerToContact(vendor, 'vendor');
saved++;
} catch (error) {
failed++;
errors.push({
entity: 'vendor',
identifier: vendor.CardCode,
error: error instanceof Error ? error.message : 'Error desconocido',
timestamp: new Date(),
});
}
}
break;
}
case 'items': {
const items = await this.inventory!.getItems({ activeOnly: true });
processed = items.length;
for (const item of items) {
try {
// Los items se sincronizan para referencia
saved++;
} catch (error) {
failed++;
errors.push({
entity: 'item',
identifier: item.ItemCode,
error: error instanceof Error ? error.message : 'Error desconocido',
timestamp: new Date(),
});
}
}
break;
}
}
return { processed, saved, failed, errors };
}
// ==========================================================================
// Mapping Functions
// ==========================================================================
/**
* Mapea una factura de venta SAP a transaccion Horux
*/
mapInvoiceToTransaction(invoice: Invoice, options: SyncOptions): MappedTransaction {
return {
externalId: `SAP_INV_${invoice.DocEntry}`,
externalSource: 'sap_b1',
type: 'income',
amount: invoice.DocTotal,
currency: invoice.DocCurrency || 'MXN',
exchangeRate: invoice.DocRate,
description: `Factura ${invoice.DocNum} - ${invoice.CardName || invoice.CardCode}`,
date: new Date(invoice.DocDate),
reference: invoice.NumAtCard,
contactExternalId: invoice.CardCode,
cfdiUuid: options.useUDFs ? invoice.U_FolioFiscalUUID : undefined,
metadata: {
sapDocEntry: invoice.DocEntry,
sapDocNum: invoice.DocNum,
sapDocType: 'Invoice',
sapSeries: invoice.Series,
sapStatus: invoice.DocumentStatus,
vatSum: invoice.VatSum,
discSum: invoice.DiscSum,
paidToDate: invoice.PaidToDate,
...(options.useUDFs && {
serieCFDI: invoice.U_SerieCFDI,
folioCFDI: invoice.U_FolioCFDI,
}),
},
};
}
/**
* Mapea una nota de credito SAP a transaccion Horux
*/
mapCreditNoteToTransaction(creditNote: CreditNote, options: SyncOptions): MappedTransaction {
return {
externalId: `SAP_CN_${creditNote.DocEntry}`,
externalSource: 'sap_b1',
type: 'expense', // Las notas de credito reducen ingresos
amount: creditNote.DocTotal,
currency: creditNote.DocCurrency || 'MXN',
exchangeRate: creditNote.DocRate,
description: `Nota de Credito ${creditNote.DocNum} - ${creditNote.CardName || creditNote.CardCode}`,
date: new Date(creditNote.DocDate),
reference: creditNote.NumAtCard,
contactExternalId: creditNote.CardCode,
cfdiUuid: options.useUDFs ? creditNote.U_FolioFiscalUUID : undefined,
metadata: {
sapDocEntry: creditNote.DocEntry,
sapDocNum: creditNote.DocNum,
sapDocType: 'CreditNote',
sapSeries: creditNote.Series,
sapStatus: creditNote.DocumentStatus,
},
};
}
/**
* Mapea una factura de compra SAP a transaccion Horux
*/
mapPurchaseInvoiceToTransaction(
purchaseInvoice: PurchaseInvoice,
options: SyncOptions
): MappedTransaction {
return {
externalId: `SAP_PI_${purchaseInvoice.DocEntry}`,
externalSource: 'sap_b1',
type: 'expense',
amount: purchaseInvoice.DocTotal,
currency: purchaseInvoice.DocCurrency || 'MXN',
exchangeRate: purchaseInvoice.DocRate,
description: `Factura Compra ${purchaseInvoice.DocNum} - ${purchaseInvoice.CardName || purchaseInvoice.CardCode}`,
date: new Date(purchaseInvoice.DocDate),
reference: purchaseInvoice.NumAtCard,
contactExternalId: purchaseInvoice.CardCode,
cfdiUuid: options.useUDFs ? purchaseInvoice.U_FolioFiscalUUID : undefined,
metadata: {
sapDocEntry: purchaseInvoice.DocEntry,
sapDocNum: purchaseInvoice.DocNum,
sapDocType: 'PurchaseInvoice',
sapSeries: purchaseInvoice.Series,
sapStatus: purchaseInvoice.DocumentStatus,
vatSum: purchaseInvoice.VatSum,
paidToDate: purchaseInvoice.PaidToDate,
},
};
}
/**
* Mapea un pago recibido SAP a transaccion Horux
*/
mapIncomingPaymentToTransaction(
payment: IncomingPayment,
options: SyncOptions
): MappedTransaction {
const totalAmount = this.calculatePaymentTotal(payment);
return {
externalId: `SAP_IP_${payment.DocEntry}`,
externalSource: 'sap_b1',
type: 'income',
amount: totalAmount,
currency: payment.DocCurrency || 'MXN',
exchangeRate: payment.DocRate,
description: `Pago Recibido ${payment.DocNum} - ${payment.CardName || payment.CardCode || 'Cuenta'}`,
date: new Date(payment.DocDate),
reference: payment.TransferReference,
contactExternalId: payment.CardCode,
metadata: {
sapDocEntry: payment.DocEntry,
sapDocNum: payment.DocNum,
sapDocType: 'IncomingPayment',
sapSeries: payment.Series,
cashSum: payment.CashSum,
transferSum: payment.TransferSum,
checkCount: payment.PaymentChecks?.length || 0,
creditCardCount: payment.PaymentCreditCards?.length || 0,
invoicesCount: payment.PaymentInvoices?.length || 0,
},
};
}
/**
* Mapea un pago emitido SAP a transaccion Horux
*/
mapOutgoingPaymentToTransaction(
payment: OutgoingPayment,
options: SyncOptions
): MappedTransaction {
const totalAmount = this.calculatePaymentTotal(payment);
return {
externalId: `SAP_OP_${payment.DocEntry}`,
externalSource: 'sap_b1',
type: 'expense',
amount: totalAmount,
currency: payment.DocCurrency || 'MXN',
exchangeRate: payment.DocRate,
description: `Pago Emitido ${payment.DocNum} - ${payment.CardName || payment.CardCode || 'Cuenta'}`,
date: new Date(payment.DocDate),
reference: payment.TransferReference,
contactExternalId: payment.CardCode,
metadata: {
sapDocEntry: payment.DocEntry,
sapDocNum: payment.DocNum,
sapDocType: 'OutgoingPayment',
sapSeries: payment.Series,
cashSum: payment.CashSum,
transferSum: payment.TransferSum,
checkCount: payment.PaymentChecks?.length || 0,
invoicesCount: payment.PaymentInvoices?.length || 0,
},
};
}
/**
* Mapea un socio de negocios SAP a contacto Horux
*/
mapBusinessPartnerToContact(
bp: BusinessPartner,
type: 'customer' | 'vendor'
): {
externalId: string;
externalSource: 'sap_b1';
type: 'customer' | 'supplier';
name: string;
rfc?: string;
email?: string;
phone?: string;
address?: string;
creditLimit?: number;
balance?: number;
metadata: Record<string, unknown>;
} {
return {
externalId: `SAP_BP_${bp.CardCode}`,
externalSource: 'sap_b1',
type: type === 'customer' ? 'customer' : 'supplier',
name: bp.CardName,
rfc: bp.FederalTaxID,
email: bp.EmailAddress,
phone: bp.Phone1 || bp.Cellular,
address: bp.Address,
creditLimit: bp.CreditLimit,
balance: bp.Balance,
metadata: {
sapCardCode: bp.CardCode,
sapCardType: bp.CardType,
sapGroupCode: bp.GroupCode,
sapPayTerms: bp.PayTermsGrpCode,
sapSalesPerson: bp.SalesPersonCode,
sapCurrency: bp.Currency,
sapPriceList: bp.PriceListNum,
frozen: bp.Frozen === 'tYES',
valid: bp.Valid === 'tYES',
},
};
}
/**
* Calcula el total de un pago
*/
private calculatePaymentTotal(payment: IncomingPayment | OutgoingPayment): number {
let total = 0;
total += payment.CashSum || 0;
total += payment.TransferSum || 0;
if (payment.PaymentChecks) {
total += payment.PaymentChecks.reduce((sum, c) => sum + (c.CheckSum || 0), 0);
}
if (payment.PaymentCreditCards) {
total += payment.PaymentCreditCards.reduce((sum, c) => sum + (c.CreditSum || 0), 0);
}
return total;
}
// ==========================================================================
// Alerts Generation
// ==========================================================================
/**
* Genera alertas basadas en datos de SAP
*/
async generateAlerts(options: SyncOptions): Promise<SAPAlert[]> {
const alerts: SAPAlert[] = [];
try {
// Alertas de facturas por vencer
const openInvoices = await this.sales!.getOpenInvoices();
const today = new Date();
for (const invoice of openInvoices) {
const dueDate = new Date(invoice.DocDueDate);
const daysUntilDue = Math.floor(
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysUntilDue <= 0) {
alerts.push({
type: 'payment_due',
severity: 'critical',
title: 'Factura Vencida',
message: `Factura ${invoice.DocNum} de ${invoice.CardName} vencida hace ${Math.abs(daysUntilDue)} dias. Saldo: $${(invoice.DocTotal - (invoice.PaidToDate || 0)).toFixed(2)}`,
entityType: 'invoice',
entityId: String(invoice.DocEntry),
metadata: {
cardCode: invoice.CardCode,
docNum: invoice.DocNum,
dueDate: invoice.DocDueDate,
balance: invoice.DocTotal - (invoice.PaidToDate || 0),
},
});
} else if (daysUntilDue <= 7) {
alerts.push({
type: 'payment_due',
severity: 'warning',
title: 'Factura Proxima a Vencer',
message: `Factura ${invoice.DocNum} de ${invoice.CardName} vence en ${daysUntilDue} dias. Saldo: $${(invoice.DocTotal - (invoice.PaidToDate || 0)).toFixed(2)}`,
entityType: 'invoice',
entityId: String(invoice.DocEntry),
metadata: {
cardCode: invoice.CardCode,
docNum: invoice.DocNum,
dueDate: invoice.DocDueDate,
balance: invoice.DocTotal - (invoice.PaidToDate || 0),
},
});
}
}
// Alertas de stock bajo
const lowStockItems = await this.inventory!.getLowStockItems();
for (const item of lowStockItems) {
alerts.push({
type: 'low_stock',
severity: 'warning',
title: 'Stock Bajo',
message: `El articulo ${item.ItemCode} - ${item.ItemName} tiene stock bajo (${item.QuantityOnStock} unidades)`,
entityType: 'item',
entityId: item.ItemCode,
metadata: {
itemCode: item.ItemCode,
itemName: item.ItemName,
currentStock: item.QuantityOnStock,
},
});
}
// Alertas de clientes con credito excedido
const customers = await this.sales!.getCustomers({ withBalance: true });
for (const customer of customers) {
if (
customer.CreditLimit &&
customer.CreditLimit > 0 &&
(customer.Balance || 0) > customer.CreditLimit
) {
alerts.push({
type: 'credit_exceeded',
severity: 'warning',
title: 'Limite de Credito Excedido',
message: `${customer.CardName} ha excedido su limite de credito. Balance: $${customer.Balance?.toFixed(2)}, Limite: $${customer.CreditLimit.toFixed(2)}`,
entityType: 'customer',
entityId: customer.CardCode,
metadata: {
cardCode: customer.CardCode,
cardName: customer.CardName,
balance: customer.Balance,
creditLimit: customer.CreditLimit,
exceeded: (customer.Balance || 0) - customer.CreditLimit,
},
});
}
}
} catch (error) {
logger.error('SAP Sync: Error generando alertas', { error });
}
return alerts;
}
}
// ============================================================================
// Factory Functions
// ============================================================================
/**
* Crea una instancia del servicio de sincronizacion
*/
export function createSAPSyncService(): SAPSyncService {
return new SAPSyncService();
}
/**
* Ejecuta sincronizacion SAP a Horux
*/
export async function syncSAPToHorux(options: SyncOptions): Promise<SAPSyncResult> {
const service = new SAPSyncService();
return service.syncToHorux(options);
}
export default SAPSyncService;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
/**
* Reports Service
*
* Executive report generation system for Horux Strategy.
* Combines financial metrics with AI-generated narratives to produce
* CFO-level reports in PDF format.
*/
// Types
export * from './report.types';
// Templates and formatting
export * from './report.templates';
// AI Prompts
export * from './report.prompts';
// PDF Generator
export {
PDFGenerator,
getPDFGenerator,
createPDFGenerator,
} from './pdf.generator';
// Main Report Generator
export {
ReportGenerator,
getReportGenerator,
createReportGenerator,
} from './report.generator';

View File

@@ -0,0 +1,879 @@
/**
* PDF Generator
*
* Generates professional PDF reports using PDFKit.
* Includes corporate branding, tables, charts (as placeholders), and executive narratives.
*/
import PDFDocument from 'pdfkit';
import { Client as MinioClient } from 'minio';
import { v4 as uuidv4 } from 'uuid';
import {
GeneratedReport,
ReportSection,
PDFGenerationOptions,
PDFGenerationResult,
DEFAULT_PDF_OPTIONS,
ChartData,
TableData,
KPISummary,
MONTH_NAMES_ES,
} from './report.types';
import {
formatCurrencyMX,
formatPercentageMX,
formatNumberMX,
formatPeriod,
formatPeriodRange,
generateChartPlaceholder,
} from './report.templates';
import { config } from '../../config';
// ============================================================================
// PDF Generator Class
// ============================================================================
export class PDFGenerator {
private options: PDFGenerationOptions;
private minioClient: MinioClient | null;
private bucketName: string;
constructor(options?: Partial<PDFGenerationOptions>) {
this.options = { ...DEFAULT_PDF_OPTIONS, ...options };
this.bucketName = config.minio.bucket;
// Initialize MinIO client
try {
this.minioClient = new MinioClient({
endPoint: config.minio.endpoint,
port: config.minio.port,
useSSL: config.minio.useSSL,
accessKey: config.minio.accessKey,
secretKey: config.minio.secretKey,
});
} catch (error) {
console.warn('MinIO client initialization failed, PDF storage will be disabled:', error);
this.minioClient = null;
}
}
/**
* Generate PDF from report
*/
async generatePDF(report: GeneratedReport): Promise<PDFGenerationResult> {
const startTime = Date.now();
// Create PDF document
const doc = new PDFDocument({
size: this.options.pageSize,
layout: this.options.orientation,
margins: this.options.margins,
bufferPages: true,
info: {
Title: report.title,
Author: 'Horux Strategy - CFO Digital',
Subject: `Reporte Financiero ${formatPeriod(report.period)}`,
Creator: 'Horux Strategy',
Producer: 'PDFKit',
CreationDate: new Date(),
},
});
// Collect chunks
const chunks: Buffer[] = [];
doc.on('data', (chunk) => chunks.push(chunk));
// Generate content
await this.generateContent(doc, report);
// Add page numbers
this.addPageNumbers(doc);
// Finalize document
doc.end();
// Wait for PDF generation to complete
const buffer = await new Promise<Buffer>((resolve) => {
doc.on('end', () => {
resolve(Buffer.concat(chunks));
});
});
const pageCount = doc.bufferedPageRange().count;
const generationTime = Date.now() - startTime;
return {
buffer,
fileName: this.generateFileName(report),
mimeType: 'application/pdf',
pageCount,
fileSize: buffer.length,
};
}
/**
* Generate PDF and upload to MinIO
*/
async generateAndStore(report: GeneratedReport): Promise<{
url: string;
size: number;
pageCount: number;
}> {
const result = await this.generatePDF(report);
if (!this.minioClient) {
throw new Error('MinIO client not available');
}
// Ensure bucket exists
const bucketExists = await this.minioClient.bucketExists(this.bucketName);
if (!bucketExists) {
await this.minioClient.makeBucket(this.bucketName);
}
// Generate storage path
const objectName = `reports/${report.tenantId}/${report.config.type}/${result.fileName}`;
// Upload to MinIO
await this.minioClient.putObject(
this.bucketName,
objectName,
result.buffer,
result.fileSize,
{
'Content-Type': result.mimeType,
'x-amz-meta-report-id': report.id,
'x-amz-meta-tenant-id': report.tenantId,
'x-amz-meta-report-type': report.config.type,
'x-amz-meta-generated-at': report.generatedAt.toISOString(),
}
);
// Generate presigned URL (valid for 7 days)
const url = await this.minioClient.presignedGetObject(
this.bucketName,
objectName,
7 * 24 * 60 * 60
);
return {
url,
size: result.fileSize,
pageCount: result.pageCount,
};
}
// ============================================================================
// Content Generation
// ============================================================================
private async generateContent(doc: PDFKit.PDFDocument, report: GeneratedReport): Promise<void> {
// Cover page
this.generateCoverPage(doc, report);
// Table of contents
doc.addPage();
this.generateTableOfContents(doc, report);
// KPI Summary page
doc.addPage();
this.generateKPISummaryPage(doc, report);
// Generate each section
for (const section of report.sections) {
doc.addPage();
this.generateSection(doc, section, report);
}
}
// ============================================================================
// Cover Page
// ============================================================================
private generateCoverPage(doc: PDFKit.PDFDocument, report: GeneratedReport): void {
const { colors, branding } = this.options;
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
const centerX = this.options.margins.left + pageWidth / 2;
// Background header band
doc.rect(0, 0, doc.page.width, 200)
.fill(colors.primary);
// Company name / Logo area
doc.fontSize(14)
.fillColor('#FFFFFF')
.text(branding.companyName.toUpperCase(), this.options.margins.left, 60, {
width: pageWidth,
align: 'center',
});
// Report title
doc.fontSize(32)
.fillColor('#FFFFFF')
.text(report.title, this.options.margins.left, 100, {
width: pageWidth,
align: 'center',
});
// Subtitle
doc.fontSize(14)
.fillColor('#FFFFFF')
.text(report.subtitle, this.options.margins.left, 150, {
width: pageWidth,
align: 'center',
});
// Period information
const periodText = formatPeriodRange(report.dateRange.dateFrom, report.dateRange.dateTo);
doc.fontSize(16)
.fillColor(colors.text)
.text('Periodo del Reporte', this.options.margins.left, 280, {
width: pageWidth,
align: 'center',
});
doc.fontSize(24)
.fillColor(colors.primary)
.text(periodText, this.options.margins.left, 310, {
width: pageWidth,
align: 'center',
});
// Generation info
const generatedDate = report.generatedAt.toLocaleDateString('es-MX', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
doc.fontSize(12)
.fillColor(colors.textLight)
.text(`Generado el ${generatedDate}`, this.options.margins.left, 400, {
width: pageWidth,
align: 'center',
});
// Divider line
doc.moveTo(centerX - 100, 450)
.lineTo(centerX + 100, 450)
.strokeColor(colors.border)
.lineWidth(2)
.stroke();
// Report summary stats
const statsY = 500;
const statWidth = pageWidth / 3;
// Sections count
doc.fontSize(36)
.fillColor(colors.primary)
.text(report.sections.length.toString(), this.options.margins.left, statsY, {
width: statWidth,
align: 'center',
});
doc.fontSize(12)
.fillColor(colors.textLight)
.text('Secciones', this.options.margins.left, statsY + 45, {
width: statWidth,
align: 'center',
});
// Charts count
const chartsCount = report.sections.reduce((sum, s) => sum + (s.charts?.length || 0), 0);
doc.fontSize(36)
.fillColor(colors.primary)
.text(chartsCount.toString(), this.options.margins.left + statWidth, statsY, {
width: statWidth,
align: 'center',
});
doc.fontSize(12)
.fillColor(colors.textLight)
.text('Graficas', this.options.margins.left + statWidth, statsY + 45, {
width: statWidth,
align: 'center',
});
// Recommendations count
const recsCount = report.aiContent?.recommendations?.length || 0;
doc.fontSize(36)
.fillColor(colors.primary)
.text(recsCount.toString(), this.options.margins.left + statWidth * 2, statsY, {
width: statWidth,
align: 'center',
});
doc.fontSize(12)
.fillColor(colors.textLight)
.text('Recomendaciones', this.options.margins.left + statWidth * 2, statsY + 45, {
width: statWidth,
align: 'center',
});
// Footer
doc.fontSize(10)
.fillColor(colors.textLight)
.text(
branding.footerText || 'Generado por Horux Strategy - Tu CFO Digital',
this.options.margins.left,
doc.page.height - this.options.margins.bottom - 20,
{ width: pageWidth, align: 'center' }
);
}
// ============================================================================
// Table of Contents
// ============================================================================
private generateTableOfContents(doc: PDFKit.PDFDocument, report: GeneratedReport): void {
const { colors } = this.options;
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
// Title
doc.fontSize(24)
.fillColor(colors.primary)
.text('Contenido', this.options.margins.left, this.options.margins.top);
doc.moveDown(1);
// Divider
doc.moveTo(this.options.margins.left, doc.y)
.lineTo(this.options.margins.left + pageWidth, doc.y)
.strokeColor(colors.border)
.lineWidth(1)
.stroke();
doc.moveDown(1);
// TOC entries
let pageNumber = 3; // Start after cover and TOC
// KPI Summary
this.addTOCEntry(doc, 'Resumen de KPIs', pageNumber, pageWidth);
pageNumber++;
// Sections
for (const section of report.sections) {
this.addTOCEntry(doc, section.title, pageNumber, pageWidth);
pageNumber++;
}
}
private addTOCEntry(
doc: PDFKit.PDFDocument,
title: string,
pageNumber: number,
pageWidth: number
): void {
const { colors } = this.options;
const startX = this.options.margins.left;
const startY = doc.y;
// Title
doc.fontSize(12)
.fillColor(colors.text)
.text(title, startX, startY, { continued: true });
// Dots
const titleWidth = doc.widthOfString(title);
const pageNumWidth = doc.widthOfString(pageNumber.toString());
const dotsWidth = pageWidth - titleWidth - pageNumWidth - 20;
const dots = '.'.repeat(Math.floor(dotsWidth / doc.widthOfString('.')));
doc.fillColor(colors.textLight)
.text(' ' + dots + ' ', { continued: true });
// Page number
doc.fillColor(colors.text)
.text(pageNumber.toString());
doc.moveDown(0.5);
}
// ============================================================================
// KPI Summary Page
// ============================================================================
private generateKPISummaryPage(doc: PDFKit.PDFDocument, report: GeneratedReport): void {
const { colors } = this.options;
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
// Header
this.addSectionHeader(doc, 'Indicadores Clave (KPIs)');
const kpis = report.kpiSummary;
const currency = report.config.currency || 'MXN';
// Create KPI cards grid (2 columns)
const cardWidth = (pageWidth - 20) / 2;
const cardHeight = 80;
let currentY = doc.y + 20;
let column = 0;
const kpiList = [
{ label: 'Ingresos', kpi: kpis.revenue },
{ label: 'Gastos', kpi: kpis.expenses },
{ label: 'Utilidad Neta', kpi: kpis.netProfit },
{ label: 'Margen de Utilidad', kpi: kpis.profitMargin },
{ label: 'Flujo de Efectivo', kpi: kpis.cashFlow },
{ label: 'Cuentas por Cobrar', kpi: kpis.accountsReceivable },
{ label: 'Cuentas por Pagar', kpi: kpis.accountsPayable },
];
// Add startup metrics if available
if (kpis.mrr) kpiList.push({ label: 'MRR', kpi: kpis.mrr });
if (kpis.arr) kpiList.push({ label: 'ARR', kpi: kpis.arr });
if (kpis.churnRate) kpiList.push({ label: 'Churn Rate', kpi: kpis.churnRate });
if (kpis.runway) kpiList.push({ label: 'Runway', kpi: kpis.runway });
// Add enterprise metrics if available
if (kpis.ebitda) kpiList.push({ label: 'EBITDA', kpi: kpis.ebitda });
if (kpis.currentRatio) kpiList.push({ label: 'Ratio Corriente', kpi: kpis.currentRatio });
if (kpis.quickRatio) kpiList.push({ label: 'Prueba Acida', kpi: kpis.quickRatio });
for (const { label, kpi } of kpiList) {
const x = this.options.margins.left + column * (cardWidth + 20);
this.drawKPICard(doc, x, currentY, cardWidth, cardHeight, label, kpi);
column++;
if (column >= 2) {
column = 0;
currentY += cardHeight + 15;
// Check for page break
if (currentY + cardHeight > doc.page.height - this.options.margins.bottom - 50) {
doc.addPage();
currentY = this.options.margins.top;
}
}
}
}
private drawKPICard(
doc: PDFKit.PDFDocument,
x: number,
y: number,
width: number,
height: number,
label: string,
kpi: {
formatted: string;
formattedChange?: string;
trend: 'up' | 'down' | 'stable';
status: 'good' | 'warning' | 'critical' | 'neutral';
unit?: string;
}
): void {
const { colors } = this.options;
// Card background
doc.roundedRect(x, y, width, height, 5)
.fillColor('#F9FAFB')
.fill();
// Status indicator
const statusColors = {
good: colors.success,
warning: colors.warning,
critical: colors.danger,
neutral: colors.textLight,
};
doc.roundedRect(x, y, 4, height, 2)
.fillColor(statusColors[kpi.status])
.fill();
// Label
doc.fontSize(10)
.fillColor(colors.textLight)
.text(label, x + 15, y + 12);
// Value
doc.fontSize(20)
.fillColor(colors.text)
.text(kpi.formatted + (kpi.unit ? ` ${kpi.unit}` : ''), x + 15, y + 30);
// Change indicator
if (kpi.formattedChange) {
const trendColor = kpi.trend === 'up' ? colors.success :
kpi.trend === 'down' ? colors.danger : colors.textLight;
const trendArrow = kpi.trend === 'up' ? '+' :
kpi.trend === 'down' ? '' : '=';
doc.fontSize(11)
.fillColor(trendColor)
.text(`${trendArrow}${kpi.formattedChange}`, x + 15, y + 55);
}
}
// ============================================================================
// Section Generation
// ============================================================================
private generateSection(
doc: PDFKit.PDFDocument,
section: ReportSection,
report: GeneratedReport
): void {
// Section header
this.addSectionHeader(doc, section.title);
// Narrative text
if (section.narrative) {
this.addNarrative(doc, section.narrative);
}
// Tables
if (section.tables && section.tables.length > 0) {
for (const table of section.tables) {
this.addTable(doc, table);
}
}
// Charts (as placeholders)
if (section.charts && section.charts.length > 0) {
for (const chart of section.charts) {
this.addChartPlaceholder(doc, chart);
}
}
}
private addSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
const { colors } = this.options;
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
// Title with accent bar
doc.rect(this.options.margins.left, this.options.margins.top, 4, 28)
.fillColor(colors.primary)
.fill();
doc.fontSize(20)
.fillColor(colors.text)
.text(title, this.options.margins.left + 15, this.options.margins.top + 5);
// Divider line
doc.moveTo(this.options.margins.left, this.options.margins.top + 40)
.lineTo(this.options.margins.left + pageWidth, this.options.margins.top + 40)
.strokeColor(colors.border)
.lineWidth(0.5)
.stroke();
doc.y = this.options.margins.top + 55;
}
private addNarrative(doc: PDFKit.PDFDocument, text: string): void {
const { colors } = this.options;
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
// Check for page break
this.checkPageBreak(doc, 100);
doc.fontSize(11)
.fillColor(colors.text)
.text(text, this.options.margins.left, doc.y, {
width: pageWidth,
align: 'justify',
lineGap: 4,
});
doc.moveDown(1.5);
}
// ============================================================================
// Table Generation
// ============================================================================
private addTable(doc: PDFKit.PDFDocument, table: TableData): void {
const { colors } = this.options;
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
if (table.headers.length === 0) return;
// Table title
if (table.title) {
this.checkPageBreak(doc, 50);
doc.fontSize(14)
.fillColor(colors.text)
.text(table.title, this.options.margins.left, doc.y);
doc.moveDown(0.5);
}
const columnCount = table.headers.length;
const columnWidth = pageWidth / columnCount;
const rowHeight = 25;
const headerHeight = 30;
// Estimate table height
const tableHeight = headerHeight + (table.rows.length + (table.totals ? 1 : 0)) * rowHeight;
this.checkPageBreak(doc, tableHeight);
let currentY = doc.y;
// Header background
doc.rect(this.options.margins.left, currentY, pageWidth, headerHeight)
.fillColor(colors.primary)
.fill();
// Header text
doc.fontSize(10)
.fillColor('#FFFFFF');
for (let i = 0; i < table.headers.length; i++) {
const x = this.options.margins.left + i * columnWidth + 5;
doc.text(table.headers[i], x, currentY + 9, {
width: columnWidth - 10,
align: i === 0 ? 'left' : 'right',
});
}
currentY += headerHeight;
// Data rows
for (let rowIdx = 0; rowIdx < table.rows.length; rowIdx++) {
const row = table.rows[rowIdx];
// Alternating row background
if (rowIdx % 2 === 1) {
doc.rect(this.options.margins.left, currentY, pageWidth, rowHeight)
.fillColor('#F9FAFB')
.fill();
}
// Highlighted row
if (row.isHighlighted) {
doc.rect(this.options.margins.left, currentY, pageWidth, rowHeight)
.fillColor('#EFF6FF')
.fill();
}
// Cell content
doc.fontSize(10);
for (let colIdx = 0; colIdx < row.cells.length; colIdx++) {
const cell = row.cells[colIdx];
const x = this.options.margins.left + colIdx * columnWidth + 5;
doc.fillColor(cell.color || colors.text);
if (cell.isBold) {
doc.font('Helvetica-Bold');
} else {
doc.font('Helvetica');
}
doc.text(cell.formatted, x, currentY + 7, {
width: columnWidth - 10,
align: cell.alignment || (colIdx === 0 ? 'left' : 'right'),
});
}
currentY += rowHeight;
}
// Totals row
if (table.totals) {
// Totals background
doc.rect(this.options.margins.left, currentY, pageWidth, rowHeight)
.fillColor(colors.primary)
.fill();
doc.fontSize(10)
.font('Helvetica-Bold')
.fillColor('#FFFFFF');
for (let colIdx = 0; colIdx < table.totals.cells.length; colIdx++) {
const cell = table.totals.cells[colIdx];
const x = this.options.margins.left + colIdx * columnWidth + 5;
doc.text(cell.formatted, x, currentY + 7, {
width: columnWidth - 10,
align: cell.alignment || (colIdx === 0 ? 'left' : 'right'),
});
}
currentY += rowHeight;
}
// Reset font
doc.font('Helvetica');
// Footnotes
if (table.footnotes && table.footnotes.length > 0) {
doc.fontSize(9)
.fillColor(colors.textLight);
for (const footnote of table.footnotes) {
doc.text(footnote, this.options.margins.left, currentY + 5);
currentY += 15;
}
}
doc.y = currentY + 20;
}
// ============================================================================
// Chart Placeholder
// ============================================================================
private addChartPlaceholder(doc: PDFKit.PDFDocument, chart: ChartData): void {
const { colors } = this.options;
const pageWidth = doc.page.width - this.options.margins.left - this.options.margins.right;
const placeholder = generateChartPlaceholder(chart);
// Estimate height
const height = 60 + placeholder.dataPoints.length * 25;
this.checkPageBreak(doc, height);
const startY = doc.y;
// Chart container
doc.roundedRect(this.options.margins.left, startY, pageWidth, height, 5)
.strokeColor(colors.border)
.lineWidth(1)
.stroke();
// Title
doc.fontSize(12)
.fillColor(colors.text)
.text(placeholder.title, this.options.margins.left + 15, startY + 15);
// Chart type indicator
doc.fontSize(9)
.fillColor(colors.textLight)
.text(`[Grafica tipo: ${placeholder.type}]`, this.options.margins.left + 15, startY + 32);
// Data representation
let dataY = startY + 55;
const barMaxWidth = pageWidth - 200;
// Find max value for scaling
const maxValue = Math.max(...placeholder.dataPoints.map(d => {
const num = parseFloat(d.value.replace(/[^0-9.-]/g, ''));
return isNaN(num) ? 0 : Math.abs(num);
}));
for (const point of placeholder.dataPoints.slice(0, 8)) {
// Label
doc.fontSize(9)
.fillColor(colors.text)
.text(point.label, this.options.margins.left + 15, dataY, {
width: 120,
align: 'left',
});
// Value bar
const numValue = parseFloat(point.value.replace(/[^0-9.-]/g, ''));
const barWidth = maxValue > 0 ? (Math.abs(numValue) / maxValue) * barMaxWidth : 0;
doc.rect(this.options.margins.left + 140, dataY, Math.max(barWidth, 2), 12)
.fillColor(numValue >= 0 ? colors.primary : colors.danger)
.fill();
// Value text
doc.fontSize(9)
.fillColor(colors.text)
.text(point.value, this.options.margins.left + 145 + barWidth, dataY);
if (point.percentage) {
doc.fillColor(colors.textLight)
.text(` (${point.percentage})`, { continued: false });
}
dataY += 20;
}
// Total if available
if (placeholder.total) {
doc.fontSize(10)
.fillColor(colors.text)
.text(`Total: ${placeholder.total}`, this.options.margins.left + 15, startY + height - 25);
}
doc.y = startY + height + 20;
}
// ============================================================================
// Page Management
// ============================================================================
private checkPageBreak(doc: PDFKit.PDFDocument, requiredSpace: number): void {
const availableSpace = doc.page.height - doc.y - this.options.margins.bottom - this.options.footerHeight;
if (availableSpace < requiredSpace) {
doc.addPage();
doc.y = this.options.margins.top;
}
}
private addPageNumbers(doc: PDFKit.PDFDocument): void {
const { colors } = this.options;
const range = doc.bufferedPageRange();
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
// Skip cover page
if (i === 0) continue;
const pageNum = i + 1;
const totalPages = range.count;
// Footer line
doc.moveTo(this.options.margins.left, doc.page.height - this.options.margins.bottom)
.lineTo(doc.page.width - this.options.margins.right, doc.page.height - this.options.margins.bottom)
.strokeColor(colors.border)
.lineWidth(0.5)
.stroke();
// Page number
doc.fontSize(9)
.fillColor(colors.textLight)
.text(
`Pagina ${pageNum} de ${totalPages}`,
this.options.margins.left,
doc.page.height - this.options.margins.bottom + 10,
{
width: doc.page.width - this.options.margins.left - this.options.margins.right,
align: 'center',
}
);
// Footer text
doc.fontSize(8)
.text(
this.options.branding.footerText || 'Horux Strategy - Tu CFO Digital',
this.options.margins.left,
doc.page.height - this.options.margins.bottom + 25,
{
width: doc.page.width - this.options.margins.left - this.options.margins.right,
align: 'center',
}
);
}
}
// ============================================================================
// Utilities
// ============================================================================
private generateFileName(report: GeneratedReport): string {
const date = new Date().toISOString().split('T')[0];
const periodStr = formatPeriod(report.period).replace(/\s+/g, '-').toLowerCase();
return `reporte-${report.config.type}-${periodStr}-${date}.pdf`;
}
}
// ============================================================================
// Factory Function
// ============================================================================
let pdfGeneratorInstance: PDFGenerator | null = null;
export function getPDFGenerator(options?: Partial<PDFGenerationOptions>): PDFGenerator {
if (!pdfGeneratorInstance) {
pdfGeneratorInstance = new PDFGenerator(options);
}
return pdfGeneratorInstance;
}
export function createPDFGenerator(options?: Partial<PDFGenerationOptions>): PDFGenerator {
return new PDFGenerator(options);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,472 @@
/**
* Report Prompts for DeepSeek AI
*
* Optimized prompts for generating executive financial narratives.
* All prompts are designed for a Mexican CFO digital context.
*/
// ============================================================================
// System Context
// ============================================================================
export const SYSTEM_CONTEXT = `Eres un CFO digital experto en finanzas corporativas mexicanas.
Tu rol es analizar metricas financieras y generar narrativas ejecutivas claras, profesionales y accionables.
Caracteristicas de tu comunicacion:
- Lenguaje profesional pero accesible
- Enfocado en insights accionables
- Contexto mexicano (pesos, regulaciones SAT, practicas locales)
- Directo y conciso, sin relleno
- Siempre basado en datos, nunca especulacion
- Usa formato de numeros mexicano (comas para miles, punto para decimales)
Formato de respuesta:
- Parrafos cortos y faciles de escanear
- Puntos clave resaltados
- Recomendaciones concretas con pasos de accion
- Sin emojis ni elementos informales`;
// ============================================================================
// Executive Summary Prompt
// ============================================================================
export const EXECUTIVE_SUMMARY_PROMPT = `Genera un resumen ejecutivo para el reporte financiero del periodo {{period}}.
DATOS DEL PERIODO:
- Ingresos: {{revenue}} ({{revenueChange}} vs periodo anterior)
- Gastos: {{expenses}} ({{expenseChange}} vs periodo anterior)
- Utilidad Neta: {{netProfit}} ({{profitChange}} vs periodo anterior)
- Margen de Utilidad: {{profitMargin}}
- Flujo de Efectivo Neto: {{cashFlow}}
- Cuentas por Cobrar: {{accountsReceivable}}
- Cuentas por Pagar: {{accountsPayable}}
{{#if hasStartupMetrics}}
- MRR: {{mrr}}
- ARR: {{arr}}
- Churn Rate: {{churnRate}}
- Runway: {{runway}} meses
{{/if}}
{{#if hasEnterpriseMetrics}}
- EBITDA: {{ebitda}}
- Ratio Corriente: {{currentRatio}}
- Prueba Acida: {{quickRatio}}
{{/if}}
ANOMALIAS DETECTADAS: {{anomaliesCount}}
{{#each anomalies}}
- {{this.description}} (Severidad: {{this.severity}})
{{/each}}
INSTRUCCIONES:
1. Resume la salud financiera general de la empresa en 2-3 oraciones
2. Destaca los 3 puntos mas importantes del periodo (positivos o negativos)
3. Menciona brevemente las areas que requieren atencion
4. Incluye una perspectiva de corto plazo
El resumen debe ser de maximo 250 palabras, profesional y orientado a la toma de decisiones.`;
// ============================================================================
// Cash Flow Analysis Prompt
// ============================================================================
export const CASH_FLOW_ANALYSIS_PROMPT = `Analiza el flujo de efectivo del periodo {{period}}.
DATOS DE FLUJO DE EFECTIVO:
- Flujo Neto: {{netCashFlow}}
- Actividades Operativas: {{operatingActivities}}
- Actividades de Inversion: {{investingActivities}}
- Actividades de Financiamiento: {{financingActivities}}
- Saldo Inicial: {{openingBalance}}
- Saldo Final: {{closingBalance}}
DESGLOSE DIARIO/SEMANAL:
{{#each breakdown}}
- {{this.date}}: Entradas {{this.inflow}}, Salidas {{this.outflow}}, Neto {{this.netFlow}}
{{/each}}
COMPARACION CON PERIODO ANTERIOR:
- Cambio en flujo neto: {{cashFlowChange}} ({{cashFlowChangePercent}})
INSTRUCCIONES:
1. Evalua la salud del flujo de efectivo
2. Identifica patrones en las entradas y salidas
3. Analiza la composicion del flujo (operativo vs inversion vs financiamiento)
4. Identifica periodos de estres de liquidez si los hay
5. Proporciona 2-3 recomendaciones para optimizar el flujo
El analisis debe ser de 150-200 palabras, enfocado en insights accionables.`;
// ============================================================================
// Revenue Analysis Prompt
// ============================================================================
export const REVENUE_ANALYSIS_PROMPT = `Analiza los ingresos del periodo {{period}}.
DATOS DE INGRESOS:
- Ingresos Totales: {{totalRevenue}}
- Numero de Facturas: {{invoiceCount}}
- Valor Promedio de Factura: {{averageInvoiceValue}}
- Cambio vs Periodo Anterior: {{revenueChange}} ({{revenueChangePercent}})
DESGLOSE POR CATEGORIA:
{{#each byCategory}}
- {{this.categoryName}}: {{this.amount}} ({{this.percentage}}%)
{{/each}}
{{#if hasProducts}}
TOP PRODUCTOS/SERVICIOS:
{{#each byProduct}}
- {{this.productName}}: {{this.amount}} ({{this.quantity}} unidades)
{{/each}}
{{/if}}
INSTRUCCIONES:
1. Evalua el desempeno de ingresos vs periodo anterior
2. Identifica las categorias o productos que mas contribuyen
3. Detecta tendencias o cambios significativos
4. Analiza la concentracion de ingresos (dependencia de pocos clientes/productos)
5. Proporciona 2-3 recomendaciones para crecimiento
El analisis debe ser de 150-200 palabras, orientado a estrategia comercial.`;
// ============================================================================
// Expense Analysis Prompt
// ============================================================================
export const EXPENSE_ANALYSIS_PROMPT = `Analiza los gastos del periodo {{period}}.
DATOS DE GASTOS:
- Gastos Totales: {{totalExpenses}}
- Gastos Fijos: {{fixedExpenses}} ({{fixedPercentage}}%)
- Gastos Variables: {{variableExpenses}} ({{variablePercentage}}%)
- Numero de Transacciones: {{expenseCount}}
- Cambio vs Periodo Anterior: {{expenseChange}} ({{expenseChangePercent}})
DESGLOSE POR CATEGORIA:
{{#each byCategory}}
- {{this.categoryName}}: {{this.amount}} ({{this.percentage}}%)
{{/each}}
RATIO GASTOS/INGRESOS: {{expenseToRevenueRatio}}%
INSTRUCCIONES:
1. Evalua la estructura de gastos (fijos vs variables)
2. Identifica categorias con mayor crecimiento o que requieren atencion
3. Compara con benchmarks de la industria si es relevante
4. Analiza la eficiencia operativa
5. Proporciona 2-3 recomendaciones de optimizacion
El analisis debe ser de 150-200 palabras, enfocado en control de costos.`;
// ============================================================================
// Profit Analysis Prompt
// ============================================================================
export const PROFIT_ANALYSIS_PROMPT = `Analiza la rentabilidad del periodo {{period}}.
DATOS DE RENTABILIDAD:
- Ingresos: {{revenue}}
- Costo de Ventas: {{cogs}}
- Utilidad Bruta: {{grossProfit}} (Margen: {{grossMargin}}%)
- Gastos Operativos: {{operatingExpenses}}
- Utilidad Neta: {{netProfit}} (Margen: {{netMargin}}%)
- Cambio en Utilidad Neta: {{profitChange}} ({{profitChangePercent}})
TENDENCIA DE MARGENES (ultimos periodos):
{{#each marginTrend}}
- {{this.period}}: Margen Bruto {{this.grossMargin}}%, Margen Neto {{this.netMargin}}%
{{/each}}
INSTRUCCIONES:
1. Evalua la salud de los margenes de rentabilidad
2. Identifica factores que impactan positiva o negativamente la utilidad
3. Compara margenes bruto vs neto para entender eficiencia operativa
4. Analiza la tendencia de rentabilidad
5. Proporciona 2-3 recomendaciones para mejorar margenes
El analisis debe ser de 150-200 palabras, enfocado en maximizar rentabilidad.`;
// ============================================================================
// Recommendations Prompt
// ============================================================================
export const RECOMMENDATIONS_PROMPT = `Genera recomendaciones estrategicas basadas en el analisis financiero del periodo {{period}}.
RESUMEN DE METRICAS CLAVE:
- Ingresos: {{revenue}} ({{revenueChange}})
- Utilidad Neta: {{netProfit}} ({{profitChange}})
- Flujo de Efectivo: {{cashFlow}} ({{cashFlowChange}})
- Margen de Utilidad: {{profitMargin}}
{{#if hasStartupMetrics}}
- Runway: {{runway}} meses
- Burn Rate: {{burnRate}}
- Churn Rate: {{churnRate}}
{{/if}}
{{#if hasEnterpriseMetrics}}
- EBITDA: {{ebitda}}
- Ratio Corriente: {{currentRatio}}
{{/if}}
ANOMALIAS DETECTADAS:
{{#each anomalies}}
- {{this.description}} ({{this.severity}})
{{/each}}
FORTALEZAS IDENTIFICADAS:
{{#each strengths}}
- {{this}}
{{/each}}
AREAS DE MEJORA:
{{#each weaknesses}}
- {{this}}
{{/each}}
INSTRUCCIONES:
Genera exactamente 5 recomendaciones priorizadas con el siguiente formato:
1. [PRIORIDAD ALTA/MEDIA/BAJA] Titulo de la recomendacion
- Descripcion breve del problema u oportunidad
- Accion especifica recomendada
- Impacto esperado
- Plazo sugerido de implementacion
Las recomendaciones deben ser:
- Especificas y accionables
- Basadas en los datos proporcionados
- Contextualizadas para el mercado mexicano
- Priorizadas por impacto y urgencia`;
// ============================================================================
// Anomaly Explanation Prompt
// ============================================================================
export const ANOMALY_EXPLANATION_PROMPT = `Explica la siguiente anomalia financiera detectada.
ANOMALIA:
- Metrica: {{metric}}
- Tipo: {{type}}
- Severidad: {{severity}}
- Valor Actual: {{currentValue}}
- Valor Esperado: {{expectedValue}}
- Desviacion: {{deviation}} ({{deviationPercent}}%)
- Periodo: {{period}}
CONTEXTO ADICIONAL:
- Tendencia historica: {{historicalTrend}}
- Factores externos conocidos: {{externalFactors}}
INSTRUCCIONES:
1. Explica en terminos simples que significa esta anomalia
2. Proporciona 2-3 posibles causas (basadas en patrones tipicos)
3. Evalua el nivel de riesgo o impacto
4. Sugiere 2-3 acciones inmediatas a considerar
La explicacion debe ser de 100-150 palabras, clara y sin jerga tecnica excesiva.`;
// ============================================================================
// Startup Metrics Analysis Prompt
// ============================================================================
export const STARTUP_METRICS_PROMPT = `Analiza las metricas SaaS/Startup del periodo {{period}}.
METRICAS DE INGRESOS RECURRENTES:
- MRR: {{mrr}}
- ARR: {{arr}}
- Net New MRR: {{netNewMRR}}
- MRR de Nuevos Clientes: {{newMRR}}
- MRR de Expansion: {{expansionMRR}}
- MRR Perdido (Churn): {{churnedMRR}}
- ARPU: {{arpu}}
METRICAS DE CLIENTES:
- Clientes Totales: {{customerCount}}
- Churn Rate: {{churnRate}}
- Clientes Perdidos: {{churnedCustomers}}
METRICAS DE EFICIENCIA:
- CAC: {{cac}}
- LTV: {{ltv}}
- Ratio LTV/CAC: {{ltvCacRatio}}
- Payback Period: {{paybackPeriod}} meses
RUNWAY Y BURN:
- Cash Actual: {{currentCash}}
- Burn Rate Mensual: {{burnRate}}
- Runway: {{runway}} meses
- Fecha Proyectada Sin Cash: {{zeroDate}}
INSTRUCCIONES:
1. Evalua la salud general de las metricas SaaS
2. Analiza la eficiencia de adquisicion de clientes (CAC, LTV/CAC)
3. Evalua la retencion y el churn
4. Analiza el runway y la sostenibilidad
5. Proporciona 3 recomendaciones especificas para startups
El analisis debe ser de 200-250 palabras, usando terminologia SaaS apropiada.`;
// ============================================================================
// Enterprise Metrics Analysis Prompt
// ============================================================================
export const ENTERPRISE_METRICS_PROMPT = `Analiza las metricas financieras enterprise del periodo {{period}}.
METRICAS DE RENTABILIDAD:
- EBITDA: {{ebitda}} (Margen: {{ebitdaMargin}}%)
- Ingreso Operativo: {{operatingIncome}}
- Depreciacion: {{depreciation}}
- Amortizacion: {{amortization}}
RETORNOS:
- ROI: {{roi}}%
- ROE: {{roe}}%
- Inversion Total: {{totalInvestment}}
- Capital Contable: {{shareholdersEquity}}
RATIOS DE LIQUIDEZ:
- Ratio Corriente: {{currentRatio}} (Activos: {{currentAssets}}, Pasivos: {{currentLiabilities}})
- Prueba Acida: {{quickRatio}} (Sin inventario: {{quickAssets}})
- Interpretacion: {{liquidityInterpretation}}
RATIO DE DEUDA:
- Ratio de Deuda: {{debtRatio}}%
- Deuda Total: {{totalDebt}}
- Activos Totales: {{totalAssets}}
- Interpretacion: {{debtInterpretation}}
INSTRUCCIONES:
1. Evalua la salud financiera desde perspectiva enterprise
2. Analiza la rentabilidad operativa (EBITDA y margenes)
3. Evalua la eficiencia de retornos (ROI, ROE)
4. Analiza la posicion de liquidez
5. Evalua el apalancamiento y riesgo de deuda
6. Proporciona 3 recomendaciones para optimizacion
El analisis debe ser de 200-250 palabras, con enfoque en gobernanza corporativa.`;
// ============================================================================
// Forecast Prompt
// ============================================================================
export const FORECAST_PROMPT = `Genera un pronostico financiero basado en las tendencias observadas.
DATOS HISTORICOS (ultimos 6 periodos):
{{#each historicalData}}
- {{this.period}}: Ingresos {{this.revenue}}, Gastos {{this.expenses}}, Utilidad {{this.profit}}
{{/each}}
TENDENCIAS IDENTIFICADAS:
- Tendencia de Ingresos: {{revenueTrend}} ({{revenueTrendPercent}}% promedio)
- Tendencia de Gastos: {{expenseTrend}} ({{expenseTrendPercent}}% promedio)
- Estacionalidad Detectada: {{seasonality}}
FACTORES CONOCIDOS:
{{#each knownFactors}}
- {{this}}
{{/each}}
INSTRUCCIONES:
1. Proyecta los ingresos para los proximos 3 periodos
2. Proyecta los gastos para los proximos 3 periodos
3. Estima la utilidad proyectada
4. Identifica riesgos para el pronostico
5. Sugiere escenarios optimista/pesimista
El pronostico debe incluir:
- Proyecciones numericas especificas
- Nivel de confianza de las proyecciones
- Supuestos clave
- Factores de riesgo
Usa lenguaje cauteloso apropiado para proyecciones financieras (150-200 palabras).`;
// ============================================================================
// Prompt Builder Helper
// ============================================================================
export interface PromptVariables {
[key: string]: string | number | boolean | unknown[] | undefined;
}
/**
* Builds a prompt by replacing variables in a template
*/
export function buildPrompt(template: string, variables: PromptVariables): string {
let result = template;
// Handle conditional blocks {{#if variable}}...{{/if}}
result = result.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (_, varName, content) => {
const value = variables[varName];
if (value && (typeof value === 'boolean' ? value : true)) {
return content;
}
return '';
});
// Handle each blocks {{#each array}}...{{/each}}
result = result.replace(/\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g, (_, varName, content) => {
const array = variables[varName];
if (!Array.isArray(array)) return '';
return array
.map((item) => {
let itemContent = content;
if (typeof item === 'object' && item !== null) {
// Replace {{this.property}} with actual values
Object.entries(item).forEach(([key, value]) => {
itemContent = itemContent.replace(
new RegExp(`\\{\\{this\\.${key}\\}\\}`, 'g'),
String(value ?? '')
);
});
} else {
itemContent = itemContent.replace(/\{\{this\}\}/g, String(item));
}
return itemContent;
})
.join('');
});
// Handle simple variable replacements {{variable}}
result = result.replace(/\{\{(\w+)\}\}/g, (_, varName) => {
const value = variables[varName];
if (value === undefined || value === null) return '';
return String(value);
});
// Clean up extra whitespace
result = result.replace(/\n{3,}/g, '\n\n').trim();
return result;
}
/**
* Formats currency for prompts (Mexican peso format)
*/
export function formatCurrencyForPrompt(amount: number, currency: string = 'MXN'): string {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
/**
* Formats percentage for prompts
*/
export function formatPercentageForPrompt(value: number): string {
const sign = value > 0 ? '+' : '';
return `${sign}${value.toFixed(1)}%`;
}
/**
* Formats change description for prompts
*/
export function formatChangeForPrompt(change: number, changePercent: number): string {
if (Math.abs(changePercent) < 1) {
return 'sin cambio significativo';
}
const direction = change > 0 ? 'aumento' : 'disminucion';
return `${direction} de ${formatPercentageForPrompt(Math.abs(changePercent))}`;
}

View File

@@ -0,0 +1,885 @@
/**
* Report Templates
*
* Templates and formatting utilities for executive reports.
* Supports PYME, Startup, and Enterprise company types with Mexican localization.
*/
import {
ReportSection,
ReportSectionType,
CompanyType,
ChartData,
ChartConfig,
TableData,
TableRow,
TableCell,
KPISummary,
KPIValue,
ReportMetricsData,
MONTH_NAMES_ES,
QUARTER_NAMES_ES,
DEFAULT_REPORT_SECTIONS,
} from './report.types';
import {
MetricPeriod,
formatCurrency,
formatPercentage,
formatNumber,
} from '../metrics/metrics.types';
// ============================================================================
// Number Formatting (Spanish Mexican)
// ============================================================================
/**
* Format number in Mexican Spanish locale
*/
export function formatNumberMX(value: number, decimals: number = 0): string {
return new Intl.NumberFormat('es-MX', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}
/**
* Format currency in Mexican Pesos
*/
export function formatCurrencyMX(amount: number, currency: string = 'MXN'): string {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
/**
* Format currency with decimals
*/
export function formatCurrencyMXDecimals(amount: number, currency: string = 'MXN'): string {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
/**
* Format percentage in Mexican format
*/
export function formatPercentageMX(value: number, decimals: number = 1): string {
return `${formatNumberMX(value, decimals)}%`;
}
/**
* Format percentage with sign
*/
export function formatPercentageWithSign(value: number, decimals: number = 1): string {
const sign = value > 0 ? '+' : '';
return `${sign}${formatPercentageMX(value, decimals)}`;
}
/**
* Format large numbers in compact form (K, M, B)
*/
export function formatCompactNumber(value: number): string {
if (Math.abs(value) >= 1_000_000_000) {
return `${formatNumberMX(value / 1_000_000_000, 1)}B`;
}
if (Math.abs(value) >= 1_000_000) {
return `${formatNumberMX(value / 1_000_000, 1)}M`;
}
if (Math.abs(value) >= 1_000) {
return `${formatNumberMX(value / 1_000, 1)}K`;
}
return formatNumberMX(value, 0);
}
// ============================================================================
// Period Formatting
// ============================================================================
/**
* Format period for display
*/
export function formatPeriod(period: MetricPeriod): string {
switch (period.type) {
case 'daily':
return `${period.day} de ${MONTH_NAMES_ES.long[(period.month || 1) - 1]} ${period.year}`;
case 'weekly':
return `Semana ${period.week} de ${period.year}`;
case 'monthly':
return `${MONTH_NAMES_ES.long[(period.month || 1) - 1]} ${period.year}`;
case 'quarterly':
return `${QUARTER_NAMES_ES[(period.quarter || 1) - 1]} ${period.year}`;
case 'yearly':
return `Ano Fiscal ${period.year}`;
default:
return `${period.year}`;
}
}
/**
* Format period range for display
*/
export function formatPeriodRange(dateFrom: Date, dateTo: Date): string {
const fromDay = dateFrom.getDate();
const fromMonth = MONTH_NAMES_ES.long[dateFrom.getMonth()];
const fromYear = dateFrom.getFullYear();
const toDay = dateTo.getDate();
const toMonth = MONTH_NAMES_ES.long[dateTo.getMonth()];
const toYear = dateTo.getFullYear();
if (fromYear === toYear && fromMonth === toMonth) {
return `${fromDay} al ${toDay} de ${fromMonth} ${fromYear}`;
}
if (fromYear === toYear) {
return `${fromDay} de ${fromMonth} al ${toDay} de ${toMonth} ${fromYear}`;
}
return `${fromDay} de ${fromMonth} ${fromYear} al ${toDay} de ${toMonth} ${toYear}`;
}
/**
* Get report title based on type and period
*/
export function getReportTitle(type: string, period: MetricPeriod): string {
const periodStr = formatPeriod(period);
switch (type) {
case 'monthly':
return `Reporte Financiero Mensual - ${periodStr}`;
case 'quarterly':
return `Reporte Financiero Trimestral - ${periodStr}`;
case 'annual':
return `Reporte Financiero Anual - ${periodStr}`;
case 'custom':
return `Reporte Financiero Personalizado`;
default:
return `Reporte Financiero - ${periodStr}`;
}
}
// ============================================================================
// Section Templates
// ============================================================================
/**
* Get section title and order
*/
export function getSectionConfig(sectionType: ReportSectionType): {
title: string;
order: number;
} {
const configs: Record<ReportSectionType, { title: string; order: number }> = {
executive_summary: { title: 'Resumen Ejecutivo', order: 1 },
kpis_overview: { title: 'Indicadores Clave (KPIs)', order: 2 },
revenue_analysis: { title: 'Analisis de Ingresos', order: 3 },
expense_analysis: { title: 'Analisis de Gastos', order: 4 },
profit_analysis: { title: 'Analisis de Rentabilidad', order: 5 },
cash_flow_analysis: { title: 'Analisis de Flujo de Efectivo', order: 6 },
accounts_receivable: { title: 'Cuentas por Cobrar', order: 7 },
accounts_payable: { title: 'Cuentas por Pagar', order: 8 },
startup_metrics: { title: 'Metricas Startup/SaaS', order: 9 },
enterprise_metrics: { title: 'Metricas Corporativas', order: 10 },
anomalies: { title: 'Alertas y Anomalias', order: 11 },
recommendations: { title: 'Recomendaciones', order: 12 },
forecast: { title: 'Proyecciones', order: 13 },
};
return configs[sectionType] || { title: sectionType, order: 99 };
}
/**
* Get sections for company type
*/
export function getSectionsForCompanyType(companyType: CompanyType): ReportSectionType[] {
return DEFAULT_REPORT_SECTIONS[companyType];
}
// ============================================================================
// KPI Templates
// ============================================================================
/**
* Determine KPI status based on value and trend
*/
export function getKPIStatus(
value: number,
change: number,
metricType: string
): 'good' | 'warning' | 'critical' | 'neutral' {
// Metrics where higher is better
const higherIsBetter = ['revenue', 'netProfit', 'profitMargin', 'cashFlow', 'mrr', 'arr', 'ebitda', 'currentRatio', 'quickRatio'];
// Metrics where lower is better
const lowerIsBetter = ['expenses', 'churnRate', 'burnRate', 'accountsPayable'];
// Metrics that are neutral (context-dependent)
const neutral = ['accountsReceivable'];
if (neutral.includes(metricType)) {
return 'neutral';
}
if (higherIsBetter.includes(metricType)) {
if (change > 5) return 'good';
if (change < -10) return 'critical';
if (change < -5) return 'warning';
return 'neutral';
}
if (lowerIsBetter.includes(metricType)) {
if (change < -5) return 'good';
if (change > 10) return 'critical';
if (change > 5) return 'warning';
return 'neutral';
}
return 'neutral';
}
/**
* Create KPI value object
*/
export function createKPIValue(
current: number,
previous: number | undefined,
metricType: string,
options: { currency?: string; unit?: string; isPercentage?: boolean } = {}
): KPIValue {
const change = previous !== undefined ? current - previous : undefined;
const changePercentage = previous !== undefined && previous !== 0
? ((current - previous) / Math.abs(previous)) * 100
: undefined;
let trend: 'up' | 'down' | 'stable' = 'stable';
if (change !== undefined) {
if (change > 0) trend = 'up';
else if (change < 0) trend = 'down';
}
let formatted: string;
let formattedChange: string | undefined;
if (options.currency) {
formatted = formatCurrencyMX(current, options.currency);
if (change !== undefined) {
formattedChange = formatCurrencyMX(change, options.currency);
}
} else if (options.isPercentage) {
formatted = formatPercentageMX(current);
if (changePercentage !== undefined) {
formattedChange = formatPercentageWithSign(changePercentage);
}
} else {
formatted = formatNumberMX(current, 1);
if (change !== undefined) {
formattedChange = `${change > 0 ? '+' : ''}${formatNumberMX(change, 1)}`;
}
}
return {
current,
previous,
change,
changePercentage,
trend,
formatted,
formattedChange,
unit: options.unit,
status: getKPIStatus(current, changePercentage || 0, metricType),
};
}
/**
* Build KPI summary from metrics data
*/
export function buildKPISummary(data: ReportMetricsData): KPISummary {
const currency = data.currency;
const summary: KPISummary = {
revenue: createKPIValue(
data.revenue.totalRevenue.amount,
data.comparisons.revenue.previous,
'revenue',
{ currency }
),
expenses: createKPIValue(
data.expenses.totalExpenses.amount,
data.comparisons.expenses.previous,
'expenses',
{ currency }
),
netProfit: createKPIValue(
data.netProfit.profit.amount,
data.comparisons.netProfit.previous,
'netProfit',
{ currency }
),
profitMargin: createKPIValue(
data.netProfit.margin.value * 100,
undefined,
'profitMargin',
{ isPercentage: true }
),
cashFlow: createKPIValue(
data.cashFlow.netCashFlow.amount,
data.comparisons.cashFlow.previous,
'cashFlow',
{ currency }
),
accountsReceivable: createKPIValue(
data.accountsReceivable.totalReceivable.amount,
undefined,
'accountsReceivable',
{ currency }
),
accountsPayable: createKPIValue(
data.accountsPayable.totalPayable.amount,
undefined,
'accountsPayable',
{ currency }
),
};
// Add startup metrics if available
if (data.startup) {
summary.mrr = createKPIValue(
data.startup.mrr.mrr.amount,
undefined,
'mrr',
{ currency }
);
summary.arr = createKPIValue(
data.startup.arr.arr.amount,
undefined,
'arr',
{ currency }
);
summary.churnRate = createKPIValue(
data.startup.churnRate.churnRate.value * 100,
undefined,
'churnRate',
{ isPercentage: true }
);
summary.runway = createKPIValue(
data.startup.runway.runwayMonths,
undefined,
'runway',
{ unit: 'meses' }
);
summary.burnRate = createKPIValue(
data.startup.burnRate.netBurnRate.amount,
undefined,
'burnRate',
{ currency }
);
}
// Add enterprise metrics if available
if (data.enterprise) {
summary.ebitda = createKPIValue(
data.enterprise.ebitda.ebitda.amount,
undefined,
'ebitda',
{ currency }
);
summary.currentRatio = createKPIValue(
data.enterprise.currentRatio.ratio.value,
undefined,
'currentRatio',
{ unit: 'x' }
);
summary.quickRatio = createKPIValue(
data.enterprise.quickRatio.ratio.value,
undefined,
'quickRatio',
{ unit: 'x' }
);
}
return summary;
}
// ============================================================================
// Chart Templates
// ============================================================================
/**
* Default chart colors for Horux
*/
export const CHART_COLORS = {
primary: ['#2563EB', '#3B82F6', '#60A5FA', '#93C5FD', '#BFDBFE'],
secondary: ['#1E40AF', '#1D4ED8', '#2563EB', '#3B82F6', '#60A5FA'],
success: ['#059669', '#10B981', '#34D399', '#6EE7B7', '#A7F3D0'],
warning: ['#D97706', '#F59E0B', '#FBBF24', '#FCD34D', '#FDE68A'],
danger: ['#DC2626', '#EF4444', '#F87171', '#FCA5A5', '#FECACA'],
neutral: ['#4B5563', '#6B7280', '#9CA3AF', '#D1D5DB', '#E5E7EB'],
};
/**
* Create revenue by category chart
*/
export function createRevenueByCategoryChart(data: ReportMetricsData): ChartData {
const chartData = data.revenue.byCategory.slice(0, 6).map((cat, index) => ({
label: cat.categoryName,
value: cat.amount.amount,
color: CHART_COLORS.primary[index % CHART_COLORS.primary.length],
}));
return {
id: 'revenue-by-category',
type: 'pie',
title: 'Ingresos por Categoria',
data: chartData,
config: {
showLegend: true,
showValues: true,
formatAsCurrency: true,
colors: CHART_COLORS.primary,
},
};
}
/**
* Create expense by category chart
*/
export function createExpenseByCategoryChart(data: ReportMetricsData): ChartData {
const chartData = data.expenses.byCategory.slice(0, 6).map((cat, index) => ({
label: cat.categoryName,
value: cat.amount.amount,
color: CHART_COLORS.warning[index % CHART_COLORS.warning.length],
}));
return {
id: 'expenses-by-category',
type: 'donut',
title: 'Gastos por Categoria',
data: chartData,
config: {
showLegend: true,
showValues: true,
formatAsCurrency: true,
colors: CHART_COLORS.warning,
},
};
}
/**
* Create cash flow waterfall chart
*/
export function createCashFlowWaterfallChart(data: ReportMetricsData): ChartData {
const cf = data.cashFlow;
const chartData = [
{ label: 'Saldo Inicial', value: cf.openingBalance.amount, color: CHART_COLORS.neutral[0] },
{ label: 'Operaciones', value: cf.operatingActivities.amount, color: cf.operatingActivities.amount >= 0 ? CHART_COLORS.success[0] : CHART_COLORS.danger[0] },
{ label: 'Inversiones', value: cf.investingActivities.amount, color: cf.investingActivities.amount >= 0 ? CHART_COLORS.success[1] : CHART_COLORS.danger[1] },
{ label: 'Financiamiento', value: cf.financingActivities.amount, color: cf.financingActivities.amount >= 0 ? CHART_COLORS.success[2] : CHART_COLORS.danger[2] },
{ label: 'Saldo Final', value: cf.closingBalance.amount, color: CHART_COLORS.primary[0] },
];
return {
id: 'cash-flow-waterfall',
type: 'waterfall',
title: 'Flujo de Efectivo',
data: chartData,
config: {
showLegend: false,
showValues: true,
formatAsCurrency: true,
colors: CHART_COLORS.primary,
},
};
}
/**
* Create cash flow trend chart (daily breakdown)
*/
export function createCashFlowTrendChart(data: ReportMetricsData): ChartData {
const chartData = data.cashFlow.breakdown.slice(-30).map((day) => ({
label: new Date(day.date).toLocaleDateString('es-MX', { day: '2-digit', month: 'short' }),
value: day.balance,
secondaryValue: day.netFlow,
}));
return {
id: 'cash-flow-trend',
type: 'area',
title: 'Tendencia de Saldo de Efectivo',
data: chartData,
config: {
xAxisLabel: 'Fecha',
yAxisLabel: 'Saldo (MXN)',
showLegend: true,
showValues: false,
formatAsCurrency: true,
colors: [CHART_COLORS.primary[0], CHART_COLORS.secondary[0]],
},
};
}
/**
* Create profit breakdown chart
*/
export function createProfitBreakdownChart(data: ReportMetricsData): ChartData {
const revenue = data.revenue.totalRevenue.amount;
const cogs = data.grossProfit.costs.amount;
const grossProfit = data.grossProfit.profit.amount;
const opex = data.expenses.totalExpenses.amount - cogs;
const netProfit = data.netProfit.profit.amount;
const chartData = [
{ label: 'Ingresos', value: revenue, color: CHART_COLORS.success[0] },
{ label: 'Costo de Ventas', value: -cogs, color: CHART_COLORS.danger[0] },
{ label: 'Utilidad Bruta', value: grossProfit, color: CHART_COLORS.primary[0] },
{ label: 'Gastos Operativos', value: -opex, color: CHART_COLORS.warning[0] },
{ label: 'Utilidad Neta', value: netProfit, color: netProfit >= 0 ? CHART_COLORS.success[1] : CHART_COLORS.danger[1] },
];
return {
id: 'profit-breakdown',
type: 'waterfall',
title: 'Desglose de Rentabilidad',
data: chartData,
config: {
showLegend: false,
showValues: true,
formatAsCurrency: true,
colors: CHART_COLORS.primary,
},
};
}
/**
* Create aging buckets chart
*/
export function createAgingChart(data: ReportMetricsData, type: 'receivable' | 'payable'): ChartData {
const agingData = type === 'receivable' ? data.agingReceivable : data.agingPayable;
if (!agingData) {
return {
id: `aging-${type}`,
type: 'bar',
title: type === 'receivable' ? 'Antiguedad de Cuentas por Cobrar' : 'Antiguedad de Cuentas por Pagar',
data: [],
config: {
showLegend: false,
showValues: true,
formatAsCurrency: true,
colors: CHART_COLORS.primary,
},
};
}
const colors = [
CHART_COLORS.success[0], // Current - green
CHART_COLORS.primary[0], // 1-30
CHART_COLORS.warning[0], // 31-60
CHART_COLORS.warning[1], // 61-90
CHART_COLORS.danger[0], // 90+
];
const chartData = agingData.buckets.map((bucket, index) => ({
label: bucket.label,
value: bucket.amount.amount,
color: colors[index],
}));
return {
id: `aging-${type}`,
type: 'bar',
title: type === 'receivable' ? 'Antiguedad de Cuentas por Cobrar' : 'Antiguedad de Cuentas por Pagar',
data: chartData,
config: {
xAxisLabel: 'Dias de Antiguedad',
yAxisLabel: 'Monto (MXN)',
showLegend: false,
showValues: true,
formatAsCurrency: true,
colors,
},
};
}
// ============================================================================
// Table Templates
// ============================================================================
/**
* Create cell helper
*/
function createCell(
value: string | number,
options: {
alignment?: 'left' | 'center' | 'right';
isBold?: boolean;
color?: string;
formatAsCurrency?: boolean;
formatAsPercentage?: boolean;
currency?: string;
} = {}
): TableCell {
let formatted: string;
if (typeof value === 'number') {
if (options.formatAsCurrency) {
formatted = formatCurrencyMX(value, options.currency || 'MXN');
} else if (options.formatAsPercentage) {
formatted = formatPercentageMX(value);
} else {
formatted = formatNumberMX(value, 2);
}
} else {
formatted = value;
}
return {
value,
formatted,
alignment: options.alignment || (typeof value === 'number' ? 'right' : 'left'),
isBold: options.isBold,
color: options.color,
};
}
/**
* Create revenue summary table
*/
export function createRevenueSummaryTable(data: ReportMetricsData): TableData {
const currency = data.currency;
const rows: TableRow[] = data.revenue.byCategory.slice(0, 10).map((cat) => ({
cells: [
createCell(cat.categoryName),
createCell(cat.amount.amount, { formatAsCurrency: true, currency }),
createCell(cat.percentage, { formatAsPercentage: true }),
],
}));
const totalsRow: TableRow = {
cells: [
createCell('Total', { isBold: true }),
createCell(data.revenue.totalRevenue.amount, { formatAsCurrency: true, currency, isBold: true }),
createCell(100, { formatAsPercentage: true, isBold: true }),
],
isHighlighted: true,
};
return {
id: 'revenue-summary',
title: 'Resumen de Ingresos por Categoria',
headers: ['Categoria', 'Monto', '% del Total'],
rows,
totals: totalsRow,
footnotes: [
`Numero de facturas: ${formatNumberMX(data.revenue.invoiceCount, 0)}`,
`Valor promedio por factura: ${formatCurrencyMX(data.revenue.averageInvoiceValue.amount, currency)}`,
],
};
}
/**
* Create expense summary table
*/
export function createExpenseSummaryTable(data: ReportMetricsData): TableData {
const currency = data.currency;
const rows: TableRow[] = data.expenses.byCategory.slice(0, 10).map((cat) => ({
cells: [
createCell(cat.categoryName),
createCell(cat.amount.amount, { formatAsCurrency: true, currency }),
createCell(cat.percentage, { formatAsPercentage: true }),
],
}));
const totalsRow: TableRow = {
cells: [
createCell('Total', { isBold: true }),
createCell(data.expenses.totalExpenses.amount, { formatAsCurrency: true, currency, isBold: true }),
createCell(100, { formatAsPercentage: true, isBold: true }),
],
isHighlighted: true,
};
const fixedPct = (data.expenses.fixedExpenses.amount / data.expenses.totalExpenses.amount) * 100;
const variablePct = 100 - fixedPct;
return {
id: 'expense-summary',
title: 'Resumen de Gastos por Categoria',
headers: ['Categoria', 'Monto', '% del Total'],
rows,
totals: totalsRow,
footnotes: [
`Gastos fijos: ${formatCurrencyMX(data.expenses.fixedExpenses.amount, currency)} (${formatPercentageMX(fixedPct)})`,
`Gastos variables: ${formatCurrencyMX(data.expenses.variableExpenses.amount, currency)} (${formatPercentageMX(variablePct)})`,
],
};
}
/**
* Create KPI comparison table
*/
export function createKPIComparisonTable(summary: KPISummary, currency: string): TableData {
const rows: TableRow[] = [
{
cells: [
createCell('Ingresos'),
createCell(summary.revenue.current, { formatAsCurrency: true, currency }),
createCell(summary.revenue.previous || 0, { formatAsCurrency: true, currency }),
createCell(summary.revenue.changePercentage || 0, {
formatAsPercentage: true,
color: (summary.revenue.changePercentage || 0) >= 0 ? '#10B981' : '#EF4444',
}),
],
},
{
cells: [
createCell('Gastos'),
createCell(summary.expenses.current, { formatAsCurrency: true, currency }),
createCell(summary.expenses.previous || 0, { formatAsCurrency: true, currency }),
createCell(summary.expenses.changePercentage || 0, {
formatAsPercentage: true,
color: (summary.expenses.changePercentage || 0) <= 0 ? '#10B981' : '#EF4444',
}),
],
},
{
cells: [
createCell('Utilidad Neta'),
createCell(summary.netProfit.current, { formatAsCurrency: true, currency }),
createCell(summary.netProfit.previous || 0, { formatAsCurrency: true, currency }),
createCell(summary.netProfit.changePercentage || 0, {
formatAsPercentage: true,
color: (summary.netProfit.changePercentage || 0) >= 0 ? '#10B981' : '#EF4444',
}),
],
isHighlighted: true,
},
{
cells: [
createCell('Flujo de Efectivo'),
createCell(summary.cashFlow.current, { formatAsCurrency: true, currency }),
createCell(summary.cashFlow.previous || 0, { formatAsCurrency: true, currency }),
createCell(summary.cashFlow.changePercentage || 0, {
formatAsPercentage: true,
color: (summary.cashFlow.changePercentage || 0) >= 0 ? '#10B981' : '#EF4444',
}),
],
},
];
return {
id: 'kpi-comparison',
title: 'Comparacion de KPIs con Periodo Anterior',
headers: ['Metrica', 'Actual', 'Anterior', 'Cambio %'],
rows,
};
}
/**
* Create accounts aging table
*/
export function createAgingTable(data: ReportMetricsData, type: 'receivable' | 'payable'): TableData {
const agingData = type === 'receivable' ? data.agingReceivable : data.agingPayable;
const currency = data.currency;
if (!agingData) {
return {
id: `aging-table-${type}`,
title: type === 'receivable' ? 'Antiguedad de Cuentas por Cobrar' : 'Antiguedad de Cuentas por Pagar',
headers: [],
rows: [],
};
}
const rows: TableRow[] = agingData.buckets.map((bucket) => ({
cells: [
createCell(bucket.label),
createCell(bucket.amount.amount, { formatAsCurrency: true, currency }),
createCell(bucket.count),
createCell(bucket.percentage, { formatAsPercentage: true }),
],
}));
const totalsRow: TableRow = {
cells: [
createCell('Total', { isBold: true }),
createCell(agingData.totalAmount.amount, { formatAsCurrency: true, currency, isBold: true }),
createCell(agingData.buckets.reduce((sum, b) => sum + b.count, 0), { isBold: true }),
createCell(100, { formatAsPercentage: true, isBold: true }),
],
isHighlighted: true,
};
return {
id: `aging-table-${type}`,
title: type === 'receivable' ? 'Antiguedad de Cuentas por Cobrar' : 'Antiguedad de Cuentas por Pagar',
headers: ['Antiguedad', 'Monto', 'Documentos', '% del Total'],
rows,
totals: totalsRow,
};
}
/**
* Create anomalies table
*/
export function createAnomaliesTable(data: ReportMetricsData): TableData {
const severityColors: Record<string, string> = {
critical: '#DC2626',
high: '#EA580C',
medium: '#D97706',
low: '#65A30D',
};
const rows: TableRow[] = data.anomalies.slice(0, 10).map((anomaly) => ({
cells: [
createCell(anomaly.description),
createCell(anomaly.severity.toUpperCase(), { color: severityColors[anomaly.severity] }),
createCell(formatPercentageMX(anomaly.deviationPercentage)),
createCell(anomaly.recommendation),
],
}));
return {
id: 'anomalies',
title: 'Alertas y Anomalias Detectadas',
headers: ['Descripcion', 'Severidad', 'Desviacion', 'Recomendacion'],
rows,
footnotes: rows.length === 0 ? ['No se detectaron anomalias en este periodo.'] : undefined,
};
}
// ============================================================================
// Chart Placeholder for PDF
// ============================================================================
/**
* Generate a placeholder representation for charts in PDF
* This creates a simple text representation that can be rendered in PDFKit
*/
export function generateChartPlaceholder(chart: ChartData): {
type: string;
title: string;
dataPoints: { label: string; value: string; percentage?: string }[];
total?: string;
} {
const total = chart.data.reduce((sum, d) => sum + d.value, 0);
const dataPoints = chart.data.map((d) => ({
label: d.label,
value: chart.config.formatAsCurrency
? formatCurrencyMX(d.value)
: formatNumberMX(d.value, 0),
percentage: total > 0 ? formatPercentageMX((d.value / total) * 100) : undefined,
}));
return {
type: chart.type,
title: chart.title,
dataPoints,
total: chart.config.formatAsCurrency ? formatCurrencyMX(total) : formatNumberMX(total, 0),
};
}

View File

@@ -0,0 +1,516 @@
/**
* Report Types
*
* Type definitions for the Horux Strategy executive reporting system.
* Supports monthly, quarterly, annual, and custom reports with AI-generated narratives.
*/
import {
MetricPeriod,
DateRange,
RevenueResult,
ExpensesResult,
ProfitResult,
CashFlowResult,
AccountsReceivableResult,
AccountsPayableResult,
AgingReportResult,
VATPositionResult,
MRRResult,
ARRResult,
ChurnRateResult,
RunwayResult,
BurnRateResult,
EBITDAResult,
CurrentRatioResult,
QuickRatioResult,
Anomaly,
MetricComparison,
} from '../metrics/metrics.types';
// ============================================================================
// Report Types and Enums
// ============================================================================
export type ReportType = 'monthly' | 'quarterly' | 'annual' | 'custom';
export type ReportFormat = 'pdf' | 'json' | 'html';
export type CompanyType = 'pyme' | 'startup' | 'enterprise';
export type ReportSectionType =
| 'executive_summary'
| 'kpis_overview'
| 'revenue_analysis'
| 'expense_analysis'
| 'profit_analysis'
| 'cash_flow_analysis'
| 'accounts_receivable'
| 'accounts_payable'
| 'startup_metrics'
| 'enterprise_metrics'
| 'anomalies'
| 'recommendations'
| 'forecast';
export type ReportStatus = 'pending' | 'generating' | 'completed' | 'failed';
// ============================================================================
// Report Configuration
// ============================================================================
export interface ReportConfig {
tenantId: string;
type: ReportType;
companyType: CompanyType;
period: MetricPeriod;
dateRange: DateRange;
sections: ReportSectionType[];
format: ReportFormat;
language: 'es-MX' | 'en-US';
currency: string;
includeCharts: boolean;
includeAIAnalysis: boolean;
includeRecommendations: boolean;
compareWithPreviousPeriod: boolean;
recipientEmail?: string;
customBranding?: ReportBranding;
}
export interface ReportBranding {
companyName: string;
logoUrl?: string;
primaryColor: string;
secondaryColor: string;
footerText?: string;
}
// ============================================================================
// Report Sections
// ============================================================================
export interface ReportSection {
id: string;
type: ReportSectionType;
title: string;
order: number;
data: unknown;
narrative?: string;
charts?: ChartData[];
tables?: TableData[];
}
export interface ChartData {
id: string;
type: 'bar' | 'line' | 'pie' | 'donut' | 'area' | 'waterfall';
title: string;
data: ChartDataPoint[];
config: ChartConfig;
}
export interface ChartDataPoint {
label: string;
value: number;
color?: string;
secondaryValue?: number;
}
export interface ChartConfig {
xAxisLabel?: string;
yAxisLabel?: string;
showLegend: boolean;
showValues: boolean;
formatAsCurrency: boolean;
colors: string[];
}
export interface TableData {
id: string;
title: string;
headers: string[];
rows: TableRow[];
totals?: TableRow;
footnotes?: string[];
}
export interface TableRow {
cells: TableCell[];
isHighlighted?: boolean;
highlightColor?: string;
}
export interface TableCell {
value: string | number;
formatted: string;
alignment?: 'left' | 'center' | 'right';
isBold?: boolean;
color?: string;
}
// ============================================================================
// KPI Summary
// ============================================================================
export interface KPISummary {
revenue: KPIValue;
expenses: KPIValue;
netProfit: KPIValue;
profitMargin: KPIValue;
cashFlow: KPIValue;
accountsReceivable: KPIValue;
accountsPayable: KPIValue;
// Startup KPIs (optional)
mrr?: KPIValue;
arr?: KPIValue;
churnRate?: KPIValue;
runway?: KPIValue;
burnRate?: KPIValue;
// Enterprise KPIs (optional)
ebitda?: KPIValue;
currentRatio?: KPIValue;
quickRatio?: KPIValue;
}
export interface KPIValue {
current: number;
previous?: number;
change?: number;
changePercentage?: number;
trend: 'up' | 'down' | 'stable';
formatted: string;
formattedChange?: string;
unit?: string;
status: 'good' | 'warning' | 'critical' | 'neutral';
}
// ============================================================================
// Metrics Data for Report
// ============================================================================
export interface ReportMetricsData {
period: MetricPeriod;
currency: string;
generatedAt: Date;
// Core metrics
revenue: RevenueResult;
expenses: ExpensesResult;
grossProfit: ProfitResult;
netProfit: ProfitResult;
cashFlow: CashFlowResult;
accountsReceivable: AccountsReceivableResult;
accountsPayable: AccountsPayableResult;
agingReceivable?: AgingReportResult;
agingPayable?: AgingReportResult;
vatPosition?: VATPositionResult;
// Comparisons
comparisons: {
revenue: MetricComparison;
expenses: MetricComparison;
netProfit: MetricComparison;
cashFlow: MetricComparison;
};
// Startup metrics (optional)
startup?: {
mrr: MRRResult;
arr: ARRResult;
churnRate: ChurnRateResult;
runway: RunwayResult;
burnRate: BurnRateResult;
};
// Enterprise metrics (optional)
enterprise?: {
ebitda: EBITDAResult;
currentRatio: CurrentRatioResult;
quickRatio: QuickRatioResult;
};
// Anomalies detected
anomalies: Anomaly[];
}
// ============================================================================
// AI Generated Content
// ============================================================================
export interface AIGeneratedContent {
executiveSummary: string;
revenueAnalysis: string;
expenseAnalysis: string;
cashFlowAnalysis: string;
profitAnalysis: string;
recommendations: string[];
anomalyExplanations: AnomalyExplanation[];
forecast?: string;
riskAssessment?: string;
}
export interface AnomalyExplanation {
anomalyId: string;
explanation: string;
suggestedActions: string[];
priority: 'low' | 'medium' | 'high' | 'critical';
}
// ============================================================================
// Generated Report
// ============================================================================
export interface GeneratedReport {
id: string;
tenantId: string;
config: ReportConfig;
status: ReportStatus;
// Report content
title: string;
subtitle: string;
period: MetricPeriod;
dateRange: DateRange;
generatedAt: Date;
// Sections
sections: ReportSection[];
// KPIs
kpiSummary: KPISummary;
// AI content
aiContent?: AIGeneratedContent;
// File references
pdfUrl?: string;
pdfSize?: number;
// Metadata
metadata: ReportMetadata;
}
export interface ReportMetadata {
version: string;
generationTimeMs: number;
metricsVersion: string;
aiModel?: string;
tokensUsed?: number;
sectionsGenerated: number;
chartsGenerated: number;
tablesGenerated: number;
warningsCount: number;
errorsCount: number;
warnings: string[];
errors: string[];
}
// ============================================================================
// Report Storage
// ============================================================================
export interface StoredReport {
id: string;
tenantId: string;
type: ReportType;
period: MetricPeriod;
title: string;
status: ReportStatus;
pdfUrl?: string;
pdfSize?: number;
generatedAt: Date;
expiresAt?: Date;
metadata: Partial<ReportMetadata>;
}
// ============================================================================
// Report Generation Events
// ============================================================================
export interface ReportGenerationEvent {
reportId: string;
tenantId: string;
type: 'started' | 'section_completed' | 'ai_generated' | 'pdf_generated' | 'completed' | 'failed';
timestamp: Date;
section?: ReportSectionType;
progress?: number;
message?: string;
error?: string;
}
export type ReportEventCallback = (event: ReportGenerationEvent) => void;
// ============================================================================
// Report Request/Response
// ============================================================================
export interface GenerateReportRequest {
type: ReportType;
companyType?: CompanyType;
month?: number; // 1-12 for monthly reports
quarter?: number; // 1-4 for quarterly reports
year: number;
dateRange?: DateRange;
sections?: ReportSectionType[];
format?: ReportFormat;
includeAIAnalysis?: boolean;
recipientEmail?: string;
}
export interface GenerateReportResponse {
reportId: string;
status: ReportStatus;
estimatedCompletionTime?: number;
message: string;
}
export interface GetReportResponse {
report: GeneratedReport | null;
status: ReportStatus;
progress?: number;
message?: string;
}
// ============================================================================
// PDF Generation Types
// ============================================================================
export interface PDFGenerationOptions {
pageSize: 'letter' | 'A4';
orientation: 'portrait' | 'landscape';
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
branding: ReportBranding;
headerHeight: number;
footerHeight: number;
fonts: {
title: string;
heading: string;
body: string;
monospace: string;
};
colors: {
primary: string;
secondary: string;
accent: string;
text: string;
textLight: string;
background: string;
success: string;
warning: string;
danger: string;
border: string;
};
}
export interface PDFGenerationResult {
buffer: Buffer;
fileName: string;
mimeType: string;
pageCount: number;
fileSize: number;
}
// ============================================================================
// Utility Types
// ============================================================================
export interface MonthNames {
short: string[];
long: string[];
}
export const MONTH_NAMES_ES: MonthNames = {
short: ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'],
long: [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
],
};
export const QUARTER_NAMES_ES = ['Q1 (Ene-Mar)', 'Q2 (Abr-Jun)', 'Q3 (Jul-Sep)', 'Q4 (Oct-Dic)'];
// ============================================================================
// Default Configurations
// ============================================================================
export const DEFAULT_REPORT_SECTIONS: Record<CompanyType, ReportSectionType[]> = {
pyme: [
'executive_summary',
'kpis_overview',
'revenue_analysis',
'expense_analysis',
'profit_analysis',
'cash_flow_analysis',
'accounts_receivable',
'accounts_payable',
'anomalies',
'recommendations',
],
startup: [
'executive_summary',
'kpis_overview',
'revenue_analysis',
'expense_analysis',
'startup_metrics',
'cash_flow_analysis',
'anomalies',
'recommendations',
'forecast',
],
enterprise: [
'executive_summary',
'kpis_overview',
'revenue_analysis',
'expense_analysis',
'profit_analysis',
'enterprise_metrics',
'cash_flow_analysis',
'accounts_receivable',
'accounts_payable',
'anomalies',
'recommendations',
],
};
export const DEFAULT_BRANDING: ReportBranding = {
companyName: 'Horux Strategy',
primaryColor: '#2563EB',
secondaryColor: '#1E40AF',
footerText: 'Generado por Horux Strategy - Tu CFO Digital',
};
export const DEFAULT_PDF_OPTIONS: PDFGenerationOptions = {
pageSize: 'letter',
orientation: 'portrait',
margins: {
top: 72,
bottom: 72,
left: 72,
right: 72,
},
branding: DEFAULT_BRANDING,
headerHeight: 60,
footerHeight: 40,
fonts: {
title: 'Helvetica-Bold',
heading: 'Helvetica-Bold',
body: 'Helvetica',
monospace: 'Courier',
},
colors: {
primary: '#2563EB',
secondary: '#1E40AF',
accent: '#3B82F6',
text: '#1F2937',
textLight: '#6B7280',
background: '#FFFFFF',
success: '#10B981',
warning: '#F59E0B',
danger: '#EF4444',
border: '#E5E7EB',
},
};

View File

@@ -0,0 +1,455 @@
'use client';
import React, { useState, useEffect } from 'react';
import { cn, formatCurrency, formatPercentage } from '@/lib/utils';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
ChatInterface,
SuggestedQuestions,
defaultSuggestedQuestions,
AIInsightCard,
InsightsList,
AIInsight,
SuggestedQuestion,
} from '@/components/ai';
import {
Bot,
Sparkles,
TrendingUp,
TrendingDown,
AlertTriangle,
Info,
Lightbulb,
BarChart3,
DollarSign,
Users,
Clock,
ArrowRight,
MessageSquare,
History,
Settings,
ChevronRight,
} from 'lucide-react';
// ============================================================================
// Mock Data
// ============================================================================
const mockInsights: AIInsight[] = [
{
id: '1',
type: 'positive',
priority: 'high',
title: 'Crecimiento de MRR sostenido',
description: 'Tu MRR ha crecido un 5.9% este mes, manteniendose por encima del promedio de la industria.',
metric: {
label: 'MRR Actual',
value: 125000,
change: 5.9,
format: 'currency',
},
action: {
label: 'Ver detalle de metricas',
onClick: () => console.log('Ver metricas'),
},
timestamp: new Date(),
},
{
id: '2',
type: 'warning',
priority: 'high',
title: 'Facturas vencidas requieren atencion',
description: 'Tienes 3 facturas vencidas por un total de $15,750. Considera enviar recordatorios.',
metric: {
label: 'Monto Vencido',
value: 15750,
format: 'currency',
},
action: {
label: 'Ver cuentas por cobrar',
onClick: () => console.log('Ver cuentas'),
},
timestamp: new Date(),
},
{
id: '3',
type: 'suggestion',
priority: 'medium',
title: 'Oportunidad de ahorro en gastos',
description: 'Identificamos suscripciones de software duplicadas que podrian consolidarse.',
metric: {
label: 'Ahorro Potencial',
value: 2500,
format: 'currency',
},
action: {
label: 'Revisar gastos',
},
timestamp: new Date(),
},
{
id: '4',
type: 'positive',
priority: 'low',
title: 'LTV/CAC ratio excelente',
description: 'Tu ratio LTV/CAC de 6.0x indica una excelente eficiencia en adquisicion de clientes.',
metric: {
label: 'LTV/CAC',
value: '6.0x',
},
timestamp: new Date(),
},
{
id: '5',
type: 'info',
priority: 'medium',
title: 'Proyeccion de flujo de caja',
description: 'Basado en tendencias actuales, tu flujo de caja proyectado para el proximo mes es positivo.',
metric: {
label: 'Flujo Proyectado',
value: 52000,
change: 12.5,
format: 'currency',
},
timestamp: new Date(),
},
];
const recentConversations = [
{ id: '1', title: 'Analisis de flujo de caja', date: 'Hace 2 horas', messages: 5 },
{ id: '2', title: 'Proyeccion Q4 2024', date: 'Ayer', messages: 12 },
{ id: '3', title: 'Optimizacion de gastos', date: 'Hace 2 dias', messages: 8 },
{ id: '4', title: 'Metricas SaaS', date: 'Hace 3 dias', messages: 6 },
];
const quickMetrics = [
{ label: 'MRR', value: 125000, change: 5.9, format: 'currency' as const },
{ label: 'Churn', value: 2.5, change: -0.7, format: 'percentage' as const, inverse: true },
{ label: 'Clientes', value: 342, change: 8.5, format: 'number' as const },
{ label: 'LTV/CAC', value: 6.0, change: 25, format: 'number' as const },
];
// ============================================================================
// Quick Metrics Bar
// ============================================================================
function QuickMetricsBar() {
const formatValue = (value: number, format: 'currency' | 'percentage' | 'number') => {
switch (format) {
case 'currency':
return formatCurrency(value);
case 'percentage':
return `${value.toFixed(1)}%`;
default:
return value.toLocaleString('es-ES');
}
};
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{quickMetrics.map((metric) => {
const isPositive = metric.inverse ? metric.change <= 0 : metric.change >= 0;
return (
<div
key={metric.label}
className="p-3 rounded-lg bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
{metric.label}
</span>
<span
className={cn(
'flex items-center gap-0.5 text-xs font-medium',
isPositive
? 'text-success-600 dark:text-success-400'
: 'text-error-600 dark:text-error-400'
)}
>
{isPositive ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{formatPercentage(Math.abs(metric.change))}
</span>
</div>
<p className="text-lg font-bold text-slate-900 dark:text-white mt-1">
{formatValue(metric.value, metric.format)}
</p>
</div>
);
})}
</div>
);
}
// ============================================================================
// Recent Conversations
// ============================================================================
interface RecentConversationsProps {
conversations: typeof recentConversations;
onSelect: (id: string) => void;
}
function RecentConversations({ conversations, onSelect }: RecentConversationsProps) {
return (
<div className="space-y-2">
{conversations.map((conv) => (
<button
key={conv.id}
onClick={() => onSelect(conv.id)}
className="w-full flex items-center justify-between p-3 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-left transition-colors group"
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-700 group-hover:bg-slate-200 dark:group-hover:bg-slate-600">
<MessageSquare className="w-4 h-4 text-slate-500 dark:text-slate-400" />
</div>
<div>
<p className="text-sm font-medium text-slate-900 dark:text-white">
{conv.title}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{conv.date} - {conv.messages} mensajes
</p>
</div>
</div>
<ChevronRight className="w-4 h-4 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
))}
</div>
);
}
// ============================================================================
// Main Page Component
// ============================================================================
export default function AsistentePage() {
const [isFullChat, setIsFullChat] = useState(false);
const [insights, setInsights] = useState<AIInsight[]>(mockInsights);
const handleSuggestedQuestion = (question: SuggestedQuestion) => {
setIsFullChat(true);
// El componente ChatInterface manejara la pregunta
};
const handleInsightAction = (insight: AIInsight) => {
console.log('Insight action:', insight.id);
};
const handleSelectConversation = (id: string) => {
console.log('Selected conversation:', id);
setIsFullChat(true);
};
// Vista de chat completo
if (isFullChat) {
return (
<div className="h-[calc(100vh-8rem)]">
<ChatInterface
suggestedQuestions={defaultSuggestedQuestions}
className="h-full rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden"
/>
</div>
);
}
// Vista de dashboard del asistente
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
<Bot className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
CFO Digital
</h1>
<p className="text-slate-500 dark:text-slate-400">
Tu asistente financiero impulsado por IA
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
leftIcon={<History className="w-4 h-4" />}
>
Historial
</Button>
<Button
variant="primary"
size="sm"
leftIcon={<MessageSquare className="w-4 h-4" />}
onClick={() => setIsFullChat(true)}
>
Nueva Conversacion
</Button>
</div>
</div>
{/* Quick Metrics */}
<QuickMetricsBar />
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Chat Preview & Suggestions */}
<div className="lg:col-span-2 space-y-6">
{/* Quick Chat Card */}
<Card>
<CardHeader
title="Pregunta al CFO Digital"
subtitle="Obten respuestas instantaneas sobre tus finanzas"
action={
<Button
variant="ghost"
size="xs"
rightIcon={<ArrowRight className="w-4 h-4" />}
onClick={() => setIsFullChat(true)}
>
Chat completo
</Button>
}
/>
<CardContent>
{/* Quick Input */}
<div className="relative mb-4">
<input
type="text"
placeholder="Escribe tu pregunta aqui..."
onClick={() => setIsFullChat(true)}
readOnly
className={cn(
'w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-700',
'bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white',
'cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 transition-colors',
'placeholder:text-slate-400 dark:placeholder:text-slate-500'
)}
/>
<Sparkles className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-violet-500" />
</div>
{/* Suggested Questions */}
<SuggestedQuestions
questions={defaultSuggestedQuestions.slice(0, 4)}
onSelect={handleSuggestedQuestion}
title="Preguntas frecuentes:"
/>
</CardContent>
</Card>
{/* AI Insights */}
<Card>
<CardHeader
title="Insights de IA"
subtitle="Recomendaciones basadas en tus datos"
action={
<div className="flex items-center gap-1 text-xs text-violet-600 dark:text-violet-400">
<Sparkles className="w-4 h-4" />
Actualizado hace 5 min
</div>
}
/>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{insights.slice(0, 4).map((insight) => (
<AIInsightCard
key={insight.id}
insight={insight}
compact
onAction={() => handleInsightAction(insight)}
/>
))}
</div>
{insights.length > 4 && (
<div className="mt-4 text-center">
<Button variant="ghost" size="sm">
Ver todos los insights ({insights.length})
</Button>
</div>
)}
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Recent Conversations */}
<Card>
<CardHeader
title="Conversaciones Recientes"
action={
<Button variant="ghost" size="xs">
Ver todas
</Button>
}
/>
<CardContent>
<RecentConversations
conversations={recentConversations}
onSelect={handleSelectConversation}
/>
</CardContent>
</Card>
{/* Capabilities */}
<Card>
<CardHeader title="Que puedo hacer" />
<CardContent>
<div className="space-y-3">
{[
{ icon: <BarChart3 className="w-4 h-4" />, label: 'Analizar metricas financieras' },
{ icon: <TrendingUp className="w-4 h-4" />, label: 'Proyectar ingresos y gastos' },
{ icon: <DollarSign className="w-4 h-4" />, label: 'Optimizar flujo de caja' },
{ icon: <Users className="w-4 h-4" />, label: 'Gestionar cuentas por cobrar' },
{ icon: <AlertTriangle className="w-4 h-4" />, label: 'Detectar alertas financieras' },
{ icon: <Lightbulb className="w-4 h-4" />, label: 'Sugerir optimizaciones' },
].map((item, index) => (
<div
key={index}
className="flex items-center gap-3 p-2 rounded-lg text-sm text-slate-700 dark:text-slate-300"
>
<span className="p-1.5 rounded-md bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">
{item.icon}
</span>
{item.label}
</div>
))}
</div>
</CardContent>
</Card>
{/* Tips Card */}
<Card className="bg-gradient-to-br from-violet-500 to-purple-600 border-0 text-white">
<CardContent>
<Sparkles className="w-8 h-8 mb-3 opacity-80" />
<h3 className="font-semibold text-lg mb-2">
Tip del dia
</h3>
<p className="text-sm opacity-90 mb-4">
Pregunta por tus metricas SaaS para obtener un analisis completo
de la salud de tu negocio incluyendo MRR, Churn y LTV/CAC.
</p>
<Button
variant="secondary"
size="sm"
onClick={() => {
setIsFullChat(true);
}}
className="bg-white/20 hover:bg-white/30 border-0 text-white"
>
Probar ahora
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,719 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardContent, CardFooter } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
/**
* Tipos de integracion
*/
interface IntegrationProvider {
type: string;
name: string;
description: string;
category: 'erp' | 'accounting' | 'fiscal' | 'bank' | 'payments' | 'custom';
logoUrl?: string;
supportedEntities: string[];
supportedDirections: string[];
supportsRealtime: boolean;
supportsWebhooks: boolean;
isAvailable: boolean;
isBeta: boolean;
regions?: string[];
}
interface ConfiguredIntegration {
id: string;
type: string;
name: string;
status: 'pending' | 'active' | 'inactive' | 'error' | 'expired';
isActive: boolean;
lastSyncAt?: string;
lastSyncStatus?: string;
healthStatus?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
provider?: {
name: string;
category: string;
logoUrl?: string;
};
}
interface SyncStatus {
integration: {
id: string;
type: string;
name: string;
status: string;
isActive: boolean;
health: string;
};
lastSync?: {
jobId: string;
status: string;
startedAt: string;
completedAt?: string;
totalRecords: number;
createdRecords: number;
updatedRecords: number;
failedRecords: number;
errorCount: number;
lastError?: string;
};
nextSyncAt?: string;
}
/**
* Iconos para categorias
*/
const CategoryIcons: Record<string, React.ReactNode> = {
erp: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
),
accounting: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
fiscal: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
bank: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
),
payments: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
custom: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
};
/**
* Estado de salud badge
*/
const HealthBadge: React.FC<{ status?: string }> = ({ status }) => {
const styles: Record<string, string> = {
healthy: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
degraded: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
unhealthy: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
unknown: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
};
const labels: Record<string, string> = {
healthy: 'Saludable',
degraded: 'Degradado',
unhealthy: 'Error',
unknown: 'Desconocido',
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status || 'unknown']}`}>
<span className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
status === 'healthy' ? 'bg-green-500' :
status === 'degraded' ? 'bg-yellow-500' :
status === 'unhealthy' ? 'bg-red-500' : 'bg-gray-500'
}`} />
{labels[status || 'unknown']}
</span>
);
};
/**
* Estado de integracion badge
*/
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
const styles: Record<string, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
inactive: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
error: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
expired: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
};
const labels: Record<string, string> = {
active: 'Activa',
pending: 'Pendiente',
inactive: 'Inactiva',
error: 'Error',
expired: 'Expirada',
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || styles.inactive}`}>
{labels[status] || status}
</span>
);
};
/**
* Pagina de Integraciones
*/
export default function IntegracionesPage() {
const [providers, setProviders] = useState<IntegrationProvider[]>([]);
const [configured, setConfigured] = useState<ConfiguredIntegration[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [isLoading, setIsLoading] = useState(true);
const [selectedIntegration, setSelectedIntegration] = useState<ConfiguredIntegration | null>(null);
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [showConfigModal, setShowConfigModal] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<IntegrationProvider | null>(null);
// Simular carga de datos
useEffect(() => {
const loadData = async () => {
setIsLoading(true);
// Simular API call - en produccion seria fetch real
await new Promise(resolve => setTimeout(resolve, 500));
// Datos de ejemplo
setProviders([
{
type: 'contpaqi',
name: 'CONTPAQi',
description: 'Integracion con sistemas CONTPAQi (Comercial, Contabilidad, Nominas)',
category: 'erp',
logoUrl: '/integrations/contpaqi-logo.svg',
supportedEntities: ['transactions', 'invoices', 'contacts', 'products', 'accounts'],
supportedDirections: ['import', 'export', 'bidirectional'],
supportsRealtime: false,
supportsWebhooks: false,
isAvailable: true,
isBeta: false,
regions: ['MX'],
},
{
type: 'aspel',
name: 'Aspel',
description: 'Integracion con sistemas Aspel (SAE, COI, NOI, BANCO)',
category: 'erp',
supportedEntities: ['transactions', 'invoices', 'contacts', 'products'],
supportedDirections: ['import', 'export'],
supportsRealtime: false,
supportsWebhooks: false,
isAvailable: true,
isBeta: false,
regions: ['MX'],
},
{
type: 'odoo',
name: 'Odoo',
description: 'Integracion con Odoo ERP (on-premise y cloud)',
category: 'erp',
supportedEntities: ['transactions', 'invoices', 'contacts', 'products', 'accounts', 'payments'],
supportedDirections: ['import', 'export', 'bidirectional'],
supportsRealtime: true,
supportsWebhooks: true,
isAvailable: true,
isBeta: false,
},
{
type: 'alegra',
name: 'Alegra',
description: 'Integracion con Alegra Contabilidad',
category: 'accounting',
supportedEntities: ['invoices', 'contacts', 'products', 'payments'],
supportedDirections: ['import', 'export', 'bidirectional'],
supportsRealtime: true,
supportsWebhooks: true,
isAvailable: true,
isBeta: false,
regions: ['MX', 'CO', 'PE', 'AR', 'CL'],
},
{
type: 'sap',
name: 'SAP',
description: 'Integracion con SAP ERP y SAP Business One',
category: 'erp',
supportedEntities: ['transactions', 'invoices', 'contacts', 'products', 'accounts', 'journal_entries'],
supportedDirections: ['import', 'export', 'bidirectional'],
supportsRealtime: true,
supportsWebhooks: true,
isAvailable: true,
isBeta: true,
},
{
type: 'sat',
name: 'SAT',
description: 'Servicio de Administracion Tributaria - Descarga de CFDIs',
category: 'fiscal',
supportedEntities: ['cfdis'],
supportedDirections: ['import'],
supportsRealtime: false,
supportsWebhooks: false,
isAvailable: true,
isBeta: false,
regions: ['MX'],
},
{
type: 'bank_bbva',
name: 'BBVA Mexico',
description: 'Conexion bancaria BBVA para estados de cuenta',
category: 'bank',
supportedEntities: ['bank_statements'],
supportedDirections: ['import'],
supportsRealtime: false,
supportsWebhooks: false,
isAvailable: true,
isBeta: false,
regions: ['MX'],
},
{
type: 'payments_stripe',
name: 'Stripe',
description: 'Procesador de pagos Stripe',
category: 'payments',
supportedEntities: ['payments', 'invoices'],
supportedDirections: ['import', 'export'],
supportsRealtime: true,
supportsWebhooks: true,
isAvailable: true,
isBeta: false,
},
{
type: 'webhook',
name: 'Webhook',
description: 'Webhook personalizado para integraciones custom',
category: 'custom',
supportedEntities: ['transactions', 'invoices', 'contacts'],
supportedDirections: ['import', 'export'],
supportsRealtime: true,
supportsWebhooks: true,
isAvailable: true,
isBeta: false,
},
]);
setConfigured([
{
id: '1',
type: 'sat',
name: 'SAT - ABC123456789',
status: 'active',
isActive: true,
lastSyncAt: '2026-01-31T10:30:00Z',
lastSyncStatus: 'completed',
healthStatus: 'healthy',
provider: {
name: 'SAT',
category: 'fiscal',
},
},
{
id: '2',
type: 'contpaqi',
name: 'CONTPAQi Comercial',
status: 'active',
isActive: true,
lastSyncAt: '2026-01-31T08:00:00Z',
lastSyncStatus: 'completed',
healthStatus: 'healthy',
provider: {
name: 'CONTPAQi',
category: 'erp',
},
},
]);
setIsLoading(false);
};
loadData();
}, []);
// Filtrar providers por categoria
const filteredProviders = selectedCategory === 'all'
? providers
: providers.filter(p => p.category === selectedCategory);
// Categorias unicas
const categories = ['all', ...new Set(providers.map(p => p.category))];
const categoryLabels: Record<string, string> = {
all: 'Todas',
erp: 'ERP',
accounting: 'Contabilidad',
fiscal: 'Fiscal',
bank: 'Bancos',
payments: 'Pagos',
custom: 'Personalizado',
};
// Formatear fecha
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('es-MX', {
dateStyle: 'short',
timeStyle: 'short',
});
};
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Integraciones
</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Conecta tus sistemas externos para sincronizar datos automaticamente
</p>
</div>
<Button
onClick={() => setShowConfigModal(true)}
leftIcon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
}
>
Nueva Integracion
</Button>
</div>
{/* Integraciones configuradas */}
{configured.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
Integraciones Configuradas
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{configured.map((integration) => (
<Card
key={integration.id}
variant="default"
hoverable
clickable
onClick={() => setSelectedIntegration(integration)}
className="cursor-pointer"
>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">
{CategoryIcons[integration.provider?.category || 'custom']}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-slate-900 dark:text-white truncate">
{integration.name}
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
{integration.provider?.name}
</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-3">
<StatusBadge status={integration.status} />
<HealthBadge status={integration.healthStatus} />
</div>
<div className="text-sm text-slate-500 dark:text-slate-400">
<p>Ultima sync: {formatDate(integration.lastSyncAt)}</p>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
// Trigger sync
}}
leftIcon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
}
>
Sincronizar
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
// Open settings
}}
leftIcon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
}
>
Configurar
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
)}
{/* Integraciones disponibles */}
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Integraciones Disponibles
</h2>
{/* Filtros de categoria */}
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-3 py-1.5 text-sm font-medium rounded-full transition-colors ${
selectedCategory === cat
? 'bg-primary-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'
}`}
>
{categoryLabels[cat] || cat}
</button>
))}
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i} variant="default" className="animate-pulse">
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-4" />
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-full mb-2" />
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-2/3" />
</Card>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredProviders.map((provider) => {
const isConfigured = configured.some(c => c.type === provider.type);
return (
<Card
key={provider.type}
variant="default"
hoverable
className={isConfigured ? 'opacity-60' : ''}
>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300">
{CategoryIcons[provider.category]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-slate-900 dark:text-white">
{provider.name}
</h3>
{provider.isBeta && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400 rounded">
Beta
</span>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 capitalize">
{categoryLabels[provider.category]}
</p>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-600 dark:text-slate-300 mb-3">
{provider.description}
</p>
<div className="flex flex-wrap gap-1.5">
{provider.supportsRealtime && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
Tiempo real
</span>
)}
{provider.supportsWebhooks && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Webhooks
</span>
)}
{provider.regions && provider.regions.length > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
{provider.regions.join(', ')}
</span>
)}
</div>
</CardContent>
<CardFooter>
<Button
fullWidth
variant={isConfigured ? 'secondary' : 'primary'}
disabled={isConfigured || !provider.isAvailable}
onClick={() => {
setSelectedProvider(provider);
setShowConfigModal(true);
}}
>
{isConfigured ? 'Ya configurada' : 'Configurar'}
</Button>
</CardFooter>
</Card>
);
})}
</div>
)}
</div>
{/* Modal de configuracion - Placeholder */}
{showConfigModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<Card className="w-full max-w-lg mx-4">
<CardHeader
title={selectedProvider ? `Configurar ${selectedProvider.name}` : 'Nueva Integracion'}
action={
<button
onClick={() => {
setShowConfigModal(false);
setSelectedProvider(null);
}}
className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
}
/>
<CardContent>
<p className="text-slate-600 dark:text-slate-300">
{selectedProvider
? `Configure los parametros de conexion para ${selectedProvider.name}.`
: 'Seleccione una integracion de la lista para configurarla.'}
</p>
{selectedProvider && (
<div className="mt-4 space-y-4">
<p className="text-sm text-slate-500 dark:text-slate-400">
Formulario de configuracion especifico para {selectedProvider.type} proximamente...
</p>
</div>
)}
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => {
setShowConfigModal(false);
setSelectedProvider(null);
}}
>
Cancelar
</Button>
<Button disabled={!selectedProvider}>
Guardar Configuracion
</Button>
</CardFooter>
</Card>
</div>
)}
{/* Modal de detalles de integracion */}
{selectedIntegration && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<Card className="w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<CardHeader
title={selectedIntegration.name}
subtitle={`Tipo: ${selectedIntegration.type}`}
action={
<button
onClick={() => setSelectedIntegration(null)}
className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
}
/>
<CardContent>
<div className="space-y-6">
{/* Estado */}
<div className="flex items-center gap-4">
<StatusBadge status={selectedIntegration.status} />
<HealthBadge status={selectedIntegration.healthStatus} />
</div>
{/* Ultima sincronizacion */}
<div>
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Ultima Sincronizacion
</h4>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500 dark:text-slate-400">Fecha:</span>
<span className="ml-2 text-slate-900 dark:text-white">
{formatDate(selectedIntegration.lastSyncAt)}
</span>
</div>
<div>
<span className="text-slate-500 dark:text-slate-400">Estado:</span>
<span className="ml-2 text-slate-900 dark:text-white capitalize">
{selectedIntegration.lastSyncStatus || '-'}
</span>
</div>
</div>
</div>
</div>
{/* Acciones */}
<div className="flex gap-2">
<Button
leftIcon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
}
>
Sincronizar Ahora
</Button>
<Button variant="outline">
Ver Historial
</Button>
<Button variant="ghost">
Configurar Schedule
</Button>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
variant="danger"
leftIcon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
}
>
Eliminar
</Button>
<Button
variant="secondary"
onClick={() => setSelectedIntegration(null)}
>
Cerrar
</Button>
</CardFooter>
</Card>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,704 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useRouter, useParams } from 'next/navigation';
import {
ArrowLeft,
Download,
Share2,
Printer,
Calendar,
FileText,
TrendingUp,
TrendingDown,
DollarSign,
PieChart,
BarChart3,
Users,
Receipt,
Mail,
Copy,
Check,
ExternalLink,
} from 'lucide-react';
import { cn, formatDate, formatCurrency, formatPercentage, formatNumber } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import {
ReportSection,
ReportSubSection,
ReportDataRow,
ReportAlert,
ReportChart,
Report,
ReportType,
} from '@/components/reports';
// ============================================================================
// Mock Data
// ============================================================================
const generateMockReportData = (id: string): Report & { fullData: ReportFullData } => {
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1, 1);
const endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 1, 0);
const ingresos = 245000;
const egresos = 180000;
return {
id,
title: `Reporte ${startDate.toLocaleString('es', { month: 'long' })} ${startDate.getFullYear()}`,
type: 'mensual' as ReportType,
status: 'completado',
period: {
start: startDate.toISOString(),
end: endDate.toISOString(),
},
sections: ['Resumen Ejecutivo', 'Ingresos', 'Egresos', 'Flujo de Caja', 'Metricas SaaS', 'Graficas'],
generatedAt: new Date().toISOString(),
fileSize: '2.4MB',
summary: {
ingresos,
egresos,
utilidad: ingresos - egresos,
},
fullData: {
resumenEjecutivo: {
highlights: [
{ label: 'Ingresos Totales', value: ingresos, change: 12.5, format: 'currency' },
{ label: 'Gastos Totales', value: egresos, change: 5.2, format: 'currency' },
{ label: 'Utilidad Neta', value: ingresos - egresos, change: 28.3, format: 'currency' },
{ label: 'Margen Bruto', value: 72.5, change: 3.2, format: 'percentage' },
],
alerts: [
{ type: 'success', title: 'Crecimiento sostenido', message: 'Los ingresos han crecido por 5to mes consecutivo' },
{ type: 'warning', title: 'Cuentas por cobrar', message: '3 facturas vencidas por $15,750 requieren atencion' },
],
},
ingresos: {
total: ingresos,
byCategory: [
{ name: 'Suscripciones', value: 125000, percentage: 51 },
{ name: 'Servicios', value: 75000, percentage: 31 },
{ name: 'Licencias', value: 35000, percentage: 14 },
{ name: 'Otros', value: 10000, percentage: 4 },
],
trend: [
{ name: 'Ene', ingresos: 180000 },
{ name: 'Feb', ingresos: 195000 },
{ name: 'Mar', ingresos: 210000 },
{ name: 'Abr', ingresos: 225000 },
{ name: 'May', ingresos: 235000 },
{ name: 'Jun', ingresos: 245000 },
],
},
egresos: {
total: egresos,
byCategory: [
{ name: 'Nomina', value: 85000, percentage: 47 },
{ name: 'Operaciones', value: 45000, percentage: 25 },
{ name: 'Marketing', value: 25000, percentage: 14 },
{ name: 'Tecnologia', value: 15000, percentage: 8 },
{ name: 'Otros', value: 10000, percentage: 6 },
],
trend: [
{ name: 'Ene', egresos: 150000 },
{ name: 'Feb', egresos: 155000 },
{ name: 'Mar', egresos: 165000 },
{ name: 'Abr', egresos: 170000 },
{ name: 'May', egresos: 175000 },
{ name: 'Jun', egresos: 180000 },
],
},
flujoCaja: {
saldoInicial: 350000,
entradas: 245000,
salidas: 180000,
saldoFinal: 415000,
trend: [
{ name: 'Ene', flujo: 320000 },
{ name: 'Feb', flujo: 345000 },
{ name: 'Mar', flujo: 365000 },
{ name: 'Abr', flujo: 380000 },
{ name: 'May', flujo: 395000 },
{ name: 'Jun', flujo: 415000 },
],
},
metricasSaas: {
mrr: 125000,
arr: 1500000,
churn: 2.5,
ltv: 15000,
cac: 2500,
ltvCac: 6.0,
nrr: 115,
activeCustomers: 342,
newCustomers: 28,
churnedCustomers: 9,
},
},
};
};
interface ReportFullData {
resumenEjecutivo: {
highlights: Array<{ label: string; value: number; change: number; format: 'currency' | 'percentage' | 'number' }>;
alerts: Array<{ type: 'info' | 'success' | 'warning' | 'error'; title: string; message: string }>;
};
ingresos: {
total: number;
byCategory: Array<{ name: string; value: number; percentage: number }>;
trend: Array<{ name: string; ingresos: number }>;
};
egresos: {
total: number;
byCategory: Array<{ name: string; value: number; percentage: number }>;
trend: Array<{ name: string; egresos: number }>;
};
flujoCaja: {
saldoInicial: number;
entradas: number;
salidas: number;
saldoFinal: number;
trend: Array<{ name: string; flujo: number }>;
};
metricasSaas: {
mrr: number;
arr: number;
churn: number;
ltv: number;
cac: number;
ltvCac: number;
nrr: number;
activeCustomers: number;
newCustomers: number;
churnedCustomers: number;
};
}
// ============================================================================
// Loading Skeleton
// ============================================================================
function ReportDetailSkeleton() {
return (
<div className="animate-pulse space-y-6">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-lg" />
<div className="flex-1">
<div className="h-6 bg-slate-200 dark:bg-slate-700 rounded w-64 mb-2" />
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-48" />
</div>
</div>
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<div className="h-5 bg-slate-200 dark:bg-slate-700 rounded w-48 mb-4" />
<div className="space-y-3">
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded" />
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4" />
</div>
</div>
))}
</div>
);
}
// ============================================================================
// Share Modal
// ============================================================================
interface ShareModalProps {
report: Report;
onClose: () => void;
}
function ShareModal({ report, onClose }: ShareModalProps) {
const [email, setEmail] = useState('');
const [copied, setCopied] = useState(false);
const shareUrl = `https://app.horux.io/reportes/${report.id}`;
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleSendEmail = () => {
console.log('Sending email to:', email);
alert(`Reporte enviado a: ${email}`);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-md w-full">
<div className="p-5 border-b border-slate-200 dark:border-slate-700">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Compartir Reporte
</h2>
</div>
<div className="p-5 space-y-4">
{/* Copy Link */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Enlace del reporte
</label>
<div className="flex gap-2">
<input
type="text"
value={shareUrl}
readOnly
className="flex-1 px-3 py-2 bg-slate-50 dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
/>
<Button
variant="secondary"
onClick={handleCopyLink}
leftIcon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
>
{copied ? 'Copiado' : 'Copiar'}
</Button>
</div>
</div>
{/* Send Email */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Enviar por email
</label>
<div className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="correo@ejemplo.com"
className="flex-1 px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
/>
<Button
variant="primary"
onClick={handleSendEmail}
disabled={!email}
leftIcon={<Mail className="w-4 h-4" />}
>
Enviar
</Button>
</div>
</div>
</div>
<div className="p-5 border-t border-slate-200 dark:border-slate-700 flex justify-end">
<Button variant="outline" onClick={onClose}>
Cerrar
</Button>
</div>
</div>
</div>
);
}
// ============================================================================
// Main Page Component
// ============================================================================
export default function ReporteDetailPage() {
const router = useRouter();
const params = useParams();
const reportId = params.id as string;
const [report, setReport] = useState<(Report & { fullData: ReportFullData }) | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showShareModal, setShowShareModal] = useState(false);
const fetchReport = useCallback(async () => {
setIsLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 600));
setReport(generateMockReportData(reportId));
} catch (error) {
console.error('Error fetching report:', error);
} finally {
setIsLoading(false);
}
}, [reportId]);
useEffect(() => {
fetchReport();
}, [fetchReport]);
const handleDownload = () => {
console.log('Downloading report:', reportId);
alert('Descargando reporte...');
};
const handlePrint = () => {
window.print();
};
const formatValue = (value: number, format: 'currency' | 'percentage' | 'number') => {
switch (format) {
case 'currency':
return formatCurrency(value);
case 'percentage':
return `${value.toFixed(1)}%`;
default:
return formatNumber(value);
}
};
if (isLoading) {
return <ReportDetailSkeleton />;
}
if (!report) {
return (
<div className="text-center py-12">
<FileText className="w-12 h-12 mx-auto text-slate-300 dark:text-slate-600 mb-4" />
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
Reporte no encontrado
</h2>
<p className="text-slate-500 dark:text-slate-400 mb-4">
El reporte que buscas no existe o ha sido eliminado.
</p>
<Button onClick={() => router.push('/reportes')}>
Volver a Reportes
</Button>
</div>
);
}
const { fullData } = report;
return (
<div className="space-y-6 print:space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 print:hidden">
<div className="flex items-center gap-4">
<button
onClick={() => router.push('/reportes')}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
{report.title}
</h1>
<p className="text-slate-500 dark:text-slate-400 flex items-center gap-2">
<Calendar className="w-4 h-4" />
{formatDate(report.period.start)} - {formatDate(report.period.end)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
leftIcon={<Printer className="w-4 h-4" />}
onClick={handlePrint}
>
Imprimir
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={<Share2 className="w-4 h-4" />}
onClick={() => setShowShareModal(true)}
>
Compartir
</Button>
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={handleDownload}
>
Descargar PDF
</Button>
</div>
</div>
{/* Print Header */}
<div className="hidden print:block">
<h1 className="text-2xl font-bold">{report.title}</h1>
<p className="text-slate-500">
Periodo: {formatDate(report.period.start)} - {formatDate(report.period.end)}
</p>
</div>
{/* Resumen Ejecutivo */}
<ReportSection
title="Resumen Ejecutivo"
icon={<FileText className="w-5 h-5" />}
badge="Destacados"
>
{/* KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{fullData.resumenEjecutivo.highlights.map((item) => (
<div
key={item.label}
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50"
>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">
{item.label}
</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">
{formatValue(item.value, item.format)}
</p>
<p
className={cn(
'text-sm flex items-center gap-1 mt-1',
item.change >= 0
? 'text-success-600 dark:text-success-400'
: 'text-error-600 dark:text-error-400'
)}
>
{item.change >= 0 ? (
<TrendingUp className="w-3.5 h-3.5" />
) : (
<TrendingDown className="w-3.5 h-3.5" />
)}
{formatPercentage(Math.abs(item.change))}
</p>
</div>
))}
</div>
{/* Alerts */}
<div className="space-y-3">
{fullData.resumenEjecutivo.alerts.map((alert, index) => (
<ReportAlert
key={index}
type={alert.type}
title={alert.title}
message={alert.message}
/>
))}
</div>
</ReportSection>
{/* Ingresos */}
<ReportSection
title="Analisis de Ingresos"
icon={<TrendingUp className="w-5 h-5" />}
badge={formatCurrency(fullData.ingresos.total)}
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* By Category */}
<div>
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-4">
Desglose por Categoria
</h4>
<div className="space-y-3">
{fullData.ingresos.byCategory.map((cat) => (
<div key={cat.name}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-700 dark:text-slate-300">{cat.name}</span>
<span className="font-medium text-slate-900 dark:text-white">
{formatCurrency(cat.value)} ({cat.percentage}%)
</span>
</div>
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-success-500 rounded-full"
style={{ width: `${cat.percentage}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Trend Chart */}
<ReportChart
title="Tendencia de Ingresos"
type="area"
data={fullData.ingresos.trend}
series={[{ dataKey: 'ingresos', name: 'Ingresos', color: '#10b981' }]}
formatTooltip="currency"
height={250}
showLegend={false}
/>
</div>
</ReportSection>
{/* Egresos */}
<ReportSection
title="Analisis de Egresos"
icon={<Receipt className="w-5 h-5" />}
badge={formatCurrency(fullData.egresos.total)}
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* By Category */}
<div>
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-4">
Desglose por Categoria
</h4>
<div className="space-y-3">
{fullData.egresos.byCategory.map((cat) => (
<div key={cat.name}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-700 dark:text-slate-300">{cat.name}</span>
<span className="font-medium text-slate-900 dark:text-white">
{formatCurrency(cat.value)} ({cat.percentage}%)
</span>
</div>
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-error-500 rounded-full"
style={{ width: `${cat.percentage}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Trend Chart */}
<ReportChart
title="Tendencia de Egresos"
type="bar"
data={fullData.egresos.trend}
series={[{ dataKey: 'egresos', name: 'Egresos', color: '#ef4444' }]}
formatTooltip="currency"
height={250}
showLegend={false}
/>
</div>
</ReportSection>
{/* Flujo de Caja */}
<ReportSection
title="Flujo de Caja"
icon={<DollarSign className="w-5 h-5" />}
>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Saldo Inicial</p>
<p className="text-xl font-bold text-slate-900 dark:text-white mt-1">
{formatCurrency(fullData.flujoCaja.saldoInicial)}
</p>
</div>
<div className="p-4 rounded-lg bg-success-50 dark:bg-success-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Entradas</p>
<p className="text-xl font-bold text-success-600 dark:text-success-400 mt-1">
+{formatCurrency(fullData.flujoCaja.entradas)}
</p>
</div>
<div className="p-4 rounded-lg bg-error-50 dark:bg-error-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Salidas</p>
<p className="text-xl font-bold text-error-600 dark:text-error-400 mt-1">
-{formatCurrency(fullData.flujoCaja.salidas)}
</p>
</div>
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Saldo Final</p>
<p className="text-xl font-bold text-primary-600 dark:text-primary-400 mt-1">
{formatCurrency(fullData.flujoCaja.saldoFinal)}
</p>
</div>
</div>
<ReportChart
title="Evolucion del Flujo de Caja"
type="line"
data={fullData.flujoCaja.trend}
series={[{ dataKey: 'flujo', name: 'Saldo', color: '#0c8ce8' }]}
formatTooltip="currency"
height={250}
/>
</ReportSection>
{/* Metricas SaaS */}
<ReportSection
title="Metricas SaaS"
icon={<BarChart3 className="w-5 h-5" />}
>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">MRR</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{formatCurrency(fullData.metricasSaas.mrr)}
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">ARR</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{formatCurrency(fullData.metricasSaas.arr)}
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">Churn Rate</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{fullData.metricasSaas.churn}%
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">NRR</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{fullData.metricasSaas.nrr}%
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">LTV</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{formatCurrency(fullData.metricasSaas.ltv)}
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">CAC</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{formatCurrency(fullData.metricasSaas.cac)}
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">LTV/CAC</p>
<p className="text-2xl font-bold text-success-600 dark:text-success-400 mt-1">
{fullData.metricasSaas.ltvCac}x
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">Clientes Activos</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{fullData.metricasSaas.activeCustomers}
</p>
</div>
</div>
<ReportSubSection title="Movimiento de Clientes">
<div className="grid grid-cols-3 gap-4">
<div className="p-4 rounded-lg bg-success-50 dark:bg-success-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Nuevos</p>
<p className="text-2xl font-bold text-success-600 dark:text-success-400">
+{fullData.metricasSaas.newCustomers}
</p>
</div>
<div className="p-4 rounded-lg bg-error-50 dark:bg-error-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Cancelados</p>
<p className="text-2xl font-bold text-error-600 dark:text-error-400">
-{fullData.metricasSaas.churnedCustomers}
</p>
</div>
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Crecimiento Neto</p>
<p className="text-2xl font-bold text-primary-600 dark:text-primary-400">
+{fullData.metricasSaas.newCustomers - fullData.metricasSaas.churnedCustomers}
</p>
</div>
</div>
</ReportSubSection>
</ReportSection>
{/* Footer */}
<div className="text-center py-6 text-sm text-slate-500 dark:text-slate-400 print:hidden">
<p>Generado el {formatDate(report.generatedAt || new Date().toISOString())}</p>
<p className="mt-1">Horux Strategy - CFO Digital</p>
</div>
{/* Share Modal */}
{showShareModal && (
<ShareModal report={report} onClose={() => setShowShareModal(false)} />
)}
</div>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, FileText, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { GenerateReportWizard, ReportConfig } from '@/components/reports';
import { cn } from '@/lib/utils';
/**
* Pagina para generar un nuevo reporte
*
* Presenta un wizard de 3 pasos para configurar y generar
* un nuevo reporte financiero.
*/
export default function NuevoReportePage() {
const router = useRouter();
const [isComplete, setIsComplete] = useState(false);
const [generatedReportId, setGeneratedReportId] = useState<string | null>(null);
const handleComplete = async (config: ReportConfig) => {
console.log('Generating report with config:', config);
// Simular generacion del reporte
await new Promise((resolve) => setTimeout(resolve, 2000));
// Simular ID del reporte generado
const reportId = `report-${Date.now()}`;
setGeneratedReportId(reportId);
setIsComplete(true);
};
const handleCancel = () => {
router.push('/reportes');
};
const handleViewReport = () => {
if (generatedReportId) {
router.push(`/reportes/${generatedReportId}`);
}
};
const handleGenerateAnother = () => {
setIsComplete(false);
setGeneratedReportId(null);
};
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<button
onClick={() => router.push('/reportes')}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Generar Nuevo Reporte
</h1>
<p className="text-slate-500 dark:text-slate-400">
Configura y genera un reporte financiero personalizado
</p>
</div>
</div>
{/* Content */}
{isComplete ? (
// Success State
<Card className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
<CheckCircle className="w-10 h-10 text-success-600 dark:text-success-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
Reporte Generado Exitosamente
</h2>
<p className="text-slate-500 dark:text-slate-400 max-w-md mx-auto mb-8">
Tu reporte ha sido generado y esta listo para visualizarse.
Puedes verlo ahora o generar otro reporte.
</p>
<div className="flex items-center justify-center gap-4">
<Button variant="outline" onClick={handleGenerateAnother}>
Generar Otro Reporte
</Button>
<Button onClick={handleViewReport} leftIcon={<FileText className="w-4 h-4" />}>
Ver Reporte
</Button>
</div>
</Card>
) : (
// Wizard
<GenerateReportWizard
onComplete={handleComplete}
onCancel={handleCancel}
className="border border-slate-200 dark:border-slate-700 shadow-lg"
/>
)}
{/* Tips Section */}
{!isComplete && (
<Card className="bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800">
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="p-2 rounded-lg bg-primary-100 dark:bg-primary-900/40">
<FileText className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
</div>
<div>
<h3 className="font-semibold text-primary-900 dark:text-primary-100 mb-1">
Consejos para generar reportes
</h3>
<ul className="text-sm text-primary-700 dark:text-primary-300 space-y-1">
<li>- Selecciona el tipo de reporte que mejor se ajuste a tus necesidades de analisis</li>
<li>- Los reportes mensuales son ideales para seguimiento operativo</li>
<li>- Los reportes trimestrales ofrecen mejor perspectiva de tendencias</li>
<li>- Incluye las secciones de metricas SaaS si tienes un modelo de suscripcion</li>
</ul>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,685 @@
'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import {
FileText,
Plus,
Search,
Filter,
Download,
RefreshCw,
Calendar,
Grid3X3,
List,
ChevronLeft,
ChevronRight,
Eye,
Clock,
CheckCircle,
X,
} from 'lucide-react';
import { cn, formatDate, formatCurrency } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { ReportCard, Report, ReportType, ReportStatus } from '@/components/reports';
// ============================================================================
// Types
// ============================================================================
interface Filters {
search: string;
type: ReportType | 'all';
status: ReportStatus | 'all';
dateFrom: string;
dateTo: string;
}
type ViewMode = 'grid' | 'list';
// ============================================================================
// Mock Data
// ============================================================================
const generateMockReports = (): Report[] => {
const reports: Report[] = [];
const types: ReportType[] = ['mensual', 'trimestral', 'anual', 'custom'];
const statuses: ReportStatus[] = ['completado', 'completado', 'completado', 'generando', 'pendiente'];
const sectionOptions = [
'Resumen Ejecutivo',
'Ingresos',
'Egresos',
'Flujo de Caja',
'Metricas SaaS',
'Cuentas por Cobrar',
'Cuentas por Pagar',
'Graficas',
];
for (let i = 0; i < 12; i++) {
const type = types[i % types.length];
const status = statuses[Math.floor(Math.random() * statuses.length)];
const date = new Date();
date.setMonth(date.getMonth() - i);
const startDate = new Date(date.getFullYear(), date.getMonth(), 1);
let endDate: Date;
switch (type) {
case 'trimestral':
endDate = new Date(date.getFullYear(), date.getMonth() + 3, 0);
break;
case 'anual':
endDate = new Date(date.getFullYear(), 11, 31);
startDate.setMonth(0);
break;
default:
endDate = new Date(date.getFullYear(), date.getMonth() + 1, 0);
}
const ingresos = Math.floor(Math.random() * 500000) + 100000;
const egresos = Math.floor(ingresos * (0.5 + Math.random() * 0.3));
reports.push({
id: `report-${i + 1}`,
title: type === 'anual'
? `Reporte Anual ${startDate.getFullYear()}`
: type === 'trimestral'
? `Reporte Q${Math.ceil((startDate.getMonth() + 1) / 3)} ${startDate.getFullYear()}`
: type === 'custom'
? `Reporte Personalizado ${i + 1}`
: `Reporte ${startDate.toLocaleString('es', { month: 'long' })} ${startDate.getFullYear()}`,
type,
status,
period: {
start: startDate.toISOString(),
end: endDate.toISOString(),
},
sections: sectionOptions.slice(0, Math.floor(Math.random() * 4) + 4),
generatedAt: status === 'completado' ? date.toISOString() : undefined,
fileSize: status === 'completado' ? `${Math.floor(Math.random() * 5) + 1}.${Math.floor(Math.random() * 9)}MB` : undefined,
summary: status === 'completado' ? {
ingresos,
egresos,
utilidad: ingresos - egresos,
} : undefined,
progress: status === 'generando' ? Math.floor(Math.random() * 80) + 10 : undefined,
});
}
return reports;
};
// ============================================================================
// Loading Skeleton
// ============================================================================
function ReportsSkeleton() {
return (
<div className="animate-pulse space-y-6">
<div className="flex items-center justify-between">
<div className="h-8 bg-slate-200 dark:bg-slate-700 rounded w-48" />
<div className="h-10 bg-slate-200 dark:bg-slate-700 rounded w-40" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-lg" />
<div className="flex-1">
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2" />
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/2" />
</div>
</div>
<div className="space-y-3">
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded" />
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-2/3" />
</div>
</div>
))}
</div>
</div>
);
}
// ============================================================================
// Filter Panel
// ============================================================================
interface FilterPanelProps {
filters: Filters;
onChange: (filters: Filters) => void;
onClose: () => void;
isOpen: boolean;
}
function FilterPanel({ filters, onChange, onClose, isOpen }: FilterPanelProps) {
if (!isOpen) return null;
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 shadow-lg">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900 dark:text-white">Filtros</h3>
<button onClick={onClose} className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Tipo de Reporte
</label>
<select
value={filters.type}
onChange={(e) => onChange({ ...filters, type: e.target.value as Filters['type'] })}
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
>
<option value="all">Todos</option>
<option value="mensual">Mensual</option>
<option value="trimestral">Trimestral</option>
<option value="anual">Anual</option>
<option value="custom">Personalizado</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Estado
</label>
<select
value={filters.status}
onChange={(e) => onChange({ ...filters, status: e.target.value as Filters['status'] })}
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
>
<option value="all">Todos</option>
<option value="completado">Completado</option>
<option value="generando">Generando</option>
<option value="pendiente">Pendiente</option>
<option value="error">Error</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Desde
</label>
<input
type="date"
value={filters.dateFrom}
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Hasta
</label>
<input
type="date"
value={filters.dateTo}
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
<button
onClick={() => onChange({ search: '', type: 'all', status: 'all', dateFrom: '', dateTo: '' })}
className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white"
>
Limpiar filtros
</button>
<Button size="sm" onClick={onClose}>
Aplicar
</Button>
</div>
</div>
);
}
// ============================================================================
// Report Preview Modal
// ============================================================================
interface ReportPreviewProps {
report: Report;
onClose: () => void;
onView: () => void;
onDownload: () => void;
}
function ReportPreview({ report, onClose, onView, onDownload }: ReportPreviewProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-primary-100 dark:bg-primary-900/30">
<FileText className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<h2 className="font-semibold text-slate-900 dark:text-white">{report.title}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
{formatDate(report.period.start)} - {formatDate(report.period.end)}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-5 space-y-6 max-h-[60vh] overflow-y-auto">
{/* Summary */}
{report.summary && (
<div>
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Resumen Financiero
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 rounded-lg bg-success-50 dark:bg-success-900/20 text-center">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">Ingresos</p>
<p className="text-xl font-bold text-success-600 dark:text-success-400">
{formatCurrency(report.summary.ingresos)}
</p>
</div>
<div className="p-4 rounded-lg bg-error-50 dark:bg-error-900/20 text-center">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">Egresos</p>
<p className="text-xl font-bold text-error-600 dark:text-error-400">
{formatCurrency(report.summary.egresos)}
</p>
</div>
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 text-center">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">Utilidad</p>
<p className="text-xl font-bold text-primary-600 dark:text-primary-400">
{formatCurrency(report.summary.utilidad)}
</p>
</div>
</div>
</div>
)}
{/* Sections */}
<div>
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Secciones Incluidas
</h3>
<div className="flex flex-wrap gap-2">
{report.sections.map((section) => (
<span
key={section}
className="px-3 py-1.5 text-sm rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300"
>
{section}
</span>
))}
</div>
</div>
{/* Details */}
<div>
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Detalles
</h3>
<dl className="space-y-2">
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-700">
<dt className="text-sm text-slate-500 dark:text-slate-400">Tipo</dt>
<dd className="text-sm font-medium text-slate-900 dark:text-white capitalize">{report.type}</dd>
</div>
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-700">
<dt className="text-sm text-slate-500 dark:text-slate-400">Estado</dt>
<dd className="text-sm font-medium text-slate-900 dark:text-white capitalize">{report.status}</dd>
</div>
{report.generatedAt && (
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-700">
<dt className="text-sm text-slate-500 dark:text-slate-400">Generado</dt>
<dd className="text-sm font-medium text-slate-900 dark:text-white">
{formatDate(report.generatedAt)}
</dd>
</div>
)}
{report.fileSize && (
<div className="flex justify-between py-2">
<dt className="text-sm text-slate-500 dark:text-slate-400">Tamano</dt>
<dd className="text-sm font-medium text-slate-900 dark:text-white">{report.fileSize}</dd>
</div>
)}
</dl>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-5 border-t border-slate-200 dark:border-slate-700">
<Button variant="outline" onClick={onClose}>
Cerrar
</Button>
{report.status === 'completado' && (
<>
<Button variant="secondary" leftIcon={<Download className="w-4 h-4" />} onClick={onDownload}>
Descargar PDF
</Button>
<Button leftIcon={<Eye className="w-4 h-4" />} onClick={onView}>
Ver Completo
</Button>
</>
)}
</div>
</div>
</div>
);
}
// ============================================================================
// Main Page Component
// ============================================================================
export default function ReportesPage() {
const router = useRouter();
const [reports, setReports] = useState<Report[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showFilters, setShowFilters] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [previewReport, setPreviewReport] = useState<Report | null>(null);
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<Filters>({
search: '',
type: 'all',
status: 'all',
dateFrom: '',
dateTo: '',
});
const limit = 9;
const fetchReports = useCallback(async () => {
setIsLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 600));
setReports(generateMockReports());
} catch (error) {
console.error('Error fetching reports:', error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchReports();
}, [fetchReports]);
// Filter reports
const filteredReports = useMemo(() => {
return reports.filter((report) => {
if (filters.search) {
const searchLower = filters.search.toLowerCase();
if (!report.title.toLowerCase().includes(searchLower)) {
return false;
}
}
if (filters.type !== 'all' && report.type !== filters.type) return false;
if (filters.status !== 'all' && report.status !== filters.status) return false;
if (filters.dateFrom && report.period.start < filters.dateFrom) return false;
if (filters.dateTo && report.period.end > filters.dateTo) return false;
return true;
});
}, [reports, filters]);
const paginatedReports = useMemo(() => {
const start = (page - 1) * limit;
return filteredReports.slice(start, start + limit);
}, [filteredReports, page]);
const totalPages = Math.ceil(filteredReports.length / limit);
// Summary stats
const stats = useMemo(() => ({
total: reports.length,
completados: reports.filter(r => r.status === 'completado').length,
generando: reports.filter(r => r.status === 'generando').length,
pendientes: reports.filter(r => r.status === 'pendiente').length,
}), [reports]);
const handleViewReport = (report: Report) => {
router.push(`/reportes/${report.id}`);
};
const handleDownloadReport = (report: Report) => {
// Simular descarga
console.log('Downloading report:', report.id);
alert(`Descargando: ${report.title}`);
};
if (isLoading) {
return (
<div className="space-y-6">
<ReportsSkeleton />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
Reportes
</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
Gestiona y genera reportes financieros
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
leftIcon={<RefreshCw className="w-4 h-4" />}
onClick={fetchReports}
>
Actualizar
</Button>
<Button
size="sm"
leftIcon={<Plus className="w-4 h-4" />}
onClick={() => router.push('/reportes/nuevo')}
>
Generar Reporte
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-slate-100 dark:bg-slate-700">
<FileText className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Total</p>
<p className="text-xl font-bold text-slate-900 dark:text-white">{stats.total}</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-success-100 dark:bg-success-900/30">
<CheckCircle className="w-5 h-5 text-success-600 dark:text-success-400" />
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Completados</p>
<p className="text-xl font-bold text-success-600 dark:text-success-400">{stats.completados}</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-primary-100 dark:bg-primary-900/30">
<Clock className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Generando</p>
<p className="text-xl font-bold text-primary-600 dark:text-primary-400">{stats.generando}</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-warning-100 dark:bg-warning-900/30">
<Calendar className="w-5 h-5 text-warning-600 dark:text-warning-400" />
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Pendientes</p>
<p className="text-xl font-bold text-warning-600 dark:text-warning-400">{stats.pendientes}</p>
</div>
</div>
</Card>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="Buscar reportes..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowFilters(!showFilters)}
className={cn(
'flex items-center gap-2 px-4 py-2.5 border rounded-lg transition-colors',
showFilters
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-400'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
<Filter className="w-4 h-4" />
<span className="hidden sm:inline">Filtros</span>
</button>
<div className="flex items-center border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('grid')}
className={cn(
'p-2.5',
viewMode === 'grid'
? 'bg-primary-500 text-white'
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={cn(
'p-2.5',
viewMode === 'list'
? 'bg-primary-500 text-white'
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Filter Panel */}
{showFilters && (
<FilterPanel
filters={filters}
onChange={setFilters}
onClose={() => setShowFilters(false)}
isOpen={showFilters}
/>
)}
{/* Reports Grid/List */}
{paginatedReports.length > 0 ? (
<div
className={cn(
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
: 'space-y-4'
)}
>
{paginatedReports.map((report) => (
<ReportCard
key={report.id}
report={report}
onView={(r) => setPreviewReport(r)}
onDownload={handleDownloadReport}
/>
))}
</div>
) : (
<Card className="text-center py-12">
<FileText className="w-12 h-12 mx-auto text-slate-300 dark:text-slate-600 mb-4" />
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
No se encontraron reportes
</h3>
<p className="text-slate-500 dark:text-slate-400 mb-4">
Ajusta los filtros o genera un nuevo reporte
</p>
<Button onClick={() => router.push('/reportes/nuevo')}>
Generar Reporte
</Button>
</Card>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500 dark:text-slate-400">
Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredReports.length)} de {filteredReports.length} reportes
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm text-slate-600 dark:text-slate-400">
Pagina {page} de {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
)}
{/* Preview Modal */}
{previewReport && (
<ReportPreview
report={previewReport}
onClose={() => setPreviewReport(null)}
onView={() => {
setPreviewReport(null);
handleViewReport(previewReport);
}}
onDownload={() => handleDownloadReport(previewReport)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,338 @@
'use client';
import React from 'react';
import { cn, formatCurrency, formatPercentage } from '@/lib/utils';
import {
TrendingUp,
TrendingDown,
AlertTriangle,
CheckCircle,
Info,
Lightbulb,
ArrowRight,
Sparkles,
} from 'lucide-react';
/**
* Tipo de insight
*/
export type InsightType = 'positive' | 'negative' | 'warning' | 'info' | 'suggestion';
/**
* Prioridad del insight
*/
export type InsightPriority = 'high' | 'medium' | 'low';
/**
* Interface del insight
*/
export interface AIInsight {
id: string;
type: InsightType;
priority: InsightPriority;
title: string;
description: string;
metric?: {
label: string;
value: number | string;
change?: number;
format?: 'currency' | 'percentage' | 'number';
};
action?: {
label: string;
href?: string;
onClick?: () => void;
};
timestamp: Date;
}
/**
* Props del componente AIInsightCard
*/
interface AIInsightCardProps {
insight: AIInsight;
onAction?: () => void;
onDismiss?: () => void;
className?: string;
compact?: boolean;
}
/**
* Estilos por tipo de insight
*/
const typeStyles: Record<InsightType, {
bg: string;
border: string;
icon: React.ReactNode;
iconBg: string;
iconColor: string;
}> = {
positive: {
bg: 'bg-success-50 dark:bg-success-900/20',
border: 'border-success-200 dark:border-success-800',
icon: <TrendingUp className="w-5 h-5" />,
iconBg: 'bg-success-100 dark:bg-success-900/40',
iconColor: 'text-success-600 dark:text-success-400',
},
negative: {
bg: 'bg-error-50 dark:bg-error-900/20',
border: 'border-error-200 dark:border-error-800',
icon: <TrendingDown className="w-5 h-5" />,
iconBg: 'bg-error-100 dark:bg-error-900/40',
iconColor: 'text-error-600 dark:text-error-400',
},
warning: {
bg: 'bg-warning-50 dark:bg-warning-900/20',
border: 'border-warning-200 dark:border-warning-800',
icon: <AlertTriangle className="w-5 h-5" />,
iconBg: 'bg-warning-100 dark:bg-warning-900/40',
iconColor: 'text-warning-600 dark:text-warning-400',
},
info: {
bg: 'bg-primary-50 dark:bg-primary-900/20',
border: 'border-primary-200 dark:border-primary-800',
icon: <Info className="w-5 h-5" />,
iconBg: 'bg-primary-100 dark:bg-primary-900/40',
iconColor: 'text-primary-600 dark:text-primary-400',
},
suggestion: {
bg: 'bg-violet-50 dark:bg-violet-900/20',
border: 'border-violet-200 dark:border-violet-800',
icon: <Lightbulb className="w-5 h-5" />,
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
iconColor: 'text-violet-600 dark:text-violet-400',
},
};
/**
* Badge de prioridad
*/
const priorityBadge: Record<InsightPriority, { label: string; className: string }> = {
high: {
label: 'Alta prioridad',
className: 'bg-error-100 text-error-700 dark:bg-error-900/40 dark:text-error-400',
},
medium: {
label: 'Media',
className: 'bg-warning-100 text-warning-700 dark:bg-warning-900/40 dark:text-warning-400',
},
low: {
label: 'Baja',
className: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-400',
},
};
/**
* Formatear valor de metrica
*/
const formatMetricValue = (value: number | string, format?: 'currency' | 'percentage' | 'number'): string => {
if (typeof value === 'string') return value;
switch (format) {
case 'currency':
return formatCurrency(value);
case 'percentage':
return `${value.toFixed(1)}%`;
case 'number':
default:
return value.toLocaleString('es-ES');
}
};
/**
* Componente AIInsightCard
*
* Tarjeta que muestra un insight generado por IA con metricas
* relevantes y acciones sugeridas.
*/
export const AIInsightCard: React.FC<AIInsightCardProps> = ({
insight,
onAction,
onDismiss,
className,
compact = false,
}) => {
const styles = typeStyles[insight.type];
const priority = priorityBadge[insight.priority];
if (compact) {
return (
<div
className={cn(
'flex items-start gap-3 p-3 rounded-lg border',
styles.bg,
styles.border,
className
)}
>
<span className={cn('flex-shrink-0 p-1.5 rounded-md', styles.iconBg, styles.iconColor)}>
{styles.icon}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-white line-clamp-1">
{insight.title}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 line-clamp-2 mt-0.5">
{insight.description}
</p>
</div>
{insight.action && (
<button
onClick={insight.action.onClick || onAction}
className={cn('flex-shrink-0 p-1 rounded', styles.iconColor, 'hover:bg-white/50 dark:hover:bg-slate-800/50')}
>
<ArrowRight className="w-4 h-4" />
</button>
)}
</div>
);
}
return (
<div
className={cn(
'rounded-xl border overflow-hidden',
styles.bg,
styles.border,
className
)}
>
{/* Header */}
<div className="p-4 flex items-start gap-3">
<span className={cn('flex-shrink-0 p-2.5 rounded-lg', styles.iconBg, styles.iconColor)}>
{styles.icon}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900 dark:text-white">
{insight.title}
</h3>
<span className={cn('text-xs font-medium px-2 py-0.5 rounded-full', priority.className)}>
{priority.label}
</span>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">
{insight.description}
</p>
</div>
<div className="flex items-center gap-1">
<Sparkles className="w-4 h-4 text-violet-500" />
<span className="text-xs text-slate-400 dark:text-slate-500">AI</span>
</div>
</div>
{/* Metric */}
{insight.metric && (
<div className="px-4 pb-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-white/50 dark:bg-slate-800/50">
<span className="text-sm text-slate-600 dark:text-slate-400">
{insight.metric.label}
</span>
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-slate-900 dark:text-white">
{formatMetricValue(insight.metric.value, insight.metric.format)}
</span>
{insight.metric.change !== undefined && (
<span
className={cn(
'flex items-center gap-0.5 text-sm font-medium',
insight.metric.change >= 0
? 'text-success-600 dark:text-success-400'
: 'text-error-600 dark:text-error-400'
)}
>
{insight.metric.change >= 0 ? (
<TrendingUp className="w-3.5 h-3.5" />
) : (
<TrendingDown className="w-3.5 h-3.5" />
)}
{formatPercentage(Math.abs(insight.metric.change))}
</span>
)}
</div>
</div>
</div>
)}
{/* Action */}
{insight.action && (
<div className="px-4 pb-4">
<button
onClick={insight.action.onClick || onAction}
className={cn(
'w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg',
'text-sm font-medium transition-colors',
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
'border border-slate-200 dark:border-slate-700',
'hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
{insight.action.label}
<ArrowRight className="w-4 h-4" />
</button>
</div>
)}
</div>
);
};
/**
* Componente InsightsList para mostrar multiples insights
*/
interface InsightsListProps {
insights: AIInsight[];
onInsightAction?: (insight: AIInsight) => void;
className?: string;
title?: string;
maxVisible?: number;
}
export const InsightsList: React.FC<InsightsListProps> = ({
insights,
onInsightAction,
className,
title = 'Insights de IA',
maxVisible = 5,
}) => {
const [showAll, setShowAll] = React.useState(false);
const visibleInsights = showAll ? insights : insights.slice(0, maxVisible);
const hasMore = insights.length > maxVisible;
return (
<div className={cn('space-y-4', className)}>
{title && (
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-white flex items-center gap-2">
<Sparkles className="w-5 h-5 text-violet-500" />
{title}
</h3>
<span className="text-sm text-slate-500 dark:text-slate-400">
{insights.length} insights
</span>
</div>
)}
<div className="space-y-3">
{visibleInsights.map((insight) => (
<AIInsightCard
key={insight.id}
insight={insight}
compact
onAction={() => onInsightAction?.(insight)}
/>
))}
</div>
{hasMore && (
<button
onClick={() => setShowAll(!showAll)}
className="w-full py-2 text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
>
{showAll ? 'Mostrar menos' : `Ver ${insights.length - maxVisible} mas`}
</button>
)}
</div>
);
};
export default AIInsightCard;

View File

@@ -0,0 +1,458 @@
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { ChatMessage, Message, MessageRole } from './ChatMessage';
import { SuggestedQuestions, defaultSuggestedQuestions, QuickActions, SuggestedQuestion } from './SuggestedQuestions';
import {
Send,
Mic,
Paperclip,
Settings,
Trash2,
Bot,
Sparkles,
StopCircle,
Menu,
} from 'lucide-react';
/**
* Props del componente ChatInterface
*/
interface ChatInterfaceProps {
onSendMessage?: (message: string) => Promise<void>;
initialMessages?: Message[];
suggestedQuestions?: SuggestedQuestion[];
isLoading?: boolean;
className?: string;
showSidebar?: boolean;
}
/**
* Generar ID unico
*/
const generateId = () => Math.random().toString(36).substring(2) + Date.now().toString(36);
/**
* Componente ChatInterface
*
* Interface de chat completa para el asistente CFO Digital
* con soporte para streaming, sugerencias y contenido enriquecido.
*/
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
onSendMessage,
initialMessages = [],
suggestedQuestions = defaultSuggestedQuestions,
isLoading = false,
className,
showSidebar = true,
}) => {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [inputValue, setInputValue] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(showSidebar);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll al final de los mensajes
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// Auto-resize del textarea
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInputValue(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
};
// Enviar mensaje
const handleSendMessage = async () => {
const trimmedInput = inputValue.trim();
if (!trimmedInput || isLoading || isStreaming) return;
// Agregar mensaje del usuario
const userMessage: Message = {
id: generateId(),
role: 'user',
content: trimmedInput,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
if (inputRef.current) {
inputRef.current.style.height = 'auto';
}
// Simular respuesta del asistente
setIsStreaming(true);
const assistantMessage: Message = {
id: generateId(),
role: 'assistant',
content: '',
timestamp: new Date(),
isStreaming: true,
};
setMessages((prev) => [...prev, assistantMessage]);
try {
// Si hay un handler externo, usarlo
if (onSendMessage) {
await onSendMessage(trimmedInput);
} else {
// Simular respuesta con streaming
await simulateStreaming(assistantMessage.id, trimmedInput);
}
} catch (error) {
console.error('Error sending message:', error);
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessage.id
? { ...msg, content: 'Lo siento, hubo un error procesando tu consulta. Por favor intenta de nuevo.', isStreaming: false, isError: true }
: msg
)
);
} finally {
setIsStreaming(false);
}
};
// Simular streaming de respuesta
const simulateStreaming = async (messageId: string, userQuery: string) => {
const responses: Record<string, { content: string; inlineContent?: Message['inlineContent'] }> = {
'flujo de caja': {
content: 'Analizando tu flujo de caja para este mes...\n\n**Resumen del Flujo de Caja:**\n\nTu flujo de caja neto es positivo este mes. Los ingresos operativos superan los gastos, lo cual es una excelente senal.\n\n**Recomendacion:** Considera destinar el excedente a una reserva de emergencia o a prepagar deuda de alto interes.',
inlineContent: [
{
type: 'metric',
data: { label: 'Flujo de Caja Neto', value: '$45,230', change: '+12.5% vs mes anterior' },
},
{
type: 'table',
data: {
headers: ['Concepto', 'Monto'],
rows: [
['Ingresos Operativos', '$125,000'],
['Gastos Operativos', '$79,770'],
['Flujo Neto', '$45,230'],
],
},
},
],
},
'metricas': {
content: 'Aqui estan tus **metricas SaaS clave** actualizadas:\n\n- **MRR (Monthly Recurring Revenue):** Crecimiento sostenido del 5.9% mensual\n- **ARR (Annual Recurring Revenue):** Proyeccion solida para el ano\n- **Churn Rate:** Por debajo del promedio de la industria\n- **LTV/CAC Ratio:** Excelente eficiencia en adquisicion\n\nTu negocio muestra indicadores saludables de crecimiento sostenible.',
inlineContent: [
{
type: 'metric',
data: { label: 'MRR', value: '$125,000', change: '+5.9% vs mes anterior' },
},
{
type: 'metric',
data: { label: 'LTV/CAC', value: '6.0x', change: '+25% YoY' },
},
],
},
'facturas vencidas': {
content: 'Analizando las cuentas por cobrar...\n\nTienes **3 clientes** con facturas vencidas por un total de **$15,750**.\n\n**Accion recomendada:** Te sugiero enviar recordatorios automaticos y considerar ofrecer un pequeno descuento por pronto pago.',
inlineContent: [
{
type: 'alert',
data: { type: 'warning', title: 'Atencion', message: '3 facturas vencidas requieren seguimiento inmediato' },
},
{
type: 'table',
data: {
headers: ['Cliente', 'Monto', 'Dias Vencido'],
rows: [
['Tech Solutions', '$8,500', '15'],
['Servicios Beta', '$4,250', '8'],
['Cliente Uno', '$3,000', '5'],
],
},
},
],
},
'default': {
content: 'Entiendo tu consulta. Dejame analizar la informacion disponible...\n\nBasandome en los datos de tu empresa, puedo darte las siguientes recomendaciones:\n\n1. **Optimizacion de costos:** Revisa los gastos recurrentes\n2. **Mejora de cobranza:** Implementa recordatorios automaticos\n3. **Diversificacion de ingresos:** Explora nuevas lineas de productos\n\nQuieres que profundice en alguno de estos puntos?',
},
};
// Determinar respuesta basada en query
let responseData = responses['default'];
const queryLower = userQuery.toLowerCase();
if (queryLower.includes('flujo') || queryLower.includes('caja')) {
responseData = responses['flujo de caja'];
} else if (queryLower.includes('metrica') || queryLower.includes('mrr') || queryLower.includes('saas')) {
responseData = responses['metricas'];
} else if (queryLower.includes('vencid') || queryLower.includes('factura') || queryLower.includes('cobrar')) {
responseData = responses['facturas vencidas'];
}
// Simular streaming caracter por caracter
const fullContent = responseData.content;
let currentContent = '';
for (let i = 0; i < fullContent.length; i++) {
await new Promise((resolve) => setTimeout(resolve, 15 + Math.random() * 10));
currentContent += fullContent[i];
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId
? { ...msg, content: currentContent }
: msg
)
);
}
// Agregar contenido inline al final
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId
? { ...msg, isStreaming: false, inlineContent: responseData.inlineContent }
: msg
)
);
};
// Manejar seleccion de pregunta sugerida
const handleSuggestedQuestion = (question: SuggestedQuestion) => {
setInputValue(question.text);
inputRef.current?.focus();
};
// Manejar accion rapida
const handleQuickAction = (action: string) => {
const actionQueries: Record<string, string> = {
resumen_diario: 'Dame un resumen ejecutivo del dia de hoy',
metricas_clave: 'Cuales son mis metricas clave actuales?',
alertas_activas: 'Hay alguna alerta financiera activa?',
proyeccion_mes: 'Cual es la proyeccion para este mes?',
};
setInputValue(actionQueries[action] || '');
inputRef.current?.focus();
};
// Limpiar chat
const handleClearChat = () => {
setMessages([]);
};
// Copiar mensaje
const handleCopyMessage = (content: string) => {
navigator.clipboard.writeText(content);
};
// Tecla Enter para enviar
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const hasMessages = messages.length > 0;
return (
<div className={cn('flex h-full bg-slate-50 dark:bg-slate-900', className)}>
{/* Sidebar */}
{sidebarOpen && (
<div className="hidden lg:flex flex-col w-80 border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
{/* Sidebar Header */}
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
<Bot className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="font-semibold text-slate-900 dark:text-white">CFO Digital</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">Asistente financiero IA</p>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-3">
Acciones Rapidas
</h3>
<QuickActions onAction={handleQuickAction} />
</div>
{/* Suggested Questions */}
<div className="flex-1 overflow-y-auto p-4">
<SuggestedQuestions
questions={suggestedQuestions.slice(0, 6)}
onSelect={handleSuggestedQuestion}
title="Sugerencias"
/>
</div>
{/* Sidebar Footer */}
<div className="p-4 border-t border-slate-200 dark:border-slate-700">
<button
onClick={handleClearChat}
disabled={!hasMessages}
className={cn(
'w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg',
'text-sm font-medium transition-colors',
'text-slate-600 dark:text-slate-400',
hasMessages
? 'hover:bg-slate-100 dark:hover:bg-slate-700'
: 'opacity-50 cursor-not-allowed'
)}
>
<Trash2 className="w-4 h-4" />
Limpiar conversacion
</button>
</div>
</div>
)}
{/* Main Chat Area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Chat Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
<div className="flex items-center gap-3">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="lg:hidden p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Menu className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</button>
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-violet-500" />
<span className="font-medium text-slate-900 dark:text-white">
Asistente CFO Digital
</span>
</div>
</div>
<button className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400">
<Settings className="w-5 h-5" />
</button>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Empty State */}
{!hasMessages && (
<div className="h-full flex flex-col items-center justify-center text-center p-6">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center mb-6">
<Bot className="w-10 h-10 text-white" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
Hola! Soy tu CFO Digital
</h2>
<p className="text-slate-500 dark:text-slate-400 max-w-md mb-8">
Estoy aqui para ayudarte a entender mejor la salud financiera de tu empresa.
Preguntame sobre metricas, proyecciones, o cualquier duda financiera.
</p>
<div className="w-full max-w-lg">
<SuggestedQuestions
questions={suggestedQuestions.slice(0, 4)}
onSelect={handleSuggestedQuestion}
title="Comienza con una pregunta:"
/>
</div>
</div>
)}
{/* Messages */}
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
onCopy={handleCopyMessage}
/>
))}
<div ref={messagesEndRef} />
</div>
{/* Suggested questions (when chat started) */}
{hasMessages && !isStreaming && (
<div className="px-4 pb-2">
<SuggestedQuestions
questions={suggestedQuestions.slice(0, 3)}
onSelect={handleSuggestedQuestion}
compact
/>
</div>
)}
{/* Input Area */}
<div className="p-4 border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
<div className="flex items-end gap-3">
{/* Attach Button */}
<button className="flex-shrink-0 p-2.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors">
<Paperclip className="w-5 h-5" />
</button>
{/* Input */}
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Escribe tu pregunta..."
rows={1}
className={cn(
'w-full resize-none rounded-xl border border-slate-300 dark:border-slate-600',
'bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white',
'px-4 py-3 pr-12',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'placeholder:text-slate-400 dark:placeholder:text-slate-500',
'max-h-[200px]'
)}
/>
</div>
{/* Voice Button */}
<button className="flex-shrink-0 p-2.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors">
<Mic className="w-5 h-5" />
</button>
{/* Send/Stop Button */}
{isStreaming ? (
<Button
variant="danger"
size="md"
onClick={() => setIsStreaming(false)}
leftIcon={<StopCircle className="w-5 h-5" />}
>
Detener
</Button>
) : (
<Button
variant="primary"
size="md"
onClick={handleSendMessage}
disabled={!inputValue.trim() || isLoading}
leftIcon={<Send className="w-5 h-5" />}
>
Enviar
</Button>
)}
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 text-center mt-2">
El CFO Digital usa IA para proporcionar insights. Siempre verifica la informacion importante.
</p>
</div>
</div>
</div>
);
};
export default ChatInterface;

View File

@@ -0,0 +1,281 @@
'use client';
import React from 'react';
import { cn, formatDate } from '@/lib/utils';
import { Bot, User, Copy, Check, RefreshCw, ThumbsUp, ThumbsDown } from 'lucide-react';
/**
* Tipo de mensaje
*/
export type MessageRole = 'user' | 'assistant' | 'system';
/**
* Contenido inline del mensaje (metricas, graficas, etc.)
*/
export interface MessageInlineContent {
type: 'metric' | 'chart' | 'table' | 'alert';
data: Record<string, unknown>;
}
/**
* Interface del mensaje
*/
export interface Message {
id: string;
role: MessageRole;
content: string;
timestamp: Date;
inlineContent?: MessageInlineContent[];
isStreaming?: boolean;
isError?: boolean;
}
/**
* Props del componente ChatMessage
*/
interface ChatMessageProps {
message: Message;
onCopy?: (content: string) => void;
onRetry?: (message: Message) => void;
onFeedback?: (message: Message, isPositive: boolean) => void;
className?: string;
}
/**
* Renderizar contenido inline
*/
const InlineContentRenderer: React.FC<{ content: MessageInlineContent }> = ({ content }) => {
switch (content.type) {
case 'metric':
return (
<div className="my-3 p-3 rounded-lg bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800">
<div className="flex items-center justify-between">
<span className="text-sm text-primary-700 dark:text-primary-300">
{content.data.label as string}
</span>
<span className="text-lg font-bold text-primary-900 dark:text-primary-100">
{content.data.value as string}
</span>
</div>
{content.data.change && (
<p className="text-xs text-primary-600 dark:text-primary-400 mt-1">
{content.data.change as string}
</p>
)}
</div>
);
case 'alert':
const alertType = (content.data.type as string) || 'info';
const alertStyles = {
info: 'bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800 text-primary-700 dark:text-primary-300',
success: 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800 text-success-700 dark:text-success-300',
warning: 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 text-warning-700 dark:text-warning-300',
error: 'bg-error-50 dark:bg-error-900/20 border-error-200 dark:border-error-800 text-error-700 dark:text-error-300',
};
return (
<div className={cn('my-3 p-3 rounded-lg border', alertStyles[alertType as keyof typeof alertStyles] || alertStyles.info)}>
<p className="font-medium text-sm">{content.data.title as string}</p>
{content.data.message && (
<p className="text-sm mt-1 opacity-80">{content.data.message as string}</p>
)}
</div>
);
case 'table':
const headers = (content.data.headers || []) as string[];
const rows = (content.data.rows || []) as string[][];
return (
<div className="my-3 overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr>
{headers.map((header, i) => (
<th
key={i}
className="px-4 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider"
>
{header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-slate-900 divide-y divide-slate-200 dark:divide-slate-700">
{rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td
key={cellIndex}
className="px-4 py-2 whitespace-nowrap text-sm text-slate-900 dark:text-slate-100"
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
default:
return null;
}
};
/**
* Componente ChatMessage
*
* Muestra un mensaje individual del chat con soporte para
* contenido enriquecido, streaming y acciones.
*/
export const ChatMessage: React.FC<ChatMessageProps> = ({
message,
onCopy,
onRetry,
onFeedback,
className,
}) => {
const [copied, setCopied] = React.useState(false);
const isUser = message.role === 'user';
const isAssistant = message.role === 'assistant';
const handleCopy = () => {
if (onCopy) {
onCopy(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
// Procesar contenido para markdown basico
const processContent = (content: string) => {
// Convertir **texto** a negrita
let processed = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Convertir *texto* a italica
processed = processed.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Convertir `codigo` a codigo inline
processed = processed.replace(/`(.*?)`/g, '<code class="px-1 py-0.5 rounded bg-slate-100 dark:bg-slate-700 text-sm">$1</code>');
// Convertir lineas nuevas
processed = processed.replace(/\n/g, '<br />');
return processed;
};
return (
<div
className={cn(
'flex gap-3',
isUser ? 'flex-row-reverse' : 'flex-row',
className
)}
>
{/* Avatar */}
<div
className={cn(
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
isUser
? 'bg-primary-500 text-white'
: 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'
)}
>
{isUser ? (
<User className="w-4 h-4" />
) : (
<Bot className="w-4 h-4" />
)}
</div>
{/* Message Content */}
<div className={cn('flex-1 max-w-[85%]', isUser && 'flex flex-col items-end')}>
{/* Header */}
<div className={cn('flex items-center gap-2 mb-1', isUser && 'flex-row-reverse')}>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{isUser ? 'Tu' : 'CFO Digital'}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
{formatDate(message.timestamp, { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
{/* Bubble */}
<div
className={cn(
'relative px-4 py-3 rounded-2xl',
isUser
? 'bg-primary-500 text-white rounded-tr-none'
: 'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-white rounded-tl-none',
message.isError && 'bg-error-100 dark:bg-error-900/30 border border-error-300 dark:border-error-700'
)}
>
{/* Content */}
<div
className={cn(
'text-sm leading-relaxed',
message.isStreaming && 'animate-pulse'
)}
dangerouslySetInnerHTML={{ __html: processContent(message.content) }}
/>
{/* Streaming indicator */}
{message.isStreaming && (
<span className="inline-block w-1.5 h-4 ml-1 bg-current animate-pulse rounded" />
)}
{/* Inline Content */}
{message.inlineContent?.map((content, index) => (
<InlineContentRenderer key={index} content={content} />
))}
</div>
{/* Actions for assistant messages */}
{isAssistant && !message.isStreaming && (
<div className="flex items-center gap-1 mt-2">
<button
onClick={handleCopy}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors"
title="Copiar respuesta"
>
{copied ? <Check className="w-4 h-4 text-success-500" /> : <Copy className="w-4 h-4" />}
</button>
{onRetry && (
<button
onClick={() => onRetry(message)}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors"
title="Regenerar respuesta"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
{onFeedback && (
<>
<div className="w-px h-4 bg-slate-200 dark:bg-slate-600 mx-1" />
<button
onClick={() => onFeedback(message, true)}
className="p-1.5 rounded-lg text-slate-400 hover:text-success-600 hover:bg-success-50 dark:hover:text-success-400 dark:hover:bg-success-900/30 transition-colors"
title="Respuesta util"
>
<ThumbsUp className="w-4 h-4" />
</button>
<button
onClick={() => onFeedback(message, false)}
className="p-1.5 rounded-lg text-slate-400 hover:text-error-600 hover:bg-error-50 dark:hover:text-error-400 dark:hover:bg-error-900/30 transition-colors"
title="Respuesta no util"
>
<ThumbsDown className="w-4 h-4" />
</button>
</>
)}
</div>
)}
</div>
</div>
);
};
export default ChatMessage;

View File

@@ -0,0 +1,265 @@
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
import {
TrendingUp,
DollarSign,
PieChart,
Users,
AlertTriangle,
Target,
Calendar,
BarChart3,
} from 'lucide-react';
/**
* Categoria de pregunta sugerida
*/
type QuestionCategory = 'financiero' | 'metricas' | 'clientes' | 'predicciones' | 'alertas';
/**
* Pregunta sugerida
*/
export interface SuggestedQuestion {
id: string;
text: string;
category: QuestionCategory;
icon?: React.ReactNode;
}
/**
* Props del componente SuggestedQuestions
*/
interface SuggestedQuestionsProps {
questions: SuggestedQuestion[];
onSelect: (question: SuggestedQuestion) => void;
title?: string;
className?: string;
compact?: boolean;
}
/**
* Icono por defecto segun categoria
*/
const defaultIcons: Record<QuestionCategory, React.ReactNode> = {
financiero: <DollarSign className="w-4 h-4" />,
metricas: <BarChart3 className="w-4 h-4" />,
clientes: <Users className="w-4 h-4" />,
predicciones: <TrendingUp className="w-4 h-4" />,
alertas: <AlertTriangle className="w-4 h-4" />,
};
/**
* Estilos por categoria
*/
const categoryStyles: Record<QuestionCategory, { bg: string; text: string; border: string; hover: string }> = {
financiero: {
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
text: 'text-emerald-700 dark:text-emerald-400',
border: 'border-emerald-200 dark:border-emerald-800',
hover: 'hover:bg-emerald-100 dark:hover:bg-emerald-900/40',
},
metricas: {
bg: 'bg-blue-50 dark:bg-blue-900/20',
text: 'text-blue-700 dark:text-blue-400',
border: 'border-blue-200 dark:border-blue-800',
hover: 'hover:bg-blue-100 dark:hover:bg-blue-900/40',
},
clientes: {
bg: 'bg-purple-50 dark:bg-purple-900/20',
text: 'text-purple-700 dark:text-purple-400',
border: 'border-purple-200 dark:border-purple-800',
hover: 'hover:bg-purple-100 dark:hover:bg-purple-900/40',
},
predicciones: {
bg: 'bg-amber-50 dark:bg-amber-900/20',
text: 'text-amber-700 dark:text-amber-400',
border: 'border-amber-200 dark:border-amber-800',
hover: 'hover:bg-amber-100 dark:hover:bg-amber-900/40',
},
alertas: {
bg: 'bg-red-50 dark:bg-red-900/20',
text: 'text-red-700 dark:text-red-400',
border: 'border-red-200 dark:border-red-800',
hover: 'hover:bg-red-100 dark:hover:bg-red-900/40',
},
};
/**
* Preguntas sugeridas por defecto
*/
export const defaultSuggestedQuestions: SuggestedQuestion[] = [
{
id: '1',
text: 'Como esta mi flujo de caja este mes?',
category: 'financiero',
},
{
id: '2',
text: 'Cuales son mis metricas SaaS clave?',
category: 'metricas',
},
{
id: '3',
text: 'Que clientes tienen facturas vencidas?',
category: 'clientes',
},
{
id: '4',
text: 'Proyecta mis ingresos para el proximo trimestre',
category: 'predicciones',
},
{
id: '5',
text: 'Hay alguna alerta financiera que deba revisar?',
category: 'alertas',
},
{
id: '6',
text: 'Cual es mi margen bruto actual?',
category: 'financiero',
},
{
id: '7',
text: 'Como ha evolucionado mi MRR?',
category: 'metricas',
},
{
id: '8',
text: 'Cual es mi tasa de churn actual?',
category: 'clientes',
},
];
/**
* Componente SuggestedQuestions
*
* Muestra una lista de preguntas sugeridas que el usuario puede
* seleccionar para iniciar una conversacion con el CFO Digital.
*/
export const SuggestedQuestions: React.FC<SuggestedQuestionsProps> = ({
questions,
onSelect,
title = 'Preguntas sugeridas',
className,
compact = false,
}) => {
if (compact) {
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{questions.map((question) => {
const styles = categoryStyles[question.category];
const icon = question.icon || defaultIcons[question.category];
return (
<button
key={question.id}
onClick={() => onSelect(question)}
className={cn(
'inline-flex items-center gap-2 px-3 py-1.5 rounded-full',
'text-sm font-medium border transition-all',
styles.bg,
styles.text,
styles.border,
styles.hover
)}
>
{icon}
<span className="max-w-[200px] truncate">{question.text}</span>
</button>
);
})}
</div>
);
}
return (
<div className={cn('space-y-3', className)}>
{title && (
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400">
{title}
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{questions.map((question) => {
const styles = categoryStyles[question.category];
const icon = question.icon || defaultIcons[question.category];
return (
<button
key={question.id}
onClick={() => onSelect(question)}
className={cn(
'flex items-start gap-3 p-3 rounded-lg text-left',
'border transition-all group',
styles.bg,
styles.border,
styles.hover
)}
>
<span
className={cn(
'flex-shrink-0 p-1.5 rounded-md',
styles.text,
'bg-white/50 dark:bg-slate-900/30'
)}
>
{icon}
</span>
<span
className={cn(
'text-sm font-medium leading-snug',
styles.text,
'group-hover:underline'
)}
>
{question.text}
</span>
</button>
);
})}
</div>
</div>
);
};
/**
* Componente QuickActions para accesos rapidos
*/
interface QuickActionsProps {
onAction: (action: string) => void;
className?: string;
}
export const QuickActions: React.FC<QuickActionsProps> = ({ onAction, className }) => {
const actions = [
{ id: 'resumen_diario', label: 'Resumen del dia', icon: <Calendar className="w-4 h-4" /> },
{ id: 'metricas_clave', label: 'Metricas clave', icon: <BarChart3 className="w-4 h-4" /> },
{ id: 'alertas_activas', label: 'Alertas activas', icon: <AlertTriangle className="w-4 h-4" /> },
{ id: 'proyeccion_mes', label: 'Proyeccion del mes', icon: <Target className="w-4 h-4" /> },
];
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{actions.map((action) => (
<button
key={action.id}
onClick={() => onAction(action.id)}
className={cn(
'inline-flex items-center gap-2 px-3 py-2 rounded-lg',
'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300',
'hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors',
'text-sm font-medium'
)}
>
{action.icon}
{action.label}
</button>
))}
</div>
);
};
export default SuggestedQuestions;

View File

@@ -0,0 +1,10 @@
export { ChatMessage } from './ChatMessage';
export type { Message, MessageRole, MessageInlineContent } from './ChatMessage';
export { SuggestedQuestions, QuickActions, defaultSuggestedQuestions } from './SuggestedQuestions';
export type { SuggestedQuestion } from './SuggestedQuestions';
export { AIInsightCard, InsightsList } from './AIInsightCard';
export type { AIInsight, InsightType, InsightPriority } from './AIInsightCard';
export { ChatInterface } from './ChatInterface';

View File

@@ -18,6 +18,8 @@ import {
Bot, Bot,
Shield, Shield,
Bell, Bell,
Plug,
FileText,
} from 'lucide-react'; } from 'lucide-react';
/** /**
@@ -93,6 +95,21 @@ const navigation: NavGroup[] = [
}, },
], ],
}, },
{
label: 'Datos',
items: [
{
label: 'Integraciones',
href: '/integraciones',
icon: <Plug className="h-5 w-5" />,
},
{
label: 'Reportes',
href: '/reportes',
icon: <FileText className="h-5 w-5" />,
},
],
},
{ {
label: 'Sistema', label: 'Sistema',
items: [ items: [

View File

@@ -0,0 +1,645 @@
'use client';
import React, { useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import {
FileText,
Calendar,
CheckCircle,
ArrowRight,
ArrowLeft,
Loader2,
Check,
CalendarRange,
BarChart3,
PieChart,
TrendingUp,
Users,
Receipt,
DollarSign,
FileBarChart,
} from 'lucide-react';
import type { ReportType } from './ReportCard';
/**
* Seccion de reporte disponible
*/
interface ReportSectionOption {
id: string;
name: string;
description: string;
icon: React.ReactNode;
required?: boolean;
}
/**
* Props del componente GenerateReportWizard
*/
interface GenerateReportWizardProps {
onComplete: (config: ReportConfig) => Promise<void>;
onCancel: () => void;
className?: string;
}
/**
* Configuracion del reporte a generar
*/
export interface ReportConfig {
type: ReportType;
period: {
start: string;
end: string;
};
sections: string[];
title: string;
description?: string;
}
/**
* Opciones de tipo de reporte
*/
const reportTypes: Array<{
type: ReportType;
name: string;
description: string;
icon: React.ReactNode;
}> = [
{
type: 'mensual',
name: 'Reporte Mensual',
description: 'Resumen completo del mes seleccionado',
icon: <Calendar className="w-6 h-6" />,
},
{
type: 'trimestral',
name: 'Reporte Trimestral',
description: 'Analisis de 3 meses consecutivos',
icon: <CalendarRange className="w-6 h-6" />,
},
{
type: 'anual',
name: 'Reporte Anual',
description: 'Vision general del ano fiscal completo',
icon: <FileBarChart className="w-6 h-6" />,
},
{
type: 'custom',
name: 'Reporte Personalizado',
description: 'Define tu propio rango de fechas',
icon: <FileText className="w-6 h-6" />,
},
];
/**
* Secciones disponibles para incluir
*/
const availableSections: ReportSectionOption[] = [
{
id: 'resumen_ejecutivo',
name: 'Resumen Ejecutivo',
description: 'Vision general de metricas clave',
icon: <FileText className="w-5 h-5" />,
required: true,
},
{
id: 'ingresos',
name: 'Analisis de Ingresos',
description: 'Desglose completo de ingresos por categoria',
icon: <TrendingUp className="w-5 h-5" />,
},
{
id: 'egresos',
name: 'Analisis de Egresos',
description: 'Control de gastos y costos operativos',
icon: <Receipt className="w-5 h-5" />,
},
{
id: 'flujo_caja',
name: 'Flujo de Caja',
description: 'Movimientos de efectivo y proyecciones',
icon: <DollarSign className="w-5 h-5" />,
},
{
id: 'cuentas_cobrar',
name: 'Cuentas por Cobrar',
description: 'Estado de facturas pendientes de cobro',
icon: <Users className="w-5 h-5" />,
},
{
id: 'cuentas_pagar',
name: 'Cuentas por Pagar',
description: 'Obligaciones pendientes con proveedores',
icon: <Receipt className="w-5 h-5" />,
},
{
id: 'metricas_saas',
name: 'Metricas SaaS',
description: 'MRR, ARR, Churn, LTV/CAC y mas',
icon: <BarChart3 className="w-5 h-5" />,
},
{
id: 'graficas',
name: 'Graficas y Visualizaciones',
description: 'Representaciones visuales de los datos',
icon: <PieChart className="w-5 h-5" />,
},
];
/**
* Componente GenerateReportWizard
*
* Wizard de 3 pasos para configurar y generar un reporte.
*/
export const GenerateReportWizard: React.FC<GenerateReportWizardProps> = ({
onComplete,
onCancel,
className,
}) => {
const [step, setStep] = useState(1);
const [isGenerating, setIsGenerating] = useState(false);
const [generationProgress, setGenerationProgress] = useState(0);
// Estado del formulario
const [selectedType, setSelectedType] = useState<ReportType | null>(null);
const [period, setPeriod] = useState({
start: '',
end: '',
});
const [selectedSections, setSelectedSections] = useState<string[]>(['resumen_ejecutivo']);
const [title, setTitle] = useState('');
// Calcular fechas basadas en el tipo
const calculatePeriod = useCallback((type: ReportType) => {
const now = new Date();
let start: Date;
let end: Date = new Date(now.getFullYear(), now.getMonth() + 1, 0);
switch (type) {
case 'mensual':
start = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case 'trimestral':
const quarter = Math.floor(now.getMonth() / 3);
start = new Date(now.getFullYear(), quarter * 3, 1);
end = new Date(now.getFullYear(), quarter * 3 + 3, 0);
break;
case 'anual':
start = new Date(now.getFullYear(), 0, 1);
end = new Date(now.getFullYear(), 11, 31);
break;
default:
start = new Date(now.getFullYear(), now.getMonth(), 1);
}
setPeriod({
start: start.toISOString().split('T')[0],
end: end.toISOString().split('T')[0],
});
}, []);
// Manejar seleccion de tipo
const handleTypeSelect = (type: ReportType) => {
setSelectedType(type);
if (type !== 'custom') {
calculatePeriod(type);
}
};
// Manejar toggle de seccion
const toggleSection = (sectionId: string) => {
const section = availableSections.find(s => s.id === sectionId);
if (section?.required) return;
setSelectedSections(prev =>
prev.includes(sectionId)
? prev.filter(id => id !== sectionId)
: [...prev, sectionId]
);
};
// Generar titulo automatico
const generateTitle = useCallback(() => {
if (!selectedType || !period.start) return '';
const startDate = new Date(period.start);
const monthNames = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
switch (selectedType) {
case 'mensual':
return `Reporte ${monthNames[startDate.getMonth()]} ${startDate.getFullYear()}`;
case 'trimestral':
const quarter = Math.floor(startDate.getMonth() / 3) + 1;
return `Reporte Q${quarter} ${startDate.getFullYear()}`;
case 'anual':
return `Reporte Anual ${startDate.getFullYear()}`;
default:
return `Reporte Personalizado`;
}
}, [selectedType, period.start]);
// Simular generacion del reporte
const handleGenerate = async () => {
if (!selectedType || !period.start || !period.end) return;
setIsGenerating(true);
setGenerationProgress(0);
const config: ReportConfig = {
type: selectedType,
period,
sections: selectedSections,
title: title || generateTitle(),
};
// Simular progreso
const progressInterval = setInterval(() => {
setGenerationProgress(prev => {
if (prev >= 95) {
clearInterval(progressInterval);
return prev;
}
return prev + Math.random() * 10;
});
}, 300);
try {
await onComplete(config);
setGenerationProgress(100);
} catch (error) {
console.error('Error generando reporte:', error);
} finally {
clearInterval(progressInterval);
setIsGenerating(false);
}
};
// Validacion por paso
const canProceed = () => {
switch (step) {
case 1:
return selectedType !== null;
case 2:
return period.start && period.end && selectedSections.length > 0;
case 3:
return true;
default:
return false;
}
};
return (
<div className={cn('bg-white dark:bg-slate-800 rounded-xl', className)}>
{/* Progress Steps */}
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between">
{[1, 2, 3].map((s) => (
<React.Fragment key={s}>
<div className="flex items-center gap-2">
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors',
step > s
? 'bg-success-500 text-white'
: step === s
? 'bg-primary-500 text-white'
: 'bg-slate-100 dark:bg-slate-700 text-slate-400 dark:text-slate-500'
)}
>
{step > s ? <Check className="w-4 h-4" /> : s}
</div>
<span
className={cn(
'text-sm font-medium hidden sm:inline',
step >= s
? 'text-slate-900 dark:text-white'
: 'text-slate-400 dark:text-slate-500'
)}
>
{s === 1 ? 'Tipo' : s === 2 ? 'Configurar' : 'Confirmar'}
</span>
</div>
{s < 3 && (
<div
className={cn(
'flex-1 h-0.5 mx-4 transition-colors',
step > s
? 'bg-success-500'
: 'bg-slate-200 dark:bg-slate-700'
)}
/>
)}
</React.Fragment>
))}
</div>
</div>
{/* Step Content */}
<div className="p-6">
{/* Step 1: Seleccionar Tipo */}
{step === 1 && (
<div className="space-y-4">
<div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
Selecciona el tipo de reporte
</h2>
<p className="text-slate-500 dark:text-slate-400 mt-1">
Elige el formato que mejor se ajuste a tus necesidades
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
{reportTypes.map((rt) => (
<button
key={rt.type}
onClick={() => handleTypeSelect(rt.type)}
className={cn(
'p-4 rounded-xl border-2 text-left transition-all',
selectedType === rt.type
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-slate-200 dark:border-slate-700 hover:border-primary-300 dark:hover:border-primary-600'
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
'p-2.5 rounded-lg',
selectedType === rt.type
? 'bg-primary-500 text-white'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400'
)}
>
{rt.icon}
</div>
<div>
<h3 className="font-semibold text-slate-900 dark:text-white">
{rt.name}
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{rt.description}
</p>
</div>
{selectedType === rt.type && (
<CheckCircle className="w-5 h-5 text-primary-500 ml-auto flex-shrink-0" />
)}
</div>
</button>
))}
</div>
</div>
)}
{/* Step 2: Configurar Periodo y Secciones */}
{step === 2 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
Configura el reporte
</h2>
<p className="text-slate-500 dark:text-slate-400 mt-1">
Define el periodo y las secciones a incluir
</p>
</div>
{/* Periodo */}
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50 space-y-4">
<h3 className="font-medium text-slate-900 dark:text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-primary-500" />
Periodo del reporte
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Fecha inicio
</label>
<input
type="date"
value={period.start}
onChange={(e) => setPeriod({ ...period, start: e.target.value })}
disabled={selectedType !== 'custom'}
className={cn(
'w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600',
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-60 disabled:cursor-not-allowed'
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Fecha fin
</label>
<input
type="date"
value={period.end}
onChange={(e) => setPeriod({ ...period, end: e.target.value })}
disabled={selectedType !== 'custom'}
className={cn(
'w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600',
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-60 disabled:cursor-not-allowed'
)}
/>
</div>
</div>
</div>
{/* Secciones */}
<div>
<h3 className="font-medium text-slate-900 dark:text-white mb-3">
Secciones a incluir
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{availableSections.map((section) => (
<button
key={section.id}
onClick={() => toggleSection(section.id)}
disabled={section.required}
className={cn(
'p-3 rounded-lg border text-left transition-all',
selectedSections.includes(section.id)
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-slate-200 dark:border-slate-700 hover:border-primary-300 dark:hover:border-primary-600',
section.required && 'opacity-80'
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
'p-1.5 rounded-md',
selectedSections.includes(section.id)
? 'bg-primary-500 text-white'
: 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400'
)}
>
{section.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-slate-900 dark:text-white text-sm">
{section.name}
</h4>
{section.required && (
<span className="text-xs px-1.5 py-0.5 rounded bg-slate-200 dark:bg-slate-700 text-slate-500 dark:text-slate-400">
Requerido
</span>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{section.description}
</p>
</div>
{selectedSections.includes(section.id) && (
<CheckCircle className="w-4 h-4 text-primary-500 flex-shrink-0" />
)}
</div>
</button>
))}
</div>
</div>
</div>
)}
{/* Step 3: Confirmar y Generar */}
{step === 3 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
Confirmar y generar
</h2>
<p className="text-slate-500 dark:text-slate-400 mt-1">
Revisa la configuracion antes de generar el reporte
</p>
</div>
{/* Titulo del reporte */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Titulo del reporte
</label>
<input
type="text"
value={title || generateTitle()}
onChange={(e) => setTitle(e.target.value)}
placeholder="Titulo del reporte..."
className={cn(
'w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600',
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent'
)}
/>
</div>
{/* Resumen de configuracion */}
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50 space-y-4">
<h3 className="font-medium text-slate-900 dark:text-white">
Resumen de configuracion
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-slate-200 dark:border-slate-700">
<span className="text-sm text-slate-500 dark:text-slate-400">Tipo de reporte</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{reportTypes.find(t => t.type === selectedType)?.name}
</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-200 dark:border-slate-700">
<span className="text-sm text-slate-500 dark:text-slate-400">Periodo</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{period.start} a {period.end}
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-500 dark:text-slate-400">Secciones</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{selectedSections.length} seleccionadas
</span>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{selectedSections.map((sectionId) => {
const section = availableSections.find(s => s.id === sectionId);
return (
<span
key={sectionId}
className="text-xs px-2 py-1 rounded-md bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400"
>
{section?.name}
</span>
);
})}
</div>
</div>
{/* Progress durante generacion */}
{isGenerating && (
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800">
<div className="flex items-center gap-3 mb-3">
<Loader2 className="w-5 h-5 text-primary-600 dark:text-primary-400 animate-spin" />
<span className="font-medium text-primary-700 dark:text-primary-300">
Generando reporte...
</span>
</div>
<div className="h-2 bg-primary-100 dark:bg-primary-900/50 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-300"
style={{ width: `${generationProgress}%` }}
/>
</div>
<p className="text-sm text-primary-600 dark:text-primary-400 mt-2">
{generationProgress < 30 && 'Recopilando datos...'}
{generationProgress >= 30 && generationProgress < 60 && 'Procesando metricas...'}
{generationProgress >= 60 && generationProgress < 90 && 'Generando graficas...'}
{generationProgress >= 90 && 'Finalizando reporte...'}
</p>
</div>
)}
</div>
)}
</div>
{/* Footer Actions */}
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex items-center justify-between">
<div>
{step > 1 && !isGenerating && (
<Button
variant="ghost"
onClick={() => setStep(step - 1)}
leftIcon={<ArrowLeft className="w-4 h-4" />}
>
Anterior
</Button>
)}
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={onCancel} disabled={isGenerating}>
Cancelar
</Button>
{step < 3 ? (
<Button
onClick={() => setStep(step + 1)}
disabled={!canProceed()}
rightIcon={<ArrowRight className="w-4 h-4" />}
>
Siguiente
</Button>
) : (
<Button
onClick={handleGenerate}
disabled={isGenerating}
isLoading={isGenerating}
leftIcon={!isGenerating ? <FileText className="w-4 h-4" /> : undefined}
>
{isGenerating ? 'Generando...' : 'Generar Reporte'}
</Button>
)}
</div>
</div>
</div>
);
};
export default GenerateReportWizard;

View File

@@ -0,0 +1,271 @@
'use client';
import React from 'react';
import { cn, formatDate, formatCurrency } from '@/lib/utils';
import {
FileText,
Download,
Eye,
Clock,
CheckCircle,
AlertCircle,
Calendar,
TrendingUp,
BarChart3,
} from 'lucide-react';
/**
* Tipos de reporte
*/
export type ReportType = 'mensual' | 'trimestral' | 'anual' | 'custom';
export type ReportStatus = 'generando' | 'completado' | 'error' | 'pendiente';
/**
* Interface del reporte
*/
export interface Report {
id: string;
title: string;
type: ReportType;
status: ReportStatus;
period: {
start: string;
end: string;
};
sections: string[];
generatedAt?: string;
fileSize?: string;
summary?: {
ingresos: number;
egresos: number;
utilidad: number;
};
progress?: number;
}
/**
* Props del componente ReportCard
*/
interface ReportCardProps {
report: Report;
onView?: (report: Report) => void;
onDownload?: (report: Report) => void;
className?: string;
}
/**
* Configuracion visual por tipo de reporte
*/
const typeConfig: Record<ReportType, { label: string; color: string; bgColor: string }> = {
mensual: {
label: 'Mensual',
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
},
trimestral: {
label: 'Trimestral',
color: 'text-purple-600 dark:text-purple-400',
bgColor: 'bg-purple-100 dark:bg-purple-900/30',
},
anual: {
label: 'Anual',
color: 'text-amber-600 dark:text-amber-400',
bgColor: 'bg-amber-100 dark:bg-amber-900/30',
},
custom: {
label: 'Personalizado',
color: 'text-slate-600 dark:text-slate-400',
bgColor: 'bg-slate-100 dark:bg-slate-700',
},
};
/**
* Configuracion visual por estado
*/
const statusConfig: Record<ReportStatus, { label: string; icon: React.ReactNode; color: string; bgColor: string }> = {
completado: {
label: 'Completado',
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-success-600 dark:text-success-400',
bgColor: 'bg-success-100 dark:bg-success-900/30',
},
generando: {
label: 'Generando',
icon: <Clock className="w-4 h-4 animate-spin" />,
color: 'text-primary-600 dark:text-primary-400',
bgColor: 'bg-primary-100 dark:bg-primary-900/30',
},
error: {
label: 'Error',
icon: <AlertCircle className="w-4 h-4" />,
color: 'text-error-600 dark:text-error-400',
bgColor: 'bg-error-100 dark:bg-error-900/30',
},
pendiente: {
label: 'Pendiente',
icon: <Clock className="w-4 h-4" />,
color: 'text-slate-600 dark:text-slate-400',
bgColor: 'bg-slate-100 dark:bg-slate-700',
},
};
/**
* Componente ReportCard
*
* Muestra una tarjeta con informacion resumida de un reporte.
*/
export const ReportCard: React.FC<ReportCardProps> = ({
report,
onView,
onDownload,
className,
}) => {
const typeStyle = typeConfig[report.type];
const statusStyle = statusConfig[report.status];
return (
<div
className={cn(
'bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700',
'hover:shadow-lg hover:border-primary-300 dark:hover:border-primary-600',
'transition-all duration-200 cursor-pointer group',
className
)}
onClick={() => onView?.(report)}
>
{/* Header */}
<div className="p-5 border-b border-slate-100 dark:border-slate-700">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className={cn('p-2.5 rounded-lg', typeStyle.bgColor)}>
<FileText className={cn('w-5 h-5', typeStyle.color)} />
</div>
<div>
<h3 className="font-semibold text-slate-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
{report.title}
</h3>
<div className="flex items-center gap-2 mt-1">
<span className={cn('text-xs font-medium px-2 py-0.5 rounded-full', typeStyle.bgColor, typeStyle.color)}>
{typeStyle.label}
</span>
</div>
</div>
</div>
<span className={cn('flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full', statusStyle.bgColor, statusStyle.color)}>
{statusStyle.icon}
{statusStyle.label}
</span>
</div>
</div>
{/* Body */}
<div className="p-5 space-y-4">
{/* Periodo */}
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
<Calendar className="w-4 h-4" />
<span>
{formatDate(report.period.start)} - {formatDate(report.period.end)}
</span>
</div>
{/* Progress bar si esta generando */}
{report.status === 'generando' && report.progress !== undefined && (
<div>
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="text-slate-500 dark:text-slate-400">Progreso</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{report.progress}%</span>
</div>
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-500"
style={{ width: `${report.progress}%` }}
/>
</div>
</div>
)}
{/* Resumen financiero */}
{report.status === 'completado' && report.summary && (
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-2.5 rounded-lg bg-success-50 dark:bg-success-900/20">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Ingresos</p>
<p className="text-sm font-semibold text-success-600 dark:text-success-400">
{formatCurrency(report.summary.ingresos)}
</p>
</div>
<div className="text-center p-2.5 rounded-lg bg-error-50 dark:bg-error-900/20">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Egresos</p>
<p className="text-sm font-semibold text-error-600 dark:text-error-400">
{formatCurrency(report.summary.egresos)}
</p>
</div>
<div className="text-center p-2.5 rounded-lg bg-primary-50 dark:bg-primary-900/20">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Utilidad</p>
<p className="text-sm font-semibold text-primary-600 dark:text-primary-400">
{formatCurrency(report.summary.utilidad)}
</p>
</div>
</div>
)}
{/* Secciones */}
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">Secciones incluidas:</p>
<div className="flex flex-wrap gap-1.5">
{report.sections.slice(0, 4).map((section) => (
<span
key={section}
className="text-xs px-2 py-1 rounded-md bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300"
>
{section}
</span>
))}
{report.sections.length > 4 && (
<span className="text-xs px-2 py-1 rounded-md bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400">
+{report.sections.length - 4} mas
</span>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-slate-100 dark:border-slate-700 flex items-center justify-between">
<div className="text-xs text-slate-500 dark:text-slate-400">
{report.generatedAt && (
<span>Generado: {formatDate(report.generatedAt)}</span>
)}
{report.fileSize && (
<span className="ml-2">({report.fileSize})</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onView?.(report);
}}
className="p-2 rounded-lg text-slate-500 hover:text-primary-600 hover:bg-primary-50 dark:hover:text-primary-400 dark:hover:bg-primary-900/30 transition-colors"
title="Ver reporte"
>
<Eye className="w-4 h-4" />
</button>
{report.status === 'completado' && (
<button
onClick={(e) => {
e.stopPropagation();
onDownload?.(report);
}}
className="p-2 rounded-lg text-slate-500 hover:text-success-600 hover:bg-success-50 dark:hover:text-success-400 dark:hover:bg-success-900/30 transition-colors"
title="Descargar PDF"
>
<Download className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
);
};
export default ReportCard;

View File

@@ -0,0 +1,334 @@
'use client';
import React from 'react';
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { cn, formatCurrency, formatNumber } from '@/lib/utils';
/**
* Tipos de grafica
*/
type ChartType = 'line' | 'area' | 'bar' | 'pie';
/**
* Datos de la grafica
*/
interface ChartDataPoint {
name: string;
[key: string]: string | number;
}
/**
* Configuracion de serie
*/
interface ChartSeries {
dataKey: string;
name: string;
color: string;
type?: 'monotone' | 'linear' | 'step';
}
/**
* Props del componente ReportChart
*/
interface ReportChartProps {
title?: string;
subtitle?: string;
type: ChartType;
data: ChartDataPoint[];
series?: ChartSeries[];
height?: number;
showLegend?: boolean;
showGrid?: boolean;
formatTooltip?: 'currency' | 'number' | 'percentage';
className?: string;
}
/**
* Colores por defecto para las series
*/
const defaultColors = [
'#0c8ce8', // primary
'#10b981', // success
'#f59e0b', // warning
'#ef4444', // error
'#8b5cf6', // purple
'#06b6d4', // cyan
'#ec4899', // pink
'#f97316', // orange
];
/**
* Componente de Tooltip personalizado
*/
interface CustomTooltipProps {
active?: boolean;
payload?: Array<{
name: string;
value: number;
color: string;
dataKey: string;
}>;
label?: string;
format?: 'currency' | 'number' | 'percentage';
}
const CustomTooltip: React.FC<CustomTooltipProps> = ({ active, payload, label, format }) => {
if (!active || !payload) return null;
const formatValue = (value: number) => {
switch (format) {
case 'currency':
return formatCurrency(value);
case 'percentage':
return `${value.toFixed(1)}%`;
case 'number':
default:
return formatNumber(value, value % 1 === 0 ? 0 : 2);
}
};
return (
<div className="bg-slate-900 dark:bg-slate-800 px-4 py-3 rounded-lg shadow-lg border border-slate-700">
<p className="text-sm font-medium text-white mb-2">{label}</p>
<div className="space-y-1">
{payload.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className="text-xs text-slate-400">{item.name}:</span>
<span className="text-xs font-semibold text-white">
{formatValue(item.value)}
</span>
</div>
))}
</div>
</div>
);
};
/**
* Componente ReportChart
*
* Grafica interactiva para reportes con soporte para multiples tipos.
*/
export const ReportChart: React.FC<ReportChartProps> = ({
title,
subtitle,
type,
data,
series,
height = 300,
showLegend = true,
showGrid = true,
formatTooltip = 'number',
className,
}) => {
// Generar series por defecto si no se proporcionan
const chartSeries: ChartSeries[] = series || (data.length > 0
? Object.keys(data[0])
.filter((key) => key !== 'name' && typeof data[0][key] === 'number')
.map((key, index) => ({
dataKey: key,
name: key.charAt(0).toUpperCase() + key.slice(1),
color: defaultColors[index % defaultColors.length],
}))
: []);
const renderChart = () => {
switch (type) {
case 'line':
return (
<LineChart data={data}>
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />}
<XAxis dataKey="name" stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
{showLegend && <Legend />}
{chartSeries.map((s) => (
<Line
key={s.dataKey}
type={s.type || 'monotone'}
dataKey={s.dataKey}
name={s.name}
stroke={s.color}
strokeWidth={2}
dot={{ r: 4, fill: s.color }}
activeDot={{ r: 6 }}
/>
))}
</LineChart>
);
case 'area':
return (
<AreaChart data={data}>
<defs>
{chartSeries.map((s) => (
<linearGradient key={`gradient-${s.dataKey}`} id={`gradient-${s.dataKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={s.color} stopOpacity={0.4} />
<stop offset="100%" stopColor={s.color} stopOpacity={0.05} />
</linearGradient>
))}
</defs>
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />}
<XAxis dataKey="name" stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
{showLegend && <Legend />}
{chartSeries.map((s) => (
<Area
key={s.dataKey}
type={s.type || 'monotone'}
dataKey={s.dataKey}
name={s.name}
stroke={s.color}
strokeWidth={2}
fill={`url(#gradient-${s.dataKey})`}
/>
))}
</AreaChart>
);
case 'bar':
return (
<BarChart data={data}>
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />}
<XAxis dataKey="name" stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
{showLegend && <Legend />}
{chartSeries.map((s) => (
<Bar
key={s.dataKey}
dataKey={s.dataKey}
name={s.name}
fill={s.color}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
);
case 'pie':
const pieData = data.map((d, index) => ({
...d,
fill: defaultColors[index % defaultColors.length],
}));
const pieDataKey = chartSeries[0]?.dataKey || 'value';
return (
<PieChart>
<Pie
data={pieData}
dataKey={pieDataKey}
nameKey="name"
cx="50%"
cy="50%"
outerRadius={100}
innerRadius={60}
paddingAngle={2}
label={({ name, percent }) => `${name} (${(percent * 100).toFixed(0)}%)`}
labelLine={{ stroke: '#6b7280' }}
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
{showLegend && <Legend />}
</PieChart>
);
default:
return null;
}
};
return (
<div className={cn('bg-white dark:bg-slate-800 rounded-xl p-5', className)}>
{/* Header */}
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Chart */}
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
{renderChart()}
</ResponsiveContainer>
</div>
</div>
);
};
/**
* Componente de grafica mini para previews
*/
interface MiniChartProps {
data: number[];
color?: string;
height?: number;
className?: string;
}
export const MiniChart: React.FC<MiniChartProps> = ({
data,
color = '#0c8ce8',
height = 40,
className,
}) => {
const chartData = data.map((value, index) => ({ value, name: index.toString() }));
return (
<div className={cn('w-full', className)} style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="miniGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
fill="url(#miniGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
export default ReportChart;

View File

@@ -0,0 +1,211 @@
'use client';
import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import { ChevronDown, ChevronRight } from 'lucide-react';
/**
* Props del componente ReportSection
*/
interface ReportSectionProps {
title: string;
icon?: React.ReactNode;
children: React.ReactNode;
defaultExpanded?: boolean;
className?: string;
headerAction?: React.ReactNode;
badge?: string | number;
}
/**
* Componente ReportSection
*
* Seccion colapsable para mostrar partes del reporte.
*/
export const ReportSection: React.FC<ReportSectionProps> = ({
title,
icon,
children,
defaultExpanded = true,
className,
headerAction,
badge,
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div
className={cn(
'bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700',
'overflow-hidden',
className
)}
>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
'w-full flex items-center justify-between p-4',
'hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors',
'text-left'
)}
>
<div className="flex items-center gap-3">
{/* Chevron */}
<span className="text-slate-400 dark:text-slate-500">
{isExpanded ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</span>
{/* Icon */}
{icon && (
<span className="text-primary-600 dark:text-primary-400">
{icon}
</span>
)}
{/* Title */}
<h3 className="font-semibold text-slate-900 dark:text-white">
{title}
</h3>
{/* Badge */}
{badge !== undefined && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
{badge}
</span>
)}
</div>
{/* Header Action */}
{headerAction && (
<div onClick={(e) => e.stopPropagation()}>
{headerAction}
</div>
)}
</button>
{/* Content */}
<div
className={cn(
'overflow-hidden transition-all duration-300 ease-in-out',
isExpanded ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<div className="p-4 pt-0 border-t border-slate-100 dark:border-slate-700">
{children}
</div>
</div>
</div>
);
};
/**
* Sub-seccion para contenido anidado
*/
interface ReportSubSectionProps {
title: string;
children: React.ReactNode;
className?: string;
}
export const ReportSubSection: React.FC<ReportSubSectionProps> = ({
title,
children,
className,
}) => {
return (
<div className={cn('mt-4', className)}>
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
{title}
</h4>
<div className="pl-4 border-l-2 border-slate-200 dark:border-slate-600">
{children}
</div>
</div>
);
};
/**
* Fila de datos para tablas del reporte
*/
interface ReportDataRowProps {
label: string;
value: string | number | React.ReactNode;
subLabel?: string;
trend?: 'up' | 'down' | 'neutral';
trendValue?: string;
className?: string;
}
export const ReportDataRow: React.FC<ReportDataRowProps> = ({
label,
value,
subLabel,
trend,
trendValue,
className,
}) => {
const trendColors = {
up: 'text-success-600 dark:text-success-400',
down: 'text-error-600 dark:text-error-400',
neutral: 'text-slate-500 dark:text-slate-400',
};
return (
<div className={cn('flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700 last:border-0', className)}>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">{label}</p>
{subLabel && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{subLabel}</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-900 dark:text-white">
{value}
</span>
{trend && trendValue && (
<span className={cn('text-xs font-medium', trendColors[trend])}>
{trendValue}
</span>
)}
</div>
</div>
);
};
/**
* Alerta o nota dentro del reporte
*/
interface ReportAlertProps {
type: 'info' | 'warning' | 'success' | 'error';
title: string;
message: string;
className?: string;
}
export const ReportAlert: React.FC<ReportAlertProps> = ({
type,
title,
message,
className,
}) => {
const styles = {
info: 'bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800 text-primary-800 dark:text-primary-300',
warning: 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 text-warning-800 dark:text-warning-300',
success: 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800 text-success-800 dark:text-success-300',
error: 'bg-error-50 dark:bg-error-900/20 border-error-200 dark:border-error-800 text-error-800 dark:text-error-300',
};
return (
<div className={cn('p-4 rounded-lg border', styles[type], className)}>
<p className="font-medium text-sm">{title}</p>
<p className="text-sm mt-1 opacity-80">{message}</p>
</div>
);
};
export default ReportSection;

View File

@@ -0,0 +1,9 @@
export { ReportCard } from './ReportCard';
export type { Report, ReportType, ReportStatus } from './ReportCard';
export { ReportSection, ReportSubSection, ReportDataRow, ReportAlert } from './ReportSection';
export { ReportChart, MiniChart } from './ReportChart';
export { GenerateReportWizard } from './GenerateReportWizard';
export type { ReportConfig } from './GenerateReportWizard';

2160
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,642 @@
-- ============================================================================
-- Horux Strategy - Integrations Schema
-- Version: 003
-- Description: Tables for external integrations management
-- Note: ${SCHEMA_NAME} will be replaced with the actual schema name
-- ============================================================================
-- ============================================================================
-- ENUM TYPES
-- ============================================================================
-- Integration type
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'integration_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE integration_type AS ENUM (
'contpaqi',
'aspel',
'odoo',
'alegra',
'sap',
'manual',
'sat',
'bank_bbva',
'bank_banamex',
'bank_santander',
'bank_banorte',
'bank_hsbc',
'payments_stripe',
'payments_openpay',
'webhook',
'api_custom'
);
END IF;
END$$;
-- Integration status
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'integration_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE integration_status AS ENUM (
'pending',
'active',
'inactive',
'error',
'expired',
'configuring'
);
END IF;
END$$;
-- Sync status
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sync_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE sync_status AS ENUM (
'pending',
'queued',
'running',
'completed',
'failed',
'cancelled',
'partial'
);
END IF;
END$$;
-- Sync direction
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sync_direction' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE sync_direction AS ENUM (
'import',
'export',
'bidirectional'
);
END IF;
END$$;
-- Sync entity type
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sync_entity_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE sync_entity_type AS ENUM (
'transactions',
'invoices',
'contacts',
'products',
'accounts',
'categories',
'journal_entries',
'payments',
'cfdis',
'bank_statements'
);
END IF;
END$$;
-- ============================================================================
-- INTEGRATIONS TABLE
-- Main table for integration configurations
-- ============================================================================
CREATE TABLE IF NOT EXISTS integrations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL,
-- Integration info
type integration_type NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
-- Status
status integration_status NOT NULL DEFAULT 'pending',
is_active BOOLEAN NOT NULL DEFAULT true,
-- Configuration (encrypted JSON)
config JSONB NOT NULL DEFAULT '{}',
-- Connection health
last_health_check_at TIMESTAMP WITH TIME ZONE,
health_status VARCHAR(20), -- healthy, degraded, unhealthy, unknown
health_message TEXT,
-- Last sync info
last_sync_at TIMESTAMP WITH TIME ZONE,
last_sync_status sync_status,
last_sync_error TEXT,
next_sync_at TIMESTAMP WITH TIME ZONE,
-- Statistics
total_syncs INTEGER NOT NULL DEFAULT 0,
successful_syncs INTEGER NOT NULL DEFAULT 0,
failed_syncs INTEGER NOT NULL DEFAULT 0,
-- Audit
created_by UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
-- Constraints
CONSTRAINT integrations_unique_type UNIQUE (type) WHERE type NOT IN ('webhook', 'api_custom') AND deleted_at IS NULL
);
-- Indexes for integrations
CREATE INDEX IF NOT EXISTS idx_integrations_type ON integrations(type);
CREATE INDEX IF NOT EXISTS idx_integrations_status ON integrations(status);
CREATE INDEX IF NOT EXISTS idx_integrations_active ON integrations(is_active) WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_integrations_next_sync ON integrations(next_sync_at) WHERE next_sync_at IS NOT NULL AND is_active = true;
CREATE INDEX IF NOT EXISTS idx_integrations_deleted ON integrations(deleted_at) WHERE deleted_at IS NULL;
-- ============================================================================
-- SYNC_JOBS TABLE
-- Individual sync job executions
-- ============================================================================
CREATE TABLE IF NOT EXISTS sync_jobs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
-- Job info
job_type VARCHAR(100) NOT NULL,
status sync_status NOT NULL DEFAULT 'pending',
-- Timing
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
-- Progress
progress INTEGER NOT NULL DEFAULT 0, -- 0-100
-- Results
records_processed INTEGER DEFAULT 0,
records_created INTEGER DEFAULT 0,
records_updated INTEGER DEFAULT 0,
records_failed INTEGER DEFAULT 0,
-- Parameters
parameters JSONB,
result_summary JSONB,
-- Error handling
error_message TEXT,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
next_retry_at TIMESTAMP WITH TIME ZONE,
-- Audit
created_by UUID,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for sync_jobs
CREATE INDEX IF NOT EXISTS idx_sync_jobs_integration ON sync_jobs(integration_id);
CREATE INDEX IF NOT EXISTS idx_sync_jobs_status ON sync_jobs(status);
CREATE INDEX IF NOT EXISTS idx_sync_jobs_created ON sync_jobs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sync_jobs_active ON sync_jobs(integration_id, status) WHERE status IN ('pending', 'queued', 'running');
CREATE INDEX IF NOT EXISTS idx_sync_jobs_retry ON sync_jobs(next_retry_at) WHERE status = 'failed' AND retry_count < max_retries;
-- ============================================================================
-- INTEGRATION_SYNC_LOGS TABLE
-- Detailed sync history and metrics
-- ============================================================================
CREATE TABLE IF NOT EXISTS integration_sync_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
job_id UUID REFERENCES sync_jobs(id) ON DELETE SET NULL,
-- Sync details
entity_type sync_entity_type,
direction sync_direction NOT NULL DEFAULT 'import',
status sync_status NOT NULL,
-- Timing
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE,
duration_ms INTEGER,
-- Results
total_records INTEGER DEFAULT 0,
created_records INTEGER DEFAULT 0,
updated_records INTEGER DEFAULT 0,
skipped_records INTEGER DEFAULT 0,
failed_records INTEGER DEFAULT 0,
-- Errors
error_count INTEGER DEFAULT 0,
last_error TEXT,
-- Trigger info
triggered_by VARCHAR(20) NOT NULL DEFAULT 'manual', -- schedule, manual, webhook, system
triggered_by_user_id UUID,
-- Metadata
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for integration_sync_logs
CREATE INDEX IF NOT EXISTS idx_sync_logs_integration ON integration_sync_logs(integration_id);
CREATE INDEX IF NOT EXISTS idx_sync_logs_job ON integration_sync_logs(job_id) WHERE job_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sync_logs_created ON integration_sync_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sync_logs_status ON integration_sync_logs(status);
CREATE INDEX IF NOT EXISTS idx_sync_logs_entity ON integration_sync_logs(entity_type) WHERE entity_type IS NOT NULL;
-- ============================================================================
-- INTEGRATION_LOGS TABLE
-- General integration event logs (connections, tests, etc.)
-- ============================================================================
CREATE TABLE IF NOT EXISTS integration_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Event info
integration_type integration_type NOT NULL,
integration_id UUID REFERENCES integrations(id) ON DELETE SET NULL,
event_type VARCHAR(50) NOT NULL, -- connection_test, config_change, error, etc.
status VARCHAR(20) NOT NULL, -- success, failed, warning
-- Details
message TEXT,
latency_ms INTEGER,
metadata JSONB,
-- Audit
user_id UUID,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for integration_logs
CREATE INDEX IF NOT EXISTS idx_integration_logs_type ON integration_logs(integration_type);
CREATE INDEX IF NOT EXISTS idx_integration_logs_integration ON integration_logs(integration_id) WHERE integration_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_integration_logs_created ON integration_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_integration_logs_event ON integration_logs(event_type);
-- ============================================================================
-- INTEGRATION_SCHEDULES TABLE
-- Scheduled sync configurations
-- ============================================================================
CREATE TABLE IF NOT EXISTS integration_schedules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
-- Schedule config
entity_type sync_entity_type,
direction sync_direction NOT NULL DEFAULT 'import',
is_enabled BOOLEAN NOT NULL DEFAULT true,
-- Cron expression
cron_expression VARCHAR(100) NOT NULL,
timezone VARCHAR(50) NOT NULL DEFAULT 'America/Mexico_City',
-- Execution window
start_time VARCHAR(5), -- HH:mm
end_time VARCHAR(5), -- HH:mm
days_of_week JSONB, -- [0,1,2,3,4,5,6]
-- Execution info
last_run_at TIMESTAMP WITH TIME ZONE,
next_run_at TIMESTAMP WITH TIME ZONE,
last_status sync_status,
-- Options
priority VARCHAR(10) NOT NULL DEFAULT 'normal', -- low, normal, high
timeout_ms INTEGER NOT NULL DEFAULT 300000, -- 5 minutes
-- Audit
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for integration_schedules
CREATE INDEX IF NOT EXISTS idx_schedules_integration ON integration_schedules(integration_id);
CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON integration_schedules(is_enabled) WHERE is_enabled = true;
CREATE INDEX IF NOT EXISTS idx_schedules_next_run ON integration_schedules(next_run_at) WHERE is_enabled = true;
-- ============================================================================
-- SYNC_MAPPINGS TABLE
-- Field mappings between external systems and Horux
-- ============================================================================
CREATE TABLE IF NOT EXISTS sync_mappings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
-- Mapping info
name VARCHAR(200) NOT NULL,
entity_type sync_entity_type NOT NULL,
source_entity VARCHAR(100) NOT NULL, -- Entity name in external system
target_entity VARCHAR(100) NOT NULL, -- Entity name in Horux
-- Field mappings
field_mappings JSONB NOT NULL DEFAULT '[]',
/*
Example:
[
{
"id": "uuid",
"sourceField": "CLAVECTE",
"targetField": "external_id",
"transformationType": "direct",
"isRequired": true
},
{
"id": "uuid",
"sourceField": "NOMCTE",
"targetField": "name",
"transformationType": "direct",
"isRequired": true
},
{
"id": "uuid",
"sourceField": "RFC",
"targetField": "rfc",
"transformationType": "formula",
"transformationValue": "UPPER(TRIM(value))",
"isRequired": false
}
]
*/
-- Filters
source_filters JSONB, -- Filters applied when fetching from source
target_filters JSONB, -- Filters applied before saving to target
-- Options
is_active BOOLEAN NOT NULL DEFAULT true,
skip_duplicates BOOLEAN NOT NULL DEFAULT true,
update_existing BOOLEAN NOT NULL DEFAULT true,
-- Audit
created_by UUID,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for sync_mappings
CREATE INDEX IF NOT EXISTS idx_mappings_integration ON sync_mappings(integration_id);
CREATE INDEX IF NOT EXISTS idx_mappings_entity ON sync_mappings(entity_type);
CREATE INDEX IF NOT EXISTS idx_mappings_active ON sync_mappings(is_active) WHERE is_active = true;
-- ============================================================================
-- SYNC_VALUE_MAPPINGS TABLE
-- Value lookups for field transformations
-- ============================================================================
CREATE TABLE IF NOT EXISTS sync_value_mappings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
mapping_id UUID NOT NULL REFERENCES sync_mappings(id) ON DELETE CASCADE,
-- Mapping
field_name VARCHAR(100) NOT NULL,
source_value VARCHAR(500) NOT NULL,
target_value VARCHAR(500) NOT NULL,
description TEXT,
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
-- Audit
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Unique constraint
UNIQUE(mapping_id, field_name, source_value)
);
-- Indexes for sync_value_mappings
CREATE INDEX IF NOT EXISTS idx_value_mappings_mapping ON sync_value_mappings(mapping_id);
CREATE INDEX IF NOT EXISTS idx_value_mappings_field ON sync_value_mappings(field_name);
CREATE INDEX IF NOT EXISTS idx_value_mappings_lookup ON sync_value_mappings(mapping_id, field_name, source_value) WHERE is_active = true;
-- ============================================================================
-- SYNC_ERRORS TABLE
-- Detailed error tracking for sync failures
-- ============================================================================
CREATE TABLE IF NOT EXISTS sync_errors (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
job_id UUID NOT NULL REFERENCES sync_jobs(id) ON DELETE CASCADE,
log_id UUID REFERENCES integration_sync_logs(id) ON DELETE SET NULL,
-- Error details
error_code VARCHAR(50) NOT NULL,
error_message TEXT NOT NULL,
source_id VARCHAR(255), -- ID in external system
target_id UUID, -- ID in Horux
field_name VARCHAR(100),
-- Context
source_data JSONB,
stack_trace TEXT,
-- Resolution
is_resolved BOOLEAN NOT NULL DEFAULT false,
resolved_at TIMESTAMP WITH TIME ZONE,
resolved_by UUID,
resolution_notes TEXT,
-- Retry info
is_retryable BOOLEAN NOT NULL DEFAULT true,
retry_count INTEGER DEFAULT 0,
-- Audit
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for sync_errors
CREATE INDEX IF NOT EXISTS idx_sync_errors_job ON sync_errors(job_id);
CREATE INDEX IF NOT EXISTS idx_sync_errors_code ON sync_errors(error_code);
CREATE INDEX IF NOT EXISTS idx_sync_errors_unresolved ON sync_errors(is_resolved, created_at DESC) WHERE is_resolved = false;
CREATE INDEX IF NOT EXISTS idx_sync_errors_source ON sync_errors(source_id) WHERE source_id IS NOT NULL;
-- ============================================================================
-- WEBHOOK_EVENTS TABLE
-- Incoming webhook events from external systems
-- ============================================================================
CREATE TABLE IF NOT EXISTS webhook_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
-- Event info
event_type VARCHAR(100) NOT NULL,
event_id VARCHAR(255), -- External event ID
-- Payload
payload JSONB NOT NULL,
headers JSONB,
-- Processing
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed
processed_at TIMESTAMP WITH TIME ZONE,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
-- Validation
signature_valid BOOLEAN,
-- Audit
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
ip_address INET
);
-- Indexes for webhook_events
CREATE INDEX IF NOT EXISTS idx_webhook_events_integration ON webhook_events(integration_id);
CREATE INDEX IF NOT EXISTS idx_webhook_events_type ON webhook_events(event_type);
CREATE INDEX IF NOT EXISTS idx_webhook_events_status ON webhook_events(status);
CREATE INDEX IF NOT EXISTS idx_webhook_events_pending ON webhook_events(integration_id, status) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_webhook_events_received ON webhook_events(received_at DESC);
-- ============================================================================
-- EXTERNAL_ID_MAPPINGS TABLE
-- Track relationships between Horux IDs and external system IDs
-- ============================================================================
CREATE TABLE IF NOT EXISTS external_id_mappings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
-- Entity info
entity_type sync_entity_type NOT NULL,
horux_id UUID NOT NULL,
external_id VARCHAR(255) NOT NULL,
-- Metadata
external_data JSONB, -- Store additional external info
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_hash VARCHAR(64), -- Hash of last synced data for change detection
-- Audit
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Unique constraint
UNIQUE(integration_id, entity_type, horux_id),
UNIQUE(integration_id, entity_type, external_id)
);
-- Indexes for external_id_mappings
CREATE INDEX IF NOT EXISTS idx_ext_mappings_integration ON external_id_mappings(integration_id);
CREATE INDEX IF NOT EXISTS idx_ext_mappings_entity ON external_id_mappings(entity_type);
CREATE INDEX IF NOT EXISTS idx_ext_mappings_horux ON external_id_mappings(horux_id);
CREATE INDEX IF NOT EXISTS idx_ext_mappings_external ON external_id_mappings(external_id);
CREATE INDEX IF NOT EXISTS idx_ext_mappings_lookup ON external_id_mappings(integration_id, entity_type, external_id);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Update integrations updated_at
CREATE TRIGGER update_integrations_updated_at BEFORE UPDATE ON integrations
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Update sync_jobs updated_at
CREATE TRIGGER update_sync_jobs_updated_at BEFORE UPDATE ON sync_jobs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Update integration_schedules updated_at
CREATE TRIGGER update_schedules_updated_at BEFORE UPDATE ON integration_schedules
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Update sync_mappings updated_at
CREATE TRIGGER update_mappings_updated_at BEFORE UPDATE ON sync_mappings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Update external_id_mappings updated_at
CREATE TRIGGER update_ext_mappings_updated_at BEFORE UPDATE ON external_id_mappings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================================
-- FUNCTIONS
-- ============================================================================
-- Function to update integration stats after sync
CREATE OR REPLACE FUNCTION update_integration_stats()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status = 'completed' THEN
UPDATE integrations
SET
last_sync_at = NEW.completed_at,
last_sync_status = NEW.status,
last_sync_error = NULL,
total_syncs = total_syncs + 1,
successful_syncs = successful_syncs + 1,
updated_at = NOW()
WHERE id = NEW.integration_id;
ELSIF NEW.status = 'failed' THEN
UPDATE integrations
SET
last_sync_at = COALESCE(NEW.completed_at, NOW()),
last_sync_status = NEW.status,
last_sync_error = NEW.error_message,
total_syncs = total_syncs + 1,
failed_syncs = failed_syncs + 1,
updated_at = NOW()
WHERE id = NEW.integration_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to update integration stats
DROP TRIGGER IF EXISTS trigger_update_integration_stats ON sync_jobs;
CREATE TRIGGER trigger_update_integration_stats
AFTER UPDATE OF status ON sync_jobs
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status AND NEW.status IN ('completed', 'failed'))
EXECUTE FUNCTION update_integration_stats();
-- Function to clean old sync logs
CREATE OR REPLACE FUNCTION cleanup_old_sync_logs(days_to_keep INTEGER DEFAULT 90)
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM integration_sync_logs
WHERE created_at < NOW() - (days_to_keep || ' days')::INTERVAL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Function to clean old webhook events
CREATE OR REPLACE FUNCTION cleanup_old_webhook_events(days_to_keep INTEGER DEFAULT 30)
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM webhook_events
WHERE received_at < NOW() - (days_to_keep || ' days')::INTERVAL
AND status IN ('completed', 'failed');
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE integrations IS 'External system integration configurations';
COMMENT ON TABLE sync_jobs IS 'Individual sync job executions';
COMMENT ON TABLE integration_sync_logs IS 'Detailed sync history and metrics';
COMMENT ON TABLE integration_logs IS 'General integration event logs';
COMMENT ON TABLE integration_schedules IS 'Scheduled sync configurations';
COMMENT ON TABLE sync_mappings IS 'Field mappings between external systems and Horux';
COMMENT ON TABLE sync_value_mappings IS 'Value lookups for field transformations';
COMMENT ON TABLE sync_errors IS 'Detailed error tracking for sync failures';
COMMENT ON TABLE webhook_events IS 'Incoming webhook events from external systems';
COMMENT ON TABLE external_id_mappings IS 'Track relationships between Horux and external IDs';
-- ============================================================================
-- DEFAULT DATA
-- ============================================================================
-- Insert default sync mappings templates (can be customized per tenant)
-- These would typically be inserted when a new integration is created