erp-core-backend-v2/src/modules/projects/hr-integration.service.ts
rckrdmrd edadaf3180 [FASE 3-4] feat: Complete Financial, Inventory, CRM, and Projects modules
EPIC-005 - Financial Module:
- Add AccountMapping entity for GL account configuration
- Create GLPostingService for automatic journal entries
- Integrate GL posting with invoice validation
- Fix tax calculation in invoice lines

EPIC-006 - Inventory Automation:
- Integrate FIFO valuation with pickings
- Create ReorderAlertsService for stock monitoring
- Add lot validation for tracked products
- Integrate valuation with inventory adjustments

EPIC-007 - CRM Improvements:
- Create ActivitiesService for activity management
- Create ForecastingService for pipeline analytics
- Add win/loss reporting and user performance metrics

EPIC-008 - Project Billing:
- Create BillingService with billing rate management
- Add getUnbilledTimesheets and createInvoiceFromTimesheets
- Support grouping options for invoice generation

EPIC-009 - HR-Projects Integration:
- Create HRIntegrationService for employee-user linking
- Add employee cost rate management
- Implement project profitability calculations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 05:49:20 -06:00

642 lines
20 KiB
TypeScript

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<EmployeeInfo | null> {
const employee = await queryOne<EmployeeInfo>(
`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<EmployeeInfo[]> {
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<EmployeeInfo>(
`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<EmployeeCostRate | null> {
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<void> {
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<ProjectProfitability> {
// 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<string, { employee_id: string; employee_name: string; hourly_cost: number }>();
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<string, {
employee_id: string;
employee_name: string;
hours: number;
billable_hours: number;
revenue: number;
cost: number;
}>();
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<EmployeeTimesheetSummary[]> {
// 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();