Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)

FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

View file

@ -0,0 +1 @@
# Migrations module

View file

@ -0,0 +1,35 @@
"""
Migration script to add admin_restricted_pages column.
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 admin_restricted_pages column to kitchen_settings table."""
logger.info("Running admin restricted pages migration...")
sql = "ALTER TABLE kitchen_settings ADD COLUMN admin_restricted_pages TEXT"
try:
async with engine.begin() as conn:
await conn.execute(text(sql))
logger.info("Added column: admin_restricted_pages")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("Column already exists, skipping")
else:
logger.warning(f"Column migration warning: {e}")
logger.info("Admin restricted pages migration completed!")
if __name__ == "__main__":
asyncio.run(run_migration())

View file

@ -0,0 +1,286 @@
"""
Migration: Add allergen_keywords table for keyword-based allergen suggestion,
plus is_prepackaged/product_ingredients/label_image_path columns on ingredients.
Seeds default keywords from OFF allergens taxonomy per kitchen.
"""
import asyncio
from sqlalchemy import text
from database import engine
# Default keywords informed by Open Food Facts allergens taxonomy, FSA guidance,
# UK food labelling terms, common food products, and dish/meal association keywords.
# Three tiers:
# 1. Direct ingredients (flour, milk, egg, etc.)
# 2. Derivative/processed forms (casein, malt extract, surimi, etc.)
# 3. Dish/meal associations — "this product often contains X" (carbonara, pesto, satay, etc.)
ALLERGEN_KEYWORDS = {
"Gluten": [
# --- Direct grains & flours ---
"wheat", "flour", "bread", "pasta", "barley", "rye", "oat", "spelt",
"couscous", "bulgur", "semolina", "noodle", "crouton", "breadcrumb",
"panko", "tortilla", "pita", "naan", "sourdough", "brioche",
"croissant", "pastry", "pastries", "biscuit", "cracker", "kamut", "durum",
"farro", "seitan", "malt", "starch",
# --- Extended grains & derivatives ---
"einkorn", "emmer", "triticale", "wheat bran", "wheat protein",
"wheat starch", "modified starch", "wheat rusk", "rusk",
"pearl barley", "barley malt", "malt extract", "malt vinegar",
"durum wheat", "wholemeal", "wholegrain", "wheat flour",
"rye flour", "barley flour", "oat fibre",
# --- Baked goods & products ---
"doughnut", "waffle", "bagel", "chapati", "focaccia", "ciabatta",
"pumpernickel", "pretzel", "pitta", "wrap", "flatbread",
"crumpet", "muffin", "cake", "sponge", "scone",
"pancake", "crepe", "batter", "dumpling",
# --- Dish/meal associations (often contains gluten) ---
"carbonara", "lasagne", "lasagna", "pizza", "quiche",
"tempura", "ramen", "gyoza", "dim sum", "spring roll",
"scotch egg", "bhaji", "samosa", "pie", "gratin",
"fondue", "gnocchi", "ravioli", "trifle", "brownie",
"tiramisu", "crumble", "french toast",
"soy sauce", # most soy sauce contains wheat
],
"Milk": [
# --- Direct dairy ---
"milk", "cream", "butter", "cheese", "yogurt", "yoghurt", "whey",
"casein", "lactose", "ghee", "mascarpone", "ricotta", "mozzarella",
"parmesan", "cheddar", "brie", "camembert", "gruyere", "halloumi",
"paneer", "creme fraiche", "custard", "bechamel", "dairy",
"condensed milk", "evaporated milk", "buttermilk", "kefir",
"quark", "fromage", "emmental", "gouda", "stilton", "feta",
"milk powder", "skimmed milk", "whole milk",
# --- Extended cheese varieties & derivatives ---
"taleggio", "pecorino", "roquefort", "manchego", "provolone",
"reblochon", "raclette", "edam", "havarti", "jarlsberg",
"wensleydale", "red leicester", "lancashire", "monterey jack",
"cottage cheese", "cream cheese", "processed cheese",
"clotted cream", "double cream", "single cream", "whipping cream",
"sour cream", "curd", "dulce de leche",
# --- Milk protein derivatives ---
"lactalbumin", "lactoglobulin", "caseinates", "sodium caseinate",
"calcium caseinate", "milk solids", "milk protein", "milk fat",
# --- Milk-containing products ---
"milk chocolate", "white chocolate", "ice cream", "gelato",
"panna cotta", "lassi", "skyr",
# --- Dish/meal associations (often contains milk) ---
"carbonara", "risotto", "gratin", "fondue", "quiche",
"bechamel", "mousse", "creme brulee", "souffle",
"tikka masala", "korma", "bisque", "chowder",
"ranch", "bearnaise", "scone", "pancake", "waffle",
"french toast", "brioche", "croissant", "brownie",
"tiramisu", "trifle", "gnocchi", "ravioli",
"naan", "tzatziki", "raita",
],
"Eggs": [
# --- Direct egg forms ---
"egg", "meringue", "mayonnaise", "aioli", "hollandaise",
"quiche", "frittata", "omelette", "albumin",
"egg white", "egg yolk", "whole egg", "dried egg", "egg powder",
"egg wash", "eggnog", "duck egg", "quail egg",
"lecithin", "lysozyme",
# --- Dish/meal associations (often contains egg) ---
"carbonara", "caesar", "pad thai", "ramen",
"scotch egg", "french toast", "pancake", "waffle",
"brioche", "croissant", "scone", "brownie",
"tiramisu", "mousse", "creme brulee", "souffle",
"trifle", "macaron", "meringue", "coleslaw",
"waldorf", "gnocchi", "ravioli", "tempura",
"tartare sauce", "ranch", "bearnaise",
"custard", "batter",
],
"Fish": [
# --- Common fish species ---
"cod", "salmon", "tuna", "haddock", "mackerel", "sardine", "sardines",
"anchovy", "anchovies", "trout", "bass", "bream", "sole", "plaice",
"halibut", "swordfish", "monkfish", "pollock", "herring",
"whitebait", "fish sauce", "worcestershire", "fish",
# --- Extended species ---
"hake", "coley", "john dory", "brill", "turbot", "dab",
"flounder", "sprat", "pike", "perch", "tilapia", "snapper",
"grouper", "barramundi", "sea bream", "sea bass",
"dover sole", "lemon sole", "pilchard",
# --- Processed fish products ---
"smoked salmon", "gravlax", "kipper", "rollmop", "surimi",
"fish stock", "bonito", "katsuobushi", "fish cake",
"fish finger", "fish pie", "taramasalata",
# --- Dish/meal associations (often contains fish) ---
"caesar", # anchovy in dressing
"kedgeree", "paella", "bouillabaisse", "sushi",
"tom yum", "laksa",
],
"Crustaceans": [
# --- Direct crustacean types ---
"prawn", "shrimp", "crab", "lobster", "crayfish", "langoustine",
"scampi", "crustacean",
# --- Extended terms ---
"crawfish", "king prawn", "tiger prawn",
"shrimp paste", "crab paste", "crab stick",
"potted shrimp",
# --- Dish/meal associations (often contains crustaceans) ---
"bisque", "thermidor", "paella", "bouillabaisse",
"tom yum", "laksa", "gumbo", "dim sum", "sushi",
"pad thai",
],
"Molluscs": [
# --- Direct mollusc types ---
"squid", "calamari", "octopus", "mussel", "clam", "oyster",
"scallop", "cockle", "whelk", "snail", "escargot", "mollusc",
# --- Extended terms ---
"cuttlefish", "abalone", "periwinkle", "razor clam",
"limpet", "winkle",
# --- Dish/meal associations (often contains molluscs) ---
"paella", "bouillabaisse", "chowder", "gumbo",
"marinara", # often includes shellfish
],
"Peanuts": [
# --- Direct forms ---
"peanut", "groundnut", "arachis", "monkey nut",
"peanut butter", "peanut oil", "peanut flour", "peanut paste",
"groundnut oil",
# --- Dish/meal associations (often contains peanuts) ---
"satay", "pad thai", "laksa", "kung pao",
],
"Tree Nuts": [
# --- Direct nut types ---
"almond", "hazelnut", "walnut", "cashew", "pecan", "pistachio",
"macadamia", "brazil nut", "pine nut", "chestnut", "praline",
"marzipan", "frangipane", "nougat",
# --- Extended nut products ---
"almond milk", "almond flour", "almond butter", "ground almond",
"hazelnut oil", "walnut oil", "cashew butter",
"pistachio paste", "pine kernel", "mixed nuts",
"nut butter", "nut milk", "nut oil",
"amaretti", "pecan pie",
# --- Dish/meal associations (often contains tree nuts) ---
"pesto", # pine nuts + parmesan
"baklava", "macaron", "korma", "waldorf",
"praline", "frangipane",
],
"Soya": [
# --- Direct soya forms ---
"soy", "soya", "tofu", "tempeh", "edamame", "miso", "tamari",
"soybean", "soy lecithin", "soya lecithin",
# --- Extended forms ---
"soy sauce", "soy protein", "soy flour", "soya oil",
"soy milk", "soybean oil", "soy protein isolate",
"bean curd", "natto", "kinako", "yuba",
# --- Dish/meal associations (often contains soya) ---
"teriyaki", "ramen", "gyoza", "dim sum", "spring roll",
"sushi", "stir fry",
],
"Celery": [
# --- Direct forms ---
"celery", "celeriac",
"celery salt", "celery seed", "celery oil", "celery powder",
# --- Dish/meal associations (often contains celery) ---
"waldorf", "bloody mary", "bolognese", "soffritto", "mirepoix",
],
"Mustard": [
# --- Direct forms ---
"mustard", "dijon",
"mustard seed", "mustard powder", "mustard oil",
"english mustard", "wholegrain mustard", "mustard flour",
# --- Dish/meal associations (often contains mustard) ---
"vinaigrette", "coleslaw", "dhal",
],
"Sesame": [
# --- Direct forms ---
"sesame", "tahini", "halva", "halvah",
"sesame oil", "sesame seed", "sesame paste", "gomashio",
# --- Dish/meal associations (often contains sesame) ---
"hummus", # tahini
"falafel", "ramen", "gyoza", "spring roll",
"sushi", "dim sum",
],
"Sulphites": [
# --- Chemical names ---
"sulphite", "sulfite", "sulphur dioxide", "sulfur dioxide",
"metabisulphite", "metabisulfite",
# --- E numbers ---
"e220", "e221", "e222", "e223", "e224", "e226", "e227", "e228",
# --- Chemical salt forms ---
"sodium sulphite", "sodium bisulphite", "potassium sulphite",
"calcium sulphite", "potassium bisulphite",
"sodium metabisulphite", "potassium metabisulphite",
# --- Foods commonly containing sulphites ---
"wine", "dried fruit", "vinegar", "cordial", "molasses",
# --- Dish/meal associations ---
"vinaigrette",
],
"Lupin": [
# --- Direct forms ---
"lupin", "lupine", "lupini",
"lupin flour", "lupin seed", "lupin bean",
],
}
async def migrate():
async with engine.begin() as conn:
# 1. Create allergen_keywords table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS allergen_keywords (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
food_flag_id INTEGER NOT NULL REFERENCES food_flags(id) ON DELETE CASCADE,
keyword VARCHAR(100) NOT NULL,
is_default BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT uq_allergen_keywords_kit_flag_kw UNIQUE (kitchen_id, food_flag_id, keyword)
)
"""))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_allergen_keywords_kitchen ON allergen_keywords(kitchen_id)"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_allergen_keywords_flag ON allergen_keywords(food_flag_id)"
))
print("+ Created allergen_keywords table")
# 2. Add new columns to ingredients table
for col_sql in [
"ALTER TABLE ingredients ADD COLUMN IF NOT EXISTS is_prepackaged BOOLEAN DEFAULT FALSE",
"ALTER TABLE ingredients ADD COLUMN IF NOT EXISTS product_ingredients TEXT",
"ALTER TABLE ingredients ADD COLUMN IF NOT EXISTS label_image_path VARCHAR(500)",
]:
try:
await conn.execute(text(col_sql))
except Exception:
pass
print("+ Added is_prepackaged, product_ingredients, label_image_path columns to ingredients")
# 3. Seed default keywords (separate transaction for safety)
async with engine.begin() as conn:
result = await conn.execute(text("SELECT id FROM kitchens"))
kitchen_ids = [row[0] for row in result.fetchall()]
for kid in kitchen_ids:
seeded = 0
for flag_name, keywords in ALLERGEN_KEYWORDS.items():
# Find the food_flag by name for this kitchen
flag_result = await conn.execute(text(
"SELECT id FROM food_flags WHERE kitchen_id = :kid AND name = :name LIMIT 1"
), {"kid": kid, "name": flag_name})
flag_row = flag_result.fetchone()
if not flag_row:
continue
flag_id = flag_row[0]
for kw in keywords:
await conn.execute(text("""
INSERT INTO allergen_keywords (kitchen_id, food_flag_id, keyword, is_default, created_at)
VALUES (:kid, :fid, :kw, TRUE, NOW())
ON CONFLICT (kitchen_id, food_flag_id, keyword) DO NOTHING
"""), {"kid": kid, "fid": flag_id, "kw": kw.lower()})
seeded += 1
print(f" Kitchen {kid}: seeded up to {seeded} allergen keywords")
print("+ Allergen keywords migration complete")
if __name__ == "__main__":
print("Running migration: add_allergen_keywords")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,34 @@
"""
Migration to add AWAITING_REPLACEMENT to DisputeStatus enum.
"""
import logging
from sqlalchemy import text
from database import engine
logger = logging.getLogger(__name__)
async def run_migration():
"""Add AWAITING_REPLACEMENT value to disputestatus enum"""
async with engine.begin() as conn:
# Check if the enum value already exists
result = await conn.execute(text("""
SELECT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'awaiting_replacement'
AND enumtypid = (
SELECT oid FROM pg_type WHERE typname = 'disputestatus'
)
);
"""))
exists = result.scalar()
if not exists:
logger.info("Adding 'awaiting_replacement' to disputestatus enum")
# Add the new enum value (position doesn't matter for enum functionality)
await conn.execute(text("""
ALTER TYPE disputestatus ADD VALUE 'awaiting_replacement';
"""))
logger.info("Successfully added 'awaiting_replacement' to disputestatus enum")
else:
logger.info("'awaiting_replacement' already exists in disputestatus enum")

View file

@ -0,0 +1,31 @@
"""
Migration: Add brakes_product_cache table for caching Brakes website product lookups.
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS brakes_product_cache (
id SERIAL PRIMARY KEY,
product_code VARCHAR(50) NOT NULL UNIQUE,
product_name VARCHAR(500),
ingredients_text TEXT,
contains_allergens TEXT,
fetched_at TIMESTAMP DEFAULT NOW(),
not_found BOOLEAN DEFAULT FALSE
)
"""))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_brakes_cache_code ON brakes_product_cache(product_code)"
))
print("+ Created brakes_product_cache table")
if __name__ == "__main__":
print("Running migration: add_brakes_cache")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,20 @@
"""
Migration: Add dietary_info column to brakes_product_cache for vegetarian/vegan suitability.
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE brakes_product_cache ADD COLUMN IF NOT EXISTS dietary_info TEXT"
))
print("+ Added brakes_product_cache.dietary_info column")
if __name__ == "__main__":
print("Running migration: add_brakes_dietary_info")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,81 @@
"""
Migration: Add budget settings columns to kitchen_settings table
Adds:
- forecast_api_url: URL for external forecasting API
- forecast_api_key: API key for forecast authentication
- budget_gp_target: Target GP percentage (default 65%)
- budget_lookback_weeks: Number of weeks for supplier % calculation (default 4)
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
# Add forecast_api_url column
try:
await conn.execute(text(
"""
ALTER TABLE kitchen_settings
ADD COLUMN forecast_api_url VARCHAR(500)
"""
))
print("+ Added forecast_api_url column")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
print("- forecast_api_url column already exists, skipping")
else:
raise
# Add forecast_api_key column
try:
await conn.execute(text(
"""
ALTER TABLE kitchen_settings
ADD COLUMN forecast_api_key VARCHAR(500)
"""
))
print("+ Added forecast_api_key column")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
print("- forecast_api_key column already exists, skipping")
else:
raise
# Add budget_gp_target column
try:
await conn.execute(text(
"""
ALTER TABLE kitchen_settings
ADD COLUMN budget_gp_target NUMERIC(5,2) DEFAULT 65.00
"""
))
print("+ Added budget_gp_target column")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
print("- budget_gp_target column already exists, skipping")
else:
raise
# Add budget_lookback_weeks column
try:
await conn.execute(text(
"""
ALTER TABLE kitchen_settings
ADD COLUMN budget_lookback_weeks INTEGER DEFAULT 4
"""
))
print("+ Added budget_lookback_weeks column")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
print("- budget_lookback_weeks column already exists, skipping")
else:
raise
if __name__ == "__main__":
print("Running migration: add_budget_settings")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,46 @@
import asyncio
import logging
from sqlalchemy import text
from database import engine
logger = logging.getLogger(__name__)
async def run_migration():
"""Add calendar_events table"""
create_table = """
CREATE TABLE IF NOT EXISTS calendar_events (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
event_date DATE NOT NULL,
event_type VARCHAR(20) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
indexes = [
"CREATE INDEX IF NOT EXISTS idx_calendar_events_kitchen_date ON calendar_events(kitchen_id, event_date)",
"CREATE INDEX IF NOT EXISTS idx_calendar_events_date_range ON calendar_events(event_date)",
]
try:
async with engine.begin() as conn:
await conn.execute(text(create_table))
logger.info("Created calendar_events table")
for sql in indexes:
await conn.execute(text(sql))
logger.info("Created calendar_events indexes")
except Exception as e:
if "already exists" not in str(e).lower():
raise
logger.warning(f"Calendar events migration: {e}")
if __name__ == "__main__":
asyncio.run(run_migration())

View file

@ -0,0 +1,24 @@
"""
Migration: Add source_invoice_id to recipe_change_log
Links price change entries back to the triggering invoice for traceability.
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE recipe_change_log ADD COLUMN IF NOT EXISTS "
"source_invoice_id INTEGER REFERENCES invoices(id) ON DELETE SET NULL"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_recipe_change_log_invoice "
"ON recipe_change_log(source_invoice_id)"
))
print("+ Added source_invoice_id to recipe_change_log")
if __name__ == "__main__":
asyncio.run(migrate())

View file

@ -0,0 +1,128 @@
"""
Migration: Create cost_distributions, cost_distribution_line_selections,
and cost_distribution_entries tables. Add cost_distribution_max_days to kitchen_settings.
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
# Create cost_distributions table
try:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS cost_distributions (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
invoice_id INTEGER NOT NULL REFERENCES invoices(id),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
method VARCHAR(20) NOT NULL,
notes TEXT,
total_distributed_value NUMERIC(12,2) NOT NULL,
remaining_balance NUMERIC(12,2) NOT NULL,
source_date DATE NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
cancelled_by INTEGER REFERENCES users(id),
cancelled_at TIMESTAMP
)
"""))
print("+ Created cost_distributions table")
except Exception as e:
if "already exists" in str(e).lower():
print("- cost_distributions table already exists, skipping")
else:
raise
# Create indexes for cost_distributions
for idx_name, idx_cols in [
("idx_cd_kitchen_status", "kitchen_id, status"),
("idx_cd_kitchen_invoice", "kitchen_id, invoice_id"),
("idx_cd_source_date", "kitchen_id, source_date"),
]:
try:
await conn.execute(text(
f"CREATE INDEX IF NOT EXISTS {idx_name} ON cost_distributions({idx_cols})"
))
print(f"+ Created index {idx_name}")
except Exception as e:
if "already exists" in str(e).lower():
print(f"- Index {idx_name} already exists, skipping")
else:
raise
# Create cost_distribution_line_selections table
try:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS cost_distribution_line_selections (
id SERIAL PRIMARY KEY,
distribution_id INTEGER NOT NULL REFERENCES cost_distributions(id) ON DELETE CASCADE,
line_item_id INTEGER NOT NULL REFERENCES line_items(id),
selected_quantity NUMERIC(10,3) NOT NULL,
unit_price NUMERIC(10,2) NOT NULL,
distributed_value NUMERIC(12,2) NOT NULL
)
"""))
print("+ Created cost_distribution_line_selections table")
except Exception as e:
if "already exists" in str(e).lower():
print("- cost_distribution_line_selections table already exists, skipping")
else:
raise
# Create cost_distribution_entries table
try:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS cost_distribution_entries (
id SERIAL PRIMARY KEY,
distribution_id INTEGER NOT NULL REFERENCES cost_distributions(id) ON DELETE CASCADE,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
entry_date DATE NOT NULL,
amount NUMERIC(12,2) NOT NULL,
is_source_offset BOOLEAN DEFAULT FALSE,
is_overpay BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
print("+ Created cost_distribution_entries table")
except Exception as e:
if "already exists" in str(e).lower():
print("- cost_distribution_entries table already exists, skipping")
else:
raise
# Create indexes for cost_distribution_entries
for idx_name, idx_cols in [
("idx_cde_kitchen_date", "kitchen_id, entry_date"),
("idx_cde_distribution", "distribution_id, entry_date"),
]:
try:
await conn.execute(text(
f"CREATE INDEX IF NOT EXISTS {idx_name} ON cost_distribution_entries({idx_cols})"
))
print(f"+ Created index {idx_name}")
except Exception as e:
if "already exists" in str(e).lower():
print(f"- Index {idx_name} already exists, skipping")
else:
raise
# Add cost_distribution_max_days to kitchen_settings
try:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN cost_distribution_max_days INTEGER DEFAULT 90"
))
print("+ Added cost_distribution_max_days to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
print("- cost_distribution_max_days column already exists, skipping")
else:
raise
if __name__ == "__main__":
print("Running migration: add_cost_distributions")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,117 @@
"""
Migration: Add cover overrides, forecast snapshots, and spend rate overrides tables.
Creates:
- cover_overrides: Per-day per-period cover overrides for lunch/dinner
- forecast_snapshots: Full weekly forecast snapshot (all periods/days) with spend rates
- forecast_week_snapshots: Weekly revenue totals at snapshot time
- spend_rate_overrides: Per-week per-period spend rate overrides
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
# Create cover_overrides table
try:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS cover_overrides (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
override_date DATE NOT NULL,
period VARCHAR(20) NOT NULL,
override_covers INTEGER NOT NULL,
original_forecast INTEGER,
original_otb INTEGER,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_by INTEGER REFERENCES users(id),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(kitchen_id, override_date, period)
)
"""))
print("+ Created cover_overrides table")
except Exception as e:
if "already exists" in str(e).lower():
print("- cover_overrides table already exists, skipping")
else:
raise
# Create forecast_snapshots table
try:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS forecast_snapshots (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
snapshot_date DATE NOT NULL,
period VARCHAR(20) NOT NULL,
forecast_covers INTEGER NOT NULL,
otb_covers INTEGER NOT NULL,
food_spend NUMERIC(10,2),
drinks_spend NUMERIC(10,2),
forecast_dry_revenue NUMERIC(10,2),
week_start DATE NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(kitchen_id, snapshot_date, period)
)
"""))
print("+ Created forecast_snapshots table")
except Exception as e:
if "already exists" in str(e).lower():
print("- forecast_snapshots table already exists, skipping")
else:
raise
# Create forecast_week_snapshots table
try:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS forecast_week_snapshots (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
week_start DATE NOT NULL,
total_forecast_revenue NUMERIC(12,2),
total_otb_revenue NUMERIC(12,2),
gp_target NUMERIC(5,2),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(kitchen_id, week_start)
)
"""))
print("+ Created forecast_week_snapshots table")
except Exception as e:
if "already exists" in str(e).lower():
print("- forecast_week_snapshots table already exists, skipping")
else:
raise
# Create spend_rate_overrides table
try:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS spend_rate_overrides (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
week_start DATE NOT NULL,
period VARCHAR(20) NOT NULL,
food_spend NUMERIC(10,2),
drinks_spend NUMERIC(10,2),
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_by INTEGER REFERENCES users(id),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(kitchen_id, week_start, period)
)
"""))
print("+ Created spend_rate_overrides table")
except Exception as e:
if "already exists" in str(e).lower():
print("- spend_rate_overrides table already exists, skipping")
else:
raise
if __name__ == "__main__":
print("Running migration: add_cover_overrides")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,82 @@
"""
Migration: Add description_aliases JSON column to ingredient_sources table.
Stores alternative descriptions that map to the same ingredient source.
Also backfills missing product codes on existing line items.
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
await conn.execute(text("""
ALTER TABLE ingredient_sources
ADD COLUMN IF NOT EXISTS description_aliases JSON DEFAULT '[]'
"""))
print("+ Added description_aliases column to ingredient_sources")
# Backfill missing product codes on existing line items.
# For every ingredient_source that has both a product_code and description_pattern,
# find line items from the same supplier with no product_code where the first-line
# description matches, and fill in the code + ingredient_id.
# This is idempotent — re-running updates 0 rows if already backfilled.
result = await conn.execute(text("""
UPDATE line_items li
SET product_code = src.product_code,
ingredient_id = COALESCE(li.ingredient_id, src.ingredient_id)
FROM ingredient_sources src, invoices inv
WHERE inv.id = li.invoice_id
AND inv.supplier_id = src.supplier_id
AND inv.kitchen_id = src.kitchen_id
AND src.product_code IS NOT NULL
AND src.product_code != ''
AND src.description_pattern IS NOT NULL
AND src.description_pattern != ''
AND (li.product_code IS NULL OR li.product_code = '')
AND LOWER(TRIM(split_part(li.description, E'\\n', 1)))
= LOWER(TRIM(src.description_pattern))
"""))
count = result.rowcount
if count > 0:
print(f"+ Backfilled product_code on {count} line items (from ingredient sources)")
else:
print("+ No line items needed product_code backfill (from ingredient sources)")
# Second pass: backfill from sibling line items.
# If any line item from a supplier has a product_code, and other line items from
# the same supplier have the same first-line description but no product_code,
# copy the code across. This handles the OCR-missed-code case (e.g. Bramleys
# butter 283) without needing ingredient sources to exist yet.
result2 = await conn.execute(text("""
UPDATE line_items li
SET product_code = known.code
FROM (
SELECT DISTINCT ON (inv.supplier_id, LOWER(TRIM(split_part(li2.description, E'\\n', 1))))
inv.supplier_id,
LOWER(TRIM(split_part(li2.description, E'\\n', 1))) AS norm_desc,
li2.product_code AS code
FROM line_items li2
JOIN invoices inv ON inv.id = li2.invoice_id
WHERE li2.product_code IS NOT NULL
AND li2.product_code != ''
ORDER BY inv.supplier_id,
LOWER(TRIM(split_part(li2.description, E'\\n', 1))),
li2.id DESC
) known, invoices inv2
WHERE inv2.id = li.invoice_id
AND inv2.supplier_id = known.supplier_id
AND (li.product_code IS NULL OR li.product_code = '')
AND LOWER(TRIM(split_part(li.description, E'\\n', 1))) = known.norm_desc
"""))
count2 = result2.rowcount
if count2 > 0:
print(f"+ Backfilled product_code on {count2} line items (from sibling line items)")
else:
print("+ No line items needed product_code backfill (from siblings)")
if __name__ == "__main__":
print("Running migration: add_description_aliases")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,215 @@
"""
Migration script to add Dext integration features:
- Invoice notes field
- Dext sent tracking (sent_at, sent_by_user_id)
- SMTP configuration in kitchen_settings
- Dext configuration in kitchen_settings
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 Dext integration."""
logger.info("Running Dext integration database migrations...")
# ===== INVOICE TABLE MIGRATIONS =====
# Add notes column to invoices table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE invoices ADD COLUMN notes TEXT"
))
logger.info("Added notes column to invoices")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("notes column already exists")
else:
logger.warning(f"notes column: {e}")
# Add dext_sent_at column to invoices table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE invoices ADD COLUMN dext_sent_at TIMESTAMP"
))
logger.info("Added dext_sent_at column to invoices")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("dext_sent_at column already exists")
else:
logger.warning(f"dext_sent_at column: {e}")
# Add dext_sent_by_user_id column to invoices table
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE invoices ADD COLUMN dext_sent_by_user_id INTEGER REFERENCES users(id)"
))
logger.info("Added dext_sent_by_user_id column to invoices")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("dext_sent_by_user_id column already exists")
else:
logger.warning(f"dext_sent_by_user_id column: {e}")
# ===== KITCHEN_SETTINGS TABLE MIGRATIONS - SMTP =====
# Add smtp_host column
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN smtp_host VARCHAR(255)"
))
logger.info("Added smtp_host column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("smtp_host column already exists")
else:
logger.warning(f"smtp_host column: {e}")
# Add smtp_port column
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN smtp_port INTEGER DEFAULT 587"
))
logger.info("Added smtp_port column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("smtp_port column already exists")
else:
logger.warning(f"smtp_port column: {e}")
# Add smtp_username column
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN smtp_username VARCHAR(255)"
))
logger.info("Added smtp_username column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("smtp_username column already exists")
else:
logger.warning(f"smtp_username column: {e}")
# Add smtp_password column
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN smtp_password VARCHAR(500)"
))
logger.info("Added smtp_password column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("smtp_password column already exists")
else:
logger.warning(f"smtp_password column: {e}")
# Add smtp_use_tls column
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN smtp_use_tls BOOLEAN DEFAULT TRUE"
))
logger.info("Added smtp_use_tls column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("smtp_use_tls column already exists")
else:
logger.warning(f"smtp_use_tls column: {e}")
# Add smtp_from_email column
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN smtp_from_email VARCHAR(255)"
))
logger.info("Added smtp_from_email column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("smtp_from_email column already exists")
else:
logger.warning(f"smtp_from_email column: {e}")
# Add smtp_from_name column
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN smtp_from_name VARCHAR(255) DEFAULT 'Kitchen Invoice System'"
))
logger.info("Added smtp_from_name column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("smtp_from_name column already exists")
else:
logger.warning(f"smtp_from_name column: {e}")
# ===== KITCHEN_SETTINGS TABLE MIGRATIONS - DEXT =====
# Add dext_email column
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN dext_email VARCHAR(255)"
))
logger.info("Added dext_email column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("dext_email column already exists")
else:
logger.warning(f"dext_email column: {e}")
# Add dext_include_notes column
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN dext_include_notes BOOLEAN DEFAULT TRUE"
))
logger.info("Added dext_include_notes column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("dext_include_notes column already exists")
else:
logger.warning(f"dext_include_notes column: {e}")
# Add dext_include_non_stock column
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN dext_include_non_stock BOOLEAN DEFAULT TRUE"
))
logger.info("Added dext_include_non_stock column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("dext_include_non_stock column already exists")
else:
logger.warning(f"dext_include_non_stock column: {e}")
# Add dext_auto_send_enabled column
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE kitchen_settings ADD COLUMN dext_auto_send_enabled BOOLEAN DEFAULT FALSE"
))
logger.info("Added dext_auto_send_enabled column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("dext_auto_send_enabled column already exists")
else:
logger.warning(f"dext_auto_send_enabled column: {e}")
logger.info("Dext integration migration completed successfully!")
if __name__ == "__main__":
asyncio.run(run_migration())

View file

@ -0,0 +1,30 @@
"""
Migration: Add dext_manual_send_enabled column to kitchen_settings table
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
# Add dext_manual_send_enabled column
try:
await conn.execute(text(
"""
ALTER TABLE kitchen_settings
ADD COLUMN dext_manual_send_enabled BOOLEAN DEFAULT TRUE
"""
))
print("✓ Added dext_manual_send_enabled column")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
print("- dext_manual_send_enabled column already exists, skipping")
else:
raise
if __name__ == "__main__":
print("Running migration: add_dext_manual_send")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,39 @@
"""
Migration: Rename recipe_type 'plated' to 'dish', add section_type to menu_sections.
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
# 1. Rename recipe_type 'plated' -> 'dish' in recipes table
result = await conn.execute(text(
"UPDATE recipes SET recipe_type = 'dish' WHERE recipe_type = 'plated'"
))
print(f" + Renamed {result.rowcount} recipes from 'plated' to 'dish'")
# 2. Add section_type column to menu_sections (default 'recipe')
await conn.execute(text(
"ALTER TABLE menu_sections ADD COLUMN IF NOT EXISTS section_type VARCHAR(20) NOT NULL DEFAULT 'recipe'"
))
# 3. Drop old unique constraint, create new one including section_type
try:
await conn.execute(text(
"ALTER TABLE menu_sections DROP CONSTRAINT IF EXISTS uq_menu_sections_kitchen_name"
))
except Exception as e:
print(f" ! Warning dropping old constraint: {e}")
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS uix_menu_sections_kn_st "
"ON menu_sections(kitchen_id, name, section_type)"
))
print("+ Added menu_sections.section_type, renamed plated -> dish")
if __name__ == "__main__":
asyncio.run(migrate())

