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,44 @@
from .user import User, Kitchen
from .invoice import Invoice
from .supplier import Supplier
from .gp import RevenueEntry, GPPeriod
from .settings import KitchenSettings
from .line_item import LineItem
from .field_mapping import FieldMapping
from .product_definition import ProductDefinition
from .newbook import NewbookGLAccount, NewbookDailyRevenue, NewbookDailyOccupancy, NewbookSyncLog
from .resos import ResosBooking, ResosDailyStats, ResosOpeningHour, ResosSyncLog
from .backup import BackupHistory
from .acknowledged_price import AcknowledgedPrice
from .dispute import (
InvoiceDispute, DisputeLineItem, DisputeAttachment, DisputeActivity, CreditNote,
DisputeType, DisputeStatus, DisputePriority
)
from .purchase_order import PurchaseOrder, PurchaseOrderLineItem
from .ingredient import Ingredient, IngredientCategory, IngredientSource, IngredientFlag
from .food_flag import FoodFlagCategory, FoodFlag, LineItemFlag, RecipeFlag, RecipeFlagOverride
from .recipe import (
Recipe, MenuSection, RecipeIngredient, RecipeSubRecipe,
RecipeStep, RecipeImage, RecipeChangeLog, RecipeCostSnapshot,
RecipeTextFlagDismissal,
)
from .menu import Menu, MenuDivision, MenuItem
from .event_order import EventOrder, EventOrderItem
__all__ = [
"User", "Kitchen", "Invoice", "Supplier", "RevenueEntry", "GPPeriod",
"KitchenSettings", "LineItem", "FieldMapping", "ProductDefinition",
"NewbookGLAccount", "NewbookDailyRevenue", "NewbookDailyOccupancy", "NewbookSyncLog",
"ResosBooking", "ResosDailyStats", "ResosOpeningHour", "ResosSyncLog",
"BackupHistory", "AcknowledgedPrice",
"InvoiceDispute", "DisputeLineItem", "DisputeAttachment", "DisputeActivity", "CreditNote",
"DisputeType", "DisputeStatus", "DisputePriority",
"PurchaseOrder", "PurchaseOrderLineItem",
"Ingredient", "IngredientCategory", "IngredientSource", "IngredientFlag",
"FoodFlagCategory", "FoodFlag", "LineItemFlag", "RecipeFlag", "RecipeFlagOverride",
"Recipe", "MenuSection", "RecipeIngredient", "RecipeSubRecipe",
"RecipeStep", "RecipeImage", "RecipeChangeLog", "RecipeCostSnapshot",
"RecipeTextFlagDismissal",
"Menu", "MenuDivision", "MenuItem",
"EventOrder", "EventOrderItem",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,50 @@
"""
AcknowledgedPrice model for tracking user-acknowledged price changes.
When a price change is detected and flagged, users can acknowledge it
to prevent it from being repeatedly flagged.
"""
from datetime import datetime
from decimal import Decimal
from sqlalchemy import ForeignKey, String, Text, Numeric, DateTime, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class AcknowledgedPrice(Base):
"""Tracks acknowledged price changes for line items."""
__tablename__ = "acknowledged_prices"
id: Mapped[int] = mapped_column(primary_key=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"))
supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"))
# Product identification (same logic as line item consolidation)
product_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# The acknowledged price point
acknowledged_price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
acknowledged_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
acknowledged_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
# Which invoice triggered this acknowledgement
source_invoice_id: Mapped[int | None] = mapped_column(ForeignKey("invoices.id"), nullable=True)
source_line_item_id: Mapped[int | None] = mapped_column(ForeignKey("line_items.id"), nullable=True)
# Relationships
kitchen = relationship("Kitchen", backref="acknowledged_prices")
supplier = relationship("Supplier", backref="acknowledged_prices")
acknowledged_by_user = relationship("User", backref="acknowledged_prices")
source_invoice = relationship("Invoice", backref="acknowledged_prices")
# Unique constraint: one acknowledged price per product per supplier per kitchen
__table_args__ = (
UniqueConstraint(
'kitchen_id', 'supplier_id', 'product_code', 'description',
name='uix_acknowledged_price'
),
)
def __repr__(self):
return f"<AcknowledgedPrice {self.id}: {self.product_code or self.description} @ {self.acknowledged_price}>"

45
backend/models/backup.py Normal file
View file

@ -0,0 +1,45 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey, Text, Integer, BigInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class BackupHistory(Base):
"""Track backup history for each kitchen"""
__tablename__ = "backup_history"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
# Backup metadata
backup_type: Mapped[str] = mapped_column(String(20)) # "manual", "scheduled"
destination: Mapped[str] = mapped_column(String(20)) # "local", "nextcloud", "smb"
status: Mapped[str] = mapped_column(String(20)) # "running", "success", "failed"
# File information
filename: Mapped[str] = mapped_column(String(255)) # e.g., "backup_kitchen1_20260115_120000.zip"
file_path: Mapped[str] = mapped_column(String(500)) # Full path to backup file
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
# Statistics
invoice_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
file_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Timing
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Error tracking
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# User who triggered (null for scheduled)
triggered_by_user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
triggered_by_user: Mapped[Optional["User"]] = relationship("User")
# Forward references
from .user import Kitchen, User

View file

@ -0,0 +1,26 @@
from datetime import datetime, date
from sqlalchemy import String, DateTime, ForeignKey, Text, Date, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class CalendarEvent(Base):
"""Calendar events, reminders, and notes"""
__tablename__ = "calendar_events"
id: Mapped[int] = mapped_column(primary_key=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"))
event_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
event_type: Mapped[str] = mapped_column(String(20), nullable=False) # reminder, event, note
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[int | None] = mapped_column(Integer, 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)
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
creator: Mapped["User"] = relationship("User")
# Forward references
from .user import Kitchen, User

View file

@ -0,0 +1,138 @@
"""
Cost Distribution models for spreading/offsetting invoice costs to future dates.
"""
from datetime import datetime, date
from decimal import Decimal
from typing import Optional
import enum
from sqlalchemy import String, DateTime, Date, ForeignKey, Numeric, Text, Integer, Index, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class DistributionStatus(str, enum.Enum):
ACTIVE = "ACTIVE"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
class DistributionMethod(str, enum.Enum):
OFFSET = "OFFSET" # Single date offset
DISTRIBUTE = "DISTRIBUTE" # Spread over DOW pattern
class CostDistribution(Base):
"""Header record for a cost distribution / cost spreading"""
__tablename__ = "cost_distributions"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoices.id"), nullable=False)
# Distribution metadata
status: Mapped[str] = mapped_column(String(20), nullable=False, default=DistributionStatus.ACTIVE.value)
method: Mapped[str] = mapped_column(String(20), nullable=False)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Financial totals
total_distributed_value: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
remaining_balance: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
# Source offset date (= invoice.invoice_date)
source_date: Mapped[date] = mapped_column(Date, nullable=False)
# Audit
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
cancelled_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
cancelled_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
invoice: Mapped["Invoice"] = relationship("Invoice", back_populates="cost_distributions")
created_by_user: Mapped["User"] = relationship("User", foreign_keys=[created_by])
cancelled_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[cancelled_by])
line_selections: Mapped[list["CostDistributionLineSelection"]] = relationship(
"CostDistributionLineSelection",
back_populates="distribution",
cascade="all, delete-orphan",
lazy="selectin",
)
entries: Mapped[list["CostDistributionEntry"]] = relationship(
"CostDistributionEntry",
back_populates="distribution",
cascade="all, delete-orphan",
order_by="CostDistributionEntry.entry_date",
lazy="selectin",
)
__table_args__ = (
Index("idx_cd_kitchen_status", "kitchen_id", "status"),
Index("idx_cd_kitchen_invoice", "kitchen_id", "invoice_id"),
Index("idx_cd_source_date", "kitchen_id", "source_date"),
)
class CostDistributionLineSelection(Base):
"""Records which invoice line items (and what qty) are included in a distribution"""
__tablename__ = "cost_distribution_line_selections"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
distribution_id: Mapped[int] = mapped_column(
ForeignKey("cost_distributions.id", ondelete="CASCADE"), nullable=False
)
line_item_id: Mapped[int] = mapped_column(
ForeignKey("line_items.id"), nullable=False
)
# How much of this line item is distributed
selected_quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), nullable=False)
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) # Snapshot at creation
distributed_value: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) # qty * unit_price
# Relationships
distribution: Mapped["CostDistribution"] = relationship(
"CostDistribution", back_populates="line_selections"
)
line_item: Mapped["LineItem"] = relationship("LineItem")
class CostDistributionEntry(Base):
"""Individual date entry in a cost distribution schedule"""
__tablename__ = "cost_distribution_entries"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
distribution_id: Mapped[int] = mapped_column(
ForeignKey("cost_distributions.id", ondelete="CASCADE"), nullable=False
)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
entry_date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
# Negative amount on source_date (offset from invoice), positive on target dates
is_source_offset: Mapped[bool] = mapped_column(Boolean, default=False)
# True for the source_date entry (negative), False for target entries (positive)
is_overpay: Mapped[bool] = mapped_column(Boolean, default=False)
# True if this entry was created via settle early
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
distribution: Mapped["CostDistribution"] = relationship(
"CostDistribution", back_populates="entries"
)
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
__table_args__ = (
Index("idx_cde_kitchen_date", "kitchen_id", "entry_date"),
Index("idx_cde_distribution", "distribution_id", "entry_date"),
)
# Forward references
from .user import Kitchen, User
from .invoice import Invoice
from .line_item import LineItem

View file

