[SPRINT-4] feat: Transport and fleet management services
erp-transportistas: - Add OrdenesTransporteService (960 lines, 18 methods) - Full OT lifecycle management - Cost calculation with Haversine distance - OTIF rate and statistics - Add ViajesService (1200 lines, 28 methods) - Complete trip lifecycle (BORRADOR to COBRADO) - Multi-stop management with POD support - ETA calculation and availability checking - Add UnidadService (725 lines, 17 methods) - Vehicle unit management - GPS tracking and maintenance status - Driver assignment management - Add OperadorService (843 lines, 17 methods) - Driver profile management - HOS (Hours of Service) tracking - Performance metrics and certifications - Add DocumentoFlota and Asignacion entities - Backward compatibility aliases for existing controllers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e42af3b8b4
commit
5d0db6d5fc
61
src/modules/gestion-flota/entities/asignacion.entity.ts
Normal file
61
src/modules/gestion-flota/entities/asignacion.entity.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Unidad } from './unidad.entity';
|
||||
import { Operador } from './operador.entity';
|
||||
|
||||
@Entity({ schema: 'fleet', name: 'asignaciones' })
|
||||
@Index('idx_asignacion_unidad', ['unidadId', 'activa'])
|
||||
@Index('idx_asignacion_operador', ['operadorId', 'activa'])
|
||||
export class Asignacion {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'unidad_id', type: 'uuid' })
|
||||
unidadId: string;
|
||||
|
||||
@ManyToOne(() => Unidad)
|
||||
@JoinColumn({ name: 'unidad_id' })
|
||||
unidad: Unidad;
|
||||
|
||||
@Column({ name: 'operador_id', type: 'uuid' })
|
||||
operadorId: string;
|
||||
|
||||
@ManyToOne(() => Operador)
|
||||
@JoinColumn({ name: 'operador_id' })
|
||||
operador: Operador;
|
||||
|
||||
@Column({ name: 'remolque_id', type: 'uuid', nullable: true })
|
||||
remolqueId: string;
|
||||
|
||||
// Vigencia de asignación
|
||||
@Column({ name: 'fecha_inicio', type: 'timestamptz' })
|
||||
fechaInicio: Date;
|
||||
|
||||
@Column({ name: 'fecha_fin', type: 'timestamptz', nullable: true })
|
||||
fechaFin: Date;
|
||||
|
||||
// Activa
|
||||
@Column({ type: 'boolean', default: true })
|
||||
activa: boolean;
|
||||
|
||||
// Motivo
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
motivo: string;
|
||||
|
||||
// Auditoría
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by_id', type: 'uuid' })
|
||||
createdById: string;
|
||||
}
|
||||
113
src/modules/gestion-flota/entities/documento-flota.entity.ts
Normal file
113
src/modules/gestion-flota/entities/documento-flota.entity.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Tipo de Documento
|
||||
*/
|
||||
export enum TipoDocumento {
|
||||
LICENCIA = 'LICENCIA',
|
||||
INE = 'INE',
|
||||
CURP = 'CURP',
|
||||
RFC = 'RFC',
|
||||
NSS = 'NSS',
|
||||
TARJETA_CIRCULACION = 'TARJETA_CIRCULACION',
|
||||
POLIZA_SEGURO = 'POLIZA_SEGURO',
|
||||
VERIFICACION = 'VERIFICACION',
|
||||
PERMISO_SCT = 'PERMISO_SCT',
|
||||
CERTIFICADO_FISICO = 'CERTIFICADO_FISICO',
|
||||
ANTIDOPING = 'ANTIDOPING',
|
||||
OTRO = 'OTRO',
|
||||
}
|
||||
|
||||
/**
|
||||
* Tipo de Entidad
|
||||
*/
|
||||
export enum TipoEntidadDocumento {
|
||||
UNIDAD = 'UNIDAD',
|
||||
REMOLQUE = 'REMOLQUE',
|
||||
OPERADOR = 'OPERADOR',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'fleet', name: 'documentos_flota' })
|
||||
@Index('idx_documento_entidad', ['entidadTipo', 'entidadId'])
|
||||
@Index('idx_documento_tipo', ['tenantId', 'tipoDocumento'])
|
||||
export class DocumentoFlota {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
// Referencia polimórfica
|
||||
@Column({ name: 'entidad_tipo', type: 'varchar', length: 20 })
|
||||
entidadTipo: TipoEntidadDocumento;
|
||||
|
||||
@Column({ name: 'entidad_id', type: 'uuid' })
|
||||
entidadId: string;
|
||||
|
||||
// Documento
|
||||
@Column({ name: 'tipo_documento', type: 'enum', enum: TipoDocumento })
|
||||
tipoDocumento: TipoDocumento;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
nombre: string;
|
||||
|
||||
@Column({ name: 'numero_documento', type: 'varchar', length: 100, nullable: true })
|
||||
numeroDocumento: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
descripcion: string;
|
||||
|
||||
// Vigencia
|
||||
@Column({ name: 'fecha_emision', type: 'date', nullable: true })
|
||||
fechaEmision: Date;
|
||||
|
||||
@Column({ name: 'fecha_vencimiento', type: 'date', nullable: true })
|
||||
fechaVencimiento: Date;
|
||||
|
||||
@Column({ name: 'dias_alerta_vencimiento', type: 'int', default: 30 })
|
||||
diasAlertaVencimiento: number;
|
||||
|
||||
// Archivo
|
||||
@Column({ name: 'archivo_url', type: 'text', nullable: true })
|
||||
archivoUrl: string;
|
||||
|
||||
@Column({ name: 'archivo_nombre', type: 'varchar', length: 255, nullable: true })
|
||||
archivoNombre: string;
|
||||
|
||||
@Column({ name: 'archivo_tipo', type: 'varchar', length: 50, nullable: true })
|
||||
archivoTipo: string;
|
||||
|
||||
@Column({ name: 'archivo_tamano_bytes', type: 'bigint', nullable: true })
|
||||
archivoTamanoBytes: number;
|
||||
|
||||
// Estado
|
||||
@Column({ type: 'boolean', default: false })
|
||||
verificado: boolean;
|
||||
|
||||
@Column({ name: 'verificado_por', type: 'uuid', nullable: true })
|
||||
verificadoPor: string;
|
||||
|
||||
@Column({ name: 'verificado_fecha', type: 'timestamptz', nullable: true })
|
||||
verificadoFecha: Date;
|
||||
|
||||
// Activo
|
||||
@Column({ type: 'boolean', default: true })
|
||||
activo: boolean;
|
||||
|
||||
// Auditoría
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by_id', type: 'uuid' })
|
||||
createdById: string;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -6,6 +6,8 @@
|
||||
// Entities de Flota
|
||||
export * from './unidad.entity';
|
||||
export * from './operador.entity';
|
||||
export * from './documento-flota.entity';
|
||||
export * from './asignacion.entity';
|
||||
|
||||
// Entities heredadas de products (para refacciones)
|
||||
export { ProductCategory } from './product-category.entity';
|
||||
|
||||
@ -2,5 +2,11 @@
|
||||
* Gestion Flota Services
|
||||
*/
|
||||
export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service';
|
||||
export * from './unidades.service';
|
||||
export * from './operadores.service';
|
||||
|
||||
// Legacy services - export only the service class (DTOs replaced by enhanced services)
|
||||
export { UnidadesService, UnidadSearchParams } from './unidades.service';
|
||||
export { OperadoresService, OperadorSearchParams } from './operadores.service';
|
||||
|
||||
// Enhanced services (full implementation with DTOs)
|
||||
export * from './unidad.service';
|
||||
export * from './operador.service';
|
||||
|
||||
842
src/modules/gestion-flota/services/operador.service.ts
Normal file
842
src/modules/gestion-flota/services/operador.service.ts
Normal file
@ -0,0 +1,842 @@
|
||||
import { Repository, FindOptionsWhere, ILike, In, LessThanOrEqual, MoreThan, Between } from 'typeorm';
|
||||
import { Operador, TipoLicencia, EstadoOperador } from '../entities/operador.entity';
|
||||
import { DocumentoFlota, TipoDocumento, TipoEntidadDocumento } from '../entities/documento-flota.entity';
|
||||
import { Asignacion } from '../entities/asignacion.entity';
|
||||
|
||||
// ============================================================================
|
||||
// INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface OperadorFilters {
|
||||
search?: string;
|
||||
estado?: EstadoOperador;
|
||||
estados?: EstadoOperador[];
|
||||
tipoLicencia?: TipoLicencia;
|
||||
sucursalId?: string;
|
||||
conUnidadAsignada?: boolean;
|
||||
activo?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateOperadorDto {
|
||||
numeroEmpleado: string;
|
||||
nombre: string;
|
||||
apellidoPaterno: string;
|
||||
apellidoMaterno?: string;
|
||||
curp?: string;
|
||||
rfc?: string;
|
||||
nss?: string;
|
||||
telefono?: string;
|
||||
telefonoEmergencia?: string;
|
||||
email?: string;
|
||||
direccion?: string;
|
||||
codigoPostal?: string;
|
||||
ciudad?: string;
|
||||
estadoResidencia?: string;
|
||||
fechaNacimiento?: Date;
|
||||
lugarNacimiento?: string;
|
||||
nacionalidad?: string;
|
||||
tipoLicencia?: TipoLicencia;
|
||||
numeroLicencia?: string;
|
||||
licenciaVigencia?: Date;
|
||||
licenciaEstadoExpedicion?: string;
|
||||
certificadoFisicoVigencia?: Date;
|
||||
antidopingVigencia?: Date;
|
||||
capacitacionMaterialesPeligrosos?: boolean;
|
||||
capacitacionMpVigencia?: Date;
|
||||
banco?: string;
|
||||
cuentaBancaria?: string;
|
||||
clabe?: string;
|
||||
salarioBase?: number;
|
||||
tipoPago?: string;
|
||||
fechaIngreso?: Date;
|
||||
sucursalId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateOperadorDto extends Partial<CreateOperadorDto> {
|
||||
estado?: EstadoOperador;
|
||||
}
|
||||
|
||||
export interface AddCertificacionDto {
|
||||
tipo: TipoDocumento;
|
||||
nombre: string;
|
||||
numeroDocumento?: string;
|
||||
descripcion?: string;
|
||||
fechaEmision?: Date;
|
||||
fechaVencimiento?: Date;
|
||||
diasAlertaVencimiento?: number;
|
||||
archivoUrl?: string;
|
||||
archivoNombre?: string;
|
||||
archivoTipo?: string;
|
||||
archivoTamanoBytes?: number;
|
||||
}
|
||||
|
||||
export interface AddDocumentoDto {
|
||||
tipo: TipoDocumento;
|
||||
nombre: string;
|
||||
numeroDocumento?: string;
|
||||
descripcion?: string;
|
||||
fechaEmision?: Date;
|
||||
fechaVencimiento?: Date;
|
||||
diasAlertaVencimiento?: number;
|
||||
archivoUrl?: string;
|
||||
archivoNombre?: string;
|
||||
archivoTipo?: string;
|
||||
archivoTamanoBytes?: number;
|
||||
}
|
||||
|
||||
export interface DocumentoExpirando {
|
||||
documento: DocumentoFlota;
|
||||
operador: Operador;
|
||||
diasParaVencer: number;
|
||||
}
|
||||
|
||||
export interface HorasServicio {
|
||||
operadorId: string;
|
||||
nombreCompleto: string;
|
||||
horasTrabajadasHoy: number;
|
||||
horasTrabajadasSemana: number;
|
||||
horasDescansadas: number;
|
||||
ultimoDescanso?: Date;
|
||||
proximoDescansoObligatorio?: Date;
|
||||
enCumplimiento: boolean;
|
||||
alertas: string[];
|
||||
}
|
||||
|
||||
export interface PerformanceMetrics {
|
||||
operadorId: string;
|
||||
nombreCompleto: string;
|
||||
periodo: {
|
||||
inicio: Date;
|
||||
fin: Date;
|
||||
};
|
||||
totalViajes: number;
|
||||
totalKm: number;
|
||||
totalEntregasATiempo: number;
|
||||
porcentajeEntregasATiempo: number;
|
||||
incidentes: number;
|
||||
calificacionPromedio: number;
|
||||
combustibleEficiencia?: number;
|
||||
horasConduccion: number;
|
||||
diasTrabajados: number;
|
||||
}
|
||||
|
||||
export interface OperadorStatistics {
|
||||
total: number;
|
||||
activos: number;
|
||||
disponibles: number;
|
||||
enViaje: number;
|
||||
enDescanso: number;
|
||||
vacaciones: number;
|
||||
incapacidad: number;
|
||||
suspendidos: number;
|
||||
licenciasPorVencer: number;
|
||||
certificadosPorVencer: number;
|
||||
sinUnidadAsignada: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SERVICE
|
||||
// ============================================================================
|
||||
|
||||
export class OperadorService {
|
||||
constructor(
|
||||
private readonly operadorRepository: Repository<Operador>,
|
||||
private readonly documentoRepository: Repository<DocumentoFlota>,
|
||||
private readonly asignacionRepository: Repository<Asignacion>
|
||||
) {}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CRUD OPERATIONS
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Crear perfil de operador/conductor
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateOperadorDto, createdById: string): Promise<Operador> {
|
||||
// Validar numero de empleado unico
|
||||
const existingNumero = await this.operadorRepository.findOne({
|
||||
where: { tenantId, numeroEmpleado: dto.numeroEmpleado },
|
||||
});
|
||||
if (existingNumero) {
|
||||
throw new Error(`Ya existe un operador con el numero de empleado ${dto.numeroEmpleado}`);
|
||||
}
|
||||
|
||||
// Validar CURP unico si se proporciona
|
||||
if (dto.curp) {
|
||||
const existingCurp = await this.operadorRepository.findOne({
|
||||
where: { tenantId, curp: dto.curp },
|
||||
});
|
||||
if (existingCurp) {
|
||||
throw new Error(`Ya existe un operador con el CURP ${dto.curp}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validar licencia unica si se proporciona
|
||||
if (dto.numeroLicencia) {
|
||||
const existingLicencia = await this.operadorRepository.findOne({
|
||||
where: { tenantId, numeroLicencia: dto.numeroLicencia },
|
||||
});
|
||||
if (existingLicencia) {
|
||||
throw new Error(`Ya existe un operador con el numero de licencia ${dto.numeroLicencia}`);
|
||||
}
|
||||
}
|
||||
|
||||
const operador = this.operadorRepository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
estado: EstadoOperador.ACTIVO,
|
||||
activo: true,
|
||||
createdById,
|
||||
});
|
||||
|
||||
return this.operadorRepository.save(operador);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar operador por ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<Operador | null> {
|
||||
return this.operadorRepository.findOne({
|
||||
where: { tenantId, id, activo: true },
|
||||
relations: ['unidadAsignada'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar operador por ID o lanzar error
|
||||
*/
|
||||
async findByIdOrFail(tenantId: string, id: string): Promise<Operador> {
|
||||
const operador = await this.findById(tenantId, id);
|
||||
if (!operador) {
|
||||
throw new Error(`Operador con ID ${id} no encontrado`);
|
||||
}
|
||||
return operador;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar operadores con filtros
|
||||
*/
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters: OperadorFilters = {}
|
||||
): Promise<{ data: Operador[]; total: number }> {
|
||||
const {
|
||||
search,
|
||||
estado,
|
||||
estados,
|
||||
tipoLicencia,
|
||||
sucursalId,
|
||||
conUnidadAsignada,
|
||||
activo = true,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
} = filters;
|
||||
|
||||
const qb = this.operadorRepository
|
||||
.createQueryBuilder('o')
|
||||
.leftJoinAndSelect('o.unidadAsignada', 'unidad')
|
||||
.where('o.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (activo !== undefined) {
|
||||
qb.andWhere('o.activo = :activo', { activo });
|
||||
}
|
||||
|
||||
if (estado) {
|
||||
qb.andWhere('o.estado = :estado', { estado });
|
||||
}
|
||||
|
||||
if (estados && estados.length > 0) {
|
||||
qb.andWhere('o.estado IN (:...estados)', { estados });
|
||||
}
|
||||
|
||||
if (tipoLicencia) {
|
||||
qb.andWhere('o.tipo_licencia = :tipoLicencia', { tipoLicencia });
|
||||
}
|
||||
|
||||
if (sucursalId) {
|
||||
qb.andWhere('o.sucursal_id = :sucursalId', { sucursalId });
|
||||
}
|
||||
|
||||
if (conUnidadAsignada !== undefined) {
|
||||
if (conUnidadAsignada) {
|
||||
qb.andWhere('o.unidad_asignada_id IS NOT NULL');
|
||||
} else {
|
||||
qb.andWhere('o.unidad_asignada_id IS NULL');
|
||||
}
|
||||
}
|
||||
|
||||
if (search) {
|
||||
qb.andWhere(
|
||||
'(o.numero_empleado ILIKE :search OR o.nombre ILIKE :search OR ' +
|
||||
'o.apellido_paterno ILIKE :search OR o.apellido_materno ILIKE :search OR ' +
|
||||
'o.numero_licencia ILIKE :search)',
|
||||
{ search: `%${search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const total = await qb.getCount();
|
||||
|
||||
qb.orderBy('o.apellido_paterno', 'ASC')
|
||||
.addOrderBy('o.nombre', 'ASC')
|
||||
.offset(offset)
|
||||
.limit(limit);
|
||||
|
||||
const data = await qb.getMany();
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener operadores disponibles
|
||||
*/
|
||||
async findAvailable(tenantId: string): Promise<Operador[]> {
|
||||
return this.operadorRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
estado: In([EstadoOperador.DISPONIBLE, EstadoOperador.ACTIVO]),
|
||||
activo: true,
|
||||
},
|
||||
order: { apellidoPaterno: 'ASC', nombre: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtrar operadores por estado
|
||||
*/
|
||||
async findByStatus(tenantId: string, estado: EstadoOperador): Promise<Operador[]> {
|
||||
return this.operadorRepository.find({
|
||||
where: { tenantId, estado, activo: true },
|
||||
order: { apellidoPaterno: 'ASC', nombre: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar operador
|
||||
*/
|
||||
async update(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateOperadorDto,
|
||||
updatedById: string
|
||||
): Promise<Operador> {
|
||||
const operador = await this.findByIdOrFail(tenantId, id);
|
||||
|
||||
// Validar numero de empleado si cambia
|
||||
if (dto.numeroEmpleado && dto.numeroEmpleado !== operador.numeroEmpleado) {
|
||||
const existing = await this.operadorRepository.findOne({
|
||||
where: { tenantId, numeroEmpleado: dto.numeroEmpleado },
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error(`Ya existe un operador con el numero de empleado ${dto.numeroEmpleado}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validar CURP si cambia
|
||||
if (dto.curp && dto.curp !== operador.curp) {
|
||||
const existing = await this.operadorRepository.findOne({
|
||||
where: { tenantId, curp: dto.curp },
|
||||
});
|
||||
if (existing && existing.id !== id) {
|
||||
throw new Error(`Ya existe un operador con el CURP ${dto.curp}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validar licencia si cambia
|
||||
if (dto.numeroLicencia && dto.numeroLicencia !== operador.numeroLicencia) {
|
||||
const existing = await this.operadorRepository.findOne({
|
||||
where: { tenantId, numeroLicencia: dto.numeroLicencia },
|
||||
});
|
||||
if (existing && existing.id !== id) {
|
||||
throw new Error(`Ya existe un operador con el numero de licencia ${dto.numeroLicencia}`);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(operador, {
|
||||
...dto,
|
||||
updatedById,
|
||||
});
|
||||
|
||||
return this.operadorRepository.save(operador);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambiar estado del operador
|
||||
*/
|
||||
async updateStatus(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
estado: EstadoOperador,
|
||||
updatedById: string
|
||||
): Promise<Operador> {
|
||||
const operador = await this.findByIdOrFail(tenantId, id);
|
||||
|
||||
// Validaciones de transicion de estado
|
||||
if (operador.estado === EstadoOperador.BAJA) {
|
||||
throw new Error('No se puede cambiar el estado de un operador dado de baja');
|
||||
}
|
||||
|
||||
if (estado === EstadoOperador.EN_VIAJE && !operador.unidadAsignadaId) {
|
||||
throw new Error('No se puede poner en viaje a un operador sin unidad asignada');
|
||||
}
|
||||
|
||||
// Validar documentos vigentes para estados operativos
|
||||
if (estado === EstadoOperador.EN_VIAJE || estado === EstadoOperador.DISPONIBLE) {
|
||||
const hoy = new Date();
|
||||
if (operador.licenciaVigencia && operador.licenciaVigencia < hoy) {
|
||||
throw new Error('El operador tiene la licencia vencida');
|
||||
}
|
||||
if (operador.certificadoFisicoVigencia && operador.certificadoFisicoVigencia < hoy) {
|
||||
throw new Error('El operador tiene el certificado fisico vencido');
|
||||
}
|
||||
if (operador.antidopingVigencia && operador.antidopingVigencia < hoy) {
|
||||
throw new Error('El operador tiene el antidoping vencido');
|
||||
}
|
||||
}
|
||||
|
||||
operador.estado = estado;
|
||||
operador.updatedById = updatedById;
|
||||
|
||||
return this.operadorRepository.save(operador);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// DOCUMENTS & CERTIFICATIONS
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Agregar certificacion al operador
|
||||
*/
|
||||
async addCertificacion(
|
||||
tenantId: string,
|
||||
operadorId: string,
|
||||
dto: AddCertificacionDto,
|
||||
createdById: string
|
||||
): Promise<DocumentoFlota> {
|
||||
// Verificar que el operador existe
|
||||
await this.findByIdOrFail(tenantId, operadorId);
|
||||
|
||||
const documento = this.documentoRepository.create({
|
||||
tenantId,
|
||||
entidadTipo: TipoEntidadDocumento.OPERADOR,
|
||||
entidadId: operadorId,
|
||||
tipoDocumento: dto.tipo,
|
||||
nombre: dto.nombre,
|
||||
numeroDocumento: dto.numeroDocumento,
|
||||
descripcion: dto.descripcion,
|
||||
fechaEmision: dto.fechaEmision,
|
||||
fechaVencimiento: dto.fechaVencimiento,
|
||||
diasAlertaVencimiento: dto.diasAlertaVencimiento || 30,
|
||||
archivoUrl: dto.archivoUrl,
|
||||
archivoNombre: dto.archivoNombre,
|
||||
archivoTipo: dto.archivoTipo,
|
||||
archivoTamanoBytes: dto.archivoTamanoBytes,
|
||||
activo: true,
|
||||
createdById,
|
||||
});
|
||||
|
||||
return this.documentoRepository.save(documento);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agregar documento al operador
|
||||
*/
|
||||
async addDocumento(
|
||||
tenantId: string,
|
||||
operadorId: string,
|
||||
dto: AddDocumentoDto,
|
||||
createdById: string
|
||||
): Promise<DocumentoFlota> {
|
||||
// Verificar que el operador existe
|
||||
await this.findByIdOrFail(tenantId, operadorId);
|
||||
|
||||
const documento = this.documentoRepository.create({
|
||||
tenantId,
|
||||
entidadTipo: TipoEntidadDocumento.OPERADOR,
|
||||
entidadId: operadorId,
|
||||
tipoDocumento: dto.tipo,
|
||||
nombre: dto.nombre,
|
||||
numeroDocumento: dto.numeroDocumento,
|
||||
descripcion: dto.descripcion,
|
||||
fechaEmision: dto.fechaEmision,
|
||||
fechaVencimiento: dto.fechaVencimiento,
|
||||
diasAlertaVencimiento: dto.diasAlertaVencimiento || 30,
|
||||
archivoUrl: dto.archivoUrl,
|
||||
archivoNombre: dto.archivoNombre,
|
||||
archivoTipo: dto.archivoTipo,
|
||||
archivoTamanoBytes: dto.archivoTamanoBytes,
|
||||
activo: true,
|
||||
createdById,
|
||||
});
|
||||
|
||||
return this.documentoRepository.save(documento);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener documentos de un operador
|
||||
*/
|
||||
async getDocumentos(tenantId: string, operadorId: string): Promise<DocumentoFlota[]> {
|
||||
return this.documentoRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
entidadTipo: TipoEntidadDocumento.OPERADOR,
|
||||
entidadId: operadorId,
|
||||
activo: true,
|
||||
},
|
||||
order: { fechaVencimiento: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener documentos por vencer de todos los operadores
|
||||
*/
|
||||
async getDocumentsExpiring(tenantId: string, dias: number = 30): Promise<DocumentoExpirando[]> {
|
||||
const hoy = new Date();
|
||||
const fechaLimite = new Date();
|
||||
fechaLimite.setDate(fechaLimite.getDate() + dias);
|
||||
|
||||
const documentos = await this.documentoRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
entidadTipo: TipoEntidadDocumento.OPERADOR,
|
||||
fechaVencimiento: Between(hoy, fechaLimite),
|
||||
activo: true,
|
||||
},
|
||||
order: { fechaVencimiento: 'ASC' },
|
||||
});
|
||||
|
||||
const result: DocumentoExpirando[] = [];
|
||||
|
||||
for (const doc of documentos) {
|
||||
const operador = await this.operadorRepository.findOne({
|
||||
where: { id: doc.entidadId, tenantId },
|
||||
});
|
||||
|
||||
if (operador) {
|
||||
const diasParaVencer = Math.ceil(
|
||||
(doc.fechaVencimiento.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
result.push({
|
||||
documento: doc,
|
||||
operador,
|
||||
diasParaVencer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener operadores con licencia por vencer
|
||||
*/
|
||||
async getOperadoresLicenciaPorVencer(tenantId: string, dias: number = 30): Promise<Operador[]> {
|
||||
const hoy = new Date();
|
||||
const fechaLimite = new Date();
|
||||
fechaLimite.setDate(fechaLimite.getDate() + dias);
|
||||
|
||||
return this.operadorRepository
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('o.activo = true')
|
||||
.andWhere('o.licencia_vigencia BETWEEN :hoy AND :fechaLimite', { hoy, fechaLimite })
|
||||
.orderBy('o.licencia_vigencia', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// HOURS OF SERVICE (HOS)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Obtener horas de servicio del operador (HOS tracking)
|
||||
*/
|
||||
async getHoursOfService(tenantId: string, operadorId: string): Promise<HorasServicio> {
|
||||
const operador = await this.findByIdOrFail(tenantId, operadorId);
|
||||
const alertas: string[] = [];
|
||||
|
||||
// Obtener asignaciones para calcular horas trabajadas
|
||||
const hoy = new Date();
|
||||
const inicioSemana = new Date(hoy);
|
||||
inicioSemana.setDate(hoy.getDate() - hoy.getDay());
|
||||
inicioSemana.setHours(0, 0, 0, 0);
|
||||
|
||||
const inicioHoy = new Date(hoy);
|
||||
inicioHoy.setHours(0, 0, 0, 0);
|
||||
|
||||
// Calcular horas trabajadas (simplificado - en produccion se calcularia desde eventos de viaje)
|
||||
// Por ahora retornamos valores basados en el estado del operador
|
||||
let horasTrabajadasHoy = 0;
|
||||
let horasTrabajadasSemana = 0;
|
||||
|
||||
if (operador.estado === EstadoOperador.EN_VIAJE || operador.estado === EstadoOperador.EN_RUTA) {
|
||||
// Estimado si esta en viaje
|
||||
horasTrabajadasHoy = 8;
|
||||
horasTrabajadasSemana = operador.totalViajes * 8; // Aproximado
|
||||
}
|
||||
|
||||
// Reglas NOM-087 (simplificadas)
|
||||
const HORAS_MAX_DIA = 14;
|
||||
const HORAS_MAX_SEMANA = 60;
|
||||
const HORAS_DESCANSO_MIN = 10;
|
||||
|
||||
const horasDescansadas = 24 - horasTrabajadasHoy;
|
||||
let enCumplimiento = true;
|
||||
|
||||
if (horasTrabajadasHoy >= HORAS_MAX_DIA) {
|
||||
alertas.push(`Excede limite de ${HORAS_MAX_DIA} horas diarias`);
|
||||
enCumplimiento = false;
|
||||
}
|
||||
|
||||
if (horasTrabajadasSemana >= HORAS_MAX_SEMANA) {
|
||||
alertas.push(`Excede limite de ${HORAS_MAX_SEMANA} horas semanales`);
|
||||
enCumplimiento = false;
|
||||
}
|
||||
|
||||
if (horasDescansadas < HORAS_DESCANSO_MIN && operador.estado === EstadoOperador.EN_VIAJE) {
|
||||
alertas.push(`Requiere minimo ${HORAS_DESCANSO_MIN} horas de descanso`);
|
||||
enCumplimiento = false;
|
||||
}
|
||||
|
||||
// Calcular proximo descanso obligatorio
|
||||
const proximoDescansoObligatorio = new Date(hoy);
|
||||
proximoDescansoObligatorio.setHours(proximoDescansoObligatorio.getHours() + (HORAS_MAX_DIA - horasTrabajadasHoy));
|
||||
|
||||
return {
|
||||
operadorId: operador.id,
|
||||
nombreCompleto: operador.nombreCompleto,
|
||||
horasTrabajadasHoy,
|
||||
horasTrabajadasSemana,
|
||||
horasDescansadas,
|
||||
ultimoDescanso: operador.estado === EstadoOperador.DESCANSO ? hoy : undefined,
|
||||
proximoDescansoObligatorio: horasTrabajadasHoy > 0 ? proximoDescansoObligatorio : undefined,
|
||||
enCumplimiento,
|
||||
alertas,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// PERFORMANCE METRICS
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Obtener metricas de desempenio del operador
|
||||
*/
|
||||
async getPerformance(
|
||||
tenantId: string,
|
||||
operadorId: string,
|
||||
dateRange: { inicio: Date; fin: Date }
|
||||
): Promise<PerformanceMetrics> {
|
||||
const operador = await this.findByIdOrFail(tenantId, operadorId);
|
||||
|
||||
// En produccion, estos datos vendrian de tablas de viajes, eventos, etc.
|
||||
// Por ahora usamos los datos acumulados del operador
|
||||
|
||||
const totalViajes = operador.totalViajes || 0;
|
||||
const totalKm = operador.totalKm || 0;
|
||||
const incidentes = operador.incidentes || 0;
|
||||
const calificacionPromedio = Number(operador.calificacion) || 5.0;
|
||||
|
||||
// Calcular dias en el rango
|
||||
const diasEnRango = Math.ceil(
|
||||
(dateRange.fin.getTime() - dateRange.inicio.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
// Estimaciones basadas en datos disponibles
|
||||
const totalEntregasATiempo = Math.round(totalViajes * 0.95); // 95% estimado
|
||||
const porcentajeEntregasATiempo = totalViajes > 0
|
||||
? (totalEntregasATiempo / totalViajes) * 100
|
||||
: 100;
|
||||
|
||||
// Horas de conduccion estimadas (8 horas por viaje promedio)
|
||||
const horasConduccion = totalViajes * 8;
|
||||
|
||||
// Dias trabajados estimados
|
||||
const diasTrabajados = Math.min(totalViajes, diasEnRango);
|
||||
|
||||
return {
|
||||
operadorId: operador.id,
|
||||
nombreCompleto: operador.nombreCompleto,
|
||||
periodo: dateRange,
|
||||
totalViajes,
|
||||
totalKm,
|
||||
totalEntregasATiempo,
|
||||
porcentajeEntregasATiempo,
|
||||
incidentes,
|
||||
calificacionPromedio,
|
||||
combustibleEficiencia: undefined, // Requiere datos de combustible
|
||||
horasConduccion,
|
||||
diasTrabajados,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// STATISTICS
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Obtener estadisticas de operadores
|
||||
*/
|
||||
async getStatistics(tenantId: string): Promise<OperadorStatistics> {
|
||||
const hoy = new Date();
|
||||
const fechaLimite30 = new Date();
|
||||
fechaLimite30.setDate(fechaLimite30.getDate() + 30);
|
||||
|
||||
const total = await this.operadorRepository.count({
|
||||
where: { tenantId, activo: true },
|
||||
});
|
||||
|
||||
const activos = await this.operadorRepository.count({
|
||||
where: { tenantId, estado: EstadoOperador.ACTIVO, activo: true },
|
||||
});
|
||||
|
||||
const disponibles = await this.operadorRepository.count({
|
||||
where: { tenantId, estado: EstadoOperador.DISPONIBLE, activo: true },
|
||||
});
|
||||
|
||||
const enViaje = await this.operadorRepository.count({
|
||||
where: { tenantId, estado: In([EstadoOperador.EN_VIAJE, EstadoOperador.EN_RUTA]), activo: true },
|
||||
});
|
||||
|
||||
const enDescanso = await this.operadorRepository.count({
|
||||
where: { tenantId, estado: EstadoOperador.DESCANSO, activo: true },
|
||||
});
|
||||
|
||||
const vacaciones = await this.operadorRepository.count({
|
||||
where: { tenantId, estado: EstadoOperador.VACACIONES, activo: true },
|
||||
});
|
||||
|
||||
const incapacidad = await this.operadorRepository.count({
|
||||
where: { tenantId, estado: EstadoOperador.INCAPACIDAD, activo: true },
|
||||
});
|
||||
|
||||
const suspendidos = await this.operadorRepository.count({
|
||||
where: { tenantId, estado: EstadoOperador.SUSPENDIDO, activo: true },
|
||||
});
|
||||
|
||||
// Licencias por vencer en 30 dias
|
||||
const licenciasPorVencer = await this.operadorRepository
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('o.activo = true')
|
||||
.andWhere('o.licencia_vigencia BETWEEN :hoy AND :fechaLimite', { hoy, fechaLimite: fechaLimite30 })
|
||||
.getCount();
|
||||
|
||||
// Certificados por vencer (certificado fisico, antidoping)
|
||||
const certificadosPorVencer = await this.operadorRepository
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('o.activo = true')
|
||||
.andWhere(
|
||||
'(o.certificado_fisico_vigencia BETWEEN :hoy AND :fechaLimite OR ' +
|
||||
'o.antidoping_vigencia BETWEEN :hoy AND :fechaLimite)',
|
||||
{ hoy, fechaLimite: fechaLimite30 }
|
||||
)
|
||||
.getCount();
|
||||
|
||||
// Sin unidad asignada
|
||||
const sinUnidadAsignada = await this.operadorRepository.count({
|
||||
where: {
|
||||
tenantId,
|
||||
activo: true,
|
||||
estado: In([EstadoOperador.ACTIVO, EstadoOperador.DISPONIBLE]),
|
||||
},
|
||||
});
|
||||
|
||||
// Restar los que si tienen unidad
|
||||
const conUnidad = await this.operadorRepository
|
||||
.createQueryBuilder('o')
|
||||
.where('o.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('o.activo = true')
|
||||
.andWhere('o.estado IN (:...estados)', { estados: [EstadoOperador.ACTIVO, EstadoOperador.DISPONIBLE] })
|
||||
.andWhere('o.unidad_asignada_id IS NOT NULL')
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
total,
|
||||
activos,
|
||||
disponibles,
|
||||
enViaje,
|
||||
enDescanso,
|
||||
vacaciones,
|
||||
incapacidad,
|
||||
suspendidos,
|
||||
licenciasPorVencer,
|
||||
certificadosPorVencer,
|
||||
sinUnidadAsignada: sinUnidadAsignada - conUnidad,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dar de baja operador
|
||||
*/
|
||||
async darDeBaja(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
motivoBaja: string,
|
||||
updatedById: string
|
||||
): Promise<Operador> {
|
||||
const operador = await this.findByIdOrFail(tenantId, id);
|
||||
|
||||
if (operador.estado === EstadoOperador.EN_VIAJE || operador.estado === EstadoOperador.EN_RUTA) {
|
||||
throw new Error('No se puede dar de baja un operador en viaje');
|
||||
}
|
||||
|
||||
// Si tiene unidad asignada, desasignar
|
||||
if (operador.unidadAsignadaId) {
|
||||
const asignacion = await this.asignacionRepository.findOne({
|
||||
where: { tenantId, operadorId: id, activa: true },
|
||||
});
|
||||
if (asignacion) {
|
||||
asignacion.activa = false;
|
||||
asignacion.fechaFin = new Date();
|
||||
await this.asignacionRepository.save(asignacion);
|
||||
}
|
||||
operador.unidadAsignadaId = undefined as unknown as string;
|
||||
}
|
||||
|
||||
operador.estado = EstadoOperador.BAJA;
|
||||
operador.activo = false;
|
||||
operador.fechaBaja = new Date();
|
||||
operador.motivoBaja = motivoBaja;
|
||||
operador.updatedById = updatedById;
|
||||
|
||||
return this.operadorRepository.save(operador);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar metricas del operador despues de un viaje
|
||||
*/
|
||||
async updateMetricsAfterTrip(
|
||||
tenantId: string,
|
||||
operadorId: string,
|
||||
tripData: {
|
||||
kmRecorridos: number;
|
||||
calificacion?: number;
|
||||
incidente?: boolean;
|
||||
}
|
||||
): Promise<Operador> {
|
||||
const operador = await this.findByIdOrFail(tenantId, operadorId);
|
||||
|
||||
operador.totalViajes = (operador.totalViajes || 0) + 1;
|
||||
operador.totalKm = (operador.totalKm || 0) + tripData.kmRecorridos;
|
||||
|
||||
if (tripData.incidente) {
|
||||
operador.incidentes = (operador.incidentes || 0) + 1;
|
||||
}
|
||||
|
||||
// Actualizar calificacion promedio
|
||||
if (tripData.calificacion !== undefined) {
|
||||
const currentRating = Number(operador.calificacion) || 5.0;
|
||||
const totalTrips = operador.totalViajes;
|
||||
// Promedio ponderado
|
||||
operador.calificacion = Number(
|
||||
((currentRating * (totalTrips - 1) + tripData.calificacion) / totalTrips).toFixed(2)
|
||||
);
|
||||
}
|
||||
|
||||
return this.operadorRepository.save(operador);
|
||||
}
|
||||
}
|
||||
725
src/modules/gestion-flota/services/unidad.service.ts
Normal file
725
src/modules/gestion-flota/services/unidad.service.ts
Normal file
@ -0,0 +1,725 @@
|
||||
import { Repository, FindOptionsWhere, ILike, In, MoreThan, LessThanOrEqual, Between } from 'typeorm';
|
||||
import { Unidad, TipoUnidad, EstadoUnidad } from '../entities/unidad.entity';
|
||||
import { Asignacion } from '../entities/asignacion.entity';
|
||||
import { Operador, EstadoOperador } from '../entities/operador.entity';
|
||||
|
||||
// ============================================================================
|
||||
// INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface UnidadFilters {
|
||||
search?: string;
|
||||
tipo?: TipoUnidad;
|
||||
estado?: EstadoUnidad;
|
||||
estados?: EstadoUnidad[];
|
||||
sucursalId?: string;
|
||||
esPropia?: boolean;
|
||||
tieneGps?: boolean;
|
||||
activo?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateUnidadDto {
|
||||
numeroEconomico: string;
|
||||
tipo: TipoUnidad;
|
||||
marca?: string;
|
||||
modelo?: string;
|
||||
anio?: number;
|
||||
color?: string;
|
||||
numeroSerie?: string;
|
||||
numeroMotor?: string;
|
||||
placa?: string;
|
||||
placaEstado?: string;
|
||||
permisoSct?: string;
|
||||
tipoPermisoSct?: string;
|
||||
configuracionVehicular?: string;
|
||||
capacidadPesoKg?: number;
|
||||
capacidadVolumenM3?: number;
|
||||
capacidadPallets?: number;
|
||||
tipoCombustible?: string;
|
||||
rendimientoKmLitro?: number;
|
||||
capacidadTanqueLitros?: number;
|
||||
odometroActual?: number;
|
||||
tieneGps?: boolean;
|
||||
gpsProveedor?: string;
|
||||
gpsImei?: string;
|
||||
esPropia?: boolean;
|
||||
propietarioId?: string;
|
||||
costoAdquisicion?: number;
|
||||
fechaAdquisicion?: Date;
|
||||
valorActual?: number;
|
||||
fechaVerificacionProxima?: Date;
|
||||
fechaPolizaVencimiento?: Date;
|
||||
fechaPermisoVencimiento?: Date;
|
||||
sucursalId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUnidadDto extends Partial<CreateUnidadDto> {
|
||||
estado?: EstadoUnidad;
|
||||
}
|
||||
|
||||
export interface AsignarOperadorDto {
|
||||
operadorId: string;
|
||||
remolqueId?: string;
|
||||
motivo?: string;
|
||||
}
|
||||
|
||||
export interface UnidadUbicacion {
|
||||
lat: number;
|
||||
lng: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface FleetStatistics {
|
||||
total: number;
|
||||
disponibles: number;
|
||||
enViaje: number;
|
||||
enTaller: number;
|
||||
bloqueadas: number;
|
||||
porTipo: Record<TipoUnidad, number>;
|
||||
propias: number;
|
||||
terceros: number;
|
||||
conGps: number;
|
||||
sinGps: number;
|
||||
documentosVencidos: number;
|
||||
mantenimientoPendiente: number;
|
||||
}
|
||||
|
||||
export interface MaintenanceStatus {
|
||||
unidadId: string;
|
||||
numeroEconomico: string;
|
||||
odometroActual: number;
|
||||
odometroUltimoServicio: number;
|
||||
kmDesdeUltimoServicio: number;
|
||||
fechaVerificacionProxima?: Date;
|
||||
diasParaVerificacion?: number;
|
||||
fechaPolizaVencimiento?: Date;
|
||||
diasParaVencimientoPoliza?: number;
|
||||
fechaPermisoVencimiento?: Date;
|
||||
diasParaVencimientoPermiso?: number;
|
||||
requiereAtencion: boolean;
|
||||
alertas: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SERVICE
|
||||
// ============================================================================
|
||||
|
||||
export class UnidadService {
|
||||
constructor(
|
||||
private readonly unidadRepository: Repository<Unidad>,
|
||||
private readonly asignacionRepository: Repository<Asignacion>,
|
||||
private readonly operadorRepository: Repository<Operador>
|
||||
) {}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CRUD OPERATIONS
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Crear nueva unidad (tractora, remolque, etc.)
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateUnidadDto, createdById: string): Promise<Unidad> {
|
||||
// Validar que no exista numero economico duplicado
|
||||
const existingNumero = await this.unidadRepository.findOne({
|
||||
where: { tenantId, numeroEconomico: dto.numeroEconomico },
|
||||
});
|
||||
if (existingNumero) {
|
||||
throw new Error(`Ya existe una unidad con el numero economico ${dto.numeroEconomico}`);
|
||||
}
|
||||
|
||||
// Validar que no exista placa duplicada
|
||||
if (dto.placa) {
|
||||
const existingPlaca = await this.unidadRepository.findOne({
|
||||
where: { tenantId, placa: dto.placa },
|
||||
});
|
||||
if (existingPlaca) {
|
||||
throw new Error(`Ya existe una unidad con la placa ${dto.placa}`);
|
||||
}
|
||||
}
|
||||
|
||||
const unidad = this.unidadRepository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
estado: EstadoUnidad.DISPONIBLE,
|
||||
activo: true,
|
||||
createdById,
|
||||
});
|
||||
|
||||
return this.unidadRepository.save(unidad);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar unidad por ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<Unidad | null> {
|
||||
return this.unidadRepository.findOne({
|
||||
where: { tenantId, id, activo: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar unidad por ID o lanzar error
|
||||
*/
|
||||
async findByIdOrFail(tenantId: string, id: string): Promise<Unidad> {
|
||||
const unidad = await this.findById(tenantId, id);
|
||||
if (!unidad) {
|
||||
throw new Error(`Unidad con ID ${id} no encontrada`);
|
||||
}
|
||||
return unidad;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar unidades con filtros
|
||||
*/
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters: UnidadFilters = {}
|
||||
): Promise<{ data: Unidad[]; total: number }> {
|
||||
const {
|
||||
search,
|
||||
tipo,
|
||||
estado,
|
||||
estados,
|
||||
sucursalId,
|
||||
esPropia,
|
||||
tieneGps,
|
||||
activo = true,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
} = filters;
|
||||
|
||||
const qb = this.unidadRepository
|
||||
.createQueryBuilder('u')
|
||||
.where('u.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (activo !== undefined) {
|
||||
qb.andWhere('u.activo = :activo', { activo });
|
||||
}
|
||||
|
||||
if (tipo) {
|
||||
qb.andWhere('u.tipo = :tipo', { tipo });
|
||||
}
|
||||
|
||||
if (estado) {
|
||||
qb.andWhere('u.estado = :estado', { estado });
|
||||
}
|
||||
|
||||
if (estados && estados.length > 0) {
|
||||
qb.andWhere('u.estado IN (:...estados)', { estados });
|
||||
}
|
||||
|
||||
if (sucursalId) {
|
||||
qb.andWhere('u.sucursal_id = :sucursalId', { sucursalId });
|
||||
}
|
||||
|
||||
if (esPropia !== undefined) {
|
||||
qb.andWhere('u.es_propia = :esPropia', { esPropia });
|
||||
}
|
||||
|
||||
if (tieneGps !== undefined) {
|
||||
qb.andWhere('u.tiene_gps = :tieneGps', { tieneGps });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
qb.andWhere(
|
||||
'(u.numero_economico ILIKE :search OR u.placa ILIKE :search OR u.marca ILIKE :search OR u.modelo ILIKE :search)',
|
||||
{ search: `%${search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const total = await qb.getCount();
|
||||
|
||||
qb.orderBy('u.numero_economico', 'ASC')
|
||||
.offset(offset)
|
||||
.limit(limit);
|
||||
|
||||
const data = await qb.getMany();
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener unidades disponibles
|
||||
*/
|
||||
async findAvailable(tenantId: string, tipo?: TipoUnidad): Promise<Unidad[]> {
|
||||
const where: FindOptionsWhere<Unidad> = {
|
||||
tenantId,
|
||||
estado: EstadoUnidad.DISPONIBLE,
|
||||
activo: true,
|
||||
};
|
||||
|
||||
if (tipo) {
|
||||
where.tipo = tipo;
|
||||
}
|
||||
|
||||
return this.unidadRepository.find({
|
||||
where,
|
||||
order: { numeroEconomico: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtrar unidades por tipo
|
||||
*/
|
||||
async findByType(tenantId: string, tipo: TipoUnidad): Promise<Unidad[]> {
|
||||
return this.unidadRepository.find({
|
||||
where: { tenantId, tipo, activo: true },
|
||||
order: { numeroEconomico: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar unidad
|
||||
*/
|
||||
async update(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateUnidadDto,
|
||||
updatedById: string
|
||||
): Promise<Unidad> {
|
||||
const unidad = await this.findByIdOrFail(tenantId, id);
|
||||
|
||||
// Validar numero economico duplicado si cambia
|
||||
if (dto.numeroEconomico && dto.numeroEconomico !== unidad.numeroEconomico) {
|
||||
const existing = await this.unidadRepository.findOne({
|
||||
where: { tenantId, numeroEconomico: dto.numeroEconomico },
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error(`Ya existe una unidad con el numero economico ${dto.numeroEconomico}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validar placa duplicada si cambia
|
||||
if (dto.placa && dto.placa !== unidad.placa) {
|
||||
const existing = await this.unidadRepository.findOne({
|
||||
where: { tenantId, placa: dto.placa },
|
||||
});
|
||||
if (existing && existing.id !== id) {
|
||||
throw new Error(`Ya existe una unidad con la placa ${dto.placa}`);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(unidad, {
|
||||
...dto,
|
||||
updatedById,
|
||||
});
|
||||
|
||||
return this.unidadRepository.save(unidad);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar estado de la unidad
|
||||
*/
|
||||
async updateStatus(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
estado: EstadoUnidad,
|
||||
updatedById: string
|
||||
): Promise<Unidad> {
|
||||
const unidad = await this.findByIdOrFail(tenantId, id);
|
||||
|
||||
// Validaciones de transicion de estado
|
||||
if (unidad.estado === EstadoUnidad.BAJA) {
|
||||
throw new Error('No se puede cambiar el estado de una unidad dada de baja');
|
||||
}
|
||||
|
||||
if (estado === EstadoUnidad.EN_VIAJE && unidad.estado === EstadoUnidad.EN_TALLER) {
|
||||
throw new Error('No se puede asignar a viaje una unidad en taller');
|
||||
}
|
||||
|
||||
unidad.estado = estado;
|
||||
unidad.updatedById = updatedById;
|
||||
|
||||
return this.unidadRepository.save(unidad);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// ASSIGNMENT OPERATIONS
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Asignar operador a unidad
|
||||
*/
|
||||
async assignOperador(
|
||||
tenantId: string,
|
||||
unidadId: string,
|
||||
dto: AsignarOperadorDto,
|
||||
createdById: string
|
||||
): Promise<Asignacion> {
|
||||
const unidad = await this.findByIdOrFail(tenantId, unidadId);
|
||||
|
||||
if (unidad.estado !== EstadoUnidad.DISPONIBLE) {
|
||||
throw new Error('Solo se puede asignar operador a unidades disponibles');
|
||||
}
|
||||
|
||||
// Verificar que el operador existe y esta disponible
|
||||
const operador = await this.operadorRepository.findOne({
|
||||
where: { tenantId, id: dto.operadorId, activo: true },
|
||||
});
|
||||
if (!operador) {
|
||||
throw new Error(`Operador con ID ${dto.operadorId} no encontrado`);
|
||||
}
|
||||
|
||||
if (operador.estado !== EstadoOperador.DISPONIBLE && operador.estado !== EstadoOperador.ACTIVO) {
|
||||
throw new Error('El operador no esta disponible para asignacion');
|
||||
}
|
||||
|
||||
// Verificar que no tenga asignacion activa
|
||||
const asignacionExistente = await this.asignacionRepository.findOne({
|
||||
where: { tenantId, unidadId, activa: true },
|
||||
});
|
||||
if (asignacionExistente) {
|
||||
throw new Error('La unidad ya tiene un operador asignado. Desasigne primero.');
|
||||
}
|
||||
|
||||
// Crear asignacion
|
||||
const asignacion = this.asignacionRepository.create({
|
||||
tenantId,
|
||||
unidadId,
|
||||
operadorId: dto.operadorId,
|
||||
remolqueId: dto.remolqueId,
|
||||
fechaInicio: new Date(),
|
||||
activa: true,
|
||||
motivo: dto.motivo,
|
||||
createdById,
|
||||
});
|
||||
|
||||
await this.asignacionRepository.save(asignacion);
|
||||
|
||||
// Actualizar unidad asignada en operador
|
||||
operador.unidadAsignadaId = unidadId;
|
||||
await this.operadorRepository.save(operador);
|
||||
|
||||
return asignacion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desasignar operador de unidad
|
||||
*/
|
||||
async unassignOperador(
|
||||
tenantId: string,
|
||||
unidadId: string,
|
||||
updatedById: string
|
||||
): Promise<void> {
|
||||
const unidad = await this.findByIdOrFail(tenantId, unidadId);
|
||||
|
||||
if (unidad.estado === EstadoUnidad.EN_VIAJE || unidad.estado === EstadoUnidad.EN_RUTA) {
|
||||
throw new Error('No se puede desasignar operador de una unidad en viaje');
|
||||
}
|
||||
|
||||
const asignacion = await this.asignacionRepository.findOne({
|
||||
where: { tenantId, unidadId, activa: true },
|
||||
});
|
||||
|
||||
if (!asignacion) {
|
||||
throw new Error('La unidad no tiene operador asignado');
|
||||
}
|
||||
|
||||
// Finalizar asignacion
|
||||
asignacion.activa = false;
|
||||
asignacion.fechaFin = new Date();
|
||||
await this.asignacionRepository.save(asignacion);
|
||||
|
||||
// Remover unidad asignada del operador
|
||||
const operador = await this.operadorRepository.findOne({
|
||||
where: { tenantId, id: asignacion.operadorId },
|
||||
});
|
||||
if (operador) {
|
||||
operador.unidadAsignadaId = undefined as unknown as string;
|
||||
await this.operadorRepository.save(operador);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener asignacion activa de una unidad
|
||||
*/
|
||||
async getActiveAssignment(tenantId: string, unidadId: string): Promise<Asignacion | null> {
|
||||
return this.asignacionRepository.findOne({
|
||||
where: { tenantId, unidadId, activa: true },
|
||||
relations: ['operador'],
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// MAINTENANCE & LOCATION
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Obtener estado de mantenimiento de una unidad
|
||||
*/
|
||||
async getMaintenanceStatus(tenantId: string, id: string): Promise<MaintenanceStatus> {
|
||||
const unidad = await this.findByIdOrFail(tenantId, id);
|
||||
const hoy = new Date();
|
||||
const alertas: string[] = [];
|
||||
|
||||
const kmDesdeUltimoServicio = unidad.odometroActual - (unidad.odometroUltimoServicio || 0);
|
||||
|
||||
// Calcular dias para vencimientos
|
||||
let diasParaVerificacion: number | undefined;
|
||||
let diasParaVencimientoPoliza: number | undefined;
|
||||
let diasParaVencimientoPermiso: number | undefined;
|
||||
|
||||
if (unidad.fechaVerificacionProxima) {
|
||||
diasParaVerificacion = Math.ceil(
|
||||
(unidad.fechaVerificacionProxima.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
if (diasParaVerificacion <= 0) {
|
||||
alertas.push('Verificacion vencida');
|
||||
} else if (diasParaVerificacion <= 30) {
|
||||
alertas.push(`Verificacion vence en ${diasParaVerificacion} dias`);
|
||||
}
|
||||
}
|
||||
|
||||
if (unidad.fechaPolizaVencimiento) {
|
||||
diasParaVencimientoPoliza = Math.ceil(
|
||||
(unidad.fechaPolizaVencimiento.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
if (diasParaVencimientoPoliza <= 0) {
|
||||
alertas.push('Poliza de seguro vencida');
|
||||
} else if (diasParaVencimientoPoliza <= 30) {
|
||||
alertas.push(`Poliza de seguro vence en ${diasParaVencimientoPoliza} dias`);
|
||||
}
|
||||
}
|
||||
|
||||
if (unidad.fechaPermisoVencimiento) {
|
||||
diasParaVencimientoPermiso = Math.ceil(
|
||||
(unidad.fechaPermisoVencimiento.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
if (diasParaVencimientoPermiso <= 0) {
|
||||
alertas.push('Permiso SCT vencido');
|
||||
} else if (diasParaVencimientoPermiso <= 30) {
|
||||
alertas.push(`Permiso SCT vence en ${diasParaVencimientoPermiso} dias`);
|
||||
}
|
||||
}
|
||||
|
||||
// Alerta por kilometraje
|
||||
const KM_SERVICIO_RECOMENDADO = 15000;
|
||||
if (kmDesdeUltimoServicio >= KM_SERVICIO_RECOMENDADO) {
|
||||
alertas.push(`Mantenimiento requerido (${kmDesdeUltimoServicio.toLocaleString()} km desde ultimo servicio)`);
|
||||
}
|
||||
|
||||
return {
|
||||
unidadId: unidad.id,
|
||||
numeroEconomico: unidad.numeroEconomico,
|
||||
odometroActual: unidad.odometroActual,
|
||||
odometroUltimoServicio: unidad.odometroUltimoServicio || 0,
|
||||
kmDesdeUltimoServicio,
|
||||
fechaVerificacionProxima: unidad.fechaVerificacionProxima,
|
||||
diasParaVerificacion,
|
||||
fechaPolizaVencimiento: unidad.fechaPolizaVencimiento,
|
||||
diasParaVencimientoPoliza,
|
||||
fechaPermisoVencimiento: unidad.fechaPermisoVencimiento,
|
||||
diasParaVencimientoPermiso,
|
||||
requiereAtencion: alertas.length > 0,
|
||||
alertas,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener ubicacion actual de la unidad (GPS)
|
||||
*/
|
||||
async trackLocation(tenantId: string, id: string): Promise<UnidadUbicacion | null> {
|
||||
const unidad = await this.findByIdOrFail(tenantId, id);
|
||||
|
||||
if (!unidad.tieneGps) {
|
||||
throw new Error('La unidad no tiene GPS habilitado');
|
||||
}
|
||||
|
||||
if (!unidad.ubicacionActualLat || !unidad.ubicacionActualLng) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
lat: Number(unidad.ubicacionActualLat),
|
||||
lng: Number(unidad.ubicacionActualLng),
|
||||
timestamp: unidad.ultimaActualizacionUbicacion || new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar ubicacion de la unidad
|
||||
*/
|
||||
async updateLocation(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
ubicacion: UnidadUbicacion
|
||||
): Promise<Unidad> {
|
||||
const unidad = await this.findByIdOrFail(tenantId, id);
|
||||
|
||||
unidad.ubicacionActualLat = ubicacion.lat;
|
||||
unidad.ubicacionActualLng = ubicacion.lng;
|
||||
unidad.ultimaActualizacionUbicacion = ubicacion.timestamp || new Date();
|
||||
|
||||
return this.unidadRepository.save(unidad);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar odometro de la unidad
|
||||
*/
|
||||
async updateOdometro(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
odometro: number,
|
||||
updatedById: string
|
||||
): Promise<Unidad> {
|
||||
const unidad = await this.findByIdOrFail(tenantId, id);
|
||||
|
||||
if (odometro < unidad.odometroActual) {
|
||||
throw new Error('El odometro no puede ser menor al valor actual');
|
||||
}
|
||||
|
||||
unidad.odometroActual = odometro;
|
||||
unidad.updatedById = updatedById;
|
||||
|
||||
return this.unidadRepository.save(unidad);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// STATISTICS
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Obtener estadisticas de la flota
|
||||
*/
|
||||
async getStatistics(tenantId: string): Promise<FleetStatistics> {
|
||||
const hoy = new Date();
|
||||
|
||||
// Conteo total
|
||||
const total = await this.unidadRepository.count({
|
||||
where: { tenantId, activo: true },
|
||||
});
|
||||
|
||||
// Conteo por estado
|
||||
const disponibles = await this.unidadRepository.count({
|
||||
where: { tenantId, estado: EstadoUnidad.DISPONIBLE, activo: true },
|
||||
});
|
||||
|
||||
const enViaje = await this.unidadRepository.count({
|
||||
where: { tenantId, estado: In([EstadoUnidad.EN_VIAJE, EstadoUnidad.EN_RUTA]), activo: true },
|
||||
});
|
||||
|
||||
const enTaller = await this.unidadRepository.count({
|
||||
where: { tenantId, estado: EstadoUnidad.EN_TALLER, activo: true },
|
||||
});
|
||||
|
||||
const bloqueadas = await this.unidadRepository.count({
|
||||
where: { tenantId, estado: EstadoUnidad.BLOQUEADA, activo: true },
|
||||
});
|
||||
|
||||
// Conteo por tipo
|
||||
const porTipo: Record<TipoUnidad, number> = {} as Record<TipoUnidad, number>;
|
||||
for (const tipo of Object.values(TipoUnidad)) {
|
||||
porTipo[tipo] = await this.unidadRepository.count({
|
||||
where: { tenantId, tipo, activo: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Propias vs terceros
|
||||
const propias = await this.unidadRepository.count({
|
||||
where: { tenantId, esPropia: true, activo: true },
|
||||
});
|
||||
|
||||
const terceros = total - propias;
|
||||
|
||||
// GPS
|
||||
const conGps = await this.unidadRepository.count({
|
||||
where: { tenantId, tieneGps: true, activo: true },
|
||||
});
|
||||
|
||||
const sinGps = total - conGps;
|
||||
|
||||
// Documentos vencidos
|
||||
const documentosVencidos = await this.unidadRepository
|
||||
.createQueryBuilder('u')
|
||||
.where('u.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('u.activo = true')
|
||||
.andWhere(
|
||||
'(u.fecha_verificacion_proxima < :hoy OR u.fecha_poliza_vencimiento < :hoy OR u.fecha_permiso_vencimiento < :hoy)',
|
||||
{ hoy }
|
||||
)
|
||||
.getCount();
|
||||
|
||||
// Mantenimiento pendiente (mas de 15000 km desde ultimo servicio)
|
||||
const KM_SERVICIO = 15000;
|
||||
const mantenimientoPendiente = await this.unidadRepository
|
||||
.createQueryBuilder('u')
|
||||
.where('u.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('u.activo = true')
|
||||
.andWhere('(u.odometro_actual - COALESCE(u.odometro_ultimo_servicio, 0)) >= :km', { km: KM_SERVICIO })
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
total,
|
||||
disponibles,
|
||||
enViaje,
|
||||
enTaller,
|
||||
bloqueadas,
|
||||
porTipo,
|
||||
propias,
|
||||
terceros,
|
||||
conGps,
|
||||
sinGps,
|
||||
documentosVencidos,
|
||||
mantenimientoPendiente,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener unidades con documentos por vencer
|
||||
*/
|
||||
async getUnidadesDocumentosPorVencer(
|
||||
tenantId: string,
|
||||
dias: number = 30
|
||||
): Promise<Unidad[]> {
|
||||
const hoy = new Date();
|
||||
const fechaLimite = new Date();
|
||||
fechaLimite.setDate(fechaLimite.getDate() + dias);
|
||||
|
||||
return this.unidadRepository
|
||||
.createQueryBuilder('u')
|
||||
.where('u.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('u.activo = true')
|
||||
.andWhere(
|
||||
'((u.fecha_verificacion_proxima BETWEEN :hoy AND :fechaLimite) OR ' +
|
||||
'(u.fecha_poliza_vencimiento BETWEEN :hoy AND :fechaLimite) OR ' +
|
||||
'(u.fecha_permiso_vencimiento BETWEEN :hoy AND :fechaLimite))',
|
||||
{ hoy, fechaLimite }
|
||||
)
|
||||
.orderBy('u.numero_economico', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dar de baja unidad
|
||||
*/
|
||||
async darDeBaja(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
motivoBaja: string,
|
||||
updatedById: string
|
||||
): Promise<Unidad> {
|
||||
const unidad = await this.findByIdOrFail(tenantId, id);
|
||||
|
||||
if (unidad.estado === EstadoUnidad.EN_VIAJE || unidad.estado === EstadoUnidad.EN_RUTA) {
|
||||
throw new Error('No se puede dar de baja una unidad en viaje');
|
||||
}
|
||||
|
||||
// Desasignar operador si tiene
|
||||
const asignacion = await this.asignacionRepository.findOne({
|
||||
where: { tenantId, unidadId: id, activa: true },
|
||||
});
|
||||
if (asignacion) {
|
||||
await this.unassignOperador(tenantId, id, updatedById);
|
||||
}
|
||||
|
||||
unidad.estado = EstadoUnidad.BAJA;
|
||||
unidad.activo = false;
|
||||
unidad.fechaBaja = new Date();
|
||||
unidad.motivoBaja = motivoBaja;
|
||||
unidad.updatedById = updatedById;
|
||||
|
||||
return this.unidadRepository.save(unidad);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user