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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 14:24:27 -06:00
parent 2120d6e8b0
commit 23ee6ce90e
9 changed files with 1496 additions and 5 deletions

View File

@ -1 +1,6 @@
/**
* Gestion Flota Services
*/
export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service';
export * from './unidades.service';
export * from './operadores.service';

View File

@ -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<CreateOperadorDto> {
estado?: EstadoOperador;
}
export class OperadoresService {
constructor(private readonly operadorRepository: Repository<Operador>) {}
async findAll(params: OperadorSearchParams): Promise<{ data: Operador[]; total: number }> {
const {
tenantId,
search,
estado,
estados,
tipoLicencia,
sucursalId,
limit = 50,
offset = 0,
} = params;
const where: FindOptionsWhere<Operador>[] = [];
const baseWhere: FindOptionsWhere<Operador> = { 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<Operador | null> {
return this.operadorRepository.findOne({
where: { id, tenantId },
});
}
async findByNumeroEmpleado(numeroEmpleado: string, tenantId: string): Promise<Operador | null> {
return this.operadorRepository.findOne({
where: { numeroEmpleado, tenantId },
});
}
async findByLicencia(numeroLicencia: string, tenantId: string): Promise<Operador | null> {
return this.operadorRepository.findOne({
where: { numeroLicencia, tenantId },
});
}
async create(tenantId: string, dto: CreateOperadorDto, createdBy: string): Promise<Operador> {
// 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<Operador | null> {
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<Operador | null> {
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<Operador[]> {
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<Operador[]> {
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<Operador[]> {
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<boolean> {
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;
}
}

View File

@ -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<CreateUnidadDto> {
estado?: EstadoUnidad;
}
export class UnidadesService {
constructor(private readonly unidadRepository: Repository<Unidad>) {}
async findAll(params: UnidadSearchParams): Promise<{ data: Unidad[]; total: number }> {
const {
tenantId,
search,
tipo,
estado,
estados,
sucursalId,
limit = 50,
offset = 0,
} = params;
const where: FindOptionsWhere<Unidad>[] = [];
const baseWhere: FindOptionsWhere<Unidad> = { 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<Unidad | null> {
return this.unidadRepository.findOne({
where: { id, tenantId },
});
}
async findByNumeroEconomico(numeroEconomico: string, tenantId: string): Promise<Unidad | null> {
return this.unidadRepository.findOne({
where: { numeroEconomico, tenantId },
});
}
async findByPlacas(placas: string, tenantId: string): Promise<Unidad | null> {
return this.unidadRepository.findOne({
where: { placas, tenantId },
});
}
async create(tenantId: string, dto: CreateUnidadDto, createdBy: string): Promise<Unidad> {
// 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<Unidad | null> {
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<Unidad | null> {
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<Unidad | null> {
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<Unidad[]> {
const where: FindOptionsWhere<Unidad> = {
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<Unidad[]> {
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<Unidad[]> {
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<boolean> {
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;
}
}

View File

@ -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.

View File

@ -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<CreateOrdenTransporteDto> {
estado?: EstadoOrdenTransporte;
}
export class OrdenesTransporteService {
constructor(private readonly otRepository: Repository<OrdenTransporte>) {}
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<OrdenTransporte>[] = [];
const baseWhere: FindOptionsWhere<OrdenTransporte> = { 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<OrdenTransporte | null> {
return this.otRepository.findOne({
where: { id, tenantId },
});
}
async findByNumeroOt(numeroOt: string, tenantId: string): Promise<OrdenTransporte | null> {
return this.otRepository.findOne({
where: { numeroOt, tenantId },
});
}
async create(tenantId: string, dto: CreateOrdenTransporteDto, createdBy: string): Promise<OrdenTransporte> {
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<OrdenTransporte | null> {
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<OrdenTransporte | null> {
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<OrdenTransporte | null> {
return this.cambiarEstado(id, tenantId, EstadoOrdenTransporte.CONFIRMADA, updatedBy);
}
async asignar(
id: string,
tenantId: string,
viajeId: string,
updatedBy: string
): Promise<OrdenTransporte | null> {
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<OrdenTransporte | null> {
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<OrdenTransporte[]> {
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<OrdenTransporte[]> {
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<OrdenTransporte[]> {
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<string> {
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, EstadoOrdenTransporte[]> = {
[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;
}
}

View File

@ -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';

View File

@ -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<string, any>;
}
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<EventoTracking>,
private readonly geocercaRepository: Repository<Geocerca>
) {}
// ==================== Eventos ====================
async registrarEvento(tenantId: string, dto: CreateEventoDto): Promise<EventoTracking> {
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<EventoTracking> {
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<EventoTracking> = { 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<EventoTracking | null> {
return this.eventoRepository.findOne({
where: {
unidadId,
tenantId,
tipoEvento: TipoEventoTracking.POSICION,
},
order: { timestamp: 'DESC' },
});
}
async getHistorialPosiciones(
unidadId: string,
tenantId: string,
fechaDesde: Date,
fechaHasta: Date
): Promise<EventoTracking[]> {
return this.eventoRepository.find({
where: {
unidadId,
tenantId,
tipoEvento: TipoEventoTracking.POSICION,
timestamp: Between(fechaDesde, fechaHasta),
},
order: { timestamp: 'ASC' },
});
}
async getRutaViaje(viajeId: string, tenantId: string): Promise<EventoTracking[]> {
return this.eventoRepository.find({
where: {
viajeId,
tenantId,
tipoEvento: In([TipoEventoTracking.POSICION, TipoEventoTracking.PARADA]),
},
order: { timestamp: 'ASC' },
});
}
async getEventosViaje(viajeId: string, tenantId: string): Promise<EventoTracking[]> {
return this.eventoRepository.find({
where: { viajeId, tenantId },
order: { timestamp: 'ASC' },
});
}
// Posiciones actuales de todas las unidades
async getPosicionesActuales(tenantId: string, unidadIds?: string[]): Promise<EventoTracking[]> {
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<Geocerca> = { 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<Geocerca | null> {
return this.geocercaRepository.findOne({
where: { id, tenantId },
});
}
async createGeocerca(tenantId: string, dto: CreateGeocercaDto, createdBy: string): Promise<Geocerca> {
const geocerca = this.geocercaRepository.create({
...dto,
tenantId,
activa: true,
createdById: createdBy,
});
return this.geocercaRepository.save(geocerca);
}
async updateGeocerca(
id: string,
tenantId: string,
dto: Partial<CreateGeocercaDto>,
updatedBy: string
): Promise<Geocerca | null> {
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<boolean> {
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<void> {
// 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);
}
}

View File

@ -0,0 +1,4 @@
/**
* Viajes Services
*/
export * from './viajes.service';

View File

@ -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<CreateViajeDto> {
estado?: EstadoViaje;
fechaRealSalida?: Date;
fechaRealLlegada?: Date;
kmInicial?: number;
kmFinal?: number;
}
export class ViajesService {
constructor(
private readonly viajeRepository: Repository<Viaje>,
private readonly paradaRepository: Repository<ParadaViaje>,
private readonly podRepository: Repository<Pod>
) {}
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<Viaje>[] = [];
const baseWhere: FindOptionsWhere<Viaje> = { 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<Viaje | null> {
return this.viajeRepository.findOne({
where: { id, tenantId },
relations: ['paradas', 'pods'],
});
}
async findByNumero(numeroViaje: string, tenantId: string): Promise<Viaje | null> {
return this.viajeRepository.findOne({
where: { numeroViaje, tenantId },
relations: ['paradas'],
});
}
async create(tenantId: string, dto: CreateViajeDto, createdBy: string): Promise<Viaje> {
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<Viaje | null> {
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<Viaje | null> {
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<Viaje> = { 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<Viaje | null> {
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<Viaje | null> {
return this.cambiarEstado(id, tenantId, EstadoViaje.EN_TRANSITO, updatedBy);
}
async registrarLlegada(id: string, tenantId: string, datos: {
kmFinal?: number;
}, updatedBy: string): Promise<Viaje | null> {
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<Viaje | null> {
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<Viaje[]> {
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<Viaje[]> {
return this.viajeRepository.find({
where: { operadorId, tenantId },
relations: ['paradas'],
order: { fechaProgramadaSalida: 'DESC' },
take: 20,
});
}
// Viajes por unidad
async getViajesUnidad(unidadId: string, tenantId: string): Promise<Viaje[]> {
return this.viajeRepository.find({
where: { unidadId, tenantId },
relations: ['paradas'],
order: { fechaProgramadaSalida: 'DESC' },
take: 20,
});
}
// Helpers
private async generateNumeroViaje(tenantId: string): Promise<string> {
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, EstadoViaje[]> = {
[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;
}
}