@ -0,0 +1,88 @@
"""
Models for cover override and forecast snapshot tables.
Tables:
- cover_overrides: Per-day per-period cover overrides (lunch/dinner)
- forecast_snapshots: Per-day per-period snapshot of forecast at snapshot time
- forecast_week_snapshots: Weekly revenue totals at snapshot time
- spend_rate_overrides: Per-week per-period spend rate overrides
"""
from sqlalchemy import Column, Integer, String, Numeric, Date, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.sql import func
from database import Base
class CoverOverride(Base):
__tablename__ = "cover_overrides"
id = Column(Integer, primary_key=True)
kitchen_id = Column(Integer, ForeignKey("kitchens.id"), nullable=False)
override_date = Column(Date, nullable=False)
period = Column(String(20), nullable=False) # 'lunch' or 'dinner'
override_covers = Column(Integer, nullable=False) # target total covers (0 = expect zero)
original_forecast = Column(Integer) # snapshot at creation time
original_otb = Column(Integer) # snapshot at creation time
created_by = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime, server_default=func.now())
updated_by = Column(Integer, ForeignKey("users.id"))
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("kitchen_id", "override_date", "period", name="uq_cover_override_date_period"),
)
class ForecastSnapshot(Base):
__tablename__ = "forecast_snapshots"
id = Column(Integer, primary_key=True)
kitchen_id = Column(Integer, ForeignKey("kitchens.id"), nullable=False)
snapshot_date = Column(Date, nullable=False) # the forecasted date
period = Column(String(20), nullable=False) # 'breakfast', 'lunch', or 'dinner'
forecast_covers = Column(Integer, nullable=False) # total forecast (otb+pickup)
otb_covers = Column(Integer, nullable=False) # OTB at snapshot time
food_spend = Column(Numeric(10, 2)) # food spend per cover (net ex VAT)
drinks_spend = Column(Numeric(10, 2)) # drinks spend per cover (net ex VAT)
forecast_dry_revenue = Column(Numeric(10, 2)) # forecast dry revenue for this day/period
week_start = Column(Date, nullable=False)
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
UniqueConstraint("kitchen_id", "snapshot_date", "period", name="uq_forecast_snapshot_date_period"),
)
class ForecastWeekSnapshot(Base):
__tablename__ = "forecast_week_snapshots"
id = Column(Integer, primary_key=True)
kitchen_id = Column(Integer, ForeignKey("kitchens.id"), nullable=False)
week_start = Column(Date, nullable=False)
total_forecast_revenue = Column(Numeric(12, 2)) # total dry revenue for the week
total_otb_revenue = Column(Numeric(12, 2)) # OTB-only revenue for the week
gp_target = Column(Numeric(5, 2)) # GP target % at snapshot time
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("kitchen_id", "week_start", name="uq_forecast_week_snapshot"),
)
class SpendRateOverride(Base):
__tablename__ = "spend_rate_overrides"
id = Column(Integer, primary_key=True)
kitchen_id = Column(Integer, ForeignKey("kitchens.id"), nullable=False)
week_start = Column(Date, nullable=False)
period = Column(String(20), nullable=False) # 'breakfast', 'lunch', or 'dinner'
food_spend = Column(Numeric(10, 2)) # overridden food spend per cover (NULL = use snapshot/API)
drinks_spend = Column(Numeric(10, 2)) # overridden drinks spend per cover (NULL = use snapshot/API)
created_by = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime, server_default=func.now())
updated_by = Column(Integer, ForeignKey("users.id"))
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("kitchen_id", "week_start", "period", name="uq_spend_rate_override"),
)

252
backend/models/dispute.py Normal file
View file

