# Integracion Geofencing (Geocercas Avanzadas) **Proyecto:** ERP Transportistas | **Version:** 1.0.0 | **Actualizado:** 2026-01-27 **Referencia GAP:** GAP-006 - Geocercas Avanzadas (dwell time, triggers, categorias) --- ## Descripcion General El sistema de geofencing de ERP Transportistas permite definir zonas geograficas de interes (geocercas) y detectar automaticamente cuando las unidades de transporte entran o salen de estas zonas. La implementacion utiliza PostGIS para consultas espaciales de alto rendimiento y soporta geocercas circulares y poligonales con multiples categorias y triggers configurables. --- ## Tipos de Geocercas ### Geocerca Circular Definida por un punto central (latitud, longitud) y un radio en metros. Es la mas eficiente computacionalmente y adecuada para ubicaciones puntuales. ``` Radio (metros) | v +------+------+ / \ / \ | X | <- Centro (lat, lon) \ / \ / +------+------+ ``` **Casos de uso:** - Clientes (ubicacion de entrega) - Gasolineras - Casetas de peaje - Puntos de control **Definicion:** ```json { "tipo": "circular", "centro": { "latitud": 19.4326, "longitud": -99.1332 }, "radioMetros": 500 } ``` --- ### Geocerca Poligonal Definida por un conjunto de vertices que forman un poligono cerrado en formato GeoJSON. Permite definir zonas irregulares con precision. ``` A /\ / \ / \ / \ B--------C | | | | D--------E ``` **Casos de uso:** - Zonas de riesgo (colonias, carreteras) - Patios y bodegas (perimetro exacto) - Zonas de cobertura de servicio - Areas restringidas **Definicion (GeoJSON):** ```json { "tipo": "poligonal", "poligono": { "type": "Polygon", "coordinates": [[ [-99.1400, 19.4400], [-99.1200, 19.4400], [-99.1200, 19.4200], [-99.1400, 19.4200], [-99.1400, 19.4400] ]] } } ``` **Nota:** El primer y ultimo punto deben ser identicos para cerrar el poligono. --- ## Categorias de Geocercas El sistema soporta las siguientes categorias predefinidas: | Categoria | Codigo | Descripcion | Uso Tipico | |-----------|--------|-------------|------------| | Base | `base` | Patios, bodegas, centros de distribucion propios | Control de salidas/llegadas de flota | | Cobertura | `coverage` | Zonas geograficas de servicio | Validar que servicios esten dentro de zona | | Restringida | `restricted` | Areas donde no debe transitar la unidad | Alertas de desvio, zonas prohibidas | | Alto Riesgo | `high_risk` | Colonias/carreteras con indice de inseguridad | Alertas de precaucion, seguimiento intensivo | | Cliente | `client` | Ubicaciones de entrega/recoleccion de clientes | Deteccion de arribo, calculo de dwell time | | Area de Descanso | `rest_area` | Estacionamientos, hoteles, areas de descanso | Control de HOS, paradas autorizadas | | Caseta | `toll` | Casetas de peaje | Validacion de rutas, control de gastos | | Personalizada | `custom` | Definida por el usuario | Cualquier proposito especifico | ### Enum en Base de Datos ```sql CREATE TYPE tracking.categoria_geocerca AS ENUM ( 'base', 'coverage', 'restricted', 'high_risk', 'client', 'rest_area', 'toll', 'custom' ); ``` --- ## Configuracion de Triggers Cada geocerca puede configurar triggers independientes para entrada, salida y permanencia. ### triggerOnEnter Se activa cuando una unidad ingresa a la geocerca (transicion de fuera a dentro). ```json { "triggerOnEnter": true, "onEnter": { "generarEvento": true, "tipoEvento": "ENTRADA_GEOCERCA", "generarAlerta": true, "severidadAlerta": "INFO", "notificar": { "email": ["trafico@empresa.com"], "push": true, "sms": false } } } ``` ### triggerOnExit Se activa cuando una unidad sale de la geocerca (transicion de dentro a fuera). ```json { "triggerOnExit": true, "onExit": { "generarEvento": true, "tipoEvento": "SALIDA_GEOCERCA", "generarAlerta": true, "severidadAlerta": "INFO", "notificar": { "email": ["trafico@empresa.com"], "push": true } } } ``` ### dwellTimeSeconds (Tiempo de Permanencia) Genera un evento/alerta cuando la unidad permanece dentro de la geocerca por mas del tiempo especificado. ```json { "dwellTimeSeconds": 1800, "onDwellExceeded": { "generarAlerta": true, "severidadAlerta": "WARNING", "tipoAlerta": "PARADA_PROLONGADA", "mensaje": "Unidad {unidad} ha permanecido mas de 30 minutos en {geocerca}", "notificar": { "email": ["supervisores@empresa.com"], "push": true } } } ``` ### Combinacion de Triggers Una geocerca puede tener multiples triggers activos: ```json { "codigo": "CLIENTE-WALMART-CDMX-01", "nombre": "Walmart CEDIS CDMX", "categoria": "client", "triggerOnEnter": true, "triggerOnExit": true, "dwellTimeSeconds": 7200, "onEnter": { "generarEvento": true, "tipoEvento": "ARRIBO_CLIENTE", "notificar": { "push": true } }, "onExit": { "generarEvento": true, "tipoEvento": "SALIDA_CLIENTE" }, "onDwellExceeded": { "generarAlerta": true, "severidadAlerta": "WARNING", "tipoAlerta": "DETENTION_TIME", "mensaje": "Detention time excedido: mas de 2 horas en cliente" } } ``` --- ## Entidades de Base de Datos ### Geofence (Geocerca) **Tabla:** `tracking.geocercas` | Campo | Tipo | Descripcion | |-------|------|-------------| | id | UUID | Identificador unico | | tenant_id | UUID | Tenant propietario | | codigo | VARCHAR(50) | Codigo unico por tenant | | nombre | VARCHAR(200) | Nombre descriptivo | | descripcion | TEXT | Descripcion detallada | | categoria | categoria_geocerca | Categoria de la geocerca | | es_circular | BOOLEAN | True = circular, False = poligonal | | centro_latitud | DECIMAL(10,7) | Latitud del centro (circular) | | centro_longitud | DECIMAL(10,7) | Longitud del centro (circular) | | radio_metros | INTEGER | Radio en metros (circular) | | poligono | GEOMETRY(POLYGON, 4326) | Geometria PostGIS (poligonal) | | trigger_on_enter | BOOLEAN | Activar trigger de entrada | | trigger_on_exit | BOOLEAN | Activar trigger de salida | | dwell_time_seconds | INTEGER | Tiempo maximo de permanencia | | config_triggers | JSONB | Configuracion detallada de triggers | | color | VARCHAR(7) | Color hex para visualizacion (#RRGGBB) | | cliente_id | UUID | FK a cliente asociado (opcional) | | direccion | TEXT | Direccion textual | | activa | BOOLEAN | Estado activo/inactivo | | metadata | JSONB | Datos adicionales | | created_at | TIMESTAMPTZ | Fecha de creacion | | updated_at | TIMESTAMPTZ | Fecha de actualizacion | | created_by_id | UUID | Usuario creador | **DDL:** ```sql CREATE TABLE tracking.geocercas ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES core.tenants(id), codigo VARCHAR(50) NOT NULL, nombre VARCHAR(200) NOT NULL, descripcion TEXT, categoria tracking.categoria_geocerca NOT NULL DEFAULT 'custom', es_circular BOOLEAN NOT NULL DEFAULT TRUE, centro_latitud DECIMAL(10,7), centro_longitud DECIMAL(10,7), radio_metros INTEGER, poligono GEOMETRY(POLYGON, 4326), trigger_on_enter BOOLEAN NOT NULL DEFAULT TRUE, trigger_on_exit BOOLEAN NOT NULL DEFAULT TRUE, dwell_time_seconds INTEGER, config_triggers JSONB DEFAULT '{}', color VARCHAR(7) DEFAULT '#3388FF', cliente_id UUID REFERENCES partners.clientes(id), direccion TEXT, activa BOOLEAN NOT NULL DEFAULT TRUE, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by_id UUID, CONSTRAINT uq_geocerca_tenant_codigo UNIQUE (tenant_id, codigo), CONSTRAINT chk_geocerca_circular CHECK ( (es_circular = TRUE AND centro_latitud IS NOT NULL AND centro_longitud IS NOT NULL AND radio_metros IS NOT NULL) OR (es_circular = FALSE AND poligono IS NOT NULL) ) ); -- Indices CREATE INDEX idx_geocerca_tenant ON tracking.geocercas(tenant_id); CREATE INDEX idx_geocerca_categoria ON tracking.geocercas(tenant_id, categoria) WHERE activa = TRUE; CREATE INDEX idx_geocerca_cliente ON tracking.geocercas(cliente_id) WHERE cliente_id IS NOT NULL; CREATE INDEX idx_geocerca_geo USING GIST (poligono); -- RLS ALTER TABLE tracking.geocercas ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_geocercas ON tracking.geocercas USING (tenant_id = current_setting('app.tenant_id')::uuid); ``` --- ### GeofenceEvent (Evento de Geocerca) **Tabla:** `tracking.geofence_events` | Campo | Tipo | Descripcion | |-------|------|-------------| | id | UUID | Identificador unico | | tenant_id | UUID | Tenant propietario | | geocerca_id | UUID | FK a geocercas | | unidad_id | UUID | FK a unidades | | viaje_id | UUID | FK a viajes (opcional) | | tipo_evento | VARCHAR(20) | ENTER, EXIT, DWELL_EXCEEDED | | posicion_id | UUID | FK a posiciones_gps | | latitud | DECIMAL(10,7) | Latitud del evento | | longitud | DECIMAL(10,7) | Longitud del evento | | timestamp_evento | TIMESTAMPTZ | Fecha/hora del evento | | dwell_time_actual | INTEGER | Segundos de permanencia (para DWELL) | | alerta_generada_id | UUID | FK a alerta generada (si aplica) | | datos | JSONB | Datos adicionales | | created_at | TIMESTAMPTZ | Fecha de creacion | **DDL:** ```sql CREATE TABLE tracking.geofence_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES core.tenants(id), geocerca_id UUID NOT NULL REFERENCES tracking.geocercas(id), unidad_id UUID NOT NULL REFERENCES fleet.unidades(id), viaje_id UUID REFERENCES transport.viajes(id), tipo_evento VARCHAR(20) NOT NULL, posicion_id UUID REFERENCES tracking.posiciones_gps(id), latitud DECIMAL(10,7) NOT NULL, longitud DECIMAL(10,7) NOT NULL, timestamp_evento TIMESTAMPTZ NOT NULL, dwell_time_actual INTEGER, alerta_generada_id UUID REFERENCES tracking.alertas(id), datos JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT chk_tipo_evento CHECK (tipo_evento IN ('ENTER', 'EXIT', 'DWELL_EXCEEDED')) ); -- Indices CREATE INDEX idx_geofence_event_tenant ON tracking.geofence_events(tenant_id); CREATE INDEX idx_geofence_event_geocerca ON tracking.geofence_events(geocerca_id); CREATE INDEX idx_geofence_event_unidad ON tracking.geofence_events(unidad_id); CREATE INDEX idx_geofence_event_viaje ON tracking.geofence_events(viaje_id) WHERE viaje_id IS NOT NULL; CREATE INDEX idx_geofence_event_fecha ON tracking.geofence_events(timestamp_evento); -- RLS ALTER TABLE tracking.geofence_events ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_geofence_events ON tracking.geofence_events USING (tenant_id = current_setting('app.tenant_id')::uuid); ``` --- ## API Endpoints ### Crear Geocerca ``` POST /api/gps/geofences ``` **Request - Geocerca Circular:** ```json { "codigo": "CLIENTE-WALMART-01", "nombre": "Walmart CEDIS Cuautitlan", "categoria": "client", "esCircular": true, "centroLatitud": 19.6789, "centroLongitud": -99.1234, "radioMetros": 300, "triggerOnEnter": true, "triggerOnExit": true, "dwellTimeSeconds": 3600, "configTriggers": { "onEnter": { "generarEvento": true, "tipoEvento": "ARRIBO_CLIENTE" }, "onExit": { "generarEvento": true, "tipoEvento": "SALIDA_CLIENTE" }, "onDwellExceeded": { "generarAlerta": true, "severidadAlerta": "WARNING" } }, "color": "#FF5733", "clienteId": "uuid-cliente", "direccion": "Av. Industrial 123, Cuautitlan Izcalli" } ``` **Request - Geocerca Poligonal:** ```json { "codigo": "ZONA-RIESGO-ECATEPEC", "nombre": "Zona de Alto Riesgo - Ecatepec Centro", "categoria": "high_risk", "esCircular": false, "poligono": { "type": "Polygon", "coordinates": [[ [-99.0500, 19.6100], [-99.0300, 19.6100], [-99.0300, 19.5900], [-99.0500, 19.5900], [-99.0500, 19.6100] ]] }, "triggerOnEnter": true, "triggerOnExit": false, "configTriggers": { "onEnter": { "generarAlerta": true, "severidadAlerta": "WARNING", "tipoAlerta": "ZONA_RIESGO", "notificar": { "push": true, "sms": true } } }, "color": "#DC3545" } ``` **Response:** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "codigo": "CLIENTE-WALMART-01", "nombre": "Walmart CEDIS Cuautitlan", "categoria": "client", "activa": true, "createdAt": "2026-01-27T10:00:00Z" } ``` --- ### Listar Geocercas ``` GET /api/gps/geofences ``` **Query Parameters:** | Parametro | Tipo | Descripcion | |-----------|------|-------------| | categoria | string | Filtrar por categoria | | activa | boolean | Filtrar por estado | | clienteId | UUID | Filtrar por cliente | | bbox | string | Bounding box: minLon,minLat,maxLon,maxLat | | page | number | Pagina (default 1) | | limit | number | Limite (default 50) | **Response:** ```json { "data": [ { "id": "uuid-1", "codigo": "CLIENTE-WALMART-01", "nombre": "Walmart CEDIS Cuautitlan", "categoria": "client", "esCircular": true, "centroLatitud": 19.6789, "centroLongitud": -99.1234, "radioMetros": 300, "triggerOnEnter": true, "triggerOnExit": true, "dwellTimeSeconds": 3600, "color": "#FF5733", "activa": true } ], "pagination": { "page": 1, "limit": 50, "total": 125, "pages": 3 } } ``` --- ### Obtener Geocerca por ID ``` GET /api/gps/geofences/:id ``` **Response:** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "codigo": "CLIENTE-WALMART-01", "nombre": "Walmart CEDIS Cuautitlan", "descripcion": "Centro de distribucion principal", "categoria": "client", "esCircular": true, "centroLatitud": 19.6789, "centroLongitud": -99.1234, "radioMetros": 300, "triggerOnEnter": true, "triggerOnExit": true, "dwellTimeSeconds": 3600, "configTriggers": { "onEnter": { "generarEvento": true, "tipoEvento": "ARRIBO_CLIENTE" }, "onExit": { "generarEvento": true, "tipoEvento": "SALIDA_CLIENTE" }, "onDwellExceeded": { "generarAlerta": true, "severidadAlerta": "WARNING" } }, "color": "#FF5733", "clienteId": "uuid-cliente", "cliente": { "id": "uuid-cliente", "nombre": "Walmart de Mexico" }, "direccion": "Av. Industrial 123, Cuautitlan Izcalli", "activa": true, "metadata": {}, "createdAt": "2026-01-27T10:00:00Z", "updatedAt": "2026-01-27T10:00:00Z" } ``` --- ### Actualizar Geocerca ``` PUT /api/gps/geofences/:id ``` **Request:** ```json { "nombre": "Walmart CEDIS Cuautitlan - Actualizado", "radioMetros": 350, "dwellTimeSeconds": 5400 } ``` --- ### Eliminar/Desactivar Geocerca ``` DELETE /api/gps/geofences/:id ``` Realiza soft-delete (activa = false). --- ### Verificar Posicion en Geocercas ``` POST /api/gps/geofences/check ``` Verifica si una posicion esta dentro de alguna geocerca activa. **Request:** ```json { "latitud": 19.6790, "longitud": -99.1235, "categorias": ["client", "high_risk"] } ``` **Response:** ```json { "dentro": true, "geocercas": [ { "id": "uuid-1", "codigo": "CLIENTE-WALMART-01", "nombre": "Walmart CEDIS Cuautitlan", "categoria": "client", "distanciaMetros": 45 } ] } ``` --- ### Registrar Evento de Geocerca ``` POST /api/gps/geofences/events ``` Endpoint interno para registrar eventos de geocerca (usado por el procesador de posiciones). **Request:** ```json { "geocercaId": "uuid-geocerca", "unidadId": "uuid-unidad", "viajeId": "uuid-viaje", "tipoEvento": "ENTER", "posicionId": "uuid-posicion", "latitud": 19.6790, "longitud": -99.1235, "timestampEvento": "2026-01-27T14:30:00Z" } ``` --- ### Consultar Eventos de Geocerca ``` GET /api/gps/geofences/events ``` **Query Parameters:** | Parametro | Tipo | Descripcion | |-----------|------|-------------| | geocercaId | UUID | Filtrar por geocerca | | unidadId | UUID | Filtrar por unidad | | viajeId | UUID | Filtrar por viaje | | tipoEvento | string | ENTER, EXIT, DWELL_EXCEEDED | | desde | datetime | Fecha inicio | | hasta | datetime | Fecha fin | **Response:** ```json { "data": [ { "id": "uuid-evento", "geocerca": { "id": "uuid-geocerca", "codigo": "CLIENTE-WALMART-01", "nombre": "Walmart CEDIS Cuautitlan" }, "unidad": { "id": "uuid-unidad", "numeroEconomico": "T-001" }, "tipoEvento": "ENTER", "latitud": 19.6790, "longitud": -99.1235, "timestampEvento": "2026-01-27T14:30:00Z", "dwellTimeActual": null }, { "id": "uuid-evento-2", "geocerca": { "id": "uuid-geocerca", "codigo": "CLIENTE-WALMART-01", "nombre": "Walmart CEDIS Cuautitlan" }, "unidad": { "id": "uuid-unidad", "numeroEconomico": "T-001" }, "tipoEvento": "DWELL_EXCEEDED", "latitud": 19.6790, "longitud": -99.1235, "timestampEvento": "2026-01-27T15:30:00Z", "dwellTimeActual": 3605 } ] } ``` --- ## Consultas PostGIS ### Verificar si un punto esta dentro de una geocerca circular ```sql -- Usando ST_DWithin para geocercas circulares SELECT g.id, g.codigo, g.nombre FROM tracking.geocercas g WHERE g.tenant_id = current_setting('app.tenant_id')::uuid AND g.activa = TRUE AND g.es_circular = TRUE AND ST_DWithin( ST_SetSRID(ST_MakePoint(g.centro_longitud, g.centro_latitud), 4326)::geography, ST_SetSRID(ST_MakePoint(-99.1235, 19.6790), 4326)::geography, g.radio_metros ); ``` ### Verificar si un punto esta dentro de una geocerca poligonal ```sql -- Usando ST_Contains para geocercas poligonales SELECT g.id, g.codigo, g.nombre FROM tracking.geocercas g WHERE g.tenant_id = current_setting('app.tenant_id')::uuid AND g.activa = TRUE AND g.es_circular = FALSE AND ST_Contains( g.poligono, ST_SetSRID(ST_MakePoint(-99.1235, 19.6790), 4326) ); ``` ### Buscar todas las geocercas que contienen un punto ```sql -- Consulta combinada para ambos tipos WITH punto AS ( SELECT ST_SetSRID(ST_MakePoint(-99.1235, 19.6790), 4326) AS geom ) SELECT g.id, g.codigo, g.nombre, g.categoria, CASE WHEN g.es_circular THEN ST_Distance( ST_SetSRID(ST_MakePoint(g.centro_longitud, g.centro_latitud), 4326)::geography, (SELECT geom FROM punto)::geography ) ELSE ST_Distance( ST_Centroid(g.poligono)::geography, (SELECT geom FROM punto)::geography ) END AS distancia_metros FROM tracking.geocercas g, punto p WHERE g.tenant_id = current_setting('app.tenant_id')::uuid AND g.activa = TRUE AND ( (g.es_circular = TRUE AND ST_DWithin( ST_SetSRID(ST_MakePoint(g.centro_longitud, g.centro_latitud), 4326)::geography, p.geom::geography, g.radio_metros )) OR (g.es_circular = FALSE AND ST_Contains(g.poligono, p.geom)) ) ORDER BY distancia_metros; ``` ### Buscar geocercas dentro de un bounding box ```sql -- Geocercas dentro de un area rectangular (para carga de mapa) SELECT g.* FROM tracking.geocercas g WHERE g.tenant_id = current_setting('app.tenant_id')::uuid AND g.activa = TRUE AND ( (g.es_circular = TRUE AND ST_Intersects( ST_SetSRID(ST_MakePoint(g.centro_longitud, g.centro_latitud), 4326), ST_MakeEnvelope(-99.20, 19.35, -99.05, 19.55, 4326) )) OR (g.es_circular = FALSE AND ST_Intersects( g.poligono, ST_MakeEnvelope(-99.20, 19.35, -99.05, 19.55, 4326) )) ); ``` ### Calcular distancia de un punto al borde de la geocerca ```sql -- Distancia al perimetro (para alertas de aproximacion) SELECT g.id, g.codigo, CASE WHEN g.es_circular THEN GREATEST(0, ST_Distance( ST_SetSRID(ST_MakePoint(g.centro_longitud, g.centro_latitud), 4326)::geography, ST_SetSRID(ST_MakePoint(-99.1235, 19.6790), 4326)::geography ) - g.radio_metros) ELSE ST_Distance( ST_Boundary(g.poligono)::geography, ST_SetSRID(ST_MakePoint(-99.1235, 19.6790), 4326)::geography ) END AS distancia_al_borde_metros FROM tracking.geocercas g WHERE g.tenant_id = current_setting('app.tenant_id')::uuid AND g.activa = TRUE; ``` --- ## Procesamiento de Eventos ### Motor de Geocercas El motor de geocercas procesa cada posicion GPS recibida: ```typescript // src/modules/tracking/services/geofence-engine.service.ts @Injectable() export class GeofenceEngineService { constructor( private readonly geocercaRepository: GeocercaRepository, private readonly geofenceEventRepository: GeofenceEventRepository, private readonly alertService: AlertService, private readonly dwellTracker: DwellTimeTrackerService, ) {} async processPosition(position: GpsPosition, unidadId: string, viajeId?: string): Promise { // 1. Obtener geocercas activas del tenant const geocercas = await this.geocercaRepository.findActiveGeocercas(); // 2. Verificar cada geocerca for (const geocerca of geocercas) { const dentroAhora = await this.isInsideGeocerca(position, geocerca); const dentroAntes = await this.wasInsideGeocerca(unidadId, geocerca.id); // 3. Detectar transiciones if (dentroAhora && !dentroAntes) { // ENTRADA await this.handleEnter(geocerca, position, unidadId, viajeId); } else if (!dentroAhora && dentroAntes) { // SALIDA await this.handleExit(geocerca, position, unidadId, viajeId); } else if (dentroAhora && dentroAntes) { // DENTRO - verificar dwell time await this.checkDwellTime(geocerca, position, unidadId, viajeId); } // 4. Actualizar estado await this.updateGeocercaState(unidadId, geocerca.id, dentroAhora); } } private async handleEnter( geocerca: Geocerca, position: GpsPosition, unidadId: string, viajeId?: string ): Promise { // Registrar evento const evento = await this.geofenceEventRepository.create({ geocercaId: geocerca.id, unidadId, viajeId, tipoEvento: 'ENTER', latitud: position.latitude, longitud: position.longitude, timestampEvento: position.timestamp, }); // Iniciar tracking de dwell time if (geocerca.dwellTimeSeconds) { await this.dwellTracker.startTracking(unidadId, geocerca.id, position.timestamp); } // Generar alerta si configurado if (geocerca.triggerOnEnter && geocerca.configTriggers?.onEnter?.generarAlerta) { await this.alertService.create({ tipo: geocerca.configTriggers.onEnter.tipoAlerta || 'ENTRADA_GEOCERCA', severidad: geocerca.configTriggers.onEnter.severidadAlerta || 'INFO', unidadId, viajeId, geocercaId: geocerca.id, titulo: `Entrada a geocerca: ${geocerca.nombre}`, mensaje: `La unidad ingreso a ${geocerca.nombre}`, latitud: position.latitude, longitud: position.longitude, }); } } private async checkDwellTime( geocerca: Geocerca, position: GpsPosition, unidadId: string, viajeId?: string ): Promise { if (!geocerca.dwellTimeSeconds) return; const dwellInfo = await this.dwellTracker.getDwellTime(unidadId, geocerca.id); if (!dwellInfo || dwellInfo.alertaGenerada) return; const dwellActual = Math.floor((position.timestamp.getTime() - dwellInfo.entradaAt.getTime()) / 1000); if (dwellActual >= geocerca.dwellTimeSeconds) { // Registrar evento de dwell excedido await this.geofenceEventRepository.create({ geocercaId: geocerca.id, unidadId, viajeId, tipoEvento: 'DWELL_EXCEEDED', latitud: position.latitude, longitud: position.longitude, timestampEvento: position.timestamp, dwellTimeActual: dwellActual, }); // Generar alerta if (geocerca.configTriggers?.onDwellExceeded?.generarAlerta) { await this.alertService.create({ tipo: geocerca.configTriggers.onDwellExceeded.tipoAlerta || 'PARADA_PROLONGADA', severidad: geocerca.configTriggers.onDwellExceeded.severidadAlerta || 'WARNING', unidadId, viajeId, geocercaId: geocerca.id, titulo: `Tiempo excedido en: ${geocerca.nombre}`, mensaje: `La unidad ha permanecido ${Math.floor(dwellActual / 60)} minutos en ${geocerca.nombre}`, latitud: position.latitude, longitud: position.longitude, }); } // Marcar que ya se genero alerta await this.dwellTracker.markAlertGenerated(unidadId, geocerca.id); } } } ``` ### Servicio de Tracking de Dwell Time ```typescript // src/modules/tracking/services/dwell-time-tracker.service.ts @Injectable() export class DwellTimeTrackerService { constructor( @InjectRedis() private readonly redis: Redis, ) {} private getKey(unidadId: string, geocercaId: string): string { return `dwell:${unidadId}:${geocercaId}`; } async startTracking(unidadId: string, geocercaId: string, entradaAt: Date): Promise { const key = this.getKey(unidadId, geocercaId); await this.redis.hset(key, { entradaAt: entradaAt.toISOString(), alertaGenerada: 'false', }); // TTL de 24 horas await this.redis.expire(key, 86400); } async getDwellTime(unidadId: string, geocercaId: string): Promise<{ entradaAt: Date; alertaGenerada: boolean; } | null> { const key = this.getKey(unidadId, geocercaId); const data = await this.redis.hgetall(key); if (!data.entradaAt) return null; return { entradaAt: new Date(data.entradaAt), alertaGenerada: data.alertaGenerada === 'true', }; } async markAlertGenerated(unidadId: string, geocercaId: string): Promise { const key = this.getKey(unidadId, geocercaId); await this.redis.hset(key, 'alertaGenerada', 'true'); } async stopTracking(unidadId: string, geocercaId: string): Promise { const key = this.getKey(unidadId, geocercaId); await this.redis.del(key); } } ``` --- ## Consideraciones de Rendimiento ### Indices Espaciales PostGIS utiliza indices GIST para consultas espaciales eficientes: ```sql -- Indice para geocercas poligonales CREATE INDEX idx_geocerca_geo USING GIST (poligono); -- Indice para busquedas por centro (circulares) CREATE INDEX idx_geocerca_centro ON tracking.geocercas USING GIST (ST_SetSRID(ST_MakePoint(centro_longitud, centro_latitud), 4326)) WHERE es_circular = TRUE AND activa = TRUE; ``` ### Cache de Geocercas Las geocercas activas se cachean en Redis para reducir consultas a BD: ```typescript // Estructura de cache const cacheKey = `geocercas:${tenantId}`; const ttl = 300; // 5 minutos // Al crear/actualizar/eliminar geocerca await this.redis.del(cacheKey); ``` ### Procesamiento por Lotes Para alto volumen de posiciones, procesar en lotes: ```typescript // Procesar posiciones en lotes de 100 const BATCH_SIZE = 100; async processBatch(positions: GpsPosition[]): Promise { // 1. Agrupar por tenant const byTenant = groupBy(positions, 'tenantId'); // 2. Procesar cada tenant en paralelo await Promise.all( Object.entries(byTenant).map(([tenantId, tenantPositions]) => this.processTenantPositions(tenantId, tenantPositions) ) ); } ``` --- ## Referencias - [PostGIS Documentation](https://postgis.net/documentation/) - [GeoJSON Specification](https://geojson.org/) - [WGS 84 (SRID 4326)](https://epsg.io/4326) - [ST_DWithin Documentation](https://postgis.net/docs/ST_DWithin.html) - [ST_Contains Documentation](https://postgis.net/docs/ST_Contains.html) --- *Integracion Geofencing - ERP Transportistas v1.0.0*