[WORKSPACE] feat: Add dispatch, gps, offline and whatsapp modules

This commit is contained in:
Adrian Flores Cortes 2026-01-29 17:57:14 -06:00
parent 0ff4089b71
commit 0501a2e52a
56 changed files with 8983 additions and 3 deletions

View File

@ -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 = {

View File

@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, any>;
@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;
}

View File

@ -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<string, any>;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

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

View File

@ -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<string, any>;
}

View File

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

View File

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

View File

@ -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<string, any>;
// 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;
}

View File

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

View File

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

View File

@ -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<NivelCertificacion, number>;
}[];
operadores: {
id: string;
certificacionesCount: number;
certificaciones: string[];
}[];
}
export class CertificacionService {
private certificacionRepository: Repository<OperadorCertificacion>;
constructor(dataSource: DataSource) {
this.certificacionRepository = dataSource.getRepository(OperadorCertificacion);
}
/**
* Add certification to operator
*/
async agregarCertificacion(tenantId: string, dto: CreateCertificacionDto): Promise<OperadorCertificacion> {
// 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<OperadorCertificacion | null> {
return this.certificacionRepository.findOne({
where: { id, tenantId },
});
}
/**
* Update certification
*/
async actualizarCertificacion(
tenantId: string,
id: string,
dto: UpdateCertificacionDto
): Promise<OperadorCertificacion | null> {
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<boolean> {
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<OperadorCertificacion[]> {
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<OperadorCertificacion[]> {
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<OperadorCertificacion[]> {
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<OperadorCertificacion | null> {
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<MatrizCertificaciones> {
const todasCertificaciones = await this.certificacionRepository.find({
where: { tenantId, activa: true },
});
// Group by certification code
const certMap = new Map<
string,
{ nombre: string; operadores: Set<string>; niveles: Record<NivelCertificacion, number> }
>();
// Group by operator
const operadorMap = new Map<string, Set<string>>();
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),
};
}
}

View File

@ -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<string, any>;
}
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<EstadoUnidad>;
private logDespachoRepository: Repository<LogDespacho>;
private tableroRepository: Repository<TableroDespacho>;
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<TableroDespacho> {
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<TableroDespacho | null> {
return this.tableroRepository.findOne({
where: { id, tenantId },
});
}
async getTableroActivo(tenantId: string): Promise<TableroDespacho | null> {
return this.tableroRepository.findOne({
where: { tenantId, activo: true },
order: { createdAt: 'DESC' },
});
}
// ==========================================
// Estado Unidad Management
// ==========================================
async crearEstadoUnidad(tenantId: string, dto: CreateEstadoUnidadDto): Promise<EstadoUnidad> {
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<EstadoUnidad | null> {
return this.estadoUnidadRepository.findOne({
where: { tenantId, unidadId },
});
}
async actualizarEstadoUnidad(
tenantId: string,
unidadId: string,
dto: UpdateEstadoUnidadDto
): Promise<EstadoUnidad | null> {
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<EstadoUnidad[]> {
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<EstadoUnidad[]> {
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<EstadoUnidad | null> {
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<EstadoUnidad | null> {
// 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<EstadoUnidad | null> {
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<EstadoUnidad | null> {
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<EstadoUnidad | null> {
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<EstadoUnidad | null> {
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<SugerenciaAsignacion[]> {
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<string, any>;
}
): Promise<LogDespacho> {
const log = this.logDespachoRepository.create({
tenantId,
...data,
ejecutadoEn: new Date(),
});
return this.logDespachoRepository.save(log);
}
async getLogsViaje(
tenantId: string,
viajeId: string
): Promise<LogDespacho[]> {
return this.logDespachoRepository.find({
where: { tenantId, viajeId },
order: { ejecutadoEn: 'DESC' },
});
}
async getLogsRecientes(
tenantId: string,
limite: number = 50
): Promise<LogDespacho[]> {
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);
}
}

View File

@ -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<EstadoUnidad>;
private dispositivoGpsRepository: Repository<DispositivoGps>;
private posicionGpsRepository: Repository<PosicionGps>;
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<number> {
// 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<PosicionUnidadGps[]> {
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<SugerenciaConGps[]> {
// 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<EstadoUnidad | null> {
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);
}
}

View File

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

View File

@ -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<string, any>;
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<string, any>;
intervaloRepeticionMinutos?: number;
maxEscalamientos?: number;
activo?: boolean;
}
export interface ReglaCoincidente {
regla: ReglaDespacho;
score: number;
}
export class RuleService {
private reglaDespachoRepository: Repository<ReglaDespacho>;
private reglaEscalamientoRepository: Repository<ReglaEscalamiento>;
constructor(dataSource: DataSource) {
this.reglaDespachoRepository = dataSource.getRepository(ReglaDespacho);
this.reglaEscalamientoRepository = dataSource.getRepository(ReglaEscalamiento);
}
// ==========================================
// Reglas de Despacho
// ==========================================
async crearReglaDespacho(
tenantId: string,
dto: CreateReglaDespachoDto
): Promise<ReglaDespacho> {
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<ReglaDespacho | null> {
return this.reglaDespachoRepository.findOne({
where: { id, tenantId },
});
}
async actualizarReglaDespacho(
tenantId: string,
id: string,
dto: UpdateReglaDespachoDto
): Promise<ReglaDespacho | null> {
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<boolean> {
const result = await this.reglaDespachoRepository.delete({ id, tenantId });
return (result.affected || 0) > 0;
}
async getReglasDespachoActivas(tenantId: string): Promise<ReglaDespacho[]> {
return this.reglaDespachoRepository.find({
where: { tenantId, activo: true },
order: { prioridad: 'DESC' },
});
}
async getReglasDespachoCoincidentes(
tenantId: string,
tipoViaje?: string,
categoriaViaje?: string
): Promise<ReglaDespacho[]> {
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<ReglaCoincidente[]> {
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<ReglaEscalamiento> {
const regla = this.reglaEscalamientoRepository.create({
tenantId,
...dto,
activo: true,
});
return this.reglaEscalamientoRepository.save(regla);
}
async getReglaEscalamientoById(
tenantId: string,
id: string
): Promise<ReglaEscalamiento | null> {
return this.reglaEscalamientoRepository.findOne({
where: { id, tenantId },
});
}
async actualizarReglaEscalamiento(
tenantId: string,
id: string,
dto: UpdateReglaEscalamientoDto
): Promise<ReglaEscalamiento | null> {
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<boolean> {
const result = await this.reglaEscalamientoRepository.delete({ id, tenantId });
return (result.affected || 0) > 0;
}
async getReglasEscalamientoActivas(tenantId: string): Promise<ReglaEscalamiento[]> {
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<ReglaEscalamiento[]> {
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),
};
}
}

View File

@ -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<TurnoOperador>;
constructor(dataSource: DataSource) {
this.turnoRepository = dataSource.getRepository(TurnoOperador);
}
/**
* Create a new shift
*/
async crearTurno(tenantId: string, dto: CreateTurnoDto): Promise<TurnoOperador> {
// 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<TurnoOperador | null> {
return this.turnoRepository.findOne({
where: { id, tenantId },
});
}
/**
* Update shift
*/
async actualizarTurno(
tenantId: string,
id: string,
dto: UpdateTurnoDto
): Promise<TurnoOperador | null> {
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<boolean> {
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<TurnoOperador[]> {
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<TurnoOperador[]> {
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<DisponibilidadOperador[]> {
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<TurnoOperador[]> {
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<TurnoOperador | null> {
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<TurnoOperador | null> {
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<TurnoOperador | null> {
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<TurnoOperador | null> {
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<TurnoOperador[]> {
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<TurnoOperador[]> {
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),
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, any>;
// 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[];
}

View File

@ -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<string, any>;
// Relations
@ManyToOne(() => DispositivoGps, dispositivo => dispositivo.eventosGeocerca)
@JoinColumn({ name: 'dispositivo_id' })
dispositivo: DispositivoGps;
@ManyToOne(() => PosicionGps)
@JoinColumn({ name: 'posicion_id' })
posicion?: PosicionGps;
}

View File

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

View File

@ -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<string, any>;
// 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;
}

View File

@ -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<string, any>;
// 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;
}

View File

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

45
src/modules/gps/index.ts Normal file
View File

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

View File

@ -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<string, any>;
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<string, any>;
}
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<DispositivoGps>;
constructor(dataSource: DataSource) {
this.dispositivoRepository = dataSource.getRepository(DispositivoGps);
}
/**
* Register a new GPS device
*/
async create(tenantId: string, dto: CreateDispositivoGpsDto): Promise<DispositivoGps> {
// 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<DispositivoGps | null> {
return this.dispositivoRepository.findOne({
where: { id, tenantId },
});
}
/**
* Find device by external ID
*/
async findByExternalId(tenantId: string, externalDeviceId: string): Promise<DispositivoGps | null> {
return this.dispositivoRepository.findOne({
where: { tenantId, externalDeviceId },
});
}
/**
* Find device by unit ID
*/
async findByUnidadId(tenantId: string, unidadId: string): Promise<DispositivoGps | null> {
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<DispositivoGps | null> {
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<DispositivoGps | null> {
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<boolean> {
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<DispositivoGps[]> {
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<DispositivoGps[]> {
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<PlataformaGps, number>;
porTipoUnidad: Record<TipoUnidadGps, number>;
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, number> = {
[PlataformaGps.TRACCAR]: 0,
[PlataformaGps.WIALON]: 0,
[PlataformaGps.SAMSARA]: 0,
[PlataformaGps.GEOTAB]: 0,
[PlataformaGps.MANUAL]: 0,
};
const porTipoUnidad: Record<TipoUnidadGps, number> = {
[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,
};
}
}

View File

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

View File

@ -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<string, any>;
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<PosicionGps>;
private dispositivoRepository: Repository<DispositivoGps>;
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<PosicionGps> {
// 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<number> {
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<PosicionGps | null> {
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<PosicionGps | null> {
return this.posicionRepository.findOne({
where: { tenantId, dispositivoId },
order: { tiempoDispositivo: 'DESC' },
});
}
/**
* Get last positions for multiple devices
*/
async getUltimasPosiciones(tenantId: string, dispositivoIds: string[]): Promise<PosicionGps[]> {
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<PuntoPosicion[]> {
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<ResumenTrack | null> {
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<number> {
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<number> {
return this.posicionRepository.count({
where: { tenantId, dispositivoId },
});
}
}

View File

@ -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<string, any>;
}
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<SegmentoRuta>;
private posicionRepository: Repository<PosicionGps>;
constructor(dataSource: DataSource) {
this.segmentoRepository = dataSource.getRepository(SegmentoRuta);
this.posicionRepository = dataSource.getRepository(PosicionGps);
}
/**
* Create a route segment
*/
async create(tenantId: string, dto: CreateSegmentoRutaDto): Promise<SegmentoRuta> {
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<SegmentoRuta | null> {
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<SegmentoRuta[]> {
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<SegmentoRuta | null> {
// 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<RutaCalculada | null> {
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<SegmentoRuta | null> {
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<boolean> {
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<TipoSegmento, number>;
}> {
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, number> = {
[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;
}
}

View File

@ -0,0 +1,7 @@
/**
* Offline Module Controllers
* ERP Transportistas
* Sprint: S4 - TASK-007
*/
export { createSyncController } from './sync.controller';

View File

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

View File

@ -0,0 +1,12 @@
/**
* Offline Module Entities
* ERP Transportistas
* Sprint: S4 - TASK-007
*/
export {
OfflineQueue,
TipoOperacionOffline,
EstadoSincronizacion,
PrioridadSync,
} from './offline-queue.entity';

View File

@ -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<string, any>;
// 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;
}

View File

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

View File

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

View File

@ -0,0 +1,13 @@
/**
* Offline Module Services
* ERP Transportistas
* Sprint: S4 - TASK-007
*/
export {
SyncService,
CreateOfflineOperationDto,
SyncResultDto,
SyncBatchResult,
FiltrosOfflineQueue,
} from './sync.service';

View File

@ -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<string, any>;
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<OfflineQueue>;
constructor(private dataSource: DataSource) {
this.queueRepository = dataSource.getRepository(OfflineQueue);
}
/**
* Enqueue an offline operation
*/
async encolar(tenantId: string, dto: CreateOfflineOperationDto): Promise<OfflineQueue> {
// 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<OfflineQueue[]> {
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<OfflineQueue[]> {
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<OfflineQueue[]> {
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<OfflineQueue | null> {
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<OfflineQueue | null> {
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<OfflineQueue | null> {
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<OfflineQueue | null> {
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<SyncBatchResult> {
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<PrioridadSync, number>;
porTipo: Record<TipoOperacionOffline, number>;
}> {
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<TipoOperacionOffline, number>,
};
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<number> {
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<number> {
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' };
}
}
}

View File

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

View File

@ -0,0 +1,7 @@
/**
* WhatsApp Controllers Index
* ERP Transportistas
* Sprint: S5 - TASK-007
*/
export { createWhatsAppController } from './whatsapp.controller';

View File

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

View File

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

View File

@ -0,0 +1,13 @@
/**
* WhatsApp Module Services
* ERP Transportistas
* Sprint: S5 - TASK-007
*/
export {
WhatsAppNotificationService,
NotificationResult,
NotificationRequest,
BatchNotificationResult,
WhatsAppConfig,
} from './whatsapp-notification.service';

View File

@ -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<string, string>;
metadata?: Record<string, any>;
}
/**
* 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<NotificationResult> {
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<BatchNotificationResult> {
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<NotificationResult> {
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<NotificationResult> {
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<NotificationResult> {
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<NotificationResult> {
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<NotificationResult> {
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<NotificationResult> {
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<NotificationResult> {
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<string, string>
): 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<NotificationResult> {
// 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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

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

View File

@ -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, WhatsAppTemplate> = {
[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<string, string>
): {
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,
};
}

View File

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