[WORKSPACE] feat: Add dispatch, gps, offline and whatsapp modules
This commit is contained in:
parent
0ff4089b71
commit
0501a2e52a
15
src/app.ts
15
src/app.ts
@ -19,6 +19,11 @@ import financialRoutes from './modules/financial/financial.routes.js';
|
||||
import salesRoutes from './modules/ordenes-transporte/sales.routes.js';
|
||||
import productsRoutes from './modules/gestion-flota/products.routes.js';
|
||||
import projectsRoutes from './modules/viajes/projects.routes.js';
|
||||
import gpsRoutes from './modules/gps/gps.routes.js';
|
||||
import dispatchRoutes from './modules/dispatch/dispatch.routes.js';
|
||||
import offlineRoutes from './modules/offline/offline.routes.js';
|
||||
import { createWhatsAppRoutes } from './modules/whatsapp/index.js';
|
||||
import { AppDataSource } from './config/typeorm.js';
|
||||
|
||||
const app: Application = express();
|
||||
|
||||
@ -65,6 +70,16 @@ app.use(`${apiPrefix}/sales`, salesRoutes);
|
||||
app.use(`${apiPrefix}/products`, productsRoutes);
|
||||
app.use(`${apiPrefix}/projects`, projectsRoutes);
|
||||
|
||||
// API routes - GPS & Dispatch (Sprint S1+S2 - TASK-007)
|
||||
app.use(`${apiPrefix}/gps`, gpsRoutes);
|
||||
app.use(`${apiPrefix}/despacho`, dispatchRoutes);
|
||||
|
||||
// API routes - Offline Sync (Sprint S4 - TASK-007)
|
||||
app.use(`${apiPrefix}/offline`, offlineRoutes);
|
||||
|
||||
// API routes - WhatsApp Notifications (Sprint S5 - TASK-007)
|
||||
app.use(`${apiPrefix}/whatsapp`, createWhatsAppRoutes(AppDataSource));
|
||||
|
||||
// 404 handler
|
||||
app.use((_req: Request, res: Response) => {
|
||||
const response: ApiResponse = {
|
||||
|
||||
@ -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)
|
||||
|
||||
224
src/modules/dispatch/controllers/certificacion.controller.ts
Normal file
224
src/modules/dispatch/controllers/certificacion.controller.ts
Normal 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;
|
||||
}
|
||||
377
src/modules/dispatch/controllers/dispatch.controller.ts
Normal file
377
src/modules/dispatch/controllers/dispatch.controller.ts
Normal 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;
|
||||
}
|
||||
157
src/modules/dispatch/controllers/gps-integration.controller.ts
Normal file
157
src/modules/dispatch/controllers/gps-integration.controller.ts
Normal 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;
|
||||
}
|
||||
13
src/modules/dispatch/controllers/index.ts
Normal file
13
src/modules/dispatch/controllers/index.ts
Normal 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';
|
||||
260
src/modules/dispatch/controllers/rule.controller.ts
Normal file
260
src/modules/dispatch/controllers/rule.controller.ts
Normal 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;
|
||||
}
|
||||
305
src/modules/dispatch/controllers/turno.controller.ts
Normal file
305
src/modules/dispatch/controllers/turno.controller.ts
Normal 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;
|
||||
}
|
||||
45
src/modules/dispatch/dispatch.routes.ts
Normal file
45
src/modules/dispatch/dispatch.routes.ts
Normal 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;
|
||||
72
src/modules/dispatch/entities/dispatch-board.entity.ts
Normal file
72
src/modules/dispatch/entities/dispatch-board.entity.ts
Normal 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;
|
||||
}
|
||||
120
src/modules/dispatch/entities/estado-unidad.entity.ts
Normal file
120
src/modules/dispatch/entities/estado-unidad.entity.ts
Normal 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;
|
||||
}
|
||||
18
src/modules/dispatch/entities/index.ts
Normal file
18
src/modules/dispatch/entities/index.ts
Normal 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';
|
||||
91
src/modules/dispatch/entities/log-despacho.entity.ts
Normal file
91
src/modules/dispatch/entities/log-despacho.entity.ts
Normal 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>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
114
src/modules/dispatch/entities/regla-despacho.entity.ts
Normal file
114
src/modules/dispatch/entities/regla-despacho.entity.ts
Normal 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;
|
||||
}
|
||||
93
src/modules/dispatch/entities/regla-escalamiento.entity.ts
Normal file
93
src/modules/dispatch/entities/regla-escalamiento.entity.ts
Normal 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;
|
||||
}
|
||||
95
src/modules/dispatch/entities/turno-operador.entity.ts
Normal file
95
src/modules/dispatch/entities/turno-operador.entity.ts
Normal 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;
|
||||
}
|
||||
69
src/modules/dispatch/index.ts
Normal file
69
src/modules/dispatch/index.ts
Normal 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';
|
||||
360
src/modules/dispatch/services/certificacion.service.ts
Normal file
360
src/modules/dispatch/services/certificacion.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
595
src/modules/dispatch/services/dispatch.service.ts
Normal file
595
src/modules/dispatch/services/dispatch.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
49
src/modules/dispatch/services/index.ts
Normal file
49
src/modules/dispatch/services/index.ts
Normal 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';
|
||||
398
src/modules/dispatch/services/rule.service.ts
Normal file
398
src/modules/dispatch/services/rule.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
378
src/modules/dispatch/services/turno.service.ts
Normal file
378
src/modules/dispatch/services/turno.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
220
src/modules/gps/controllers/dispositivo-gps.controller.ts
Normal file
220
src/modules/gps/controllers/dispositivo-gps.controller.ts
Normal 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;
|
||||
}
|
||||
9
src/modules/gps/controllers/index.ts
Normal file
9
src/modules/gps/controllers/index.ts
Normal 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';
|
||||
221
src/modules/gps/controllers/posicion-gps.controller.ts
Normal file
221
src/modules/gps/controllers/posicion-gps.controller.ts
Normal 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;
|
||||
}
|
||||
206
src/modules/gps/controllers/segmento-ruta.controller.ts
Normal file
206
src/modules/gps/controllers/segmento-ruta.controller.ts
Normal 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;
|
||||
}
|
||||
129
src/modules/gps/entities/dispositivo-gps.entity.ts
Normal file
129
src/modules/gps/entities/dispositivo-gps.entity.ts
Normal 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[];
|
||||
}
|
||||
90
src/modules/gps/entities/evento-geocerca.entity.ts
Normal file
90
src/modules/gps/entities/evento-geocerca.entity.ts
Normal 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;
|
||||
}
|
||||
11
src/modules/gps/entities/index.ts
Normal file
11
src/modules/gps/entities/index.ts
Normal 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';
|
||||
89
src/modules/gps/entities/posicion-gps.entity.ts
Normal file
89
src/modules/gps/entities/posicion-gps.entity.ts
Normal 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;
|
||||
}
|
||||
133
src/modules/gps/entities/segmento-ruta.entity.ts
Normal file
133
src/modules/gps/entities/segmento-ruta.entity.ts
Normal 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;
|
||||
}
|
||||
37
src/modules/gps/gps.routes.ts
Normal file
37
src/modules/gps/gps.routes.ts
Normal 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
45
src/modules/gps/index.ts
Normal 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';
|
||||
328
src/modules/gps/services/dispositivo-gps.service.ts
Normal file
328
src/modules/gps/services/dispositivo-gps.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
28
src/modules/gps/services/index.ts
Normal file
28
src/modules/gps/services/index.ts
Normal 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';
|
||||
389
src/modules/gps/services/posicion-gps.service.ts
Normal file
389
src/modules/gps/services/posicion-gps.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
429
src/modules/gps/services/segmento-ruta.service.ts
Normal file
429
src/modules/gps/services/segmento-ruta.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
7
src/modules/offline/controllers/index.ts
Normal file
7
src/modules/offline/controllers/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Offline Module Controllers
|
||||
* ERP Transportistas
|
||||
* Sprint: S4 - TASK-007
|
||||
*/
|
||||
|
||||
export { createSyncController } from './sync.controller';
|
||||
259
src/modules/offline/controllers/sync.controller.ts
Normal file
259
src/modules/offline/controllers/sync.controller.ts
Normal 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;
|
||||
}
|
||||
12
src/modules/offline/entities/index.ts
Normal file
12
src/modules/offline/entities/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Offline Module Entities
|
||||
* ERP Transportistas
|
||||
* Sprint: S4 - TASK-007
|
||||
*/
|
||||
|
||||
export {
|
||||
OfflineQueue,
|
||||
TipoOperacionOffline,
|
||||
EstadoSincronizacion,
|
||||
PrioridadSync,
|
||||
} from './offline-queue.entity';
|
||||
172
src/modules/offline/entities/offline-queue.entity.ts
Normal file
172
src/modules/offline/entities/offline-queue.entity.ts
Normal 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;
|
||||
}
|
||||
27
src/modules/offline/index.ts
Normal file
27
src/modules/offline/index.ts
Normal 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';
|
||||
22
src/modules/offline/offline.routes.ts
Normal file
22
src/modules/offline/offline.routes.ts
Normal 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;
|
||||
13
src/modules/offline/services/index.ts
Normal file
13
src/modules/offline/services/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Offline Module Services
|
||||
* ERP Transportistas
|
||||
* Sprint: S4 - TASK-007
|
||||
*/
|
||||
|
||||
export {
|
||||
SyncService,
|
||||
CreateOfflineOperationDto,
|
||||
SyncResultDto,
|
||||
SyncBatchResult,
|
||||
FiltrosOfflineQueue,
|
||||
} from './sync.service';
|
||||
447
src/modules/offline/services/sync.service.ts
Normal file
447
src/modules/offline/services/sync.service.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
7
src/modules/whatsapp/controllers/index.ts
Normal file
7
src/modules/whatsapp/controllers/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* WhatsApp Controllers Index
|
||||
* ERP Transportistas
|
||||
* Sprint: S5 - TASK-007
|
||||
*/
|
||||
|
||||
export { createWhatsAppController } from './whatsapp.controller';
|
||||
423
src/modules/whatsapp/controllers/whatsapp.controller.ts
Normal file
423
src/modules/whatsapp/controllers/whatsapp.controller.ts
Normal 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;
|
||||
}
|
||||
56
src/modules/whatsapp/index.ts
Normal file
56
src/modules/whatsapp/index.ts
Normal 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';
|
||||
13
src/modules/whatsapp/services/index.ts
Normal file
13
src/modules/whatsapp/services/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* WhatsApp Module Services
|
||||
* ERP Transportistas
|
||||
* Sprint: S5 - TASK-007
|
||||
*/
|
||||
|
||||
export {
|
||||
WhatsAppNotificationService,
|
||||
NotificationResult,
|
||||
NotificationRequest,
|
||||
BatchNotificationResult,
|
||||
WhatsAppConfig,
|
||||
} from './whatsapp-notification.service';
|
||||
422
src/modules/whatsapp/services/whatsapp-notification.service.ts
Normal file
422
src/modules/whatsapp/services/whatsapp-notification.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
16
src/modules/whatsapp/templates/index.ts
Normal file
16
src/modules/whatsapp/templates/index.ts
Normal 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';
|
||||
403
src/modules/whatsapp/templates/transport-templates.ts
Normal file
403
src/modules/whatsapp/templates/transport-templates.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
src/modules/whatsapp/whatsapp.routes.ts
Normal file
39
src/modules/whatsapp/whatsapp.routes.ts
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user