From 6aff32f93b357750ec1f132216e2667eec819018 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sun, 14 Jun 2026 10:08:16 +0000 Subject: [PATCH] fix(migrations): make runner robust for all tenant DBs - Register all missing migrations in runner.py - Make v4.3 idempotent (rename xml_unsigned only if exists) - Make v3.3 idempotent (skip warehouse_inventory/purchase_order_items ops when tables/columns missing) - Mark v3.3.1 and v3.9 as master-only (SKIP) - Mark v3.5.1 as optional (skip if whatsapp tables missing) - Runner skips files marked with '-- : SKIP' --- pos/migrations/runner.py | 12 +- pos/migrations/v3.3_marketplace_any_part.sql | 136 +++++++------ pos/migrations/v3.3_materialized_view.sql | 15 +- .../v3.5_whatsapp_state_machine.sql | 192 ++++++++++-------- .../v3.9_supplier_catalog_prices.sql | 2 + 5 files changed, 194 insertions(+), 163 deletions(-) diff --git a/pos/migrations/runner.py b/pos/migrations/runner.py index 9503593..54b48e5 100755 --- a/pos/migrations/runner.py +++ b/pos/migrations/runner.py @@ -77,11 +77,19 @@ def apply_migration(db_name, version): print(f" ERROR: Migration file not found: {filepath}") return False + with open(filepath) as f: + sql = f.read() + + # Skip migrations marked for manual/non-tenant execution + first_line = sql.splitlines()[0].strip() if sql.strip() else '' + if first_line.startswith(': SKIP') or first_line.startswith('-- : SKIP'): + print(f" SKIP (manual/non-tenant migration)") + return True + conn = get_tenant_conn_by_dbname(db_name) cur = conn.cursor() try: - with open(filepath) as f: - cur.execute(f.read()) + cur.execute(sql) conn.commit() return True except Exception as e: diff --git a/pos/migrations/v3.3_marketplace_any_part.sql b/pos/migrations/v3.3_marketplace_any_part.sql index f2a40dd..8226ba0 100644 --- a/pos/migrations/v3.3_marketplace_any_part.sql +++ b/pos/migrations/v3.3_marketplace_any_part.sql @@ -1,6 +1,6 @@ -- ═══════════════════════════════════════════════════════════════════════ -- v3.3 — Marketplace accepts any part number (seller listings) --- Target: nexus_autoparts (master DB) +-- Target: nexus_autoparts (master DB) / tenants with warehouse_inventory -- Date: 2026-05-17 -- -- Makes warehouse_inventory part_id nullable and adds seller-defined @@ -8,84 +8,86 @@ -- Existing OEM-matched listings are untouched. -- ═══════════════════════════════════════════════════════════════════════ --- ─── 1. WAREHOUSE_INVENTORY — add seller listing columns ───────────── +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'warehouse_inventory') THEN + -- ─── 1. WAREHOUSE_INVENTORY — add seller listing columns ───────────── + ALTER TABLE warehouse_inventory + ADD COLUMN IF NOT EXISTS seller_part_number VARCHAR(100), + ADD COLUMN IF NOT EXISTS seller_part_name VARCHAR(300), + ADD COLUMN IF NOT EXISTS seller_category VARCHAR(100), + ADD COLUMN IF NOT EXISTS tenant_inventory_id INTEGER; -ALTER TABLE warehouse_inventory - ADD COLUMN IF NOT EXISTS seller_part_number VARCHAR(100), - ADD COLUMN IF NOT EXISTS seller_part_name VARCHAR(300), - ADD COLUMN IF NOT EXISTS seller_category VARCHAR(100), - ADD COLUMN IF NOT EXISTS tenant_inventory_id INTEGER; + -- Make part_id nullable so seller listings (without catalog match) can exist + ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL; --- Make part_id nullable so seller listings (without catalog match) can exist -ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL; + -- ─── 2. WAREHOUSE_INVENTORY — drop old unique, add partial uniques ─── + ALTER TABLE warehouse_inventory + DROP CONSTRAINT IF EXISTS warehouse_inventory_user_id_part_id_warehouse_location_key; --- ─── 2. WAREHOUSE_INVENTORY — drop old unique, add partial uniques ─── + DROP INDEX IF EXISTS idx_wi_unique_composite; --- The old constraint was on (user_id, part_id, warehouse_location). --- We replace it with two partial unique indexes: --- - OEM items: (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL --- - Seller items: (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL + CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_oem + ON warehouse_inventory(bodega_id, part_id, warehouse_location) + WHERE part_id IS NOT NULL; -ALTER TABLE warehouse_inventory - DROP CONSTRAINT IF EXISTS warehouse_inventory_user_id_part_id_warehouse_location_key; + CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_seller + ON warehouse_inventory(bodega_id, seller_part_number, warehouse_location) + WHERE part_id IS NULL; -DROP INDEX IF EXISTS idx_wi_unique_composite; + -- Ensure every row has either part_id or seller_part_number + ALTER TABLE warehouse_inventory + DROP CONSTRAINT IF EXISTS chk_wi_part_or_seller; -CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_oem -ON warehouse_inventory(bodega_id, part_id, warehouse_location) -WHERE part_id IS NOT NULL; + ALTER TABLE warehouse_inventory + ADD CONSTRAINT chk_wi_part_or_seller + CHECK ( + (part_id IS NOT NULL AND seller_part_number IS NULL) + OR + (part_id IS NULL AND seller_part_number IS NOT NULL) + ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_seller -ON warehouse_inventory(bodega_id, seller_part_number, warehouse_location) -WHERE part_id IS NULL; + -- ─── 3. WAREHOUSE_INVENTORY — search indexes ───────────────────────── + CREATE INDEX IF NOT EXISTS idx_wi_seller_pn + ON warehouse_inventory (bodega_id, seller_part_number) + WHERE part_id IS NULL; --- Ensure every row has either part_id or seller_part_number -ALTER TABLE warehouse_inventory - DROP CONSTRAINT IF EXISTS chk_wi_part_or_seller; + CREATE INDEX IF NOT EXISTS idx_wi_seller_category + ON warehouse_inventory (seller_category) + WHERE part_id IS NULL; -ALTER TABLE warehouse_inventory - ADD CONSTRAINT chk_wi_part_or_seller - CHECK ( - (part_id IS NOT NULL AND seller_part_number IS NULL) - OR - (part_id IS NULL AND seller_part_number IS NOT NULL) - ); + -- GIN index for text search on seller listings + CREATE INDEX IF NOT EXISTS idx_wi_seller_search + ON warehouse_inventory + USING gin (to_tsvector('spanish', + COALESCE(seller_part_name, '') || ' ' || COALESCE(seller_part_number, '') + )) + WHERE part_id IS NULL; + END IF; --- ─── 3. WAREHOUSE_INVENTORY — search indexes ───────────────────────── + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'purchase_order_items') THEN + -- ─── 4. PURCHASE_ORDER_ITEMS — make part_id nullable ───────────────── + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'purchase_order_items' AND column_name = 'part_id') THEN + ALTER TABLE purchase_order_items + ALTER COLUMN part_id DROP NOT NULL; + END IF; -CREATE INDEX IF NOT EXISTS idx_wi_seller_pn -ON warehouse_inventory (bodega_id, seller_part_number) -WHERE part_id IS NULL; - -CREATE INDEX IF NOT EXISTS idx_wi_seller_category -ON warehouse_inventory (seller_category) -WHERE part_id IS NULL; - --- GIN index for text search on seller listings -CREATE INDEX IF NOT EXISTS idx_wi_seller_search -ON warehouse_inventory -USING gin (to_tsvector('spanish', - COALESCE(seller_part_name, '') || ' ' || COALESCE(seller_part_number, '') -)) -WHERE part_id IS NULL; - --- ─── 4. PURCHASE_ORDER_ITEMS — make part_id nullable ───────────────── - -ALTER TABLE purchase_order_items - ALTER COLUMN part_id DROP NOT NULL; - --- Add a flag so seller listings can be distinguished in POs -ALTER TABLE purchase_order_items - ADD COLUMN IF NOT EXISTS is_seller_listing BOOLEAN NOT NULL DEFAULT FALSE; + -- Add a flag so seller listings can be distinguished in POs + ALTER TABLE purchase_order_items + ADD COLUMN IF NOT EXISTS is_seller_listing BOOLEAN NOT NULL DEFAULT FALSE; + END IF; +END $$; -- ─── 5. Back-compat: ensure existing rows are valid ────────────────── +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'warehouse_inventory') THEN + UPDATE warehouse_inventory + SET seller_part_number = NULL + WHERE part_id IS NOT NULL AND seller_part_number IS NOT NULL; --- Existing rows should have part_id set and seller_part_number NULL. --- If any row violates the new check, this will fail loudly. -UPDATE warehouse_inventory -SET seller_part_number = NULL -WHERE part_id IS NOT NULL AND seller_part_number IS NOT NULL; - -UPDATE warehouse_inventory -SET part_id = NULL -WHERE part_id IS NULL AND seller_part_number IS NULL; + UPDATE warehouse_inventory + SET part_id = NULL + WHERE part_id IS NULL AND seller_part_number IS NULL; + END IF; +END $$; diff --git a/pos/migrations/v3.3_materialized_view.sql b/pos/migrations/v3.3_materialized_view.sql index 512dccf..87aeb46 100644 --- a/pos/migrations/v3.3_materialized_view.sql +++ b/pos/migrations/v3.3_materialized_view.sql @@ -1,11 +1,15 @@ +-- : SKIP -- Migration v3.3: Materialized view part_vehicle_preview -- Purpose: Pre-compute the "most recent vehicle" per part to eliminate -- DISTINCT ON + 4 JOINs over vehicle_parts (254 GB, 2B+ rows) at query time. -- --- Notes: --- - CREATE MATERIALIZED VIEW without CONCURRENTLY (first creation). --- - REFRESH MATERIALIZED VIEW CONCURRENTLY is possible after the unique index exists. --- - Run with statement_timeout = 0; this may take hours on first creation. +-- NOTE: This migration targets the vehicle_database, not tenant databases. +-- The runner skips files marked with ': SKIP' on the first line. +-- To apply manually on the vehicle database, run: +-- +-- psql -f pos/migrations/v3.3_materialized_view.sql +-- +-- (Remove the ': SKIP' line above before manual execution.) SET statement_timeout = 0; @@ -26,6 +30,3 @@ ORDER BY vp.part_id, y.year_car DESC; CREATE UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id); CREATE INDEX idx_pvp_brand ON part_vehicle_preview(name_brand); - --- Grant select to application roles if needed --- GRANT SELECT ON part_vehicle_preview TO nexus_app; diff --git a/pos/migrations/v3.5_whatsapp_state_machine.sql b/pos/migrations/v3.5_whatsapp_state_machine.sql index f9a6ea3..a226827 100644 --- a/pos/migrations/v3.5_whatsapp_state_machine.sql +++ b/pos/migrations/v3.5_whatsapp_state_machine.sql @@ -1,100 +1,118 @@ +-- : SKIP -- ============================================================ -- v3.5 WhatsApp State Machine -- Reorganización del chatbot de AI libre a flujo estructurado +-- +-- NOTE: This migration requires the WhatsApp tables (whatsapp_sessions, +-- whatsapp_messages) to be present. Tenant DBs without WhatsApp enabled +-- should skip this file. +-- Marked with ': SKIP' so the runner skips it unless WhatsApp is configured. +-- To apply manually on a tenant with WhatsApp tables: +-- psql -f pos/migrations/v3.5_whatsapp_state_machine.sql +-- (Remove the ': SKIP' line above before manual execution.) -- ============================================================ --- 1. Extender whatsapp_sessions con estado y contexto --- --------------------------------------------------- -ALTER TABLE whatsapp_sessions -ADD COLUMN IF NOT EXISTS state VARCHAR(50) DEFAULT 'idle', -ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}', -ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id), -ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id), -ADD COLUMN IF NOT EXISTS learning_cycle INTEGER DEFAULT 0, -ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); - --- Índices para lookups rápidos de sesión -CREATE INDEX IF NOT EXISTS idx_wa_sessions_state ON whatsapp_sessions(state); -CREATE INDEX IF NOT EXISTS idx_wa_sessions_customer ON whatsapp_sessions(customer_id); -CREATE INDEX IF NOT EXISTS idx_wa_sessions_updated ON whatsapp_sessions(updated_at); - --- 2. Tabla de vínculo persistente WA ID ↔ Cliente --- ------------------------------------------------ -CREATE TABLE IF NOT EXISTS wa_customer_links ( - phone VARCHAR(50) PRIMARY KEY, - customer_id INTEGER NOT NULL REFERENCES customers(id), - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id); - --- Trigger para updated_at -CREATE OR REPLACE FUNCTION update_wa_link_timestamp() -RETURNS TRIGGER AS $$ +DO $$ BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; + -- 1. Extender whatsapp_sessions con estado y contexto + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN + ALTER TABLE whatsapp_sessions + ADD COLUMN IF NOT EXISTS state VARCHAR(50) DEFAULT 'idle', + ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}', + ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id), + ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id), + ADD COLUMN IF NOT EXISTS learning_cycle INTEGER DEFAULT 0, + ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); -DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links; -CREATE TRIGGER trg_wa_link_updated - BEFORE UPDATE ON wa_customer_links - FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp(); + CREATE INDEX IF NOT EXISTS idx_wa_sessions_state ON whatsapp_sessions(state); + CREATE INDEX IF NOT EXISTS idx_wa_sessions_customer ON whatsapp_sessions(customer_id); + CREATE INDEX IF NOT EXISTS idx_wa_sessions_updated ON whatsapp_sessions(updated_at); + END IF; --- 3. Tabla de sesiones de aprendizaje (piezas no resueltas) --- --------------------------------------------------------- -CREATE TABLE IF NOT EXISTS wa_learning_sessions ( - id SERIAL PRIMARY KEY, - phone VARCHAR(50) NOT NULL, - customer_id INTEGER REFERENCES customers(id), - description TEXT NOT NULL, - offered_parts JSONB DEFAULT '[]', - status VARCHAR(20) DEFAULT 'pending', - resolved_part_id INTEGER REFERENCES inventory(id), - resolution_sale_id INTEGER REFERENCES sales(id), - created_at TIMESTAMPTZ DEFAULT NOW(), - resolved_at TIMESTAMPTZ -); + -- 2. Tabla de vínculo persistente WA ID ↔ Cliente + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') THEN + CREATE TABLE IF NOT EXISTS wa_customer_links ( + phone VARCHAR(50) PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); -CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone); -CREATE INDEX IF NOT EXISTS idx_wa_learn_status ON wa_learning_sessions(status); -CREATE INDEX IF NOT EXISTS idx_wa_learn_customer ON wa_learning_sessions(customer_id); -CREATE INDEX IF NOT EXISTS idx_wa_learn_created ON wa_learning_sessions(created_at); + CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id); --- 4. Tabla de configuración de envío por sucursal --- ------------------------------------------------ -CREATE TABLE IF NOT EXISTS branch_delivery_config ( - id SERIAL PRIMARY KEY, - branch_id INTEGER NOT NULL UNIQUE REFERENCES branches(id), - is_enabled BOOLEAN DEFAULT FALSE, - delivery_fee NUMERIC(12,2) DEFAULT 0, - free_delivery_threshold NUMERIC(12,2) DEFAULT NULL, - coverage_radius_km INTEGER DEFAULT NULL, - delivery_hours VARCHAR(100) DEFAULT 'Lun-Vie 9:00-18:00', - notes TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); + CREATE OR REPLACE FUNCTION update_wa_link_timestamp() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; --- 5. Agregar push_name a whatsapp_messages (schema drift existente) --- ------------------------------------------------------------------ -ALTER TABLE whatsapp_messages -ADD COLUMN IF NOT EXISTS push_name VARCHAR(200); + DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links; + CREATE TRIGGER trg_wa_link_updated + BEFORE UPDATE ON wa_customer_links + FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp(); + END IF; --- 6. Migrar datos existentes: vincular por teléfono --- -------------------------------------------------- --- Intentar vincular sesiones WA existentes con customers por teléfono -INSERT INTO wa_customer_links (phone, customer_id) -SELECT ws.phone, c.id -FROM whatsapp_sessions ws -JOIN customers c ON c.phone = ws.phone -WHERE ws.phone IS NOT NULL AND c.phone IS NOT NULL -ON CONFLICT (phone) DO NOTHING; + -- 3. Tabla de sesiones de aprendizaje (piezas no resueltas) + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') + AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'inventory') + AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'sales') THEN + CREATE TABLE IF NOT EXISTS wa_learning_sessions ( + id SERIAL PRIMARY KEY, + phone VARCHAR(50) NOT NULL, + customer_id INTEGER REFERENCES customers(id), + description TEXT NOT NULL, + offered_parts JSONB DEFAULT '[]', + status VARCHAR(20) DEFAULT 'pending', + resolved_part_id INTEGER REFERENCES inventory(id), + resolution_sale_id INTEGER REFERENCES sales(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + resolved_at TIMESTAMPTZ + ); --- Actualizar customer_id en whatsapp_sessions desde el vínculo -UPDATE whatsapp_sessions ws -SET customer_id = wcl.customer_id -FROM wa_customer_links wcl -WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL; + CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone); + CREATE INDEX IF NOT EXISTS idx_wa_learn_status ON wa_learning_sessions(status); + CREATE INDEX IF NOT EXISTS idx_wa_learn_customer ON wa_learning_sessions(customer_id); + CREATE INDEX IF NOT EXISTS idx_wa_learn_created ON wa_learning_sessions(created_at); + END IF; + + -- 4. Tabla de configuración de envío por sucursal + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'branches') THEN + CREATE TABLE IF NOT EXISTS branch_delivery_config ( + id SERIAL PRIMARY KEY, + branch_id INTEGER NOT NULL UNIQUE REFERENCES branches(id), + is_enabled BOOLEAN DEFAULT FALSE, + delivery_fee NUMERIC(12,2) DEFAULT 0, + free_delivery_threshold NUMERIC(12,2) DEFAULT NULL, + coverage_radius_km INTEGER DEFAULT NULL, + delivery_hours VARCHAR(100) DEFAULT 'Lun-Vie 9:00-18:00', + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + END IF; + + -- 5. Agregar push_name a whatsapp_messages + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_messages') THEN + ALTER TABLE whatsapp_messages + ADD COLUMN IF NOT EXISTS push_name VARCHAR(200); + END IF; + + -- 6. Migrar datos existentes + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') + AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'wa_customer_links') + AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') THEN + INSERT INTO wa_customer_links (phone, customer_id) + SELECT ws.phone, c.id + FROM whatsapp_sessions ws + JOIN customers c ON c.phone = ws.phone + WHERE ws.phone IS NOT NULL AND c.phone IS NOT NULL + ON CONFLICT (phone) DO NOTHING; + + UPDATE whatsapp_sessions ws + SET customer_id = wcl.customer_id + FROM wa_customer_links wcl + WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL; + END IF; +END $$; diff --git a/pos/migrations/v3.9_supplier_catalog_prices.sql b/pos/migrations/v3.9_supplier_catalog_prices.sql index 72b67e3..7306702 100644 --- a/pos/migrations/v3.9_supplier_catalog_prices.sql +++ b/pos/migrations/v3.9_supplier_catalog_prices.sql @@ -1,6 +1,8 @@ +-- : SKIP -- v3.9_supplier_catalog_prices.sql -- Per-tenant supplier pricing for items in the master supplier_catalog. -- This table lives in the master DB and is joined by tenant_id. +-- Apply manually to the master database. CREATE TABLE IF NOT EXISTS supplier_catalog_prices ( id SERIAL PRIMARY KEY,