- Replace old DDL structure with new numbered files (01-24) - Update migrations and seeds for new schema - Clean up deprecated files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
394 lines
15 KiB
PL/PgSQL
394 lines
15 KiB
PL/PgSQL
-- =============================================================
|
|
-- 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';
|