Files
Autoparts-DB/pos/migrations/v4.0_multi_branch.sql
consultoria-as 383799ff3d fix(pos): prevent null branch_id errors during sales
- process_sale now falls back to main branch when g.branch_id is missing
- Accept branch_id from request body as override
- Make update_inventory_stock trigger skip operations with NULL branch_id
- Applied updated trigger to all active tenant DBs
2026-06-11 23:11:02 +00:00

254 lines
11 KiB
PL/PgSQL

-- 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
-- Skip operations that are not tied to a specific branch.
-- Per-branch stock tracking requires a branch_id; without it we can't
-- assign the stock to any location.
IF NEW.branch_id IS NULL THEN
RETURN NEW;
END IF;
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();