# ET-HR-003: Implementación de Costeo de Mano de Obra **Epic:** MAI-007 - RRHH, Asistencias y Nómina **RF:** RF-HR-003 **Tipo:** Especificación Técnica **Prioridad:** Alta **Estado:** 🚧 En Implementación **Última actualización:** 2025-11-17 --- ## 🏗️ Arquitectura ``` ┌────────────────────────────────────────────────┐ │ Frontend (React) │ │ CostDashboard, CrewBudgetAssignment │ └────────────────────┬───────────────────────────┘ │ REST API ┌────────────────────▼───────────────────────────┐ │ Backend (NestJS) │ │ LaborCostService - Cálculo automático │ │ Event: OnAttendanceApproved → calculateCost │ └────────────────────┬───────────────────────────┘ │ TypeORM ┌────────────────────▼───────────────────────────┐ │ PostgreSQL │ │ labor_costs (computed column) │ │ fsr_configuration │ │ crew_budget_assignments │ └────────────────────────────────────────────────┘ ``` --- ## 🔧 Implementación Backend ### 1. LaborCost Entity **Archivo:** `apps/backend/src/modules/hr/labor-costs/entities/labor-cost.entity.ts` ```typescript import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, CreateDateColumn, Index, } from 'typeorm'; import { AttendanceRecord } from '../../attendance/entities/attendance-record.entity'; import { Employee } from '../../employees/entities/employee.entity'; import { Project } from '../../../projects/entities/project.entity'; import { BudgetItem } from '../../../budgets/entities/budget-item.entity'; @Entity('labor_costs', { schema: 'hr' }) @Index(['workId', 'workDate']) @Index(['budgetItemId']) export class LaborCost { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', unique: true }) attendanceId: string; @ManyToOne(() => AttendanceRecord) @JoinColumn({ name: 'attendanceId' }) attendance: AttendanceRecord; @Column({ type: 'uuid' }) employeeId: string; @ManyToOne(() => Employee) @JoinColumn({ name: 'employeeId' }) employee: Employee; @Column({ type: 'uuid' }) workId: string; @ManyToOne(() => Project) @JoinColumn({ name: 'workId' }) work: Project; @Column({ type: 'uuid', nullable: true }) budgetItemId: string; @ManyToOne(() => BudgetItem, { nullable: true }) @JoinColumn({ name: 'budgetItemId' }) budgetItem: BudgetItem; @Column({ type: 'date' }) workDate: Date; @Column({ type: 'decimal', precision: 3, scale: 2 }) daysWorked: number; // 1.0 = día completo, 0.5 = medio día @Column({ type: 'decimal', precision: 10, scale: 2 }) dailySalary: number; @Column({ type: 'decimal', precision: 4, scale: 2 }) fsr: number; // Factor de Salario Real // Computed column (calculado en BD) @Column({ type: 'decimal', precision: 10, scale: 2, generatedType: 'STORED' }) realCost: number; // = dailySalary * daysWorked * fsr @CreateDateColumn() createdAt: Date; } ``` --- ### 2. LaborCostService **Archivo:** `apps/backend/src/modules/hr/labor-costs/labor-costs.service.ts` ```typescript import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { OnEvent } from '@nestjs/event-emitter'; import { LaborCost } from './entities/labor-cost.entity'; import { AttendanceRecord } from '../attendance/entities/attendance-record.entity'; import { Employee } from '../employees/entities/employee.entity'; import { FSRConfiguration } from '../fsr/entities/fsr-configuration.entity'; import { CrewBudgetAssignment } from '../crews/entities/crew-budget-assignment.entity'; @Injectable() export class LaborCostsService { constructor( @InjectRepository(LaborCost) private laborCostRepo: Repository, @InjectRepository(Employee) private employeeRepo: Repository, @InjectRepository(FSRConfiguration) private fsrConfigRepo: Repository, ) {} /** * Event listener: Cuando se aprueba asistencia, calcular costo */ @OnEvent('attendance.approved') async handleAttendanceApproved(attendance: AttendanceRecord) { // Obtener empleado const employee = await this.employeeRepo.findOne({ where: { id: attendance.employeeId }, relations: ['workAssignments'], }); // Obtener FSR de la constructora const fsrConfig = await this.fsrConfigRepo.findOne({ where: { constructoraId: employee.constructoraId }, }); // Calcular días trabajados (basado en check-in y check-out) const daysWorked = await this.calculateDaysWorked(attendance); // Obtener salario (específico de obra o base) const workAssignment = employee.workAssignments.find( (a) => a.workId === attendance.workId && a.endDate === null, ); const dailySalary = workAssignment?.workSpecificSalary || employee.currentSalary; // Determinar partida presupuestal const budgetItemId = await this.determineBudgetItem( attendance.employeeId, attendance.workId, attendance.workDate, ); // Crear registro de costo const laborCost = this.laborCostRepo.create({ attendanceId: attendance.id, employeeId: attendance.employeeId, workId: attendance.workId, budgetItemId, workDate: attendance.workDate, daysWorked, dailySalary, fsr: fsrConfig.totalFsr, // realCost se calcula automáticamente en BD }); await this.laborCostRepo.save(laborCost); } /** * Calcular días trabajados basado en check-in/check-out */ private async calculateDaysWorked(attendance: AttendanceRecord): Promise { // Obtener check-in y check-out del mismo día const checkOut = await this.attendanceRepo.findOne({ where: { employeeId: attendance.employeeId, workDate: attendance.workDate, type: 'check_out', }, }); if (!checkOut) { return 1.0; // Día completo por defecto } // Calcular horas trabajadas const hours = (checkOut.timestamp.getTime() - attendance.timestamp.getTime()) / (1000 * 60 * 60); if (hours >= 8) return 1.0; // Día completo if (hours >= 4) return 0.5; // Medio día return 0.25; // Cuarto de día } /** * Determinar partida presupuestal asignada */ private async determineBudgetItem( employeeId: string, workId: string, workDate: Date, ): Promise { // Buscar si el empleado está en una cuadrilla asignada a partida const assignment = await this.crewBudgetRepo .createQueryBuilder('assignment') .innerJoin('hr.crew_members', 'member', 'member.crew_id = assignment.crew_id') .where('member.employee_id = :employeeId', { employeeId }) .andWhere('assignment.work_id = :workId', { workId }) .andWhere('assignment.start_date <= :workDate', { workDate }) .andWhere('(assignment.end_date IS NULL OR assignment.end_date >= :workDate)', { workDate, }) .getOne(); return assignment?.budgetItemId || null; } /** * Obtener costos por obra */ async getCostsByWork(workId: string, filters?: { startDate?: Date; endDate?: Date }) { const query = this.laborCostRepo .createQueryBuilder('cost') .leftJoinAndSelect('cost.employee', 'employee') .leftJoinAndSelect('cost.budgetItem', 'budgetItem') .where('cost.workId = :workId', { workId }); if (filters?.startDate) { query.andWhere('cost.workDate >= :startDate', { startDate: filters.startDate }); } if (filters?.endDate) { query.andWhere('cost.workDate <= :endDate', { endDate: filters.endDate }); } const costs = await query.getMany(); const total = costs.reduce((sum, cost) => sum + Number(cost.realCost), 0); const byBudgetItem = this.groupByBudgetItem(costs); return { costs, total, byBudgetItem }; } /** * Comparar real vs presupuestado */ async compareRealVsBudget(workId: string) { // Obtener total presupuestado de MO const budgetedLabor = await this.budgetRepo .createQueryBuilder('item') .select('SUM(item.labor_cost)', 'total') .where('item.work_id = :workId', { workId }) .getRawOne(); // Obtener total real gastado const realLabor = await this.laborCostRepo .createQueryBuilder('cost') .select('SUM(cost.real_cost)', 'total') .where('cost.work_id = :workId', { workId }) .getRawOne(); // Obtener % de avance físico (de control de obra) const physicalProgress = await this.getPhysicalProgress(workId); // Proyección al 100% const projected = physicalProgress > 10 ? (Number(realLabor.total) / physicalProgress) * 100 : null; // Desviación const deviation = projected ? ((projected - Number(budgetedLabor.total)) / Number(budgetedLabor.total)) * 100 : null; return { budgeted: Number(budgetedLabor.total), real: Number(realLabor.total), physicalProgress, projected, deviation, status: this.getDeviationStatus(deviation), }; } private getDeviationStatus(deviation: number | null): string { if (!deviation) return 'unknown'; if (Math.abs(deviation) < 10) return 'green'; // Normal if (Math.abs(deviation) < 20) return 'yellow'; // Advertencia return 'red'; // Crítico } private groupByBudgetItem(costs: LaborCost[]) { const grouped = {}; costs.forEach((cost) => { const key = cost.budgetItemId || 'indirect'; if (!grouped[key]) { grouped[key] = { budgetItemId: cost.budgetItemId, budgetItemName: cost.budgetItem?.name || 'Indirecto', total: 0, count: 0, }; } grouped[key].total += Number(cost.realCost); grouped[key].count += 1; }); return Object.values(grouped); } } ``` --- ### 3. FSRConfiguration Entity **Archivo:** `apps/backend/src/modules/hr/fsr/entities/fsr-configuration.entity.ts` ```typescript import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, } from 'typeorm'; @Entity('fsr_configuration', { schema: 'hr' }) export class FSRConfiguration { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', unique: true }) constructoraId: string; // Componentes del FSR @Column({ type: 'decimal', precision: 5, scale: 2, default: 23.0 }) imssPercentage: number; @Column({ type: 'decimal', precision: 5, scale: 2, default: 5.0 }) infonavitPercentage: number; @Column({ type: 'decimal', precision: 5, scale: 2, default: 4.17 }) aguinaldoPercentage: number; @Column({ type: 'decimal', precision: 5, scale: 2, default: 1.67 }) vacacionesPercentage: number; @Column({ type: 'decimal', precision: 5, scale: 2, default: 0.42 }) primaVacacionalPercentage: number; @Column({ type: 'decimal', precision: 5, scale: 2, default: 14.28 }) domingosPercentage: number; @Column({ type: 'decimal', precision: 5, scale: 2, default: 2.19 }) festivosPercentage: number; @Column({ type: 'decimal', precision: 5, scale: 2, default: 5.0 }) ausentismoPercentage: number; @Column({ type: 'decimal', precision: 5, scale: 2, default: 3.0 }) otrosPercentage: number; // FSR calculado automáticamente @Column({ type: 'decimal', precision: 4, scale: 2, generatedType: 'STORED' }) totalFsr: number; // = 1 + (sum of all percentages) / 100 @Column({ type: 'date', default: () => 'CURRENT_DATE' }) effectiveDate: Date; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } ``` --- ## 🎨 Implementación Frontend ### CostDashboard Component **Archivo:** `apps/frontend/src/features/hr/labor-costs/components/CostDashboard.tsx` ```typescript import { useQuery } from '@tanstack/react-query'; import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { apiService } from '@/services/api.service'; export function CostDashboard({ workId }: { workId: string }) { const { data, isLoading } = useQuery({ queryKey: ['labor-costs', 'comparison', workId], queryFn: () => apiService.get(`/hr/labor-costs/compare/${workId}`), }); if (isLoading) return
Cargando...
; const statusColor = { green: 'bg-green-500', yellow: 'bg-yellow-500', red: 'bg-red-500', }; return (
{/* Resumen */}

Presupuesto MO

${data.budgeted.toLocaleString('es-MX')}

Real Gastado

${data.real.toLocaleString('es-MX')}

Proyección 100%

${data.projected?.toLocaleString('es-MX') || 'N/A'}

Desviación

{data.deviation?.toFixed(1)}%

{data.status.toUpperCase()}
{/* Detalles */}

Costo por Partida

{data.byBudgetItem.map((item) => ( ))}
Partida Días-Hombre Costo Real
{item.budgetItemName} {item.count} ${item.total.toLocaleString('es-MX')}
); } ``` --- **Fecha de creación:** 2025-11-17 **Versión:** 1.0