import { query, queryOne } from '../../config/database.js'; import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; import { logger } from '../../shared/utils/logger.js'; // ============================================================================ // TYPES // ============================================================================ export interface EmployeeInfo { employee_id: string; employee_number: string; employee_name: string; user_id: string; department_id?: string; department_name?: string; job_position_id?: string; job_position_name?: string; status: string; } export interface EmployeeCostRate { employee_id: string; employee_name: string; user_id: string; hourly_cost: number; monthly_cost: number; currency_id?: string; currency_code?: string; source: 'contract' | 'cost_rate' | 'default'; effective_date?: Date; } export interface EmployeeTimesheetSummary { employee_id: string; employee_name: string; user_id: string; project_id: string; project_name: string; total_hours: number; billable_hours: number; non_billable_hours: number; billable_amount: number; cost_amount: number; margin: number; margin_percent: number; } export interface ProjectProfitability { project_id: string; project_name: string; partner_id?: string; partner_name?: string; total_hours: number; billable_hours: number; billable_revenue: number; employee_cost: number; gross_margin: number; margin_percent: number; by_employee: { employee_id: string; employee_name: string; hours: number; billable_hours: number; revenue: number; cost: number; margin: number; }[]; } export interface CreateCostRateDto { company_id: string; employee_id: string; hourly_cost: number; currency_id: string; effective_from?: string; effective_to?: string; } // ============================================================================ // SERVICE // ============================================================================ class HRIntegrationService { // -------------------------------------------------------------------------- // EMPLOYEE LOOKUPS // -------------------------------------------------------------------------- /** * Get employee by user_id */ async getEmployeeByUserId( userId: string, tenantId: string ): Promise { const employee = await queryOne( `SELECT e.id as employee_id, e.employee_number, CONCAT(e.first_name, ' ', e.last_name) as employee_name, e.user_id, e.department_id, d.name as department_name, e.job_position_id, j.name as job_position_name, e.status FROM hr.employees e LEFT JOIN hr.departments d ON e.department_id = d.id LEFT JOIN hr.job_positions j ON e.job_position_id = j.id WHERE e.user_id = $1 AND e.tenant_id = $2 AND e.status = 'active'`, [userId, tenantId] ); return employee; } /** * Get all employees with user accounts */ async getEmployeesWithUserAccounts( tenantId: string, companyId?: string ): Promise { let whereClause = 'WHERE e.tenant_id = $1 AND e.user_id IS NOT NULL AND e.status = \'active\''; const params: any[] = [tenantId]; if (companyId) { whereClause += ' AND e.company_id = $2'; params.push(companyId); } return query( `SELECT e.id as employee_id, e.employee_number, CONCAT(e.first_name, ' ', e.last_name) as employee_name, e.user_id, e.department_id, d.name as department_name, e.job_position_id, j.name as job_position_name, e.status FROM hr.employees e LEFT JOIN hr.departments d ON e.department_id = d.id LEFT JOIN hr.job_positions j ON e.job_position_id = j.id ${whereClause} ORDER BY e.last_name, e.first_name`, params ); } // -------------------------------------------------------------------------- // COST RATES // -------------------------------------------------------------------------- /** * Get employee cost rate * Priority: explicit cost_rate > active contract wage > default */ async getEmployeeCostRate( employeeId: string, tenantId: string, date?: Date ): Promise { const targetDate = date || new Date(); // First check explicit cost rates table const explicitRate = await queryOne<{ hourly_cost: string; currency_id: string; currency_code: string; effective_from: Date; }>( `SELECT ecr.hourly_cost, ecr.currency_id, c.code as currency_code, ecr.effective_from FROM hr.employee_cost_rates ecr LEFT JOIN core.currencies c ON ecr.currency_id = c.id WHERE ecr.employee_id = $1 AND ecr.tenant_id = $2 AND ecr.active = true AND (ecr.effective_from IS NULL OR ecr.effective_from <= $3) AND (ecr.effective_to IS NULL OR ecr.effective_to >= $3) ORDER BY ecr.effective_from DESC NULLS LAST LIMIT 1`, [employeeId, tenantId, targetDate] ); // Get employee info const employee = await queryOne<{ employee_id: string; employee_name: string; user_id: string; }>( `SELECT id as employee_id, CONCAT(first_name, ' ', last_name) as employee_name, user_id FROM hr.employees WHERE id = $1 AND tenant_id = $2`, [employeeId, tenantId] ); if (!employee) { return null; } if (explicitRate) { const hourlyCost = parseFloat(explicitRate.hourly_cost); return { employee_id: employee.employee_id, employee_name: employee.employee_name, user_id: employee.user_id, hourly_cost: hourlyCost, monthly_cost: hourlyCost * 173.33, // ~40hrs/week * 4.33 weeks currency_id: explicitRate.currency_id, currency_code: explicitRate.currency_code, source: 'cost_rate', effective_date: explicitRate.effective_from, }; } // Fallback to contract wage const contract = await queryOne<{ wage: string; wage_type: string; hours_per_week: string; currency_id: string; currency_code: string; }>( `SELECT c.wage, c.wage_type, c.hours_per_week, c.currency_id, cur.code as currency_code FROM hr.contracts c LEFT JOIN core.currencies cur ON c.currency_id = cur.id WHERE c.employee_id = $1 AND c.tenant_id = $2 AND c.status = 'active' AND c.date_start <= $3 AND (c.date_end IS NULL OR c.date_end >= $3) ORDER BY c.date_start DESC LIMIT 1`, [employeeId, tenantId, targetDate] ); if (contract) { const wage = parseFloat(contract.wage); const hoursPerWeek = parseFloat(contract.hours_per_week) || 40; const wageType = contract.wage_type || 'monthly'; let hourlyCost: number; let monthlyCost: number; if (wageType === 'hourly') { hourlyCost = wage; monthlyCost = wage * hoursPerWeek * 4.33; } else if (wageType === 'daily') { hourlyCost = wage / 8; monthlyCost = wage * 21.67; // avg workdays per month } else { // monthly (default) monthlyCost = wage; hourlyCost = wage / (hoursPerWeek * 4.33); } return { employee_id: employee.employee_id, employee_name: employee.employee_name, user_id: employee.user_id, hourly_cost: hourlyCost, monthly_cost: monthlyCost, currency_id: contract.currency_id, currency_code: contract.currency_code, source: 'contract', }; } // No cost data available - return with zero cost return { employee_id: employee.employee_id, employee_name: employee.employee_name, user_id: employee.user_id, hourly_cost: 0, monthly_cost: 0, source: 'default', }; } /** * Create or update employee cost rate */ async setEmployeeCostRate( dto: CreateCostRateDto, tenantId: string, userId: string ): Promise { if (dto.hourly_cost < 0) { throw new ValidationError('El costo por hora no puede ser negativo'); } // Deactivate any existing rates for the same period if (dto.effective_from) { await query( `UPDATE hr.employee_cost_rates SET active = false, updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE employee_id = $2 AND tenant_id = $3 AND active = true AND (effective_from IS NULL OR effective_from >= $4)`, [userId, dto.employee_id, tenantId, dto.effective_from] ); } await query( `INSERT INTO hr.employee_cost_rates ( tenant_id, company_id, employee_id, hourly_cost, currency_id, effective_from, effective_to, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ tenantId, dto.company_id, dto.employee_id, dto.hourly_cost, dto.currency_id, dto.effective_from, dto.effective_to, userId ] ); logger.info('Employee cost rate set', { employee_id: dto.employee_id, hourly_cost: dto.hourly_cost, }); } // -------------------------------------------------------------------------- // PROJECT PROFITABILITY // -------------------------------------------------------------------------- /** * Calculate project profitability */ async getProjectProfitability( tenantId: string, projectId: string ): Promise { // Get project info const project = await queryOne<{ id: string; name: string; partner_id: string; partner_name: string; }>( `SELECT p.id, p.name, p.partner_id, pr.name as partner_name FROM projects.projects p LEFT JOIN core.partners pr ON p.partner_id = pr.id WHERE p.id = $1 AND p.tenant_id = $2`, [projectId, tenantId] ); if (!project) { throw new NotFoundError('Proyecto no encontrado'); } // Get timesheets with billing info const timesheets = await query<{ user_id: string; hours: string; billable: boolean; hourly_rate: string; billable_amount: string; }>( `SELECT ts.user_id, ts.hours, ts.billable, COALESCE(br.hourly_rate, 0) as hourly_rate, COALESCE(br.hourly_rate * ts.hours, 0) as billable_amount FROM projects.timesheets ts LEFT JOIN LATERAL ( SELECT hourly_rate FROM projects.billing_rates br2 WHERE br2.tenant_id = ts.tenant_id AND br2.company_id = ts.company_id AND br2.active = true AND (br2.effective_from IS NULL OR br2.effective_from <= ts.date) AND (br2.effective_to IS NULL OR br2.effective_to >= ts.date) AND ( (br2.project_id = ts.project_id AND br2.user_id = ts.user_id) OR (br2.project_id = ts.project_id AND br2.user_id IS NULL) OR (br2.project_id IS NULL AND br2.user_id = ts.user_id) OR (br2.project_id IS NULL AND br2.user_id IS NULL) ) ORDER BY CASE WHEN br2.project_id IS NOT NULL AND br2.user_id IS NOT NULL THEN 1 WHEN br2.project_id IS NOT NULL THEN 2 WHEN br2.user_id IS NOT NULL THEN 3 ELSE 4 END LIMIT 1 ) br ON true WHERE ts.project_id = $1 AND ts.tenant_id = $2 AND ts.status = 'approved'`, [projectId, tenantId] ); // Get unique users with their employees const userIds = [...new Set(timesheets.map(ts => ts.user_id))]; // Get employee cost rates for each user const employeeCosts = new Map(); for (const userId of userIds) { const employee = await this.getEmployeeByUserId(userId, tenantId); if (employee) { const costRate = await this.getEmployeeCostRate(employee.employee_id, tenantId); if (costRate) { employeeCosts.set(userId, { employee_id: employee.employee_id, employee_name: employee.employee_name, hourly_cost: costRate.hourly_cost, }); } } } // Calculate by employee const byEmployeeMap = new Map(); let totalHours = 0; let billableHours = 0; let totalRevenue = 0; let totalCost = 0; for (const ts of timesheets) { const hours = parseFloat(ts.hours); const billableAmount = parseFloat(ts.billable_amount); const employeeInfo = employeeCosts.get(ts.user_id); const hourlyCost = employeeInfo?.hourly_cost || 0; const cost = hours * hourlyCost; totalHours += hours; totalCost += cost; if (ts.billable) { billableHours += hours; totalRevenue += billableAmount; } const key = ts.user_id; const existing = byEmployeeMap.get(key); if (existing) { existing.hours += hours; if (ts.billable) { existing.billable_hours += hours; existing.revenue += billableAmount; } existing.cost += cost; } else { byEmployeeMap.set(key, { employee_id: employeeInfo?.employee_id || ts.user_id, employee_name: employeeInfo?.employee_name || 'Unknown', hours, billable_hours: ts.billable ? hours : 0, revenue: ts.billable ? billableAmount : 0, cost, }); } } const grossMargin = totalRevenue - totalCost; const marginPercent = totalRevenue > 0 ? (grossMargin / totalRevenue) * 100 : 0; const byEmployee = Array.from(byEmployeeMap.values()).map(emp => ({ ...emp, margin: emp.revenue - emp.cost, })).sort((a, b) => b.hours - a.hours); return { project_id: project.id, project_name: project.name, partner_id: project.partner_id, partner_name: project.partner_name, total_hours: totalHours, billable_hours: billableHours, billable_revenue: totalRevenue, employee_cost: totalCost, gross_margin: grossMargin, margin_percent: marginPercent, by_employee: byEmployee, }; } /** * Get employee timesheet summary across projects */ async getEmployeeTimesheetSummary( tenantId: string, employeeId: string, dateFrom?: string, dateTo?: string ): Promise { // Get employee with user_id const employee = await queryOne<{ employee_id: string; employee_name: string; user_id: string; }>( `SELECT id as employee_id, CONCAT(first_name, ' ', last_name) as employee_name, user_id FROM hr.employees WHERE id = $1 AND tenant_id = $2`, [employeeId, tenantId] ); if (!employee || !employee.user_id) { throw new NotFoundError('Empleado no encontrado o sin usuario asociado'); } // Get cost rate const costRate = await this.getEmployeeCostRate(employeeId, tenantId); const hourlyCost = costRate?.hourly_cost || 0; let whereClause = `WHERE ts.user_id = $1 AND ts.tenant_id = $2 AND ts.status = 'approved'`; const params: any[] = [employee.user_id, tenantId]; let paramIndex = 3; if (dateFrom) { whereClause += ` AND ts.date >= $${paramIndex++}`; params.push(dateFrom); } if (dateTo) { whereClause += ` AND ts.date <= $${paramIndex++}`; params.push(dateTo); } const timesheets = await query<{ project_id: string; project_name: string; total_hours: string; billable_hours: string; non_billable_hours: string; billable_amount: string; }>( `SELECT ts.project_id, p.name as project_name, SUM(ts.hours) as total_hours, SUM(CASE WHEN ts.billable THEN ts.hours ELSE 0 END) as billable_hours, SUM(CASE WHEN NOT ts.billable THEN ts.hours ELSE 0 END) as non_billable_hours, SUM(CASE WHEN ts.billable THEN COALESCE(br.hourly_rate * ts.hours, 0) ELSE 0 END) as billable_amount FROM projects.timesheets ts JOIN projects.projects p ON ts.project_id = p.id LEFT JOIN LATERAL ( SELECT hourly_rate FROM projects.billing_rates br2 WHERE br2.tenant_id = ts.tenant_id AND br2.company_id = ts.company_id AND br2.active = true AND (br2.effective_from IS NULL OR br2.effective_from <= ts.date) AND (br2.effective_to IS NULL OR br2.effective_to >= ts.date) AND ( (br2.project_id = ts.project_id AND br2.user_id = ts.user_id) OR (br2.project_id = ts.project_id AND br2.user_id IS NULL) OR (br2.project_id IS NULL AND br2.user_id = ts.user_id) OR (br2.project_id IS NULL AND br2.user_id IS NULL) ) ORDER BY CASE WHEN br2.project_id IS NOT NULL AND br2.user_id IS NOT NULL THEN 1 WHEN br2.project_id IS NOT NULL THEN 2 WHEN br2.user_id IS NOT NULL THEN 3 ELSE 4 END LIMIT 1 ) br ON true ${whereClause} GROUP BY ts.project_id, p.name ORDER BY total_hours DESC`, params ); return timesheets.map(ts => { const totalHours = parseFloat(ts.total_hours); const billableHours = parseFloat(ts.billable_hours); const billableAmount = parseFloat(ts.billable_amount); const costAmount = totalHours * hourlyCost; const margin = billableAmount - costAmount; return { employee_id: employee.employee_id, employee_name: employee.employee_name, user_id: employee.user_id, project_id: ts.project_id, project_name: ts.project_name, total_hours: totalHours, billable_hours: billableHours, non_billable_hours: parseFloat(ts.non_billable_hours), billable_amount: billableAmount, cost_amount: costAmount, margin, margin_percent: billableAmount > 0 ? (margin / billableAmount) * 100 : 0, }; }); } /** * Validate project team assignment */ async validateTeamAssignment( tenantId: string, projectId: string, userId: string ): Promise<{ valid: boolean; employee?: EmployeeInfo; message?: string }> { // Check if user has an employee record const employee = await this.getEmployeeByUserId(userId, tenantId); if (!employee) { return { valid: false, message: 'El usuario no tiene un registro de empleado activo', }; } // Get project info const project = await queryOne<{ company_id: string }>( `SELECT company_id FROM projects.projects WHERE id = $1 AND tenant_id = $2`, [projectId, tenantId] ); if (!project) { return { valid: false, message: 'Proyecto no encontrado', }; } // Optionally check if employee belongs to the same company // This is a soft validation - you might want to allow cross-company assignments const employeeCompany = await queryOne<{ company_id: string }>( `SELECT company_id FROM hr.employees WHERE id = $1`, [employee.employee_id] ); if (employeeCompany && employeeCompany.company_id !== project.company_id) { return { valid: true, // Still valid but with warning employee, message: 'El empleado pertenece a una compañía diferente al proyecto', }; } return { valid: true, employee, }; } } export const hrIntegrationService = new HRIntegrationService();