[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:
Adrian Flores Cortes 2026-02-03 01:36:00 -06:00
parent e42af3b8b4
commit 5d0db6d5fc
8 changed files with 3760 additions and 355 deletions

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

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

View File

@ -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';

View File

@ -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';

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

View 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