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>
642 lines
20 KiB
TypeScript
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();
|