-- v4.0_multi_branch.sql -- Multi-branch overhaul: branch fiscal data + shared inventory with per-branch stock. -- WARNING: this migration restructures inventory data. A full DB backup is required. -- ═════════════════════════════════════════════════════════════════════════════ -- 1. BRANCHES: fiscal fields + main flag -- ═════════════════════════════════════════════════════════════════════════════ ALTER TABLE branches ADD COLUMN IF NOT EXISTS is_main BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS rfc VARCHAR(13), ADD COLUMN IF NOT EXISTS razon_social VARCHAR(300), ADD COLUMN IF NOT EXISTS regimen_fiscal VARCHAR(10), ADD COLUMN IF NOT EXISTS cp VARCHAR(5), ADD COLUMN IF NOT EXISTS direccion_fiscal TEXT, ADD COLUMN IF NOT EXISTS serie_cfdi VARCHAR(10) DEFAULT 'A', ADD COLUMN IF NOT EXISTS folio_inicio INTEGER DEFAULT 1, ADD COLUMN IF NOT EXISTS folio_actual INTEGER DEFAULT 1, ADD COLUMN IF NOT EXISTS email VARCHAR(200); -- Ensure at least one branch is marked main (the first one created). DO $$ DECLARE main_branch_id INTEGER; branch_count INTEGER; BEGIN SELECT COUNT(*) INTO branch_count FROM branches; IF branch_count = 0 THEN INSERT INTO branches (name, is_main) VALUES ('Principal', TRUE); ELSE SELECT id INTO main_branch_id FROM branches ORDER BY id LIMIT 1; UPDATE branches SET is_main = FALSE; UPDATE branches SET is_main = TRUE WHERE id = main_branch_id; END IF; END $$; -- Constraint: only one main branch per tenant. -- Because this runs inside a single tenant DB, a simple partial unique index is enough. CREATE UNIQUE INDEX IF NOT EXISTS idx_branches_single_main ON branches (is_main) WHERE is_main = TRUE; -- ═════════════════════════════════════════════════════════════════════════════ -- 2. INVENTORY STOCK: new per-branch stock table -- ═════════════════════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS inventory_stock ( id SERIAL PRIMARY KEY, inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE, branch_id INTEGER NOT NULL REFERENCES branches(id) ON DELETE CASCADE, stock INTEGER DEFAULT 0, location VARCHAR(50), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(inventory_id, branch_id) ); CREATE INDEX IF NOT EXISTS idx_inventory_stock_branch ON inventory_stock(branch_id); CREATE INDEX IF NOT EXISTS idx_inventory_stock_inventory ON inventory_stock(inventory_id); CREATE OR REPLACE FUNCTION update_inventory_stock_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_inventory_stock_updated_at ON inventory_stock; CREATE TRIGGER trg_inventory_stock_updated_at BEFORE UPDATE ON inventory_stock FOR EACH ROW EXECUTE FUNCTION update_inventory_stock_updated_at(); -- ═════════════════════════════════════════════════════════════════════════════ -- 3. INVENTORY: make branch_id nullable + prepare for consolidation -- ═════════════════════════════════════════════════════════════════════════════ -- Drop the old unique constraint that forces one record per (branch, part_number). DROP INDEX IF EXISTS idx_inventory_branch_part; -- Make branch_id nullable so we can have master records without a branch. ALTER TABLE inventory ALTER COLUMN branch_id DROP NOT NULL; -- Add unique constraint on part_number at tenant level so a product exists once. -- If duplicates still exist this will fail, so we consolidate below first. -- We create it at the end of this migration after deduplication. -- ═════════════════════════════════════════════════════════════════════════════ -- 4. DATA MIGRATION: consolidate duplicated inventory rows by part_number -- ═════════════════════════════════════════════════════════════════════════════ -- Build a mapping: for each duplicated part_number, choose the master record. -- Master = record belonging to the main branch; fallback = oldest id. CREATE TEMP TABLE _inventory_master_map AS SELECT DISTINCT ON (part_number) id AS master_id, part_number FROM inventory ORDER BY part_number, CASE WHEN branch_id = (SELECT id FROM branches WHERE is_main = TRUE LIMIT 1) THEN 0 ELSE 1 END, id ASC; -- Create temp table of duplicates (all rows that are NOT the master for their part_number). CREATE TEMP TABLE _inventory_duplicates AS SELECT i.id AS duplicate_id, m.master_id FROM inventory i JOIN _inventory_master_map m ON i.part_number = m.part_number WHERE i.id <> m.master_id; -- Compute per-duplicate stock and insert into inventory_stock against master_id + duplicate's branch. INSERT INTO inventory_stock (inventory_id, branch_id, stock, location) SELECT dups.master_id, dups.branch_id, GREATEST(0, COALESCE(stock_by_dup.stock, 0))::int, dups.location FROM ( SELECT d.master_id, d.duplicate_id, i.branch_id, i.location FROM _inventory_duplicates d JOIN inventory i ON i.id = d.duplicate_id ) dups JOIN LATERAL ( SELECT COALESCE(SUM(quantity), 0) AS stock FROM inventory_operations WHERE inventory_id = dups.duplicate_id AND branch_id = dups.branch_id ) stock_by_dup ON TRUE ON CONFLICT (inventory_id, branch_id) DO UPDATE SET stock = inventory_stock.stock + EXCLUDED.stock; -- Also migrate stock from master records themselves (they were already in inventory.branch_id). INSERT INTO inventory_stock (inventory_id, branch_id, stock, location) SELECT i.id, i.branch_id, GREATEST(0, COALESCE(stock_by_inv.stock, 0))::int, i.location FROM inventory i JOIN _inventory_master_map m ON i.id = m.master_id JOIN LATERAL ( SELECT COALESCE(SUM(quantity), 0) AS stock FROM inventory_operations WHERE inventory_id = i.id AND branch_id = i.branch_id ) stock_by_inv ON TRUE WHERE i.branch_id IS NOT NULL ON CONFLICT (inventory_id, branch_id) DO UPDATE SET stock = EXCLUDED.stock; -- Handle inventory_stock_summary specially: it has PK on inventory_id. -- If master already has a summary row, add duplicate's stock and remove duplicate row. -- Otherwise repoint the duplicate row to master. UPDATE inventory_stock_summary s SET stock = s.stock + d.stock FROM ( SELECT duplicate_id, master_id, stock FROM inventory_stock_summary ss JOIN _inventory_duplicates m ON ss.inventory_id = m.duplicate_id WHERE EXISTS (SELECT 1 FROM inventory_stock_summary sm WHERE sm.inventory_id = m.master_id) ) d WHERE s.inventory_id = d.master_id; DELETE FROM inventory_stock_summary WHERE inventory_id IN ( SELECT m.duplicate_id FROM _inventory_duplicates m WHERE EXISTS (SELECT 1 FROM inventory_stock_summary sm WHERE sm.inventory_id = m.master_id) ); UPDATE inventory_stock_summary SET inventory_id = m.master_id FROM _inventory_duplicates m WHERE inventory_id = m.duplicate_id; -- Update FK references from duplicate inventory rows to master inventory rows. -- We use dynamic SQL to update every known referencing table. DO $$ DECLARE rec RECORD; fk_sql TEXT; BEGIN FOR rec IN SELECT tc.table_name, kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name WHERE tc.constraint_type = 'FOREIGN KEY' AND ccu.table_name = 'inventory' AND tc.table_name <> 'inventory_stock_summary' LOOP fk_sql := format( 'UPDATE %I SET %I = m.master_id FROM _inventory_duplicates m WHERE %I = m.duplicate_id', rec.table_name, rec.column_name, rec.column_name ); EXECUTE fk_sql; END LOOP; END $$; -- Delete duplicate inventory rows now that FKs are repointed. DELETE FROM inventory WHERE id IN (SELECT duplicate_id FROM _inventory_duplicates); -- Clean up master records: remove branch_id so they become shared catalog items. UPDATE inventory SET branch_id = NULL WHERE branch_id IS NOT NULL; -- Now safe to enforce uniqueness at tenant level. CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_part_unique ON inventory (part_number); -- Clean temp tables. DROP TABLE IF EXISTS _inventory_master_map; DROP TABLE IF EXISTS _inventory_duplicates; -- ═════════════════════════════════════════════════════════════════════════════ -- 5. CFDI_QUEUE: allow sale_id to be NULL for global invoices (Phase 3 prep) -- ═════════════════════════════════════════════════════════════════════════════ ALTER TABLE cfdi_queue ALTER COLUMN sale_id DROP NOT NULL; -- ═════════════════════════════════════════════════════════════════════════════ -- 6. TRIGGER: Keep inventory_stock in sync with inventory_operations -- ═════════════════════════════════════════════════════════════════════════════ CREATE OR REPLACE FUNCTION update_inventory_stock() RETURNS TRIGGER AS $$ BEGIN INSERT INTO inventory_stock (inventory_id, branch_id, stock) VALUES (NEW.inventory_id, NEW.branch_id, NEW.quantity) ON CONFLICT (inventory_id, branch_id) DO UPDATE SET stock = inventory_stock.stock + EXCLUDED.stock, updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_update_inventory_stock ON inventory_operations; CREATE TRIGGER trg_update_inventory_stock AFTER INSERT ON inventory_operations FOR EACH ROW EXECUTE FUNCTION update_inventory_stock();