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'
This commit is contained in:
2026-06-14 10:08:16 +00:00
parent 7d21d21200
commit 6aff32f93b
5 changed files with 194 additions and 163 deletions

View File

@@ -77,11 +77,19 @@ def apply_migration(db_name, version):
print(f" ERROR: Migration file not found: {filepath}") print(f" ERROR: Migration file not found: {filepath}")
return False 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) conn = get_tenant_conn_by_dbname(db_name)
cur = conn.cursor() cur = conn.cursor()
try: try:
with open(filepath) as f: cur.execute(sql)
cur.execute(f.read())
conn.commit() conn.commit()
return True return True
except Exception as e: except Exception as e:

View File

@@ -1,6 +1,6 @@
-- ═══════════════════════════════════════════════════════════════════════ -- ═══════════════════════════════════════════════════════════════════════
-- v3.3 — Marketplace accepts any part number (seller listings) -- 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 -- Date: 2026-05-17
-- --
-- Makes warehouse_inventory part_id nullable and adds seller-defined -- Makes warehouse_inventory part_id nullable and adds seller-defined
@@ -8,84 +8,86 @@
-- Existing OEM-matched listings are untouched. -- 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 -- Make part_id nullable so seller listings (without catalog match) can exist
ADD COLUMN IF NOT EXISTS seller_part_number VARCHAR(100), ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL;
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 -- ─── 2. WAREHOUSE_INVENTORY — drop old unique, add partial uniques ───
ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL; 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). CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_oem
-- We replace it with two partial unique indexes: ON warehouse_inventory(bodega_id, part_id, warehouse_location)
-- - OEM items: (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL WHERE part_id IS NOT NULL;
-- - Seller items: (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL
ALTER TABLE warehouse_inventory CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_seller
DROP CONSTRAINT IF EXISTS warehouse_inventory_user_id_part_id_warehouse_location_key; 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 ALTER TABLE warehouse_inventory
ON warehouse_inventory(bodega_id, part_id, warehouse_location) ADD CONSTRAINT chk_wi_part_or_seller
WHERE part_id IS NOT NULL; 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 -- ─── 3. WAREHOUSE_INVENTORY — search indexes ─────────────────────────
ON warehouse_inventory(bodega_id, seller_part_number, warehouse_location) CREATE INDEX IF NOT EXISTS idx_wi_seller_pn
WHERE part_id IS NULL; ON warehouse_inventory (bodega_id, seller_part_number)
WHERE part_id IS NULL;
-- Ensure every row has either part_id or seller_part_number CREATE INDEX IF NOT EXISTS idx_wi_seller_category
ALTER TABLE warehouse_inventory ON warehouse_inventory (seller_category)
DROP CONSTRAINT IF EXISTS chk_wi_part_or_seller; WHERE part_id IS NULL;
ALTER TABLE warehouse_inventory -- GIN index for text search on seller listings
ADD CONSTRAINT chk_wi_part_or_seller CREATE INDEX IF NOT EXISTS idx_wi_seller_search
CHECK ( ON warehouse_inventory
(part_id IS NOT NULL AND seller_part_number IS NULL) USING gin (to_tsvector('spanish',
OR COALESCE(seller_part_name, '') || ' ' || COALESCE(seller_part_number, '')
(part_id IS NULL AND seller_part_number IS NOT NULL) ))
); 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 -- Add a flag so seller listings can be distinguished in POs
ON warehouse_inventory (bodega_id, seller_part_number) ALTER TABLE purchase_order_items
WHERE part_id IS NULL; ADD COLUMN IF NOT EXISTS is_seller_listing BOOLEAN NOT NULL DEFAULT FALSE;
END IF;
CREATE INDEX IF NOT EXISTS idx_wi_seller_category END $$;
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;
-- ─── 5. Back-compat: ensure existing rows are valid ────────────────── -- ─── 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. UPDATE warehouse_inventory
-- If any row violates the new check, this will fail loudly. SET part_id = NULL
UPDATE warehouse_inventory WHERE part_id IS NULL AND seller_part_number IS NULL;
SET seller_part_number = NULL END IF;
WHERE part_id IS NOT NULL AND seller_part_number IS NOT NULL; END $$;
UPDATE warehouse_inventory
SET part_id = NULL
WHERE part_id IS NULL AND seller_part_number IS NULL;

View File

@@ -1,11 +1,15 @@
-- : SKIP
-- Migration v3.3: Materialized view part_vehicle_preview -- Migration v3.3: Materialized view part_vehicle_preview
-- Purpose: Pre-compute the "most recent vehicle" per part to eliminate -- 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. -- DISTINCT ON + 4 JOINs over vehicle_parts (254 GB, 2B+ rows) at query time.
-- --
-- Notes: -- NOTE: This migration targets the vehicle_database, not tenant databases.
-- - CREATE MATERIALIZED VIEW without CONCURRENTLY (first creation). -- The runner skips files marked with ': SKIP' on the first line.
-- - REFRESH MATERIALIZED VIEW CONCURRENTLY is possible after the unique index exists. -- To apply manually on the vehicle database, run:
-- - Run with statement_timeout = 0; this may take hours on first creation. --
-- psql <vehicle_db> -f pos/migrations/v3.3_materialized_view.sql
--
-- (Remove the ': SKIP' line above before manual execution.)
SET statement_timeout = 0; 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 UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id);
CREATE INDEX idx_pvp_brand ON part_vehicle_preview(name_brand); 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;

View File

@@ -1,100 +1,118 @@
-- : SKIP
-- ============================================================ -- ============================================================
-- v3.5 WhatsApp State Machine -- v3.5 WhatsApp State Machine
-- Reorganización del chatbot de AI libre a flujo estructurado -- 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 <tenant_db> -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 DO $$
-- ---------------------------------------------------
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 $$
BEGIN BEGIN
NEW.updated_at = NOW(); -- 1. Extender whatsapp_sessions con estado y contexto
RETURN NEW; IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN
END; ALTER TABLE whatsapp_sessions
$$ LANGUAGE plpgsql; 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 INDEX IF NOT EXISTS idx_wa_sessions_state ON whatsapp_sessions(state);
CREATE TRIGGER trg_wa_link_updated CREATE INDEX IF NOT EXISTS idx_wa_sessions_customer ON whatsapp_sessions(customer_id);
BEFORE UPDATE ON wa_customer_links CREATE INDEX IF NOT EXISTS idx_wa_sessions_updated ON whatsapp_sessions(updated_at);
FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp(); END IF;
-- 3. Tabla de sesiones de aprendizaje (piezas no resueltas) -- 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_learning_sessions ( CREATE TABLE IF NOT EXISTS wa_customer_links (
id SERIAL PRIMARY KEY, phone VARCHAR(50) PRIMARY KEY,
phone VARCHAR(50) NOT NULL, customer_id INTEGER NOT NULL REFERENCES customers(id),
customer_id INTEGER REFERENCES customers(id), created_at TIMESTAMPTZ DEFAULT NOW(),
description TEXT NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW()
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
);
CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone); CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
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);
-- 4. Tabla de configuración de envío por sucursal CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
-- ------------------------------------------------ RETURNS TRIGGER AS $$
CREATE TABLE IF NOT EXISTS branch_delivery_config ( BEGIN
id SERIAL PRIMARY KEY, NEW.updated_at = NOW();
branch_id INTEGER NOT NULL UNIQUE REFERENCES branches(id), RETURN NEW;
is_enabled BOOLEAN DEFAULT FALSE, END;
delivery_fee NUMERIC(12,2) DEFAULT 0, $$ LANGUAGE plpgsql;
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()
);
-- 5. Agregar push_name a whatsapp_messages (schema drift existente) DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
-- ------------------------------------------------------------------ CREATE TRIGGER trg_wa_link_updated
ALTER TABLE whatsapp_messages BEFORE UPDATE ON wa_customer_links
ADD COLUMN IF NOT EXISTS push_name VARCHAR(200); FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp();
END IF;
-- 6. Migrar datos existentes: vincular por teléfono -- 3. Tabla de sesiones de aprendizaje (piezas no resueltas)
-- -------------------------------------------------- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers')
-- Intentar vincular sesiones WA existentes con customers por teléfono AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'inventory')
INSERT INTO wa_customer_links (phone, customer_id) AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'sales') THEN
SELECT ws.phone, c.id CREATE TABLE IF NOT EXISTS wa_learning_sessions (
FROM whatsapp_sessions ws id SERIAL PRIMARY KEY,
JOIN customers c ON c.phone = ws.phone phone VARCHAR(50) NOT NULL,
WHERE ws.phone IS NOT NULL AND c.phone IS NOT NULL customer_id INTEGER REFERENCES customers(id),
ON CONFLICT (phone) DO NOTHING; 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 CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone);
UPDATE whatsapp_sessions ws CREATE INDEX IF NOT EXISTS idx_wa_learn_status ON wa_learning_sessions(status);
SET customer_id = wcl.customer_id CREATE INDEX IF NOT EXISTS idx_wa_learn_customer ON wa_learning_sessions(customer_id);
FROM wa_customer_links wcl CREATE INDEX IF NOT EXISTS idx_wa_learn_created ON wa_learning_sessions(created_at);
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL; 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 $$;

View File

@@ -1,6 +1,8 @@
-- : SKIP
-- v3.9_supplier_catalog_prices.sql -- v3.9_supplier_catalog_prices.sql
-- Per-tenant supplier pricing for items in the master supplier_catalog. -- Per-tenant supplier pricing for items in the master supplier_catalog.
-- This table lives in the master DB and is joined by tenant_id. -- 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 ( CREATE TABLE IF NOT EXISTS supplier_catalog_prices (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,