@ -0,0 +1,252 @@
"""
Dispute tracking models for invoice issues.
Handles:
- Invoice disputes (price discrepancies, short deliveries, quality issues, etc.)
- Dispute line items (specific products in dispute)
- Dispute attachments (photos, emails, delivery notes)
- Dispute activity log (audit trail)
- Credit notes (supplier credits for resolving disputes)
"""
from datetime import datetime, date
from decimal import Decimal
import enum
from typing import TYPE_CHECKING
from sqlalchemy import String, DateTime, Date, ForeignKey, Numeric, Text, Integer, Enum as SQLEnum
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
if TYPE_CHECKING:
from models.user import User, Kitchen
from models.invoice import Invoice
from models.line_item import LineItem
from models.supplier import Supplier
class DisputeType(str, enum.Enum):
"""Types of disputes that can be raised"""
PRICE_DISCREPANCY = "price_discrepancy"
SHORT_DELIVERY = "short_delivery"
WRONG_PRODUCT = "wrong_product"
QUALITY_ISSUE = "quality_issue"
CALCULATION_ERROR = "calculation_error"
MISSING_ITEMS = "missing_items"
DAMAGED_GOODS = "damaged_goods"
OTHER = "other"
class DisputeStatus(str, enum.Enum):
"""Dispute workflow statuses"""
NEW = "NEW" # Initial state (no action taken yet)
OPEN = "OPEN" # Legacy - migrated to NEW
CONTACTED = "CONTACTED" # Supplier has been contacted
IN_PROGRESS = "IN_PROGRESS" # Actively being worked on
AWAITING_CREDIT = "AWAITING_CREDIT" # Waiting for credit note
AWAITING_REPLACEMENT = "AWAITING_REPLACEMENT" # Waiting for replacement goods
RESOLVED = "RESOLVED" # Dispute resolved
CLOSED = "CLOSED" # Dispute closed
ESCALATED = "ESCALATED" # Escalated to manager/higher authority
class DisputePriority(str, enum.Enum):
"""Dispute priority levels"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class InvoiceDispute(Base):
"""Main dispute record for tracking invoice issues"""
__tablename__ = "invoice_disputes"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoices.id"), nullable=False, index=True)
# Dispute classification
dispute_type: Mapped[DisputeType] = mapped_column(SQLEnum(DisputeType), nullable=False, index=True)
status: Mapped[DisputeStatus] = mapped_column(SQLEnum(DisputeStatus), default=DisputeStatus.NEW, index=True)
priority: Mapped[DisputePriority] = mapped_column(SQLEnum(DisputePriority), default=DisputePriority.MEDIUM)
# Financial impact
disputed_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) # What was charged
expected_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) # What should be charged
difference_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) # Disputed - Expected
# Description
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
resolution_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Supplier communication
supplier_contacted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
supplier_response: Mapped[str | None] = mapped_column(Text, nullable=True)
supplier_contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Resolution
credit_note_id: Mapped[int | None] = mapped_column(ForeignKey("credit_notes.id"), nullable=True)
resolved_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) # Actual credit received
# Audit trail
opened_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
opened_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
resolved_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
closed_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Metadata
reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True) # Internal tracking number
tags: Mapped[list | None] = mapped_column(JSONB, nullable=True) # ["urgent", "recurring_issue", etc.]
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="invoice_disputes")
invoice: Mapped["Invoice"] = relationship("Invoice", back_populates="disputes", foreign_keys=[invoice_id])
opened_by_user: Mapped["User"] = relationship("User", foreign_keys=[opened_by])
resolved_by_user: Mapped["User"] = relationship("User", foreign_keys=[resolved_by])
closed_by_user: Mapped["User"] = relationship("User", foreign_keys=[closed_by])
credit_note: Mapped["CreditNote"] = relationship("CreditNote", back_populates="dispute", uselist=False)
line_items: Mapped[list["DisputeLineItem"]] = relationship(
"DisputeLineItem", back_populates="dispute", cascade="all, delete-orphan"
)
attachments: Mapped[list["DisputeAttachment"]] = relationship(
"DisputeAttachment", back_populates="dispute", cascade="all, delete-orphan"
)
activity_log: Mapped[list["DisputeActivity"]] = relationship(
"DisputeActivity", back_populates="dispute", cascade="all, delete-orphan"
)
class DisputeLineItem(Base):
"""Specific line items that are disputed"""
__tablename__ = "dispute_line_items"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("invoice_disputes.id"), nullable=False, index=True)
invoice_line_item_id: Mapped[int | None] = mapped_column(ForeignKey("line_items.id"), nullable=True)
# Product details
product_name: Mapped[str] = mapped_column(String(255), nullable=False)
product_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Quantity dispute
quantity_ordered: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True)
quantity_received: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True)
quantity_difference: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True)
# Price dispute
unit_price_quoted: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
unit_price_charged: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
price_difference: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
# Line total
total_charged: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
total_expected: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="line_items")
invoice_line_item: Mapped["LineItem"] = relationship("LineItem")
class DisputeAttachment(Base):
"""Supporting documents for disputes (photos, emails, delivery notes)"""
__tablename__ = "dispute_attachments"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("invoice_disputes.id"), nullable=False, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
# File details
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
file_type: Mapped[str] = mapped_column(String(50), nullable=False) # image/jpeg, application/pdf, message/rfc822
file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
# Categorization
attachment_type: Mapped[str] = mapped_column(String(50), nullable=False) # photo, email, delivery_note, credit_note, other
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Public sharing - hash for unauthenticated access (e.g., email to suppliers)
public_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, index=True)
# File storage (following Invoice model pattern)
file_storage_location: Mapped[str] = mapped_column(String(20), default="local") # "local" or "nextcloud"
nextcloud_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
uploaded_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
uploaded_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="attachments")
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
uploaded_by_user: Mapped["User"] = relationship("User")
class DisputeActivity(Base):
"""Activity log for dispute tracking (timeline of actions)"""
__tablename__ = "dispute_activity"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("invoice_disputes.id"), nullable=False, index=True)
# Activity details
activity_type: Mapped[str] = mapped_column(String(50), nullable=False) # created, status_change, contacted_supplier, note_added, etc.
description: Mapped[str] = mapped_column(Text, nullable=False)
# Old/new values for changes
old_value: Mapped[str | None] = mapped_column(String(255), nullable=True)
new_value: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
# Relationships
dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="activity_log")
created_by_user: Mapped["User"] = relationship("User")
class CreditNote(Base):
"""Credit notes issued by suppliers (resolves disputes)"""
__tablename__ = "credit_notes"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoices.id"), nullable=False, index=True)
supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), nullable=False)
# Credit note details
credit_note_number: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
credit_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
credit_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Document storage (following Invoice model pattern)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
file_storage_location: Mapped[str] = mapped_column(String(20), default="local") # "local" or "nextcloud"
nextcloud_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
original_local_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Audit
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
invoice: Mapped["Invoice"] = relationship("Invoice", back_populates="credit_notes")
supplier: Mapped["Supplier"] = relationship("Supplier")
created_by_user: Mapped["User"] = relationship("User")
dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="credit_note", uselist=False)

View file

@ -0,0 +1,50 @@
"""
Email processing log model for tracking IMAP email inbox sync.
"""
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Integer, Boolean, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class EmailProcessingLog(Base):
"""Track processed emails to prevent re-processing"""
__tablename__ = "email_processing_log"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
# Email identification (RFC 2822 Message-ID)
message_id: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
# Email metadata for audit trail
email_subject: Mapped[str | None] = mapped_column(String(500), nullable=True)
email_from: Mapped[str | None] = mapped_column(String(255), nullable=True)
email_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Processing results
attachments_count: Mapped[int] = mapped_column(Integer, default=0)
invoices_created: Mapped[int] = mapped_column(Integer, default=0)
confident_invoices: Mapped[int] = mapped_column(Integer, default=0)
# Status tracking
marked_as_read: Mapped[bool] = mapped_column(Boolean, default=False)
processing_status: Mapped[str] = mapped_column(String(50), default="pending") # pending, success, failed, skipped
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
# Invoice IDs created from this email
invoice_ids: Mapped[list | None] = mapped_column(JSONB, nullable=True)
processed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="email_processing_logs")
__table_args__ = (
UniqueConstraint('kitchen_id', 'message_id', name='uq_email_message_id'),
)
# Forward reference
from .user import Kitchen

View file

@ -0,0 +1,46 @@
from datetime import datetime, date
from typing import Optional, List
from sqlalchemy import String, DateTime, ForeignKey, Integer, Text, Date
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class EventOrder(Base):
"""Function/event ordering (select recipes x quantities -> generate shopping list)"""
__tablename__ = "event_orders"
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)
event_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="DRAFT") # DRAFT | FINALISED | ORDERED
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")
created_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by])
items: Mapped[List["EventOrderItem"]] = relationship("EventOrderItem", back_populates="event_order", cascade="all, delete-orphan")
class EventOrderItem(Base):
"""Recipes and quantities for an event"""
__tablename__ = "event_order_items"
id: Mapped[int] = mapped_column(primary_key=True)
event_order_id: Mapped[int] = mapped_column(ForeignKey("event_orders.id", ondelete="CASCADE"), nullable=False, index=True)
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id", ondelete="RESTRICT"), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
# Relationships
event_order: Mapped["EventOrder"] = relationship("EventOrder", back_populates="items")
recipe: Mapped["Recipe"] = relationship("Recipe")
# Forward references
from .user import Kitchen, User
from .recipe import Recipe

View file

@ -0,0 +1,101 @@
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Text, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class FieldMapping(Base):
"""
Maps supplier-specific field names to standard invoice/line item fields.
For example, if a supplier's invoices have "Document No" instead of "Invoice ID",
we can create a mapping:
source_field="Document No" -> target_field="invoice_number"
Field types:
- invoice: Maps to Invoice model fields (invoice_number, invoice_date, total, etc.)
- line_item: Maps to LineItem model fields (description, quantity, amount, etc.)
"""
__tablename__ = "field_mappings"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# Can be kitchen-wide (supplier_id=null) or supplier-specific
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), nullable=True)
# The field name as it appears in the Azure OCR response
source_field: Mapped[str] = mapped_column(String(100), nullable=False)
# The target field in our model (e.g., "invoice_number", "description", "amount")
target_field: Mapped[str] = mapped_column(String(100), nullable=False)
# Field type: "invoice" or "line_item"
field_type: Mapped[str] = mapped_column(String(20), default="invoice")
# Optional transformation (for future use): "direct", "date", "currency", "number"
transform: Mapped[str] = mapped_column(String(50), default="direct")
# Priority for applying mappings (higher = applied first)
priority: Mapped[int] = mapped_column(Integer, default=0)
# Timestamps
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")
supplier: Mapped["Supplier"] = relationship("Supplier")
# Standard Azure field names for reference
AZURE_INVOICE_FIELDS = [
"InvoiceId",
"InvoiceDate",
"InvoiceTotal",
"SubTotal",
"TotalTax",
"AmountDue",
"VendorName",
"VendorAddress",
"CustomerName",
"CustomerAddress",
"PurchaseOrder",
"DueDate",
"ServiceDate",
"ServiceStartDate",
"ServiceEndDate",
]
AZURE_LINE_ITEM_FIELDS = [
"Description",
"Quantity",
"UnitPrice",
"Amount",
"ProductCode",
"Unit",
"Date",
"Tax",
]
# Our target fields
TARGET_INVOICE_FIELDS = [
"invoice_number",
"invoice_date",
"total",
"net_total",
"vendor_name",
"order_number",
]
TARGET_LINE_ITEM_FIELDS = [
"description",
"quantity",
"unit_price",
"amount",
"product_code",
]
from .user import Kitchen
from .supplier import Supplier

133
backend/models/food_flag.py Normal file
View file

@ -0,0 +1,133 @@
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, DateTime, ForeignKey, Integer, Text, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class FoodFlagCategory(Base):
"""Configurable flag category types (Allergy, Dietary, etc.)"""
__tablename__ = "food_flag_categories"
__table_args__ = (UniqueConstraint("kitchen_id", "name", name="uq_food_flag_categories_kitchen_name"),)
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)
propagation_type: Mapped[str] = mapped_column(String(20), nullable=False) # "contains" | "suitable_for"
required: Mapped[bool] = mapped_column(Boolean, default=False) # Must be assessed on ingredients
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")
flags: Mapped[List["FoodFlag"]] = relationship("FoodFlag", back_populates="category", cascade="all, delete-orphan")
class FoodFlag(Base):
"""Individual flags within categories (Gluten, Milk, Vegetarian, etc.)"""
__tablename__ = "food_flags"
__table_args__ = (UniqueConstraint("kitchen_id", "name", name="uq_food_flags_kitchen_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
category_id: Mapped[int] = mapped_column(ForeignKey("food_flag_categories.id", ondelete="CASCADE"), nullable=False, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
code: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) # Short code: "Gl", "Mi", "V"
icon: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
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")
category: Mapped["FoodFlagCategory"] = relationship("FoodFlagCategory", back_populates="flags")
class LineItemFlag(Base):
"""Flags on supplier line items (data entry mechanism, triggers latching)"""
__tablename__ = "line_item_flags"
__table_args__ = (UniqueConstraint("line_item_id", "food_flag_id", name="uq_line_item_flags_li_flag"),)
id: Mapped[int] = mapped_column(primary_key=True)
line_item_id: Mapped[int] = mapped_column(ForeignKey("line_items.id", ondelete="CASCADE"), nullable=False, index=True)
food_flag_id: Mapped[int] = mapped_column(ForeignKey("food_flags.id", ondelete="CASCADE"), nullable=False)
flagged_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
line_item: Mapped["LineItem"] = relationship("LineItem")
food_flag: Mapped["FoodFlag"] = relationship("FoodFlag")
flagged_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[flagged_by])
class RecipeFlag(Base):
"""Flag state on recipes (manual additions + override state)"""
__tablename__ = "recipe_flags"
__table_args__ = (UniqueConstraint("recipe_id", "food_flag_id", name="uq_recipe_flags_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)
source_type: Mapped[str] = mapped_column(String(20), nullable=False) # "auto" | "manual"
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
excludable_on_request: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
recipe: Mapped["Recipe"] = relationship("Recipe", back_populates="flags")
food_flag: Mapped["FoodFlag"] = relationship("FoodFlag")
class RecipeFlagOverride(Base):
"""Audit log for flag changes (mandatory notes)"""
__tablename__ = "recipe_flag_overrides"
id: Mapped[int] = mapped_column(primary_key=True)
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False)
food_flag_id: Mapped[int] = mapped_column(ForeignKey("food_flags.id", ondelete="CASCADE"), nullable=False)
action: Mapped[str] = mapped_column(String(20), nullable=False) # "deactivated" | "reactivated" | "set_excludable" | "unset_excludable"
note: Mapped[str] = mapped_column(Text, nullable=False)
user_id: 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")
food_flag: Mapped["FoodFlag"] = relationship("FoodFlag")
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
class BrakesProductCache(Base):
"""Cache for Brakes product page scraping (ingredients + allergens)"""
__tablename__ = "brakes_product_cache"
id: Mapped[int] = mapped_column(primary_key=True)
product_code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
product_name: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
ingredients_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
contains_allergens: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON list
dietary_info: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON list e.g. ["Vegetarian", "Vegan"]
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
not_found: Mapped[bool] = mapped_column(Boolean, default=False) # cache 404s too
class AllergenKeyword(Base):
"""Keyword-to-flag mappings for allergen suggestion (seeded from OFF taxonomy, admin-editable)"""
__tablename__ = "allergen_keywords"
__table_args__ = (UniqueConstraint("kitchen_id", "food_flag_id", "keyword", name="uq_allergen_keywords_kit_flag_kw"),)
id: Mapped[int] = mapped_column(primary_key=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
food_flag_id: Mapped[int] = mapped_column(ForeignKey("food_flags.id", ondelete="CASCADE"), nullable=False, index=True)
keyword: Mapped[str] = mapped_column(String(100), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
food_flag: Mapped["FoodFlag"] = relationship("FoodFlag")
# Forward references
from .user import Kitchen, User
from .line_item import LineItem
from .recipe import Recipe

56
backend/models/gp.py Normal file
View file

@ -0,0 +1,56 @@
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import String, DateTime, Date, ForeignKey, Numeric, Text, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class RevenueEntry(Base):
"""Daily revenue entries for GP calculation"""
__tablename__ = "revenue_entries"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
# Category breakdown (e.g., "food", "beverages", "other")
category: Mapped[str] = mapped_column(String(50), default="total")
notes: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="revenue_entries")
class GPPeriod(Base):
"""Calculated GP for a specific period"""
__tablename__ = "gp_periods"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
# Calculated values
total_revenue: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
total_costs: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
gp_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
gp_percentage: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False)
# Breakdown by category (JSON)
# {"food": {"revenue": 1000, "cost": 300, "gp": 70}, ...}
category_breakdown: Mapped[dict | None] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="gp_periods")
# Forward reference
from .user import Kitchen

View file

@ -0,0 +1,147 @@
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, UniqueConstraint, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class IngredientCategory(Base):
"""Configurable ingredient groupings (Dairy, Meat, Produce, etc.)"""
__tablename__ = "ingredient_categories"
__table_args__ = (UniqueConstraint("kitchen_id", "name", name="uq_ingredient_categories_kitchen_name"),)
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)
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")
ingredients: Mapped[List["Ingredient"]] = relationship("Ingredient", back_populates="category")
class Ingredient(Base):
"""Canonical ingredient library entry"""
__tablename__ = "ingredients"
__table_args__ = (UniqueConstraint("kitchen_id", "name", name="uq_ingredients_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)
category_id: Mapped[Optional[int]] = mapped_column(ForeignKey("ingredient_categories.id", ondelete="SET NULL"), nullable=True, index=True)
standard_unit: Mapped[str] = mapped_column(String(20), nullable=False) # g, kg, ml, ltr, each
yield_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("100.00"))
manual_price: Mapped[Optional[Decimal]] = mapped_column(Numeric(12, 6), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_archived: Mapped[bool] = mapped_column(Boolean, default=False)
flags_assessed: Mapped[bool] = mapped_column(Boolean, default=False) # True when user has reviewed flags (even if none apply)
is_prepackaged: Mapped[bool] = mapped_column(Boolean, default=False) # manufactured/pre-made product
is_free: Mapped[bool] = mapped_column(Boolean, default=False) # free item (e.g. water) — skip price warnings
product_ingredients: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # ingredients list from product label (OCR or manual)
label_image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # stored label photo path
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")
category: Mapped[Optional["IngredientCategory"]] = relationship("IngredientCategory", back_populates="ingredients")
created_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by])
sources: Mapped[List["IngredientSource"]] = relationship("IngredientSource", back_populates="ingredient", cascade="all, delete-orphan")
flags: Mapped[List["IngredientFlag"]] = relationship("IngredientFlag", back_populates="ingredient", cascade="all, delete-orphan")
flag_nones: Mapped[List["IngredientFlagNone"]] = relationship("IngredientFlagNone", cascade="all, delete-orphan")
flag_dismissals: Mapped[List["IngredientFlagDismissal"]] = relationship("IngredientFlagDismissal", cascade="all, delete-orphan")
class IngredientSource(Base):
"""Maps supplier products to ingredients with unit conversion and price tracking"""
__tablename__ = "ingredient_sources"
__table_args__ = (UniqueConstraint("kitchen_id", "ingredient_id", "supplier_id", "product_code", name="uq_ingredient_sources_kitchen_ing_sup_code"),)
id: Mapped[int] = mapped_column(primary_key=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
ingredient_id: Mapped[int] = mapped_column(ForeignKey("ingredients.id", ondelete="CASCADE"), nullable=False, index=True)
supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), nullable=False, index=True)
product_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
description_pattern: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
description_aliases: Mapped[Optional[list]] = mapped_column(JSON, default=list)
# Pack/conversion data
pack_quantity: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
unit_size: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 3), nullable=True)
unit_size_type: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
# Price tracking (auto-updated from most recent matched line item)
latest_unit_price: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 2), nullable=True)
latest_invoice_id: Mapped[Optional[int]] = mapped_column(ForeignKey("invoices.id", ondelete="SET NULL"), nullable=True)
latest_invoice_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
price_per_std_unit: Mapped[Optional[Decimal]] = mapped_column(Numeric(12, 6), 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")
ingredient: Mapped["Ingredient"] = relationship("Ingredient", back_populates="sources")
supplier: Mapped["Supplier"] = relationship("Supplier")
latest_invoice: Mapped[Optional["Invoice"]] = relationship("Invoice", foreign_keys=[latest_invoice_id])
class IngredientFlag(Base):
"""Canonical flag assignments on ingredients (latching from line items)"""
__tablename__ = "ingredient_flags"
__table_args__ = (UniqueConstraint("ingredient_id", "food_flag_id", name="uq_ingredient_flags_ing_flag"),)
id: Mapped[int] = mapped_column(primary_key=True)
ingredient_id: Mapped[int] = mapped_column(ForeignKey("ingredients.id", ondelete="CASCADE"), nullable=False, index=True)
food_flag_id: Mapped[int] = mapped_column(ForeignKey("food_flags.id", ondelete="CASCADE"), nullable=False, index=True)
flagged_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
source: Mapped[str] = mapped_column(String(20), default="manual") # "manual" | "latched"
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
ingredient: Mapped["Ingredient"] = relationship("Ingredient", back_populates="flags")
food_flag: Mapped["FoodFlag"] = relationship("FoodFlag")
flagged_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[flagged_by])
class IngredientFlagNone(Base):
"""Tracks 'None apply' per ingredient per flag category (for required categories)"""
__tablename__ = "ingredient_flag_nones"
__table_args__ = (UniqueConstraint("ingredient_id", "category_id", name="uq_ingredient_flag_nones_ing_cat"),)
id: Mapped[int] = mapped_column(primary_key=True)
ingredient_id: Mapped[int] = mapped_column(ForeignKey("ingredients.id", ondelete="CASCADE"), nullable=False, index=True)
category_id: Mapped[int] = mapped_column(ForeignKey("food_flag_categories.id", ondelete="CASCADE"), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
ingredient: Mapped["Ingredient"] = relationship("Ingredient")
category: Mapped["FoodFlagCategory"] = relationship("FoodFlagCategory")
class IngredientFlagDismissal(Base):
"""Tracks dismissed allergen suggestions per ingredient+flag with accountability"""
__tablename__ = "ingredient_flag_dismissals"
__table_args__ = (UniqueConstraint("ingredient_id", "food_flag_id", name="uq_ingredient_flag_dismissals_ing_flag"),)
id: Mapped[int] = mapped_column(primary_key=True)
ingredient_id: Mapped[int] = mapped_column(ForeignKey("ingredients.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
ingredient: Mapped["Ingredient"] = relationship("Ingredient")
food_flag: Mapped["FoodFlag"] = relationship("FoodFlag")
# Forward references
from .user import Kitchen, User
from .supplier import Supplier
from .invoice import Invoice
from .food_flag import FoodFlag, FoodFlagCategory

148
backend/models/invoice.py Normal file
View file

@ -0,0 +1,148 @@
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, Date, ForeignKey, Numeric, Text, Enum, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
import enum
if TYPE_CHECKING:
from .line_item import LineItem
class InvoiceStatus(str, enum.Enum):
PENDING = "PENDING" # Uploaded, awaiting OCR
PROCESSED = "PROCESSED" # OCR complete, awaiting review
REVIEWED = "REVIEWED" # User has reviewed/corrected
CONFIRMED = "CONFIRMED" # Confirmed and included in GP
class Invoice(Base):
__tablename__ = "invoices"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), nullable=True)
# Extracted data
invoice_number: Mapped[str] = mapped_column(String(100), nullable=True)
invoice_date: Mapped[date] = mapped_column(Date, nullable=True)
total: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=True) # Gross total (inc. VAT)
net_total: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=True) # Net total (exc. VAT)
vendor_name: Mapped[str] = mapped_column(String(255), nullable=True) # OCR-extracted vendor name
supplier_match_type: Mapped[str] = mapped_column(String(20), nullable=True) # "exact", "fuzzy", or null
# Document type and order tracking
document_type: Mapped[str] = mapped_column(String(50), nullable=True, default="invoice")
order_number: Mapped[str] = mapped_column(String(100), nullable=True)
# Duplicate detection
duplicate_status: Mapped[str] = mapped_column(String(50), nullable=True)
duplicate_of_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("invoices.id", ondelete="SET NULL"), nullable=True
)
related_document_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("invoices.id", ondelete="SET NULL"), nullable=True
)
# OCR metadata
image_path: Mapped[str] = mapped_column(String(500), nullable=False)
ocr_raw_text: Mapped[str] = mapped_column(Text, nullable=True)
ocr_raw_json: Mapped[str] = mapped_column(Text, nullable=True) # Full Azure response JSON for debugging/remapping
ocr_confidence: Mapped[float] = mapped_column(Numeric(5, 4), nullable=True)
# Status tracking
status: Mapped[InvoiceStatus] = mapped_column(
Enum(InvoiceStatus),
default=InvoiceStatus.PENDING
)
# Category for GP breakdown
category: Mapped[str] = mapped_column(String(50), nullable=True, default="food")
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Dext integration
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
dext_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
dext_sent_by_user_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("users.id"), nullable=True
)
# File storage tracking (for Nextcloud archival)
file_storage_location: Mapped[str] = mapped_column(String(20), default="local") # "local" or "nextcloud"
nextcloud_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
original_local_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Source tracking - where did this invoice come from?
source: Mapped[str] = mapped_column(String(50), default="upload") # upload, email, api
source_reference: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # email subject, API caller, etc.
# Dispute linking - for credit notes linked to disputes
linked_dispute_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("invoice_disputes.id", ondelete="SET NULL"), nullable=True
)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="invoices")
supplier: Mapped["Supplier"] = relationship("Supplier", back_populates="invoices")
line_items: Mapped[list["LineItem"]] = relationship(
"LineItem",
back_populates="invoice",
cascade="all, delete-orphan",
order_by="LineItem.line_number"
)
# Dispute tracking relationships
disputes: Mapped[list["InvoiceDispute"]] = relationship(
"InvoiceDispute",
back_populates="invoice",
cascade="all, delete-orphan",
foreign_keys="InvoiceDispute.invoice_id"
)
credit_notes: Mapped[list["CreditNote"]] = relationship(
"CreditNote",
back_populates="invoice"
)
cost_distributions: Mapped[list["CostDistribution"]] = relationship(
"CostDistribution",
back_populates="invoice"
)
# Self-referential relationships for duplicate tracking
duplicate_of: Mapped[Optional["Invoice"]] = relationship(
"Invoice",
remote_side=[id],
foreign_keys=[duplicate_of_id],
uselist=False
)
related_document: Mapped[Optional["Invoice"]] = relationship(
"Invoice",
remote_side=[id],
foreign_keys=[related_document_id],
uselist=False
)
# Dext integration relationship
dext_sent_by_user: Mapped[Optional["User"]] = relationship(
"User",
foreign_keys=[dext_sent_by_user_id]
)
# Linked dispute relationship (for credit notes)
linked_dispute: Mapped[Optional["InvoiceDispute"]] = relationship(
"InvoiceDispute",
foreign_keys=[linked_dispute_id],
uselist=False
)
# Forward references
from .user import Kitchen, User
from .supplier import Supplier
from .line_item import LineItem
from .dispute import InvoiceDispute, CreditNote
from .cost_distribution import CostDistribution

View file

@ -0,0 +1,69 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, DateTime, ForeignKey, Numeric, Integer, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class LineItem(Base):
__tablename__ = "line_items"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
invoice_id: Mapped[int] = mapped_column(
ForeignKey("invoices.id", ondelete="CASCADE"),
nullable=False,
index=True
)
# Core fields from Azure extraction
product_code: Mapped[str] = mapped_column(String(100), nullable=True)
description: Mapped[str] = mapped_column(Text, nullable=True)
description_alt: Mapped[str] = mapped_column(Text, nullable=True) # Alternative description (Azure content vs value mismatch)
unit: Mapped[str] = mapped_column(String(50), nullable=True) # Unit of measure (UN, Box, KG, etc.)
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), nullable=True) # Delivered quantity
order_quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), nullable=True) # Ordered quantity
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=True)
tax_rate: Mapped[str] = mapped_column(String(50), nullable=True) # VAT rate (ZERO, 20.00, No VAT, etc.)
tax_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=True) # VAT amount
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=True) # Net line total
# Ordering for display
line_number: Mapped[int] = mapped_column(Integer, default=0)
# Non-stock flag (excluded from GP calculations)
is_non_stock: Mapped[bool] = mapped_column(Boolean, default=False)
# Raw OCR content for this line item (used for pack size parsing)
raw_content: Mapped[str] = mapped_column(Text, nullable=True)
# Pack size fields (parsed from raw_content or manually edited)
pack_quantity: Mapped[int] = mapped_column(Integer, nullable=True) # e.g., 120 for "120x15g"
unit_size: Mapped[Decimal] = mapped_column(Numeric(10, 3), nullable=True) # e.g., 15 for "120x15g"
unit_size_type: Mapped[str] = mapped_column(String(10), nullable=True) # e.g., "g", "kg", "ml", "ltr"
portions_per_unit: Mapped[int] = mapped_column(Integer, nullable=True) # User-editable servings per unit (null = not defined)
# Calculated cost fields
cost_per_item: Mapped[Decimal] = mapped_column(Numeric(10, 4), nullable=True) # unit_price / pack_quantity
cost_per_portion: Mapped[Decimal] = mapped_column(Numeric(10, 4), nullable=True) # unit_price / (pack_quantity * portions_per_unit)
# OCR warnings (e.g., "quantity capped from 11112121115 to 999999")
ocr_warnings: Mapped[str] = mapped_column(Text, nullable=True)
# Ingredient mapping (set when user maps line item to ingredient via modal)
ingredient_id: Mapped[int | None] = mapped_column(
ForeignKey("ingredients.id", ondelete="SET NULL"),
nullable=True,
index=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
invoice: Mapped["Invoice"] = relationship("Invoice", back_populates="line_items")
ingredient: Mapped["Ingredient | None"] = relationship("Ingredient", foreign_keys=[ingredient_id])
from .invoice import Invoice
from .ingredient import Ingredient

43
backend/models/llm.py Normal file
View file

@ -0,0 +1,43 @@
"""
LLM integration models usage tracking and response caching.
LLM FEATURE see LLM-MANIFEST.md for removal instructions
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, Integer, Text, Boolean, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
class LlmUsageLog(Base):
"""Log every LLM API call for cost tracking and debugging"""
__tablename__ = "llm_usage_log"
id: Mapped[int] = mapped_column(primary_key=True)
kitchen_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
feature: Mapped[str] = mapped_column(String(50), nullable=False, index=True) # label_analysis, invoice_assist, etc.
model: Mapped[str] = mapped_column(String(100), nullable=False)
input_tokens: Mapped[int] = mapped_column(Integer, default=0)
output_tokens: Mapped[int] = mapped_column(Integer, default=0)
latency_ms: Mapped[int] = mapped_column(Integer, default=0)
success: Mapped[bool] = mapped_column(Boolean, default=True)
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class LlmAnalysisCache(Base):
"""Cache LLM responses to avoid redundant calls for identical inputs"""
__tablename__ = "llm_analysis_cache"
__table_args__ = (
UniqueConstraint("feature", "input_hash", "prompt_version", name="uq_llm_cache_feature_hash_version"),
)
id: Mapped[int] = mapped_column(primary_key=True)
kitchen_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
feature: Mapped[str] = mapped_column(String(50), nullable=False) # label_analysis, ingredient_match, etc.
input_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True) # SHA-256 of input
result_json: Mapped[dict] = mapped_column(JSONB, nullable=False)
model_used: Mapped[str] = mapped_column(String(100), nullable=False)
prompt_version: Mapped[str] = mapped_column(String(10), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

143
backend/models/logbook.py Normal file
View file

@ -0,0 +1,143 @@
"""
Logbook models for tracking wastage, transfers, staff food, and manual adjustments.
"""
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import String, DateTime, Date, ForeignKey, Numeric, Boolean, Text, Integer, Index, Enum
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
import enum
class EntryType(str, enum.Enum):
WASTAGE = "wastage"
TRANSFER = "transfer"
STAFF_FOOD = "staff_food"
MANUAL_ADJUSTMENT = "manual_adjustment"
class WastageReason(str, enum.Enum):
SPOILED = "spoiled"
DAMAGED = "damaged"
EXPIRED = "expired"
OVERPRODUCTION = "overproduction"
QUALITY_ISSUE = "quality_issue"
OTHER = "other"
class TransferStatus(str, enum.Enum):
PENDING = "pending"
IN_TRANSIT = "in_transit"
RECEIVED = "received"
CANCELLED = "cancelled"
class LogbookEntry(Base):
"""Base table for all logbook entries"""
__tablename__ = "logbook_entries"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
# Entry metadata
entry_type: Mapped[EntryType] = mapped_column(Enum(EntryType), nullable=False, index=True)
entry_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
# Type-specific data (stored as JSONB for flexibility)
# Wastage: {reason: "spoiled"}
# Transfer: {destination_kitchen_id: 2, status: "pending"}
# Staff food: {meal_type: "lunch", staff_count: 3}
# Manual adjustment: {adjustment_reason: "...", original_invoice_id: 123}
type_data: Mapped[dict] = mapped_column(JSONB, nullable=False, default={})
# Common fields
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0.0) # Calculated from line items
# Audit fields
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Soft delete
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="logbook_entries")
created_by_user: Mapped["User"] = relationship("User", foreign_keys=[created_by])
line_items: Mapped[list["LogbookLineItem"]] = relationship(
"LogbookLineItem", back_populates="entry", cascade="all, delete-orphan", lazy="selectin"
)
attachments: Mapped[list["LogbookAttachment"]] = relationship(
"LogbookAttachment", back_populates="entry", cascade="all, delete-orphan", lazy="selectin"
)
__table_args__ = (
Index('idx_logbook_kitchen_date', 'kitchen_id', 'entry_date'),
Index('idx_logbook_type', 'kitchen_id', 'entry_type', 'entry_date'),
)
class LogbookLineItem(Base):
"""Line items for logbook entries (products/items)"""
__tablename__ = "logbook_line_items"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("logbook_entries.id"), nullable=False, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
# Product reference (nullable for manual entries)
# TODO: Add ForeignKey when Product model is created
product_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Product details (denormalized for historical accuracy)
product_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
product_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
supplier_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Quantity and cost
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), nullable=False)
unit: Mapped[str | None] = mapped_column(String(50), nullable=True) # kg, litres, each
unit_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 4), nullable=True)
total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
# Additional data
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
entry: Mapped["LogbookEntry"] = relationship("LogbookEntry", back_populates="line_items")
# product: Mapped["Product"] = relationship("Product", lazy="selectin") # TODO: Add Product model
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
class LogbookAttachment(Base):
"""Photos and documents attached to logbook entries"""
__tablename__ = "logbook_attachments"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("logbook_entries.id"), nullable=False, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
# File details
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_path: Mapped[str] = mapped_column(String(500), nullable=False) # Relative path in storage
file_type: Mapped[str] = mapped_column(String(50), nullable=False) # image/jpeg, application/pdf
file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
# Metadata
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
uploaded_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
uploaded_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
entry: Mapped["LogbookEntry"] = relationship("LogbookEntry", back_populates="attachments")
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
uploaded_by_user: Mapped["User"] = relationship("User")
# Forward references
from .user import Kitchen, User

