Backend overhaul: new models, routers, schemas for shifts, business day, flags, messages, settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 12:12:05 +03:00
parent 603fd45eaa
commit defc49f84f
31 changed files with 2626 additions and 55 deletions

View File

@@ -9,13 +9,23 @@ from middleware.license_check import LicenseCheckMiddleware
from services.cloud_sync import start_cloud_sync
# Import all models so SQLAlchemy can create their tables
import models.user # noqa: F401 — also registers WaiterZone
import models.table # noqa: F401
import models.printer # noqa: F401
import models.product # noqa: F401
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount
import models.user # noqa: F401 — also registers WaiterZone
import models.table # noqa: F401
import models.printer # noqa: F401
import models.product # noqa: F401
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount
import models.business_day # noqa: F401
import models.shift # noqa: F401 — registers WaiterShift, ShiftBreak
import models.settings # noqa: F401
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
from routers import auth, tables, products, orders, waiters, reports, system
from routers import business_day as business_day_router
from routers import shifts as shifts_router
from routers import settings as settings_router
from routers import flags as flags_router
from routers import messages as messages_router
def _run_migrations():
@@ -64,6 +74,17 @@ def _run_migrations():
"ALTER TABLE users ADD COLUMN nickname VARCHAR",
"ALTER TABLE users ADD COLUMN mobile_phone VARCHAR",
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR",
# Quick options (flat, allow_multiple)
"""CREATE TABLE IF NOT EXISTS product_quick_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES products(id),
name VARCHAR NOT NULL,
price REAL NOT NULL DEFAULT 0.0,
allow_multiple INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
)""",
# allow_multiple flag on extras (product_options)
"ALTER TABLE product_options ADD COLUMN allow_multiple INTEGER NOT NULL DEFAULT 0",
# Discounts table (future-proofed, schema ready now)
"""CREATE TABLE IF NOT EXISTS order_discounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -75,6 +96,87 @@ def _run_migrations():
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
reason TEXT
)""",
# Business day scoping on orders
"ALTER TABLE orders ADD COLUMN business_day_id INTEGER REFERENCES business_days(id)",
# Shift attribution on paid items
"ALTER TABLE order_items ADD COLUMN paid_in_shift_id INTEGER REFERENCES waiter_shifts(id)",
# Seed default POS settings (INSERT OR IGNORE = no-op if already exists)
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_start', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_end', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('business_day.force_close_allowed', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('flags.display_mode', 'both', CURRENT_TIMESTAMP)",
# Table flags
"""CREATE TABLE IF NOT EXISTS table_flag_defs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
emoji VARCHAR,
color VARCHAR DEFAULT '#6b7280',
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE TABLE IF NOT EXISTS table_flag_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id INTEGER NOT NULL REFERENCES tables(id),
flag_id INTEGER NOT NULL REFERENCES table_flag_defs(id),
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
assigned_by INTEGER REFERENCES users(id)
)""",
# Staff messaging
"""CREATE TABLE IF NOT EXISTS quick_message_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body VARCHAR NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE TABLE IF NOT EXISTS staff_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
target_waiter_ids TEXT NOT NULL DEFAULT '[]',
table_ids TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE TABLE IF NOT EXISTS staff_message_acks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES staff_messages(id),
waiter_id INTEGER NOT NULL REFERENCES users(id),
acked_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Seed default flag definitions
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (1, 'Χρειάζεται καθάρισμα', '🧹', '#ef4444', 1)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (2, 'Χρειάζεται Βοήθεια', '🆘', '#f97316', 2)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (3, 'Χρειάζεται Σερβιτόρο', '🔔', '#eab308', 3)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (4, 'Περιμένει να πληρώσει', '💳', '#3b82f6', 4)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (5, 'VIP', '', '#8b5cf6', 5)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (6, 'Ευγενικός Πελάτης', '😊', '#22c55e', 6)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (7, 'Αγενής Πελάτης', '😤', '#dc2626', 7)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (8, 'Αλλεργίες', '⚠️', '#f59e0b', 8)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (9, 'Παιδιά στο τραπέζι', '👶', '#06b6d4', 9)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (10, 'Επέτειος / Γενέθλια', '🎂', '#ec4899', 10)",
# Seed default quick message templates
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (1, 'Σε χρειάζομαι τώρα', 1)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (2, 'Πάρε διάλειμμα', 2)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (3, 'Ετοιμάσου για κλείσιμο', 3)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (4, 'Ήρθε νέος πελάτης', 4)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (5, 'Ο πελάτης περιμένει να πληρώσει', 5)",
# Product lifecycle status (active / archived)
"ALTER TABLE products ADD COLUMN lifecycle_status VARCHAR NOT NULL DEFAULT 'active'",
# Favorite flags + ordering on all product sub-item types
"ALTER TABLE product_quick_options ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_quick_options ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_options ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_options ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_ingredients ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_ingredients ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_preference_sets ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_preference_sets ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
# Sub-category support
"ALTER TABLE categories ADD COLUMN parent_id INTEGER REFERENCES categories(id)",
"ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0",
# Auto-expand flag for sub-categories on the PWA accordion
"ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0",
]
for sql in migrations:
try:
@@ -114,10 +216,15 @@ AVATAR_DIR = "/app/data/avatars"
os.makedirs(AVATAR_DIR, exist_ok=True)
app.mount("/static/avatars", StaticFiles(directory=AVATAR_DIR), name="avatars")
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(tables.router, prefix="/api/tables", tags=["tables"])
app.include_router(products.router, prefix="/api/products", tags=["products"])
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
app.include_router(waiters.router, prefix="/api/waiters", tags=["waiters"])
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
app.include_router(system.router, prefix="/api/system", tags=["system"])
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(tables.router, prefix="/api/tables", tags=["tables"])
app.include_router(products.router, prefix="/api/products", tags=["products"])
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
app.include_router(waiters.router, prefix="/api/waiters", tags=["waiters"])
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
app.include_router(system.router, prefix="/api/system", tags=["system"])
app.include_router(business_day_router.router, prefix="/api/business-day", tags=["business-day"])
app.include_router(shifts_router.router, prefix="/api/shifts", tags=["shifts"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(flags_router.router, prefix="/api/flags", tags=["flags"])
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])

View 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")

View File

@@ -0,0 +1,37 @@
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
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")

View 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")

View File

@@ -1,20 +1,25 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime
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, default=datetime.utcnow)
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")
@@ -32,7 +37,7 @@ class OrderWaiter(Base):
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, default=datetime.utcnow)
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
order = relationship("Order", back_populates="waiters")
waiter = relationship("User", back_populates="order_assignments")
@@ -51,13 +56,14 @@ class OrderItem(Base):
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, default=datetime.utcnow)
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")
@@ -71,7 +77,7 @@ class PrintLog(Base):
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, default=datetime.utcnow)
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)
@@ -93,7 +99,7 @@ class OrderAuditLog(Base):
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, default=datetime.utcnow)
created_at = Column(DateTime(timezone=True), default=_utcnow)
order = relationship("Order", back_populates="audit_logs")
waiter = relationship("User")
@@ -113,7 +119,7 @@ class OrderDiscount(Base):
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, default=datetime.utcnow)
applied_at = Column(DateTime(timezone=True), default=_utcnow)
reason = Column(Text, nullable=True)
order = relationship("Order", back_populates="discounts")

View File

@@ -10,8 +10,16 @@ class Category(Base):
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):
@@ -22,12 +30,15 @@ class Product(Base):
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")
@@ -41,12 +52,30 @@ class ProductOption(Base):
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)
product = relationship("Product", back_populates="quick_options")
class ProductIngredient(Base):
__tablename__ = "product_ingredients"
@@ -54,6 +83,8 @@ class ProductIngredient(Base):
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")
@@ -68,6 +99,8 @@ class ProductPreferenceSet(Base):
# 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")

View 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])

View 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")

View File

@@ -1,9 +1,13 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
@@ -16,7 +20,7 @@ class User(Base):
nickname = Column(String, nullable=True)
mobile_phone = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
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")
@@ -46,7 +50,7 @@ class WaiterZone(Base):
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, default=datetime.utcnow)
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
waiter = relationship("User", back_populates="zone_assignments")
group = relationship("TableGroup", back_populates="waiter_zones")
@@ -58,7 +62,7 @@ class AssistantAssignment(Base):
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, default=datetime.utcnow)
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")

305
local_backend/print_test.py Normal file
View File

@@ -0,0 +1,305 @@
"""
Printer font & symbol test script.
Usage (inside Docker): python print_test.py [IP] [PORT]
Defaults to 10.98.20.25:9100
"""
import sys
PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25"
PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
from escpos.printer import Network
def _gr(text: str) -> bytes:
return text.encode('cp737', errors='replace')
def _open():
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
p._raw(b'\x1b\x40') # ESC @ reset
p._raw(b'\x1b\x74\x1d') # CP737 Greek code page
return p
def _t(p, text: str):
p._raw(_gr(text))
def _divider(p, char="-", width=48):
p._raw(b'\x1b\x61\x00')
_t(p, char * width + "\n")
def _center(p):
p._raw(b'\x1b\x61\x01')
def _left(p):
p._raw(b'\x1b\x61\x00')
# ── ESC ! n byte reference ──────────────────────────────────────────────────
# Bit 0 → underline (not tested, minor)
# Bit 1 → double-strike (bold)
# Bit 3 → double-height
# Bit 4 → double-width
# Bit 5 → delete-line
# Bit 7 → bold (ESC E alias in some models)
# Common combos used here:
# 0x00 = normal
# 0x08 = double-height only (48 chars wide)
# 0x10 = double-height only (alt bit) (48 chars wide)
# 0x18 = double-height + bold
# 0x20 = double-width only (24 chars wide)
# 0x30 = double-width + double-height (24 chars wide)
# 0x38 = double-width + double-height + bold
# 0x48 = double-height (bit 6 combo — some printers)
MODES = [
(0x00, "Normal (0x00)"),
(0x08, "Double-height bit3 (0x08)"),
(0x10, "Double-height bit4 (0x10)"),
(0x18, "Double-height + Bold (0x18)"),
(0x20, "Double-width (0x20)"),
(0x30, "Double-width + Double-height (0x30)"),
(0x38, "Double-width + Double-height + Bold (0x38)"),
]
# ── Section 1 — Font sizes & styles, English ───────────────────────────────
def section_english(p):
_center(p)
p._raw(b'\x1b\x21\x38')
_t(p, "=== FONT SIZES (EN) ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
for code, label in MODES:
_left(p)
_t(p, f"[{label}]\n")
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "TEST PRINT Hello World Abc123\n")
p._raw(b'\x1b\x21\x00')
# bold on/off via ESC E
_t(p, " -> bold ON: ")
p._raw(b'\x1b\x45\x01')
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "Bold Sample\n")
p._raw(b'\x1b\x45\x00')
p._raw(b'\x1b\x21\x00')
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 2 — Font sizes & styles, Greek ─────────────────────────────────
def section_greek(p):
_center(p)
p._raw(b'\x1b\x21\x38')
_t(p, "=== FONT SIZES (GR) ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
for code, label in MODES:
_left(p)
_t(p, f"[{label}]\n")
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "ΔΟΚΙΜΑΣΤΙΚΗ ΕΚΤΥΠΩΣΗ\n")
_t(p, "δοκιμαστικη εκτυπωση\n") # lowercase
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x01')
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "Bold: Καλημερα Κοσμε\n")
p._raw(b'\x1b\x45\x00')
p._raw(b'\x1b\x21\x00')
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 3 — All printable ASCII symbols ────────────────────────────────
def section_ascii_symbols(p):
_center(p)
p._raw(b'\x1b\x21\x18') # double-height bold for header
_t(p, "=== ASCII SYMBOLS ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
# Printable ASCII 0x20 0x7E, 16 per line
chars = [chr(c) for c in range(0x20, 0x7F)]
line = ""
for i, ch in enumerate(chars):
line += ch + " "
if (i + 1) % 24 == 0:
_t(p, line + "\n")
line = ""
if line:
_t(p, line + "\n")
_t(p, "\n")
_t(p, "Notable:\n")
_t(p, " Bullets : * + - # @ ! ? > < | / \\ ^ ~ _\n")
_t(p, " Framing : [ ] { } ( ) = : ; , . \"\n")
_t(p, " Currency : $ %\n")
_divider(p)
p._raw(b'\n')
# ── Section 4 — CP737 extended chars (0x800xFF) ───────────────────────────
def section_extended(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== CP737 EXTENDED (0x80-FF) ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
_t(p, "Hex offset rows x16 columns\n\n")
for row in range(8): # 0x80, 0x90 ... 0xF0
base = 0x80 + row * 16
row_bytes = bytes(range(base, base + 16))
label = f"0x{base:02X}: "
p._raw(_gr(label))
p._raw(row_bytes)
p._raw(b'\n')
_t(p, "\n")
_t(p, "Key CP737 specials:\n")
specials = [
(0xB3, "─ thin horiz line"),
(0xC4, "─ double line"),
(0xBA, "│ vert line"),
(0xBB, "┐ corner"),
(0xBC, "┘ corner"),
(0xC9, "╔ corner"),
(0xCA, "╩ junction"),
(0xCB, "╦ junction"),
(0xCC, "╠ junction"),
(0xCD, "═ double horiz"),
(0xCE, "╬ cross"),
(0xC8, "╚ corner"),
(0xBB, "╗ corner"),
(0xBC, "╝ corner"),
(0xDB, "█ full block"),
(0xDC, "▄ lower block"),
(0xDF, "▀ upper block"),
(0xB0, "░ light shade"),
(0xB1, "▒ medium shade"),
(0xB2, "▓ dark shade"),
(0xF8, "° degree"),
(0xF9, "· middle dot"),
(0xFA, "· bullet dot"),
(0xFB, "√ check / tick"),
(0xFE, "■ filled square"),
]
for code, desc in specials:
row_bytes = bytes([code, 0x20]) # char + space
p._raw(row_bytes)
_t(p, f" {desc}\n")
_divider(p)
p._raw(b'\n')
# ── Section 5 — Underline ──────────────────────────────────────────────────
def section_underline(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== UNDERLINE TEST ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
# ESC - n : underline 0=off, 1=thin, 2=thick
for ul in [1, 2]:
p._raw(bytes([0x1b, 0x2d, ul]))
_t(p, f"Underline mode {ul}: TEST PRINT Abc123\n")
p._raw(b'\x1b\x2d\x00') # off
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 6 — Inverted / white-on-black ─────────────────────────────────
def section_invert(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== INVERT (white-on-black) ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
# GS B n — invert
p._raw(b'\x1d\x42\x01')
_t(p, " INVERTED TEXT SAMPLE \n")
p._raw(b'\x1d\x42\x00')
_t(p, "Normal text after invert\n")
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 7 — QR Code sample ────────────────────────────────────────────
def section_qr(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== QR CODE SAMPLE ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
data = b"https://pos.test"
# GS ( k — QR store data
store_len = len(data) + 3
p._raw(b'\x1d\x28\x6b' + bytes([store_len & 0xFF, (store_len >> 8) & 0xFF, 0x31, 0x50, 0x30]) + data)
# GS ( k — set size (module=6)
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x43\x06')
# GS ( k — error correction level M
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x45\x31')
# GS ( k — print
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x51\x30')
_t(p, "\nhttps://pos.test\n\n")
_divider(p)
p._raw(b'\n')
# ── Main ───────────────────────────────────────────────────────────────────
def main():
print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT} ...")
# ---- PAGE 1: English fonts ----
p = _open()
section_english(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 1 sent (English fonts)")
# ---- PAGE 2: Greek fonts ----
p = _open()
section_greek(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 2 sent (Greek fonts)")
# ---- PAGE 3: Symbols & special chars ----
p = _open()
section_ascii_symbols(p)
section_extended(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 3 sent (ASCII + CP737 extended)")
# ---- PAGE 4: Underline + Invert + QR ----
p = _open()
section_underline(p)
section_invert(p)
section_qr(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 4 sent (underline / invert / QR)")
print("Done — 4 pages printed.")
if __name__ == "__main__":
main()

View File

@@ -5,6 +5,11 @@ from sqlalchemy.orm import Session
from database import get_db
from models.user import User
from schemas.auth import LoginRequest, TokenResponse
from pydantic import BaseModel as _PydanticBase
class LoginByIdRequest(_PydanticBase):
waiter_id: int
pin: str
from schemas.user import UserOut
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
@@ -20,6 +25,16 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
@router.post("/login-by-id", response_model=TokenResponse)
def login_by_id(body: LoginByIdRequest, db: Session = Depends(get_db)):
"""Login using waiter id + PIN (used by the waiter-picker login screen)."""
user = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Λανθασμένο PIN")
token = make_token(user)
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
@router.post("/refresh", response_model=TokenResponse)
def refresh(token: str, db: Session = Depends(get_db)):
payload = decode_token(token)
@@ -40,3 +55,37 @@ def logout(token: str):
@router.get("/me", response_model=UserOut)
def me(user: User = Depends(get_current_user)):
return user
# ─── Public waiter list (login screen — no auth required) ────────────────────
from pydantic import BaseModel as _BaseModel
class PublicWaiterOut(_BaseModel):
id: int
full_name: str | None
nickname: str | None
avatar_url: str | None
on_shift: bool
model_config = {"from_attributes": True}
@router.get("/waiters", response_model=list[PublicWaiterOut])
def public_waiter_list(db: Session = Depends(get_db)):
"""Public endpoint — returns active waiters with on-shift flag. No auth required."""
from models.shift import WaiterShift
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
on_shift_ids = {
row.waiter_id
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
}
return [
PublicWaiterOut(
id=w.id,
full_name=w.full_name,
nickname=w.nickname,
avatar_url=w.avatar_url,
on_shift=w.id in on_shift_ids,
)
for w in waiters
]

View File

@@ -0,0 +1,162 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime, timezone
from database import get_db
from models.business_day import BusinessDay
from models.order import Order, OrderItem, OrderAuditLog
from models.shift import WaiterShift
from models.flag import TableFlagAssignment
from models.message import StaffMessage, StaffMessageAck
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
from routers.deps import get_current_user, require_manager
from models.user import User
router = APIRouter()
def _dt(dt):
if dt is None:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
@router.get("/current", response_model=Optional[BusinessDayOut])
def get_current_business_day(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(BusinessDay).filter(BusinessDay.status == "open").first()
@router.post("/open", response_model=BusinessDayOut, status_code=status.HTTP_201_CREATED)
def open_business_day(
body: OpenBusinessDayRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
existing = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if existing:
raise HTTPException(status_code=400, detail="A business day is already open")
day = BusinessDay(opened_by_id=user.id, notes=body.notes)
db.add(day)
db.commit()
db.refresh(day)
return day
@router.post("/close", response_model=BusinessDayOut)
def close_business_day(
body: CloseBusinessDayRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if not day:
raise HTTPException(status_code=404, detail="No open business day")
open_orders = db.query(Order).filter(
Order.business_day_id == day.id,
Order.status.in_(["open", "partially_paid"]),
).all()
if open_orders and not body.force:
# Count orders that have at least one active (unpaid) item — covers both
# "open" (fully unpaid) and "partially_paid" (partially unpaid) orders.
with_pending = sum(
1 for o in open_orders
if db.query(OrderItem).filter(
OrderItem.order_id == o.id,
OrderItem.status == "active",
).first() is not None
)
raise HTTPException(
status_code=409,
detail={
"message": f"{len(open_orders)} table(s) still open, {with_pending} with unpaid items.",
"open_orders": len(open_orders),
"partially_paid": with_pending,
},
)
now = datetime.now(timezone.utc)
# Close all non-terminal orders for this business day (open, partially_paid, paid)
all_unclosed = db.query(Order).filter(
Order.business_day_id == day.id,
Order.status.in_(["open", "partially_paid", "paid"]),
).all()
for order in all_unclosed:
was_unpaid = order.status in ("open", "partially_paid")
order.status = "closed"
order.closed_at = now
order.closed_by = user.id
if was_unpaid:
db.add(OrderAuditLog(
order_id=order.id,
event_type="ORDER_CLOSED",
waiter_id=user.id,
note="Force-closed at end of business day",
))
active_shifts = db.query(WaiterShift).filter(
WaiterShift.business_day_id == day.id,
WaiterShift.ended_at == None,
).all()
for shift in active_shifts:
items = db.query(OrderItem).filter(
OrderItem.paid_in_shift_id == shift.id,
OrderItem.status == "paid",
).all()
shift.total_collected = sum(i.unit_price * i.quantity for i in items)
shift.ended_at = now
# Clear all table flags and staff messages — fresh slate for the next day
db.query(TableFlagAssignment).delete(synchronize_session=False)
db.query(StaffMessageAck).delete(synchronize_session=False)
db.query(StaffMessage).delete(synchronize_session=False)
day.status = "closed"
day.closed_at = now
day.closed_by_id = user.id
if body.notes:
day.notes = body.notes
db.commit()
db.refresh(day)
return day
@router.get("/history")
def business_day_history(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
days = db.query(BusinessDay).order_by(BusinessDay.opened_at.desc()).all()
result = []
for day in days:
order_count = db.query(Order).filter(Order.business_day_id == day.id).count()
revenue = (
db.query(func.sum(OrderItem.unit_price * OrderItem.quantity))
.join(Order)
.filter(Order.business_day_id == day.id, OrderItem.status == "paid")
.scalar() or 0.0
)
w_opener = day.opener
w_closer = day.closer
result.append({
"id": day.id,
"status": day.status,
"opened_at": _dt(day.opened_at),
"closed_at": _dt(day.closed_at),
"opened_by_id": day.opened_by_id,
"opened_by_name": (w_opener.full_name or w_opener.username) if w_opener else None,
"closed_by_id": day.closed_by_id,
"closed_by_name": (w_closer.full_name or w_closer.username) if w_closer else None,
"notes": day.notes,
"order_count": order_count,
"revenue": round(revenue, 2),
})
return {"business_days": result}

View File

@@ -0,0 +1,141 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.flag import TableFlagDef, TableFlagAssignment
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
from routers.deps import get_current_user, require_manager
from models.user import User
router = APIRouter()
# ─── Flag definitions (manager only) ─────────────────────────────────────────
@router.get("/defs", response_model=List[FlagDefOut])
def list_flag_defs(
include_inactive: bool = False,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
q = db.query(TableFlagDef)
if not include_inactive:
q = q.filter(TableFlagDef.is_active == True)
return q.order_by(TableFlagDef.sort_order, TableFlagDef.id).all()
@router.post("/defs", response_model=FlagDefOut, status_code=status.HTTP_201_CREATED)
def create_flag_def(
body: FlagDefCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
flag = TableFlagDef(**body.model_dump())
db.add(flag)
db.commit()
db.refresh(flag)
return flag
@router.put("/defs/{flag_id}", response_model=FlagDefOut)
def update_flag_def(
flag_id: int,
body: FlagDefUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
if not flag:
raise HTTPException(status_code=404, detail="Flag not found")
for k, v in body.model_dump(exclude_unset=True).items():
setattr(flag, k, v)
db.commit()
db.refresh(flag)
return flag
@router.delete("/defs/{flag_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_flag_def(
flag_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
if not flag:
raise HTTPException(status_code=404, detail="Flag not found")
# Soft delete — keeps existing assignments readable
flag.is_active = False
db.commit()
# ─── All assignments (bulk endpoint for manager views) ───────────────────────
@router.get("/assignments", response_model=List[FlagAssignmentOut])
def get_all_assignments(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""All active flag assignments across all tables (for manager dashboard bulk load)."""
return db.query(TableFlagAssignment).all()
# ─── Table flag assignments ───────────────────────────────────────────────────
@router.get("/table/{table_id}", response_model=List[FlagAssignmentOut])
def get_table_flags(
table_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(TableFlagAssignment).filter(
TableFlagAssignment.table_id == table_id
).all()
@router.put("/table/{table_id}", response_model=List[FlagAssignmentOut])
def set_table_flags(
table_id: int,
body: SetTableFlagsRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Replace all flags on a table with the given set of flag_ids."""
# Validate all flag_ids exist and are active
if body.flag_ids:
valid = db.query(TableFlagDef).filter(
TableFlagDef.id.in_(body.flag_ids),
TableFlagDef.is_active == True,
).count()
if valid != len(body.flag_ids):
raise HTTPException(status_code=400, detail="One or more flag IDs are invalid")
# Delete existing assignments for this table
db.query(TableFlagAssignment).filter(
TableFlagAssignment.table_id == table_id
).delete(synchronize_session=False)
# Insert new assignments
for flag_id in body.flag_ids:
db.add(TableFlagAssignment(
table_id=table_id,
flag_id=flag_id,
assigned_by=user.id,
))
db.commit()
return db.query(TableFlagAssignment).filter(
TableFlagAssignment.table_id == table_id
).all()
@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT)
def clear_table_flags(
table_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
db.query(TableFlagAssignment).filter(
TableFlagAssignment.table_id == table_id
).delete(synchronize_session=False)
db.commit()

View File

@@ -0,0 +1,199 @@
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from typing import List
from database import get_db
from models.message import StaffMessage, StaffMessageAck, QuickMessageTemplate
from models.user import User
from schemas.message import (
SendMessageRequest, StaffMessageOut,
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
)
from routers.deps import get_current_user, require_manager
router = APIRouter()
def _load_msg(db: Session, msg_id: int) -> StaffMessage:
"""Reload a message with sender and acks eagerly loaded."""
return db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).filter(StaffMessage.id == msg_id).one()
def _message_out(msg: StaffMessage) -> StaffMessageOut:
sender_name = None
try:
sender_name = msg.sender.username if msg.sender else None
except Exception:
pass
return StaffMessageOut(
id=msg.id,
sender_id=msg.sender_id,
sender_name=sender_name,
body=msg.body,
target_waiter_ids=msg.target_waiter_ids,
table_ids=msg.table_ids,
created_at=msg.created_at,
acked_by=[ack.waiter_id for ack in msg.acks],
)
# ─── Quick templates ──────────────────────────────────────────────────────────
@router.get("/templates", response_model=List[QuickTemplateOut])
def list_templates(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(QuickMessageTemplate).filter(
QuickMessageTemplate.is_active == True
).order_by(QuickMessageTemplate.sort_order, QuickMessageTemplate.id).all()
@router.post("/templates", response_model=QuickTemplateOut, status_code=status.HTTP_201_CREATED)
def create_template(
body: QuickTemplateCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
t = QuickMessageTemplate(**body.model_dump())
db.add(t)
db.commit()
db.refresh(t)
return t
@router.put("/templates/{template_id}", response_model=QuickTemplateOut)
def update_template(
template_id: int,
body: QuickTemplateUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
if not t:
raise HTTPException(status_code=404, detail="Template not found")
for k, v in body.model_dump(exclude_unset=True).items():
setattr(t, k, v)
db.commit()
db.refresh(t)
return t
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_template(
template_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
if not t:
raise HTTPException(status_code=404, detail="Template not found")
t.is_active = False
db.commit()
# ─── Staff messages ───────────────────────────────────────────────────────────
@router.post("/send", response_model=StaffMessageOut, status_code=status.HTTP_201_CREATED)
def send_message(
body: SendMessageRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
msg = StaffMessage(
sender_id=user.id,
body=body.body,
target_waiter_ids=json.dumps(body.target_waiter_ids),
table_ids=json.dumps(body.table_ids or []),
)
db.add(msg)
db.commit()
msg = _load_msg(db, msg.id)
return _message_out(msg)
@router.get("/unread", response_model=List[StaffMessageOut])
def get_unread_messages(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Returns messages targeting this waiter that they haven't acked yet.
A message targets a waiter if their ID is in target_waiter_ids,
OR if target_waiter_ids is empty (broadcast to all).
"""
all_msgs = db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).order_by(StaffMessage.created_at.desc()).limit(200).all()
acked_ids = {
ack.message_id
for ack in db.query(StaffMessageAck).filter(StaffMessageAck.waiter_id == user.id).all()
}
result = []
for msg in all_msgs:
if msg.id in acked_ids:
continue
targets = json.loads(msg.target_waiter_ids or "[]")
# Empty list = broadcast to all
if not targets or user.id in targets:
result.append(_message_out(msg))
return result
@router.get("/recent", response_model=List[StaffMessageOut])
def get_recent_messages(
limit: int = 10,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Last N messages targeting this user (for notification history drawer)."""
all_msgs = db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).order_by(StaffMessage.created_at.desc()).limit(200).all()
result = []
for msg in all_msgs:
targets = json.loads(msg.target_waiter_ids or "[]")
if not targets or user.id in targets:
result.append(_message_out(msg))
if len(result) >= limit:
break
return result
@router.post("/{message_id}/ack", status_code=status.HTTP_204_NO_CONTENT)
def ack_message(
message_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
msg = db.query(StaffMessage).filter(StaffMessage.id == message_id).first()
if not msg:
raise HTTPException(status_code=404, detail="Message not found")
existing = db.query(StaffMessageAck).filter(
StaffMessageAck.message_id == message_id,
StaffMessageAck.waiter_id == user.id,
).first()
if not existing:
db.add(StaffMessageAck(message_id=message_id, waiter_id=user.id))
db.commit()
@router.get("/all", response_model=List[StaffMessageOut])
def list_all_messages(
limit: int = 50,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
msgs = db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).order_by(StaffMessage.created_at.desc()).limit(limit).all()
return [_message_out(m) for m in msgs]

View File

@@ -1,5 +1,5 @@
import json
from datetime import datetime
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List, Optional
@@ -14,8 +14,25 @@ from pydantic import BaseModel
class PrintOrderRequest(BaseModel):
printer_id: int
class TransferOrderRequest(BaseModel):
target_table_id: int
class MergeOrderRequest(BaseModel):
target_order_id: int
class SplitItemRequest(BaseModel):
quantity: int # how many to split off into a new item row
class PrintSynopsisRequest(BaseModel):
printer_id: int
class MoveItemsRequest(BaseModel):
item_ids: List[int]
target_order_id: int
from routers.deps import get_current_user, require_manager
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
router = APIRouter()
@@ -79,6 +96,29 @@ def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_us
return direct + [o for o in also_opened if o.id not in seen]
class ActiveOrderSlim(BaseModel):
id: int
table_id: int
status: str
waiter_ids: List[int]
model_config = {"from_attributes": True}
@router.get("/active", response_model=List[ActiveOrderSlim])
def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""All currently open/partially-paid/paid orders (lightweight). Accessible to all staff."""
orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid", "paid"])).all()
return [
ActiveOrderSlim(
id=o.id,
table_id=o.table_id,
status=o.status,
waiter_ids=[w.waiter_id for w in o.waiters],
)
for o in orders
]
@router.get("/{order_id}", response_model=OrderOut)
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
order = db.query(Order).filter(Order.id == order_id).first()
@@ -91,13 +131,28 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
from models.business_day import BusinessDay
from models.shift import WaiterShift
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if not active_day:
raise HTTPException(status_code=403, detail="Restaurant is not open — manager must open the business day first")
if user.role == "waiter":
active_shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
if not active_shift:
raise HTTPException(status_code=403, detail="You do not have an active shift")
existing = db.query(Order).filter(
Order.table_id == body.table_id,
Order.status.in_(["open", "partially_paid"]),
Order.status.in_(["open", "partially_paid", "paid"]),
).first()
if existing:
raise HTTPException(status_code=400, detail="Table already has an open order")
order = Order(table_id=body.table_id, opened_by=user.id)
order = Order(table_id=body.table_id, opened_by=user.id, business_day_id=active_day.id)
db.add(order)
db.flush()
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
@@ -119,9 +174,13 @@ def add_items(
raise HTTPException(status_code=404, detail="Order not found")
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
if order.status not in ("open", "partially_paid"):
if order.status not in ("open", "partially_paid", "paid"):
raise HTTPException(status_code=400, detail="Order is not open")
# Adding items to a fully-paid order reopens it — partially_paid since prior items were paid
if order.status == "paid":
order.status = "partially_paid"
new_item_ids = []
for item_in in body.items:
product = db.query(Product).filter(Product.id == item_in.product_id).first()
@@ -154,6 +213,27 @@ def add_items(
return {"order": order, "print_results": print_results}
@router.post("/{order_id}/retry-print", response_model=AddItemsResponse)
def retry_print(
order_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
unprinted_ids = [item.id for item in order.items if not item.printed and item.status == "active"]
if not unprinted_ids:
return {"order": order, "print_results": []}
print_results = route_and_print_sync(order_id, unprinted_ids, db)
db.refresh(order)
return {"order": order, "print_results": print_results}
@router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut)
def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Session = Depends(get_db), user: User = Depends(require_manager)):
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
@@ -184,20 +264,28 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
from models.shift import WaiterShift
items = db.query(OrderItem).filter(
OrderItem.id.in_(body.item_ids),
OrderItem.order_id == order_id,
OrderItem.status == "active",
).all()
now = datetime.utcnow()
now = datetime.now(timezone.utc)
active_shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
total_paid = 0.0
for item in items:
item.status = "paid"
item.paid_by = user.id
item.paid_at = now
item.payment_method = body.payment_method
item.paid_in_shift_id = active_shift.id if active_shift else None
total_paid += item.unit_price * item.quantity
db.flush() # write item status changes before counting, since autoflush=False
active_remaining = db.query(OrderItem).filter(
OrderItem.order_id == order_id, OrderItem.status == "active"
).count()
@@ -220,7 +308,7 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
if order.status not in ("paid", "open", "partially_paid"):
raise HTTPException(status_code=400, detail="Cannot close order in current status")
order.status = "closed"
order.closed_at = datetime.utcnow()
order.closed_at = datetime.now(timezone.utc)
order.closed_by = user.id
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
db.commit()
@@ -233,14 +321,14 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
if not order:
raise HTTPException(status_code=404, detail="Order not found")
order.status = "cancelled"
order.closed_at = datetime.utcnow()
order.closed_at = datetime.now(timezone.utc)
order.closed_by = user.id
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
db.commit()
@router.put("/{order_id}/assign-waiter")
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
@@ -318,3 +406,295 @@ def print_order(
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
return {"status": "printing"}
# ─── Transfer order to a different table ─────────────────────────────────────
@router.post("/{order_id}/transfer")
def transfer_order(
order_id: int,
body: TransferOrderRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
if order.status not in ("open", "partially_paid", "paid"):
raise HTTPException(status_code=400, detail="Order is not active")
target_table = db.query(Table).filter(Table.id == body.target_table_id, Table.is_active == True).first()
if not target_table:
raise HTTPException(status_code=404, detail="Target table not found")
if body.target_table_id == order.table_id:
raise HTTPException(status_code=400, detail="Table is already assigned to this order")
conflict = db.query(Order).filter(
Order.table_id == body.target_table_id,
Order.status.in_(["open", "partially_paid", "paid"]),
).first()
if conflict:
raise HTTPException(status_code=400, detail="Target table already has an active order")
old_table_id = order.table_id
order.table_id = body.target_table_id
_audit(db, order_id, "TABLE_TRANSFER", waiter_id=user.id,
note=f"Transferred from table {old_table_id} to table {body.target_table_id}")
db.commit()
db.refresh(order)
return order
# ─── Merge another order into this one ───────────────────────────────────────
@router.post("/{order_id}/merge")
def merge_order(
order_id: int,
body: MergeOrderRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Merge source order (order_id) INTO target order (body.target_order_id).
All items (paid + active) from the source are reassigned to the target.
Source waiters are added to the target if not already there.
Source order is cancelled with audit note.
"""
source = db.query(Order).filter(Order.id == order_id).first()
if not source:
raise HTTPException(status_code=404, detail="Source order not found")
if not _can_access_order(source, user, db):
raise HTTPException(status_code=403, detail="Access denied")
if source.status not in ("open", "partially_paid", "paid"):
raise HTTPException(status_code=400, detail="Source order is not active")
target = db.query(Order).filter(Order.id == body.target_order_id).first()
if not target:
raise HTTPException(status_code=404, detail="Target order not found")
if not _can_access_order(target, user, db):
raise HTTPException(status_code=403, detail="Access denied to target order")
if target.status not in ("open", "partially_paid", "paid"):
raise HTTPException(status_code=400, detail="Target order is not active")
if source.id == target.id:
raise HTTPException(status_code=400, detail="Cannot merge an order with itself")
# Move all items to target order
moved_item_ids = []
for item in source.items:
item.order_id = target.id
moved_item_ids.append(item.id)
# Copy source waiters to target (no duplicates)
existing_waiter_ids = {w.waiter_id for w in target.waiters}
for ow in source.waiters:
if ow.waiter_id not in existing_waiter_ids:
db.add(OrderWaiter(order_id=target.id, waiter_id=ow.waiter_id))
# Recompute target status after flush
db.flush()
active_remaining = db.query(OrderItem).filter(
OrderItem.order_id == target.id, OrderItem.status == "active"
).count()
paid_exists = db.query(OrderItem).filter(
OrderItem.order_id == target.id, OrderItem.status == "paid"
).count()
if active_remaining > 0:
target.status = "partially_paid" if paid_exists > 0 else "open"
else:
target.status = "paid"
# Cancel source order
source.status = "cancelled"
source.closed_at = datetime.now(timezone.utc)
source.closed_by = user.id
_audit(db, source.id, "ORDER_CANCELLED", waiter_id=user.id,
note=f"Merged into order #{target.id} (table {target.table_id})")
_audit(db, target.id, "ITEMS_ADDED", waiter_id=user.id, item_ids=moved_item_ids,
note=f"Items merged from order #{source.id} (table {source.table_id})")
db.commit()
db.refresh(target)
return target
# ─── Split a stacked item into two rows ──────────────────────────────────────
@router.post("/{order_id}/items/{item_id}/split", response_model=List[OrderItemOut])
def split_item(
order_id: int,
item_id: int,
body: SplitItemRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Split qty units off item_id into a new item row.
Both rows share all properties (product, price, options, notes).
Only active items can be split.
"""
item = db.query(OrderItem).filter(
OrderItem.id == item_id, OrderItem.order_id == order_id
).first()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if item.status != "active":
raise HTTPException(status_code=400, detail="Only active items can be split")
if body.quantity <= 0 or body.quantity >= item.quantity:
raise HTTPException(
status_code=400,
detail=f"Split quantity must be between 1 and {item.quantity - 1}"
)
order = db.query(Order).filter(Order.id == order_id).first()
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
# Reduce original item
item.quantity -= body.quantity
# Create split-off item
new_item = OrderItem(
order_id=order_id,
product_id=item.product_id,
added_by=item.added_by,
quantity=body.quantity,
unit_price=item.unit_price,
selected_options=item.selected_options,
removed_ingredients=item.removed_ingredients,
notes=item.notes,
status="active",
printed=item.printed,
)
db.add(new_item)
db.commit()
db.refresh(item)
db.refresh(new_item)
return [item, new_item]
# ─── Move selected items to another order ────────────────────────────────────
@router.post("/{order_id}/move-items")
def move_items(
order_id: int,
body: MoveItemsRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Move specific active items from this order to another open order."""
source = db.query(Order).filter(Order.id == order_id).first()
if not source:
raise HTTPException(status_code=404, detail="Source order not found")
if not _can_access_order(source, user, db):
raise HTTPException(status_code=403, detail="Access denied")
if source.status not in ("open", "partially_paid"):
raise HTTPException(status_code=400, detail="Source order is not active")
target = db.query(Order).filter(Order.id == body.target_order_id).first()
if not target:
raise HTTPException(status_code=404, detail="Target order not found")
if not _can_access_order(target, user, db):
raise HTTPException(status_code=403, detail="Access denied to target order")
if target.status not in ("open", "partially_paid"):
raise HTTPException(status_code=400, detail="Target order is not active")
if source.id == target.id:
raise HTTPException(status_code=400, detail="Source and target orders are the same")
items = db.query(OrderItem).filter(
OrderItem.id.in_(body.item_ids),
OrderItem.order_id == order_id,
OrderItem.status == "active",
).all()
if not items:
raise HTTPException(status_code=400, detail="No active items found to move")
moved_ids = []
for item in items:
item.order_id = target.id
moved_ids.append(item.id)
# Recompute source status
db.flush()
src_active = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "active").count()
src_paid = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "paid").count()
if src_active == 0 and src_paid == 0:
source.status = "open"
elif src_active == 0:
source.status = "paid"
else:
source.status = "partially_paid" if src_paid > 0 else "open"
# Recompute target status
tgt_active = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "active").count()
tgt_paid = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "paid").count()
target.status = "partially_paid" if (tgt_active > 0 and tgt_paid > 0) else ("paid" if tgt_active == 0 else "open")
_audit(db, source.id, "ITEMS_MOVED_OUT", waiter_id=user.id, item_ids=moved_ids,
note=f"Moved to order #{target.id} (table {target.table_id})")
_audit(db, target.id, "ITEMS_MOVED_IN", waiter_id=user.id, item_ids=moved_ids,
note=f"Moved from order #{source.id} (table {source.table_id})")
db.commit()
db.refresh(source)
return {"moved_item_ids": moved_ids, "source_status": source.status, "target_status": target.status}
# ─── Print order synopsis ─────────────────────────────────────────────────────
@router.post("/{order_id}/print-synopsis")
def print_synopsis(
order_id: int,
body: PrintSynopsisRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
from models.printer import Printer
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
table = db.query(Table).filter(Table.id == order.table_id).first()
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
opener = db.query(User).filter(User.id == order.opened_by).first()
waiter_name = (opener.nickname or opener.username) if opener else f"#{order.opened_by}"
items_data = []
for item in order.items:
if item.status == "cancelled":
continue
product_name = item.product.name if item.product else f"#{item.product_id}"
items_data.append({
"name": product_name,
"quantity": item.quantity,
"unit_price": item.unit_price,
"total": item.unit_price * item.quantity,
"status": item.status,
})
total = sum(i["total"] for i in items_data)
paid_total = sum(i["total"] for i in items_data if i["status"] == "paid")
synopsis = {
"order_id": order.id,
"table_name": table_name,
"waiter_name": waiter_name,
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
"items": items_data,
"total": total,
"paid_total": paid_total,
"remaining": total - paid_total,
}
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis)
return {"status": "printing"}

View File

@@ -6,13 +6,14 @@ from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.order import OrderItem
from models.user import User
from schemas.product import (
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
PreferenceSetCreate,
SubcategoryReorderItem, ParentGeneralReorderItem,
PreferenceSetCreate, ProductQuickOptionCreate,
)
from routers.deps import get_current_user, require_manager
@@ -21,6 +22,22 @@ router = APIRouter()
IMAGE_DIR = "/app/data/product_images"
def _replace_quick_options(db, product, quick_options):
for qo in product.quick_options:
db.delete(qo)
db.flush()
for i, qo in enumerate(quick_options):
db.add(ProductQuickOption(
product_id=product.id,
name=qo.name,
price=qo.price,
allow_multiple=qo.allow_multiple,
sort_order=qo.sort_order if qo.sort_order else i,
is_favorite=qo.is_favorite,
favorite_sort_order=qo.favorite_sort_order,
))
def _replace_options(db, product, options):
for opt in product.options:
db.delete(opt)
@@ -31,7 +48,10 @@ def _replace_options(db, product, options):
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
allow_multiple=opt.allow_multiple,
sub_choices=sub_json,
is_favorite=opt.is_favorite,
favorite_sort_order=opt.favorite_sort_order,
))
@@ -53,6 +73,8 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
product_id=product.id,
name=ps.name,
shared_subset=shared_json,
is_favorite=ps.is_favorite,
favorite_sort_order=ps.favorite_sort_order,
)
db.add(new_set)
db.flush()
@@ -82,8 +104,15 @@ def list_categories(db: Session = Depends(get_db), user: User = Depends(get_curr
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
max_order = db.query(Category).count()
cat = Category(name=body.name, color=body.color, sort_order=max_order)
# sort_order is among siblings (same parent_id level)
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
cat = Category(
name=body.name,
color=body.color,
sort_order=sibling_count,
parent_id=body.parent_id,
general_sort_order=body.general_sort_order,
)
db.add(cat)
db.commit()
db.refresh(cat)
@@ -99,6 +128,26 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
db.commit()
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
"""Reorder sub-categories within their parent (sort_order among siblings)."""
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
if cat:
cat.sort_order = item.sort_order
db.commit()
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
"""Update general_sort_order on parent categories (position of the General group)."""
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
if cat:
cat.general_sort_order = item.general_sort_order
db.commit()
@router.put("/categories/{category_id}", response_model=CategoryOut)
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
cat = db.query(Category).filter(Category.id == category_id).first()
@@ -126,7 +175,8 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
q = db.query(Product)
if not all or user.role not in ("manager", "sysadmin"):
q = q.filter(Product.is_available == True)
# Waiters only see active, available products
q = q.filter(Product.is_available == True, Product.lifecycle_status == "active")
return q.order_by(Product.sort_order, Product.id).all()
@@ -141,15 +191,33 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
data = body.model_dump(exclude={"options", "ingredients", "preference_sets"})
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets"})
if data.get("sort_order") == 0:
data["sort_order"] = db.query(Product).count()
product = Product(**data)
db.add(product)
db.flush()
for i, qo in enumerate(body.quick_options):
db.add(ProductQuickOption(
product_id=product.id,
name=qo.name,
price=qo.price,
allow_multiple=qo.allow_multiple,
sort_order=qo.sort_order if qo.sort_order else i,
is_favorite=qo.is_favorite,
favorite_sort_order=qo.favorite_sort_order,
))
for opt in body.options:
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
db.add(ProductOption(product_id=product.id, name=opt.name, extra_cost=opt.extra_cost, sub_choices=sub_json))
db.add(ProductOption(
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
allow_multiple=opt.allow_multiple,
sub_choices=sub_json,
is_favorite=opt.is_favorite,
favorite_sort_order=opt.favorite_sort_order,
))
for ing in body.ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
_replace_preference_sets(db, product, body.preference_sets)
@@ -163,8 +231,10 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
for field, value in body.model_dump(exclude_none=True, exclude={"options", "ingredients", "preference_sets"}).items():
for field, value in body.model_dump(exclude_none=True, exclude={"quick_options", "options", "ingredients", "preference_sets"}).items():
setattr(product, field, value)
if body.quick_options is not None:
_replace_quick_options(db, product, body.quick_options)
if body.options is not None:
_replace_options(db, product, body.options)
if body.ingredients is not None:
@@ -216,9 +286,14 @@ def delete_product(product_id: int, hard: bool = False, db: Session = Depends(ge
if has_orders:
raise HTTPException(
status_code=400,
detail="Cannot permanently delete a product that appears in past orders. Deactivate it instead."
detail="Cannot permanently delete a product that appears in past orders. Archive it instead."
)
db.delete(product)
else:
product.is_available = False
# If product has order history, archive it; otherwise hard delete
has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first()
if has_orders:
product.lifecycle_status = "archived"
else:
db.delete(product)
db.commit()

View File

@@ -12,6 +12,7 @@ from models.order import Order, OrderItem, OrderWaiter, PrintLog
from models.user import User
from models.table import Table
from models.printer import Printer
from models.shift import WaiterShift
from schemas.order import OrderOut
from schemas.table import TableOut
from routers.deps import require_manager
@@ -20,6 +21,12 @@ from services.printer_service import print_waiter_report, print_printer_report,
router = APIRouter()
def _dt(dt):
if dt is None:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
@router.get("/shift")
def shift_summary(
from_dt: Optional[str] = Query(default=None, alias="from"),
@@ -438,3 +445,213 @@ def print_printer_totals(
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
return {"status": "printing"}
# ---------------------------------------------------------------------------
# Shift history report
# ---------------------------------------------------------------------------
@router.get("/shifts")
def shifts_report(
waiter_id: Optional[int] = None,
business_day_id: Optional[int] = None,
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
active_only: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from routers.shifts import compute_shift_total
q = db.query(WaiterShift)
if waiter_id:
q = q.filter(WaiterShift.waiter_id == waiter_id)
if business_day_id:
q = q.filter(WaiterShift.business_day_id == business_day_id)
if from_dt:
q = q.filter(WaiterShift.started_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(WaiterShift.started_at <= datetime.fromisoformat(to_dt))
if active_only:
q = q.filter(WaiterShift.ended_at == None)
shifts = q.order_by(WaiterShift.started_at.desc()).all()
waiters_db = {u.id: u for u in db.query(User).all()}
result = []
for shift in shifts:
w = waiters_db.get(shift.waiter_id)
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
result.append({
"id": shift.id,
"waiter_id": shift.waiter_id,
"waiter_name": wname,
"business_day_id": shift.business_day_id,
"started_at": _dt(shift.started_at),
"ended_at": _dt(shift.ended_at),
"starting_cash": shift.starting_cash,
"total_collected": total,
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
"is_active": shift.ended_at is None,
"notes": shift.notes,
})
return {"shifts": result}
# ---------------------------------------------------------------------------
# Product performance analytics
# ---------------------------------------------------------------------------
@router.get("/products/performance")
def product_performance(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
category_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.product import Product
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if from_dt:
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.join(Order).filter(Order.business_day_id == business_day_id)
items = q.all()
products_db = {p.id: p for p in db.query(Product).all()}
summary: dict = {}
for item in items:
pid = item.product_id
product = products_db.get(pid)
if category_id and (not product or product.category_id != category_id):
continue
if pid not in summary:
summary[pid] = {
"product_id": pid,
"product_name": product.name if product else f"#{pid}",
"category_id": product.category_id if product else None,
"qty_sold": 0,
"revenue": 0.0,
"order_ids": set(),
}
summary[pid]["qty_sold"] += item.quantity
summary[pid]["revenue"] += item.unit_price * item.quantity
summary[pid]["order_ids"].add(item.order_id)
result = []
for entry in summary.values():
entry["order_count"] = len(entry.pop("order_ids"))
entry["revenue"] = round(entry["revenue"], 2)
result.append(entry)
result.sort(key=lambda x: x["qty_sold"], reverse=True)
return {"products": result}
# ---------------------------------------------------------------------------
# Table performance analytics
# ---------------------------------------------------------------------------
@router.get("/tables/performance")
def table_performance(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
if from_dt:
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.filter(Order.business_day_id == business_day_id)
orders = q.all()
tables_db = {t.id: t for t in db.query(Table).all()}
summary: dict = {}
for order in orders:
tid = order.table_id
if tid not in summary:
t = tables_db.get(tid)
summary[tid] = {
"table_id": tid,
"table_name": (t.label or f"T{t.number}") if t else f"#{tid}",
"order_count": 0,
"revenue": 0.0,
"durations": [],
}
summary[tid]["order_count"] += 1
summary[tid]["revenue"] += sum(
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
)
if order.closed_at and order.opened_at:
summary[tid]["durations"].append(
(order.closed_at - order.opened_at).total_seconds() / 60
)
result = []
for entry in summary.values():
durations = entry.pop("durations")
entry["avg_duration_minutes"] = round(sum(durations) / len(durations), 1) if durations else None
entry["revenue"] = round(entry["revenue"], 2)
result.append(entry)
result.sort(key=lambda x: x["revenue"], reverse=True)
return {"tables": result}
# ---------------------------------------------------------------------------
# Traffic analysis (hour-of-day / day-of-week)
# ---------------------------------------------------------------------------
@router.get("/traffic")
def traffic_analysis(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Order)
if from_dt:
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.filter(Order.business_day_id == business_day_id)
orders = q.all()
by_hour = {h: {"hour": h, "orders": 0, "revenue": 0.0} for h in range(24)}
day_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
by_weekday = {d: {"day": d, "label": day_labels[d], "orders": 0, "revenue": 0.0} for d in range(7)}
for order in orders:
revenue = sum(
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
)
h = order.opened_at.hour
d = order.opened_at.weekday()
by_hour[h]["orders"] += 1
by_hour[h]["revenue"] += revenue
by_weekday[d]["orders"] += 1
by_weekday[d]["revenue"] += revenue
for h in by_hour:
by_hour[h]["revenue"] = round(by_hour[h]["revenue"], 2)
for d in by_weekday:
by_weekday[d]["revenue"] = round(by_weekday[d]["revenue"], 2)
return {
"by_hour": list(by_hour.values()),
"by_weekday": list(by_weekday.values()),
}

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime, timezone
from database import get_db
from models.settings import PosSettings
from schemas.settings import UpdateSettingRequest
from routers.deps import get_current_user, require_manager
from models.user import User
router = APIRouter()
VALID_SETTINGS = {
"shifts.waiter_self_start": "Allow waiters to start their own shifts without manager action",
"shifts.waiter_self_end": "Allow waiters to end their own shifts without manager action",
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
}
DEFAULTS = {
"shifts.waiter_self_start": "true",
"shifts.waiter_self_end": "true",
"business_day.force_close_allowed": "true",
"system.timezone": "Europe/Athens",
"ui.table_colours": "",
}
@router.get("/")
def get_all_settings(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
stored = {s.key: s.value for s in db.query(PosSettings).all()}
result = {}
for key, description in VALID_SETTINGS.items():
result[key] = {
"value": stored.get(key, DEFAULTS.get(key, "true")),
"description": description,
}
return result
@router.put("/{key}")
def update_setting(
key: str,
body: UpdateSettingRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
if key not in VALID_SETTINGS:
raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}")
setting = db.query(PosSettings).filter(PosSettings.key == key).first()
if setting:
setting.value = body.value
setting.updated_at = datetime.now(timezone.utc)
setting.updated_by_id = user.id
else:
setting = PosSettings(key=key, value=body.value, updated_by_id=user.id)
db.add(setting)
db.commit()
db.refresh(setting)
return {"key": setting.key, "value": setting.value}

View File

@@ -0,0 +1,347 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime, timezone
from database import get_db
from models.shift import WaiterShift, ShiftBreak
from models.business_day import BusinessDay
from models.order import OrderItem
from models.settings import PosSettings
from models.user import User
from schemas.shift import StartShiftRequest, EndShiftRequest
from routers.deps import get_current_user, require_manager
router = APIRouter()
def _dt(dt):
"""Serialize a naive-UTC datetime to ISO string with Z so JS parses it as UTC."""
if dt is None:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
def _get_setting(db: Session, key: str, default: str = "true") -> str:
s = db.query(PosSettings).filter(PosSettings.key == key).first()
return s.value if s else default
def compute_shift_total(shift_id: int, db: Session) -> float:
items = db.query(OrderItem).filter(
OrderItem.paid_in_shift_id == shift_id,
OrderItem.status == "paid",
).all()
return round(sum(i.unit_price * i.quantity for i in items), 2)
def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
w = shift.waiter
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
return {
"id": shift.id,
"waiter_id": shift.waiter_id,
"waiter_name": wname,
"business_day_id": shift.business_day_id,
"started_at": _dt(shift.started_at),
"ended_at": _dt(shift.ended_at),
"starting_cash": shift.starting_cash,
"total_collected": total,
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
"is_active": shift.ended_at is None,
"notes": shift.notes,
"breaks": [
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
for b in shift.breaks
],
}
@router.get("/my")
def my_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
return _enrich_shift(shift, db) if shift else None
@router.post("/start", status_code=status.HTTP_201_CREATED)
def start_shift(
body: StartShiftRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
target_id = body.waiter_id
if target_id and target_id != user.id:
if user.role not in ("manager", "sysadmin"):
raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters")
target = db.query(User).filter(User.id == target_id, User.is_active == True).first()
if not target:
raise HTTPException(status_code=404, detail="Waiter not found")
else:
target_id = user.id
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
raise HTTPException(status_code=403, detail="Shift start requires manager confirmation")
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if not active_day:
raise HTTPException(status_code=400, detail="No open business day — manager must open the restaurant first")
existing = db.query(WaiterShift).filter(
WaiterShift.waiter_id == target_id,
WaiterShift.ended_at == None,
).first()
if existing:
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
shift = WaiterShift(
waiter_id=target_id,
business_day_id=active_day.id,
starting_cash=body.starting_cash,
)
db.add(shift)
db.commit()
db.refresh(shift)
return _enrich_shift(shift, db)
@router.post("/end")
def end_shift(
body: EndShiftRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
raise HTTPException(status_code=403, detail="Shift end requires manager confirmation")
shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
if not shift:
raise HTTPException(status_code=404, detail="No active shift found")
now = datetime.now(timezone.utc)
shift.total_collected = compute_shift_total(shift.id, db)
shift.ended_at = now
if body.notes:
shift.notes = body.notes
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
).first()
if open_break:
open_break.ended_at = now
db.commit()
db.refresh(shift)
return _enrich_shift(shift, db)
@router.post("/manager/start", status_code=status.HTTP_201_CREATED)
def manager_start_shift(
body: StartShiftRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
if not body.waiter_id:
raise HTTPException(status_code=400, detail="waiter_id is required")
target = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
if not target:
raise HTTPException(status_code=404, detail="Waiter not found")
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if not active_day:
raise HTTPException(status_code=400, detail="No open business day")
existing = db.query(WaiterShift).filter(
WaiterShift.waiter_id == body.waiter_id,
WaiterShift.ended_at == None,
).first()
if existing:
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
shift = WaiterShift(
waiter_id=body.waiter_id,
business_day_id=active_day.id,
starting_cash=body.starting_cash,
)
db.add(shift)
db.commit()
db.refresh(shift)
return _enrich_shift(shift, db)
@router.post("/manager/end/{shift_id}")
def manager_end_shift(
shift_id: int,
body: EndShiftRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
shift = db.query(WaiterShift).filter(
WaiterShift.id == shift_id,
WaiterShift.ended_at == None,
).first()
if not shift:
raise HTTPException(status_code=404, detail="Active shift not found")
now = datetime.now(timezone.utc)
shift.total_collected = compute_shift_total(shift.id, db)
shift.ended_at = now
if body.notes:
shift.notes = body.notes
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
).first()
if open_break:
open_break.ended_at = now
db.commit()
db.refresh(shift)
return _enrich_shift(shift, db)
@router.post("/{shift_id}/break/start")
def start_break(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
raise HTTPException(status_code=403, detail="Access denied")
if shift.ended_at:
raise HTTPException(status_code=400, detail="Shift already ended")
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
).first()
if open_break:
raise HTTPException(status_code=400, detail="Break already in progress")
b = ShiftBreak(shift_id=shift_id)
db.add(b)
db.commit()
db.refresh(b)
return {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
@router.post("/{shift_id}/break/end")
def end_break(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
raise HTTPException(status_code=403, detail="Access denied")
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
).first()
if not open_break:
raise HTTPException(status_code=404, detail="No active break found")
open_break.ended_at = datetime.now(timezone.utc)
db.commit()
db.refresh(open_break)
return {"id": open_break.id, "shift_id": open_break.shift_id, "started_at": _dt(open_break.started_at), "ended_at": _dt(open_break.ended_at)}
@router.get("/")
def list_shifts(
waiter_id: Optional[int] = None,
business_day_id: Optional[int] = None,
active_only: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(WaiterShift)
if waiter_id:
q = q.filter(WaiterShift.waiter_id == waiter_id)
if business_day_id:
q = q.filter(WaiterShift.business_day_id == business_day_id)
if active_only:
q = q.filter(WaiterShift.ended_at == None)
shifts = q.order_by(WaiterShift.started_at.desc()).all()
return {"shifts": [_enrich_shift(s, db) for s in shifts]}
@router.get("/{shift_id}")
def get_shift(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
return _enrich_shift(shift, db)
@router.get("/{shift_id}/summary")
def get_shift_summary(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Full shift summary: enriched shift data + paid items grouped by order."""
from models.order import Order
from sqlalchemy.orm import joinedload
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
items = db.query(OrderItem).options(
joinedload(OrderItem.product),
joinedload(OrderItem.order),
).filter(
OrderItem.paid_in_shift_id == shift_id,
OrderItem.status == "paid",
).all()
orders_seen = {}
for item in items:
oid = item.order_id
if oid not in orders_seen:
o = item.order
orders_seen[oid] = {
"order_id": oid,
"table_id": o.table_id if o else None,
"opened_at": _dt(o.opened_at) if o else None,
"items": [],
}
orders_seen[oid]["items"].append({
"id": item.id,
"product_name": item.product.name if item.product else f"#{item.product_id}",
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"subtotal": round(float(item.unit_price) * item.quantity, 2),
"paid_at": _dt(item.paid_at),
})
# Compute hours worked
started = shift.started_at
ended = shift.ended_at
duration_minutes = None
if started and ended:
duration_minutes = int((ended - started).total_seconds() / 60)
elif started:
from datetime import datetime, timezone as tz
duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60)
enriched = _enrich_shift(shift, db)
enriched["orders"] = list(orders_seen.values())
enriched["duration_minutes"] = duration_minutes
return enriched

View File

@@ -28,7 +28,7 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
raise HTTPException(status_code=400, detail="Group name already exists")
sort_order = db.query(TableGroup).count()
group = TableGroup(name=body.name, prefix=body.prefix, sort_order=sort_order)
group = TableGroup(name=body.name, prefix=body.prefix, color=body.color, sort_order=sort_order)
db.add(group)
db.commit()
db.refresh(group)
@@ -86,7 +86,7 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
active_table_ids = {
row[0] for row in db.query(Order.table_id).filter(
Order.status.in_(["open", "partially_paid"])
Order.status.in_(["open", "partially_paid", "paid"])
).all()
}
@@ -167,7 +167,7 @@ def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db
raise HTTPException(status_code=404, detail="Table not found")
active_order = db.query(Order).filter(
Order.table_id == table_id,
Order.status.in_(["open", "partially_paid"])
Order.status.in_(["open", "partially_paid", "paid"])
).first()
if active_order:
raise HTTPException(
@@ -194,7 +194,7 @@ def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depe
raise HTTPException(status_code=404, detail="Table not found")
active_order = (
db.query(Order)
.filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid"]))
.filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid", "paid"]))
.first()
)
return {

View File

@@ -7,8 +7,9 @@ from typing import List
from database import get_db
from models.user import User, AssistantAssignment, WaiterZone
from models.shift import WaiterShift
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
from routers.deps import require_manager
from routers.deps import require_manager, get_current_user
router = APIRouter()
@@ -26,6 +27,13 @@ def _waiter_or_404(waiter_id: int, db: Session) -> User:
# ── CRUD ──────────────────────────────────────────────────────────────────────
@router.get("/on-shift", response_model=List[UserOut])
def list_waiters_on_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Waiters with an active (not-ended) shift. Accessible to all staff."""
waiter_ids = db.query(WaiterShift.waiter_id).filter(WaiterShift.ended_at == None).subquery()
return db.query(User).filter(User.id.in_(waiter_ids), User.role == "waiter", User.is_active == True).all()
@router.get("/", response_model=List[UserOut])
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
return db.query(User).filter(User.role == "waiter").all()
@@ -36,7 +44,15 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
if db.query(User).filter(User.username == body.username).first():
raise HTTPException(status_code=400, detail="Username already exists")
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
new_user = User(username=body.username, pin_hash=pin_hash, role=body.role, is_active=body.is_active)
new_user = User(
username=body.username,
pin_hash=pin_hash,
role=body.role,
is_active=body.is_active,
full_name=body.full_name,
nickname=body.nickname,
mobile_phone=body.mobile_phone,
)
db.add(new_user)
db.commit()
db.refresh(new_user)

View File

@@ -0,0 +1,14 @@
from datetime import datetime
from typing import Annotated
from pydantic import PlainSerializer
# SQLite strips tzinfo on read-back, so naive datetimes from DB are actually UTC.
# This serializer appends "Z" so browsers parse them correctly as UTC.
UTCDatetime = Annotated[
datetime,
PlainSerializer(
lambda dt: (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat(),
return_type=str,
when_used="json",
),
]

View File

@@ -0,0 +1,24 @@
from pydantic import BaseModel
from typing import Optional
from schemas.base import UTCDatetime
class BusinessDayOut(BaseModel):
id: int
status: str
opened_at: UTCDatetime
opened_by_id: int
closed_at: Optional[UTCDatetime] = None
closed_by_id: Optional[int] = None
notes: Optional[str] = None
model_config = {"from_attributes": True}
class OpenBusinessDayRequest(BaseModel):
notes: Optional[str] = None
class CloseBusinessDayRequest(BaseModel):
force: bool = False
notes: Optional[str] = None

View File

@@ -0,0 +1,44 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class FlagDefCreate(BaseModel):
name: str
emoji: Optional[str] = None
color: Optional[str] = "#6b7280"
sort_order: Optional[int] = 0
class FlagDefUpdate(BaseModel):
name: Optional[str] = None
emoji: Optional[str] = None
color: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class FlagDefOut(BaseModel):
id: int
name: str
emoji: Optional[str] = None
color: Optional[str] = None
sort_order: int
is_active: bool
model_config = {"from_attributes": True}
class FlagAssignmentOut(BaseModel):
id: int
table_id: int
flag_id: int
flag_def: Optional[FlagDefOut] = None
assigned_at: datetime
assigned_by: Optional[int] = None
model_config = {"from_attributes": True}
class SetTableFlagsRequest(BaseModel):
flag_ids: List[int]

View File

@@ -0,0 +1,42 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class QuickTemplateCreate(BaseModel):
body: str
sort_order: Optional[int] = 0
class QuickTemplateUpdate(BaseModel):
body: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class QuickTemplateOut(BaseModel):
id: int
body: str
sort_order: int
is_active: bool
model_config = {"from_attributes": True}
class SendMessageRequest(BaseModel):
body: str
target_waiter_ids: List[int] # empty = all active waiters
table_ids: Optional[List[int]] = []
class StaffMessageOut(BaseModel):
id: int
sender_id: int
sender_name: Optional[str] = None
body: str
target_waiter_ids: str # raw JSON string — frontend parses
table_ids: str
created_at: datetime
acked_by: List[int] = [] # waiter ids who have acked
model_config = {"from_attributes": True}

View File

@@ -1,6 +1,7 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional, List
from schemas.base import UTCDatetime
class SelectedOptionInput(BaseModel):
@@ -40,11 +41,12 @@ class OrderItemOut(BaseModel):
removed_ingredients: Optional[str] = None
notes: Optional[str] = None
status: str
added_at: datetime
added_at: UTCDatetime
printed: bool
paid_by: Optional[int] = None
paid_at: Optional[datetime] = None
paid_at: Optional[UTCDatetime] = None
payment_method: Optional[str] = None
paid_in_shift_id: Optional[int] = None
model_config = {"from_attributes": True}
@@ -90,7 +92,7 @@ class AuditLogOut(BaseModel):
amount: Optional[float] = None
payment_method: Optional[str] = None
note: Optional[str] = None
created_at: datetime
created_at: UTCDatetime
model_config = {"from_attributes": True}
@@ -99,11 +101,12 @@ class OrderOut(BaseModel):
id: int
table_id: int
opened_by: int
opened_at: datetime
opened_at: UTCDatetime
status: str
closed_at: Optional[datetime] = None
closed_at: Optional[UTCDatetime] = None
closed_by: Optional[int] = None
notes: Optional[str] = None
business_day_id: Optional[int] = None
items: List[OrderItemOut] = []
waiters: List[OrderWaiterOut] = []
audit_logs: List[AuditLogOut] = []

View File

@@ -7,12 +7,18 @@ class CategoryCreate(BaseModel):
name: str
color: Optional[str] = None
sort_order: int = 0
parent_id: Optional[int] = None
general_sort_order: int = 0
auto_expanded: bool = False
class CategoryUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
sort_order: Optional[int] = None
parent_id: Optional[int] = None
general_sort_order: Optional[int] = None
auto_expanded: Optional[bool] = None
class CategoryOut(BaseModel):
@@ -20,6 +26,9 @@ class CategoryOut(BaseModel):
name: str
color: Optional[str] = None
sort_order: int = 0
parent_id: Optional[int] = None
general_sort_order: int = 0
auto_expanded: bool = False
model_config = {"from_attributes": True}
@@ -29,6 +38,40 @@ class CategoryReorderItem(BaseModel):
sort_order: int
class SubcategoryReorderItem(BaseModel):
id: int
sort_order: int # position among subcategories within the parent
class ParentGeneralReorderItem(BaseModel):
id: int # parent category id
general_sort_order: int
# ── Quick Options ─────────────────────────────────────────────────────────────
class ProductQuickOptionCreate(BaseModel):
name: str
price: float = 0.0
allow_multiple: bool = False
sort_order: int = 0
is_favorite: bool = False
favorite_sort_order: int = 0
class ProductQuickOptionOut(BaseModel):
id: int
product_id: int
name: str
price: float = 0.0
allow_multiple: bool = False
sort_order: int = 0
is_favorite: bool = False
favorite_sort_order: int = 0
model_config = {"from_attributes": True}
# ── Options ──────────────────────────────────────────────────────────────────
class OptionSubChoice(BaseModel):
@@ -40,6 +83,9 @@ class OptionSubChoice(BaseModel):
class ProductOptionBase(BaseModel):
name: str
extra_cost: float = 0.0
allow_multiple: bool = False
is_favorite: bool = False
favorite_sort_order: int = 0
class ProductOptionCreate(ProductOptionBase):
@@ -64,7 +110,10 @@ class ProductOptionOut(ProductOptionBase):
'product_id': data.product_id,
'name': data.name,
'extra_cost': data.extra_cost,
'allow_multiple': getattr(data, 'allow_multiple', False) or False,
'sub_choices': parsed,
'is_favorite': getattr(data, 'is_favorite', False) or False,
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
}
return data
@@ -74,6 +123,8 @@ class ProductOptionOut(ProductOptionBase):
class ProductIngredientBase(BaseModel):
name: str
extra_cost: float = 0.0
is_favorite: bool = False
favorite_sort_order: int = 0
class ProductIngredientCreate(ProductIngredientBase):
@@ -155,6 +206,8 @@ class PreferenceSetCreate(BaseModel):
choices: List[PreferenceChoiceCreate] = []
default_choice_index: Optional[int] = None # index into choices (0-based)
shared_subset: Optional[SharedSubset] = None
is_favorite: bool = False
favorite_sort_order: int = 0
class PreferenceSetOut(BaseModel):
@@ -164,6 +217,8 @@ class PreferenceSetOut(BaseModel):
choices: List[PreferenceChoiceOut] = []
default_choice_id: Optional[int] = None
shared_subset: Optional[SharedSubset] = None
is_favorite: bool = False
favorite_sort_order: int = 0
model_config = {"from_attributes": True}
@@ -186,6 +241,8 @@ class PreferenceSetOut(BaseModel):
'choices': list(data.choices),
'default_choice_id': data.default_choice_id,
'shared_subset': parsed,
'is_favorite': getattr(data, 'is_favorite', False) or False,
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
}
return data
@@ -197,11 +254,13 @@ class ProductBase(BaseModel):
category_id: Optional[int] = None
base_price: float
is_available: bool = True
lifecycle_status: str = "active"
printer_zone_id: Optional[int] = None
sort_order: int = 0
class ProductCreate(ProductBase):
quick_options: List[ProductQuickOptionCreate] = []
options: List[ProductOptionCreate] = []
ingredients: List[ProductIngredientCreate] = []
preference_sets: List[PreferenceSetCreate] = []
@@ -212,8 +271,10 @@ class ProductUpdate(BaseModel):
category_id: Optional[int] = None
base_price: Optional[float] = None
is_available: Optional[bool] = None
lifecycle_status: Optional[str] = None
printer_zone_id: Optional[int] = None
sort_order: Optional[int] = None
quick_options: Optional[List[ProductQuickOptionCreate]] = None
options: Optional[List[ProductOptionCreate]] = None
ingredients: Optional[List[ProductIngredientCreate]] = None
preference_sets: Optional[List[PreferenceSetCreate]] = None
@@ -226,6 +287,7 @@ class ProductReorderItem(BaseModel):
class ProductOut(ProductBase):
id: int
quick_options: List[ProductQuickOptionOut] = []
options: List[ProductOptionOut] = []
ingredients: List[ProductIngredientOut] = []
preference_sets: List[PreferenceSetOut] = []

View File

@@ -0,0 +1,16 @@
from pydantic import BaseModel
from typing import Optional
from schemas.base import UTCDatetime
class PosSettingOut(BaseModel):
key: str
value: str
updated_at: Optional[UTCDatetime] = None
updated_by_id: Optional[int] = None
model_config = {"from_attributes": True}
class UpdateSettingRequest(BaseModel):
value: str

View File

@@ -0,0 +1,38 @@
from pydantic import BaseModel
from typing import Optional, List
from schemas.base import UTCDatetime
class ShiftBreakOut(BaseModel):
id: int
shift_id: int
started_at: UTCDatetime
ended_at: Optional[UTCDatetime] = None
model_config = {"from_attributes": True}
class WaiterShiftOut(BaseModel):
id: int
waiter_id: int
waiter_name: Optional[str] = None
business_day_id: int
started_at: UTCDatetime
ended_at: Optional[UTCDatetime] = None
starting_cash: Optional[float] = None
total_collected: Optional[float] = None
net_to_deliver: Optional[float] = None
is_active: bool = True
notes: Optional[str] = None
breaks: List[ShiftBreakOut] = []
model_config = {"from_attributes": True}
class StartShiftRequest(BaseModel):
starting_cash: Optional[float] = None
waiter_id: Optional[int] = None # manager use: start shift for a specific waiter
class EndShiftRequest(BaseModel):
notes: Optional[str] = None

View File

@@ -1,6 +1,7 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional, List
from schemas.base import UTCDatetime
class UserBase(BaseModel):
@@ -36,7 +37,7 @@ class WaiterZoneOut(BaseModel):
class UserOut(UserBase):
id: int
created_at: datetime
created_at: UTCDatetime
zone_assignments: List[WaiterZoneOut] = []
model_config = {"from_attributes": True}
@@ -54,6 +55,6 @@ class AssistantAssignmentOut(BaseModel):
id: int
primary_waiter_id: int
assistant_waiter_id: int
assigned_at: datetime
assigned_at: UTCDatetime
model_config = {"from_attributes": True}

View File

@@ -327,6 +327,62 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
logger.error("print_order_receipt failed for %s:%s%s", ip, port, e)
def print_order_synopsis(ip: str, port: int, synopsis: dict):
"""Print a waiter-triggered order synopsis (not a kitchen ticket)."""
try:
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΣΥΝΟΨΗ ΠΑΡΑΓΓΕΛΙΑΣ\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Τραπεζι: {synopsis['table_name']}\n")
_raw_text(p, f"Σερβιτορος: {synopsis['waiter_name']}\n")
_raw_text(p, f"Ωρα: {synopsis['opened_at']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
paid_items = [i for i in synopsis.get("items", []) if i["status"] == "paid"]
active_items = [i for i in synopsis.get("items", []) if i["status"] == "active"]
if active_items:
p._raw(b'\x1b\x21\x10')
_raw_text(p, "ΕΚΚΡΕΜΗ:\n")
p._raw(b'\x1b\x21\x00')
for item in active_items:
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
_divider(p)
if paid_items:
p._raw(b'\x1b\x21\x10')
_raw_text(p, "ΠΛΗΡΩΜΕΝΑ:\n")
p._raw(b'\x1b\x21\x00')
for item in paid_items:
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
_divider(p)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {synopsis['total']:.2f}e\n")
if synopsis.get('paid_total', 0) > 0:
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Πληρωμενο: {synopsis['paid_total']:.2f}e\n")
_raw_text(p, f"Εκκρεμει: {synopsis['remaining']:.2f}e\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_order_synopsis failed for %s:%s%s", ip, port, e)
# ── Routing logic ────────────────────────────────────────────────────────────
def route_and_print(order_id: int, item_ids: List[int]):