View file

@ -0,0 +1,42 @@
"""
Migration to add public_hash column to dispute_attachments table.
This enables public shareable links for dispute attachments, allowing
suppliers to view images/documents via email without authentication.
"""
import logging
from sqlalchemy import text
from database import engine
logger = logging.getLogger(__name__)
async def run_migration():
"""Add public_hash column to dispute_attachments"""
async with engine.begin() as conn:
# Check if column already exists
result = await conn.execute(text("""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'dispute_attachments'
AND column_name = 'public_hash'
);
"""))
exists = result.scalar()
if not exists:
logger.info("Adding 'public_hash' column to dispute_attachments table")
await conn.execute(text("""
ALTER TABLE dispute_attachments
ADD COLUMN public_hash VARCHAR(64) UNIQUE;
"""))
# Create index for fast lookups
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_dispute_attachments_public_hash
ON dispute_attachments(public_hash) WHERE public_hash IS NOT NULL;
"""))
logger.info("Successfully added 'public_hash' column with index")
else:
logger.info("'public_hash' column already exists in dispute_attachments")

View file

@ -0,0 +1,45 @@
"""
Migration: Add 'required' to food_flag_categories, 'flags_assessed' to ingredients,
and 'ingredient_flag_nones' table for per-category "None apply" tracking.
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
# Add 'required' boolean to food_flag_categories (default false)
await conn.execute(text(
"ALTER TABLE food_flag_categories ADD COLUMN IF NOT EXISTS required BOOLEAN DEFAULT false"
))
# Auto-set 'contains' categories as required (allergens should be assessed)
await conn.execute(text(
"UPDATE food_flag_categories SET required = true WHERE propagation_type = 'contains'"
))
# Add 'flags_assessed' boolean to ingredients (kept for compat, not actively used)
await conn.execute(text(
"ALTER TABLE ingredients ADD COLUMN IF NOT EXISTS flags_assessed BOOLEAN DEFAULT false"
))
# Create ingredient_flag_nones table (per-category "None apply" tracking)
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS ingredient_flag_nones (
id SERIAL PRIMARY KEY,
ingredient_id INTEGER NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES food_flag_categories(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT uq_ingredient_flag_nones_ing_cat UNIQUE (ingredient_id, category_id)
)
"""))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_ingredient_flag_nones_ing ON ingredient_flag_nones(ingredient_id)"
))
print("+ Added food_flag_categories.required, ingredient_flag_nones table")
if __name__ == "__main__":
asyncio.run(migrate())

View file

@ -0,0 +1,22 @@
"""Add gross_sell_price column to recipes table."""
import logging
from sqlalchemy import text
from database import engine
logger = logging.getLogger(__name__)
async def migrate():
async with engine.begin() as conn:
result = await conn.execute(text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'recipes' AND column_name = 'gross_sell_price'"
))
if result.scalar_one_or_none():
logger.info("recipes.gross_sell_price already exists, skipping")
return
await conn.execute(text(
"ALTER TABLE recipes ADD COLUMN gross_sell_price NUMERIC(10,2) DEFAULT NULL"
))
logger.info("Added recipes.gross_sell_price column")

View file

@ -0,0 +1,94 @@
"""
Migration script to add IMAP email inbox integration:
- IMAP configuration fields on kitchen_settings
- source and source_reference fields on invoices
- email_processing_log table
"""
import asyncio
import logging
from sqlalchemy import text
from database import engine
logger = logging.getLogger(__name__)
async def run_migration():
"""Add IMAP email inbox integration tables and columns"""
# Add IMAP columns to kitchen_settings (execute each separately for asyncpg)
imap_settings_statements = [
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS imap_host VARCHAR(255)",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS imap_port INTEGER DEFAULT 993",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS imap_use_ssl BOOLEAN DEFAULT TRUE",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS imap_username VARCHAR(255)",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS imap_password VARCHAR(500)",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS imap_folder VARCHAR(255) DEFAULT 'INBOX'",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS imap_poll_interval INTEGER DEFAULT 15",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS imap_enabled BOOLEAN DEFAULT FALSE",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS imap_confidence_threshold NUMERIC(3,2) DEFAULT 0.50",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS imap_last_sync TIMESTAMP",
]
# Add source columns to invoices
invoice_source_statements = [
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS source VARCHAR(50) DEFAULT 'upload'",
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS source_reference VARCHAR(255)",
]
# Create email_processing_log table
create_table = """
CREATE TABLE IF NOT EXISTS email_processing_log (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
message_id VARCHAR(500) NOT NULL,
email_subject VARCHAR(500),
email_from VARCHAR(255),
email_date TIMESTAMP,
attachments_count INTEGER DEFAULT 0,
invoices_created INTEGER DEFAULT 0,
confident_invoices INTEGER DEFAULT 0,
marked_as_read BOOLEAN DEFAULT FALSE,
processing_status VARCHAR(50) DEFAULT 'pending',
error_message TEXT,
invoice_ids JSONB,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(kitchen_id, message_id)
)
"""
index_statements = [
"CREATE INDEX IF NOT EXISTS idx_email_log_kitchen ON email_processing_log(kitchen_id)",
"CREATE INDEX IF NOT EXISTS idx_email_log_message_id ON email_processing_log(message_id)",
"CREATE INDEX IF NOT EXISTS idx_email_log_processed_at ON email_processing_log(processed_at)",
]
try:
async with engine.begin() as conn:
# Add IMAP columns to kitchen_settings
for sql in imap_settings_statements:
await conn.execute(text(sql))
logger.info("Added IMAP columns to kitchen_settings")
# Add source columns to invoices
for sql in invoice_source_statements:
await conn.execute(text(sql))
logger.info("Added source columns to invoices")
# Create email_processing_log table
await conn.execute(text(create_table))
logger.info("Created email_processing_log table")
# Create indexes
for sql in index_statements:
await conn.execute(text(sql))
logger.info("Created indexes on email_processing_log")
logger.info("IMAP integration migration completed")
except Exception as e:
if "already exists" not in str(e).lower():
raise
logger.warning(f"IMAP migration: {e}")
if __name__ == "__main__":
asyncio.run(run_migration())

View file

@ -0,0 +1,32 @@
"""
Migration: Add ingredient_flag_dismissals table for tracking dismissed allergen suggestions.
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS ingredient_flag_dismissals (
id SERIAL PRIMARY KEY,
ingredient_id INTEGER NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE,
food_flag_id INTEGER NOT NULL REFERENCES food_flags(id) ON DELETE CASCADE,
dismissed_by_name VARCHAR(100) NOT NULL,
reason TEXT,
matched_keyword VARCHAR(200),
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT uq_ingredient_flag_dismissals_ing_flag UNIQUE (ingredient_id, food_flag_id)
)
"""))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_ingredient_flag_dismissals_ing ON ingredient_flag_dismissals(ingredient_id)"
))
print("+ Created ingredient_flag_dismissals table")
if __name__ == "__main__":
print("Running migration: add_ingredient_flag_dismissals")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,21 @@
"""
Migration: Add is_free column to ingredients table.
Free items (e.g. water) bypass no-price/manual-price warnings on recipes.
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE ingredients ADD COLUMN IF NOT EXISTS is_free BOOLEAN DEFAULT false"
))
print("+ Added is_free column to ingredients")
if __name__ == "__main__":
print("Running migration: add_ingredient_is_free")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,66 @@
"""
Migration script to add invoice dispute tracking tables and indexes.
Tables created automatically by SQLAlchemy from models:
- invoice_disputes
- dispute_line_items
- dispute_attachments
- dispute_activity
- credit_notes
This migration adds performance indexes.
"""
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 indexes for invoice dispute tables."""
logger.info("Running invoice disputes migration...")
# Create indexes for performance
indexes = [
# Invoice disputes indexes
"CREATE INDEX IF NOT EXISTS idx_disputes_kitchen_status ON invoice_disputes(kitchen_id, status, opened_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_disputes_invoice ON invoice_disputes(invoice_id)",
"CREATE INDEX IF NOT EXISTS idx_disputes_type ON invoice_disputes(dispute_type)",
"CREATE INDEX IF NOT EXISTS idx_disputes_opened_at ON invoice_disputes(opened_at DESC)",
# Credit notes indexes
"CREATE INDEX IF NOT EXISTS idx_credit_notes_invoice ON credit_notes(invoice_id)",
"CREATE INDEX IF NOT EXISTS idx_credit_notes_kitchen ON credit_notes(kitchen_id)",
"CREATE INDEX IF NOT EXISTS idx_credit_notes_date ON credit_notes(credit_date DESC)",
# Dispute attachments indexes
"CREATE INDEX IF NOT EXISTS idx_dispute_attachments_dispute ON dispute_attachments(dispute_id)",
"CREATE INDEX IF NOT EXISTS idx_dispute_attachments_kitchen ON dispute_attachments(kitchen_id)",
# Dispute activity indexes
"CREATE INDEX IF NOT EXISTS idx_dispute_activity_dispute ON dispute_activity(dispute_id)",
"CREATE INDEX IF NOT EXISTS idx_dispute_activity_created_at ON dispute_activity(created_at DESC)",
# Dispute line items indexes
"CREATE INDEX IF NOT EXISTS idx_dispute_line_items_dispute ON dispute_line_items(dispute_id)",
]
for sql in indexes:
try:
async with engine.begin() as conn:
await conn.execute(text(sql))
logger.info(f"Created index: {sql[35:80]}...")
except Exception as e:
if "already exists" in str(e).lower():
logger.info(f"Index already exists, skipping")
else:
logger.warning(f"Index creation warning: {e}")
logger.info("Invoice disputes migration completed successfully!")
if __name__ == "__main__":
asyncio.run(run_migration())

View file

@ -0,0 +1,426 @@
"""
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 document_type VARCHAR(50) DEFAULT 'invoice'",
"ALTER TABLE invoices ADD COLUMN order_number VARCHAR(100)",
"ALTER TABLE invoices ADD COLUMN duplicate_status VARCHAR(50)",
"ALTER TABLE invoices ADD COLUMN duplicate_of_id INTEGER REFERENCES invoices(id)",
"ALTER TABLE invoices ADD COLUMN 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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())

