diff --git a/src/modules/carta-porte/services/figura-transporte.service.ts b/src/modules/carta-porte/services/figura-transporte.service.ts new file mode 100644 index 0000000..68137e4 --- /dev/null +++ b/src/modules/carta-porte/services/figura-transporte.service.ts @@ -0,0 +1,461 @@ +import { Repository, DataSource } from 'typeorm'; +import { FiguraTransporte, TipoFigura, ParteTransporte } from '../entities'; +import { CartaPorte, EstadoCartaPorte } from '../entities/carta-porte.entity'; + +/** + * DTO para crear figura de transporte + */ +export interface CreateFiguraDto { + tipoFigura: string; + rfcFigura?: string; + nombreFigura?: string; + numLicencia?: string; + pais?: string; + estado?: string; + codigoPostal?: string; + calle?: string; + partesTransporte?: ParteTransporte[]; +} + +/** + * DTO para actualizar figura de transporte + */ +export interface UpdateFiguraDto extends Partial {} + +/** + * Resultado de validacion de figuras requeridas + */ +export interface ValidacionFigurasRequeridas { + valid: boolean; + tieneOperador: boolean; + tienePropietario: boolean; + operadores: FiguraTransporte[]; + totalFiguras: number; + errors: string[]; + warnings: string[]; +} + +/** + * Servicio para gestion de figuras de transporte de Carta Porte + * CFDI 3.1 Compliance + */ +export class FiguraTransporteService { + private figuraRepository: Repository; + private cartaPorteRepository: Repository; + + constructor(private readonly dataSource: DataSource) { + this.figuraRepository = dataSource.getRepository(FiguraTransporte); + this.cartaPorteRepository = dataSource.getRepository(CartaPorte); + } + + /** + * Verifica que la Carta Porte exista y pertenezca al tenant + */ + private async getCartaPorteOrFail( + cartaPorteId: string, + tenantId: string + ): Promise { + const cartaPorte = await this.cartaPorteRepository.findOne({ + where: { id: cartaPorteId, tenantId }, + }); + + if (!cartaPorte) { + throw new Error('Carta Porte no encontrada'); + } + + return cartaPorte; + } + + /** + * Verifica que la Carta Porte este en estado editable + */ + private assertEditable(cartaPorte: CartaPorte): void { + if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) { + throw new Error( + `No se pueden modificar figuras de transporte en estado ${cartaPorte.estado}` + ); + } + } + + /** + * Crea una nueva figura de transporte + */ + async create( + tenantId: string, + cartaPorteId: string, + data: CreateFiguraDto + ): Promise { + const cartaPorte = await this.getCartaPorteOrFail(cartaPorteId, tenantId); + this.assertEditable(cartaPorte); + + // Validar datos segun tipo de figura + this.validateFiguraData(data); + + const figura = this.figuraRepository.create({ + tenantId, + cartaPorteId, + tipoFigura: data.tipoFigura, + rfcFigura: data.rfcFigura || null, + nombreFigura: data.nombreFigura || null, + numLicencia: data.numLicencia || null, + pais: data.pais || null, + estado: data.estado || null, + codigoPostal: data.codigoPostal || null, + calle: data.calle || null, + partesTransporte: data.partesTransporte || null, + }); + + return this.figuraRepository.save(figura); + } + + /** + * Obtiene todas las figuras de una Carta Porte + */ + async findByCartaParte( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + + return this.figuraRepository.find({ + where: { cartaPorteId, tenantId }, + order: { tipoFigura: 'ASC' }, + }); + } + + /** + * Obtiene una figura por ID + */ + async findById( + tenantId: string, + id: string + ): Promise { + return this.figuraRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Actualiza una figura existente + */ + async update( + tenantId: string, + id: string, + data: UpdateFiguraDto + ): Promise { + const figura = await this.findById(tenantId, id); + if (!figura) return null; + + const cartaPorte = await this.getCartaPorteOrFail( + figura.cartaPorteId, + tenantId + ); + this.assertEditable(cartaPorte); + + // Validar datos si se actualiza tipo de figura + if (data.tipoFigura) { + this.validateFiguraData({ + tipoFigura: data.tipoFigura, + rfcFigura: data.rfcFigura ?? figura.rfcFigura ?? undefined, + nombreFigura: data.nombreFigura ?? figura.nombreFigura ?? undefined, + numLicencia: data.numLicencia ?? figura.numLicencia ?? undefined, + } as CreateFiguraDto); + } + + Object.assign(figura, data); + return this.figuraRepository.save(figura); + } + + /** + * Elimina una figura de transporte + */ + async delete(tenantId: string, id: string): Promise { + const figura = await this.findById(tenantId, id); + if (!figura) return false; + + const cartaPorte = await this.getCartaPorteOrFail( + figura.cartaPorteId, + tenantId + ); + this.assertEditable(cartaPorte); + + const result = await this.figuraRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Valida que las figuras requeridas esten presentes + */ + async validateFigurasRequeridas( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + const figuras = await this.findByCartaParte(tenantId, cartaPorteId); + + const errors: string[] = []; + const warnings: string[] = []; + + const operadores = figuras.filter(f => f.tipoFigura === TipoFigura.OPERADOR); + const propietarios = figuras.filter(f => f.tipoFigura === TipoFigura.PROPIETARIO); + const arrendadores = figuras.filter(f => f.tipoFigura === TipoFigura.ARRENDADOR); + + const tieneOperador = operadores.length > 0; + const tienePropietario = propietarios.length > 0; + + // Validacion SAT: Se requiere al menos un operador + if (!tieneOperador) { + errors.push('Se requiere al menos un operador (figura tipo 01)'); + } + + // Validar datos de operadores + for (const op of operadores) { + if (!op.rfcFigura) { + errors.push(`Operador "${op.nombreFigura || 'sin nombre'}" requiere RFC`); + } + if (!op.numLicencia) { + errors.push(`Operador "${op.nombreFigura || 'sin nombre'}" requiere numero de licencia`); + } + if (!op.nombreFigura) { + errors.push(`Operador con RFC ${op.rfcFigura || 'sin RFC'} requiere nombre`); + } + } + + // Validar datos de propietarios + for (const prop of propietarios) { + if (!prop.rfcFigura) { + errors.push(`Propietario "${prop.nombreFigura || 'sin nombre'}" requiere RFC`); + } + if (!prop.nombreFigura) { + errors.push(`Propietario con RFC ${prop.rfcFigura || 'sin RFC'} requiere nombre`); + } + } + + // Validar datos de arrendadores + for (const arr of arrendadores) { + if (!arr.rfcFigura) { + errors.push(`Arrendador "${arr.nombreFigura || 'sin nombre'}" requiere RFC`); + } + if (!arr.nombreFigura) { + errors.push(`Arrendador con RFC ${arr.rfcFigura || 'sin RFC'} requiere nombre`); + } + } + + // Warnings opcionales + if (operadores.length > 2) { + warnings.push('Se registraron mas de 2 operadores'); + } + + if (!tienePropietario && arrendadores.length === 0) { + warnings.push('No se registro propietario ni arrendador de la mercancia'); + } + + return { + valid: errors.length === 0, + tieneOperador, + tienePropietario, + operadores, + totalFiguras: figuras.length, + errors, + warnings, + }; + } + + /** + * Obtiene solo los operadores de una Carta Porte + */ + async getOperadores( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + + return this.figuraRepository.find({ + where: { + cartaPorteId, + tenantId, + tipoFigura: TipoFigura.OPERADOR, + }, + }); + } + + /** + * Obtiene propietarios de mercancia + */ + async getPropietarios( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + + return this.figuraRepository.find({ + where: { + cartaPorteId, + tenantId, + tipoFigura: TipoFigura.PROPIETARIO, + }, + }); + } + + /** + * Obtiene arrendadores + */ + async getArrendadores( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + + return this.figuraRepository.find({ + where: { + cartaPorteId, + tenantId, + tipoFigura: TipoFigura.ARRENDADOR, + }, + }); + } + + /** + * Agrega un operador con datos minimos requeridos + */ + async addOperador( + tenantId: string, + cartaPorteId: string, + data: { + rfcFigura: string; + nombreFigura: string; + numLicencia: string; + pais?: string; + estado?: string; + codigoPostal?: string; + } + ): Promise { + return this.create(tenantId, cartaPorteId, { + tipoFigura: TipoFigura.OPERADOR, + ...data, + }); + } + + /** + * Agrega un propietario de mercancia + */ + async addPropietario( + tenantId: string, + cartaPorteId: string, + data: { + rfcFigura: string; + nombreFigura: string; + pais?: string; + estado?: string; + codigoPostal?: string; + calle?: string; + partesTransporte?: ParteTransporte[]; + } + ): Promise { + return this.create(tenantId, cartaPorteId, { + tipoFigura: TipoFigura.PROPIETARIO, + ...data, + }); + } + + /** + * Agrega un arrendador + */ + async addArrendador( + tenantId: string, + cartaPorteId: string, + data: { + rfcFigura: string; + nombreFigura: string; + pais?: string; + estado?: string; + codigoPostal?: string; + calle?: string; + partesTransporte?: ParteTransporte[]; + } + ): Promise { + return this.create(tenantId, cartaPorteId, { + tipoFigura: TipoFigura.ARRENDADOR, + ...data, + }); + } + + /** + * Obtiene resumen de figuras por tipo + */ + async getResumenPorTipo( + tenantId: string, + cartaPorteId: string + ): Promise<{ + operadores: number; + propietarios: number; + arrendadores: number; + notificados: number; + total: number; + }> { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + + const figuras = await this.findByCartaParte(tenantId, cartaPorteId); + + return { + operadores: figuras.filter(f => f.tipoFigura === TipoFigura.OPERADOR).length, + propietarios: figuras.filter(f => f.tipoFigura === TipoFigura.PROPIETARIO).length, + arrendadores: figuras.filter(f => f.tipoFigura === TipoFigura.ARRENDADOR).length, + notificados: figuras.filter(f => f.tipoFigura === TipoFigura.NOTIFICADO).length, + total: figuras.length, + }; + } + + /** + * Valida datos de figura segun tipo + */ + private validateFiguraData(data: CreateFiguraDto): void { + const errors: string[] = []; + + if (!data.tipoFigura) { + errors.push('tipoFigura es requerido'); + } + + const tiposValidos = Object.values(TipoFigura); + if (data.tipoFigura && !tiposValidos.includes(data.tipoFigura as TipoFigura)) { + errors.push(`tipoFigura debe ser uno de: ${tiposValidos.join(', ')}`); + } + + // Validaciones especificas por tipo + if (data.tipoFigura === TipoFigura.OPERADOR) { + if (!data.rfcFigura) { + errors.push('RFC es requerido para operador'); + } + if (!data.numLicencia) { + errors.push('Numero de licencia es requerido para operador'); + } + if (!data.nombreFigura) { + errors.push('Nombre es requerido para operador'); + } + } + + if ( + data.tipoFigura === TipoFigura.PROPIETARIO || + data.tipoFigura === TipoFigura.ARRENDADOR + ) { + if (!data.rfcFigura) { + errors.push(`RFC es requerido para ${data.tipoFigura === TipoFigura.PROPIETARIO ? 'propietario' : 'arrendador'}`); + } + if (!data.nombreFigura) { + errors.push(`Nombre es requerido para ${data.tipoFigura === TipoFigura.PROPIETARIO ? 'propietario' : 'arrendador'}`); + } + } + + // Validar formato RFC (opcional pero si se proporciona debe ser valido) + if (data.rfcFigura) { + const rfcRegex = /^[A-Z&]{3,4}\d{6}[A-Z0-9]{3}$/; + if (!rfcRegex.test(data.rfcFigura.toUpperCase())) { + errors.push('Formato de RFC invalido'); + } + } + + if (errors.length > 0) { + throw new Error(`Datos de figura invalidos: ${errors.join(', ')}`); + } + } +} diff --git a/src/modules/carta-porte/services/index.ts b/src/modules/carta-porte/services/index.ts index 601392e..641fffe 100644 --- a/src/modules/carta-porte/services/index.ts +++ b/src/modules/carta-porte/services/index.ts @@ -2,8 +2,22 @@ * Carta Porte Services * CFDI con Complemento Carta Porte 3.1 */ + +// Main Carta Porte Service export * from './carta-porte.service'; +// Mercancia (Cargo/Merchandise) Service +export * from './mercancia.service'; + +// Ubicacion (Locations) Service +export * from './ubicacion-carta-porte.service'; + +// Figura Transporte (Transportation Figures) Service +export * from './figura-transporte.service'; + +// Inspeccion Pre-Viaje (Pre-trip Inspection) Service +export * from './inspeccion-pre-viaje.service'; + // TODO: Implement additional services // - carta-porte-validator.service.ts (validacion SAT detallada) // - pac-integration.service.ts (integracion con PAC) diff --git a/src/modules/carta-porte/services/inspeccion-pre-viaje.service.ts b/src/modules/carta-porte/services/inspeccion-pre-viaje.service.ts new file mode 100644 index 0000000..98e5dfa --- /dev/null +++ b/src/modules/carta-porte/services/inspeccion-pre-viaje.service.ts @@ -0,0 +1,614 @@ +import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { InspeccionPreViaje, ChecklistItem, FotoInspeccion } from '../entities'; + +/** + * DTO para crear inspeccion pre-viaje + */ +export interface CreateInspeccionDto { + viajeId: string; + remolqueId?: string; + fechaInspeccion?: Date; + checklistItems?: ChecklistItem[]; + fotos?: FotoInspeccion[]; +} + +/** + * DTO para actualizar inspeccion + */ +export interface UpdateInspeccionDto { + checklistItems?: ChecklistItem[]; + fotos?: FotoInspeccion[]; + firmaOperador?: string; +} + +/** + * Rango de fechas para busquedas + */ +export interface DateRange { + desde: Date; + hasta: Date; +} + +/** + * Resumen de defectos + */ +export interface ResumenDefectos { + totalItems: number; + itemsOk: number; + defectosMenores: number; + defectosCriticos: number; + noAplica: number; + porcentajeAprobacion: number; + defectosDetallados: { + item: string; + categoria: string; + estado: string; + observacion?: string; + }[]; +} + +/** + * Resultado de verificacion de despacho + */ +export interface ResultadoDespacho { + puedeDespachar: boolean; + unidadId: string; + ultimaInspeccion: InspeccionPreViaje | null; + horasDesdeUltimaInspeccion: number | null; + defectosCriticosPendientes: number; + requiereNuevaInspeccion: boolean; + motivos: string[]; +} + +/** + * Categorias de checklist predefinidas + */ +export const CATEGORIAS_CHECKLIST = { + MOTOR: 'Motor', + FRENOS: 'Frenos', + LLANTAS: 'Llantas', + LUCES: 'Luces', + SUSPENSION: 'Suspension', + CABINA: 'Cabina', + CARROCERIA: 'Carroceria', + DOCUMENTOS: 'Documentos', + SEGURIDAD: 'Seguridad', + REMOLQUE: 'Remolque', +} as const; + +/** + * Servicio para gestion de inspecciones pre-viaje + * NOM-087-SCT-2-2017 Compliance + */ +export class InspeccionPreViajeService { + private inspeccionRepository: Repository; + + // Horas maximas desde ultima inspeccion para permitir despacho + private readonly HORAS_VALIDEZ_INSPECCION = 24; + + constructor(private readonly dataSource: DataSource) { + this.inspeccionRepository = dataSource.getRepository(InspeccionPreViaje); + } + + /** + * Crea una nueva inspeccion pre-viaje + */ + async create( + tenantId: string, + unidadId: string, + operadorId: string, + data: CreateInspeccionDto + ): Promise { + // Validar que no exista inspeccion en progreso para esta unidad + const inspeccionEnProgreso = await this.findInspeccionEnProgreso( + tenantId, + unidadId + ); + + if (inspeccionEnProgreso) { + throw new Error( + `Ya existe una inspeccion en progreso para la unidad. ID: ${inspeccionEnProgreso.id}` + ); + } + + const checklistItems = data.checklistItems || this.getChecklistDefault(); + + const inspeccion = this.inspeccionRepository.create({ + tenantId, + viajeId: data.viajeId, + unidadId, + remolqueId: data.remolqueId || null, + operadorId, + fechaInspeccion: data.fechaInspeccion || new Date(), + aprobada: false, + checklistItems, + defectosEncontrados: null, + defectosCriticos: 0, + defectosMenores: 0, + firmaOperador: null, + firmaFecha: null, + fotos: data.fotos || null, + }); + + return this.inspeccionRepository.save(inspeccion); + } + + /** + * Obtiene una inspeccion por ID + */ + async findById( + tenantId: string, + id: string + ): Promise { + return this.inspeccionRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Obtiene inspecciones por unidad en un rango de fechas + */ + async findByUnidad( + tenantId: string, + unidadId: string, + dateRange?: DateRange + ): Promise { + const where: any = { tenantId, unidadId }; + + if (dateRange) { + where.fechaInspeccion = Between(dateRange.desde, dateRange.hasta); + } + + return this.inspeccionRepository.find({ + where, + order: { fechaInspeccion: 'DESC' }, + }); + } + + /** + * Obtiene inspecciones por operador en un rango de fechas + */ + async findByOperador( + tenantId: string, + operadorId: string, + dateRange?: DateRange + ): Promise { + const where: any = { tenantId, operadorId }; + + if (dateRange) { + where.fechaInspeccion = Between(dateRange.desde, dateRange.hasta); + } + + return this.inspeccionRepository.find({ + where, + order: { fechaInspeccion: 'DESC' }, + }); + } + + /** + * Obtiene inspecciones por viaje + */ + async findByViaje( + tenantId: string, + viajeId: string + ): Promise { + return this.inspeccionRepository.find({ + where: { tenantId, viajeId }, + order: { fechaInspeccion: 'DESC' }, + }); + } + + /** + * Agrega o actualiza un item del checklist + */ + async addChecklistItem( + tenantId: string, + inspeccionId: string, + item: ChecklistItem + ): Promise { + const inspeccion = await this.findById(tenantId, inspeccionId); + if (!inspeccion) return null; + + // No permitir modificar inspecciones ya completadas + if (inspeccion.aprobada || inspeccion.firmaOperador) { + throw new Error('No se puede modificar una inspeccion completada'); + } + + // Validar item + this.validateChecklistItem(item); + + // Buscar si el item ya existe + const existingIndex = inspeccion.checklistItems.findIndex( + i => i.item === item.item && i.categoria === item.categoria + ); + + if (existingIndex >= 0) { + // Actualizar item existente + inspeccion.checklistItems[existingIndex] = item; + } else { + // Agregar nuevo item + inspeccion.checklistItems.push(item); + } + + // Recalcular conteos de defectos + this.recalcularDefectos(inspeccion); + + return this.inspeccionRepository.save(inspeccion); + } + + /** + * Actualiza multiples items del checklist + */ + async updateChecklistItems( + tenantId: string, + inspeccionId: string, + items: ChecklistItem[] + ): Promise { + const inspeccion = await this.findById(tenantId, inspeccionId); + if (!inspeccion) return null; + + if (inspeccion.aprobada || inspeccion.firmaOperador) { + throw new Error('No se puede modificar una inspeccion completada'); + } + + // Validar todos los items + for (const item of items) { + this.validateChecklistItem(item); + } + + inspeccion.checklistItems = items; + this.recalcularDefectos(inspeccion); + + return this.inspeccionRepository.save(inspeccion); + } + + /** + * Completa una inspeccion y la marca como aprobada o rechazada + */ + async completeInspeccion( + tenantId: string, + id: string, + firmaOperador?: string + ): Promise { + const inspeccion = await this.findById(tenantId, id); + if (!inspeccion) return null; + + if (inspeccion.aprobada || inspeccion.firmaOperador) { + throw new Error('La inspeccion ya fue completada'); + } + + // Recalcular defectos finales + this.recalcularDefectos(inspeccion); + + // Determinar si aprobada (sin defectos criticos) + inspeccion.aprobada = inspeccion.defectosCriticos === 0; + + // Registrar firma y fecha + if (firmaOperador) { + inspeccion.firmaOperador = firmaOperador; + inspeccion.firmaFecha = new Date(); + } + + // Generar lista de defectos encontrados + const defectos = inspeccion.checklistItems + .filter(i => i.estado === 'DEFECTO_MENOR' || i.estado === 'DEFECTO_CRITICO') + .map(i => `[${i.estado}] ${i.categoria}: ${i.item}${i.observacion ? ` - ${i.observacion}` : ''}`); + + inspeccion.defectosEncontrados = defectos.length > 0 ? defectos : null; + + return this.inspeccionRepository.save(inspeccion); + } + + /** + * Obtiene los defectos encontrados en una inspeccion + */ + async getDefectos( + tenantId: string, + id: string + ): Promise { + const inspeccion = await this.findById(tenantId, id); + if (!inspeccion) return null; + + const items = inspeccion.checklistItems; + const itemsOk = items.filter(i => i.estado === 'OK').length; + const defectosMenores = items.filter(i => i.estado === 'DEFECTO_MENOR').length; + const defectosCriticos = items.filter(i => i.estado === 'DEFECTO_CRITICO').length; + const noAplica = items.filter(i => i.estado === 'NO_APLICA').length; + + const itemsEvaluados = items.length - noAplica; + const porcentajeAprobacion = itemsEvaluados > 0 + ? (itemsOk / itemsEvaluados) * 100 + : 0; + + const defectosDetallados = items + .filter(i => i.estado === 'DEFECTO_MENOR' || i.estado === 'DEFECTO_CRITICO') + .map(i => ({ + item: i.item, + categoria: i.categoria, + estado: i.estado, + observacion: i.observacion, + })); + + return { + totalItems: items.length, + itemsOk, + defectosMenores, + defectosCriticos, + noAplica, + porcentajeAprobacion, + defectosDetallados, + }; + } + + /** + * Verifica si una unidad puede ser despachada + */ + async canDespachar( + tenantId: string, + unidadId: string + ): Promise { + const motivos: string[] = []; + + // Obtener ultima inspeccion de la unidad + const inspecciones = await this.findByUnidad(tenantId, unidadId); + const ultimaInspeccion = inspecciones.length > 0 ? inspecciones[0] : null; + + // Calcular horas desde ultima inspeccion + let horasDesdeUltimaInspeccion: number | null = null; + let requiereNuevaInspeccion = true; + + if (ultimaInspeccion) { + const ahora = new Date(); + const fechaInspeccion = new Date(ultimaInspeccion.fechaInspeccion); + horasDesdeUltimaInspeccion = + (ahora.getTime() - fechaInspeccion.getTime()) / (1000 * 60 * 60); + + requiereNuevaInspeccion = horasDesdeUltimaInspeccion > this.HORAS_VALIDEZ_INSPECCION; + } + + // Verificar si hay defectos criticos pendientes + let defectosCriticosPendientes = 0; + + if (ultimaInspeccion) { + defectosCriticosPendientes = ultimaInspeccion.defectosCriticos; + } + + // Determinar si puede despachar + let puedeDespachar = true; + + if (!ultimaInspeccion) { + puedeDespachar = false; + motivos.push('No existe inspeccion pre-viaje para esta unidad'); + } else { + if (!ultimaInspeccion.aprobada) { + puedeDespachar = false; + motivos.push('La ultima inspeccion no fue aprobada'); + } + + if (defectosCriticosPendientes > 0) { + puedeDespachar = false; + motivos.push(`Existen ${defectosCriticosPendientes} defectos criticos sin resolver`); + } + + if (requiereNuevaInspeccion) { + puedeDespachar = false; + motivos.push( + `Han pasado mas de ${this.HORAS_VALIDEZ_INSPECCION} horas desde la ultima inspeccion` + ); + } + + if (!ultimaInspeccion.firmaOperador) { + puedeDespachar = false; + motivos.push('La inspeccion no cuenta con firma del operador'); + } + } + + return { + puedeDespachar, + unidadId, + ultimaInspeccion, + horasDesdeUltimaInspeccion, + defectosCriticosPendientes, + requiereNuevaInspeccion, + motivos, + }; + } + + /** + * Obtiene la ultima inspeccion aprobada de una unidad + */ + async getUltimaInspeccionAprobada( + tenantId: string, + unidadId: string + ): Promise { + return this.inspeccionRepository.findOne({ + where: { tenantId, unidadId, aprobada: true }, + order: { fechaInspeccion: 'DESC' }, + }); + } + + /** + * Agrega fotos a una inspeccion + */ + async addFotos( + tenantId: string, + inspeccionId: string, + fotos: FotoInspeccion[] + ): Promise { + const inspeccion = await this.findById(tenantId, inspeccionId); + if (!inspeccion) return null; + + const fotosActuales = inspeccion.fotos || []; + inspeccion.fotos = [...fotosActuales, ...fotos]; + + return this.inspeccionRepository.save(inspeccion); + } + + /** + * Obtiene estadisticas de inspecciones por periodo + */ + async getEstadisticas( + tenantId: string, + dateRange: DateRange + ): Promise<{ + totalInspecciones: number; + aprobadas: number; + rechazadas: number; + porcentajeAprobacion: number; + defectosMasComunes: { item: string; count: number }[]; + }> { + const inspecciones = await this.inspeccionRepository.find({ + where: { + tenantId, + fechaInspeccion: Between(dateRange.desde, dateRange.hasta), + }, + }); + + const aprobadas = inspecciones.filter(i => i.aprobada).length; + const rechazadas = inspecciones.length - aprobadas; + + // Contar defectos mas comunes + const defectosCount: Record = {}; + for (const insp of inspecciones) { + for (const item of insp.checklistItems) { + if (item.estado === 'DEFECTO_MENOR' || item.estado === 'DEFECTO_CRITICO') { + const key = `${item.categoria}: ${item.item}`; + defectosCount[key] = (defectosCount[key] || 0) + 1; + } + } + } + + const defectosMasComunes = Object.entries(defectosCount) + .map(([item, count]) => ({ item, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + return { + totalInspecciones: inspecciones.length, + aprobadas, + rechazadas, + porcentajeAprobacion: inspecciones.length > 0 + ? (aprobadas / inspecciones.length) * 100 + : 0, + defectosMasComunes, + }; + } + + /** + * Busca inspeccion en progreso (sin completar) para una unidad + */ + private async findInspeccionEnProgreso( + tenantId: string, + unidadId: string + ): Promise { + // Buscar inspecciones de las ultimas 24 horas sin firma + const hace24Horas = new Date(); + hace24Horas.setHours(hace24Horas.getHours() - 24); + + return this.inspeccionRepository.findOne({ + where: { + tenantId, + unidadId, + firmaOperador: undefined, + fechaInspeccion: MoreThanOrEqual(hace24Horas), + }, + order: { fechaInspeccion: 'DESC' }, + }); + } + + /** + * Valida un item del checklist + */ + private validateChecklistItem(item: ChecklistItem): void { + const errors: string[] = []; + + if (!item.item || item.item.trim().length === 0) { + errors.push('item es requerido'); + } + + if (!item.categoria || item.categoria.trim().length === 0) { + errors.push('categoria es requerida'); + } + + const estadosValidos = ['OK', 'DEFECTO_MENOR', 'DEFECTO_CRITICO', 'NO_APLICA']; + if (!estadosValidos.includes(item.estado)) { + errors.push(`estado debe ser uno de: ${estadosValidos.join(', ')}`); + } + + if (errors.length > 0) { + throw new Error(`Item de checklist invalido: ${errors.join(', ')}`); + } + } + + /** + * Recalcula contadores de defectos + */ + private recalcularDefectos(inspeccion: InspeccionPreViaje): void { + const items = inspeccion.checklistItems; + + inspeccion.defectosCriticos = items.filter( + i => i.estado === 'DEFECTO_CRITICO' + ).length; + + inspeccion.defectosMenores = items.filter( + i => i.estado === 'DEFECTO_MENOR' + ).length; + } + + /** + * Obtiene checklist por defecto segun NOM-087 + */ + private getChecklistDefault(): ChecklistItem[] { + return [ + // Motor + { item: 'Nivel de aceite de motor', categoria: CATEGORIAS_CHECKLIST.MOTOR, estado: 'OK' }, + { item: 'Nivel de refrigerante', categoria: CATEGORIAS_CHECKLIST.MOTOR, estado: 'OK' }, + { item: 'Fugas visibles', categoria: CATEGORIAS_CHECKLIST.MOTOR, estado: 'OK' }, + { item: 'Bandas y mangueras', categoria: CATEGORIAS_CHECKLIST.MOTOR, estado: 'OK' }, + + // Frenos + { item: 'Presion de aire en sistema', categoria: CATEGORIAS_CHECKLIST.FRENOS, estado: 'OK' }, + { item: 'Funcionamiento freno de servicio', categoria: CATEGORIAS_CHECKLIST.FRENOS, estado: 'OK' }, + { item: 'Funcionamiento freno de estacionamiento', categoria: CATEGORIAS_CHECKLIST.FRENOS, estado: 'OK' }, + { item: 'Ajuste de frenos', categoria: CATEGORIAS_CHECKLIST.FRENOS, estado: 'OK' }, + + // Llantas + { item: 'Presion de inflado', categoria: CATEGORIAS_CHECKLIST.LLANTAS, estado: 'OK' }, + { item: 'Profundidad de dibujo', categoria: CATEGORIAS_CHECKLIST.LLANTAS, estado: 'OK' }, + { item: 'Danos visibles', categoria: CATEGORIAS_CHECKLIST.LLANTAS, estado: 'OK' }, + { item: 'Birlos y tuercas', categoria: CATEGORIAS_CHECKLIST.LLANTAS, estado: 'OK' }, + + // Luces + { item: 'Luces frontales', categoria: CATEGORIAS_CHECKLIST.LUCES, estado: 'OK' }, + { item: 'Luces traseras', categoria: CATEGORIAS_CHECKLIST.LUCES, estado: 'OK' }, + { item: 'Direccionales', categoria: CATEGORIAS_CHECKLIST.LUCES, estado: 'OK' }, + { item: 'Luces de freno', categoria: CATEGORIAS_CHECKLIST.LUCES, estado: 'OK' }, + { item: 'Luces de galibo', categoria: CATEGORIAS_CHECKLIST.LUCES, estado: 'OK' }, + + // Suspension + { item: 'Amortiguadores', categoria: CATEGORIAS_CHECKLIST.SUSPENSION, estado: 'OK' }, + { item: 'Muelles/bolsas de aire', categoria: CATEGORIAS_CHECKLIST.SUSPENSION, estado: 'OK' }, + { item: 'Quinta rueda', categoria: CATEGORIAS_CHECKLIST.SUSPENSION, estado: 'OK' }, + + // Cabina + { item: 'Espejos retrovisores', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' }, + { item: 'Parabrisas', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' }, + { item: 'Limpiadores', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' }, + { item: 'Cinturones de seguridad', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' }, + { item: 'Claxon', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' }, + { item: 'Instrumentos tablero', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' }, + + // Seguridad + { item: 'Extintor', categoria: CATEGORIAS_CHECKLIST.SEGURIDAD, estado: 'OK' }, + { item: 'Triangulos reflectantes', categoria: CATEGORIAS_CHECKLIST.SEGURIDAD, estado: 'OK' }, + { item: 'Botiquin', categoria: CATEGORIAS_CHECKLIST.SEGURIDAD, estado: 'OK' }, + { item: 'Calzas', categoria: CATEGORIAS_CHECKLIST.SEGURIDAD, estado: 'OK' }, + + // Documentos + { item: 'Licencia vigente', categoria: CATEGORIAS_CHECKLIST.DOCUMENTOS, estado: 'OK' }, + { item: 'Tarjeta de circulacion', categoria: CATEGORIAS_CHECKLIST.DOCUMENTOS, estado: 'OK' }, + { item: 'Poliza de seguro', categoria: CATEGORIAS_CHECKLIST.DOCUMENTOS, estado: 'OK' }, + { item: 'Permiso SCT', categoria: CATEGORIAS_CHECKLIST.DOCUMENTOS, estado: 'OK' }, + ]; + } +} diff --git a/src/modules/carta-porte/services/mercancia.service.ts b/src/modules/carta-porte/services/mercancia.service.ts new file mode 100644 index 0000000..103d3d7 --- /dev/null +++ b/src/modules/carta-porte/services/mercancia.service.ts @@ -0,0 +1,521 @@ +import { Repository, FindOptionsWhere, DataSource } from 'typeorm'; +import { MercanciaCartaPorte } from '../entities'; +import { CartaPorte, EstadoCartaPorte } from '../entities/carta-porte.entity'; + +/** + * DTO para crear mercancia + */ +export interface CreateMercanciaDto { + bienesTransp: string; + descripcion: string; + cantidad: number; + claveUnidad: string; + unidad?: string; + pesoEnKg: number; + largoCm?: number; + anchoCm?: number; + altoCm?: number; + valorMercancia?: number; + moneda?: string; + materialPeligroso?: boolean; + cveMaterialPeligroso?: string; + tipoEmbalaje?: string; + descripcionEmbalaje?: string; + fraccionArancelaria?: string; + uuidComercioExt?: string; + pedimentos?: string[]; + guias?: string[]; + secuencia?: number; +} + +/** + * DTO para actualizar mercancia + */ +export interface UpdateMercanciaDto extends Partial {} + +/** + * Resultado de validacion de pesos + */ +export interface ValidacionPesos { + valid: boolean; + pesoTotalMercancias: number; + pesoBrutoDeclarado: number | null; + diferencia: number; + porcentajeDiferencia: number; + toleranciaPorcentaje: number; + errors: string[]; +} + +/** + * Resumen de valor de mercancias + */ +export interface ValorMercanciaResumen { + valorTotal: number; + moneda: string; + cantidadMercancias: number; + valorPromedioPorMercancia: number; + mercanciaMayorValor: { + id: string; + descripcion: string; + valor: number; + } | null; +} + +/** + * Servicio para gestion de mercancias de Carta Porte + * CFDI 3.1 Compliance + */ +export class MercanciaService { + private mercanciaRepository: Repository; + private cartaPorteRepository: Repository; + + constructor(private readonly dataSource: DataSource) { + this.mercanciaRepository = dataSource.getRepository(MercanciaCartaPorte); + this.cartaPorteRepository = dataSource.getRepository(CartaPorte); + } + + /** + * Verifica que la Carta Porte exista y pertenezca al tenant + */ + private async getCartaPorteOrFail( + cartaPorteId: string, + tenantId: string + ): Promise { + const cartaPorte = await this.cartaPorteRepository.findOne({ + where: { id: cartaPorteId, tenantId }, + }); + + if (!cartaPorte) { + throw new Error('Carta Porte no encontrada'); + } + + return cartaPorte; + } + + /** + * Verifica que la Carta Porte este en estado editable + */ + private assertEditable(cartaPorte: CartaPorte): void { + if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) { + throw new Error( + `No se pueden modificar mercancias en estado ${cartaPorte.estado}` + ); + } + } + + /** + * Obtiene el siguiente numero de secuencia para mercancias + */ + private async getNextSecuencia(cartaPorteId: string): Promise { + const result = await this.mercanciaRepository + .createQueryBuilder('m') + .select('COALESCE(MAX(m.secuencia), 0)', 'maxSecuencia') + .where('m.cartaPorteId = :cartaPorteId', { cartaPorteId }) + .getRawOne(); + + return (result?.maxSecuencia || 0) + 1; + } + + /** + * Crea una nueva mercancia para una Carta Porte + */ + async create( + tenantId: string, + cartaPorteId: string, + data: CreateMercanciaDto + ): Promise { + const cartaPorte = await this.getCartaPorteOrFail(cartaPorteId, tenantId); + this.assertEditable(cartaPorte); + + // Validar datos requeridos por SAT + this.validateMercanciaData(data); + + const secuencia = data.secuencia ?? (await this.getNextSecuencia(cartaPorteId)); + + const mercancia = this.mercanciaRepository.create({ + tenantId, + cartaPorteId, + bienesTransp: data.bienesTransp, + descripcion: data.descripcion, + cantidad: data.cantidad, + claveUnidad: data.claveUnidad, + unidad: data.unidad || null, + pesoEnKg: data.pesoEnKg, + largoCm: data.largoCm || null, + anchoCm: data.anchoCm || null, + altoCm: data.altoCm || null, + valorMercancia: data.valorMercancia || null, + moneda: data.moneda || 'MXN', + materialPeligroso: data.materialPeligroso || false, + cveMaterialPeligroso: data.cveMaterialPeligroso || null, + tipoEmbalaje: data.tipoEmbalaje || null, + descripcionEmbalaje: data.descripcionEmbalaje || null, + fraccionArancelaria: data.fraccionArancelaria || null, + uuidComercioExt: data.uuidComercioExt || null, + pedimentos: data.pedimentos || null, + guias: data.guias || null, + secuencia, + }); + + const saved = await this.mercanciaRepository.save(mercancia); + + // Actualizar contador en Carta Porte + await this.updateCartaPorteTotals(cartaPorteId, tenantId); + + return saved; + } + + /** + * Obtiene todas las mercancias de una Carta Porte + */ + async findByCartaParte( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + + return this.mercanciaRepository.find({ + where: { cartaPorteId, tenantId }, + order: { secuencia: 'ASC' }, + }); + } + + /** + * Obtiene una mercancia por ID + */ + async findById( + tenantId: string, + id: string + ): Promise { + return this.mercanciaRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Actualiza una mercancia existente + */ + async update( + tenantId: string, + id: string, + data: UpdateMercanciaDto + ): Promise { + const mercancia = await this.findById(tenantId, id); + if (!mercancia) return null; + + const cartaPorte = await this.getCartaPorteOrFail( + mercancia.cartaPorteId, + tenantId + ); + this.assertEditable(cartaPorte); + + // Validar datos si se actualizan campos requeridos + if (data.bienesTransp || data.descripcion || data.cantidad || data.claveUnidad || data.pesoEnKg) { + this.validateMercanciaData({ + bienesTransp: data.bienesTransp ?? mercancia.bienesTransp, + descripcion: data.descripcion ?? mercancia.descripcion, + cantidad: data.cantidad ?? mercancia.cantidad, + claveUnidad: data.claveUnidad ?? mercancia.claveUnidad, + pesoEnKg: data.pesoEnKg ?? mercancia.pesoEnKg, + } as CreateMercanciaDto); + } + + // Validar material peligroso + if (data.materialPeligroso === true && !data.cveMaterialPeligroso && !mercancia.cveMaterialPeligroso) { + throw new Error('Se requiere clave de material peligroso cuando materialPeligroso es true'); + } + + Object.assign(mercancia, data); + const updated = await this.mercanciaRepository.save(mercancia); + + // Actualizar totales + await this.updateCartaPorteTotals(mercancia.cartaPorteId, tenantId); + + return updated; + } + + /** + * Elimina una mercancia + */ + async delete(tenantId: string, id: string): Promise { + const mercancia = await this.findById(tenantId, id); + if (!mercancia) return false; + + const cartaPorte = await this.getCartaPorteOrFail( + mercancia.cartaPorteId, + tenantId + ); + this.assertEditable(cartaPorte); + + const cartaPorteId = mercancia.cartaPorteId; + const result = await this.mercanciaRepository.delete(id); + + if ((result.affected ?? 0) > 0) { + // Reordenar secuencias + await this.reorderSecuencias(cartaPorteId); + // Actualizar totales + await this.updateCartaPorteTotals(cartaPorteId, tenantId); + return true; + } + + return false; + } + + /** + * Valida que los pesos totales de mercancias coincidan con el declarado + */ + async validatePesosTotales( + tenantId: string, + cartaPorteId: string + ): Promise { + const cartaPorte = await this.getCartaPorteOrFail(cartaPorteId, tenantId); + const mercancias = await this.findByCartaParte(tenantId, cartaPorteId); + + const errors: string[] = []; + const toleranciaPorcentaje = 2; // 2% de tolerancia + + if (mercancias.length === 0) { + return { + valid: false, + pesoTotalMercancias: 0, + pesoBrutoDeclarado: cartaPorte.pesoBrutoTotal, + diferencia: 0, + porcentajeDiferencia: 0, + toleranciaPorcentaje, + errors: ['No hay mercancias registradas'], + }; + } + + const pesoTotalMercancias = mercancias.reduce( + (sum, m) => sum + Number(m.pesoEnKg) * Number(m.cantidad), + 0 + ); + + const pesoBrutoDeclarado = cartaPorte.pesoBrutoTotal + ? Number(cartaPorte.pesoBrutoTotal) + : null; + + if (pesoBrutoDeclarado === null) { + errors.push('No se ha declarado peso bruto total en la Carta Porte'); + return { + valid: false, + pesoTotalMercancias, + pesoBrutoDeclarado: null, + diferencia: 0, + porcentajeDiferencia: 0, + toleranciaPorcentaje, + errors, + }; + } + + const diferencia = Math.abs(pesoTotalMercancias - pesoBrutoDeclarado); + const porcentajeDiferencia = + pesoBrutoDeclarado > 0 ? (diferencia / pesoBrutoDeclarado) * 100 : 0; + + if (porcentajeDiferencia > toleranciaPorcentaje) { + errors.push( + `Diferencia de peso (${porcentajeDiferencia.toFixed(2)}%) excede tolerancia de ${toleranciaPorcentaje}%` + ); + } + + // Validar pesos individuales + for (const m of mercancias) { + if (Number(m.pesoEnKg) <= 0) { + errors.push(`Mercancia "${m.descripcion}" tiene peso invalido`); + } + if (Number(m.cantidad) <= 0) { + errors.push(`Mercancia "${m.descripcion}" tiene cantidad invalida`); + } + } + + return { + valid: errors.length === 0, + pesoTotalMercancias, + pesoBrutoDeclarado, + diferencia, + porcentajeDiferencia, + toleranciaPorcentaje, + errors, + }; + } + + /** + * Calcula el valor total de las mercancias + */ + async calculateValorMercancia( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + const mercancias = await this.findByCartaParte(tenantId, cartaPorteId); + + if (mercancias.length === 0) { + return { + valorTotal: 0, + moneda: 'MXN', + cantidadMercancias: 0, + valorPromedioPorMercancia: 0, + mercanciaMayorValor: null, + }; + } + + // Asumimos misma moneda (SAT requiere MXN para valores) + const moneda = mercancias[0]?.moneda || 'MXN'; + + let valorTotal = 0; + let mercanciaMayorValor: { id: string; descripcion: string; valor: number } | null = null; + + for (const m of mercancias) { + const valorItem = m.valorMercancia ? Number(m.valorMercancia) * Number(m.cantidad) : 0; + valorTotal += valorItem; + + if (!mercanciaMayorValor || valorItem > mercanciaMayorValor.valor) { + mercanciaMayorValor = { + id: m.id, + descripcion: m.descripcion, + valor: valorItem, + }; + } + } + + return { + valorTotal, + moneda, + cantidadMercancias: mercancias.length, + valorPromedioPorMercancia: mercancias.length > 0 ? valorTotal / mercancias.length : 0, + mercanciaMayorValor, + }; + } + + /** + * Obtiene solo las mercancias peligrosas de una Carta Porte + */ + async getMercanciasPeligrosas( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + + return this.mercanciaRepository.find({ + where: { + cartaPorteId, + tenantId, + materialPeligroso: true, + }, + order: { secuencia: 'ASC' }, + }); + } + + /** + * Obtiene estadisticas de mercancias peligrosas + */ + async getEstadisticasMercanciasPeligrosas( + tenantId: string, + cartaPorteId: string + ): Promise<{ + totalMercancias: number; + mercanciasPeligrosas: number; + porcentajePeligrosas: number; + clavesMaterialPeligroso: string[]; + }> { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + const todas = await this.findByCartaParte(tenantId, cartaPorteId); + const peligrosas = todas.filter(m => m.materialPeligroso); + + const clavesMaterialPeligroso = Array.from( + new Set( + peligrosas + .map(m => m.cveMaterialPeligroso) + .filter((c): c is string => c !== null) + ) + ); + + return { + totalMercancias: todas.length, + mercanciasPeligrosas: peligrosas.length, + porcentajePeligrosas: todas.length > 0 ? (peligrosas.length / todas.length) * 100 : 0, + clavesMaterialPeligroso, + }; + } + + /** + * Valida datos de mercancia segun requerimientos SAT + */ + private validateMercanciaData(data: CreateMercanciaDto): void { + const errors: string[] = []; + + if (!data.bienesTransp || data.bienesTransp.length === 0) { + errors.push('bienesTransp es requerido'); + } + + if (!data.descripcion || data.descripcion.length === 0) { + errors.push('descripcion es requerida'); + } + + if (!data.cantidad || data.cantidad <= 0) { + errors.push('cantidad debe ser mayor a 0'); + } + + if (!data.claveUnidad || data.claveUnidad.length === 0) { + errors.push('claveUnidad es requerida'); + } + + if (!data.pesoEnKg || data.pesoEnKg <= 0) { + errors.push('pesoEnKg debe ser mayor a 0'); + } + + // Validaciones material peligroso + if (data.materialPeligroso === true) { + if (!data.cveMaterialPeligroso) { + errors.push('cveMaterialPeligroso es requerido para material peligroso'); + } + if (!data.tipoEmbalaje) { + errors.push('tipoEmbalaje es requerido para material peligroso'); + } + } + + if (errors.length > 0) { + throw new Error(`Datos de mercancia invalidos: ${errors.join(', ')}`); + } + } + + /** + * Reordena las secuencias despues de eliminar + */ + private async reorderSecuencias(cartaPorteId: string): Promise { + const mercancias = await this.mercanciaRepository.find({ + where: { cartaPorteId }, + order: { secuencia: 'ASC' }, + }); + + for (let i = 0; i < mercancias.length; i++) { + if (mercancias[i].secuencia !== i + 1) { + mercancias[i].secuencia = i + 1; + await this.mercanciaRepository.save(mercancias[i]); + } + } + } + + /** + * Actualiza totales en la Carta Porte + */ + private async updateCartaPorteTotals( + cartaPorteId: string, + tenantId: string + ): Promise { + const mercancias = await this.mercanciaRepository.find({ + where: { cartaPorteId, tenantId }, + }); + + const numTotalMercancias = mercancias.length; + const pesoBrutoTotal = mercancias.reduce( + (sum, m) => sum + Number(m.pesoEnKg) * Number(m.cantidad), + 0 + ); + + await this.cartaPorteRepository.update( + { id: cartaPorteId, tenantId }, + { numTotalMercancias, pesoBrutoTotal } + ); + } +} diff --git a/src/modules/carta-porte/services/ubicacion-carta-porte.service.ts b/src/modules/carta-porte/services/ubicacion-carta-porte.service.ts new file mode 100644 index 0000000..3fc5ec8 --- /dev/null +++ b/src/modules/carta-porte/services/ubicacion-carta-porte.service.ts @@ -0,0 +1,526 @@ +import { Repository, DataSource } from 'typeorm'; +import { UbicacionCartaPorte, TipoUbicacionCartaPorte } from '../entities'; +import { CartaPorte, EstadoCartaPorte } from '../entities/carta-porte.entity'; + +/** + * DTO para crear ubicacion + */ +export interface CreateUbicacionDto { + tipoUbicacion: string; + idUbicacion?: string; + rfcRemitenteDestinatario?: string; + nombreRemitenteDestinatario?: string; + pais?: string; + estado?: string; + municipio?: string; + localidad?: string; + codigoPostal: string; + colonia?: string; + calle?: string; + numeroExterior?: string; + numeroInterior?: string; + referencia?: string; + fechaHoraSalidaLlegada?: Date; + distanciaRecorrida?: number; + secuencia?: number; +} + +/** + * DTO para actualizar ubicacion + */ +export interface UpdateUbicacionDto extends Partial {} + +/** + * Resultado de validacion de secuencia + */ +export interface ValidacionSecuencia { + valid: boolean; + tieneOrigen: boolean; + tieneDestino: boolean; + secuenciaCorrecta: boolean; + distanciaTotalKm: number; + errors: string[]; +} + +/** + * Servicio para gestion de ubicaciones de Carta Porte + * CFDI 3.1 Compliance + */ +export class UbicacionCartaPorteService { + private ubicacionRepository: Repository; + private cartaPorteRepository: Repository; + + constructor(private readonly dataSource: DataSource) { + this.ubicacionRepository = dataSource.getRepository(UbicacionCartaPorte); + this.cartaPorteRepository = dataSource.getRepository(CartaPorte); + } + + /** + * Verifica que la Carta Porte exista y pertenezca al tenant + */ + private async getCartaPorteOrFail( + cartaPorteId: string, + tenantId: string + ): Promise { + const cartaPorte = await this.cartaPorteRepository.findOne({ + where: { id: cartaPorteId, tenantId }, + }); + + if (!cartaPorte) { + throw new Error('Carta Porte no encontrada'); + } + + return cartaPorte; + } + + /** + * Verifica que la Carta Porte este en estado editable + */ + private assertEditable(cartaPorte: CartaPorte): void { + if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) { + throw new Error( + `No se pueden modificar ubicaciones en estado ${cartaPorte.estado}` + ); + } + } + + /** + * Obtiene el siguiente numero de secuencia + */ + private async getNextSecuencia(cartaPorteId: string): Promise { + const result = await this.ubicacionRepository + .createQueryBuilder('u') + .select('COALESCE(MAX(u.secuencia), 0)', 'maxSecuencia') + .where('u.cartaPorteId = :cartaPorteId', { cartaPorteId }) + .getRawOne(); + + return (result?.maxSecuencia || 0) + 1; + } + + /** + * Crea una nueva ubicacion para una Carta Porte + */ + async create( + tenantId: string, + cartaPorteId: string, + data: CreateUbicacionDto + ): Promise { + const cartaPorte = await this.getCartaPorteOrFail(cartaPorteId, tenantId); + this.assertEditable(cartaPorte); + + // Validar datos requeridos por SAT + this.validateUbicacionData(data); + + // Validar reglas de negocio + await this.validateBusinessRules(cartaPorteId, data); + + const secuencia = data.secuencia ?? (await this.getNextSecuencia(cartaPorteId)); + + const ubicacion = this.ubicacionRepository.create({ + tenantId, + cartaPorteId, + tipoUbicacion: data.tipoUbicacion, + idUbicacion: data.idUbicacion || null, + rfcRemitenteDestinatario: data.rfcRemitenteDestinatario || null, + nombreRemitenteDestinatario: data.nombreRemitenteDestinatario || null, + pais: data.pais || 'MEX', + estado: data.estado || null, + municipio: data.municipio || null, + localidad: data.localidad || null, + codigoPostal: data.codigoPostal, + colonia: data.colonia || null, + calle: data.calle || null, + numeroExterior: data.numeroExterior || null, + numeroInterior: data.numeroInterior || null, + referencia: data.referencia || null, + fechaHoraSalidaLlegada: data.fechaHoraSalidaLlegada || null, + distanciaRecorrida: data.distanciaRecorrida || null, + secuencia, + }); + + return this.ubicacionRepository.save(ubicacion); + } + + /** + * Obtiene todas las ubicaciones de una Carta Porte ordenadas por secuencia + */ + async findByCartaParte( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + + return this.ubicacionRepository.find({ + where: { cartaPorteId, tenantId }, + order: { secuencia: 'ASC' }, + }); + } + + /** + * Obtiene una ubicacion por ID + */ + async findById( + tenantId: string, + id: string + ): Promise { + return this.ubicacionRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Actualiza una ubicacion existente + */ + async update( + tenantId: string, + id: string, + data: UpdateUbicacionDto + ): Promise { + const ubicacion = await this.findById(tenantId, id); + if (!ubicacion) return null; + + const cartaPorte = await this.getCartaPorteOrFail( + ubicacion.cartaPorteId, + tenantId + ); + this.assertEditable(cartaPorte); + + // Validar datos si se actualizan campos requeridos + if (data.tipoUbicacion || data.codigoPostal) { + this.validateUbicacionData({ + tipoUbicacion: data.tipoUbicacion ?? ubicacion.tipoUbicacion, + codigoPostal: data.codigoPostal ?? ubicacion.codigoPostal, + } as CreateUbicacionDto); + } + + Object.assign(ubicacion, data); + return this.ubicacionRepository.save(ubicacion); + } + + /** + * Elimina una ubicacion + */ + async delete(tenantId: string, id: string): Promise { + const ubicacion = await this.findById(tenantId, id); + if (!ubicacion) return false; + + const cartaPorte = await this.getCartaPorteOrFail( + ubicacion.cartaPorteId, + tenantId + ); + this.assertEditable(cartaPorte); + + const cartaPorteId = ubicacion.cartaPorteId; + const result = await this.ubicacionRepository.delete(id); + + if ((result.affected ?? 0) > 0) { + // Reordenar secuencias + await this.reorderSecuencias(cartaPorteId); + return true; + } + + return false; + } + + /** + * Reordena las ubicaciones segun un nuevo orden + */ + async reorder( + tenantId: string, + cartaPorteId: string, + newOrder: string[] + ): Promise { + const cartaPorte = await this.getCartaPorteOrFail(cartaPorteId, tenantId); + this.assertEditable(cartaPorte); + + const ubicaciones = await this.findByCartaParte(tenantId, cartaPorteId); + + // Validar que todos los IDs existan + const existingIds = new Set(ubicaciones.map(u => u.id)); + for (const id of newOrder) { + if (!existingIds.has(id)) { + throw new Error(`Ubicacion con ID ${id} no encontrada`); + } + } + + // Validar que todos los IDs esten presentes + if (newOrder.length !== ubicaciones.length) { + throw new Error('El nuevo orden debe contener todas las ubicaciones'); + } + + // Actualizar secuencias + const updates: Promise[] = []; + for (let i = 0; i < newOrder.length; i++) { + const ubicacion = ubicaciones.find(u => u.id === newOrder[i]); + if (ubicacion && ubicacion.secuencia !== i + 1) { + ubicacion.secuencia = i + 1; + updates.push(this.ubicacionRepository.save(ubicacion)); + } + } + + await Promise.all(updates); + + // Validar que el nuevo orden tenga sentido (origen primero, destino ultimo) + const reordered = await this.findByCartaParte(tenantId, cartaPorteId); + await this.validateSecuenciaAfterReorder(reordered); + + return reordered; + } + + /** + * Valida la integridad de la secuencia de ubicaciones + */ + async validateSecuencia( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + const ubicaciones = await this.findByCartaParte(tenantId, cartaPorteId); + + const errors: string[] = []; + + if (ubicaciones.length === 0) { + return { + valid: false, + tieneOrigen: false, + tieneDestino: false, + secuenciaCorrecta: false, + distanciaTotalKm: 0, + errors: ['No hay ubicaciones registradas'], + }; + } + + // Verificar origen y destino + const tieneOrigen = ubicaciones.some( + u => u.tipoUbicacion === TipoUbicacionCartaPorte.ORIGEN + ); + const tieneDestino = ubicaciones.some( + u => u.tipoUbicacion === TipoUbicacionCartaPorte.DESTINO + ); + + if (!tieneOrigen) { + errors.push('Se requiere al menos una ubicacion de origen'); + } + + if (!tieneDestino) { + errors.push('Se requiere al menos una ubicacion de destino'); + } + + // Verificar que origen sea primero y destino sea ultimo + const primera = ubicaciones[0]; + const ultima = ubicaciones[ubicaciones.length - 1]; + + let secuenciaCorrecta = true; + + if (primera.tipoUbicacion !== TipoUbicacionCartaPorte.ORIGEN) { + errors.push('La primera ubicacion debe ser de tipo Origen'); + secuenciaCorrecta = false; + } + + if (ultima.tipoUbicacion !== TipoUbicacionCartaPorte.DESTINO) { + errors.push('La ultima ubicacion debe ser de tipo Destino'); + secuenciaCorrecta = false; + } + + // Verificar secuencia de numeros + for (let i = 0; i < ubicaciones.length; i++) { + if (ubicaciones[i].secuencia !== i + 1) { + errors.push(`Secuencia incorrecta en ubicacion ${i + 1}`); + secuenciaCorrecta = false; + } + } + + // Verificar fechas en orden cronologico + for (let i = 1; i < ubicaciones.length; i++) { + const fechaAnterior = ubicaciones[i - 1].fechaHoraSalidaLlegada; + const fechaActual = ubicaciones[i].fechaHoraSalidaLlegada; + + if (fechaAnterior && fechaActual && fechaActual < fechaAnterior) { + errors.push( + `Fecha de ubicacion ${i + 1} es anterior a ubicacion ${i}` + ); + secuenciaCorrecta = false; + } + } + + // Calcular distancia total + const distanciaTotalKm = ubicaciones.reduce( + (sum, u) => sum + (u.distanciaRecorrida ? Number(u.distanciaRecorrida) : 0), + 0 + ); + + // Validar que haya al menos 2 ubicaciones (SAT requirement) + if (ubicaciones.length < 2) { + errors.push('Se requieren al menos 2 ubicaciones (origen y destino)'); + } + + return { + valid: errors.length === 0, + tieneOrigen, + tieneDestino, + secuenciaCorrecta, + distanciaTotalKm, + errors, + }; + } + + /** + * Obtiene la ubicacion de origen + */ + async getOrigen( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + + return this.ubicacionRepository.findOne({ + where: { + cartaPorteId, + tenantId, + tipoUbicacion: TipoUbicacionCartaPorte.ORIGEN, + }, + order: { secuencia: 'ASC' }, + }); + } + + /** + * Obtiene la ubicacion de destino final + */ + async getDestinoFinal( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + + return this.ubicacionRepository.findOne({ + where: { + cartaPorteId, + tenantId, + tipoUbicacion: TipoUbicacionCartaPorte.DESTINO, + }, + order: { secuencia: 'DESC' }, + }); + } + + /** + * Obtiene la distancia total del recorrido + */ + async getDistanciaTotal( + tenantId: string, + cartaPorteId: string + ): Promise { + await this.getCartaPorteOrFail(cartaPorteId, tenantId); + + const result = await this.ubicacionRepository + .createQueryBuilder('u') + .select('COALESCE(SUM(u.distanciaRecorrida), 0)', 'total') + .where('u.cartaPorteId = :cartaPorteId', { cartaPorteId }) + .andWhere('u.tenantId = :tenantId', { tenantId }) + .getRawOne(); + + return Number(result?.total || 0); + } + + /** + * Valida datos de ubicacion segun requerimientos SAT + */ + private validateUbicacionData(data: CreateUbicacionDto): void { + const errors: string[] = []; + + if (!data.tipoUbicacion) { + errors.push('tipoUbicacion es requerido'); + } + + if ( + data.tipoUbicacion && + !Object.values(TipoUbicacionCartaPorte).includes( + data.tipoUbicacion as TipoUbicacionCartaPorte + ) + ) { + errors.push( + `tipoUbicacion debe ser: ${Object.values(TipoUbicacionCartaPorte).join(', ')}` + ); + } + + if (!data.codigoPostal) { + errors.push('codigoPostal es requerido'); + } + + if (data.codigoPostal && !/^\d{5}$/.test(data.codigoPostal)) { + errors.push('codigoPostal debe tener 5 digitos'); + } + + if (errors.length > 0) { + throw new Error(`Datos de ubicacion invalidos: ${errors.join(', ')}`); + } + } + + /** + * Valida reglas de negocio para ubicaciones + */ + private async validateBusinessRules( + cartaPorteId: string, + data: CreateUbicacionDto + ): Promise { + const existingUbicaciones = await this.ubicacionRepository.find({ + where: { cartaPorteId }, + order: { secuencia: 'ASC' }, + }); + + // Si es la primera ubicacion, debe ser origen + if ( + existingUbicaciones.length === 0 && + data.tipoUbicacion !== TipoUbicacionCartaPorte.ORIGEN + ) { + throw new Error('La primera ubicacion debe ser de tipo Origen'); + } + } + + /** + * Valida secuencia despues de reordenar + */ + private async validateSecuenciaAfterReorder( + ubicaciones: UbicacionCartaPorte[] + ): Promise { + if (ubicaciones.length < 2) return; + + const primera = ubicaciones[0]; + const ultima = ubicaciones[ubicaciones.length - 1]; + + const warnings: string[] = []; + + if (primera.tipoUbicacion !== TipoUbicacionCartaPorte.ORIGEN) { + warnings.push( + 'Advertencia: La primera ubicacion no es de tipo Origen' + ); + } + + if (ultima.tipoUbicacion !== TipoUbicacionCartaPorte.DESTINO) { + warnings.push( + 'Advertencia: La ultima ubicacion no es de tipo Destino' + ); + } + + // Log warnings but don't throw - let user decide + if (warnings.length > 0) { + console.warn('Ubicaciones reordenadas con advertencias:', warnings); + } + } + + /** + * Reordena las secuencias despues de eliminar + */ + private async reorderSecuencias(cartaPorteId: string): Promise { + const ubicaciones = await this.ubicacionRepository.find({ + where: { cartaPorteId }, + order: { secuencia: 'ASC' }, + }); + + for (let i = 0; i < ubicaciones.length; i++) { + if (ubicaciones[i].secuencia !== i + 1) { + ubicaciones[i].secuencia = i + 1; + await this.ubicacionRepository.save(ubicaciones[i]); + } + } + } +} diff --git a/src/modules/gestion-flota/services/asignacion.service.ts b/src/modules/gestion-flota/services/asignacion.service.ts new file mode 100644 index 0000000..48799d3 --- /dev/null +++ b/src/modules/gestion-flota/services/asignacion.service.ts @@ -0,0 +1,455 @@ +import { Repository, IsNull, Not } from 'typeorm'; +import { Asignacion } from '../entities/asignacion.entity'; +import { Unidad, EstadoUnidad } from '../entities/unidad.entity'; +import { Operador, EstadoOperador } from '../entities/operador.entity'; +import { DocumentoFlota, TipoDocumento, TipoEntidadDocumento } from '../entities/documento-flota.entity'; + +// ============================================================================ +// INTERFACES +// ============================================================================ + +export interface CreateAsignacionDto { + remolqueId?: string; + motivo?: string; + fechaInicio?: Date; +} + +export interface AsignacionHistorial { + asignacion: Asignacion; + unidad: Unidad; + operador: Operador; + duracionDias: number; +} + +export interface DisponibilidadResult { + unidadDisponible: boolean; + operadorDisponible: boolean; + unidadMotivo?: string; + operadorMotivo?: string; + documentosVencidosUnidad: string[]; + documentosVencidosOperador: string[]; + puedeAsignar: boolean; +} + +// Documentos criticos que bloquean asignacion +const DOCUMENTOS_CRITICOS_UNIDAD: TipoDocumento[] = [ + TipoDocumento.TARJETA_CIRCULACION, + TipoDocumento.POLIZA_SEGURO, + TipoDocumento.VERIFICACION, + TipoDocumento.PERMISO_SCT, +]; + +const DOCUMENTOS_CRITICOS_OPERADOR: TipoDocumento[] = [ + TipoDocumento.LICENCIA, + TipoDocumento.CERTIFICADO_FISICO, + TipoDocumento.ANTIDOPING, +]; + +// ============================================================================ +// SERVICE +// ============================================================================ + +export class AsignacionService { + constructor( + private readonly asignacionRepository: Repository, + private readonly unidadRepository: Repository, + private readonly operadorRepository: Repository, + private readonly documentoRepository: Repository + ) {} + + // -------------------------------------------------------------------------- + // CRUD OPERATIONS + // -------------------------------------------------------------------------- + + /** + * Crear asignacion de unidad a operador + */ + async create( + tenantId: string, + unidadId: string, + operadorId: string, + dto: CreateAsignacionDto, + createdById: string + ): Promise { + // Validar disponibilidad de ambos + const disponibilidad = await this.validateDisponibilidad(tenantId, unidadId, operadorId); + + if (!disponibilidad.puedeAsignar) { + const motivos: string[] = []; + if (!disponibilidad.unidadDisponible) { + motivos.push(`Unidad: ${disponibilidad.unidadMotivo}`); + } + if (!disponibilidad.operadorDisponible) { + motivos.push(`Operador: ${disponibilidad.operadorMotivo}`); + } + if (disponibilidad.documentosVencidosUnidad.length > 0) { + motivos.push(`Documentos vencidos de unidad: ${disponibilidad.documentosVencidosUnidad.join(', ')}`); + } + if (disponibilidad.documentosVencidosOperador.length > 0) { + motivos.push(`Documentos vencidos de operador: ${disponibilidad.documentosVencidosOperador.join(', ')}`); + } + throw new Error(`No se puede crear la asignacion: ${motivos.join('; ')}`); + } + + // Verificar que no haya asignacion activa para la unidad + const asignacionExistenteUnidad = await this.asignacionRepository.findOne({ + where: { tenantId, unidadId, activa: true }, + }); + if (asignacionExistenteUnidad) { + throw new Error('La unidad ya tiene un operador asignado. Termine la asignacion actual primero.'); + } + + // Verificar que el operador no tenga asignacion activa + const asignacionExistenteOperador = await this.asignacionRepository.findOne({ + where: { tenantId, operadorId, activa: true }, + }); + if (asignacionExistenteOperador) { + throw new Error('El operador ya tiene una unidad asignada. Termine la asignacion actual primero.'); + } + + // Obtener unidad y operador + const unidad = await this.unidadRepository.findOne({ where: { tenantId, id: unidadId } }); + const operador = await this.operadorRepository.findOne({ where: { tenantId, id: operadorId } }); + + if (!unidad) throw new Error(`Unidad con ID ${unidadId} no encontrada`); + if (!operador) throw new Error(`Operador con ID ${operadorId} no encontrado`); + + // Crear asignacion + const asignacion = this.asignacionRepository.create({ + tenantId, + unidadId, + operadorId, + remolqueId: dto.remolqueId, + fechaInicio: dto.fechaInicio || new Date(), + activa: true, + motivo: dto.motivo, + createdById, + }); + + const savedAsignacion = await this.asignacionRepository.save(asignacion); + + // Actualizar unidad asignada en operador + operador.unidadAsignadaId = unidadId; + await this.operadorRepository.save(operador); + + return savedAsignacion; + } + + /** + * Buscar asignacion por ID + */ + async findById(tenantId: string, id: string): Promise { + return this.asignacionRepository.findOne({ + where: { tenantId, id }, + relations: ['unidad', 'operador'], + }); + } + + /** + * Buscar asignacion por ID o lanzar error + */ + async findByIdOrFail(tenantId: string, id: string): Promise { + const asignacion = await this.findById(tenantId, id); + if (!asignacion) { + throw new Error(`Asignacion con ID ${id} no encontrada`); + } + return asignacion; + } + + /** + * Buscar historial de asignaciones de una unidad + */ + async findByUnidad(tenantId: string, unidadId: string): Promise { + return this.asignacionRepository.find({ + where: { tenantId, unidadId }, + relations: ['operador'], + order: { fechaInicio: 'DESC' }, + }); + } + + /** + * Buscar historial de asignaciones de un operador + */ + async findByOperador(tenantId: string, operadorId: string): Promise { + return this.asignacionRepository.find({ + where: { tenantId, operadorId }, + relations: ['unidad'], + order: { fechaInicio: 'DESC' }, + }); + } + + /** + * Obtener todas las asignaciones activas + */ + async findActivas(tenantId: string): Promise { + return this.asignacionRepository.find({ + where: { tenantId, activa: true }, + relations: ['unidad', 'operador'], + order: { fechaInicio: 'DESC' }, + }); + } + + /** + * Obtener asignacion actual de una unidad + */ + async getAsignacionActual(tenantId: string, unidadId: string): Promise { + return this.asignacionRepository.findOne({ + where: { tenantId, unidadId, activa: true }, + relations: ['operador'], + }); + } + + /** + * Terminar asignacion + */ + async terminar( + tenantId: string, + id: string, + motivo: string, + terminadoPorId: string + ): Promise { + const asignacion = await this.findByIdOrFail(tenantId, id); + + if (!asignacion.activa) { + throw new Error('La asignacion ya esta terminada'); + } + + // Verificar que la unidad no este en viaje + const unidad = await this.unidadRepository.findOne({ + where: { tenantId, id: asignacion.unidadId }, + }); + + if (unidad && (unidad.estado === EstadoUnidad.EN_VIAJE || unidad.estado === EstadoUnidad.EN_RUTA)) { + throw new Error('No se puede terminar la asignacion de una unidad en viaje'); + } + + // Terminar asignacion + asignacion.activa = false; + asignacion.fechaFin = new Date(); + asignacion.motivo = motivo; + + await this.asignacionRepository.save(asignacion); + + // Actualizar 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); + } + + return asignacion; + } + + /** + * Transferir unidad a nuevo operador + */ + async transferir( + tenantId: string, + unidadId: string, + nuevoOperadorId: string, + motivo: string, + transferidoPorId: string + ): Promise { + // Verificar que la unidad existe + const unidad = await this.unidadRepository.findOne({ + where: { tenantId, id: unidadId }, + }); + if (!unidad) { + throw new Error(`Unidad con ID ${unidadId} no encontrada`); + } + + // Verificar que la unidad no este en viaje + if (unidad.estado === EstadoUnidad.EN_VIAJE || unidad.estado === EstadoUnidad.EN_RUTA) { + throw new Error('No se puede transferir una unidad en viaje'); + } + + // Terminar asignacion actual si existe + const asignacionActual = await this.getAsignacionActual(tenantId, unidadId); + if (asignacionActual) { + await this.terminar(tenantId, asignacionActual.id, `Transferencia: ${motivo}`, transferidoPorId); + } + + // Crear nueva asignacion + return this.create( + tenantId, + unidadId, + nuevoOperadorId, + { motivo: `Transferencia: ${motivo}` }, + transferidoPorId + ); + } + + /** + * Obtener historial completo de asignaciones de una unidad + */ + async getHistorial(tenantId: string, unidadId: string): Promise { + const asignaciones = await this.asignacionRepository.find({ + where: { tenantId, unidadId }, + relations: ['unidad', 'operador'], + order: { fechaInicio: 'DESC' }, + }); + + const historial: AsignacionHistorial[] = []; + + for (const asignacion of asignaciones) { + const fechaFin = asignacion.fechaFin || new Date(); + const duracionMs = fechaFin.getTime() - asignacion.fechaInicio.getTime(); + const duracionDias = Math.ceil(duracionMs / (1000 * 60 * 60 * 24)); + + historial.push({ + asignacion, + unidad: asignacion.unidad, + operador: asignacion.operador, + duracionDias, + }); + } + + return historial; + } + + /** + * Validar disponibilidad de unidad y operador para asignacion + */ + async validateDisponibilidad( + tenantId: string, + unidadId: string, + operadorId: string + ): Promise { + const result: DisponibilidadResult = { + unidadDisponible: true, + operadorDisponible: true, + documentosVencidosUnidad: [], + documentosVencidosOperador: [], + puedeAsignar: true, + }; + + const hoy = new Date(); + hoy.setHours(0, 0, 0, 0); + + // Verificar unidad + const unidad = await this.unidadRepository.findOne({ + where: { tenantId, id: unidadId }, + }); + + if (!unidad) { + result.unidadDisponible = false; + result.unidadMotivo = 'Unidad no encontrada'; + result.puedeAsignar = false; + } else if (!unidad.activo) { + result.unidadDisponible = false; + result.unidadMotivo = 'Unidad no activa'; + result.puedeAsignar = false; + } else if (unidad.estado !== EstadoUnidad.DISPONIBLE) { + result.unidadDisponible = false; + result.unidadMotivo = `Unidad en estado ${unidad.estado}`; + result.puedeAsignar = false; + } + + // Verificar operador + const operador = await this.operadorRepository.findOne({ + where: { tenantId, id: operadorId }, + }); + + if (!operador) { + result.operadorDisponible = false; + result.operadorMotivo = 'Operador no encontrado'; + result.puedeAsignar = false; + } else if (!operador.activo) { + result.operadorDisponible = false; + result.operadorMotivo = 'Operador no activo'; + result.puedeAsignar = false; + } else if ( + operador.estado !== EstadoOperador.DISPONIBLE && + operador.estado !== EstadoOperador.ACTIVO + ) { + result.operadorDisponible = false; + result.operadorMotivo = `Operador en estado ${operador.estado}`; + result.puedeAsignar = false; + } + + // Verificar documentos criticos de la unidad + if (unidad) { + const docsUnidad = await this.documentoRepository.find({ + where: { + tenantId, + entidadId: unidadId, + entidadTipo: TipoEntidadDocumento.UNIDAD, + activo: true, + }, + }); + + for (const tipoDoc of DOCUMENTOS_CRITICOS_UNIDAD) { + const doc = docsUnidad.find(d => d.tipoDocumento === tipoDoc); + if (doc && doc.fechaVencimiento && doc.fechaVencimiento < hoy) { + result.documentosVencidosUnidad.push(tipoDoc); + result.puedeAsignar = false; + } + } + + // Verificar fechas en la entidad unidad + if (unidad.fechaPolizaVencimiento && unidad.fechaPolizaVencimiento < hoy) { + if (!result.documentosVencidosUnidad.includes(TipoDocumento.POLIZA_SEGURO)) { + result.documentosVencidosUnidad.push(TipoDocumento.POLIZA_SEGURO); + result.puedeAsignar = false; + } + } + if (unidad.fechaVerificacionProxima && unidad.fechaVerificacionProxima < hoy) { + if (!result.documentosVencidosUnidad.includes(TipoDocumento.VERIFICACION)) { + result.documentosVencidosUnidad.push(TipoDocumento.VERIFICACION); + result.puedeAsignar = false; + } + } + if (unidad.fechaPermisoVencimiento && unidad.fechaPermisoVencimiento < hoy) { + if (!result.documentosVencidosUnidad.includes(TipoDocumento.PERMISO_SCT)) { + result.documentosVencidosUnidad.push(TipoDocumento.PERMISO_SCT); + result.puedeAsignar = false; + } + } + } + + // Verificar documentos criticos del operador + if (operador) { + const docsOperador = await this.documentoRepository.find({ + where: { + tenantId, + entidadId: operadorId, + entidadTipo: TipoEntidadDocumento.OPERADOR, + activo: true, + }, + }); + + for (const tipoDoc of DOCUMENTOS_CRITICOS_OPERADOR) { + const doc = docsOperador.find(d => d.tipoDocumento === tipoDoc); + if (doc && doc.fechaVencimiento && doc.fechaVencimiento < hoy) { + result.documentosVencidosOperador.push(tipoDoc); + result.puedeAsignar = false; + } + } + + // Verificar fechas en la entidad operador + if (operador.licenciaVigencia && operador.licenciaVigencia < hoy) { + if (!result.documentosVencidosOperador.includes(TipoDocumento.LICENCIA)) { + result.documentosVencidosOperador.push(TipoDocumento.LICENCIA); + result.puedeAsignar = false; + } + } + if (operador.certificadoFisicoVigencia && operador.certificadoFisicoVigencia < hoy) { + if (!result.documentosVencidosOperador.includes(TipoDocumento.CERTIFICADO_FISICO)) { + result.documentosVencidosOperador.push(TipoDocumento.CERTIFICADO_FISICO); + result.puedeAsignar = false; + } + } + if (operador.antidopingVigencia && operador.antidopingVigencia < hoy) { + if (!result.documentosVencidosOperador.includes(TipoDocumento.ANTIDOPING)) { + result.documentosVencidosOperador.push(TipoDocumento.ANTIDOPING); + result.puedeAsignar = false; + } + } + } + + return result; + } +} diff --git a/src/modules/gestion-flota/services/documento-flota.service.ts b/src/modules/gestion-flota/services/documento-flota.service.ts new file mode 100644 index 0000000..824ad1a --- /dev/null +++ b/src/modules/gestion-flota/services/documento-flota.service.ts @@ -0,0 +1,730 @@ +import { Repository, Between, LessThan, LessThanOrEqual, MoreThan, In } from 'typeorm'; +import { + DocumentoFlota, + TipoDocumento, + TipoEntidadDocumento, +} from '../entities/documento-flota.entity'; +import { Unidad, EstadoUnidad } from '../entities/unidad.entity'; +import { Operador, EstadoOperador } from '../entities/operador.entity'; + +// ============================================================================ +// INTERFACES +// ============================================================================ + +export interface CreateDocumentoFlotaDto { + entidadTipo: TipoEntidadDocumento; + entidadId: string; + tipoDocumento: TipoDocumento; + nombre: string; + numeroDocumento?: string; + descripcion?: string; + fechaEmision?: Date; + fechaVencimiento?: Date; + diasAlertaVencimiento?: number; + archivoUrl?: string; + archivoNombre?: string; + archivoTipo?: string; + archivoTamanoBytes?: number; +} + +export interface UpdateDocumentoFlotaDto { + nombre?: string; + numeroDocumento?: string; + descripcion?: string; + fechaEmision?: Date; + fechaVencimiento?: Date; + diasAlertaVencimiento?: number; + archivoUrl?: string; + archivoNombre?: string; + archivoTipo?: string; + archivoTamanoBytes?: number; + verificado?: boolean; +} + +export interface DocumentoVencimiento { + documento: DocumentoFlota; + entidadTipo: TipoEntidadDocumento; + entidadId: string; + entidadNombre: string; + diasParaVencer: number; + esCritico: boolean; +} + +export interface ResumenVencimientos { + totalDocumentos: number; + vencidos: number; + porVencer7Dias: number; + porVencer15Dias: number; + porVencer30Dias: number; + porCategoria: { + unidades: { vencidos: number; porVencer: number }; + remolques: { vencidos: number; porVencer: number }; + operadores: { vencidos: number; porVencer: number }; + }; + documentosCriticosVencidos: DocumentoVencimiento[]; +} + +export interface EntidadBloqueada { + entidadTipo: TipoEntidadDocumento; + entidadId: string; + entidadNombre: string; + documentosVencidos: DocumentoFlota[]; + motivoBloqueo: string[]; +} + +// Documentos criticos que bloquean operacion +const DOCUMENTOS_CRITICOS_UNIDAD: TipoDocumento[] = [ + TipoDocumento.TARJETA_CIRCULACION, + TipoDocumento.POLIZA_SEGURO, + TipoDocumento.VERIFICACION, + TipoDocumento.PERMISO_SCT, +]; + +const DOCUMENTOS_CRITICOS_OPERADOR: TipoDocumento[] = [ + TipoDocumento.LICENCIA, + TipoDocumento.CERTIFICADO_FISICO, + TipoDocumento.ANTIDOPING, +]; + +// ============================================================================ +// SERVICE +// ============================================================================ + +export class DocumentoFlotaService { + constructor( + private readonly documentoRepository: Repository, + private readonly unidadRepository: Repository, + private readonly operadorRepository: Repository + ) {} + + // -------------------------------------------------------------------------- + // CRUD OPERATIONS + // -------------------------------------------------------------------------- + + /** + * Crear documento de flota (soporta UNIDAD, REMOLQUE, OPERADOR) + */ + async create( + tenantId: string, + dto: CreateDocumentoFlotaDto, + createdById: string + ): Promise { + // Validar que la entidad existe + await this.validateEntidadExists(tenantId, dto.entidadTipo, dto.entidadId); + + // Verificar si ya existe un documento del mismo tipo para la entidad + const existingDoc = await this.documentoRepository.findOne({ + where: { + tenantId, + entidadTipo: dto.entidadTipo, + entidadId: dto.entidadId, + tipoDocumento: dto.tipoDocumento, + activo: true, + }, + }); + + if (existingDoc) { + throw new Error( + `Ya existe un documento de tipo ${dto.tipoDocumento} para esta entidad. ` + + `Use el metodo renovar() para actualizarlo.` + ); + } + + const documento = this.documentoRepository.create({ + ...dto, + tenantId, + diasAlertaVencimiento: dto.diasAlertaVencimiento || 30, + activo: true, + createdById, + }); + + return this.documentoRepository.save(documento); + } + + /** + * Buscar documento por ID + */ + async findById(tenantId: string, id: string): Promise { + return this.documentoRepository.findOne({ + where: { tenantId, id, activo: true }, + }); + } + + /** + * Buscar documento por ID o lanzar error + */ + async findByIdOrFail(tenantId: string, id: string): Promise { + const documento = await this.findById(tenantId, id); + if (!documento) { + throw new Error(`Documento con ID ${id} no encontrado`); + } + return documento; + } + + /** + * Buscar documentos de una unidad + */ + async findByUnidad(tenantId: string, unidadId: string): Promise { + // Verificar que la unidad existe + const unidad = await this.unidadRepository.findOne({ + where: { tenantId, id: unidadId }, + }); + if (!unidad) { + throw new Error(`Unidad con ID ${unidadId} no encontrada`); + } + + return this.documentoRepository.find({ + where: { + tenantId, + entidadTipo: In([TipoEntidadDocumento.UNIDAD, TipoEntidadDocumento.REMOLQUE]), + entidadId: unidadId, + activo: true, + }, + order: { fechaVencimiento: 'ASC' }, + }); + } + + /** + * Buscar documentos de un operador + */ + async findByOperador(tenantId: string, operadorId: string): Promise { + // Verificar que el operador existe + const operador = await this.operadorRepository.findOne({ + where: { tenantId, id: operadorId }, + }); + if (!operador) { + throw new Error(`Operador con ID ${operadorId} no encontrado`); + } + + return this.documentoRepository.find({ + where: { + tenantId, + entidadTipo: TipoEntidadDocumento.OPERADOR, + entidadId: operadorId, + activo: true, + }, + order: { fechaVencimiento: 'ASC' }, + }); + } + + /** + * Buscar documentos por tipo + */ + async findByTipo(tenantId: string, tipo: TipoDocumento): Promise { + return this.documentoRepository.find({ + where: { + tenantId, + tipoDocumento: tipo, + activo: true, + }, + order: { fechaVencimiento: 'ASC' }, + }); + } + + /** + * Obtener documentos vencidos + */ + async findVencidos(tenantId: string): Promise { + const hoy = new Date(); + hoy.setHours(0, 0, 0, 0); + + return this.documentoRepository.find({ + where: { + tenantId, + fechaVencimiento: LessThan(hoy), + activo: true, + }, + order: { fechaVencimiento: 'ASC' }, + }); + } + + /** + * Obtener documentos por vencer en X dias + */ + async findPorVencer(tenantId: string, dias: number): Promise { + const hoy = new Date(); + hoy.setHours(0, 0, 0, 0); + + const fechaLimite = new Date(hoy); + fechaLimite.setDate(fechaLimite.getDate() + dias); + + return this.documentoRepository.find({ + where: { + tenantId, + fechaVencimiento: Between(hoy, fechaLimite), + activo: true, + }, + order: { fechaVencimiento: 'ASC' }, + }); + } + + /** + * Actualizar documento + */ + async update( + tenantId: string, + id: string, + dto: UpdateDocumentoFlotaDto, + updatedById: string + ): Promise { + const documento = await this.findByIdOrFail(tenantId, id); + + // Si se marca como verificado, registrar quien y cuando + if (dto.verificado && !documento.verificado) { + documento.verificado = true; + documento.verificadoPor = updatedById; + documento.verificadoFecha = new Date(); + } else if (dto.verificado !== undefined) { + documento.verificado = dto.verificado; + if (!dto.verificado) { + documento.verificadoPor = undefined as unknown as string; + documento.verificadoFecha = undefined as unknown as Date; + } + } + + // Actualizar otros campos + if (dto.nombre !== undefined) documento.nombre = dto.nombre; + if (dto.numeroDocumento !== undefined) documento.numeroDocumento = dto.numeroDocumento; + if (dto.descripcion !== undefined) documento.descripcion = dto.descripcion; + if (dto.fechaEmision !== undefined) documento.fechaEmision = dto.fechaEmision; + if (dto.fechaVencimiento !== undefined) documento.fechaVencimiento = dto.fechaVencimiento; + if (dto.diasAlertaVencimiento !== undefined) documento.diasAlertaVencimiento = dto.diasAlertaVencimiento; + if (dto.archivoUrl !== undefined) documento.archivoUrl = dto.archivoUrl; + if (dto.archivoNombre !== undefined) documento.archivoNombre = dto.archivoNombre; + if (dto.archivoTipo !== undefined) documento.archivoTipo = dto.archivoTipo; + if (dto.archivoTamanoBytes !== undefined) documento.archivoTamanoBytes = dto.archivoTamanoBytes; + + return this.documentoRepository.save(documento); + } + + /** + * Renovar documento (actualiza fecha de vencimiento) + */ + async renovar( + tenantId: string, + id: string, + nuevaFechaVencimiento: Date, + updatedById: string + ): Promise { + const documento = await this.findByIdOrFail(tenantId, id); + + // Validar que la nueva fecha sea futura + const hoy = new Date(); + if (nuevaFechaVencimiento <= hoy) { + throw new Error('La nueva fecha de vencimiento debe ser una fecha futura'); + } + + // Guardar la fecha anterior para historial (podria extenderse a tabla de historial) + const fechaAnterior = documento.fechaVencimiento; + + documento.fechaVencimiento = nuevaFechaVencimiento; + documento.fechaEmision = new Date(); // Fecha de renovacion + documento.verificado = false; // Requiere nueva verificacion + documento.verificadoPor = undefined as unknown as string; + documento.verificadoFecha = undefined as unknown as Date; + + // Agregar nota en descripcion sobre renovacion + const notaRenovacion = `\n[Renovado el ${new Date().toISOString().split('T')[0]} - Vigencia anterior: ${fechaAnterior?.toISOString().split('T')[0] || 'N/A'}]`; + documento.descripcion = (documento.descripcion || '') + notaRenovacion; + + return this.documentoRepository.save(documento); + } + + /** + * Eliminar documento (soft delete) + */ + async delete(tenantId: string, id: string): Promise { + const documento = await this.findByIdOrFail(tenantId, id); + documento.activo = false; + await this.documentoRepository.save(documento); + } + + // -------------------------------------------------------------------------- + // REPORTS & ALERTS + // -------------------------------------------------------------------------- + + /** + * Obtener resumen de vencimientos por categoria + */ + async getResumenVencimientos(tenantId: string): Promise { + const hoy = new Date(); + hoy.setHours(0, 0, 0, 0); + + const fecha7Dias = new Date(hoy); + fecha7Dias.setDate(fecha7Dias.getDate() + 7); + + const fecha15Dias = new Date(hoy); + fecha15Dias.setDate(fecha15Dias.getDate() + 15); + + const fecha30Dias = new Date(hoy); + fecha30Dias.setDate(fecha30Dias.getDate() + 30); + + // Total documentos activos + const totalDocumentos = await this.documentoRepository.count({ + where: { tenantId, activo: true }, + }); + + // Vencidos + const vencidos = await this.documentoRepository.count({ + where: { + tenantId, + fechaVencimiento: LessThan(hoy), + activo: true, + }, + }); + + // Por vencer en 7 dias + const porVencer7Dias = await this.documentoRepository.count({ + where: { + tenantId, + fechaVencimiento: Between(hoy, fecha7Dias), + activo: true, + }, + }); + + // Por vencer en 15 dias + const porVencer15Dias = await this.documentoRepository.count({ + where: { + tenantId, + fechaVencimiento: Between(hoy, fecha15Dias), + activo: true, + }, + }); + + // Por vencer en 30 dias + const porVencer30Dias = await this.documentoRepository.count({ + where: { + tenantId, + fechaVencimiento: Between(hoy, fecha30Dias), + activo: true, + }, + }); + + // Por categoria - Unidades + const unidadesVencidos = await this.documentoRepository.count({ + where: { + tenantId, + entidadTipo: TipoEntidadDocumento.UNIDAD, + fechaVencimiento: LessThan(hoy), + activo: true, + }, + }); + const unidadesPorVencer = await this.documentoRepository.count({ + where: { + tenantId, + entidadTipo: TipoEntidadDocumento.UNIDAD, + fechaVencimiento: Between(hoy, fecha30Dias), + activo: true, + }, + }); + + // Por categoria - Remolques + const remolquesVencidos = await this.documentoRepository.count({ + where: { + tenantId, + entidadTipo: TipoEntidadDocumento.REMOLQUE, + fechaVencimiento: LessThan(hoy), + activo: true, + }, + }); + const remolquesPorVencer = await this.documentoRepository.count({ + where: { + tenantId, + entidadTipo: TipoEntidadDocumento.REMOLQUE, + fechaVencimiento: Between(hoy, fecha30Dias), + activo: true, + }, + }); + + // Por categoria - Operadores + const operadoresVencidos = await this.documentoRepository.count({ + where: { + tenantId, + entidadTipo: TipoEntidadDocumento.OPERADOR, + fechaVencimiento: LessThan(hoy), + activo: true, + }, + }); + const operadoresPorVencer = await this.documentoRepository.count({ + where: { + tenantId, + entidadTipo: TipoEntidadDocumento.OPERADOR, + fechaVencimiento: Between(hoy, fecha30Dias), + activo: true, + }, + }); + + // Documentos criticos vencidos + const documentosCriticosVencidos = await this.getDocumentosCriticosVencidos(tenantId); + + return { + totalDocumentos, + vencidos, + porVencer7Dias, + porVencer15Dias, + porVencer30Dias, + porCategoria: { + unidades: { vencidos: unidadesVencidos, porVencer: unidadesPorVencer }, + remolques: { vencidos: remolquesVencidos, porVencer: remolquesPorVencer }, + operadores: { vencidos: operadoresVencidos, porVencer: operadoresPorVencer }, + }, + documentosCriticosVencidos, + }; + } + + /** + * Obtener unidades/operadores con documentos criticos vencidos (bloqueo operativo) + */ + async bloquearPorDocumentosVencidos(tenantId: string): Promise { + const hoy = new Date(); + hoy.setHours(0, 0, 0, 0); + + const entidadesBloqueadas: EntidadBloqueada[] = []; + + // Obtener documentos criticos vencidos de unidades + const docsUnidades = await this.documentoRepository.find({ + where: { + tenantId, + entidadTipo: In([TipoEntidadDocumento.UNIDAD, TipoEntidadDocumento.REMOLQUE]), + tipoDocumento: In(DOCUMENTOS_CRITICOS_UNIDAD), + fechaVencimiento: LessThan(hoy), + activo: true, + }, + }); + + // Agrupar por entidad + const unidadesMap = new Map(); + for (const doc of docsUnidades) { + const docs = unidadesMap.get(doc.entidadId) || []; + docs.push(doc); + unidadesMap.set(doc.entidadId, docs); + } + + // Procesar unidades + for (const [entidadId, docs] of unidadesMap) { + const unidad = await this.unidadRepository.findOne({ + where: { tenantId, id: entidadId }, + }); + + if (unidad && unidad.activo) { + entidadesBloqueadas.push({ + entidadTipo: docs[0].entidadTipo, + entidadId, + entidadNombre: `${unidad.numeroEconomico} - ${unidad.marca || ''} ${unidad.modelo || ''}`.trim(), + documentosVencidos: docs, + motivoBloqueo: docs.map(d => `${d.tipoDocumento}: vencido`), + }); + } + } + + // Obtener documentos criticos vencidos de operadores + const docsOperadores = await this.documentoRepository.find({ + where: { + tenantId, + entidadTipo: TipoEntidadDocumento.OPERADOR, + tipoDocumento: In(DOCUMENTOS_CRITICOS_OPERADOR), + fechaVencimiento: LessThan(hoy), + activo: true, + }, + }); + + // Agrupar por operador + const operadoresMap = new Map(); + for (const doc of docsOperadores) { + const docs = operadoresMap.get(doc.entidadId) || []; + docs.push(doc); + operadoresMap.set(doc.entidadId, docs); + } + + // Procesar operadores + for (const [entidadId, docs] of operadoresMap) { + const operador = await this.operadorRepository.findOne({ + where: { tenantId, id: entidadId }, + }); + + if (operador && operador.activo) { + entidadesBloqueadas.push({ + entidadTipo: TipoEntidadDocumento.OPERADOR, + entidadId, + entidadNombre: `${operador.numeroEmpleado} - ${operador.nombre} ${operador.apellidoPaterno}`, + documentosVencidos: docs, + motivoBloqueo: docs.map(d => `${d.tipoDocumento}: vencido`), + }); + } + } + + return entidadesBloqueadas; + } + + /** + * Enviar alertas de vencimiento (retorna lista de alertas a enviar) + */ + async sendAlertasVencimiento( + tenantId: string, + diasAnticipacion: number = 30 + ): Promise { + const hoy = new Date(); + hoy.setHours(0, 0, 0, 0); + + const fechaLimite = new Date(hoy); + fechaLimite.setDate(fechaLimite.getDate() + diasAnticipacion); + + // Obtener documentos que vencen dentro del rango + const documentos = await this.documentoRepository.find({ + where: { + tenantId, + fechaVencimiento: Between(hoy, fechaLimite), + activo: true, + }, + order: { fechaVencimiento: 'ASC' }, + }); + + const alertas: DocumentoVencimiento[] = []; + + for (const doc of documentos) { + const diasParaVencer = Math.ceil( + (doc.fechaVencimiento.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24) + ); + + // Solo alertar si esta dentro del periodo de alerta configurado del documento + if (diasParaVencer <= doc.diasAlertaVencimiento) { + const entidadNombre = await this.getEntidadNombre(tenantId, doc.entidadTipo, doc.entidadId); + const esCritico = this.esDocumentoCritico(doc.entidadTipo, doc.tipoDocumento); + + alertas.push({ + documento: doc, + entidadTipo: doc.entidadTipo, + entidadId: doc.entidadId, + entidadNombre, + diasParaVencer, + esCritico, + }); + } + } + + // Ordenar: criticos primero, luego por dias para vencer + alertas.sort((a, b) => { + if (a.esCritico !== b.esCritico) { + return a.esCritico ? -1 : 1; + } + return a.diasParaVencer - b.diasParaVencer; + }); + + return alertas; + } + + // -------------------------------------------------------------------------- + // HELPER METHODS + // -------------------------------------------------------------------------- + + /** + * Validar que la entidad existe + */ + private async validateEntidadExists( + tenantId: string, + entidadTipo: TipoEntidadDocumento, + entidadId: string + ): Promise { + if (entidadTipo === TipoEntidadDocumento.UNIDAD || entidadTipo === TipoEntidadDocumento.REMOLQUE) { + const unidad = await this.unidadRepository.findOne({ + where: { tenantId, id: entidadId }, + }); + if (!unidad) { + throw new Error(`Unidad/Remolque con ID ${entidadId} no encontrada`); + } + } else if (entidadTipo === TipoEntidadDocumento.OPERADOR) { + const operador = await this.operadorRepository.findOne({ + where: { tenantId, id: entidadId }, + }); + if (!operador) { + throw new Error(`Operador con ID ${entidadId} no encontrado`); + } + } + } + + /** + * Obtener nombre de la entidad para mostrar + */ + private async getEntidadNombre( + tenantId: string, + entidadTipo: TipoEntidadDocumento, + entidadId: string + ): Promise { + if (entidadTipo === TipoEntidadDocumento.UNIDAD || entidadTipo === TipoEntidadDocumento.REMOLQUE) { + const unidad = await this.unidadRepository.findOne({ + where: { tenantId, id: entidadId }, + }); + if (unidad) { + return `${unidad.numeroEconomico} - ${unidad.marca || ''} ${unidad.modelo || ''}`.trim(); + } + } else if (entidadTipo === TipoEntidadDocumento.OPERADOR) { + const operador = await this.operadorRepository.findOne({ + where: { tenantId, id: entidadId }, + }); + if (operador) { + return `${operador.numeroEmpleado} - ${operador.nombre} ${operador.apellidoPaterno}`; + } + } + return 'Entidad no encontrada'; + } + + /** + * Determinar si un documento es critico + */ + private esDocumentoCritico(entidadTipo: TipoEntidadDocumento, tipoDocumento: TipoDocumento): boolean { + if (entidadTipo === TipoEntidadDocumento.UNIDAD || entidadTipo === TipoEntidadDocumento.REMOLQUE) { + return DOCUMENTOS_CRITICOS_UNIDAD.includes(tipoDocumento); + } else if (entidadTipo === TipoEntidadDocumento.OPERADOR) { + return DOCUMENTOS_CRITICOS_OPERADOR.includes(tipoDocumento); + } + return false; + } + + /** + * Obtener documentos criticos vencidos con detalles + */ + private async getDocumentosCriticosVencidos(tenantId: string): Promise { + const hoy = new Date(); + hoy.setHours(0, 0, 0, 0); + + // Documentos criticos vencidos + const documentos = await this.documentoRepository.find({ + where: [ + { + tenantId, + entidadTipo: In([TipoEntidadDocumento.UNIDAD, TipoEntidadDocumento.REMOLQUE]), + tipoDocumento: In(DOCUMENTOS_CRITICOS_UNIDAD), + fechaVencimiento: LessThan(hoy), + activo: true, + }, + { + tenantId, + entidadTipo: TipoEntidadDocumento.OPERADOR, + tipoDocumento: In(DOCUMENTOS_CRITICOS_OPERADOR), + fechaVencimiento: LessThan(hoy), + activo: true, + }, + ], + }); + + const result: DocumentoVencimiento[] = []; + + for (const doc of documentos) { + const diasParaVencer = Math.ceil( + (doc.fechaVencimiento.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24) + ); + const entidadNombre = await this.getEntidadNombre(tenantId, doc.entidadTipo, doc.entidadId); + + result.push({ + documento: doc, + entidadTipo: doc.entidadTipo, + entidadId: doc.entidadId, + entidadNombre, + diasParaVencer, // Sera negativo si ya vencio + esCritico: true, + }); + } + + return result; + } +} diff --git a/src/modules/gestion-flota/services/index.ts b/src/modules/gestion-flota/services/index.ts index 3e6e08c..b92fc92 100644 --- a/src/modules/gestion-flota/services/index.ts +++ b/src/modules/gestion-flota/services/index.ts @@ -10,3 +10,5 @@ export { OperadoresService, OperadorSearchParams } from './operadores.service'; // Enhanced services (full implementation with DTOs) export * from './unidad.service'; export * from './operador.service'; +export * from './documento-flota.service'; +export * from './asignacion.service'; diff --git a/src/modules/gps/services/evento-geocerca.service.ts b/src/modules/gps/services/evento-geocerca.service.ts new file mode 100644 index 0000000..ab5b72d --- /dev/null +++ b/src/modules/gps/services/evento-geocerca.service.ts @@ -0,0 +1,488 @@ +/** + * EventoGeocerca Service + * ERP Transportistas + * + * Business logic for geofence events (entry/exit/permanence). + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { Repository, DataSource, Between, IsNull, Not } from 'typeorm'; +import { EventoGeocerca, TipoEventoGeocerca } from '../entities/evento-geocerca.entity'; +import { DispositivoGps } from '../entities/dispositivo-gps.entity'; +import { PosicionGps } from '../entities/posicion-gps.entity'; + +// ==================== DTOs ==================== + +export interface CreateEventoGeocercaDto { + geocercaId: string; + dispositivoId: string; + unidadId: string; + tipoEvento: TipoEventoGeocerca; + posicionId?: string; + latitud: number; + longitud: number; + tiempoEvento: Date; + viajeId?: string; + metadata?: Record; +} + +export interface DateRange { + fechaInicio: Date; + fechaFin: Date; +} + +export interface EventoGeocercaFilters { + geocercaId?: string; + dispositivoId?: string; + unidadId?: string; + tipoEvento?: TipoEventoGeocerca; + viajeId?: string; +} + +export interface TiempoEnGeocerca { + geocercaId: string; + unidadId: string; + tiempoTotalMinutos: number; + visitasTotales: number; + promedioMinutosPorVisita: number; + ultimaEntrada?: Date; + ultimaSalida?: Date; +} + +export interface UnidadEnGeocerca { + unidadId: string; + dispositivoId: string; + horaEntrada: Date; + tiempoTranscurridoMinutos: number; + latitud: number; + longitud: number; + metadata?: Record; +} + +export interface GeocercaEstadisticas { + totalEventos: number; + totalEntradas: number; + totalSalidas: number; + totalPermanencias: number; + unidadesUnicas: number; + tiempoPromedioMinutos: number; + alertasGeneradas: number; + periodoInicio: Date; + periodoFin: Date; +} + +// ==================== Service ==================== + +export class EventoGeocercaService { + private eventoGeocercaRepository: Repository; + private dispositivoRepository: Repository; + private posicionRepository: Repository; + + constructor(dataSource: DataSource) { + this.eventoGeocercaRepository = dataSource.getRepository(EventoGeocerca); + this.dispositivoRepository = dataSource.getRepository(DispositivoGps); + this.posicionRepository = dataSource.getRepository(PosicionGps); + } + + /** + * Record a new geofence event (entry/exit) + */ + async create(tenantId: string, data: CreateEventoGeocercaDto): Promise { + // Validate device exists + const dispositivo = await this.dispositivoRepository.findOne({ + where: { id: data.dispositivoId, tenantId }, + }); + + if (!dispositivo) { + throw new Error(`Dispositivo ${data.dispositivoId} no encontrado`); + } + + const evento = this.eventoGeocercaRepository.create({ + tenantId, + geocercaId: data.geocercaId, + dispositivoId: data.dispositivoId, + unidadId: data.unidadId, + tipoEvento: data.tipoEvento, + posicionId: data.posicionId, + latitud: data.latitud, + longitud: data.longitud, + tiempoEvento: data.tiempoEvento, + viajeId: data.viajeId, + metadata: data.metadata || {}, + }); + + return this.eventoGeocercaRepository.save(evento); + } + + /** + * Find events for a specific geofence in date range + */ + async findByGeocerca( + tenantId: string, + geocercaId: string, + dateRange: DateRange + ): Promise { + return this.eventoGeocercaRepository.find({ + where: { + tenantId, + geocercaId, + tiempoEvento: Between(dateRange.fechaInicio, dateRange.fechaFin), + }, + order: { tiempoEvento: 'DESC' }, + }); + } + + /** + * Find events for a specific vehicle/unit in date range + */ + async findByUnidad( + tenantId: string, + unidadId: string, + dateRange: DateRange + ): Promise { + return this.eventoGeocercaRepository.find({ + where: { + tenantId, + unidadId, + tiempoEvento: Between(dateRange.fechaInicio, dateRange.fechaFin), + }, + relations: ['dispositivo'], + order: { tiempoEvento: 'DESC' }, + }); + } + + /** + * Find recent geofence events across fleet + */ + async findRecientes(tenantId: string, limit: number = 50): Promise { + return this.eventoGeocercaRepository.find({ + where: { tenantId }, + relations: ['dispositivo'], + order: { tiempoEvento: 'DESC' }, + take: limit, + }); + } + + /** + * Get entry events only for a geofence + */ + async getEntradas( + tenantId: string, + geocercaId: string, + dateRange: DateRange + ): Promise { + return this.eventoGeocercaRepository.find({ + where: { + tenantId, + geocercaId, + tipoEvento: TipoEventoGeocerca.ENTRADA, + tiempoEvento: Between(dateRange.fechaInicio, dateRange.fechaFin), + }, + order: { tiempoEvento: 'DESC' }, + }); + } + + /** + * Get exit events only for a geofence + */ + async getSalidas( + tenantId: string, + geocercaId: string, + dateRange: DateRange + ): Promise { + return this.eventoGeocercaRepository.find({ + where: { + tenantId, + geocercaId, + tipoEvento: TipoEventoGeocerca.SALIDA, + tiempoEvento: Between(dateRange.fechaInicio, dateRange.fechaFin), + }, + order: { tiempoEvento: 'DESC' }, + }); + } + + /** + * Calculate time spent in a geofence by a unit + */ + async getTiempoEnGeocerca( + tenantId: string, + unidadId: string, + geocercaId: string + ): Promise { + // Get all entry/exit events for this unit in this geofence + const eventos = await this.eventoGeocercaRepository.find({ + where: { + tenantId, + unidadId, + geocercaId, + tipoEvento: Not(TipoEventoGeocerca.PERMANENCIA), + }, + order: { tiempoEvento: 'ASC' }, + }); + + let tiempoTotalMinutos = 0; + let visitasTotales = 0; + let ultimaEntrada: Date | undefined; + let ultimaSalida: Date | undefined; + let entradaActual: Date | null = null; + + for (const evento of eventos) { + if (evento.tipoEvento === TipoEventoGeocerca.ENTRADA) { + entradaActual = evento.tiempoEvento; + ultimaEntrada = evento.tiempoEvento; + } else if (evento.tipoEvento === TipoEventoGeocerca.SALIDA && entradaActual) { + const duracion = (evento.tiempoEvento.getTime() - entradaActual.getTime()) / (1000 * 60); + tiempoTotalMinutos += duracion; + visitasTotales++; + ultimaSalida = evento.tiempoEvento; + entradaActual = null; + } + } + + // If still inside (entry without exit), count time until now + if (entradaActual) { + const now = new Date(); + const duracion = (now.getTime() - entradaActual.getTime()) / (1000 * 60); + tiempoTotalMinutos += duracion; + visitasTotales++; + } + + const promedioMinutosPorVisita = visitasTotales > 0 + ? tiempoTotalMinutos / visitasTotales + : 0; + + return { + geocercaId, + unidadId, + tiempoTotalMinutos: Math.round(tiempoTotalMinutos * 100) / 100, + visitasTotales, + promedioMinutosPorVisita: Math.round(promedioMinutosPorVisita * 100) / 100, + ultimaEntrada, + ultimaSalida, + }; + } + + /** + * Get units currently inside a geofence + */ + async getUnidadesEnGeocerca( + tenantId: string, + geocercaId: string + ): Promise { + // Find the latest event for each unit in this geofence + const subQuery = this.eventoGeocercaRepository + .createQueryBuilder('sub') + .select('sub.unidad_id', 'unidad_id') + .addSelect('MAX(sub.tiempo_evento)', 'max_tiempo') + .where('sub.tenant_id = :tenantId', { tenantId }) + .andWhere('sub.geocerca_id = :geocercaId', { geocercaId }) + .groupBy('sub.unidad_id'); + + const latestEvents = await this.eventoGeocercaRepository + .createQueryBuilder('evento') + .innerJoin( + `(${subQuery.getQuery()})`, + 'latest', + 'evento.unidad_id = latest.unidad_id AND evento.tiempo_evento = latest.max_tiempo' + ) + .setParameters({ tenantId, geocercaId }) + .where('evento.tenant_id = :tenantId', { tenantId }) + .andWhere('evento.geocerca_id = :geocercaId', { geocercaId }) + .getMany(); + + // Filter only those where last event was ENTRADA + const unidadesDentro: UnidadEnGeocerca[] = []; + const now = new Date(); + + for (const evento of latestEvents) { + if (evento.tipoEvento === TipoEventoGeocerca.ENTRADA) { + const tiempoTranscurridoMinutos = + (now.getTime() - evento.tiempoEvento.getTime()) / (1000 * 60); + + unidadesDentro.push({ + unidadId: evento.unidadId, + dispositivoId: evento.dispositivoId, + horaEntrada: evento.tiempoEvento, + tiempoTranscurridoMinutos: Math.round(tiempoTranscurridoMinutos * 100) / 100, + latitud: Number(evento.latitud), + longitud: Number(evento.longitud), + metadata: evento.metadata, + }); + } + } + + return unidadesDentro; + } + + /** + * Trigger an alert for a geofence event + */ + async triggerAlerta(tenantId: string, eventoId: string): Promise { + const evento = await this.eventoGeocercaRepository.findOne({ + where: { id: eventoId, tenantId }, + }); + + if (!evento) return null; + + // Mark the event as having triggered an alert + evento.metadata = { + ...evento.metadata, + alertaTriggered: true, + alertaTriggeredAt: new Date().toISOString(), + }; + + return this.eventoGeocercaRepository.save(evento); + } + + /** + * Get statistics for a geofence + */ + async getEstadisticas( + tenantId: string, + geocercaId: string, + dateRange: DateRange + ): Promise { + const eventos = await this.eventoGeocercaRepository.find({ + where: { + tenantId, + geocercaId, + tiempoEvento: Between(dateRange.fechaInicio, dateRange.fechaFin), + }, + }); + + let totalEntradas = 0; + let totalSalidas = 0; + let totalPermanencias = 0; + let alertasGeneradas = 0; + const unidadesSet = new Set(); + const tiemposVisita: number[] = []; + let entradaActualPorUnidad: Record = {}; + + // Sort events by time for proper entry/exit pairing + const eventosOrdenados = [...eventos].sort( + (a, b) => a.tiempoEvento.getTime() - b.tiempoEvento.getTime() + ); + + for (const evento of eventosOrdenados) { + unidadesSet.add(evento.unidadId); + + if (evento.metadata?.alertaTriggered) { + alertasGeneradas++; + } + + switch (evento.tipoEvento) { + case TipoEventoGeocerca.ENTRADA: + totalEntradas++; + entradaActualPorUnidad[evento.unidadId] = evento.tiempoEvento; + break; + + case TipoEventoGeocerca.SALIDA: + totalSalidas++; + if (entradaActualPorUnidad[evento.unidadId]) { + const duracion = + (evento.tiempoEvento.getTime() - entradaActualPorUnidad[evento.unidadId].getTime()) / + (1000 * 60); + tiemposVisita.push(duracion); + delete entradaActualPorUnidad[evento.unidadId]; + } + break; + + case TipoEventoGeocerca.PERMANENCIA: + totalPermanencias++; + break; + } + } + + const tiempoPromedioMinutos = + tiemposVisita.length > 0 + ? tiemposVisita.reduce((a, b) => a + b, 0) / tiemposVisita.length + : 0; + + return { + totalEventos: eventos.length, + totalEntradas, + totalSalidas, + totalPermanencias, + unidadesUnicas: unidadesSet.size, + tiempoPromedioMinutos: Math.round(tiempoPromedioMinutos * 100) / 100, + alertasGeneradas, + periodoInicio: dateRange.fechaInicio, + periodoFin: dateRange.fechaFin, + }; + } + + /** + * Find events with filters + */ + async findAll( + tenantId: string, + filters: EventoGeocercaFilters = {}, + pagination = { page: 1, limit: 50 } + ) { + const queryBuilder = this.eventoGeocercaRepository + .createQueryBuilder('evento') + .where('evento.tenant_id = :tenantId', { tenantId }); + + if (filters.geocercaId) { + queryBuilder.andWhere('evento.geocerca_id = :geocercaId', { + geocercaId: filters.geocercaId, + }); + } + + if (filters.dispositivoId) { + queryBuilder.andWhere('evento.dispositivo_id = :dispositivoId', { + dispositivoId: filters.dispositivoId, + }); + } + + if (filters.unidadId) { + queryBuilder.andWhere('evento.unidad_id = :unidadId', { + unidadId: filters.unidadId, + }); + } + + if (filters.tipoEvento) { + queryBuilder.andWhere('evento.tipo_evento = :tipoEvento', { + tipoEvento: filters.tipoEvento, + }); + } + + if (filters.viajeId) { + queryBuilder.andWhere('evento.viaje_id = :viajeId', { + viajeId: filters.viajeId, + }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .leftJoinAndSelect('evento.dispositivo', 'dispositivo') + .orderBy('evento.tiempo_evento', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Delete old events (for data retention) + */ + async eliminarEventosAntiguos(tenantId: string, antesDe: Date): Promise { + const result = await this.eventoGeocercaRepository + .createQueryBuilder() + .delete() + .where('tenant_id = :tenantId', { tenantId }) + .andWhere('tiempo_evento < :antesDe', { antesDe }) + .execute(); + + return result.affected || 0; + } +} diff --git a/src/modules/gps/services/index.ts b/src/modules/gps/services/index.ts index a52ba2b..091479b 100644 --- a/src/modules/gps/services/index.ts +++ b/src/modules/gps/services/index.ts @@ -26,3 +26,13 @@ export { SegmentoRutaFilters, RutaCalculada, } from './segmento-ruta.service'; + +export { + EventoGeocercaService, + CreateEventoGeocercaDto, + DateRange as GeocercaDateRange, + EventoGeocercaFilters, + TiempoEnGeocerca, + UnidadEnGeocerca, + GeocercaEstadisticas, +} from './evento-geocerca.service'; diff --git a/src/modules/ordenes-transporte/controllers/ordenes-transporte.controller.ts b/src/modules/ordenes-transporte/controllers/ordenes-transporte.controller.ts index fff8957..197f018 100644 --- a/src/modules/ordenes-transporte/controllers/ordenes-transporte.controller.ts +++ b/src/modules/ordenes-transporte/controllers/ordenes-transporte.controller.ts @@ -1,5 +1,10 @@ import { Request, Response, NextFunction, Router } from 'express'; -import { OrdenesTransporteService, CreateOrdenTransporteDto, UpdateOrdenTransporteDto } from '../services/ordenes-transporte.service'; +import { + OrdenesTransporteService, + CreateOrdenTransporteDto, + UpdateOrdenTransporteDto, + AsignarUnidadDto, +} from '../services/ordenes-transporte.service'; import { EstadoOrdenTransporte, ModalidadServicio } from '../entities'; export class OrdenesTransporteController { @@ -26,6 +31,11 @@ export class OrdenesTransporteController { this.router.post('/:id/confirmar', this.confirmar.bind(this)); this.router.post('/:id/asignar', this.asignar.bind(this)); this.router.post('/:id/cancelar', this.cancelar.bind(this)); + + // MAI-003: Nuevas operaciones de asignacion y tarifa + this.router.post('/:id/asignar-unidad', this.asignarUnidad.bind(this)); + this.router.get('/:id/calcular-tarifa', this.calcularTarifa.bind(this)); + this.router.get('/:id/estadisticas', this.getEstadisticas.bind(this)); } private async findAll(req: Request, res: Response, next: NextFunction): Promise { @@ -329,4 +339,129 @@ export class OrdenesTransporteController { next(error); } } + + /** + * POST /:id/asignar-unidad + * Asignar unidad y operador a una OT, creando el viaje asociado + */ + private async asignarUnidad(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + if (!userId) { + res.status(400).json({ error: 'User ID is required' }); + return; + } + + const { id } = req.params; + const { unidadId, operadorId, remolqueId, fechaSalidaProgramada, fechaLlegadaProgramada } = req.body; + + if (!unidadId) { + res.status(400).json({ error: 'unidadId es requerido' }); + return; + } + + if (!operadorId) { + res.status(400).json({ error: 'operadorId es requerido' }); + return; + } + + const dto: AsignarUnidadDto = { + unidadId, + operadorId, + remolqueId, + fechaSalidaProgramada: fechaSalidaProgramada ? new Date(fechaSalidaProgramada) : undefined, + fechaLlegadaProgramada: fechaLlegadaProgramada ? new Date(fechaLlegadaProgramada) : undefined, + }; + + const result = await this.otService.asignarUnidad(id, dto, { tenantId, userId }); + + if (!result) { + res.status(404).json({ error: 'Orden de transporte no encontrada' }); + return; + } + + res.json({ + data: result.orden, + viajeId: result.viajeId, + message: `Orden asignada exitosamente a viaje ${result.viajeId}`, + }); + } catch (error: any) { + if (error.message.includes('no disponible') || + error.message.includes('documentos vencidos') || + error.message.includes('No se puede asignar')) { + res.status(422).json({ error: error.message }); + return; + } + next(error); + } + } + + /** + * GET /:id/calcular-tarifa + * Calcular tarifa para una OT basada en lane y recargos + */ + private async calcularTarifa(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string || 'system'; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + + const tarifa = await this.otService.calcularTarifa(id, { tenantId, userId }); + + if (!tarifa) { + res.status(404).json({ error: 'Orden de transporte no encontrada' }); + return; + } + + res.json({ data: tarifa }); + } catch (error: any) { + if (error.message.includes('no encontrada')) { + res.status(404).json({ error: error.message }); + return; + } + next(error); + } + } + + /** + * GET /:id/estadisticas + * Obtener estadisticas de ordenes de transporte + */ + private async getEstadisticas(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { fechaDesde, fechaHasta } = req.query; + + // Por defecto, ultimos 30 dias + const to = fechaHasta ? new Date(fechaHasta as string) : new Date(); + const from = fechaDesde + ? new Date(fechaDesde as string) + : new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); + + const stats = await this.otService.getStatistics(tenantId, { from, to }); + + res.json({ data: stats }); + } catch (error) { + next(error); + } + } } diff --git a/src/modules/ordenes-transporte/services/ordenes-transporte.service.ts b/src/modules/ordenes-transporte/services/ordenes-transporte.service.ts index 0bc92f8..841af8e 100644 --- a/src/modules/ordenes-transporte/services/ordenes-transporte.service.ts +++ b/src/modules/ordenes-transporte/services/ordenes-transporte.service.ts @@ -138,6 +138,70 @@ export interface DateRange { to: Date; } +/** + * Contexto de servicio para operaciones con tenant y usuario + */ +export interface ServiceContext { + tenantId: string; + userId: string; + correlationId?: string; +} + +/** + * DTO para asignar unidad y operador a una OT + */ +export interface AsignarUnidadDto { + unidadId: string; + operadorId: string; + remolqueId?: string; + fechaSalidaProgramada?: Date; + fechaLlegadaProgramada?: Date; +} + +/** + * Resultado del calculo de tarifa + */ +export interface TarifaCalculada { + ordenId: string; + tarifaId?: string; + tarifaCodigo?: string; + laneId?: string; + // Desglose de tarifa + tarifaBase: number; + montoVariable: number; + // Recargos + recargoCombustible: number; + recargoManiobras: number; + recargoEspera: number; + recargoOtros: number; + totalRecargos: number; + // Descuentos + descuentos: number; + // Totales + subtotal: number; + iva: number; + ivaRate: number; + total: number; + moneda: string; + // Informacion adicional + distanciaEstimadaKm?: number; + tiempoEstimadoHoras?: number; + minimoAplicado: boolean; +} + +/** + * DTO para validacion de documentos de operador + */ +export interface DocumentosOperadorStatus { + operadorId: string; + documentosVigentes: boolean; + licenciaVigente: boolean; + certificadoFisicoVigente: boolean; + antidopingVigente: boolean; + capacitacionMpVigente?: boolean; + documentosVencidos: string[]; +} + // ============================================================================= // SERVICIO ORDENES DE TRANSPORTE // ============================================================================= @@ -812,6 +876,585 @@ export class OrdenesTransporteService { return this.changeStatus(tenantId, id, EstadoOrdenTransporte.ENTREGADA, deliveredById); } + // =========================================================================== + // ASIGNACION DE UNIDAD Y OPERADOR (MAI-003 REQUERIMIENTO) + // =========================================================================== + + /** + * Asignar unidad y operador a una orden de transporte. + * Crea un viaje asociado y valida: + * - Unidad disponible + * - Operador con documentos vigentes + * - Orden en estado CONFIRMADA + * + * @param id - ID de la orden de transporte + * @param dto - Datos de asignacion (unidadId, operadorId, remolqueId opcional) + * @param ctx - Contexto del servicio (tenantId, userId) + * @returns Objeto con la orden actualizada y el viaje creado + */ + async asignarUnidad( + id: string, + dto: AsignarUnidadDto, + ctx: ServiceContext + ): Promise<{ orden: OrdenTransporte; viajeId: string } | null> { + const { tenantId, userId } = ctx; + + // 1. Obtener la orden + const orden = await this.findById(tenantId, id); + if (!orden) { + throw new Error('Orden de transporte no encontrada'); + } + + // 2. Validar que la orden este en estado valido para asignacion + const estadosAsignables = [ + EstadoOrdenTransporte.CONFIRMADA, + EstadoOrdenTransporte.PENDIENTE, + EstadoOrdenTransporte.SOLICITADA, + ]; + if (!estadosAsignables.includes(orden.estado)) { + throw new Error( + `No se puede asignar unidad a una OT en estado ${orden.estado}. ` + + `Estados permitidos: ${estadosAsignables.join(', ')}` + ); + } + + // 3. Validar que la orden no tenga ya un viaje asignado + if (orden.viajeId) { + throw new Error( + `La orden ya tiene un viaje asignado: ${orden.viajeId}. ` + + `Desasigne primero el viaje actual.` + ); + } + + // 4. Validar unidad disponible + const unidadValidation = await this.validateUnidadDisponible( + tenantId, + dto.unidadId, + dto.fechaSalidaProgramada || orden.fechaRecoleccionProgramada, + dto.fechaLlegadaProgramada || orden.fechaEntregaProgramada + ); + if (!unidadValidation.disponible) { + throw new Error( + `Unidad ${dto.unidadId} no disponible: ${unidadValidation.razon}` + ); + } + + // 5. Validar documentos del operador + const docsStatus = await this.validateDocumentosOperador(tenantId, dto.operadorId); + if (!docsStatus.documentosVigentes) { + throw new Error( + `Operador ${dto.operadorId} tiene documentos vencidos: ${docsStatus.documentosVencidos.join(', ')}. ` + + `Debe renovar los documentos antes de asignar viajes.` + ); + } + + // 6. Generar codigo de viaje + const year = new Date().getFullYear(); + const month = String(new Date().getMonth() + 1).padStart(2, '0'); + const viajeCount = await this.otRepository.manager.query( + `SELECT COUNT(*) as count FROM transport.viajes WHERE tenant_id = $1`, + [tenantId] + ); + const viajeNumero = parseInt(viajeCount[0]?.count || '0', 10) + 1; + const viajeCodigo = `VJ-${year}${month}-${String(viajeNumero).padStart(6, '0')}`; + + // 7. Crear viaje asociado + const viajeInsertResult = await this.otRepository.manager.query( + `INSERT INTO transport.viajes ( + tenant_id, codigo, numero_viaje, unidad_id, remolque_id, operador_id, + cliente_id, origen_principal, origen_ciudad, destino_principal, destino_ciudad, + distancia_estimada_km, tiempo_estimado_horas, + fecha_salida_programada, fecha_llegada_programada, estado, + created_by_id, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'PLANEADO', $16, NOW(), NOW() + ) RETURNING id`, + [ + tenantId, + viajeCodigo, + viajeCodigo, + dto.unidadId, + dto.remolqueId || null, + dto.operadorId, + orden.clienteId, + orden.origenDireccion, + orden.origenCiudad, + orden.destinoDireccion, + orden.destinoCiudad, + this.calculateDistanceFromCoords(orden), + this.calculateTimeFromDistance(this.calculateDistanceFromCoords(orden)), + dto.fechaSalidaProgramada || orden.fechaRecoleccionProgramada, + dto.fechaLlegadaProgramada || orden.fechaEntregaProgramada, + userId, + ] + ); + + const viajeId = viajeInsertResult[0]?.id; + if (!viajeId) { + throw new Error('Error al crear el viaje asociado'); + } + + // 8. Actualizar la orden con el viaje y cambiar estado + orden.viajeId = viajeId; + orden.estado = EstadoOrdenTransporte.ASIGNADA; + orden.updatedById = userId; + + const ordenActualizada = await this.otRepository.save(orden); + + // 9. Registrar evento de tracking (si existe tabla) + try { + await this.otRepository.manager.query( + `INSERT INTO tracking.eventos_tracking ( + tenant_id, viaje_id, orden_id, tipo_evento, descripcion, + latitud, longitud, created_at, created_by_id + ) VALUES ($1, $2, $3, 'ASIGNACION', $4, $5, $6, NOW(), $7)`, + [ + tenantId, + viajeId, + orden.id, + `Orden ${orden.codigo} asignada a viaje ${viajeCodigo}`, + orden.origenLatitud, + orden.origenLongitud, + userId, + ] + ); + } catch { + // Ignorar si la tabla de eventos no existe + } + + return { + orden: ordenActualizada, + viajeId, + }; + } + + /** + * Validar si una unidad esta disponible para un rango de fechas + */ + private async validateUnidadDisponible( + tenantId: string, + unidadId: string, + fechaInicio?: Date, + fechaFin?: Date + ): Promise<{ disponible: boolean; razon?: string }> { + // Verificar que la unidad existe y su estado + const unidadResult = await this.otRepository.manager.query( + `SELECT id, estado, activo FROM fleet.unidades WHERE id = $1 AND tenant_id = $2`, + [unidadId, tenantId] + ); + + if (!unidadResult || unidadResult.length === 0) { + return { disponible: false, razon: 'Unidad no encontrada' }; + } + + const unidad = unidadResult[0]; + if (!unidad.activo) { + return { disponible: false, razon: 'Unidad inactiva' }; + } + + const estadosNoDisponibles = ['EN_VIAJE', 'EN_RUTA', 'EN_TALLER', 'BLOQUEADA', 'BAJA']; + if (estadosNoDisponibles.includes(unidad.estado)) { + return { disponible: false, razon: `Estado actual: ${unidad.estado}` }; + } + + // Verificar conflictos de viajes en el rango de fechas + if (fechaInicio && fechaFin) { + const conflictos = await this.otRepository.manager.query( + `SELECT id, codigo, fecha_salida_programada, fecha_llegada_programada + FROM transport.viajes + WHERE tenant_id = $1 + AND unidad_id = $2 + AND estado NOT IN ('CANCELADO', 'CERRADO', 'FACTURADO', 'COBRADO') + AND ( + (fecha_salida_programada <= $3 AND fecha_llegada_programada >= $3) + OR (fecha_salida_programada <= $4 AND fecha_llegada_programada >= $4) + OR (fecha_salida_programada >= $3 AND fecha_llegada_programada <= $4) + )`, + [tenantId, unidadId, fechaInicio, fechaFin] + ); + + if (conflictos && conflictos.length > 0) { + return { + disponible: false, + razon: `Conflicto con viaje(s): ${conflictos.map((v: any) => v.codigo).join(', ')}`, + }; + } + } + + return { disponible: true }; + } + + /** + * Validar documentos vigentes del operador + */ + private async validateDocumentosOperador( + tenantId: string, + operadorId: string + ): Promise { + const operadorResult = await this.otRepository.manager.query( + `SELECT id, nombre, apellido_paterno, estado, activo, + licencia_vigencia, certificado_fisico_vigencia, + antidoping_vigencia, capacitacion_mp_vigencia, + capacitacion_materiales_peligrosos + FROM fleet.operadores + WHERE id = $1 AND tenant_id = $2`, + [operadorId, tenantId] + ); + + if (!operadorResult || operadorResult.length === 0) { + return { + operadorId, + documentosVigentes: false, + licenciaVigente: false, + certificadoFisicoVigente: false, + antidopingVigente: false, + documentosVencidos: ['Operador no encontrado'], + }; + } + + const operador = operadorResult[0]; + const hoy = new Date(); + const documentosVencidos: string[] = []; + + // Verificar estado activo + if (!operador.activo) { + documentosVencidos.push('Operador inactivo'); + } + + const estadosNoDisponibles = ['SUSPENDIDO', 'BAJA', 'VACACIONES', 'INCAPACIDAD']; + if (estadosNoDisponibles.includes(operador.estado)) { + documentosVencidos.push(`Estado: ${operador.estado}`); + } + + // Verificar licencia + const licenciaVigente = operador.licencia_vigencia && new Date(operador.licencia_vigencia) >= hoy; + if (!licenciaVigente) { + documentosVencidos.push('Licencia de conducir'); + } + + // Verificar certificado fisico + const certificadoFisicoVigente = operador.certificado_fisico_vigencia && + new Date(operador.certificado_fisico_vigencia) >= hoy; + if (!certificadoFisicoVigente) { + documentosVencidos.push('Certificado fisico'); + } + + // Verificar antidoping + const antidopingVigente = operador.antidoping_vigencia && + new Date(operador.antidoping_vigencia) >= hoy; + if (!antidopingVigente) { + documentosVencidos.push('Antidoping'); + } + + // Verificar capacitacion materiales peligrosos (si aplica) + let capacitacionMpVigente: boolean | undefined; + if (operador.capacitacion_materiales_peligrosos) { + capacitacionMpVigente = operador.capacitacion_mp_vigencia && + new Date(operador.capacitacion_mp_vigencia) >= hoy; + if (!capacitacionMpVigente) { + documentosVencidos.push('Capacitacion materiales peligrosos'); + } + } + + return { + operadorId, + documentosVigentes: documentosVencidos.length === 0, + licenciaVigente, + certificadoFisicoVigente, + antidopingVigente, + capacitacionMpVigente, + documentosVencidos, + }; + } + + // =========================================================================== + // CALCULO DE TARIFA (MAI-003 REQUERIMIENTO) + // =========================================================================== + + /** + * Calcular tarifa para una orden de transporte. + * Busca la tarifa aplicable por lane (origen-destino) y cliente. + * Aplica recargos (combustible, maniobras, etc.) y calcula total con IVA. + * + * @param id - ID de la orden de transporte + * @param ctx - Contexto del servicio (tenantId, userId) + * @returns Desglose completo de la tarifa calculada + */ + async calcularTarifa(id: string, ctx: ServiceContext): Promise { + const { tenantId } = ctx; + + // 1. Obtener la orden + const orden = await this.findById(tenantId, id); + if (!orden) { + throw new Error('Orden de transporte no encontrada'); + } + + // 2. Calcular distancia si hay coordenadas + let distanciaEstimadaKm = this.calculateDistanceFromCoords(orden); + let tiempoEstimadoHoras = this.calculateTimeFromDistance(distanciaEstimadaKm); + + // 3. Buscar tarifa aplicable + const tarifa = await this.findTarifaAplicable(tenantId, { + clienteId: orden.clienteId, + origenCiudad: orden.origenCiudad, + origenEstado: orden.origenEstado, + destinoCiudad: orden.destinoCiudad, + destinoEstado: orden.destinoEstado, + modalidadServicio: orden.modalidadServicio, + tipoCarga: orden.tipoCarga, + }); + + // 4. Calcular monto base + let tarifaBase = 0; + let montoVariable = 0; + let tarifaId: string | undefined; + let tarifaCodigo: string | undefined; + let laneId: string | undefined; + let minimoAplicado = false; + let moneda = 'MXN'; + + if (tarifa) { + tarifaId = tarifa.id; + tarifaCodigo = tarifa.codigo; + laneId = tarifa.laneId; + moneda = tarifa.moneda || 'MXN'; + tarifaBase = Number(tarifa.tarifaBase) || 0; + + // Calcular monto variable segun tipo de tarifa + switch (tarifa.tipoTarifa) { + case 'POR_KM': + montoVariable = (distanciaEstimadaKm || 0) * (Number(tarifa.tarifaKm) || 0); + break; + case 'POR_TONELADA': + montoVariable = ((orden.pesoKg || 0) / 1000) * (Number(tarifa.tarifaTonelada) || 0); + break; + case 'POR_M3': + montoVariable = (orden.volumenM3 || 0) * (Number(tarifa.tarifaM3) || 0); + break; + case 'POR_PALLET': + montoVariable = (orden.pallets || 0) * (Number(tarifa.tarifaPallet) || 0); + break; + case 'MIXTA': + montoVariable = (distanciaEstimadaKm || 0) * (Number(tarifa.tarifaKm) || 0); + montoVariable += ((orden.pesoKg || 0) / 1000) * (Number(tarifa.tarifaTonelada) || 0); + break; + default: + montoVariable = 0; + } + + // Aplicar minimo si existe + const montoBase = tarifaBase + montoVariable; + if (tarifa.minimoFacturar && montoBase < Number(tarifa.minimoFacturar)) { + tarifaBase = Number(tarifa.minimoFacturar); + montoVariable = 0; + minimoAplicado = true; + } + } else { + // Sin tarifa especifica, usar estimacion por defecto + // Tarifa base de $15,000 MXN + $25/km + tarifaBase = 15000; + montoVariable = (distanciaEstimadaKm || 0) * 25; + } + + // 5. Calcular recargos + const recargoCombustible = this.calcularRecargoCombustible( + distanciaEstimadaKm, + orden.requiereTemperatura + ); + const recargoManiobras = this.calcularRecargoManiobras(orden); + const recargoEspera = 0; // Se calcula al momento de la entrega + const recargoOtros = Number(orden.recargos) || 0; + const totalRecargos = recargoCombustible + recargoManiobras + recargoEspera + recargoOtros; + + // 6. Aplicar descuentos + const descuentos = Number(orden.descuentos) || 0; + + // 7. Calcular totales + const subtotal = tarifaBase + montoVariable + totalRecargos - descuentos; + const ivaRate = 0.16; // IVA Mexico 16% + const iva = subtotal * ivaRate; + const total = subtotal + iva; + + return { + ordenId: orden.id, + tarifaId, + tarifaCodigo, + laneId, + tarifaBase: Math.round(tarifaBase * 100) / 100, + montoVariable: Math.round(montoVariable * 100) / 100, + recargoCombustible: Math.round(recargoCombustible * 100) / 100, + recargoManiobras: Math.round(recargoManiobras * 100) / 100, + recargoEspera: Math.round(recargoEspera * 100) / 100, + recargoOtros: Math.round(recargoOtros * 100) / 100, + totalRecargos: Math.round(totalRecargos * 100) / 100, + descuentos: Math.round(descuentos * 100) / 100, + subtotal: Math.round(subtotal * 100) / 100, + iva: Math.round(iva * 100) / 100, + ivaRate, + total: Math.round(total * 100) / 100, + moneda, + distanciaEstimadaKm: distanciaEstimadaKm ? Math.round(distanciaEstimadaKm) : undefined, + tiempoEstimadoHoras: tiempoEstimadoHoras ? Math.round(tiempoEstimadoHoras * 10) / 10 : undefined, + minimoAplicado, + }; + } + + /** + * Buscar la tarifa aplicable para una orden + */ + private async findTarifaAplicable( + tenantId: string, + params: { + clienteId?: string; + origenCiudad?: string; + origenEstado?: string; + destinoCiudad?: string; + destinoEstado?: string; + modalidadServicio?: string; + tipoCarga?: string; + } + ): Promise { + const hoy = new Date().toISOString().split('T')[0]; + + // 1. Buscar lane que coincida con origen-destino + let laneId: string | null = null; + if (params.origenCiudad && params.destinoCiudad) { + const laneResult = await this.otRepository.manager.query( + `SELECT id FROM billing.lanes + WHERE tenant_id = $1 + AND activa = true + AND ( + (origen_ciudad ILIKE $2 AND destino_ciudad ILIKE $3) + OR (origen_estado = $4 AND destino_estado = $5) + ) + ORDER BY + CASE WHEN origen_ciudad ILIKE $2 AND destino_ciudad ILIKE $3 THEN 0 ELSE 1 END + LIMIT 1`, + [tenantId, params.origenCiudad, params.destinoCiudad, params.origenEstado, params.destinoEstado] + ); + if (laneResult && laneResult.length > 0) { + laneId = laneResult[0].id; + } + } + + // 2. Buscar tarifa por prioridad: cliente+lane > cliente > lane > general + const tarifaQuery = ` + SELECT t.*, l.id as lane_id, l.origen_ciudad, l.destino_ciudad + FROM billing.tarifas t + LEFT JOIN billing.lanes l ON t.lane_id = l.id + WHERE t.tenant_id = $1 + AND t.activa = true + AND t.fecha_inicio <= $2 + AND (t.fecha_fin IS NULL OR t.fecha_fin >= $2) + AND ( + -- Prioridad 1: cliente + lane especificos + (t.cliente_id = $3 AND t.lane_id = $4) + -- Prioridad 2: cliente especifico, cualquier lane + OR (t.cliente_id = $3 AND t.lane_id IS NULL) + -- Prioridad 3: lane especifica, cualquier cliente + OR (t.cliente_id IS NULL AND t.lane_id = $4) + -- Prioridad 4: tarifa general + OR (t.cliente_id IS NULL AND t.lane_id IS NULL) + ) + ORDER BY + CASE + WHEN t.cliente_id = $3 AND t.lane_id = $4 THEN 1 + WHEN t.cliente_id = $3 AND t.lane_id IS NULL THEN 2 + WHEN t.cliente_id IS NULL AND t.lane_id = $4 THEN 3 + ELSE 4 + END, + t.created_at DESC + LIMIT 1`; + + const tarifaResult = await this.otRepository.manager.query(tarifaQuery, [ + tenantId, + hoy, + params.clienteId || null, + laneId, + ]); + + return tarifaResult && tarifaResult.length > 0 ? tarifaResult[0] : null; + } + + /** + * Calcular recargo por combustible + */ + private calcularRecargoCombustible(distanciaKm?: number, requiereRefrigeracion?: boolean): number { + if (!distanciaKm || distanciaKm <= 0) return 0; + + // Factor de ajuste por precio de diesel (fuel surcharge) + // Precio base referencia: $22 MXN/litro + // Consumo promedio: 2.5 km/litro + const precioDieselActual = 24; // MXN/litro (puede venir de configuracion) + const precioDieselBase = 22; + const consumoPromedio = 2.5; // km/litro + + const litrosEstimados = distanciaKm / consumoPromedio; + const costoBaseCombus = litrosEstimados * precioDieselBase; + const costoActualCombus = litrosEstimados * precioDieselActual; + let recargo = costoActualCombus - costoBaseCombus; + + // Recargo adicional por refrigeracion (30% mas consumo) + if (requiereRefrigeracion) { + recargo *= 1.3; + } + + return Math.max(0, recargo); + } + + /** + * Calcular recargo por maniobras de carga/descarga + */ + private calcularRecargoManiobras(orden: OrdenTransporte): number { + let recargo = 0; + + // Maniobras por piezas o pallets + if (orden.piezas && orden.piezas > 100) { + recargo += (orden.piezas - 100) * 5; // $5 por pieza extra despues de 100 + } + + if (orden.pallets && orden.pallets > 10) { + recargo += (orden.pallets - 10) * 150; // $150 por pallet extra despues de 10 + } + + // Carga peligrosa tiene recargo adicional + if (orden.tipoCarga === TipoCarga.PELIGROSA) { + recargo += 3000; // Recargo fijo por manejo especial + } + + // Carga sobredimensionada + if (orden.tipoCarga === TipoCarga.SOBREDIMENSIONADA) { + recargo += 5000; // Recargo por permisos especiales + } + + return recargo; + } + + /** + * Calcular distancia desde coordenadas de la orden + */ + private calculateDistanceFromCoords(orden: OrdenTransporte): number | undefined { + if (orden.origenLatitud && orden.origenLongitud && + orden.destinoLatitud && orden.destinoLongitud) { + return this.calculateDistance( + orden.origenLatitud, + orden.origenLongitud, + orden.destinoLatitud, + orden.destinoLongitud + ); + } + return undefined; + } + + /** + * Calcular tiempo estimado desde distancia + */ + private calculateTimeFromDistance(distanciaKm?: number): number | undefined { + if (!distanciaKm) return undefined; + // Velocidad promedio de 60 km/h considerando paradas + return distanciaKm / 60; + } + /** * Cambiar estado con validacion de transiciones */ diff --git a/src/modules/tracking/services/evento-tracking.service.ts b/src/modules/tracking/services/evento-tracking.service.ts new file mode 100644 index 0000000..b89139a --- /dev/null +++ b/src/modules/tracking/services/evento-tracking.service.ts @@ -0,0 +1,489 @@ +/** + * EventoTracking Service + * ERP Transportistas + * + * Business logic for tracking events, alerts, and trip timelines. + * Module: MAI-006 Tracking + */ + +import { Repository, DataSource, Between, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { EventoTracking, TipoEventoTracking, FuenteEvento } from '../entities/evento-tracking.entity'; +import { Geocerca } from '../entities/geocerca.entity'; + +// ==================== DTOs ==================== + +export interface CreateEventoTrackingDto { + viajeId?: string; + unidadId?: string; + operadorId?: string; + tipoEvento: TipoEventoTracking; + fuente?: FuenteEvento; + latitud?: number; + longitud?: number; + direccion?: string; + timestamp?: Date; + timestampEvento?: Date; + velocidad?: number; + rumbo?: number; + altitud?: number; + precision?: number; + odometro?: number; + nivelCombustible?: number; + motorEncendido?: boolean; + descripcion?: string; + datosAdicionales?: Record; + datos?: Record; + paradaId?: string; + generadoPorId?: string; + generadoPorTipo?: string; + evidencias?: Record; + observaciones?: string; +} + +export interface DateRange { + fechaInicio: Date; + fechaFin: Date; +} + +export interface CreateAlertaDto { + unidadId?: string; + operadorId?: string; + tipoAlerta: string; + severidad: 'baja' | 'media' | 'alta' | 'critica'; + latitud?: number; + longitud?: number; + descripcion: string; + datos?: Record; +} + +export interface PosicionGpsInput { + latitud: number; + longitud: number; + velocidad?: number; + rumbo?: number; + altitud?: number; + precision?: number; + odometro?: number; + nivelCombustible?: number; + motorEncendido?: boolean; + timestamp?: Date; +} + +export interface EventoEstadisticas { + totalEventos: number; + eventosPorTipo: Record; + eventosPorFuente: Record; + alertasTotales: number; + alertasPorSeveridad: Record; + alertasSinAcknowledge: number; + promedioEventosPorDia: number; +} + +// ==================== Service ==================== + +export class EventoTrackingService { + private eventoRepository: Repository; + private geocercaRepository: Repository; + + constructor(dataSource: DataSource) { + this.eventoRepository = dataSource.getRepository(EventoTracking); + this.geocercaRepository = dataSource.getRepository(Geocerca); + } + + /** + * Create a new tracking event + */ + async create(tenantId: string, viajeId: string, data: CreateEventoTrackingDto): Promise { + const now = new Date(); + + const evento = this.eventoRepository.create({ + tenantId, + viajeId, + unidadId: data.unidadId, + operadorId: data.operadorId, + tipoEvento: data.tipoEvento, + fuente: data.fuente || FuenteEvento.SISTEMA, + latitud: data.latitud, + longitud: data.longitud, + direccion: data.direccion, + timestamp: data.timestamp || now, + timestampEvento: data.timestampEvento || data.timestamp || now, + velocidad: data.velocidad, + rumbo: data.rumbo, + altitud: data.altitud, + precision: data.precision, + odometro: data.odometro, + nivelCombustible: data.nivelCombustible, + motorEncendido: data.motorEncendido, + descripcion: data.descripcion, + datosAdicionales: data.datosAdicionales, + datos: data.datos, + paradaId: data.paradaId, + generadoPorId: data.generadoPorId, + generadoPorTipo: data.generadoPorTipo, + evidencias: data.evidencias, + observaciones: data.observaciones, + }); + + return this.eventoRepository.save(evento); + } + + /** + * Find all events for a trip + */ + async findByViaje(tenantId: string, viajeId: string): Promise { + return this.eventoRepository.find({ + where: { tenantId, viajeId }, + order: { timestampEvento: 'ASC' }, + }); + } + + /** + * Find events by vehicle/unit in date range + */ + async findByUnidad( + tenantId: string, + unidadId: string, + dateRange: DateRange + ): Promise { + return this.eventoRepository.find({ + where: { + tenantId, + unidadId, + timestampEvento: Between(dateRange.fechaInicio, dateRange.fechaFin), + }, + order: { timestampEvento: 'DESC' }, + }); + } + + /** + * Find events by type in date range + */ + async findByTipo( + tenantId: string, + tipo: TipoEventoTracking, + dateRange: DateRange + ): Promise { + return this.eventoRepository.find({ + where: { + tenantId, + tipoEvento: tipo, + timestampEvento: Between(dateRange.fechaInicio, dateRange.fechaFin), + }, + order: { timestampEvento: 'DESC' }, + }); + } + + /** + * Get the last event for a trip + */ + async getUltimoEvento(tenantId: string, viajeId: string): Promise { + return this.eventoRepository.findOne({ + where: { tenantId, viajeId }, + order: { timestampEvento: 'DESC' }, + }); + } + + /** + * Get recent events across the fleet + */ + async getEventosRecientes(tenantId: string, limit: number = 50): Promise { + return this.eventoRepository.find({ + where: { tenantId }, + order: { timestampEvento: 'DESC' }, + take: limit, + }); + } + + /** + * Get full timeline for a trip (ordered events) + */ + async getTimeline(tenantId: string, viajeId: string): Promise { + return this.eventoRepository.find({ + where: { tenantId, viajeId }, + order: { timestampEvento: 'ASC' }, + }); + } + + /** + * Auto-create event from GPS position data + */ + async createFromGPS( + tenantId: string, + viajeId: string, + posicionGps: PosicionGpsInput & { unidadId: string; operadorId?: string } + ): Promise { + const now = new Date(); + + const evento = this.eventoRepository.create({ + tenantId, + viajeId, + unidadId: posicionGps.unidadId, + operadorId: posicionGps.operadorId, + tipoEvento: TipoEventoTracking.GPS_POSICION, + fuente: FuenteEvento.GPS, + latitud: posicionGps.latitud, + longitud: posicionGps.longitud, + timestamp: posicionGps.timestamp || now, + timestampEvento: posicionGps.timestamp || now, + velocidad: posicionGps.velocidad, + rumbo: posicionGps.rumbo, + altitud: posicionGps.altitud, + precision: posicionGps.precision, + odometro: posicionGps.odometro, + nivelCombustible: posicionGps.nivelCombustible, + motorEncendido: posicionGps.motorEncendido, + }); + + const savedEvento = await this.eventoRepository.save(evento); + + // Check geofences asynchronously + this.checkGeocercas(tenantId, savedEvento).catch(err => { + console.error('Error checking geofences:', err); + }); + + return savedEvento; + } + + /** + * Create an alert event + */ + async createAlerta( + tenantId: string, + viajeId: string, + alertaData: CreateAlertaDto + ): Promise { + const now = new Date(); + + // Map alert type to event type + let tipoEvento = TipoEventoTracking.INCIDENTE; + if (alertaData.tipoAlerta === 'desvio') { + tipoEvento = TipoEventoTracking.DESVIO; + } else if (alertaData.tipoAlerta === 'parada') { + tipoEvento = TipoEventoTracking.PARADA; + } + + const evento = this.eventoRepository.create({ + tenantId, + viajeId, + unidadId: alertaData.unidadId, + operadorId: alertaData.operadorId, + tipoEvento, + fuente: FuenteEvento.SISTEMA, + latitud: alertaData.latitud, + longitud: alertaData.longitud, + timestamp: now, + timestampEvento: now, + descripcion: alertaData.descripcion, + datos: { + esAlerta: true, + tipoAlerta: alertaData.tipoAlerta, + severidad: alertaData.severidad, + acknowledged: false, + acknowledgedAt: null, + acknowledgedBy: null, + ...alertaData.datos, + }, + }); + + return this.eventoRepository.save(evento); + } + + /** + * Get alerts in date range, optionally filtered by severity + */ + async getAlertas( + tenantId: string, + dateRange: DateRange, + severidad?: 'baja' | 'media' | 'alta' | 'critica' + ): Promise { + const queryBuilder = this.eventoRepository + .createQueryBuilder('evento') + .where('evento.tenant_id = :tenantId', { tenantId }) + .andWhere("evento.datos->>'esAlerta' = :esAlerta", { esAlerta: 'true' }) + .andWhere('evento.timestamp_evento BETWEEN :fechaInicio AND :fechaFin', { + fechaInicio: dateRange.fechaInicio, + fechaFin: dateRange.fechaFin, + }); + + if (severidad) { + queryBuilder.andWhere("evento.datos->>'severidad' = :severidad", { severidad }); + } + + return queryBuilder + .orderBy('evento.timestamp_evento', 'DESC') + .getMany(); + } + + /** + * Acknowledge an alert + */ + async acknowledgeAlerta( + tenantId: string, + alertaId: string, + userId: string + ): Promise { + const evento = await this.eventoRepository.findOne({ + where: { id: alertaId, tenantId }, + }); + + if (!evento || !evento.datos?.esAlerta) { + return null; + } + + const now = new Date(); + evento.datos = { + ...evento.datos, + acknowledged: true, + acknowledgedAt: now.toISOString(), + acknowledgedBy: userId, + }; + + return this.eventoRepository.save(evento); + } + + /** + * Get event statistics for date range + */ + async getEstadisticas(tenantId: string, dateRange: DateRange): Promise { + // Get events in range + const eventos = await this.eventoRepository.find({ + where: { + tenantId, + timestampEvento: Between(dateRange.fechaInicio, dateRange.fechaFin), + }, + }); + + // Calculate statistics + const eventosPorTipo: Record = {}; + const eventosPorFuente: Record = {}; + const alertasPorSeveridad: Record = {}; + let alertasTotales = 0; + let alertasSinAcknowledge = 0; + + for (const evento of eventos) { + // Count by type + eventosPorTipo[evento.tipoEvento] = (eventosPorTipo[evento.tipoEvento] || 0) + 1; + + // Count by source + if (evento.fuente) { + eventosPorFuente[evento.fuente] = (eventosPorFuente[evento.fuente] || 0) + 1; + } + + // Count alerts + if (evento.datos?.esAlerta) { + alertasTotales++; + const severidad = evento.datos.severidad || 'media'; + alertasPorSeveridad[severidad] = (alertasPorSeveridad[severidad] || 0) + 1; + + if (!evento.datos.acknowledged) { + alertasSinAcknowledge++; + } + } + } + + // Calculate average events per day + const diasEnRango = Math.max( + 1, + Math.ceil( + (dateRange.fechaFin.getTime() - dateRange.fechaInicio.getTime()) / (1000 * 60 * 60 * 24) + ) + ); + const promedioEventosPorDia = eventos.length / diasEnRango; + + return { + totalEventos: eventos.length, + eventosPorTipo, + eventosPorFuente, + alertasTotales, + alertasPorSeveridad, + alertasSinAcknowledge, + promedioEventosPorDia: Math.round(promedioEventosPorDia * 100) / 100, + }; + } + + /** + * Check if a position triggers any geofence events + */ + private async checkGeocercas(tenantId: string, evento: EventoTracking): Promise { + if (!evento.latitud || !evento.longitud) return; + + // Get active geofences + const geocercas = await this.geocercaRepository.find({ + where: { tenantId, activa: true }, + }); + + for (const geocerca of geocercas) { + const dentroGeocerca = this.puntoEnGeocerca( + Number(evento.latitud), + Number(evento.longitud), + geocerca + ); + + if (dentroGeocerca && geocerca.alertaEntrada) { + // Create geofence entry event + await this.create(tenantId, evento.viajeId, { + unidadId: evento.unidadId, + operadorId: evento.operadorId, + tipoEvento: TipoEventoTracking.GEOCERCA_ENTRADA, + fuente: FuenteEvento.GEOCERCA, + latitud: evento.latitud, + longitud: evento.longitud, + timestamp: evento.timestamp, + descripcion: `Entrada a geocerca: ${geocerca.nombre}`, + datosAdicionales: { + geocercaId: geocerca.id, + geocercaNombre: geocerca.nombre, + geocercaTipo: geocerca.tipo, + }, + }); + } + } + } + + /** + * Check if a point is inside a geofence + */ + private puntoEnGeocerca(lat: number, lon: number, geocerca: Geocerca): boolean { + if (geocerca.esCircular && geocerca.centroLatitud && geocerca.centroLongitud && geocerca.radioMetros) { + const distancia = this.calcularDistancia( + lat, + lon, + Number(geocerca.centroLatitud), + Number(geocerca.centroLongitud) + ); + return distancia <= Number(geocerca.radioMetros); + } + + // For circular geofences using geometria + if (geocerca.geometria?.type === 'Point' && geocerca.radio) { + const centro = geocerca.geometria.coordinates; + if (centro && centro.length >= 2) { + const distancia = this.calcularDistancia(lat, lon, centro[1], centro[0]); + return distancia <= Number(geocerca.radio); + } + } + + // For polygon geofences, would need proper geometric check (PostGIS recommended) + return false; + } + + /** + * Calculate distance between two points using Haversine formula + */ + private calcularDistancia(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371000; // Earth radius in meters + const dLat = this.toRad(lat2 - lat1); + const dLon = this.toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + private toRad(deg: number): number { + return deg * (Math.PI / 180); + } +} diff --git a/src/modules/tracking/services/index.ts b/src/modules/tracking/services/index.ts index a7f0d23..60c6273 100644 --- a/src/modules/tracking/services/index.ts +++ b/src/modules/tracking/services/index.ts @@ -1,4 +1,16 @@ /** * Tracking Services + * ERP Transportistas + * Module: MAI-006 Tracking */ + export * from './tracking.service'; + +export { + EventoTrackingService, + CreateEventoTrackingDto, + DateRange, + CreateAlertaDto, + PosicionGpsInput, + EventoEstadisticas, +} from './evento-tracking.service';