-- ============================================================= -- ARCHIVO: 04-mobile.sql -- DESCRIPCION: Sesiones moviles, sincronizacion offline, push tokens -- VERSION: 1.0.0 -- PROYECTO: ERP-Core V2 -- FECHA: 2026-01-10 -- ============================================================= -- ===================== -- SCHEMA: mobile -- ===================== CREATE SCHEMA IF NOT EXISTS mobile; -- ===================== -- TABLA: mobile_sessions -- Sesiones activas de la aplicacion movil -- ===================== CREATE TABLE IF NOT EXISTS mobile.mobile_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, branch_id UUID REFERENCES core.branches(id), -- Estado de la sesion status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, paused, expired, terminated -- Perfil activo active_profile_id UUID REFERENCES auth.user_profiles(id), active_profile_code VARCHAR(10), -- Modo de operacion is_offline_mode BOOLEAN DEFAULT FALSE, offline_since TIMESTAMPTZ, -- Sincronizacion last_sync_at TIMESTAMPTZ, pending_sync_count INTEGER DEFAULT 0, -- Ubicacion last_latitude DECIMAL(10, 8), last_longitude DECIMAL(11, 8), last_location_at TIMESTAMPTZ, -- Metadata app_version VARCHAR(20), platform VARCHAR(20), -- ios, android os_version VARCHAR(20), -- Tiempos started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, last_activity_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMPTZ, ended_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para mobile_sessions CREATE INDEX IF NOT EXISTS idx_mobile_sessions_user ON mobile.mobile_sessions(user_id); CREATE INDEX IF NOT EXISTS idx_mobile_sessions_device ON mobile.mobile_sessions(device_id); CREATE INDEX IF NOT EXISTS idx_mobile_sessions_tenant ON mobile.mobile_sessions(tenant_id); CREATE INDEX IF NOT EXISTS idx_mobile_sessions_branch ON mobile.mobile_sessions(branch_id); CREATE INDEX IF NOT EXISTS idx_mobile_sessions_active ON mobile.mobile_sessions(status) WHERE status = 'active'; -- ===================== -- TABLA: offline_sync_queue -- Cola de operaciones pendientes de sincronizar -- ===================== CREATE TABLE IF NOT EXISTS mobile.offline_sync_queue ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, session_id UUID REFERENCES mobile.mobile_sessions(id), -- Operacion entity_type VARCHAR(50) NOT NULL, -- sale, attendance, inventory_count, etc. entity_id UUID, -- ID local del registro operation VARCHAR(20) NOT NULL, -- create, update, delete -- Datos payload JSONB NOT NULL, metadata JSONB DEFAULT '{}', -- Orden y dependencias sequence_number BIGINT NOT NULL, depends_on UUID, -- ID de otra operacion que debe procesarse primero -- Estado status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed, conflict -- Procesamiento retry_count INTEGER DEFAULT 0, max_retries INTEGER DEFAULT 3, last_error TEXT, processed_at TIMESTAMPTZ, -- Conflicto conflict_data JSONB, conflict_resolved_at TIMESTAMPTZ, conflict_resolution VARCHAR(20), -- local_wins, server_wins, merged, manual created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para offline_sync_queue CREATE INDEX IF NOT EXISTS idx_offline_sync_user ON mobile.offline_sync_queue(user_id); CREATE INDEX IF NOT EXISTS idx_offline_sync_device ON mobile.offline_sync_queue(device_id); CREATE INDEX IF NOT EXISTS idx_offline_sync_tenant ON mobile.offline_sync_queue(tenant_id); CREATE INDEX IF NOT EXISTS idx_offline_sync_status ON mobile.offline_sync_queue(status); CREATE INDEX IF NOT EXISTS idx_offline_sync_sequence ON mobile.offline_sync_queue(device_id, sequence_number); CREATE INDEX IF NOT EXISTS idx_offline_sync_pending ON mobile.offline_sync_queue(status, created_at) WHERE status = 'pending'; -- ===================== -- TABLA: sync_conflicts -- Registro de conflictos de sincronizacion -- ===================== CREATE TABLE IF NOT EXISTS mobile.sync_conflicts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), sync_queue_id UUID NOT NULL REFERENCES mobile.offline_sync_queue(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), -- Tipo de conflicto conflict_type VARCHAR(30) NOT NULL, -- version_mismatch, deleted_on_server, concurrent_edit -- Datos en conflicto local_data JSONB NOT NULL, server_data JSONB NOT NULL, -- Resolucion resolution VARCHAR(20), -- local_wins, server_wins, merged, manual merged_data JSONB, resolved_by UUID REFERENCES auth.users(id), resolved_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para sync_conflicts CREATE INDEX IF NOT EXISTS idx_sync_conflicts_queue ON mobile.sync_conflicts(sync_queue_id); CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user ON mobile.sync_conflicts(user_id); CREATE INDEX IF NOT EXISTS idx_sync_conflicts_unresolved ON mobile.sync_conflicts(resolved_at) WHERE resolved_at IS NULL; -- ===================== -- TABLA: push_tokens -- Tokens de notificaciones push -- ===================== CREATE TABLE IF NOT EXISTS mobile.push_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Token token TEXT NOT NULL, platform VARCHAR(20) NOT NULL, -- ios, android provider VARCHAR(30) NOT NULL DEFAULT 'firebase', -- firebase, apns, fcm -- Estado is_active BOOLEAN DEFAULT TRUE, is_valid BOOLEAN DEFAULT TRUE, invalid_reason TEXT, -- Topics suscritos subscribed_topics TEXT[] DEFAULT '{}', -- Ultima actividad last_used_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(device_id, platform) ); -- Indices para push_tokens CREATE INDEX IF NOT EXISTS idx_push_tokens_user ON mobile.push_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_push_tokens_device ON mobile.push_tokens(device_id); CREATE INDEX IF NOT EXISTS idx_push_tokens_tenant ON mobile.push_tokens(tenant_id); CREATE INDEX IF NOT EXISTS idx_push_tokens_active ON mobile.push_tokens(is_active, is_valid) WHERE is_active = TRUE AND is_valid = TRUE; -- ===================== -- TABLA: push_notifications_log -- Log de notificaciones enviadas -- ===================== CREATE TABLE IF NOT EXISTS mobile.push_notifications_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Destino user_id UUID REFERENCES auth.users(id), device_id UUID REFERENCES auth.devices(id), push_token_id UUID REFERENCES mobile.push_tokens(id), -- Notificacion title VARCHAR(200) NOT NULL, body TEXT, data JSONB DEFAULT '{}', category VARCHAR(50), -- attendance, sale, inventory, alert, system -- Envio sent_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, provider_message_id VARCHAR(255), -- Estado status VARCHAR(20) NOT NULL DEFAULT 'sent', -- sent, delivered, failed, read delivered_at TIMESTAMPTZ, read_at TIMESTAMPTZ, error_message TEXT, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para push_notifications_log CREATE INDEX IF NOT EXISTS idx_push_log_tenant ON mobile.push_notifications_log(tenant_id); CREATE INDEX IF NOT EXISTS idx_push_log_user ON mobile.push_notifications_log(user_id); CREATE INDEX IF NOT EXISTS idx_push_log_device ON mobile.push_notifications_log(device_id); CREATE INDEX IF NOT EXISTS idx_push_log_created ON mobile.push_notifications_log(created_at DESC); CREATE INDEX IF NOT EXISTS idx_push_log_category ON mobile.push_notifications_log(category); -- ===================== -- TABLA: payment_transactions -- Transacciones de pago desde terminales moviles -- ===================== CREATE TABLE IF NOT EXISTS mobile.payment_transactions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, branch_id UUID REFERENCES core.branches(id), user_id UUID NOT NULL REFERENCES auth.users(id), device_id UUID REFERENCES auth.devices(id), -- Referencia al documento origen source_type VARCHAR(30) NOT NULL, -- sale, invoice, subscription source_id UUID NOT NULL, -- Terminal de pago terminal_provider VARCHAR(30) NOT NULL, -- clip, mercadopago, stripe terminal_id VARCHAR(100), -- Transaccion external_transaction_id VARCHAR(255), amount DECIMAL(12,2) NOT NULL, currency VARCHAR(3) DEFAULT 'MXN', tip_amount DECIMAL(12,2) DEFAULT 0, total_amount DECIMAL(12,2) NOT NULL, -- Metodo de pago payment_method VARCHAR(30) NOT NULL, -- card, contactless, qr, link card_brand VARCHAR(20), -- visa, mastercard, amex card_last_four VARCHAR(4), card_type VARCHAR(20), -- credit, debit -- Estado status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed, refunded, cancelled failure_reason TEXT, -- Tiempos initiated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMPTZ, -- Metadata del proveedor provider_response JSONB DEFAULT '{}', -- Recibo receipt_url TEXT, receipt_sent BOOLEAN DEFAULT FALSE, receipt_sent_to VARCHAR(255), created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para payment_transactions CREATE INDEX IF NOT EXISTS idx_payment_tx_tenant ON mobile.payment_transactions(tenant_id); CREATE INDEX IF NOT EXISTS idx_payment_tx_branch ON mobile.payment_transactions(branch_id); CREATE INDEX IF NOT EXISTS idx_payment_tx_user ON mobile.payment_transactions(user_id); CREATE INDEX IF NOT EXISTS idx_payment_tx_source ON mobile.payment_transactions(source_type, source_id); CREATE INDEX IF NOT EXISTS idx_payment_tx_external ON mobile.payment_transactions(external_transaction_id); CREATE INDEX IF NOT EXISTS idx_payment_tx_status ON mobile.payment_transactions(status); CREATE INDEX IF NOT EXISTS idx_payment_tx_created ON mobile.payment_transactions(created_at DESC); -- ===================== -- RLS POLICIES -- ===================== ALTER TABLE mobile.mobile_sessions ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_mobile_sessions ON mobile.mobile_sessions USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE mobile.offline_sync_queue ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_sync_queue ON mobile.offline_sync_queue USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE mobile.sync_conflicts ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_sync_conflicts ON mobile.sync_conflicts USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE mobile.push_tokens ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_push_tokens ON mobile.push_tokens USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE mobile.push_notifications_log ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_push_log ON mobile.push_notifications_log USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE mobile.payment_transactions ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_payment_tx ON mobile.payment_transactions USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- ===================== -- FUNCIONES -- ===================== -- Funcion para obtener siguiente numero de secuencia CREATE OR REPLACE FUNCTION mobile.get_next_sync_sequence(p_device_id UUID) RETURNS BIGINT AS $$ DECLARE next_seq BIGINT; BEGIN SELECT COALESCE(MAX(sequence_number), 0) + 1 INTO next_seq FROM mobile.offline_sync_queue WHERE device_id = p_device_id; RETURN next_seq; END; $$ LANGUAGE plpgsql; -- Funcion para procesar cola de sincronizacion CREATE OR REPLACE FUNCTION mobile.process_sync_queue(p_device_id UUID, p_batch_size INTEGER DEFAULT 100) RETURNS TABLE ( processed_count INTEGER, failed_count INTEGER, conflict_count INTEGER ) AS $$ DECLARE v_processed INTEGER := 0; v_failed INTEGER := 0; v_conflicts INTEGER := 0; BEGIN -- Marcar items como processing (usando subquery para ORDER BY y LIMIT en PostgreSQL) UPDATE mobile.offline_sync_queue SET status = 'processing', updated_at = CURRENT_TIMESTAMP WHERE id IN ( SELECT osq.id FROM mobile.offline_sync_queue osq WHERE osq.device_id = p_device_id AND osq.status = 'pending' AND (osq.depends_on IS NULL OR osq.depends_on IN ( SELECT id FROM mobile.offline_sync_queue WHERE status = 'completed' )) ORDER BY osq.sequence_number LIMIT p_batch_size ); -- Aqui iria la logica de procesamiento real -- Por ahora solo retornamos conteos SELECT COUNT(*) INTO v_processed FROM mobile.offline_sync_queue WHERE device_id = p_device_id AND status = 'processing'; RETURN QUERY SELECT v_processed, v_failed, v_conflicts; END; $$ LANGUAGE plpgsql; -- Funcion para limpiar sesiones inactivas CREATE OR REPLACE FUNCTION mobile.cleanup_inactive_sessions(p_hours INTEGER DEFAULT 24) RETURNS INTEGER AS $$ DECLARE cleaned_count INTEGER; BEGIN UPDATE mobile.mobile_sessions SET status = 'expired', ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE status = 'active' AND last_activity_at < CURRENT_TIMESTAMP - (p_hours || ' hours')::INTERVAL; GET DIAGNOSTICS cleaned_count = ROW_COUNT; RETURN cleaned_count; END; $$ LANGUAGE plpgsql; -- ===================== -- COMENTARIOS DE TABLAS -- ===================== COMMENT ON TABLE mobile.mobile_sessions IS 'Sesiones activas de la aplicacion movil'; COMMENT ON TABLE mobile.offline_sync_queue IS 'Cola de operaciones pendientes de sincronizar desde modo offline'; COMMENT ON TABLE mobile.sync_conflicts IS 'Registro de conflictos de sincronizacion detectados'; COMMENT ON TABLE mobile.push_tokens IS 'Tokens de notificaciones push por dispositivo'; COMMENT ON TABLE mobile.push_notifications_log IS 'Log de notificaciones push enviadas'; COMMENT ON TABLE mobile.payment_transactions IS 'Transacciones de pago desde terminales moviles';