View file

@ -0,0 +1,39 @@
"""
Migration: Add kitchen detail columns to kitchen_settings table.
Used for Purchase Order letterhead (preview and email).
"""
import asyncio
from sqlalchemy import text
from database import engine
COLUMNS = [
("kitchen_display_name", "VARCHAR(255)"),
("kitchen_address_line1", "VARCHAR(255)"),
("kitchen_address_line2", "VARCHAR(255)"),
("kitchen_city", "VARCHAR(100)"),
("kitchen_postcode", "VARCHAR(20)"),
("kitchen_phone", "VARCHAR(50)"),
("kitchen_email", "VARCHAR(255)"),
]
async def migrate():
async with engine.begin() as conn:
for col_name, col_type in COLUMNS:
try:
await conn.execute(text(
f"ALTER TABLE kitchen_settings ADD COLUMN {col_name} {col_type}"
))
print(f"+ Added {col_name} column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
print(f"- {col_name} column already exists, skipping")
else:
raise
if __name__ == "__main__":
print("Running migration: add_kitchen_details")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,44 @@
"""
Migration script to add line item search capabilities:
- Enable pg_trgm extension for fuzzy text matching
- Create GIN index on line_items.description for trigram similarity
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 pg_trgm extension and trigram index for line item search."""
logger.info("Running line item search migration...")
# Enable pg_trgm extension for fuzzy text matching
try:
async with engine.begin() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
logger.info("Enabled pg_trgm extension")
except Exception as e:
logger.warning(f"pg_trgm extension: {e}")
# Create GIN index for trigram similarity on description
try:
async with engine.begin() as conn:
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_line_items_description_trgm "
"ON line_items USING gin (description gin_trgm_ops)"
))
logger.info("Created trigram index on line_items.description")
except Exception as e:
logger.warning(f"Trigram index: {e}")
logger.info("Line item search migration completed!")
if __name__ == "__main__":
asyncio.run(run_migration())