76
backend/models/menu.py Normal file
View file

@ -0,0 +1,76 @@
from datetime import datetime
from decimal import Decimal
from typing import Optional, List
from sqlalchemy import String, DateTime, ForeignKey, Numeric, Integer, Text, Boolean, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class Menu(Base):
"""Named menus (e.g., 'Dinner Menu', 'Sunday Lunch')"""
__tablename__ = "menus"
__table_args__ = (UniqueConstraint("kitchen_id", "name", name="uq_menus_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)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # customer-facing subtitle
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # internal staff comments
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
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")
divisions: Mapped[List["MenuDivision"]] = relationship("MenuDivision", back_populates="menu", cascade="all, delete-orphan")
items: Mapped[List["MenuItem"]] = relationship("MenuItem", back_populates="menu", cascade="all, delete-orphan")
class MenuDivision(Base):
"""Sections within a specific menu (per-menu, not shared)"""
__tablename__ = "menu_divisions"
__table_args__ = (UniqueConstraint("menu_id", "name", name="uq_menu_divisions_menu_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
menu_id: Mapped[int] = mapped_column(ForeignKey("menus.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
# Relationships
menu: Mapped["Menu"] = relationship("Menu", back_populates="divisions")
items: Mapped[List["MenuItem"]] = relationship("MenuItem", back_populates="division", cascade="all, delete-orphan")
class MenuItem(Base):
"""Published dishes on a menu"""
__tablename__ = "menu_items"
__table_args__ = (UniqueConstraint("menu_id", "recipe_id", name="uq_menu_items_menu_recipe"),)
id: Mapped[int] = mapped_column(primary_key=True)
menu_id: Mapped[int] = mapped_column(ForeignKey("menus.id", ondelete="CASCADE"), nullable=False, index=True)
division_id: Mapped[int] = mapped_column(ForeignKey("menu_divisions.id", ondelete="CASCADE"), nullable=False, index=True)
recipe_id: Mapped[Optional[int]] = mapped_column(ForeignKey("recipes.id", ondelete="SET NULL"), nullable=True, index=True)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
price: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 2), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
snapshot_json: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
confirmed_by_user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
confirmed_by_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
published_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
uploaded_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
# Relationships
menu: Mapped["Menu"] = relationship("Menu", back_populates="items")
division: Mapped["MenuDivision"] = relationship("MenuDivision", back_populates="items")
recipe: Mapped[Optional["Recipe"]] = relationship("Recipe")
confirmed_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[confirmed_by_user_id])
uploaded_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[uploaded_by])
# Forward references
from .user import Kitchen, User
from .recipe import Recipe

171
backend/models/newbook.py Normal file
View file

@ -0,0 +1,171 @@
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import String, DateTime, Date, ForeignKey, Numeric, Boolean, Text, Integer, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class NewbookGLAccount(Base):
"""GL Accounts fetched from Newbook API"""
__tablename__ = "newbook_gl_accounts"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
# Newbook GL Account identifiers
gl_account_id: Mapped[str] = mapped_column(String(50), nullable=False) # Newbook's internal ID
gl_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # Account code
gl_name: Mapped[str] = mapped_column(String(255), nullable=False) # Account name/description
gl_type: Mapped[str | None] = mapped_column(String(100), nullable=True) # Account type from Newbook
# Group info for categorization
gl_group_id: Mapped[str | None] = mapped_column(String(50), nullable=True) # Parent group ID
gl_group_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # Parent group name (e.g., "FNB - Food & Beverage")
# User selection
is_tracked: Mapped[bool] = mapped_column(Boolean, default=False) # User selected for revenue tracking
display_order: Mapped[int] = mapped_column(Integer, default=0) # Order in reports
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", back_populates="newbook_gl_accounts")
daily_revenue: Mapped[list["NewbookDailyRevenue"]] = relationship(
"NewbookDailyRevenue", back_populates="gl_account", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint('kitchen_id', 'gl_account_id', name='uq_newbook_gl_account'),
)
class NewbookDailyRevenue(Base):
"""Daily revenue amounts per GL account from Newbook"""
__tablename__ = "newbook_daily_revenue"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
gl_account_id: Mapped[int] = mapped_column(ForeignKey("newbook_gl_accounts.id"), nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
amount_net: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) # Net/exc tax amount
amount_gross: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) # Gross/inc tax (if available)
# Sync metadata
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="newbook_daily_revenue")
gl_account: Mapped["NewbookGLAccount"] = relationship("NewbookGLAccount", back_populates="daily_revenue")
__table_args__ = (
UniqueConstraint('kitchen_id', 'gl_account_id', 'date', name='uq_newbook_revenue_per_day'),
)
class NewbookDailyOccupancy(Base):
"""Daily occupancy and meal allocation data from Newbook"""
__tablename__ = "newbook_daily_occupancy"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
# Occupancy metrics
total_rooms: Mapped[int | None] = mapped_column(Integer, nullable=True)
occupied_rooms: Mapped[int | None] = mapped_column(Integer, nullable=True)
occupancy_percentage: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
total_guests: Mapped[int | None] = mapped_column(Integer, nullable=True) # People count
# Meal allocation data (from booking inventory items)
breakfast_allocation_qty: Mapped[int | None] = mapped_column(Integer, nullable=True)
breakfast_allocation_netvalue: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
dinner_allocation_qty: Mapped[int | None] = mapped_column(Integer, nullable=True)
dinner_allocation_netvalue: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
# Arrival tracking (for cross-referencing with Resos table bookings)
arrival_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # Number of bookings arriving this day
arrival_booking_ids: Mapped[list | None] = mapped_column(JSONB, nullable=True) # List of booking IDs arriving
arrival_booking_details: Mapped[list | None] = mapped_column(JSONB, nullable=True) # Full arrival details with booking refs
# Room-level breakdown (JSONB array) - array of {room_number, booking_id, is_dbb, is_package} for each occupied room
rooms_breakdown: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Room-level details for residents table chart (DEPRECATED - use rooms_breakdown instead)
room_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
booking_id: Mapped[str | None] = mapped_column(String(100), nullable=True) # Newbook booking reference
guest_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
is_dbb: Mapped[bool] = mapped_column(Boolean, default=False) # Dinner Bed & Breakfast
is_package: Mapped[bool] = mapped_column(Boolean, default=False) # Package deal
# Forecast indicator
is_forecast: Mapped[bool] = mapped_column(Boolean, default=False) # True for future booked dates
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="newbook_daily_occupancy")
__table_args__ = (
UniqueConstraint('kitchen_id', 'date', name='uq_newbook_occupancy_per_day'),
)
class NewbookSyncLog(Base):
"""Log of Newbook sync operations"""
__tablename__ = "newbook_sync_log"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
sync_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'gl_accounts', 'revenue', 'occupancy'
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="running") # running, success, failed
records_fetched: Mapped[int] = mapped_column(Integer, default=0)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
# Date range for the sync (for revenue/occupancy)
date_from: Mapped[date | None] = mapped_column(Date, nullable=True)
date_to: Mapped[date | None] = mapped_column(Date, nullable=True)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="newbook_sync_logs")
class NewbookRoomCategory(Base):
"""Room types/categories aggregated from Newbook API (site_list)"""
__tablename__ = "newbook_room_categories"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
# Room type identifiers (aggregated from site_list)
site_id: Mapped[str] = mapped_column(String(50), nullable=False) # Type name as ID
site_name: Mapped[str] = mapped_column(String(255), nullable=False) # Type name (e.g., "Standard Room")
site_type: Mapped[str | None] = mapped_column(String(100), nullable=True) # Same as site_name
# Room count of this type
room_count: Mapped[int] = mapped_column(Integer, default=0) # Number of rooms of this type
# User selection - included in occupancy/guest calculations
is_included: Mapped[bool] = mapped_column(Boolean, default=True) # Include in occupancy calcs
display_order: Mapped[int] = mapped_column(Integer, default=0)
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", back_populates="newbook_room_categories")
__table_args__ = (
UniqueConstraint('kitchen_id', 'site_id', name='uq_newbook_room_category'),
)
# Forward reference
from .user import Kitchen

