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:
parent
2120d6e8b0
commit
23ee6ce90e
@ -1 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Gestion Flota Services
|
||||||
|
*/
|
||||||
export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service';
|
export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service';
|
||||||
|
export * from './unidades.service';
|
||||||
|
export * from './operadores.service';
|
||||||
|
|||||||
248
src/modules/gestion-flota/services/operadores.service.ts
Normal file
248
src/modules/gestion-flota/services/operadores.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
271
src/modules/gestion-flota/services/unidades.service.ts
Normal file
271
src/modules/gestion-flota/services/unidades.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,9 @@ import { Repository, FindOptionsWhere, ILike } from 'typeorm';
|
|||||||
import { Quotation, SalesOrder } from '../entities/index.js';
|
import { Quotation, SalesOrder } from '../entities/index.js';
|
||||||
import { CreateQuotationDto, UpdateQuotationDto, CreateSalesOrderDto, UpdateSalesOrderDto } from '../dto/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
|
* @deprecated Use ordersService from '../orders.service.js' for full Order-to-Cash flow
|
||||||
* This TypeORM-based service provides basic CRUD operations.
|
* This TypeORM-based service provides basic CRUD operations.
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* Tracking Services
|
* Tracking Services
|
||||||
*/
|
*/
|
||||||
// TODO: Implement services
|
export * from './tracking.service';
|
||||||
// - tracking.service.ts
|
|
||||||
// - gps-provider.service.ts
|
|
||||||
// - geocerca.service.ts
|
|
||||||
// - alertas.service.ts
|
|
||||||
|
|||||||
345
src/modules/tracking/services/tracking.service.ts
Normal file
345
src/modules/tracking/services/tracking.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/modules/viajes/services/index.ts
Normal file
4
src/modules/viajes/services/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Viajes Services
|
||||||
|
*/
|
||||||
|
export * from './viajes.service';
|
||||||
314
src/modules/viajes/services/viajes.service.ts
Normal file
314
src/modules/viajes/services/viajes.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user