View file

@ -0,0 +1,32 @@
"""
Migration: Add linked_dispute_id column to invoices table
This allows credit notes to track which dispute they resolved.
"""
import logging
from sqlalchemy import text
from database import AsyncSessionLocal
logger = logging.getLogger(__name__)
async def run_migration():
"""Add linked_dispute_id column to invoices table"""
async with AsyncSessionLocal() as db:
try:
# Add linked_dispute_id column with foreign key to invoice_disputes
await db.execute(text("""
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS linked_dispute_id INTEGER REFERENCES invoice_disputes(id) ON DELETE SET NULL
"""))
# Create index for faster lookups
await db.execute(text("""
CREATE INDEX IF NOT EXISTS ix_invoices_linked_dispute_id ON invoices(linked_dispute_id)
"""))
await db.commit()
logger.info("Linked dispute migration completed successfully")
except Exception as e:
logger.warning(f"Linked dispute migration warning: {e}")
await db.rollback()

View file

@ -0,0 +1,85 @@
"""
Migration: Add LLM infrastructure settings columns, usage log table, analysis cache table.
LLM FEATURE see LLM-MANIFEST.md for removal instructions
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
# 1. Add LLM columns to kitchen_settings
for col_sql in [
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS llm_enabled BOOLEAN DEFAULT FALSE",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS anthropic_api_key VARCHAR(500)",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS llm_model VARCHAR(100) DEFAULT 'claude-haiku-4-5-20251001'",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS llm_confidence_threshold NUMERIC(3,2) DEFAULT 0.80",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS llm_monthly_token_limit INTEGER DEFAULT 500000",
"""ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS llm_features_enabled JSONB DEFAULT '{
"label_parsing": true, "invoice_assist": true, "ingredient_match": true,
"recipe_scanning": true, "line_item_reconciliation": true, "menu_description": true,
"dispute_email": true, "duplicate_detection": true, "supplier_alias": true, "yield_estimation": true
}'::jsonb""",
]:
try:
await conn.execute(text(col_sql))
except Exception:
pass
print("+ Added LLM columns to kitchen_settings")
# 2. Create llm_usage_log table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS llm_usage_log (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL,
feature VARCHAR(50) NOT NULL,
model VARCHAR(100) NOT NULL,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
latency_ms INTEGER DEFAULT 0,
success BOOLEAN DEFAULT TRUE,
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_llm_usage_kitchen ON llm_usage_log(kitchen_id)"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_llm_usage_feature ON llm_usage_log(feature)"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_llm_usage_created ON llm_usage_log(created_at)"
))
print("+ Created llm_usage_log table")
# 3. Create llm_analysis_cache table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS llm_analysis_cache (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL,
feature VARCHAR(50) NOT NULL,
input_hash VARCHAR(64) NOT NULL,
result_json JSONB NOT NULL,
model_used VARCHAR(100) NOT NULL,
prompt_version VARCHAR(10) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT uq_llm_cache_feature_hash_version UNIQUE (feature, input_hash, prompt_version)
)
"""))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_llm_cache_kitchen ON llm_analysis_cache(kitchen_id)"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_llm_cache_hash ON llm_analysis_cache(input_hash)"
))
print("+ Created llm_analysis_cache table")
print("+ LLM infrastructure migration complete")
if __name__ == "__main__":
print("Running migration: add_llm_infrastructure")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,58 @@
import asyncio
import logging
from sqlalchemy import text
from database import engine
logger = logging.getLogger(__name__)
async def run_migration():
"""Create logbook tables and enums"""
# Create enums one at a time (asyncpg doesn't support multiple statements)
enum_statements = [
"""
DO $$ BEGIN
CREATE TYPE entry_type AS ENUM (
'wastage', 'transfer', 'staff_food', 'manual_adjustment'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$
""",
"""
DO $$ BEGIN
CREATE TYPE wastage_reason AS ENUM (
'spoiled', 'damaged', 'expired', 'overproduction', 'quality_issue', 'other'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$
""",
"""
DO $$ BEGIN
CREATE TYPE transfer_status AS ENUM (
'pending', 'in_transit', 'received', 'cancelled'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$
"""
]
try:
async with engine.begin() as conn:
for sql in enum_statements:
await conn.execute(text(sql))
logger.info("Created logbook enums")
# Tables will be created by SQLAlchemy Base.metadata.create_all()
logger.info("Logbook migration completed")
except Exception as e:
if "already exists" not in str(e).lower():
raise
logger.warning(f"Logbook migration: {e}")
if __name__ == "__main__":
asyncio.run(run_migration())

View file

@ -0,0 +1,79 @@
"""
Migration: Add menus, menu_divisions, and menu_items tables for the Menus feature.
"""
import asyncio
from sqlalchemy import text
from database import engine
async def migrate():
async with engine.begin() as conn:
# Menus table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS menus (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
name VARCHAR(255) NOT NULL,
description TEXT,
notes TEXT,
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT uq_menus_kitchen_name UNIQUE (kitchen_id, name)
)
"""))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_menus_kitchen_id ON menus(kitchen_id)"
))
# Menu divisions table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS menu_divisions (
id SERIAL PRIMARY KEY,
menu_id INTEGER NOT NULL REFERENCES menus(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
sort_order INTEGER DEFAULT 0,
CONSTRAINT uq_menu_divisions_menu_name UNIQUE (menu_id, name)
)
"""))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_menu_divisions_menu_id ON menu_divisions(menu_id)"
))
# Menu items table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS menu_items (
id SERIAL PRIMARY KEY,
menu_id INTEGER NOT NULL REFERENCES menus(id) ON DELETE CASCADE,
division_id INTEGER NOT NULL REFERENCES menu_divisions(id) ON DELETE CASCADE,
recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL,
display_name VARCHAR(255) NOT NULL,
description TEXT,
price NUMERIC(10, 2),
sort_order INTEGER DEFAULT 0,
snapshot_json JSONB,
confirmed_by_user_id INTEGER REFERENCES users(id),
confirmed_by_name VARCHAR(100),
published_at TIMESTAMP DEFAULT NOW(),
image_path VARCHAR(500),
uploaded_by INTEGER REFERENCES users(id),
CONSTRAINT uq_menu_items_menu_recipe UNIQUE (menu_id, recipe_id)
)
"""))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_menu_items_menu_id ON menu_items(menu_id)"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_menu_items_division_id ON menu_items(division_id)"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_menu_items_recipe_id ON menu_items(recipe_id)"
))
print("+ Created menus, menu_divisions, and menu_items tables")
if __name__ == "__main__":
print("Running migration: add_menus")
asyncio.run(migrate())
print("Migration complete!")

