import { Repository, FindOptionsWhere, ILike, In, LessThanOrEqual, MoreThan, Between } from 'typeorm'; import { Operador, TipoLicencia, EstadoOperador } from '../entities/operador.entity'; import { DocumentoFlota, TipoDocumento, TipoEntidadDocumento } from '../entities/documento-flota.entity'; import { Asignacion } from '../entities/asignacion.entity'; // ============================================================================ // INTERFACES // ============================================================================ export interface OperadorFilters { search?: string; estado?: EstadoOperador; estados?: EstadoOperador[]; tipoLicencia?: TipoLicencia; sucursalId?: string; conUnidadAsignada?: boolean; activo?: boolean; limit?: number; offset?: number; } export interface CreateOperadorDto { numeroEmpleado: string; nombre: string; apellidoPaterno: string; apellidoMaterno?: string; curp?: string; rfc?: string; nss?: string; telefono?: string; telefonoEmergencia?: string; email?: string; direccion?: string; codigoPostal?: string; ciudad?: string; estadoResidencia?: string; fechaNacimiento?: Date; lugarNacimiento?: string; nacionalidad?: string; tipoLicencia?: TipoLicencia; numeroLicencia?: string; licenciaVigencia?: Date; licenciaEstadoExpedicion?: string; certificadoFisicoVigencia?: Date; antidopingVigencia?: Date; capacitacionMaterialesPeligrosos?: boolean; capacitacionMpVigencia?: Date; banco?: string; cuentaBancaria?: string; clabe?: string; salarioBase?: number; tipoPago?: string; fechaIngreso?: Date; sucursalId?: string; } export interface UpdateOperadorDto extends Partial { estado?: EstadoOperador; } export interface AddCertificacionDto { tipo: TipoDocumento; nombre: string; numeroDocumento?: string; descripcion?: string; fechaEmision?: Date; fechaVencimiento?: Date; diasAlertaVencimiento?: number; archivoUrl?: string; archivoNombre?: string; archivoTipo?: string; archivoTamanoBytes?: number; } export interface AddDocumentoDto { tipo: TipoDocumento; nombre: string; numeroDocumento?: string; descripcion?: string; fechaEmision?: Date; fechaVencimiento?: Date; diasAlertaVencimiento?: number; archivoUrl?: string; archivoNombre?: string; archivoTipo?: string; archivoTamanoBytes?: number; } export interface DocumentoExpirando { documento: DocumentoFlota; operador: Operador; diasParaVencer: number; } export interface HorasServicio { operadorId: string; nombreCompleto: string; horasTrabajadasHoy: number; horasTrabajadasSemana: number; horasDescansadas: number; ultimoDescanso?: Date; proximoDescansoObligatorio?: Date; enCumplimiento: boolean; alertas: string[]; } export interface PerformanceMetrics { operadorId: string; nombreCompleto: string; periodo: { inicio: Date; fin: Date; }; totalViajes: number; totalKm: number; totalEntregasATiempo: number; porcentajeEntregasATiempo: number; incidentes: number; calificacionPromedio: number; combustibleEficiencia?: number; horasConduccion: number; diasTrabajados: number; } export interface OperadorStatistics { total: number; activos: number; disponibles: number; enViaje: number; enDescanso: number; vacaciones: number; incapacidad: number; suspendidos: number; licenciasPorVencer: number; certificadosPorVencer: number; sinUnidadAsignada: number; } // ============================================================================ // SERVICE // ============================================================================ export class OperadorService { constructor( private readonly operadorRepository: Repository, private readonly documentoRepository: Repository, private readonly asignacionRepository: Repository ) {} // -------------------------------------------------------------------------- // CRUD OPERATIONS // -------------------------------------------------------------------------- /** * Crear perfil de operador/conductor */ async create(tenantId: string, dto: CreateOperadorDto, createdById: string): Promise { // Validar numero de empleado unico const existingNumero = await this.operadorRepository.findOne({ where: { tenantId, numeroEmpleado: dto.numeroEmpleado }, }); if (existingNumero) { throw new Error(`Ya existe un operador con el numero de empleado ${dto.numeroEmpleado}`); } // Validar CURP unico si se proporciona if (dto.curp) { const existingCurp = await this.operadorRepository.findOne({ where: { tenantId, curp: dto.curp }, }); if (existingCurp) { throw new Error(`Ya existe un operador con el CURP ${dto.curp}`); } } // Validar licencia unica si se proporciona if (dto.numeroLicencia) { const existingLicencia = await this.operadorRepository.findOne({ where: { tenantId, numeroLicencia: dto.numeroLicencia }, }); if (existingLicencia) { throw new Error(`Ya existe un operador con el numero de licencia ${dto.numeroLicencia}`); } } const operador = this.operadorRepository.create({ ...dto, tenantId, estado: EstadoOperador.ACTIVO, activo: true, createdById, }); return this.operadorRepository.save(operador); } /** * Buscar operador por ID */ async findById(tenantId: string, id: string): Promise { return this.operadorRepository.findOne({ where: { tenantId, id, activo: true }, relations: ['unidadAsignada'], }); } /** * Buscar operador por ID o lanzar error */ async findByIdOrFail(tenantId: string, id: string): Promise { const operador = await this.findById(tenantId, id); if (!operador) { throw new Error(`Operador con ID ${id} no encontrado`); } return operador; } /** * Listar operadores con filtros */ async findAll( tenantId: string, filters: OperadorFilters = {} ): Promise<{ data: Operador[]; total: number }> { const { search, estado, estados, tipoLicencia, sucursalId, conUnidadAsignada, activo = true, limit = 50, offset = 0, } = filters; const qb = this.operadorRepository .createQueryBuilder('o') .leftJoinAndSelect('o.unidadAsignada', 'unidad') .where('o.tenant_id = :tenantId', { tenantId }); if (activo !== undefined) { qb.andWhere('o.activo = :activo', { activo }); } if (estado) { qb.andWhere('o.estado = :estado', { estado }); } if (estados && estados.length > 0) { qb.andWhere('o.estado IN (:...estados)', { estados }); } if (tipoLicencia) { qb.andWhere('o.tipo_licencia = :tipoLicencia', { tipoLicencia }); } if (sucursalId) { qb.andWhere('o.sucursal_id = :sucursalId', { sucursalId }); } if (conUnidadAsignada !== undefined) { if (conUnidadAsignada) { qb.andWhere('o.unidad_asignada_id IS NOT NULL'); } else { qb.andWhere('o.unidad_asignada_id IS NULL'); } } if (search) { qb.andWhere( '(o.numero_empleado ILIKE :search OR o.nombre ILIKE :search OR ' + 'o.apellido_paterno ILIKE :search OR o.apellido_materno ILIKE :search OR ' + 'o.numero_licencia ILIKE :search)', { search: `%${search}%` } ); } const total = await qb.getCount(); qb.orderBy('o.apellido_paterno', 'ASC') .addOrderBy('o.nombre', 'ASC') .offset(offset) .limit(limit); const data = await qb.getMany(); return { data, total }; } /** * Obtener operadores disponibles */ async findAvailable(tenantId: string): Promise { return this.operadorRepository.find({ where: { tenantId, estado: In([EstadoOperador.DISPONIBLE, EstadoOperador.ACTIVO]), activo: true, }, order: { apellidoPaterno: 'ASC', nombre: 'ASC' }, }); } /** * Filtrar operadores por estado */ async findByStatus(tenantId: string, estado: EstadoOperador): Promise { return this.operadorRepository.find({ where: { tenantId, estado, activo: true }, order: { apellidoPaterno: 'ASC', nombre: 'ASC' }, }); } /** * Actualizar operador */ async update( tenantId: string, id: string, dto: UpdateOperadorDto, updatedById: string ): Promise { const operador = await this.findByIdOrFail(tenantId, id); // Validar numero de empleado si cambia if (dto.numeroEmpleado && dto.numeroEmpleado !== operador.numeroEmpleado) { const existing = await this.operadorRepository.findOne({ where: { tenantId, numeroEmpleado: dto.numeroEmpleado }, }); if (existing) { throw new Error(`Ya existe un operador con el numero de empleado ${dto.numeroEmpleado}`); } } // Validar CURP si cambia if (dto.curp && dto.curp !== operador.curp) { const existing = await this.operadorRepository.findOne({ where: { tenantId, curp: dto.curp }, }); if (existing && existing.id !== id) { throw new Error(`Ya existe un operador con el CURP ${dto.curp}`); } } // Validar licencia si cambia if (dto.numeroLicencia && dto.numeroLicencia !== operador.numeroLicencia) { const existing = await this.operadorRepository.findOne({ where: { tenantId, numeroLicencia: dto.numeroLicencia }, }); if (existing && existing.id !== id) { throw new Error(`Ya existe un operador con el numero de licencia ${dto.numeroLicencia}`); } } Object.assign(operador, { ...dto, updatedById, }); return this.operadorRepository.save(operador); } /** * Cambiar estado del operador */ async updateStatus( tenantId: string, id: string, estado: EstadoOperador, updatedById: string ): Promise { const operador = await this.findByIdOrFail(tenantId, id); // Validaciones de transicion de estado if (operador.estado === EstadoOperador.BAJA) { throw new Error('No se puede cambiar el estado de un operador dado de baja'); } if (estado === EstadoOperador.EN_VIAJE && !operador.unidadAsignadaId) { throw new Error('No se puede poner en viaje a un operador sin unidad asignada'); } // Validar documentos vigentes para estados operativos if (estado === EstadoOperador.EN_VIAJE || estado === EstadoOperador.DISPONIBLE) { const hoy = new Date(); if (operador.licenciaVigencia && operador.licenciaVigencia < hoy) { throw new Error('El operador tiene la licencia vencida'); } if (operador.certificadoFisicoVigencia && operador.certificadoFisicoVigencia < hoy) { throw new Error('El operador tiene el certificado fisico vencido'); } if (operador.antidopingVigencia && operador.antidopingVigencia < hoy) { throw new Error('El operador tiene el antidoping vencido'); } } operador.estado = estado; operador.updatedById = updatedById; return this.operadorRepository.save(operador); } // -------------------------------------------------------------------------- // DOCUMENTS & CERTIFICATIONS // -------------------------------------------------------------------------- /** * Agregar certificacion al operador */ async addCertificacion( tenantId: string, operadorId: string, dto: AddCertificacionDto, createdById: string ): Promise { // Verificar que el operador existe await this.findByIdOrFail(tenantId, operadorId); const documento = this.documentoRepository.create({ tenantId, entidadTipo: TipoEntidadDocumento.OPERADOR, entidadId: operadorId, tipoDocumento: dto.tipo, nombre: dto.nombre, numeroDocumento: dto.numeroDocumento, descripcion: dto.descripcion, fechaEmision: dto.fechaEmision, fechaVencimiento: dto.fechaVencimiento, diasAlertaVencimiento: dto.diasAlertaVencimiento || 30, archivoUrl: dto.archivoUrl, archivoNombre: dto.archivoNombre, archivoTipo: dto.archivoTipo, archivoTamanoBytes: dto.archivoTamanoBytes, activo: true, createdById, }); return this.documentoRepository.save(documento); } /** * Agregar documento al operador */ async addDocumento( tenantId: string, operadorId: string, dto: AddDocumentoDto, createdById: string ): Promise { // Verificar que el operador existe await this.findByIdOrFail(tenantId, operadorId); const documento = this.documentoRepository.create({ tenantId, entidadTipo: TipoEntidadDocumento.OPERADOR, entidadId: operadorId, tipoDocumento: dto.tipo, nombre: dto.nombre, numeroDocumento: dto.numeroDocumento, descripcion: dto.descripcion, fechaEmision: dto.fechaEmision, fechaVencimiento: dto.fechaVencimiento, diasAlertaVencimiento: dto.diasAlertaVencimiento || 30, archivoUrl: dto.archivoUrl, archivoNombre: dto.archivoNombre, archivoTipo: dto.archivoTipo, archivoTamanoBytes: dto.archivoTamanoBytes, activo: true, createdById, }); return this.documentoRepository.save(documento); } /** * Obtener documentos de un operador */ async getDocumentos(tenantId: string, operadorId: string): Promise { return this.documentoRepository.find({ where: { tenantId, entidadTipo: TipoEntidadDocumento.OPERADOR, entidadId: operadorId, activo: true, }, order: { fechaVencimiento: 'ASC' }, }); } /** * Obtener documentos por vencer de todos los operadores */ async getDocumentsExpiring(tenantId: string, dias: number = 30): Promise { const hoy = new Date(); const fechaLimite = new Date(); fechaLimite.setDate(fechaLimite.getDate() + dias); const documentos = await this.documentoRepository.find({ where: { tenantId, entidadTipo: TipoEntidadDocumento.OPERADOR, fechaVencimiento: Between(hoy, fechaLimite), activo: true, }, order: { fechaVencimiento: 'ASC' }, }); const result: DocumentoExpirando[] = []; for (const doc of documentos) { const operador = await this.operadorRepository.findOne({ where: { id: doc.entidadId, tenantId }, }); if (operador) { const diasParaVencer = Math.ceil( (doc.fechaVencimiento.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24) ); result.push({ documento: doc, operador, diasParaVencer, }); } } return result; } /** * Obtener operadores con licencia por vencer */ async getOperadoresLicenciaPorVencer(tenantId: string, dias: number = 30): Promise { const hoy = new Date(); const fechaLimite = new Date(); fechaLimite.setDate(fechaLimite.getDate() + dias); return this.operadorRepository .createQueryBuilder('o') .where('o.tenant_id = :tenantId', { tenantId }) .andWhere('o.activo = true') .andWhere('o.licencia_vigencia BETWEEN :hoy AND :fechaLimite', { hoy, fechaLimite }) .orderBy('o.licencia_vigencia', 'ASC') .getMany(); } // -------------------------------------------------------------------------- // HOURS OF SERVICE (HOS) // -------------------------------------------------------------------------- /** * Obtener horas de servicio del operador (HOS tracking) */ async getHoursOfService(tenantId: string, operadorId: string): Promise { const operador = await this.findByIdOrFail(tenantId, operadorId); const alertas: string[] = []; // Obtener asignaciones para calcular horas trabajadas const hoy = new Date(); const inicioSemana = new Date(hoy); inicioSemana.setDate(hoy.getDate() - hoy.getDay()); inicioSemana.setHours(0, 0, 0, 0); const inicioHoy = new Date(hoy); inicioHoy.setHours(0, 0, 0, 0); // Calcular horas trabajadas (simplificado - en produccion se calcularia desde eventos de viaje) // Por ahora retornamos valores basados en el estado del operador let horasTrabajadasHoy = 0; let horasTrabajadasSemana = 0; if (operador.estado === EstadoOperador.EN_VIAJE || operador.estado === EstadoOperador.EN_RUTA) { // Estimado si esta en viaje horasTrabajadasHoy = 8; horasTrabajadasSemana = operador.totalViajes * 8; // Aproximado } // Reglas NOM-087 (simplificadas) const HORAS_MAX_DIA = 14; const HORAS_MAX_SEMANA = 60; const HORAS_DESCANSO_MIN = 10; const horasDescansadas = 24 - horasTrabajadasHoy; let enCumplimiento = true; if (horasTrabajadasHoy >= HORAS_MAX_DIA) { alertas.push(`Excede limite de ${HORAS_MAX_DIA} horas diarias`); enCumplimiento = false; } if (horasTrabajadasSemana >= HORAS_MAX_SEMANA) { alertas.push(`Excede limite de ${HORAS_MAX_SEMANA} horas semanales`); enCumplimiento = false; } if (horasDescansadas < HORAS_DESCANSO_MIN && operador.estado === EstadoOperador.EN_VIAJE) { alertas.push(`Requiere minimo ${HORAS_DESCANSO_MIN} horas de descanso`); enCumplimiento = false; } // Calcular proximo descanso obligatorio const proximoDescansoObligatorio = new Date(hoy); proximoDescansoObligatorio.setHours(proximoDescansoObligatorio.getHours() + (HORAS_MAX_DIA - horasTrabajadasHoy)); return { operadorId: operador.id, nombreCompleto: operador.nombreCompleto, horasTrabajadasHoy, horasTrabajadasSemana, horasDescansadas, ultimoDescanso: operador.estado === EstadoOperador.DESCANSO ? hoy : undefined, proximoDescansoObligatorio: horasTrabajadasHoy > 0 ? proximoDescansoObligatorio : undefined, enCumplimiento, alertas, }; } // -------------------------------------------------------------------------- // PERFORMANCE METRICS // -------------------------------------------------------------------------- /** * Obtener metricas de desempenio del operador */ async getPerformance( tenantId: string, operadorId: string, dateRange: { inicio: Date; fin: Date } ): Promise { const operador = await this.findByIdOrFail(tenantId, operadorId); // En produccion, estos datos vendrian de tablas de viajes, eventos, etc. // Por ahora usamos los datos acumulados del operador const totalViajes = operador.totalViajes || 0; const totalKm = operador.totalKm || 0; const incidentes = operador.incidentes || 0; const calificacionPromedio = Number(operador.calificacion) || 5.0; // Calcular dias en el rango const diasEnRango = Math.ceil( (dateRange.fin.getTime() - dateRange.inicio.getTime()) / (1000 * 60 * 60 * 24) ); // Estimaciones basadas en datos disponibles const totalEntregasATiempo = Math.round(totalViajes * 0.95); // 95% estimado const porcentajeEntregasATiempo = totalViajes > 0 ? (totalEntregasATiempo / totalViajes) * 100 : 100; // Horas de conduccion estimadas (8 horas por viaje promedio) const horasConduccion = totalViajes * 8; // Dias trabajados estimados const diasTrabajados = Math.min(totalViajes, diasEnRango); return { operadorId: operador.id, nombreCompleto: operador.nombreCompleto, periodo: dateRange, totalViajes, totalKm, totalEntregasATiempo, porcentajeEntregasATiempo, incidentes, calificacionPromedio, combustibleEficiencia: undefined, // Requiere datos de combustible horasConduccion, diasTrabajados, }; } // -------------------------------------------------------------------------- // STATISTICS // -------------------------------------------------------------------------- /** * Obtener estadisticas de operadores */ async getStatistics(tenantId: string): Promise { const hoy = new Date(); const fechaLimite30 = new Date(); fechaLimite30.setDate(fechaLimite30.getDate() + 30); const total = await this.operadorRepository.count({ where: { tenantId, activo: true }, }); const activos = await this.operadorRepository.count({ where: { tenantId, estado: EstadoOperador.ACTIVO, activo: true }, }); const disponibles = await this.operadorRepository.count({ where: { tenantId, estado: EstadoOperador.DISPONIBLE, activo: true }, }); const enViaje = await this.operadorRepository.count({ where: { tenantId, estado: In([EstadoOperador.EN_VIAJE, EstadoOperador.EN_RUTA]), activo: true }, }); const enDescanso = await this.operadorRepository.count({ where: { tenantId, estado: EstadoOperador.DESCANSO, activo: true }, }); const vacaciones = await this.operadorRepository.count({ where: { tenantId, estado: EstadoOperador.VACACIONES, activo: true }, }); const incapacidad = await this.operadorRepository.count({ where: { tenantId, estado: EstadoOperador.INCAPACIDAD, activo: true }, }); const suspendidos = await this.operadorRepository.count({ where: { tenantId, estado: EstadoOperador.SUSPENDIDO, activo: true }, }); // Licencias por vencer en 30 dias const licenciasPorVencer = await this.operadorRepository .createQueryBuilder('o') .where('o.tenant_id = :tenantId', { tenantId }) .andWhere('o.activo = true') .andWhere('o.licencia_vigencia BETWEEN :hoy AND :fechaLimite', { hoy, fechaLimite: fechaLimite30 }) .getCount(); // Certificados por vencer (certificado fisico, antidoping) const certificadosPorVencer = await this.operadorRepository .createQueryBuilder('o') .where('o.tenant_id = :tenantId', { tenantId }) .andWhere('o.activo = true') .andWhere( '(o.certificado_fisico_vigencia BETWEEN :hoy AND :fechaLimite OR ' + 'o.antidoping_vigencia BETWEEN :hoy AND :fechaLimite)', { hoy, fechaLimite: fechaLimite30 } ) .getCount(); // Sin unidad asignada const sinUnidadAsignada = await this.operadorRepository.count({ where: { tenantId, activo: true, estado: In([EstadoOperador.ACTIVO, EstadoOperador.DISPONIBLE]), }, }); // Restar los que si tienen unidad const conUnidad = await this.operadorRepository .createQueryBuilder('o') .where('o.tenant_id = :tenantId', { tenantId }) .andWhere('o.activo = true') .andWhere('o.estado IN (:...estados)', { estados: [EstadoOperador.ACTIVO, EstadoOperador.DISPONIBLE] }) .andWhere('o.unidad_asignada_id IS NOT NULL') .getCount(); return { total, activos, disponibles, enViaje, enDescanso, vacaciones, incapacidad, suspendidos, licenciasPorVencer, certificadosPorVencer, sinUnidadAsignada: sinUnidadAsignada - conUnidad, }; } /** * Dar de baja operador */ async darDeBaja( tenantId: string, id: string, motivoBaja: string, updatedById: string ): Promise { const operador = await this.findByIdOrFail(tenantId, id); if (operador.estado === EstadoOperador.EN_VIAJE || operador.estado === EstadoOperador.EN_RUTA) { throw new Error('No se puede dar de baja un operador en viaje'); } // Si tiene unidad asignada, desasignar if (operador.unidadAsignadaId) { const asignacion = await this.asignacionRepository.findOne({ where: { tenantId, operadorId: id, activa: true }, }); if (asignacion) { asignacion.activa = false; asignacion.fechaFin = new Date(); await this.asignacionRepository.save(asignacion); } operador.unidadAsignadaId = undefined as unknown as string; } operador.estado = EstadoOperador.BAJA; operador.activo = false; operador.fechaBaja = new Date(); operador.motivoBaja = motivoBaja; operador.updatedById = updatedById; return this.operadorRepository.save(operador); } /** * Actualizar metricas del operador despues de un viaje */ async updateMetricsAfterTrip( tenantId: string, operadorId: string, tripData: { kmRecorridos: number; calificacion?: number; incidente?: boolean; } ): Promise { const operador = await this.findByIdOrFail(tenantId, operadorId); operador.totalViajes = (operador.totalViajes || 0) + 1; operador.totalKm = (operador.totalKm || 0) + tripData.kmRecorridos; if (tripData.incidente) { operador.incidentes = (operador.incidentes || 0) + 1; } // Actualizar calificacion promedio if (tripData.calificacion !== undefined) { const currentRating = Number(operador.calificacion) || 5.0; const totalTrips = operador.totalViajes; // Promedio ponderado operador.calificacion = Number( ((currentRating * (totalTrips - 1) + tripData.calificacion) / totalTrips).toFixed(2) ); } return this.operadorRepository.save(operador); } }