Backend overhaul: new models, routers, schemas for shifts, business day, flags, messages, settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,8 +14,18 @@ import models.table # noqa: F401
|
|||||||
import models.printer # noqa: F401
|
import models.printer # noqa: F401
|
||||||
import models.product # noqa: F401
|
import models.product # noqa: F401
|
||||||
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount
|
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 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():
|
def _run_migrations():
|
||||||
@@ -64,6 +74,17 @@ def _run_migrations():
|
|||||||
"ALTER TABLE users ADD COLUMN nickname VARCHAR",
|
"ALTER TABLE users ADD COLUMN nickname VARCHAR",
|
||||||
"ALTER TABLE users ADD COLUMN mobile_phone VARCHAR",
|
"ALTER TABLE users ADD COLUMN mobile_phone VARCHAR",
|
||||||
"ALTER TABLE users ADD COLUMN avatar_url 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)
|
# Discounts table (future-proofed, schema ready now)
|
||||||
"""CREATE TABLE IF NOT EXISTS order_discounts (
|
"""CREATE TABLE IF NOT EXISTS order_discounts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -75,6 +96,87 @@ def _run_migrations():
|
|||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
reason TEXT
|
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:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -121,3 +223,8 @@ app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
|
|||||||
app.include_router(waiters.router, prefix="/api/waiters", tags=["waiters"])
|
app.include_router(waiters.router, prefix="/api/waiters", tags=["waiters"])
|
||||||
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
|
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
|
||||||
app.include_router(system.router, prefix="/api/system", tags=["system"])
|
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"])
|
||||||
|
|||||||
24
local_backend/models/business_day.py
Normal file
24
local_backend/models/business_day.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessDay(Base):
|
||||||
|
__tablename__ = "business_days"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
status = Column(String, default="open", nullable=False) # 'open' | 'closed'
|
||||||
|
opened_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||||
|
opened_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
opener = relationship("User", foreign_keys=[opened_by_id])
|
||||||
|
closer = relationship("User", foreign_keys=[closed_by_id])
|
||||||
|
shifts = relationship("WaiterShift", back_populates="business_day")
|
||||||
37
local_backend/models/flag.py
Normal file
37
local_backend/models/flag.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class TableFlagDef(Base):
|
||||||
|
"""Manager-configurable flag definitions (name, emoji, color)."""
|
||||||
|
__tablename__ = "table_flag_defs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
emoji = Column(String, nullable=True)
|
||||||
|
color = Column(String, nullable=True, default="#6b7280") # hex
|
||||||
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
assignments = relationship("TableFlagAssignment", back_populates="flag_def", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class TableFlagAssignment(Base):
|
||||||
|
"""Active flag on a specific table."""
|
||||||
|
__tablename__ = "table_flag_assignments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
||||||
|
flag_id = Column(Integer, ForeignKey("table_flag_defs.id"), nullable=False)
|
||||||
|
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
assigned_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
flag_def = relationship("TableFlagDef", back_populates="assignments")
|
||||||
|
assigned_by_user = relationship("User")
|
||||||
48
local_backend/models/message.py
Normal file
48
local_backend/models/message.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class QuickMessageTemplate(Base):
|
||||||
|
"""Manager-configurable quick message templates."""
|
||||||
|
__tablename__ = "quick_message_templates"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
body = Column(String, nullable=False)
|
||||||
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class StaffMessage(Base):
|
||||||
|
"""A message sent from a manager to one or more waiters."""
|
||||||
|
__tablename__ = "staff_messages"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
# JSON arrays stored as text: "[1,2,3]" for waiter ids, "[5,6]" for table ids
|
||||||
|
target_waiter_ids = Column(Text, nullable=False, default="[]")
|
||||||
|
table_ids = Column(Text, nullable=False, default="[]")
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
sender = relationship("User", foreign_keys=[sender_id])
|
||||||
|
acks = relationship("StaffMessageAck", back_populates="message", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class StaffMessageAck(Base):
|
||||||
|
"""Acknowledgement by a specific waiter for a specific message."""
|
||||||
|
__tablename__ = "staff_message_acks"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
message_id = Column(Integer, ForeignKey("staff_messages.id"), nullable=False)
|
||||||
|
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
acked_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
message = relationship("StaffMessage", back_populates="acks")
|
||||||
|
waiter = relationship("User")
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
|
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from database import Base
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class Order(Base):
|
class Order(Base):
|
||||||
__tablename__ = "orders"
|
__tablename__ = "orders"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
||||||
opened_by = Column(Integer, ForeignKey("users.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
|
status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled
|
||||||
closed_at = Column(DateTime, nullable=True)
|
closed_at = Column(DateTime, nullable=True)
|
||||||
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
|
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
|
||||||
|
|
||||||
table = relationship("Table", back_populates="orders")
|
table = relationship("Table", back_populates="orders")
|
||||||
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
|
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)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||||
waiter_id = Column(Integer, ForeignKey("users.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")
|
order = relationship("Order", back_populates="waiters")
|
||||||
waiter = relationship("User", back_populates="order_assignments")
|
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
|
removed_ingredients = Column(Text, nullable=True) # JSON array of ingredient ids
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
status = Column(String, default="active", nullable=False) # active|paid|cancelled
|
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)
|
printed = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
# Payment tracking
|
# Payment tracking
|
||||||
paid_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
paid_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
paid_at = Column(DateTime, nullable=True)
|
paid_at = Column(DateTime, nullable=True)
|
||||||
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
|
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")
|
order = relationship("Order", back_populates="items")
|
||||||
product = relationship("Product", back_populates="order_items")
|
product = relationship("Product", back_populates="order_items")
|
||||||
@@ -71,7 +77,7 @@ class PrintLog(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||||
printer_id = Column(Integer, ForeignKey("printers.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
|
item_ids = Column(Text, nullable=False) # JSON array of order_item ids
|
||||||
success = Column(Boolean, nullable=False)
|
success = Column(Boolean, nullable=False)
|
||||||
error_message = Column(Text, nullable=True)
|
error_message = Column(Text, nullable=True)
|
||||||
@@ -93,7 +99,7 @@ class OrderAuditLog(Base):
|
|||||||
amount = Column(Float, nullable=True) # total value for PAYMENT events
|
amount = Column(Float, nullable=True) # total value for PAYMENT events
|
||||||
payment_method = Column(String, nullable=True)
|
payment_method = Column(String, nullable=True)
|
||||||
note = Column(Text, 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")
|
order = relationship("Order", back_populates="audit_logs")
|
||||||
waiter = relationship("User")
|
waiter = relationship("User")
|
||||||
@@ -113,7 +119,7 @@ class OrderDiscount(Base):
|
|||||||
discount_type = Column(String, nullable=False) # 'percent' | 'fixed'
|
discount_type = Column(String, nullable=False) # 'percent' | 'fixed'
|
||||||
discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00
|
discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00
|
||||||
applied_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
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)
|
reason = Column(Text, nullable=True)
|
||||||
|
|
||||||
order = relationship("Order", back_populates="discounts")
|
order = relationship("Order", back_populates="discounts")
|
||||||
|
|||||||
@@ -10,8 +10,16 @@ class Category(Base):
|
|||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
color = Column(String, nullable=True)
|
color = Column(String, nullable=True)
|
||||||
sort_order = Column(Integer, default=0)
|
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")
|
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):
|
class Product(Base):
|
||||||
@@ -22,12 +30,15 @@ class Product(Base):
|
|||||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||||
base_price = Column(Float, nullable=False)
|
base_price = Column(Float, nullable=False)
|
||||||
is_available = Column(Boolean, default=True, 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)
|
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
||||||
image_url = Column(String, nullable=True)
|
image_url = Column(String, nullable=True)
|
||||||
sort_order = Column(Integer, default=0, nullable=False)
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
category = relationship("Category", back_populates="products")
|
category = relationship("Category", back_populates="products")
|
||||||
printer_zone = relationship("Printer", 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")
|
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
|
||||||
ingredients = relationship("ProductIngredient", 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")
|
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)
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
extra_cost = Column(Float, default=0.0)
|
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
|
# JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked
|
||||||
sub_choices = Column(Text, nullable=True)
|
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")
|
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):
|
class ProductIngredient(Base):
|
||||||
__tablename__ = "product_ingredients"
|
__tablename__ = "product_ingredients"
|
||||||
|
|
||||||
@@ -54,6 +83,8 @@ class ProductIngredient(Base):
|
|||||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
extra_cost = Column(Float, default=0.0)
|
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")
|
product = relationship("Product", back_populates="ingredients")
|
||||||
|
|
||||||
@@ -68,6 +99,8 @@ class ProductPreferenceSet(Base):
|
|||||||
# JSON: {name, default_choice_index, choices:[{name,extra_cost,is_default}]}
|
# 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 sub-set shown for all choices that don't have disables_subset=True
|
||||||
shared_subset = Column(Text, nullable=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")
|
product = relationship("Product", back_populates="preference_sets")
|
||||||
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
|
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
|
||||||
|
|||||||
19
local_backend/models/settings.py
Normal file
19
local_backend/models/settings.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class PosSettings(Base):
|
||||||
|
__tablename__ = "pos_settings"
|
||||||
|
|
||||||
|
key = Column(String, primary_key=True)
|
||||||
|
value = Column(String, nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False)
|
||||||
|
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
updated_by = relationship("User", foreign_keys=[updated_by_id])
|
||||||
36
local_backend/models/shift.py
Normal file
36
local_backend/models/shift.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from sqlalchemy import Column, Integer, Float, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class WaiterShift(Base):
|
||||||
|
__tablename__ = "waiter_shifts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=False)
|
||||||
|
started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||||
|
ended_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
starting_cash = Column(Float, nullable=True)
|
||||||
|
total_collected = Column(Float, nullable=True) # snapshot written at shift end
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
waiter = relationship("User", foreign_keys=[waiter_id])
|
||||||
|
business_day = relationship("BusinessDay", back_populates="shifts")
|
||||||
|
breaks = relationship("ShiftBreak", back_populates="shift", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftBreak(Base):
|
||||||
|
__tablename__ = "shift_breaks"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=False)
|
||||||
|
started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||||
|
ended_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
shift = relationship("WaiterShift", back_populates="breaks")
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from database import Base
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -16,7 +20,7 @@ class User(Base):
|
|||||||
nickname = Column(String, nullable=True)
|
nickname = Column(String, nullable=True)
|
||||||
mobile_phone = Column(String, nullable=True)
|
mobile_phone = Column(String, nullable=True)
|
||||||
avatar_url = 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_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||||
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
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)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True) # NULL = all zones
|
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")
|
waiter = relationship("User", back_populates="zone_assignments")
|
||||||
group = relationship("TableGroup", back_populates="waiter_zones")
|
group = relationship("TableGroup", back_populates="waiter_zones")
|
||||||
@@ -58,7 +62,7 @@ class AssistantAssignment(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
primary_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
primary_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
assistant_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")
|
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")
|
assistant_waiter = relationship("User", foreign_keys=[assistant_waiter_id], back_populates="assistant_assignments")
|
||||||
|
|||||||
305
local_backend/print_test.py
Normal file
305
local_backend/print_test.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
Printer font & symbol test script.
|
||||||
|
Usage (inside Docker): python print_test.py [IP] [PORT]
|
||||||
|
Defaults to 10.98.20.25:9100
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25"
|
||||||
|
PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
|
||||||
|
|
||||||
|
from escpos.printer import Network
|
||||||
|
|
||||||
|
def _gr(text: str) -> bytes:
|
||||||
|
return text.encode('cp737', errors='replace')
|
||||||
|
|
||||||
|
def _open():
|
||||||
|
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
|
||||||
|
p._raw(b'\x1b\x40') # ESC @ reset
|
||||||
|
p._raw(b'\x1b\x74\x1d') # CP737 Greek code page
|
||||||
|
return p
|
||||||
|
|
||||||
|
def _t(p, text: str):
|
||||||
|
p._raw(_gr(text))
|
||||||
|
|
||||||
|
def _divider(p, char="-", width=48):
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
_t(p, char * width + "\n")
|
||||||
|
|
||||||
|
def _center(p):
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
|
||||||
|
def _left(p):
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
|
||||||
|
# ── ESC ! n byte reference ──────────────────────────────────────────────────
|
||||||
|
# Bit 0 → underline (not tested, minor)
|
||||||
|
# Bit 1 → double-strike (bold)
|
||||||
|
# Bit 3 → double-height
|
||||||
|
# Bit 4 → double-width
|
||||||
|
# Bit 5 → delete-line
|
||||||
|
# Bit 7 → bold (ESC E alias in some models)
|
||||||
|
# Common combos used here:
|
||||||
|
# 0x00 = normal
|
||||||
|
# 0x08 = double-height only (48 chars wide)
|
||||||
|
# 0x10 = double-height only (alt bit) (48 chars wide)
|
||||||
|
# 0x18 = double-height + bold
|
||||||
|
# 0x20 = double-width only (24 chars wide)
|
||||||
|
# 0x30 = double-width + double-height (24 chars wide)
|
||||||
|
# 0x38 = double-width + double-height + bold
|
||||||
|
# 0x48 = double-height (bit 6 combo — some printers)
|
||||||
|
|
||||||
|
MODES = [
|
||||||
|
(0x00, "Normal (0x00)"),
|
||||||
|
(0x08, "Double-height bit3 (0x08)"),
|
||||||
|
(0x10, "Double-height bit4 (0x10)"),
|
||||||
|
(0x18, "Double-height + Bold (0x18)"),
|
||||||
|
(0x20, "Double-width (0x20)"),
|
||||||
|
(0x30, "Double-width + Double-height (0x30)"),
|
||||||
|
(0x38, "Double-width + Double-height + Bold (0x38)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Section 1 — Font sizes & styles, English ───────────────────────────────
|
||||||
|
|
||||||
|
def section_english(p):
|
||||||
|
_center(p)
|
||||||
|
p._raw(b'\x1b\x21\x38')
|
||||||
|
_t(p, "=== FONT SIZES (EN) ===\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, "=")
|
||||||
|
|
||||||
|
for code, label in MODES:
|
||||||
|
_left(p)
|
||||||
|
_t(p, f"[{label}]\n")
|
||||||
|
p._raw(bytes([0x1b, 0x21, code]))
|
||||||
|
_t(p, "TEST PRINT Hello World Abc123\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
# bold on/off via ESC E
|
||||||
|
_t(p, " -> bold ON: ")
|
||||||
|
p._raw(b'\x1b\x45\x01')
|
||||||
|
p._raw(bytes([0x1b, 0x21, code]))
|
||||||
|
_t(p, "Bold Sample\n")
|
||||||
|
p._raw(b'\x1b\x45\x00')
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_t(p, "\n")
|
||||||
|
|
||||||
|
_divider(p)
|
||||||
|
p._raw(b'\n')
|
||||||
|
|
||||||
|
# ── Section 2 — Font sizes & styles, Greek ─────────────────────────────────
|
||||||
|
|
||||||
|
def section_greek(p):
|
||||||
|
_center(p)
|
||||||
|
p._raw(b'\x1b\x21\x38')
|
||||||
|
_t(p, "=== FONT SIZES (GR) ===\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, "=")
|
||||||
|
|
||||||
|
for code, label in MODES:
|
||||||
|
_left(p)
|
||||||
|
_t(p, f"[{label}]\n")
|
||||||
|
p._raw(bytes([0x1b, 0x21, code]))
|
||||||
|
_t(p, "ΔΟΚΙΜΑΣΤΙΚΗ ΕΚΤΥΠΩΣΗ\n")
|
||||||
|
_t(p, "δοκιμαστικη εκτυπωση\n") # lowercase
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1b\x45\x01')
|
||||||
|
p._raw(bytes([0x1b, 0x21, code]))
|
||||||
|
_t(p, "Bold: Καλημερα Κοσμε\n")
|
||||||
|
p._raw(b'\x1b\x45\x00')
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_t(p, "\n")
|
||||||
|
|
||||||
|
_divider(p)
|
||||||
|
p._raw(b'\n')
|
||||||
|
|
||||||
|
# ── Section 3 — All printable ASCII symbols ────────────────────────────────
|
||||||
|
|
||||||
|
def section_ascii_symbols(p):
|
||||||
|
_center(p)
|
||||||
|
p._raw(b'\x1b\x21\x18') # double-height bold for header
|
||||||
|
_t(p, "=== ASCII SYMBOLS ===\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, "=")
|
||||||
|
_left(p)
|
||||||
|
|
||||||
|
# Printable ASCII 0x20 – 0x7E, 16 per line
|
||||||
|
chars = [chr(c) for c in range(0x20, 0x7F)]
|
||||||
|
line = ""
|
||||||
|
for i, ch in enumerate(chars):
|
||||||
|
line += ch + " "
|
||||||
|
if (i + 1) % 24 == 0:
|
||||||
|
_t(p, line + "\n")
|
||||||
|
line = ""
|
||||||
|
if line:
|
||||||
|
_t(p, line + "\n")
|
||||||
|
|
||||||
|
_t(p, "\n")
|
||||||
|
_t(p, "Notable:\n")
|
||||||
|
_t(p, " Bullets : * + - # @ ! ? > < | / \\ ^ ~ _\n")
|
||||||
|
_t(p, " Framing : [ ] { } ( ) = : ; , . \"\n")
|
||||||
|
_t(p, " Currency : $ %\n")
|
||||||
|
_divider(p)
|
||||||
|
p._raw(b'\n')
|
||||||
|
|
||||||
|
# ── Section 4 — CP737 extended chars (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()
|
||||||
@@ -5,6 +5,11 @@ from sqlalchemy.orm import Session
|
|||||||
from database import get_db
|
from database import get_db
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from schemas.auth import LoginRequest, TokenResponse
|
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 schemas.user import UserOut
|
||||||
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
|
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))
|
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)
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
def refresh(token: str, db: Session = Depends(get_db)):
|
def refresh(token: str, db: Session = Depends(get_db)):
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
@@ -40,3 +55,37 @@ def logout(token: str):
|
|||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
def me(user: User = Depends(get_current_user)):
|
def me(user: User = Depends(get_current_user)):
|
||||||
return 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
|
||||||
|
]
|
||||||
|
|||||||
162
local_backend/routers/business_day.py
Normal file
162
local_backend/routers/business_day.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.business_day import BusinessDay
|
||||||
|
from models.order import Order, OrderItem, OrderAuditLog
|
||||||
|
from models.shift import WaiterShift
|
||||||
|
from models.flag import TableFlagAssignment
|
||||||
|
from models.message import StaffMessage, StaffMessageAck
|
||||||
|
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(dt):
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/current", response_model=Optional[BusinessDayOut])
|
||||||
|
def get_current_business_day(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/open", response_model=BusinessDayOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def open_business_day(
|
||||||
|
body: OpenBusinessDayRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
existing = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="A business day is already open")
|
||||||
|
day = BusinessDay(opened_by_id=user.id, notes=body.notes)
|
||||||
|
db.add(day)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(day)
|
||||||
|
return day
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/close", response_model=BusinessDayOut)
|
||||||
|
def close_business_day(
|
||||||
|
body: CloseBusinessDayRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
if not day:
|
||||||
|
raise HTTPException(status_code=404, detail="No open business day")
|
||||||
|
|
||||||
|
open_orders = db.query(Order).filter(
|
||||||
|
Order.business_day_id == day.id,
|
||||||
|
Order.status.in_(["open", "partially_paid"]),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if open_orders and not body.force:
|
||||||
|
# Count orders that have at least one active (unpaid) item — covers both
|
||||||
|
# "open" (fully unpaid) and "partially_paid" (partially unpaid) orders.
|
||||||
|
with_pending = sum(
|
||||||
|
1 for o in open_orders
|
||||||
|
if db.query(OrderItem).filter(
|
||||||
|
OrderItem.order_id == o.id,
|
||||||
|
OrderItem.status == "active",
|
||||||
|
).first() is not None
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": f"{len(open_orders)} table(s) still open, {with_pending} with unpaid items.",
|
||||||
|
"open_orders": len(open_orders),
|
||||||
|
"partially_paid": with_pending,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Close all non-terminal orders for this business day (open, partially_paid, paid)
|
||||||
|
all_unclosed = db.query(Order).filter(
|
||||||
|
Order.business_day_id == day.id,
|
||||||
|
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||||
|
).all()
|
||||||
|
for order in all_unclosed:
|
||||||
|
was_unpaid = order.status in ("open", "partially_paid")
|
||||||
|
order.status = "closed"
|
||||||
|
order.closed_at = now
|
||||||
|
order.closed_by = user.id
|
||||||
|
if was_unpaid:
|
||||||
|
db.add(OrderAuditLog(
|
||||||
|
order_id=order.id,
|
||||||
|
event_type="ORDER_CLOSED",
|
||||||
|
waiter_id=user.id,
|
||||||
|
note="Force-closed at end of business day",
|
||||||
|
))
|
||||||
|
|
||||||
|
active_shifts = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.business_day_id == day.id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).all()
|
||||||
|
for shift in active_shifts:
|
||||||
|
items = db.query(OrderItem).filter(
|
||||||
|
OrderItem.paid_in_shift_id == shift.id,
|
||||||
|
OrderItem.status == "paid",
|
||||||
|
).all()
|
||||||
|
shift.total_collected = sum(i.unit_price * i.quantity for i in items)
|
||||||
|
shift.ended_at = now
|
||||||
|
|
||||||
|
# Clear all table flags and staff messages — fresh slate for the next day
|
||||||
|
db.query(TableFlagAssignment).delete(synchronize_session=False)
|
||||||
|
db.query(StaffMessageAck).delete(synchronize_session=False)
|
||||||
|
db.query(StaffMessage).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
day.status = "closed"
|
||||||
|
day.closed_at = now
|
||||||
|
day.closed_by_id = user.id
|
||||||
|
if body.notes:
|
||||||
|
day.notes = body.notes
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(day)
|
||||||
|
return day
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
def business_day_history(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
days = db.query(BusinessDay).order_by(BusinessDay.opened_at.desc()).all()
|
||||||
|
result = []
|
||||||
|
for day in days:
|
||||||
|
order_count = db.query(Order).filter(Order.business_day_id == day.id).count()
|
||||||
|
revenue = (
|
||||||
|
db.query(func.sum(OrderItem.unit_price * OrderItem.quantity))
|
||||||
|
.join(Order)
|
||||||
|
.filter(Order.business_day_id == day.id, OrderItem.status == "paid")
|
||||||
|
.scalar() or 0.0
|
||||||
|
)
|
||||||
|
w_opener = day.opener
|
||||||
|
w_closer = day.closer
|
||||||
|
result.append({
|
||||||
|
"id": day.id,
|
||||||
|
"status": day.status,
|
||||||
|
"opened_at": _dt(day.opened_at),
|
||||||
|
"closed_at": _dt(day.closed_at),
|
||||||
|
"opened_by_id": day.opened_by_id,
|
||||||
|
"opened_by_name": (w_opener.full_name or w_opener.username) if w_opener else None,
|
||||||
|
"closed_by_id": day.closed_by_id,
|
||||||
|
"closed_by_name": (w_closer.full_name or w_closer.username) if w_closer else None,
|
||||||
|
"notes": day.notes,
|
||||||
|
"order_count": order_count,
|
||||||
|
"revenue": round(revenue, 2),
|
||||||
|
})
|
||||||
|
return {"business_days": result}
|
||||||
141
local_backend/routers/flags.py
Normal file
141
local_backend/routers/flags.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.flag import TableFlagDef, TableFlagAssignment
|
||||||
|
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Flag definitions (manager only) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/defs", response_model=List[FlagDefOut])
|
||||||
|
def list_flag_defs(
|
||||||
|
include_inactive: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = db.query(TableFlagDef)
|
||||||
|
if not include_inactive:
|
||||||
|
q = q.filter(TableFlagDef.is_active == True)
|
||||||
|
return q.order_by(TableFlagDef.sort_order, TableFlagDef.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/defs", response_model=FlagDefOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_flag_def(
|
||||||
|
body: FlagDefCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
flag = TableFlagDef(**body.model_dump())
|
||||||
|
db.add(flag)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(flag)
|
||||||
|
return flag
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/defs/{flag_id}", response_model=FlagDefOut)
|
||||||
|
def update_flag_def(
|
||||||
|
flag_id: int,
|
||||||
|
body: FlagDefUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||||
|
if not flag:
|
||||||
|
raise HTTPException(status_code=404, detail="Flag not found")
|
||||||
|
for k, v in body.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(flag, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(flag)
|
||||||
|
return flag
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/defs/{flag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_flag_def(
|
||||||
|
flag_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||||
|
if not flag:
|
||||||
|
raise HTTPException(status_code=404, detail="Flag not found")
|
||||||
|
# Soft delete — keeps existing assignments readable
|
||||||
|
flag.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── All assignments (bulk endpoint for manager views) ───────────────────────
|
||||||
|
|
||||||
|
@router.get("/assignments", response_model=List[FlagAssignmentOut])
|
||||||
|
def get_all_assignments(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""All active flag assignments across all tables (for manager dashboard bulk load)."""
|
||||||
|
return db.query(TableFlagAssignment).all()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Table flag assignments ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/table/{table_id}", response_model=List[FlagAssignmentOut])
|
||||||
|
def get_table_flags(
|
||||||
|
table_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return db.query(TableFlagAssignment).filter(
|
||||||
|
TableFlagAssignment.table_id == table_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/table/{table_id}", response_model=List[FlagAssignmentOut])
|
||||||
|
def set_table_flags(
|
||||||
|
table_id: int,
|
||||||
|
body: SetTableFlagsRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Replace all flags on a table with the given set of flag_ids."""
|
||||||
|
# Validate all flag_ids exist and are active
|
||||||
|
if body.flag_ids:
|
||||||
|
valid = db.query(TableFlagDef).filter(
|
||||||
|
TableFlagDef.id.in_(body.flag_ids),
|
||||||
|
TableFlagDef.is_active == True,
|
||||||
|
).count()
|
||||||
|
if valid != len(body.flag_ids):
|
||||||
|
raise HTTPException(status_code=400, detail="One or more flag IDs are invalid")
|
||||||
|
|
||||||
|
# Delete existing assignments for this table
|
||||||
|
db.query(TableFlagAssignment).filter(
|
||||||
|
TableFlagAssignment.table_id == table_id
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Insert new assignments
|
||||||
|
for flag_id in body.flag_ids:
|
||||||
|
db.add(TableFlagAssignment(
|
||||||
|
table_id=table_id,
|
||||||
|
flag_id=flag_id,
|
||||||
|
assigned_by=user.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return db.query(TableFlagAssignment).filter(
|
||||||
|
TableFlagAssignment.table_id == table_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def clear_table_flags(
|
||||||
|
table_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db.query(TableFlagAssignment).filter(
|
||||||
|
TableFlagAssignment.table_id == table_id
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
199
local_backend/routers/messages.py
Normal file
199
local_backend/routers/messages.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.message import StaffMessage, StaffMessageAck, QuickMessageTemplate
|
||||||
|
from models.user import User
|
||||||
|
from schemas.message import (
|
||||||
|
SendMessageRequest, StaffMessageOut,
|
||||||
|
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
|
||||||
|
)
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_msg(db: Session, msg_id: int) -> StaffMessage:
|
||||||
|
"""Reload a message with sender and acks eagerly loaded."""
|
||||||
|
return db.query(StaffMessage).options(
|
||||||
|
joinedload(StaffMessage.sender),
|
||||||
|
joinedload(StaffMessage.acks),
|
||||||
|
).filter(StaffMessage.id == msg_id).one()
|
||||||
|
|
||||||
|
|
||||||
|
def _message_out(msg: StaffMessage) -> StaffMessageOut:
|
||||||
|
sender_name = None
|
||||||
|
try:
|
||||||
|
sender_name = msg.sender.username if msg.sender else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return StaffMessageOut(
|
||||||
|
id=msg.id,
|
||||||
|
sender_id=msg.sender_id,
|
||||||
|
sender_name=sender_name,
|
||||||
|
body=msg.body,
|
||||||
|
target_waiter_ids=msg.target_waiter_ids,
|
||||||
|
table_ids=msg.table_ids,
|
||||||
|
created_at=msg.created_at,
|
||||||
|
acked_by=[ack.waiter_id for ack in msg.acks],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Quick templates ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/templates", response_model=List[QuickTemplateOut])
|
||||||
|
def list_templates(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return db.query(QuickMessageTemplate).filter(
|
||||||
|
QuickMessageTemplate.is_active == True
|
||||||
|
).order_by(QuickMessageTemplate.sort_order, QuickMessageTemplate.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates", response_model=QuickTemplateOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_template(
|
||||||
|
body: QuickTemplateCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
t = QuickMessageTemplate(**body.model_dump())
|
||||||
|
db.add(t)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/templates/{template_id}", response_model=QuickTemplateOut)
|
||||||
|
def update_template(
|
||||||
|
template_id: int,
|
||||||
|
body: QuickTemplateUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
for k, v in body.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(t, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_template(
|
||||||
|
template_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
t.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Staff messages ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/send", response_model=StaffMessageOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def send_message(
|
||||||
|
body: SendMessageRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
msg = StaffMessage(
|
||||||
|
sender_id=user.id,
|
||||||
|
body=body.body,
|
||||||
|
target_waiter_ids=json.dumps(body.target_waiter_ids),
|
||||||
|
table_ids=json.dumps(body.table_ids or []),
|
||||||
|
)
|
||||||
|
db.add(msg)
|
||||||
|
db.commit()
|
||||||
|
msg = _load_msg(db, msg.id)
|
||||||
|
return _message_out(msg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unread", response_model=List[StaffMessageOut])
|
||||||
|
def get_unread_messages(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns messages targeting this waiter that they haven't acked yet.
|
||||||
|
A message targets a waiter if their ID is in target_waiter_ids,
|
||||||
|
OR if target_waiter_ids is empty (broadcast to all).
|
||||||
|
"""
|
||||||
|
all_msgs = db.query(StaffMessage).options(
|
||||||
|
joinedload(StaffMessage.sender),
|
||||||
|
joinedload(StaffMessage.acks),
|
||||||
|
).order_by(StaffMessage.created_at.desc()).limit(200).all()
|
||||||
|
acked_ids = {
|
||||||
|
ack.message_id
|
||||||
|
for ack in db.query(StaffMessageAck).filter(StaffMessageAck.waiter_id == user.id).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for msg in all_msgs:
|
||||||
|
if msg.id in acked_ids:
|
||||||
|
continue
|
||||||
|
targets = json.loads(msg.target_waiter_ids or "[]")
|
||||||
|
# Empty list = broadcast to all
|
||||||
|
if not targets or user.id in targets:
|
||||||
|
result.append(_message_out(msg))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent", response_model=List[StaffMessageOut])
|
||||||
|
def get_recent_messages(
|
||||||
|
limit: int = 10,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Last N messages targeting this user (for notification history drawer)."""
|
||||||
|
all_msgs = db.query(StaffMessage).options(
|
||||||
|
joinedload(StaffMessage.sender),
|
||||||
|
joinedload(StaffMessage.acks),
|
||||||
|
).order_by(StaffMessage.created_at.desc()).limit(200).all()
|
||||||
|
result = []
|
||||||
|
for msg in all_msgs:
|
||||||
|
targets = json.loads(msg.target_waiter_ids or "[]")
|
||||||
|
if not targets or user.id in targets:
|
||||||
|
result.append(_message_out(msg))
|
||||||
|
if len(result) >= limit:
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{message_id}/ack", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def ack_message(
|
||||||
|
message_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
msg = db.query(StaffMessage).filter(StaffMessage.id == message_id).first()
|
||||||
|
if not msg:
|
||||||
|
raise HTTPException(status_code=404, detail="Message not found")
|
||||||
|
existing = db.query(StaffMessageAck).filter(
|
||||||
|
StaffMessageAck.message_id == message_id,
|
||||||
|
StaffMessageAck.waiter_id == user.id,
|
||||||
|
).first()
|
||||||
|
if not existing:
|
||||||
|
db.add(StaffMessageAck(message_id=message_id, waiter_id=user.id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/all", response_model=List[StaffMessageOut])
|
||||||
|
def list_all_messages(
|
||||||
|
limit: int = 50,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
msgs = db.query(StaffMessage).options(
|
||||||
|
joinedload(StaffMessage.sender),
|
||||||
|
joinedload(StaffMessage.acks),
|
||||||
|
).order_by(StaffMessage.created_at.desc()).limit(limit).all()
|
||||||
|
return [_message_out(m) for m in msgs]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@@ -14,8 +14,25 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
class PrintOrderRequest(BaseModel):
|
class PrintOrderRequest(BaseModel):
|
||||||
printer_id: int
|
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 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()
|
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]
|
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)
|
@router.get("/{order_id}", response_model=OrderOut)
|
||||||
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
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()
|
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)
|
@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)):
|
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(
|
existing = db.query(Order).filter(
|
||||||
Order.table_id == body.table_id,
|
Order.table_id == body.table_id,
|
||||||
Order.status.in_(["open", "partially_paid"]),
|
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Table already has an open order")
|
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.add(order)
|
||||||
db.flush()
|
db.flush()
|
||||||
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
|
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")
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
if not _can_access_order(order, user, db):
|
if not _can_access_order(order, user, db):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
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")
|
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 = []
|
new_item_ids = []
|
||||||
for item_in in body.items:
|
for item_in in body.items:
|
||||||
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
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}
|
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)
|
@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)):
|
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()
|
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):
|
if not _can_access_order(order, user, db):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
from models.shift import WaiterShift
|
||||||
|
|
||||||
items = db.query(OrderItem).filter(
|
items = db.query(OrderItem).filter(
|
||||||
OrderItem.id.in_(body.item_ids),
|
OrderItem.id.in_(body.item_ids),
|
||||||
OrderItem.order_id == order_id,
|
OrderItem.order_id == order_id,
|
||||||
OrderItem.status == "active",
|
OrderItem.status == "active",
|
||||||
).all()
|
).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
|
total_paid = 0.0
|
||||||
for item in items:
|
for item in items:
|
||||||
item.status = "paid"
|
item.status = "paid"
|
||||||
item.paid_by = user.id
|
item.paid_by = user.id
|
||||||
item.paid_at = now
|
item.paid_at = now
|
||||||
item.payment_method = body.payment_method
|
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
|
total_paid += item.unit_price * item.quantity
|
||||||
|
|
||||||
|
db.flush() # write item status changes before counting, since autoflush=False
|
||||||
active_remaining = db.query(OrderItem).filter(
|
active_remaining = db.query(OrderItem).filter(
|
||||||
OrderItem.order_id == order_id, OrderItem.status == "active"
|
OrderItem.order_id == order_id, OrderItem.status == "active"
|
||||||
).count()
|
).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"):
|
if order.status not in ("paid", "open", "partially_paid"):
|
||||||
raise HTTPException(status_code=400, detail="Cannot close order in current status")
|
raise HTTPException(status_code=400, detail="Cannot close order in current status")
|
||||||
order.status = "closed"
|
order.status = "closed"
|
||||||
order.closed_at = datetime.utcnow()
|
order.closed_at = datetime.now(timezone.utc)
|
||||||
order.closed_by = user.id
|
order.closed_by = user.id
|
||||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -233,14 +321,14 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
|
|||||||
if not order:
|
if not order:
|
||||||
raise HTTPException(status_code=404, detail="Order not found")
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
order.status = "cancelled"
|
order.status = "cancelled"
|
||||||
order.closed_at = datetime.utcnow()
|
order.closed_at = datetime.now(timezone.utc)
|
||||||
order.closed_by = user.id
|
order.closed_by = user.id
|
||||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{order_id}/assign-waiter")
|
@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()
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
if not order:
|
if not order:
|
||||||
raise HTTPException(status_code=404, detail="Order not found")
|
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)
|
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
|
||||||
return {"status": "printing"}
|
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"}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ from sqlalchemy.orm import Session
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from database import get_db
|
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.order import OrderItem
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from schemas.product import (
|
from schemas.product import (
|
||||||
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
|
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
|
||||||
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
||||||
PreferenceSetCreate,
|
SubcategoryReorderItem, ParentGeneralReorderItem,
|
||||||
|
PreferenceSetCreate, ProductQuickOptionCreate,
|
||||||
)
|
)
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
|
|
||||||
@@ -21,6 +22,22 @@ router = APIRouter()
|
|||||||
IMAGE_DIR = "/app/data/product_images"
|
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):
|
def _replace_options(db, product, options):
|
||||||
for opt in product.options:
|
for opt in product.options:
|
||||||
db.delete(opt)
|
db.delete(opt)
|
||||||
@@ -31,7 +48,10 @@ def _replace_options(db, product, options):
|
|||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
name=opt.name,
|
name=opt.name,
|
||||||
extra_cost=opt.extra_cost,
|
extra_cost=opt.extra_cost,
|
||||||
|
allow_multiple=opt.allow_multiple,
|
||||||
sub_choices=sub_json,
|
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,
|
product_id=product.id,
|
||||||
name=ps.name,
|
name=ps.name,
|
||||||
shared_subset=shared_json,
|
shared_subset=shared_json,
|
||||||
|
is_favorite=ps.is_favorite,
|
||||||
|
favorite_sort_order=ps.favorite_sort_order,
|
||||||
)
|
)
|
||||||
db.add(new_set)
|
db.add(new_set)
|
||||||
db.flush()
|
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)
|
@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)):
|
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
max_order = db.query(Category).count()
|
# sort_order is among siblings (same parent_id level)
|
||||||
cat = Category(name=body.name, color=body.color, sort_order=max_order)
|
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.add(cat)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(cat)
|
db.refresh(cat)
|
||||||
@@ -99,6 +128,26 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
|
|||||||
db.commit()
|
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)
|
@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)):
|
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()
|
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)):
|
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
q = db.query(Product)
|
q = db.query(Product)
|
||||||
if not all or user.role not in ("manager", "sysadmin"):
|
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()
|
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)
|
@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)):
|
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:
|
if data.get("sort_order") == 0:
|
||||||
data["sort_order"] = db.query(Product).count()
|
data["sort_order"] = db.query(Product).count()
|
||||||
product = Product(**data)
|
product = Product(**data)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
db.flush()
|
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:
|
for opt in body.options:
|
||||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
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:
|
for ing in body.ingredients:
|
||||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||||
_replace_preference_sets(db, product, body.preference_sets)
|
_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()
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
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)
|
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:
|
if body.options is not None:
|
||||||
_replace_options(db, product, body.options)
|
_replace_options(db, product, body.options)
|
||||||
if body.ingredients is not None:
|
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:
|
if has_orders:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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)
|
db.delete(product)
|
||||||
else:
|
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()
|
db.commit()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from models.order import Order, OrderItem, OrderWaiter, PrintLog
|
|||||||
from models.user import User
|
from models.user import User
|
||||||
from models.table import Table
|
from models.table import Table
|
||||||
from models.printer import Printer
|
from models.printer import Printer
|
||||||
|
from models.shift import WaiterShift
|
||||||
from schemas.order import OrderOut
|
from schemas.order import OrderOut
|
||||||
from schemas.table import TableOut
|
from schemas.table import TableOut
|
||||||
from routers.deps import require_manager
|
from routers.deps import require_manager
|
||||||
@@ -20,6 +21,12 @@ from services.printer_service import print_waiter_report, print_printer_report,
|
|||||||
router = APIRouter()
|
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")
|
@router.get("/shift")
|
||||||
def shift_summary(
|
def shift_summary(
|
||||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
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)
|
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
|
||||||
return {"status": "printing"}
|
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()),
|
||||||
|
}
|
||||||
|
|||||||
66
local_backend/routers/settings.py
Normal file
66
local_backend/routers/settings.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.settings import PosSettings
|
||||||
|
from schemas.settings import UpdateSettingRequest
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
VALID_SETTINGS = {
|
||||||
|
"shifts.waiter_self_start": "Allow waiters to start their own shifts without manager action",
|
||||||
|
"shifts.waiter_self_end": "Allow waiters to end their own shifts without manager action",
|
||||||
|
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
|
||||||
|
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
|
||||||
|
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"shifts.waiter_self_start": "true",
|
||||||
|
"shifts.waiter_self_end": "true",
|
||||||
|
"business_day.force_close_allowed": "true",
|
||||||
|
"system.timezone": "Europe/Athens",
|
||||||
|
"ui.table_colours": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def get_all_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
stored = {s.key: s.value for s in db.query(PosSettings).all()}
|
||||||
|
result = {}
|
||||||
|
for key, description in VALID_SETTINGS.items():
|
||||||
|
result[key] = {
|
||||||
|
"value": stored.get(key, DEFAULTS.get(key, "true")),
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{key}")
|
||||||
|
def update_setting(
|
||||||
|
key: str,
|
||||||
|
body: UpdateSettingRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
if key not in VALID_SETTINGS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}")
|
||||||
|
|
||||||
|
setting = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||||
|
if setting:
|
||||||
|
setting.value = body.value
|
||||||
|
setting.updated_at = datetime.now(timezone.utc)
|
||||||
|
setting.updated_by_id = user.id
|
||||||
|
else:
|
||||||
|
setting = PosSettings(key=key, value=body.value, updated_by_id=user.id)
|
||||||
|
db.add(setting)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(setting)
|
||||||
|
return {"key": setting.key, "value": setting.value}
|
||||||
347
local_backend/routers/shifts.py
Normal file
347
local_backend/routers/shifts.py
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.shift import WaiterShift, ShiftBreak
|
||||||
|
from models.business_day import BusinessDay
|
||||||
|
from models.order import OrderItem
|
||||||
|
from models.settings import PosSettings
|
||||||
|
from models.user import User
|
||||||
|
from schemas.shift import StartShiftRequest, EndShiftRequest
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(dt):
|
||||||
|
"""Serialize a naive-UTC datetime to ISO string with Z so JS parses it as UTC."""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_setting(db: Session, key: str, default: str = "true") -> str:
|
||||||
|
s = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||||
|
return s.value if s else default
|
||||||
|
|
||||||
|
|
||||||
|
def compute_shift_total(shift_id: int, db: Session) -> float:
|
||||||
|
items = db.query(OrderItem).filter(
|
||||||
|
OrderItem.paid_in_shift_id == shift_id,
|
||||||
|
OrderItem.status == "paid",
|
||||||
|
).all()
|
||||||
|
return round(sum(i.unit_price * i.quantity for i in items), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
||||||
|
w = shift.waiter
|
||||||
|
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
||||||
|
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
||||||
|
return {
|
||||||
|
"id": shift.id,
|
||||||
|
"waiter_id": shift.waiter_id,
|
||||||
|
"waiter_name": wname,
|
||||||
|
"business_day_id": shift.business_day_id,
|
||||||
|
"started_at": _dt(shift.started_at),
|
||||||
|
"ended_at": _dt(shift.ended_at),
|
||||||
|
"starting_cash": shift.starting_cash,
|
||||||
|
"total_collected": total,
|
||||||
|
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
|
||||||
|
"is_active": shift.ended_at is None,
|
||||||
|
"notes": shift.notes,
|
||||||
|
"breaks": [
|
||||||
|
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
||||||
|
for b in shift.breaks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my")
|
||||||
|
def my_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
shift = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == user.id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
return _enrich_shift(shift, db) if shift else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start", status_code=status.HTTP_201_CREATED)
|
||||||
|
def start_shift(
|
||||||
|
body: StartShiftRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
target_id = body.waiter_id
|
||||||
|
|
||||||
|
if target_id and target_id != user.id:
|
||||||
|
if user.role not in ("manager", "sysadmin"):
|
||||||
|
raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters")
|
||||||
|
target = db.query(User).filter(User.id == target_id, User.is_active == True).first()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||||
|
else:
|
||||||
|
target_id = user.id
|
||||||
|
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
|
||||||
|
raise HTTPException(status_code=403, detail="Shift start requires manager confirmation")
|
||||||
|
|
||||||
|
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
if not active_day:
|
||||||
|
raise HTTPException(status_code=400, detail="No open business day — manager must open the restaurant first")
|
||||||
|
|
||||||
|
existing = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == target_id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
||||||
|
|
||||||
|
shift = WaiterShift(
|
||||||
|
waiter_id=target_id,
|
||||||
|
business_day_id=active_day.id,
|
||||||
|
starting_cash=body.starting_cash,
|
||||||
|
)
|
||||||
|
db.add(shift)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shift)
|
||||||
|
return _enrich_shift(shift, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/end")
|
||||||
|
def end_shift(
|
||||||
|
body: EndShiftRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
|
||||||
|
raise HTTPException(status_code=403, detail="Shift end requires manager confirmation")
|
||||||
|
|
||||||
|
shift = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == user.id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="No active shift found")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
shift.total_collected = compute_shift_total(shift.id, db)
|
||||||
|
shift.ended_at = now
|
||||||
|
if body.notes:
|
||||||
|
shift.notes = body.notes
|
||||||
|
|
||||||
|
open_break = db.query(ShiftBreak).filter(
|
||||||
|
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||||
|
).first()
|
||||||
|
if open_break:
|
||||||
|
open_break.ended_at = now
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shift)
|
||||||
|
return _enrich_shift(shift, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/manager/start", status_code=status.HTTP_201_CREATED)
|
||||||
|
def manager_start_shift(
|
||||||
|
body: StartShiftRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
if not body.waiter_id:
|
||||||
|
raise HTTPException(status_code=400, detail="waiter_id is required")
|
||||||
|
|
||||||
|
target = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||||
|
|
||||||
|
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
if not active_day:
|
||||||
|
raise HTTPException(status_code=400, detail="No open business day")
|
||||||
|
|
||||||
|
existing = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == body.waiter_id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
||||||
|
|
||||||
|
shift = WaiterShift(
|
||||||
|
waiter_id=body.waiter_id,
|
||||||
|
business_day_id=active_day.id,
|
||||||
|
starting_cash=body.starting_cash,
|
||||||
|
)
|
||||||
|
db.add(shift)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shift)
|
||||||
|
return _enrich_shift(shift, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/manager/end/{shift_id}")
|
||||||
|
def manager_end_shift(
|
||||||
|
shift_id: int,
|
||||||
|
body: EndShiftRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
shift = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.id == shift_id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="Active shift not found")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
shift.total_collected = compute_shift_total(shift.id, db)
|
||||||
|
shift.ended_at = now
|
||||||
|
if body.notes:
|
||||||
|
shift.notes = body.notes
|
||||||
|
|
||||||
|
open_break = db.query(ShiftBreak).filter(
|
||||||
|
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||||
|
).first()
|
||||||
|
if open_break:
|
||||||
|
open_break.ended_at = now
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shift)
|
||||||
|
return _enrich_shift(shift, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{shift_id}/break/start")
|
||||||
|
def start_break(
|
||||||
|
shift_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="Shift not found")
|
||||||
|
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
if shift.ended_at:
|
||||||
|
raise HTTPException(status_code=400, detail="Shift already ended")
|
||||||
|
|
||||||
|
open_break = db.query(ShiftBreak).filter(
|
||||||
|
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
|
||||||
|
).first()
|
||||||
|
if open_break:
|
||||||
|
raise HTTPException(status_code=400, detail="Break already in progress")
|
||||||
|
|
||||||
|
b = ShiftBreak(shift_id=shift_id)
|
||||||
|
db.add(b)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(b)
|
||||||
|
return {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{shift_id}/break/end")
|
||||||
|
def end_break(
|
||||||
|
shift_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="Shift not found")
|
||||||
|
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
open_break = db.query(ShiftBreak).filter(
|
||||||
|
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
|
||||||
|
).first()
|
||||||
|
if not open_break:
|
||||||
|
raise HTTPException(status_code=404, detail="No active break found")
|
||||||
|
|
||||||
|
open_break.ended_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(open_break)
|
||||||
|
return {"id": open_break.id, "shift_id": open_break.shift_id, "started_at": _dt(open_break.started_at), "ended_at": _dt(open_break.ended_at)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def list_shifts(
|
||||||
|
waiter_id: Optional[int] = None,
|
||||||
|
business_day_id: Optional[int] = None,
|
||||||
|
active_only: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
q = db.query(WaiterShift)
|
||||||
|
if waiter_id:
|
||||||
|
q = q.filter(WaiterShift.waiter_id == waiter_id)
|
||||||
|
if business_day_id:
|
||||||
|
q = q.filter(WaiterShift.business_day_id == business_day_id)
|
||||||
|
if active_only:
|
||||||
|
q = q.filter(WaiterShift.ended_at == None)
|
||||||
|
shifts = q.order_by(WaiterShift.started_at.desc()).all()
|
||||||
|
return {"shifts": [_enrich_shift(s, db) for s in shifts]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shift_id}")
|
||||||
|
def get_shift(
|
||||||
|
shift_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="Shift not found")
|
||||||
|
return _enrich_shift(shift, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shift_id}/summary")
|
||||||
|
def get_shift_summary(
|
||||||
|
shift_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
"""Full shift summary: enriched shift data + paid items grouped by order."""
|
||||||
|
from models.order import Order
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="Shift not found")
|
||||||
|
|
||||||
|
items = db.query(OrderItem).options(
|
||||||
|
joinedload(OrderItem.product),
|
||||||
|
joinedload(OrderItem.order),
|
||||||
|
).filter(
|
||||||
|
OrderItem.paid_in_shift_id == shift_id,
|
||||||
|
OrderItem.status == "paid",
|
||||||
|
).all()
|
||||||
|
|
||||||
|
orders_seen = {}
|
||||||
|
for item in items:
|
||||||
|
oid = item.order_id
|
||||||
|
if oid not in orders_seen:
|
||||||
|
o = item.order
|
||||||
|
orders_seen[oid] = {
|
||||||
|
"order_id": oid,
|
||||||
|
"table_id": o.table_id if o else None,
|
||||||
|
"opened_at": _dt(o.opened_at) if o else None,
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
orders_seen[oid]["items"].append({
|
||||||
|
"id": item.id,
|
||||||
|
"product_name": item.product.name if item.product else f"#{item.product_id}",
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"unit_price": float(item.unit_price),
|
||||||
|
"subtotal": round(float(item.unit_price) * item.quantity, 2),
|
||||||
|
"paid_at": _dt(item.paid_at),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Compute hours worked
|
||||||
|
started = shift.started_at
|
||||||
|
ended = shift.ended_at
|
||||||
|
duration_minutes = None
|
||||||
|
if started and ended:
|
||||||
|
duration_minutes = int((ended - started).total_seconds() / 60)
|
||||||
|
elif started:
|
||||||
|
from datetime import datetime, timezone as tz
|
||||||
|
duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60)
|
||||||
|
|
||||||
|
enriched = _enrich_shift(shift, db)
|
||||||
|
enriched["orders"] = list(orders_seen.values())
|
||||||
|
enriched["duration_minutes"] = duration_minutes
|
||||||
|
return enriched
|
||||||
@@ -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():
|
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
|
||||||
raise HTTPException(status_code=400, detail="Group name already exists")
|
raise HTTPException(status_code=400, detail="Group name already exists")
|
||||||
sort_order = db.query(TableGroup).count()
|
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.add(group)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(group)
|
db.refresh(group)
|
||||||
@@ -86,7 +86,7 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
|
|||||||
|
|
||||||
active_table_ids = {
|
active_table_ids = {
|
||||||
row[0] for row in db.query(Order.table_id).filter(
|
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()
|
).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")
|
raise HTTPException(status_code=404, detail="Table not found")
|
||||||
active_order = db.query(Order).filter(
|
active_order = db.query(Order).filter(
|
||||||
Order.table_id == table_id,
|
Order.table_id == table_id,
|
||||||
Order.status.in_(["open", "partially_paid"])
|
Order.status.in_(["open", "partially_paid", "paid"])
|
||||||
).first()
|
).first()
|
||||||
if active_order:
|
if active_order:
|
||||||
raise HTTPException(
|
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")
|
raise HTTPException(status_code=404, detail="Table not found")
|
||||||
active_order = (
|
active_order = (
|
||||||
db.query(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()
|
.first()
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from typing import List
|
|||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.user import User, AssistantAssignment, WaiterZone
|
from models.user import User, AssistantAssignment, WaiterZone
|
||||||
|
from models.shift import WaiterShift
|
||||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -26,6 +27,13 @@ def _waiter_or_404(waiter_id: int, db: Session) -> User:
|
|||||||
|
|
||||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
# ── 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])
|
@router.get("/", response_model=List[UserOut])
|
||||||
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
return db.query(User).filter(User.role == "waiter").all()
|
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():
|
if db.query(User).filter(User.username == body.username).first():
|
||||||
raise HTTPException(status_code=400, detail="Username already exists")
|
raise HTTPException(status_code=400, detail="Username already exists")
|
||||||
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
|
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.add(new_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_user)
|
db.refresh(new_user)
|
||||||
|
|||||||
14
local_backend/schemas/base.py
Normal file
14
local_backend/schemas/base.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
from pydantic import PlainSerializer
|
||||||
|
|
||||||
|
# SQLite strips tzinfo on read-back, so naive datetimes from DB are actually UTC.
|
||||||
|
# This serializer appends "Z" so browsers parse them correctly as UTC.
|
||||||
|
UTCDatetime = Annotated[
|
||||||
|
datetime,
|
||||||
|
PlainSerializer(
|
||||||
|
lambda dt: (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat(),
|
||||||
|
return_type=str,
|
||||||
|
when_used="json",
|
||||||
|
),
|
||||||
|
]
|
||||||
24
local_backend/schemas/business_day.py
Normal file
24
local_backend/schemas/business_day.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from schemas.base import UTCDatetime
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessDayOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
status: str
|
||||||
|
opened_at: UTCDatetime
|
||||||
|
opened_by_id: int
|
||||||
|
closed_at: Optional[UTCDatetime] = None
|
||||||
|
closed_by_id: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class OpenBusinessDayRequest(BaseModel):
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CloseBusinessDayRequest(BaseModel):
|
||||||
|
force: bool = False
|
||||||
|
notes: Optional[str] = None
|
||||||
44
local_backend/schemas/flag.py
Normal file
44
local_backend/schemas/flag.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FlagDefCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
emoji: Optional[str] = None
|
||||||
|
color: Optional[str] = "#6b7280"
|
||||||
|
sort_order: Optional[int] = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FlagDefUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
emoji: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FlagDefOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
emoji: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
sort_order: int
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class FlagAssignmentOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
table_id: int
|
||||||
|
flag_id: int
|
||||||
|
flag_def: Optional[FlagDefOut] = None
|
||||||
|
assigned_at: datetime
|
||||||
|
assigned_by: Optional[int] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SetTableFlagsRequest(BaseModel):
|
||||||
|
flag_ids: List[int]
|
||||||
42
local_backend/schemas/message.py
Normal file
42
local_backend/schemas/message.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class QuickTemplateCreate(BaseModel):
|
||||||
|
body: str
|
||||||
|
sort_order: Optional[int] = 0
|
||||||
|
|
||||||
|
|
||||||
|
class QuickTemplateUpdate(BaseModel):
|
||||||
|
body: Optional[str] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuickTemplateOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
body: str
|
||||||
|
sort_order: int
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SendMessageRequest(BaseModel):
|
||||||
|
body: str
|
||||||
|
target_waiter_ids: List[int] # empty = all active waiters
|
||||||
|
table_ids: Optional[List[int]] = []
|
||||||
|
|
||||||
|
|
||||||
|
class StaffMessageOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
sender_id: int
|
||||||
|
sender_name: Optional[str] = None
|
||||||
|
body: str
|
||||||
|
target_waiter_ids: str # raw JSON string — frontend parses
|
||||||
|
table_ids: str
|
||||||
|
created_at: datetime
|
||||||
|
acked_by: List[int] = [] # waiter ids who have acked
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
from schemas.base import UTCDatetime
|
||||||
|
|
||||||
|
|
||||||
class SelectedOptionInput(BaseModel):
|
class SelectedOptionInput(BaseModel):
|
||||||
@@ -40,11 +41,12 @@ class OrderItemOut(BaseModel):
|
|||||||
removed_ingredients: Optional[str] = None
|
removed_ingredients: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
added_at: datetime
|
added_at: UTCDatetime
|
||||||
printed: bool
|
printed: bool
|
||||||
paid_by: Optional[int] = None
|
paid_by: Optional[int] = None
|
||||||
paid_at: Optional[datetime] = None
|
paid_at: Optional[UTCDatetime] = None
|
||||||
payment_method: Optional[str] = None
|
payment_method: Optional[str] = None
|
||||||
|
paid_in_shift_id: Optional[int] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ class AuditLogOut(BaseModel):
|
|||||||
amount: Optional[float] = None
|
amount: Optional[float] = None
|
||||||
payment_method: Optional[str] = None
|
payment_method: Optional[str] = None
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: UTCDatetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -99,11 +101,12 @@ class OrderOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
table_id: int
|
table_id: int
|
||||||
opened_by: int
|
opened_by: int
|
||||||
opened_at: datetime
|
opened_at: UTCDatetime
|
||||||
status: str
|
status: str
|
||||||
closed_at: Optional[datetime] = None
|
closed_at: Optional[UTCDatetime] = None
|
||||||
closed_by: Optional[int] = None
|
closed_by: Optional[int] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
business_day_id: Optional[int] = None
|
||||||
items: List[OrderItemOut] = []
|
items: List[OrderItemOut] = []
|
||||||
waiters: List[OrderWaiterOut] = []
|
waiters: List[OrderWaiterOut] = []
|
||||||
audit_logs: List[AuditLogOut] = []
|
audit_logs: List[AuditLogOut] = []
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ class CategoryCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
general_sort_order: int = 0
|
||||||
|
auto_expanded: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CategoryUpdate(BaseModel):
|
class CategoryUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
sort_order: Optional[int] = 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):
|
class CategoryOut(BaseModel):
|
||||||
@@ -20,6 +26,9 @@ class CategoryOut(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
general_sort_order: int = 0
|
||||||
|
auto_expanded: bool = False
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -29,6 +38,40 @@ class CategoryReorderItem(BaseModel):
|
|||||||
sort_order: int
|
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 ──────────────────────────────────────────────────────────────────
|
# ── Options ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class OptionSubChoice(BaseModel):
|
class OptionSubChoice(BaseModel):
|
||||||
@@ -40,6 +83,9 @@ class OptionSubChoice(BaseModel):
|
|||||||
class ProductOptionBase(BaseModel):
|
class ProductOptionBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
extra_cost: float = 0.0
|
extra_cost: float = 0.0
|
||||||
|
allow_multiple: bool = False
|
||||||
|
is_favorite: bool = False
|
||||||
|
favorite_sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ProductOptionCreate(ProductOptionBase):
|
class ProductOptionCreate(ProductOptionBase):
|
||||||
@@ -64,7 +110,10 @@ class ProductOptionOut(ProductOptionBase):
|
|||||||
'product_id': data.product_id,
|
'product_id': data.product_id,
|
||||||
'name': data.name,
|
'name': data.name,
|
||||||
'extra_cost': data.extra_cost,
|
'extra_cost': data.extra_cost,
|
||||||
|
'allow_multiple': getattr(data, 'allow_multiple', False) or False,
|
||||||
'sub_choices': parsed,
|
'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
|
return data
|
||||||
|
|
||||||
@@ -74,6 +123,8 @@ class ProductOptionOut(ProductOptionBase):
|
|||||||
class ProductIngredientBase(BaseModel):
|
class ProductIngredientBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
extra_cost: float = 0.0
|
extra_cost: float = 0.0
|
||||||
|
is_favorite: bool = False
|
||||||
|
favorite_sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ProductIngredientCreate(ProductIngredientBase):
|
class ProductIngredientCreate(ProductIngredientBase):
|
||||||
@@ -155,6 +206,8 @@ class PreferenceSetCreate(BaseModel):
|
|||||||
choices: List[PreferenceChoiceCreate] = []
|
choices: List[PreferenceChoiceCreate] = []
|
||||||
default_choice_index: Optional[int] = None # index into choices (0-based)
|
default_choice_index: Optional[int] = None # index into choices (0-based)
|
||||||
shared_subset: Optional[SharedSubset] = None
|
shared_subset: Optional[SharedSubset] = None
|
||||||
|
is_favorite: bool = False
|
||||||
|
favorite_sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class PreferenceSetOut(BaseModel):
|
class PreferenceSetOut(BaseModel):
|
||||||
@@ -164,6 +217,8 @@ class PreferenceSetOut(BaseModel):
|
|||||||
choices: List[PreferenceChoiceOut] = []
|
choices: List[PreferenceChoiceOut] = []
|
||||||
default_choice_id: Optional[int] = None
|
default_choice_id: Optional[int] = None
|
||||||
shared_subset: Optional[SharedSubset] = None
|
shared_subset: Optional[SharedSubset] = None
|
||||||
|
is_favorite: bool = False
|
||||||
|
favorite_sort_order: int = 0
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -186,6 +241,8 @@ class PreferenceSetOut(BaseModel):
|
|||||||
'choices': list(data.choices),
|
'choices': list(data.choices),
|
||||||
'default_choice_id': data.default_choice_id,
|
'default_choice_id': data.default_choice_id,
|
||||||
'shared_subset': parsed,
|
'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
|
return data
|
||||||
|
|
||||||
@@ -197,11 +254,13 @@ class ProductBase(BaseModel):
|
|||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
base_price: float
|
base_price: float
|
||||||
is_available: bool = True
|
is_available: bool = True
|
||||||
|
lifecycle_status: str = "active"
|
||||||
printer_zone_id: Optional[int] = None
|
printer_zone_id: Optional[int] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(ProductBase):
|
class ProductCreate(ProductBase):
|
||||||
|
quick_options: List[ProductQuickOptionCreate] = []
|
||||||
options: List[ProductOptionCreate] = []
|
options: List[ProductOptionCreate] = []
|
||||||
ingredients: List[ProductIngredientCreate] = []
|
ingredients: List[ProductIngredientCreate] = []
|
||||||
preference_sets: List[PreferenceSetCreate] = []
|
preference_sets: List[PreferenceSetCreate] = []
|
||||||
@@ -212,8 +271,10 @@ class ProductUpdate(BaseModel):
|
|||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
base_price: Optional[float] = None
|
base_price: Optional[float] = None
|
||||||
is_available: Optional[bool] = None
|
is_available: Optional[bool] = None
|
||||||
|
lifecycle_status: Optional[str] = None
|
||||||
printer_zone_id: Optional[int] = None
|
printer_zone_id: Optional[int] = None
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
|
quick_options: Optional[List[ProductQuickOptionCreate]] = None
|
||||||
options: Optional[List[ProductOptionCreate]] = None
|
options: Optional[List[ProductOptionCreate]] = None
|
||||||
ingredients: Optional[List[ProductIngredientCreate]] = None
|
ingredients: Optional[List[ProductIngredientCreate]] = None
|
||||||
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
||||||
@@ -226,6 +287,7 @@ class ProductReorderItem(BaseModel):
|
|||||||
|
|
||||||
class ProductOut(ProductBase):
|
class ProductOut(ProductBase):
|
||||||
id: int
|
id: int
|
||||||
|
quick_options: List[ProductQuickOptionOut] = []
|
||||||
options: List[ProductOptionOut] = []
|
options: List[ProductOptionOut] = []
|
||||||
ingredients: List[ProductIngredientOut] = []
|
ingredients: List[ProductIngredientOut] = []
|
||||||
preference_sets: List[PreferenceSetOut] = []
|
preference_sets: List[PreferenceSetOut] = []
|
||||||
|
|||||||
16
local_backend/schemas/settings.py
Normal file
16
local_backend/schemas/settings.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from schemas.base import UTCDatetime
|
||||||
|
|
||||||
|
|
||||||
|
class PosSettingOut(BaseModel):
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
updated_at: Optional[UTCDatetime] = None
|
||||||
|
updated_by_id: Optional[int] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSettingRequest(BaseModel):
|
||||||
|
value: str
|
||||||
38
local_backend/schemas/shift.py
Normal file
38
local_backend/schemas/shift.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from schemas.base import UTCDatetime
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftBreakOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
shift_id: int
|
||||||
|
started_at: UTCDatetime
|
||||||
|
ended_at: Optional[UTCDatetime] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class WaiterShiftOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
waiter_id: int
|
||||||
|
waiter_name: Optional[str] = None
|
||||||
|
business_day_id: int
|
||||||
|
started_at: UTCDatetime
|
||||||
|
ended_at: Optional[UTCDatetime] = None
|
||||||
|
starting_cash: Optional[float] = None
|
||||||
|
total_collected: Optional[float] = None
|
||||||
|
net_to_deliver: Optional[float] = None
|
||||||
|
is_active: bool = True
|
||||||
|
notes: Optional[str] = None
|
||||||
|
breaks: List[ShiftBreakOut] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class StartShiftRequest(BaseModel):
|
||||||
|
starting_cash: Optional[float] = None
|
||||||
|
waiter_id: Optional[int] = None # manager use: start shift for a specific waiter
|
||||||
|
|
||||||
|
|
||||||
|
class EndShiftRequest(BaseModel):
|
||||||
|
notes: Optional[str] = None
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
from schemas.base import UTCDatetime
|
||||||
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
@@ -36,7 +37,7 @@ class WaiterZoneOut(BaseModel):
|
|||||||
|
|
||||||
class UserOut(UserBase):
|
class UserOut(UserBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: UTCDatetime
|
||||||
zone_assignments: List[WaiterZoneOut] = []
|
zone_assignments: List[WaiterZoneOut] = []
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
@@ -54,6 +55,6 @@ class AssistantAssignmentOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
primary_waiter_id: int
|
primary_waiter_id: int
|
||||||
assistant_waiter_id: int
|
assistant_waiter_id: int
|
||||||
assigned_at: datetime
|
assigned_at: UTCDatetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -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)
|
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 ────────────────────────────────────────────────────────────
|
# ── Routing logic ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def route_and_print(order_id: int, item_ids: List[int]):
|
def route_and_print(order_id: int, item_ids: List[int]):
|
||||||
|
|||||||
Reference in New Issue
Block a user