View file

@ -0,0 +1,51 @@
"""
Migration to add NEW status and update OPEN disputes to NEW.
"""
import logging
from sqlalchemy import text
from database import engine
logger = logging.getLogger(__name__)
async def run_migration():
"""Add NEW status and migrate OPEN disputes"""
# First transaction: Add NEW enum value
async with engine.begin() as conn:
# Check if NEW enum value already exists
result = await conn.execute(text("""
SELECT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'NEW'
AND enumtypid = (
SELECT oid FROM pg_type WHERE typname = 'disputestatus'
)
);
"""))
exists = result.scalar()
if not exists:
logger.info("Adding 'NEW' to disputestatus enum")
await conn.execute(text("""
ALTER TYPE disputestatus ADD VALUE 'NEW';
"""))
logger.info("Successfully added 'NEW' to disputestatus enum")
else:
logger.info("'NEW' already exists in disputestatus enum")
# Second transaction: Update existing OPEN disputes to NEW (requires commit after enum addition)
async with engine.begin() as conn:
# Check if there are any OPEN disputes that need migration
result = await conn.execute(text("""
SELECT COUNT(*) FROM invoice_disputes WHERE status = 'OPEN';
"""))
open_count = result.scalar()
if open_count > 0:
logger.info(f"Updating {open_count} existing OPEN disputes to NEW")
await conn.execute(text("""
UPDATE invoice_disputes SET status = 'NEW' WHERE status = 'OPEN';
"""))
logger.info("Successfully migrated OPEN disputes to NEW")
else:
logger.info("No OPEN disputes to migrate")

