diff --git a/src/modules/gestion-flota/entities/asignacion.entity.ts b/src/modules/gestion-flota/entities/asignacion.entity.ts new file mode 100644 index 0000000..a1a874f --- /dev/null +++ b/src/modules/gestion-flota/entities/asignacion.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Unidad } from './unidad.entity'; +import { Operador } from './operador.entity'; + +@Entity({ schema: 'fleet', name: 'asignaciones' }) +@Index('idx_asignacion_unidad', ['unidadId', 'activa']) +@Index('idx_asignacion_operador', ['operadorId', 'activa']) +export class Asignacion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'unidad_id', type: 'uuid' }) + unidadId: string; + + @ManyToOne(() => Unidad) + @JoinColumn({ name: 'unidad_id' }) + unidad: Unidad; + + @Column({ name: 'operador_id', type: 'uuid' }) + operadorId: string; + + @ManyToOne(() => Operador) + @JoinColumn({ name: 'operador_id' }) + operador: Operador; + + @Column({ name: 'remolque_id', type: 'uuid', nullable: true }) + remolqueId: string; + + // Vigencia de asignación + @Column({ name: 'fecha_inicio', type: 'timestamptz' }) + fechaInicio: Date; + + @Column({ name: 'fecha_fin', type: 'timestamptz', nullable: true }) + fechaFin: Date; + + // Activa + @Column({ type: 'boolean', default: true }) + activa: boolean; + + // Motivo + @Column({ type: 'varchar', length: 200, nullable: true }) + motivo: string; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by_id', type: 'uuid' }) + createdById: string; +} diff --git a/src/modules/gestion-flota/entities/documento-flota.entity.ts b/src/modules/gestion-flota/entities/documento-flota.entity.ts new file mode 100644 index 0000000..f03f5a7 --- /dev/null +++ b/src/modules/gestion-flota/entities/documento-flota.entity.ts @@ -0,0 +1,113 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +/** + * Tipo de Documento + */ +export enum TipoDocumento { + LICENCIA = 'LICENCIA', + INE = 'INE', + CURP = 'CURP', + RFC = 'RFC', + NSS = 'NSS', + TARJETA_CIRCULACION = 'TARJETA_CIRCULACION', + POLIZA_SEGURO = 'POLIZA_SEGURO', + VERIFICACION = 'VERIFICACION', + PERMISO_SCT = 'PERMISO_SCT', + CERTIFICADO_FISICO = 'CERTIFICADO_FISICO', + ANTIDOPING = 'ANTIDOPING', + OTRO = 'OTRO', +} + +/** + * Tipo de Entidad + */ +export enum TipoEntidadDocumento { + UNIDAD = 'UNIDAD', + REMOLQUE = 'REMOLQUE', + OPERADOR = 'OPERADOR', +} + +@Entity({ schema: 'fleet', name: 'documentos_flota' }) +@Index('idx_documento_entidad', ['entidadTipo', 'entidadId']) +@Index('idx_documento_tipo', ['tenantId', 'tipoDocumento']) +export class DocumentoFlota { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Referencia polimórfica + @Column({ name: 'entidad_tipo', type: 'varchar', length: 20 }) + entidadTipo: TipoEntidadDocumento; + + @Column({ name: 'entidad_id', type: 'uuid' }) + entidadId: string; + + // Documento + @Column({ name: 'tipo_documento', type: 'enum', enum: TipoDocumento }) + tipoDocumento: TipoDocumento; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ name: 'numero_documento', type: 'varchar', length: 100, nullable: true }) + numeroDocumento: string; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + // Vigencia + @Column({ name: 'fecha_emision', type: 'date', nullable: true }) + fechaEmision: Date; + + @Column({ name: 'fecha_vencimiento', type: 'date', nullable: true }) + fechaVencimiento: Date; + + @Column({ name: 'dias_alerta_vencimiento', type: 'int', default: 30 }) + diasAlertaVencimiento: number; + + // Archivo + @Column({ name: 'archivo_url', type: 'text', nullable: true }) + archivoUrl: string; + + @Column({ name: 'archivo_nombre', type: 'varchar', length: 255, nullable: true }) + archivoNombre: string; + + @Column({ name: 'archivo_tipo', type: 'varchar', length: 50, nullable: true }) + archivoTipo: string; + + @Column({ name: 'archivo_tamano_bytes', type: 'bigint', nullable: true }) + archivoTamanoBytes: number; + + // Estado + @Column({ type: 'boolean', default: false }) + verificado: boolean; + + @Column({ name: 'verificado_por', type: 'uuid', nullable: true }) + verificadoPor: string; + + @Column({ name: 'verificado_fecha', type: 'timestamptz', nullable: true }) + verificadoFecha: Date; + + // Activo + @Column({ type: 'boolean', default: true }) + activo: boolean; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by_id', type: 'uuid' }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/gestion-flota/entities/index.ts b/src/modules/gestion-flota/entities/index.ts index 89c800b..6fdc9f9 100644 --- a/src/modules/gestion-flota/entities/index.ts +++ b/src/modules/gestion-flota/entities/index.ts @@ -6,6 +6,8 @@ // Entities de Flota export * from './unidad.entity'; export * from './operador.entity'; +export * from './documento-flota.entity'; +export * from './asignacion.entity'; // Entities heredadas de products (para refacciones) export { ProductCategory } from './product-category.entity'; diff --git a/src/modules/gestion-flota/services/index.ts b/src/modules/gestion-flota/services/index.ts index 1ea219c..3e6e08c 100644 --- a/src/modules/gestion-flota/services/index.ts +++ b/src/modules/gestion-flota/services/index.ts @@ -2,5 +2,11 @@ * Gestion Flota Services */ export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service'; -export * from './unidades.service'; -export * from './operadores.service'; + +// Legacy services - export only the service class (DTOs replaced by enhanced services) +export { UnidadesService, UnidadSearchParams } from './unidades.service'; +export { OperadoresService, OperadorSearchParams } from './operadores.service'; + +// Enhanced services (full implementation with DTOs) +export * from './unidad.service'; +export * from './operador.service'; diff --git a/src/modules/gestion-flota/services/operador.service.ts b/src/modules/gestion-flota/services/operador.service.ts new file mode 100644 index 0000000..5813f1c --- /dev/null +++ b/src/modules/gestion-flota/services/operador.service.ts @@ -0,0 +1,842 @@ +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); + } +} diff --git a/src/modules/gestion-flota/services/unidad.service.ts b/src/modules/gestion-flota/services/unidad.service.ts new file mode 100644 index 0000000..ba17746 --- /dev/null +++ b/src/modules/gestion-flota/services/unidad.service.ts @@ -0,0 +1,725 @@ +import { Repository, FindOptionsWhere, ILike, In, MoreThan, LessThanOrEqual, Between } from 'typeorm'; +import { Unidad, TipoUnidad, EstadoUnidad } from '../entities/unidad.entity'; +import { Asignacion } from '../entities/asignacion.entity'; +import { Operador, EstadoOperador } from '../entities/operador.entity'; + +// ============================================================================ +// INTERFACES +// ============================================================================ + +export interface UnidadFilters { + search?: string; + tipo?: TipoUnidad; + estado?: EstadoUnidad; + estados?: EstadoUnidad[]; + sucursalId?: string; + esPropia?: boolean; + tieneGps?: boolean; + activo?: boolean; + limit?: number; + offset?: number; +} + +export interface CreateUnidadDto { + numeroEconomico: string; + tipo: TipoUnidad; + marca?: string; + modelo?: string; + anio?: number; + color?: string; + numeroSerie?: string; + numeroMotor?: string; + placa?: string; + placaEstado?: string; + permisoSct?: string; + tipoPermisoSct?: string; + configuracionVehicular?: string; + capacidadPesoKg?: number; + capacidadVolumenM3?: number; + capacidadPallets?: number; + tipoCombustible?: string; + rendimientoKmLitro?: number; + capacidadTanqueLitros?: number; + odometroActual?: number; + tieneGps?: boolean; + gpsProveedor?: string; + gpsImei?: string; + esPropia?: boolean; + propietarioId?: string; + costoAdquisicion?: number; + fechaAdquisicion?: Date; + valorActual?: number; + fechaVerificacionProxima?: Date; + fechaPolizaVencimiento?: Date; + fechaPermisoVencimiento?: Date; + sucursalId?: string; +} + +export interface UpdateUnidadDto extends Partial { + estado?: EstadoUnidad; +} + +export interface AsignarOperadorDto { + operadorId: string; + remolqueId?: string; + motivo?: string; +} + +export interface UnidadUbicacion { + lat: number; + lng: number; + timestamp: Date; +} + +export interface FleetStatistics { + total: number; + disponibles: number; + enViaje: number; + enTaller: number; + bloqueadas: number; + porTipo: Record; + propias: number; + terceros: number; + conGps: number; + sinGps: number; + documentosVencidos: number; + mantenimientoPendiente: number; +} + +export interface MaintenanceStatus { + unidadId: string; + numeroEconomico: string; + odometroActual: number; + odometroUltimoServicio: number; + kmDesdeUltimoServicio: number; + fechaVerificacionProxima?: Date; + diasParaVerificacion?: number; + fechaPolizaVencimiento?: Date; + diasParaVencimientoPoliza?: number; + fechaPermisoVencimiento?: Date; + diasParaVencimientoPermiso?: number; + requiereAtencion: boolean; + alertas: string[]; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +export class UnidadService { + constructor( + private readonly unidadRepository: Repository, + private readonly asignacionRepository: Repository, + private readonly operadorRepository: Repository + ) {} + + // -------------------------------------------------------------------------- + // CRUD OPERATIONS + // -------------------------------------------------------------------------- + + /** + * Crear nueva unidad (tractora, remolque, etc.) + */ + async create(tenantId: string, dto: CreateUnidadDto, createdById: string): Promise { + // Validar que no exista numero economico duplicado + const existingNumero = await this.unidadRepository.findOne({ + where: { tenantId, numeroEconomico: dto.numeroEconomico }, + }); + if (existingNumero) { + throw new Error(`Ya existe una unidad con el numero economico ${dto.numeroEconomico}`); + } + + // Validar que no exista placa duplicada + if (dto.placa) { + const existingPlaca = await this.unidadRepository.findOne({ + where: { tenantId, placa: dto.placa }, + }); + if (existingPlaca) { + throw new Error(`Ya existe una unidad con la placa ${dto.placa}`); + } + } + + const unidad = this.unidadRepository.create({ + ...dto, + tenantId, + estado: EstadoUnidad.DISPONIBLE, + activo: true, + createdById, + }); + + return this.unidadRepository.save(unidad); + } + + /** + * Buscar unidad por ID + */ + async findById(tenantId: string, id: string): Promise { + return this.unidadRepository.findOne({ + where: { tenantId, id, activo: true }, + }); + } + + /** + * Buscar unidad por ID o lanzar error + */ + async findByIdOrFail(tenantId: string, id: string): Promise { + const unidad = await this.findById(tenantId, id); + if (!unidad) { + throw new Error(`Unidad con ID ${id} no encontrada`); + } + return unidad; + } + + /** + * Listar unidades con filtros + */ + async findAll( + tenantId: string, + filters: UnidadFilters = {} + ): Promise<{ data: Unidad[]; total: number }> { + const { + search, + tipo, + estado, + estados, + sucursalId, + esPropia, + tieneGps, + activo = true, + limit = 50, + offset = 0, + } = filters; + + const qb = this.unidadRepository + .createQueryBuilder('u') + .where('u.tenant_id = :tenantId', { tenantId }); + + if (activo !== undefined) { + qb.andWhere('u.activo = :activo', { activo }); + } + + if (tipo) { + qb.andWhere('u.tipo = :tipo', { tipo }); + } + + if (estado) { + qb.andWhere('u.estado = :estado', { estado }); + } + + if (estados && estados.length > 0) { + qb.andWhere('u.estado IN (:...estados)', { estados }); + } + + if (sucursalId) { + qb.andWhere('u.sucursal_id = :sucursalId', { sucursalId }); + } + + if (esPropia !== undefined) { + qb.andWhere('u.es_propia = :esPropia', { esPropia }); + } + + if (tieneGps !== undefined) { + qb.andWhere('u.tiene_gps = :tieneGps', { tieneGps }); + } + + if (search) { + qb.andWhere( + '(u.numero_economico ILIKE :search OR u.placa ILIKE :search OR u.marca ILIKE :search OR u.modelo ILIKE :search)', + { search: `%${search}%` } + ); + } + + const total = await qb.getCount(); + + qb.orderBy('u.numero_economico', 'ASC') + .offset(offset) + .limit(limit); + + const data = await qb.getMany(); + + return { data, total }; + } + + /** + * Obtener unidades disponibles + */ + async findAvailable(tenantId: string, tipo?: TipoUnidad): Promise { + const where: FindOptionsWhere = { + tenantId, + estado: EstadoUnidad.DISPONIBLE, + activo: true, + }; + + if (tipo) { + where.tipo = tipo; + } + + return this.unidadRepository.find({ + where, + order: { numeroEconomico: 'ASC' }, + }); + } + + /** + * Filtrar unidades por tipo + */ + async findByType(tenantId: string, tipo: TipoUnidad): Promise { + return this.unidadRepository.find({ + where: { tenantId, tipo, activo: true }, + order: { numeroEconomico: 'ASC' }, + }); + } + + /** + * Actualizar unidad + */ + async update( + tenantId: string, + id: string, + dto: UpdateUnidadDto, + updatedById: string + ): Promise { + const unidad = await this.findByIdOrFail(tenantId, id); + + // Validar numero economico duplicado si cambia + if (dto.numeroEconomico && dto.numeroEconomico !== unidad.numeroEconomico) { + const existing = await this.unidadRepository.findOne({ + where: { tenantId, numeroEconomico: dto.numeroEconomico }, + }); + if (existing) { + throw new Error(`Ya existe una unidad con el numero economico ${dto.numeroEconomico}`); + } + } + + // Validar placa duplicada si cambia + if (dto.placa && dto.placa !== unidad.placa) { + const existing = await this.unidadRepository.findOne({ + where: { tenantId, placa: dto.placa }, + }); + if (existing && existing.id !== id) { + throw new Error(`Ya existe una unidad con la placa ${dto.placa}`); + } + } + + Object.assign(unidad, { + ...dto, + updatedById, + }); + + return this.unidadRepository.save(unidad); + } + + /** + * Actualizar estado de la unidad + */ + async updateStatus( + tenantId: string, + id: string, + estado: EstadoUnidad, + updatedById: string + ): Promise { + const unidad = await this.findByIdOrFail(tenantId, id); + + // Validaciones de transicion de estado + if (unidad.estado === EstadoUnidad.BAJA) { + throw new Error('No se puede cambiar el estado de una unidad dada de baja'); + } + + if (estado === EstadoUnidad.EN_VIAJE && unidad.estado === EstadoUnidad.EN_TALLER) { + throw new Error('No se puede asignar a viaje una unidad en taller'); + } + + unidad.estado = estado; + unidad.updatedById = updatedById; + + return this.unidadRepository.save(unidad); + } + + // -------------------------------------------------------------------------- + // ASSIGNMENT OPERATIONS + // -------------------------------------------------------------------------- + + /** + * Asignar operador a unidad + */ + async assignOperador( + tenantId: string, + unidadId: string, + dto: AsignarOperadorDto, + createdById: string + ): Promise { + const unidad = await this.findByIdOrFail(tenantId, unidadId); + + if (unidad.estado !== EstadoUnidad.DISPONIBLE) { + throw new Error('Solo se puede asignar operador a unidades disponibles'); + } + + // Verificar que el operador existe y esta disponible + const operador = await this.operadorRepository.findOne({ + where: { tenantId, id: dto.operadorId, activo: true }, + }); + if (!operador) { + throw new Error(`Operador con ID ${dto.operadorId} no encontrado`); + } + + if (operador.estado !== EstadoOperador.DISPONIBLE && operador.estado !== EstadoOperador.ACTIVO) { + throw new Error('El operador no esta disponible para asignacion'); + } + + // Verificar que no tenga asignacion activa + const asignacionExistente = await this.asignacionRepository.findOne({ + where: { tenantId, unidadId, activa: true }, + }); + if (asignacionExistente) { + throw new Error('La unidad ya tiene un operador asignado. Desasigne primero.'); + } + + // Crear asignacion + const asignacion = this.asignacionRepository.create({ + tenantId, + unidadId, + operadorId: dto.operadorId, + remolqueId: dto.remolqueId, + fechaInicio: new Date(), + activa: true, + motivo: dto.motivo, + createdById, + }); + + await this.asignacionRepository.save(asignacion); + + // Actualizar unidad asignada en operador + operador.unidadAsignadaId = unidadId; + await this.operadorRepository.save(operador); + + return asignacion; + } + + /** + * Desasignar operador de unidad + */ + async unassignOperador( + tenantId: string, + unidadId: string, + updatedById: string + ): Promise { + const unidad = await this.findByIdOrFail(tenantId, unidadId); + + if (unidad.estado === EstadoUnidad.EN_VIAJE || unidad.estado === EstadoUnidad.EN_RUTA) { + throw new Error('No se puede desasignar operador de una unidad en viaje'); + } + + const asignacion = await this.asignacionRepository.findOne({ + where: { tenantId, unidadId, activa: true }, + }); + + if (!asignacion) { + throw new Error('La unidad no tiene operador asignado'); + } + + // Finalizar asignacion + asignacion.activa = false; + asignacion.fechaFin = new Date(); + await this.asignacionRepository.save(asignacion); + + // Remover unidad asignada del operador + const operador = await this.operadorRepository.findOne({ + where: { tenantId, id: asignacion.operadorId }, + }); + if (operador) { + operador.unidadAsignadaId = undefined as unknown as string; + await this.operadorRepository.save(operador); + } + } + + /** + * Obtener asignacion activa de una unidad + */ + async getActiveAssignment(tenantId: string, unidadId: string): Promise { + return this.asignacionRepository.findOne({ + where: { tenantId, unidadId, activa: true }, + relations: ['operador'], + }); + } + + // -------------------------------------------------------------------------- + // MAINTENANCE & LOCATION + // -------------------------------------------------------------------------- + + /** + * Obtener estado de mantenimiento de una unidad + */ + async getMaintenanceStatus(tenantId: string, id: string): Promise { + const unidad = await this.findByIdOrFail(tenantId, id); + const hoy = new Date(); + const alertas: string[] = []; + + const kmDesdeUltimoServicio = unidad.odometroActual - (unidad.odometroUltimoServicio || 0); + + // Calcular dias para vencimientos + let diasParaVerificacion: number | undefined; + let diasParaVencimientoPoliza: number | undefined; + let diasParaVencimientoPermiso: number | undefined; + + if (unidad.fechaVerificacionProxima) { + diasParaVerificacion = Math.ceil( + (unidad.fechaVerificacionProxima.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24) + ); + if (diasParaVerificacion <= 0) { + alertas.push('Verificacion vencida'); + } else if (diasParaVerificacion <= 30) { + alertas.push(`Verificacion vence en ${diasParaVerificacion} dias`); + } + } + + if (unidad.fechaPolizaVencimiento) { + diasParaVencimientoPoliza = Math.ceil( + (unidad.fechaPolizaVencimiento.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24) + ); + if (diasParaVencimientoPoliza <= 0) { + alertas.push('Poliza de seguro vencida'); + } else if (diasParaVencimientoPoliza <= 30) { + alertas.push(`Poliza de seguro vence en ${diasParaVencimientoPoliza} dias`); + } + } + + if (unidad.fechaPermisoVencimiento) { + diasParaVencimientoPermiso = Math.ceil( + (unidad.fechaPermisoVencimiento.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24) + ); + if (diasParaVencimientoPermiso <= 0) { + alertas.push('Permiso SCT vencido'); + } else if (diasParaVencimientoPermiso <= 30) { + alertas.push(`Permiso SCT vence en ${diasParaVencimientoPermiso} dias`); + } + } + + // Alerta por kilometraje + const KM_SERVICIO_RECOMENDADO = 15000; + if (kmDesdeUltimoServicio >= KM_SERVICIO_RECOMENDADO) { + alertas.push(`Mantenimiento requerido (${kmDesdeUltimoServicio.toLocaleString()} km desde ultimo servicio)`); + } + + return { + unidadId: unidad.id, + numeroEconomico: unidad.numeroEconomico, + odometroActual: unidad.odometroActual, + odometroUltimoServicio: unidad.odometroUltimoServicio || 0, + kmDesdeUltimoServicio, + fechaVerificacionProxima: unidad.fechaVerificacionProxima, + diasParaVerificacion, + fechaPolizaVencimiento: unidad.fechaPolizaVencimiento, + diasParaVencimientoPoliza, + fechaPermisoVencimiento: unidad.fechaPermisoVencimiento, + diasParaVencimientoPermiso, + requiereAtencion: alertas.length > 0, + alertas, + }; + } + + /** + * Obtener ubicacion actual de la unidad (GPS) + */ + async trackLocation(tenantId: string, id: string): Promise { + const unidad = await this.findByIdOrFail(tenantId, id); + + if (!unidad.tieneGps) { + throw new Error('La unidad no tiene GPS habilitado'); + } + + if (!unidad.ubicacionActualLat || !unidad.ubicacionActualLng) { + return null; + } + + return { + lat: Number(unidad.ubicacionActualLat), + lng: Number(unidad.ubicacionActualLng), + timestamp: unidad.ultimaActualizacionUbicacion || new Date(), + }; + } + + /** + * Actualizar ubicacion de la unidad + */ + async updateLocation( + tenantId: string, + id: string, + ubicacion: UnidadUbicacion + ): Promise { + const unidad = await this.findByIdOrFail(tenantId, id); + + unidad.ubicacionActualLat = ubicacion.lat; + unidad.ubicacionActualLng = ubicacion.lng; + unidad.ultimaActualizacionUbicacion = ubicacion.timestamp || new Date(); + + return this.unidadRepository.save(unidad); + } + + /** + * Actualizar odometro de la unidad + */ + async updateOdometro( + tenantId: string, + id: string, + odometro: number, + updatedById: string + ): Promise { + const unidad = await this.findByIdOrFail(tenantId, id); + + if (odometro < unidad.odometroActual) { + throw new Error('El odometro no puede ser menor al valor actual'); + } + + unidad.odometroActual = odometro; + unidad.updatedById = updatedById; + + return this.unidadRepository.save(unidad); + } + + // -------------------------------------------------------------------------- + // STATISTICS + // -------------------------------------------------------------------------- + + /** + * Obtener estadisticas de la flota + */ + async getStatistics(tenantId: string): Promise { + const hoy = new Date(); + + // Conteo total + const total = await this.unidadRepository.count({ + where: { tenantId, activo: true }, + }); + + // Conteo por estado + const disponibles = await this.unidadRepository.count({ + where: { tenantId, estado: EstadoUnidad.DISPONIBLE, activo: true }, + }); + + const enViaje = await this.unidadRepository.count({ + where: { tenantId, estado: In([EstadoUnidad.EN_VIAJE, EstadoUnidad.EN_RUTA]), activo: true }, + }); + + const enTaller = await this.unidadRepository.count({ + where: { tenantId, estado: EstadoUnidad.EN_TALLER, activo: true }, + }); + + const bloqueadas = await this.unidadRepository.count({ + where: { tenantId, estado: EstadoUnidad.BLOQUEADA, activo: true }, + }); + + // Conteo por tipo + const porTipo: Record = {} as Record; + for (const tipo of Object.values(TipoUnidad)) { + porTipo[tipo] = await this.unidadRepository.count({ + where: { tenantId, tipo, activo: true }, + }); + } + + // Propias vs terceros + const propias = await this.unidadRepository.count({ + where: { tenantId, esPropia: true, activo: true }, + }); + + const terceros = total - propias; + + // GPS + const conGps = await this.unidadRepository.count({ + where: { tenantId, tieneGps: true, activo: true }, + }); + + const sinGps = total - conGps; + + // Documentos vencidos + const documentosVencidos = await this.unidadRepository + .createQueryBuilder('u') + .where('u.tenant_id = :tenantId', { tenantId }) + .andWhere('u.activo = true') + .andWhere( + '(u.fecha_verificacion_proxima < :hoy OR u.fecha_poliza_vencimiento < :hoy OR u.fecha_permiso_vencimiento < :hoy)', + { hoy } + ) + .getCount(); + + // Mantenimiento pendiente (mas de 15000 km desde ultimo servicio) + const KM_SERVICIO = 15000; + const mantenimientoPendiente = await this.unidadRepository + .createQueryBuilder('u') + .where('u.tenant_id = :tenantId', { tenantId }) + .andWhere('u.activo = true') + .andWhere('(u.odometro_actual - COALESCE(u.odometro_ultimo_servicio, 0)) >= :km', { km: KM_SERVICIO }) + .getCount(); + + return { + total, + disponibles, + enViaje, + enTaller, + bloqueadas, + porTipo, + propias, + terceros, + conGps, + sinGps, + documentosVencidos, + mantenimientoPendiente, + }; + } + + /** + * Obtener unidades con documentos por vencer + */ + async getUnidadesDocumentosPorVencer( + tenantId: string, + dias: number = 30 + ): Promise { + const hoy = new Date(); + const fechaLimite = new Date(); + fechaLimite.setDate(fechaLimite.getDate() + dias); + + return this.unidadRepository + .createQueryBuilder('u') + .where('u.tenant_id = :tenantId', { tenantId }) + .andWhere('u.activo = true') + .andWhere( + '((u.fecha_verificacion_proxima BETWEEN :hoy AND :fechaLimite) OR ' + + '(u.fecha_poliza_vencimiento BETWEEN :hoy AND :fechaLimite) OR ' + + '(u.fecha_permiso_vencimiento BETWEEN :hoy AND :fechaLimite))', + { hoy, fechaLimite } + ) + .orderBy('u.numero_economico', 'ASC') + .getMany(); + } + + /** + * Dar de baja unidad + */ + async darDeBaja( + tenantId: string, + id: string, + motivoBaja: string, + updatedById: string + ): Promise { + const unidad = await this.findByIdOrFail(tenantId, id); + + if (unidad.estado === EstadoUnidad.EN_VIAJE || unidad.estado === EstadoUnidad.EN_RUTA) { + throw new Error('No se puede dar de baja una unidad en viaje'); + } + + // Desasignar operador si tiene + const asignacion = await this.asignacionRepository.findOne({ + where: { tenantId, unidadId: id, activa: true }, + }); + if (asignacion) { + await this.unassignOperador(tenantId, id, updatedById); + } + + unidad.estado = EstadoUnidad.BAJA; + unidad.activo = false; + unidad.fechaBaja = new Date(); + unidad.motivoBaja = motivoBaja; + unidad.updatedById = updatedById; + + return this.unidadRepository.save(unidad); + } +} diff --git a/src/modules/ordenes-transporte/services/ordenes-transporte.service.ts b/src/modules/ordenes-transporte/services/ordenes-transporte.service.ts index 7df11fd..0bc92f8 100644 --- a/src/modules/ordenes-transporte/services/ordenes-transporte.service.ts +++ b/src/modules/ordenes-transporte/services/ordenes-transporte.service.ts @@ -1,5 +1,15 @@ -import { Repository, FindOptionsWhere, ILike, In, Between } from 'typeorm'; -import { OrdenTransporte, EstadoOrdenTransporte, ModalidadServicio, TipoEquipo } from '../entities'; +import { Repository, FindOptionsWhere, ILike, In, Between, MoreThanOrEqual, LessThanOrEqual, IsNull } from 'typeorm'; +import { + OrdenTransporte, + EstadoOrdenTransporte, + ModalidadServicio, + TipoEquipo, + TipoCarga, +} from '../entities'; + +// ============================================================================= +// INTERFACES Y TIPOS +// ============================================================================= export interface OrdenTransporteSearchParams { tenantId: string; @@ -7,20 +17,36 @@ export interface OrdenTransporteSearchParams { estado?: EstadoOrdenTransporte; estados?: EstadoOrdenTransporte[]; clienteId?: string; + shipperId?: string; + consigneeId?: string; modalidad?: ModalidadServicio; + tipoCarga?: TipoCarga; fechaDesde?: Date; fechaHasta?: Date; + viajeId?: string; + sinAsignar?: boolean; limit?: number; offset?: number; + orderBy?: 'createdAt' | 'fechaRecoleccion' | 'fechaEntrega' | 'total'; + orderDir?: 'ASC' | 'DESC'; } export interface CreateOrdenTransporteDto { clienteId: string; referenciaCliente?: string; - modalidadServicio: ModalidadServicio; - tipoEquipo: TipoEquipo; + modalidadServicio?: ModalidadServicio; + tipoEquipo?: TipoEquipo; + tipoCarga?: TipoCarga; prioridad?: 'NORMAL' | 'URGENTE' | 'CRITICA'; + // Shipper + shipperId: string; + shipperNombre: string; + + // Consignee + consigneeId: string; + consigneeNombre: string; + // Origen origenDireccion: string; origenCiudad?: string; @@ -28,11 +54,9 @@ export interface CreateOrdenTransporteDto { origenCodigoPostal?: string; origenLatitud?: number; origenLongitud?: number; - origenContactoNombre?: string; - origenContactoTelefono?: string; - fechaRecoleccion?: Date; - horaRecoleccionDesde?: string; - horaRecoleccionHasta?: string; + origenContacto?: string; + origenTelefono?: string; + fechaRecoleccionProgramada?: Date; // Destino destinoDireccion: string; @@ -41,80 +65,279 @@ export interface CreateOrdenTransporteDto { destinoCodigoPostal?: string; destinoLatitud?: number; destinoLongitud?: number; - destinoContactoNombre?: string; - destinoContactoTelefono?: string; - fechaEntrega?: Date; - horaEntregaDesde?: string; - horaEntregaHasta?: string; + destinoContacto?: string; + destinoTelefono?: string; + fechaEntregaProgramada?: Date; // Carga descripcionCarga?: string; - peso?: number; - volumen?: number; + pesoKg?: number; + volumenM3?: number; piezas?: number; pallets?: number; - requiereCustodia?: boolean; - requiereRefrigeracion?: boolean; - temperaturaMinima?: number; - temperaturaMaxima?: number; - esMaterialPeligroso?: boolean; + valorDeclarado?: number; + + // Requisitos + requiereTemperatura?: boolean; + temperaturaMin?: number; + temperaturaMax?: number; + requiereGps?: boolean; + requiereEscolta?: boolean; + instruccionesEspeciales?: string; // Tarifa tarifaId?: string; - montoFlete?: number; + tarifaBase?: number; + recargos?: number; + descuentos?: number; observaciones?: string; - instruccionesEspeciales?: string; } export interface UpdateOrdenTransporteDto extends Partial { estado?: EstadoOrdenTransporte; } +export interface OrdenTransporteTimeline { + id: string; + ordenId: string; + estado: EstadoOrdenTransporte; + estadoAnterior?: EstadoOrdenTransporte; + fecha: Date; + usuario: string; + comentario?: string; +} + +export interface CostosEstimados { + ordenId: string; + tarifaBase: number; + combustible: number; + peajes: number; + viaticos: number; + recargos: number; + descuentos: number; + subtotal: number; + iva: number; + total: number; + distanciaEstimadaKm?: number; + tiempoEstimadoHoras?: number; +} + +export interface OrdenTransporteStats { + total: number; + porEstado: Record; + porModalidad: Record; + totalIngresos: number; + promedioIngreso: number; + otifRate: number; // On-Time In-Full rate + tiempoPromedioEntregaHoras: number; +} + +export interface DateRange { + from: Date; + to: Date; +} + +// ============================================================================= +// SERVICIO ORDENES DE TRANSPORTE +// ============================================================================= + export class OrdenesTransporteService { constructor(private readonly otRepository: Repository) {} - async findAll(params: OrdenTransporteSearchParams): Promise<{ data: OrdenTransporte[]; total: number }> { - const { + // =========================================================================== + // METODOS CRUD BASICOS + // =========================================================================== + + /** + * Crear nueva Orden de Transporte + */ + async create( + tenantId: string, + dto: CreateOrdenTransporteDto, + createdById: string + ): Promise { + const codigo = await this.generateCodigo(tenantId); + + // Calcular totales si se proporciona tarifa + let subtotal = dto.tarifaBase || 0; + subtotal += dto.recargos || 0; + subtotal -= dto.descuentos || 0; + const iva = subtotal * 0.16; // IVA 16% Mexico + const total = subtotal + iva; + + const orden = this.otRepository.create({ tenantId, + codigo, + numeroOt: codigo, + clienteId: dto.clienteId, + referenciaCliente: dto.referenciaCliente, + modalidadServicio: dto.modalidadServicio || ModalidadServicio.FTL, + tipoCarga: dto.tipoCarga || TipoCarga.GENERAL, + + // Shipper + shipperId: dto.shipperId, + shipperNombre: dto.shipperNombre, + + // Consignee + consigneeId: dto.consigneeId, + consigneeNombre: dto.consigneeNombre, + + // Origen + origenDireccion: dto.origenDireccion, + origenCiudad: dto.origenCiudad, + origenEstado: dto.origenEstado, + origenCodigoPostal: dto.origenCodigoPostal, + origenLatitud: dto.origenLatitud, + origenLongitud: dto.origenLongitud, + origenContacto: dto.origenContacto, + origenTelefono: dto.origenTelefono, + fechaRecoleccionProgramada: dto.fechaRecoleccionProgramada, + + // Destino + destinoDireccion: dto.destinoDireccion, + destinoCiudad: dto.destinoCiudad, + destinoEstado: dto.destinoEstado, + destinoCodigoPostal: dto.destinoCodigoPostal, + destinoLatitud: dto.destinoLatitud, + destinoLongitud: dto.destinoLongitud, + destinoContacto: dto.destinoContacto, + destinoTelefono: dto.destinoTelefono, + fechaEntregaProgramada: dto.fechaEntregaProgramada, + + // Carga + descripcionCarga: dto.descripcionCarga, + pesoKg: dto.pesoKg, + volumenM3: dto.volumenM3, + piezas: dto.piezas, + pallets: dto.pallets, + valorDeclarado: dto.valorDeclarado, + + // Requisitos + requiereTemperatura: dto.requiereTemperatura || false, + temperaturaMin: dto.temperaturaMin, + temperaturaMax: dto.temperaturaMax, + requiereGps: dto.requiereGps || false, + requiereEscolta: dto.requiereEscolta || false, + instruccionesEspeciales: dto.instruccionesEspeciales, + + // Tarifa + tarifaId: dto.tarifaId, + tarifaBase: dto.tarifaBase, + recargos: dto.recargos || 0, + descuentos: dto.descuentos || 0, + subtotal, + iva, + total, + + observaciones: dto.observaciones, + estado: EstadoOrdenTransporte.BORRADOR, + createdById, + }); + + return this.otRepository.save(orden); + } + + /** + * Buscar orden por ID + */ + async findById(tenantId: string, id: string): Promise { + return this.otRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Buscar orden por codigo + */ + async findByCodigo(tenantId: string, codigo: string): Promise { + return this.otRepository.findOne({ + where: { codigo, tenantId }, + }); + } + + /** + * Listar ordenes con filtros y paginacion + * Soporta dos firmas: + * - findAll(tenantId: string, filters: object) - nueva firma + * - findAll(params: { tenantId, ...filters }) - compatibilidad con controller existente + */ + async findAll( + tenantIdOrParams: string | OrdenTransporteSearchParams, + filters: Omit = {} + ): Promise<{ data: OrdenTransporte[]; total: number; page: number; pageSize: number }> { + // Soportar ambas firmas: findAll(tenantId, filters) y findAll({ tenantId, ...filters }) + let tenantId: string; + let actualFilters: Omit; + + if (typeof tenantIdOrParams === 'string') { + tenantId = tenantIdOrParams; + actualFilters = filters; + } else { + tenantId = tenantIdOrParams.tenantId; + const { tenantId: _, ...rest } = tenantIdOrParams; + actualFilters = rest; + } + + const { search, estado, estados, clienteId, + shipperId, + consigneeId, modalidad, + tipoCarga, fechaDesde, fechaHasta, + viajeId, + sinAsignar, limit = 50, offset = 0, - } = params; + orderBy = 'createdAt', + orderDir = 'DESC', + } = actualFilters; const where: FindOptionsWhere[] = []; - const baseWhere: FindOptionsWhere = { tenantId }; + const baseWhere: FindOptionsWhere = { tenantId, deletedAt: IsNull() }; + // Filtros de estado if (estado) { baseWhere.estado = estado; - } - - if (estados && estados.length > 0) { + } else if (estados && estados.length > 0) { baseWhere.estado = In(estados); } - if (clienteId) { - baseWhere.clienteId = clienteId; - } + // Filtros de cliente/shipper/consignee + if (clienteId) baseWhere.clienteId = clienteId; + if (shipperId) baseWhere.shipperId = shipperId; + if (consigneeId) baseWhere.consigneeId = consigneeId; - if (modalidad) { - baseWhere.modalidadServicio = modalidad; - } + // Filtros de servicio + if (modalidad) baseWhere.modalidadServicio = modalidad; + if (tipoCarga) baseWhere.tipoCarga = tipoCarga; + // Filtro de viaje + if (viajeId) baseWhere.viajeId = viajeId; + if (sinAsignar) baseWhere.viajeId = IsNull(); + + // Filtros de fecha if (fechaDesde && fechaHasta) { - baseWhere.fechaRecoleccion = Between(fechaDesde, fechaHasta); + baseWhere.fechaRecoleccionProgramada = Between(fechaDesde, fechaHasta); + } else if (fechaDesde) { + baseWhere.fechaRecoleccionProgramada = MoreThanOrEqual(fechaDesde); + } else if (fechaHasta) { + baseWhere.fechaRecoleccionProgramada = LessThanOrEqual(fechaHasta); } + // Busqueda por texto if (search) { where.push( + { ...baseWhere, codigo: ILike(`%${search}%`) }, { ...baseWhere, numeroOt: ILike(`%${search}%`) }, { ...baseWhere, referenciaCliente: ILike(`%${search}%`) }, + { ...baseWhere, shipperNombre: ILike(`%${search}%`) }, + { ...baseWhere, consigneeNombre: ILike(`%${search}%`) }, { ...baseWhere, origenCiudad: ILike(`%${search}%`) }, { ...baseWhere, destinoCiudad: ILike(`%${search}%`) } ); @@ -122,188 +345,641 @@ export class OrdenesTransporteService { where.push(baseWhere); } + // Mapeo de campo de ordenamiento + const orderField = this.mapOrderField(orderBy); + const [data, total] = await this.otRepository.findAndCount({ where, take: limit, skip: offset, - order: { createdAt: 'DESC' }, + order: { [orderField]: orderDir }, }); - return { data, total }; + return { + data, + total, + page: Math.floor(offset / limit) + 1, + pageSize: limit, + }; } - async findOne(id: string, tenantId: string): Promise { - return this.otRepository.findOne({ - where: { id, tenantId }, - }); - } - - async findByNumeroOt(numeroOt: string, tenantId: string): Promise { - return this.otRepository.findOne({ - where: { numeroOt, tenantId }, - }); - } - - async create(tenantId: string, dto: CreateOrdenTransporteDto, createdBy: string): Promise { - const numeroOt = await this.generateNumeroOt(tenantId); - - const ot = this.otRepository.create({ - ...dto, - tenantId, - numeroOt, - estado: EstadoOrdenTransporte.SOLICITADA, - createdById: createdBy, - }); - - return this.otRepository.save(ot); - } - - async update( - id: string, + /** + * Buscar ordenes por estado + */ + async findByStatus( tenantId: string, - dto: UpdateOrdenTransporteDto, - updatedBy: string - ): Promise { - const ot = await this.findOne(id, tenantId); - if (!ot) return null; - - // Don't allow updates to completed/cancelled orders - if ([EstadoOrdenTransporte.ENTREGADA, EstadoOrdenTransporte.CANCELADA].includes(ot.estado)) { - throw new Error('No se puede modificar una OT entregada o cancelada'); - } - - Object.assign(ot, { - ...dto, - updatedById: updatedBy, - }); - - return this.otRepository.save(ot); - } - - async cambiarEstado( - id: string, - tenantId: string, - nuevoEstado: EstadoOrdenTransporte, - updatedBy: string - ): Promise { - const ot = await this.findOne(id, tenantId); - if (!ot) return null; - - // Validate state transition - if (!this.isValidTransition(ot.estado, nuevoEstado)) { - throw new Error(`Transición de estado inválida de ${ot.estado} a ${nuevoEstado}`); - } - - ot.estado = nuevoEstado; - ot.updatedById = updatedBy; - - return this.otRepository.save(ot); - } - - async confirmar(id: string, tenantId: string, updatedBy: string): Promise { - return this.cambiarEstado(id, tenantId, EstadoOrdenTransporte.CONFIRMADA, updatedBy); - } - - async asignar( - id: string, - tenantId: string, - viajeId: string, - updatedBy: string - ): Promise { - const ot = await this.findOne(id, tenantId); - if (!ot) return null; - - ot.estado = EstadoOrdenTransporte.ASIGNADA; - ot.viajeId = viajeId; - ot.updatedById = updatedBy; - - return this.otRepository.save(ot); - } - - async cancelar( - id: string, - tenantId: string, - motivo: string, - updatedBy: string - ): Promise { - const ot = await this.findOne(id, tenantId); - if (!ot) return null; - - if (ot.estado === EstadoOrdenTransporte.EN_TRANSITO) { - throw new Error('No se puede cancelar una OT en tránsito'); - } - - ot.estado = EstadoOrdenTransporte.CANCELADA; - ot.observaciones = `${ot.observaciones || ''}\nMotivo cancelación: ${motivo}`; - ot.updatedById = updatedBy; - - return this.otRepository.save(ot); - } - - // OTs pendientes de asignación - async getOtsPendientes(tenantId: string): Promise { + status: EstadoOrdenTransporte, + limit: number = 100 + ): Promise { return this.otRepository.find({ - where: { - tenantId, - estado: In([ - EstadoOrdenTransporte.SOLICITADA, - EstadoOrdenTransporte.CONFIRMADA, - ]), - }, - order: { fechaRecoleccion: 'ASC' }, - }); - } - - // OTs por cliente - async getOtsCliente(clienteId: string, tenantId: string, limit: number = 20): Promise { - return this.otRepository.find({ - where: { clienteId, tenantId }, - order: { createdAt: 'DESC' }, + where: { tenantId, estado: status, deletedAt: IsNull() }, + order: { fechaRecoleccionProgramada: 'ASC' }, take: limit, }); } - // OTs para programación (fecha específica) - async getOtsParaProgramacion(tenantId: string, fecha: Date): Promise { - const inicioDia = new Date(fecha); - inicioDia.setHours(0, 0, 0, 0); - const finDia = new Date(fecha); - finDia.setHours(23, 59, 59, 999); + /** + * Actualizar orden de transporte + */ + async update( + tenantId: string, + id: string, + dto: UpdateOrdenTransporteDto, + updatedById: string + ): Promise { + const orden = await this.findById(tenantId, id); + if (!orden) return null; + // No permitir modificaciones en estados finales + const estadosFinales = [ + EstadoOrdenTransporte.ENTREGADA, + EstadoOrdenTransporte.FACTURADA, + EstadoOrdenTransporte.CANCELADA, + ]; + if (estadosFinales.includes(orden.estado)) { + throw new Error(`No se puede modificar una OT en estado ${orden.estado}`); + } + + // Recalcular totales si cambian valores de tarifa + let subtotal = orden.subtotal; + let iva = orden.iva; + let total = orden.total; + + if (dto.tarifaBase !== undefined || dto.recargos !== undefined || dto.descuentos !== undefined) { + subtotal = (dto.tarifaBase ?? orden.tarifaBase) || 0; + subtotal += (dto.recargos ?? orden.recargos) || 0; + subtotal -= (dto.descuentos ?? orden.descuentos) || 0; + iva = subtotal * 0.16; + total = subtotal + iva; + } + + Object.assign(orden, { + ...dto, + subtotal, + iva, + total, + updatedById, + }); + + return this.otRepository.save(orden); + } + + /** + * Cancelar orden de transporte + */ + async cancel( + tenantId: string, + id: string, + reason: string, + cancelledById: string + ): Promise { + const orden = await this.findById(tenantId, id); + if (!orden) return null; + + // No se puede cancelar una OT en transito o ya entregada + const estadosNoCancelables = [ + EstadoOrdenTransporte.EN_TRANSITO, + EstadoOrdenTransporte.COMPLETADA, + EstadoOrdenTransporte.ENTREGADA, + EstadoOrdenTransporte.FACTURADA, + EstadoOrdenTransporte.CANCELADA, + ]; + if (estadosNoCancelables.includes(orden.estado)) { + throw new Error(`No se puede cancelar una OT en estado ${orden.estado}`); + } + + // Si esta asignada a un viaje, desasignar primero + if (orden.viajeId) { + orden.viajeId = undefined as unknown as string; + } + + orden.estado = EstadoOrdenTransporte.CANCELADA; + orden.observaciones = `${orden.observaciones || ''}\n[CANCELADA ${new Date().toISOString()}] Motivo: ${reason}`.trim(); + orden.updatedById = cancelledById; + + return this.otRepository.save(orden); + } + + // =========================================================================== + // ASIGNACION A VIAJES + // =========================================================================== + + /** + * Asignar orden a un viaje + */ + async assignToViaje( + tenantId: string, + orderId: string, + viajeId: string, + assignedById: string + ): Promise { + const orden = await this.findById(tenantId, orderId); + if (!orden) return null; + + // Solo se pueden asignar OTs confirmadas o pendientes + const estadosAsignables = [ + EstadoOrdenTransporte.CONFIRMADA, + EstadoOrdenTransporte.PENDIENTE, + EstadoOrdenTransporte.SOLICITADA, + ]; + if (!estadosAsignables.includes(orden.estado)) { + throw new Error(`No se puede asignar una OT en estado ${orden.estado}`); + } + + // Si ya esta asignada a otro viaje, validar + if (orden.viajeId && orden.viajeId !== viajeId) { + throw new Error(`La OT ya esta asignada al viaje ${orden.viajeId}`); + } + + orden.viajeId = viajeId; + orden.estado = EstadoOrdenTransporte.ASIGNADA; + orden.updatedById = assignedById; + + return this.otRepository.save(orden); + } + + /** + * Desasignar orden de un viaje + */ + async unassignFromViaje( + tenantId: string, + orderId: string, + unassignedById: string + ): Promise { + const orden = await this.findById(tenantId, orderId); + if (!orden) return null; + + // Solo se pueden desasignar OTs que esten asignadas pero no en proceso + if (orden.estado === EstadoOrdenTransporte.EN_PROCESO || + orden.estado === EstadoOrdenTransporte.EN_TRANSITO) { + throw new Error(`No se puede desasignar una OT que ya esta en proceso/transito`); + } + + orden.viajeId = undefined as unknown as string; + orden.estado = EstadoOrdenTransporte.CONFIRMADA; + orden.updatedById = unassignedById; + + return this.otRepository.save(orden); + } + + /** + * Obtener ordenes asignadas a un viaje + */ + async findByViaje(tenantId: string, viajeId: string): Promise { return this.otRepository.find({ - where: { - tenantId, - estado: In([EstadoOrdenTransporte.CONFIRMADA]), - fechaRecoleccion: Between(inicioDia, finDia), - }, - order: { fechaRecoleccion: 'ASC' }, + where: { tenantId, viajeId, deletedAt: IsNull() }, + order: { createdAt: 'ASC' }, }); } - // Helpers - private async generateNumeroOt(tenantId: string): Promise { - const year = new Date().getFullYear(); - const count = await this.otRepository.count({ - where: { tenantId }, + /** + * Obtener ordenes sin asignar (disponibles para programacion) + */ + async findUnassigned(tenantId: string, limit: number = 100): Promise { + return this.otRepository.find({ + where: { + tenantId, + viajeId: IsNull(), + estado: In([ + EstadoOrdenTransporte.CONFIRMADA, + EstadoOrdenTransporte.PENDIENTE, + EstadoOrdenTransporte.SOLICITADA, + ]), + deletedAt: IsNull(), + }, + order: { fechaRecoleccionProgramada: 'ASC' }, + take: limit, }); - return `OT-${year}-${String(count + 1).padStart(6, '0')}`; + } + + // =========================================================================== + // TIMELINE E HISTORIAL + // =========================================================================== + + /** + * Obtener timeline de la orden (historial de estados) + * Nota: Esta es una implementacion simplificada. En produccion se debe + * implementar una tabla de historial separada. + */ + async getTimeline(tenantId: string, id: string): Promise { + const orden = await this.findById(tenantId, id); + if (!orden) return []; + + // Generar timeline basico con los datos disponibles + const timeline: OrdenTransporteTimeline[] = [ + { + id: `${orden.id}-created`, + ordenId: orden.id, + estado: EstadoOrdenTransporte.BORRADOR, + fecha: orden.createdAt, + usuario: orden.createdById, + comentario: 'Orden creada', + }, + ]; + + // Si hay cambio de estado, agregar entrada + if (orden.estado !== EstadoOrdenTransporte.BORRADOR) { + timeline.push({ + id: `${orden.id}-current`, + ordenId: orden.id, + estado: orden.estado, + estadoAnterior: EstadoOrdenTransporte.BORRADOR, + fecha: orden.updatedAt, + usuario: orden.updatedById || orden.createdById, + comentario: `Estado cambiado a ${orden.estado}`, + }); + } + + // Si esta asignada a viaje + if (orden.viajeId) { + timeline.push({ + id: `${orden.id}-assigned`, + ordenId: orden.id, + estado: EstadoOrdenTransporte.ASIGNADA, + fecha: orden.updatedAt, + usuario: orden.updatedById || orden.createdById, + comentario: `Asignada a viaje ${orden.viajeId}`, + }); + } + + return timeline.sort((a, b) => a.fecha.getTime() - b.fecha.getTime()); + } + + // =========================================================================== + // CALCULOS DE COSTOS + // =========================================================================== + + /** + * Calcular costos estimados de la orden + */ + async calculateCosts(tenantId: string, id: string): Promise { + const orden = await this.findById(tenantId, id); + if (!orden) return null; + + // Calcular distancia si hay coordenadas + let distanciaEstimadaKm: number | undefined; + let tiempoEstimadoHoras: number | undefined; + + if (orden.origenLatitud && orden.origenLongitud && + orden.destinoLatitud && orden.destinoLongitud) { + distanciaEstimadaKm = this.calculateDistance( + orden.origenLatitud, + orden.origenLongitud, + orden.destinoLatitud, + orden.destinoLongitud + ); + // Velocidad promedio de 60 km/h considerando paradas + tiempoEstimadoHoras = distanciaEstimadaKm / 60; + } + + // Estimaciones basadas en distancia (si esta disponible) + const tarifaBase = orden.tarifaBase || 0; + + // Costo combustible estimado: 2.5 litros/km * precio diesel (~$22/litro) + const combustible = distanciaEstimadaKm ? distanciaEstimadaKm * 2.5 * 22 / 100 : 0; + + // Peajes estimados: promedio $2 por km en autopistas + const peajes = distanciaEstimadaKm ? distanciaEstimadaKm * 0.5 : 0; + + // Viaticos: $500 por dia estimado + const viaticos = tiempoEstimadoHoras ? Math.ceil(tiempoEstimadoHoras / 10) * 500 : 0; + + const recargos = orden.recargos || 0; + const descuentos = orden.descuentos || 0; + + const subtotal = tarifaBase + combustible + peajes + viaticos + recargos - descuentos; + const iva = subtotal * 0.16; + const total = subtotal + iva; + + return { + ordenId: orden.id, + tarifaBase, + combustible: Math.round(combustible * 100) / 100, + peajes: Math.round(peajes * 100) / 100, + viaticos, + recargos, + descuentos, + subtotal: Math.round(subtotal * 100) / 100, + iva: Math.round(iva * 100) / 100, + total: Math.round(total * 100) / 100, + distanciaEstimadaKm: distanciaEstimadaKm ? Math.round(distanciaEstimadaKm) : undefined, + tiempoEstimadoHoras: tiempoEstimadoHoras ? Math.round(tiempoEstimadoHoras * 10) / 10 : undefined, + }; + } + + // =========================================================================== + // ESTADISTICAS + // =========================================================================== + + /** + * Obtener estadisticas de ordenes de transporte + */ + async getStatistics(tenantId: string, dateRange: DateRange): Promise { + const { from, to } = dateRange; + + // Obtener todas las ordenes del periodo + const ordenes = await this.otRepository.find({ + where: { + tenantId, + createdAt: Between(from, to), + deletedAt: IsNull(), + }, + }); + + // Conteo por estado + const porEstado: Record = { + [EstadoOrdenTransporte.BORRADOR]: 0, + [EstadoOrdenTransporte.PENDIENTE]: 0, + [EstadoOrdenTransporte.SOLICITADA]: 0, + [EstadoOrdenTransporte.CONFIRMADA]: 0, + [EstadoOrdenTransporte.ASIGNADA]: 0, + [EstadoOrdenTransporte.EN_PROCESO]: 0, + [EstadoOrdenTransporte.EN_TRANSITO]: 0, + [EstadoOrdenTransporte.COMPLETADA]: 0, + [EstadoOrdenTransporte.ENTREGADA]: 0, + [EstadoOrdenTransporte.FACTURADA]: 0, + [EstadoOrdenTransporte.CANCELADA]: 0, + }; + + // Conteo por modalidad + const porModalidad: Record = { + [ModalidadServicio.FTL]: 0, + [ModalidadServicio.LTL]: 0, + [ModalidadServicio.DEDICADO]: 0, + [ModalidadServicio.EXPRESS]: 0, + [ModalidadServicio.CONSOLIDADO]: 0, + }; + + let totalIngresos = 0; + let entregadasATiempo = 0; + let totalEntregadas = 0; + let tiempoTotalEntrega = 0; + + for (const orden of ordenes) { + porEstado[orden.estado]++; + porModalidad[orden.modalidadServicio]++; + totalIngresos += Number(orden.total) || 0; + + // Calcular OTIF (On-Time In-Full) para ordenes entregadas + if (orden.estado === EstadoOrdenTransporte.ENTREGADA || + orden.estado === EstadoOrdenTransporte.FACTURADA) { + totalEntregadas++; + + // Verificar si fue a tiempo (entregada antes de fecha programada) + if (orden.fechaEntregaProgramada && orden.updatedAt <= orden.fechaEntregaProgramada) { + entregadasATiempo++; + } + + // Calcular tiempo de entrega + if (orden.fechaRecoleccionProgramada) { + const tiempoEntrega = orden.updatedAt.getTime() - orden.fechaRecoleccionProgramada.getTime(); + tiempoTotalEntrega += tiempoEntrega / (1000 * 60 * 60); // Convertir a horas + } + } + } + + const total = ordenes.length; + const otifRate = totalEntregadas > 0 ? (entregadasATiempo / totalEntregadas) * 100 : 0; + const tiempoPromedioEntregaHoras = totalEntregadas > 0 ? tiempoTotalEntrega / totalEntregadas : 0; + + return { + total, + porEstado, + porModalidad, + totalIngresos: Math.round(totalIngresos * 100) / 100, + promedioIngreso: total > 0 ? Math.round((totalIngresos / total) * 100) / 100 : 0, + otifRate: Math.round(otifRate * 10) / 10, + tiempoPromedioEntregaHoras: Math.round(tiempoPromedioEntregaHoras * 10) / 10, + }; + } + + // =========================================================================== + // CAMBIOS DE ESTADO + // =========================================================================== + + /** + * Confirmar orden (cambiar de BORRADOR/SOLICITADA a CONFIRMADA) + */ + async confirm( + tenantId: string, + id: string, + confirmedById: string + ): Promise { + return this.changeStatus(tenantId, id, EstadoOrdenTransporte.CONFIRMADA, confirmedById); + } + + /** + * Marcar orden en proceso + */ + async startProcess( + tenantId: string, + id: string, + startedById: string + ): Promise { + return this.changeStatus(tenantId, id, EstadoOrdenTransporte.EN_PROCESO, startedById); + } + + /** + * Marcar orden en transito + */ + async startTransit( + tenantId: string, + id: string, + startedById: string + ): Promise { + return this.changeStatus(tenantId, id, EstadoOrdenTransporte.EN_TRANSITO, startedById); + } + + /** + * Completar orden (carga entregada) + */ + async complete( + tenantId: string, + id: string, + completedById: string + ): Promise { + return this.changeStatus(tenantId, id, EstadoOrdenTransporte.COMPLETADA, completedById); + } + + /** + * Marcar como entregada (con POD) + */ + async deliver( + tenantId: string, + id: string, + deliveredById: string + ): Promise { + return this.changeStatus(tenantId, id, EstadoOrdenTransporte.ENTREGADA, deliveredById); + } + + /** + * Cambiar estado con validacion de transiciones + */ + private async changeStatus( + tenantId: string, + id: string, + newStatus: EstadoOrdenTransporte, + changedById: string + ): Promise { + const orden = await this.findById(tenantId, id); + if (!orden) return null; + + if (!this.isValidTransition(orden.estado, newStatus)) { + throw new Error( + `Transicion de estado invalida de ${orden.estado} a ${newStatus}` + ); + } + + orden.estado = newStatus; + orden.updatedById = changedById; + + return this.otRepository.save(orden); + } + + // =========================================================================== + // HELPERS PRIVADOS + // =========================================================================== + + private async generateCodigo(tenantId: string): Promise { + const year = new Date().getFullYear(); + const month = String(new Date().getMonth() + 1).padStart(2, '0'); + const count = await this.otRepository.count({ where: { tenantId } }); + return `OT-${year}${month}-${String(count + 1).padStart(6, '0')}`; } private isValidTransition(from: EstadoOrdenTransporte, to: EstadoOrdenTransporte): boolean { const transitions: Record = { - [EstadoOrdenTransporte.BORRADOR]: [EstadoOrdenTransporte.PENDIENTE, EstadoOrdenTransporte.SOLICITADA, EstadoOrdenTransporte.CANCELADA], - [EstadoOrdenTransporte.PENDIENTE]: [EstadoOrdenTransporte.SOLICITADA, EstadoOrdenTransporte.CONFIRMADA, EstadoOrdenTransporte.CANCELADA], - [EstadoOrdenTransporte.SOLICITADA]: [EstadoOrdenTransporte.CONFIRMADA, EstadoOrdenTransporte.CANCELADA], - [EstadoOrdenTransporte.CONFIRMADA]: [EstadoOrdenTransporte.ASIGNADA, EstadoOrdenTransporte.CANCELADA], - [EstadoOrdenTransporte.ASIGNADA]: [EstadoOrdenTransporte.EN_PROCESO, EstadoOrdenTransporte.EN_TRANSITO, EstadoOrdenTransporte.CANCELADA], - [EstadoOrdenTransporte.EN_PROCESO]: [EstadoOrdenTransporte.EN_TRANSITO, EstadoOrdenTransporte.CANCELADA], - [EstadoOrdenTransporte.EN_TRANSITO]: [EstadoOrdenTransporte.COMPLETADA, EstadoOrdenTransporte.ENTREGADA], - [EstadoOrdenTransporte.COMPLETADA]: [EstadoOrdenTransporte.ENTREGADA], - [EstadoOrdenTransporte.ENTREGADA]: [EstadoOrdenTransporte.FACTURADA], + [EstadoOrdenTransporte.BORRADOR]: [ + EstadoOrdenTransporte.PENDIENTE, + EstadoOrdenTransporte.SOLICITADA, + EstadoOrdenTransporte.CONFIRMADA, + EstadoOrdenTransporte.CANCELADA, + ], + [EstadoOrdenTransporte.PENDIENTE]: [ + EstadoOrdenTransporte.SOLICITADA, + EstadoOrdenTransporte.CONFIRMADA, + EstadoOrdenTransporte.CANCELADA, + ], + [EstadoOrdenTransporte.SOLICITADA]: [ + EstadoOrdenTransporte.CONFIRMADA, + EstadoOrdenTransporte.CANCELADA, + ], + [EstadoOrdenTransporte.CONFIRMADA]: [ + EstadoOrdenTransporte.ASIGNADA, + EstadoOrdenTransporte.CANCELADA, + ], + [EstadoOrdenTransporte.ASIGNADA]: [ + EstadoOrdenTransporte.EN_PROCESO, + EstadoOrdenTransporte.EN_TRANSITO, + EstadoOrdenTransporte.CONFIRMADA, // Desasignacion + EstadoOrdenTransporte.CANCELADA, + ], + [EstadoOrdenTransporte.EN_PROCESO]: [ + EstadoOrdenTransporte.EN_TRANSITO, + EstadoOrdenTransporte.COMPLETADA, + ], + [EstadoOrdenTransporte.EN_TRANSITO]: [ + EstadoOrdenTransporte.COMPLETADA, + EstadoOrdenTransporte.ENTREGADA, + ], + [EstadoOrdenTransporte.COMPLETADA]: [ + EstadoOrdenTransporte.ENTREGADA, + ], + [EstadoOrdenTransporte.ENTREGADA]: [ + EstadoOrdenTransporte.FACTURADA, + ], [EstadoOrdenTransporte.FACTURADA]: [], [EstadoOrdenTransporte.CANCELADA]: [], }; return transitions[from]?.includes(to) ?? false; } + + private mapOrderField(orderBy: string): string { + const mapping: Record = { + createdAt: 'createdAt', + fechaRecoleccion: 'fechaRecoleccionProgramada', + fechaEntrega: 'fechaEntregaProgramada', + total: 'total', + }; + return mapping[orderBy] || 'createdAt'; + } + + /** + * Calcular distancia entre dos coordenadas usando formula de Haversine + */ + private calculateDistance( + lat1: number, + lon1: number, + lat2: number, + lon2: number + ): number { + const R = 6371; // Radio de la Tierra en km + const dLat = this.toRad(lat2 - lat1); + const dLon = this.toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + // Multiplicar por 1.3 para considerar que las rutas no son linea recta + return R * c * 1.3; + } + + private toRad(deg: number): number { + return deg * (Math.PI / 180); + } + + // =========================================================================== + // ALIAS METHODS - Backward compatibility with existing controllers + // =========================================================================== + + /** @deprecated Use findById instead */ + async findOne(tenantId: string, id: string) { + return this.findById(tenantId, id); + } + + /** @deprecated Use findByCodigo instead */ + async findByNumeroOt(tenantId: string, codigo: string) { + return this.findByCodigo(tenantId, codigo); + } + + /** @deprecated Use specific state transition methods instead */ + async cambiarEstado(tenantId: string, id: string, nuevoEstado: EstadoOrdenTransporte, updatedById: string) { + const orden = await this.findById(tenantId, id); + if (!orden) return null; + orden.estado = nuevoEstado; + orden.updatedById = updatedById; + return this.otRepository.save(orden); + } + + /** @deprecated Use confirm instead */ + async confirmar(tenantId: string, id: string, confirmedById: string) { + return this.confirm(tenantId, id, confirmedById); + } + + /** @deprecated Use assignToViaje instead */ + async asignar(tenantId: string, orderId: string, viajeId: string, assignedById: string) { + return this.assignToViaje(tenantId, orderId, viajeId, assignedById); + } + + /** @deprecated Use cancel instead */ + async cancelar(tenantId: string, id: string, motivo: string, cancelledById: string) { + return this.cancel(tenantId, id, motivo, cancelledById); + } + + /** @deprecated Use findUnassigned instead */ + async getOtsPendientes(tenantId: string, limit?: number) { + return this.findUnassigned(tenantId, limit); + } + + /** @deprecated Use findAll with clienteId filter instead */ + async getOtsCliente(clienteId: string, tenantId: string, limit?: number) { + return this.findAll(tenantId, { clienteId, limit: limit || 100 }); + } + + /** @deprecated Use findUnassigned instead */ + async getOtsParaProgramacion(tenantId: string, _fecha?: Date) { + return this.findUnassigned(tenantId, 100); + } } diff --git a/src/modules/viajes/services/viajes.service.ts b/src/modules/viajes/services/viajes.service.ts index 70a245f..d4410bd 100644 --- a/src/modules/viajes/services/viajes.service.ts +++ b/src/modules/viajes/services/viajes.service.ts @@ -1,5 +1,19 @@ -import { Repository, FindOptionsWhere, ILike, In, Between } from 'typeorm'; -import { Viaje, EstadoViaje, ParadaViaje, Pod } from '../entities'; +import { Repository, FindOptionsWhere, ILike, In, Between, MoreThanOrEqual, LessThanOrEqual, IsNull, Not } from 'typeorm'; +import { + Viaje, + EstadoViaje, + InfoSello, + ParadaViaje, + TipoParada, + EstadoParada, + Pod, + EstadoPod, + FotoEvidencia, +} from '../entities'; + +// ============================================================================= +// INTERFACES Y TIPOS +// ============================================================================= export interface ViajeSearchParams { tenantId: string; @@ -13,40 +27,129 @@ export interface ViajeSearchParams { fechaHasta?: Date; limit?: number; offset?: number; + orderBy?: 'createdAt' | 'fechaSalida' | 'fechaLlegada' | 'codigo'; + orderDir?: 'ASC' | 'DESC'; } export interface CreateViajeDto { - ordenTransporteId?: string; + // Unidad y operador unidadId: string; remolqueId?: string; operadorId: string; - operadorAuxiliarId?: string; - fechaProgramadaSalida?: Date; - fechaProgramadaLlegada?: Date; - origenDireccion: string; + + // Cliente (opcional) + clienteId?: string; + + // Ruta + origenPrincipal: string; origenCiudad?: string; - origenEstado?: string; - origenCodigoPostal?: string; - origenLatitud?: number; - origenLongitud?: number; - destinoDireccion: string; + destinoPrincipal: string; destinoCiudad?: string; - destinoEstado?: string; - destinoCodigoPostal?: string; - destinoLatitud?: number; - destinoLongitud?: number; distanciaEstimadaKm?: number; - observaciones?: string; + tiempoEstimadoHoras?: number; + + // Fechas + fechaSalidaProgramada?: Date; + fechaLlegadaProgramada?: Date; } export interface UpdateViajeDto extends Partial { estado?: EstadoViaje; - fechaRealSalida?: Date; - fechaRealLlegada?: Date; - kmInicial?: number; - kmFinal?: number; + kmInicio?: number; + kmFin?: number; + costoCombustible?: number; + costoPeajes?: number; + costoViaticos?: number; + costoOtros?: number; + ingresoTotal?: number; } +export interface CreateParadaDto { + tipo: TipoParada; + direccion: string; + ciudad?: string; + estado?: string; + codigoPostal?: string; + latitud?: number; + longitud?: number; + contactoNombre?: string; + contactoTelefono?: string; + horaProgramadaLlegada?: Date; + horaProgramadaSalida?: Date; + otsIds?: string[]; + observaciones?: string; +} + +export interface UpdateParadaDto { + horaProgramadaLlegada?: Date; + horaProgramadaSalida?: Date; + horaRealLlegada?: Date; + horaRealSalida?: Date; + estadoParada?: EstadoParada; + observaciones?: string; +} + +export interface CompletarParadaDto { + receptorNombre: string; + receptorIdentificacion?: string; + piezasEntregadas?: number; + piezasRechazadas?: number; + piezasDanadas?: number; + firmaDigital?: string; + fotosEntrega?: FotoEvidencia[]; + observaciones?: string; + motivoRechazo?: string; +} + +export interface DespachoDto { + kmInicio: number; + sellosSalida?: InfoSello[]; + checklistCompletado: boolean; + checklistObservaciones?: string; +} + +export interface ETAInfo { + viajeId: string; + paradaActualSecuencia: number; + paradaSiguiente?: { + id: string; + secuencia: number; + direccion: string; + ciudad?: string; + etaEstimada: Date; + distanciaRestanteKm: number; + tiempoRestanteMinutos: number; + }; + destinoFinal: { + etaEstimada: Date; + distanciaRestanteKm: number; + tiempoRestanteMinutos: number; + }; + progresoViaje: number; // Porcentaje 0-100 + ultimaActualizacion: Date; +} + +export interface ViajeStats { + total: number; + porEstado: Record; + kmTotales: number; + costoTotal: number; + ingresoTotal: number; + margenTotal: number; + promedioKmPorViaje: number; + promedioCostoPorViaje: number; + tiempoPromedioHoras: number; +} + +export interface DateRange { + from: Date; + to: Date; +} + +// ============================================================================= +// SERVICIO DE VIAJES +// ============================================================================= + export class ViajesService { constructor( private readonly viajeRepository: Repository, @@ -54,9 +157,114 @@ export class ViajesService { private readonly podRepository: Repository ) {} - async findAll(params: ViajeSearchParams): Promise<{ data: Viaje[]; total: number }> { - const { + // =========================================================================== + // METODOS CRUD BASICOS + // =========================================================================== + + /** + * Crear nuevo viaje + */ + async create(tenantId: string, dto: CreateViajeDto, createdById: string): Promise { + const codigo = await this.generateCodigo(tenantId); + + const viaje = this.viajeRepository.create({ tenantId, + codigo, + numeroViaje: codigo, + + // Unidad y operador + unidadId: dto.unidadId, + remolqueId: dto.remolqueId, + operadorId: dto.operadorId, + + // Cliente + clienteId: dto.clienteId, + + // Ruta + origenPrincipal: dto.origenPrincipal, + origenCiudad: dto.origenCiudad, + destinoPrincipal: dto.destinoPrincipal, + destinoCiudad: dto.destinoCiudad, + distanciaEstimadaKm: dto.distanciaEstimadaKm, + tiempoEstimadoHoras: dto.tiempoEstimadoHoras, + + // Fechas + fechaSalidaProgramada: dto.fechaSalidaProgramada, + fechaProgramadaSalida: dto.fechaSalidaProgramada, + fechaLlegadaProgramada: dto.fechaLlegadaProgramada, + + // Estado inicial + estado: EstadoViaje.BORRADOR, + + // Auditoria + createdById, + }); + + return this.viajeRepository.save(viaje); + } + + /** + * Buscar viaje por ID con relaciones + */ + async findById(tenantId: string, id: string): Promise { + const viaje = await this.viajeRepository.findOne({ + where: { id, tenantId }, + }); + + if (viaje) { + // Cargar paradas manualmente si existen + const paradas = await this.paradaRepository.find({ + where: { tenantId, viajeId: id }, + order: { secuencia: 'ASC' }, + }); + (viaje as any).paradas = paradas; + + // Cargar PODs + const pods = await this.podRepository.find({ + where: { tenantId, viajeId: id }, + }); + (viaje as any).pods = pods; + } + + return viaje; + } + + /** + * Buscar viaje por codigo + */ + async findByCodigo(tenantId: string, codigo: string): Promise { + return this.viajeRepository.findOne({ + where: { codigo, tenantId }, + }); + } + + /** + * Listar viajes con filtros y paginacion + */ + /** + * Listar viajes con filtros y paginacion + * Soporta dos firmas: + * - findAll(tenantId: string, filters: object) - nueva firma + * - findAll(params: { tenantId, ...filters }) - compatibilidad con controller existente + */ + async findAll( + tenantIdOrParams: string | ViajeSearchParams, + filters: Omit = {} + ): Promise<{ data: Viaje[]; total: number; page: number; pageSize: number }> { + // Soportar ambas firmas + let tenantId: string; + let actualFilters: Omit; + + if (typeof tenantIdOrParams === 'string') { + tenantId = tenantIdOrParams; + actualFilters = filters; + } else { + tenantId = tenantIdOrParams.tenantId; + const { tenantId: _, ...rest } = tenantIdOrParams; + actualFilters = rest; + } + + const { search, estado, estados, @@ -67,232 +275,859 @@ export class ViajesService { fechaHasta, limit = 50, offset = 0, - } = params; + orderBy = 'createdAt', + orderDir = 'DESC', + } = actualFilters; const where: FindOptionsWhere[] = []; const baseWhere: FindOptionsWhere = { tenantId }; + // Filtros de estado if (estado) { baseWhere.estado = estado; - } - - if (estados && estados.length > 0) { + } else if (estados && estados.length > 0) { baseWhere.estado = In(estados); } - if (unidadId) { - baseWhere.unidadId = unidadId; - } - - if (operadorId) { - baseWhere.operadorId = operadorId; - } - - if (clienteId) { - baseWhere.clienteId = clienteId; - } + // Filtros de recursos + if (unidadId) baseWhere.unidadId = unidadId; + if (operadorId) baseWhere.operadorId = operadorId; + if (clienteId) baseWhere.clienteId = clienteId; + // Filtros de fecha if (fechaDesde && fechaHasta) { - baseWhere.fechaProgramadaSalida = Between(fechaDesde, fechaHasta); + baseWhere.fechaSalidaProgramada = Between(fechaDesde, fechaHasta); + } else if (fechaDesde) { + baseWhere.fechaSalidaProgramada = MoreThanOrEqual(fechaDesde); + } else if (fechaHasta) { + baseWhere.fechaSalidaProgramada = LessThanOrEqual(fechaHasta); } + // Busqueda por texto if (search) { where.push( + { ...baseWhere, codigo: ILike(`%${search}%`) }, { ...baseWhere, numeroViaje: ILike(`%${search}%`) }, + { ...baseWhere, origenPrincipal: ILike(`%${search}%`) }, { ...baseWhere, origenCiudad: ILike(`%${search}%`) }, + { ...baseWhere, destinoPrincipal: ILike(`%${search}%`) }, { ...baseWhere, destinoCiudad: ILike(`%${search}%`) } ); } else { where.push(baseWhere); } + // Mapeo de campo de ordenamiento + const orderField = this.mapOrderField(orderBy); + const [data, total] = await this.viajeRepository.findAndCount({ where, - relations: ['paradas'], take: limit, skip: offset, - order: { fechaProgramadaSalida: 'DESC' }, + order: { [orderField]: orderDir }, }); - return { data, total }; + return { + data, + total, + page: Math.floor(offset / limit) + 1, + pageSize: limit, + }; } - async findOne(id: string, tenantId: string): Promise { - return this.viajeRepository.findOne({ - where: { id, tenantId }, - relations: ['paradas', 'pods'], - }); - } - - async findByNumero(numeroViaje: string, tenantId: string): Promise { - return this.viajeRepository.findOne({ - where: { numeroViaje, tenantId }, - relations: ['paradas'], - }); - } - - async create(tenantId: string, dto: CreateViajeDto, createdBy: string): Promise { - const numeroViaje = await this.generateNumeroViaje(tenantId); - - const viaje = this.viajeRepository.create({ - ...dto, - tenantId, - numeroViaje, - estado: EstadoViaje.BORRADOR, - createdById: createdBy, - }); - - return this.viajeRepository.save(viaje); - } - - async update( - id: string, + /** + * Buscar viajes por unidad + */ + async findByUnidad( tenantId: string, + unidadId: string, + limit: number = 50 + ): Promise { + return this.viajeRepository.find({ + where: { tenantId, unidadId }, + order: { fechaSalidaProgramada: 'DESC' }, + take: limit, + }); + } + + /** + * Buscar viajes por operador + */ + async findByOperador( + tenantId: string, + operadorId: string, + limit: number = 50 + ): Promise { + return this.viajeRepository.find({ + where: { tenantId, operadorId }, + order: { fechaSalidaProgramada: 'DESC' }, + take: limit, + }); + } + + /** + * Obtener viajes activos (en progreso) + */ + async getActiveTrips(tenantId: string): Promise { + const estadosActivos = [ + EstadoViaje.DESPACHADO, + EstadoViaje.EN_TRANSITO, + EstadoViaje.EN_DESTINO, + ]; + + const viajes = await this.viajeRepository.find({ + where: { tenantId, estado: In(estadosActivos) }, + order: { fechaSalidaReal: 'ASC' }, + }); + + // Cargar paradas para cada viaje activo + for (const viaje of viajes) { + const paradas = await this.paradaRepository.find({ + where: { tenantId, viajeId: viaje.id }, + order: { secuencia: 'ASC' }, + }); + (viaje as any).paradas = paradas; + } + + return viajes; + } + + /** + * Actualizar viaje + */ + async update( + tenantId: string, + id: string, dto: UpdateViajeDto, - updatedBy: string + updatedById: string ): Promise { - const viaje = await this.findOne(id, tenantId); + const viaje = await this.findById(tenantId, id); if (!viaje) return null; + // No permitir modificaciones en estados finales + const estadosFinales = [ + EstadoViaje.CERRADO, + EstadoViaje.FACTURADO, + EstadoViaje.COBRADO, + EstadoViaje.CANCELADO, + ]; + if (estadosFinales.includes(viaje.estado)) { + throw new Error(`No se puede modificar un viaje en estado ${viaje.estado}`); + } + + // Recalcular costo total si se actualizan costos + let costoTotal = viaje.costoTotal; + if (dto.costoCombustible !== undefined || + dto.costoPeajes !== undefined || + dto.costoViaticos !== undefined || + dto.costoOtros !== undefined) { + costoTotal = + (dto.costoCombustible ?? viaje.costoCombustible) + + (dto.costoPeajes ?? viaje.costoPeajes) + + (dto.costoViaticos ?? viaje.costoViaticos) + + (dto.costoOtros ?? viaje.costoOtros); + } + Object.assign(viaje, { ...dto, - updatedById: updatedBy, + costoTotal, + updatedById, }); return this.viajeRepository.save(viaje); } - async cambiarEstado( - id: string, - tenantId: string, - nuevoEstado: EstadoViaje, - updatedBy: string - ): Promise { - const viaje = await this.findOne(id, tenantId); + // =========================================================================== + // CICLO DE VIDA DEL VIAJE + // =========================================================================== + + /** + * Planear viaje (pasar de borrador a planeado) + */ + async plan(tenantId: string, id: string, updatedById: string): Promise { + const viaje = await this.findById(tenantId, id); if (!viaje) return null; - // Validate state transition - if (!this.isValidTransition(viaje.estado, nuevoEstado)) { - throw new Error(`Invalid state transition from ${viaje.estado} to ${nuevoEstado}`); + if (viaje.estado !== EstadoViaje.BORRADOR) { + throw new Error('El viaje debe estar en BORRADOR para ser planeado'); } - // Set timestamps based on state - const updates: Partial = { estado: nuevoEstado, updatedById: updatedBy }; + viaje.estado = EstadoViaje.PLANEADO; + viaje.updatedById = updatedById; - if (nuevoEstado === EstadoViaje.DESPACHADO && !viaje.fechaRealSalida) { - updates.fechaRealSalida = new Date(); - } - - if (nuevoEstado === EstadoViaje.ENTREGADO && !viaje.fechaRealLlegada) { - updates.fechaRealLlegada = new Date(); - } - - Object.assign(viaje, updates); return this.viajeRepository.save(viaje); } - async despachar(id: string, tenantId: string, datos: { - kmInicial?: number; - sellos?: any[]; - }, updatedBy: string): Promise { - const viaje = await this.findOne(id, tenantId); + /** + * Iniciar viaje (despachar) + */ + async start( + tenantId: string, + id: string, + despachoData: DespachoDto, + startedById: string + ): Promise { + const viaje = await this.findById(tenantId, id); if (!viaje) return null; if (viaje.estado !== EstadoViaje.PLANEADO) { throw new Error('El viaje debe estar PLANEADO para ser despachado'); } - Object.assign(viaje, { - estado: EstadoViaje.DESPACHADO, - fechaRealSalida: new Date(), - kmInicial: datos.kmInicial, - sellos: datos.sellos, - updatedById: updatedBy, - }); + if (!despachoData.checklistCompletado) { + throw new Error('El checklist pre-viaje debe estar completado'); + } + + viaje.estado = EstadoViaje.DESPACHADO; + viaje.fechaSalidaReal = new Date(); + viaje.fechaRealSalida = new Date(); + viaje.kmInicio = despachoData.kmInicio; + viaje.sellosSalida = despachoData.sellosSalida || []; + viaje.checklistCompletado = true; + viaje.checklistFecha = new Date(); + viaje.checklistObservaciones = despachoData.checklistObservaciones || ''; + viaje.updatedById = startedById; return this.viajeRepository.save(viaje); } - async iniciarTransito(id: string, tenantId: string, updatedBy: string): Promise { - return this.cambiarEstado(id, tenantId, EstadoViaje.EN_TRANSITO, updatedBy); - } - - async registrarLlegada(id: string, tenantId: string, datos: { - kmFinal?: number; - }, updatedBy: string): Promise { - const viaje = await this.findOne(id, tenantId); + /** + * Iniciar transito + */ + async startTransit(tenantId: string, id: string, updatedById: string): Promise { + const viaje = await this.findById(tenantId, id); if (!viaje) return null; - Object.assign(viaje, { - estado: EstadoViaje.EN_DESTINO, - kmFinal: datos.kmFinal, - updatedById: updatedBy, - }); + if (viaje.estado !== EstadoViaje.DESPACHADO) { + throw new Error('El viaje debe estar DESPACHADO para iniciar transito'); + } + + viaje.estado = EstadoViaje.EN_TRANSITO; + viaje.updatedById = updatedById; return this.viajeRepository.save(viaje); } - async registrarEntrega(id: string, tenantId: string, datos: { - kmFinal?: number; - observaciones?: string; - }, updatedBy: string): Promise { - const viaje = await this.findOne(id, tenantId); + /** + * Registrar llegada a destino + */ + async arriveAtDestination( + tenantId: string, + id: string, + kmActual: number, + updatedById: string + ): Promise { + const viaje = await this.findById(tenantId, id); if (!viaje) return null; - Object.assign(viaje, { - estado: EstadoViaje.ENTREGADO, - fechaRealLlegada: new Date(), - kmFinal: datos.kmFinal, - observaciones: datos.observaciones, - updatedById: updatedBy, - }); + if (viaje.estado !== EstadoViaje.EN_TRANSITO) { + throw new Error('El viaje debe estar EN_TRANSITO para registrar llegada'); + } + + viaje.estado = EstadoViaje.EN_DESTINO; + viaje.kmFin = kmActual; + viaje.updatedById = updatedById; return this.viajeRepository.save(viaje); } - // Viajes en progreso - async getViajesEnProgreso(tenantId: string): Promise { + /** + * Completar viaje (todas las entregas realizadas) + */ + async complete( + tenantId: string, + id: string, + completionData: { + kmFin: number; + sellosLlegada?: InfoSello[]; + }, + completedById: string + ): Promise { + const viaje = await this.findById(tenantId, id); + if (!viaje) return null; + + if (viaje.estado !== EstadoViaje.EN_DESTINO && viaje.estado !== EstadoViaje.EN_TRANSITO) { + throw new Error('El viaje debe estar EN_DESTINO o EN_TRANSITO para completarse'); + } + + // Verificar que todas las paradas esten completadas + const paradas = await this.paradaRepository.find({ + where: { tenantId, viajeId: id }, + }); + + const paradasPendientes = paradas.filter( + p => p.estadoParada !== EstadoParada.COMPLETADA && p.estadoParada !== EstadoParada.OMITIDA + ); + + if (paradasPendientes.length > 0) { + throw new Error(`Hay ${paradasPendientes.length} parada(s) pendientes de completar`); + } + + viaje.estado = EstadoViaje.ENTREGADO; + viaje.fechaLlegadaReal = new Date(); + viaje.fechaRealLlegada = new Date(); + viaje.kmFin = completionData.kmFin; + viaje.sellosLlegada = completionData.sellosLlegada || []; + viaje.updatedById = completedById; + + return this.viajeRepository.save(viaje); + } + + /** + * Cerrar viaje (revision completada, listo para facturar) + */ + async close(tenantId: string, id: string, closedById: string): Promise { + const viaje = await this.findById(tenantId, id); + if (!viaje) return null; + + if (viaje.estado !== EstadoViaje.ENTREGADO) { + throw new Error('El viaje debe estar ENTREGADO para cerrarse'); + } + + viaje.estado = EstadoViaje.CERRADO; + viaje.updatedById = closedById; + + return this.viajeRepository.save(viaje); + } + + /** + * Cancelar viaje + */ + async cancel( + tenantId: string, + id: string, + reason: string, + cancelledById: string + ): Promise { + const viaje = await this.findById(tenantId, id); + if (!viaje) return null; + + // No se puede cancelar un viaje ya despachado/en transito + const estadosNoCancelables = [ + EstadoViaje.DESPACHADO, + EstadoViaje.EN_TRANSITO, + EstadoViaje.EN_DESTINO, + EstadoViaje.ENTREGADO, + EstadoViaje.CERRADO, + EstadoViaje.FACTURADO, + EstadoViaje.COBRADO, + EstadoViaje.CANCELADO, + ]; + + if (estadosNoCancelables.includes(viaje.estado)) { + throw new Error(`No se puede cancelar un viaje en estado ${viaje.estado}`); + } + + viaje.estado = EstadoViaje.CANCELADO; + viaje.checklistObservaciones = `${viaje.checklistObservaciones || ''}\n[CANCELADO] ${reason}`.trim(); + viaje.updatedById = cancelledById; + + return this.viajeRepository.save(viaje); + } + + // =========================================================================== + // GESTION DE PARADAS + // =========================================================================== + + /** + * Agregar parada al viaje + */ + async addParada( + tenantId: string, + viajeId: string, + paradaData: CreateParadaDto, + createdById: string + ): Promise { + const viaje = await this.findById(tenantId, viajeId); + if (!viaje) { + throw new Error('Viaje no encontrado'); + } + + // No agregar paradas a viajes ya despachados o posteriores + const estadosModificables = [EstadoViaje.BORRADOR, EstadoViaje.PLANEADO]; + if (!estadosModificables.includes(viaje.estado)) { + throw new Error(`No se pueden agregar paradas a un viaje en estado ${viaje.estado}`); + } + + // Obtener siguiente secuencia + const ultimaParada = await this.paradaRepository.findOne({ + where: { tenantId, viajeId }, + order: { secuencia: 'DESC' }, + }); + const nuevaSecuencia = (ultimaParada?.secuencia || 0) + 1; + + const parada = this.paradaRepository.create({ + tenantId, + viajeId, + secuencia: nuevaSecuencia, + tipo: paradaData.tipo, + direccion: paradaData.direccion, + ciudad: paradaData.ciudad, + estado: paradaData.estado, + codigoPostal: paradaData.codigoPostal, + latitud: paradaData.latitud, + longitud: paradaData.longitud, + contactoNombre: paradaData.contactoNombre, + contactoTelefono: paradaData.contactoTelefono, + horaProgramadaLlegada: paradaData.horaProgramadaLlegada, + horaProgramadaSalida: paradaData.horaProgramadaSalida, + otsIds: paradaData.otsIds || [], + estadoParada: EstadoParada.PENDIENTE, + observaciones: paradaData.observaciones, + }); + + return this.paradaRepository.save(parada); + } + + /** + * Actualizar parada + */ + async updateParada( + tenantId: string, + paradaId: string, + data: UpdateParadaDto, + updatedById: string + ): Promise { + const parada = await this.paradaRepository.findOne({ + where: { id: paradaId, tenantId }, + }); + + if (!parada) return null; + + Object.assign(parada, data); + + return this.paradaRepository.save(parada); + } + + /** + * Registrar llegada a parada + */ + async arrivedAtParada( + tenantId: string, + paradaId: string, + updatedById: string + ): Promise { + const parada = await this.paradaRepository.findOne({ + where: { id: paradaId, tenantId }, + }); + + if (!parada) return null; + + parada.estadoParada = EstadoParada.LLEGADA; + parada.horaRealLlegada = new Date(); + + return this.paradaRepository.save(parada); + } + + /** + * Iniciar proceso en parada (descarga/carga) + */ + async startParadaProcess( + tenantId: string, + paradaId: string, + updatedById: string + ): Promise { + const parada = await this.paradaRepository.findOne({ + where: { id: paradaId, tenantId }, + }); + + if (!parada) return null; + + parada.estadoParada = EstadoParada.EN_PROCESO; + + return this.paradaRepository.save(parada); + } + + /** + * Completar parada con POD + */ + async completeParada( + tenantId: string, + paradaId: string, + podData: CompletarParadaDto, + completedById: string + ): Promise<{ parada: ParadaViaje; pod: Pod }> { + const parada = await this.paradaRepository.findOne({ + where: { id: paradaId, tenantId }, + }); + + if (!parada) { + throw new Error('Parada no encontrada'); + } + + // Actualizar parada + parada.estadoParada = EstadoParada.COMPLETADA; + parada.horaRealSalida = new Date(); + parada.observaciones = podData.observaciones || parada.observaciones; + + // Determinar estado del POD + let estadoPod = EstadoPod.COMPLETO; + if (podData.motivoRechazo) { + estadoPod = EstadoPod.RECHAZADO; + } else if ((podData.piezasRechazadas || 0) > 0 || (podData.piezasDanadas || 0) > 0) { + estadoPod = EstadoPod.PARCIAL; + } + + // Crear POD + const pod = this.podRepository.create({ + tenantId, + viajeId: parada.viajeId, + paradaId: parada.id, + estado: estadoPod, + receptorNombre: podData.receptorNombre, + receptorIdentificacion: podData.receptorIdentificacion, + fechaRecepcion: new Date(), + firmaDigital: podData.firmaDigital, + fotosEntrega: podData.fotosEntrega || [], + piezasEntregadas: podData.piezasEntregadas, + piezasRechazadas: podData.piezasRechazadas, + piezasDanadas: podData.piezasDanadas, + observaciones: podData.observaciones, + motivoRechazo: podData.motivoRechazo, + createdById: completedById, + }); + + // Guardar ambos + const [savedParada, savedPod] = await Promise.all([ + this.paradaRepository.save(parada), + this.podRepository.save(pod), + ]); + + return { parada: savedParada, pod: savedPod }; + } + + /** + * Omitir parada + */ + async skipParada( + tenantId: string, + paradaId: string, + reason: string, + skippedById: string + ): Promise { + const parada = await this.paradaRepository.findOne({ + where: { id: paradaId, tenantId }, + }); + + if (!parada) return null; + + parada.estadoParada = EstadoParada.OMITIDA; + parada.observaciones = `${parada.observaciones || ''}\n[OMITIDA] ${reason}`.trim(); + + return this.paradaRepository.save(parada); + } + + /** + * Obtener paradas de un viaje + */ + async getParadas(tenantId: string, viajeId: string): Promise { + return this.paradaRepository.find({ + where: { tenantId, viajeId }, + order: { secuencia: 'ASC' }, + }); + } + + /** + * Reordenar paradas + */ + async reorderParadas( + tenantId: string, + viajeId: string, + newOrder: string[], // Array de IDs en nuevo orden + updatedById: string + ): Promise { + const viaje = await this.findById(tenantId, viajeId); + if (!viaje) { + throw new Error('Viaje no encontrado'); + } + + // Solo permitir reordenar en estados modificables + const estadosModificables = [EstadoViaje.BORRADOR, EstadoViaje.PLANEADO]; + if (!estadosModificables.includes(viaje.estado)) { + throw new Error(`No se pueden reordenar paradas en viaje con estado ${viaje.estado}`); + } + + const paradas = await this.paradaRepository.find({ + where: { tenantId, viajeId }, + }); + + // Actualizar secuencias + for (let i = 0; i < newOrder.length; i++) { + const parada = paradas.find(p => p.id === newOrder[i]); + if (parada) { + parada.secuencia = i + 1; + await this.paradaRepository.save(parada); + } + } + + return this.getParadas(tenantId, viajeId); + } + + // =========================================================================== + // CALCULO DE ETA + // =========================================================================== + + /** + * Calcular ETA basado en ruta y paradas + */ + async calculateETA(tenantId: string, id: string): Promise { + const viaje = await this.findById(tenantId, id); + if (!viaje) return null; + + const paradas = await this.getParadas(tenantId, id); + + // Encontrar parada actual (ultima completada o primera pendiente) + let paradaActualSecuencia = 0; + for (const parada of paradas) { + if (parada.estadoParada === EstadoParada.COMPLETADA) { + paradaActualSecuencia = parada.secuencia; + } + } + + // Siguiente parada pendiente + const paradaSiguiente = paradas.find( + p => p.secuencia > paradaActualSecuencia && + p.estadoParada !== EstadoParada.COMPLETADA && + p.estadoParada !== EstadoParada.OMITIDA + ); + + // Calcular progreso + const paradasCompletadas = paradas.filter( + p => p.estadoParada === EstadoParada.COMPLETADA || p.estadoParada === EstadoParada.OMITIDA + ).length; + const progresoViaje = paradas.length > 0 ? (paradasCompletadas / paradas.length) * 100 : 0; + + // Calcular distancia y tiempo restante (aproximacion simplificada) + const distanciaTotal = viaje.distanciaEstimadaKm || 0; + const tiempoTotalHoras = viaje.tiempoEstimadoHoras || (distanciaTotal / 60); + const distanciaRestanteKm = distanciaTotal * (1 - progresoViaje / 100); + const tiempoRestanteMinutos = tiempoTotalHoras * 60 * (1 - progresoViaje / 100); + + // Calcular ETA estimada + const ahora = new Date(); + const etaFinal = new Date(ahora.getTime() + tiempoRestanteMinutos * 60 * 1000); + + const result: ETAInfo = { + viajeId: viaje.id, + paradaActualSecuencia, + destinoFinal: { + etaEstimada: etaFinal, + distanciaRestanteKm: Math.round(distanciaRestanteKm * 10) / 10, + tiempoRestanteMinutos: Math.round(tiempoRestanteMinutos), + }, + progresoViaje: Math.round(progresoViaje * 10) / 10, + ultimaActualizacion: ahora, + }; + + // Agregar info de siguiente parada si existe + if (paradaSiguiente) { + const distanciaASiguiente = distanciaRestanteKm / (paradas.length - paradasCompletadas); + const tiempoASiguiente = tiempoRestanteMinutos / (paradas.length - paradasCompletadas); + + result.paradaSiguiente = { + id: paradaSiguiente.id, + secuencia: paradaSiguiente.secuencia, + direccion: paradaSiguiente.direccion, + ciudad: paradaSiguiente.ciudad, + etaEstimada: new Date(ahora.getTime() + tiempoASiguiente * 60 * 1000), + distanciaRestanteKm: Math.round(distanciaASiguiente * 10) / 10, + tiempoRestanteMinutos: Math.round(tiempoASiguiente), + }; + } + + return result; + } + + // =========================================================================== + // ESTADISTICAS + // =========================================================================== + + /** + * Obtener estadisticas de viajes + */ + async getStatistics(tenantId: string, dateRange: DateRange): Promise { + const { from, to } = dateRange; + + const viajes = await this.viajeRepository.find({ + where: { + tenantId, + createdAt: Between(from, to), + }, + }); + + // Conteo por estado + const porEstado: Record = { + [EstadoViaje.BORRADOR]: 0, + [EstadoViaje.PLANEADO]: 0, + [EstadoViaje.DESPACHADO]: 0, + [EstadoViaje.EN_TRANSITO]: 0, + [EstadoViaje.EN_DESTINO]: 0, + [EstadoViaje.ENTREGADO]: 0, + [EstadoViaje.CERRADO]: 0, + [EstadoViaje.FACTURADO]: 0, + [EstadoViaje.COBRADO]: 0, + [EstadoViaje.CANCELADO]: 0, + }; + + let kmTotales = 0; + let costoTotal = 0; + let ingresoTotal = 0; + let tiempoTotal = 0; + let viajesConTiempo = 0; + + for (const viaje of viajes) { + porEstado[viaje.estado]++; + kmTotales += viaje.kmRecorridos || 0; + costoTotal += Number(viaje.costoTotal) || 0; + ingresoTotal += Number(viaje.ingresoTotal) || 0; + + // Calcular tiempo de viaje para viajes completados + if (viaje.fechaSalidaReal && viaje.fechaLlegadaReal) { + const tiempoViaje = viaje.fechaLlegadaReal.getTime() - viaje.fechaSalidaReal.getTime(); + tiempoTotal += tiempoViaje / (1000 * 60 * 60); // Convertir a horas + viajesConTiempo++; + } + } + + const total = viajes.length; + const margenTotal = ingresoTotal - costoTotal; + + return { + total, + porEstado, + kmTotales: Math.round(kmTotales), + costoTotal: Math.round(costoTotal * 100) / 100, + ingresoTotal: Math.round(ingresoTotal * 100) / 100, + margenTotal: Math.round(margenTotal * 100) / 100, + promedioKmPorViaje: total > 0 ? Math.round(kmTotales / total) : 0, + promedioCostoPorViaje: total > 0 ? Math.round((costoTotal / total) * 100) / 100 : 0, + tiempoPromedioHoras: viajesConTiempo > 0 ? Math.round((tiempoTotal / viajesConTiempo) * 10) / 10 : 0, + }; + } + + // =========================================================================== + // CONSULTAS ESPECIALIZADAS + // =========================================================================== + + /** + * Obtener viajes programados para una fecha + */ + async getScheduledForDate(tenantId: string, date: Date): Promise { + const inicioDia = new Date(date); + inicioDia.setHours(0, 0, 0, 0); + const finDia = new Date(date); + finDia.setHours(23, 59, 59, 999); + return this.viajeRepository.find({ where: { tenantId, - estado: In([ - EstadoViaje.DESPACHADO, - EstadoViaje.EN_TRANSITO, - EstadoViaje.EN_DESTINO, - ]), + fechaSalidaProgramada: Between(inicioDia, finDia), + estado: In([EstadoViaje.BORRADOR, EstadoViaje.PLANEADO]), }, - relations: ['paradas'], - order: { fechaProgramadaSalida: 'ASC' }, + order: { fechaSalidaProgramada: 'ASC' }, }); } - // Viajes por operador - async getViajesOperador(operadorId: string, tenantId: string): Promise { - return this.viajeRepository.find({ - where: { operadorId, tenantId }, - relations: ['paradas'], - order: { fechaProgramadaSalida: 'DESC' }, - take: 20, + /** + * Verificar disponibilidad de unidad en fecha + */ + async isUnidadAvailable( + tenantId: string, + unidadId: string, + fechaInicio: Date, + fechaFin: Date, + excludeViajeId?: string + ): Promise { + const whereCondition: FindOptionsWhere = { + tenantId, + unidadId, + estado: Not(In([EstadoViaje.CANCELADO, EstadoViaje.CERRADO, EstadoViaje.FACTURADO, EstadoViaje.COBRADO])), + }; + + if (excludeViajeId) { + whereCondition.id = Not(excludeViajeId); + } + + const viajesConflicto = await this.viajeRepository.find({ + where: whereCondition, }); + + // Verificar si hay solapamiento de fechas + for (const viaje of viajesConflicto) { + const viajeInicio = viaje.fechaSalidaProgramada || viaje.fechaSalidaReal; + const viajeFin = viaje.fechaLlegadaProgramada || viaje.fechaLlegadaReal || viajeInicio; + + if (viajeInicio && viajeFin) { + // Hay conflicto si: no (fechaFin < viajeInicio || fechaInicio > viajeFin) + const sinConflicto = fechaFin < viajeInicio || fechaInicio > viajeFin; + if (!sinConflicto) { + return false; + } + } + } + + return true; } - // Viajes por unidad - async getViajesUnidad(unidadId: string, tenantId: string): Promise { - return this.viajeRepository.find({ - where: { unidadId, tenantId }, - relations: ['paradas'], - order: { fechaProgramadaSalida: 'DESC' }, - take: 20, + /** + * Verificar disponibilidad de operador en fecha + */ + async isOperadorAvailable( + tenantId: string, + operadorId: string, + fechaInicio: Date, + fechaFin: Date, + excludeViajeId?: string + ): Promise { + const whereCondition: FindOptionsWhere = { + tenantId, + operadorId, + estado: Not(In([EstadoViaje.CANCELADO, EstadoViaje.CERRADO, EstadoViaje.FACTURADO, EstadoViaje.COBRADO])), + }; + + if (excludeViajeId) { + whereCondition.id = Not(excludeViajeId); + } + + const viajesConflicto = await this.viajeRepository.find({ + where: whereCondition, }); + + // Verificar si hay solapamiento de fechas + for (const viaje of viajesConflicto) { + const viajeInicio = viaje.fechaSalidaProgramada || viaje.fechaSalidaReal; + const viajeFin = viaje.fechaLlegadaProgramada || viaje.fechaLlegadaReal || viajeInicio; + + if (viajeInicio && viajeFin) { + const sinConflicto = fechaFin < viajeInicio || fechaInicio > viajeFin; + if (!sinConflicto) { + return false; + } + } + } + + return true; } - // Helpers - private async generateNumeroViaje(tenantId: string): Promise { + // =========================================================================== + // HELPERS PRIVADOS + // =========================================================================== + + private async generateCodigo(tenantId: string): Promise { const year = new Date().getFullYear(); - const count = await this.viajeRepository.count({ - where: { tenantId }, - }); - return `VJ-${year}-${String(count + 1).padStart(6, '0')}`; + const month = String(new Date().getMonth() + 1).padStart(2, '0'); + const count = await this.viajeRepository.count({ where: { tenantId } }); + return `VJ-${year}${month}-${String(count + 1).padStart(6, '0')}`; + } + + private mapOrderField(orderBy: string): string { + const mapping: Record = { + createdAt: 'createdAt', + fechaSalida: 'fechaSalidaProgramada', + fechaLlegada: 'fechaLlegadaProgramada', + codigo: 'codigo', + }; + return mapping[orderBy] || 'createdAt'; } private isValidTransition(from: EstadoViaje, to: EstadoViaje): boolean { @@ -311,4 +1146,149 @@ export class ViajesService { return transitions[from]?.includes(to) ?? false; } + + // =========================================================================== + // ALIAS METHODS - Backward compatibility with existing controllers + // =========================================================================== + + /** @deprecated Use findById instead */ + async findOne(tenantId: string, id: string) { + return this.findById(tenantId, id); + } + + /** @deprecated Use findByCodigo instead */ + async findByNumero(tenantId: string, codigo: string) { + return this.findByCodigo(tenantId, codigo); + } + + /** @deprecated Use specific state transition methods instead */ + async cambiarEstado(tenantId: string, id: string, nuevoEstado: EstadoViaje, updatedById: string) { + const viaje = await this.findById(tenantId, id); + if (!viaje) return null; + viaje.estado = nuevoEstado; + viaje.updatedById = updatedById; + return this.viajeRepository.save(viaje); + } + + /** @deprecated Use start instead */ + async despachar( + idOrTenantId: string, + tenantIdOrData: string | { kmInicial?: number; kmInicio?: number; sellos?: InfoSello[]; checklistObservaciones?: string }, + despachoDataOrUserId?: { kmInicial?: number; kmInicio?: number; sellos?: InfoSello[]; checklistObservaciones?: string } | string, + startedById?: string + ) { + // Handle legacy signature: despachar(id, tenantId, { kmInicial, sellos }, userId) + let tenantId: string; + let id: string; + let data: DespachoDto; + let userId: string; + + if (typeof tenantIdOrData === 'string' && typeof despachoDataOrUserId === 'object') { + // Legacy: despachar(id, tenantId, data, userId) + id = idOrTenantId; + tenantId = tenantIdOrData; + const legacyData = despachoDataOrUserId as { kmInicial?: number; kmInicio?: number; sellos?: InfoSello[] }; + data = { + kmInicio: legacyData.kmInicio || legacyData.kmInicial || 0, + sellosSalida: legacyData.sellos, + checklistCompletado: true, + }; + userId = startedById || ''; + } else { + // New: despachar(tenantId, id, data, userId) + tenantId = idOrTenantId; + id = tenantIdOrData as string; + data = despachoDataOrUserId as DespachoDto; + userId = startedById || ''; + } + + return this.start(tenantId, id, data, userId); + } + + /** @deprecated Use startTransit instead */ + async iniciarTransito(idOrTenantId: string, tenantIdOrUserId: string, userIdOpt?: string) { + // Handle legacy signature: iniciarTransito(id, tenantId, userId) + if (userIdOpt) { + return this.startTransit(tenantIdOrUserId, idOrTenantId, userIdOpt); + } + return this.startTransit(idOrTenantId, tenantIdOrUserId, ''); + } + + /** @deprecated Use arriveAtDestination instead */ + async registrarLlegada( + idOrTenantId: string, + tenantIdOrKm: string | number | { kmFinal?: number }, + kmOrUserId?: number | { kmFinal?: number } | string, + userIdOpt?: string + ) { + // Handle legacy signature: registrarLlegada(id, tenantId, { kmFinal }, userId) + let tenantId: string; + let id: string; + let kmActual: number; + let userId: string; + + if (typeof tenantIdOrKm === 'string' && typeof kmOrUserId === 'object') { + // Legacy: registrarLlegada(id, tenantId, { kmFinal }, userId) + id = idOrTenantId; + tenantId = tenantIdOrKm; + kmActual = (kmOrUserId as { kmFinal?: number }).kmFinal || 0; + userId = userIdOpt || ''; + } else { + // New: registrarLlegada(tenantId, id, kmActual, userId) + tenantId = idOrTenantId; + id = tenantIdOrKm as string; + kmActual = kmOrUserId as number; + userId = userIdOpt || ''; + } + + return this.arriveAtDestination(tenantId, id, kmActual, userId); + } + + /** @deprecated Use complete or close instead */ + async registrarEntrega( + idOrTenantId: string, + tenantIdOrCompletionData: string | { kmFin?: number; kmFinal?: number; sellosLlegada?: InfoSello[]; observaciones?: string }, + completionDataOrCompletedById?: { kmFin?: number; kmFinal?: number; sellosLlegada?: InfoSello[]; observaciones?: string } | string, + completedByIdOpt?: string + ) { + // Handle legacy signature: registrarEntrega(id, tenantId, data, userId) + let tenantId: string; + let id: string; + let completionData: { kmFin?: number; kmFinal?: number; sellosLlegada?: InfoSello[]; observaciones?: string }; + let completedById: string; + + if (typeof tenantIdOrCompletionData === 'string' && typeof completionDataOrCompletedById === 'object') { + // Legacy: registrarEntrega(id, tenantId, { kmFinal, observaciones }, userId) + id = idOrTenantId; + tenantId = tenantIdOrCompletionData; + completionData = completionDataOrCompletedById; + completedById = completedByIdOpt || ''; + } else { + // New: registrarEntrega(tenantId, id, data, userId) + tenantId = idOrTenantId; + id = tenantIdOrCompletionData as string; + completionData = completionDataOrCompletedById as { kmFin?: number; kmFinal?: number; sellosLlegada?: InfoSello[] }; + completedById = completedByIdOpt || ''; + } + + return this.complete(tenantId, id, { + kmFin: completionData.kmFin || completionData.kmFinal || 0, + sellosLlegada: completionData.sellosLlegada, + }, completedById); + } + + /** @deprecated Use getActiveTrips instead */ + async getViajesEnProgreso(tenantId: string) { + return this.getActiveTrips(tenantId); + } + + /** @deprecated Use findByOperador instead */ + async getViajesOperador(tenantId: string, operadorId: string) { + return this.findByOperador(tenantId, operadorId); + } + + /** @deprecated Use findByUnidad instead */ + async getViajesUnidad(tenantId: string, unidadId: string) { + return this.findByUnidad(tenantId, unidadId); + } }