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,130 @@
"""
Brakes (brake.co.uk) product data scraper.
Fetches ingredients list and allergen "Contains" statement from product pages.
URL pattern: https://www.brake.co.uk/p/{product_code}
"""
import re
import logging
import httpx
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class BrakesProduct:
product_name: str = ""
ingredients_text: str = "" # full ingredients list (HTML tags stripped)
contains_allergens: list[str] = field(default_factory=list) # ["Egg", "Milk"]
raw_contains: str = "" # "Egg and Milk" — original text from Contains field
suitable_for: list[str] = field(default_factory=list) # ["Vegetarian", "Vegan"]
def _strip_html_tags(html: str) -> str:
"""Remove HTML tags, collapse whitespace."""
text = re.sub(r"<[^>]+>", "", html)
text = re.sub(r"\s+", " ", text).strip()
return text
def _bold_to_uppercase(html: str) -> str:
"""Convert <strong>text</strong> to UPPERCASE, then strip remaining tags."""
text = re.sub(
r"<strong>(.*?)</strong>",
lambda m: m.group(1).upper(),
html, flags=re.DOTALL | re.IGNORECASE
)
text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def _parse_contains(raw: str) -> list[str]:
"""Parse 'Egg, Milk and Gluten' into ['Egg', 'Milk', 'Gluten']."""
if not raw:
return []
# "None of the 14 Food Allergens" means no allergens
if "none" in raw.lower():
return []
# Split on commas first
parts = [p.strip() for p in raw.split(",")]
# The last part may contain " and " — split that too
expanded = []
for part in parts:
if " and " in part:
expanded.extend(p.strip() for p in part.split(" and ") if p.strip())
else:
if part:
expanded.append(part)
# Title-case each allergen for consistent matching
return [a.strip().title() for a in expanded if a.strip()]
def parse_brakes_html(html: str) -> BrakesProduct:
"""Extract product name, ingredients, and Contains statement from Brakes product page HTML."""
product = BrakesProduct()
# Product name — typically in <h1> or page title
title_match = re.search(r"<h1[^>]*>(.*?)</h1>", html, re.DOTALL | re.IGNORECASE)
if title_match:
product.product_name = _strip_html_tags(title_match.group(1))
# Ingredients — Brakes uses: <p>Ingredients: <p>...actual ingredients...</p></p>
# or sometimes <p>Ingredients: ...text...</p>
ing_match = re.search(
r"<p>\s*Ingredients\s*:\s*(.*?)</p>\s*</p>",
html, re.DOTALL | re.IGNORECASE
)
if not ing_match:
# Fallback: single <p> without nested <p>
ing_match = re.search(
r"<p>\s*Ingredients\s*:\s*(.*?)</p>",
html, re.DOTALL | re.IGNORECASE
)
if ing_match:
product.ingredients_text = _bold_to_uppercase(ing_match.group(1))
# Contains — Brakes uses: <p>Contains : Egg and Milk</p> (note space before colon)
contains_match = re.search(
r"<p>\s*Contains\s*:\s*(.*?)</p>",
html, re.DOTALL | re.IGNORECASE
)
if contains_match:
raw = _strip_html_tags(contains_match.group(1))
product.raw_contains = raw
product.contains_allergens = _parse_contains(raw)
# Dietary suitability — plain text "Suitable for Vegetarians" / "Suitable for Vegans"
page_text = _strip_html_tags(html)
if re.search(r"Suitable\s+for\s+Vegetarians", page_text, re.IGNORECASE):
product.suitable_for.append("Vegetarian")
if re.search(r"Suitable\s+for\s+Vegans", page_text, re.IGNORECASE):
product.suitable_for.append("Vegan")
return product
async def fetch_brakes_product(product_code: str) -> BrakesProduct | None:
"""Fetch product data from brake.co.uk/p/{code}. Returns None on 404/error."""
# Strip OCR artefacts like $ prefix
clean_code = product_code.lstrip("$").strip()
if not clean_code:
return None
url = f"https://www.brake.co.uk/p/{clean_code}"
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.get(url, headers={
"User-Agent": "KitchenApp/1.0 (ingredient-lookup)",
"Accept": "text/html",
})
if response.status_code != 200:
logger.info(f"Brakes lookup {clean_code}: HTTP {response.status_code}")
return None
return parse_brakes_html(response.text)
except httpx.TimeoutException:
logger.warning(f"Brakes lookup {clean_code}: timeout")
return None
except Exception as e:
logger.warning(f"Brakes lookup {clean_code}: {e}")
return None