View file

@ -0,0 +1,50 @@
"""
Migration script to add arrival tracking columns to newbook_daily_occupancy table:
- arrival_count: Integer count of bookings arriving on this date
- arrival_booking_ids: JSONB list of booking IDs arriving
- arrival_booking_details: JSONB list of full arrival details with booking refs
This enables cross-referencing hotel arrivals with restaurant (Resos) table bookings.
Run this script once after deploying the new code.
"""
import asyncio
import logging
import sys
sys.path.insert(0, '/app')
from sqlalchemy import text
from database import engine
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def run_migration():
"""Add arrival tracking columns to newbook_daily_occupancy table."""
logger.info("Running Newbook arrival tracking migration...")
# Add arrival tracking columns to newbook_daily_occupancy table
arrival_columns = [
"ALTER TABLE newbook_daily_occupancy ADD COLUMN arrival_count INTEGER",
"ALTER TABLE newbook_daily_occupancy ADD COLUMN arrival_booking_ids JSONB",
"ALTER TABLE newbook_daily_occupancy ADD COLUMN arrival_booking_details JSONB",
]
for sql in arrival_columns:
try:
async with engine.begin() as conn:
await conn.execute(text(sql))
col_name = sql.split("ADD COLUMN")[1].split()[0]
logger.info(f"Added column: {col_name}")
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"Column migration warning: {e}")
logger.info("Newbook arrival tracking migration completed successfully")
if __name__ == "__main__":
asyncio.run(run_migration())

