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:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
44
backend/models/__init__.py
Normal file
44
backend/models/__init__.py
Normal 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",
|
||||
]
|
||||
BIN
backend/models/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/acknowledged_price.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/acknowledged_price.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/backup.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/backup.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/calendar_events.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/calendar_events.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/cost_distribution.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/cost_distribution.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/cover_override.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/cover_override.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/dispute.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/dispute.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/email_processing.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/email_processing.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/event_order.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/event_order.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/field_mapping.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/field_mapping.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/food_flag.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/food_flag.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/gp.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/gp.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/ingredient.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/ingredient.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/invoice.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/invoice.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/kds.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/kds.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/line_item.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/line_item.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/llm.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/llm.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/logbook.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/logbook.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/menu.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/menu.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/newbook.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/newbook.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/product_definition.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/product_definition.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/purchase_order.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/purchase_order.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/recipe.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/recipe.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/resos.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/resos.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/settings.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/settings.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/supplier.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/supplier.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/user.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/user.cpython-313.pyc
Normal file
Binary file not shown.
50
backend/models/acknowledged_price.py
Normal file
50
backend/models/acknowledged_price.py
Normal 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
45
backend/models/backup.py
Normal 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
|
||||
26
backend/models/calendar_events.py
Normal file
26
backend/models/calendar_events.py
Normal 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
|
||||
138
backend/models/cost_distribution.py
Normal file
138
backend/models/cost_distribution.py
Normal 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
|
||||
88
backend/models/cover_override.py
Normal file
88
backend/models/cover_override.py
Normal 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
252
backend/models/dispute.py
Normal 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)
|
||||
50
backend/models/email_processing.py
Normal file
50
backend/models/email_processing.py
Normal 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
|
||||
46
backend/models/event_order.py
Normal file
46
backend/models/event_order.py
Normal 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
|
||||
101
backend/models/field_mapping.py
Normal file
101
backend/models/field_mapping.py
Normal 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
133
backend/models/food_flag.py
Normal 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
56
backend/models/gp.py
Normal 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
|
||||
147
backend/models/ingredient.py
Normal file
147
backend/models/ingredient.py
Normal 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
148
backend/models/invoice.py
Normal 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
|
||||
69
backend/models/line_item.py
Normal file
69
backend/models/line_item.py
Normal 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
43
backend/models/llm.py
Normal 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
143
backend/models/logbook.py
Normal 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
76
backend/models/menu.py
Normal 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
171
backend/models/newbook.py
Normal 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
|
||||
70
backend/models/product_definition.py
Normal file
70
backend/models/product_definition.py
Normal 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
|
||||
81
backend/models/purchase_order.py
Normal file
81
backend/models/purchase_order.py
Normal 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
194
backend/models/recipe.py
Normal 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
145
backend/models/resos.py
Normal 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
238
backend/models/settings.py
Normal 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
|
||||
46
backend/models/supplier.py
Normal file
46
backend/models/supplier.py
Normal 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
94
backend/models/user.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue