kitchen/backend/migrations/add_invoice_features.py
jtricerolph bc874ac9ff Fix migration transaction poisoning: ADD COLUMN IF NOT EXISTS
Migrations used try/except around ADD COLUMN inside a single engine.begin()
block. When a 'column already exists' error was caught, asyncpg left the
transaction in aborted state, causing all subsequent DDL in the block to fail
with InFailedSQLTransactionError. Replace with IF NOT EXISTS to prevent the
error entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:53:26 +00:00

426 lines
18 KiB
Python

"""
Migration script to add new invoice features:
- document_type, order_number columns on invoices
- duplicate_status, duplicate_of_id, related_document_id columns on invoices
- line_items table
Run this script once after deploying the new code.
"""
import asyncio
import logging
from sqlalchemy import text
from database import engine
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def run_migration():
"""Add new columns and tables for invoice features."""
# Always run all migrations - each has its own try/except to handle existing columns
logger.info("Running database migrations...")
# Run each migration in its own transaction
migrations = [
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS document_type VARCHAR(50) DEFAULT 'invoice'",
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS order_number VARCHAR(100)",
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS duplicate_status VARCHAR(50)",
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS duplicate_of_id INTEGER REFERENCES invoices(id)",
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS related_document_id INTEGER REFERENCES invoices(id)",
]
for sql in migrations:
try:
async with engine.begin() as conn:
await conn.execute(text(sql))
logger.info(f"Executed: {sql[:60]}...")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info(f"Column already exists, skipping")
else:
logger.warning(f"Migration warning: {e}")
# Create line_items table
create_line_items = """
CREATE TABLE IF NOT EXISTS line_items (
id SERIAL PRIMARY KEY,
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT,
quantity NUMERIC(10, 3),
unit_price NUMERIC(10, 2),
amount NUMERIC(10, 2),
product_code VARCHAR(100),
line_number INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
try:
async with engine.begin() as conn:
await conn.execute(text(create_line_items))
logger.info("Created line_items table")
except Exception as e:
logger.warning(f"line_items table: {e}")
# Create index
try:
async with engine.begin() as conn:
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_line_items_invoice_id ON line_items(invoice_id)"
))
logger.info("Created index on line_items.invoice_id")
except Exception as e:
logger.warning(f"Index: {e}")
# Add aliases column to suppliers table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS aliases JSON DEFAULT '[]'"
))
logger.info("Added aliases column to suppliers")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("aliases column already exists")
else:
logger.warning(f"aliases column: {e}")
# Add net_total column to invoices table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS net_total NUMERIC(10, 2)"
))
logger.info("Added net_total column to invoices")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("net_total column already exists")
else:
logger.warning(f"net_total column: {e}")
# Add is_non_stock column to line_items table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS is_non_stock BOOLEAN DEFAULT FALSE"
))
logger.info("Added is_non_stock column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("is_non_stock column already exists")
else:
logger.warning(f"is_non_stock column: {e}")
# Add vendor_name column to invoices table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS vendor_name VARCHAR(255)"
))
logger.info("Added vendor_name column to invoices")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("vendor_name column already exists")
else:
logger.warning(f"vendor_name column: {e}")
# Add ocr_raw_json column to invoices table (for storing full Azure response)
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS ocr_raw_json TEXT"
))
logger.info("Added ocr_raw_json column to invoices")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("ocr_raw_json column already exists")
else:
logger.warning(f"ocr_raw_json column: {e}")
# Add supplier_match_type column to invoices table (for fuzzy matching)
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS supplier_match_type VARCHAR(20)"
))
logger.info("Added supplier_match_type column to invoices")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("supplier_match_type column already exists")
else:
logger.warning(f"supplier_match_type column: {e}")
# Create field_mappings table
create_field_mappings = """
CREATE TABLE IF NOT EXISTS field_mappings (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
supplier_id INTEGER REFERENCES suppliers(id),
source_field VARCHAR(100) NOT NULL,
target_field VARCHAR(100) NOT NULL,
field_type VARCHAR(20) DEFAULT 'invoice',
transform VARCHAR(50) DEFAULT 'direct',
priority INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
try:
async with engine.begin() as conn:
await conn.execute(text(create_field_mappings))
logger.info("Created field_mappings table")
except Exception as e:
logger.warning(f"field_mappings table: {e}")
# Create index on field_mappings
try:
async with engine.begin() as conn:
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_field_mappings_kitchen_id ON field_mappings(kitchen_id)"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_field_mappings_supplier_id ON field_mappings(supplier_id)"
))
logger.info("Created indexes on field_mappings")
except Exception as e:
logger.warning(f"field_mappings indexes: {e}")
# Add unit column to line_items table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS unit VARCHAR(50)"
))
logger.info("Added unit column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("unit column already exists")
else:
logger.warning(f"unit column: {e}")
# Add order_quantity column to line_items table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS order_quantity NUMERIC(10, 3)"
))
logger.info("Added order_quantity column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("order_quantity column already exists")
else:
logger.warning(f"order_quantity column: {e}")
# Add tax_rate column to line_items table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS tax_rate VARCHAR(50)"
))
logger.info("Added tax_rate column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("tax_rate column already exists")
else:
logger.warning(f"tax_rate column: {e}")
# Add tax_amount column to line_items table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS tax_amount NUMERIC(10, 2)"
))
logger.info("Added tax_amount column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("tax_amount column already exists")
else:
logger.warning(f"tax_amount column: {e}")
# Add raw_content column to line_items table (for pack size parsing)
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS raw_content TEXT"
))
logger.info("Added raw_content column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("raw_content column already exists")
else:
logger.warning(f"raw_content column: {e}")
# Add pack_quantity column to line_items table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS pack_quantity INTEGER"
))
logger.info("Added pack_quantity column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("pack_quantity column already exists")
else:
logger.warning(f"pack_quantity column: {e}")
# Add unit_size column to line_items table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS unit_size NUMERIC(10, 3)"
))
logger.info("Added unit_size column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("unit_size column already exists")
else:
logger.warning(f"unit_size column: {e}")
# Add unit_size_type column to line_items table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS unit_size_type VARCHAR(10)"
))
logger.info("Added unit_size_type column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("unit_size_type column already exists")
else:
logger.warning(f"unit_size_type column: {e}")
# Add portions_per_unit column to line_items table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS portions_per_unit INTEGER DEFAULT 1"
))
logger.info("Added portions_per_unit column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("portions_per_unit column already exists")
else:
logger.warning(f"portions_per_unit column: {e}")
# Add cost_per_item column to line_items table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS cost_per_item NUMERIC(10, 4)"
))
logger.info("Added cost_per_item column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("cost_per_item column already exists")
else:
logger.warning(f"cost_per_item column: {e}")
# Add cost_per_portion column to line_items table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS cost_per_portion NUMERIC(10, 4)"
))
logger.info("Added cost_per_portion column to line_items")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("cost_per_portion column already exists")
else:
logger.warning(f"cost_per_portion column: {e}")
# Create product_definitions table for persistent portion/pack data
create_product_definitions = """
CREATE TABLE IF NOT EXISTS product_definitions (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
supplier_id INTEGER REFERENCES suppliers(id),
product_code VARCHAR(100),
description_pattern VARCHAR(255),
pack_quantity INTEGER,
unit_size NUMERIC(10, 3),
unit_size_type VARCHAR(10),
portions_per_unit INTEGER,
portion_description VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(kitchen_id, supplier_id, product_code)
)
"""
try:
async with engine.begin() as conn:
await conn.execute(text(create_product_definitions))
logger.info("Created product_definitions table")
except Exception as e:
logger.warning(f"product_definitions table: {e}")
# Create indexes on product_definitions
try:
async with engine.begin() as conn:
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_product_definitions_kitchen_id ON product_definitions(kitchen_id)"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_product_definitions_supplier_id ON product_definitions(supplier_id)"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_product_definitions_product_code ON product_definitions(product_code)"
))
logger.info("Created indexes on product_definitions")
except Exception as e:
logger.warning(f"product_definitions indexes: {e}")
# Remove default from portions_per_unit column (make it truly nullable)
# PostgreSQL doesn't have a simple ALTER COLUMN DROP DEFAULT, but we can change the default to NULL
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE line_items ALTER COLUMN portions_per_unit DROP DEFAULT"
))
logger.info("Removed default from portions_per_unit column")
except Exception as e:
logger.warning(f"portions_per_unit default removal: {e}")
# Add saved_by_user_id column to product_definitions table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE product_definitions ADD COLUMN IF NOT EXISTS saved_by_user_id INTEGER REFERENCES users(id)"
))
logger.info("Added saved_by_user_id column to product_definitions")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("saved_by_user_id column already exists")
else:
logger.warning(f"saved_by_user_id column: {e}")
# Add source_invoice_id column to product_definitions table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE product_definitions ADD COLUMN IF NOT EXISTS source_invoice_id INTEGER REFERENCES invoices(id) ON DELETE SET NULL"
))
logger.info("Added source_invoice_id column to product_definitions")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("source_invoice_id column already exists")
else:
logger.warning(f"source_invoice_id column: {e}")
# Add source_invoice_number column to product_definitions table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE product_definitions ADD COLUMN IF NOT EXISTS source_invoice_number VARCHAR(100)"
))
logger.info("Added source_invoice_number column to product_definitions")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("source_invoice_number column already exists")
else:
logger.warning(f"source_invoice_number column: {e}")
logger.info("Migration completed successfully!")
if __name__ == "__main__":
asyncio.run(run_migration())