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>
628 lines
29 KiB
Python
628 lines
29 KiB
Python
"""
|
|
Migration: Recipe, Ingredient & Food Flag System
|
|
Creates all tables for the recipe system and pre-seeds reference data.
|
|
"""
|
|
import asyncio
|
|
from sqlalchemy import text
|
|
from database import engine
|
|
|
|
|
|
async def migrate():
|
|
# ══ CRITICAL: Add columns to existing tables FIRST (own transaction) ══
|
|
# These MUST succeed even if new table creation fails, otherwise
|
|
# the KitchenSettings SQLAlchemy model breaks ALL existing endpoints.
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.execute(text(
|
|
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS api_key VARCHAR(100)"
|
|
))
|
|
await conn.execute(text(
|
|
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS api_key_enabled BOOLEAN DEFAULT false"
|
|
))
|
|
await conn.execute(text(
|
|
"ALTER TABLE line_items ADD COLUMN IF NOT EXISTS ingredient_id INTEGER REFERENCES ingredients(id) ON DELETE SET NULL"
|
|
))
|
|
await conn.execute(text(
|
|
"CREATE INDEX IF NOT EXISTS idx_line_items_ingredient ON line_items(ingredient_id)"
|
|
))
|
|
print("+ Added columns to existing tables (api_key, api_key_enabled, ingredient_id)")
|
|
except Exception as e:
|
|
print(f"! CRITICAL: Failed to add columns to existing tables: {e}")
|
|
|
|
# ── pg_trgm extension (separate transaction — may need superuser) ──
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
|
print("+ Enabled pg_trgm extension")
|
|
except Exception as e:
|
|
print(f"- pg_trgm extension not available ({e}), fuzzy search will be limited")
|
|
|
|
# ══ Add missing UNIQUE constraints (tables may have been created by
|
|
# SQLAlchemy create_all WITHOUT these constraints) ══
|
|
try:
|
|
async with engine.begin() as conn:
|
|
for idx_name, idx_def in [
|
|
("uix_ingredient_categories_kn", "ingredient_categories(kitchen_id, name)"),
|
|
("uix_ingredients_kn", "ingredients(kitchen_id, name)"),
|
|
("uix_ingredient_sources_kisc", "ingredient_sources(kitchen_id, ingredient_id, supplier_id, product_code)"),
|
|
("uix_ingredient_flags_if", "ingredient_flags(ingredient_id, food_flag_id)"),
|
|
("uix_food_flag_categories_kn", "food_flag_categories(kitchen_id, name)"),
|
|
("uix_food_flags_kn", "food_flags(kitchen_id, name)"),
|
|
("uix_line_item_flags_lf", "line_item_flags(line_item_id, food_flag_id)"),
|
|
("uix_menu_sections_kn", "menu_sections(kitchen_id, name)"),
|
|
("uix_recipes_kn", "recipes(kitchen_id, name)"),
|
|
("uix_recipe_flags_rf", "recipe_flags(recipe_id, food_flag_id)"),
|
|
("uix_recipe_cost_snapshots_rd", "recipe_cost_snapshots(recipe_id, snapshot_date)"),
|
|
]:
|
|
await conn.execute(text(
|
|
f"CREATE UNIQUE INDEX IF NOT EXISTS {idx_name} ON {idx_def}"
|
|
))
|
|
print("+ Ensured all unique constraints/indexes exist")
|
|
except Exception as e:
|
|
print(f"! Warning adding unique indexes: {e}")
|
|
|
|
async with engine.begin() as conn:
|
|
# ── ingredient_categories ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS ingredient_categories (
|
|
id SERIAL PRIMARY KEY,
|
|
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
|
|
name VARCHAR(100) NOT NULL,
|
|
sort_order INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(kitchen_id, name)
|
|
)
|
|
"""))
|
|
print("+ Created ingredient_categories table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- ingredient_categories table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── ingredients ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS ingredients (
|
|
id SERIAL PRIMARY KEY,
|
|
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
|
|
name VARCHAR(255) NOT NULL,
|
|
category_id INTEGER REFERENCES ingredient_categories(id) ON DELETE SET NULL,
|
|
standard_unit VARCHAR(20) NOT NULL,
|
|
yield_percent NUMERIC(5,2) DEFAULT 100.00,
|
|
manual_price NUMERIC(12,6),
|
|
notes TEXT,
|
|
is_archived BOOLEAN DEFAULT false,
|
|
created_by INTEGER REFERENCES users(id),
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(kitchen_id, name)
|
|
)
|
|
"""))
|
|
print("+ Created ingredients table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- ingredients table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── ingredient_sources ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS ingredient_sources (
|
|
id SERIAL PRIMARY KEY,
|
|
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
|
|
ingredient_id INTEGER NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE,
|
|
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
|
product_code VARCHAR(100),
|
|
description_pattern VARCHAR(255),
|
|
pack_quantity INTEGER,
|
|
unit_size NUMERIC(10,3),
|
|
unit_size_type VARCHAR(10),
|
|
latest_unit_price NUMERIC(10,2),
|
|
latest_invoice_id INTEGER REFERENCES invoices(id) ON DELETE SET NULL,
|
|
latest_invoice_date DATE,
|
|
price_per_std_unit NUMERIC(12,6),
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(kitchen_id, ingredient_id, supplier_id, product_code)
|
|
)
|
|
"""))
|
|
print("+ Created ingredient_sources table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- ingredient_sources table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# Partial unique index for no-SKU items (product_code IS NULL)
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uix_ingredient_source_desc
|
|
ON ingredient_sources(kitchen_id, ingredient_id, supplier_id, description_pattern)
|
|
WHERE product_code IS NULL
|
|
"""))
|
|
print("+ Created partial unique index uix_ingredient_source_desc")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- Index uix_ingredient_source_desc already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── food_flag_categories ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS food_flag_categories (
|
|
id SERIAL PRIMARY KEY,
|
|
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
|
|
name VARCHAR(100) NOT NULL,
|
|
propagation_type VARCHAR(20) NOT NULL,
|
|
sort_order INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(kitchen_id, name)
|
|
)
|
|
"""))
|
|
print("+ Created food_flag_categories table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- food_flag_categories table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── food_flags ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS food_flags (
|
|
id SERIAL PRIMARY KEY,
|
|
category_id INTEGER NOT NULL REFERENCES food_flag_categories(id) ON DELETE CASCADE,
|
|
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
|
|
name VARCHAR(100) NOT NULL,
|
|
code VARCHAR(10),
|
|
icon VARCHAR(10),
|
|
sort_order INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(kitchen_id, name)
|
|
)
|
|
"""))
|
|
print("+ Created food_flags table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- food_flags table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── ingredient_flags (canonical flag source) ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS ingredient_flags (
|
|
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,
|
|
flagged_by INTEGER REFERENCES users(id),
|
|
source VARCHAR(20) DEFAULT 'manual',
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(ingredient_id, food_flag_id)
|
|
)
|
|
"""))
|
|
print("+ Created ingredient_flags table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- ingredient_flags table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── line_item_flags (data entry mechanism) ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS line_item_flags (
|
|
id SERIAL PRIMARY KEY,
|
|
line_item_id INTEGER NOT NULL REFERENCES line_items(id) ON DELETE CASCADE,
|
|
food_flag_id INTEGER NOT NULL REFERENCES food_flags(id) ON DELETE CASCADE,
|
|
flagged_by INTEGER REFERENCES users(id),
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(line_item_id, food_flag_id)
|
|
)
|
|
"""))
|
|
print("+ Created line_item_flags table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- line_item_flags table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── menu_sections ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS menu_sections (
|
|
id SERIAL PRIMARY KEY,
|
|
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
|
|
name VARCHAR(100) NOT NULL,
|
|
sort_order INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(kitchen_id, name)
|
|
)
|
|
"""))
|
|
print("+ Created menu_sections table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- menu_sections table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── recipes ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS recipes (
|
|
id SERIAL PRIMARY KEY,
|
|
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
|
|
name VARCHAR(255) NOT NULL,
|
|
recipe_type VARCHAR(20) NOT NULL,
|
|
menu_section_id INTEGER REFERENCES menu_sections(id) ON DELETE SET NULL,
|
|
description TEXT,
|
|
batch_portions INTEGER NOT NULL DEFAULT 1,
|
|
prep_time_minutes INTEGER,
|
|
cook_time_minutes INTEGER,
|
|
notes TEXT,
|
|
is_archived BOOLEAN DEFAULT false,
|
|
kds_menu_item_name VARCHAR(255),
|
|
created_by INTEGER REFERENCES users(id),
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(kitchen_id, name)
|
|
)
|
|
"""))
|
|
print("+ Created recipes table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- recipes table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── recipe_ingredients ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS recipe_ingredients (
|
|
id SERIAL PRIMARY KEY,
|
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
ingredient_id INTEGER NOT NULL REFERENCES ingredients(id) ON DELETE RESTRICT,
|
|
quantity NUMERIC(10,3) NOT NULL,
|
|
notes TEXT,
|
|
sort_order INTEGER DEFAULT 0
|
|
)
|
|
"""))
|
|
print("+ Created recipe_ingredients table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- recipe_ingredients table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── recipe_sub_recipes ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS recipe_sub_recipes (
|
|
id SERIAL PRIMARY KEY,
|
|
parent_recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
child_recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE RESTRICT,
|
|
portions_needed NUMERIC(10,3) NOT NULL,
|
|
notes TEXT,
|
|
sort_order INTEGER DEFAULT 0,
|
|
CHECK(parent_recipe_id != child_recipe_id)
|
|
)
|
|
"""))
|
|
print("+ Created recipe_sub_recipes table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- recipe_sub_recipes table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── recipe_steps ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS recipe_steps (
|
|
id SERIAL PRIMARY KEY,
|
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
step_number INTEGER NOT NULL,
|
|
instruction TEXT NOT NULL,
|
|
image_path VARCHAR(500),
|
|
duration_minutes INTEGER,
|
|
notes TEXT
|
|
)
|
|
"""))
|
|
print("+ Created recipe_steps table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- recipe_steps table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── recipe_images ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS recipe_images (
|
|
id SERIAL PRIMARY KEY,
|
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
image_path VARCHAR(500) NOT NULL,
|
|
caption TEXT,
|
|
image_type VARCHAR(20) DEFAULT 'general',
|
|
sort_order INTEGER DEFAULT 0,
|
|
uploaded_by INTEGER REFERENCES users(id),
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
"""))
|
|
print("+ Created recipe_images table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- recipe_images table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── recipe_flags (manual additions + override state) ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS recipe_flags (
|
|
id SERIAL PRIMARY KEY,
|
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
food_flag_id INTEGER NOT NULL REFERENCES food_flags(id) ON DELETE CASCADE,
|
|
source_type VARCHAR(20) NOT NULL,
|
|
is_active BOOLEAN DEFAULT true,
|
|
excludable_on_request BOOLEAN DEFAULT false,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(recipe_id, food_flag_id)
|
|
)
|
|
"""))
|
|
print("+ Created recipe_flags table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- recipe_flags table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── recipe_flag_overrides (audit log) ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS recipe_flag_overrides (
|
|
id SERIAL PRIMARY KEY,
|
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
food_flag_id INTEGER NOT NULL REFERENCES food_flags(id) ON DELETE CASCADE,
|
|
action VARCHAR(20) NOT NULL,
|
|
note TEXT NOT NULL,
|
|
user_id INTEGER REFERENCES users(id),
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
"""))
|
|
print("+ Created recipe_flag_overrides table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- recipe_flag_overrides table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── recipe_change_log ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS recipe_change_log (
|
|
id SERIAL PRIMARY KEY,
|
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
change_summary TEXT NOT NULL,
|
|
user_id INTEGER REFERENCES users(id),
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
"""))
|
|
print("+ Created recipe_change_log table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- recipe_change_log table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── recipe_cost_snapshots ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS recipe_cost_snapshots (
|
|
id SERIAL PRIMARY KEY,
|
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
cost_per_portion NUMERIC(12,6) NOT NULL,
|
|
total_cost NUMERIC(12,6) NOT NULL,
|
|
snapshot_date DATE NOT NULL,
|
|
trigger_source VARCHAR(100),
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(recipe_id, snapshot_date)
|
|
)
|
|
"""))
|
|
print("+ Created recipe_cost_snapshots table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- recipe_cost_snapshots table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── event_orders ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS event_orders (
|
|
id SERIAL PRIMARY KEY,
|
|
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
|
|
name VARCHAR(255) NOT NULL,
|
|
event_date DATE,
|
|
notes TEXT,
|
|
status VARCHAR(20) DEFAULT 'DRAFT',
|
|
created_by INTEGER REFERENCES users(id),
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
)
|
|
"""))
|
|
print("+ Created event_orders table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- event_orders table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# ── event_order_items ──
|
|
try:
|
|
await conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS event_order_items (
|
|
id SERIAL PRIMARY KEY,
|
|
event_order_id INTEGER NOT NULL REFERENCES event_orders(id) ON DELETE CASCADE,
|
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE RESTRICT,
|
|
quantity INTEGER NOT NULL,
|
|
notes TEXT,
|
|
sort_order INTEGER DEFAULT 0
|
|
)
|
|
"""))
|
|
print("+ Created event_order_items table")
|
|
except Exception as e:
|
|
if "already exists" in str(e).lower():
|
|
print("- event_order_items table already exists, skipping")
|
|
else:
|
|
raise
|
|
|
|
# (ALTER TABLE for existing tables already ran in separate transaction above)
|
|
|
|
# ── Indexes ──
|
|
for idx_name, idx_def in [
|
|
("idx_ingredients_kitchen", "ingredients(kitchen_id)"),
|
|
("idx_ingredients_category", "ingredients(category_id)"),
|
|
("idx_ingredient_sources_ingredient", "ingredient_sources(ingredient_id)"),
|
|
("idx_ingredient_sources_supplier", "ingredient_sources(supplier_id)"),
|
|
("idx_ingredient_flags_ingredient", "ingredient_flags(ingredient_id)"),
|
|
("idx_ingredient_flags_flag", "ingredient_flags(food_flag_id)"),
|
|
("idx_food_flags_category", "food_flags(category_id)"),
|
|
("idx_line_item_flags_line_item", "line_item_flags(line_item_id)"),
|
|
("idx_recipes_kitchen", "recipes(kitchen_id)"),
|
|
("idx_recipes_type", "recipes(kitchen_id, recipe_type)"),
|
|
("idx_recipes_section", "recipes(menu_section_id)"),
|
|
("idx_recipe_ingredients_recipe", "recipe_ingredients(recipe_id)"),
|
|
("idx_recipe_sub_recipes_parent", "recipe_sub_recipes(parent_recipe_id)"),
|
|
("idx_recipe_sub_recipes_child", "recipe_sub_recipes(child_recipe_id)"),
|
|
("idx_recipe_steps_recipe", "recipe_steps(recipe_id)"),
|
|
("idx_recipe_images_recipe", "recipe_images(recipe_id)"),
|
|
("idx_recipe_flags_recipe", "recipe_flags(recipe_id)"),
|
|
("idx_recipe_cost_snapshots_recipe", "recipe_cost_snapshots(recipe_id)"),
|
|
("idx_event_orders_kitchen", "event_orders(kitchen_id)"),
|
|
("idx_event_order_items_order", "event_order_items(event_order_id)"),
|
|
]:
|
|
try:
|
|
await conn.execute(text(
|
|
f"CREATE INDEX IF NOT EXISTS {idx_name} ON {idx_def}"
|
|
))
|
|
except Exception:
|
|
pass # Skip silently — indexes are non-critical if they exist
|
|
print("+ Created indexes")
|
|
|
|
# ══ Seeding in separate transaction (so failures don't rollback table creation) ══
|
|
async with engine.begin() as conn:
|
|
# ── Pre-seed ingredient categories for all existing kitchens ──
|
|
try:
|
|
result = await conn.execute(text("SELECT id FROM kitchens"))
|
|
kitchen_ids = [row[0] for row in result.fetchall()]
|
|
|
|
categories = [
|
|
"Dairy", "Meat", "Seafood", "Produce", "Dry Goods",
|
|
"Oils & Fats", "Herbs & Spices", "Bakery", "Beverages",
|
|
"Condiments", "Other"
|
|
]
|
|
|
|
for kid in kitchen_ids:
|
|
for i, cat in enumerate(categories):
|
|
await conn.execute(text("""
|
|
INSERT INTO ingredient_categories (kitchen_id, name, sort_order, created_at)
|
|
VALUES (:kid, :name, :sort, NOW())
|
|
ON CONFLICT (kitchen_id, name) DO NOTHING
|
|
"""), {"kid": kid, "name": cat, "sort": i})
|
|
|
|
print(f"+ Pre-seeded ingredient categories for {len(kitchen_ids)} kitchen(s)")
|
|
except Exception as e:
|
|
print(f"! Warning seeding ingredient categories: {e}")
|
|
|
|
# ── Pre-seed food flag categories and flags ──
|
|
try:
|
|
allergy_flags = [
|
|
("Celery", "Ce", None),
|
|
("Gluten", "Gl", None),
|
|
("Crustaceans", "Cr", None),
|
|
("Eggs", "Eg", None),
|
|
("Fish", "Fi", None),
|
|
("Lupin", "Lu", None),
|
|
("Milk", "Mi", None),
|
|
("Molluscs", "Mo", None),
|
|
("Mustard", "Mu", None),
|
|
("Tree Nuts", "TN", None),
|
|
("Peanuts", "Pn", None),
|
|
("Sesame", "Se", None),
|
|
("Soya", "So", None),
|
|
("Sulphites", "Su", None),
|
|
]
|
|
|
|
dietary_flags = [
|
|
("Vegetarian", "V", None),
|
|
("Vegan", "Ve", None),
|
|
("Pescatarian", "Pe", None),
|
|
("Gluten-Free", "GF", None),
|
|
]
|
|
|
|
for kid in kitchen_ids:
|
|
# Create Allergy category (only seed flags if category is new)
|
|
result = await conn.execute(text("""
|
|
INSERT INTO food_flag_categories (kitchen_id, name, propagation_type, sort_order, created_at)
|
|
VALUES (:kid, 'Allergy', 'contains', 0, NOW())
|
|
ON CONFLICT (kitchen_id, name) DO NOTHING
|
|
RETURNING id
|
|
"""), {"kid": kid})
|
|
row = result.fetchone()
|
|
allergy_is_new = row is not None
|
|
if row:
|
|
allergy_cat_id = row[0]
|
|
else:
|
|
r = await conn.execute(text(
|
|
"SELECT id FROM food_flag_categories WHERE kitchen_id = :kid AND name = 'Allergy'"
|
|
), {"kid": kid})
|
|
allergy_cat_id = r.fetchone()[0]
|
|
|
|
# Create Dietary category (only seed flags if category is new)
|
|
result = await conn.execute(text("""
|
|
INSERT INTO food_flag_categories (kitchen_id, name, propagation_type, sort_order, created_at)
|
|
VALUES (:kid, 'Dietary', 'suitable_for', 1, NOW())
|
|
ON CONFLICT (kitchen_id, name) DO NOTHING
|
|
RETURNING id
|
|
"""), {"kid": kid})
|
|
row = result.fetchone()
|
|
dietary_is_new = row is not None
|
|
if row:
|
|
dietary_cat_id = row[0]
|
|
else:
|
|
r = await conn.execute(text(
|
|
"SELECT id FROM food_flag_categories WHERE kitchen_id = :kid AND name = 'Dietary'"
|
|
), {"kid": kid})
|
|
dietary_cat_id = r.fetchone()[0]
|
|
|
|
# Only seed flags for newly created categories (don't re-insert user-deleted flags)
|
|
if allergy_is_new:
|
|
for i, (name, code, icon) in enumerate(allergy_flags):
|
|
await conn.execute(text("""
|
|
INSERT INTO food_flags (category_id, kitchen_id, name, code, icon, sort_order, created_at)
|
|
VALUES (:cat_id, :kid, :name, :code, :icon, :sort, NOW())
|
|
ON CONFLICT (kitchen_id, name) DO NOTHING
|
|
"""), {"cat_id": allergy_cat_id, "kid": kid, "name": name, "code": code, "icon": icon, "sort": i})
|
|
|
|
if dietary_is_new:
|
|
for i, (name, code, icon) in enumerate(dietary_flags):
|
|
await conn.execute(text("""
|
|
INSERT INTO food_flags (category_id, kitchen_id, name, code, icon, sort_order, created_at)
|
|
VALUES (:cat_id, :kid, :name, :code, :icon, :sort, NOW())
|
|
ON CONFLICT (kitchen_id, name) DO NOTHING
|
|
"""), {"cat_id": dietary_cat_id, "kid": kid, "name": name, "code": code, "icon": icon, "sort": i})
|
|
|
|
print(f"+ Pre-seeded food flag categories and flags for {len(kitchen_ids)} kitchen(s)")
|
|
except Exception as e:
|
|
print(f"! Warning seeding food flags: {e}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("Running migration: add_recipe_system")
|
|
asyncio.run(migrate())
|
|
print("Migration complete!")
|