workspace-v1/projects/erp-construccion/docs/02-definicion-modulos/MAI-007-rrhh-asistencias/especificaciones/ET-HR-003-costeo-mano-obra.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

15 KiB

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

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

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<LaborCost>,
    @InjectRepository(Employee)
    private employeeRepo: Repository<Employee>,
    @InjectRepository(FSRConfiguration)
    private fsrConfigRepo: Repository<FSRConfiguration),
    @InjectRepository(CrewBudgetAssignment)
    private crewBudgetRepo: Repository<CrewBudgetAssignment>,
  ) {}

  /**
   * 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<number> {
    // 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<string | null> {
    // 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

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

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<any>(`/hr/labor-costs/compare/${workId}`),
  });

  if (isLoading) return <div>Cargando...</div>;

  const statusColor = {
    green: 'bg-green-500',
    yellow: 'bg-yellow-500',
    red: 'bg-red-500',
  };

  return (
    <div className="space-y-6">
      {/* Resumen */}
      <div className="grid grid-cols-4 gap-4">
        <Card className="p-4">
          <p className="text-sm text-muted-foreground">Presupuesto MO</p>
          <p className="text-2xl font-bold">
            ${data.budgeted.toLocaleString('es-MX')}
          </p>
        </Card>

        <Card className="p-4">
          <p className="text-sm text-muted-foreground">Real Gastado</p>
          <p className="text-2xl font-bold">
            ${data.real.toLocaleString('es-MX')}
          </p>
        </Card>

        <Card className="p-4">
          <p className="text-sm text-muted-foreground">Proyección 100%</p>
          <p className="text-2xl font-bold">
            ${data.projected?.toLocaleString('es-MX') || 'N/A'}
          </p>
        </Card>

        <Card className="p-4">
          <p className="text-sm text-muted-foreground">Desviación</p>
          <div className="flex items-center gap-2">
            <p className="text-2xl font-bold">
              {data.deviation?.toFixed(1)}%
            </p>
            <Badge className={statusColor[data.status]}>
              {data.status.toUpperCase()}
            </Badge>
          </div>
        </Card>
      </div>

      {/* Detalles */}
      <Card className="p-6">
        <h3 className="font-semibold mb-4">Costo por Partida</h3>
        <table className="w-full">
          <thead>
            <tr>
              <th>Partida</th>
              <th>Días-Hombre</th>
              <th>Costo Real</th>
            </tr>
          </thead>
          <tbody>
            {data.byBudgetItem.map((item) => (
              <tr key={item.budgetItemId}>
                <td>{item.budgetItemName}</td>
                <td>{item.count}</td>
                <td>${item.total.toLocaleString('es-MX')}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </Card>
    </div>
  );
}

Fecha de creación: 2025-11-17 Versión: 1.0