erp-transportistas: - Add OrdenesTransporteService (960 lines, 18 methods) - Full OT lifecycle management - Cost calculation with Haversine distance - OTIF rate and statistics - Add ViajesService (1200 lines, 28 methods) - Complete trip lifecycle (BORRADOR to COBRADO) - Multi-stop management with POD support - ETA calculation and availability checking - Add UnidadService (725 lines, 17 methods) - Vehicle unit management - GPS tracking and maintenance status - Driver assignment management - Add OperadorService (843 lines, 17 methods) - Driver profile management - HOS (Hours of Service) tracking - Performance metrics and certifications - Add DocumentoFlota and Asignacion entities - Backward compatibility aliases for existing controllers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
843 lines
25 KiB
TypeScript
843 lines
25 KiB
TypeScript
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<CreateOperadorDto> {
|
|
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<Operador>,
|
|
private readonly documentoRepository: Repository<DocumentoFlota>,
|
|
private readonly asignacionRepository: Repository<Asignacion>
|
|
) {}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// CRUD OPERATIONS
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Crear perfil de operador/conductor
|
|
*/
|
|
async create(tenantId: string, dto: CreateOperadorDto, createdById: string): Promise<Operador> {
|
|
// 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<Operador | null> {
|
|
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<Operador> {
|
|
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<Operador[]> {
|
|
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<Operador[]> {
|
|
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<Operador> {
|
|
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<Operador> {
|
|
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<DocumentoFlota> {
|
|
// 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<DocumentoFlota> {
|
|
// 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<DocumentoFlota[]> {
|
|
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<DocumentoExpirando[]> {
|
|
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<Operador[]> {
|
|
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<HorasServicio> {
|
|
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<PerformanceMetrics> {
|
|
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<OperadorStatistics> {
|
|
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<Operador> {
|
|
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<Operador> {
|
|
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);
|
|
}
|
|
}
|