View file

@ -0,0 +1,196 @@
"""
Migration script to add Newbook PMS integration tables and settings:
- Newbook credential fields on kitchen_settings
- newbook_gl_accounts table
- newbook_daily_revenue table
- newbook_daily_occupancy table
- newbook_sync_log 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 Newbook integration tables and settings columns."""
logger.info("Running Newbook integration migrations...")
# Add Newbook columns to kitchen_settings table
settings_columns = [
"ALTER TABLE kitchen_settings ADD COLUMN newbook_api_username VARCHAR(255)",
"ALTER TABLE kitchen_settings ADD COLUMN newbook_api_password VARCHAR(500)",
"ALTER TABLE kitchen_settings ADD COLUMN newbook_api_key VARCHAR(500)",
"ALTER TABLE kitchen_settings ADD COLUMN newbook_api_region VARCHAR(10)",
"ALTER TABLE kitchen_settings ADD COLUMN newbook_instance_id VARCHAR(100)",
"ALTER TABLE kitchen_settings ADD COLUMN newbook_last_sync TIMESTAMP",
"ALTER TABLE kitchen_settings ADD COLUMN newbook_auto_sync_enabled BOOLEAN DEFAULT FALSE",
"ALTER TABLE kitchen_settings ADD COLUMN newbook_breakfast_gl_codes VARCHAR(500)",
"ALTER TABLE kitchen_settings ADD COLUMN newbook_dinner_gl_codes VARCHAR(500)",
]
for sql in settings_columns:
try:
async with engine.begin() as conn:
await conn.execute(text(sql))
col_name = sql.split("ADD COLUMN")[1].split()[0]
logger.info(f"Added column: {col_name}")
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"Column migration warning: {e}")
# Create newbook_gl_accounts table
create_gl_accounts = """
CREATE TABLE IF NOT EXISTS newbook_gl_accounts (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
gl_account_id VARCHAR(50) NOT NULL,
gl_code VARCHAR(50),
gl_name VARCHAR(255) NOT NULL,
gl_type VARCHAR(100),
is_tracked BOOLEAN DEFAULT FALSE,
display_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_newbook_gl_account UNIQUE(kitchen_id, gl_account_id)
)
"""
try:
async with engine.begin() as conn:
await conn.execute(text(create_gl_accounts))
logger.info("Created newbook_gl_accounts table")
except Exception as e:
logger.warning(f"newbook_gl_accounts table: {e}")
# Create newbook_daily_revenue table
create_daily_revenue = """
CREATE TABLE IF NOT EXISTS newbook_daily_revenue (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
gl_account_id INTEGER NOT NULL REFERENCES newbook_gl_accounts(id),
date DATE NOT NULL,
amount_net NUMERIC(12, 2) NOT NULL,
amount_gross NUMERIC(12, 2),
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_newbook_revenue_per_day UNIQUE(kitchen_id, gl_account_id, date)
)
"""
try:
async with engine.begin() as conn:
await conn.execute(text(create_daily_revenue))
logger.info("Created newbook_daily_revenue table")
except Exception as e:
logger.warning(f"newbook_daily_revenue table: {e}")
# Create newbook_daily_occupancy table
create_daily_occupancy = """
CREATE TABLE IF NOT EXISTS newbook_daily_occupancy (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
date DATE NOT NULL,
total_rooms INTEGER,
occupied_rooms INTEGER,
occupancy_percentage NUMERIC(5, 2),
total_guests INTEGER,
breakfast_allocation_qty INTEGER,
breakfast_allocation_netvalue NUMERIC(12, 2),
dinner_allocation_qty INTEGER,
dinner_allocation_netvalue NUMERIC(12, 2),
is_forecast BOOLEAN DEFAULT FALSE,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_newbook_occupancy_per_day UNIQUE(kitchen_id, date)
)
"""
try:
async with engine.begin() as conn:
await conn.execute(text(create_daily_occupancy))
logger.info("Created newbook_daily_occupancy table")
except Exception as e:
logger.warning(f"newbook_daily_occupancy table: {e}")
# Create newbook_sync_log table
create_sync_log = """
CREATE TABLE IF NOT EXISTS newbook_sync_log (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
sync_type VARCHAR(50) NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
status VARCHAR(20) DEFAULT 'running',
records_fetched INTEGER DEFAULT 0,
error_message TEXT,
date_from DATE,
date_to DATE
)
"""
try:
async with engine.begin() as conn:
await conn.execute(text(create_sync_log))
logger.info("Created newbook_sync_log table")
except Exception as e:
logger.warning(f"newbook_sync_log table: {e}")
# Create newbook_room_categories table
create_room_categories = """
CREATE TABLE IF NOT EXISTS newbook_room_categories (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
site_id VARCHAR(50) NOT NULL,
site_name VARCHAR(255) NOT NULL,
site_type VARCHAR(100),
room_count INTEGER DEFAULT 0,
is_included BOOLEAN DEFAULT TRUE,
display_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_newbook_room_category UNIQUE(kitchen_id, site_id)
)
"""
try:
async with engine.begin() as conn:
await conn.execute(text(create_room_categories))
logger.info("Created newbook_room_categories table")
except Exception as e:
logger.warning(f"newbook_room_categories table: {e}")
# Add room_count column if it doesn't exist
try:
async with engine.begin() as conn:
await conn.execute(text("ALTER TABLE newbook_room_categories ADD COLUMN room_count INTEGER DEFAULT 0"))
logger.info("Added room_count column to newbook_room_categories")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info("room_count column already exists, skipping")
else:
logger.warning(f"room_count column: {e}")
# Create indexes
indexes = [
"CREATE INDEX IF NOT EXISTS ix_newbook_gl_accounts_kitchen_id ON newbook_gl_accounts(kitchen_id)",
"CREATE INDEX IF NOT EXISTS ix_newbook_revenue_date ON newbook_daily_revenue(date)",
"CREATE INDEX IF NOT EXISTS ix_newbook_revenue_kitchen_id ON newbook_daily_revenue(kitchen_id)",
"CREATE INDEX IF NOT EXISTS ix_newbook_occupancy_date ON newbook_daily_occupancy(date)",
"CREATE INDEX IF NOT EXISTS ix_newbook_occupancy_kitchen_id ON newbook_daily_occupancy(kitchen_id)",
"CREATE INDEX IF NOT EXISTS ix_newbook_sync_log_kitchen_id ON newbook_sync_log(kitchen_id)",
"CREATE INDEX IF NOT EXISTS ix_newbook_room_categories_kitchen_id ON newbook_room_categories(kitchen_id)",
]
for sql in indexes:
try:
async with engine.begin() as conn:
await conn.execute(text(sql))
except Exception as e:
logger.warning(f"Index: {e}")
logger.info("Newbook migration completed successfully!")
if __name__ == "__main__":
asyncio.run(run_migration())

