feat(MMD-014): Add GPS tracking schema

- Create gps_tracking schema with 5 tables
- Tables: gps_devices, gps_positions, geofences, geofence_events, route_segments
- Add PostGIS-compatible distance functions (haversine_distance)
- Add RLS policies for multi-tenant isolation
- Add views: vw_last_positions, vw_device_summary
- Add triggers for automatic last_position updates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-27 01:33:57 -06:00
parent 863ab28b09
commit d72145f0b7

View File

@ -0,0 +1,530 @@
-- ===========================================
-- MECANICAS DIESEL - Schema gps_tracking
-- ===========================================
-- Integracion GPS, posiciones, geocercas, rutas
-- Modulo: MMD-014 GPS Integration
-- -------------------------------------------
-- SCHEMA CREATION
-- -------------------------------------------
CREATE SCHEMA IF NOT EXISTS gps_tracking;
COMMENT ON SCHEMA gps_tracking IS 'Tracking GPS, posiciones en tiempo real, geocercas y calculo de rutas';
GRANT USAGE ON SCHEMA gps_tracking TO mecanicas_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA gps_tracking TO mecanicas_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA gps_tracking GRANT ALL ON TABLES TO mecanicas_user;
SET search_path TO gps_tracking, public;
-- -------------------------------------------
-- GPS_DEVICES - Dispositivos GPS vinculados a unidades
-- -------------------------------------------
CREATE TABLE gps_tracking.gps_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Vinculacion a unidad de flota
unit_id UUID NOT NULL, -- FK a vehicle_management.vehicles
unit_type VARCHAR(20) DEFAULT 'vehicle'
CHECK (unit_type IN ('vehicle', 'trailer', 'equipment', 'technician')),
-- Identificacion externa (plataforma GPS)
external_device_id VARCHAR(100) NOT NULL,
platform VARCHAR(30) NOT NULL DEFAULT 'traccar'
CHECK (platform IN ('traccar', 'wialon', 'samsara', 'geotab', 'manual')),
-- Identificadores del dispositivo
imei VARCHAR(20),
serial_number VARCHAR(50),
phone_number VARCHAR(20),
model VARCHAR(50),
manufacturer VARCHAR(50),
-- Estado
is_active BOOLEAN DEFAULT TRUE,
last_position_at TIMESTAMP WITH TIME ZONE,
last_position_lat DECIMAL(10, 7),
last_position_lng DECIMAL(10, 7),
-- Configuracion
position_interval_seconds INTEGER DEFAULT 30 CHECK (position_interval_seconds >= 5),
metadata JSONB DEFAULT '{}',
-- Auditoria
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by UUID,
CONSTRAINT uq_device_external UNIQUE (tenant_id, platform, external_device_id),
CONSTRAINT uq_device_unit UNIQUE (tenant_id, unit_id)
);
CREATE INDEX idx_gps_devices_tenant ON gps_tracking.gps_devices(tenant_id);
CREATE INDEX idx_gps_devices_unit ON gps_tracking.gps_devices(unit_id);
CREATE INDEX idx_gps_devices_external ON gps_tracking.gps_devices(external_device_id);
CREATE INDEX idx_gps_devices_platform ON gps_tracking.gps_devices(platform);
CREATE INDEX idx_gps_devices_active ON gps_tracking.gps_devices(is_active) WHERE is_active = TRUE;
CREATE TRIGGER trg_gps_devices_updated_at
BEFORE UPDATE ON gps_tracking.gps_devices
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('gps_tracking', 'gps_devices');
COMMENT ON TABLE gps_tracking.gps_devices IS 'Dispositivos GPS vinculados a unidades de la flota';
COMMENT ON COLUMN gps_tracking.gps_devices.external_device_id IS 'ID del dispositivo en la plataforma GPS externa (Traccar, etc.)';
COMMENT ON COLUMN gps_tracking.gps_devices.platform IS 'Plataforma GPS: traccar (recomendado), wialon, samsara, geotab, manual';
-- -------------------------------------------
-- GPS_POSITIONS - Posiciones registradas (time-series)
-- -------------------------------------------
CREATE TABLE gps_tracking.gps_positions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
device_id UUID NOT NULL REFERENCES gps_tracking.gps_devices(id) ON DELETE CASCADE,
unit_id UUID NOT NULL, -- Denormalizado para queries rapidos
-- Posicion
latitude DECIMAL(10, 7) NOT NULL CHECK (latitude >= -90 AND latitude <= 90),
longitude DECIMAL(10, 7) NOT NULL CHECK (longitude >= -180 AND longitude <= 180),
altitude DECIMAL(8, 2), -- metros sobre nivel del mar
-- Movimiento
speed DECIMAL(6, 2) CHECK (speed >= 0), -- km/h
course DECIMAL(5, 2) CHECK (course >= 0 AND course < 360), -- grados (0 = norte)
-- Precision
accuracy DECIMAL(6, 2) CHECK (accuracy >= 0), -- metros
hdop DECIMAL(4, 2), -- Horizontal Dilution of Precision
-- Atributos adicionales (del dispositivo)
attributes JSONB DEFAULT '{}',
-- attributes puede contener: ignition, fuel, odometer, engineHours, batteryLevel, etc.
-- Timestamps
device_time TIMESTAMP WITH TIME ZONE NOT NULL, -- Hora del dispositivo
server_time TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- Hora de recepcion
fix_time TIMESTAMP WITH TIME ZONE, -- Hora del fix GPS
-- Validacion
is_valid BOOLEAN DEFAULT TRUE, -- Si paso filtros anti-ruido
-- Indices para busquedas
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Particionar por fecha si hay muchos registros
-- CREATE TABLE gps_tracking.gps_positions_YYYY_MM PARTITION OF gps_tracking.gps_positions
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE INDEX idx_gps_positions_tenant ON gps_tracking.gps_positions(tenant_id);
CREATE INDEX idx_gps_positions_device ON gps_tracking.gps_positions(device_id);
CREATE INDEX idx_gps_positions_unit ON gps_tracking.gps_positions(unit_id);
CREATE INDEX idx_gps_positions_device_time ON gps_tracking.gps_positions(device_id, device_time DESC);
CREATE INDEX idx_gps_positions_time ON gps_tracking.gps_positions(device_time DESC);
CREATE INDEX idx_gps_positions_valid ON gps_tracking.gps_positions(is_valid) WHERE is_valid = TRUE;
-- Indice espacial para queries geograficos
CREATE INDEX idx_gps_positions_location ON gps_tracking.gps_positions
USING GIST (ST_SetSRID(ST_MakePoint(longitude, latitude), 4326))
WHERE longitude IS NOT NULL AND latitude IS NOT NULL;
SELECT create_tenant_rls_policies('gps_tracking', 'gps_positions');
COMMENT ON TABLE gps_tracking.gps_positions IS 'Posiciones GPS registradas - datos time-series';
COMMENT ON COLUMN gps_tracking.gps_positions.attributes IS 'Atributos adicionales: ignition, fuel, odometer, engineHours, etc.';
COMMENT ON COLUMN gps_tracking.gps_positions.is_valid IS 'Si la posicion paso los filtros anti-ruido';
-- -------------------------------------------
-- GEOFENCES - Geocercas (areas de interes)
-- -------------------------------------------
CREATE TABLE gps_tracking.geofences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Identificacion
name VARCHAR(100) NOT NULL,
code VARCHAR(30),
description TEXT,
-- Tipo de geocerca
geofence_type VARCHAR(20) NOT NULL DEFAULT 'circle'
CHECK (geofence_type IN ('circle', 'polygon')),
-- Para tipo CIRCLE
center_lat DECIMAL(10, 7),
center_lng DECIMAL(10, 7),
radius_meters DECIMAL(10, 2) CHECK (radius_meters > 0),
-- Para tipo POLYGON (formato GeoJSON)
polygon_geojson JSONB,
-- Ejemplo: {"type": "Polygon", "coordinates": [[[lng1,lat1],[lng2,lat2],...]]}
-- Geometria PostGIS (calculada)
geometry GEOMETRY(Geometry, 4326),
-- Categoria operativa
category VARCHAR(30) NOT NULL DEFAULT 'custom'
CHECK (category IN ('base', 'coverage', 'restricted', 'high_risk', 'client', 'custom')),
-- Configuracion de eventos
trigger_on_enter BOOLEAN DEFAULT TRUE,
trigger_on_exit BOOLEAN DEFAULT TRUE,
dwell_time_seconds INTEGER DEFAULT 0, -- Tiempo minimo para disparar evento
-- Estado
is_active BOOLEAN DEFAULT TRUE,
color VARCHAR(7) DEFAULT '#3388ff', -- Color en mapa (hex)
metadata JSONB DEFAULT '{}',
-- Auditoria
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by UUID
);
CREATE INDEX idx_geofences_tenant ON gps_tracking.geofences(tenant_id);
CREATE INDEX idx_geofences_category ON gps_tracking.geofences(category);
CREATE INDEX idx_geofences_active ON gps_tracking.geofences(is_active) WHERE is_active = TRUE;
-- Indice espacial
CREATE INDEX idx_geofences_geometry ON gps_tracking.geofences USING GIST (geometry);
CREATE TRIGGER trg_geofences_updated_at
BEFORE UPDATE ON gps_tracking.geofences
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('gps_tracking', 'geofences');
COMMENT ON TABLE gps_tracking.geofences IS 'Geocercas para detectar entrada/salida de areas';
COMMENT ON COLUMN gps_tracking.geofences.category IS 'Categoria: base (taller), coverage (zona atendida), restricted, high_risk, client, custom';
COMMENT ON COLUMN gps_tracking.geofences.dwell_time_seconds IS 'Segundos minimos dentro/fuera para disparar evento';
-- Trigger para calcular geometria automaticamente
CREATE OR REPLACE FUNCTION gps_tracking.calculate_geofence_geometry()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.geofence_type = 'circle' AND NEW.center_lat IS NOT NULL AND NEW.center_lng IS NOT NULL THEN
-- Crear circulo
NEW.geometry := ST_Buffer(
ST_SetSRID(ST_MakePoint(NEW.center_lng, NEW.center_lat), 4326)::geography,
NEW.radius_meters
)::geometry;
ELSIF NEW.geofence_type = 'polygon' AND NEW.polygon_geojson IS NOT NULL THEN
-- Crear poligono desde GeoJSON
NEW.geometry := ST_SetSRID(ST_GeomFromGeoJSON(NEW.polygon_geojson::text), 4326);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_geofences_geometry
BEFORE INSERT OR UPDATE ON gps_tracking.geofences
FOR EACH ROW EXECUTE FUNCTION gps_tracking.calculate_geofence_geometry();
-- -------------------------------------------
-- GEOFENCE_EVENTS - Eventos de entrada/salida
-- -------------------------------------------
CREATE TABLE gps_tracking.geofence_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
geofence_id UUID NOT NULL REFERENCES gps_tracking.geofences(id) ON DELETE CASCADE,
device_id UUID NOT NULL REFERENCES gps_tracking.gps_devices(id) ON DELETE CASCADE,
unit_id UUID NOT NULL,
-- Tipo de evento
event_type VARCHAR(10) NOT NULL CHECK (event_type IN ('enter', 'exit', 'dwell')),
-- Posicion que disparo el evento
position_id UUID REFERENCES gps_tracking.gps_positions(id),
latitude DECIMAL(10, 7) NOT NULL,
longitude DECIMAL(10, 7) NOT NULL,
-- Timestamps
event_time TIMESTAMP WITH TIME ZONE NOT NULL,
processed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Vinculacion a operaciones
related_incident_id UUID, -- FK a rescue_order si aplica
-- Metadata
metadata JSONB DEFAULT '{}'
);
CREATE INDEX idx_geofence_events_tenant ON gps_tracking.geofence_events(tenant_id);
CREATE INDEX idx_geofence_events_geofence ON gps_tracking.geofence_events(geofence_id);
CREATE INDEX idx_geofence_events_device ON gps_tracking.geofence_events(device_id);
CREATE INDEX idx_geofence_events_unit ON gps_tracking.geofence_events(unit_id);
CREATE INDEX idx_geofence_events_time ON gps_tracking.geofence_events(event_time DESC);
CREATE INDEX idx_geofence_events_incident ON gps_tracking.geofence_events(related_incident_id)
WHERE related_incident_id IS NOT NULL;
SELECT create_tenant_rls_policies('gps_tracking', 'geofence_events');
COMMENT ON TABLE gps_tracking.geofence_events IS 'Eventos de entrada/salida de geocercas';
-- -------------------------------------------
-- ROUTE_SEGMENTS - Segmentos de ruta para km facturables
-- -------------------------------------------
CREATE TABLE gps_tracking.route_segments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Vinculacion a incidente/servicio
incident_id UUID, -- FK a rescue_order
unit_id UUID NOT NULL,
device_id UUID REFERENCES gps_tracking.gps_devices(id),
-- Posiciones de inicio/fin
start_position_id UUID REFERENCES gps_tracking.gps_positions(id),
end_position_id UUID REFERENCES gps_tracking.gps_positions(id),
-- Coordenadas (denormalizadas)
start_lat DECIMAL(10, 7) NOT NULL,
start_lng DECIMAL(10, 7) NOT NULL,
end_lat DECIMAL(10, 7) NOT NULL,
end_lng DECIMAL(10, 7) NOT NULL,
-- Distancias
distance_km DECIMAL(10, 3) NOT NULL CHECK (distance_km >= 0),
raw_distance_km DECIMAL(10, 3), -- Antes de filtros
-- Tiempos
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
end_time TIMESTAMP WITH TIME ZONE NOT NULL,
duration_minutes DECIMAL(8, 2) CHECK (duration_minutes >= 0),
-- Tipo de segmento
segment_type VARCHAR(30) NOT NULL DEFAULT 'other'
CHECK (segment_type IN ('to_incident', 'at_incident', 'return', 'between_incidents', 'other')),
-- Validacion
is_valid BOOLEAN DEFAULT TRUE,
validation_notes TEXT,
-- Polyline codificada (para visualizacion)
encoded_polyline TEXT,
-- Metadata
metadata JSONB DEFAULT '{}',
-- Auditoria
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
calculated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT chk_segment_times CHECK (end_time >= start_time)
);
CREATE INDEX idx_route_segments_tenant ON gps_tracking.route_segments(tenant_id);
CREATE INDEX idx_route_segments_incident ON gps_tracking.route_segments(incident_id);
CREATE INDEX idx_route_segments_unit ON gps_tracking.route_segments(unit_id);
CREATE INDEX idx_route_segments_type ON gps_tracking.route_segments(segment_type);
CREATE INDEX idx_route_segments_time ON gps_tracking.route_segments(start_time DESC);
CREATE INDEX idx_route_segments_valid ON gps_tracking.route_segments(is_valid) WHERE is_valid = TRUE;
SELECT create_tenant_rls_policies('gps_tracking', 'route_segments');
COMMENT ON TABLE gps_tracking.route_segments IS 'Segmentos de ruta calculados para facturacion de km';
COMMENT ON COLUMN gps_tracking.route_segments.segment_type IS 'Tipo: to_incident (ida), at_incident (en sitio), return (regreso), between_incidents, other';
COMMENT ON COLUMN gps_tracking.route_segments.is_valid IS 'Si el segmento paso validacion anti-ruido';
COMMENT ON COLUMN gps_tracking.route_segments.encoded_polyline IS 'Polyline codificada (Google format) para visualizar en mapa';
-- -------------------------------------------
-- FUNCTIONS: Calculo de distancias
-- -------------------------------------------
-- Funcion para calcular distancia entre dos puntos (Haversine)
CREATE OR REPLACE FUNCTION gps_tracking.haversine_distance(
lat1 DECIMAL, lng1 DECIMAL,
lat2 DECIMAL, lng2 DECIMAL
)
RETURNS DECIMAL AS $$
DECLARE
R DECIMAL := 6371; -- Radio de la Tierra en km
dlat DECIMAL;
dlng DECIMAL;
a DECIMAL;
c DECIMAL;
BEGIN
dlat := RADIANS(lat2 - lat1);
dlng := RADIANS(lng2 - lng1);
a := SIN(dlat/2) * SIN(dlat/2) +
COS(RADIANS(lat1)) * COS(RADIANS(lat2)) *
SIN(dlng/2) * SIN(dlng/2);
c := 2 * ATAN2(SQRT(a), SQRT(1-a));
RETURN R * c;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
COMMENT ON FUNCTION gps_tracking.haversine_distance IS 'Calcula distancia en km entre dos puntos usando formula Haversine';
-- Funcion para calcular distancia total de una ruta (array de posiciones)
CREATE OR REPLACE FUNCTION gps_tracking.calculate_route_distance(
p_device_id UUID,
p_start_time TIMESTAMP WITH TIME ZONE,
p_end_time TIMESTAMP WITH TIME ZONE
)
RETURNS TABLE (
total_distance_km DECIMAL,
position_count INTEGER,
duration_minutes DECIMAL
) AS $$
DECLARE
v_prev_lat DECIMAL;
v_prev_lng DECIMAL;
v_prev_time TIMESTAMP WITH TIME ZONE;
v_distance DECIMAL := 0;
v_count INTEGER := 0;
v_rec RECORD;
BEGIN
FOR v_rec IN
SELECT latitude, longitude, device_time
FROM gps_tracking.gps_positions
WHERE device_id = p_device_id
AND device_time BETWEEN p_start_time AND p_end_time
AND is_valid = TRUE
ORDER BY device_time
LOOP
IF v_prev_lat IS NOT NULL THEN
v_distance := v_distance + gps_tracking.haversine_distance(
v_prev_lat, v_prev_lng,
v_rec.latitude, v_rec.longitude
);
ELSE
v_prev_time := v_rec.device_time;
END IF;
v_prev_lat := v_rec.latitude;
v_prev_lng := v_rec.longitude;
v_count := v_count + 1;
END LOOP;
RETURN QUERY SELECT
ROUND(v_distance, 3)::DECIMAL,
v_count,
ROUND(EXTRACT(EPOCH FROM (v_rec.device_time - v_prev_time)) / 60, 2)::DECIMAL;
END;
$$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION gps_tracking.calculate_route_distance IS 'Calcula distancia total de una ruta entre dos timestamps';
-- Funcion para verificar si un punto esta dentro de una geocerca
CREATE OR REPLACE FUNCTION gps_tracking.is_point_in_geofence(
p_lat DECIMAL,
p_lng DECIMAL,
p_geofence_id UUID
)
RETURNS BOOLEAN AS $$
DECLARE
v_geofence gps_tracking.geofences;
BEGIN
SELECT * INTO v_geofence
FROM gps_tracking.geofences
WHERE id = p_geofence_id AND is_active = TRUE;
IF v_geofence IS NULL THEN
RETURN FALSE;
END IF;
-- Usar PostGIS para verificacion precisa
RETURN ST_Contains(
v_geofence.geometry,
ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)
);
END;
$$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION gps_tracking.is_point_in_geofence IS 'Verifica si un punto esta dentro de una geocerca';
-- -------------------------------------------
-- VIEWS
-- -------------------------------------------
-- Vista de ultima posicion por unidad
CREATE OR REPLACE VIEW gps_tracking.vw_last_positions AS
SELECT DISTINCT ON (d.unit_id)
d.id as device_id,
d.tenant_id,
d.unit_id,
d.external_device_id,
d.platform,
d.is_active,
p.id as position_id,
p.latitude,
p.longitude,
p.speed,
p.course,
p.attributes,
p.device_time,
p.is_valid,
EXTRACT(EPOCH FROM (NOW() - p.device_time)) / 60 as minutes_ago
FROM gps_tracking.gps_devices d
LEFT JOIN gps_tracking.gps_positions p ON d.id = p.device_id
WHERE d.is_active = TRUE
AND d.tenant_id = get_current_tenant_id()
ORDER BY d.unit_id, p.device_time DESC;
COMMENT ON VIEW gps_tracking.vw_last_positions IS 'Vista de ultima posicion conocida por unidad con filtro RLS';
-- Vista de resumen de dispositivos
CREATE OR REPLACE VIEW gps_tracking.vw_device_summary AS
SELECT
d.id,
d.tenant_id,
d.unit_id,
d.external_device_id,
d.platform,
d.model,
d.is_active,
d.last_position_at,
d.last_position_lat,
d.last_position_lng,
CASE
WHEN d.last_position_at IS NULL THEN 'never'
WHEN NOW() - d.last_position_at < INTERVAL '5 minutes' THEN 'online'
WHEN NOW() - d.last_position_at < INTERVAL '1 hour' THEN 'recent'
ELSE 'offline'
END as connection_status,
(SELECT COUNT(*) FROM gps_tracking.gps_positions p
WHERE p.device_id = d.id AND p.device_time > NOW() - INTERVAL '24 hours') as positions_24h
FROM gps_tracking.gps_devices d
WHERE d.tenant_id = get_current_tenant_id();
COMMENT ON VIEW gps_tracking.vw_device_summary IS 'Vista resumida de dispositivos GPS con estado de conexion';
-- -------------------------------------------
-- TRIGGER: Actualizar ultima posicion en device
-- -------------------------------------------
CREATE OR REPLACE FUNCTION gps_tracking.update_device_last_position()
RETURNS TRIGGER AS $$
BEGIN
UPDATE gps_tracking.gps_devices
SET
last_position_at = NEW.device_time,
last_position_lat = NEW.latitude,
last_position_lng = NEW.longitude
WHERE id = NEW.device_id
AND (last_position_at IS NULL OR last_position_at < NEW.device_time);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_device_last_position
AFTER INSERT ON gps_tracking.gps_positions
FOR EACH ROW EXECUTE FUNCTION gps_tracking.update_device_last_position();
-- -------------------------------------------
-- DATOS INICIALES: Geocercas base
-- -------------------------------------------
-- NOTA: Ejecutar despues de tener tenant_id valido
-- INSERT INTO gps_tracking.geofences (tenant_id, name, code, geofence_type, center_lat, center_lng, radius_meters, category)
-- VALUES
-- ('{tenant_id}', 'Taller Principal', 'BASE-01', 'circle', 19.4326, -99.1332, 200, 'base');
COMMENT ON SCHEMA gps_tracking IS 'Schema completo para tracking GPS - MMD-014';