[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
|
// Entities de Flota
|
||||||
export * from './unidad.entity';
|
export * from './unidad.entity';
|
||||||
export * from './operador.entity';
|
export * from './operador.entity';
|
||||||
|
export * from './documento-flota.entity';
|
||||||
|
export * from './asignacion.entity';
|
||||||
|
|
||||||
// Entities heredadas de products (para refacciones)
|
// Entities heredadas de products (para refacciones)
|
||||||
export { ProductCategory } from './product-category.entity';
|
export { ProductCategory } from './product-category.entity';
|
||||||
|
|||||||
@ -2,5 +2,11 @@
|
|||||||
* Gestion Flota Services
|
* Gestion Flota Services
|
||||||
*/
|
*/
|
||||||
export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service';
|
export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service';
|
||||||
export * from './unidades.service';
|
|
||||||
export * from './operadores.service';
|
// 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