View file

@ -0,0 +1,39 @@
import asyncio
import logging
from sqlalchemy import text
from database import engine
logger = logging.getLogger(__name__)
async def run_migration():
"""Add Newbook upcoming sync schedule columns"""
alter_statements = [
"""
ALTER TABLE kitchen_settings
ADD COLUMN IF NOT EXISTS newbook_upcoming_sync_interval INTEGER DEFAULT 15
""",
"""
ALTER TABLE kitchen_settings
ADD COLUMN IF NOT EXISTS newbook_upcoming_sync_enabled BOOLEAN DEFAULT FALSE
""",
"""
ALTER TABLE kitchen_settings
ADD COLUMN IF NOT EXISTS newbook_last_upcoming_sync TIMESTAMP
"""
]
try:
async with engine.begin() as conn:
for sql in alter_statements:
await conn.execute(text(sql))
logger.info("Added Newbook upcoming sync columns to kitchen_settings")
except Exception as e:
if "already exists" not in str(e).lower():
raise
logger.warning(f"Newbook upcoming sync migration: {e}")
if __name__ == "__main__":
asyncio.run(run_migration())

View file

@ -0,0 +1,142 @@
"""
Migration script to add Nextcloud and Backup features.
Adds:
- Nextcloud settings columns to kitchen_settings
- Backup settings columns to kitchen_settings
- File storage tracking columns to invoices
- backup_history 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 for Nextcloud and Backup features."""
logger.info("Running Nextcloud/Backup database migrations...")
# ===== KITCHEN_SETTINGS - NEXTCLOUD COLUMNS =====
nextcloud_columns = [
("nextcloud_host", "VARCHAR(500)"),
("nextcloud_username", "VARCHAR(255)"),
("nextcloud_password", "VARCHAR(500)"),
("nextcloud_base_path", "VARCHAR(500) DEFAULT '/Kitchen Invoices'"),
("nextcloud_enabled", "BOOLEAN DEFAULT FALSE"),
("nextcloud_delete_local", "BOOLEAN DEFAULT FALSE"),
]
for col_name, col_type in nextcloud_columns:
try:
async with engine.begin() as conn:
await conn.execute(text(
f"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS {col_name} {col_type}"
))
logger.info(f"Added {col_name} column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info(f"{col_name} column already exists")
else:
logger.warning(f"{col_name} column: {e}")
# ===== KITCHEN_SETTINGS - BACKUP COLUMNS =====
backup_columns = [
("backup_frequency", "VARCHAR(20) DEFAULT 'manual'"),
("backup_retention_count", "INTEGER DEFAULT 7"),
("backup_destination", "VARCHAR(20) DEFAULT 'local'"),
("backup_time", "VARCHAR(5) DEFAULT '03:00'"),
("backup_nextcloud_path", "VARCHAR(500) DEFAULT '/Backups'"),
("backup_smb_host", "VARCHAR(255)"),
("backup_smb_share", "VARCHAR(255)"),
("backup_smb_username", "VARCHAR(255)"),
("backup_smb_password", "VARCHAR(500)"),
("backup_smb_path", "VARCHAR(500) DEFAULT '/backups'"),
("backup_last_run_at", "TIMESTAMP"),
("backup_last_status", "VARCHAR(50)"),
("backup_last_error", "TEXT"),
]
for col_name, col_type in backup_columns:
try:
async with engine.begin() as conn:
await conn.execute(text(
f"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS {col_name} {col_type}"
))
logger.info(f"Added {col_name} column to kitchen_settings")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info(f"{col_name} column already exists")
else:
logger.warning(f"{col_name} column: {e}")
# ===== INVOICES - FILE STORAGE COLUMNS =====
invoice_columns = [
("file_storage_location", "VARCHAR(20) DEFAULT 'local'"),
("nextcloud_path", "VARCHAR(500)"),
("archived_at", "TIMESTAMP"),
("original_local_path", "VARCHAR(500)"),
]
for col_name, col_type in invoice_columns:
try:
async with engine.begin() as conn:
await conn.execute(text(
f"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS {col_name} {col_type}"
))
logger.info(f"Added {col_name} column to invoices")
except Exception as e:
if "already exists" in str(e).lower() or "duplicate column" in str(e).lower():
logger.info(f"{col_name} column already exists")
else:
logger.warning(f"{col_name} column: {e}")
# ===== CREATE BACKUP_HISTORY TABLE =====
try:
async with engine.begin() as conn:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS backup_history (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
backup_type VARCHAR(20) NOT NULL,
destination VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size_bytes BIGINT,
invoice_count INTEGER,
file_count INTEGER,
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP,
error_message TEXT,
triggered_by_user_id INTEGER REFERENCES users(id)
)
"""))
logger.info("Created backup_history table")
except Exception as e:
if "already exists" in str(e).lower():
logger.info("backup_history table already exists")
else:
logger.warning(f"backup_history table: {e}")
# Create index on backup_history
try:
async with engine.begin() as conn:
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_backup_history_kitchen "
"ON backup_history(kitchen_id, started_at DESC)"
))
logger.info("Created backup_history index")
except Exception as e:
logger.warning(f"backup_history index: {e}")
logger.info("Nextcloud/Backup migration completed successfully!")
if __name__ == "__main__":
asyncio.run(run_migration())

View file

@ -0,0 +1,40 @@
"""
Migration: Add OCR post-processing settings and description_alt field
- ocr_clean_product_codes: Strip section headers (like "CHILL/AMBIENT") from product codes
- ocr_filter_subtotal_rows: Filter subtotal/total rows from line items
- description_alt: Alternative description for Azure content vs value mismatches
"""
import logging
from sqlalchemy import text
from database import AsyncSessionLocal
logger = logging.getLogger(__name__)
async def run_migration():
"""Add OCR post-processing settings to kitchen_settings and description_alt to line_items"""
async with AsyncSessionLocal() as db:
try:
# Add ocr_clean_product_codes column to kitchen_settings
await db.execute(text("""
ALTER TABLE kitchen_settings
ADD COLUMN IF NOT EXISTS ocr_clean_product_codes BOOLEAN DEFAULT FALSE
"""))
# Add ocr_filter_subtotal_rows column to kitchen_settings
await db.execute(text("""
ALTER TABLE kitchen_settings
ADD COLUMN IF NOT EXISTS ocr_filter_subtotal_rows BOOLEAN DEFAULT FALSE
"""))
# Add description_alt column to line_items
await db.execute(text("""
ALTER TABLE line_items
ADD COLUMN IF NOT EXISTS description_alt TEXT
"""))
await db.commit()
logger.info("OCR post-processing migration completed successfully")
except Exception as e:
logger.warning(f"OCR post-processing migration warning: {e}")
await db.rollback()

View file

@ -0,0 +1,24 @@
"""
Migration: Add OCR weight-as-quantity setting
- ocr_use_weight_as_quantity: For KG items, use weight as quantity when it matches total
"""
import logging
from sqlalchemy import text
from database import AsyncSessionLocal
logger = logging.getLogger(__name__)
async def run_migration():
"""Add ocr_use_weight_as_quantity column to kitchen_settings"""
async with AsyncSessionLocal() as db:
try:
await db.execute(text("""
ALTER TABLE kitchen_settings
ADD COLUMN IF NOT EXISTS ocr_use_weight_as_quantity BOOLEAN DEFAULT FALSE
"""))
await db.commit()
logger.info("OCR weight setting migration completed successfully")
except Exception as e:
logger.warning(f"OCR weight setting migration warning: {e}")
await db.rollback()

Some files were not shown because too many files have changed in this diff Show more