feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date: - local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync - manager_dashboard: React manager UI with product/category management, reports, settings - waiter_pwa: React PWA for waiter devices - Category reparent endpoint and UI - Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response - QR code modal in AppInfoTab for waiter domain - Product form: number input spinners removed, category pre-selected on new product - Category row: count badge moved to far right Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
local_backend/models/__init__.py
Normal file
0
local_backend/models/__init__.py
Normal file
24
local_backend/models/business_day.py
Normal file
24
local_backend/models/business_day.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class BusinessDay(Base):
|
||||
__tablename__ = "business_days"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
status = Column(String, default="open", nullable=False) # 'open' | 'closed'
|
||||
opened_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
opened_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
opener = relationship("User", foreign_keys=[opened_by_id])
|
||||
closer = relationship("User", foreign_keys=[closed_by_id])
|
||||
shifts = relationship("WaiterShift", back_populates="business_day")
|
||||
38
local_backend/models/flag.py
Normal file
38
local_backend/models/flag.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class TableFlagDef(Base):
|
||||
"""Manager-configurable flag definitions (name, emoji, color)."""
|
||||
__tablename__ = "table_flag_defs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
emoji = Column(String, nullable=True)
|
||||
color = Column(String, nullable=True, default="#6b7280") # hex background
|
||||
text_color = Column(String, nullable=True, default=None) # hex text; None = white
|
||||
sort_order = Column(Integer, default=0, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
assignments = relationship("TableFlagAssignment", back_populates="flag_def", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class TableFlagAssignment(Base):
|
||||
"""Active flag on a specific table."""
|
||||
__tablename__ = "table_flag_assignments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
||||
flag_id = Column(Integer, ForeignKey("table_flag_defs.id"), nullable=False)
|
||||
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
assigned_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
flag_def = relationship("TableFlagDef", back_populates="assignments")
|
||||
assigned_by_user = relationship("User")
|
||||
48
local_backend/models/message.py
Normal file
48
local_backend/models/message.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class QuickMessageTemplate(Base):
|
||||
"""Manager-configurable quick message templates."""
|
||||
__tablename__ = "quick_message_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
body = Column(String, nullable=False)
|
||||
sort_order = Column(Integer, default=0, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
|
||||
class StaffMessage(Base):
|
||||
"""A message sent from a manager to one or more waiters."""
|
||||
__tablename__ = "staff_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
body = Column(Text, nullable=False)
|
||||
# JSON arrays stored as text: "[1,2,3]" for waiter ids, "[5,6]" for table ids
|
||||
target_waiter_ids = Column(Text, nullable=False, default="[]")
|
||||
table_ids = Column(Text, nullable=False, default="[]")
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
sender = relationship("User", foreign_keys=[sender_id])
|
||||
acks = relationship("StaffMessageAck", back_populates="message", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class StaffMessageAck(Base):
|
||||
"""Acknowledgement by a specific waiter for a specific message."""
|
||||
__tablename__ = "staff_message_acks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("staff_messages.id"), nullable=False)
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
acked_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
message = relationship("StaffMessage", back_populates="acks")
|
||||
waiter = relationship("User")
|
||||
131
local_backend/models/order.py
Normal file
131
local_backend/models/order.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Order(Base):
|
||||
__tablename__ = "orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
||||
opened_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
opened_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
|
||||
|
||||
table = relationship("Table", back_populates="orders")
|
||||
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
|
||||
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed")
|
||||
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
|
||||
print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")
|
||||
audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan")
|
||||
discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class OrderWaiter(Base):
|
||||
__tablename__ = "order_waiters"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
order = relationship("Order", back_populates="waiters")
|
||||
waiter = relationship("User", back_populates="order_assignments")
|
||||
|
||||
|
||||
class OrderItem(Base):
|
||||
__tablename__ = "order_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
added_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
unit_price = Column(Float, nullable=False) # price snapshot at time of order
|
||||
selected_options = Column(Text, nullable=True) # JSON array of option ids
|
||||
removed_ingredients = Column(Text, nullable=True) # JSON array of ingredient ids
|
||||
notes = Column(Text, nullable=True)
|
||||
status = Column(String, default="active", nullable=False) # active|paid|cancelled
|
||||
added_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
printed = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Payment tracking
|
||||
paid_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
|
||||
paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=True)
|
||||
|
||||
order = relationship("Order", back_populates="items")
|
||||
product = relationship("Product", back_populates="order_items")
|
||||
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
|
||||
paid_by_user = relationship("User", foreign_keys=[paid_by], back_populates="items_paid")
|
||||
|
||||
|
||||
class PrintLog(Base):
|
||||
__tablename__ = "print_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
printer_id = Column(Integer, ForeignKey("printers.id"), nullable=False)
|
||||
printed_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
item_ids = Column(Text, nullable=False) # JSON array of order_item ids
|
||||
success = Column(Boolean, nullable=False)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("Order", back_populates="print_logs")
|
||||
printer = relationship("Printer", back_populates="print_logs")
|
||||
|
||||
|
||||
class OrderAuditLog(Base):
|
||||
"""Immutable append-only audit trail for every action on an order."""
|
||||
__tablename__ = "order_audit_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
event_type = Column(String, nullable=False)
|
||||
# ORDER_OPENED | ITEMS_ADDED | PAYMENT | PAYMENT_OFFLINE | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids
|
||||
amount = Column(Float, nullable=True) # total value for PAYMENT events
|
||||
payment_method = Column(String, nullable=True)
|
||||
note = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
# Emergency offline payment fields
|
||||
offline_uuid = Column(String, nullable=True) # client-generated UUID for dedup
|
||||
offline_at = Column(String, nullable=True) # ISO timestamp from client
|
||||
is_duplicate = Column(Integer, nullable=False, default=0) # 1 = duplicate payment flagged
|
||||
|
||||
order = relationship("Order", back_populates="audit_logs")
|
||||
waiter = relationship("User")
|
||||
|
||||
@property
|
||||
def waiter_name(self):
|
||||
return self.waiter.username if self.waiter else None
|
||||
|
||||
|
||||
class OrderDiscount(Base):
|
||||
"""Records a discount applied to an order or a specific item."""
|
||||
__tablename__ = "order_discounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) # NULL = whole-order discount
|
||||
discount_type = Column(String, nullable=False) # 'percent' | 'fixed'
|
||||
discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00
|
||||
applied_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
applied_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
reason = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("Order", back_populates="discounts")
|
||||
item = relationship("OrderItem")
|
||||
applied_by_user = relationship("User")
|
||||
17
local_backend/models/printer.py
Normal file
17
local_backend/models/printer.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
class Printer(Base):
|
||||
__tablename__ = "printers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
ip_address = Column(String, nullable=False)
|
||||
port = Column(Integer, default=9100, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
protocol = Column(String, default="escpos_tcp", nullable=False)
|
||||
|
||||
products = relationship("Product", back_populates="printer_zone")
|
||||
print_logs = relationship("PrintLog", back_populates="printer")
|
||||
123
local_backend/models/product.py
Normal file
123
local_backend/models/product.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
color = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
# self-referential: null = top-level, non-null = sub-category of parent
|
||||
parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
# position of the "General" group (direct products) among sub-categories
|
||||
general_sort_order = Column(Integer, default=0, nullable=False)
|
||||
# sub-categories only: if True, the accordion section is expanded by default on the PWA
|
||||
auto_expanded = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
products = relationship("Product", back_populates="category")
|
||||
subcategories = relationship("Category", back_populates="parent", foreign_keys="Category.parent_id")
|
||||
parent = relationship("Category", back_populates="subcategories", remote_side="Category.id", foreign_keys="Category.parent_id")
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
base_price = Column(Float, nullable=False)
|
||||
is_available = Column(Boolean, default=True, nullable=False)
|
||||
# "active" | "archived" — archived products are kept for order history but hidden from active use
|
||||
lifecycle_status = Column(String, default="active", nullable=False)
|
||||
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
||||
image_url = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, default=0, nullable=False)
|
||||
|
||||
category = relationship("Category", back_populates="products")
|
||||
printer_zone = relationship("Printer", back_populates="products")
|
||||
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
|
||||
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
|
||||
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
|
||||
preference_sets = relationship("ProductPreferenceSet", back_populates="product", cascade="all, delete-orphan")
|
||||
order_items = relationship("OrderItem", back_populates="product")
|
||||
|
||||
|
||||
class ProductOption(Base):
|
||||
__tablename__ = "product_options"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
allow_multiple = Column(Boolean, default=False, nullable=False)
|
||||
# JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked
|
||||
sub_choices = Column(Text, nullable=True)
|
||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="options")
|
||||
|
||||
|
||||
class ProductQuickOption(Base):
|
||||
__tablename__ = "product_quick_options"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
price = Column(Float, default=0.0, nullable=False)
|
||||
allow_multiple = Column(Boolean, default=False, nullable=False)
|
||||
sort_order = Column(Integer, default=0, nullable=False)
|
||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||
is_compact = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="quick_options")
|
||||
|
||||
|
||||
class ProductIngredient(Base):
|
||||
__tablename__ = "product_ingredients"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="ingredients")
|
||||
|
||||
|
||||
class ProductPreferenceSet(Base):
|
||||
__tablename__ = "product_preference_sets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
default_choice_id = Column(Integer, nullable=True)
|
||||
# JSON: {name, default_choice_index, choices:[{name,extra_cost,is_default}]}
|
||||
# Shared sub-set shown for all choices that don't have disables_subset=True
|
||||
shared_subset = Column(Text, nullable=True)
|
||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="preference_sets")
|
||||
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ProductPreferenceChoice(Base):
|
||||
__tablename__ = "product_preference_choices"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
# JSON array of sub-choice objects: [{name, extra_cost, is_default}]
|
||||
# Per-choice inline sub-preference shown only when this choice is selected.
|
||||
sub_choices = Column(Text, nullable=True)
|
||||
# When True this choice hides the set-level shared_subset on the PWA.
|
||||
disables_subset = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
set = relationship("ProductPreferenceSet", back_populates="choices")
|
||||
19
local_backend/models/settings.py
Normal file
19
local_backend/models/settings.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class PosSettings(Base):
|
||||
__tablename__ = "pos_settings"
|
||||
|
||||
key = Column(String, primary_key=True)
|
||||
value = Column(String, nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False)
|
||||
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
updated_by = relationship("User", foreign_keys=[updated_by_id])
|
||||
36
local_backend/models/shift.py
Normal file
36
local_backend/models/shift.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Column, Integer, Float, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class WaiterShift(Base):
|
||||
__tablename__ = "waiter_shifts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=False)
|
||||
started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
ended_at = Column(DateTime(timezone=True), nullable=True)
|
||||
starting_cash = Column(Float, nullable=True)
|
||||
total_collected = Column(Float, nullable=True) # snapshot written at shift end
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
waiter = relationship("User", foreign_keys=[waiter_id])
|
||||
business_day = relationship("BusinessDay", back_populates="shifts")
|
||||
breaks = relationship("ShiftBreak", back_populates="shift", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ShiftBreak(Base):
|
||||
__tablename__ = "shift_breaks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=False)
|
||||
started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
ended_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
shift = relationship("WaiterShift", back_populates="breaks")
|
||||
31
local_backend/models/table.py
Normal file
31
local_backend/models/table.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
class TableGroup(Base):
|
||||
__tablename__ = "table_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, unique=True)
|
||||
prefix = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
color = Column(String, nullable=True)
|
||||
|
||||
tables = relationship("Table", back_populates="group")
|
||||
waiter_zones = relationship("WaiterZone", back_populates="group")
|
||||
|
||||
|
||||
class Table(Base):
|
||||
__tablename__ = "tables"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
number = Column(Integer, nullable=False)
|
||||
label = Column(String, nullable=True)
|
||||
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
floor_x = Column(Float, nullable=True)
|
||||
floor_y = Column(Float, nullable=True)
|
||||
|
||||
group = relationship("TableGroup", back_populates="tables")
|
||||
orders = relationship("Order", back_populates="table")
|
||||
70
local_backend/models/user.py
Normal file
70
local_backend/models/user.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, nullable=False, index=True)
|
||||
pin_hash = Column(String, nullable=False)
|
||||
password_hash = Column(String, nullable=True)
|
||||
email = Column(String, nullable=True)
|
||||
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
full_name = Column(String, nullable=True)
|
||||
nickname = Column(String, nullable=True)
|
||||
mobile_phone = Column(String, nullable=True)
|
||||
avatar_url = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
||||
order_items = relationship("OrderItem", foreign_keys="OrderItem.added_by", back_populates="added_by_user")
|
||||
items_paid = relationship("OrderItem", foreign_keys="OrderItem.paid_by", back_populates="paid_by_user")
|
||||
order_assignments = relationship("OrderWaiter", back_populates="waiter")
|
||||
zone_assignments = relationship("WaiterZone", back_populates="waiter", cascade="all, delete-orphan")
|
||||
|
||||
primary_assignments = relationship(
|
||||
"AssistantAssignment",
|
||||
foreign_keys="AssistantAssignment.primary_waiter_id",
|
||||
back_populates="primary_waiter",
|
||||
)
|
||||
assistant_assignments = relationship(
|
||||
"AssistantAssignment",
|
||||
foreign_keys="AssistantAssignment.assistant_waiter_id",
|
||||
back_populates="assistant_waiter",
|
||||
)
|
||||
|
||||
|
||||
class WaiterZone(Base):
|
||||
"""Maps a waiter to a table group they are allowed to operate in.
|
||||
If a waiter has NO rows here, they see NOTHING.
|
||||
A sentinel row with group_id=NULL means 'all zones'."""
|
||||
__tablename__ = "waiter_zones"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True) # NULL = all zones
|
||||
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
waiter = relationship("User", back_populates="zone_assignments")
|
||||
group = relationship("TableGroup", back_populates="waiter_zones")
|
||||
|
||||
|
||||
class AssistantAssignment(Base):
|
||||
__tablename__ = "assistant_assignments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
primary_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
assistant_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
primary_waiter = relationship("User", foreign_keys=[primary_waiter_id], back_populates="primary_assignments")
|
||||
assistant_waiter = relationship("User", foreign_keys=[assistant_waiter_id], back_populates="assistant_assignments")
|
||||
Reference in New Issue
Block a user