Carta Porte Module (4 controllers): - mercancia.controller.ts: Cargo management endpoints - ubicacion-carta-porte.controller.ts: Location endpoints - figura-transporte.controller.ts: Transport figures endpoints - inspeccion-pre-viaje.controller.ts: Pre-trip inspection endpoints Gestion Flota Module (2 controllers): - documento-flota.controller.ts: Fleet document management - asignacion.controller.ts: Unit-operator assignment endpoints Tarifas Transporte Module (2 controllers): - factura-transporte.controller.ts: Invoice endpoints with IVA 16% - recargos.controller.ts: Surcharge catalog and fuel surcharge endpoints GPS Module (1 controller): - evento-geocerca.controller.ts: Geofence event endpoints Total: 9 new controllers following Express Router pattern Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
427 lines
13 KiB
TypeScript
427 lines
13 KiB
TypeScript
/**
|
|
* EventoGeocerca Controller
|
|
* ERP Transportistas
|
|
*
|
|
* REST API endpoints for geofence events (entry/exit/permanence).
|
|
* 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 {
|
|
EventoGeocercaService,
|
|
EventoGeocercaFilters,
|
|
DateRange,
|
|
} from '../services/evento-geocerca.service';
|
|
import { TipoEventoGeocerca } from '../entities/evento-geocerca.entity';
|
|
|
|
interface TenantRequest extends Request {
|
|
tenantId?: string;
|
|
userId?: string;
|
|
}
|
|
|
|
export function createEventoGeocercaController(dataSource: DataSource): Router {
|
|
const router = Router();
|
|
const service = new EventoGeocercaService(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);
|
|
|
|
/**
|
|
* Helper to parse date range from query parameters
|
|
*/
|
|
const parseDateRange = (query: any): DateRange => {
|
|
const now = new Date();
|
|
const defaultStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7 days ago
|
|
return {
|
|
fechaInicio: query.fechaDesde ? new Date(query.fechaDesde as string) : defaultStart,
|
|
fechaFin: query.fechaHasta ? new Date(query.fechaHasta as string) : now,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* List geofence events with filters
|
|
* GET /api/gps/eventos-geocerca
|
|
* Query params: geocercaId, unidadId, viajeId, tipoEvento, fechaDesde, fechaHasta, page, limit
|
|
*/
|
|
router.get('/', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const filters: EventoGeocercaFilters = {
|
|
geocercaId: req.query.geocercaId as string,
|
|
unidadId: req.query.unidadId as string,
|
|
viajeId: req.query.viajeId as string,
|
|
tipoEvento: req.query.tipoEvento as TipoEventoGeocerca,
|
|
dispositivoId: req.query.dispositivoId as string,
|
|
};
|
|
|
|
const pagination = {
|
|
page: parseInt(req.query.page as string, 10) || 1,
|
|
limit: Math.min(parseInt(req.query.limit as string, 10) || 50, 200),
|
|
};
|
|
|
|
const result = await service.findAll(req.tenantId!, filters, pagination);
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get geofence alerts (events marked with alerts)
|
|
* GET /api/gps/eventos-geocerca/alertas
|
|
*/
|
|
router.get('/alertas', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const dateRange = parseDateRange(req.query);
|
|
const limit = parseInt(req.query.limit as string, 10) || 50;
|
|
|
|
// Get recent events and filter those with alerts
|
|
const result = await service.findAll(
|
|
req.tenantId!,
|
|
{},
|
|
{ page: 1, limit: 500 }
|
|
);
|
|
|
|
const alertas = result.data.filter(
|
|
(evento) =>
|
|
evento.metadata?.alertaTriggered === true &&
|
|
evento.tiempoEvento >= dateRange.fechaInicio &&
|
|
evento.tiempoEvento <= dateRange.fechaFin
|
|
);
|
|
|
|
res.json({
|
|
data: alertas.slice(0, limit),
|
|
total: alertas.length,
|
|
fechaDesde: dateRange.fechaInicio,
|
|
fechaHasta: dateRange.fechaFin,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get summary statistics by geofence
|
|
* GET /api/gps/eventos-geocerca/resumen/geocerca/:geocercaId
|
|
*/
|
|
router.get('/resumen/geocerca/:geocercaId', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const dateRange = parseDateRange(req.query);
|
|
const estadisticas = await service.getEstadisticas(
|
|
req.tenantId!,
|
|
req.params.geocercaId,
|
|
dateRange
|
|
);
|
|
res.json(estadisticas);
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get summary statistics by unit
|
|
* GET /api/gps/eventos-geocerca/resumen/unidad/:unidadId
|
|
*/
|
|
router.get('/resumen/unidad/:unidadId', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const dateRange = parseDateRange(req.query);
|
|
|
|
// Get events for this unit
|
|
const eventos = await service.findByUnidad(
|
|
req.tenantId!,
|
|
req.params.unidadId,
|
|
dateRange
|
|
);
|
|
|
|
// Calculate summary statistics
|
|
const geocercasVisitadas = new Set<string>();
|
|
let totalEntradas = 0;
|
|
let totalSalidas = 0;
|
|
let totalPermanencias = 0;
|
|
|
|
for (const evento of eventos) {
|
|
geocercasVisitadas.add(evento.geocercaId);
|
|
switch (evento.tipoEvento) {
|
|
case TipoEventoGeocerca.ENTRADA:
|
|
totalEntradas++;
|
|
break;
|
|
case TipoEventoGeocerca.SALIDA:
|
|
totalSalidas++;
|
|
break;
|
|
case TipoEventoGeocerca.PERMANENCIA:
|
|
totalPermanencias++;
|
|
break;
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
unidadId: req.params.unidadId,
|
|
totalEventos: eventos.length,
|
|
totalEntradas,
|
|
totalSalidas,
|
|
totalPermanencias,
|
|
geocercasVisitadas: geocercasVisitadas.size,
|
|
geocercaIds: Array.from(geocercasVisitadas),
|
|
periodoInicio: dateRange.fechaInicio,
|
|
periodoFin: dateRange.fechaFin,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get dwell time (permanence) statistics by geofence
|
|
* GET /api/gps/eventos-geocerca/permanencia/geocerca/:geocercaId
|
|
*/
|
|
router.get('/permanencia/geocerca/:geocercaId', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const dateRange = parseDateRange(req.query);
|
|
|
|
// Get events for this geofence
|
|
const eventos = await service.findByGeocerca(
|
|
req.tenantId!,
|
|
req.params.geocercaId,
|
|
dateRange
|
|
);
|
|
|
|
// Group events by unit and calculate dwell times
|
|
const unidadEventos: Record<string, typeof eventos> = {};
|
|
for (const evento of eventos) {
|
|
if (!unidadEventos[evento.unidadId]) {
|
|
unidadEventos[evento.unidadId] = [];
|
|
}
|
|
unidadEventos[evento.unidadId].push(evento);
|
|
}
|
|
|
|
const tiemposPorUnidad: Array<{
|
|
unidadId: string;
|
|
tiempoTotalMinutos: number;
|
|
visitas: number;
|
|
promedioMinutosPorVisita: number;
|
|
}> = [];
|
|
|
|
for (const [unidadId, eventosUnidad] of Object.entries(unidadEventos)) {
|
|
const tiempoInfo = await service.getTiempoEnGeocerca(
|
|
req.tenantId!,
|
|
unidadId,
|
|
req.params.geocercaId
|
|
);
|
|
tiemposPorUnidad.push({
|
|
unidadId,
|
|
tiempoTotalMinutos: tiempoInfo.tiempoTotalMinutos,
|
|
visitas: tiempoInfo.visitasTotales,
|
|
promedioMinutosPorVisita: tiempoInfo.promedioMinutosPorVisita,
|
|
});
|
|
}
|
|
|
|
// Sort by total time descending
|
|
tiemposPorUnidad.sort((a, b) => b.tiempoTotalMinutos - a.tiempoTotalMinutos);
|
|
|
|
const tiempoTotalGeocerca = tiemposPorUnidad.reduce(
|
|
(sum, t) => sum + t.tiempoTotalMinutos,
|
|
0
|
|
);
|
|
const visitasTotales = tiemposPorUnidad.reduce((sum, t) => sum + t.visitas, 0);
|
|
|
|
res.json({
|
|
geocercaId: req.params.geocercaId,
|
|
tiempoTotalMinutos: Math.round(tiempoTotalGeocerca * 100) / 100,
|
|
visitasTotales,
|
|
unidadesUnicas: tiemposPorUnidad.length,
|
|
promedioMinutosPorVisita:
|
|
visitasTotales > 0
|
|
? Math.round((tiempoTotalGeocerca / visitasTotales) * 100) / 100
|
|
: 0,
|
|
detallesPorUnidad: tiemposPorUnidad,
|
|
periodoInicio: dateRange.fechaInicio,
|
|
periodoFin: dateRange.fechaFin,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get events by geofence
|
|
* GET /api/gps/eventos-geocerca/geocerca/:geocercaId
|
|
*/
|
|
router.get('/geocerca/:geocercaId', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const dateRange = parseDateRange(req.query);
|
|
const eventos = await service.findByGeocerca(
|
|
req.tenantId!,
|
|
req.params.geocercaId,
|
|
dateRange
|
|
);
|
|
res.json({
|
|
data: eventos,
|
|
total: eventos.length,
|
|
geocercaId: req.params.geocercaId,
|
|
fechaDesde: dateRange.fechaInicio,
|
|
fechaHasta: dateRange.fechaFin,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get events by unit
|
|
* GET /api/gps/eventos-geocerca/unidad/:unidadId
|
|
*/
|
|
router.get('/unidad/:unidadId', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const dateRange = parseDateRange(req.query);
|
|
const eventos = await service.findByUnidad(
|
|
req.tenantId!,
|
|
req.params.unidadId,
|
|
dateRange
|
|
);
|
|
res.json({
|
|
data: eventos,
|
|
total: eventos.length,
|
|
unidadId: req.params.unidadId,
|
|
fechaDesde: dateRange.fechaInicio,
|
|
fechaHasta: dateRange.fechaFin,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get events by trip
|
|
* GET /api/gps/eventos-geocerca/viaje/:viajeId
|
|
*/
|
|
router.get('/viaje/:viajeId', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const filters: EventoGeocercaFilters = {
|
|
viajeId: req.params.viajeId,
|
|
};
|
|
|
|
const result = await service.findAll(req.tenantId!, filters, { page: 1, limit: 500 });
|
|
res.json({
|
|
data: result.data,
|
|
total: result.total,
|
|
viajeId: req.params.viajeId,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Register a new geofence event
|
|
* POST /api/gps/eventos-geocerca
|
|
*/
|
|
router.post('/', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const evento = await service.create(req.tenantId!, {
|
|
geocercaId: req.body.geocercaId,
|
|
dispositivoId: req.body.dispositivoId,
|
|
unidadId: req.body.unidadId,
|
|
tipoEvento: req.body.tipoEvento,
|
|
posicionId: req.body.posicionId,
|
|
latitud: parseFloat(req.body.latitud),
|
|
longitud: parseFloat(req.body.longitud),
|
|
tiempoEvento: new Date(req.body.tiempoEvento),
|
|
viajeId: req.body.viajeId,
|
|
metadata: req.body.metadata,
|
|
});
|
|
res.status(201).json(evento);
|
|
} catch (error) {
|
|
res.status(400).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Trigger alert for an event
|
|
* POST /api/gps/eventos-geocerca/:id/alerta
|
|
*/
|
|
router.post('/:id/alerta', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const evento = await service.triggerAlerta(req.tenantId!, req.params.id);
|
|
if (!evento) {
|
|
return res.status(404).json({ error: 'Evento de geocerca no encontrado' });
|
|
}
|
|
res.json(evento);
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get units currently inside a geofence
|
|
* GET /api/gps/eventos-geocerca/geocerca/:geocercaId/unidades-dentro
|
|
*/
|
|
router.get('/geocerca/:geocercaId/unidades-dentro', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const unidades = await service.getUnidadesEnGeocerca(
|
|
req.tenantId!,
|
|
req.params.geocercaId
|
|
);
|
|
res.json({
|
|
data: unidades,
|
|
total: unidades.length,
|
|
geocercaId: req.params.geocercaId,
|
|
timestamp: new Date(),
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get event by ID
|
|
* GET /api/gps/eventos-geocerca/:id
|
|
*/
|
|
router.get('/:id', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const result = await service.findAll(
|
|
req.tenantId!,
|
|
{},
|
|
{ page: 1, limit: 1 }
|
|
);
|
|
|
|
// Find by ID in result
|
|
const evento = result.data.find((e) => e.id === req.params.id);
|
|
if (!evento) {
|
|
return res.status(404).json({ error: 'Evento de geocerca no encontrado' });
|
|
}
|
|
res.json(evento);
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Delete old events (data retention)
|
|
* DELETE /api/gps/eventos-geocerca/antiguos
|
|
*/
|
|
router.delete('/antiguos', async (req: TenantRequest, res: Response) => {
|
|
try {
|
|
const antesDe = new Date(req.query.antesDe as string);
|
|
if (isNaN(antesDe.getTime())) {
|
|
return res.status(400).json({ error: 'Fecha inválida. Use formato ISO 8601.' });
|
|
}
|
|
const eliminados = await service.eliminarEventosAntiguos(req.tenantId!, antesDe);
|
|
res.json({ eliminados });
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|