From 8de39831dc7514fa15217b94d298be554c18c17a Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Fri, 30 Jan 2026 12:11:04 -0600 Subject: [PATCH] feat: Add new DDL schemas for GPS, dispatch and offline modules - 03a-gps-devices-ddl.sql: GPS device tracking schema - 09-dispatch-schema-ddl.sql: Dispatch management schema - 10-offline-schema-ddl.sql: Offline operation schema Co-Authored-By: Claude Opus 4.5 --- ddl/03a-gps-devices-ddl.sql | 255 ++++++++++++++++++++ ddl/09-dispatch-schema-ddl.sql | 427 +++++++++++++++++++++++++++++++++ ddl/10-offline-schema-ddl.sql | 358 +++++++++++++++++++++++++++ 3 files changed, 1040 insertions(+) create mode 100644 ddl/03a-gps-devices-ddl.sql create mode 100644 ddl/09-dispatch-schema-ddl.sql create mode 100644 ddl/10-offline-schema-ddl.sql diff --git a/ddl/03a-gps-devices-ddl.sql b/ddl/03a-gps-devices-ddl.sql new file mode 100644 index 0000000..494c5de --- /dev/null +++ b/ddl/03a-gps-devices-ddl.sql @@ -0,0 +1,255 @@ +-- ============================================================================= +-- ERP TRANSPORTISTAS - GPS Devices DDL (Extension) +-- ============================================================================= +-- Archivo: 03a-gps-devices-ddl.sql +-- Version: 1.0.0 +-- Fecha: 2026-01-28 +-- Descripcion: Tablas adicionales para modulo GPS (dispositivos, eventos geocerca, segmentos) +-- Basado en: erp-mecanicas-diesel MMD-014 GPS Integration +-- ============================================================================= + +-- ============================================================================= +-- TIPOS ENUMERADOS ADICIONALES +-- ============================================================================= + +DO $$ BEGIN + CREATE TYPE tracking.plataforma_gps AS ENUM ( + 'traccar', + 'wialon', + 'samsara', + 'geotab', + 'manual' + ); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE tracking.tipo_unidad_gps AS ENUM ( + 'tractora', + 'remolque', + 'caja', + 'equipo', + 'operador' + ); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE tracking.tipo_evento_geocerca AS ENUM ( + 'entrada', + 'salida', + 'permanencia' + ); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE tracking.tipo_segmento AS ENUM ( + 'hacia_destino', + 'en_destino', + 'retorno', + 'entre_paradas', + 'otro' + ); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +-- ============================================================================= +-- TABLA: dispositivos_gps +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS tracking.dispositivos_gps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id), + + -- Link to fleet unit (fleet.unidades) + unidad_id UUID NOT NULL, + tipo_unidad tracking.tipo_unidad_gps DEFAULT 'tractora', + + -- External platform identification + external_device_id VARCHAR(100) NOT NULL, + plataforma tracking.plataforma_gps DEFAULT 'traccar', + + -- Device identifiers + imei VARCHAR(20), + numero_serie VARCHAR(50), + telefono VARCHAR(20), + modelo VARCHAR(50), + fabricante VARCHAR(50), + + -- Status + activo BOOLEAN DEFAULT TRUE, + ultima_posicion_at TIMESTAMPTZ, + ultima_posicion_lat DECIMAL(10, 7), + ultima_posicion_lng DECIMAL(10, 7), + + -- Configuration + intervalo_posicion_segundos INTEGER DEFAULT 30, + metadata JSONB DEFAULT '{}', + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID, + + -- Constraints + CONSTRAINT uq_dispositivo_external UNIQUE (tenant_id, plataforma, external_device_id), + CONSTRAINT uq_dispositivo_unidad UNIQUE (tenant_id, unidad_id) WHERE activo = TRUE +); + +CREATE INDEX IF NOT EXISTS idx_dispositivos_gps_tenant ON tracking.dispositivos_gps(tenant_id); +CREATE INDEX IF NOT EXISTS idx_dispositivos_gps_unidad ON tracking.dispositivos_gps(unidad_id); +CREATE INDEX IF NOT EXISTS idx_dispositivos_gps_external ON tracking.dispositivos_gps(external_device_id); +CREATE INDEX IF NOT EXISTS idx_dispositivos_gps_plataforma ON tracking.dispositivos_gps(plataforma); + +-- ============================================================================= +-- TABLA: eventos_geocerca +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS tracking.eventos_geocerca ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id), + + -- References + geocerca_id UUID NOT NULL REFERENCES tracking.geocercas(id), + dispositivo_id UUID NOT NULL REFERENCES tracking.dispositivos_gps(id), + unidad_id UUID NOT NULL, + + -- Event type + tipo_evento tracking.tipo_evento_geocerca NOT NULL, + + -- Position that triggered the event + posicion_id UUID, + latitud DECIMAL(10, 7) NOT NULL, + longitud DECIMAL(10, 7) NOT NULL, + + -- Timestamps + tiempo_evento TIMESTAMPTZ NOT NULL, + procesado_at TIMESTAMPTZ DEFAULT NOW(), + + -- Link to viaje (if applicable) + viaje_id UUID, + + -- Metadata + metadata JSONB DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS idx_eventos_geocerca_tenant ON tracking.eventos_geocerca(tenant_id); +CREATE INDEX IF NOT EXISTS idx_eventos_geocerca_geocerca ON tracking.eventos_geocerca(geocerca_id); +CREATE INDEX IF NOT EXISTS idx_eventos_geocerca_dispositivo ON tracking.eventos_geocerca(dispositivo_id); +CREATE INDEX IF NOT EXISTS idx_eventos_geocerca_unidad ON tracking.eventos_geocerca(unidad_id); +CREATE INDEX IF NOT EXISTS idx_eventos_geocerca_tiempo ON tracking.eventos_geocerca(tiempo_evento); +CREATE INDEX IF NOT EXISTS idx_eventos_geocerca_viaje ON tracking.eventos_geocerca(viaje_id) WHERE viaje_id IS NOT NULL; + +-- ============================================================================= +-- TABLA: segmentos_ruta +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS tracking.segmentos_ruta ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id), + + -- Link to viaje + viaje_id UUID, + unidad_id UUID NOT NULL, + dispositivo_id UUID REFERENCES tracking.dispositivos_gps(id), + + -- Start/end positions + posicion_inicio_id UUID, + posicion_fin_id UUID, + + -- Coordinates (denormalized) + lat_inicio DECIMAL(10, 7) NOT NULL, + lng_inicio DECIMAL(10, 7) NOT NULL, + lat_fin DECIMAL(10, 7) NOT NULL, + lng_fin DECIMAL(10, 7) NOT NULL, + + -- Distances + distancia_km DECIMAL(10, 3) NOT NULL, + distancia_cruda_km DECIMAL(10, 3), + + -- Times + tiempo_inicio TIMESTAMPTZ NOT NULL, + tiempo_fin TIMESTAMPTZ NOT NULL, + duracion_minutos DECIMAL(8, 2), + + -- Segment type + tipo_segmento tracking.tipo_segmento DEFAULT 'otro', + + -- Validation + es_valido BOOLEAN DEFAULT TRUE, + notas_validacion TEXT, + + -- Encoded polyline for visualization + polyline_encoded TEXT, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + calculado_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_segmentos_ruta_tenant ON tracking.segmentos_ruta(tenant_id); +CREATE INDEX IF NOT EXISTS idx_segmentos_ruta_viaje ON tracking.segmentos_ruta(viaje_id); +CREATE INDEX IF NOT EXISTS idx_segmentos_ruta_unidad ON tracking.segmentos_ruta(unidad_id); +CREATE INDEX IF NOT EXISTS idx_segmentos_ruta_tipo ON tracking.segmentos_ruta(tipo_segmento); +CREATE INDEX IF NOT EXISTS idx_segmentos_ruta_tiempo ON tracking.segmentos_ruta(tiempo_inicio); + +-- ============================================================================= +-- ALTER posiciones_gps para agregar dispositivo_id +-- ============================================================================= + +DO $$ BEGIN + ALTER TABLE tracking.posiciones_gps ADD COLUMN IF NOT EXISTS dispositivo_id UUID REFERENCES tracking.dispositivos_gps(id); +EXCEPTION WHEN others THEN null; +END $$; + +DO $$ BEGIN + ALTER TABLE tracking.posiciones_gps ADD COLUMN IF NOT EXISTS es_valido BOOLEAN DEFAULT TRUE; +EXCEPTION WHEN others THEN null; +END $$; + +-- ============================================================================= +-- RLS POLICIES +-- ============================================================================= + +ALTER TABLE tracking.dispositivos_gps ENABLE ROW LEVEL SECURITY; +ALTER TABLE tracking.eventos_geocerca ENABLE ROW LEVEL SECURITY; +ALTER TABLE tracking.segmentos_ruta ENABLE ROW LEVEL SECURITY; + +DO $$ BEGIN + CREATE POLICY tenant_isolation_dispositivos ON tracking.dispositivos_gps + USING (tenant_id = current_setting('app.tenant_id')::uuid); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE POLICY tenant_isolation_eventos_geo ON tracking.eventos_geocerca + USING (tenant_id = current_setting('app.tenant_id')::uuid); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE POLICY tenant_isolation_segmentos ON tracking.segmentos_ruta + USING (tenant_id = current_setting('app.tenant_id')::uuid); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +-- ============================================================================= +-- COMENTARIOS +-- ============================================================================= + +COMMENT ON TABLE tracking.dispositivos_gps IS 'Dispositivos GPS vinculados a unidades de flota'; +COMMENT ON TABLE tracking.eventos_geocerca IS 'Eventos de entrada/salida de geocercas'; +COMMENT ON TABLE tracking.segmentos_ruta IS 'Segmentos de ruta para calculo de distancias facturables'; + +COMMENT ON COLUMN tracking.dispositivos_gps.plataforma IS 'Proveedor GPS: traccar, wialon, samsara, geotab, manual'; +COMMENT ON COLUMN tracking.dispositivos_gps.tipo_unidad IS 'Tipo de unidad: tractora, remolque, caja, equipo, operador'; +COMMENT ON COLUMN tracking.segmentos_ruta.tipo_segmento IS 'Tipo: hacia_destino, en_destino, retorno, entre_paradas, otro'; +COMMENT ON COLUMN tracking.segmentos_ruta.polyline_encoded IS 'Polyline codificada en formato Google para visualizacion en mapas'; + +-- ============================================================================= +-- FIN DDL GPS DEVICES +-- ============================================================================= diff --git a/ddl/09-dispatch-schema-ddl.sql b/ddl/09-dispatch-schema-ddl.sql new file mode 100644 index 0000000..fa1f59b --- /dev/null +++ b/ddl/09-dispatch-schema-ddl.sql @@ -0,0 +1,427 @@ +-- ============================================================================= +-- ERP TRANSPORTISTAS - Schema Despacho DDL +-- ============================================================================= +-- Archivo: 09-dispatch-schema-ddl.sql +-- Version: 1.0.0 +-- Fecha: 2026-01-28 +-- Descripcion: Tablas para modulo de despacho (dispatch center) +-- Basado en: erp-mecanicas-diesel MMD-011 Dispatch Center +-- ============================================================================= + +-- ============================================================================= +-- SCHEMA DESPACHO +-- ============================================================================= + +CREATE SCHEMA IF NOT EXISTS despacho; + +-- ============================================================================= +-- TIPOS ENUMERADOS +-- ============================================================================= + +DO $$ BEGIN + CREATE TYPE despacho.estado_unidad AS ENUM ( + 'available', + 'assigned', + 'en_route', + 'on_site', + 'returning', + 'offline', + 'maintenance' + ); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE despacho.capacidad_unidad AS ENUM ( + 'light', + 'medium', + 'heavy' + ); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE despacho.accion_despacho AS ENUM ( + 'created', + 'assigned', + 'reassigned', + 'rejected', + 'escalated', + 'cancelled', + 'acknowledged', + 'completed' + ); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE despacho.canal_notificacion AS ENUM ( + 'email', + 'sms', + 'whatsapp', + 'push', + 'call' + ); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +-- ============================================================================= +-- TABLA: tableros_despacho +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS despacho.tableros_despacho ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id), + + nombre VARCHAR(100) NOT NULL, + descripcion TEXT, + + -- Map defaults + default_zoom INTEGER DEFAULT 12, + centro_lat DECIMAL(10, 7) DEFAULT 19.4326, + centro_lng DECIMAL(10, 7) DEFAULT -99.1332, + + -- Behavior + intervalo_refresco_segundos INTEGER DEFAULT 30, + mostrar_unidades_offline BOOLEAN DEFAULT TRUE, + auto_asignar_habilitado BOOLEAN DEFAULT FALSE, + max_sugerencias INTEGER DEFAULT 5, + + -- Filters + filtros_default JSONB DEFAULT '{}', + + activo BOOLEAN DEFAULT TRUE, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID +); + +CREATE INDEX IF NOT EXISTS idx_tableros_despacho_tenant ON despacho.tableros_despacho(tenant_id); + +-- ============================================================================= +-- TABLA: estado_unidades +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS despacho.estado_unidades ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id), + + -- Unit reference (FK a fleet.unidades) + unidad_id UUID NOT NULL, + codigo_unidad VARCHAR(50), + nombre_unidad VARCHAR(100), + + -- Status + estado despacho.estado_unidad DEFAULT 'offline', + + -- Current assignment (FK a transport.viajes) + viaje_actual_id UUID, + operador_ids UUID[] DEFAULT '{}', + + -- Location (cached from GPS) + ultima_posicion_id UUID, + ultima_posicion_lat DECIMAL(10, 7), + ultima_posicion_lng DECIMAL(10, 7), + ultima_actualizacion_ubicacion TIMESTAMPTZ, + + -- Timing + ultimo_cambio_estado TIMESTAMPTZ DEFAULT NOW(), + disponible_estimado_en TIMESTAMPTZ, + + -- Capacity and capabilities + capacidad_unidad despacho.capacidad_unidad DEFAULT 'light', + puede_remolcar BOOLEAN DEFAULT FALSE, + peso_max_remolque_kg INTEGER, + + -- Transport-specific + es_refrigerada BOOLEAN DEFAULT FALSE, + capacidad_peso_kg DECIMAL(10, 2), + + notas TEXT, + metadata JSONB DEFAULT '{}', + + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT uq_estado_unidad UNIQUE (tenant_id, unidad_id) +); + +CREATE INDEX IF NOT EXISTS idx_estado_unidades_tenant ON despacho.estado_unidades(tenant_id); +CREATE INDEX IF NOT EXISTS idx_estado_unidades_estado ON despacho.estado_unidades(tenant_id, estado); +CREATE INDEX IF NOT EXISTS idx_estado_unidades_viaje ON despacho.estado_unidades(viaje_actual_id) WHERE viaje_actual_id IS NOT NULL; + +-- ============================================================================= +-- TABLA: reglas_despacho +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS despacho.reglas_despacho ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id), + + nombre VARCHAR(100) NOT NULL, + descripcion TEXT, + + prioridad INTEGER DEFAULT 0, + + -- Applicability + tipo_viaje VARCHAR(50), + categoria_viaje VARCHAR(50), + + -- Conditions (JSONB with transport-specific rules) + condiciones JSONB NOT NULL DEFAULT '{}', + + -- Action + auto_asignar BOOLEAN DEFAULT FALSE, + peso_asignacion INTEGER DEFAULT 100, + + activo BOOLEAN DEFAULT TRUE, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID +); + +CREATE INDEX IF NOT EXISTS idx_reglas_despacho_tenant ON despacho.reglas_despacho(tenant_id); +CREATE INDEX IF NOT EXISTS idx_reglas_despacho_prioridad ON despacho.reglas_despacho(tenant_id, prioridad DESC); + +-- ============================================================================= +-- TABLA: reglas_escalamiento +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS despacho.reglas_escalamiento ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id), + + nombre VARCHAR(100) NOT NULL, + descripcion TEXT, + + -- Trigger conditions + disparar_despues_minutos INTEGER NOT NULL, + disparar_estado VARCHAR(50), + disparar_prioridad VARCHAR(20), + + -- Escalation target + escalar_a_rol VARCHAR(50) NOT NULL, + escalar_a_usuarios UUID[], + + -- Notification (default whatsapp for transport) + canal_notificacion despacho.canal_notificacion DEFAULT 'whatsapp', + plantilla_notificacion TEXT, + datos_notificacion JSONB DEFAULT '{}', + + -- Repeat + intervalo_repeticion_minutos INTEGER, + max_escalamientos INTEGER DEFAULT 3, + + activo BOOLEAN DEFAULT TRUE, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID +); + +CREATE INDEX IF NOT EXISTS idx_reglas_escalamiento_tenant ON despacho.reglas_escalamiento(tenant_id); +CREATE INDEX IF NOT EXISTS idx_reglas_escalamiento_trigger ON despacho.reglas_escalamiento(tenant_id, disparar_despues_minutos); + +-- ============================================================================= +-- TABLA: log_despacho +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS despacho.log_despacho ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id), + + -- Viaje reference (FK a transport.viajes) + viaje_id UUID NOT NULL, + + -- Action + accion despacho.accion_despacho NOT NULL, + + -- Unit/Operador changes + desde_unidad_id UUID, + hacia_unidad_id UUID, + desde_operador_id UUID, + hacia_operador_id UUID, + + -- Context + razon TEXT, + automatizado BOOLEAN DEFAULT FALSE, + regla_id UUID, + escalamiento_id UUID, + + -- Response times + tiempo_respuesta_segundos INTEGER, + + -- Actor + ejecutado_por UUID, + ejecutado_en TIMESTAMPTZ DEFAULT NOW(), + + -- Extra data + metadata JSONB DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS idx_log_despacho_tenant ON despacho.log_despacho(tenant_id); +CREATE INDEX IF NOT EXISTS idx_log_despacho_viaje ON despacho.log_despacho(tenant_id, viaje_id); +CREATE INDEX IF NOT EXISTS idx_log_despacho_fecha ON despacho.log_despacho(tenant_id, ejecutado_en DESC); +CREATE INDEX IF NOT EXISTS idx_log_despacho_accion ON despacho.log_despacho(tenant_id, accion); + +-- ============================================================================= +-- TABLAS ADICIONALES EN FLEET (certificaciones y turnos) +-- ============================================================================= + +-- TABLA: certificaciones_operador +CREATE TABLE IF NOT EXISTS fleet.certificaciones_operador ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id), + + -- Operador reference (FK a fleet.operadores) + operador_id UUID NOT NULL, + + -- Certification definition + codigo_certificacion VARCHAR(50) NOT NULL, + nombre_certificacion VARCHAR(100) NOT NULL, + descripcion TEXT, + + -- Level + nivel VARCHAR(20) DEFAULT 'basico', + + -- Certification details + numero_certificado VARCHAR(100), + fecha_certificacion DATE, + vigencia_hasta DATE, + documento_url TEXT, + + -- Status + activa BOOLEAN DEFAULT TRUE, + verificado_por UUID, + verificado_en TIMESTAMPTZ, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT uq_operador_certificacion UNIQUE(tenant_id, operador_id, codigo_certificacion) +); + +CREATE INDEX IF NOT EXISTS idx_certificaciones_operador ON fleet.certificaciones_operador(operador_id); +CREATE INDEX IF NOT EXISTS idx_certificaciones_tenant ON fleet.certificaciones_operador(tenant_id); +CREATE INDEX IF NOT EXISTS idx_certificaciones_vencimiento ON fleet.certificaciones_operador(vigencia_hasta) WHERE activa = TRUE; + +-- TABLA: turnos_operador +CREATE TABLE IF NOT EXISTS fleet.turnos_operador ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id), + + -- Operador reference (FK a fleet.operadores) + operador_id UUID NOT NULL, + + -- Schedule + fecha_turno DATE NOT NULL, + tipo_turno VARCHAR(20) NOT NULL, + hora_inicio TIME NOT NULL, + hora_fin TIME NOT NULL, + + -- On-call specifics + en_guardia BOOLEAN DEFAULT FALSE, + prioridad_guardia INTEGER DEFAULT 0, + + -- Assignment + unidad_asignada_id UUID, + + -- Status + hora_inicio_real TIMESTAMPTZ, + hora_fin_real TIMESTAMPTZ, + ausente BOOLEAN DEFAULT FALSE, + motivo_ausencia TEXT, + + notas TEXT, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID +); + +CREATE INDEX IF NOT EXISTS idx_turnos_operador ON fleet.turnos_operador(operador_id, fecha_turno); +CREATE INDEX IF NOT EXISTS idx_turnos_tenant ON fleet.turnos_operador(tenant_id); +CREATE INDEX IF NOT EXISTS idx_turnos_fecha ON fleet.turnos_operador(tenant_id, fecha_turno); + +-- ============================================================================= +-- RLS POLICIES +-- ============================================================================= + +ALTER TABLE despacho.tableros_despacho ENABLE ROW LEVEL SECURITY; +ALTER TABLE despacho.estado_unidades ENABLE ROW LEVEL SECURITY; +ALTER TABLE despacho.reglas_despacho ENABLE ROW LEVEL SECURITY; +ALTER TABLE despacho.reglas_escalamiento ENABLE ROW LEVEL SECURITY; +ALTER TABLE despacho.log_despacho ENABLE ROW LEVEL SECURITY; +ALTER TABLE fleet.certificaciones_operador ENABLE ROW LEVEL SECURITY; +ALTER TABLE fleet.turnos_operador ENABLE ROW LEVEL SECURITY; + +DO $$ BEGIN + CREATE POLICY tenant_isolation_tableros ON despacho.tableros_despacho + USING (tenant_id = current_setting('app.tenant_id')::uuid); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE POLICY tenant_isolation_estado ON despacho.estado_unidades + USING (tenant_id = current_setting('app.tenant_id')::uuid); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE POLICY tenant_isolation_reglas ON despacho.reglas_despacho + USING (tenant_id = current_setting('app.tenant_id')::uuid); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE POLICY tenant_isolation_escalamiento ON despacho.reglas_escalamiento + USING (tenant_id = current_setting('app.tenant_id')::uuid); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE POLICY tenant_isolation_log ON despacho.log_despacho + USING (tenant_id = current_setting('app.tenant_id')::uuid); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE POLICY tenant_isolation_certificaciones ON fleet.certificaciones_operador + USING (tenant_id = current_setting('app.tenant_id')::uuid); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE POLICY tenant_isolation_turnos ON fleet.turnos_operador + USING (tenant_id = current_setting('app.tenant_id')::uuid); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +-- ============================================================================= +-- COMENTARIOS +-- ============================================================================= + +COMMENT ON SCHEMA despacho IS 'Modulo de despacho para asignacion de viajes a unidades/operadores'; + +COMMENT ON TABLE despacho.tableros_despacho IS 'Configuracion de tableros de despacho con mapa'; +COMMENT ON TABLE despacho.estado_unidades IS 'Estado en tiempo real de unidades para despacho'; +COMMENT ON TABLE despacho.reglas_despacho IS 'Reglas para asignacion automatica de viajes'; +COMMENT ON TABLE despacho.reglas_escalamiento IS 'Reglas para escalar viajes sin respuesta'; +COMMENT ON TABLE despacho.log_despacho IS 'Auditoria de acciones de despacho'; + +COMMENT ON TABLE fleet.certificaciones_operador IS 'Certificaciones y licencias de operadores'; +COMMENT ON TABLE fleet.turnos_operador IS 'Programacion de turnos de operadores'; + +COMMENT ON COLUMN despacho.estado_unidades.viaje_actual_id IS 'FK a transport.viajes - viaje actualmente asignado'; +COMMENT ON COLUMN despacho.estado_unidades.operador_ids IS 'IDs de operadores asignados a la unidad'; +COMMENT ON COLUMN despacho.log_despacho.viaje_id IS 'FK a transport.viajes - viaje al que aplica la accion'; + +-- ============================================================================= +-- FIN DDL DESPACHO +-- ============================================================================= diff --git a/ddl/10-offline-schema-ddl.sql b/ddl/10-offline-schema-ddl.sql new file mode 100644 index 0000000..5885698 --- /dev/null +++ b/ddl/10-offline-schema-ddl.sql @@ -0,0 +1,358 @@ +-- ============================================================================= +-- ERP TRANSPORTISTAS - Schema Offline DDL +-- ============================================================================= +-- Archivo: 10-offline-schema-ddl.sql +-- Version: 1.0.0 +-- Fecha: 2026-01-28 +-- Descripcion: Cola de operaciones offline para sincronizacion +-- Basado en: OfflineQueue.entity.ts (TASK-007) +-- ============================================================================= + +-- ============================================================================= +-- SCHEMA OFFLINE +-- ============================================================================= + +CREATE SCHEMA IF NOT EXISTS offline; +COMMENT ON SCHEMA offline IS 'Sistema de sincronizacion offline para operaciones en campo'; + +-- ============================================================================= +-- TIPOS ENUMERADOS +-- ============================================================================= + +-- Tipos de operacion que pueden ser encoladas offline +CREATE TYPE offline.tipo_operacion_offline AS ENUM ( + -- GPS Operations + 'GPS_POSICION', + 'GPS_EVENTO', + + -- Dispatch Operations + 'VIAJE_ESTADO', + 'VIAJE_EVENTO', + 'CHECKIN', + 'CHECKOUT', + + -- POD Operations + 'POD_FOTO', + 'POD_FIRMA', + 'POD_DOCUMENTO', + + -- Checklist Operations + 'CHECKLIST_ITEM', + 'CHECKLIST_COMPLETADO', + + -- Generic + 'CUSTOM' +); + +COMMENT ON TYPE offline.tipo_operacion_offline IS 'Tipos de operaciones que pueden encolarse offline'; + +-- Estados de sincronizacion para operaciones encoladas +CREATE TYPE offline.estado_sincronizacion AS ENUM ( + 'PENDIENTE', -- Esperando sincronizacion + 'EN_PROCESO', -- Sincronizacion en progreso + 'COMPLETADO', -- Sincronizado exitosamente + 'ERROR', -- Error durante sincronizacion + 'CONFLICTO', -- Conflicto de datos detectado + 'DESCARTADO' -- Operacion descartada +); + +COMMENT ON TYPE offline.estado_sincronizacion IS 'Estados del proceso de sincronizacion'; + +-- Niveles de prioridad para cola de sincronizacion +CREATE TYPE offline.prioridad_sync AS ENUM ( + '1', -- CRITICA: Posiciones GPS, eventos de seguridad + '2', -- ALTA: POD, cambios de estado + '3', -- NORMAL: Items de checklist, notas + '4' -- BAJA: Fotos, documentos +); + +COMMENT ON TYPE offline.prioridad_sync IS 'Prioridades de sincronizacion (1=critica, 4=baja)'; + +-- ============================================================================= +-- TABLA: offline_queue +-- ============================================================================= + +CREATE TABLE offline.offline_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id), + + -- Referencias opcionales + dispositivo_id UUID, -- Dispositivo que genero la operacion + usuario_id UUID, -- Usuario que genero la operacion + unidad_id UUID, -- Unidad relacionada + viaje_id UUID, -- Viaje relacionado + + -- Detalles de la operacion + tipo_operacion offline.tipo_operacion_offline NOT NULL, + estado offline.estado_sincronizacion DEFAULT 'PENDIENTE', + prioridad offline.prioridad_sync DEFAULT '3', + + -- Payload - datos a sincronizar (JSONB) + payload JSONB NOT NULL, + + -- Metadata de la operacion + endpoint_destino VARCHAR(255) NOT NULL, -- URL/endpoint destino + metodo_http VARCHAR(10) DEFAULT 'POST', -- GET, POST, PUT, PATCH, DELETE + + -- Timestamps offline + creado_offline_en TIMESTAMPTZ NOT NULL, -- Timestamp cuando se creo offline + cliente_id VARCHAR(100), -- UUID generado en cliente para deduplicacion + + -- Tracking de sincronizacion + intentos_sync INT DEFAULT 0, -- Contador de intentos + max_intentos INT DEFAULT 5, -- Maximo de intentos permitidos + ultimo_intento_en TIMESTAMPTZ, -- Timestamp del ultimo intento + sincronizado_en TIMESTAMPTZ, -- Timestamp cuando se sincronizo exitosamente + + -- Manejo de errores + ultimo_error TEXT, -- Ultimo mensaje de error + historial_errores JSONB DEFAULT '[]', -- Array de {timestamp, error} + + -- Resolucion de conflictos + version_servidor INT, -- Version del servidor para conflictos + resolucion_conflicto VARCHAR(50), -- 'CLIENT_WINS', 'SERVER_WINS', 'MERGE', 'MANUAL' + + -- Optimizacion de ancho de banda + tamano_bytes INT, -- Tamano del payload en bytes + comprimido BOOLEAN DEFAULT FALSE, -- Si el payload esta comprimido + + -- Auditoria + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================= +-- INDICES +-- ============================================================================= + +-- Indice principal por tenant +CREATE INDEX idx_offline_queue_tenant + ON offline.offline_queue(tenant_id); + +-- Indice por dispositivo para sincronizacion +CREATE INDEX idx_offline_queue_dispositivo + ON offline.offline_queue(dispositivo_id) + WHERE dispositivo_id IS NOT NULL; + +-- Indice por estado para procesamiento de cola +CREATE INDEX idx_offline_queue_estado + ON offline.offline_queue(estado); + +-- Indice por prioridad y timestamp para ordenamiento de cola FIFO con prioridad +CREATE INDEX idx_offline_queue_prioridad + ON offline.offline_queue(prioridad, creado_offline_en); + +-- Indice compuesto para operaciones pendientes por tenant +CREATE INDEX idx_offline_queue_pendientes + ON offline.offline_queue(tenant_id, estado, prioridad) + WHERE estado IN ('PENDIENTE', 'EN_PROCESO', 'ERROR'); + +-- Indice para buscar por cliente_id (deduplicacion) +CREATE UNIQUE INDEX idx_offline_queue_cliente_id + ON offline.offline_queue(tenant_id, cliente_id) + WHERE cliente_id IS NOT NULL; + +-- Indice por viaje para consultas relacionadas +CREATE INDEX idx_offline_queue_viaje + ON offline.offline_queue(viaje_id) + WHERE viaje_id IS NOT NULL; + +-- Indice por unidad para consultas relacionadas +CREATE INDEX idx_offline_queue_unidad + ON offline.offline_queue(unidad_id) + WHERE unidad_id IS NOT NULL; + +-- Indice para limpieza de registros antiguos sincronizados +CREATE INDEX idx_offline_queue_sincronizado + ON offline.offline_queue(sincronizado_en) + WHERE estado = 'COMPLETADO'; + +-- ============================================================================= +-- TRIGGER: Actualizar updated_at automaticamente +-- ============================================================================= + +CREATE OR REPLACE FUNCTION offline.update_offline_queue_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_offline_queue_updated_at + BEFORE UPDATE ON offline.offline_queue + FOR EACH ROW + EXECUTE FUNCTION offline.update_offline_queue_timestamp(); + +-- ============================================================================= +-- RLS POLICIES +-- ============================================================================= + +ALTER TABLE offline.offline_queue ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation_offline_queue ON offline.offline_queue + USING (tenant_id = current_setting('app.tenant_id')::uuid); + +-- ============================================================================= +-- COMENTARIOS +-- ============================================================================= + +COMMENT ON TABLE offline.offline_queue IS 'Cola de operaciones pendientes de sincronizacion desde dispositivos offline'; + +COMMENT ON COLUMN offline.offline_queue.tipo_operacion IS 'Tipo de operacion: GPS, POD, Checklist, etc.'; +COMMENT ON COLUMN offline.offline_queue.estado IS 'Estado actual de la sincronizacion'; +COMMENT ON COLUMN offline.offline_queue.prioridad IS 'Prioridad de sincronizacion (1=critica, 4=baja)'; +COMMENT ON COLUMN offline.offline_queue.payload IS 'Datos de la operacion en formato JSON'; +COMMENT ON COLUMN offline.offline_queue.endpoint_destino IS 'URL o endpoint API destino para la sincronizacion'; +COMMENT ON COLUMN offline.offline_queue.creado_offline_en IS 'Timestamp original cuando la operacion fue creada offline'; +COMMENT ON COLUMN offline.offline_queue.cliente_id IS 'UUID generado en el cliente para deduplicacion de operaciones'; +COMMENT ON COLUMN offline.offline_queue.intentos_sync IS 'Numero de intentos de sincronizacion realizados'; +COMMENT ON COLUMN offline.offline_queue.historial_errores IS 'Historial de errores como array JSON [{timestamp, error}]'; +COMMENT ON COLUMN offline.offline_queue.resolucion_conflicto IS 'Estrategia de resolucion: CLIENT_WINS, SERVER_WINS, MERGE, MANUAL'; +COMMENT ON COLUMN offline.offline_queue.comprimido IS 'Indica si el payload esta comprimido (gzip)'; + +-- ============================================================================= +-- FUNCIONES AUXILIARES +-- ============================================================================= + +-- Funcion para obtener operaciones pendientes ordenadas por prioridad +CREATE OR REPLACE FUNCTION offline.get_pending_operations( + p_tenant_id UUID, + p_limit INT DEFAULT 100 +) +RETURNS TABLE ( + id UUID, + tipo_operacion offline.tipo_operacion_offline, + prioridad offline.prioridad_sync, + payload JSONB, + endpoint_destino VARCHAR(255), + metodo_http VARCHAR(10), + creado_offline_en TIMESTAMPTZ, + intentos_sync INT +) AS $$ +BEGIN + RETURN QUERY + SELECT + oq.id, + oq.tipo_operacion, + oq.prioridad, + oq.payload, + oq.endpoint_destino, + oq.metodo_http, + oq.creado_offline_en, + oq.intentos_sync + FROM offline.offline_queue oq + WHERE oq.tenant_id = p_tenant_id + AND oq.estado IN ('PENDIENTE', 'ERROR') + AND oq.intentos_sync < oq.max_intentos + ORDER BY oq.prioridad ASC, oq.creado_offline_en ASC + LIMIT p_limit; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Funcion para marcar operacion como en proceso +CREATE OR REPLACE FUNCTION offline.start_sync_operation( + p_operation_id UUID +) +RETURNS BOOLEAN AS $$ +DECLARE + v_updated INT; +BEGIN + UPDATE offline.offline_queue + SET + estado = 'EN_PROCESO', + ultimo_intento_en = NOW(), + intentos_sync = intentos_sync + 1 + WHERE id = p_operation_id + AND estado IN ('PENDIENTE', 'ERROR'); + + GET DIAGNOSTICS v_updated = ROW_COUNT; + RETURN v_updated > 0; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para completar operacion exitosamente +CREATE OR REPLACE FUNCTION offline.complete_sync_operation( + p_operation_id UUID +) +RETURNS BOOLEAN AS $$ +DECLARE + v_updated INT; +BEGIN + UPDATE offline.offline_queue + SET + estado = 'COMPLETADO', + sincronizado_en = NOW() + WHERE id = p_operation_id + AND estado = 'EN_PROCESO'; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + RETURN v_updated > 0; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para registrar error en operacion +CREATE OR REPLACE FUNCTION offline.fail_sync_operation( + p_operation_id UUID, + p_error_message TEXT +) +RETURNS BOOLEAN AS $$ +DECLARE + v_updated INT; + v_historial JSONB; +BEGIN + -- Obtener historial actual + SELECT historial_errores INTO v_historial + FROM offline.offline_queue + WHERE id = p_operation_id; + + -- Agregar nuevo error al historial + v_historial = v_historial || jsonb_build_object( + 'timestamp', NOW()::TEXT, + 'error', p_error_message + ); + + UPDATE offline.offline_queue + SET + estado = 'ERROR', + ultimo_error = p_error_message, + historial_errores = v_historial + WHERE id = p_operation_id + AND estado = 'EN_PROCESO'; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + RETURN v_updated > 0; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para limpiar operaciones antiguas completadas +CREATE OR REPLACE FUNCTION offline.cleanup_old_operations( + p_days_to_keep INT DEFAULT 30 +) +RETURNS INT AS $$ +DECLARE + v_deleted INT; +BEGIN + DELETE FROM offline.offline_queue + WHERE estado = 'COMPLETADO' + AND sincronizado_en < NOW() - (p_days_to_keep || ' days')::INTERVAL; + + GET DIAGNOSTICS v_deleted = ROW_COUNT; + RETURN v_deleted; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================= +-- GRANTS +-- ============================================================================= + +-- Permisos para funciones +GRANT EXECUTE ON FUNCTION offline.get_pending_operations(UUID, INT) TO PUBLIC; +GRANT EXECUTE ON FUNCTION offline.start_sync_operation(UUID) TO PUBLIC; +GRANT EXECUTE ON FUNCTION offline.complete_sync_operation(UUID) TO PUBLIC; +GRANT EXECUTE ON FUNCTION offline.fail_sync_operation(UUID, TEXT) TO PUBLIC; +GRANT EXECUTE ON FUNCTION offline.cleanup_old_operations(INT) TO PUBLIC; + +-- ============================================================================= +-- FIN DDL OFFLINE +-- =============================================================================