View file

@ -0,0 +1,70 @@
from datetime import datetime
from decimal import Decimal
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey, Numeric, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class ProductDefinition(Base):
"""
Stores persistent portion/pack size definitions for products.
When a product appears on a new invoice, these definitions are auto-applied
so staff don't need to re-enter portions_per_unit every time.
"""
__tablename__ = "product_definitions"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(
ForeignKey("kitchens.id"),
nullable=False,
index=True
)
supplier_id: Mapped[int] = mapped_column(
ForeignKey("suppliers.id"),
nullable=True, # Can be null for kitchen-wide definitions
index=True
)
# Product identification
product_code: Mapped[str] = mapped_column(String(100), nullable=True)
description_pattern: Mapped[str] = mapped_column(String(255), nullable=True) # For fuzzy matching
# Pack size info (can override OCR-parsed values)
pack_quantity: Mapped[int] = mapped_column(Integer, nullable=True)
unit_size: Mapped[Decimal] = mapped_column(Numeric(10, 3), nullable=True)
unit_size_type: Mapped[str] = mapped_column(String(10), nullable=True) # g, kg, ml, ltr, oz, cl
# Portion info (the key persistent data)
portions_per_unit: Mapped[int] = mapped_column(Integer, nullable=True)
portion_description: Mapped[str] = mapped_column(String(100), nullable=True) # e.g., "250ml glass", "50g serving"
# Saved by metadata - who saved this definition and from which invoice
saved_by_user_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("users.id"),
nullable=True
)
source_invoice_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("invoices.id", ondelete="SET NULL"),
nullable=True
)
# Timestamps
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")
supplier: Mapped["Supplier"] = relationship("Supplier")
saved_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[saved_by_user_id])
source_invoice: Mapped[Optional["Invoice"]] = relationship("Invoice", foreign_keys=[source_invoice_id])
# Unique constraint: one definition per product_code per supplier per kitchen
__table_args__ = (
UniqueConstraint('kitchen_id', 'supplier_id', 'product_code', name='uix_product_definition'),
)
from .user import Kitchen, User
from .supplier import Supplier
from .invoice import Invoice

