diff --git a/local_backend/main.py b/local_backend/main.py index 9d2fd28..b0f54ec 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -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"]) diff --git a/local_backend/models/business_day.py b/local_backend/models/business_day.py new file mode 100644 index 0000000..393037d --- /dev/null +++ b/local_backend/models/business_day.py @@ -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") diff --git a/local_backend/models/flag.py b/local_backend/models/flag.py new file mode 100644 index 0000000..6dc893a --- /dev/null +++ b/local_backend/models/flag.py @@ -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") diff --git a/local_backend/models/message.py b/local_backend/models/message.py new file mode 100644 index 0000000..4b593e3 --- /dev/null +++ b/local_backend/models/message.py @@ -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") diff --git a/local_backend/models/order.py b/local_backend/models/order.py index 6c5b9fd..628466d 100644 --- a/local_backend/models/order.py +++ b/local_backend/models/order.py @@ -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") diff --git a/local_backend/models/product.py b/local_backend/models/product.py index a2c2895..ea2436b 100644 --- a/local_backend/models/product.py +++ b/local_backend/models/product.py @@ -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") diff --git a/local_backend/models/settings.py b/local_backend/models/settings.py new file mode 100644 index 0000000..b671a7c --- /dev/null +++ b/local_backend/models/settings.py @@ -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]) diff --git a/local_backend/models/shift.py b/local_backend/models/shift.py new file mode 100644 index 0000000..d06a1c2 --- /dev/null +++ b/local_backend/models/shift.py @@ -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") diff --git a/local_backend/models/user.py b/local_backend/models/user.py index 0392487..0ae872c 100644 --- a/local_backend/models/user.py +++ b/local_backend/models/user.py @@ -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") diff --git a/local_backend/print_test.py b/local_backend/print_test.py new file mode 100644 index 0000000..325128d --- /dev/null +++ b/local_backend/print_test.py @@ -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 (0x80–0xFF) ─────────────────────────── + +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() diff --git a/local_backend/routers/auth.py b/local_backend/routers/auth.py index 80c7653..7ec80a9 100644 --- a/local_backend/routers/auth.py +++ b/local_backend/routers/auth.py @@ -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 + ] diff --git a/local_backend/routers/business_day.py b/local_backend/routers/business_day.py new file mode 100644 index 0000000..db6e03d --- /dev/null +++ b/local_backend/routers/business_day.py @@ -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} diff --git a/local_backend/routers/flags.py b/local_backend/routers/flags.py new file mode 100644 index 0000000..935bdf8 --- /dev/null +++ b/local_backend/routers/flags.py @@ -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() diff --git a/local_backend/routers/messages.py b/local_backend/routers/messages.py new file mode 100644 index 0000000..47406e5 --- /dev/null +++ b/local_backend/routers/messages.py @@ -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] diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py index a604c6e..fb30983 100644 --- a/local_backend/routers/orders.py +++ b/local_backend/routers/orders.py @@ -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"} diff --git a/local_backend/routers/products.py b/local_backend/routers/products.py index 6a2dce0..2915055 100644 --- a/local_backend/routers/products.py +++ b/local_backend/routers/products.py @@ -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() diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index 0256f33..43ea25d 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -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()), + } diff --git a/local_backend/routers/settings.py b/local_backend/routers/settings.py new file mode 100644 index 0000000..6cdbc29 --- /dev/null +++ b/local_backend/routers/settings.py @@ -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} diff --git a/local_backend/routers/shifts.py b/local_backend/routers/shifts.py new file mode 100644 index 0000000..71079f4 --- /dev/null +++ b/local_backend/routers/shifts.py @@ -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 diff --git a/local_backend/routers/tables.py b/local_backend/routers/tables.py index 846d595..dd021b0 100644 --- a/local_backend/routers/tables.py +++ b/local_backend/routers/tables.py @@ -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 { diff --git a/local_backend/routers/waiters.py b/local_backend/routers/waiters.py index 0645e75..b291f00 100644 --- a/local_backend/routers/waiters.py +++ b/local_backend/routers/waiters.py @@ -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) diff --git a/local_backend/schemas/base.py b/local_backend/schemas/base.py new file mode 100644 index 0000000..2b0ff50 --- /dev/null +++ b/local_backend/schemas/base.py @@ -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", + ), +] diff --git a/local_backend/schemas/business_day.py b/local_backend/schemas/business_day.py new file mode 100644 index 0000000..57abc6d --- /dev/null +++ b/local_backend/schemas/business_day.py @@ -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 diff --git a/local_backend/schemas/flag.py b/local_backend/schemas/flag.py new file mode 100644 index 0000000..505c87c --- /dev/null +++ b/local_backend/schemas/flag.py @@ -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] diff --git a/local_backend/schemas/message.py b/local_backend/schemas/message.py new file mode 100644 index 0000000..10eda2d --- /dev/null +++ b/local_backend/schemas/message.py @@ -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} diff --git a/local_backend/schemas/order.py b/local_backend/schemas/order.py index 430497a..5da7633 100644 --- a/local_backend/schemas/order.py +++ b/local_backend/schemas/order.py @@ -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] = [] diff --git a/local_backend/schemas/product.py b/local_backend/schemas/product.py index 7847973..7dbdd99 100644 --- a/local_backend/schemas/product.py +++ b/local_backend/schemas/product.py @@ -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] = [] diff --git a/local_backend/schemas/settings.py b/local_backend/schemas/settings.py new file mode 100644 index 0000000..327b6a2 --- /dev/null +++ b/local_backend/schemas/settings.py @@ -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 diff --git a/local_backend/schemas/shift.py b/local_backend/schemas/shift.py new file mode 100644 index 0000000..8c50658 --- /dev/null +++ b/local_backend/schemas/shift.py @@ -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 diff --git a/local_backend/schemas/user.py b/local_backend/schemas/user.py index f0b005b..98c26f9 100644 --- a/local_backend/schemas/user.py +++ b/local_backend/schemas/user.py @@ -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} diff --git a/local_backend/services/printer_service.py b/local_backend/services/printer_service.py index 7a98892..976d842 100644 --- a/local_backend/services/printer_service.py +++ b/local_backend/services/printer_service.py @@ -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]):