From 0501a2e52a2105dc804f7b78a1a06c3f77d02c1e Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Thu, 29 Jan 2026 17:57:14 -0600 Subject: [PATCH] [WORKSPACE] feat: Add dispatch, gps, offline and whatsapp modules --- src/app.ts | 15 + src/config/typeorm.ts | 37 ++ .../controllers/certificacion.controller.ts | 224 +++++++ .../controllers/dispatch.controller.ts | 377 +++++++++++ .../controllers/gps-integration.controller.ts | 157 +++++ src/modules/dispatch/controllers/index.ts | 13 + .../dispatch/controllers/rule.controller.ts | 260 ++++++++ .../dispatch/controllers/turno.controller.ts | 305 +++++++++ src/modules/dispatch/dispatch.routes.ts | 45 ++ .../entities/dispatch-board.entity.ts | 72 +++ .../dispatch/entities/estado-unidad.entity.ts | 120 ++++ src/modules/dispatch/entities/index.ts | 18 + .../dispatch/entities/log-despacho.entity.ts | 91 +++ .../entities/operador-certificacion.entity.ts | 97 +++ .../entities/regla-despacho.entity.ts | 114 ++++ .../entities/regla-escalamiento.entity.ts | 93 +++ .../entities/turno-operador.entity.ts | 95 +++ src/modules/dispatch/index.ts | 69 ++ .../services/certificacion.service.ts | 360 +++++++++++ .../dispatch/services/dispatch.service.ts | 595 ++++++++++++++++++ .../gps-dispatch-integration.service.ts | 293 +++++++++ src/modules/dispatch/services/index.ts | 49 ++ src/modules/dispatch/services/rule.service.ts | 398 ++++++++++++ .../dispatch/services/turno.service.ts | 378 +++++++++++ .../controllers/dispositivo-gps.controller.ts | 220 +++++++ src/modules/gps/controllers/index.ts | 9 + .../controllers/posicion-gps.controller.ts | 221 +++++++ .../controllers/segmento-ruta.controller.ts | 206 ++++++ .../gps/entities/dispositivo-gps.entity.ts | 129 ++++ .../gps/entities/evento-geocerca.entity.ts | 90 +++ src/modules/gps/entities/index.ts | 11 + .../gps/entities/posicion-gps.entity.ts | 89 +++ .../gps/entities/segmento-ruta.entity.ts | 133 ++++ src/modules/gps/gps.routes.ts | 37 ++ src/modules/gps/index.ts | 45 ++ .../gps/services/dispositivo-gps.service.ts | 328 ++++++++++ src/modules/gps/services/index.ts | 28 + .../gps/services/posicion-gps.service.ts | 389 ++++++++++++ .../gps/services/segmento-ruta.service.ts | 429 +++++++++++++ src/modules/offline/controllers/index.ts | 7 + .../offline/controllers/sync.controller.ts | 259 ++++++++ src/modules/offline/entities/index.ts | 12 + .../offline/entities/offline-queue.entity.ts | 172 +++++ src/modules/offline/index.ts | 27 + src/modules/offline/offline.routes.ts | 22 + src/modules/offline/services/index.ts | 13 + src/modules/offline/services/sync.service.ts | 447 +++++++++++++ src/modules/tracking/entities/index.ts | 9 +- src/modules/whatsapp/controllers/index.ts | 7 + .../controllers/whatsapp.controller.ts | 423 +++++++++++++ src/modules/whatsapp/index.ts | 56 ++ src/modules/whatsapp/services/index.ts | 13 + .../services/whatsapp-notification.service.ts | 422 +++++++++++++ src/modules/whatsapp/templates/index.ts | 16 + .../whatsapp/templates/transport-templates.ts | 403 ++++++++++++ src/modules/whatsapp/whatsapp.routes.ts | 39 ++ 56 files changed, 8983 insertions(+), 3 deletions(-) create mode 100644 src/modules/dispatch/controllers/certificacion.controller.ts create mode 100644 src/modules/dispatch/controllers/dispatch.controller.ts create mode 100644 src/modules/dispatch/controllers/gps-integration.controller.ts create mode 100644 src/modules/dispatch/controllers/index.ts create mode 100644 src/modules/dispatch/controllers/rule.controller.ts create mode 100644 src/modules/dispatch/controllers/turno.controller.ts create mode 100644 src/modules/dispatch/dispatch.routes.ts create mode 100644 src/modules/dispatch/entities/dispatch-board.entity.ts create mode 100644 src/modules/dispatch/entities/estado-unidad.entity.ts create mode 100644 src/modules/dispatch/entities/index.ts create mode 100644 src/modules/dispatch/entities/log-despacho.entity.ts create mode 100644 src/modules/dispatch/entities/operador-certificacion.entity.ts create mode 100644 src/modules/dispatch/entities/regla-despacho.entity.ts create mode 100644 src/modules/dispatch/entities/regla-escalamiento.entity.ts create mode 100644 src/modules/dispatch/entities/turno-operador.entity.ts create mode 100644 src/modules/dispatch/index.ts create mode 100644 src/modules/dispatch/services/certificacion.service.ts create mode 100644 src/modules/dispatch/services/dispatch.service.ts create mode 100644 src/modules/dispatch/services/gps-dispatch-integration.service.ts create mode 100644 src/modules/dispatch/services/index.ts create mode 100644 src/modules/dispatch/services/rule.service.ts create mode 100644 src/modules/dispatch/services/turno.service.ts create mode 100644 src/modules/gps/controllers/dispositivo-gps.controller.ts create mode 100644 src/modules/gps/controllers/index.ts create mode 100644 src/modules/gps/controllers/posicion-gps.controller.ts create mode 100644 src/modules/gps/controllers/segmento-ruta.controller.ts create mode 100644 src/modules/gps/entities/dispositivo-gps.entity.ts create mode 100644 src/modules/gps/entities/evento-geocerca.entity.ts create mode 100644 src/modules/gps/entities/index.ts create mode 100644 src/modules/gps/entities/posicion-gps.entity.ts create mode 100644 src/modules/gps/entities/segmento-ruta.entity.ts create mode 100644 src/modules/gps/gps.routes.ts create mode 100644 src/modules/gps/index.ts create mode 100644 src/modules/gps/services/dispositivo-gps.service.ts create mode 100644 src/modules/gps/services/index.ts create mode 100644 src/modules/gps/services/posicion-gps.service.ts create mode 100644 src/modules/gps/services/segmento-ruta.service.ts create mode 100644 src/modules/offline/controllers/index.ts create mode 100644 src/modules/offline/controllers/sync.controller.ts create mode 100644 src/modules/offline/entities/index.ts create mode 100644 src/modules/offline/entities/offline-queue.entity.ts create mode 100644 src/modules/offline/index.ts create mode 100644 src/modules/offline/offline.routes.ts create mode 100644 src/modules/offline/services/index.ts create mode 100644 src/modules/offline/services/sync.service.ts create mode 100644 src/modules/whatsapp/controllers/index.ts create mode 100644 src/modules/whatsapp/controllers/whatsapp.controller.ts create mode 100644 src/modules/whatsapp/index.ts create mode 100644 src/modules/whatsapp/services/index.ts create mode 100644 src/modules/whatsapp/services/whatsapp-notification.service.ts create mode 100644 src/modules/whatsapp/templates/index.ts create mode 100644 src/modules/whatsapp/templates/transport-templates.ts create mode 100644 src/modules/whatsapp/whatsapp.routes.ts diff --git a/src/app.ts b/src/app.ts index 5d85a12..86a3dd7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -19,6 +19,11 @@ import financialRoutes from './modules/financial/financial.routes.js'; import salesRoutes from './modules/ordenes-transporte/sales.routes.js'; import productsRoutes from './modules/gestion-flota/products.routes.js'; import projectsRoutes from './modules/viajes/projects.routes.js'; +import gpsRoutes from './modules/gps/gps.routes.js'; +import dispatchRoutes from './modules/dispatch/dispatch.routes.js'; +import offlineRoutes from './modules/offline/offline.routes.js'; +import { createWhatsAppRoutes } from './modules/whatsapp/index.js'; +import { AppDataSource } from './config/typeorm.js'; const app: Application = express(); @@ -65,6 +70,16 @@ app.use(`${apiPrefix}/sales`, salesRoutes); app.use(`${apiPrefix}/products`, productsRoutes); app.use(`${apiPrefix}/projects`, projectsRoutes); +// API routes - GPS & Dispatch (Sprint S1+S2 - TASK-007) +app.use(`${apiPrefix}/gps`, gpsRoutes); +app.use(`${apiPrefix}/despacho`, dispatchRoutes); + +// API routes - Offline Sync (Sprint S4 - TASK-007) +app.use(`${apiPrefix}/offline`, offlineRoutes); + +// API routes - WhatsApp Notifications (Sprint S5 - TASK-007) +app.use(`${apiPrefix}/whatsapp`, createWhatsAppRoutes(AppDataSource)); + // 404 handler app.use((_req: Request, res: Response) => { const response: ApiResponse = { diff --git a/src/config/typeorm.ts b/src/config/typeorm.ts index 9903c2f..3af217d 100644 --- a/src/config/typeorm.ts +++ b/src/config/typeorm.ts @@ -86,6 +86,28 @@ import { WithholdingType, } from '../modules/fiscal/entities/index.js'; +// Import GPS Module Entities (Sprint S1 - TASK-007) +import { + DispositivoGps, + PosicionGps, + EventoGeocerca, + SegmentoRuta, +} from '../modules/gps/entities/index.js'; + +// Import Dispatch Module Entities (Sprint S2 - TASK-007) +import { + TableroDespacho, + EstadoUnidad, + OperadorCertificacion, + TurnoOperador, + ReglaDespacho, + ReglaEscalamiento, + LogDespacho, +} from '../modules/dispatch/entities/index.js'; + +// Import Offline Module Entities (Sprint S4 - TASK-007) +import { OfflineQueue } from '../modules/offline/entities/index.js'; + /** * TypeORM DataSource configuration * @@ -171,6 +193,21 @@ export const AppDataSource = new DataSource({ PaymentMethod, PaymentType, WithholdingType, + // GPS Module Entities (Sprint S1 - TASK-007) + DispositivoGps, + PosicionGps, + EventoGeocerca, + SegmentoRuta, + // Dispatch Module Entities (Sprint S2 - TASK-007) + TableroDespacho, + EstadoUnidad, + OperadorCertificacion, + TurnoOperador, + ReglaDespacho, + ReglaEscalamiento, + LogDespacho, + // Offline Module Entities (Sprint S4 - TASK-007) + OfflineQueue, ], // Directorios de migraciones (para uso futuro) diff --git a/src/modules/dispatch/controllers/certificacion.controller.ts b/src/modules/dispatch/controllers/certificacion.controller.ts new file mode 100644 index 0000000..e4ed327 --- /dev/null +++ b/src/modules/dispatch/controllers/certificacion.controller.ts @@ -0,0 +1,224 @@ +/** + * Certificacion Controller + * ERP Transportistas + * + * REST API endpoints for operator certifications. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 SkillController + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { CertificacionService, FiltrosCertificacion } from '../services/certificacion.service'; +import { NivelCertificacion } from '../entities/operador-certificacion.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createCertificacionController(dataSource: DataSource): Router { + const router = Router(); + const service = new CertificacionService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID es requerido' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Add certification to operator + * POST /api/despacho/certificaciones + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const certificacion = await service.agregarCertificacion(req.tenantId!, req.body); + res.status(201).json(certificacion); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List certifications with filters + * GET /api/despacho/certificaciones + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filtros: FiltrosCertificacion = { + operadorId: req.query.operadorId as string, + codigoCertificacion: req.query.codigoCertificacion as string, + nivel: req.query.nivel as NivelCertificacion, + activa: req.query.activa === 'true' ? true : req.query.activa === 'false' ? false : undefined, + porVencerDias: req.query.porVencerDias + ? parseInt(req.query.porVencerDias as string, 10) + : undefined, + }; + + const paginacion = { + pagina: parseInt(req.query.pagina as string, 10) || 1, + limite: Math.min(parseInt(req.query.limite as string, 10) || 20, 100), + }; + + const resultado = await service.listar(req.tenantId!, filtros, paginacion); + res.json(resultado); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get certification matrix + * GET /api/despacho/certificaciones/matriz + */ + router.get('/matriz', async (req: TenantRequest, res: Response) => { + try { + const matriz = await service.getMatrizCertificaciones(req.tenantId!); + res.json(matriz); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get expiring certifications + * GET /api/despacho/certificaciones/por-vencer + */ + router.get('/por-vencer', async (req: TenantRequest, res: Response) => { + try { + const dias = parseInt(req.query.dias as string, 10) || 30; + const certificaciones = await service.getCertificacionesPorVencer(req.tenantId!, dias); + res.json(certificaciones); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get operators with specific certification + * GET /api/despacho/certificaciones/por-codigo/:codigoCertificacion + */ + router.get('/por-codigo/:codigoCertificacion', async (req: TenantRequest, res: Response) => { + try { + const nivelMinimo = req.query.nivelMinimo as NivelCertificacion; + const certificaciones = await service.getOperadoresConCertificacion( + req.tenantId!, + req.params.codigoCertificacion, + nivelMinimo + ); + res.json(certificaciones); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get certifications for operator + * GET /api/despacho/certificaciones/operador/:operadorId + */ + router.get('/operador/:operadorId', async (req: TenantRequest, res: Response) => { + try { + const soloActivas = req.query.soloActivas !== 'false'; + const certificaciones = await service.getCertificacionesOperador( + req.tenantId!, + req.params.operadorId, + soloActivas + ); + res.json(certificaciones); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Validate operator certifications for service + * POST /api/despacho/certificaciones/validar + */ + router.post('/validar', async (req: TenantRequest, res: Response) => { + try { + const { operadorId, certificacionesRequeridas, nivelMinimo } = req.body; + const resultado = await service.validarCertificacionesOperador( + req.tenantId!, + operadorId, + certificacionesRequeridas, + nivelMinimo + ); + res.json(resultado); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get certification by ID + * GET /api/despacho/certificaciones/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const certificacion = await service.getById(req.tenantId!, req.params.id); + if (!certificacion) { + return res.status(404).json({ error: 'Certificacion no encontrada' }); + } + res.json(certificacion); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update certification + * PATCH /api/despacho/certificaciones/:id + */ + router.patch('/:id', async (req: TenantRequest, res: Response) => { + try { + const certificacion = await service.actualizarCertificacion(req.tenantId!, req.params.id, req.body); + if (!certificacion) { + return res.status(404).json({ error: 'Certificacion no encontrada' }); + } + res.json(certificacion); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Verify certification (admin) + * POST /api/despacho/certificaciones/:id/verificar + */ + router.post('/:id/verificar', async (req: TenantRequest, res: Response) => { + try { + const certificacion = await service.verificarCertificacion(req.tenantId!, req.params.id, req.userId!); + if (!certificacion) { + return res.status(404).json({ error: 'Certificacion no encontrada' }); + } + res.json(certificacion); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Deactivate certification + * DELETE /api/despacho/certificaciones/:id + */ + router.delete('/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.desactivarCertificacion(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Certificacion no encontrada' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/dispatch/controllers/dispatch.controller.ts b/src/modules/dispatch/controllers/dispatch.controller.ts new file mode 100644 index 0000000..e0d0249 --- /dev/null +++ b/src/modules/dispatch/controllers/dispatch.controller.ts @@ -0,0 +1,377 @@ +/** + * Dispatch Controller + * ERP Transportistas + * + * REST API endpoints for dispatch operations. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 Dispatch Center + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { DispatchService } from '../services/dispatch.service'; +import { EstadoUnidadEnum, CapacidadUnidad } from '../entities/estado-unidad.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createDispatchController(dataSource: DataSource): Router { + const router = Router(); + const service = new DispatchService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID es requerido' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + // ========================================== + // Tablero Despacho + // ========================================== + + /** + * Create dispatch board + * POST /api/despacho/tablero + */ + router.post('/tablero', async (req: TenantRequest, res: Response) => { + try { + const tablero = await service.crearTablero(req.tenantId!, { + ...req.body, + createdBy: req.userId, + }); + res.status(201).json(tablero); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get active dispatch board + * GET /api/despacho/tablero + */ + router.get('/tablero', async (req: TenantRequest, res: Response) => { + try { + const tablero = await service.getTableroActivo(req.tenantId!); + if (!tablero) { + return res.status(404).json({ error: 'No se encontro tablero de despacho activo' }); + } + res.json(tablero); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // ========================================== + // Estado Unidades + // ========================================== + + /** + * Create unit status + * POST /api/despacho/unidades + */ + router.post('/unidades', async (req: TenantRequest, res: Response) => { + try { + const estadoUnidad = await service.crearEstadoUnidad(req.tenantId!, req.body); + res.status(201).json(estadoUnidad); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get all units + * GET /api/despacho/unidades + */ + router.get('/unidades', async (req: TenantRequest, res: Response) => { + try { + const incluirOffline = req.query.incluirOffline !== 'false'; + const unidades = await service.getTodasUnidades(req.tenantId!, incluirOffline); + res.json(unidades); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get available units + * GET /api/despacho/unidades/disponibles + */ + router.get('/unidades/disponibles', async (req: TenantRequest, res: Response) => { + try { + const filtros = { + capacidadUnidad: req.query.capacidad as CapacidadUnidad, + puedeRemolcar: req.query.puedeRemolcar === 'true', + tieneUbicacion: req.query.tieneUbicacion === 'true', + esRefrigerada: req.query.esRefrigerada === 'true' ? true : undefined, + }; + const unidades = await service.getUnidadesDisponibles(req.tenantId!, filtros); + res.json(unidades); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get dispatch stats + * GET /api/despacho/estadisticas + */ + router.get('/estadisticas', async (req: TenantRequest, res: Response) => { + try { + const stats = await service.getEstadisticasDespacho(req.tenantId!); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get unit status by unit ID + * GET /api/despacho/unidades/:unidadId + */ + router.get('/unidades/:unidadId', async (req: TenantRequest, res: Response) => { + try { + const estadoUnidad = await service.getEstadoUnidad(req.tenantId!, req.params.unidadId); + if (!estadoUnidad) { + return res.status(404).json({ error: 'Estado de unidad no encontrado' }); + } + res.json(estadoUnidad); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update unit status + * PATCH /api/despacho/unidades/:unidadId + */ + router.patch('/unidades/:unidadId', async (req: TenantRequest, res: Response) => { + try { + const estadoUnidad = await service.actualizarEstadoUnidad( + req.tenantId!, + req.params.unidadId, + req.body + ); + if (!estadoUnidad) { + return res.status(404).json({ error: 'Estado de unidad no encontrado' }); + } + res.json(estadoUnidad); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Update unit position (from GPS) + * PATCH /api/despacho/unidades/:unidadId/posicion + */ + router.patch('/unidades/:unidadId/posicion', async (req: TenantRequest, res: Response) => { + try { + const { latitud, longitud, posicionId } = req.body; + const estadoUnidad = await service.actualizarEstadoUnidad(req.tenantId!, req.params.unidadId, { + ultimaPosicionLat: latitud, + ultimaPosicionLng: longitud, + ultimaPosicionId: posicionId, + }); + if (!estadoUnidad) { + return res.status(404).json({ error: 'Estado de unidad no encontrado' }); + } + res.json(estadoUnidad); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ========================================== + // Assignment Operations + // ========================================== + + /** + * Assign viaje to unit + * POST /api/despacho/asignar + */ + router.post('/asignar', async (req: TenantRequest, res: Response) => { + try { + const { viajeId, unidadId, operadorIds, notas } = req.body; + const estadoUnidad = await service.asignarViaje( + req.tenantId!, + viajeId, + { unidadId, operadorIds, notas }, + req.userId! + ); + res.json(estadoUnidad); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Reassign viaje to different unit + * POST /api/despacho/reasignar + */ + router.post('/reasignar', async (req: TenantRequest, res: Response) => { + try { + const { viajeId, unidadId, operadorIds, razon } = req.body; + const estadoUnidad = await service.reasignarViaje( + req.tenantId!, + viajeId, + { unidadId, operadorIds }, + razon, + req.userId! + ); + res.json(estadoUnidad); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Suggest best assignment for viaje + * POST /api/despacho/sugerir + */ + router.post('/sugerir', async (req: TenantRequest, res: Response) => { + try { + const { + viajeId, + origenLat, + origenLng, + tipoCarga, + pesoKg, + distanciaRutaKm, + requiereFrio, + requiereLicenciaFederal, + requiereCertificadoMP, + } = req.body; + const sugerencias = await service.sugerirMejorAsignacion(req.tenantId!, { + viajeId, + origenLat, + origenLng, + tipoCarga, + pesoKg, + distanciaRutaKm, + requiereFrio, + requiereLicenciaFederal, + requiereCertificadoMP, + }); + res.json(sugerencias); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Mark unit as en route + * POST /api/despacho/unidades/:unidadId/en-ruta + */ + router.post('/unidades/:unidadId/en-ruta', async (req: TenantRequest, res: Response) => { + try { + const estadoUnidad = await service.marcarEnRuta( + req.tenantId!, + req.params.unidadId, + req.userId! + ); + if (!estadoUnidad) { + return res.status(404).json({ error: 'Unidad no encontrada o sin viaje activo' }); + } + res.json(estadoUnidad); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Mark unit as on site + * POST /api/despacho/unidades/:unidadId/en-sitio + */ + router.post('/unidades/:unidadId/en-sitio', async (req: TenantRequest, res: Response) => { + try { + const estadoUnidad = await service.marcarEnSitio( + req.tenantId!, + req.params.unidadId, + req.userId! + ); + if (!estadoUnidad) { + return res.status(404).json({ error: 'Unidad no encontrada o sin viaje activo' }); + } + res.json(estadoUnidad); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Complete viaje and mark unit as returning + * POST /api/despacho/unidades/:unidadId/completar + */ + router.post('/unidades/:unidadId/completar', async (req: TenantRequest, res: Response) => { + try { + const estadoUnidad = await service.completarViaje( + req.tenantId!, + req.params.unidadId, + req.userId! + ); + if (!estadoUnidad) { + return res.status(404).json({ error: 'Unidad no encontrada o sin viaje activo' }); + } + res.json(estadoUnidad); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Release unit (make available) + * POST /api/despacho/unidades/:unidadId/liberar + */ + router.post('/unidades/:unidadId/liberar', async (req: TenantRequest, res: Response) => { + try { + const estadoUnidad = await service.liberarUnidad(req.tenantId!, req.params.unidadId); + if (!estadoUnidad) { + return res.status(404).json({ error: 'Unidad no encontrada' }); + } + res.json(estadoUnidad); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ========================================== + // Dispatch Logs + // ========================================== + + /** + * Get dispatch logs for viaje + * GET /api/despacho/logs/viaje/:viajeId + */ + router.get('/logs/viaje/:viajeId', async (req: TenantRequest, res: Response) => { + try { + const logs = await service.getLogsViaje(req.tenantId!, req.params.viajeId); + res.json(logs); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get recent dispatch logs + * GET /api/despacho/logs + */ + router.get('/logs', async (req: TenantRequest, res: Response) => { + try { + const limite = parseInt(req.query.limite as string, 10) || 50; + const logs = await service.getLogsRecientes(req.tenantId!, Math.min(limite, 200)); + res.json(logs); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/dispatch/controllers/gps-integration.controller.ts b/src/modules/dispatch/controllers/gps-integration.controller.ts new file mode 100644 index 0000000..286dc9c --- /dev/null +++ b/src/modules/dispatch/controllers/gps-integration.controller.ts @@ -0,0 +1,157 @@ +/** + * GPS Integration Controller + * ERP Transportistas + * + * REST API endpoints for GPS-Dispatch integration. + * Sprint: S3 - TASK-007 + * Module: MAI-005/MAI-006 Integration + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { GpsDispatchIntegrationService } from '../services/gps-dispatch-integration.service'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createGpsIntegrationController(dataSource: DataSource): Router { + const router = Router(); + const service = new GpsDispatchIntegrationService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID es requerido' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Sync GPS positions to unit statuses + * POST /api/despacho/gps/sincronizar + */ + router.post('/sincronizar', async (req: TenantRequest, res: Response) => { + try { + const actualizados = await service.sincronizarPosicionesGps(req.tenantId!); + res.json({ + success: true, + unidadesActualizadas: actualizados, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get all units with GPS positions + * GET /api/despacho/gps/unidades + */ + router.get('/unidades', async (req: TenantRequest, res: Response) => { + try { + const unidades = await service.obtenerUnidadesConPosicionGps(req.tenantId!); + res.json({ + data: unidades, + total: unidades.length, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get assignment suggestions with real-time GPS data + * POST /api/despacho/gps/sugerir + */ + router.post('/sugerir', async (req: TenantRequest, res: Response) => { + try { + const { + viajeId, + origenLat, + origenLng, + tipoCarga, + pesoKg, + distanciaRutaKm, + requiereFrio, + requiereLicenciaFederal, + requiereCertificadoMP, + } = req.body; + + if (!viajeId || !origenLat || !origenLng) { + return res.status(400).json({ + error: 'viajeId, origenLat y origenLng son requeridos', + }); + } + + const sugerencias = await service.sugerirAsignacionConGps(req.tenantId!, { + viajeId, + origenLat: parseFloat(origenLat), + origenLng: parseFloat(origenLng), + tipoCarga, + pesoKg: pesoKg ? parseFloat(pesoKg) : undefined, + distanciaRutaKm: distanciaRutaKm ? parseFloat(distanciaRutaKm) : undefined, + requiereFrio: requiereFrio === true || requiereFrio === 'true', + requiereLicenciaFederal: requiereLicenciaFederal === true || requiereLicenciaFederal === 'true', + requiereCertificadoMP: requiereCertificadoMP === true || requiereCertificadoMP === 'true', + }); + + res.json({ + data: sugerencias, + total: sugerencias.length, + viajeId, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Update unit status from GPS position + * POST /api/despacho/gps/actualizar-posicion + */ + router.post('/actualizar-posicion', async (req: TenantRequest, res: Response) => { + try { + const { unidadId, latitud, longitud, timestamp } = req.body; + + if (!unidadId || latitud === undefined || longitud === undefined) { + return res.status(400).json({ + error: 'unidadId, latitud y longitud son requeridos', + }); + } + + const estadoUnidad = await service.actualizarEstadoUnidadPorGps( + req.tenantId!, + unidadId, + parseFloat(latitud), + parseFloat(longitud), + timestamp ? new Date(timestamp) : new Date() + ); + + if (!estadoUnidad) { + return res.status(404).json({ error: 'Unidad no encontrada' }); + } + + res.json({ + success: true, + unidadId: estadoUnidad.unidadId, + posicion: { + lat: estadoUnidad.ultimaPosicionLat, + lng: estadoUnidad.ultimaPosicionLng, + timestamp: estadoUnidad.ultimaActualizacionUbicacion, + }, + }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/dispatch/controllers/index.ts b/src/modules/dispatch/controllers/index.ts new file mode 100644 index 0000000..82273be --- /dev/null +++ b/src/modules/dispatch/controllers/index.ts @@ -0,0 +1,13 @@ +/** + * Dispatch Module Controllers Index + * ERP Transportistas + * + * Module: MAI-005 Despacho + */ + +export { createDispatchController } from './dispatch.controller'; +export { createCertificacionController } from './certificacion.controller'; +export { createTurnoController } from './turno.controller'; +export { createRuleController } from './rule.controller'; +// GPS Integration (Sprint S3 - TASK-007) +export { createGpsIntegrationController } from './gps-integration.controller'; diff --git a/src/modules/dispatch/controllers/rule.controller.ts b/src/modules/dispatch/controllers/rule.controller.ts new file mode 100644 index 0000000..09881c4 --- /dev/null +++ b/src/modules/dispatch/controllers/rule.controller.ts @@ -0,0 +1,260 @@ +/** + * Rule Controller + * ERP Transportistas + * + * REST API endpoints for dispatch and escalation rules. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 RuleController + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { RuleService } from '../services/rule.service'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createRuleController(dataSource: DataSource): Router { + const router = Router(); + const service = new RuleService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID es requerido' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + // ========================================== + // Reglas de Despacho + // ========================================== + + /** + * Create dispatch rule + * POST /api/despacho/reglas/despacho + */ + router.post('/despacho', async (req: TenantRequest, res: Response) => { + try { + const regla = await service.crearReglaDespacho(req.tenantId!, { + ...req.body, + createdBy: req.userId, + }); + res.status(201).json(regla); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List dispatch rules + * GET /api/despacho/reglas/despacho + */ + router.get('/despacho', async (req: TenantRequest, res: Response) => { + try { + const soloActivas = req.query.soloActivas === 'true'; + const paginacion = { + pagina: parseInt(req.query.pagina as string, 10) || 1, + limite: Math.min(parseInt(req.query.limite as string, 10) || 20, 100), + }; + + const resultado = await service.listarReglasDespacho(req.tenantId!, soloActivas, paginacion); + res.json(resultado); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get matching dispatch rules for viaje + * POST /api/despacho/reglas/despacho/coincidentes + */ + router.post('/despacho/coincidentes', async (req: TenantRequest, res: Response) => { + try { + const { tipoViaje, categoriaViaje } = req.body; + const reglas = await service.getReglasDespachoCoincidentes( + req.tenantId!, + tipoViaje, + categoriaViaje + ); + res.json(reglas); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Evaluate rules for assignment + * POST /api/despacho/reglas/despacho/evaluar + */ + router.post('/despacho/evaluar', async (req: TenantRequest, res: Response) => { + try { + const coincidentes = await service.evaluarReglas(req.tenantId!, req.body); + res.json(coincidentes); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get dispatch rule by ID + * GET /api/despacho/reglas/despacho/:id + */ + router.get('/despacho/:id', async (req: TenantRequest, res: Response) => { + try { + const regla = await service.getReglaDespachoById(req.tenantId!, req.params.id); + if (!regla) { + return res.status(404).json({ error: 'Regla de despacho no encontrada' }); + } + res.json(regla); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update dispatch rule + * PATCH /api/despacho/reglas/despacho/:id + */ + router.patch('/despacho/:id', async (req: TenantRequest, res: Response) => { + try { + const regla = await service.actualizarReglaDespacho(req.tenantId!, req.params.id, req.body); + if (!regla) { + return res.status(404).json({ error: 'Regla de despacho no encontrada' }); + } + res.json(regla); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Delete dispatch rule + * DELETE /api/despacho/reglas/despacho/:id + */ + router.delete('/despacho/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.eliminarReglaDespacho(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Regla de despacho no encontrada' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // ========================================== + // Reglas de Escalamiento + // ========================================== + + /** + * Create escalation rule + * POST /api/despacho/reglas/escalamiento + */ + router.post('/escalamiento', async (req: TenantRequest, res: Response) => { + try { + const regla = await service.crearReglaEscalamiento(req.tenantId!, { + ...req.body, + createdBy: req.userId, + }); + res.status(201).json(regla); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List escalation rules + * GET /api/despacho/reglas/escalamiento + */ + router.get('/escalamiento', async (req: TenantRequest, res: Response) => { + try { + const soloActivas = req.query.soloActivas === 'true'; + const paginacion = { + pagina: parseInt(req.query.pagina as string, 10) || 1, + limite: Math.min(parseInt(req.query.limite as string, 10) || 20, 100), + }; + + const resultado = await service.listarReglasEscalamiento(req.tenantId!, soloActivas, paginacion); + res.json(resultado); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get triggered escalation rules + * POST /api/despacho/reglas/escalamiento/disparadas + */ + router.post('/escalamiento/disparadas', async (req: TenantRequest, res: Response) => { + try { + const { minutosTranscurridos, estadoViaje, prioridadViaje } = req.body; + const reglas = await service.getReglasEscalamientoDisparadas( + req.tenantId!, + minutosTranscurridos, + estadoViaje, + prioridadViaje + ); + res.json(reglas); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get escalation rule by ID + * GET /api/despacho/reglas/escalamiento/:id + */ + router.get('/escalamiento/:id', async (req: TenantRequest, res: Response) => { + try { + const regla = await service.getReglaEscalamientoById(req.tenantId!, req.params.id); + if (!regla) { + return res.status(404).json({ error: 'Regla de escalamiento no encontrada' }); + } + res.json(regla); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update escalation rule + * PATCH /api/despacho/reglas/escalamiento/:id + */ + router.patch('/escalamiento/:id', async (req: TenantRequest, res: Response) => { + try { + const regla = await service.actualizarReglaEscalamiento(req.tenantId!, req.params.id, req.body); + if (!regla) { + return res.status(404).json({ error: 'Regla de escalamiento no encontrada' }); + } + res.json(regla); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Delete escalation rule + * DELETE /api/despacho/reglas/escalamiento/:id + */ + router.delete('/escalamiento/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.eliminarReglaEscalamiento(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Regla de escalamiento no encontrada' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/dispatch/controllers/turno.controller.ts b/src/modules/dispatch/controllers/turno.controller.ts new file mode 100644 index 0000000..d8c4737 --- /dev/null +++ b/src/modules/dispatch/controllers/turno.controller.ts @@ -0,0 +1,305 @@ +/** + * Turno Controller + * ERP Transportistas + * + * REST API endpoints for operator shifts. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 ShiftController + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { TurnoService, FiltrosTurno } from '../services/turno.service'; +import { TipoTurno } from '../entities/turno-operador.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createTurnoController(dataSource: DataSource): Router { + const router = Router(); + const service = new TurnoService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID es requerido' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Create shift + * POST /api/despacho/turnos + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const turno = await service.crearTurno(req.tenantId!, { + ...req.body, + fechaTurno: new Date(req.body.fechaTurno), + createdBy: req.userId, + }); + res.status(201).json(turno); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List shifts with filters + * GET /api/despacho/turnos + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filtros: FiltrosTurno = { + operadorId: req.query.operadorId as string, + tipoTurno: req.query.tipoTurno as TipoTurno, + fechaDesde: req.query.fechaDesde ? new Date(req.query.fechaDesde as string) : undefined, + fechaHasta: req.query.fechaHasta ? new Date(req.query.fechaHasta as string) : undefined, + enGuardia: req.query.enGuardia === 'true' ? true : req.query.enGuardia === 'false' ? false : undefined, + unidadAsignadaId: req.query.unidadAsignadaId as string, + ausente: req.query.ausente === 'true' ? true : req.query.ausente === 'false' ? false : undefined, + }; + + const paginacion = { + pagina: parseInt(req.query.pagina as string, 10) || 1, + limite: Math.min(parseInt(req.query.limite as string, 10) || 20, 100), + }; + + const resultado = await service.listar(req.tenantId!, filtros, paginacion); + res.json(resultado); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get shifts for date + * GET /api/despacho/turnos/fecha/:fecha + */ + router.get('/fecha/:fecha', async (req: TenantRequest, res: Response) => { + try { + const fecha = new Date(req.params.fecha); + const excluirAusentes = req.query.excluirAusentes !== 'false'; + const turnos = await service.getTurnosPorFecha(req.tenantId!, fecha, excluirAusentes); + res.json(turnos); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get available operators for date/time + * GET /api/despacho/turnos/disponibles + */ + router.get('/disponibles', async (req: TenantRequest, res: Response) => { + try { + const fecha = new Date(req.query.fecha as string); + const hora = req.query.hora as string; + const disponibilidad = await service.getOperadoresDisponibles(req.tenantId!, fecha, hora); + res.json(disponibilidad); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get on-call operators for date + * GET /api/despacho/turnos/en-guardia + */ + router.get('/en-guardia', async (req: TenantRequest, res: Response) => { + try { + const fecha = new Date(req.query.fecha as string); + const turnos = await service.getOperadoresEnGuardia(req.tenantId!, fecha); + res.json(turnos); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get shifts for operator + * GET /api/despacho/turnos/operador/:operadorId + */ + router.get('/operador/:operadorId', async (req: TenantRequest, res: Response) => { + try { + const fechaDesde = req.query.fechaDesde ? new Date(req.query.fechaDesde as string) : undefined; + const fechaHasta = req.query.fechaHasta ? new Date(req.query.fechaHasta as string) : undefined; + const turnos = await service.getTurnosOperador( + req.tenantId!, + req.params.operadorId, + fechaDesde, + fechaHasta + ); + res.json(turnos); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get shifts by unit + * GET /api/despacho/turnos/unidad/:unidadId + */ + router.get('/unidad/:unidadId', async (req: TenantRequest, res: Response) => { + try { + const fecha = new Date(req.query.fecha as string); + const turnos = await service.getTurnosPorUnidad(req.tenantId!, req.params.unidadId, fecha); + res.json(turnos); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Generate weekly shifts + * POST /api/despacho/turnos/generar-semanales + */ + router.post('/generar-semanales', async (req: TenantRequest, res: Response) => { + try { + const { + operadorId, + fechaInicioSemana, + tipoTurno, + horaInicio, + horaFin, + diasSemana, + } = req.body; + + const turnos = await service.generarTurnosSemanales( + req.tenantId!, + operadorId, + new Date(fechaInicioSemana), + tipoTurno, + horaInicio, + horaFin, + diasSemana, + req.userId + ); + res.status(201).json(turnos); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get shift by ID + * GET /api/despacho/turnos/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const turno = await service.getById(req.tenantId!, req.params.id); + if (!turno) { + return res.status(404).json({ error: 'Turno no encontrado' }); + } + res.json(turno); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update shift + * PATCH /api/despacho/turnos/:id + */ + router.patch('/:id', async (req: TenantRequest, res: Response) => { + try { + const turno = await service.actualizarTurno(req.tenantId!, req.params.id, req.body); + if (!turno) { + return res.status(404).json({ error: 'Turno no encontrado' }); + } + res.json(turno); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Start shift + * POST /api/despacho/turnos/:id/iniciar + */ + router.post('/:id/iniciar', async (req: TenantRequest, res: Response) => { + try { + const turno = await service.iniciarTurno(req.tenantId!, req.params.id); + if (!turno) { + return res.status(404).json({ error: 'Turno no encontrado' }); + } + res.json(turno); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * End shift + * POST /api/despacho/turnos/:id/finalizar + */ + router.post('/:id/finalizar', async (req: TenantRequest, res: Response) => { + try { + const turno = await service.finalizarTurno(req.tenantId!, req.params.id); + if (!turno) { + return res.status(404).json({ error: 'Turno no encontrado' }); + } + res.json(turno); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Mark absent + * POST /api/despacho/turnos/:id/ausente + */ + router.post('/:id/ausente', async (req: TenantRequest, res: Response) => { + try { + const { motivo } = req.body; + const turno = await service.marcarAusente(req.tenantId!, req.params.id, motivo); + if (!turno) { + return res.status(404).json({ error: 'Turno no encontrado' }); + } + res.json(turno); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Assign unit to shift + * POST /api/despacho/turnos/:id/asignar-unidad + */ + router.post('/:id/asignar-unidad', async (req: TenantRequest, res: Response) => { + try { + const { unidadId } = req.body; + const turno = await service.asignarUnidadATurno(req.tenantId!, req.params.id, unidadId); + if (!turno) { + return res.status(404).json({ error: 'Turno no encontrado' }); + } + res.json(turno); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Delete shift + * DELETE /api/despacho/turnos/:id + */ + router.delete('/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.eliminarTurno(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Turno no encontrado' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/dispatch/dispatch.routes.ts b/src/modules/dispatch/dispatch.routes.ts new file mode 100644 index 0000000..3a3b256 --- /dev/null +++ b/src/modules/dispatch/dispatch.routes.ts @@ -0,0 +1,45 @@ +/** + * Dispatch Module Routes + * ERP Transportistas + * + * Routes for dispatch center, certifications, shifts, and rules. + * Module: MAI-005 Despacho + * Sprint: S2 - TASK-007 + */ + +import { Router } from 'express'; +import { AppDataSource } from '../../config/typeorm.js'; +import { + createDispatchController, + createCertificacionController, + createTurnoController, + createRuleController, + createGpsIntegrationController, +} from './controllers/index.js'; + +const router = Router(); + +// Create controller routers with DataSource +const dispatchRouter = createDispatchController(AppDataSource); +const certificacionRouter = createCertificacionController(AppDataSource); +const turnoRouter = createTurnoController(AppDataSource); +const ruleRouter = createRuleController(AppDataSource); +const gpsIntegrationRouter = createGpsIntegrationController(AppDataSource); + +// Mount routes +// /api/despacho - Core dispatch operations (boards, units, assignments) +router.use('/', dispatchRouter); + +// /api/despacho/certificaciones - Operator certifications +router.use('/certificaciones', certificacionRouter); + +// /api/despacho/turnos - Operator shifts +router.use('/turnos', turnoRouter); + +// /api/despacho/reglas - Dispatch and escalation rules +router.use('/reglas', ruleRouter); + +// /api/despacho/gps - GPS-Dispatch integration (Sprint S3) +router.use('/gps', gpsIntegrationRouter); + +export default router; diff --git a/src/modules/dispatch/entities/dispatch-board.entity.ts b/src/modules/dispatch/entities/dispatch-board.entity.ts new file mode 100644 index 0000000..c1039ee --- /dev/null +++ b/src/modules/dispatch/entities/dispatch-board.entity.ts @@ -0,0 +1,72 @@ +/** + * TableroDespacho Entity + * ERP Transportistas + * + * Dispatch board configuration for map and assignment views. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 Dispatch Center + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'tableros_despacho', schema: 'despacho' }) +@Index('idx_tableros_despacho_tenant', ['tenantId']) +export class TableroDespacho { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 100 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion?: string; + + // Map defaults + @Column({ name: 'default_zoom', type: 'integer', default: 12 }) + defaultZoom: number; + + @Column({ name: 'centro_lat', type: 'decimal', precision: 10, scale: 7, default: 19.4326 }) + centroLat: number; + + @Column({ name: 'centro_lng', type: 'decimal', precision: 10, scale: 7, default: -99.1332 }) + centroLng: number; + + // Behavior + @Column({ name: 'intervalo_refresco_segundos', type: 'integer', default: 30 }) + intervaloRefrescoSegundos: number; + + @Column({ name: 'mostrar_unidades_offline', type: 'boolean', default: true }) + mostrarUnidadesOffline: boolean; + + @Column({ name: 'auto_asignar_habilitado', type: 'boolean', default: false }) + autoAsignarHabilitado: boolean; + + @Column({ name: 'max_sugerencias', type: 'integer', default: 5 }) + maxSugerencias: number; + + // Filters + @Column({ name: 'filtros_default', type: 'jsonb', default: {} }) + filtrosDefault: Record; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/dispatch/entities/estado-unidad.entity.ts b/src/modules/dispatch/entities/estado-unidad.entity.ts new file mode 100644 index 0000000..18d9dca --- /dev/null +++ b/src/modules/dispatch/entities/estado-unidad.entity.ts @@ -0,0 +1,120 @@ +/** + * EstadoUnidad Entity + * ERP Transportistas + * + * Real-time status of fleet units for dispatch. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 Dispatch Center + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum EstadoUnidadEnum { + AVAILABLE = 'available', + ASSIGNED = 'assigned', + EN_ROUTE = 'en_route', + ON_SITE = 'on_site', + RETURNING = 'returning', + OFFLINE = 'offline', + MAINTENANCE = 'maintenance', +} + +export enum CapacidadUnidad { + LIGHT = 'light', + MEDIUM = 'medium', + HEAVY = 'heavy', +} + +@Entity({ name: 'estado_unidades', schema: 'despacho' }) +@Index('idx_estado_unidades_tenant_unit', ['tenantId', 'unidadId'], { unique: true }) +@Index('idx_estado_unidades_status', ['tenantId', 'estado']) +export class EstadoUnidad { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Unit reference (FK a fleet.unidades) + @Column({ name: 'unidad_id', type: 'uuid' }) + unidadId: string; + + @Column({ name: 'codigo_unidad', type: 'varchar', length: 50, nullable: true }) + codigoUnidad?: string; + + @Column({ name: 'nombre_unidad', type: 'varchar', length: 100, nullable: true }) + nombreUnidad?: string; + + // Status + @Column({ + type: 'varchar', + length: 20, + default: EstadoUnidadEnum.OFFLINE, + }) + estado: EstadoUnidadEnum; + + // Current assignment - viaje instead of incident + @Column({ name: 'viaje_actual_id', type: 'uuid', nullable: true }) + viajeActualId?: string; + + // Operadores instead of technicians + @Column({ name: 'operador_ids', type: 'uuid', array: true, default: [] }) + operadorIds: string[]; + + // Location (cached from GPS) + @Column({ name: 'ultima_posicion_id', type: 'uuid', nullable: true }) + ultimaPosicionId?: string; + + @Column({ name: 'ultima_posicion_lat', type: 'decimal', precision: 10, scale: 7, nullable: true }) + ultimaPosicionLat?: number; + + @Column({ name: 'ultima_posicion_lng', type: 'decimal', precision: 10, scale: 7, nullable: true }) + ultimaPosicionLng?: number; + + @Column({ name: 'ultima_actualizacion_ubicacion', type: 'timestamptz', nullable: true }) + ultimaActualizacionUbicacion?: Date; + + // Timing + @Column({ name: 'ultimo_cambio_estado', type: 'timestamptz', default: () => 'NOW()' }) + ultimoCambioEstado: Date; + + @Column({ name: 'disponible_estimado_en', type: 'timestamptz', nullable: true }) + disponibleEstimadoEn?: Date; + + // Capacity and capabilities + @Column({ + name: 'capacidad_unidad', + type: 'varchar', + length: 20, + default: CapacidadUnidad.LIGHT, + }) + capacidadUnidad: CapacidadUnidad; + + @Column({ name: 'puede_remolcar', type: 'boolean', default: false }) + puedeRemolcar: boolean; + + @Column({ name: 'peso_max_remolque_kg', type: 'integer', nullable: true }) + pesoMaxRemolqueKg?: number; + + // Transport-specific fields + @Column({ name: 'es_refrigerada', type: 'boolean', default: false }) + esRefrigerada: boolean; + + @Column({ name: 'capacidad_peso_kg', type: 'decimal', precision: 10, scale: 2, nullable: true }) + capacidadPesoKg?: number; + + @Column({ type: 'text', nullable: true }) + notas?: string; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/dispatch/entities/index.ts b/src/modules/dispatch/entities/index.ts new file mode 100644 index 0000000..3f2b734 --- /dev/null +++ b/src/modules/dispatch/entities/index.ts @@ -0,0 +1,18 @@ +/** + * Dispatch Module Entities Index + * ERP Transportistas + * + * Module: MAI-005 Despacho + */ + +export { TableroDespacho } from './dispatch-board.entity'; +export { EstadoUnidad, EstadoUnidadEnum, CapacidadUnidad } from './estado-unidad.entity'; +export { OperadorCertificacion, NivelCertificacion } from './operador-certificacion.entity'; +export { TurnoOperador, TipoTurno } from './turno-operador.entity'; +export { + ReglaDespacho, + CondicionesDespacho, + CondicionesDespachoTransporte, +} from './regla-despacho.entity'; +export { ReglaEscalamiento, CanalNotificacion } from './regla-escalamiento.entity'; +export { LogDespacho, AccionDespacho } from './log-despacho.entity'; diff --git a/src/modules/dispatch/entities/log-despacho.entity.ts b/src/modules/dispatch/entities/log-despacho.entity.ts new file mode 100644 index 0000000..ea849ba --- /dev/null +++ b/src/modules/dispatch/entities/log-despacho.entity.ts @@ -0,0 +1,91 @@ +/** + * LogDespacho Entity + * ERP Transportistas + * + * Audit log of all dispatch actions. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 DispatchLog + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export enum AccionDespacho { + CREATED = 'created', + ASSIGNED = 'assigned', + REASSIGNED = 'reassigned', + REJECTED = 'rejected', + ESCALATED = 'escalated', + CANCELLED = 'cancelled', + ACKNOWLEDGED = 'acknowledged', + COMPLETED = 'completed', +} + +@Entity({ name: 'log_despacho', schema: 'despacho' }) +@Index('idx_log_despacho_tenant', ['tenantId']) +@Index('idx_log_despacho_viaje', ['tenantId', 'viajeId']) +@Index('idx_log_despacho_fecha', ['tenantId', 'ejecutadoEn']) +@Index('idx_log_despacho_accion', ['tenantId', 'accion']) +export class LogDespacho { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Viaje reference (FK a transport.viajes) - instead of incident + @Column({ name: 'viaje_id', type: 'uuid' }) + viajeId: string; + + // Action + @Column({ + type: 'varchar', + length: 20, + }) + accion: AccionDespacho; + + // Unit/Operador changes - instead of technician + @Column({ name: 'desde_unidad_id', type: 'uuid', nullable: true }) + desdeUnidadId?: string; + + @Column({ name: 'hacia_unidad_id', type: 'uuid', nullable: true }) + haciaUnidadId?: string; + + @Column({ name: 'desde_operador_id', type: 'uuid', nullable: true }) + desdeOperadorId?: string; + + @Column({ name: 'hacia_operador_id', type: 'uuid', nullable: true }) + haciaOperadorId?: string; + + // Context + @Column({ type: 'text', nullable: true }) + razon?: string; + + @Column({ type: 'boolean', default: false }) + automatizado: boolean; + + @Column({ name: 'regla_id', type: 'uuid', nullable: true }) + reglaId?: string; + + @Column({ name: 'escalamiento_id', type: 'uuid', nullable: true }) + escalamientoId?: string; + + // Response times + @Column({ name: 'tiempo_respuesta_segundos', type: 'integer', nullable: true }) + tiempoRespuestaSegundos?: number; + + // Actor + @Column({ name: 'ejecutado_por', type: 'uuid', nullable: true }) + ejecutadoPor?: string; + + @Column({ name: 'ejecutado_en', type: 'timestamptz', default: () => 'NOW()' }) + ejecutadoEn: Date; + + // Extra data + @Column({ type: 'jsonb', default: {} }) + metadata: Record; +} diff --git a/src/modules/dispatch/entities/operador-certificacion.entity.ts b/src/modules/dispatch/entities/operador-certificacion.entity.ts new file mode 100644 index 0000000..1887e98 --- /dev/null +++ b/src/modules/dispatch/entities/operador-certificacion.entity.ts @@ -0,0 +1,97 @@ +/** + * OperadorCertificacion Entity + * ERP Transportistas + * + * Certifications and licenses of operators. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 TechnicianSkill + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum NivelCertificacion { + BASICO = 'basico', + INTERMEDIO = 'intermedio', + AVANZADO = 'avanzado', + EXPERTO = 'experto', + FEDERAL = 'federal', // Added for SCT licenses +} + +/** + * Codigos de certificacion especificos para transporte: + * - LIC_FEDERAL: Licencia federal de conductor + * - CERT_MP: Materiales peligrosos + * - CERT_REFRIGERADO: Operacion de equipos refrigerados + * - CERT_GRUA: Operacion de gruas + * - ANTIDOPING: Certificado antidoping vigente + * - FISICO: Examen medico vigente + */ + +@Entity({ name: 'certificaciones_operador', schema: 'fleet' }) +@Index('idx_certificaciones_tenant', ['tenantId']) +@Index('idx_certificaciones_operador', ['tenantId', 'operadorId']) +@Index('idx_certificaciones_codigo', ['tenantId', 'codigoCertificacion']) +export class OperadorCertificacion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Operador reference (FK a fleet.operadores) + @Column({ name: 'operador_id', type: 'uuid' }) + operadorId: string; + + // Certification definition + @Column({ name: 'codigo_certificacion', type: 'varchar', length: 50 }) + codigoCertificacion: string; + + @Column({ name: 'nombre_certificacion', type: 'varchar', length: 100 }) + nombreCertificacion: string; + + @Column({ type: 'text', nullable: true }) + descripcion?: string; + + @Column({ + type: 'varchar', + length: 20, + default: NivelCertificacion.BASICO, + }) + nivel: NivelCertificacion; + + // Certification details + @Column({ name: 'numero_certificado', type: 'varchar', length: 100, nullable: true }) + numeroCertificado?: string; + + @Column({ name: 'fecha_certificacion', type: 'date', nullable: true }) + fechaCertificacion?: Date; + + @Column({ name: 'vigencia_hasta', type: 'date', nullable: true }) + vigenciaHasta?: Date; + + @Column({ name: 'documento_url', type: 'varchar', length: 500, nullable: true }) + documentoUrl?: string; + + // Status + @Column({ type: 'boolean', default: true }) + activa: boolean; + + @Column({ name: 'verificado_por', type: 'uuid', nullable: true }) + verificadoPor?: string; + + @Column({ name: 'verificado_en', type: 'timestamptz', nullable: true }) + verificadoEn?: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/dispatch/entities/regla-despacho.entity.ts b/src/modules/dispatch/entities/regla-despacho.entity.ts new file mode 100644 index 0000000..6e16f75 --- /dev/null +++ b/src/modules/dispatch/entities/regla-despacho.entity.ts @@ -0,0 +1,114 @@ +/** + * ReglaDespacho Entity + * ERP Transportistas + * + * Rules for automatic assignment suggestions. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 DispatchRule + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +/** + * Base dispatch conditions + */ +export interface CondicionesDespacho { + // Skills and certifications + requiereCertificaciones?: string[]; + nivelMinimoCertificacion?: 'basico' | 'intermedio' | 'avanzado' | 'experto' | 'federal'; + + // Capacity + capacidadMinimaUnidad?: 'light' | 'medium' | 'heavy'; + + // Distance + distanciaMaximaKm?: number; + + // Zones + codigosZona?: string[]; + excluirGuardia?: boolean; +} + +/** + * Transport-specific dispatch conditions + */ +export interface CondicionesDespachoTransporte extends CondicionesDespacho { + // Weight capacities + pesoMinimoKg?: number; + pesoMaximoKg?: number; + + // Temperature control + requiereRefrigeracion?: boolean; + temperaturaMin?: number; + temperaturaMax?: number; + + // Licenses and permits + requiereLicenciaFederal?: boolean; + requiereCertificadoMP?: boolean; // Materiales peligrosos + + // Availability (HOS - Hours of Service) + horasDisponiblesMinimas?: number; + + // Zone restrictions + zonasPermitidas?: string[]; + zonasRestringidas?: string[]; + + // Trip types + tiposViaje?: ('LOCAL' | 'FORANEO' | 'INTERNACIONAL')[]; +} + +@Entity({ name: 'reglas_despacho', schema: 'despacho' }) +@Index('idx_reglas_despacho_tenant', ['tenantId']) +@Index('idx_reglas_despacho_prioridad', ['tenantId', 'prioridad']) +export class ReglaDespacho { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 100 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion?: string; + + @Column({ type: 'integer', default: 0 }) + prioridad: number; + + // Applicability (transport-specific) + @Column({ name: 'tipo_viaje', type: 'varchar', length: 50, nullable: true }) + tipoViaje?: string; + + @Column({ name: 'categoria_viaje', type: 'varchar', length: 50, nullable: true }) + categoriaViaje?: string; + + // Conditions (supports both base and transport-specific) + @Column({ type: 'jsonb', default: {} }) + condiciones: CondicionesDespachoTransporte; + + // Action + @Column({ name: 'auto_asignar', type: 'boolean', default: false }) + autoAsignar: boolean; + + @Column({ name: 'peso_asignacion', type: 'integer', default: 100 }) + pesoAsignacion: number; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/dispatch/entities/regla-escalamiento.entity.ts b/src/modules/dispatch/entities/regla-escalamiento.entity.ts new file mode 100644 index 0000000..02bb291 --- /dev/null +++ b/src/modules/dispatch/entities/regla-escalamiento.entity.ts @@ -0,0 +1,93 @@ +/** + * ReglaEscalamiento Entity + * ERP Transportistas + * + * Rules for escalating unresponded trips. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 EscalationRule + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum CanalNotificacion { + EMAIL = 'email', + SMS = 'sms', + WHATSAPP = 'whatsapp', // Default for transport + PUSH = 'push', + CALL = 'call', +} + +@Entity({ name: 'reglas_escalamiento', schema: 'despacho' }) +@Index('idx_reglas_escalamiento_tenant', ['tenantId']) +@Index('idx_reglas_escalamiento_trigger', ['tenantId', 'dispararDespuesMinutos']) +export class ReglaEscalamiento { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 100 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion?: string; + + // Trigger conditions + @Column({ name: 'disparar_despues_minutos', type: 'integer' }) + dispararDespuesMinutos: number; + + @Column({ name: 'disparar_estado', type: 'varchar', length: 50, nullable: true }) + dispararEstado?: string; + + @Column({ name: 'disparar_prioridad', type: 'varchar', length: 20, nullable: true }) + dispararPrioridad?: string; + + // Escalation target + @Column({ name: 'escalar_a_rol', type: 'varchar', length: 50 }) + escalarARol: string; + + @Column({ name: 'escalar_a_usuarios', type: 'uuid', array: true, nullable: true }) + escalarAUsuarios?: string[]; + + // Notification (default whatsapp for transport) + @Column({ + name: 'canal_notificacion', + type: 'varchar', + length: 20, + default: CanalNotificacion.WHATSAPP, + }) + canalNotificacion: CanalNotificacion; + + @Column({ name: 'plantilla_notificacion', type: 'text', nullable: true }) + plantillaNotificacion?: string; + + @Column({ name: 'datos_notificacion', type: 'jsonb', default: {} }) + datosNotificacion: Record; + + // Repeat + @Column({ name: 'intervalo_repeticion_minutos', type: 'integer', nullable: true }) + intervaloRepeticionMinutos?: number; + + @Column({ name: 'max_escalamientos', type: 'integer', default: 3 }) + maxEscalamientos: number; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/dispatch/entities/turno-operador.entity.ts b/src/modules/dispatch/entities/turno-operador.entity.ts new file mode 100644 index 0000000..9efdb83 --- /dev/null +++ b/src/modules/dispatch/entities/turno-operador.entity.ts @@ -0,0 +1,95 @@ +/** + * TurnoOperador Entity + * ERP Transportistas + * + * Shift schedules for operators. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 TechnicianShift + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum TipoTurno { + MATUTINO = 'matutino', + VESPERTINO = 'vespertino', + NOCTURNO = 'nocturno', + JORNADA_COMPLETA = 'jornada_completa', + GUARDIA = 'guardia', + DISPONIBLE_24H = 'disponible_24h', // Added for transport +} + +@Entity({ name: 'turnos_operador', schema: 'fleet' }) +@Index('idx_turnos_operador_tenant', ['tenantId']) +@Index('idx_turnos_operador_fecha', ['tenantId', 'operadorId', 'fechaTurno']) +@Index('idx_turnos_fecha', ['tenantId', 'fechaTurno']) +export class TurnoOperador { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Operador reference (FK a fleet.operadores) + @Column({ name: 'operador_id', type: 'uuid' }) + operadorId: string; + + // Schedule + @Column({ name: 'fecha_turno', type: 'date' }) + fechaTurno: Date; + + @Column({ + name: 'tipo_turno', + type: 'varchar', + length: 20, + }) + tipoTurno: TipoTurno; + + @Column({ name: 'hora_inicio', type: 'time' }) + horaInicio: string; + + @Column({ name: 'hora_fin', type: 'time' }) + horaFin: string; + + // On-call specifics + @Column({ name: 'en_guardia', type: 'boolean', default: false }) + enGuardia: boolean; + + @Column({ name: 'prioridad_guardia', type: 'integer', default: 0 }) + prioridadGuardia: number; + + // Assignment + @Column({ name: 'unidad_asignada_id', type: 'uuid', nullable: true }) + unidadAsignadaId?: string; + + // Status + @Column({ name: 'hora_inicio_real', type: 'timestamptz', nullable: true }) + horaInicioReal?: Date; + + @Column({ name: 'hora_fin_real', type: 'timestamptz', nullable: true }) + horaFinReal?: Date; + + @Column({ type: 'boolean', default: false }) + ausente: boolean; + + @Column({ name: 'motivo_ausencia', type: 'text', nullable: true }) + motivoAusencia?: string; + + @Column({ type: 'text', nullable: true }) + notas?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/dispatch/index.ts b/src/modules/dispatch/index.ts new file mode 100644 index 0000000..8ab31d7 --- /dev/null +++ b/src/modules/dispatch/index.ts @@ -0,0 +1,69 @@ +/** + * Dispatch Module + * ERP Transportistas + * + * Dispatch center for trip assignment and operator management. + * Adapted from erp-mecanicas-diesel MMD-011 Dispatch Center + * Module: MAI-005 Despacho + */ + +// Entities +export { + TableroDespacho, + EstadoUnidad, + EstadoUnidadEnum, + CapacidadUnidad, + OperadorCertificacion, + NivelCertificacion, + TurnoOperador, + TipoTurno, + ReglaDespacho, + CondicionesDespacho, + CondicionesDespachoTransporte, + ReglaEscalamiento, + CanalNotificacion, + LogDespacho, + AccionDespacho, +} from './entities'; + +// Services +export { + DispatchService, + CreateEstadoUnidadDto, + UpdateEstadoUnidadDto, + AsignacionDto, + SugerenciaAsignacion, + FiltrosEstadoUnidad, + CreateTablerDto, + ParametrosSugerenciaTransporte, + CertificacionService, + CreateCertificacionDto, + UpdateCertificacionDto, + FiltrosCertificacion, + MatrizCertificaciones, + TurnoService, + CreateTurnoDto, + UpdateTurnoDto, + FiltrosTurno, + DisponibilidadOperador, + RuleService, + CreateReglaDespachoDto, + UpdateReglaDespachoDto, + CreateReglaEscalamientoDto, + UpdateReglaEscalamientoDto, + ReglaCoincidente, + // GPS Integration (Sprint S3) + GpsDispatchIntegrationService, + PosicionUnidadGps, + SugerenciaConGps, +} from './services'; + +// Controllers +export { + createDispatchController, + createCertificacionController, + createTurnoController, + createRuleController, + // GPS Integration (Sprint S3) + createGpsIntegrationController, +} from './controllers'; diff --git a/src/modules/dispatch/services/certificacion.service.ts b/src/modules/dispatch/services/certificacion.service.ts new file mode 100644 index 0000000..d9e175f --- /dev/null +++ b/src/modules/dispatch/services/certificacion.service.ts @@ -0,0 +1,360 @@ +/** + * Certificacion Service + * ERP Transportistas + * + * Business logic for operator certifications management. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 SkillService + */ + +import { Repository, DataSource } from 'typeorm'; +import { OperadorCertificacion, NivelCertificacion } from '../entities/operador-certificacion.entity'; + +// DTOs +export interface CreateCertificacionDto { + operadorId: string; + codigoCertificacion: string; + nombreCertificacion: string; + descripcion?: string; + nivel?: NivelCertificacion; + numeroCertificado?: string; + fechaCertificacion?: Date; + vigenciaHasta?: Date; + documentoUrl?: string; +} + +export interface UpdateCertificacionDto { + nombreCertificacion?: string; + descripcion?: string; + nivel?: NivelCertificacion; + numeroCertificado?: string; + fechaCertificacion?: Date; + vigenciaHasta?: Date; + documentoUrl?: string; + activa?: boolean; +} + +export interface FiltrosCertificacion { + operadorId?: string; + codigoCertificacion?: string; + nivel?: NivelCertificacion; + activa?: boolean; + porVencerDias?: number; +} + +export interface MatrizCertificaciones { + certificaciones: { + codigo: string; + nombre: string; + operadoresCount: number; + porNivel: Record; + }[]; + operadores: { + id: string; + certificacionesCount: number; + certificaciones: string[]; + }[]; +} + +export class CertificacionService { + private certificacionRepository: Repository; + + constructor(dataSource: DataSource) { + this.certificacionRepository = dataSource.getRepository(OperadorCertificacion); + } + + /** + * Add certification to operator + */ + async agregarCertificacion(tenantId: string, dto: CreateCertificacionDto): Promise { + // Check for existing certification + const existente = await this.certificacionRepository.findOne({ + where: { + tenantId, + operadorId: dto.operadorId, + codigoCertificacion: dto.codigoCertificacion, + }, + }); + + if (existente) { + throw new Error( + `Operador ${dto.operadorId} ya tiene certificacion ${dto.codigoCertificacion}` + ); + } + + const certificacion = this.certificacionRepository.create({ + tenantId, + ...dto, + nivel: dto.nivel || NivelCertificacion.BASICO, + activa: true, + }); + + return this.certificacionRepository.save(certificacion); + } + + /** + * Get certification by ID + */ + async getById(tenantId: string, id: string): Promise { + return this.certificacionRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Update certification + */ + async actualizarCertificacion( + tenantId: string, + id: string, + dto: UpdateCertificacionDto + ): Promise { + const certificacion = await this.getById(tenantId, id); + if (!certificacion) return null; + + Object.assign(certificacion, dto); + return this.certificacionRepository.save(certificacion); + } + + /** + * Deactivate certification + */ + async desactivarCertificacion(tenantId: string, id: string): Promise { + const certificacion = await this.getById(tenantId, id); + if (!certificacion) return false; + + certificacion.activa = false; + await this.certificacionRepository.save(certificacion); + return true; + } + + /** + * Get certifications for operator + */ + async getCertificacionesOperador( + tenantId: string, + operadorId: string, + soloActivas: boolean = true + ): Promise { + const where: any = { tenantId, operadorId }; + if (soloActivas) { + where.activa = true; + } + + return this.certificacionRepository.find({ + where, + order: { codigoCertificacion: 'ASC' }, + }); + } + + /** + * Get operators with specific certification + */ + async getOperadoresConCertificacion( + tenantId: string, + codigoCertificacion: string, + nivelMinimo?: NivelCertificacion + ): Promise { + const qb = this.certificacionRepository + .createQueryBuilder('cert') + .where('cert.tenant_id = :tenantId', { tenantId }) + .andWhere('cert.codigo_certificacion = :codigoCertificacion', { codigoCertificacion }) + .andWhere('cert.activa = :activa', { activa: true }); + + if (nivelMinimo) { + const ordenNivel = [ + NivelCertificacion.BASICO, + NivelCertificacion.INTERMEDIO, + NivelCertificacion.AVANZADO, + NivelCertificacion.EXPERTO, + NivelCertificacion.FEDERAL, + ]; + const minIndex = ordenNivel.indexOf(nivelMinimo); + const nivelesValidos = ordenNivel.slice(minIndex); + qb.andWhere('cert.nivel IN (:...niveles)', { niveles: nivelesValidos }); + } + + return qb.orderBy('cert.nivel', 'DESC').getMany(); + } + + /** + * Check if operator has required certifications + */ + async validarCertificacionesOperador( + tenantId: string, + operadorId: string, + certificacionesRequeridas: string[], + nivelMinimo?: NivelCertificacion + ): Promise<{ valido: boolean; faltantes: string[] }> { + const certificaciones = await this.getCertificacionesOperador(tenantId, operadorId); + const ordenNivel = [ + NivelCertificacion.BASICO, + NivelCertificacion.INTERMEDIO, + NivelCertificacion.AVANZADO, + NivelCertificacion.EXPERTO, + NivelCertificacion.FEDERAL, + ]; + + const codigosCert = new Set( + certificaciones + .filter((c) => { + if (!nivelMinimo) return true; + return ordenNivel.indexOf(c.nivel) >= ordenNivel.indexOf(nivelMinimo); + }) + .map((c) => c.codigoCertificacion) + ); + + const faltantes = certificacionesRequeridas.filter((codigo) => !codigosCert.has(codigo)); + + return { + valido: faltantes.length === 0, + faltantes, + }; + } + + /** + * Get expiring certifications + */ + async getCertificacionesPorVencer( + tenantId: string, + diasAnticipacion: number = 30 + ): Promise { + const fechaVencimiento = new Date(); + fechaVencimiento.setDate(fechaVencimiento.getDate() + diasAnticipacion); + + return this.certificacionRepository + .createQueryBuilder('cert') + .where('cert.tenant_id = :tenantId', { tenantId }) + .andWhere('cert.activa = :activa', { activa: true }) + .andWhere('cert.vigencia_hasta IS NOT NULL') + .andWhere('cert.vigencia_hasta <= :fechaVencimiento', { fechaVencimiento }) + .orderBy('cert.vigencia_hasta', 'ASC') + .getMany(); + } + + /** + * Verify certification (admin approval) + */ + async verificarCertificacion( + tenantId: string, + id: string, + verificadoPor: string + ): Promise { + const certificacion = await this.getById(tenantId, id); + if (!certificacion) return null; + + certificacion.verificadoPor = verificadoPor; + certificacion.verificadoEn = new Date(); + return this.certificacionRepository.save(certificacion); + } + + /** + * Get certification matrix (overview of all certifications and operators) + */ + async getMatrizCertificaciones(tenantId: string): Promise { + const todasCertificaciones = await this.certificacionRepository.find({ + where: { tenantId, activa: true }, + }); + + // Group by certification code + const certMap = new Map< + string, + { nombre: string; operadores: Set; niveles: Record } + >(); + + // Group by operator + const operadorMap = new Map>(); + + for (const cert of todasCertificaciones) { + // Certification aggregation + if (!certMap.has(cert.codigoCertificacion)) { + certMap.set(cert.codigoCertificacion, { + nombre: cert.nombreCertificacion, + operadores: new Set(), + niveles: { + [NivelCertificacion.BASICO]: 0, + [NivelCertificacion.INTERMEDIO]: 0, + [NivelCertificacion.AVANZADO]: 0, + [NivelCertificacion.EXPERTO]: 0, + [NivelCertificacion.FEDERAL]: 0, + }, + }); + } + const certData = certMap.get(cert.codigoCertificacion)!; + certData.operadores.add(cert.operadorId); + certData.niveles[cert.nivel]++; + + // Operator aggregation + if (!operadorMap.has(cert.operadorId)) { + operadorMap.set(cert.operadorId, new Set()); + } + operadorMap.get(cert.operadorId)!.add(cert.codigoCertificacion); + } + + return { + certificaciones: Array.from(certMap.entries()).map(([codigo, data]) => ({ + codigo, + nombre: data.nombre, + operadoresCount: data.operadores.size, + porNivel: data.niveles, + })), + operadores: Array.from(operadorMap.entries()).map(([id, certs]) => ({ + id, + certificacionesCount: certs.size, + certificaciones: Array.from(certs), + })), + }; + } + + /** + * List all certifications with filters + */ + async listar( + tenantId: string, + filtros: FiltrosCertificacion = {}, + paginacion = { pagina: 1, limite: 20 } + ) { + const qb = this.certificacionRepository + .createQueryBuilder('cert') + .where('cert.tenant_id = :tenantId', { tenantId }); + + if (filtros.operadorId) { + qb.andWhere('cert.operador_id = :operadorId', { + operadorId: filtros.operadorId, + }); + } + if (filtros.codigoCertificacion) { + qb.andWhere('cert.codigo_certificacion = :codigoCertificacion', { + codigoCertificacion: filtros.codigoCertificacion, + }); + } + if (filtros.nivel) { + qb.andWhere('cert.nivel = :nivel', { nivel: filtros.nivel }); + } + if (filtros.activa !== undefined) { + qb.andWhere('cert.activa = :activa', { activa: filtros.activa }); + } + if (filtros.porVencerDias) { + const fechaVencimiento = new Date(); + fechaVencimiento.setDate(fechaVencimiento.getDate() + filtros.porVencerDias); + qb.andWhere('cert.vigencia_hasta IS NOT NULL'); + qb.andWhere('cert.vigencia_hasta <= :fechaVencimiento', { fechaVencimiento }); + } + + const skip = (paginacion.pagina - 1) * paginacion.limite; + + const [data, total] = await qb + .orderBy('cert.codigo_certificacion', 'ASC') + .skip(skip) + .take(paginacion.limite) + .getManyAndCount(); + + return { + data, + total, + pagina: paginacion.pagina, + limite: paginacion.limite, + totalPaginas: Math.ceil(total / paginacion.limite), + }; + } +} diff --git a/src/modules/dispatch/services/dispatch.service.ts b/src/modules/dispatch/services/dispatch.service.ts new file mode 100644 index 0000000..a4c0656 --- /dev/null +++ b/src/modules/dispatch/services/dispatch.service.ts @@ -0,0 +1,595 @@ +/** + * Dispatch Service + * ERP Transportistas + * + * Business logic for dispatch operations. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 Dispatch Center + */ + +import { Repository, DataSource } from 'typeorm'; +import { EstadoUnidad, EstadoUnidadEnum, CapacidadUnidad } from '../entities/estado-unidad.entity'; +import { LogDespacho, AccionDespacho } from '../entities/log-despacho.entity'; +import { TableroDespacho } from '../entities/dispatch-board.entity'; + +// DTOs +export interface CreateEstadoUnidadDto { + unidadId: string; + codigoUnidad?: string; + nombreUnidad?: string; + capacidadUnidad?: CapacidadUnidad; + puedeRemolcar?: boolean; + pesoMaxRemolqueKg?: number; + esRefrigerada?: boolean; + capacidadPesoKg?: number; +} + +export interface UpdateEstadoUnidadDto { + estado?: EstadoUnidadEnum; + viajeActualId?: string | null; + operadorIds?: string[]; + ultimaPosicionLat?: number; + ultimaPosicionLng?: number; + ultimaPosicionId?: string; + disponibleEstimadoEn?: Date; + notas?: string; + metadata?: Record; +} + +export interface AsignacionDto { + unidadId: string; + operadorIds?: string[]; + notas?: string; +} + +export interface SugerenciaAsignacion { + unidadId: string; + codigoUnidad?: string; + nombreUnidad?: string; + score: number; + distanciaKm?: number; + operadorIds?: string[]; + razones: string[]; +} + +export interface FiltrosEstadoUnidad { + estado?: EstadoUnidadEnum; + capacidadUnidad?: CapacidadUnidad; + puedeRemolcar?: boolean; + tieneUbicacion?: boolean; + esRefrigerada?: boolean; +} + +export interface CreateTablerDto { + nombre: string; + descripcion?: string; + defaultZoom?: number; + centroLat?: number; + centroLng?: number; + intervaloRefrescoSegundos?: number; + mostrarUnidadesOffline?: boolean; + autoAsignarHabilitado?: boolean; + createdBy?: string; +} + +/** + * Transport-specific parameters for assignment suggestion + */ +export interface ParametrosSugerenciaTransporte { + viajeId: string; + origenLat: number; + origenLng: number; + tipoCarga?: string; + pesoKg?: number; + distanciaRutaKm?: number; + requiereFrio?: boolean; + requiereLicenciaFederal?: boolean; + requiereCertificadoMP?: boolean; +} + +export class DispatchService { + private estadoUnidadRepository: Repository; + private logDespachoRepository: Repository; + private tableroRepository: Repository; + + constructor(dataSource: DataSource) { + this.estadoUnidadRepository = dataSource.getRepository(EstadoUnidad); + this.logDespachoRepository = dataSource.getRepository(LogDespacho); + this.tableroRepository = dataSource.getRepository(TableroDespacho); + } + + // ========================================== + // Tablero Despacho Management + // ========================================== + + async crearTablero(tenantId: string, dto: CreateTablerDto): Promise { + const tablero = this.tableroRepository.create({ + tenantId, + nombre: dto.nombre, + descripcion: dto.descripcion, + defaultZoom: dto.defaultZoom, + centroLat: dto.centroLat, + centroLng: dto.centroLng, + intervaloRefrescoSegundos: dto.intervaloRefrescoSegundos, + mostrarUnidadesOffline: dto.mostrarUnidadesOffline, + autoAsignarHabilitado: dto.autoAsignarHabilitado, + createdBy: dto.createdBy, + }); + return this.tableroRepository.save(tablero); + } + + async getTablero(tenantId: string, id: string): Promise { + return this.tableroRepository.findOne({ + where: { id, tenantId }, + }); + } + + async getTableroActivo(tenantId: string): Promise { + return this.tableroRepository.findOne({ + where: { tenantId, activo: true }, + order: { createdAt: 'DESC' }, + }); + } + + // ========================================== + // Estado Unidad Management + // ========================================== + + async crearEstadoUnidad(tenantId: string, dto: CreateEstadoUnidadDto): Promise { + const existente = await this.estadoUnidadRepository.findOne({ + where: { tenantId, unidadId: dto.unidadId }, + }); + + if (existente) { + throw new Error(`Estado de unidad para ${dto.unidadId} ya existe`); + } + + const estadoUnidad = this.estadoUnidadRepository.create({ + tenantId, + ...dto, + estado: EstadoUnidadEnum.OFFLINE, + }); + + return this.estadoUnidadRepository.save(estadoUnidad); + } + + async getEstadoUnidad(tenantId: string, unidadId: string): Promise { + return this.estadoUnidadRepository.findOne({ + where: { tenantId, unidadId }, + }); + } + + async actualizarEstadoUnidad( + tenantId: string, + unidadId: string, + dto: UpdateEstadoUnidadDto + ): Promise { + const estadoUnidad = await this.getEstadoUnidad(tenantId, unidadId); + if (!estadoUnidad) return null; + + if (dto.ultimaPosicionLat !== undefined && dto.ultimaPosicionLng !== undefined) { + estadoUnidad.ultimaActualizacionUbicacion = new Date(); + } + + Object.assign(estadoUnidad, dto); + return this.estadoUnidadRepository.save(estadoUnidad); + } + + async getUnidadesDisponibles( + tenantId: string, + filtros: FiltrosEstadoUnidad = {} + ): Promise { + const qb = this.estadoUnidadRepository + .createQueryBuilder('eu') + .where('eu.tenant_id = :tenantId', { tenantId }) + .andWhere('eu.estado = :estado', { estado: EstadoUnidadEnum.AVAILABLE }); + + if (filtros.capacidadUnidad) { + qb.andWhere('eu.capacidad_unidad = :capacidad', { capacidad: filtros.capacidadUnidad }); + } + if (filtros.puedeRemolcar !== undefined) { + qb.andWhere('eu.puede_remolcar = :puedeRemolcar', { puedeRemolcar: filtros.puedeRemolcar }); + } + if (filtros.tieneUbicacion) { + qb.andWhere('eu.ultima_posicion_lat IS NOT NULL AND eu.ultima_posicion_lng IS NOT NULL'); + } + if (filtros.esRefrigerada !== undefined) { + qb.andWhere('eu.es_refrigerada = :esRefrigerada', { esRefrigerada: filtros.esRefrigerada }); + } + + return qb.orderBy('eu.ultimo_cambio_estado', 'ASC').getMany(); + } + + async getTodasUnidades( + tenantId: string, + incluirOffline: boolean = true + ): Promise { + const qb = this.estadoUnidadRepository + .createQueryBuilder('eu') + .where('eu.tenant_id = :tenantId', { tenantId }); + + if (!incluirOffline) { + qb.andWhere('eu.estado != :offline', { offline: EstadoUnidadEnum.OFFLINE }); + } + + return qb.orderBy('eu.estado', 'ASC').addOrderBy('eu.codigo_unidad', 'ASC').getMany(); + } + + // ========================================== + // Assignment Operations + // ========================================== + + async asignarViaje( + tenantId: string, + viajeId: string, + asignacion: AsignacionDto, + ejecutadoPor: string + ): Promise { + const estadoUnidad = await this.getEstadoUnidad(tenantId, asignacion.unidadId); + if (!estadoUnidad) { + throw new Error(`Unidad ${asignacion.unidadId} no encontrada`); + } + + if (estadoUnidad.estado !== EstadoUnidadEnum.AVAILABLE) { + throw new Error(`Unidad ${asignacion.unidadId} no disponible (estado: ${estadoUnidad.estado})`); + } + + // Update unit status + estadoUnidad.estado = EstadoUnidadEnum.ASSIGNED; + estadoUnidad.viajeActualId = viajeId; + estadoUnidad.operadorIds = asignacion.operadorIds || []; + estadoUnidad.notas = asignacion.notas; + await this.estadoUnidadRepository.save(estadoUnidad); + + // Log the assignment + await this.registrarAccion(tenantId, { + viajeId, + accion: AccionDespacho.ASSIGNED, + haciaUnidadId: asignacion.unidadId, + haciaOperadorId: asignacion.operadorIds?.[0], + ejecutadoPor, + }); + + return estadoUnidad; + } + + async reasignarViaje( + tenantId: string, + viajeId: string, + nuevaAsignacion: AsignacionDto, + razon: string, + ejecutadoPor: string + ): Promise { + // Find current assignment + const unidadActual = await this.estadoUnidadRepository.findOne({ + where: { tenantId, viajeActualId: viajeId }, + }); + + // Release current unit + if (unidadActual) { + unidadActual.estado = EstadoUnidadEnum.AVAILABLE; + unidadActual.viajeActualId = undefined; + unidadActual.operadorIds = []; + await this.estadoUnidadRepository.save(unidadActual); + } + + // Assign new unit + const nuevaUnidad = await this.getEstadoUnidad(tenantId, nuevaAsignacion.unidadId); + if (!nuevaUnidad) { + throw new Error(`Unidad ${nuevaAsignacion.unidadId} no encontrada`); + } + + if (nuevaUnidad.estado !== EstadoUnidadEnum.AVAILABLE) { + throw new Error(`Unidad ${nuevaAsignacion.unidadId} no disponible`); + } + + nuevaUnidad.estado = EstadoUnidadEnum.ASSIGNED; + nuevaUnidad.viajeActualId = viajeId; + nuevaUnidad.operadorIds = nuevaAsignacion.operadorIds || []; + await this.estadoUnidadRepository.save(nuevaUnidad); + + // Log the reassignment + await this.registrarAccion(tenantId, { + viajeId, + accion: AccionDespacho.REASSIGNED, + desdeUnidadId: unidadActual?.unidadId, + haciaUnidadId: nuevaAsignacion.unidadId, + desdeOperadorId: unidadActual?.operadorIds?.[0], + haciaOperadorId: nuevaAsignacion.operadorIds?.[0], + razon, + ejecutadoPor, + }); + + return nuevaUnidad; + } + + async marcarEnRuta( + tenantId: string, + unidadId: string, + ejecutadoPor: string + ): Promise { + const estadoUnidad = await this.getEstadoUnidad(tenantId, unidadId); + if (!estadoUnidad || !estadoUnidad.viajeActualId) return null; + + estadoUnidad.estado = EstadoUnidadEnum.EN_ROUTE; + await this.estadoUnidadRepository.save(estadoUnidad); + + await this.registrarAccion(tenantId, { + viajeId: estadoUnidad.viajeActualId, + accion: AccionDespacho.ACKNOWLEDGED, + haciaUnidadId: unidadId, + ejecutadoPor, + }); + + return estadoUnidad; + } + + async marcarEnSitio( + tenantId: string, + unidadId: string, + ejecutadoPor: string + ): Promise { + const estadoUnidad = await this.getEstadoUnidad(tenantId, unidadId); + if (!estadoUnidad || !estadoUnidad.viajeActualId) return null; + + estadoUnidad.estado = EstadoUnidadEnum.ON_SITE; + await this.estadoUnidadRepository.save(estadoUnidad); + + return estadoUnidad; + } + + async completarViaje( + tenantId: string, + unidadId: string, + ejecutadoPor: string + ): Promise { + const estadoUnidad = await this.getEstadoUnidad(tenantId, unidadId); + if (!estadoUnidad || !estadoUnidad.viajeActualId) return null; + + const viajeId = estadoUnidad.viajeActualId; + + estadoUnidad.estado = EstadoUnidadEnum.RETURNING; + await this.estadoUnidadRepository.save(estadoUnidad); + + await this.registrarAccion(tenantId, { + viajeId, + accion: AccionDespacho.COMPLETED, + haciaUnidadId: unidadId, + ejecutadoPor, + }); + + return estadoUnidad; + } + + async liberarUnidad( + tenantId: string, + unidadId: string + ): Promise { + const estadoUnidad = await this.getEstadoUnidad(tenantId, unidadId); + if (!estadoUnidad) return null; + + estadoUnidad.estado = EstadoUnidadEnum.AVAILABLE; + estadoUnidad.viajeActualId = undefined; + estadoUnidad.operadorIds = []; + estadoUnidad.disponibleEstimadoEn = undefined; + + return this.estadoUnidadRepository.save(estadoUnidad); + } + + // ========================================== + // Assignment Suggestions - Transport-Specific + // ========================================== + + async sugerirMejorAsignacion( + tenantId: string, + params: ParametrosSugerenciaTransporte + ): Promise { + const filtros: FiltrosEstadoUnidad = { + tieneUbicacion: true, + }; + + // Filter by refrigeration if required + if (params.requiereFrio) { + filtros.esRefrigerada = true; + } + + const unidadesDisponibles = await this.getUnidadesDisponibles(tenantId, filtros); + + const sugerencias: SugerenciaAsignacion[] = []; + + for (const unidad of unidadesDisponibles) { + if (!unidad.ultimaPosicionLat || !unidad.ultimaPosicionLng) continue; + + const distancia = this.calcularDistancia( + params.origenLat, + params.origenLng, + Number(unidad.ultimaPosicionLat), + Number(unidad.ultimaPosicionLng) + ); + + // Base score from distance (closer = higher score) + let score = Math.max(0, 100 - distancia * 1.5); + const razones: string[] = []; + + // Distance evaluation + if (distancia <= 10) { + razones.push('Unidad cercana (< 10km)'); + score += 15; + } else if (distancia <= 25) { + razones.push('Distancia media (10-25km)'); + } else { + razones.push(`Unidad lejana (${distancia.toFixed(1)}km)`); + score -= 15; + } + + // Weight capacity check + if (params.pesoKg && unidad.capacidadPesoKg) { + if (params.pesoKg > Number(unidad.capacidadPesoKg)) { + // Disqualify - cannot carry the load + continue; + } + razones.push(`Capacidad de peso adecuada`); + score += 5; + } + + // Refrigeration check + if (params.requiereFrio) { + if (!unidad.esRefrigerada) { + // Already filtered above, but double-check + continue; + } + razones.push('Unidad refrigerada'); + score += 10; + } + + // TODO: Add HOS check when operator data is available + // const horasDisponibles = calcularHorasDisponibles(operador); + // if (params.distanciaRutaKm) { + // const horasRuta = params.distanciaRutaKm / 60; + // if (horasDisponibles < horasRuta) score -= 30; + // } + + sugerencias.push({ + unidadId: unidad.unidadId, + codigoUnidad: unidad.codigoUnidad, + nombreUnidad: unidad.nombreUnidad, + score: Math.round(score), + distanciaKm: Math.round(distancia * 10) / 10, + operadorIds: unidad.operadorIds, + razones, + }); + } + + // Sort by score descending + return sugerencias.sort((a, b) => b.score - a.score).slice(0, 5); + } + + // ========================================== + // Dispatch Log + // ========================================== + + async registrarAccion( + tenantId: string, + data: { + viajeId: string; + accion: AccionDespacho; + desdeUnidadId?: string; + haciaUnidadId?: string; + desdeOperadorId?: string; + haciaOperadorId?: string; + razon?: string; + automatizado?: boolean; + reglaId?: string; + escalamientoId?: string; + tiempoRespuestaSegundos?: number; + ejecutadoPor?: string; + metadata?: Record; + } + ): Promise { + const log = this.logDespachoRepository.create({ + tenantId, + ...data, + ejecutadoEn: new Date(), + }); + return this.logDespachoRepository.save(log); + } + + async getLogsViaje( + tenantId: string, + viajeId: string + ): Promise { + return this.logDespachoRepository.find({ + where: { tenantId, viajeId }, + order: { ejecutadoEn: 'DESC' }, + }); + } + + async getLogsRecientes( + tenantId: string, + limite: number = 50 + ): Promise { + return this.logDespachoRepository.find({ + where: { tenantId }, + order: { ejecutadoEn: 'DESC' }, + take: limite, + }); + } + + // ========================================== + // Statistics + // ========================================== + + async getEstadisticasDespacho(tenantId: string): Promise<{ + totalUnidades: number; + disponibles: number; + asignadas: number; + enRuta: number; + enSitio: number; + offline: number; + mantenimiento: number; + }> { + const unidades = await this.estadoUnidadRepository.find({ where: { tenantId } }); + + const stats = { + totalUnidades: unidades.length, + disponibles: 0, + asignadas: 0, + enRuta: 0, + enSitio: 0, + offline: 0, + mantenimiento: 0, + }; + + for (const unidad of unidades) { + switch (unidad.estado) { + case EstadoUnidadEnum.AVAILABLE: + stats.disponibles++; + break; + case EstadoUnidadEnum.ASSIGNED: + stats.asignadas++; + break; + case EstadoUnidadEnum.EN_ROUTE: + stats.enRuta++; + break; + case EstadoUnidadEnum.ON_SITE: + stats.enSitio++; + break; + case EstadoUnidadEnum.OFFLINE: + stats.offline++; + break; + case EstadoUnidadEnum.MAINTENANCE: + stats.mantenimiento++; + break; + } + } + + return stats; + } + + // ========================================== + // Helpers + // ========================================== + + private calcularDistancia( + lat1: number, + lng1: number, + lat2: number, + lng2: number + ): number { + const R = 6371; // Earth's radius in km + const dLat = this.toRad(lat2 - lat1); + const dLng = this.toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * + Math.cos(this.toRad(lat2)) * + Math.sin(dLng / 2) * + Math.sin(dLng / 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/dispatch/services/gps-dispatch-integration.service.ts b/src/modules/dispatch/services/gps-dispatch-integration.service.ts new file mode 100644 index 0000000..0664107 --- /dev/null +++ b/src/modules/dispatch/services/gps-dispatch-integration.service.ts @@ -0,0 +1,293 @@ +/** + * GPS-Dispatch Integration Service + * ERP Transportistas + * + * Integrates GPS module with Dispatch module for real-time position updates. + * Sprint: S3 - TASK-007 + * Module: MAI-005/MAI-006 Integration + */ + +import { DataSource, Repository } from 'typeorm'; +import { EstadoUnidad, EstadoUnidadEnum } from '../entities/estado-unidad.entity'; +import { DispositivoGps } from '../../gps/entities/dispositivo-gps.entity'; +import { PosicionGps } from '../../gps/entities/posicion-gps.entity'; +import { SugerenciaAsignacion, ParametrosSugerenciaTransporte } from './dispatch.service'; + +/** + * Interface for GPS position with device info + */ +export interface PosicionUnidadGps { + unidadId: string; + dispositivoId: string; + latitud: number; + longitud: number; + velocidadKmh?: number; + timestamp: Date; + enLinea: boolean; +} + +/** + * Interface for enhanced assignment suggestion with real GPS data + */ +export interface SugerenciaConGps extends SugerenciaAsignacion { + posicionGpsActualizada: boolean; + ultimaActualizacionGps?: Date; + velocidadActualKmh?: number; +} + +/** + * GPS-Dispatch Integration Service + * + * Handles synchronization between GPS positions and dispatch unit status, + * and enhances assignment suggestions with real-time GPS data. + */ +export class GpsDispatchIntegrationService { + private estadoUnidadRepository: Repository; + private dispositivoGpsRepository: Repository; + private posicionGpsRepository: Repository; + + constructor(private dataSource: DataSource) { + this.estadoUnidadRepository = dataSource.getRepository(EstadoUnidad); + this.dispositivoGpsRepository = dataSource.getRepository(DispositivoGps); + this.posicionGpsRepository = dataSource.getRepository(PosicionGps); + } + + /** + * Synchronize unit status positions with GPS device positions + * Updates EstadoUnidad.ultimaPosicion from DispositivoGps.ultimaPosicion + */ + async sincronizarPosicionesGps(tenantId: string): Promise { + // Get all active GPS devices with positions + const dispositivos = await this.dispositivoGpsRepository.find({ + where: { tenantId, activo: true }, + }); + + let actualizados = 0; + + for (const dispositivo of dispositivos) { + if (!dispositivo.ultimaPosicionLat || !dispositivo.ultimaPosicionLng) { + continue; + } + + // Find corresponding unit status + const estadoUnidad = await this.estadoUnidadRepository.findOne({ + where: { tenantId, unidadId: dispositivo.unidadId }, + }); + + if (!estadoUnidad) { + continue; + } + + // Check if GPS position is newer than stored position + const gpsTimestamp = dispositivo.ultimaPosicionAt; + const storedTimestamp = estadoUnidad.ultimaActualizacionUbicacion; + + if (!storedTimestamp || (gpsTimestamp && gpsTimestamp > storedTimestamp)) { + estadoUnidad.ultimaPosicionLat = dispositivo.ultimaPosicionLat; + estadoUnidad.ultimaPosicionLng = dispositivo.ultimaPosicionLng; + estadoUnidad.ultimaActualizacionUbicacion = gpsTimestamp || new Date(); + await this.estadoUnidadRepository.save(estadoUnidad); + actualizados++; + } + } + + return actualizados; + } + + /** + * Get all units with their current GPS positions + */ + async obtenerUnidadesConPosicionGps(tenantId: string): Promise { + const umbralMinutos = 10; + const threshold = new Date(Date.now() - umbralMinutos * 60 * 1000); + + const dispositivos = await this.dispositivoGpsRepository.find({ + where: { tenantId, activo: true }, + }); + + return dispositivos + .filter(d => d.ultimaPosicionLat && d.ultimaPosicionLng) + .map(d => ({ + unidadId: d.unidadId, + dispositivoId: d.id, + latitud: Number(d.ultimaPosicionLat), + longitud: Number(d.ultimaPosicionLng), + timestamp: d.ultimaPosicionAt || new Date(), + enLinea: d.ultimaPosicionAt ? d.ultimaPosicionAt > threshold : false, + })); + } + + /** + * Get enhanced assignment suggestions using real-time GPS data + */ + async sugerirAsignacionConGps( + tenantId: string, + params: ParametrosSugerenciaTransporte + ): Promise { + // First, sync positions from GPS + await this.sincronizarPosicionesGps(tenantId); + + // Get available units + const unidadesDisponibles = await this.estadoUnidadRepository.find({ + where: { + tenantId, + estado: EstadoUnidadEnum.AVAILABLE, + }, + }); + + const sugerencias: SugerenciaConGps[] = []; + const umbralMinutos = 10; + const threshold = new Date(Date.now() - umbralMinutos * 60 * 1000); + + for (const unidad of unidadesDisponibles) { + // Get GPS device for unit + const dispositivo = await this.dispositivoGpsRepository.findOne({ + where: { tenantId, unidadId: unidad.unidadId, activo: true }, + }); + + // Determine position source (GPS real-time or cached) + let lat: number | undefined; + let lng: number | undefined; + let posicionGpsActualizada = false; + let ultimaActualizacionGps: Date | undefined; + let velocidadActualKmh: number | undefined; + + if (dispositivo?.ultimaPosicionLat && dispositivo?.ultimaPosicionLng) { + // Use GPS position + lat = Number(dispositivo.ultimaPosicionLat); + lng = Number(dispositivo.ultimaPosicionLng); + ultimaActualizacionGps = dispositivo.ultimaPosicionAt; + posicionGpsActualizada = dispositivo.ultimaPosicionAt ? dispositivo.ultimaPosicionAt > threshold : false; + + // Get last position record for velocity + const ultimaPosicion = await this.posicionGpsRepository.findOne({ + where: { dispositivoId: dispositivo.id }, + order: { tiempoDispositivo: 'DESC' }, + }); + if (ultimaPosicion) { + velocidadActualKmh = ultimaPosicion.velocidad ? Number(ultimaPosicion.velocidad) : undefined; + } + } else if (unidad.ultimaPosicionLat && unidad.ultimaPosicionLng) { + // Fallback to cached position in unit status + lat = Number(unidad.ultimaPosicionLat); + lng = Number(unidad.ultimaPosicionLng); + ultimaActualizacionGps = unidad.ultimaActualizacionUbicacion; + posicionGpsActualizada = false; + } + + if (!lat || !lng) continue; + + const distancia = this.calcularDistancia(params.origenLat, params.origenLng, lat, lng); + + // Base score from distance + let score = Math.max(0, 100 - distancia * 1.5); + const razones: string[] = []; + + // Distance bonus/penalty + if (distancia <= 10) { + razones.push('Unidad cercana (< 10km)'); + score += 15; + } else if (distancia <= 25) { + razones.push('Distancia media (10-25km)'); + } else { + razones.push(`Unidad lejana (${distancia.toFixed(1)}km)`); + score -= 15; + } + + // GPS freshness bonus + if (posicionGpsActualizada) { + razones.push('GPS en tiempo real'); + score += 10; + } else { + razones.push('Posición no actualizada'); + score -= 5; + } + + // Weight capacity check + if (params.pesoKg && unidad.capacidadPesoKg) { + if (params.pesoKg > Number(unidad.capacidadPesoKg)) { + continue; // Disqualify + } + razones.push('Capacidad de peso adecuada'); + score += 5; + } + + // Refrigeration check + if (params.requiereFrio) { + if (!unidad.esRefrigerada) { + continue; // Disqualify + } + razones.push('Unidad refrigerada'); + score += 10; + } + + // Velocity consideration (moving towards origin is better) + if (velocidadActualKmh && velocidadActualKmh > 0) { + razones.push(`En movimiento (${velocidadActualKmh.toFixed(0)} km/h)`); + // Slight bonus for moving units as they might arrive sooner + score += 3; + } + + sugerencias.push({ + unidadId: unidad.unidadId, + codigoUnidad: unidad.codigoUnidad, + nombreUnidad: unidad.nombreUnidad, + score: Math.round(score), + distanciaKm: Math.round(distancia * 10) / 10, + operadorIds: unidad.operadorIds, + razones, + posicionGpsActualizada, + ultimaActualizacionGps, + velocidadActualKmh, + }); + } + + return sugerencias.sort((a, b) => b.score - a.score).slice(0, 5); + } + + /** + * Update unit status when GPS position changes + * Called by GPS position service after receiving new position + */ + async actualizarEstadoUnidadPorGps( + tenantId: string, + unidadId: string, + lat: number, + lng: number, + timestamp: Date + ): Promise { + const estadoUnidad = await this.estadoUnidadRepository.findOne({ + where: { tenantId, unidadId }, + }); + + if (!estadoUnidad) return null; + + estadoUnidad.ultimaPosicionLat = lat; + estadoUnidad.ultimaPosicionLng = lng; + estadoUnidad.ultimaActualizacionUbicacion = timestamp; + + // Update status based on position context (optional enhancement) + // Could check if unit is within geofence of assigned destination + + return this.estadoUnidadRepository.save(estadoUnidad); + } + + /** + * Calculate Haversine distance between two points + */ + private calcularDistancia(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; // Earth's radius in km + const dLat = this.toRad(lat2 - lat1); + const dLng = this.toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * + Math.sin(dLng / 2) * Math.sin(dLng / 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/dispatch/services/index.ts b/src/modules/dispatch/services/index.ts new file mode 100644 index 0000000..e5cd38c --- /dev/null +++ b/src/modules/dispatch/services/index.ts @@ -0,0 +1,49 @@ +/** + * Dispatch Module Services Index + * ERP Transportistas + * + * Module: MAI-005 Despacho + */ + +export { + DispatchService, + CreateEstadoUnidadDto, + UpdateEstadoUnidadDto, + AsignacionDto, + SugerenciaAsignacion, + FiltrosEstadoUnidad, + CreateTablerDto, + ParametrosSugerenciaTransporte, +} from './dispatch.service'; + +export { + CertificacionService, + CreateCertificacionDto, + UpdateCertificacionDto, + FiltrosCertificacion, + MatrizCertificaciones, +} from './certificacion.service'; + +export { + TurnoService, + CreateTurnoDto, + UpdateTurnoDto, + FiltrosTurno, + DisponibilidadOperador, +} from './turno.service'; + +export { + RuleService, + CreateReglaDespachoDto, + UpdateReglaDespachoDto, + CreateReglaEscalamientoDto, + UpdateReglaEscalamientoDto, + ReglaCoincidente, +} from './rule.service'; + +// GPS-Dispatch Integration (Sprint S3 - TASK-007) +export { + GpsDispatchIntegrationService, + PosicionUnidadGps, + SugerenciaConGps, +} from './gps-dispatch-integration.service'; diff --git a/src/modules/dispatch/services/rule.service.ts b/src/modules/dispatch/services/rule.service.ts new file mode 100644 index 0000000..3dc5deb --- /dev/null +++ b/src/modules/dispatch/services/rule.service.ts @@ -0,0 +1,398 @@ +/** + * Rule Service + * ERP Transportistas + * + * Business logic for dispatch and escalation rules. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 RuleService + */ + +import { Repository, DataSource } from 'typeorm'; +import { ReglaDespacho, CondicionesDespachoTransporte } from '../entities/regla-despacho.entity'; +import { ReglaEscalamiento, CanalNotificacion } from '../entities/regla-escalamiento.entity'; + +// DTOs +export interface CreateReglaDespachoDto { + nombre: string; + descripcion?: string; + prioridad?: number; + tipoViaje?: string; + categoriaViaje?: string; + condiciones: CondicionesDespachoTransporte; + autoAsignar?: boolean; + pesoAsignacion?: number; + createdBy?: string; +} + +export interface UpdateReglaDespachoDto { + nombre?: string; + descripcion?: string; + prioridad?: number; + tipoViaje?: string; + categoriaViaje?: string; + condiciones?: CondicionesDespachoTransporte; + autoAsignar?: boolean; + pesoAsignacion?: number; + activo?: boolean; +} + +export interface CreateReglaEscalamientoDto { + nombre: string; + descripcion?: string; + dispararDespuesMinutos: number; + dispararEstado?: string; + dispararPrioridad?: string; + escalarARol: string; + escalarAUsuarios?: string[]; + canalNotificacion: CanalNotificacion; + plantillaNotificacion?: string; + datosNotificacion?: Record; + intervaloRepeticionMinutos?: number; + maxEscalamientos?: number; + createdBy?: string; +} + +export interface UpdateReglaEscalamientoDto { + nombre?: string; + descripcion?: string; + dispararDespuesMinutos?: number; + dispararEstado?: string; + dispararPrioridad?: string; + escalarARol?: string; + escalarAUsuarios?: string[]; + canalNotificacion?: CanalNotificacion; + plantillaNotificacion?: string; + datosNotificacion?: Record; + intervaloRepeticionMinutos?: number; + maxEscalamientos?: number; + activo?: boolean; +} + +export interface ReglaCoincidente { + regla: ReglaDespacho; + score: number; +} + +export class RuleService { + private reglaDespachoRepository: Repository; + private reglaEscalamientoRepository: Repository; + + constructor(dataSource: DataSource) { + this.reglaDespachoRepository = dataSource.getRepository(ReglaDespacho); + this.reglaEscalamientoRepository = dataSource.getRepository(ReglaEscalamiento); + } + + // ========================================== + // Reglas de Despacho + // ========================================== + + async crearReglaDespacho( + tenantId: string, + dto: CreateReglaDespachoDto + ): Promise { + const regla = this.reglaDespachoRepository.create({ + tenantId, + ...dto, + prioridad: dto.prioridad ?? 0, + activo: true, + }); + return this.reglaDespachoRepository.save(regla); + } + + async getReglaDespachoById( + tenantId: string, + id: string + ): Promise { + return this.reglaDespachoRepository.findOne({ + where: { id, tenantId }, + }); + } + + async actualizarReglaDespacho( + tenantId: string, + id: string, + dto: UpdateReglaDespachoDto + ): Promise { + const regla = await this.getReglaDespachoById(tenantId, id); + if (!regla) return null; + + Object.assign(regla, dto); + return this.reglaDespachoRepository.save(regla); + } + + async eliminarReglaDespacho(tenantId: string, id: string): Promise { + const result = await this.reglaDespachoRepository.delete({ id, tenantId }); + return (result.affected || 0) > 0; + } + + async getReglasDespachoActivas(tenantId: string): Promise { + return this.reglaDespachoRepository.find({ + where: { tenantId, activo: true }, + order: { prioridad: 'DESC' }, + }); + } + + async getReglasDespachoCoincidentes( + tenantId: string, + tipoViaje?: string, + categoriaViaje?: string + ): Promise { + const qb = this.reglaDespachoRepository + .createQueryBuilder('regla') + .where('regla.tenant_id = :tenantId', { tenantId }) + .andWhere('regla.activo = :activo', { activo: true }); + + // Match rules that apply to this trip type or are generic + if (tipoViaje) { + qb.andWhere( + '(regla.tipo_viaje IS NULL OR regla.tipo_viaje = :tipoViaje)', + { tipoViaje } + ); + } + + if (categoriaViaje) { + qb.andWhere( + '(regla.categoria_viaje IS NULL OR regla.categoria_viaje = :categoriaViaje)', + { categoriaViaje } + ); + } + + return qb.orderBy('regla.prioridad', 'DESC').getMany(); + } + + /** + * Evaluate rules and return applicable ones with scores + * Transport-specific evaluation + */ + async evaluarReglas( + tenantId: string, + contexto: { + tipoViaje?: string; + categoriaViaje?: string; + certificacionesOperador?: string[]; + capacidadUnidad?: string; + distanciaKm?: number; + codigoZona?: string; + pesoKg?: number; + requiereFrio?: boolean; + requiereLicenciaFederal?: boolean; + requiereCertificadoMP?: boolean; + } + ): Promise { + const reglas = await this.getReglasDespachoCoincidentes( + tenantId, + contexto.tipoViaje, + contexto.categoriaViaje + ); + + const coincidentes: ReglaCoincidente[] = []; + + for (const regla of reglas) { + let score = regla.pesoAsignacion; + let aplicable = true; + const condiciones = regla.condiciones; + + // Check certification requirements + if (condiciones.requiereCertificaciones && condiciones.requiereCertificaciones.length > 0) { + const tieneCertificaciones = condiciones.requiereCertificaciones.every((cert) => + contexto.certificacionesOperador?.includes(cert) + ); + if (!tieneCertificaciones) aplicable = false; + } + + // Check unit capacity + if (condiciones.capacidadMinimaUnidad) { + const ordenCapacidad = ['light', 'medium', 'heavy']; + const minIdx = ordenCapacidad.indexOf(condiciones.capacidadMinimaUnidad); + const actualIdx = ordenCapacidad.indexOf(contexto.capacidadUnidad || 'light'); + if (actualIdx < minIdx) aplicable = false; + } + + // Check distance + if (condiciones.distanciaMaximaKm && contexto.distanciaKm) { + if (contexto.distanciaKm > condiciones.distanciaMaximaKm) { + aplicable = false; + } else { + // Bonus for being within distance + score += Math.round((1 - contexto.distanciaKm / condiciones.distanciaMaximaKm) * 20); + } + } + + // Check zone + if (condiciones.codigosZona && condiciones.codigosZona.length > 0) { + if (!contexto.codigoZona || !condiciones.codigosZona.includes(contexto.codigoZona)) { + aplicable = false; + } + } + + // Transport-specific: Check weight + if (condiciones.pesoMaximoKg && contexto.pesoKg) { + if (contexto.pesoKg > condiciones.pesoMaximoKg) { + aplicable = false; + } + } + + // Transport-specific: Check refrigeration + if (condiciones.requiereRefrigeracion && !contexto.requiereFrio) { + // Rule requires refrigeration but context doesn't need it - skip this rule + } + + // Transport-specific: Check federal license + if (condiciones.requiereLicenciaFederal && !contexto.requiereLicenciaFederal) { + aplicable = false; + } + + // Transport-specific: Check hazmat certification + if (condiciones.requiereCertificadoMP && !contexto.requiereCertificadoMP) { + aplicable = false; + } + + if (aplicable) { + coincidentes.push({ regla, score }); + } + } + + return coincidentes.sort((a, b) => b.score - a.score); + } + + async listarReglasDespacho( + tenantId: string, + soloActivas: boolean = false, + paginacion = { pagina: 1, limite: 20 } + ) { + const qb = this.reglaDespachoRepository + .createQueryBuilder('regla') + .where('regla.tenant_id = :tenantId', { tenantId }); + + if (soloActivas) { + qb.andWhere('regla.activo = :activo', { activo: true }); + } + + const skip = (paginacion.pagina - 1) * paginacion.limite; + + const [data, total] = await qb + .orderBy('regla.prioridad', 'DESC') + .skip(skip) + .take(paginacion.limite) + .getManyAndCount(); + + return { + data, + total, + pagina: paginacion.pagina, + limite: paginacion.limite, + totalPaginas: Math.ceil(total / paginacion.limite), + }; + } + + // ========================================== + // Reglas de Escalamiento + // ========================================== + + async crearReglaEscalamiento( + tenantId: string, + dto: CreateReglaEscalamientoDto + ): Promise { + const regla = this.reglaEscalamientoRepository.create({ + tenantId, + ...dto, + activo: true, + }); + return this.reglaEscalamientoRepository.save(regla); + } + + async getReglaEscalamientoById( + tenantId: string, + id: string + ): Promise { + return this.reglaEscalamientoRepository.findOne({ + where: { id, tenantId }, + }); + } + + async actualizarReglaEscalamiento( + tenantId: string, + id: string, + dto: UpdateReglaEscalamientoDto + ): Promise { + const regla = await this.getReglaEscalamientoById(tenantId, id); + if (!regla) return null; + + Object.assign(regla, dto); + return this.reglaEscalamientoRepository.save(regla); + } + + async eliminarReglaEscalamiento(tenantId: string, id: string): Promise { + const result = await this.reglaEscalamientoRepository.delete({ id, tenantId }); + return (result.affected || 0) > 0; + } + + async getReglasEscalamientoActivas(tenantId: string): Promise { + return this.reglaEscalamientoRepository.find({ + where: { tenantId, activo: true }, + order: { dispararDespuesMinutos: 'ASC' }, + }); + } + + /** + * Get escalation rules that should trigger based on elapsed time + */ + async getReglasEscalamientoDisparadas( + tenantId: string, + minutosTranscurridos: number, + estadoViaje?: string, + prioridadViaje?: string + ): Promise { + const qb = this.reglaEscalamientoRepository + .createQueryBuilder('regla') + .where('regla.tenant_id = :tenantId', { tenantId }) + .andWhere('regla.activo = :activo', { activo: true }) + .andWhere('regla.disparar_despues_minutos <= :minutosTranscurridos', { minutosTranscurridos }); + + if (estadoViaje) { + qb.andWhere( + '(regla.disparar_estado IS NULL OR regla.disparar_estado = :estadoViaje)', + { estadoViaje } + ); + } + + if (prioridadViaje) { + qb.andWhere( + '(regla.disparar_prioridad IS NULL OR regla.disparar_prioridad = :prioridadViaje)', + { prioridadViaje } + ); + } + + return qb.orderBy('regla.disparar_despues_minutos', 'ASC').getMany(); + } + + async listarReglasEscalamiento( + tenantId: string, + soloActivas: boolean = false, + paginacion = { pagina: 1, limite: 20 } + ) { + const qb = this.reglaEscalamientoRepository + .createQueryBuilder('regla') + .where('regla.tenant_id = :tenantId', { tenantId }); + + if (soloActivas) { + qb.andWhere('regla.activo = :activo', { activo: true }); + } + + const skip = (paginacion.pagina - 1) * paginacion.limite; + + const [data, total] = await qb + .orderBy('regla.disparar_despues_minutos', 'ASC') + .skip(skip) + .take(paginacion.limite) + .getManyAndCount(); + + return { + data, + total, + pagina: paginacion.pagina, + limite: paginacion.limite, + totalPaginas: Math.ceil(total / paginacion.limite), + }; + } +} diff --git a/src/modules/dispatch/services/turno.service.ts b/src/modules/dispatch/services/turno.service.ts new file mode 100644 index 0000000..b694feb --- /dev/null +++ b/src/modules/dispatch/services/turno.service.ts @@ -0,0 +1,378 @@ +/** + * Turno Service + * ERP Transportistas + * + * Business logic for operator shift management. + * Module: MAI-005 Despacho + * Adapted from: erp-mecanicas-diesel MMD-011 ShiftService + */ + +import { Repository, DataSource } from 'typeorm'; +import { TurnoOperador, TipoTurno } from '../entities/turno-operador.entity'; + +// DTOs +export interface CreateTurnoDto { + operadorId: string; + fechaTurno: Date; + tipoTurno: TipoTurno; + horaInicio: string; + horaFin: string; + enGuardia?: boolean; + prioridadGuardia?: number; + unidadAsignadaId?: string; + notas?: string; + createdBy?: string; +} + +export interface UpdateTurnoDto { + tipoTurno?: TipoTurno; + horaInicio?: string; + horaFin?: string; + enGuardia?: boolean; + prioridadGuardia?: number; + unidadAsignadaId?: string; + ausente?: boolean; + motivoAusencia?: string; + notas?: string; +} + +export interface FiltrosTurno { + operadorId?: string; + tipoTurno?: TipoTurno; + fechaDesde?: Date; + fechaHasta?: Date; + enGuardia?: boolean; + unidadAsignadaId?: string; + ausente?: boolean; +} + +export interface DisponibilidadOperador { + operadorId: string; + turno?: TurnoOperador; + disponible: boolean; + enGuardia: boolean; + razon?: string; +} + +export class TurnoService { + private turnoRepository: Repository; + + constructor(dataSource: DataSource) { + this.turnoRepository = dataSource.getRepository(TurnoOperador); + } + + /** + * Create a new shift + */ + async crearTurno(tenantId: string, dto: CreateTurnoDto): Promise { + // Check for overlapping shifts + const existente = await this.turnoRepository.findOne({ + where: { + tenantId, + operadorId: dto.operadorId, + fechaTurno: dto.fechaTurno, + }, + }); + + if (existente) { + throw new Error( + `Operador ${dto.operadorId} ya tiene turno el ${dto.fechaTurno}` + ); + } + + const turno = this.turnoRepository.create({ + tenantId, + ...dto, + }); + + return this.turnoRepository.save(turno); + } + + /** + * Get shift by ID + */ + async getById(tenantId: string, id: string): Promise { + return this.turnoRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Update shift + */ + async actualizarTurno( + tenantId: string, + id: string, + dto: UpdateTurnoDto + ): Promise { + const turno = await this.getById(tenantId, id); + if (!turno) return null; + + Object.assign(turno, dto); + return this.turnoRepository.save(turno); + } + + /** + * Delete shift + */ + async eliminarTurno(tenantId: string, id: string): Promise { + const result = await this.turnoRepository.delete({ id, tenantId }); + return (result.affected || 0) > 0; + } + + /** + * Get shifts for a specific date + */ + async getTurnosPorFecha( + tenantId: string, + fecha: Date, + excluirAusentes: boolean = true + ): Promise { + const qb = this.turnoRepository + .createQueryBuilder('turno') + .where('turno.tenant_id = :tenantId', { tenantId }) + .andWhere('turno.fecha_turno = :fecha', { fecha }); + + if (excluirAusentes) { + qb.andWhere('turno.ausente = :ausente', { ausente: false }); + } + + return qb.orderBy('turno.hora_inicio', 'ASC').getMany(); + } + + /** + * Get operator's shifts + */ + async getTurnosOperador( + tenantId: string, + operadorId: string, + fechaDesde?: Date, + fechaHasta?: Date + ): Promise { + const qb = this.turnoRepository + .createQueryBuilder('turno') + .where('turno.tenant_id = :tenantId', { tenantId }) + .andWhere('turno.operador_id = :operadorId', { operadorId }); + + if (fechaDesde) { + qb.andWhere('turno.fecha_turno >= :fechaDesde', { fechaDesde }); + } + if (fechaHasta) { + qb.andWhere('turno.fecha_turno <= :fechaHasta', { fechaHasta }); + } + + return qb.orderBy('turno.fecha_turno', 'ASC').getMany(); + } + + /** + * Get available operators for a specific date and time + */ + async getOperadoresDisponibles( + tenantId: string, + fecha: Date, + hora?: string + ): Promise { + const turnos = await this.getTurnosPorFecha(tenantId, fecha, true); + + return turnos + .filter((turno) => { + if (hora) { + // Check if time is within shift hours + return hora >= turno.horaInicio && hora <= turno.horaFin; + } + return true; + }) + .map((turno) => ({ + operadorId: turno.operadorId, + turno, + disponible: !turno.ausente, + enGuardia: turno.enGuardia, + })); + } + + /** + * Get on-call operators for a date + */ + async getOperadoresEnGuardia( + tenantId: string, + fecha: Date + ): Promise { + return this.turnoRepository.find({ + where: { + tenantId, + fechaTurno: fecha, + enGuardia: true, + ausente: false, + }, + order: { prioridadGuardia: 'ASC' }, + }); + } + + /** + * Mark operator as started shift + */ + async iniciarTurno(tenantId: string, id: string): Promise { + const turno = await this.getById(tenantId, id); + if (!turno) return null; + + turno.horaInicioReal = new Date(); + return this.turnoRepository.save(turno); + } + + /** + * Mark operator as ended shift + */ + async finalizarTurno(tenantId: string, id: string): Promise { + const turno = await this.getById(tenantId, id); + if (!turno) return null; + + turno.horaFinReal = new Date(); + return this.turnoRepository.save(turno); + } + + /** + * Mark operator as absent + */ + async marcarAusente( + tenantId: string, + id: string, + motivo: string + ): Promise { + const turno = await this.getById(tenantId, id); + if (!turno) return null; + + turno.ausente = true; + turno.motivoAusencia = motivo; + return this.turnoRepository.save(turno); + } + + /** + * Assign unit to shift + */ + async asignarUnidadATurno( + tenantId: string, + id: string, + unidadId: string + ): Promise { + const turno = await this.getById(tenantId, id); + if (!turno) return null; + + turno.unidadAsignadaId = unidadId; + return this.turnoRepository.save(turno); + } + + /** + * Get shifts by unit + */ + async getTurnosPorUnidad( + tenantId: string, + unidadId: string, + fecha: Date + ): Promise { + return this.turnoRepository.find({ + where: { + tenantId, + unidadAsignadaId: unidadId, + fechaTurno: fecha, + ausente: false, + }, + order: { horaInicio: 'ASC' }, + }); + } + + /** + * Generate bulk shifts for a week + */ + async generarTurnosSemanales( + tenantId: string, + operadorId: string, + fechaInicioSemana: Date, + tipoTurno: TipoTurno, + horaInicio: string, + horaFin: string, + diasSemana: number[], // 0 = Sunday, 1 = Monday, etc. + createdBy?: string + ): Promise { + const turnos: TurnoOperador[] = []; + + for (let i = 0; i < 7; i++) { + const fecha = new Date(fechaInicioSemana); + fecha.setDate(fecha.getDate() + i); + const diaSemana = fecha.getDay(); + + if (diasSemana.includes(diaSemana)) { + try { + const turno = await this.crearTurno(tenantId, { + operadorId, + fechaTurno: fecha, + tipoTurno, + horaInicio, + horaFin, + createdBy, + }); + turnos.push(turno); + } catch { + // Skip if shift already exists + } + } + } + + return turnos; + } + + /** + * List shifts with filters + */ + async listar( + tenantId: string, + filtros: FiltrosTurno = {}, + paginacion = { pagina: 1, limite: 20 } + ) { + const qb = this.turnoRepository + .createQueryBuilder('turno') + .where('turno.tenant_id = :tenantId', { tenantId }); + + if (filtros.operadorId) { + qb.andWhere('turno.operador_id = :operadorId', { + operadorId: filtros.operadorId, + }); + } + if (filtros.tipoTurno) { + qb.andWhere('turno.tipo_turno = :tipoTurno', { tipoTurno: filtros.tipoTurno }); + } + if (filtros.fechaDesde) { + qb.andWhere('turno.fecha_turno >= :fechaDesde', { fechaDesde: filtros.fechaDesde }); + } + if (filtros.fechaHasta) { + qb.andWhere('turno.fecha_turno <= :fechaHasta', { fechaHasta: filtros.fechaHasta }); + } + if (filtros.enGuardia !== undefined) { + qb.andWhere('turno.en_guardia = :enGuardia', { enGuardia: filtros.enGuardia }); + } + if (filtros.unidadAsignadaId) { + qb.andWhere('turno.unidad_asignada_id = :unidadAsignadaId', { + unidadAsignadaId: filtros.unidadAsignadaId, + }); + } + if (filtros.ausente !== undefined) { + qb.andWhere('turno.ausente = :ausente', { ausente: filtros.ausente }); + } + + const skip = (paginacion.pagina - 1) * paginacion.limite; + + const [data, total] = await qb + .orderBy('turno.fecha_turno', 'DESC') + .addOrderBy('turno.hora_inicio', 'ASC') + .skip(skip) + .take(paginacion.limite) + .getManyAndCount(); + + return { + data, + total, + pagina: paginacion.pagina, + limite: paginacion.limite, + totalPaginas: Math.ceil(total / paginacion.limite), + }; + } +} diff --git a/src/modules/gps/controllers/dispositivo-gps.controller.ts b/src/modules/gps/controllers/dispositivo-gps.controller.ts new file mode 100644 index 0000000..4e2e989 --- /dev/null +++ b/src/modules/gps/controllers/dispositivo-gps.controller.ts @@ -0,0 +1,220 @@ +/** + * DispositivoGps Controller + * ERP Transportistas + * + * REST API endpoints for GPS device management. + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { DispositivoGpsService, DispositivoGpsFilters } from '../services/dispositivo-gps.service'; +import { PlataformaGps, TipoUnidadGps } from '../entities/dispositivo-gps.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createDispositivoGpsController(dataSource: DataSource): Router { + const router = Router(); + const service = new DispositivoGpsService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID es requerido' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Register a new GPS device + * POST /api/gps/dispositivos + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const dispositivo = await service.create(req.tenantId!, { + ...req.body, + createdBy: req.userId, + }); + res.status(201).json(dispositivo); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List GPS devices with filters + * GET /api/gps/dispositivos + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: DispositivoGpsFilters = { + unidadId: req.query.unidadId as string, + tipoUnidad: req.query.tipoUnidad as TipoUnidadGps, + plataforma: req.query.plataforma as PlataformaGps, + activo: req.query.activo === 'true' ? true : req.query.activo === 'false' ? false : undefined, + search: req.query.search as string, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get device statistics + * GET /api/gps/dispositivos/estadisticas + */ + router.get('/estadisticas', async (req: TenantRequest, res: Response) => { + try { + const stats = await service.getEstadisticas(req.tenantId!); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get all active devices with positions (for map view) + * GET /api/gps/dispositivos/activos + */ + router.get('/activos', async (req: TenantRequest, res: Response) => { + try { + const dispositivos = await service.findActivosConPosicion(req.tenantId!); + res.json(dispositivos); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get devices with stale positions + * GET /api/gps/dispositivos/inactivos + */ + router.get('/inactivos', async (req: TenantRequest, res: Response) => { + try { + const umbralMinutos = parseInt(req.query.umbral as string, 10) || 10; + const dispositivos = await service.findDispositivosInactivos(req.tenantId!, umbralMinutos); + res.json(dispositivos); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get device by ID + * GET /api/gps/dispositivos/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const dispositivo = await service.findById(req.tenantId!, req.params.id); + if (!dispositivo) { + return res.status(404).json({ error: 'Dispositivo GPS no encontrado' }); + } + res.json(dispositivo); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get device by unit ID + * GET /api/gps/dispositivos/unidad/:unidadId + */ + router.get('/unidad/:unidadId', async (req: TenantRequest, res: Response) => { + try { + const dispositivo = await service.findByUnidadId(req.tenantId!, req.params.unidadId); + if (!dispositivo) { + return res.status(404).json({ error: 'Dispositivo GPS no encontrado para la unidad' }); + } + res.json(dispositivo); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get device by external ID + * GET /api/gps/dispositivos/external/:externalId + */ + router.get('/external/:externalId', async (req: TenantRequest, res: Response) => { + try { + const dispositivo = await service.findByExternalId(req.tenantId!, req.params.externalId); + if (!dispositivo) { + return res.status(404).json({ error: 'Dispositivo GPS no encontrado' }); + } + res.json(dispositivo); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update GPS device + * PATCH /api/gps/dispositivos/:id + */ + router.patch('/:id', async (req: TenantRequest, res: Response) => { + try { + const dispositivo = await service.update(req.tenantId!, req.params.id, req.body); + if (!dispositivo) { + return res.status(404).json({ error: 'Dispositivo GPS no encontrado' }); + } + res.json(dispositivo); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Update last position + * PATCH /api/gps/dispositivos/:id/posicion + */ + router.patch('/:id/posicion', async (req: TenantRequest, res: Response) => { + try { + const { latitud, longitud, timestamp } = req.body; + const dispositivo = await service.updateUltimaPosicion(req.tenantId!, req.params.id, { + latitud, + longitud, + timestamp: new Date(timestamp), + }); + if (!dispositivo) { + return res.status(404).json({ error: 'Dispositivo GPS no encontrado' }); + } + res.json(dispositivo); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Deactivate GPS device + * DELETE /api/gps/dispositivos/:id + */ + router.delete('/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.deactivate(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Dispositivo GPS no encontrado' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/gps/controllers/index.ts b/src/modules/gps/controllers/index.ts new file mode 100644 index 0000000..0178de7 --- /dev/null +++ b/src/modules/gps/controllers/index.ts @@ -0,0 +1,9 @@ +/** + * GPS Controllers + * ERP Transportistas + * Module: MAI-006 Tracking + */ + +export { createDispositivoGpsController } from './dispositivo-gps.controller'; +export { createPosicionGpsController } from './posicion-gps.controller'; +export { createSegmentoRutaController } from './segmento-ruta.controller'; diff --git a/src/modules/gps/controllers/posicion-gps.controller.ts b/src/modules/gps/controllers/posicion-gps.controller.ts new file mode 100644 index 0000000..8096f47 --- /dev/null +++ b/src/modules/gps/controllers/posicion-gps.controller.ts @@ -0,0 +1,221 @@ +/** + * PosicionGps Controller + * ERP Transportistas + * + * REST API endpoints for GPS position tracking. + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { PosicionGpsService, PosicionFilters } from '../services/posicion-gps.service'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createPosicionGpsController(dataSource: DataSource): Router { + const router = Router(); + const service = new PosicionGpsService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID es requerido' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Record a new GPS position + * POST /api/gps/posiciones + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const posicion = await service.create(req.tenantId!, { + ...req.body, + tiempoDispositivo: new Date(req.body.tiempoDispositivo), + tiempoFix: req.body.tiempoFix ? new Date(req.body.tiempoFix) : undefined, + }); + res.status(201).json(posicion); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Record multiple positions in batch + * POST /api/gps/posiciones/batch + */ + router.post('/batch', async (req: TenantRequest, res: Response) => { + try { + const posiciones = req.body.posiciones.map((p: any) => ({ + ...p, + tiempoDispositivo: new Date(p.tiempoDispositivo), + tiempoFix: p.tiempoFix ? new Date(p.tiempoFix) : undefined, + })); + const count = await service.createBatch(req.tenantId!, posiciones); + res.status(201).json({ insertados: count }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get position history with filters + * GET /api/gps/posiciones + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: PosicionFilters = { + dispositivoId: req.query.dispositivoId as string, + unidadId: req.query.unidadId as string, + tiempoInicio: req.query.tiempoInicio ? new Date(req.query.tiempoInicio as string) : undefined, + tiempoFin: req.query.tiempoFin ? new Date(req.query.tiempoFin as string) : undefined, + velocidadMinima: req.query.velocidadMinima ? parseFloat(req.query.velocidadMinima as string) : undefined, + velocidadMaxima: req.query.velocidadMaxima ? parseFloat(req.query.velocidadMaxima as string) : undefined, + esValido: req.query.esValido === 'true' ? true : req.query.esValido === 'false' ? false : undefined, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 100, 500), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get last position for a device + * GET /api/gps/posiciones/ultima/:dispositivoId + */ + router.get('/ultima/:dispositivoId', async (req: TenantRequest, res: Response) => { + try { + const posicion = await service.getUltimaPosicion(req.tenantId!, req.params.dispositivoId); + if (!posicion) { + return res.status(404).json({ error: 'Posicion no encontrada' }); + } + res.json(posicion); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get last positions for multiple devices + * POST /api/gps/posiciones/ultimas + */ + router.post('/ultimas', async (req: TenantRequest, res: Response) => { + try { + const { dispositivoIds } = req.body; + const posiciones = await service.getUltimasPosiciones(req.tenantId!, dispositivoIds); + res.json(posiciones); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get track for a device + * GET /api/gps/posiciones/track/:dispositivoId + */ + router.get('/track/:dispositivoId', async (req: TenantRequest, res: Response) => { + try { + const tiempoInicio = new Date(req.query.tiempoInicio as string); + const tiempoFin = new Date(req.query.tiempoFin as string); + const simplificar = req.query.simplificar === 'true'; + + const track = await service.getTrack( + req.tenantId!, + req.params.dispositivoId, + tiempoInicio, + tiempoFin, + simplificar + ); + res.json(track); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get track summary for a device + * GET /api/gps/posiciones/track/:dispositivoId/resumen + */ + router.get('/track/:dispositivoId/resumen', async (req: TenantRequest, res: Response) => { + try { + const tiempoInicio = new Date(req.query.tiempoInicio as string); + const tiempoFin = new Date(req.query.tiempoFin as string); + + const resumen = await service.getResumenTrack( + req.tenantId!, + req.params.dispositivoId, + tiempoInicio, + tiempoFin + ); + + if (!resumen) { + return res.status(404).json({ error: 'No hay posiciones en el rango de tiempo' }); + } + res.json(resumen); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Calculate distance between two points + * POST /api/gps/posiciones/distancia + */ + router.post('/distancia', async (req: TenantRequest, res: Response) => { + try { + const { lat1, lng1, lat2, lng2 } = req.body; + const distanciaKm = service.calcularDistancia(lat1, lng1, lat2, lng2); + res.json({ distanciaKm }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get position by ID + * GET /api/gps/posiciones/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const posicion = await service.findById(req.tenantId!, req.params.id); + if (!posicion) { + return res.status(404).json({ error: 'Posicion no encontrada' }); + } + res.json(posicion); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Delete old positions + * DELETE /api/gps/posiciones/antiguas + */ + router.delete('/antiguas', async (req: TenantRequest, res: Response) => { + try { + const antesDe = new Date(req.query.antesDe as string); + const eliminadas = await service.eliminarPosicionesAntiguas(req.tenantId!, antesDe); + res.json({ eliminadas }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/gps/controllers/segmento-ruta.controller.ts b/src/modules/gps/controllers/segmento-ruta.controller.ts new file mode 100644 index 0000000..ec94e34 --- /dev/null +++ b/src/modules/gps/controllers/segmento-ruta.controller.ts @@ -0,0 +1,206 @@ +/** + * SegmentoRuta Controller + * ERP Transportistas + * + * REST API endpoints for route segment management. + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { SegmentoRutaService, SegmentoRutaFilters } from '../services/segmento-ruta.service'; +import { TipoSegmento } from '../entities/segmento-ruta.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createSegmentoRutaController(dataSource: DataSource): Router { + const router = Router(); + const service = new SegmentoRutaService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID es requerido' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Create a route segment + * POST /api/gps/segmentos + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const segmento = await service.create(req.tenantId!, { + ...req.body, + tiempoInicio: new Date(req.body.tiempoInicio), + tiempoFin: new Date(req.body.tiempoFin), + }); + res.status(201).json(segmento); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Calculate route segment from positions + * POST /api/gps/segmentos/calcular + */ + router.post('/calcular', async (req: TenantRequest, res: Response) => { + try { + const { dispositivoId, tiempoInicio, tiempoFin, viajeId, tipoSegmento } = req.body; + const segmento = await service.calcularRutaDesdePosiciones( + req.tenantId!, + dispositivoId, + new Date(tiempoInicio), + new Date(tiempoFin), + viajeId, + tipoSegmento as TipoSegmento + ); + + if (!segmento) { + return res.status(400).json({ error: 'No hay suficientes posiciones para calcular el segmento' }); + } + res.status(201).json(segmento); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * List route segments with filters + * GET /api/gps/segmentos + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: SegmentoRutaFilters = { + viajeId: req.query.viajeId as string, + unidadId: req.query.unidadId as string, + dispositivoId: req.query.dispositivoId as string, + tipoSegmento: req.query.tipoSegmento as TipoSegmento, + esValido: req.query.esValido === 'true' ? true : req.query.esValido === 'false' ? false : undefined, + fechaInicio: req.query.fechaInicio ? new Date(req.query.fechaInicio as string) : undefined, + fechaFin: req.query.fechaFin ? new Date(req.query.fechaFin as string) : undefined, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get route summary for a viaje + * GET /api/gps/segmentos/viaje/:viajeId/resumen + */ + router.get('/viaje/:viajeId/resumen', async (req: TenantRequest, res: Response) => { + try { + const resumen = await service.getResumenRutaViaje(req.tenantId!, req.params.viajeId); + if (!resumen) { + return res.status(404).json({ error: 'No hay segmentos para el viaje' }); + } + res.json(resumen); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get segments for a viaje + * GET /api/gps/segmentos/viaje/:viajeId + */ + router.get('/viaje/:viajeId', async (req: TenantRequest, res: Response) => { + try { + const segmentos = await service.findByViaje(req.tenantId!, req.params.viajeId); + res.json(segmentos); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get statistics for a unit + * GET /api/gps/segmentos/unidad/:unidadId/estadisticas + */ + router.get('/unidad/:unidadId/estadisticas', async (req: TenantRequest, res: Response) => { + try { + const fechaInicio = req.query.fechaInicio ? new Date(req.query.fechaInicio as string) : undefined; + const fechaFin = req.query.fechaFin ? new Date(req.query.fechaFin as string) : undefined; + + const estadisticas = await service.getEstadisticasUnidad( + req.tenantId!, + req.params.unidadId, + fechaInicio, + fechaFin + ); + res.json(estadisticas); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get segment by ID + * GET /api/gps/segmentos/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const segmento = await service.findById(req.tenantId!, req.params.id); + if (!segmento) { + return res.status(404).json({ error: 'Segmento no encontrado' }); + } + res.json(segmento); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Update segment validity + * PATCH /api/gps/segmentos/:id/validez + */ + router.patch('/:id/validez', async (req: TenantRequest, res: Response) => { + try { + const { esValido, notas } = req.body; + const segmento = await service.updateValidez(req.tenantId!, req.params.id, esValido, notas); + if (!segmento) { + return res.status(404).json({ error: 'Segmento no encontrado' }); + } + res.json(segmento); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Delete segment + * DELETE /api/gps/segmentos/:id + */ + router.delete('/:id', async (req: TenantRequest, res: Response) => { + try { + const success = await service.delete(req.tenantId!, req.params.id); + if (!success) { + return res.status(404).json({ error: 'Segmento no encontrado' }); + } + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/gps/entities/dispositivo-gps.entity.ts b/src/modules/gps/entities/dispositivo-gps.entity.ts new file mode 100644 index 0000000..7beae58 --- /dev/null +++ b/src/modules/gps/entities/dispositivo-gps.entity.ts @@ -0,0 +1,129 @@ +/** + * DispositivoGps Entity + * ERP Transportistas + * + * Represents GPS tracking devices linked to fleet units. + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { PosicionGps } from './posicion-gps.entity'; +import { EventoGeocerca } from './evento-geocerca.entity'; +import { SegmentoRuta } from './segmento-ruta.entity'; + +export enum PlataformaGps { + TRACCAR = 'traccar', + WIALON = 'wialon', + SAMSARA = 'samsara', + GEOTAB = 'geotab', + MANUAL = 'manual', +} + +export enum TipoUnidadGps { + TRACTORA = 'tractora', + REMOLQUE = 'remolque', + CAJA = 'caja', + EQUIPO = 'equipo', + OPERADOR = 'operador', +} + +@Entity({ name: 'dispositivos_gps', schema: 'tracking' }) +@Index('idx_dispositivos_gps_tenant', ['tenantId']) +@Index('idx_dispositivos_gps_unidad', ['unidadId']) +@Index('idx_dispositivos_gps_external', ['externalDeviceId']) +@Index('idx_dispositivos_gps_plataforma', ['plataforma']) +export class DispositivoGps { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Link to fleet unit (fleet.unidades) + @Column({ name: 'unidad_id', type: 'uuid' }) + unidadId: string; + + @Column({ + name: 'tipo_unidad', + type: 'varchar', + length: 20, + default: TipoUnidadGps.TRACTORA, + }) + tipoUnidad: TipoUnidadGps; + + // External platform identification + @Column({ name: 'external_device_id', type: 'varchar', length: 100 }) + externalDeviceId: string; + + @Column({ + type: 'varchar', + length: 30, + default: PlataformaGps.TRACCAR, + }) + plataforma: PlataformaGps; + + // Device identifiers + @Column({ type: 'varchar', length: 20, nullable: true }) + imei?: string; + + @Column({ name: 'numero_serie', type: 'varchar', length: 50, nullable: true }) + numeroSerie?: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + telefono?: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + modelo?: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + fabricante?: string; + + // Status + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @Column({ name: 'ultima_posicion_at', type: 'timestamptz', nullable: true }) + ultimaPosicionAt?: Date; + + @Column({ name: 'ultima_posicion_lat', type: 'decimal', precision: 10, scale: 7, nullable: true }) + ultimaPosicionLat?: number; + + @Column({ name: 'ultima_posicion_lng', type: 'decimal', precision: 10, scale: 7, nullable: true }) + ultimaPosicionLng?: number; + + // Configuration + @Column({ name: 'intervalo_posicion_segundos', type: 'integer', default: 30 }) + intervaloPosicionSegundos: number; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + // Relations + @OneToMany(() => PosicionGps, position => position.dispositivo) + posiciones: PosicionGps[]; + + @OneToMany(() => EventoGeocerca, event => event.dispositivo) + eventosGeocerca: EventoGeocerca[]; + + @OneToMany(() => SegmentoRuta, segment => segment.dispositivo) + segmentosRuta: SegmentoRuta[]; +} diff --git a/src/modules/gps/entities/evento-geocerca.entity.ts b/src/modules/gps/entities/evento-geocerca.entity.ts new file mode 100644 index 0000000..9a382d7 --- /dev/null +++ b/src/modules/gps/entities/evento-geocerca.entity.ts @@ -0,0 +1,90 @@ +/** + * EventoGeocerca Entity + * ERP Transportistas + * + * Represents geofence entry/exit events. + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { DispositivoGps } from './dispositivo-gps.entity'; +import { PosicionGps } from './posicion-gps.entity'; + +export enum TipoEventoGeocerca { + ENTRADA = 'entrada', + SALIDA = 'salida', + PERMANENCIA = 'permanencia', +} + +@Entity({ name: 'eventos_geocerca', schema: 'tracking' }) +@Index('idx_eventos_geocerca_tenant', ['tenantId']) +@Index('idx_eventos_geocerca_geocerca', ['geocercaId']) +@Index('idx_eventos_geocerca_dispositivo', ['dispositivoId']) +@Index('idx_eventos_geocerca_unidad', ['unidadId']) +@Index('idx_eventos_geocerca_tiempo', ['tiempoEvento']) +export class EventoGeocerca { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'geocerca_id', type: 'uuid' }) + geocercaId: string; + + @Column({ name: 'dispositivo_id', type: 'uuid' }) + dispositivoId: string; + + @Column({ name: 'unidad_id', type: 'uuid' }) + unidadId: string; + + // Event type + @Column({ + name: 'tipo_evento', + type: 'varchar', + length: 15, + }) + tipoEvento: TipoEventoGeocerca; + + // Position that triggered the event + @Column({ name: 'posicion_id', type: 'uuid', nullable: true }) + posicionId?: string; + + @Column({ type: 'decimal', precision: 10, scale: 7 }) + latitud: number; + + @Column({ type: 'decimal', precision: 10, scale: 7 }) + longitud: number; + + // Timestamps + @Column({ name: 'tiempo_evento', type: 'timestamptz' }) + tiempoEvento: Date; + + @Column({ name: 'procesado_at', type: 'timestamptz', default: () => 'NOW()' }) + procesadoAt: Date; + + // Link to operations (viaje instead of incident) + @Column({ name: 'viaje_id', type: 'uuid', nullable: true }) + viajeId?: string; // FK to transport.viajes if applicable + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + // Relations + @ManyToOne(() => DispositivoGps, dispositivo => dispositivo.eventosGeocerca) + @JoinColumn({ name: 'dispositivo_id' }) + dispositivo: DispositivoGps; + + @ManyToOne(() => PosicionGps) + @JoinColumn({ name: 'posicion_id' }) + posicion?: PosicionGps; +} diff --git a/src/modules/gps/entities/index.ts b/src/modules/gps/entities/index.ts new file mode 100644 index 0000000..d742c24 --- /dev/null +++ b/src/modules/gps/entities/index.ts @@ -0,0 +1,11 @@ +/** + * GPS Entities + * ERP Transportistas + * Schema: tracking + * Module: MAI-006 Tracking + */ + +export { DispositivoGps, PlataformaGps, TipoUnidadGps } from './dispositivo-gps.entity'; +export { PosicionGps } from './posicion-gps.entity'; +export { EventoGeocerca, TipoEventoGeocerca } from './evento-geocerca.entity'; +export { SegmentoRuta, TipoSegmento } from './segmento-ruta.entity'; diff --git a/src/modules/gps/entities/posicion-gps.entity.ts b/src/modules/gps/entities/posicion-gps.entity.ts new file mode 100644 index 0000000..e0792b5 --- /dev/null +++ b/src/modules/gps/entities/posicion-gps.entity.ts @@ -0,0 +1,89 @@ +/** + * PosicionGps Entity + * ERP Transportistas + * + * Represents GPS positions (time-series data). + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { DispositivoGps } from './dispositivo-gps.entity'; + +@Entity({ name: 'posiciones_gps', schema: 'tracking' }) +@Index('idx_posiciones_gps_tenant', ['tenantId']) +@Index('idx_posiciones_gps_dispositivo', ['dispositivoId']) +@Index('idx_posiciones_gps_unidad', ['unidadId']) +@Index('idx_posiciones_gps_tiempo', ['tiempoDispositivo']) +export class PosicionGps { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'dispositivo_id', type: 'uuid' }) + dispositivoId: string; + + @Column({ name: 'unidad_id', type: 'uuid' }) + unidadId: string; + + // Position + @Column({ type: 'decimal', precision: 10, scale: 7 }) + latitud: number; + + @Column({ type: 'decimal', precision: 10, scale: 7 }) + longitud: number; + + @Column({ type: 'decimal', precision: 8, scale: 2, nullable: true }) + altitud?: number; + + // Movement + @Column({ type: 'decimal', precision: 6, scale: 2, nullable: true }) + velocidad?: number; // km/h + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + rumbo?: number; // degrees (0 = north) + + // Precision + @Column({ type: 'decimal', precision: 6, scale: 2, nullable: true }) + precision?: number; // meters + + @Column({ type: 'decimal', precision: 4, scale: 2, nullable: true }) + hdop?: number; // Horizontal Dilution of Precision + + // Additional attributes from device + @Column({ type: 'jsonb', default: {} }) + atributos: Record; + // Can contain: ignition, fuel, odometer, engineHours, batteryLevel, etc. + + // Timestamps + @Column({ name: 'tiempo_dispositivo', type: 'timestamptz' }) + tiempoDispositivo: Date; + + @Column({ name: 'tiempo_servidor', type: 'timestamptz', default: () => 'NOW()' }) + tiempoServidor: Date; + + @Column({ name: 'tiempo_fix', type: 'timestamptz', nullable: true }) + tiempoFix?: Date; + + // Validation + @Column({ name: 'es_valido', type: 'boolean', default: true }) + esValido: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => DispositivoGps, dispositivo => dispositivo.posiciones) + @JoinColumn({ name: 'dispositivo_id' }) + dispositivo: DispositivoGps; +} diff --git a/src/modules/gps/entities/segmento-ruta.entity.ts b/src/modules/gps/entities/segmento-ruta.entity.ts new file mode 100644 index 0000000..fe13651 --- /dev/null +++ b/src/modules/gps/entities/segmento-ruta.entity.ts @@ -0,0 +1,133 @@ +/** + * SegmentoRuta Entity + * ERP Transportistas + * + * Represents route segments for distance calculation and billing. + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { DispositivoGps } from './dispositivo-gps.entity'; +import { PosicionGps } from './posicion-gps.entity'; + +export enum TipoSegmento { + HACIA_DESTINO = 'hacia_destino', // Trip to destination + EN_DESTINO = 'en_destino', // At destination location + RETORNO = 'retorno', // Return trip + ENTRE_PARADAS = 'entre_paradas', // Between stops + OTRO = 'otro', +} + +@Entity({ name: 'segmentos_ruta', schema: 'tracking' }) +@Index('idx_segmentos_ruta_tenant', ['tenantId']) +@Index('idx_segmentos_ruta_viaje', ['viajeId']) +@Index('idx_segmentos_ruta_unidad', ['unidadId']) +@Index('idx_segmentos_ruta_tipo', ['tipoSegmento']) +@Index('idx_segmentos_ruta_tiempo', ['tiempoInicio']) +export class SegmentoRuta { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Link to viaje (transport.viajes) + @Column({ name: 'viaje_id', type: 'uuid', nullable: true }) + viajeId?: string; + + @Column({ name: 'unidad_id', type: 'uuid' }) + unidadId: string; + + @Column({ name: 'dispositivo_id', type: 'uuid', nullable: true }) + dispositivoId?: string; + + // Start/end positions + @Column({ name: 'posicion_inicio_id', type: 'uuid', nullable: true }) + posicionInicioId?: string; + + @Column({ name: 'posicion_fin_id', type: 'uuid', nullable: true }) + posicionFinId?: string; + + // Coordinates (denormalized for quick access) + @Column({ name: 'lat_inicio', type: 'decimal', precision: 10, scale: 7 }) + latInicio: number; + + @Column({ name: 'lng_inicio', type: 'decimal', precision: 10, scale: 7 }) + lngInicio: number; + + @Column({ name: 'lat_fin', type: 'decimal', precision: 10, scale: 7 }) + latFin: number; + + @Column({ name: 'lng_fin', type: 'decimal', precision: 10, scale: 7 }) + lngFin: number; + + // Distances + @Column({ name: 'distancia_km', type: 'decimal', precision: 10, scale: 3 }) + distanciaKm: number; + + @Column({ name: 'distancia_cruda_km', type: 'decimal', precision: 10, scale: 3, nullable: true }) + distanciaCrudaKm?: number; // Before filters + + // Times + @Column({ name: 'tiempo_inicio', type: 'timestamptz' }) + tiempoInicio: Date; + + @Column({ name: 'tiempo_fin', type: 'timestamptz' }) + tiempoFin: Date; + + @Column({ name: 'duracion_minutos', type: 'decimal', precision: 8, scale: 2, nullable: true }) + duracionMinutos?: number; + + // Segment type + @Column({ + name: 'tipo_segmento', + type: 'varchar', + length: 30, + default: TipoSegmento.OTRO, + }) + tipoSegmento: TipoSegmento; + + // Validation + @Column({ name: 'es_valido', type: 'boolean', default: true }) + esValido: boolean; + + @Column({ name: 'notas_validacion', type: 'text', nullable: true }) + notasValidacion?: string; + + // Encoded polyline for visualization + @Column({ name: 'polyline_encoded', type: 'text', nullable: true }) + polylineEncoded?: string; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'calculado_at', type: 'timestamptz', default: () => 'NOW()' }) + calculadoAt: Date; + + // Relations + @ManyToOne(() => DispositivoGps, dispositivo => dispositivo.segmentosRuta, { nullable: true }) + @JoinColumn({ name: 'dispositivo_id' }) + dispositivo?: DispositivoGps; + + @ManyToOne(() => PosicionGps, { nullable: true }) + @JoinColumn({ name: 'posicion_inicio_id' }) + posicionInicio?: PosicionGps; + + @ManyToOne(() => PosicionGps, { nullable: true }) + @JoinColumn({ name: 'posicion_fin_id' }) + posicionFin?: PosicionGps; +} diff --git a/src/modules/gps/gps.routes.ts b/src/modules/gps/gps.routes.ts new file mode 100644 index 0000000..abd7ddb --- /dev/null +++ b/src/modules/gps/gps.routes.ts @@ -0,0 +1,37 @@ +/** + * GPS Module Routes + * ERP Transportistas + * + * Routes for GPS devices, positions, geofences, and route segments. + * Module: MAI-006 Tracking + * Sprint: S1 - TASK-007 + */ + +import { Router } from 'express'; +import { AppDataSource } from '../../config/typeorm.js'; +import { + createDispositivoGpsController, + createPosicionGpsController, + createSegmentoRutaController, +} from './controllers/index.js'; + +const router = Router(); + +// Create controller routers with DataSource +const dispositivoRouter = createDispositivoGpsController(AppDataSource); +const posicionRouter = createPosicionGpsController(AppDataSource); +const segmentoRouter = createSegmentoRutaController(AppDataSource); + +// Mount routes +// /api/gps/dispositivos - GPS device management +router.use('/dispositivos', dispositivoRouter); + +// /api/gps/posiciones - GPS position tracking +router.use('/posiciones', posicionRouter); + +// /api/gps/segmentos - Route segments +router.use('/segmentos', segmentoRouter); + +// Note: Geocerca operations are part of dispositivo-gps controller (linked devices) + +export default router; diff --git a/src/modules/gps/index.ts b/src/modules/gps/index.ts new file mode 100644 index 0000000..86b0248 --- /dev/null +++ b/src/modules/gps/index.ts @@ -0,0 +1,45 @@ +/** + * GPS Module + * ERP Transportistas + * + * GPS tracking, positions, geofencing and route segments. + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +// Entities +export { + DispositivoGps, + PlataformaGps, + TipoUnidadGps, +} from './entities/dispositivo-gps.entity'; +export { PosicionGps } from './entities/posicion-gps.entity'; +export { EventoGeocerca, TipoEventoGeocerca } from './entities/evento-geocerca.entity'; +export { SegmentoRuta, TipoSegmento } from './entities/segmento-ruta.entity'; + +// Services +export { + DispositivoGpsService, + CreateDispositivoGpsDto, + UpdateDispositivoGpsDto, + DispositivoGpsFilters, + UltimaPosicion, +} from './services/dispositivo-gps.service'; +export { + PosicionGpsService, + CreatePosicionDto, + PosicionFilters, + PuntoPosicion, + ResumenTrack, +} from './services/posicion-gps.service'; +export { + SegmentoRutaService, + CreateSegmentoRutaDto, + SegmentoRutaFilters, + RutaCalculada, +} from './services/segmento-ruta.service'; + +// Controllers +export { createDispositivoGpsController } from './controllers/dispositivo-gps.controller'; +export { createPosicionGpsController } from './controllers/posicion-gps.controller'; +export { createSegmentoRutaController } from './controllers/segmento-ruta.controller'; diff --git a/src/modules/gps/services/dispositivo-gps.service.ts b/src/modules/gps/services/dispositivo-gps.service.ts new file mode 100644 index 0000000..d6ce472 --- /dev/null +++ b/src/modules/gps/services/dispositivo-gps.service.ts @@ -0,0 +1,328 @@ +/** + * DispositivoGps Service + * ERP Transportistas + * + * Business logic for GPS device management. + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { Repository, DataSource } from 'typeorm'; +import { DispositivoGps, PlataformaGps, TipoUnidadGps } from '../entities/dispositivo-gps.entity'; + +// DTOs +export interface CreateDispositivoGpsDto { + unidadId: string; + tipoUnidad?: TipoUnidadGps; + externalDeviceId: string; + plataforma?: PlataformaGps; + imei?: string; + numeroSerie?: string; + telefono?: string; + modelo?: string; + fabricante?: string; + intervaloPosicionSegundos?: number; + metadata?: Record; + createdBy?: string; +} + +export interface UpdateDispositivoGpsDto { + externalDeviceId?: string; + plataforma?: PlataformaGps; + imei?: string; + numeroSerie?: string; + telefono?: string; + modelo?: string; + fabricante?: string; + intervaloPosicionSegundos?: number; + activo?: boolean; + metadata?: Record; +} + +export interface DispositivoGpsFilters { + unidadId?: string; + tipoUnidad?: TipoUnidadGps; + plataforma?: PlataformaGps; + activo?: boolean; + search?: string; +} + +export interface UltimaPosicion { + latitud: number; + longitud: number; + timestamp: Date; +} + +export class DispositivoGpsService { + private dispositivoRepository: Repository; + + constructor(dataSource: DataSource) { + this.dispositivoRepository = dataSource.getRepository(DispositivoGps); + } + + /** + * Register a new GPS device + */ + async create(tenantId: string, dto: CreateDispositivoGpsDto): Promise { + // Check for duplicate external device ID + const existing = await this.dispositivoRepository.findOne({ + where: { tenantId, externalDeviceId: dto.externalDeviceId }, + }); + + if (existing) { + throw new Error(`Dispositivo con ID externo ${dto.externalDeviceId} ya existe`); + } + + // Check if unit already has a device + const unidadDispositivo = await this.dispositivoRepository.findOne({ + where: { tenantId, unidadId: dto.unidadId, activo: true }, + }); + + if (unidadDispositivo) { + throw new Error(`La unidad ${dto.unidadId} ya tiene un dispositivo GPS activo`); + } + + const dispositivo = this.dispositivoRepository.create({ + tenantId, + unidadId: dto.unidadId, + tipoUnidad: dto.tipoUnidad || TipoUnidadGps.TRACTORA, + externalDeviceId: dto.externalDeviceId, + plataforma: dto.plataforma || PlataformaGps.TRACCAR, + imei: dto.imei, + numeroSerie: dto.numeroSerie, + telefono: dto.telefono, + modelo: dto.modelo, + fabricante: dto.fabricante, + intervaloPosicionSegundos: dto.intervaloPosicionSegundos || 30, + metadata: dto.metadata || {}, + createdBy: dto.createdBy, + activo: true, + }); + + return this.dispositivoRepository.save(dispositivo); + } + + /** + * Find device by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.dispositivoRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Find device by external ID + */ + async findByExternalId(tenantId: string, externalDeviceId: string): Promise { + return this.dispositivoRepository.findOne({ + where: { tenantId, externalDeviceId }, + }); + } + + /** + * Find device by unit ID + */ + async findByUnidadId(tenantId: string, unidadId: string): Promise { + return this.dispositivoRepository.findOne({ + where: { tenantId, unidadId, activo: true }, + }); + } + + /** + * List devices with filters + */ + async findAll( + tenantId: string, + filters: DispositivoGpsFilters = {}, + pagination = { page: 1, limit: 20 } + ) { + const queryBuilder = this.dispositivoRepository.createQueryBuilder('dispositivo') + .where('dispositivo.tenant_id = :tenantId', { tenantId }); + + if (filters.unidadId) { + queryBuilder.andWhere('dispositivo.unidad_id = :unidadId', { unidadId: filters.unidadId }); + } + if (filters.tipoUnidad) { + queryBuilder.andWhere('dispositivo.tipo_unidad = :tipoUnidad', { tipoUnidad: filters.tipoUnidad }); + } + if (filters.plataforma) { + queryBuilder.andWhere('dispositivo.plataforma = :plataforma', { plataforma: filters.plataforma }); + } + if (filters.activo !== undefined) { + queryBuilder.andWhere('dispositivo.activo = :activo', { activo: filters.activo }); + } + if (filters.search) { + queryBuilder.andWhere( + '(dispositivo.external_device_id ILIKE :search OR dispositivo.imei ILIKE :search OR dispositivo.numero_serie ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('dispositivo.created_at', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Update device + */ + async update(tenantId: string, id: string, dto: UpdateDispositivoGpsDto): Promise { + const dispositivo = await this.findById(tenantId, id); + if (!dispositivo) return null; + + // Check external ID uniqueness if changing + if (dto.externalDeviceId && dto.externalDeviceId !== dispositivo.externalDeviceId) { + const existing = await this.findByExternalId(tenantId, dto.externalDeviceId); + if (existing) { + throw new Error(`Dispositivo con ID externo ${dto.externalDeviceId} ya existe`); + } + } + + Object.assign(dispositivo, dto); + return this.dispositivoRepository.save(dispositivo); + } + + /** + * Update last position (called when new position is received) + */ + async updateUltimaPosicion( + tenantId: string, + id: string, + posicion: UltimaPosicion + ): Promise { + const dispositivo = await this.findById(tenantId, id); + if (!dispositivo) return null; + + dispositivo.ultimaPosicionLat = posicion.latitud; + dispositivo.ultimaPosicionLng = posicion.longitud; + dispositivo.ultimaPosicionAt = posicion.timestamp; + + return this.dispositivoRepository.save(dispositivo); + } + + /** + * Deactivate device + */ + async deactivate(tenantId: string, id: string): Promise { + const dispositivo = await this.findById(tenantId, id); + if (!dispositivo) return false; + + dispositivo.activo = false; + await this.dispositivoRepository.save(dispositivo); + return true; + } + + /** + * Get devices with stale positions (no update in X minutes) + */ + async findDispositivosInactivos(tenantId: string, umbralMinutos: number = 10): Promise { + const threshold = new Date(Date.now() - umbralMinutos * 60 * 1000); + + return this.dispositivoRepository + .createQueryBuilder('dispositivo') + .where('dispositivo.tenant_id = :tenantId', { tenantId }) + .andWhere('dispositivo.activo = :activo', { activo: true }) + .andWhere('(dispositivo.ultima_posicion_at IS NULL OR dispositivo.ultima_posicion_at < :threshold)', { threshold }) + .getMany(); + } + + /** + * Get all active devices with last position + */ + async findActivosConPosicion(tenantId: string): Promise { + return this.dispositivoRepository.find({ + where: { tenantId, activo: true }, + order: { ultimaPosicionAt: 'DESC' }, + }); + } + + /** + * Get device statistics + */ + async getEstadisticas(tenantId: string): Promise<{ + total: number; + activos: number; + porPlataforma: Record; + porTipoUnidad: Record; + enLinea: number; + fueraDeLinea: number; + }> { + const umbralMinutos = 10; + const threshold = new Date(Date.now() - umbralMinutos * 60 * 1000); + + const [total, activos, plataformaCounts, tipoUnidadCounts, enLinea] = await Promise.all([ + this.dispositivoRepository.count({ where: { tenantId } }), + this.dispositivoRepository.count({ where: { tenantId, activo: true } }), + this.dispositivoRepository + .createQueryBuilder('dispositivo') + .select('dispositivo.plataforma', 'plataforma') + .addSelect('COUNT(*)', 'count') + .where('dispositivo.tenant_id = :tenantId', { tenantId }) + .groupBy('dispositivo.plataforma') + .getRawMany(), + this.dispositivoRepository + .createQueryBuilder('dispositivo') + .select('dispositivo.tipo_unidad', 'tipoUnidad') + .addSelect('COUNT(*)', 'count') + .where('dispositivo.tenant_id = :tenantId', { tenantId }) + .groupBy('dispositivo.tipo_unidad') + .getRawMany(), + this.dispositivoRepository + .createQueryBuilder('dispositivo') + .where('dispositivo.tenant_id = :tenantId', { tenantId }) + .andWhere('dispositivo.activo = :activo', { activo: true }) + .andWhere('dispositivo.ultima_posicion_at >= :threshold', { threshold }) + .getCount(), + ]); + + const porPlataforma: Record = { + [PlataformaGps.TRACCAR]: 0, + [PlataformaGps.WIALON]: 0, + [PlataformaGps.SAMSARA]: 0, + [PlataformaGps.GEOTAB]: 0, + [PlataformaGps.MANUAL]: 0, + }; + + const porTipoUnidad: Record = { + [TipoUnidadGps.TRACTORA]: 0, + [TipoUnidadGps.REMOLQUE]: 0, + [TipoUnidadGps.CAJA]: 0, + [TipoUnidadGps.EQUIPO]: 0, + [TipoUnidadGps.OPERADOR]: 0, + }; + + for (const row of plataformaCounts) { + if (row.plataforma) { + porPlataforma[row.plataforma as PlataformaGps] = parseInt(row.count, 10); + } + } + + for (const row of tipoUnidadCounts) { + if (row.tipoUnidad) { + porTipoUnidad[row.tipoUnidad as TipoUnidadGps] = parseInt(row.count, 10); + } + } + + return { + total, + activos, + porPlataforma, + porTipoUnidad, + enLinea, + fueraDeLinea: activos - enLinea, + }; + } +} diff --git a/src/modules/gps/services/index.ts b/src/modules/gps/services/index.ts new file mode 100644 index 0000000..a52ba2b --- /dev/null +++ b/src/modules/gps/services/index.ts @@ -0,0 +1,28 @@ +/** + * GPS Services + * ERP Transportistas + * Module: MAI-006 Tracking + */ + +export { + DispositivoGpsService, + CreateDispositivoGpsDto, + UpdateDispositivoGpsDto, + DispositivoGpsFilters, + UltimaPosicion, +} from './dispositivo-gps.service'; + +export { + PosicionGpsService, + CreatePosicionDto, + PosicionFilters, + PuntoPosicion, + ResumenTrack, +} from './posicion-gps.service'; + +export { + SegmentoRutaService, + CreateSegmentoRutaDto, + SegmentoRutaFilters, + RutaCalculada, +} from './segmento-ruta.service'; diff --git a/src/modules/gps/services/posicion-gps.service.ts b/src/modules/gps/services/posicion-gps.service.ts new file mode 100644 index 0000000..b3f4aa5 --- /dev/null +++ b/src/modules/gps/services/posicion-gps.service.ts @@ -0,0 +1,389 @@ +/** + * PosicionGps Service + * ERP Transportistas + * + * Business logic for GPS position tracking and history. + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { Repository, DataSource } from 'typeorm'; +import { PosicionGps } from '../entities/posicion-gps.entity'; +import { DispositivoGps } from '../entities/dispositivo-gps.entity'; + +// DTOs +export interface CreatePosicionDto { + dispositivoId: string; + unidadId: string; + latitud: number; + longitud: number; + altitud?: number; + velocidad?: number; + rumbo?: number; + precision?: number; + hdop?: number; + atributos?: Record; + tiempoDispositivo: Date; + tiempoFix?: Date; + esValido?: boolean; +} + +export interface PosicionFilters { + dispositivoId?: string; + unidadId?: string; + tiempoInicio?: Date; + tiempoFin?: Date; + velocidadMinima?: number; + velocidadMaxima?: number; + esValido?: boolean; +} + +export interface PuntoPosicion { + latitud: number; + longitud: number; + timestamp: Date; + velocidad?: number; +} + +export interface ResumenTrack { + totalPuntos: number; + distanciaTotalKm: number; + velocidadPromedio: number; + velocidadMaxima: number; + duracionMinutos: number; + tiempoInicio: Date; + tiempoFin: Date; +} + +export class PosicionGpsService { + private posicionRepository: Repository; + private dispositivoRepository: Repository; + + constructor(dataSource: DataSource) { + this.posicionRepository = dataSource.getRepository(PosicionGps); + this.dispositivoRepository = dataSource.getRepository(DispositivoGps); + } + + /** + * Record a new GPS position + */ + async create(tenantId: string, dto: CreatePosicionDto): Promise { + // Validate device exists and belongs to tenant + const dispositivo = await this.dispositivoRepository.findOne({ + where: { id: dto.dispositivoId, tenantId }, + }); + + if (!dispositivo) { + throw new Error(`Dispositivo ${dto.dispositivoId} no encontrado`); + } + + const posicion = this.posicionRepository.create({ + tenantId, + dispositivoId: dto.dispositivoId, + unidadId: dto.unidadId, + latitud: dto.latitud, + longitud: dto.longitud, + altitud: dto.altitud, + velocidad: dto.velocidad, + rumbo: dto.rumbo, + precision: dto.precision, + hdop: dto.hdop, + atributos: dto.atributos || {}, + tiempoDispositivo: dto.tiempoDispositivo, + tiempoFix: dto.tiempoFix, + esValido: dto.esValido !== false, + }); + + const savedPosicion = await this.posicionRepository.save(posicion); + + // Update device last position (fire and forget) + this.dispositivoRepository.update( + { id: dto.dispositivoId }, + { + ultimaPosicionLat: dto.latitud, + ultimaPosicionLng: dto.longitud, + ultimaPosicionAt: dto.tiempoDispositivo, + } + ); + + return savedPosicion; + } + + /** + * Record multiple positions in batch + */ + async createBatch(tenantId: string, posiciones: CreatePosicionDto[]): Promise { + if (posiciones.length === 0) return 0; + + const entities = posiciones.map(dto => this.posicionRepository.create({ + tenantId, + dispositivoId: dto.dispositivoId, + unidadId: dto.unidadId, + latitud: dto.latitud, + longitud: dto.longitud, + altitud: dto.altitud, + velocidad: dto.velocidad, + rumbo: dto.rumbo, + precision: dto.precision, + hdop: dto.hdop, + atributos: dto.atributos || {}, + tiempoDispositivo: dto.tiempoDispositivo, + tiempoFix: dto.tiempoFix, + esValido: dto.esValido !== false, + })); + + const result = await this.posicionRepository.insert(entities); + return result.identifiers.length; + } + + /** + * Get position by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.posicionRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Get position history with filters + */ + async findAll( + tenantId: string, + filters: PosicionFilters = {}, + pagination = { page: 1, limit: 100 } + ) { + const queryBuilder = this.posicionRepository.createQueryBuilder('pos') + .where('pos.tenant_id = :tenantId', { tenantId }); + + if (filters.dispositivoId) { + queryBuilder.andWhere('pos.dispositivo_id = :dispositivoId', { dispositivoId: filters.dispositivoId }); + } + if (filters.unidadId) { + queryBuilder.andWhere('pos.unidad_id = :unidadId', { unidadId: filters.unidadId }); + } + if (filters.tiempoInicio) { + queryBuilder.andWhere('pos.tiempo_dispositivo >= :tiempoInicio', { tiempoInicio: filters.tiempoInicio }); + } + if (filters.tiempoFin) { + queryBuilder.andWhere('pos.tiempo_dispositivo <= :tiempoFin', { tiempoFin: filters.tiempoFin }); + } + if (filters.velocidadMinima !== undefined) { + queryBuilder.andWhere('pos.velocidad >= :velocidadMinima', { velocidadMinima: filters.velocidadMinima }); + } + if (filters.velocidadMaxima !== undefined) { + queryBuilder.andWhere('pos.velocidad <= :velocidadMaxima', { velocidadMaxima: filters.velocidadMaxima }); + } + if (filters.esValido !== undefined) { + queryBuilder.andWhere('pos.es_valido = :esValido', { esValido: filters.esValido }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('pos.tiempo_dispositivo', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Get last position for a device + */ + async getUltimaPosicion(tenantId: string, dispositivoId: string): Promise { + return this.posicionRepository.findOne({ + where: { tenantId, dispositivoId }, + order: { tiempoDispositivo: 'DESC' }, + }); + } + + /** + * Get last positions for multiple devices + */ + async getUltimasPosiciones(tenantId: string, dispositivoIds: string[]): Promise { + if (dispositivoIds.length === 0) return []; + + const subQuery = this.posicionRepository + .createQueryBuilder('sub') + .select('sub.dispositivo_id', 'dispositivo_id') + .addSelect('MAX(sub.tiempo_dispositivo)', 'max_time') + .where('sub.tenant_id = :tenantId', { tenantId }) + .andWhere('sub.dispositivo_id IN (:...dispositivoIds)', { dispositivoIds }) + .groupBy('sub.dispositivo_id'); + + return this.posicionRepository + .createQueryBuilder('pos') + .innerJoin( + `(${subQuery.getQuery()})`, + 'latest', + 'pos.dispositivo_id = latest.dispositivo_id AND pos.tiempo_dispositivo = latest.max_time' + ) + .setParameters(subQuery.getParameters()) + .where('pos.tenant_id = :tenantId', { tenantId }) + .getMany(); + } + + /** + * Get track for a device in a time range + */ + async getTrack( + tenantId: string, + dispositivoId: string, + tiempoInicio: Date, + tiempoFin: Date, + simplificar: boolean = false + ): Promise { + const queryBuilder = this.posicionRepository + .createQueryBuilder('pos') + .select(['pos.latitud', 'pos.longitud', 'pos.tiempoDispositivo', 'pos.velocidad']) + .where('pos.tenant_id = :tenantId', { tenantId }) + .andWhere('pos.dispositivo_id = :dispositivoId', { dispositivoId }) + .andWhere('pos.tiempo_dispositivo BETWEEN :tiempoInicio AND :tiempoFin', { tiempoInicio, tiempoFin }) + .andWhere('pos.es_valido = :esValido', { esValido: true }) + .orderBy('pos.tiempo_dispositivo', 'ASC'); + + const posiciones = await queryBuilder.getMany(); + + const puntos: PuntoPosicion[] = posiciones.map(p => ({ + latitud: Number(p.latitud), + longitud: Number(p.longitud), + timestamp: p.tiempoDispositivo, + velocidad: p.velocidad ? Number(p.velocidad) : undefined, + })); + + if (simplificar && puntos.length > 500) { + return this.simplificarTrack(puntos, 500); + } + + return puntos; + } + + /** + * Get track summary statistics + */ + async getResumenTrack( + tenantId: string, + dispositivoId: string, + tiempoInicio: Date, + tiempoFin: Date + ): Promise { + const result = await this.posicionRepository + .createQueryBuilder('pos') + .select('COUNT(*)', 'totalPuntos') + .addSelect('AVG(pos.velocidad)', 'velocidadPromedio') + .addSelect('MAX(pos.velocidad)', 'velocidadMaxima') + .addSelect('MIN(pos.tiempo_dispositivo)', 'tiempoInicio') + .addSelect('MAX(pos.tiempo_dispositivo)', 'tiempoFin') + .where('pos.tenant_id = :tenantId', { tenantId }) + .andWhere('pos.dispositivo_id = :dispositivoId', { dispositivoId }) + .andWhere('pos.tiempo_dispositivo BETWEEN :tiempoInicio AND :tiempoFin', { tiempoInicio, tiempoFin }) + .andWhere('pos.es_valido = :esValido', { esValido: true }) + .getRawOne(); + + if (!result || result.totalPuntos === '0') return null; + + // Calculate distance using positions + const track = await this.getTrack(tenantId, dispositivoId, tiempoInicio, tiempoFin, false); + const distanciaTotalKm = this.calcularDistanciaTrack(track); + + const actualTiempoInicio = new Date(result.tiempoInicio); + const actualTiempoFin = new Date(result.tiempoFin); + const duracionMinutos = (actualTiempoFin.getTime() - actualTiempoInicio.getTime()) / (1000 * 60); + + return { + totalPuntos: parseInt(result.totalPuntos, 10), + distanciaTotalKm, + velocidadPromedio: parseFloat(result.velocidadPromedio) || 0, + velocidadMaxima: parseFloat(result.velocidadMaxima) || 0, + duracionMinutos, + tiempoInicio: actualTiempoInicio, + tiempoFin: actualTiempoFin, + }; + } + + /** + * Calculate distance between two coordinates using Haversine formula + */ + calcularDistancia(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; // Earth radius in km + const dLat = this.toRad(lat2 - lat1); + const dLng = this.toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * + Math.sin(dLng / 2) * Math.sin(dLng / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + /** + * Calculate total distance for a track + */ + calcularDistanciaTrack(puntos: PuntoPosicion[]): number { + if (puntos.length < 2) return 0; + + let distanciaTotal = 0; + for (let i = 1; i < puntos.length; i++) { + distanciaTotal += this.calcularDistancia( + puntos[i - 1].latitud, + puntos[i - 1].longitud, + puntos[i].latitud, + puntos[i].longitud + ); + } + return Math.round(distanciaTotal * 1000) / 1000; // 3 decimal places + } + + /** + * Simplify track using nth-point sampling + */ + private simplificarTrack(puntos: PuntoPosicion[], maxPuntos: number): PuntoPosicion[] { + if (puntos.length <= maxPuntos) return puntos; + + const step = Math.ceil(puntos.length / maxPuntos); + const simplified: PuntoPosicion[] = [puntos[0]]; + + for (let i = step; i < puntos.length - 1; i += step) { + simplified.push(puntos[i]); + } + + // Always include last point + simplified.push(puntos[puntos.length - 1]); + return simplified; + } + + private toRad(degrees: number): number { + return degrees * (Math.PI / 180); + } + + /** + * Delete old positions (for data retention) + */ + async eliminarPosicionesAntiguas(tenantId: string, antesDe: Date): Promise { + const result = await this.posicionRepository + .createQueryBuilder() + .delete() + .where('tenant_id = :tenantId', { tenantId }) + .andWhere('tiempo_dispositivo < :antesDe', { antesDe }) + .execute(); + + return result.affected || 0; + } + + /** + * Get position count for a device + */ + async getContadorPosiciones(tenantId: string, dispositivoId: string): Promise { + return this.posicionRepository.count({ + where: { tenantId, dispositivoId }, + }); + } +} diff --git a/src/modules/gps/services/segmento-ruta.service.ts b/src/modules/gps/services/segmento-ruta.service.ts new file mode 100644 index 0000000..2b1716e --- /dev/null +++ b/src/modules/gps/services/segmento-ruta.service.ts @@ -0,0 +1,429 @@ +/** + * SegmentoRuta Service + * ERP Transportistas + * + * Business logic for route segment calculation and billing. + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { Repository, DataSource } from 'typeorm'; +import { SegmentoRuta, TipoSegmento } from '../entities/segmento-ruta.entity'; +import { PosicionGps } from '../entities/posicion-gps.entity'; + +// DTOs +export interface CreateSegmentoRutaDto { + viajeId?: string; + unidadId: string; + dispositivoId?: string; + posicionInicioId?: string; + posicionFinId?: string; + latInicio: number; + lngInicio: number; + latFin: number; + lngFin: number; + distanciaKm: number; + distanciaCrudaKm?: number; + tiempoInicio: Date; + tiempoFin: Date; + duracionMinutos?: number; + tipoSegmento?: TipoSegmento; + esValido?: boolean; + notasValidacion?: string; + polylineEncoded?: string; + metadata?: Record; +} + +export interface SegmentoRutaFilters { + viajeId?: string; + unidadId?: string; + dispositivoId?: string; + tipoSegmento?: TipoSegmento; + esValido?: boolean; + fechaInicio?: Date; + fechaFin?: Date; +} + +export interface RutaCalculada { + segmentos: SegmentoRuta[]; + distanciaTotalKm: number; + duracionTotalMinutos: number; + resumen: { + haciaDestinoKm: number; + enDestinoKm: number; + retornoKm: number; + }; +} + +export class SegmentoRutaService { + private segmentoRepository: Repository; + private posicionRepository: Repository; + + constructor(dataSource: DataSource) { + this.segmentoRepository = dataSource.getRepository(SegmentoRuta); + this.posicionRepository = dataSource.getRepository(PosicionGps); + } + + /** + * Create a route segment + */ + async create(tenantId: string, dto: CreateSegmentoRutaDto): Promise { + const duracionMinutos = dto.duracionMinutos ?? + (dto.tiempoFin.getTime() - dto.tiempoInicio.getTime()) / (1000 * 60); + + const segmento = this.segmentoRepository.create({ + tenantId, + viajeId: dto.viajeId, + unidadId: dto.unidadId, + dispositivoId: dto.dispositivoId, + posicionInicioId: dto.posicionInicioId, + posicionFinId: dto.posicionFinId, + latInicio: dto.latInicio, + lngInicio: dto.lngInicio, + latFin: dto.latFin, + lngFin: dto.lngFin, + distanciaKm: dto.distanciaKm, + distanciaCrudaKm: dto.distanciaCrudaKm, + tiempoInicio: dto.tiempoInicio, + tiempoFin: dto.tiempoFin, + duracionMinutos, + tipoSegmento: dto.tipoSegmento || TipoSegmento.OTRO, + esValido: dto.esValido !== false, + notasValidacion: dto.notasValidacion, + polylineEncoded: dto.polylineEncoded, + metadata: dto.metadata || {}, + }); + + return this.segmentoRepository.save(segmento); + } + + /** + * Find segment by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.segmentoRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * List segments with filters + */ + async findAll( + tenantId: string, + filters: SegmentoRutaFilters = {}, + pagination = { page: 1, limit: 20 } + ) { + const queryBuilder = this.segmentoRepository.createQueryBuilder('segmento') + .where('segmento.tenant_id = :tenantId', { tenantId }); + + if (filters.viajeId) { + queryBuilder.andWhere('segmento.viaje_id = :viajeId', { viajeId: filters.viajeId }); + } + if (filters.unidadId) { + queryBuilder.andWhere('segmento.unidad_id = :unidadId', { unidadId: filters.unidadId }); + } + if (filters.dispositivoId) { + queryBuilder.andWhere('segmento.dispositivo_id = :dispositivoId', { dispositivoId: filters.dispositivoId }); + } + if (filters.tipoSegmento) { + queryBuilder.andWhere('segmento.tipo_segmento = :tipoSegmento', { tipoSegmento: filters.tipoSegmento }); + } + if (filters.esValido !== undefined) { + queryBuilder.andWhere('segmento.es_valido = :esValido', { esValido: filters.esValido }); + } + if (filters.fechaInicio) { + queryBuilder.andWhere('segmento.tiempo_inicio >= :fechaInicio', { fechaInicio: filters.fechaInicio }); + } + if (filters.fechaFin) { + queryBuilder.andWhere('segmento.tiempo_fin <= :fechaFin', { fechaFin: filters.fechaFin }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('segmento.tiempo_inicio', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Get segments for a viaje + */ + async findByViaje(tenantId: string, viajeId: string): Promise { + return this.segmentoRepository.find({ + where: { tenantId, viajeId }, + order: { tiempoInicio: 'ASC' }, + }); + } + + /** + * Calculate route from positions + */ + async calcularRutaDesdePosiciones( + tenantId: string, + dispositivoId: string, + tiempoInicio: Date, + tiempoFin: Date, + viajeId?: string, + tipoSegmento?: TipoSegmento + ): Promise { + // Get positions in time range + const posiciones = await this.posicionRepository.find({ + where: { + tenantId, + dispositivoId, + esValido: true, + }, + order: { tiempoDispositivo: 'ASC' }, + }); + + // Filter by time range + const posicionesFiltradas = posiciones.filter( + p => p.tiempoDispositivo >= tiempoInicio && p.tiempoDispositivo <= tiempoFin + ); + + if (posicionesFiltradas.length < 2) { + return null; + } + + const primeraPosicion = posicionesFiltradas[0]; + const ultimaPosicion = posicionesFiltradas[posicionesFiltradas.length - 1]; + + // Calculate total distance + let distanciaTotal = 0; + for (let i = 1; i < posicionesFiltradas.length; i++) { + distanciaTotal += this.calcularDistancia( + Number(posicionesFiltradas[i - 1].latitud), + Number(posicionesFiltradas[i - 1].longitud), + Number(posicionesFiltradas[i].latitud), + Number(posicionesFiltradas[i].longitud) + ); + } + + // Create encoded polyline + const polylineEncoded = this.encodePolyline( + posicionesFiltradas.map(p => ({ + lat: Number(p.latitud), + lng: Number(p.longitud), + })) + ); + + return this.create(tenantId, { + viajeId, + unidadId: primeraPosicion.unidadId, + dispositivoId, + posicionInicioId: primeraPosicion.id, + posicionFinId: ultimaPosicion.id, + latInicio: Number(primeraPosicion.latitud), + lngInicio: Number(primeraPosicion.longitud), + latFin: Number(ultimaPosicion.latitud), + lngFin: Number(ultimaPosicion.longitud), + distanciaKm: Math.round(distanciaTotal * 1000) / 1000, + distanciaCrudaKm: distanciaTotal, + tiempoInicio: primeraPosicion.tiempoDispositivo, + tiempoFin: ultimaPosicion.tiempoDispositivo, + tipoSegmento: tipoSegmento || TipoSegmento.OTRO, + polylineEncoded, + metadata: { + cantidadPosiciones: posicionesFiltradas.length, + }, + }); + } + + /** + * Get calculated route summary for a viaje + */ + async getResumenRutaViaje(tenantId: string, viajeId: string): Promise { + const segmentos = await this.findByViaje(tenantId, viajeId); + + if (segmentos.length === 0) { + return null; + } + + let distanciaTotalKm = 0; + let duracionTotalMinutos = 0; + let haciaDestinoKm = 0; + let enDestinoKm = 0; + let retornoKm = 0; + + for (const segmento of segmentos) { + if (segmento.esValido) { + distanciaTotalKm += Number(segmento.distanciaKm); + duracionTotalMinutos += Number(segmento.duracionMinutos || 0); + + switch (segmento.tipoSegmento) { + case TipoSegmento.HACIA_DESTINO: + haciaDestinoKm += Number(segmento.distanciaKm); + break; + case TipoSegmento.EN_DESTINO: + enDestinoKm += Number(segmento.distanciaKm); + break; + case TipoSegmento.RETORNO: + retornoKm += Number(segmento.distanciaKm); + break; + } + } + } + + return { + segmentos, + distanciaTotalKm: Math.round(distanciaTotalKm * 1000) / 1000, + duracionTotalMinutos: Math.round(duracionTotalMinutos * 100) / 100, + resumen: { + haciaDestinoKm: Math.round(haciaDestinoKm * 1000) / 1000, + enDestinoKm: Math.round(enDestinoKm * 1000) / 1000, + retornoKm: Math.round(retornoKm * 1000) / 1000, + }, + }; + } + + /** + * Update segment validity + */ + async updateValidez( + tenantId: string, + id: string, + esValido: boolean, + notas?: string + ): Promise { + const segmento = await this.findById(tenantId, id); + if (!segmento) return null; + + segmento.esValido = esValido; + if (notas) { + segmento.notasValidacion = notas; + } + + return this.segmentoRepository.save(segmento); + } + + /** + * Delete segment + */ + async delete(tenantId: string, id: string): Promise { + const result = await this.segmentoRepository.delete({ id, tenantId }); + return (result.affected || 0) > 0; + } + + /** + * Get distance statistics for a unit + */ + async getEstadisticasUnidad( + tenantId: string, + unidadId: string, + fechaInicio?: Date, + fechaFin?: Date + ): Promise<{ + distanciaTotalKm: number; + totalSegmentos: number; + distanciaPromedioPorSegmento: number; + porTipoSegmento: Record; + }> { + const queryBuilder = this.segmentoRepository.createQueryBuilder('segmento') + .where('segmento.tenant_id = :tenantId', { tenantId }) + .andWhere('segmento.unidad_id = :unidadId', { unidadId }) + .andWhere('segmento.es_valido = :esValido', { esValido: true }); + + if (fechaInicio) { + queryBuilder.andWhere('segmento.tiempo_inicio >= :fechaInicio', { fechaInicio }); + } + if (fechaFin) { + queryBuilder.andWhere('segmento.tiempo_fin <= :fechaFin', { fechaFin }); + } + + const segmentos = await queryBuilder.getMany(); + + const porTipoSegmento: Record = { + [TipoSegmento.HACIA_DESTINO]: 0, + [TipoSegmento.EN_DESTINO]: 0, + [TipoSegmento.RETORNO]: 0, + [TipoSegmento.ENTRE_PARADAS]: 0, + [TipoSegmento.OTRO]: 0, + }; + + let distanciaTotalKm = 0; + for (const segmento of segmentos) { + const distancia = Number(segmento.distanciaKm); + distanciaTotalKm += distancia; + porTipoSegmento[segmento.tipoSegmento] += distancia; + } + + return { + distanciaTotalKm: Math.round(distanciaTotalKm * 1000) / 1000, + totalSegmentos: segmentos.length, + distanciaPromedioPorSegmento: segmentos.length > 0 + ? Math.round((distanciaTotalKm / segmentos.length) * 1000) / 1000 + : 0, + porTipoSegmento, + }; + } + + /** + * Calculate distance using Haversine formula + */ + private calcularDistancia(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; // Earth radius in km + const dLat = this.toRad(lat2 - lat1); + const dLng = this.toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * + Math.sin(dLng / 2) * Math.sin(dLng / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + private toRad(degrees: number): number { + return degrees * (Math.PI / 180); + } + + /** + * Encode polyline using Google's algorithm + */ + private encodePolyline(points: { lat: number; lng: number }[]): string { + if (points.length === 0) return ''; + + let encoded = ''; + let prevLat = 0; + let prevLng = 0; + + for (const point of points) { + const lat = Math.round(point.lat * 1e5); + const lng = Math.round(point.lng * 1e5); + + encoded += this.encodeNumber(lat - prevLat); + encoded += this.encodeNumber(lng - prevLng); + + prevLat = lat; + prevLng = lng; + } + + return encoded; + } + + private encodeNumber(num: number): string { + let sgnNum = num << 1; + if (num < 0) { + sgnNum = ~sgnNum; + } + + let encoded = ''; + while (sgnNum >= 0x20) { + encoded += String.fromCharCode((0x20 | (sgnNum & 0x1f)) + 63); + sgnNum >>= 5; + } + encoded += String.fromCharCode(sgnNum + 63); + + return encoded; + } +} diff --git a/src/modules/offline/controllers/index.ts b/src/modules/offline/controllers/index.ts new file mode 100644 index 0000000..6af9e6b --- /dev/null +++ b/src/modules/offline/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * Offline Module Controllers + * ERP Transportistas + * Sprint: S4 - TASK-007 + */ + +export { createSyncController } from './sync.controller'; diff --git a/src/modules/offline/controllers/sync.controller.ts b/src/modules/offline/controllers/sync.controller.ts new file mode 100644 index 0000000..c05af3a --- /dev/null +++ b/src/modules/offline/controllers/sync.controller.ts @@ -0,0 +1,259 @@ +/** + * Sync Controller + * ERP Transportistas + * + * REST API endpoints for offline sync operations. + * Sprint: S4 - TASK-007 + * Module: Offline Sync + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { SyncService } from '../services/sync.service'; +import { TipoOperacionOffline, EstadoSincronizacion, PrioridadSync } from '../entities/offline-queue.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; + dispositivoId?: string; +} + +export function createSyncController(dataSource: DataSource): Router { + const router = Router(); + const service = new SyncService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID es requerido' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + req.dispositivoId = req.headers['x-dispositivo-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Enqueue a single offline operation + * POST /api/offline/encolar + */ + router.post('/encolar', async (req: TenantRequest, res: Response) => { + try { + const { + dispositivoId, + usuarioId, + unidadId, + viajeId, + tipoOperacion, + prioridad, + payload, + endpointDestino, + metodoHttp, + creadoOfflineEn, + clienteId, + } = req.body; + + if (!tipoOperacion || !payload || !endpointDestino) { + return res.status(400).json({ + error: 'tipoOperacion, payload y endpointDestino son requeridos', + }); + } + + const operacion = await service.encolar(req.tenantId!, { + dispositivoId: dispositivoId || req.dispositivoId, + usuarioId: usuarioId || req.userId, + unidadId, + viajeId, + tipoOperacion, + prioridad, + payload, + endpointDestino, + metodoHttp, + creadoOfflineEn: creadoOfflineEn ? new Date(creadoOfflineEn) : new Date(), + clienteId, + }); + + res.status(201).json({ + success: true, + data: operacion, + }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Enqueue multiple operations (batch) + * POST /api/offline/encolar-lote + */ + router.post('/encolar-lote', async (req: TenantRequest, res: Response) => { + try { + const { operaciones } = req.body; + + if (!Array.isArray(operaciones) || operaciones.length === 0) { + return res.status(400).json({ + error: 'operaciones debe ser un array no vacío', + }); + } + + if (operaciones.length > 100) { + return res.status(400).json({ + error: 'Máximo 100 operaciones por lote', + }); + } + + const resultados = await service.encolarLote( + req.tenantId!, + operaciones.map((op: any) => ({ + ...op, + dispositivoId: op.dispositivoId || req.dispositivoId, + usuarioId: op.usuarioId || req.userId, + creadoOfflineEn: op.creadoOfflineEn ? new Date(op.creadoOfflineEn) : new Date(), + })) + ); + + res.status(201).json({ + success: true, + total: resultados.length, + data: resultados, + }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Get pending operations + * GET /api/offline/pendientes + */ + router.get('/pendientes', async (req: TenantRequest, res: Response) => { + try { + const limite = parseInt(req.query.limite as string, 10) || 50; + const operaciones = await service.obtenerPendientes(req.tenantId!, Math.min(limite, 100)); + + res.json({ + success: true, + total: operaciones.length, + data: operaciones, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get operations for a device + * GET /api/offline/dispositivo/:dispositivoId + */ + router.get('/dispositivo/:dispositivoId', async (req: TenantRequest, res: Response) => { + try { + const { dispositivoId } = req.params; + const { estado, tipoOperacion, viajeId } = req.query; + + const operaciones = await service.obtenerPorDispositivo(req.tenantId!, dispositivoId, { + estado: estado as EstadoSincronizacion, + tipoOperacion: tipoOperacion as TipoOperacionOffline, + viajeId: viajeId as string, + }); + + res.json({ + success: true, + total: operaciones.length, + data: operaciones, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Process sync batch + * POST /api/offline/sincronizar + */ + router.post('/sincronizar', async (req: TenantRequest, res: Response) => { + try { + const limite = parseInt(req.body.limite as string, 10) || 20; + const resultado = await service.procesarLote(req.tenantId!, Math.min(limite, 50)); + + res.json({ + success: true, + ...resultado, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get queue statistics + * GET /api/offline/estadisticas + */ + router.get('/estadisticas', async (req: TenantRequest, res: Response) => { + try { + const estadisticas = await service.getEstadisticas(req.tenantId!); + + res.json({ + success: true, + data: estadisticas, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Retry failed operations + * POST /api/offline/reintentar + */ + router.post('/reintentar', async (req: TenantRequest, res: Response) => { + try { + const reintentados = await service.reintentarFallidos(req.tenantId!); + + res.json({ + success: true, + reintentados, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Clean up old completed operations + * DELETE /api/offline/limpiar + */ + router.delete('/limpiar', async (req: TenantRequest, res: Response) => { + try { + const diasAntiguedad = parseInt(req.query.dias as string, 10) || 7; + const eliminados = await service.limpiarCompletados(req.tenantId!, diasAntiguedad); + + res.json({ + success: true, + eliminados, + diasAntiguedad, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get enum values for client reference + * GET /api/offline/tipos + */ + router.get('/tipos', (_req: TenantRequest, res: Response) => { + res.json({ + tiposOperacion: Object.values(TipoOperacionOffline), + estadosSincronizacion: Object.values(EstadoSincronizacion), + prioridades: Object.values(PrioridadSync), + }); + }); + + return router; +} diff --git a/src/modules/offline/entities/index.ts b/src/modules/offline/entities/index.ts new file mode 100644 index 0000000..e18a488 --- /dev/null +++ b/src/modules/offline/entities/index.ts @@ -0,0 +1,12 @@ +/** + * Offline Module Entities + * ERP Transportistas + * Sprint: S4 - TASK-007 + */ + +export { + OfflineQueue, + TipoOperacionOffline, + EstadoSincronizacion, + PrioridadSync, +} from './offline-queue.entity'; diff --git a/src/modules/offline/entities/offline-queue.entity.ts b/src/modules/offline/entities/offline-queue.entity.ts new file mode 100644 index 0000000..44166c7 --- /dev/null +++ b/src/modules/offline/entities/offline-queue.entity.ts @@ -0,0 +1,172 @@ +/** + * OfflineQueue Entity + * ERP Transportistas + * + * Queue for offline operations pending synchronization. + * Sprint: S4 - TASK-007 + * Module: Offline Sync + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +/** + * Operation types that can be queued offline + */ +export enum TipoOperacionOffline { + // GPS Operations + GPS_POSICION = 'GPS_POSICION', + GPS_EVENTO = 'GPS_EVENTO', + + // Dispatch Operations + VIAJE_ESTADO = 'VIAJE_ESTADO', + VIAJE_EVENTO = 'VIAJE_EVENTO', + CHECKIN = 'CHECKIN', + CHECKOUT = 'CHECKOUT', + + // POD Operations + POD_FOTO = 'POD_FOTO', + POD_FIRMA = 'POD_FIRMA', + POD_DOCUMENTO = 'POD_DOCUMENTO', + + // Checklist Operations + CHECKLIST_ITEM = 'CHECKLIST_ITEM', + CHECKLIST_COMPLETADO = 'CHECKLIST_COMPLETADO', + + // Generic + CUSTOM = 'CUSTOM', +} + +/** + * Sync status for queued operations + */ +export enum EstadoSincronizacion { + PENDIENTE = 'PENDIENTE', + EN_PROCESO = 'EN_PROCESO', + COMPLETADO = 'COMPLETADO', + ERROR = 'ERROR', + CONFLICTO = 'CONFLICTO', + DESCARTADO = 'DESCARTADO', +} + +/** + * Priority levels for sync queue + */ +export enum PrioridadSync { + CRITICA = 1, // GPS positions, safety events + ALTA = 2, // POD, status changes + NORMAL = 3, // Checklist items, notes + BAJA = 4, // Photos, documents +} + +@Entity({ name: 'offline_queue', schema: 'tracking' }) +@Index('idx_offline_queue_tenant', ['tenantId']) +@Index('idx_offline_queue_dispositivo', ['dispositivoId']) +@Index('idx_offline_queue_estado', ['estado']) +@Index('idx_offline_queue_prioridad', ['prioridad', 'creadoOfflineEn']) +export class OfflineQueue { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'dispositivo_id', type: 'uuid', nullable: true }) + dispositivoId?: string; + + @Column({ name: 'usuario_id', type: 'uuid', nullable: true }) + usuarioId?: string; + + @Column({ name: 'unidad_id', type: 'uuid', nullable: true }) + unidadId?: string; + + @Column({ name: 'viaje_id', type: 'uuid', nullable: true }) + viajeId?: string; + + // Operation details + @Column({ + name: 'tipo_operacion', + type: 'enum', + enum: TipoOperacionOffline, + }) + tipoOperacion: TipoOperacionOffline; + + @Column({ + type: 'enum', + enum: EstadoSincronizacion, + default: EstadoSincronizacion.PENDIENTE, + }) + estado: EstadoSincronizacion; + + @Column({ + type: 'enum', + enum: PrioridadSync, + default: PrioridadSync.NORMAL, + }) + prioridad: PrioridadSync; + + // Payload - the actual data to sync + @Column({ type: 'jsonb' }) + payload: Record; + + // Metadata + @Column({ name: 'endpoint_destino', type: 'varchar', length: 255 }) + endpointDestino: string; + + @Column({ name: 'metodo_http', type: 'varchar', length: 10, default: 'POST' }) + metodoHttp: string; + + // Offline timestamps + @Column({ name: 'creado_offline_en', type: 'timestamptz' }) + creadoOfflineEn: Date; + + @Column({ name: 'cliente_id', type: 'varchar', length: 100, nullable: true }) + clienteId?: string; // UUID generated on client for deduplication + + // Sync tracking + @Column({ name: 'intentos_sync', type: 'int', default: 0 }) + intentosSync: number; + + @Column({ name: 'max_intentos', type: 'int', default: 5 }) + maxIntentos: number; + + @Column({ name: 'ultimo_intento_en', type: 'timestamptz', nullable: true }) + ultimoIntentoEn?: Date; + + @Column({ name: 'sincronizado_en', type: 'timestamptz', nullable: true }) + sincronizadoEn?: Date; + + // Error handling + @Column({ name: 'ultimo_error', type: 'text', nullable: true }) + ultimoError?: string; + + @Column({ name: 'historial_errores', type: 'jsonb', default: [] }) + historialErrores: Array<{ timestamp: string; error: string }>; + + // Conflict resolution + @Column({ name: 'version_servidor', type: 'int', nullable: true }) + versionServidor?: number; + + @Column({ name: 'resolucion_conflicto', type: 'varchar', length: 50, nullable: true }) + resolucionConflicto?: 'CLIENT_WINS' | 'SERVER_WINS' | 'MERGE' | 'MANUAL'; + + // Size tracking for bandwidth optimization + @Column({ name: 'tamano_bytes', type: 'int', nullable: true }) + tamanoBytes?: number; + + @Column({ name: 'comprimido', type: 'boolean', default: false }) + comprimido: boolean; + + // Timestamps + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/offline/index.ts b/src/modules/offline/index.ts new file mode 100644 index 0000000..73262dc --- /dev/null +++ b/src/modules/offline/index.ts @@ -0,0 +1,27 @@ +/** + * Offline Module + * ERP Transportistas + * + * Offline queue and synchronization for mobile operations. + * Sprint: S4 - TASK-007 + */ + +// Entities +export { + OfflineQueue, + TipoOperacionOffline, + EstadoSincronizacion, + PrioridadSync, +} from './entities'; + +// Services +export { + SyncService, + CreateOfflineOperationDto, + SyncResultDto, + SyncBatchResult, + FiltrosOfflineQueue, +} from './services'; + +// Controllers +export { createSyncController } from './controllers'; diff --git a/src/modules/offline/offline.routes.ts b/src/modules/offline/offline.routes.ts new file mode 100644 index 0000000..628e9d1 --- /dev/null +++ b/src/modules/offline/offline.routes.ts @@ -0,0 +1,22 @@ +/** + * Offline Module Routes + * ERP Transportistas + * + * Routes for offline queue and synchronization. + * Sprint: S4 - TASK-007 + */ + +import { Router } from 'express'; +import { AppDataSource } from '../../config/typeorm.js'; +import { createSyncController } from './controllers/index.js'; + +const router = Router(); + +// Create controller router with DataSource +const syncRouter = createSyncController(AppDataSource); + +// Mount routes +// /api/offline/* - Offline sync operations +router.use('/', syncRouter); + +export default router; diff --git a/src/modules/offline/services/index.ts b/src/modules/offline/services/index.ts new file mode 100644 index 0000000..8369b9a --- /dev/null +++ b/src/modules/offline/services/index.ts @@ -0,0 +1,13 @@ +/** + * Offline Module Services + * ERP Transportistas + * Sprint: S4 - TASK-007 + */ + +export { + SyncService, + CreateOfflineOperationDto, + SyncResultDto, + SyncBatchResult, + FiltrosOfflineQueue, +} from './sync.service'; diff --git a/src/modules/offline/services/sync.service.ts b/src/modules/offline/services/sync.service.ts new file mode 100644 index 0000000..1632e82 --- /dev/null +++ b/src/modules/offline/services/sync.service.ts @@ -0,0 +1,447 @@ +/** + * Sync Service + * ERP Transportistas + * + * Handles offline queue synchronization with priority and conflict resolution. + * Sprint: S4 - TASK-007 + * Module: Offline Sync + */ + +import { Repository, DataSource, LessThan, In } from 'typeorm'; +import { + OfflineQueue, + TipoOperacionOffline, + EstadoSincronizacion, + PrioridadSync, +} from '../entities/offline-queue.entity'; + +// DTOs +export interface CreateOfflineOperationDto { + dispositivoId?: string; + usuarioId?: string; + unidadId?: string; + viajeId?: string; + tipoOperacion: TipoOperacionOffline; + prioridad?: PrioridadSync; + payload: Record; + endpointDestino: string; + metodoHttp?: string; + creadoOfflineEn: Date; + clienteId?: string; +} + +export interface SyncResultDto { + id: string; + clienteId?: string; + estado: EstadoSincronizacion; + error?: string; + sincronizadoEn?: Date; +} + +export interface SyncBatchResult { + procesados: number; + exitosos: number; + errores: number; + conflictos: number; + resultados: SyncResultDto[]; +} + +export interface FiltrosOfflineQueue { + dispositivoId?: string; + usuarioId?: string; + unidadId?: string; + viajeId?: string; + tipoOperacion?: TipoOperacionOffline; + estado?: EstadoSincronizacion; + prioridad?: PrioridadSync; +} + +/** + * Sync Service - Priority Queue Manager + */ +export class SyncService { + private queueRepository: Repository; + + constructor(private dataSource: DataSource) { + this.queueRepository = dataSource.getRepository(OfflineQueue); + } + + /** + * Enqueue an offline operation + */ + async encolar(tenantId: string, dto: CreateOfflineOperationDto): Promise { + // Check for duplicate by clienteId + if (dto.clienteId) { + const existente = await this.queueRepository.findOne({ + where: { tenantId, clienteId: dto.clienteId }, + }); + if (existente) { + return existente; // Idempotent - return existing + } + } + + const operacion = this.queueRepository.create({ + tenantId, + dispositivoId: dto.dispositivoId, + usuarioId: dto.usuarioId, + unidadId: dto.unidadId, + viajeId: dto.viajeId, + tipoOperacion: dto.tipoOperacion, + prioridad: dto.prioridad || this.determinarPrioridad(dto.tipoOperacion), + payload: dto.payload, + endpointDestino: dto.endpointDestino, + metodoHttp: dto.metodoHttp || 'POST', + creadoOfflineEn: dto.creadoOfflineEn, + clienteId: dto.clienteId, + estado: EstadoSincronizacion.PENDIENTE, + tamanoBytes: JSON.stringify(dto.payload).length, + }); + + return this.queueRepository.save(operacion); + } + + /** + * Enqueue multiple operations (batch) + */ + async encolarLote( + tenantId: string, + operaciones: CreateOfflineOperationDto[] + ): Promise { + const resultados: OfflineQueue[] = []; + + for (const dto of operaciones) { + const operacion = await this.encolar(tenantId, dto); + resultados.push(operacion); + } + + return resultados; + } + + /** + * Get pending operations ordered by priority + */ + async obtenerPendientes( + tenantId: string, + limite: number = 50 + ): Promise { + return this.queueRepository.find({ + where: { + tenantId, + estado: In([EstadoSincronizacion.PENDIENTE, EstadoSincronizacion.ERROR]), + }, + order: { + prioridad: 'ASC', + creadoOfflineEn: 'ASC', + }, + take: limite, + }); + } + + /** + * Get operations for a specific device + */ + async obtenerPorDispositivo( + tenantId: string, + dispositivoId: string, + filtros: FiltrosOfflineQueue = {} + ): Promise { + const where: any = { tenantId, dispositivoId }; + + if (filtros.estado) where.estado = filtros.estado; + if (filtros.tipoOperacion) where.tipoOperacion = filtros.tipoOperacion; + if (filtros.viajeId) where.viajeId = filtros.viajeId; + + return this.queueRepository.find({ + where, + order: { prioridad: 'ASC', creadoOfflineEn: 'ASC' }, + }); + } + + /** + * Mark operation as in progress + */ + async marcarEnProceso(id: string): Promise { + const operacion = await this.queueRepository.findOne({ where: { id } }); + if (!operacion) return null; + + operacion.estado = EstadoSincronizacion.EN_PROCESO; + operacion.ultimoIntentoEn = new Date(); + operacion.intentosSync++; + + return this.queueRepository.save(operacion); + } + + /** + * Mark operation as completed + */ + async marcarCompletado(id: string): Promise { + const operacion = await this.queueRepository.findOne({ where: { id } }); + if (!operacion) return null; + + operacion.estado = EstadoSincronizacion.COMPLETADO; + operacion.sincronizadoEn = new Date(); + + return this.queueRepository.save(operacion); + } + + /** + * Mark operation as error + */ + async marcarError(id: string, error: string): Promise { + const operacion = await this.queueRepository.findOne({ where: { id } }); + if (!operacion) return null; + + operacion.estado = operacion.intentosSync >= operacion.maxIntentos + ? EstadoSincronizacion.DESCARTADO + : EstadoSincronizacion.ERROR; + operacion.ultimoError = error; + operacion.historialErrores.push({ + timestamp: new Date().toISOString(), + error, + }); + + return this.queueRepository.save(operacion); + } + + /** + * Mark operation as conflict + */ + async marcarConflicto( + id: string, + versionServidor: number, + resolucion?: 'CLIENT_WINS' | 'SERVER_WINS' | 'MERGE' | 'MANUAL' + ): Promise { + const operacion = await this.queueRepository.findOne({ where: { id } }); + if (!operacion) return null; + + operacion.estado = EstadoSincronizacion.CONFLICTO; + operacion.versionServidor = versionServidor; + operacion.resolucionConflicto = resolucion; + + return this.queueRepository.save(operacion); + } + + /** + * Process sync batch - simulates processing pending operations + * In real implementation, this would call actual endpoints + */ + async procesarLote(tenantId: string, limite: number = 20): Promise { + const pendientes = await this.obtenerPendientes(tenantId, limite); + const resultados: SyncResultDto[] = []; + + let exitosos = 0; + let errores = 0; + let conflictos = 0; + + for (const operacion of pendientes) { + await this.marcarEnProceso(operacion.id); + + try { + // Simulate processing based on operation type + const resultado = await this.procesarOperacion(operacion); + + if (resultado.success) { + await this.marcarCompletado(operacion.id); + exitosos++; + resultados.push({ + id: operacion.id, + clienteId: operacion.clienteId, + estado: EstadoSincronizacion.COMPLETADO, + sincronizadoEn: new Date(), + }); + } else if (resultado.conflict) { + await this.marcarConflicto(operacion.id, resultado.serverVersion || 0, 'SERVER_WINS'); + conflictos++; + resultados.push({ + id: operacion.id, + clienteId: operacion.clienteId, + estado: EstadoSincronizacion.CONFLICTO, + error: 'Conflicto de versión', + }); + } else { + await this.marcarError(operacion.id, resultado.error || 'Error desconocido'); + errores++; + resultados.push({ + id: operacion.id, + clienteId: operacion.clienteId, + estado: EstadoSincronizacion.ERROR, + error: resultado.error, + }); + } + } catch (error) { + await this.marcarError(operacion.id, (error as Error).message); + errores++; + resultados.push({ + id: operacion.id, + clienteId: operacion.clienteId, + estado: EstadoSincronizacion.ERROR, + error: (error as Error).message, + }); + } + } + + return { + procesados: pendientes.length, + exitosos, + errores, + conflictos, + resultados, + }; + } + + /** + * Get queue statistics + */ + async getEstadisticas(tenantId: string): Promise<{ + total: number; + pendientes: number; + enProceso: number; + completados: number; + errores: number; + conflictos: number; + descartados: number; + porPrioridad: Record; + porTipo: Record; + }> { + const operaciones = await this.queueRepository.find({ where: { tenantId } }); + + const stats = { + total: operaciones.length, + pendientes: 0, + enProceso: 0, + completados: 0, + errores: 0, + conflictos: 0, + descartados: 0, + porPrioridad: { + [PrioridadSync.CRITICA]: 0, + [PrioridadSync.ALTA]: 0, + [PrioridadSync.NORMAL]: 0, + [PrioridadSync.BAJA]: 0, + }, + porTipo: {} as Record, + }; + + for (const op of operaciones) { + // Count by status + switch (op.estado) { + case EstadoSincronizacion.PENDIENTE: + stats.pendientes++; + break; + case EstadoSincronizacion.EN_PROCESO: + stats.enProceso++; + break; + case EstadoSincronizacion.COMPLETADO: + stats.completados++; + break; + case EstadoSincronizacion.ERROR: + stats.errores++; + break; + case EstadoSincronizacion.CONFLICTO: + stats.conflictos++; + break; + case EstadoSincronizacion.DESCARTADO: + stats.descartados++; + break; + } + + // Count by priority + stats.porPrioridad[op.prioridad]++; + + // Count by type + stats.porTipo[op.tipoOperacion] = (stats.porTipo[op.tipoOperacion] || 0) + 1; + } + + return stats; + } + + /** + * Clean up old completed operations + */ + async limpiarCompletados(tenantId: string, diasAntiguedad: number = 7): Promise { + const threshold = new Date(); + threshold.setDate(threshold.getDate() - diasAntiguedad); + + const result = await this.queueRepository.delete({ + tenantId, + estado: EstadoSincronizacion.COMPLETADO, + sincronizadoEn: LessThan(threshold), + }); + + return result.affected || 0; + } + + /** + * Retry failed operations + */ + async reintentarFallidos(tenantId: string): Promise { + const result = await this.queueRepository.update( + { + tenantId, + estado: EstadoSincronizacion.ERROR, + }, + { + estado: EstadoSincronizacion.PENDIENTE, + } + ); + + return result.affected || 0; + } + + // ========================================== + // Private Helpers + // ========================================== + + /** + * Determine priority based on operation type + */ + private determinarPrioridad(tipo: TipoOperacionOffline): PrioridadSync { + switch (tipo) { + case TipoOperacionOffline.GPS_POSICION: + case TipoOperacionOffline.GPS_EVENTO: + return PrioridadSync.CRITICA; + + case TipoOperacionOffline.VIAJE_ESTADO: + case TipoOperacionOffline.POD_FIRMA: + case TipoOperacionOffline.CHECKIN: + case TipoOperacionOffline.CHECKOUT: + return PrioridadSync.ALTA; + + case TipoOperacionOffline.CHECKLIST_ITEM: + case TipoOperacionOffline.CHECKLIST_COMPLETADO: + case TipoOperacionOffline.VIAJE_EVENTO: + return PrioridadSync.NORMAL; + + case TipoOperacionOffline.POD_FOTO: + case TipoOperacionOffline.POD_DOCUMENTO: + return PrioridadSync.BAJA; + + default: + return PrioridadSync.NORMAL; + } + } + + /** + * Process a single operation (mock implementation) + * In real implementation, this would call the actual service + */ + private async procesarOperacion(operacion: OfflineQueue): Promise<{ + success: boolean; + conflict?: boolean; + serverVersion?: number; + error?: string; + }> { + // Simulate processing delay + await new Promise(resolve => setTimeout(resolve, 10)); + + // Mock success rate (95%) + const random = Math.random(); + if (random < 0.95) { + return { success: true }; + } else if (random < 0.98) { + return { success: false, conflict: true, serverVersion: 2 }; + } else { + return { success: false, error: 'Simulated error' }; + } + } +} diff --git a/src/modules/tracking/entities/index.ts b/src/modules/tracking/entities/index.ts index 771b9ea..6698821 100644 --- a/src/modules/tracking/entities/index.ts +++ b/src/modules/tracking/entities/index.ts @@ -5,6 +5,9 @@ export * from './evento-tracking.entity'; export * from './geocerca.entity'; -// TODO: Implement remaining entities -// - alerta.entity.ts -// - posicion-gps.entity.ts +// GPS Module entities are in modules/gps/entities +// - DispositivoGps (dispositivos_gps) +// - PosicionGps (posiciones_gps) +// - EventoGeocerca (eventos_geocerca) +// - SegmentoRuta (segmentos_ruta) +// See: modules/gps/index.ts diff --git a/src/modules/whatsapp/controllers/index.ts b/src/modules/whatsapp/controllers/index.ts new file mode 100644 index 0000000..820ee3d --- /dev/null +++ b/src/modules/whatsapp/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * WhatsApp Controllers Index + * ERP Transportistas + * Sprint: S5 - TASK-007 + */ + +export { createWhatsAppController } from './whatsapp.controller'; diff --git a/src/modules/whatsapp/controllers/whatsapp.controller.ts b/src/modules/whatsapp/controllers/whatsapp.controller.ts new file mode 100644 index 0000000..fb5868e --- /dev/null +++ b/src/modules/whatsapp/controllers/whatsapp.controller.ts @@ -0,0 +1,423 @@ +/** + * WhatsApp Controller + * ERP Transportistas + * + * REST API endpoints for WhatsApp notifications. + * Sprint: S5 - TASK-007 + * Module: WhatsApp Integration + */ + +import { Request, Response, Router } from 'express'; +import { DataSource } from 'typeorm'; +import { + WhatsAppNotificationService, + NotificationRequest, +} from '../services/whatsapp-notification.service'; +import { TipoTemplateTransporte } from '../templates/transport-templates'; + +/** + * Create WhatsApp controller with DataSource injection + */ +export function createWhatsAppController(dataSource: DataSource): Router { + const router = Router(); + const notificationService = new WhatsAppNotificationService(); + + /** + * POST /configurar + * Configure WhatsApp API credentials + */ + router.post('/configurar', async (req: Request, res: Response) => { + try { + const { apiUrl, accessToken, phoneNumberId, businessAccountId } = req.body; + + if (!apiUrl || !accessToken || !phoneNumberId || !businessAccountId) { + return res.status(400).json({ + success: false, + error: 'Faltan campos requeridos: apiUrl, accessToken, phoneNumberId, businessAccountId', + }); + } + + notificationService.configure({ + apiUrl, + accessToken, + phoneNumberId, + businessAccountId, + }); + + res.json({ + success: true, + data: { + message: 'WhatsApp API configurado correctamente', + enabled: notificationService.isEnabled(), + }, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }); + + /** + * GET /estado + * Get service status + */ + router.get('/estado', async (_req: Request, res: Response) => { + try { + res.json({ + success: true, + data: { + enabled: notificationService.isEnabled(), + templatesDisponibles: Object.values(TipoTemplateTransporte), + }, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }); + + /** + * POST /enviar + * Send a single notification + */ + router.post('/enviar', async (req: Request, res: Response) => { + try { + const { telefono, tipoTemplate, parametros, metadata } = req.body; + + if (!telefono || !tipoTemplate || !parametros) { + return res.status(400).json({ + success: false, + error: 'Faltan campos requeridos: telefono, tipoTemplate, parametros', + }); + } + + // Validate template type + if (!Object.values(TipoTemplateTransporte).includes(tipoTemplate)) { + return res.status(400).json({ + success: false, + error: `Template inválido. Opciones: ${Object.values(TipoTemplateTransporte).join(', ')}`, + }); + } + + const request: NotificationRequest = { + telefono, + tipoTemplate, + parametros, + metadata, + }; + + const result = await notificationService.enviarNotificacion(request); + + res.json({ + success: result.success, + data: result, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }); + + /** + * POST /enviar-lote + * Send batch notifications + */ + router.post('/enviar-lote', async (req: Request, res: Response) => { + try { + const { notificaciones } = req.body; + + if (!notificaciones || !Array.isArray(notificaciones) || notificaciones.length === 0) { + return res.status(400).json({ + success: false, + error: 'Se requiere un array de notificaciones', + }); + } + + // Validate each notification + for (const notif of notificaciones) { + if (!notif.telefono || !notif.tipoTemplate || !notif.parametros) { + return res.status(400).json({ + success: false, + error: 'Cada notificación requiere: telefono, tipoTemplate, parametros', + }); + } + if (!Object.values(TipoTemplateTransporte).includes(notif.tipoTemplate)) { + return res.status(400).json({ + success: false, + error: `Template inválido: ${notif.tipoTemplate}`, + }); + } + } + + const result = await notificationService.enviarLote(notificaciones); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }); + + // ========================================== + // Transport-Specific Endpoints + // ========================================== + + /** + * POST /viaje-asignado + * Notify operator of trip assignment + */ + router.post('/viaje-asignado', async (req: Request, res: Response) => { + try { + const { telefono, nombreOperador, origen, destino, fecha, horaCita, folioViaje } = req.body; + + if (!telefono || !nombreOperador || !origen || !destino || !fecha || !horaCita || !folioViaje) { + return res.status(400).json({ + success: false, + error: 'Faltan campos requeridos', + }); + } + + const result = await notificationService.notificarViajeAsignado(telefono, { + nombreOperador, + origen, + destino, + fecha, + horaCita, + folioViaje, + }); + + res.json({ + success: result.success, + data: result, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }); + + /** + * POST /viaje-confirmado + * Notify client of shipment confirmation + */ + router.post('/viaje-confirmado', async (req: Request, res: Response) => { + try { + const { telefono, nombreCliente, folio, unidad, operador, fecha, eta, codigoTracking } = req.body; + + if (!telefono || !nombreCliente || !folio || !unidad || !operador || !fecha || !eta || !codigoTracking) { + return res.status(400).json({ + success: false, + error: 'Faltan campos requeridos', + }); + } + + const result = await notificationService.notificarViajeConfirmado(telefono, { + nombreCliente, + folio, + unidad, + operador, + fecha, + eta, + codigoTracking, + }); + + res.json({ + success: result.success, + data: result, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }); + + /** + * POST /eta-actualizado + * Notify ETA update + */ + router.post('/eta-actualizado', async (req: Request, res: Response) => { + try { + const { telefono, folio, nuevoEta, motivo } = req.body; + + if (!telefono || !folio || !nuevoEta || !motivo) { + return res.status(400).json({ + success: false, + error: 'Faltan campos requeridos: telefono, folio, nuevoEta, motivo', + }); + } + + const result = await notificationService.notificarEtaActualizado(telefono, { + folio, + nuevoEta, + motivo, + }); + + res.json({ + success: result.success, + data: result, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }); + + /** + * POST /viaje-completado + * Notify trip completion + */ + router.post('/viaje-completado', async (req: Request, res: Response) => { + try { + const { telefono, nombreCliente, folio, destino, fechaHora, receptor } = req.body; + + if (!telefono || !nombreCliente || !folio || !destino || !fechaHora || !receptor) { + return res.status(400).json({ + success: false, + error: 'Faltan campos requeridos', + }); + } + + const result = await notificationService.notificarViajeCompletado(telefono, { + nombreCliente, + folio, + destino, + fechaHora, + receptor, + }); + + res.json({ + success: result.success, + data: result, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }); + + /** + * POST /alerta-retraso + * Notify delay alert + */ + router.post('/alerta-retraso', async (req: Request, res: Response) => { + try { + const { telefono, nombre, folio, etaOriginal, nuevoEta, motivo } = req.body; + + if (!telefono || !nombre || !folio || !etaOriginal || !nuevoEta || !motivo) { + return res.status(400).json({ + success: false, + error: 'Faltan campos requeridos', + }); + } + + const result = await notificationService.notificarAlertaRetraso(telefono, { + nombre, + folio, + etaOriginal, + nuevoEta, + motivo, + }); + + res.json({ + success: result.success, + data: result, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }); + + /** + * POST /asignacion-carrier + * Notify carrier of service request + */ + router.post('/asignacion-carrier', async (req: Request, res: Response) => { + try { + const { telefono, nombreCarrier, origen, destino, fecha, tarifa } = req.body; + + if (!telefono || !nombreCarrier || !origen || !destino || !fecha || !tarifa) { + return res.status(400).json({ + success: false, + error: 'Faltan campos requeridos', + }); + } + + const result = await notificationService.notificarAsignacionCarrier(telefono, { + nombreCarrier, + origen, + destino, + fecha, + tarifa, + }); + + res.json({ + success: result.success, + data: result, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }); + + /** + * POST /recordatorio-mantenimiento + * Notify maintenance reminder + */ + router.post('/recordatorio-mantenimiento', async (req: Request, res: Response) => { + try { + const { telefono, unidad, tipoMantenimiento, fechaLimite, kmActual, kmProgramado } = req.body; + + if (!telefono || !unidad || !tipoMantenimiento || !fechaLimite || !kmActual || !kmProgramado) { + return res.status(400).json({ + success: false, + error: 'Faltan campos requeridos', + }); + } + + const result = await notificationService.notificarRecordatorioMantenimiento(telefono, { + unidad, + tipoMantenimiento, + fechaLimite, + kmActual, + kmProgramado, + }); + + res.json({ + success: result.success, + data: result, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }); + + return router; +} diff --git a/src/modules/whatsapp/index.ts b/src/modules/whatsapp/index.ts new file mode 100644 index 0000000..208ea46 --- /dev/null +++ b/src/modules/whatsapp/index.ts @@ -0,0 +1,56 @@ +/** + * WhatsApp Module + * ERP Transportistas + * + * Module for sending WhatsApp Business API notifications + * with transport-specific message templates. + * + * Sprint: S5 - TASK-007 + * Module: WhatsApp Integration + * + * Features: + * - 9 transport-specific message templates + * - Single and batch notification sending + * - Mock mode for development (no API key required) + * - Rate limiting for batch operations + * - Mexican phone number validation + * + * Templates Available: + * - viaje_asignado: Notify operator of trip assignment + * - viaje_confirmado: Notify client of shipment confirmation + * - eta_actualizado: Notify ETA changes + * - viaje_completado: Notify trip completion + * - alerta_retraso: Notify delay alerts + * - asignacion_carrier: Notify carrier of service request + * - recordatorio_mantenimiento: Notify maintenance reminder + * - pod_disponible: Notify POD availability + * - factura_lista: Notify invoice availability + */ + +// Templates +export { + CategoriaTemplate, + IdiomaTemplate, + TipoTemplateTransporte, + WhatsAppTemplate, + TRANSPORT_TEMPLATES, + getTemplate, + getAllTemplates, + buildMessagePayload, +} from './templates'; + +// Services +export { + WhatsAppNotificationService, + NotificationResult, + NotificationRequest, + BatchNotificationResult, + WhatsAppConfig, +} from './services'; + +// Controllers +export { createWhatsAppController } from './controllers'; + +// Routes +export { createWhatsAppRoutes } from './whatsapp.routes'; +export { default as whatsappRoutes } from './whatsapp.routes'; diff --git a/src/modules/whatsapp/services/index.ts b/src/modules/whatsapp/services/index.ts new file mode 100644 index 0000000..0e19bc0 --- /dev/null +++ b/src/modules/whatsapp/services/index.ts @@ -0,0 +1,13 @@ +/** + * WhatsApp Module Services + * ERP Transportistas + * Sprint: S5 - TASK-007 + */ + +export { + WhatsAppNotificationService, + NotificationResult, + NotificationRequest, + BatchNotificationResult, + WhatsAppConfig, +} from './whatsapp-notification.service'; diff --git a/src/modules/whatsapp/services/whatsapp-notification.service.ts b/src/modules/whatsapp/services/whatsapp-notification.service.ts new file mode 100644 index 0000000..d74b9a2 --- /dev/null +++ b/src/modules/whatsapp/services/whatsapp-notification.service.ts @@ -0,0 +1,422 @@ +/** + * WhatsApp Notification Service + * ERP Transportistas + * + * Service for sending WhatsApp notifications using transport templates. + * Sprint: S5 - TASK-007 + * Module: WhatsApp Integration + */ + +import { + TipoTemplateTransporte, + TRANSPORT_TEMPLATES, + buildMessagePayload, + WhatsAppTemplate, +} from '../templates/transport-templates'; + +/** + * Notification result + */ +export interface NotificationResult { + success: boolean; + messageId?: string; + error?: string; + timestamp: Date; +} + +/** + * Notification request + */ +export interface NotificationRequest { + telefono: string; + tipoTemplate: TipoTemplateTransporte; + parametros: Record; + metadata?: Record; +} + +/** + * Batch notification result + */ +export interface BatchNotificationResult { + total: number; + exitosos: number; + fallidos: number; + resultados: Array<{ + telefono: string; + result: NotificationResult; + }>; +} + +/** + * WhatsApp API configuration + */ +export interface WhatsAppConfig { + apiUrl: string; + accessToken: string; + phoneNumberId: string; + businessAccountId: string; +} + +/** + * WhatsApp Notification Service + * + * Handles sending transport-related notifications via WhatsApp Business API. + * In production, this would integrate with Meta's Cloud API. + */ +export class WhatsAppNotificationService { + private config: WhatsAppConfig | null = null; + private enabled: boolean = false; + + constructor(config?: WhatsAppConfig) { + if (config) { + this.config = config; + this.enabled = true; + } + } + + /** + * Configure the service + */ + configure(config: WhatsAppConfig): void { + this.config = config; + this.enabled = true; + } + + /** + * Check if service is enabled + */ + isEnabled(): boolean { + return this.enabled && this.config !== null; + } + + /** + * Send a single notification + */ + async enviarNotificacion(request: NotificationRequest): Promise { + const template = TRANSPORT_TEMPLATES[request.tipoTemplate]; + if (!template) { + return { + success: false, + error: `Template ${request.tipoTemplate} no encontrado`, + timestamp: new Date(), + }; + } + + // Validate phone number + const telefonoLimpio = this.limpiarTelefono(request.telefono); + if (!telefonoLimpio) { + return { + success: false, + error: 'Número de teléfono inválido', + timestamp: new Date(), + }; + } + + // If not configured, simulate success for development + if (!this.isEnabled()) { + return this.simularEnvio(telefonoLimpio, template, request.parametros); + } + + // Build and send message + try { + const payload = buildMessagePayload(template, request.parametros); + const result = await this.enviarMensajeAPI(telefonoLimpio, payload); + return result; + } catch (error) { + return { + success: false, + error: (error as Error).message, + timestamp: new Date(), + }; + } + } + + /** + * Send batch notifications + */ + async enviarLote(requests: NotificationRequest[]): Promise { + const resultados: Array<{ telefono: string; result: NotificationResult }> = []; + let exitosos = 0; + let fallidos = 0; + + for (const request of requests) { + const result = await this.enviarNotificacion(request); + resultados.push({ telefono: request.telefono, result }); + + if (result.success) { + exitosos++; + } else { + fallidos++; + } + + // Rate limiting - wait between messages + await this.delay(100); + } + + return { + total: requests.length, + exitosos, + fallidos, + resultados, + }; + } + + // ========================================== + // Transport-Specific Notification Methods + // ========================================== + + /** + * Notify operator of trip assignment + */ + async notificarViajeAsignado( + telefono: string, + datos: { + nombreOperador: string; + origen: string; + destino: string; + fecha: string; + horaCita: string; + folioViaje: string; + } + ): Promise { + return this.enviarNotificacion({ + telefono, + tipoTemplate: TipoTemplateTransporte.VIAJE_ASIGNADO, + parametros: { + nombre_operador: datos.nombreOperador, + origen: datos.origen, + destino: datos.destino, + fecha: datos.fecha, + hora_cita: datos.horaCita, + folio_viaje: datos.folioViaje, + }, + }); + } + + /** + * Notify client of shipment confirmation + */ + async notificarViajeConfirmado( + telefono: string, + datos: { + nombreCliente: string; + folio: string; + unidad: string; + operador: string; + fecha: string; + eta: string; + codigoTracking: string; + } + ): Promise { + return this.enviarNotificacion({ + telefono, + tipoTemplate: TipoTemplateTransporte.VIAJE_CONFIRMADO, + parametros: { + nombre_cliente: datos.nombreCliente, + folio: datos.folio, + unidad: datos.unidad, + operador: datos.operador, + fecha: datos.fecha, + eta: datos.eta, + codigo_tracking: datos.codigoTracking, + }, + }); + } + + /** + * Notify ETA update + */ + async notificarEtaActualizado( + telefono: string, + datos: { + folio: string; + nuevoEta: string; + motivo: string; + } + ): Promise { + return this.enviarNotificacion({ + telefono, + tipoTemplate: TipoTemplateTransporte.ETA_ACTUALIZADO, + parametros: { + folio: datos.folio, + nuevo_eta: datos.nuevoEta, + motivo: datos.motivo, + }, + }); + } + + /** + * Notify trip completion + */ + async notificarViajeCompletado( + telefono: string, + datos: { + nombreCliente: string; + folio: string; + destino: string; + fechaHora: string; + receptor: string; + } + ): Promise { + return this.enviarNotificacion({ + telefono, + tipoTemplate: TipoTemplateTransporte.VIAJE_COMPLETADO, + parametros: { + nombre_cliente: datos.nombreCliente, + folio: datos.folio, + destino: datos.destino, + fecha_hora: datos.fechaHora, + receptor: datos.receptor, + }, + }); + } + + /** + * Notify delay alert + */ + async notificarAlertaRetraso( + telefono: string, + datos: { + nombre: string; + folio: string; + etaOriginal: string; + nuevoEta: string; + motivo: string; + } + ): Promise { + return this.enviarNotificacion({ + telefono, + tipoTemplate: TipoTemplateTransporte.ALERTA_RETRASO, + parametros: { + nombre: datos.nombre, + folio: datos.folio, + eta_original: datos.etaOriginal, + nuevo_eta: datos.nuevoEta, + motivo: datos.motivo, + }, + }); + } + + /** + * Notify carrier of service request + */ + async notificarAsignacionCarrier( + telefono: string, + datos: { + nombreCarrier: string; + origen: string; + destino: string; + fecha: string; + tarifa: string; + } + ): Promise { + return this.enviarNotificacion({ + telefono, + tipoTemplate: TipoTemplateTransporte.ASIGNACION_CARRIER, + parametros: { + nombre_carrier: datos.nombreCarrier, + origen: datos.origen, + destino: datos.destino, + fecha: datos.fecha, + tarifa: datos.tarifa, + }, + }); + } + + /** + * Notify maintenance reminder + */ + async notificarRecordatorioMantenimiento( + telefono: string, + datos: { + unidad: string; + tipoMantenimiento: string; + fechaLimite: string; + kmActual: string; + kmProgramado: string; + } + ): Promise { + return this.enviarNotificacion({ + telefono, + tipoTemplate: TipoTemplateTransporte.RECORDATORIO_MANTENIMIENTO, + parametros: { + unidad: datos.unidad, + tipo_mantenimiento: datos.tipoMantenimiento, + fecha_limite: datos.fechaLimite, + km_actual: datos.kmActual, + km_programado: datos.kmProgramado, + }, + }); + } + + // ========================================== + // Private Helpers + // ========================================== + + /** + * Clean and validate phone number + */ + private limpiarTelefono(telefono: string): string | null { + // Remove non-numeric characters + const limpio = telefono.replace(/\D/g, ''); + + // Mexican numbers: 10 digits (without country code) or 12 (with 52) + if (limpio.length === 10) { + return `52${limpio}`; + } else if (limpio.length === 12 && limpio.startsWith('52')) { + return limpio; + } else if (limpio.length === 13 && limpio.startsWith('521')) { + // Remove old mobile prefix + return `52${limpio.slice(3)}`; + } + + return null; + } + + /** + * Simulate message sending for development + */ + private simularEnvio( + telefono: string, + template: WhatsAppTemplate, + parametros: Record + ): NotificationResult { + console.log(`[WhatsApp Simulation] Sending ${template.nombre} to ${telefono}`); + console.log(`[WhatsApp Simulation] Parameters:`, parametros); + + return { + success: true, + messageId: `sim_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + timestamp: new Date(), + }; + } + + /** + * Send message via WhatsApp API (mock implementation) + */ + private async enviarMensajeAPI( + telefono: string, + payload: { template: string; language: string; components: any[] } + ): Promise { + // In production, this would call Meta's Cloud API + // POST https://graph.facebook.com/v17.0/{phone-number-id}/messages + + if (!this.config) { + throw new Error('WhatsApp API no configurado'); + } + + // Mock implementation + return { + success: true, + messageId: `wamid_${Date.now()}`, + timestamp: new Date(), + }; + } + + /** + * Delay helper for rate limiting + */ + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/src/modules/whatsapp/templates/index.ts b/src/modules/whatsapp/templates/index.ts new file mode 100644 index 0000000..e909a47 --- /dev/null +++ b/src/modules/whatsapp/templates/index.ts @@ -0,0 +1,16 @@ +/** + * WhatsApp Templates Index + * ERP Transportistas + * Sprint: S5 - TASK-007 + */ + +export { + CategoriaTemplate, + IdiomaTemplate, + TipoTemplateTransporte, + WhatsAppTemplate, + TRANSPORT_TEMPLATES, + getTemplate, + getAllTemplates, + buildMessagePayload, +} from './transport-templates'; diff --git a/src/modules/whatsapp/templates/transport-templates.ts b/src/modules/whatsapp/templates/transport-templates.ts new file mode 100644 index 0000000..4ff1996 --- /dev/null +++ b/src/modules/whatsapp/templates/transport-templates.ts @@ -0,0 +1,403 @@ +/** + * WhatsApp Transport Templates + * ERP Transportistas + * + * Template definitions for transport-specific WhatsApp notifications. + * Sprint: S5 - TASK-007 + * Module: WhatsApp Integration + */ + +/** + * Template categories for Meta Business API + */ +export enum CategoriaTemplate { + UTILITY = 'UTILITY', + MARKETING = 'MARKETING', + AUTHENTICATION = 'AUTHENTICATION', +} + +/** + * Template language codes + */ +export enum IdiomaTemplate { + ES_MX = 'es_MX', + ES = 'es', + EN = 'en', +} + +/** + * Transport template types + */ +export enum TipoTemplateTransporte { + VIAJE_ASIGNADO = 'viaje_asignado', + VIAJE_CONFIRMADO = 'viaje_confirmado', + ETA_ACTUALIZADO = 'eta_actualizado', + VIAJE_COMPLETADO = 'viaje_completado', + ALERTA_RETRASO = 'alerta_retraso', + ASIGNACION_CARRIER = 'asignacion_carrier', + RECORDATORIO_MANTENIMIENTO = 'recordatorio_mantenimiento', + POD_DISPONIBLE = 'pod_disponible', + FACTURA_LISTA = 'factura_lista', +} + +/** + * Template definition interface + */ +export interface WhatsAppTemplate { + nombre: string; + tipo: TipoTemplateTransporte; + categoria: CategoriaTemplate; + idioma: IdiomaTemplate; + componentes: { + header?: { + tipo: 'TEXT' | 'IMAGE' | 'DOCUMENT' | 'VIDEO'; + texto?: string; + parametros?: string[]; + }; + body: { + texto: string; + parametros: string[]; + }; + footer?: { + texto: string; + }; + buttons?: Array<{ + tipo: 'QUICK_REPLY' | 'URL' | 'PHONE_NUMBER'; + texto: string; + url?: string; + telefono?: string; + }>; + }; +} + +/** + * Transport WhatsApp Templates for Mexico + */ +export const TRANSPORT_TEMPLATES: Record = { + [TipoTemplateTransporte.VIAJE_ASIGNADO]: { + nombre: 'viaje_asignado_v1', + tipo: TipoTemplateTransporte.VIAJE_ASIGNADO, + categoria: CategoriaTemplate.UTILITY, + idioma: IdiomaTemplate.ES_MX, + componentes: { + header: { + tipo: 'TEXT', + texto: '🚚 Nuevo Viaje Asignado', + }, + body: { + texto: `Hola {{1}}, + +Se te ha asignado un nuevo viaje: + +📍 Origen: {{2}} +📍 Destino: {{3}} +📅 Fecha: {{4}} +⏰ Hora cita: {{5}} +🔢 Folio: {{6}} + +Por favor confirma tu disponibilidad.`, + parametros: ['nombre_operador', 'origen', 'destino', 'fecha', 'hora_cita', 'folio_viaje'], + }, + footer: { + texto: 'Transportes - Sistema de Despacho', + }, + buttons: [ + { tipo: 'QUICK_REPLY', texto: '✅ Confirmar' }, + { tipo: 'QUICK_REPLY', texto: '❌ No disponible' }, + ], + }, + }, + + [TipoTemplateTransporte.VIAJE_CONFIRMADO]: { + nombre: 'viaje_confirmado_v1', + tipo: TipoTemplateTransporte.VIAJE_CONFIRMADO, + categoria: CategoriaTemplate.UTILITY, + idioma: IdiomaTemplate.ES_MX, + componentes: { + header: { + tipo: 'TEXT', + texto: '✅ Confirmación de Embarque', + }, + body: { + texto: `Estimado cliente {{1}}, + +Su embarque ha sido confirmado: + +🔢 Folio: {{2}} +🚚 Unidad: {{3}} +👤 Operador: {{4}} +📅 Fecha estimada: {{5}} +⏰ ETA: {{6}} + +Puede dar seguimiento en tiempo real con el código: {{7}}`, + parametros: ['nombre_cliente', 'folio', 'unidad', 'operador', 'fecha', 'eta', 'codigo_tracking'], + }, + footer: { + texto: 'Gracias por su preferencia', + }, + buttons: [ + { tipo: 'URL', texto: '📍 Rastrear Envío', url: 'https://track.example.com/{{1}}' }, + ], + }, + }, + + [TipoTemplateTransporte.ETA_ACTUALIZADO]: { + nombre: 'eta_actualizado_v1', + tipo: TipoTemplateTransporte.ETA_ACTUALIZADO, + categoria: CategoriaTemplate.UTILITY, + idioma: IdiomaTemplate.ES_MX, + componentes: { + body: { + texto: `📢 Actualización de ETA + +Folio: {{1}} +Nueva hora estimada de llegada: {{2}} + +Motivo: {{3}} + +Disculpe las molestias.`, + parametros: ['folio', 'nuevo_eta', 'motivo'], + }, + footer: { + texto: 'Sistema de Tracking', + }, + }, + }, + + [TipoTemplateTransporte.VIAJE_COMPLETADO]: { + nombre: 'viaje_completado_v1', + tipo: TipoTemplateTransporte.VIAJE_COMPLETADO, + categoria: CategoriaTemplate.UTILITY, + idioma: IdiomaTemplate.ES_MX, + componentes: { + header: { + tipo: 'TEXT', + texto: '✅ Entrega Completada', + }, + body: { + texto: `Estimado cliente {{1}}, + +Su envío ha sido entregado exitosamente. + +🔢 Folio: {{2}} +📍 Destino: {{3}} +📅 Fecha/Hora: {{4}} +👤 Recibió: {{5}} + +El comprobante de entrega (POD) está disponible.`, + parametros: ['nombre_cliente', 'folio', 'destino', 'fecha_hora', 'receptor'], + }, + footer: { + texto: 'Gracias por su preferencia', + }, + buttons: [ + { tipo: 'URL', texto: '📄 Ver POD', url: 'https://pod.example.com/{{1}}' }, + ], + }, + }, + + [TipoTemplateTransporte.ALERTA_RETRASO]: { + nombre: 'alerta_retraso_v1', + tipo: TipoTemplateTransporte.ALERTA_RETRASO, + categoria: CategoriaTemplate.UTILITY, + idioma: IdiomaTemplate.ES_MX, + componentes: { + header: { + tipo: 'TEXT', + texto: '⚠️ Alerta de Retraso', + }, + body: { + texto: `Estimado {{1}}, + +Le informamos que el viaje {{2}} presenta un retraso. + +🕐 ETA original: {{3}} +🕐 Nuevo ETA: {{4}} +📝 Motivo: {{5}} + +Nuestro equipo está trabajando para minimizar el impacto.`, + parametros: ['nombre', 'folio', 'eta_original', 'nuevo_eta', 'motivo'], + }, + footer: { + texto: 'Lamentamos los inconvenientes', + }, + }, + }, + + [TipoTemplateTransporte.ASIGNACION_CARRIER]: { + nombre: 'asignacion_carrier_v1', + tipo: TipoTemplateTransporte.ASIGNACION_CARRIER, + categoria: CategoriaTemplate.UTILITY, + idioma: IdiomaTemplate.ES_MX, + componentes: { + header: { + tipo: 'TEXT', + texto: '📋 Solicitud de Servicio', + }, + body: { + texto: `Estimado proveedor {{1}}, + +Tenemos un viaje disponible para su flota: + +📍 Origen: {{2}} +📍 Destino: {{3}} +📅 Fecha: {{4}} +💰 Tarifa: {{5}} MXN + +¿Le interesa tomarlo?`, + parametros: ['nombre_carrier', 'origen', 'destino', 'fecha', 'tarifa'], + }, + buttons: [ + { tipo: 'QUICK_REPLY', texto: '✅ Acepto' }, + { tipo: 'QUICK_REPLY', texto: '❌ Declino' }, + { tipo: 'QUICK_REPLY', texto: '💬 Negociar' }, + ], + }, + }, + + [TipoTemplateTransporte.RECORDATORIO_MANTENIMIENTO]: { + nombre: 'recordatorio_mantenimiento_v1', + tipo: TipoTemplateTransporte.RECORDATORIO_MANTENIMIENTO, + categoria: CategoriaTemplate.UTILITY, + idioma: IdiomaTemplate.ES_MX, + componentes: { + header: { + tipo: 'TEXT', + texto: '🔧 Recordatorio de Mantenimiento', + }, + body: { + texto: `Unidad {{1}} requiere mantenimiento: + +📋 Tipo: {{2}} +📅 Fecha límite: {{3}} +🔢 Km actual: {{4}} +🔢 Km programado: {{5}} + +Por favor agende el servicio a la brevedad.`, + parametros: ['unidad', 'tipo_mantenimiento', 'fecha_limite', 'km_actual', 'km_programado'], + }, + footer: { + texto: 'Sistema de Mantenimiento', + }, + }, + }, + + [TipoTemplateTransporte.POD_DISPONIBLE]: { + nombre: 'pod_disponible_v1', + tipo: TipoTemplateTransporte.POD_DISPONIBLE, + categoria: CategoriaTemplate.UTILITY, + idioma: IdiomaTemplate.ES_MX, + componentes: { + body: { + texto: `📄 POD Disponible + +Folio: {{1}} +El comprobante de entrega ya está disponible para descarga. + +Acceda con su código: {{2}}`, + parametros: ['folio', 'codigo_acceso'], + }, + buttons: [ + { tipo: 'URL', texto: '📥 Descargar POD', url: 'https://pod.example.com/{{1}}' }, + ], + }, + }, + + [TipoTemplateTransporte.FACTURA_LISTA]: { + nombre: 'factura_lista_v1', + tipo: TipoTemplateTransporte.FACTURA_LISTA, + categoria: CategoriaTemplate.UTILITY, + idioma: IdiomaTemplate.ES_MX, + componentes: { + header: { + tipo: 'TEXT', + texto: '📋 Factura Disponible', + }, + body: { + texto: `Estimado cliente {{1}}, + +Su factura está lista: + +🔢 Folio: {{2}} +💰 Total: \${{3}} MXN +📅 Fecha: {{4}} +📋 UUID: {{5}} + +Puede descargar el PDF y XML desde el portal.`, + parametros: ['nombre_cliente', 'folio_factura', 'total', 'fecha', 'uuid'], + }, + buttons: [ + { tipo: 'URL', texto: '📥 Descargar Factura', url: 'https://facturas.example.com/{{1}}' }, + ], + }, + }, +}; + +/** + * Get template by type + */ +export function getTemplate(tipo: TipoTemplateTransporte): WhatsAppTemplate | undefined { + return TRANSPORT_TEMPLATES[tipo]; +} + +/** + * Get all templates + */ +export function getAllTemplates(): WhatsAppTemplate[] { + return Object.values(TRANSPORT_TEMPLATES); +} + +/** + * Build message payload from template + */ +export function buildMessagePayload( + template: WhatsAppTemplate, + parametros: Record +): { + template: string; + language: string; + components: any[]; +} { + const components: any[] = []; + + // Header parameters + if (template.componentes.header?.parametros) { + components.push({ + type: 'header', + parameters: template.componentes.header.parametros.map(p => ({ + type: 'text', + text: parametros[p] || '', + })), + }); + } + + // Body parameters + if (template.componentes.body.parametros.length > 0) { + components.push({ + type: 'body', + parameters: template.componentes.body.parametros.map(p => ({ + type: 'text', + text: parametros[p] || '', + })), + }); + } + + // Button parameters (for URL buttons with dynamic values) + if (template.componentes.buttons) { + const urlButtons = template.componentes.buttons.filter(b => b.tipo === 'URL' && b.url?.includes('{{')); + if (urlButtons.length > 0) { + components.push({ + type: 'button', + sub_type: 'url', + index: 0, + parameters: [{ type: 'text', text: parametros['url_param'] || parametros['folio'] || '' }], + }); + } + } + + return { + template: template.nombre, + language: template.idioma, + components, + }; +} diff --git a/src/modules/whatsapp/whatsapp.routes.ts b/src/modules/whatsapp/whatsapp.routes.ts new file mode 100644 index 0000000..7d6f1d4 --- /dev/null +++ b/src/modules/whatsapp/whatsapp.routes.ts @@ -0,0 +1,39 @@ +/** + * WhatsApp Routes + * ERP Transportistas + * + * Route configuration for WhatsApp notification endpoints. + * Sprint: S5 - TASK-007 + * Module: WhatsApp Integration + */ + +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { createWhatsAppController } from './controllers'; + +/** + * Create WhatsApp routes + * @param dataSource - TypeORM DataSource instance + * @returns Express Router with WhatsApp endpoints + */ +export function createWhatsAppRoutes(dataSource: DataSource): Router { + const router = Router(); + + // WhatsApp notification endpoints + // POST /configurar - Configure API credentials + // GET /estado - Get service status + // POST /enviar - Send single notification + // POST /enviar-lote - Send batch notifications + // POST /viaje-asignado - Notify trip assignment + // POST /viaje-confirmado - Notify shipment confirmation + // POST /eta-actualizado - Notify ETA update + // POST /viaje-completado - Notify trip completion + // POST /alerta-retraso - Notify delay alert + // POST /asignacion-carrier - Notify carrier assignment + // POST /recordatorio-mantenimiento - Notify maintenance reminder + router.use('/', createWhatsAppController(dataSource)); + + return router; +} + +export default createWhatsAppRoutes;