View file

@ -0,0 +1,81 @@
"""
Purchase Order models for pre-allocating budget spend.
"""
from datetime import datetime, date
from decimal import Decimal
from typing import Optional
from sqlalchemy import String, DateTime, Date, ForeignKey, Numeric, Text, Integer, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class PurchaseOrder(Base):
__tablename__ = "purchase_orders"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), nullable=False)
order_date: Mapped[date] = mapped_column(Date, nullable=False)
order_type: Mapped[str] = mapped_column(String(20), nullable=False) # 'itemised' or 'single_value'
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
total_amount: Mapped[Optional[Decimal]] = mapped_column(Numeric(12, 2), nullable=True)
order_reference: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
attachment_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
attachment_original_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
linked_invoice_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("invoices.id", ondelete="SET NULL"), nullable=True
)
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
supplier: Mapped["Supplier"] = relationship("Supplier", back_populates="purchase_orders")
linked_invoice: Mapped[Optional["Invoice"]] = relationship("Invoice", foreign_keys=[linked_invoice_id])
created_by_user: Mapped["User"] = relationship("User", foreign_keys=[created_by])
updated_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[updated_by])
line_items: Mapped[list["PurchaseOrderLineItem"]] = relationship(
"PurchaseOrderLineItem",
back_populates="purchase_order",
cascade="all, delete-orphan",
order_by="PurchaseOrderLineItem.line_number",
lazy="selectin",
)
__table_args__ = (
Index("idx_po_kitchen_date", "kitchen_id", "order_date"),
Index("idx_po_kitchen_supplier", "kitchen_id", "supplier_id"),
Index("idx_po_kitchen_status", "kitchen_id", "status"),
)
class PurchaseOrderLineItem(Base):
__tablename__ = "purchase_order_line_items"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
purchase_order_id: Mapped[int] = mapped_column(ForeignKey("purchase_orders.id", ondelete="CASCADE"), nullable=False)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
product_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
product_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
description: Mapped[str] = mapped_column(String(500), nullable=False)
unit: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
unit_price: Mapped[Decimal] = mapped_column(Numeric(12, 4), nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), nullable=False)
total: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
line_number: Mapped[int] = mapped_column(Integer, default=0)
source: Mapped[str] = mapped_column(String(20), default="manual") # 'search' or 'manual'
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
purchase_order: Mapped["PurchaseOrder"] = relationship("PurchaseOrder", back_populates="line_items")
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
# Forward references
from .user import Kitchen, User
from .supplier import Supplier
from .invoice import Invoice

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

