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

194
backend/models/recipe.py Normal file
View file

@ -0,0 +1,194 @@
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, List
from sqlalchemy import String, DateTime, ForeignKey, Numeric, Integer, Text, Boolean, Date, CheckConstraint, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class MenuSection(Base):
"""Groupings for recipes and dishes (section_type: 'recipe' | 'dish')"""
__tablename__ = "menu_sections"
__table_args__ = (UniqueConstraint("kitchen_id", "name", "section_type", name="uq_menu_sections_kitchen_name_type"),)
id: Mapped[int] = mapped_column(primary_key=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
section_type: Mapped[str] = mapped_column(String(20), nullable=False, default="recipe") # "recipe" | "dish"
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
recipes: Mapped[List["Recipe"]] = relationship("Recipe", back_populates="menu_section")
class Recipe(Base):
"""Component recipes and dishes"""
__tablename__ = "recipes"
__table_args__ = (UniqueConstraint("kitchen_id", "name", name="uq_recipes_kitchen_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
recipe_type: Mapped[str] = mapped_column(String(20), nullable=False) # "component" | "dish"
menu_section_id: Mapped[Optional[int]] = mapped_column(ForeignKey("menu_sections.id", ondelete="SET NULL"), nullable=True, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
batch_portions: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
batch_output_type: Mapped[str] = mapped_column(String(20), nullable=False, default="portions") # "portions" | "bulk"
batch_yield_qty: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 3), nullable=True)
batch_yield_unit: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) # g, kg, ml, ltr
prep_time_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
cook_time_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_archived: Mapped[bool] = mapped_column(Boolean, default=False)
gross_sell_price: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 2), nullable=True)
kds_menu_item_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Phase 7: KDS link
sambapos_portion_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # SambaPOS portion selector (e.g. "Beef", "BLT")
created_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
menu_section: Mapped[Optional["MenuSection"]] = relationship("MenuSection", back_populates="recipes")
created_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by])
ingredients: Mapped[List["RecipeIngredient"]] = relationship("RecipeIngredient", back_populates="recipe", cascade="all, delete-orphan")
sub_recipes: Mapped[List["RecipeSubRecipe"]] = relationship("RecipeSubRecipe", back_populates="parent_recipe", foreign_keys="RecipeSubRecipe.parent_recipe_id", cascade="all, delete-orphan")
used_in_recipes: Mapped[List["RecipeSubRecipe"]] = relationship("RecipeSubRecipe", foreign_keys="RecipeSubRecipe.child_recipe_id")
steps: Mapped[List["RecipeStep"]] = relationship("RecipeStep", back_populates="recipe", cascade="all, delete-orphan")
images: Mapped[List["RecipeImage"]] = relationship("RecipeImage", back_populates="recipe", cascade="all, delete-orphan")
flags: Mapped[List["RecipeFlag"]] = relationship("RecipeFlag", back_populates="recipe", cascade="all, delete-orphan")
change_log: Mapped[List["RecipeChangeLog"]] = relationship("RecipeChangeLog", back_populates="recipe", cascade="all, delete-orphan")
cost_snapshots: Mapped[List["RecipeCostSnapshot"]] = relationship("RecipeCostSnapshot", back_populates="recipe", cascade="all, delete-orphan")
class RecipeIngredient(Base):
"""Ingredients used in a recipe"""
__tablename__ = "recipe_ingredients"
id: Mapped[int] = mapped_column(primary_key=True)
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True)
ingredient_id: Mapped[int] = mapped_column(ForeignKey("ingredients.id", ondelete="RESTRICT"), nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), nullable=False)
unit: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) # override display unit (e.g. kg when std is g)
yield_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("100.00"))
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
# Relationships
recipe: Mapped["Recipe"] = relationship("Recipe", back_populates="ingredients")
ingredient: Mapped["Ingredient"] = relationship("Ingredient")
class RecipeSubRecipe(Base):
"""Sub-recipes used in a recipe (max 5 levels deep)"""
__tablename__ = "recipe_sub_recipes"
id: Mapped[int] = mapped_column(primary_key=True)
parent_recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True)
child_recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id", ondelete="RESTRICT"), nullable=False, index=True)
portions_needed: Mapped[Decimal] = mapped_column(Numeric(10, 3), nullable=False)
portions_needed_unit: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) # override unit (e.g. ml when child yields ltr)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
__table_args__ = (
CheckConstraint("parent_recipe_id != child_recipe_id", name="chk_no_self_reference"),
)
# Relationships
parent_recipe: Mapped["Recipe"] = relationship("Recipe", foreign_keys=[parent_recipe_id], back_populates="sub_recipes")
child_recipe: Mapped["Recipe"] = relationship("Recipe", foreign_keys=[child_recipe_id], overlaps="used_in_recipes")
class RecipeStep(Base):
"""Cooking instructions"""
__tablename__ = "recipe_steps"
id: Mapped[int] = mapped_column(primary_key=True)
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True)
step_number: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
instruction: Mapped[str] = mapped_column(Text, nullable=False)
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
duration_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Relationships
recipe: Mapped["Recipe"] = relationship("Recipe", back_populates="steps")
class RecipeImage(Base):
"""General recipe/plating photos"""
__tablename__ = "recipe_images"
id: Mapped[int] = mapped_column(primary_key=True)
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True)
image_path: Mapped[str] = mapped_column(String(500), nullable=False)
caption: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
image_type: Mapped[str] = mapped_column(String(20), default="general") # "general" | "plating" | "method"
sort_order: Mapped[int] = mapped_column(Integer, default=0)
uploaded_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
recipe: Mapped["Recipe"] = relationship("Recipe", back_populates="images")
uploaded_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[uploaded_by])
class RecipeChangeLog(Base):
"""Recipe change history"""
__tablename__ = "recipe_change_log"
id: Mapped[int] = mapped_column(primary_key=True)
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False)
change_summary: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
source_invoice_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("invoices.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
recipe: Mapped["Recipe"] = relationship("Recipe", back_populates="change_log")
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
class RecipeCostSnapshot(Base):
"""Cost trending over time (upsert: one snapshot per recipe per day)"""
__tablename__ = "recipe_cost_snapshots"
__table_args__ = (UniqueConstraint("recipe_id", "snapshot_date", name="uq_recipe_cost_snapshots_recipe_date"),)
id: Mapped[int] = mapped_column(primary_key=True)
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True)
cost_per_portion: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False)
total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False)
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False)
trigger_source: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
recipe: Mapped["Recipe"] = relationship("Recipe", back_populates="cost_snapshots")
class RecipeTextFlagDismissal(Base):
"""Tracks dismissed allergen keyword suggestions from recipe-level text fields"""
__tablename__ = "recipe_text_flag_dismissals"
__table_args__ = (UniqueConstraint("recipe_id", "food_flag_id", name="uq_recipe_text_flag_dismissals_recipe_flag"),)
id: Mapped[int] = mapped_column(primary_key=True)
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True)
food_flag_id: Mapped[int] = mapped_column(ForeignKey("food_flags.id", ondelete="CASCADE"), nullable=False, index=True)
dismissed_by_name: Mapped[str] = mapped_column(String(100), nullable=False)
reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
matched_keyword: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
recipe: Mapped["Recipe"] = relationship("Recipe")
food_flag: Mapped["FoodFlag"] = relationship("FoodFlag")
# Forward references
from .user import Kitchen, User
from .ingredient import Ingredient
from .food_flag import RecipeFlag, FoodFlag