-- ============================================================= -- ARCHIVO: 03-core-branches.sql -- DESCRIPCION: Sucursales, jerarquia y asignaciones de usuarios -- VERSION: 1.0.0 -- PROYECTO: ERP-Core V2 -- FECHA: 2026-01-10 -- ============================================================= -- ===================== -- EXTENSIONES REQUERIDAS -- ===================== CREATE EXTENSION IF NOT EXISTS cube; CREATE EXTENSION IF NOT EXISTS earthdistance; -- ===================== -- SCHEMA: core (si no existe) -- ===================== CREATE SCHEMA IF NOT EXISTS core; -- ===================== -- TABLA: branches -- Sucursales/ubicaciones del negocio -- ===================== CREATE TABLE IF NOT EXISTS core.branches ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, parent_id UUID REFERENCES core.branches(id) ON DELETE SET NULL, -- Identificacion code VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL, short_name VARCHAR(50), -- Tipo branch_type VARCHAR(30) NOT NULL DEFAULT 'store', -- headquarters, regional, store, warehouse, office, factory -- Contacto phone VARCHAR(20), email VARCHAR(255), manager_id UUID REFERENCES auth.users(id), -- Direccion address_line1 VARCHAR(200), address_line2 VARCHAR(200), city VARCHAR(100), state VARCHAR(100), postal_code VARCHAR(20), country VARCHAR(3) DEFAULT 'MEX', -- Geolocalizacion latitude DECIMAL(10, 8), longitude DECIMAL(11, 8), geofence_radius INTEGER DEFAULT 100, -- Radio en metros para validacion de ubicacion geofence_enabled BOOLEAN DEFAULT TRUE, -- Configuracion timezone VARCHAR(50) DEFAULT 'America/Mexico_City', currency VARCHAR(3) DEFAULT 'MXN', is_active BOOLEAN DEFAULT TRUE, is_main BOOLEAN DEFAULT FALSE, -- Sucursal principal/matriz -- Horarios de operacion operating_hours JSONB DEFAULT '{}', -- Ejemplo: {"monday": {"open": "09:00", "close": "18:00"}, ...} -- Configuraciones especificas settings JSONB DEFAULT '{}', -- Ejemplo: {"allow_pos": true, "allow_warehouse": true, ...} -- Jerarquia (path materializado para consultas eficientes) hierarchy_path TEXT, -- Ejemplo: /root/regional-norte/sucursal-01 hierarchy_level INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_by UUID REFERENCES auth.users(id), deleted_at TIMESTAMPTZ, UNIQUE(tenant_id, code) ); -- Indices para branches CREATE INDEX IF NOT EXISTS idx_branches_tenant ON core.branches(tenant_id); CREATE INDEX IF NOT EXISTS idx_branches_parent ON core.branches(parent_id); CREATE INDEX IF NOT EXISTS idx_branches_code ON core.branches(code); CREATE INDEX IF NOT EXISTS idx_branches_type ON core.branches(branch_type); CREATE INDEX IF NOT EXISTS idx_branches_active ON core.branches(is_active) WHERE is_active = TRUE; CREATE INDEX IF NOT EXISTS idx_branches_hierarchy ON core.branches(hierarchy_path); CREATE INDEX IF NOT EXISTS idx_branches_location ON core.branches USING gist ( ll_to_earth(latitude, longitude) ) WHERE latitude IS NOT NULL AND longitude IS NOT NULL; -- ===================== -- TABLA: user_branch_assignments -- Asignacion de usuarios a sucursales -- ===================== CREATE TABLE IF NOT EXISTS core.user_branch_assignments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Tipo de asignacion assignment_type VARCHAR(30) NOT NULL DEFAULT 'primary', -- primary, secondary, temporary, floating -- Rol en la sucursal branch_role VARCHAR(50), -- manager, supervisor, staff -- Permisos especificos permissions JSONB DEFAULT '[]', -- Vigencia (para asignaciones temporales) valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, valid_until TIMESTAMPTZ, -- Estado is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, branch_id, assignment_type) ); -- Indices para user_branch_assignments CREATE INDEX IF NOT EXISTS idx_user_branch_user ON core.user_branch_assignments(user_id); CREATE INDEX IF NOT EXISTS idx_user_branch_branch ON core.user_branch_assignments(branch_id); CREATE INDEX IF NOT EXISTS idx_user_branch_tenant ON core.user_branch_assignments(tenant_id); CREATE INDEX IF NOT EXISTS idx_user_branch_active ON core.user_branch_assignments(is_active) WHERE is_active = TRUE; -- ===================== -- TABLA: branch_schedules -- Horarios de trabajo por sucursal -- ===================== CREATE TABLE IF NOT EXISTS core.branch_schedules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE, -- Identificacion name VARCHAR(100) NOT NULL, description TEXT, -- Tipo schedule_type VARCHAR(30) NOT NULL DEFAULT 'regular', -- regular, holiday, special -- Dia de la semana (0=domingo, 1=lunes, ..., 6=sabado) o fecha especifica day_of_week INTEGER, -- NULL para fechas especificas specific_date DATE, -- Para dias festivos o especiales -- Horarios open_time TIME NOT NULL, close_time TIME NOT NULL, -- Turnos (si aplica) shifts JSONB DEFAULT '[]', -- Ejemplo: [{"name": "Matutino", "start": "08:00", "end": "14:00"}, ...] -- Estado is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para branch_schedules CREATE INDEX IF NOT EXISTS idx_branch_schedules_branch ON core.branch_schedules(branch_id); CREATE INDEX IF NOT EXISTS idx_branch_schedules_day ON core.branch_schedules(day_of_week); CREATE INDEX IF NOT EXISTS idx_branch_schedules_date ON core.branch_schedules(specific_date); -- ===================== -- TABLA: branch_inventory_settings -- Configuracion de inventario por sucursal -- ===================== CREATE TABLE IF NOT EXISTS core.branch_inventory_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE, -- Almacen asociado warehouse_id UUID, -- Referencia a inventory.warehouses -- Configuracion de stock default_stock_min INTEGER DEFAULT 0, default_stock_max INTEGER DEFAULT 1000, auto_reorder_enabled BOOLEAN DEFAULT FALSE, -- Configuracion de precios price_list_id UUID, -- Referencia a sales.price_lists allow_price_override BOOLEAN DEFAULT FALSE, max_discount_percent DECIMAL(5,2) DEFAULT 0, -- Configuracion de impuestos tax_config JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(branch_id) ); -- Indices para branch_inventory_settings CREATE INDEX IF NOT EXISTS idx_branch_inventory_branch ON core.branch_inventory_settings(branch_id); -- ===================== -- TABLA: branch_payment_terminals -- Terminales de pago asociadas a sucursal -- ===================== CREATE TABLE IF NOT EXISTS core.branch_payment_terminals ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE, -- Terminal terminal_provider VARCHAR(30) NOT NULL, -- clip, mercadopago, stripe terminal_id VARCHAR(100) NOT NULL, terminal_name VARCHAR(100), -- Credenciales (encriptadas) credentials JSONB NOT NULL DEFAULT '{}', -- Configuracion is_primary BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, -- Limites daily_limit DECIMAL(12,2), transaction_limit DECIMAL(12,2), -- Ultima actividad last_transaction_at TIMESTAMPTZ, last_health_check_at TIMESTAMPTZ, health_status VARCHAR(20) DEFAULT 'unknown', -- healthy, degraded, offline, unknown created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(branch_id, terminal_provider, terminal_id) ); -- Indices para branch_payment_terminals CREATE INDEX IF NOT EXISTS idx_branch_terminals_branch ON core.branch_payment_terminals(branch_id); CREATE INDEX IF NOT EXISTS idx_branch_terminals_provider ON core.branch_payment_terminals(terminal_provider); CREATE INDEX IF NOT EXISTS idx_branch_terminals_active ON core.branch_payment_terminals(is_active) WHERE is_active = TRUE; -- ===================== -- RLS POLICIES -- ===================== ALTER TABLE core.branches ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_branches ON core.branches USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE core.user_branch_assignments ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_branch_assignments ON core.user_branch_assignments USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE core.branch_schedules ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_branch_schedules ON core.branch_schedules USING (branch_id IN ( SELECT id FROM core.branches WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid )); ALTER TABLE core.branch_inventory_settings ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_branch_inventory ON core.branch_inventory_settings USING (branch_id IN ( SELECT id FROM core.branches WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid )); ALTER TABLE core.branch_payment_terminals ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_branch_terminals ON core.branch_payment_terminals USING (branch_id IN ( SELECT id FROM core.branches WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid )); -- ===================== -- FUNCIONES -- ===================== -- Funcion para actualizar hierarchy_path CREATE OR REPLACE FUNCTION core.update_branch_hierarchy() RETURNS TRIGGER AS $$ DECLARE parent_path TEXT; BEGIN IF NEW.parent_id IS NULL THEN NEW.hierarchy_path := '/' || NEW.code; NEW.hierarchy_level := 0; ELSE SELECT hierarchy_path, hierarchy_level + 1 INTO parent_path, NEW.hierarchy_level FROM core.branches WHERE id = NEW.parent_id; NEW.hierarchy_path := parent_path || '/' || NEW.code; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; -- Trigger para actualizar hierarchy_path automaticamente CREATE TRIGGER trg_update_branch_hierarchy BEFORE INSERT OR UPDATE OF parent_id, code ON core.branches FOR EACH ROW EXECUTE FUNCTION core.update_branch_hierarchy(); -- Funcion para obtener todas las sucursales hijas CREATE OR REPLACE FUNCTION core.get_branch_children(parent_branch_id UUID) RETURNS SETOF core.branches AS $$ BEGIN RETURN QUERY WITH RECURSIVE branch_tree AS ( SELECT * FROM core.branches WHERE id = parent_branch_id UNION ALL SELECT b.* FROM core.branches b JOIN branch_tree bt ON b.parent_id = bt.id ) SELECT * FROM branch_tree WHERE id != parent_branch_id; END; $$ LANGUAGE plpgsql; -- Funcion para validar si usuario esta en rango de geofence CREATE OR REPLACE FUNCTION core.is_within_geofence( branch_id UUID, user_lat DECIMAL(10, 8), user_lon DECIMAL(11, 8) ) RETURNS BOOLEAN AS $$ DECLARE branch_record RECORD; distance_meters FLOAT; BEGIN SELECT latitude, longitude, geofence_radius, geofence_enabled INTO branch_record FROM core.branches WHERE id = branch_id; IF NOT branch_record.geofence_enabled THEN RETURN TRUE; END IF; IF branch_record.latitude IS NULL OR branch_record.longitude IS NULL THEN RETURN TRUE; END IF; -- Calcular distancia usando formula Haversine (aproximada) distance_meters := 6371000 * acos( cos(radians(user_lat)) * cos(radians(branch_record.latitude)) * cos(radians(branch_record.longitude) - radians(user_lon)) + sin(radians(user_lat)) * sin(radians(branch_record.latitude)) ); RETURN distance_meters <= branch_record.geofence_radius; END; $$ LANGUAGE plpgsql; -- ===================== -- COMENTARIOS DE TABLAS -- ===================== COMMENT ON TABLE core.branches IS 'Sucursales/ubicaciones del negocio con soporte para jerarquia'; COMMENT ON TABLE core.user_branch_assignments IS 'Asignacion de usuarios a sucursales'; COMMENT ON TABLE core.branch_schedules IS 'Horarios de operacion por sucursal'; COMMENT ON TABLE core.branch_inventory_settings IS 'Configuracion de inventario especifica por sucursal'; COMMENT ON TABLE core.branch_payment_terminals IS 'Terminales de pago asociadas a cada sucursal';