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 * 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 { 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.
|
||||
|
||||
@ -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
|
||||
*/
|
||||
// TODO: Implement services
|
||||
// - tracking.service.ts
|
||||
// - gps-provider.service.ts
|
||||
// - geocerca.service.ts
|
||||
// - alertas.service.ts
|
||||
export * from './tracking.service';
|
||||
|
||||
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