145
backend/models/resos.py Normal file
View file

@ -0,0 +1,145 @@
from datetime import datetime, date, time
from sqlalchemy import String, DateTime, Date, Time, ForeignKey, Boolean, Text, Integer, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class ResosBooking(Base):
"""Individual booking records from Resos API"""
__tablename__ = "resos_bookings"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
resos_booking_id: Mapped[str] = mapped_column(String(255), nullable=False)
# Booking details
booking_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
booking_time: Mapped[time] = mapped_column(Time, nullable=False)
people: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(50), nullable=False)
# Guest info (non-PII)
seating_area: Mapped[str | None] = mapped_column(String(255), nullable=True)
table_name: Mapped[str | None] = mapped_column(String(100), nullable=True) # Phase 8.1: Table from Resos
# Custom fields
hotel_booking_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_hotel_guest: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
is_dbb: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
is_package: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
exclude_flag: Mapped[str | None] = mapped_column(String(500), nullable=True)
allergies: Mapped[str | None] = mapped_column(Text, nullable=True)
# Notes
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Metadata
booked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
opening_hour_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
opening_hour_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Flags
is_flagged: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
flag_reasons: Mapped[str | None] = mapped_column(Text, nullable=True)
# Sync metadata
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
is_forecast: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_bookings")
__table_args__ = (
UniqueConstraint('kitchen_id', 'resos_booking_id', name='uq_resos_booking'),
)
class ResosDailyStats(Base):
"""Aggregated daily booking statistics"""
__tablename__ = "resos_daily_stats"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
# Overall totals
total_bookings: Mapped[int] = mapped_column(Integer, default=0)
total_covers: Mapped[int] = mapped_column(Integer, default=0)
# By service period (JSONB)
# Format: [{"period": "Lunch", "bookings": 15, "covers": 32}, ...]
service_breakdown: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Flags
flagged_booking_count: Mapped[int] = mapped_column(Integer, default=0)
# Unique flag types present on this day (JSONB list)
# Format: ["allergies", "large_group", "note_keyword_birthday"]
unique_flag_types: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Consolidated booking data for quick access (JSONB)
# Format: [{"time": "19:00", "people": 2, "period": "Dinner", "booked_at": "2026-01-15T10:30:00", "is_flagged": true, "status": "confirmed"}, ...]
bookings_summary: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Metadata
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
is_forecast: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_daily_stats")
__table_args__ = (
UniqueConstraint('kitchen_id', 'date', name='uq_resos_daily_stat'),
)
class ResosOpeningHour(Base):
"""Cached service period definitions from Resos"""
__tablename__ = "resos_opening_hours"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
resos_opening_hour_id: Mapped[str] = mapped_column(String(255), nullable=False)
# Period details
name: Mapped[str] = mapped_column(String(255), nullable=False)
start_time: Mapped[time | None] = mapped_column(Time, nullable=True)
end_time: Mapped[time | None] = mapped_column(Time, nullable=True)
days_of_week: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Metadata
is_special: Mapped[bool] = mapped_column(Boolean, default=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_opening_hours")
__table_args__ = (
UniqueConstraint('kitchen_id', 'resos_opening_hour_id', name='uq_resos_opening_hour'),
)
class ResosSyncLog(Base):
"""Audit trail for sync operations"""
__tablename__ = "resos_sync_log"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
sync_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'forecast', 'historical', 'daily'
status: Mapped[str] = mapped_column(String(20), nullable=False) # 'running', 'success', 'failed'
date_from: Mapped[date | None] = mapped_column(Date, nullable=True)
date_to: Mapped[date | None] = mapped_column(Date, nullable=True)
bookings_fetched: Mapped[int] = mapped_column(Integer, default=0)
bookings_flagged: Mapped[int] = mapped_column(Integer, default=0)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_sync_logs")

238
backend/models/settings.py Normal file
View file

@ -0,0 +1,238 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, DateTime, ForeignKey, Text, Boolean, Numeric, Integer
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class KitchenSettings(Base):
"""Kitchen-level settings including OCR and Newbook configuration"""
__tablename__ = "kitchen_settings"
id: Mapped[int] = mapped_column(primary_key=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), unique=True)
# Azure Document Intelligence settings
azure_endpoint: Mapped[str | None] = mapped_column(String(500), nullable=True)
azure_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
# OCR post-processing options
ocr_clean_product_codes: Mapped[bool] = mapped_column(Boolean, default=False) # Strip section headers from product codes
ocr_filter_subtotal_rows: Mapped[bool] = mapped_column(Boolean, default=False) # Filter subtotal/total rows from line items
ocr_use_weight_as_quantity: Mapped[bool] = mapped_column(Boolean, default=False) # For KG items, use weight as quantity when it matches total
# Newbook API settings
newbook_api_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
newbook_api_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
newbook_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
newbook_api_region: Mapped[str | None] = mapped_column(String(10), nullable=True) # au, ap, eu, us
newbook_instance_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Newbook sync configuration
newbook_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
newbook_auto_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# Separate sync interval for next 7 days (in minutes, default 15)
newbook_upcoming_sync_interval: Mapped[int] = mapped_column(Integer, default=15)
newbook_upcoming_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
newbook_last_upcoming_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Newbook allocation GL mapping (CSV-style, e.g. "4100,4101,4102")
newbook_breakfast_gl_codes: Mapped[str | None] = mapped_column(String(500), nullable=True)
newbook_dinner_gl_codes: Mapped[str | None] = mapped_column(String(500), nullable=True)
# VAT rates for calculating net from gross (e.g., 0.10 for 10% VAT)
newbook_breakfast_vat_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True, default=Decimal("0.10"))
newbook_dinner_vat_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True, default=Decimal("0.10"))
# Resos API Configuration
resos_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
resos_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
resos_auto_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# Separate sync interval for next 7 days (in minutes, default 15)
resos_upcoming_sync_interval: Mapped[int] = mapped_column(Integer, default=15)
resos_upcoming_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
resos_last_upcoming_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Resos Flagging Configuration
resos_large_group_threshold: Mapped[int] = mapped_column(Integer, default=8)
resos_note_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) # Pipe-separated: "birthday|anniversary|proposal"
resos_allergy_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) # Pipe-separated: "gluten|dairy|nut|shellfish"
# Resos Custom Field & Period Mapping
resos_custom_field_mapping: Mapped[dict | None] = mapped_column(JSONB, nullable=True) # Format: {"booking_number": "field_id_123", ...}
resos_opening_hours_mapping: Mapped[list | None] = mapped_column(JSONB, nullable=True) # Format: [{"resos_id": "abc123", "display_name": "Lunch", "actual_end": "14:30"}, ...]
# Resos SambaPOS Integration
resos_restaurant_table_entities: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated entity names
# Manual Breakfast Configuration (not in Resos)
resos_enable_manual_breakfast: Mapped[bool] = mapped_column(Boolean, default=False)
# Format: [{"day": 1, "start": "07:00", "end": "11:00"}, ...] where day: 1=Monday, 7=Sunday
resos_manual_breakfast_periods: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Resos Flag Icon Mapping (customizable icons for each flag type)
# Format: {"allergies": "🦀", "large_group": "⚠️", "birthday": "🎂", "anniversary": "💍", ...}
resos_flag_icon_mapping: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Resos Arrival Widget Service Filter (filter arrivals widget by service type from mapping)
resos_arrival_widget_service_filter: Mapped[str | None] = mapped_column(String(50), nullable=True) # service_type: breakfast/lunch/dinner/other
# SambaPOS MSSQL Connection
sambapos_db_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
sambapos_db_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=1433)
sambapos_db_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
sambapos_db_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
sambapos_db_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
# SambaPOS tracked categories (comma-separated list, order preserved for display)
sambapos_tracked_categories: Mapped[str | None] = mapped_column(String(1000), nullable=True)
# SambaPOS excluded menu items (comma-separated list of menu item names to exclude from reports)
sambapos_excluded_items: Mapped[str | None] = mapped_column(Text, nullable=True)
# Phase 8.1: GL code configuration for food/beverage split
sambapos_food_gl_codes: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated GL codes for food items
sambapos_beverage_gl_codes: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated GL codes for beverage items
# SMTP email configuration
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=587)
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
smtp_from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_from_name: Mapped[str | None] = mapped_column(String(255), nullable=True, default="Kitchen Invoice System")
# Support request email (where screenshot reports are sent)
support_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Dext integration
dext_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
dext_include_notes: Mapped[bool] = mapped_column(Boolean, default=True)
dext_include_non_stock: Mapped[bool] = mapped_column(Boolean, default=True)
dext_auto_send_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
dext_manual_send_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
dext_include_annotations: Mapped[bool] = mapped_column(Boolean, default=True) # Include PDF annotations when sending to Dext
# Nextcloud settings
nextcloud_host: Mapped[str | None] = mapped_column(String(500), nullable=True)
nextcloud_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
nextcloud_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
nextcloud_base_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/Kitchen Invoices")
nextcloud_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
nextcloud_delete_local: Mapped[bool] = mapped_column(Boolean, default=False) # Delete local file after successful archive
# Backup settings
backup_frequency: Mapped[str | None] = mapped_column(String(20), nullable=True, default="manual") # daily, weekly, manual
backup_retention_count: Mapped[int] = mapped_column(Integer, default=7)
backup_destination: Mapped[str | None] = mapped_column(String(20), nullable=True, default="local") # local, nextcloud, smb
backup_time: Mapped[str | None] = mapped_column(String(5), nullable=True, default="03:00")
# Nextcloud backup path (when backup_destination = "nextcloud")
backup_nextcloud_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/Backups")
# SMB backup settings (used when backup_destination = "smb")
backup_smb_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
backup_smb_share: Mapped[str | None] = mapped_column(String(255), nullable=True)
backup_smb_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
backup_smb_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
backup_smb_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/backups")
# Last backup tracking
backup_last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
backup_last_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
backup_last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
# IMAP Email Inbox settings
imap_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
imap_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=993)
imap_use_ssl: Mapped[bool] = mapped_column(Boolean, default=True)
imap_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
imap_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
imap_folder: Mapped[str | None] = mapped_column(String(255), nullable=True, default="INBOX")
imap_poll_interval: Mapped[int] = mapped_column(Integer, default=15) # minutes
imap_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
imap_confidence_threshold: Mapped[Decimal | None] = mapped_column(Numeric(3, 2), nullable=True, default=Decimal("0.50"))
imap_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# General settings
currency_symbol: Mapped[str] = mapped_column(String(5), default="£")
date_format: Mapped[str] = mapped_column(String(20), default="DD/MM/YYYY")
# Warning thresholds
high_quantity_threshold: Mapped[int] = mapped_column(default=100) # Warn if qty > this value
# PDF annotation settings
pdf_annotations_enabled: Mapped[bool] = mapped_column(Boolean, default=True) # Enable adding annotations to PDFs
pdf_preview_show_annotations: Mapped[bool] = mapped_column(Boolean, default=True) # Show annotations in preview window
# Price change detection settings
price_change_lookback_days: Mapped[int] = mapped_column(Integer, default=30) # Days to look back for price comparison
price_change_amber_threshold: Mapped[int] = mapped_column(Integer, default=10) # % change for amber warning
price_change_red_threshold: Mapped[int] = mapped_column(Integer, default=20) # % change for red alert
# Admin-only page restrictions (comma-separated list of page paths, e.g., "/settings,/suppliers")
admin_restricted_pages: Mapped[str | None] = mapped_column(Text, nullable=True)
# Forecast API integration (Spend Budget feature)
forecast_api_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
forecast_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Budget settings
budget_gp_target: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True, default=Decimal("65.00"))
budget_lookback_weeks: Mapped[int] = mapped_column(Integer, default=4)
# KDS (Kitchen Display System) settings
kds_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
kds_graphql_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
kds_graphql_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
kds_graphql_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
kds_graphql_client_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
kds_poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=6000)
kds_timer_green_seconds: Mapped[int] = mapped_column(Integer, default=300)
kds_timer_amber_seconds: Mapped[int] = mapped_column(Integer, default=600)
kds_timer_red_seconds: Mapped[int] = mapped_column(Integer, default=900)
kds_course_order: Mapped[list | None] = mapped_column(JSONB, nullable=True, default=["Starters", "Mains", "Desserts"])
kds_show_completed_for_seconds: Mapped[int] = mapped_column(Integer, default=30)
# Away timer thresholds (time since food sent to table - "eating" phase)
kds_away_timer_green_seconds: Mapped[int] = mapped_column(Integer, default=600) # 10 minutes
kds_away_timer_amber_seconds: Mapped[int] = mapped_column(Integer, default=900) # 15 minutes
kds_away_timer_red_seconds: Mapped[int] = mapped_column(Integer, default=1200) # 20 minutes
kds_bookings_refresh_seconds: Mapped[int] = mapped_column(Integer, default=60)
# Cost distribution settings
cost_distribution_max_days: Mapped[int] = mapped_column(Integer, default=90)
# LLM integration (Claude) — see LLM-MANIFEST.md for removal instructions
llm_enabled: Mapped[bool] = mapped_column(Boolean, default=False) # Master kill switch — False = zero AI footprint
anthropic_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
llm_model: Mapped[str | None] = mapped_column(String(100), nullable=True, default="claude-haiku-4-5-20251001")
llm_confidence_threshold: Mapped[Decimal | None] = mapped_column(Numeric(3, 2), nullable=True, default=Decimal("0.80"))
llm_monthly_token_limit: Mapped[int] = mapped_column(Integer, default=500000) # ~$1.25/month on Haiku
llm_features_enabled: Mapped[dict | None] = mapped_column(JSONB, nullable=True, 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
})
# Internal API key (for in-house apps like menu display plugin)
api_key: Mapped[str | None] = mapped_column(String(100), nullable=True)
api_key_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# Kitchen details (for PO letterhead)
kitchen_display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
kitchen_address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True)
kitchen_address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
kitchen_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
kitchen_postcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
kitchen_phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
kitchen_email: Mapped[str | None] = mapped_column(String(255), 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", back_populates="settings")
# Forward reference
from .user import Kitchen

View file

@ -0,0 +1,46 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey, JSON, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class Supplier(Base):
__tablename__ = "suppliers"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
# Alternative names for this supplier (for OCR matching)
# Example: ["US Foods Inc", "USF", "U.S. Foods"]
aliases: Mapped[Optional[list]] = mapped_column(JSON, default=list)
# Template configuration for OCR extraction patterns
# Example: {"invoice_number": "INV-\\d+", "date": "\\d{2}/\\d{2}/\\d{4}", "total": "Total:\\s*£?([\\d,]+\\.\\d{2})"}
template_config: Mapped[dict] = mapped_column(JSON, default=dict)
# Identifier patterns to auto-detect this supplier from invoices
# Example: {"keywords": ["Sysco", "SYSCO FOODS"], "logo_hash": "abc123"}
identifier_config: Mapped[dict] = mapped_column(JSON, default=dict)
# Skip sending invoices from this supplier to Dext
skip_dext: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Purchase order fields
order_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
account_number: Mapped[str | None] = mapped_column(String(100), 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", back_populates="suppliers")
invoices: Mapped[list["Invoice"]] = relationship("Invoice", back_populates="supplier")
purchase_orders: Mapped[list["PurchaseOrder"]] = relationship("PurchaseOrder", back_populates="supplier")
# Forward reference
from .user import Kitchen
from .invoice import Invoice
from .purchase_order import PurchaseOrder

94
backend/models/user.py Normal file
View file

@ -0,0 +1,94 @@
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class Kitchen(Base):
__tablename__ = "kitchens"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
users: Mapped[list["User"]] = relationship("User", back_populates="kitchen")
suppliers: Mapped[list["Supplier"]] = relationship("Supplier", back_populates="kitchen")
invoices: Mapped[list["Invoice"]] = relationship("Invoice", back_populates="kitchen")
revenue_entries: Mapped[list["RevenueEntry"]] = relationship("RevenueEntry", back_populates="kitchen")
gp_periods: Mapped[list["GPPeriod"]] = relationship("GPPeriod", back_populates="kitchen")
settings: Mapped["KitchenSettings"] = relationship("KitchenSettings", back_populates="kitchen", uselist=False)
# Newbook relationships
newbook_gl_accounts: Mapped[list["NewbookGLAccount"]] = relationship(
"NewbookGLAccount", back_populates="kitchen", cascade="all, delete-orphan"
)
newbook_daily_revenue: Mapped[list["NewbookDailyRevenue"]] = relationship(
"NewbookDailyRevenue", back_populates="kitchen", cascade="all, delete-orphan"
)
newbook_daily_occupancy: Mapped[list["NewbookDailyOccupancy"]] = relationship(
"NewbookDailyOccupancy", back_populates="kitchen", cascade="all, delete-orphan"
)
newbook_sync_logs: Mapped[list["NewbookSyncLog"]] = relationship(
"NewbookSyncLog", back_populates="kitchen", cascade="all, delete-orphan"
)
newbook_room_categories: Mapped[list["NewbookRoomCategory"]] = relationship(
"NewbookRoomCategory", back_populates="kitchen", cascade="all, delete-orphan"
)
# Resos relationships
resos_bookings: Mapped[list["ResosBooking"]] = relationship(
"ResosBooking", back_populates="kitchen", cascade="all, delete-orphan"
)
resos_daily_stats: Mapped[list["ResosDailyStats"]] = relationship(
"ResosDailyStats", back_populates="kitchen", cascade="all, delete-orphan"
)
resos_opening_hours: Mapped[list["ResosOpeningHour"]] = relationship(
"ResosOpeningHour", back_populates="kitchen", cascade="all, delete-orphan"
)
resos_sync_logs: Mapped[list["ResosSyncLog"]] = relationship(
"ResosSyncLog", back_populates="kitchen", cascade="all, delete-orphan"
)
# Dispute tracking relationships
invoice_disputes: Mapped[list["InvoiceDispute"]] = relationship(
"InvoiceDispute", back_populates="kitchen", cascade="all, delete-orphan"
)
# Logbook relationships
logbook_entries: Mapped[list["LogbookEntry"]] = relationship(
"LogbookEntry", back_populates="kitchen", cascade="all, delete-orphan"
)
# Email processing logs (IMAP inbox sync)
email_processing_logs: Mapped[list["EmailProcessingLog"]] = relationship(
"EmailProcessingLog", back_populates="kitchen", cascade="all, delete-orphan"
)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="users")
# Forward references for relationships
from .supplier import Supplier
from .invoice import Invoice
from .gp import RevenueEntry, GPPeriod
from .settings import KitchenSettings
from .newbook import NewbookGLAccount, NewbookDailyRevenue, NewbookDailyOccupancy, NewbookSyncLog, NewbookRoomCategory
from .resos import ResosBooking, ResosDailyStats, ResosOpeningHour, ResosSyncLog
from .dispute import InvoiceDispute
from .logbook import LogbookEntry
from .email_processing import EmailProcessingLog