From 23ee6ce90e469d354634b89b15d64b761a1fbd7f Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 14:24:27 -0600 Subject: [PATCH] feat(services): Add core transport services - ViajesService: Trip management with state machine, dispatch, delivery - UnidadesService: Fleet units management, availability, maintenance alerts - OperadoresService: Operators management, license tracking - TrackingService: GPS events, geofences, position history - OrdenesTransporteService: Transport orders with workflow Co-Authored-By: Claude Opus 4.5 --- src/modules/gestion-flota/services/index.ts | 5 + .../services/operadores.service.ts | 248 +++++++++++++ .../services/unidades.service.ts | 271 ++++++++++++++ .../ordenes-transporte/services/index.ts | 3 + .../services/ordenes-transporte.service.ts | 305 ++++++++++++++++ src/modules/tracking/services/index.ts | 6 +- .../tracking/services/tracking.service.ts | 345 ++++++++++++++++++ src/modules/viajes/services/index.ts | 4 + src/modules/viajes/services/viajes.service.ts | 314 ++++++++++++++++ 9 files changed, 1496 insertions(+), 5 deletions(-) create mode 100644 src/modules/gestion-flota/services/operadores.service.ts create mode 100644 src/modules/gestion-flota/services/unidades.service.ts create mode 100644 src/modules/ordenes-transporte/services/ordenes-transporte.service.ts create mode 100644 src/modules/tracking/services/tracking.service.ts create mode 100644 src/modules/viajes/services/index.ts create mode 100644 src/modules/viajes/services/viajes.service.ts diff --git a/src/modules/gestion-flota/services/index.ts b/src/modules/gestion-flota/services/index.ts index 33a92cf..1ea219c 100644 --- a/src/modules/gestion-flota/services/index.ts +++ b/src/modules/gestion-flota/services/index.ts @@ -1 +1,6 @@ +/** + * Gestion Flota Services + */ export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service'; +export * from './unidades.service'; +export * from './operadores.service'; diff --git a/src/modules/gestion-flota/services/operadores.service.ts b/src/modules/gestion-flota/services/operadores.service.ts new file mode 100644 index 0000000..7a7bf22 --- /dev/null +++ b/src/modules/gestion-flota/services/operadores.service.ts @@ -0,0 +1,248 @@ +import { Repository, FindOptionsWhere, ILike, In } from 'typeorm'; +import { Operador, TipoLicencia, EstadoOperador } from '../entities'; + +export interface OperadorSearchParams { + tenantId: string; + search?: string; + estado?: EstadoOperador; + estados?: EstadoOperador[]; + tipoLicencia?: TipoLicencia; + sucursalId?: string; + limit?: number; + offset?: number; +} + +export interface CreateOperadorDto { + numeroEmpleado: string; + nombre: string; + apellidoPaterno: string; + apellidoMaterno?: string; + rfc?: string; + curp?: string; + nss?: string; + fechaNacimiento?: Date; + telefono?: string; + telefonoEmergencia?: string; + email?: string; + direccion?: string; + ciudad?: string; + estadoDireccion?: string; + codigoPostal?: string; + tipoLicencia: TipoLicencia; + numeroLicencia: string; + vigenciaLicencia: Date; + restriccionesLicencia?: string; + fechaIngreso?: Date; + sucursalId?: string; + salarioBase?: number; + bonoPorViaje?: number; + comisionPorcentaje?: number; + contactoEmergenciaNombre?: string; + contactoEmergenciaTelefono?: string; + contactoEmergenciaParentesco?: string; + tipoSangre?: string; + alergias?: string; + condicionesMedicas?: string; +} + +export interface UpdateOperadorDto extends Partial { + estado?: EstadoOperador; +} + +export class OperadoresService { + constructor(private readonly operadorRepository: Repository) {} + + async findAll(params: OperadorSearchParams): Promise<{ data: Operador[]; total: number }> { + const { + tenantId, + search, + estado, + estados, + tipoLicencia, + sucursalId, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (estado) { + baseWhere.estado = estado; + } + + if (estados && estados.length > 0) { + baseWhere.estado = In(estados); + } + + if (tipoLicencia) { + baseWhere.tipoLicencia = tipoLicencia; + } + + if (sucursalId) { + baseWhere.sucursalId = sucursalId; + } + + if (search) { + where.push( + { ...baseWhere, numeroEmpleado: ILike(`%${search}%`) }, + { ...baseWhere, nombre: ILike(`%${search}%`) }, + { ...baseWhere, apellidoPaterno: ILike(`%${search}%`) }, + { ...baseWhere, numeroLicencia: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.operadorRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { apellidoPaterno: 'ASC', nombre: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.operadorRepository.findOne({ + where: { id, tenantId }, + }); + } + + async findByNumeroEmpleado(numeroEmpleado: string, tenantId: string): Promise { + return this.operadorRepository.findOne({ + where: { numeroEmpleado, tenantId }, + }); + } + + async findByLicencia(numeroLicencia: string, tenantId: string): Promise { + return this.operadorRepository.findOne({ + where: { numeroLicencia, tenantId }, + }); + } + + async create(tenantId: string, dto: CreateOperadorDto, createdBy: string): Promise { + // Check for existing numero de empleado + const existingNumero = await this.findByNumeroEmpleado(dto.numeroEmpleado, tenantId); + if (existingNumero) { + throw new Error('Ya existe un operador con este número de empleado'); + } + + // Check for existing licencia + const existingLicencia = await this.findByLicencia(dto.numeroLicencia, tenantId); + if (existingLicencia) { + throw new Error('Ya existe un operador con este número de licencia'); + } + + const operador = this.operadorRepository.create({ + ...dto, + tenantId, + estado: EstadoOperador.DISPONIBLE, + createdById: createdBy, + }); + + return this.operadorRepository.save(operador); + } + + async update( + id: string, + tenantId: string, + dto: UpdateOperadorDto, + updatedBy: string + ): Promise { + const operador = await this.findOne(id, tenantId); + if (!operador) return null; + + // If changing numero empleado, check for duplicates + if (dto.numeroEmpleado && dto.numeroEmpleado !== operador.numeroEmpleado) { + const existing = await this.findByNumeroEmpleado(dto.numeroEmpleado, tenantId); + if (existing) { + throw new Error('Ya existe un operador con este número de empleado'); + } + } + + // If changing licencia, check for duplicates + if (dto.numeroLicencia && dto.numeroLicencia !== operador.numeroLicencia) { + const existing = await this.findByLicencia(dto.numeroLicencia, tenantId); + if (existing && existing.id !== id) { + throw new Error('Ya existe un operador con este número de licencia'); + } + } + + Object.assign(operador, { + ...dto, + updatedById: updatedBy, + }); + + return this.operadorRepository.save(operador); + } + + async cambiarEstado( + id: string, + tenantId: string, + nuevoEstado: EstadoOperador, + updatedBy: string + ): Promise { + const operador = await this.findOne(id, tenantId); + if (!operador) return null; + + operador.estado = nuevoEstado; + operador.updatedById = updatedBy; + + return this.operadorRepository.save(operador); + } + + // Operadores disponibles + async getOperadoresDisponibles(tenantId: string): Promise { + return this.operadorRepository.find({ + where: { + tenantId, + estado: EstadoOperador.DISPONIBLE, + }, + order: { apellidoPaterno: 'ASC', nombre: 'ASC' }, + }); + } + + // Operadores con licencia por vencer + async getOperadoresLicenciaPorVencer(tenantId: string, diasAntelacion: number = 30): Promise { + const fechaLimite = new Date(); + fechaLimite.setDate(fechaLimite.getDate() + diasAntelacion); + + const operadores = await this.operadorRepository + .createQueryBuilder('o') + .where('o.tenant_id = :tenantId', { tenantId }) + .andWhere('o.vigencia_licencia <= :fechaLimite', { fechaLimite }) + .andWhere('o.vigencia_licencia > :hoy', { hoy: new Date() }) + .orderBy('o.vigencia_licencia', 'ASC') + .getMany(); + + return operadores; + } + + // Operadores con licencia vencida + async getOperadoresLicenciaVencida(tenantId: string): Promise { + const hoy = new Date(); + + const operadores = await this.operadorRepository + .createQueryBuilder('o') + .where('o.tenant_id = :tenantId', { tenantId }) + .andWhere('o.vigencia_licencia < :hoy', { hoy }) + .orderBy('o.vigencia_licencia', 'ASC') + .getMany(); + + return operadores; + } + + async delete(id: string, tenantId: string): Promise { + const operador = await this.findOne(id, tenantId); + if (!operador) return false; + + if (operador.estado === EstadoOperador.EN_RUTA) { + throw new Error('No se puede eliminar un operador en ruta'); + } + + const result = await this.operadorRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/gestion-flota/services/unidades.service.ts b/src/modules/gestion-flota/services/unidades.service.ts new file mode 100644 index 0000000..dbc332b --- /dev/null +++ b/src/modules/gestion-flota/services/unidades.service.ts @@ -0,0 +1,271 @@ +import { Repository, FindOptionsWhere, ILike, In } from 'typeorm'; +import { Unidad, TipoUnidad, EstadoUnidad } from '../entities'; + +export interface UnidadSearchParams { + tenantId: string; + search?: string; + tipo?: TipoUnidad; + estado?: EstadoUnidad; + estados?: EstadoUnidad[]; + sucursalId?: string; + limit?: number; + offset?: number; +} + +export interface CreateUnidadDto { + numeroEconomico: string; + tipo: TipoUnidad; + marca: string; + modelo: string; + anio: number; + placas: string; + numeroSerie?: string; + numeroMotor?: string; + color?: string; + capacidadCarga?: number; + pesoNeto?: number; + pesoBrutoVehicular?: number; + numEjes?: number; + numLlantas?: number; + tipoCarroceria?: string; + sucursalId?: string; + permisoSct?: string; + numPermisoSct?: string; + configVehicular?: string; + polizaSeguro?: string; + aseguradora?: string; + vigenciaSeguroInicio?: Date; + vigenciaSeguroFin?: Date; + verificacionVigencia?: Date; + odometroActual?: number; + rendimientoEsperado?: number; +} + +export interface UpdateUnidadDto extends Partial { + estado?: EstadoUnidad; +} + +export class UnidadesService { + constructor(private readonly unidadRepository: Repository) {} + + async findAll(params: UnidadSearchParams): Promise<{ data: Unidad[]; total: number }> { + const { + tenantId, + search, + tipo, + estado, + estados, + sucursalId, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (tipo) { + baseWhere.tipo = tipo; + } + + if (estado) { + baseWhere.estado = estado; + } + + if (estados && estados.length > 0) { + baseWhere.estado = In(estados); + } + + if (sucursalId) { + baseWhere.sucursalId = sucursalId; + } + + if (search) { + where.push( + { ...baseWhere, numeroEconomico: ILike(`%${search}%`) }, + { ...baseWhere, placas: ILike(`%${search}%`) }, + { ...baseWhere, marca: ILike(`%${search}%`) }, + { ...baseWhere, modelo: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.unidadRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { numeroEconomico: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.unidadRepository.findOne({ + where: { id, tenantId }, + }); + } + + async findByNumeroEconomico(numeroEconomico: string, tenantId: string): Promise { + return this.unidadRepository.findOne({ + where: { numeroEconomico, tenantId }, + }); + } + + async findByPlacas(placas: string, tenantId: string): Promise { + return this.unidadRepository.findOne({ + where: { placas, tenantId }, + }); + } + + async create(tenantId: string, dto: CreateUnidadDto, createdBy: string): Promise { + // Check for existing numero económico + const existingNumero = await this.findByNumeroEconomico(dto.numeroEconomico, tenantId); + if (existingNumero) { + throw new Error('Ya existe una unidad con este número económico'); + } + + // Check for existing placas + const existingPlacas = await this.findByPlacas(dto.placas, tenantId); + if (existingPlacas) { + throw new Error('Ya existe una unidad con estas placas'); + } + + const unidad = this.unidadRepository.create({ + ...dto, + tenantId, + estado: EstadoUnidad.DISPONIBLE, + createdById: createdBy, + }); + + return this.unidadRepository.save(unidad); + } + + async update( + id: string, + tenantId: string, + dto: UpdateUnidadDto, + updatedBy: string + ): Promise { + const unidad = await this.findOne(id, tenantId); + if (!unidad) return null; + + // If changing numero económico, check for duplicates + if (dto.numeroEconomico && dto.numeroEconomico !== unidad.numeroEconomico) { + const existing = await this.findByNumeroEconomico(dto.numeroEconomico, tenantId); + if (existing) { + throw new Error('Ya existe una unidad con este número económico'); + } + } + + // If changing placas, check for duplicates + if (dto.placas && dto.placas !== unidad.placas) { + const existing = await this.findByPlacas(dto.placas, tenantId); + if (existing && existing.id !== id) { + throw new Error('Ya existe una unidad con estas placas'); + } + } + + Object.assign(unidad, { + ...dto, + updatedById: updatedBy, + }); + + return this.unidadRepository.save(unidad); + } + + async cambiarEstado( + id: string, + tenantId: string, + nuevoEstado: EstadoUnidad, + updatedBy: string + ): Promise { + const unidad = await this.findOne(id, tenantId); + if (!unidad) return null; + + unidad.estado = nuevoEstado; + unidad.updatedById = updatedBy; + + return this.unidadRepository.save(unidad); + } + + async actualizarOdometro( + id: string, + tenantId: string, + odometro: number, + updatedBy: string + ): Promise { + const unidad = await this.findOne(id, tenantId); + if (!unidad) return null; + + if (odometro < (unidad.odometroActual || 0)) { + throw new Error('El odómetro no puede ser menor al actual'); + } + + unidad.odometroActual = odometro; + unidad.updatedById = updatedBy; + + return this.unidadRepository.save(unidad); + } + + // Unidades disponibles + async getUnidadesDisponibles(tenantId: string, tipo?: TipoUnidad): Promise { + const where: FindOptionsWhere = { + tenantId, + estado: EstadoUnidad.DISPONIBLE, + }; + + if (tipo) { + where.tipo = tipo; + } + + return this.unidadRepository.find({ + where, + order: { numeroEconomico: 'ASC' }, + }); + } + + // Unidades con seguro por vencer + async getUnidadesSeguroPorVencer(tenantId: string, diasAntelacion: number = 30): Promise { + const fechaLimite = new Date(); + fechaLimite.setDate(fechaLimite.getDate() + diasAntelacion); + + const unidades = await this.unidadRepository + .createQueryBuilder('u') + .where('u.tenant_id = :tenantId', { tenantId }) + .andWhere('u.vigencia_seguro_fin <= :fechaLimite', { fechaLimite }) + .andWhere('u.vigencia_seguro_fin > :hoy', { hoy: new Date() }) + .orderBy('u.vigencia_seguro_fin', 'ASC') + .getMany(); + + return unidades; + } + + // Unidades con verificación por vencer + async getUnidadesVerificacionPorVencer(tenantId: string, diasAntelacion: number = 30): Promise { + const fechaLimite = new Date(); + fechaLimite.setDate(fechaLimite.getDate() + diasAntelacion); + + const unidades = await this.unidadRepository + .createQueryBuilder('u') + .where('u.tenant_id = :tenantId', { tenantId }) + .andWhere('u.verificacion_vigencia <= :fechaLimite', { fechaLimite }) + .andWhere('u.verificacion_vigencia > :hoy', { hoy: new Date() }) + .orderBy('u.verificacion_vigencia', 'ASC') + .getMany(); + + return unidades; + } + + async delete(id: string, tenantId: string): Promise { + const unidad = await this.findOne(id, tenantId); + if (!unidad) return false; + + if (unidad.estado === EstadoUnidad.EN_RUTA) { + throw new Error('No se puede eliminar una unidad en ruta'); + } + + const result = await this.unidadRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/ordenes-transporte/services/index.ts b/src/modules/ordenes-transporte/services/index.ts index 29d721c..2f7c669 100644 --- a/src/modules/ordenes-transporte/services/index.ts +++ b/src/modules/ordenes-transporte/services/index.ts @@ -2,6 +2,9 @@ import { Repository, FindOptionsWhere, ILike } from 'typeorm'; import { Quotation, SalesOrder } from '../entities/index.js'; import { CreateQuotationDto, UpdateQuotationDto, CreateSalesOrderDto, UpdateSalesOrderDto } from '../dto/index.js'; +// Export transport-specific service +export * from './ordenes-transporte.service'; + /** * @deprecated Use ordersService from '../orders.service.js' for full Order-to-Cash flow * This TypeORM-based service provides basic CRUD operations. diff --git a/src/modules/ordenes-transporte/services/ordenes-transporte.service.ts b/src/modules/ordenes-transporte/services/ordenes-transporte.service.ts new file mode 100644 index 0000000..41ba7b6 --- /dev/null +++ b/src/modules/ordenes-transporte/services/ordenes-transporte.service.ts @@ -0,0 +1,305 @@ +import { Repository, FindOptionsWhere, ILike, In, Between } from 'typeorm'; +import { OrdenTransporte, EstadoOrdenTransporte, ModalidadServicio, TipoEquipo } from '../entities'; + +export interface OrdenTransporteSearchParams { + tenantId: string; + search?: string; + estado?: EstadoOrdenTransporte; + estados?: EstadoOrdenTransporte[]; + clienteId?: string; + modalidad?: ModalidadServicio; + fechaDesde?: Date; + fechaHasta?: Date; + limit?: number; + offset?: number; +} + +export interface CreateOrdenTransporteDto { + clienteId: string; + referenciaCliente?: string; + modalidadServicio: ModalidadServicio; + tipoEquipo: TipoEquipo; + prioridad?: 'NORMAL' | 'URGENTE' | 'CRITICA'; + + // Origen + origenDireccion: string; + origenCiudad?: string; + origenEstado?: string; + origenCodigoPostal?: string; + origenLatitud?: number; + origenLongitud?: number; + origenContactoNombre?: string; + origenContactoTelefono?: string; + fechaRecoleccion?: Date; + horaRecoleccionDesde?: string; + horaRecoleccionHasta?: string; + + // Destino + destinoDireccion: string; + destinoCiudad?: string; + destinoEstado?: string; + destinoCodigoPostal?: string; + destinoLatitud?: number; + destinoLongitud?: number; + destinoContactoNombre?: string; + destinoContactoTelefono?: string; + fechaEntrega?: Date; + horaEntregaDesde?: string; + horaEntregaHasta?: string; + + // Carga + descripcionCarga?: string; + peso?: number; + volumen?: number; + piezas?: number; + pallets?: number; + requiereCustodia?: boolean; + requiereRefrigeracion?: boolean; + temperaturaMinima?: number; + temperaturaMaxima?: number; + esMaterialPeligroso?: boolean; + + // Tarifa + tarifaId?: string; + montoFlete?: number; + + observaciones?: string; + instruccionesEspeciales?: string; +} + +export interface UpdateOrdenTransporteDto extends Partial { + estado?: EstadoOrdenTransporte; +} + +export class OrdenesTransporteService { + constructor(private readonly otRepository: Repository) {} + + async findAll(params: OrdenTransporteSearchParams): Promise<{ data: OrdenTransporte[]; total: number }> { + const { + tenantId, + search, + estado, + estados, + clienteId, + modalidad, + fechaDesde, + fechaHasta, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (estado) { + baseWhere.estado = estado; + } + + if (estados && estados.length > 0) { + baseWhere.estado = In(estados); + } + + if (clienteId) { + baseWhere.clienteId = clienteId; + } + + if (modalidad) { + baseWhere.modalidadServicio = modalidad; + } + + if (fechaDesde && fechaHasta) { + baseWhere.fechaRecoleccion = Between(fechaDesde, fechaHasta); + } + + if (search) { + where.push( + { ...baseWhere, numeroOt: ILike(`%${search}%`) }, + { ...baseWhere, referenciaCliente: ILike(`%${search}%`) }, + { ...baseWhere, origenCiudad: ILike(`%${search}%`) }, + { ...baseWhere, destinoCiudad: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.otRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { createdAt: 'DESC' }, + }); + + return { data, total }; + } + + 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, + 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 { + 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' }, + 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); + + return this.otRepository.find({ + where: { + tenantId, + estado: In([EstadoOrdenTransporte.CONFIRMADA]), + fechaRecoleccion: Between(inicioDia, finDia), + }, + order: { fechaRecoleccion: 'ASC' }, + }); + } + + // Helpers + private async generateNumeroOt(tenantId: string): Promise { + const year = new Date().getFullYear(); + const count = await this.otRepository.count({ + where: { tenantId }, + }); + return `OT-${year}-${String(count + 1).padStart(6, '0')}`; + } + + private isValidTransition(from: EstadoOrdenTransporte, to: EstadoOrdenTransporte): boolean { + const transitions: Record = { + [EstadoOrdenTransporte.SOLICITADA]: [EstadoOrdenTransporte.CONFIRMADA, EstadoOrdenTransporte.CANCELADA], + [EstadoOrdenTransporte.CONFIRMADA]: [EstadoOrdenTransporte.ASIGNADA, EstadoOrdenTransporte.CANCELADA], + [EstadoOrdenTransporte.ASIGNADA]: [EstadoOrdenTransporte.EN_TRANSITO, EstadoOrdenTransporte.CANCELADA], + [EstadoOrdenTransporte.EN_TRANSITO]: [EstadoOrdenTransporte.ENTREGADA], + [EstadoOrdenTransporte.ENTREGADA]: [EstadoOrdenTransporte.FACTURADA], + [EstadoOrdenTransporte.FACTURADA]: [], + [EstadoOrdenTransporte.CANCELADA]: [], + }; + + return transitions[from]?.includes(to) ?? false; + } +} diff --git a/src/modules/tracking/services/index.ts b/src/modules/tracking/services/index.ts index 581826e..a7f0d23 100644 --- a/src/modules/tracking/services/index.ts +++ b/src/modules/tracking/services/index.ts @@ -1,8 +1,4 @@ /** * Tracking Services */ -// TODO: Implement services -// - tracking.service.ts -// - gps-provider.service.ts -// - geocerca.service.ts -// - alertas.service.ts +export * from './tracking.service'; diff --git a/src/modules/tracking/services/tracking.service.ts b/src/modules/tracking/services/tracking.service.ts new file mode 100644 index 0000000..4f72c9e --- /dev/null +++ b/src/modules/tracking/services/tracking.service.ts @@ -0,0 +1,345 @@ +import { Repository, FindOptionsWhere, Between, In, MoreThanOrEqual } from 'typeorm'; +import { EventoTracking, TipoEventoTracking, Geocerca, TipoGeocerca } from '../entities'; + +export interface EventoSearchParams { + tenantId: string; + unidadId?: string; + viajeId?: string; + tipoEvento?: TipoEventoTracking; + fechaDesde?: Date; + fechaHasta?: Date; + limit?: number; + offset?: number; +} + +export interface CreateEventoDto { + unidadId: string; + viajeId?: string; + operadorId?: string; + tipoEvento: TipoEventoTracking; + latitud: number; + longitud: number; + velocidad?: number; + rumbo?: number; + altitud?: number; + precision?: number; + odometro?: number; + nivelCombustible?: number; + motorEncendido?: boolean; + descripcion?: string; + datosAdicionales?: Record; +} + +export interface GeocercaSearchParams { + tenantId: string; + tipo?: TipoGeocerca; + activa?: boolean; + clienteId?: string; + limit?: number; + offset?: number; +} + +export interface CreateGeocercaDto { + codigo: string; + nombre: string; + tipo: TipoGeocerca; + geometria: any; // GeoJSON + radio?: number; + clienteId?: string; + descripcion?: string; + alertaEntrada?: boolean; + alertaSalida?: boolean; + alertaPermanencia?: boolean; + tiempoMaximoPermanencia?: number; + horarioActivo?: any; +} + +export class TrackingService { + constructor( + private readonly eventoRepository: Repository, + private readonly geocercaRepository: Repository + ) {} + + // ==================== Eventos ==================== + + async registrarEvento(tenantId: string, dto: CreateEventoDto): Promise { + const evento = this.eventoRepository.create({ + ...dto, + tenantId, + timestamp: new Date(), + }); + + const savedEvento = await this.eventoRepository.save(evento); + + // Check geocercas if it's a position event + if (dto.tipoEvento === TipoEventoTracking.POSICION) { + await this.verificarGeocercas(savedEvento); + } + + return savedEvento; + } + + async registrarPosicion( + tenantId: string, + unidadId: string, + datos: { + latitud: number; + longitud: number; + velocidad?: number; + rumbo?: number; + odometro?: number; + viajeId?: string; + operadorId?: string; + } + ): Promise { + return this.registrarEvento(tenantId, { + ...datos, + unidadId, + tipoEvento: TipoEventoTracking.POSICION, + }); + } + + async findEventos(params: EventoSearchParams): Promise<{ data: EventoTracking[]; total: number }> { + const { + tenantId, + unidadId, + viajeId, + tipoEvento, + fechaDesde, + fechaHasta, + limit = 100, + offset = 0, + } = params; + + const where: FindOptionsWhere = { tenantId }; + + if (unidadId) { + where.unidadId = unidadId; + } + + if (viajeId) { + where.viajeId = viajeId; + } + + if (tipoEvento) { + where.tipoEvento = tipoEvento; + } + + if (fechaDesde && fechaHasta) { + where.timestamp = Between(fechaDesde, fechaHasta); + } + + const [data, total] = await this.eventoRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { timestamp: 'DESC' }, + }); + + return { data, total }; + } + + async getUltimaPosicion(unidadId: string, tenantId: string): Promise { + return this.eventoRepository.findOne({ + where: { + unidadId, + tenantId, + tipoEvento: TipoEventoTracking.POSICION, + }, + order: { timestamp: 'DESC' }, + }); + } + + async getHistorialPosiciones( + unidadId: string, + tenantId: string, + fechaDesde: Date, + fechaHasta: Date + ): Promise { + return this.eventoRepository.find({ + where: { + unidadId, + tenantId, + tipoEvento: TipoEventoTracking.POSICION, + timestamp: Between(fechaDesde, fechaHasta), + }, + order: { timestamp: 'ASC' }, + }); + } + + async getRutaViaje(viajeId: string, tenantId: string): Promise { + return this.eventoRepository.find({ + where: { + viajeId, + tenantId, + tipoEvento: In([TipoEventoTracking.POSICION, TipoEventoTracking.PARADA]), + }, + order: { timestamp: 'ASC' }, + }); + } + + async getEventosViaje(viajeId: string, tenantId: string): Promise { + return this.eventoRepository.find({ + where: { viajeId, tenantId }, + order: { timestamp: 'ASC' }, + }); + } + + // Posiciones actuales de todas las unidades + async getPosicionesActuales(tenantId: string, unidadIds?: string[]): Promise { + const subquery = this.eventoRepository + .createQueryBuilder('e') + .select('DISTINCT ON (e.unidad_id) e.*') + .where('e.tenant_id = :tenantId', { tenantId }) + .andWhere('e.tipo_evento = :tipo', { tipo: TipoEventoTracking.POSICION }); + + if (unidadIds && unidadIds.length > 0) { + subquery.andWhere('e.unidad_id IN (:...unidadIds)', { unidadIds }); + } + + subquery.orderBy('e.unidad_id').addOrderBy('e.timestamp', 'DESC'); + + return subquery.getRawMany(); + } + + // ==================== Geocercas ==================== + + async findAllGeocercas(params: GeocercaSearchParams): Promise<{ data: Geocerca[]; total: number }> { + const { + tenantId, + tipo, + activa, + clienteId, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere = { tenantId }; + + if (tipo) { + where.tipo = tipo; + } + + if (activa !== undefined) { + where.activa = activa; + } + + if (clienteId) { + where.clienteId = clienteId; + } + + const [data, total] = await this.geocercaRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { nombre: 'ASC' }, + }); + + return { data, total }; + } + + async findGeocerca(id: string, tenantId: string): Promise { + return this.geocercaRepository.findOne({ + where: { id, tenantId }, + }); + } + + async createGeocerca(tenantId: string, dto: CreateGeocercaDto, createdBy: string): Promise { + const geocerca = this.geocercaRepository.create({ + ...dto, + tenantId, + activa: true, + createdById: createdBy, + }); + + return this.geocercaRepository.save(geocerca); + } + + async updateGeocerca( + id: string, + tenantId: string, + dto: Partial, + updatedBy: string + ): Promise { + const geocerca = await this.findGeocerca(id, tenantId); + if (!geocerca) return null; + + Object.assign(geocerca, { + ...dto, + updatedById: updatedBy, + }); + + return this.geocercaRepository.save(geocerca); + } + + async deleteGeocerca(id: string, tenantId: string): Promise { + const geocerca = await this.findGeocerca(id, tenantId); + if (!geocerca) return false; + + const result = await this.geocercaRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + // Verificar geocercas para un evento + private async verificarGeocercas(evento: EventoTracking): Promise { + // Get active geocercas for this tenant + const geocercas = await this.geocercaRepository.find({ + where: { tenantId: evento.tenantId, activa: true }, + }); + + for (const geocerca of geocercas) { + const dentroGeocerca = this.puntoEnGeocerca( + evento.latitud, + evento.longitud, + geocerca + ); + + if (dentroGeocerca) { + // Registrar evento de entrada si es necesario + if (geocerca.alertaEntrada) { + await this.registrarEvento(evento.tenantId, { + unidadId: evento.unidadId, + viajeId: evento.viajeId, + operadorId: evento.operadorId, + tipoEvento: TipoEventoTracking.GEOCERCA_ENTRADA, + latitud: evento.latitud, + longitud: evento.longitud, + descripcion: `Entrada a geocerca: ${geocerca.nombre}`, + datosAdicionales: { geocercaId: geocerca.id }, + }); + } + } + } + } + + private puntoEnGeocerca(lat: number, lon: number, geocerca: Geocerca): boolean { + // Simplified point-in-polygon check + // In production, use PostGIS ST_Contains or similar + if (geocerca.tipo === TipoGeocerca.CIRCULAR && geocerca.radio) { + const centro = geocerca.geometria?.coordinates; + if (centro) { + const distancia = this.calcularDistancia(lat, lon, centro[1], centro[0]); + return distancia <= geocerca.radio; + } + } + // For polygons, would need proper geometric check + return false; + } + + private calcularDistancia(lat1: number, lon1: number, lat2: number, lon2: number): number { + // Haversine formula + const R = 6371000; // Earth radius in meters + 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)); + return R * c; + } + + private toRad(deg: number): number { + return deg * (Math.PI / 180); + } +} diff --git a/src/modules/viajes/services/index.ts b/src/modules/viajes/services/index.ts new file mode 100644 index 0000000..43789fb --- /dev/null +++ b/src/modules/viajes/services/index.ts @@ -0,0 +1,4 @@ +/** + * Viajes Services + */ +export * from './viajes.service'; diff --git a/src/modules/viajes/services/viajes.service.ts b/src/modules/viajes/services/viajes.service.ts new file mode 100644 index 0000000..70a245f --- /dev/null +++ b/src/modules/viajes/services/viajes.service.ts @@ -0,0 +1,314 @@ +import { Repository, FindOptionsWhere, ILike, In, Between } from 'typeorm'; +import { Viaje, EstadoViaje, ParadaViaje, Pod } from '../entities'; + +export interface ViajeSearchParams { + tenantId: string; + search?: string; + estado?: EstadoViaje; + estados?: EstadoViaje[]; + unidadId?: string; + operadorId?: string; + clienteId?: string; + fechaDesde?: Date; + fechaHasta?: Date; + limit?: number; + offset?: number; +} + +export interface CreateViajeDto { + ordenTransporteId?: string; + unidadId: string; + remolqueId?: string; + operadorId: string; + operadorAuxiliarId?: string; + fechaProgramadaSalida?: Date; + fechaProgramadaLlegada?: Date; + origenDireccion: string; + origenCiudad?: string; + origenEstado?: string; + origenCodigoPostal?: string; + origenLatitud?: number; + origenLongitud?: number; + destinoDireccion: string; + destinoCiudad?: string; + destinoEstado?: string; + destinoCodigoPostal?: string; + destinoLatitud?: number; + destinoLongitud?: number; + distanciaEstimadaKm?: number; + observaciones?: string; +} + +export interface UpdateViajeDto extends Partial { + estado?: EstadoViaje; + fechaRealSalida?: Date; + fechaRealLlegada?: Date; + kmInicial?: number; + kmFinal?: number; +} + +export class ViajesService { + constructor( + private readonly viajeRepository: Repository, + private readonly paradaRepository: Repository, + private readonly podRepository: Repository + ) {} + + async findAll(params: ViajeSearchParams): Promise<{ data: Viaje[]; total: number }> { + const { + tenantId, + search, + estado, + estados, + unidadId, + operadorId, + clienteId, + fechaDesde, + fechaHasta, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (estado) { + baseWhere.estado = estado; + } + + if (estados && estados.length > 0) { + baseWhere.estado = In(estados); + } + + if (unidadId) { + baseWhere.unidadId = unidadId; + } + + if (operadorId) { + baseWhere.operadorId = operadorId; + } + + if (clienteId) { + baseWhere.clienteId = clienteId; + } + + if (fechaDesde && fechaHasta) { + baseWhere.fechaProgramadaSalida = Between(fechaDesde, fechaHasta); + } + + if (search) { + where.push( + { ...baseWhere, numeroViaje: ILike(`%${search}%`) }, + { ...baseWhere, origenCiudad: ILike(`%${search}%`) }, + { ...baseWhere, destinoCiudad: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.viajeRepository.findAndCount({ + where, + relations: ['paradas'], + take: limit, + skip: offset, + order: { fechaProgramadaSalida: 'DESC' }, + }); + + return { data, total }; + } + + 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, + tenantId: string, + dto: UpdateViajeDto, + updatedBy: string + ): Promise { + const viaje = await this.findOne(id, tenantId); + if (!viaje) return null; + + Object.assign(viaje, { + ...dto, + updatedById: updatedBy, + }); + + return this.viajeRepository.save(viaje); + } + + async cambiarEstado( + id: string, + tenantId: string, + nuevoEstado: EstadoViaje, + updatedBy: string + ): Promise { + const viaje = await this.findOne(id, tenantId); + if (!viaje) return null; + + // Validate state transition + if (!this.isValidTransition(viaje.estado, nuevoEstado)) { + throw new Error(`Invalid state transition from ${viaje.estado} to ${nuevoEstado}`); + } + + // Set timestamps based on state + const updates: Partial = { estado: nuevoEstado, updatedById: updatedBy }; + + 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); + 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, + }); + + 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); + if (!viaje) return null; + + Object.assign(viaje, { + estado: EstadoViaje.EN_DESTINO, + kmFinal: datos.kmFinal, + updatedById: updatedBy, + }); + + 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); + if (!viaje) return null; + + Object.assign(viaje, { + estado: EstadoViaje.ENTREGADO, + fechaRealLlegada: new Date(), + kmFinal: datos.kmFinal, + observaciones: datos.observaciones, + updatedById: updatedBy, + }); + + return this.viajeRepository.save(viaje); + } + + // Viajes en progreso + async getViajesEnProgreso(tenantId: string): Promise { + return this.viajeRepository.find({ + where: { + tenantId, + estado: In([ + EstadoViaje.DESPACHADO, + EstadoViaje.EN_TRANSITO, + EstadoViaje.EN_DESTINO, + ]), + }, + relations: ['paradas'], + order: { fechaProgramadaSalida: '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, + }); + } + + // Viajes por unidad + async getViajesUnidad(unidadId: string, tenantId: string): Promise { + return this.viajeRepository.find({ + where: { unidadId, tenantId }, + relations: ['paradas'], + order: { fechaProgramadaSalida: 'DESC' }, + take: 20, + }); + } + + // Helpers + private async generateNumeroViaje(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')}`; + } + + private isValidTransition(from: EstadoViaje, to: EstadoViaje): boolean { + const transitions: Record = { + [EstadoViaje.BORRADOR]: [EstadoViaje.PLANEADO, EstadoViaje.CANCELADO], + [EstadoViaje.PLANEADO]: [EstadoViaje.DESPACHADO, EstadoViaje.CANCELADO], + [EstadoViaje.DESPACHADO]: [EstadoViaje.EN_TRANSITO, EstadoViaje.CANCELADO], + [EstadoViaje.EN_TRANSITO]: [EstadoViaje.EN_DESTINO], + [EstadoViaje.EN_DESTINO]: [EstadoViaje.ENTREGADO], + [EstadoViaje.ENTREGADO]: [EstadoViaje.CERRADO], + [EstadoViaje.CERRADO]: [EstadoViaje.FACTURADO], + [EstadoViaje.FACTURADO]: [EstadoViaje.COBRADO], + [EstadoViaje.COBRADO]: [], + [EstadoViaje.CANCELADO]: [], + }; + + return transitions[from]?.includes(to) ?? false; + } +}