erp-transportistas-backend-v2/src/modules/gestion-flota/services/unidades.service.ts
Adrian Flores Cortes 23ee6ce90e 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>
2026-01-25 14:24:27 -06:00

272 lines
7.3 KiB
TypeScript

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;
}
}