feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,10 @@ class Settings(BaseSettings):
|
||||
VERSION: str = "0.3.1"
|
||||
CONNECT_SYNC_INTERVAL_SECONDS: int = 30 # how often to poll for pending online orders
|
||||
|
||||
# Break-glass master account — lives only in env vars, never in the DB
|
||||
MASTER_USERNAME: str = ""
|
||||
MASTER_PASSWORD: str = ""
|
||||
|
||||
model_config = {"env_file": str(_HERE / ".env"), "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
|
||||
@@ -4,22 +4,28 @@ Populates the POS database with realistic demo data for customer presentations.
|
||||
|
||||
Generates:
|
||||
- 1 manager account (username: manager / PIN: 1234 / password: password)
|
||||
- 4 demo waiters
|
||||
- 2 table groups with 12 tables total
|
||||
- A full menu: 3 categories, ~20 products
|
||||
- 45 days of backdated history (business days, shifts, orders, payments)
|
||||
- 4 waiters with real Greek names
|
||||
- 2 table groups with 13 tables total
|
||||
- A full menu: 3 categories, ~20 products with costs
|
||||
- 120 days of backdated history with realistic time distribution
|
||||
- Online orders (source="online") scattered through history
|
||||
- Order cancellations (~10% of items)
|
||||
- Site notes and todos
|
||||
- Contacts (suppliers) and expenses over the period
|
||||
- Customers and tabs
|
||||
- Scheduled shifts for the next 3 weeks
|
||||
|
||||
Usage:
|
||||
python demo_seed.py
|
||||
|
||||
Must be run from the local_backend directory. Designed to run on a clean DB
|
||||
(after wipe_database.py), but safe to run multiple times — checks for the
|
||||
[DEMO] marker before inserting anything.
|
||||
DEMO guard before inserting anything.
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, date, timezone
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import text
|
||||
@@ -30,12 +36,19 @@ import models.user
|
||||
import models.table
|
||||
import models.printer
|
||||
import models.product
|
||||
import models.customers # noqa: F401 — must be before models.order (orders.customer_id FK)
|
||||
import models.order
|
||||
import models.business_day
|
||||
import models.shift
|
||||
import models.settings
|
||||
import models.flag
|
||||
import models.message
|
||||
import models.notes # noqa: F401 — registers tables with Base.metadata
|
||||
import models.expenses # noqa: F401
|
||||
import models.tabs # noqa: F401
|
||||
import models.schedule # noqa: F401
|
||||
import models.waste # noqa: F401
|
||||
import models.reservation # noqa: F401
|
||||
|
||||
from models.user import User, WaiterZone
|
||||
from models.table import TableGroup, Table
|
||||
@@ -43,6 +56,11 @@ from models.product import Category, Product
|
||||
from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
|
||||
from models.business_day import BusinessDay
|
||||
from models.shift import WaiterShift
|
||||
from models.notes import SiteNote, SiteTodo
|
||||
from models.expenses import Contact, Expense, ExpensePayment
|
||||
from models.customers import Customer
|
||||
from models.tabs import Tab, TabEntry, TabPayment
|
||||
from models.schedule import ScheduledShift
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -83,34 +101,58 @@ def _audit(db, order_id, event_type, waiter_id=None, item_ids=None,
|
||||
db.add(log)
|
||||
|
||||
|
||||
def _random_time_in_day(day_date: date, hour_weights: list) -> datetime:
|
||||
"""
|
||||
Returns a random datetime in day_date, biased toward peak hours.
|
||||
hour_weights: list of (hour, weight) tuples covering the operating hours.
|
||||
"""
|
||||
hours = [h for h, _ in hour_weights]
|
||||
weights = [w for _, w in hour_weights]
|
||||
h = random.choices(hours, weights=weights)[0]
|
||||
m = random.randint(0, 59)
|
||||
return datetime(day_date.year, day_date.month, day_date.day, h, m)
|
||||
|
||||
|
||||
# Operating hours weighted toward lunch (12-14) and dinner peaks (19-22),
|
||||
# with lighter morning coffee traffic (10-11) and afternoon lulls (15-18).
|
||||
HOUR_WEIGHTS = [
|
||||
(10, 3), (11, 5),
|
||||
(12, 12), (13, 15), (14, 10),
|
||||
(15, 4), (16, 3), (17, 4),
|
||||
(18, 6), (19, 14), (20, 16), (21, 13), (22, 8),
|
||||
]
|
||||
|
||||
# Day-of-week multipliers (Mon=0 … Sun=6) — weekends busier
|
||||
DOW_MULTIPLIER = {0: 0.75, 1: 0.75, 2: 0.80, 3: 0.85, 4: 1.00, 5: 1.20, 6: 1.10}
|
||||
|
||||
# ── Menu definition ───────────────────────────────────────────────────────────
|
||||
# (category_name, color, [(product_name, price), ...])
|
||||
# (category_name, color, [(product_name, price, cost), ...])
|
||||
MENU = [
|
||||
("Ορεκτικά", "#f97316", [
|
||||
("Τζατζίκι", 4.50),
|
||||
("Ταραμοσαλάτα", 4.50),
|
||||
("Χωριάτικη Σαλάτα", 7.50),
|
||||
("Κεφτεδάκια", 8.00),
|
||||
("Σαγανάκι", 6.50),
|
||||
("Χούμους", 5.00),
|
||||
("Τζατζίκι", 4.50, 1.20),
|
||||
("Ταραμοσαλάτα", 4.50, 1.10),
|
||||
("Χωριάτικη Σαλάτα",7.50, 2.00),
|
||||
("Κεφτεδάκια", 8.00, 2.80),
|
||||
("Σαγανάκι", 6.50, 2.20),
|
||||
("Χούμους", 5.00, 1.40),
|
||||
]),
|
||||
("Κυρίως Πιάτα", "#ef4444", [
|
||||
("Μπριζόλα Χοιρινή", 12.00),
|
||||
("Μπριζόλα Μοσχαρίσια", 16.00),
|
||||
("Σουβλάκι Κοτόπουλο", 10.00),
|
||||
("Μουσακάς", 11.00),
|
||||
("Παστίτσιο", 10.00),
|
||||
("Σπαγγέτι Μπολονέζ", 9.50),
|
||||
("Γύρος Χοιρινός", 8.50),
|
||||
("Μπριζόλα Χοιρινή", 12.00, 4.50),
|
||||
("Μπριζόλα Μοσχαρίσια", 16.00, 7.00),
|
||||
("Σουβλάκι Κοτόπουλο", 10.00, 3.20),
|
||||
("Μουσακάς", 11.00, 3.80),
|
||||
("Παστίτσιο", 10.00, 3.50),
|
||||
("Σπαγγέτι Μπολονέζ", 9.50, 3.00),
|
||||
("Γύρος Χοιρινός", 8.50, 2.60),
|
||||
]),
|
||||
("Ποτά & Αναψυκτικά", "#3b82f6", [
|
||||
("Νερό 500ml", 1.00),
|
||||
("Αναψυκτικό", 2.50),
|
||||
("Μπύρα Φιάλη", 4.00),
|
||||
("Κρασί (ποτήρι)", 4.50),
|
||||
("Καφές Ελληνικός", 2.50),
|
||||
("Φραπέ", 3.00),
|
||||
("Χυμός Πορτοκάλι", 3.50),
|
||||
("Νερό 500ml", 1.00, 0.20),
|
||||
("Αναψυκτικό", 2.50, 0.60),
|
||||
("Μπύρα Φιάλη", 4.00, 1.20),
|
||||
("Κρασί (ποτήρι)", 4.50, 1.00),
|
||||
("Καφές Ελληνικός", 2.50, 0.45),
|
||||
("Φραπέ", 3.00, 0.50),
|
||||
("Χυμός Πορτοκάλι", 3.50, 0.90),
|
||||
]),
|
||||
]
|
||||
|
||||
@@ -118,23 +160,30 @@ MENU = [
|
||||
CATEGORY_WEIGHTS = [0.25, 0.35, 0.40]
|
||||
|
||||
WAITER_NAMES = [
|
||||
("Νίκος", "Νίκος Π."),
|
||||
("Μαρία", "Μαρία Κ."),
|
||||
("Πέτρος", "Πέτρος Α."),
|
||||
("Έλενα", "Έλενα Μ."),
|
||||
("Νίκος", "Νίκος Παπαδόπουλος", "demo_nikos", "2201"),
|
||||
("Μαρία", "Μαρία Κωνσταντίνου", "demo_maria", "3302"),
|
||||
("Πέτρος", "Πέτρος Αναστασίου", "demo_petros", "4403"),
|
||||
("Έλενα", "Έλενα Μιχαηλίδου", "demo_elena", "5504"),
|
||||
]
|
||||
|
||||
PAYMENT_METHODS = ["cash", "cash", "cash", "card", "card"] # 60% cash / 40% card
|
||||
|
||||
CANCEL_REASONS = [
|
||||
"Λάθος παραγγελία",
|
||||
"Ο πελάτης άλλαξε γνώμη",
|
||||
"Το προϊόν δεν είναι διαθέσιμο",
|
||||
"Λάθος τραπέζι",
|
||||
]
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# ── Guard: abort if demo data already present ─────────────────────────────
|
||||
existing = db.query(User).filter(User.full_name.like("[DEMO]%")).first()
|
||||
existing = db.query(User).filter(User.username == "demo_nikos").first()
|
||||
if existing:
|
||||
print("Demo data already present (found [DEMO] user). Nothing to do.")
|
||||
print("Demo data already present (found demo_nikos user). Nothing to do.")
|
||||
print("Run python wipe_database.py first if you want a fresh seed.")
|
||||
sys.exit(0)
|
||||
|
||||
@@ -155,7 +204,6 @@ try:
|
||||
db.flush()
|
||||
print(" created manager (username: manager / PIN: 1234 / password: password)")
|
||||
else:
|
||||
# Ensure password_hash is set on existing manager
|
||||
if not manager.password_hash:
|
||||
manager.password_hash = _hash_password("password")
|
||||
db.flush()
|
||||
@@ -163,22 +211,21 @@ try:
|
||||
|
||||
# ── Waiters ───────────────────────────────────────────────────────────────
|
||||
waiters = []
|
||||
for nickname, full_name in WAITER_NAMES:
|
||||
username = f"demo_{nickname.lower()}"
|
||||
for nickname, full_name, username, pin in WAITER_NAMES:
|
||||
w = db.query(User).filter(User.username == username).first()
|
||||
if not w:
|
||||
w = User(
|
||||
username=username,
|
||||
pin_hash=_hash_pin(str(random.randint(1000, 9999))),
|
||||
pin_hash=_hash_pin(pin),
|
||||
role="waiter",
|
||||
full_name=f"[DEMO] {full_name}",
|
||||
full_name=full_name,
|
||||
nickname=nickname,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(w)
|
||||
db.flush()
|
||||
waiters.append(w)
|
||||
print(f" created {len(waiters)} demo waiters")
|
||||
print(f" created {len(waiters)} waiters")
|
||||
|
||||
# ── Table groups & tables ─────────────────────────────────────────────────
|
||||
group_configs = [
|
||||
@@ -201,7 +248,7 @@ try:
|
||||
all_tables.append(tbl)
|
||||
print(f" created {len(all_tables)} tables across {len(group_configs)} groups")
|
||||
|
||||
# ── Waiter zone assignments (all waiters → all zones) ─────────────────────
|
||||
# ── Waiter zone assignments ───────────────────────────────────────────────
|
||||
for w in waiters:
|
||||
existing_zone = db.query(WaiterZone).filter(
|
||||
WaiterZone.waiter_id == w.id, WaiterZone.group_id.is_(None)
|
||||
@@ -212,8 +259,8 @@ try:
|
||||
|
||||
# ── Menu ──────────────────────────────────────────────────────────────────
|
||||
categories = []
|
||||
all_products = [] # flat list for random picks
|
||||
category_product_map = [] # parallel list of product sub-lists (for weighted pick)
|
||||
all_products = []
|
||||
category_product_map = []
|
||||
|
||||
for sort_idx, (cat_name, cat_color, items) in enumerate(MENU):
|
||||
cat = db.query(Category).filter(Category.name == cat_name).first()
|
||||
@@ -224,24 +271,82 @@ try:
|
||||
categories.append(cat)
|
||||
|
||||
cat_products = []
|
||||
for prod_sort, (prod_name, prod_price) in enumerate(items):
|
||||
for prod_sort, (prod_name, prod_price, prod_cost) in enumerate(items):
|
||||
p = db.query(Product).filter(Product.name == prod_name, Product.category_id == cat.id).first()
|
||||
if not p:
|
||||
p = Product(
|
||||
name=prod_name,
|
||||
category_id=cat.id,
|
||||
base_price=prod_price,
|
||||
cost_simple=prod_cost,
|
||||
is_available=True,
|
||||
lifecycle_status="active",
|
||||
sort_order=prod_sort,
|
||||
)
|
||||
db.add(p)
|
||||
db.flush()
|
||||
else:
|
||||
p.cost_simple = prod_cost
|
||||
db.flush()
|
||||
cat_products.append(p)
|
||||
all_products.append(p)
|
||||
category_product_map.append(cat_products)
|
||||
|
||||
print(f" created {len(categories)} categories with {len(all_products)} products")
|
||||
print(f" created {len(categories)} categories with {len(all_products)} products (with costs)")
|
||||
|
||||
# ── Contacts (suppliers & utilities) ─────────────────────────────────────
|
||||
contact_defs = [
|
||||
("Κρεοπωλείο Παπαδόπουλος", "supplier", "+30 22310 41234", None, "Πιερία 12, Κατερίνη", "Κύριος προμηθευτής κρέατος"),
|
||||
("Αλιεύματα Κόκκαλης", "supplier", "+30 22310 55678", None, "Λιμάνι, Κατερίνη", "Φρέσκα ψάρια και θαλασσινά"),
|
||||
("Χονδρικό Ελαιόλαδο", "supplier", "+30 23310 22100", "oil@agrofarm.gr","Αγροτική Οδός 5", "Ελαιόλαδο και τυρί χύμα"),
|
||||
("Αναψυκτικά ΑΕΒΕ", "supplier", "+30 210 9012345", "orders@aeve.gr", "Βιομηχανική Ζώνη, Αθήνα","Αναψυκτικά, νερά, μπύρες"),
|
||||
("ΔΕΗ", "utility", "11500", None, None, "Ηλεκτρισμός"),
|
||||
("ΕΥΔΑΠ / Τοπικό Δίκτυο", "utility", "11888", None, None, "Νερό"),
|
||||
("Cosmote Επαγγελματικό", "utility", "13888", None, None, "Τηλεφωνία & internet"),
|
||||
]
|
||||
contacts = []
|
||||
for c_name, c_type, c_phone, c_email, c_addr, c_notes in contact_defs:
|
||||
c = db.query(Contact).filter(Contact.name == c_name).first()
|
||||
if not c:
|
||||
c = Contact(
|
||||
name=c_name,
|
||||
type=c_type,
|
||||
phone=c_phone,
|
||||
email=c_email,
|
||||
address=c_addr,
|
||||
notes=c_notes,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(c)
|
||||
db.flush()
|
||||
contacts.append(c)
|
||||
print(f" created {len(contacts)} contacts")
|
||||
|
||||
# ── Customers ─────────────────────────────────────────────────────────────
|
||||
customer_defs = [
|
||||
("Γιώργος Σταθόπουλος", "Γιώργης", "+30 6944 111222", None, "Τακτικός πελάτης, κάθε Παρασκευή βράδυ"),
|
||||
("Ελευθερία Νικολάου", "Ελευθερία","+30 6977 334455", "eleftheria@gmail.com", "Αλλεργία στη γλουτένη"),
|
||||
("Δημήτρης Παππάς", "Μήτσος", "+30 6955 667788", None, "Προτιμά τραπέζι στη βεράντα"),
|
||||
("Σοφία Αλεξίου", "Σοφία", "+30 6933 998877", "sofia.alex@hotmail.com", "VIP πελάτης"),
|
||||
("Ομάδα Εργατών", None, "+30 6900 123456", None, "Ομάδα οικοδόμων — συχνά μεσημέρι"),
|
||||
]
|
||||
customers = []
|
||||
for c_name, c_nick, c_phone, c_email, c_notes in customer_defs:
|
||||
c = db.query(Customer).filter(Customer.name == c_name).first()
|
||||
if not c:
|
||||
c = Customer(
|
||||
name=c_name,
|
||||
nickname=c_nick,
|
||||
phone=c_phone,
|
||||
email=c_email,
|
||||
notes=c_notes,
|
||||
is_active=True,
|
||||
created_by_id=manager.id,
|
||||
)
|
||||
db.add(c)
|
||||
db.flush()
|
||||
customers.append(c)
|
||||
print(f" created {len(customers)} customers")
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -252,10 +357,14 @@ try:
|
||||
|
||||
print(f"\nGenerating {DEMO_DAYS} days of history...")
|
||||
|
||||
# Keep track of created business days (date → BusinessDay) for expense linking
|
||||
bday_by_date: dict[date, BusinessDay] = {}
|
||||
|
||||
for days_ago in range(DEMO_DAYS, 0, -1):
|
||||
day_date = today - timedelta(days=days_ago)
|
||||
dow = day_date.weekday()
|
||||
multiplier = DOW_MULTIPLIER[dow]
|
||||
|
||||
# Business day: opens at ~10:00, closes at ~23:30
|
||||
open_time = _utc(datetime(day_date.year, day_date.month, day_date.day,
|
||||
10, random.randint(0, 30)))
|
||||
close_time = _utc(datetime(day_date.year, day_date.month, day_date.day,
|
||||
@@ -270,12 +379,13 @@ try:
|
||||
)
|
||||
db.add(bday)
|
||||
db.flush()
|
||||
bday_by_date[day_date] = bday
|
||||
|
||||
# 2-4 waiters work each day
|
||||
working_waiters = random.sample(waiters, k=random.randint(2, len(waiters)))
|
||||
STARTING_CASH_OPTIONS = [50.0, 80.0, 100.0, 110.0, 120.0, 150.0, 200.0]
|
||||
shifts = {}
|
||||
shift_totals = {} # shift_id → running revenue total, filled during order loop
|
||||
shift_totals = {}
|
||||
for w in working_waiters:
|
||||
shift_start = open_time + timedelta(minutes=random.randint(0, 30))
|
||||
shift_end = close_time - timedelta(minutes=random.randint(0, 20))
|
||||
@@ -291,51 +401,53 @@ try:
|
||||
shifts[w.id] = shift
|
||||
shift_totals[shift.id] = 0.0
|
||||
|
||||
# Orders — two rushes: lunch (12-15h) and dinner (19-23h)
|
||||
n_orders = random.randint(*ORDERS_PER_DAY)
|
||||
lunch_count = int(n_orders * 0.4)
|
||||
dinner_count = n_orders - lunch_count
|
||||
# Orders distributed realistically across the day
|
||||
base_n = random.randint(*ORDERS_PER_DAY)
|
||||
n_orders = max(5, int(base_n * multiplier))
|
||||
|
||||
# ~8% of days have 1-2 online orders
|
||||
n_online = random.choices([0, 1, 2], weights=[0.60, 0.28, 0.12])[0]
|
||||
n_pos = n_orders - n_online
|
||||
|
||||
order_times = []
|
||||
for _ in range(lunch_count):
|
||||
h = random.randint(12, 14)
|
||||
m = random.randint(0, 59)
|
||||
order_times.append(datetime(day_date.year, day_date.month, day_date.day, h, m))
|
||||
for _ in range(dinner_count):
|
||||
h = random.randint(19, 22)
|
||||
m = random.randint(0, 59)
|
||||
order_times.append(datetime(day_date.year, day_date.month, day_date.day, h, m))
|
||||
for _ in range(n_pos):
|
||||
order_times.append(_random_time_in_day(day_date, HOUR_WEIGHTS))
|
||||
order_times.sort()
|
||||
|
||||
# Track which tables are "busy" at each point in time so we don't double-seat
|
||||
# Format: {table_id: release_time}
|
||||
# Online orders land at lunch or dinner time
|
||||
online_order_times = []
|
||||
for _ in range(n_online):
|
||||
h = random.choices([12, 13, 19, 20, 21], weights=[2, 2, 3, 3, 2])[0]
|
||||
online_order_times.append(datetime(day_date.year, day_date.month, day_date.day,
|
||||
h, random.randint(0, 59)))
|
||||
|
||||
table_busy_until: dict[int, datetime] = {}
|
||||
|
||||
# ── POS orders ────────────────────────────────────────────────────────
|
||||
for opened_at_naive in order_times:
|
||||
opened_at = _utc(opened_at_naive)
|
||||
|
||||
# Pick a free table
|
||||
free_tables = [
|
||||
t for t in all_tables
|
||||
if table_busy_until.get(t.id, datetime.min.replace(tzinfo=timezone.utc)) <= opened_at
|
||||
]
|
||||
if not free_tables:
|
||||
free_tables = all_tables # all busy → just reuse (edge case)
|
||||
free_tables = all_tables
|
||||
table = random.choice(free_tables)
|
||||
|
||||
# Pick a waiter who is working today
|
||||
waiter = random.choice(working_waiters)
|
||||
shift = shifts[waiter.id]
|
||||
|
||||
# Order duration: 20-70 min
|
||||
duration_min = random.randint(20, 70)
|
||||
closed_at = opened_at + timedelta(minutes=duration_min)
|
||||
# Cap to close_time
|
||||
if closed_at > close_time:
|
||||
closed_at = close_time - timedelta(minutes=2)
|
||||
|
||||
table_busy_until[table.id] = closed_at
|
||||
|
||||
# ~15% of orders linked to a known customer
|
||||
order_customer = random.choice(customers) if random.random() < 0.15 else None
|
||||
|
||||
order = Order(
|
||||
table_id=table.id,
|
||||
opened_by=waiter.id,
|
||||
@@ -344,21 +456,16 @@ try:
|
||||
closed_by=manager.id,
|
||||
notes=None,
|
||||
source="pos",
|
||||
customer_id=order_customer.id if order_customer else None,
|
||||
)
|
||||
order.opened_at = opened_at
|
||||
order.closed_at = closed_at
|
||||
db.add(order)
|
||||
db.flush()
|
||||
|
||||
db.add(OrderWaiter(
|
||||
order_id=order.id,
|
||||
waiter_id=waiter.id,
|
||||
assigned_at=opened_at,
|
||||
))
|
||||
|
||||
db.add(OrderWaiter(order_id=order.id, waiter_id=waiter.id, assigned_at=opened_at))
|
||||
_audit(db, order.id, "ORDER_OPENED", waiter_id=waiter.id, created_at=opened_at)
|
||||
|
||||
# Items: 2-6 items, weighted by category
|
||||
n_items = random.randint(2, 6)
|
||||
chosen_category_lists = random.choices(category_product_map, weights=CATEGORY_WEIGHTS, k=n_items)
|
||||
items_added = []
|
||||
@@ -368,10 +475,11 @@ try:
|
||||
for prod_list in chosen_category_lists:
|
||||
product = random.choice(prod_list)
|
||||
qty = random.choices([1, 2, 3], weights=[0.7, 0.2, 0.1])[0]
|
||||
payment_method = random.choice(PAYMENT_METHODS)
|
||||
|
||||
# ~10% chance the item gets cancelled
|
||||
is_cancelled = random.random() < 0.10
|
||||
paid_at = closed_at - timedelta(minutes=random.randint(1, 5))
|
||||
line_total = product.base_price * qty
|
||||
order_total += line_total
|
||||
|
||||
item = OrderItem(
|
||||
order_id=order.id,
|
||||
@@ -379,44 +487,384 @@ try:
|
||||
added_by=waiter.id,
|
||||
quantity=qty,
|
||||
unit_price=product.base_price,
|
||||
status="paid",
|
||||
unit_cost=product.cost_simple,
|
||||
status="cancelled" if is_cancelled else "paid",
|
||||
printed=True,
|
||||
paid_by=waiter.id,
|
||||
paid_at=_utc(paid_at),
|
||||
payment_method=payment_method,
|
||||
paid_in_shift_id=shift.id,
|
||||
)
|
||||
|
||||
if is_cancelled:
|
||||
cancel_at = items_added_at + timedelta(minutes=random.randint(1, 10))
|
||||
item.cancelled_by = waiter.id
|
||||
item.cancelled_at = _utc(cancel_at)
|
||||
item.cancel_reason = random.choice(CANCEL_REASONS)
|
||||
else:
|
||||
payment_method = random.choice(PAYMENT_METHODS)
|
||||
item.paid_by = waiter.id
|
||||
item.paid_at = _utc(paid_at)
|
||||
item.payment_method = payment_method
|
||||
item.paid_in_shift_id = shift.id
|
||||
order_total += line_total
|
||||
shift_totals[shift.id] = round(shift_totals[shift.id] + line_total, 2)
|
||||
|
||||
item.added_at = _utc(items_added_at)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
items_added.append(item.id)
|
||||
shift_totals[shift.id] = round(shift_totals[shift.id] + line_total, 2)
|
||||
|
||||
_audit(db, order.id, "ITEMS_ADDED",
|
||||
waiter_id=waiter.id, item_ids=items_added,
|
||||
created_at=items_added_at)
|
||||
waiter_id=waiter.id, item_ids=items_added, created_at=items_added_at)
|
||||
|
||||
# Payment audit (single payment per order for simplicity)
|
||||
payment_method = random.choice(PAYMENT_METHODS)
|
||||
pay_at = closed_at - timedelta(minutes=1)
|
||||
_audit(db, order.id, "PAYMENT",
|
||||
waiter_id=waiter.id, item_ids=items_added,
|
||||
amount=round(order_total, 2), payment_method=payment_method,
|
||||
created_at=pay_at)
|
||||
|
||||
_audit(db, order.id, "ORDER_CLOSED",
|
||||
waiter_id=waiter.id, created_at=closed_at)
|
||||
_audit(db, order.id, "ORDER_CLOSED", waiter_id=waiter.id, created_at=closed_at)
|
||||
|
||||
total_orders += 1
|
||||
total_revenue += order_total
|
||||
|
||||
# Write total_collected snapshot onto each ended shift
|
||||
# ── Online orders ─────────────────────────────────────────────────────
|
||||
online_ref_counter = days_ago * 10 # rough unique ref per day
|
||||
for ot in online_order_times:
|
||||
opened_at = _utc(ot)
|
||||
duration_min = random.randint(25, 50)
|
||||
closed_at = opened_at + timedelta(minutes=duration_min)
|
||||
if closed_at > close_time:
|
||||
closed_at = close_time - timedelta(minutes=2)
|
||||
|
||||
online_type = random.choice(["delivery", "dine_in"])
|
||||
online_names = ["Αλέξης Κ.", "Μαρία Π.", "Νίκος Σ.", "Αθηνά Λ.", "Θανάσης Β.", "Γιώτα Δ."]
|
||||
online_phones = ["+30 694 111 0000", "+30 697 222 1111", "+30 693 333 2222",
|
||||
"+30 699 444 3333", "+30 698 555 4444"]
|
||||
o_name = random.choice(online_names)
|
||||
o_phone = random.choice(online_phones)
|
||||
o_status = random.choices(
|
||||
["delivered", "delivered", "delivered", "rejected"],
|
||||
weights=[0.85, 0.05, 0.05, 0.05]
|
||||
)[0]
|
||||
|
||||
ref = f"ORD-{online_ref_counter:04d}"
|
||||
online_ref_counter += 1
|
||||
|
||||
order = Order(
|
||||
table_id=None,
|
||||
opened_by=manager.id,
|
||||
business_day_id=bday.id,
|
||||
status="closed" if o_status == "delivered" else "cancelled",
|
||||
closed_by=manager.id,
|
||||
source="online",
|
||||
online_order_ref=ref,
|
||||
online_status=o_status,
|
||||
online_customer_name=o_name,
|
||||
online_customer_phone=o_phone,
|
||||
online_order_type=online_type,
|
||||
online_customer_notes=random.choice([None, None, "Χωρίς κρεμμύδι", "Γρήγορα παρακαλώ", None]),
|
||||
order_type="delivery" if online_type == "delivery" else "here",
|
||||
)
|
||||
order.opened_at = opened_at
|
||||
order.closed_at = closed_at
|
||||
db.add(order)
|
||||
db.flush()
|
||||
|
||||
_audit(db, order.id, "ORDER_OPENED", waiter_id=manager.id, created_at=opened_at)
|
||||
|
||||
n_items = random.randint(1, 4)
|
||||
chosen_category_lists = random.choices(category_product_map, weights=CATEGORY_WEIGHTS, k=n_items)
|
||||
items_added = []
|
||||
order_total = 0.0
|
||||
items_added_at = opened_at + timedelta(minutes=2)
|
||||
|
||||
for prod_list in chosen_category_lists:
|
||||
product = random.choice(prod_list)
|
||||
qty = random.choices([1, 2], weights=[0.8, 0.2])[0]
|
||||
line_total = product.base_price * qty
|
||||
order_total += line_total
|
||||
|
||||
item = OrderItem(
|
||||
order_id=order.id,
|
||||
product_id=product.id,
|
||||
added_by=manager.id,
|
||||
quantity=qty,
|
||||
unit_price=product.base_price,
|
||||
unit_cost=product.cost_simple,
|
||||
status="paid" if o_status == "delivered" else "cancelled",
|
||||
printed=True,
|
||||
paid_by=manager.id if o_status == "delivered" else None,
|
||||
paid_at=_utc(closed_at) if o_status == "delivered" else None,
|
||||
payment_method="card" if o_status == "delivered" else None,
|
||||
)
|
||||
item.added_at = _utc(items_added_at)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
items_added.append(item.id)
|
||||
|
||||
if o_status == "delivered":
|
||||
_audit(db, order.id, "PAYMENT",
|
||||
waiter_id=manager.id, item_ids=items_added,
|
||||
amount=round(order_total, 2), payment_method="card",
|
||||
created_at=closed_at - timedelta(minutes=1))
|
||||
total_revenue += order_total
|
||||
_audit(db, order.id, "ORDER_CLOSED", waiter_id=manager.id, created_at=closed_at)
|
||||
total_orders += 1
|
||||
|
||||
# Write total_collected onto shifts
|
||||
for w in working_waiters:
|
||||
s = shifts[w.id]
|
||||
s.total_collected = shift_totals[s.id]
|
||||
|
||||
db.commit()
|
||||
print(f" {day_date} {n_orders:2d} orders {len(working_waiters)} waiters")
|
||||
print(f" {day_date} {n_orders:2d} orders {len(working_waiters)} waiters (dow={dow})")
|
||||
|
||||
# ── Expenses ──────────────────────────────────────────────────────────────
|
||||
print("\nAdding expenses...")
|
||||
|
||||
supplier_meat = contacts[0]
|
||||
supplier_fish = contacts[1]
|
||||
supplier_olive = contacts[2]
|
||||
supplier_bev = contacts[3]
|
||||
util_power = contacts[4]
|
||||
util_water = contacts[5]
|
||||
util_phone = contacts[6]
|
||||
|
||||
def _add_expense(description, category, contact, amount, paid_amount,
|
||||
days_back, notes=None):
|
||||
exp_date = today - timedelta(days=days_back)
|
||||
bday_obj = bday_by_date.get(exp_date)
|
||||
due = _utc(datetime(exp_date.year, exp_date.month, exp_date.day, 9, 0))
|
||||
exp = Expense(
|
||||
description=description,
|
||||
category=category,
|
||||
contact_id=contact.id if contact else None,
|
||||
total_amount=amount,
|
||||
paid_amount=paid_amount,
|
||||
due_date=due,
|
||||
business_day_id=bday_obj.id if bday_obj else None,
|
||||
created_by_id=manager.id,
|
||||
notes=notes,
|
||||
)
|
||||
exp.created_at = due
|
||||
db.add(exp)
|
||||
db.flush()
|
||||
if paid_amount > 0:
|
||||
pmt = ExpensePayment(
|
||||
expense_id=exp.id,
|
||||
amount=paid_amount,
|
||||
paid_by_id=manager.id,
|
||||
notes=None,
|
||||
)
|
||||
pmt.paid_at = due + timedelta(hours=1)
|
||||
db.add(pmt)
|
||||
db.flush()
|
||||
|
||||
# Recurring weekly: meat supplier (~every 5 days)
|
||||
for i in range(0, DEMO_DAYS, 5):
|
||||
_add_expense("Κρέας εβδομάδας", "supplier", supplier_meat,
|
||||
round(random.uniform(180, 280), 2), 0,
|
||||
DEMO_DAYS - i, "Παράδοση Δευτέρα πρωί")
|
||||
|
||||
# Recurring bi-weekly: fish
|
||||
for i in range(0, DEMO_DAYS, 10):
|
||||
_add_expense("Ψάρια & θαλασσινά", "supplier", supplier_fish,
|
||||
round(random.uniform(90, 160), 2), 0,
|
||||
DEMO_DAYS - i)
|
||||
|
||||
# Olive oil once a month
|
||||
for i in range(0, DEMO_DAYS, 30):
|
||||
_add_expense("Ελαιόλαδο & τυρί", "supplier", supplier_olive,
|
||||
round(random.uniform(120, 200), 2), 0,
|
||||
DEMO_DAYS - i)
|
||||
|
||||
# Beverages every 2 weeks
|
||||
for i in range(0, DEMO_DAYS, 14):
|
||||
amt = round(random.uniform(200, 350), 2)
|
||||
_add_expense("Αναψυκτικά & μπύρες", "supplier", supplier_bev,
|
||||
amt, amt, # fully paid
|
||||
DEMO_DAYS - i)
|
||||
|
||||
# Monthly utility bills
|
||||
for month_offset in range(4):
|
||||
days_back = 30 * (month_offset + 1)
|
||||
_add_expense("ΔΕΗ — μηνιαίος λογαριασμός", "utilities", util_power,
|
||||
round(random.uniform(280, 420), 2), 0, days_back)
|
||||
_add_expense("Νερό — μηνιαίος λογαριασμός", "utilities", util_water,
|
||||
round(random.uniform(45, 90), 2), 0, days_back + 2)
|
||||
_add_expense("Τηλεφωνία & Internet", "utilities", util_phone,
|
||||
49.90, 49.90, days_back + 1)
|
||||
|
||||
# Rent once a month
|
||||
for month_offset in range(4):
|
||||
days_back = 30 * (month_offset + 1) - 5
|
||||
_add_expense("Ενοίκιο καταστήματος", "rent", None,
|
||||
1200.0, 1200.0, days_back,
|
||||
"Πάντα στις αρχές του μήνα")
|
||||
|
||||
# One maintenance expense
|
||||
_add_expense("Επισκευή ψυγείου", "maintenance", None,
|
||||
320.0, 320.0, 45, "Συμπιεστής — Τεχνίτης Σωτήρης")
|
||||
|
||||
db.commit()
|
||||
print(" expenses done")
|
||||
|
||||
# ── Tabs ──────────────────────────────────────────────────────────────────
|
||||
print("Adding tabs...")
|
||||
|
||||
def _add_tab(customer, entries_desc, payments, is_closed=False):
|
||||
"""entries_desc: list of (amount, description). payments: list of amounts."""
|
||||
tab_opened = _utc(datetime.now(timezone.utc) - timedelta(days=random.randint(5, 30)))
|
||||
tab = Tab(
|
||||
customer_id=customer.id,
|
||||
status="closed" if is_closed else "open",
|
||||
opened_at=tab_opened,
|
||||
closed_by_id=manager.id if is_closed else None,
|
||||
closed_at=tab_opened + timedelta(days=random.randint(3, 15)) if is_closed else None,
|
||||
notes=None,
|
||||
)
|
||||
db.add(tab)
|
||||
db.flush()
|
||||
|
||||
entry_time = tab_opened
|
||||
for amount, desc in entries_desc:
|
||||
entry_time += timedelta(days=random.randint(1, 3))
|
||||
e = TabEntry(
|
||||
tab_id=tab.id,
|
||||
order_id=None,
|
||||
order_item_id=None,
|
||||
amount=amount,
|
||||
description=desc,
|
||||
created_by_id=manager.id,
|
||||
)
|
||||
e.created_at = entry_time
|
||||
db.add(e)
|
||||
|
||||
pay_time = entry_time + timedelta(days=1)
|
||||
for pay_amount in payments:
|
||||
pay_time += timedelta(hours=random.randint(1, 24))
|
||||
p = TabPayment(
|
||||
tab_id=tab.id,
|
||||
amount=pay_amount,
|
||||
payment_method=random.choice(["cash", "card"]),
|
||||
received_by_id=manager.id,
|
||||
notes=None,
|
||||
)
|
||||
p.created_at = pay_time
|
||||
db.add(p)
|
||||
|
||||
db.flush()
|
||||
|
||||
# Open tab: Γιώργης hasn't settled his tab yet
|
||||
_add_tab(customers[0], [
|
||||
(35.50, "Δείπνο 3 ατόμων — 15/05"),
|
||||
(28.00, "Μεσημεριανό — 22/05"),
|
||||
(42.00, "Δείπνο 4 ατόμων — 28/05"),
|
||||
], [20.00]) # partial payment
|
||||
|
||||
# Closed tab: Σοφία settled hers
|
||||
_add_tab(customers[3], [
|
||||
(55.00, "Εταιρικό δείπνο — 10/04"),
|
||||
(30.00, "Μεσημεριανό — 18/04"),
|
||||
], [85.00], is_closed=True)
|
||||
|
||||
# Open tab: Ομάδα εργατών — running tab
|
||||
_add_tab(customers[4], [
|
||||
(22.00, "Μεσημεριανό ομάδα — 01/06"),
|
||||
(25.50, "Μεσημεριανό ομάδα — 05/06"),
|
||||
(19.00, "Μεσημεριανό ομάδα — 09/06"),
|
||||
], [30.00])
|
||||
|
||||
db.commit()
|
||||
print(" tabs done")
|
||||
|
||||
# ── Site Notes & Todos ────────────────────────────────────────────────────
|
||||
print("Adding notes & todos...")
|
||||
|
||||
notes_data = [
|
||||
("Ο φούρνος χρειάζεται σέρβις — να κλείσουμε ραντεβού με τεχνίτη.", True),
|
||||
("Νέα τιμή μπύρας από 1 Ιουλίου: Heineken 4.50€ (από 4.00€).", False),
|
||||
("Παραγγελία ελαιολάδου: 10 κιλά εξαιρετικό παρθένο από Αλεξόπουλο.", False),
|
||||
("ΔΕΗ: η επόμενη πληρωμή λήγει στις 20 Ιουλίου. Να μην ξεχαστεί!", True),
|
||||
("Το κλιματιστικό βεράντας λειτουργεί πάλι μετά την επισκευή (Παρ. 07/06).", False),
|
||||
]
|
||||
for body, pinned in notes_data:
|
||||
note = SiteNote(
|
||||
body=body,
|
||||
created_by_id=manager.id,
|
||||
is_pinned=pinned,
|
||||
)
|
||||
db.add(note)
|
||||
|
||||
todos_data = [
|
||||
("Ανανέωση τιμοκαταλόγου για καλοκαίρι", "high", False),
|
||||
("Αγορά νέων ποτηριών κρασιού (χάλασαν 4)", "normal", False),
|
||||
("Έλεγχος πυροσβεστήρων — Ιούλιος", "high", False),
|
||||
("Εκπαίδευση νέου σερβιτόρου στο σύστημα", "normal", False),
|
||||
("Ανανέωση άδειας λειτουργίας", "high", False),
|
||||
("Παραγγελία νέων χαρτοπετσετών και υλικών bar", "normal", True),
|
||||
("Φωτογράφιση νέων πιάτων για online menu", "normal", False),
|
||||
]
|
||||
for body, priority, is_done in todos_data:
|
||||
todo = SiteTodo(
|
||||
body=body,
|
||||
priority=priority,
|
||||
is_done=is_done,
|
||||
created_by_id=manager.id,
|
||||
done_by_id=manager.id if is_done else None,
|
||||
done_at=_utc(datetime.now(timezone.utc) - timedelta(days=2)) if is_done else None,
|
||||
)
|
||||
db.add(todo)
|
||||
|
||||
db.commit()
|
||||
print(" notes & todos done")
|
||||
|
||||
# ── Schedule (next 3 weeks) ───────────────────────────────────────────────
|
||||
print("Adding schedule for next 3 weeks...")
|
||||
|
||||
# Each waiter works 5 days out of 7, with realistic shift patterns
|
||||
# Mon-Fri: full shifts, Sat: both sessions, Sun: afternoon/evening only
|
||||
shift_patterns = {
|
||||
0: [("10:00", "16:00"), ("16:00", "23:30")], # Mon — two shifts
|
||||
1: [("10:00", "16:00"), ("16:00", "23:30")], # Tue
|
||||
2: [("10:00", "16:00"), ("16:00", "23:30")], # Wed
|
||||
3: [("10:00", "16:00"), ("16:00", "23:30")], # Thu
|
||||
4: [("10:00", "17:00"), ("17:00", "23:30")], # Fri
|
||||
5: [("10:00", "16:00"), ("16:00", "23:30")], # Sat — busy day
|
||||
6: [("11:00", "16:00"), ("16:00", "23:00")], # Sun
|
||||
}
|
||||
|
||||
# Assign waiters to shifts: each day, 2 waiters per slot
|
||||
for weeks_ahead in range(3):
|
||||
for day_offset in range(7):
|
||||
sched_date = today + timedelta(weeks=weeks_ahead, days=day_offset)
|
||||
dow = sched_date.weekday()
|
||||
day_patterns = shift_patterns[dow]
|
||||
|
||||
# Shuffle waiters and assign 2 per slot
|
||||
waiter_pool = waiters[:]
|
||||
random.shuffle(waiter_pool)
|
||||
for slot_idx, (start_t, end_t) in enumerate(day_patterns):
|
||||
# Pick 2 waiters per slot (rotate through pool)
|
||||
slot_waiters = [waiter_pool[slot_idx % len(waiter_pool)],
|
||||
waiter_pool[(slot_idx + 1) % len(waiter_pool)]]
|
||||
for w in slot_waiters:
|
||||
# Don't double-schedule same waiter on same day
|
||||
already = db.query(ScheduledShift).filter(
|
||||
ScheduledShift.user_id == w.id,
|
||||
ScheduledShift.scheduled_date == sched_date,
|
||||
).first()
|
||||
if not already:
|
||||
sched = ScheduledShift(
|
||||
user_id=w.id,
|
||||
scheduled_date=sched_date,
|
||||
start_time=start_t,
|
||||
end_time=end_t,
|
||||
notes=None,
|
||||
created_by_id=manager.id,
|
||||
)
|
||||
db.add(sched)
|
||||
|
||||
db.commit()
|
||||
print(" schedule done")
|
||||
|
||||
print(f"\nDone.")
|
||||
print(f" Total orders : {total_orders}")
|
||||
@@ -424,7 +872,7 @@ try:
|
||||
print(f"\nLogin credentials:")
|
||||
print(f" Manager — username: manager | password: password | PIN: 1234")
|
||||
for w in waiters:
|
||||
print(f" Waiter — username: {w.username}")
|
||||
print(f" Waiter — username: {w.username} | nickname: {w.nickname}")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -13,7 +13,7 @@ import models.user # noqa: F401 — also registers WaiterZone
|
||||
import models.table # noqa: F401
|
||||
import models.printer # noqa: F401
|
||||
import models.product # noqa: F401
|
||||
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount
|
||||
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount, PrintJob
|
||||
import models.business_day # noqa: F401
|
||||
import models.shift # noqa: F401 — registers WaiterShift, ShiftBreak
|
||||
import models.settings # noqa: F401
|
||||
@@ -26,6 +26,10 @@ import models.customers # noqa: F401 — registers Customer
|
||||
import models.tabs # noqa: F401 — registers Tab, TabEntry, TabPayment
|
||||
import models.waste # noqa: F401 — registers WasteLog
|
||||
import models.schedule # noqa: F401 — registers ScheduledShift
|
||||
import models.chat # noqa: F401 — registers Conversation, ConversationParticipant, ChatMessage
|
||||
import models.prep_zone # noqa: F401 — registers PrepZone, prep_zone_printers, product_prep_zones
|
||||
import models.pricing # noqa: F401 — registers PriceGroup, PriceModifier, Deal, PriceEventLog, WaiterDiscountSettings
|
||||
import models.recovery_code # noqa: F401 — registers RecoveryCode
|
||||
|
||||
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
|
||||
from routers import business_day as business_day_router
|
||||
@@ -34,6 +38,7 @@ from routers import settings as settings_router
|
||||
from routers import flags as flags_router
|
||||
from routers import messages as messages_router
|
||||
from routers import sse as sse_router
|
||||
from routers import ws as ws_router
|
||||
from routers import data_transfer as data_transfer_router
|
||||
from routers import connect_orders as connect_orders_router
|
||||
from routers import reservations as reservations_router
|
||||
@@ -44,6 +49,12 @@ from routers import tabs as tabs_router
|
||||
from routers import waste as waste_router
|
||||
from routers import kds as kds_router
|
||||
from routers import schedule as schedule_router
|
||||
from routers import chat as chat_router
|
||||
from routers import prep_zones as prep_zones_router
|
||||
from routers import pricing as pricing_router
|
||||
from routers import fiscal as fiscal_router
|
||||
from routers import phone as phone_router
|
||||
from routers import recovery as recovery_router
|
||||
|
||||
|
||||
def _run_migrations():
|
||||
@@ -275,6 +286,8 @@ def _run_migrations():
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
# Product unit of measure (piece / portion / kg / liter / gram / ml)
|
||||
"ALTER TABLE products ADD COLUMN unit_type VARCHAR NOT NULL DEFAULT 'piece'",
|
||||
# Phase 2A — product cost tracking
|
||||
"ALTER TABLE products ADD COLUMN cost_simple REAL",
|
||||
"ALTER TABLE products ADD COLUMN cost_breakdown TEXT",
|
||||
@@ -411,6 +424,314 @@ def _run_migrations():
|
||||
# Waiter cancellation permissions
|
||||
"ALTER TABLE users ADD COLUMN can_cancel_orders INTEGER NOT NULL DEFAULT 0",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.waiter_cancellations_allowed', 'false', CURRENT_TIMESTAMP)",
|
||||
# KDS — kitchen display system status tracking
|
||||
"ALTER TABLE orders ADD COLUMN kds_status VARCHAR NOT NULL DEFAULT 'pending'",
|
||||
"ALTER TABLE orders ADD COLUMN order_type VARCHAR NOT NULL DEFAULT 'here'",
|
||||
"ALTER TABLE order_items ADD COLUMN kds_status VARCHAR NOT NULL DEFAULT 'pending'",
|
||||
# KDS notifications — message type + zone tagging on staff messages
|
||||
"ALTER TABLE staff_messages ADD COLUMN message_type VARCHAR NOT NULL DEFAULT 'manager'",
|
||||
"ALTER TABLE staff_messages ADD COLUMN kds_zone VARCHAR",
|
||||
# Chat system
|
||||
"""CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type VARCHAR NOT NULL DEFAULT 'direct',
|
||||
name VARCHAR,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_read_at DATETIME
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL,
|
||||
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME
|
||||
)""",
|
||||
# Prep Zones — replaces per-product printer assignment
|
||||
"""CREATE TABLE IF NOT EXISTS prep_zones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
description VARCHAR
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS prep_zone_printers (
|
||||
prep_zone_id INTEGER NOT NULL REFERENCES prep_zones(id),
|
||||
printer_id INTEGER NOT NULL REFERENCES printers(id),
|
||||
PRIMARY KEY (prep_zone_id, printer_id)
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS product_prep_zones (
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
prep_zone_id INTEGER NOT NULL REFERENCES prep_zones(id),
|
||||
PRIMARY KEY (product_id, prep_zone_id)
|
||||
)""",
|
||||
# KDS decline tracking on items
|
||||
"ALTER TABLE order_items ADD COLUMN decline_note TEXT",
|
||||
# Prep zone notification name
|
||||
"ALTER TABLE prep_zones ADD COLUMN notification_name VARCHAR",
|
||||
# KDS status change timestamp on orders (for "latest change" sorting in PWA)
|
||||
"ALTER TABLE orders ADD COLUMN kds_status_changed_at DATETIME",
|
||||
# Auto-close table after full payment setting
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.auto_close_on_full_payment', 'false', CURRENT_TIMESTAMP)",
|
||||
# Quick Add: allow direct 1-unit add from product list without opening config modal
|
||||
"ALTER TABLE products ADD COLUMN quick_add_enabled INTEGER NOT NULL DEFAULT 1",
|
||||
# Per-waiter settings + favorites sync (JSON blob)
|
||||
"ALTER TABLE users ADD COLUMN waiter_settings TEXT",
|
||||
# Prep zone print settings — synced with KDS auto-print controls
|
||||
"ALTER TABLE prep_zones ADD COLUMN auto_print VARCHAR NOT NULL DEFAULT 'none'",
|
||||
"ALTER TABLE prep_zones ADD COLUMN print_copies INTEGER NOT NULL DEFAULT 1",
|
||||
# Bypass KDS/serve flow: new orders immediately start as served
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.bypass_kds_serve', 'false', CURRENT_TIMESTAMP)",
|
||||
# Prep zone: rename auto_print value 'primary' → 'master' for existing rows
|
||||
"UPDATE prep_zones SET auto_print = 'master' WHERE auto_print = 'primary'",
|
||||
# Prep zone: master printer (single FK) + per-role copy counts
|
||||
"ALTER TABLE prep_zones ADD COLUMN master_printer_id INTEGER REFERENCES printers(id)",
|
||||
"ALTER TABLE prep_zones ADD COLUMN master_copies INTEGER NOT NULL DEFAULT 1",
|
||||
"ALTER TABLE prep_zones ADD COLUMN secondary_copies INTEGER NOT NULL DEFAULT 1",
|
||||
# Prep zone: item sort / category grouping options
|
||||
"ALTER TABLE prep_zones ADD COLUMN sort_items_by VARCHAR NOT NULL DEFAULT 'order_time'",
|
||||
"ALTER TABLE prep_zones ADD COLUMN group_by_category INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE prep_zones ADD COLUMN category_order TEXT NOT NULL DEFAULT '[]'",
|
||||
# Prep zone: ticket formatting option
|
||||
"ALTER TABLE prep_zones ADD COLUMN print_checkboxes INTEGER NOT NULL DEFAULT 0",
|
||||
# Prep zone: KDS bypass options
|
||||
"ALTER TABLE prep_zones ADD COLUMN bypass_pending INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE prep_zones ADD COLUMN bypass_kds INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE prep_zones ADD COLUMN auto_ready_to_served INTEGER NOT NULL DEFAULT 0",
|
||||
# Prep zone: migrate existing master_printer_id from first printer in zone (best-effort)
|
||||
# (handled in Python at runtime, not SQL — no-op SQL here)
|
||||
# Product tags (JSON array of strings)
|
||||
"ALTER TABLE products ADD COLUMN tags TEXT",
|
||||
# Hide revenue from waiters on shift overview
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.hide_revenue_from_waiters', 'false', CURRENT_TIMESTAMP)",
|
||||
# WebSocket event log — append-only, pruned to 24h on startup
|
||||
"""CREATE TABLE IF NOT EXISTS sync_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type VARCHAR NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
target_user_ids TEXT,
|
||||
created_at VARCHAR NOT NULL
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_sync_events_created_at ON sync_events (created_at)",
|
||||
# Migrate from legacy per-product printer to prep zones: clear old printer_zone_id
|
||||
"UPDATE products SET printer_zone_id = NULL WHERE printer_zone_id IS NOT NULL",
|
||||
# Per-printer ESC/POS code page index (n in ESC t n) — default 29 = CP737 on Jolimark
|
||||
"ALTER TABLE printers ADD COLUMN codepage_n INTEGER NOT NULL DEFAULT 29",
|
||||
# Price adjustment: per-item on-the-fly price delta (positive or negative)
|
||||
"ALTER TABLE order_items ADD COLUMN price_adjustment REAL NOT NULL DEFAULT 0.0",
|
||||
# Customer count: how many people are at the table for this order
|
||||
"ALTER TABLE orders ADD COLUMN customer_count INTEGER",
|
||||
# Service items: flag on products, these are non-inventoried table-service items
|
||||
"ALTER TABLE products ADD COLUMN is_service_item INTEGER NOT NULL DEFAULT 0",
|
||||
# Table seat count — physical seats at each table (foundation for per-seat dish assignment)
|
||||
"ALTER TABLE tables ADD COLUMN seat_count INTEGER",
|
||||
# Courses — optional course assignment for sequenced firing
|
||||
"ALTER TABLE order_items ADD COLUMN course_id INTEGER",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.courses_enabled', 'false', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.courses', '[]', CURRENT_TIMESTAMP)",
|
||||
# Print job lifecycle tracking — auto-retry system
|
||||
"""CREATE TABLE IF NOT EXISTS print_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
printer_id INTEGER NOT NULL REFERENCES printers(id),
|
||||
zone_id INTEGER,
|
||||
item_ids TEXT NOT NULL,
|
||||
copies INTEGER NOT NULL DEFAULT 1,
|
||||
status VARCHAR NOT NULL DEFAULT 'pending',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_attempted_at DATETIME,
|
||||
succeeded_at DATETIME,
|
||||
cancelled_at DATETIME,
|
||||
cancel_reason VARCHAR
|
||||
)""",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.waiter_price_adjust_allowed', 'false', CURRENT_TIMESTAMP)",
|
||||
# Pricing system — Phase 2 pricing overhaul
|
||||
"""CREATE TABLE IF NOT EXISTS price_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR,
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
auto_enable_time VARCHAR,
|
||||
auto_disable_time VARCHAR,
|
||||
auto_days TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS price_modifiers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
allow_stack INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
scope VARCHAR NOT NULL DEFAULT 'global',
|
||||
item_id INTEGER REFERENCES products(id),
|
||||
action_type VARCHAR NOT NULL,
|
||||
action_value REAL NOT NULL,
|
||||
round_to VARCHAR,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
updated_at DATETIME,
|
||||
updated_by INTEGER REFERENCES users(id)
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS price_modifier_conditions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
modifier_id INTEGER NOT NULL REFERENCES price_modifiers(id) ON DELETE CASCADE,
|
||||
condition_type VARCHAR NOT NULL,
|
||||
params TEXT NOT NULL DEFAULT '{}'
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS price_modifier_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
modifier_id INTEGER NOT NULL REFERENCES price_modifiers(id) ON DELETE CASCADE,
|
||||
target_type VARCHAR NOT NULL,
|
||||
target_id INTEGER,
|
||||
target_tag VARCHAR
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS deals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
action_type VARCHAR NOT NULL,
|
||||
action_modifier_id INTEGER REFERENCES price_modifiers(id),
|
||||
action_value REAL,
|
||||
action_free_item_id INTEGER REFERENCES products(id),
|
||||
action_free_target_type VARCHAR,
|
||||
action_free_target_ids TEXT,
|
||||
action_free_quantity INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS deal_conditions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
deal_id INTEGER NOT NULL REFERENCES deals(id) ON DELETE CASCADE,
|
||||
condition_type VARCHAR NOT NULL,
|
||||
params TEXT NOT NULL DEFAULT '{}'
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS deal_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
deal_id INTEGER NOT NULL REFERENCES deals(id) ON DELETE CASCADE,
|
||||
target_type VARCHAR NOT NULL,
|
||||
target_id INTEGER,
|
||||
target_tag VARCHAR
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS price_event_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
order_item_id INTEGER REFERENCES order_items(id),
|
||||
event_type VARCHAR NOT NULL,
|
||||
modifier_id INTEGER REFERENCES price_modifiers(id),
|
||||
deal_id INTEGER REFERENCES deals(id),
|
||||
price_before REAL,
|
||||
price_after REAL,
|
||||
delta_amount REAL,
|
||||
applied_by_user_id INTEGER REFERENCES users(id),
|
||||
waiter_note TEXT,
|
||||
conditions_snapshot TEXT,
|
||||
selected_item_ids TEXT,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_price_event_log_order_id ON price_event_log (order_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_price_event_log_order_item_id ON price_event_log (order_item_id)",
|
||||
"""CREATE TABLE IF NOT EXISTS waiter_discount_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
can_apply_discounts INTEGER NOT NULL DEFAULT 0,
|
||||
max_discount_percent REAL,
|
||||
max_discount_amount REAL,
|
||||
max_total_value_shift REAL,
|
||||
max_total_value_workday REAL,
|
||||
max_items_per_shift INTEGER,
|
||||
max_items_per_workday INTEGER,
|
||||
max_items_per_order INTEGER
|
||||
)""",
|
||||
# Extend order_discounts with price snapshot columns for clean audit trail
|
||||
"ALTER TABLE order_discounts ADD COLUMN price_before REAL",
|
||||
"ALTER TABLE order_discounts ADD COLUMN price_after REAL",
|
||||
# Global discount settings in pos_settings
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('discounts.enabled', 'true', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('discounts.max_total_value_workday', '', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('discounts.max_total_value_shift', '', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('discounts.max_items_per_shift', '', CURRENT_TIMESTAMP)",
|
||||
# Deal item binding — which deal created this item, and which item it's linked to
|
||||
"ALTER TABLE order_items ADD COLUMN deal_id INTEGER REFERENCES deals(id)",
|
||||
"ALTER TABLE order_items ADD COLUMN linked_item_id INTEGER REFERENCES order_items(id)",
|
||||
# Multi-select targets: JSON arrays of IDs/tags per target row
|
||||
"ALTER TABLE price_modifier_targets ADD COLUMN target_ids TEXT",
|
||||
"ALTER TABLE price_modifier_targets ADD COLUMN target_tags TEXT",
|
||||
"ALTER TABLE deal_targets ADD COLUMN target_ids TEXT",
|
||||
"ALTER TABLE deal_targets ADD COLUMN target_tags TEXT",
|
||||
# Role & permissions overhaul — granular per-user permission flags
|
||||
# Access permissions
|
||||
"ALTER TABLE users ADD COLUMN perm_access_dashboard INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE users ADD COLUMN perm_access_waiter_app INTEGER NOT NULL DEFAULT 1",
|
||||
"ALTER TABLE users ADD COLUMN perm_access_kds INTEGER NOT NULL DEFAULT 0",
|
||||
# Order action permissions
|
||||
"ALTER TABLE users ADD COLUMN perm_cancel_orders INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE users ADD COLUMN perm_apply_discounts INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE users ADD COLUMN perm_modify_prices INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE users ADD COLUMN perm_open_orders INTEGER NOT NULL DEFAULT 1",
|
||||
"ALTER TABLE users ADD COLUMN perm_close_orders INTEGER NOT NULL DEFAULT 1",
|
||||
# Management permissions
|
||||
"ALTER TABLE users ADD COLUMN perm_view_reports INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE users ADD COLUMN perm_manage_staff INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE users ADD COLUMN perm_manage_tables INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE users ADD COLUMN perm_manage_menu INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE users ADD COLUMN perm_manage_settings INTEGER NOT NULL DEFAULT 0",
|
||||
# Migrate existing managers/sysadmins to have dashboard access + all management perms
|
||||
"UPDATE users SET perm_access_dashboard=1, perm_access_waiter_app=1, perm_access_kds=1, perm_cancel_orders=1, perm_apply_discounts=1, perm_modify_prices=1, perm_open_orders=1, perm_close_orders=1, perm_view_reports=1, perm_manage_staff=1, perm_manage_tables=1, perm_manage_menu=1, perm_manage_settings=1 WHERE role IN ('manager', 'sysadmin')",
|
||||
# Migrate existing waiters: update can_cancel_orders → perm_cancel_orders
|
||||
"UPDATE users SET perm_cancel_orders = can_cancel_orders WHERE role = 'waiter'",
|
||||
# Rename legacy roles: manager → store_manager, sysadmin → superadmin
|
||||
"UPDATE users SET role = 'superadmin' WHERE role = 'sysadmin'",
|
||||
"UPDATE users SET role = 'store_manager' WHERE role = 'manager'",
|
||||
# Feature: modifier groups (folders), multi-select extras, compact mode
|
||||
"""CREATE TABLE IF NOT EXISTS product_modifier_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
modifier_type VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
)""",
|
||||
"ALTER TABLE product_options ADD COLUMN multi_select INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_options ADD COLUMN group_id INTEGER REFERENCES product_modifier_groups(id)",
|
||||
"ALTER TABLE product_ingredients ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_ingredients ADD COLUMN group_id INTEGER REFERENCES product_modifier_groups(id)",
|
||||
"ALTER TABLE product_preference_choices ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_preference_sets ADD COLUMN group_id INTEGER REFERENCES product_modifier_groups(id)",
|
||||
# Fiscal printer (ΦΗΜ) — product fields
|
||||
"ALTER TABLE products ADD COLUMN fiscal_name VARCHAR",
|
||||
"ALTER TABLE products ADD COLUMN fiscal_vat_group_id INTEGER",
|
||||
# Fiscal status on orders (NULL=fiscal off, pending/success/failed)
|
||||
"ALTER TABLE orders ADD COLUMN fiscal_status VARCHAR",
|
||||
# Drop legacy can_cancel_orders column — data already migrated to perm_cancel_orders
|
||||
"ALTER TABLE users DROP COLUMN can_cancel_orders",
|
||||
# Recovery codes for account self-recovery
|
||||
"""CREATE TABLE IF NOT EXISTS recovery_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash VARCHAR NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at DATETIME
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_recovery_codes_user_id ON recovery_codes (user_id)",
|
||||
# Multi-select preference sets
|
||||
"ALTER TABLE product_preference_sets ADD COLUMN allow_multi_select INTEGER NOT NULL DEFAULT 0",
|
||||
# Per-choice quantity on multi-select preference sets (x2, x3, …)
|
||||
"ALTER TABLE product_preference_sets ADD COLUMN allow_choice_quantity INTEGER NOT NULL DEFAULT 0",
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
@@ -421,14 +742,39 @@ def _run_migrations():
|
||||
pass
|
||||
|
||||
|
||||
def _init_chat():
|
||||
"""Bootstrap the system group chat and sync all active users into it."""
|
||||
from services.chat_service import ensure_system_group, add_user_to_system_group
|
||||
from models.user import User
|
||||
from database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_system_group(db)
|
||||
active_users = db.query(User).filter(User.is_active == True).all() # noqa: E712
|
||||
for u in active_users:
|
||||
add_user_to_system_group(db, u.id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
import asyncio
|
||||
from services.sse_bus import init_loop
|
||||
from services.sse_bus import init_loop as sse_init_loop
|
||||
from services.ws_bus import init_loop as ws_init_loop, prune_old_events
|
||||
from services.reservation_tasks import start_reservation_tasks
|
||||
init_loop(asyncio.get_running_loop())
|
||||
from services.printer_service import start_print_retry_thread
|
||||
loop = asyncio.get_running_loop()
|
||||
sse_init_loop(loop)
|
||||
ws_init_loop(loop)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_run_migrations()
|
||||
start_print_retry_thread()
|
||||
pruned = prune_old_events(hours=24)
|
||||
if pruned:
|
||||
import logging
|
||||
logging.getLogger(__name__).info("ws_bus: pruned %d old sync_events", pruned)
|
||||
_init_chat()
|
||||
sync_task = await start_cloud_sync()
|
||||
reservation_task = await start_reservation_tasks()
|
||||
yield
|
||||
@@ -468,8 +814,9 @@ app.include_router(business_day_router.router, prefix="/api/business-day", tag
|
||||
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"])
|
||||
app.include_router(messages_router.router, prefix="/api/notifications", tags=["messages"])
|
||||
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])
|
||||
app.include_router(ws_router.router, prefix="/api/ws", tags=["ws"])
|
||||
app.include_router(data_transfer_router.router, prefix="/api/data-transfer", tags=["data-transfer"])
|
||||
app.include_router(connect_orders_router.router, prefix="/api/connect", tags=["connect"])
|
||||
app.include_router(reservations_router.router, prefix="/api/reservations", tags=["reservations"])
|
||||
@@ -481,3 +828,9 @@ app.include_router(tabs_router.router, prefix="/api/tabs", tag
|
||||
app.include_router(waste_router.router, prefix="/api/waste", tags=["waste"])
|
||||
app.include_router(kds_router.router, prefix="/api/kds", tags=["kds"])
|
||||
app.include_router(schedule_router.router, prefix="/api/schedule", tags=["schedule"])
|
||||
app.include_router(chat_router.router, prefix="/api/chat", tags=["chat"])
|
||||
app.include_router(prep_zones_router.router, prefix="/api/prep-zones", tags=["prep-zones"])
|
||||
app.include_router(pricing_router.router, prefix="/api/pricing", tags=["pricing"])
|
||||
app.include_router(fiscal_router.router, prefix="/api/fiscal", tags=["fiscal"])
|
||||
app.include_router(phone_router.router, prefix="/api/phone", tags=["phone"])
|
||||
app.include_router(recovery_router.router, prefix="/api/recovery", tags=["recovery"])
|
||||
|
||||
57
local_backend/models/chat.py
Normal file
57
local_backend/models/chat.py
Normal file
@@ -0,0 +1,57 @@
|
||||
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 Conversation(Base):
|
||||
__tablename__ = "conversations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
type = Column(String, nullable=False, default="direct") # 'direct' | 'group'
|
||||
name = Column(String, nullable=True) # required for group
|
||||
is_system = Column(Boolean, nullable=False, default=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
participants = relationship(
|
||||
"ConversationParticipant",
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
messages = relationship(
|
||||
"ChatMessage",
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ConversationParticipant(Base):
|
||||
__tablename__ = "conversation_participants"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
joined_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
last_read_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
conversation = relationship("Conversation", back_populates="participants")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
|
||||
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
body = Column(Text, nullable=False)
|
||||
sent_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
conversation = relationship("Conversation", back_populates="messages")
|
||||
sender = relationship("User")
|
||||
@@ -20,7 +20,7 @@ class QuickMessageTemplate(Base):
|
||||
|
||||
|
||||
class StaffMessage(Base):
|
||||
"""A message sent from a manager to one or more waiters."""
|
||||
"""A message sent from a manager or KDS to one or more waiters."""
|
||||
__tablename__ = "staff_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@@ -29,6 +29,10 @@ class StaffMessage(Base):
|
||||
# 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="[]")
|
||||
# message_type: 'manager' | 'kds_order_done' | 'kds_item_done' | 'kds_call_order' | 'kds_call_general'
|
||||
message_type = Column(String, nullable=False, default="manager")
|
||||
# kds_zone: display name of the prep zone that sent this (e.g. "Κουζίνα")
|
||||
kds_zone = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
sender = relationship("User", foreign_keys=[sender_id])
|
||||
|
||||
@@ -20,6 +20,11 @@ class Order(Base):
|
||||
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
|
||||
# KDS
|
||||
kds_status = Column(String, default="pending", nullable=False) # pending|preparing|done
|
||||
kds_status_changed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
order_type = Column(String, default="here", nullable=False) # here|takeaway|delivery
|
||||
|
||||
# Xenia Connect — online order fields
|
||||
source = Column(String, default="pos", nullable=False) # "pos" | "online"
|
||||
online_order_ref = Column(String, nullable=True) # e.g. "ORD-0042"
|
||||
@@ -33,6 +38,12 @@ class Order(Base):
|
||||
# Phase 2F — customer CRM
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True)
|
||||
|
||||
# Number of customers at the table for this order
|
||||
customer_count = Column(Integer, nullable=True)
|
||||
|
||||
# Fiscal printer status: NULL = fiscal was off, "pending" | "success" | "failed"
|
||||
fiscal_status = Column(String, nullable=True)
|
||||
|
||||
table = relationship("Table", back_populates="orders")
|
||||
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
|
||||
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed")
|
||||
@@ -40,6 +51,7 @@ class Order(Base):
|
||||
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
|
||||
print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")
|
||||
print_jobs = relationship("PrintJob", back_populates="order", cascade="all, delete-orphan")
|
||||
audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan")
|
||||
discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
@@ -63,12 +75,13 @@ class OrderItem(Base):
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
added_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
quantity = Column(Float, nullable=False)
|
||||
unit_price = Column(Float, nullable=False) # price snapshot at time of order
|
||||
selected_options = Column(Text, nullable=True) # JSON array of option ids
|
||||
removed_ingredients = Column(Text, nullable=True) # JSON array of ingredient ids
|
||||
notes = Column(Text, nullable=True)
|
||||
status = Column(String, default="active", nullable=False) # active|paid|cancelled
|
||||
status = Column(String, default="active", nullable=False) # active|paid|cancelled
|
||||
kds_status = Column(String, default="pending", nullable=False) # pending|preparing|done|served|declined
|
||||
added_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
printed = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
@@ -81,16 +94,33 @@ class OrderItem(Base):
|
||||
# Phase 2A — cost snapshot (copied from product at time of order, never updated)
|
||||
unit_cost = Column(Float, nullable=True)
|
||||
|
||||
# On-the-fly price adjustment (positive = surcharge, negative = discount)
|
||||
price_adjustment = Column(Float, nullable=False, default=0.0)
|
||||
|
||||
# Phase 2B — cancellation tracking
|
||||
cancelled_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
cancel_reason = Column(Text, nullable=True)
|
||||
|
||||
# KDS decline tracking
|
||||
decline_note = Column(Text, nullable=True) # reason set by kitchen when declining
|
||||
|
||||
# Courses — optional course assignment for sequenced firing
|
||||
course_id = Column(Integer, nullable=True)
|
||||
|
||||
# Deal binding — which deal created this item, and which original item it's linked to
|
||||
deal_id = Column(Integer, ForeignKey("deals.id"), nullable=True)
|
||||
linked_item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True)
|
||||
|
||||
order = relationship("Order", back_populates="items")
|
||||
product = relationship("Product", back_populates="order_items")
|
||||
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
|
||||
paid_by_user = relationship("User", foreign_keys=[paid_by], back_populates="items_paid")
|
||||
|
||||
@property
|
||||
def unit_type(self) -> str:
|
||||
return self.product.unit_type if self.product and self.product.unit_type else "piece"
|
||||
|
||||
|
||||
class PrintLog(Base):
|
||||
__tablename__ = "print_log"
|
||||
@@ -107,6 +137,32 @@ class PrintLog(Base):
|
||||
printer = relationship("Printer", back_populates="print_logs")
|
||||
|
||||
|
||||
class PrintJob(Base):
|
||||
"""
|
||||
One row per (order, printer, zone) print job. Tracks the full lifecycle:
|
||||
pending → success or cancelled. PrintLog rows record each individual attempt.
|
||||
"""
|
||||
__tablename__ = "print_jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
printer_id = Column(Integer, ForeignKey("printers.id"), nullable=False)
|
||||
zone_id = Column(Integer, nullable=True) # prep zone id, NULL for legacy
|
||||
item_ids = Column(Text, nullable=False) # JSON array of order_item ids
|
||||
copies = Column(Integer, nullable=False, default=1)
|
||||
|
||||
status = Column(String, nullable=False, default="pending") # pending|success|cancelled
|
||||
retry_count = Column(Integer, nullable=False, default=0)
|
||||
first_attempted_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
last_attempted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
succeeded_at = Column(DateTime(timezone=True), nullable=True)
|
||||
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
cancel_reason = Column(String, nullable=True) # "staff_cancelled" | "order_closed"
|
||||
|
||||
order = relationship("Order", back_populates="print_jobs")
|
||||
printer = relationship("Printer", back_populates="print_jobs")
|
||||
|
||||
|
||||
class OrderAuditLog(Base):
|
||||
"""Immutable append-only audit trail for every action on an order."""
|
||||
__tablename__ = "order_audit_log"
|
||||
|
||||
56
local_backend/models/prep_zone.py
Normal file
56
local_backend/models/prep_zone.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Table, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
# Many-to-many: prep_zones <-> printers (all printers assigned to a zone)
|
||||
prep_zone_printers = Table(
|
||||
"prep_zone_printers",
|
||||
Base.metadata,
|
||||
Column("prep_zone_id", Integer, ForeignKey("prep_zones.id"), primary_key=True),
|
||||
Column("printer_id", Integer, ForeignKey("printers.id"), primary_key=True),
|
||||
)
|
||||
|
||||
# Many-to-many: products <-> prep_zones
|
||||
product_prep_zones = Table(
|
||||
"product_prep_zones",
|
||||
Base.metadata,
|
||||
Column("product_id", Integer, ForeignKey("products.id"), primary_key=True),
|
||||
Column("prep_zone_id", Integer, ForeignKey("prep_zones.id"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class PrepZone(Base):
|
||||
__tablename__ = "prep_zones"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
notification_name = Column(String, nullable=True)
|
||||
|
||||
# Printer routing
|
||||
# auto_print: 'none' | 'master' | 'all'
|
||||
auto_print = Column(String, nullable=False, default='none')
|
||||
# master_printer_id: single FK to the "primary" printer (nullable — may not be set yet)
|
||||
master_printer_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
||||
master_copies = Column(Integer, nullable=False, default=1)
|
||||
# secondary printers are those in the M2M table that are NOT the master
|
||||
secondary_copies = Column(Integer, nullable=False, default=1)
|
||||
# Legacy field kept for backward compat (use master_copies/secondary_copies instead)
|
||||
print_copies = Column(Integer, nullable=False, default=1)
|
||||
|
||||
# Ticket formatting
|
||||
# sort_items_by: 'order_time' | 'item_count' | 'alpha'
|
||||
sort_items_by = Column(String, nullable=False, default='order_time')
|
||||
group_by_category = Column(Integer, nullable=False, default=0) # bool as int
|
||||
category_order = Column(String, nullable=False, default='[]') # JSON array of category IDs
|
||||
print_checkboxes = Column(Integer, nullable=False, default=0) # bool as int
|
||||
|
||||
# KDS bypass / auto-progression
|
||||
bypass_pending = Column(Integer, nullable=False, default=0) # skip pending+prep → straight to ready
|
||||
bypass_kds = Column(Integer, nullable=False, default=0) # skip KDS entirely → straight to served (implies bypass_pending)
|
||||
auto_ready_to_served = Column(Integer, nullable=False, default=0) # when item hits 'done' on KDS → auto-upgrade to served
|
||||
|
||||
# Relationships
|
||||
printers = relationship("Printer", secondary=prep_zone_printers, back_populates="prep_zones")
|
||||
products = relationship("Product", secondary=product_prep_zones, back_populates="prep_zones")
|
||||
284
local_backend/models/pricing.py
Normal file
284
local_backend/models/pricing.py
Normal file
@@ -0,0 +1,284 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class PriceGroup(Base):
|
||||
"""Named manual toggle (e.g. 'Happy Hour'). Modifiers reference these by ID."""
|
||||
__tablename__ = "price_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
color = Column(String, nullable=True)
|
||||
is_active = Column(Integer, nullable=False, default=0) # 0|1
|
||||
|
||||
# Optional auto-schedule: flip is_active based on time + day of week.
|
||||
# Evaluated at condition-check time — no background daemon needed.
|
||||
auto_enable_time = Column(String, nullable=True) # "HH:MM" 24h
|
||||
auto_disable_time = Column(String, nullable=True) # "HH:MM" 24h
|
||||
auto_days = Column(Text, nullable=True) # JSON [0..6], Mon=0
|
||||
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
|
||||
class PriceModifier(Base):
|
||||
"""
|
||||
A single pricing rule. Can be item-scoped (applies only to one product) or
|
||||
global (applies to all targets listed in PriceModifierTarget).
|
||||
|
||||
Evaluation order is controlled by sort_order (ascending = higher priority).
|
||||
|
||||
Stacking algorithm:
|
||||
- Collect all passing modifiers for a product.
|
||||
- Separate into stackable (allow_stack=1) and non-stackable.
|
||||
- Apply the FIRST passing non-stackable modifier (sort_order ascending).
|
||||
- Apply ALL passing stackable modifiers in sort_order order, on the running price.
|
||||
- Apply final system rounding to nearest 0.10 after all modifiers.
|
||||
"""
|
||||
__tablename__ = "price_modifiers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
color = Column(String, nullable=True)
|
||||
|
||||
is_active = Column(Integer, nullable=False, default=1) # quick on/off toggle
|
||||
is_favorite = Column(Integer, nullable=False, default=0) # show on dashboard
|
||||
allow_stack = Column(Integer, nullable=False, default=0) # participates in stackable pile
|
||||
sort_order = Column(Integer, nullable=False, default=0) # user-controlled priority
|
||||
|
||||
# Scope
|
||||
scope = Column(String, nullable=False, default="global") # 'item' | 'global'
|
||||
item_id = Column(Integer, ForeignKey("products.id"), nullable=True) # set when scope='item'
|
||||
|
||||
# Action — what happens to the price when this modifier fires
|
||||
action_type = Column(String, nullable=False) # 'set' | 'add_amount' | 'add_percent'
|
||||
action_value = Column(Float, nullable=False) # the number; negative = discount
|
||||
|
||||
# Optional per-modifier rounding applied immediately after this modifier's math.
|
||||
# System-wide nearest-0.10 rounding is always applied as a final pass regardless.
|
||||
round_to = Column(String, nullable=True) # '0.05'|'0.10'|'0.20'|'0.50'|'x.99'|'x.00'
|
||||
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
updated_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
conditions = relationship("PriceModifierCondition", back_populates="modifier",
|
||||
cascade="all, delete-orphan")
|
||||
targets = relationship("PriceModifierTarget", back_populates="modifier",
|
||||
cascade="all, delete-orphan")
|
||||
item = relationship("Product", foreign_keys=[item_id])
|
||||
|
||||
|
||||
class PriceModifierCondition(Base):
|
||||
"""
|
||||
One condition on a modifier. ALL conditions must pass for the modifier to fire (AND logic).
|
||||
A modifier with zero conditions is 'manual-only' — never fires automatically.
|
||||
|
||||
condition_type registry and params shapes:
|
||||
time_range {"from": "HH:MM", "to": "HH:MM"} (crosses midnight if from > to)
|
||||
date_range {"from": "YYYY-MM-DD", "to": "YYYY-MM-DD"} (inclusive)
|
||||
specific_date {"dates": ["YYYY-MM-DD", ...]}
|
||||
day_of_week {"days": [0,1,2,3,4,5,6]} (Mon=0 Sun=6)
|
||||
min_item_quantity {"min": N} (this product's qty in cart)
|
||||
min_category_qty {"category_id": N, "min": N} (items in category in cart)
|
||||
min_order_value {"min": 0.00} (cart total before modifiers)
|
||||
order_channel {"channels": ["pos","online","qr","takeaway"]}
|
||||
price_group_active {"price_group_id": N}
|
||||
user_tier {"tiers": ["bronze","silver","gold"]} (placeholder, needs user accounts)
|
||||
low_stock {"threshold": N} (placeholder, needs inventory)
|
||||
"""
|
||||
__tablename__ = "price_modifier_conditions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
modifier_id = Column(Integer, ForeignKey("price_modifiers.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
condition_type = Column(String, nullable=False)
|
||||
params = Column(Text, nullable=False, default="{}") # JSON
|
||||
|
||||
modifier = relationship("PriceModifier", back_populates="conditions")
|
||||
|
||||
|
||||
class PriceModifierTarget(Base):
|
||||
"""
|
||||
Target rows exist only for global modifiers. Defines which products the modifier applies to.
|
||||
A modifier matches a product if ANY of its target rows covers that product (OR across rows).
|
||||
|
||||
target_type='all' → applies to every product (target_id and target_tag are NULL)
|
||||
target_type='item' → target_id is a product id
|
||||
target_type='category' → target_id is a category id (includes subcategories)
|
||||
target_type='prep_zone' → target_id is a prep_zone id
|
||||
target_type='tag' → target_tag is a string matched against products.tags JSON array
|
||||
"""
|
||||
__tablename__ = "price_modifier_targets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
modifier_id = Column(Integer, ForeignKey("price_modifiers.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
target_type = Column(String, nullable=False) # 'all'|'item'|'category'|'prep_zone'|'tag'
|
||||
target_id = Column(Integer, nullable=True) # legacy single value
|
||||
target_tag = Column(String, nullable=True) # legacy single value
|
||||
target_ids = Column(Text, nullable=True) # JSON array of ints (multi-select)
|
||||
target_tags = Column(Text, nullable=True) # JSON array of strings (multi-tag)
|
||||
|
||||
modifier = relationship("PriceModifier", back_populates="targets")
|
||||
|
||||
|
||||
class Deal(Base):
|
||||
"""
|
||||
Event-driven promotion. When deal_conditions + deal_targets are met (a product/category/tag
|
||||
is present in the cart in sufficient quantity), the action fires.
|
||||
|
||||
Evaluated on every add-items call. If conditions are newly met, the waiter is notified
|
||||
via the response payload and prompted to confirm/select (never auto-applied silently).
|
||||
|
||||
action_type registry:
|
||||
apply_modifier → action_modifier_id (FK to PriceModifier with no conditions)
|
||||
set_price → action_value (exact new unit price)
|
||||
add_amount → action_value (delta; negative = discount)
|
||||
add_percent → action_value (percent; negative = discount)
|
||||
free_item → action_free_item_id (specific product added free)
|
||||
free_choice → pool defined by action_free_target_type + action_free_target_ids
|
||||
"""
|
||||
__tablename__ = "deals"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
color = Column(String, nullable=True)
|
||||
is_active = Column(Integer, nullable=False, default=1)
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Action
|
||||
action_type = Column(String, nullable=False)
|
||||
action_modifier_id = Column(Integer, ForeignKey("price_modifiers.id"), nullable=True)
|
||||
action_value = Column(Float, nullable=True)
|
||||
action_free_item_id = Column(Integer, ForeignKey("products.id"), nullable=True)
|
||||
action_free_target_type = Column(String, nullable=True) # 'item'|'category'|'tag'
|
||||
action_free_target_ids = Column(Text, nullable=True) # JSON array of IDs or tag strings
|
||||
action_free_quantity = Column(Integer, nullable=False, default=1)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
conditions = relationship("DealCondition", back_populates="deal",
|
||||
cascade="all, delete-orphan")
|
||||
targets = relationship("DealTarget", back_populates="deal",
|
||||
cascade="all, delete-orphan")
|
||||
action_modifier = relationship("PriceModifier", foreign_keys=[action_modifier_id])
|
||||
action_free_item = relationship("Product", foreign_keys=[action_free_item_id])
|
||||
|
||||
|
||||
class DealCondition(Base):
|
||||
"""Same condition_type registry as PriceModifierCondition."""
|
||||
__tablename__ = "deal_conditions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
deal_id = Column(Integer, ForeignKey("deals.id", ondelete="CASCADE"), nullable=False)
|
||||
condition_type = Column(String, nullable=False)
|
||||
params = Column(Text, nullable=False, default="{}") # JSON
|
||||
|
||||
deal = relationship("Deal", back_populates="conditions")
|
||||
|
||||
|
||||
class DealTarget(Base):
|
||||
"""
|
||||
Defines the 'buy THESE' side of a deal (what must be in the cart for the deal to trigger).
|
||||
Multiple rows are OR-combined: any matching item satisfies a row.
|
||||
"""
|
||||
__tablename__ = "deal_targets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
deal_id = Column(Integer, ForeignKey("deals.id", ondelete="CASCADE"), nullable=False)
|
||||
target_type = Column(String, nullable=False) # 'item'|'category'|'tag'|'any'
|
||||
target_id = Column(Integer, nullable=True) # legacy single value
|
||||
target_tag = Column(String, nullable=True) # legacy single value
|
||||
target_ids = Column(Text, nullable=True) # JSON array of ints (multi-select)
|
||||
target_tags = Column(Text, nullable=True) # JSON array of strings (multi-tag)
|
||||
|
||||
deal = relationship("Deal", back_populates="targets")
|
||||
|
||||
|
||||
class PriceEventLog(Base):
|
||||
"""
|
||||
Immutable append-only audit record. Written for every pricing event on an order:
|
||||
automatic modifier fires, deal triggers/accepts/dismisses, waiter discounts, free items.
|
||||
Nothing is deleted from this table.
|
||||
"""
|
||||
__tablename__ = "price_event_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
order_item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) # NULL for order-level events
|
||||
|
||||
# Event classification
|
||||
event_type = Column(String, nullable=False)
|
||||
# modifier_applied — automatic price modifier fired
|
||||
# deal_triggered — deal conditions met, action executed on a specific item
|
||||
# deal_offer_shown — waiter was shown a free-choice prompt
|
||||
# deal_offer_accepted — waiter confirmed and selected item(s)
|
||||
# deal_offer_dismissed — waiter dismissed the deal offer
|
||||
# waiter_discount — manual waiter discount applied
|
||||
# free_item_added — free item added to order as result of a deal
|
||||
|
||||
modifier_id = Column(Integer, ForeignKey("price_modifiers.id"), nullable=True)
|
||||
deal_id = Column(Integer, ForeignKey("deals.id"), nullable=True)
|
||||
|
||||
# Price delta — NULL for events that don't change a price (offer_shown, dismissed)
|
||||
price_before = Column(Float, nullable=True)
|
||||
price_after = Column(Float, nullable=True)
|
||||
delta_amount = Column(Float, nullable=True) # price_after - price_before; negative = discount
|
||||
|
||||
# Waiter discount fields
|
||||
applied_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
waiter_note = Column(Text, nullable=True)
|
||||
|
||||
# Snapshot of the conditions that evaluated to True when the event fired.
|
||||
# JSON object: {"condition_type": evaluated_value, ...}
|
||||
# e.g. {"day_of_week": 5, "time_range": "21:45", "price_group_active": true}
|
||||
conditions_snapshot = Column(Text, nullable=True)
|
||||
|
||||
# For deal_offer_accepted: product IDs the waiter chose
|
||||
selected_item_ids = Column(Text, nullable=True) # JSON array of product IDs
|
||||
|
||||
applied_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
order = relationship("Order", foreign_keys=[order_id])
|
||||
order_item = relationship("OrderItem", foreign_keys=[order_item_id])
|
||||
modifier = relationship("PriceModifier", foreign_keys=[modifier_id])
|
||||
deal = relationship("Deal", foreign_keys=[deal_id])
|
||||
applied_by = relationship("User", foreign_keys=[applied_by_user_id])
|
||||
|
||||
|
||||
class WaiterDiscountSettings(Base):
|
||||
"""
|
||||
Per-waiter discount limits. All limit fields are nullable — NULL means no limit.
|
||||
The master can_apply_discounts flag acts as an on/off switch for this waiter.
|
||||
Global limits are stored in pos_settings under the 'discounts.*' namespace.
|
||||
"""
|
||||
__tablename__ = "waiter_discount_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
||||
|
||||
can_apply_discounts = Column(Integer, nullable=False, default=0) # 0|1 master toggle
|
||||
|
||||
max_discount_percent = Column(Float, nullable=True) # e.g. 20.0 = max 20% off
|
||||
max_discount_amount = Column(Float, nullable=True) # max € off a single item
|
||||
|
||||
max_total_value_shift = Column(Float, nullable=True) # max total € given away per shift
|
||||
max_total_value_workday = Column(Float, nullable=True) # max total € given away per workday
|
||||
|
||||
max_items_per_shift = Column(Integer, nullable=True)
|
||||
max_items_per_workday = Column(Integer, nullable=True)
|
||||
max_items_per_order = Column(Integer, nullable=True)
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
@@ -14,6 +14,9 @@ class Printer(Base):
|
||||
protocol = Column(String, default="escpos_tcp", nullable=False)
|
||||
line_width = Column(Integer, default=48, nullable=False)
|
||||
duplicates = Column(Integer, default=0, nullable=False)
|
||||
codepage_n = Column(Integer, default=29, nullable=False)
|
||||
|
||||
products = relationship("Product", back_populates="printer_zone")
|
||||
print_logs = relationship("PrintLog", back_populates="printer")
|
||||
print_jobs = relationship("PrintJob", back_populates="printer")
|
||||
prep_zones = relationship("PrepZone", secondary="prep_zone_printers", back_populates="printers")
|
||||
|
||||
@@ -46,19 +46,51 @@ class Product(Base):
|
||||
digital_discount = Column(Float, default=0.0, nullable=False)
|
||||
digital_image_url = Column(String, nullable=True)
|
||||
|
||||
# Unit of measure: "piece" | "portion" | "kg" | "liter" | "gram" | "ml"
|
||||
unit_type = Column(String, default="piece", nullable=False)
|
||||
|
||||
# Phase 2A — cost tracking
|
||||
cost_simple = Column(Float, nullable=True)
|
||||
cost_breakdown = Column(Text, nullable=True) # JSON: [{"label": str, "amount": float}]
|
||||
|
||||
# Quick Add: allow adding 1 unit directly from product list without opening config modal
|
||||
quick_add_enabled = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Service item: non-inventoried table-service items (cutlery, glasses, etc.)
|
||||
is_service_item = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Tags: JSON array of strings e.g. ["vegan", "gluten-free"]
|
||||
tags = Column(Text, nullable=True)
|
||||
|
||||
# Fiscal printer (ΦΗΜ) fields
|
||||
fiscal_name = Column(String, nullable=True) # short name for receipt (falls back to name)
|
||||
fiscal_vat_group_id = Column(Integer, nullable=True) # machine VAT group ID (1-based integer)
|
||||
|
||||
category = relationship("Category", back_populates="products")
|
||||
printer_zone = relationship("Printer", back_populates="products")
|
||||
prep_zones = relationship("PrepZone", secondary="product_prep_zones", back_populates="products")
|
||||
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
|
||||
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
|
||||
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
|
||||
preference_sets = relationship("ProductPreferenceSet", back_populates="product", cascade="all, delete-orphan")
|
||||
modifier_groups = relationship("ProductModifierGroup", back_populates="product", cascade="all, delete-orphan")
|
||||
order_items = relationship("OrderItem", back_populates="product")
|
||||
|
||||
|
||||
class ProductModifierGroup(Base):
|
||||
"""A named folder/group that can contain options, ingredients, or preference sets."""
|
||||
__tablename__ = "product_modifier_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
# "option" | "ingredient" | "preference"
|
||||
modifier_type = Column(String, nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
sort_order = Column(Integer, default=0, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="modifier_groups")
|
||||
|
||||
|
||||
class ProductOption(Base):
|
||||
__tablename__ = "product_options"
|
||||
|
||||
@@ -67,10 +99,16 @@ class ProductOption(Base):
|
||||
name = Column(String, nullable=False)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
allow_multiple = Column(Boolean, default=False, nullable=False)
|
||||
# JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked
|
||||
# When True, waiter can select more than one sub-choice simultaneously
|
||||
multi_select = Column(Boolean, default=False, nullable=False)
|
||||
# JSON array [{name, extra_cost, is_default, allow_multiple}] — sub-options shown when this option is checked
|
||||
sub_choices = Column(Text, nullable=True)
|
||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||
# When True renders as half-width card on the PWA
|
||||
is_compact = Column(Boolean, default=False, nullable=False)
|
||||
# Optional group/folder this option belongs to
|
||||
group_id = Column(Integer, ForeignKey("product_modifier_groups.id"), nullable=True)
|
||||
|
||||
product = relationship("Product", back_populates="options")
|
||||
|
||||
@@ -100,6 +138,10 @@ class ProductIngredient(Base):
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||
# When True renders as half-width card on the PWA
|
||||
is_compact = Column(Boolean, default=False, nullable=False)
|
||||
# Optional group/folder this ingredient belongs to
|
||||
group_id = Column(Integer, ForeignKey("product_modifier_groups.id"), nullable=True)
|
||||
|
||||
product = relationship("Product", back_populates="ingredients")
|
||||
|
||||
@@ -116,6 +158,12 @@ class ProductPreferenceSet(Base):
|
||||
shared_subset = Column(Text, nullable=True)
|
||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||
# Optional group/folder this preference set belongs to
|
||||
group_id = Column(Integer, ForeignKey("product_modifier_groups.id"), nullable=True)
|
||||
# When True, waiter can select multiple choices instead of just one
|
||||
allow_multi_select = Column(Boolean, default=False, nullable=False)
|
||||
# When True, each selected choice can have a quantity (x2, x3, …)
|
||||
allow_choice_quantity = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="preference_sets")
|
||||
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
|
||||
@@ -128,10 +176,12 @@ class ProductPreferenceChoice(Base):
|
||||
set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
# JSON array of sub-choice objects: [{name, extra_cost, is_default}]
|
||||
# JSON array of sub-choice objects: [{name, extra_cost, is_default, is_compact}]
|
||||
# Per-choice inline sub-preference shown only when this choice is selected.
|
||||
sub_choices = Column(Text, nullable=True)
|
||||
# When True this choice hides the set-level shared_subset on the PWA.
|
||||
disables_subset = Column(Boolean, default=False, nullable=False)
|
||||
# When True renders as half-width card on the PWA
|
||||
is_compact = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
set = relationship("ProductPreferenceSet", back_populates="choices")
|
||||
|
||||
20
local_backend/models/recovery_code.py
Normal file
20
local_backend/models/recovery_code.py
Normal file
@@ -0,0 +1,20 @@
|
||||
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 RecoveryCode(Base):
|
||||
__tablename__ = "recovery_codes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
code_hash = Column(String, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user = relationship("User", backref="recovery_codes")
|
||||
@@ -24,6 +24,7 @@ class Table(Base):
|
||||
label = Column(String, nullable=True)
|
||||
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
seat_count = Column(Integer, nullable=True)
|
||||
floor_x = Column(Float, nullable=True)
|
||||
floor_y = Column(Float, nullable=True)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
@@ -16,7 +16,8 @@ class User(Base):
|
||||
pin_hash = Column(String, nullable=False)
|
||||
password_hash = Column(String, nullable=True)
|
||||
email = Column(String, nullable=True)
|
||||
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
|
||||
# Role is a label + default-permission template. Valid values: see roles.py
|
||||
role = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
full_name = Column(String, nullable=True)
|
||||
nickname = Column(String, nullable=True)
|
||||
@@ -26,8 +27,28 @@ class User(Base):
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
# Phase 2B — payroll
|
||||
hourly_rate = Column(Float, nullable=True)
|
||||
# Waiter cancellation permission
|
||||
can_cancel_orders = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# ── Access permissions (which sites/areas this user can enter) ────────────
|
||||
perm_access_dashboard = Column(Boolean, default=False, nullable=False)
|
||||
perm_access_waiter_app = Column(Boolean, default=True, nullable=False)
|
||||
perm_access_kds = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# ── Order action permissions ──────────────────────────────────────────────
|
||||
perm_cancel_orders = Column(Boolean, default=False, nullable=False)
|
||||
perm_apply_discounts = Column(Boolean, default=False, nullable=False)
|
||||
perm_modify_prices = Column(Boolean, default=False, nullable=False)
|
||||
perm_open_orders = Column(Boolean, default=True, nullable=False)
|
||||
perm_close_orders = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# ── Management permissions ────────────────────────────────────────────────
|
||||
perm_view_reports = Column(Boolean, default=False, nullable=False)
|
||||
perm_manage_staff = Column(Boolean, default=False, nullable=False)
|
||||
perm_manage_tables = Column(Boolean, default=False, nullable=False)
|
||||
perm_manage_menu = Column(Boolean, default=False, nullable=False)
|
||||
perm_manage_settings = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Per-waiter app settings + favorites (JSON blob, synced from PWA)
|
||||
waiter_settings = Column(Text, nullable=True)
|
||||
|
||||
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
||||
@@ -47,6 +68,15 @@ class User(Base):
|
||||
back_populates="assistant_waiter",
|
||||
)
|
||||
|
||||
@property
|
||||
def can_cancel_orders(self):
|
||||
"""Back-compat alias used by orders router."""
|
||||
return self.perm_cancel_orders
|
||||
|
||||
@property
|
||||
def is_superadmin(self):
|
||||
return self.role == "superadmin"
|
||||
|
||||
|
||||
class WaiterZone(Base):
|
||||
"""Maps a waiter to a table group they are allowed to operate in.
|
||||
|
||||
114
local_backend/print_codepage_probe.py
Normal file
114
local_backend/print_codepage_probe.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Greek code page probe — NETUM NT8330L (or any unknown printer)
|
||||
Usage: python print_codepage_probe.py <IP> [PORT]
|
||||
Example: python print_codepage_probe.py 192.168.1.50 9100
|
||||
|
||||
Prints one long receipt that tries every candidate (n, encoding) combination.
|
||||
Circle the block where you can read "ΚΑΛΗΜΕΡΑ ΕΛΛΑΔΑ" correctly.
|
||||
That block tells you the codepage_n value to use in the printer profile.
|
||||
"""
|
||||
import sys
|
||||
from escpos.printer import Network
|
||||
|
||||
IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.1.50"
|
||||
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
|
||||
|
||||
# (n_value, python_encoding, label)
|
||||
# Each will be tested independently with a fresh ESC @ reset.
|
||||
CANDIDATES = [
|
||||
# Standard Epson table assignments for Greek
|
||||
(14, "cp737", "n=14 CP737 (Epson standard slot)"),
|
||||
(38, "cp737", "n=38 CP737 (alt slot / CP869 area)"),
|
||||
(11, "cp737", "n=11 CP737 (CP851 slot)"),
|
||||
(47, "cp737", "n=47 CP737 (CP1253 slot, cp737 bytes)"),
|
||||
# Same slots but with cp869 encoding (different byte layout)
|
||||
(14, "cp869", "n=14 CP869 (Greek DOS #2)"),
|
||||
(38, "cp869", "n=38 CP869 (alt slot)"),
|
||||
# Windows / ISO encodings
|
||||
(47, "cp1253", "n=47 CP1253 (Windows Greek)"),
|
||||
(15, "iso8859_7", "n=15 ISO-8859-7"),
|
||||
(14, "iso8859_7", "n=14 ISO-8859-7"),
|
||||
# The Jolimark value, for reference
|
||||
(29, "cp737", "n=29 CP737 (Jolimark confirmed)"),
|
||||
# A few more obscure slots some Chinese-made printers use
|
||||
(16, "cp737", "n=16 CP737 (rare slot)"),
|
||||
(18, "cp737", "n=18 CP737 (rare slot)"),
|
||||
(21, "cp737", "n=21 CP737 (rare slot)"),
|
||||
]
|
||||
|
||||
GREEK_SAMPLE = "ΚΑΛΗΜΕΡΑ ΕΛΛΑΔΑ"
|
||||
GREEK_LOWER = "καλημέρα ελλάδα"
|
||||
GREEK_DIGITS = "ΤΙΜΗ: 12.50 ευρώ"
|
||||
|
||||
def probe(n: int, encoding: str, label: str):
|
||||
try:
|
||||
p = Network(IP, PORT, timeout=8)
|
||||
p._raw(b'\x1b\x40') # ESC @ full reset
|
||||
p._raw(bytes([0x1b, 0x74, n])) # ESC t n — select code page
|
||||
|
||||
# Label in plain ASCII (always safe)
|
||||
p._raw(b'\x1b\x21\x00') # normal size
|
||||
p._raw(f"[{label}]\n".encode('ascii', errors='replace'))
|
||||
|
||||
# Greek sample in the candidate encoding
|
||||
try:
|
||||
greek_bytes = GREEK_SAMPLE.encode(encoding, errors='replace')
|
||||
lower_bytes = GREEK_LOWER.encode(encoding, errors='replace')
|
||||
digit_bytes = GREEK_DIGITS.encode(encoding, errors='replace')
|
||||
except LookupError:
|
||||
p._raw(b' (encoding not available)\n')
|
||||
p._raw(b'\n')
|
||||
p.close()
|
||||
return
|
||||
|
||||
p._raw(b'\x1b\x21\x08') # bold
|
||||
p._raw(greek_bytes + b'\n')
|
||||
p._raw(b'\x1b\x21\x00') # normal
|
||||
p._raw(lower_bytes + b'\n')
|
||||
p._raw(digit_bytes + b'\n')
|
||||
p._raw(b'\n') # spacer
|
||||
p.close()
|
||||
print(f" OK {label}")
|
||||
except Exception as e:
|
||||
print(f" ERR {label}: {e}")
|
||||
|
||||
def main():
|
||||
print(f"Probing {IP}:{PORT}")
|
||||
print(f"Testing {len(CANDIDATES)} combinations...\n")
|
||||
|
||||
# Print header block
|
||||
try:
|
||||
p = Network(IP, PORT, timeout=8)
|
||||
p._raw(b'\x1b\x40')
|
||||
p._raw(b'\x1b\x61\x01') # center
|
||||
p._raw(b'\x1b\x21\x30') # double width+height
|
||||
p._raw(b'GREEK PROBE\n')
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
p._raw(b'\x1b\x61\x00') # left
|
||||
p._raw(b'Circle the block with readable Greek.\n')
|
||||
p._raw(b'Looking for: KALIMERA ELLADA\n')
|
||||
p._raw(b'================================================\n')
|
||||
p._raw(b'\n')
|
||||
p.close()
|
||||
except Exception as e:
|
||||
print(f"Header failed: {e}")
|
||||
return
|
||||
|
||||
for (n, enc, label) in CANDIDATES:
|
||||
probe(n, enc, label)
|
||||
|
||||
# Footer + cut
|
||||
try:
|
||||
p = Network(IP, PORT, timeout=8)
|
||||
p._raw(b'\x1b\x40')
|
||||
p._raw(b'================================================\n')
|
||||
p._raw(b'END OF PROBE\n')
|
||||
p._raw(b'\n\n\n')
|
||||
p.cut()
|
||||
p.close()
|
||||
print("\nDone. Read the printout and note which block shows readable Greek.")
|
||||
except Exception as e:
|
||||
print(f"Footer failed: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +1,6 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.30.6
|
||||
uvicorn[standard]==0.30.6
|
||||
websockets==13.1
|
||||
sqlalchemy==2.0.36
|
||||
pydantic-settings==2.6.1
|
||||
python-escpos==3.1
|
||||
|
||||
198
local_backend/roles.py
Normal file
198
local_backend/roles.py
Normal file
@@ -0,0 +1,198 @@
|
||||
# Role definitions for Xenia POS
|
||||
#
|
||||
# Roles serve two purposes:
|
||||
# 1. Label what a staff member actually does in the venue
|
||||
# 2. Set default permission values when a member is created or permissions are reset
|
||||
#
|
||||
# After creation, permissions are per-member and can be overridden freely.
|
||||
# SUPERADMIN is always fully privileged — the backend enforces this regardless of
|
||||
# stored boolean values.
|
||||
|
||||
ALL_ROLES: list[dict] = [
|
||||
{"value": "superadmin", "label": "Superadmin", "label_el": "Superadmin"},
|
||||
{"value": "owner", "label": "Owner", "label_el": "Ιδιοκτήτης"},
|
||||
{"value": "store_manager", "label": "Store Manager", "label_el": "Διαχειριστής Καταστήματος"},
|
||||
{"value": "staff_manager", "label": "Staff Manager", "label_el": "Διαχειριστής Προσωπικού"},
|
||||
{"value": "chef", "label": "Chef", "label_el": "Σεφ"},
|
||||
{"value": "assistant_chef", "label": "Assistant Chef", "label_el": "Βοηθός Σεφ"},
|
||||
{"value": "barista", "label": "Barista", "label_el": "Barista"},
|
||||
{"value": "barman", "label": "Barman / Barwoman", "label_el": "Barman / Barwoman"},
|
||||
{"value": "waiter", "label": "Waiter", "label_el": "Σερβιτόρος"},
|
||||
{"value": "assistant_waiter", "label": "Assistant Waiter", "label_el": "Βοηθός Σερβιτόρου"},
|
||||
{"value": "custom", "label": "Custom", "label_el": "Προσαρμοσμένος"},
|
||||
]
|
||||
|
||||
VALID_ROLES: set[str] = {r["value"] for r in ALL_ROLES}
|
||||
|
||||
# Roles that have manager-level dashboard access by default
|
||||
MANAGER_ROLES: set[str] = {"superadmin", "owner", "store_manager", "staff_manager"}
|
||||
|
||||
# Full permission set — what a superadmin always has
|
||||
_ALL_ON = dict(
|
||||
perm_access_dashboard=True,
|
||||
perm_access_waiter_app=True,
|
||||
perm_access_kds=True,
|
||||
perm_cancel_orders=True,
|
||||
perm_apply_discounts=True,
|
||||
perm_modify_prices=True,
|
||||
perm_open_orders=True,
|
||||
perm_close_orders=True,
|
||||
perm_view_reports=True,
|
||||
perm_manage_staff=True,
|
||||
perm_manage_tables=True,
|
||||
perm_manage_menu=True,
|
||||
perm_manage_settings=True,
|
||||
)
|
||||
|
||||
_ALL_OFF = {k: False for k in _ALL_ON}
|
||||
|
||||
# Default permissions keyed by role value.
|
||||
# These are applied at creation time and on "Reset to defaults".
|
||||
DEFAULT_PERMISSIONS: dict[str, dict] = {
|
||||
"superadmin": _ALL_ON,
|
||||
"owner": dict(
|
||||
perm_access_dashboard=True,
|
||||
perm_access_waiter_app=True,
|
||||
perm_access_kds=True,
|
||||
perm_cancel_orders=True,
|
||||
perm_apply_discounts=True,
|
||||
perm_modify_prices=True,
|
||||
perm_open_orders=True,
|
||||
perm_close_orders=True,
|
||||
perm_view_reports=True,
|
||||
perm_manage_staff=True,
|
||||
perm_manage_tables=True,
|
||||
perm_manage_menu=True,
|
||||
perm_manage_settings=True,
|
||||
),
|
||||
"store_manager": dict(
|
||||
perm_access_dashboard=True,
|
||||
perm_access_waiter_app=True,
|
||||
perm_access_kds=True,
|
||||
perm_cancel_orders=True,
|
||||
perm_apply_discounts=True,
|
||||
perm_modify_prices=False,
|
||||
perm_open_orders=True,
|
||||
perm_close_orders=True,
|
||||
perm_view_reports=True,
|
||||
perm_manage_staff=True,
|
||||
perm_manage_tables=True,
|
||||
perm_manage_menu=True,
|
||||
perm_manage_settings=False,
|
||||
),
|
||||
"staff_manager": dict(
|
||||
perm_access_dashboard=True,
|
||||
perm_access_waiter_app=True,
|
||||
perm_access_kds=False,
|
||||
perm_cancel_orders=False,
|
||||
perm_apply_discounts=False,
|
||||
perm_modify_prices=False,
|
||||
perm_open_orders=False,
|
||||
perm_close_orders=False,
|
||||
perm_view_reports=True,
|
||||
perm_manage_staff=True,
|
||||
perm_manage_tables=False,
|
||||
perm_manage_menu=False,
|
||||
perm_manage_settings=False,
|
||||
),
|
||||
"chef": dict(
|
||||
perm_access_dashboard=False,
|
||||
perm_access_waiter_app=False,
|
||||
perm_access_kds=True,
|
||||
perm_cancel_orders=False,
|
||||
perm_apply_discounts=False,
|
||||
perm_modify_prices=False,
|
||||
perm_open_orders=False,
|
||||
perm_close_orders=False,
|
||||
perm_view_reports=False,
|
||||
perm_manage_staff=False,
|
||||
perm_manage_tables=False,
|
||||
perm_manage_menu=False,
|
||||
perm_manage_settings=False,
|
||||
),
|
||||
"assistant_chef": dict(
|
||||
perm_access_dashboard=False,
|
||||
perm_access_waiter_app=False,
|
||||
perm_access_kds=True,
|
||||
perm_cancel_orders=False,
|
||||
perm_apply_discounts=False,
|
||||
perm_modify_prices=False,
|
||||
perm_open_orders=False,
|
||||
perm_close_orders=False,
|
||||
perm_view_reports=False,
|
||||
perm_manage_staff=False,
|
||||
perm_manage_tables=False,
|
||||
perm_manage_menu=False,
|
||||
perm_manage_settings=False,
|
||||
),
|
||||
"barista": dict(
|
||||
perm_access_dashboard=False,
|
||||
perm_access_waiter_app=True,
|
||||
perm_access_kds=False,
|
||||
perm_cancel_orders=False,
|
||||
perm_apply_discounts=False,
|
||||
perm_modify_prices=False,
|
||||
perm_open_orders=True,
|
||||
perm_close_orders=True,
|
||||
perm_view_reports=False,
|
||||
perm_manage_staff=False,
|
||||
perm_manage_tables=False,
|
||||
perm_manage_menu=False,
|
||||
perm_manage_settings=False,
|
||||
),
|
||||
"barman": dict(
|
||||
perm_access_dashboard=False,
|
||||
perm_access_waiter_app=True,
|
||||
perm_access_kds=False,
|
||||
perm_cancel_orders=False,
|
||||
perm_apply_discounts=False,
|
||||
perm_modify_prices=False,
|
||||
perm_open_orders=True,
|
||||
perm_close_orders=True,
|
||||
perm_view_reports=False,
|
||||
perm_manage_staff=False,
|
||||
perm_manage_tables=False,
|
||||
perm_manage_menu=False,
|
||||
perm_manage_settings=False,
|
||||
),
|
||||
"waiter": dict(
|
||||
perm_access_dashboard=False,
|
||||
perm_access_waiter_app=True,
|
||||
perm_access_kds=False,
|
||||
perm_cancel_orders=False,
|
||||
perm_apply_discounts=False,
|
||||
perm_modify_prices=False,
|
||||
perm_open_orders=True,
|
||||
perm_close_orders=True,
|
||||
perm_view_reports=False,
|
||||
perm_manage_staff=False,
|
||||
perm_manage_tables=False,
|
||||
perm_manage_menu=False,
|
||||
perm_manage_settings=False,
|
||||
),
|
||||
"assistant_waiter": dict(
|
||||
perm_access_dashboard=False,
|
||||
perm_access_waiter_app=True,
|
||||
perm_access_kds=False,
|
||||
perm_cancel_orders=False,
|
||||
perm_apply_discounts=False,
|
||||
perm_modify_prices=False,
|
||||
perm_open_orders=False,
|
||||
perm_close_orders=False,
|
||||
perm_view_reports=False,
|
||||
perm_manage_staff=False,
|
||||
perm_manage_tables=False,
|
||||
perm_manage_menu=False,
|
||||
perm_manage_settings=False,
|
||||
),
|
||||
"custom": _ALL_OFF,
|
||||
}
|
||||
|
||||
|
||||
def get_default_permissions(role: str) -> dict:
|
||||
"""Return default permission values for a given role."""
|
||||
return DEFAULT_PERMISSIONS.get(role, _ALL_OFF).copy()
|
||||
|
||||
|
||||
def is_manager_role(role: str) -> bool:
|
||||
return role in MANAGER_ROLES
|
||||
@@ -1,17 +1,22 @@
|
||||
import bcrypt
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from config import settings
|
||||
from models.user import User
|
||||
from schemas.auth import LoginRequest, TokenResponse, UpdateMeRequest
|
||||
from pydantic import BaseModel as _PydanticBase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class LoginByIdRequest(_PydanticBase):
|
||||
waiter_id: int
|
||||
pin: str
|
||||
from schemas.user import UserOut
|
||||
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
|
||||
from routers.deps import get_current_user, make_token, decode_token, blacklist_token, _make_ghost_superadmin
|
||||
from routers.recovery import try_recovery_code
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -28,7 +33,7 @@ def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)):
|
||||
if not setting or setting.value != "none":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No-auth login is not enabled.")
|
||||
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
||||
if not user or user.role not in ("manager", "sysadmin"):
|
||||
if not user or not user.perm_access_dashboard:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = make_token(user)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
@@ -36,6 +41,18 @@ def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)):
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
# Break-glass master account — checked before DB, never stored as a real user
|
||||
if (
|
||||
settings.MASTER_USERNAME
|
||||
and settings.MASTER_PASSWORD
|
||||
and body.username == settings.MASTER_USERNAME
|
||||
and body.password == settings.MASTER_PASSWORD
|
||||
):
|
||||
_logger.warning("MASTER LOGIN USED from username=%s", body.username)
|
||||
ghost = _make_ghost_superadmin()
|
||||
token = make_token(ghost)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(ghost))
|
||||
|
||||
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
@@ -46,6 +63,10 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
elif body.pin and user.pin_hash:
|
||||
authenticated = bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode())
|
||||
|
||||
# If normal auth failed, check if the submitted value is a recovery code
|
||||
if not authenticated and body.password:
|
||||
authenticated = try_recovery_code(body.password, user, db)
|
||||
|
||||
if not authenticated:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
@@ -96,9 +117,9 @@ class PublicManagerOut(_PydanticBase):
|
||||
|
||||
@router.get("/managers", response_model=list[PublicManagerOut])
|
||||
def public_manager_list(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns active manager/sysadmin accounts for login screen."""
|
||||
"""Public endpoint — returns accounts with dashboard access for login screen."""
|
||||
managers = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.perm_access_dashboard == True,
|
||||
User.is_active == True,
|
||||
).all()
|
||||
return [PublicManagerOut(id=m.id, username=m.username, full_name=m.full_name) for m in managers]
|
||||
@@ -121,7 +142,7 @@ class PublicWaiterOut(_BaseModel):
|
||||
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()
|
||||
waiters = db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()
|
||||
on_shift_ids = {
|
||||
row.waiter_id
|
||||
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
|
||||
@@ -172,3 +193,26 @@ def update_me(body: UpdateMeRequest, db: Session = Depends(get_db), user: User =
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
class WaiterSettingsPayload(_PydanticBase):
|
||||
settings: str # raw JSON string
|
||||
|
||||
|
||||
@router.get("/me/settings")
|
||||
def get_my_settings(user: User = Depends(get_current_user)):
|
||||
"""Return the stored waiter settings JSON blob for the current user."""
|
||||
return {"settings": user.waiter_settings or "{}"}
|
||||
|
||||
|
||||
@router.put("/me/settings")
|
||||
def put_my_settings(body: WaiterSettingsPayload, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Replace the stored waiter settings JSON blob."""
|
||||
import json as _json
|
||||
try:
|
||||
_json.loads(body.settings) # validate it's valid JSON
|
||||
except Exception:
|
||||
raise HTTPException(status_code=422, detail="settings must be valid JSON")
|
||||
user.waiter_settings = body.settings
|
||||
db.commit()
|
||||
return {"settings": user.waiter_settings}
|
||||
|
||||
@@ -3,6 +3,7 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.business_day import BusinessDay
|
||||
@@ -173,6 +174,60 @@ def close_business_day(
|
||||
return day
|
||||
|
||||
|
||||
class PatchBusinessDayRequest(BaseModel):
|
||||
closed_at: str # ISO-8601 datetime string
|
||||
|
||||
|
||||
@router.patch("/{day_id}", status_code=status.HTTP_200_OK)
|
||||
def patch_business_day(
|
||||
day_id: int,
|
||||
body: "PatchBusinessDayRequest",
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Edit the close-time of a past (closed) business day.
|
||||
Constraints:
|
||||
- Day must already be closed.
|
||||
- new closed_at must be ≥ the latest order closed_at in this day.
|
||||
- new closed_at must be ≤ now (UTC).
|
||||
"""
|
||||
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="Business day not found")
|
||||
if day.status != "closed":
|
||||
raise HTTPException(status_code=400, detail="Can only edit a closed business day")
|
||||
|
||||
try:
|
||||
new_closed = datetime.fromisoformat(body.closed_at.replace("Z", "+00:00"))
|
||||
if new_closed.tzinfo is None:
|
||||
new_closed = new_closed.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="Invalid datetime format")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if new_closed > now:
|
||||
raise HTTPException(status_code=400, detail="Close time cannot be in the future")
|
||||
|
||||
# Must not be earlier than the latest order closed_at in this day
|
||||
last_order_close = (
|
||||
db.query(func.max(Order.closed_at))
|
||||
.filter(Order.business_day_id == day_id, Order.closed_at != None)
|
||||
.scalar()
|
||||
)
|
||||
if last_order_close:
|
||||
if last_order_close.tzinfo is None:
|
||||
last_order_close = last_order_close.replace(tzinfo=timezone.utc)
|
||||
if new_closed < last_order_close:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Close time cannot be earlier than the last order's close time ({_dt(last_order_close)})"
|
||||
)
|
||||
|
||||
day.closed_at = new_closed
|
||||
db.commit()
|
||||
return {"id": day.id, "closed_at": _dt(day.closed_at)}
|
||||
|
||||
|
||||
@router.delete("/{day_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_business_day(
|
||||
day_id: int,
|
||||
|
||||
404
local_backend/routers/chat.py
Normal file
404
local_backend/routers/chat.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
Chat router — /api/chat
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from routers.deps import get_current_user
|
||||
from models.user import User
|
||||
from models.chat import Conversation, ConversationParticipant, ChatMessage
|
||||
from schemas.user import UserOut
|
||||
from schemas.chat import (
|
||||
ConversationCreate,
|
||||
ConversationOut,
|
||||
MessageCreate,
|
||||
MessageOut,
|
||||
ParticipantOut,
|
||||
)
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _build_participant_out(p: ConversationParticipant) -> ParticipantOut:
|
||||
return ParticipantOut(
|
||||
user_id=p.user_id,
|
||||
username=p.user.username,
|
||||
joined_at=p.joined_at,
|
||||
last_read_at=p.last_read_at,
|
||||
)
|
||||
|
||||
|
||||
def _build_message_out(msg: ChatMessage) -> MessageOut:
|
||||
return MessageOut(
|
||||
id=msg.id,
|
||||
conversation_id=msg.conversation_id,
|
||||
sender_id=msg.sender_id,
|
||||
sender_name=msg.sender.username,
|
||||
body=msg.body,
|
||||
sent_at=msg.sent_at,
|
||||
is_deleted=msg.deleted_at is not None,
|
||||
)
|
||||
|
||||
|
||||
def _unread_count(db: Session, conv_id: int, user: User) -> int:
|
||||
"""
|
||||
Count messages in a conversation that are 'unread' for this user:
|
||||
- not soft-deleted
|
||||
- not sent by this user (your own messages don't count)
|
||||
- sent after user's last_read_at (or ALL messages if last_read_at is None)
|
||||
"""
|
||||
participant = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
ConversationParticipant.conversation_id == conv_id,
|
||||
ConversationParticipant.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not participant:
|
||||
return 0
|
||||
|
||||
q = (
|
||||
db.query(ChatMessage)
|
||||
.filter(
|
||||
ChatMessage.conversation_id == conv_id,
|
||||
ChatMessage.deleted_at == None, # noqa: E711
|
||||
ChatMessage.sender_id != user.id,
|
||||
)
|
||||
)
|
||||
if participant.last_read_at is not None:
|
||||
q = q.filter(ChatMessage.sent_at > participant.last_read_at)
|
||||
return q.count()
|
||||
|
||||
|
||||
def _last_message(db: Session, conv_id: int) -> Optional[ChatMessage]:
|
||||
return (
|
||||
db.query(ChatMessage)
|
||||
.filter(
|
||||
ChatMessage.conversation_id == conv_id,
|
||||
ChatMessage.deleted_at == None, # noqa: E711
|
||||
)
|
||||
.order_by(ChatMessage.sent_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _get_participant_ids(conv: Conversation) -> List[int]:
|
||||
return [p.user_id for p in conv.participants]
|
||||
|
||||
|
||||
def _build_conversation_out(db: Session, conv: Conversation, user: User) -> ConversationOut:
|
||||
last_msg = _last_message(db, conv.id)
|
||||
return ConversationOut(
|
||||
id=conv.id,
|
||||
type=conv.type,
|
||||
name=conv.name,
|
||||
is_system=conv.is_system,
|
||||
created_at=conv.created_at,
|
||||
participants=[_build_participant_out(p) for p in conv.participants],
|
||||
last_message=_build_message_out(last_msg) if last_msg else None,
|
||||
unread_count=_unread_count(db, conv.id, user),
|
||||
)
|
||||
|
||||
|
||||
def _require_participant(db: Session, conv_id: int, user: User) -> Conversation:
|
||||
"""Return the conversation if user is a participant, else 403/404."""
|
||||
conv = db.query(Conversation).filter(Conversation.id == conv_id).first()
|
||||
if not conv:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
is_participant = any(p.user_id == user.id for p in conv.participants)
|
||||
if not is_participant:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a participant")
|
||||
return conv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/conversations", response_model=List[ConversationOut])
|
||||
def list_conversations(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all conversations the current user is a participant of, ordered by last message time desc."""
|
||||
# Fetch all conversation_ids for this user
|
||||
participant_rows = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(ConversationParticipant.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
conv_ids = [p.conversation_id for p in participant_rows]
|
||||
if not conv_ids:
|
||||
return []
|
||||
|
||||
conversations = (
|
||||
db.query(Conversation)
|
||||
.filter(Conversation.id.in_(conv_ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
# Sort by last message sent_at desc (conversations with no messages go last)
|
||||
# SQLite returns naive datetimes even for timezone=True columns, so keep everything naive
|
||||
def _sort_key(c: Conversation):
|
||||
last = _last_message(db, c.id)
|
||||
if not last:
|
||||
return datetime.min
|
||||
dt = last.sent_at
|
||||
return dt.replace(tzinfo=None) if dt.tzinfo else dt
|
||||
|
||||
conversations.sort(key=_sort_key, reverse=True)
|
||||
|
||||
return [_build_conversation_out(db, c, user) for c in conversations]
|
||||
|
||||
|
||||
@router.post("/conversations", response_model=ConversationOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_conversation(
|
||||
payload: ConversationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a direct or group conversation.
|
||||
For direct: returns existing conversation if one already exists between the two users.
|
||||
Creator is automatically added as a participant.
|
||||
"""
|
||||
# Normalise participant list — always include creator
|
||||
participant_ids = list(set(payload.participant_ids + [user.id]))
|
||||
|
||||
if payload.type == "direct":
|
||||
if len(participant_ids) != 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Direct conversations must have exactly 2 participants",
|
||||
)
|
||||
other_id = next(pid for pid in participant_ids if pid != user.id)
|
||||
|
||||
# Check for an existing non-system direct conversation between these two users
|
||||
existing_parts_me = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(ConversationParticipant.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
my_conv_ids = {p.conversation_id for p in existing_parts_me}
|
||||
|
||||
existing_parts_other = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
ConversationParticipant.user_id == other_id,
|
||||
ConversationParticipant.conversation_id.in_(my_conv_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
shared_conv_ids = {p.conversation_id for p in existing_parts_other}
|
||||
|
||||
for conv_id in shared_conv_ids:
|
||||
conv = db.query(Conversation).filter(
|
||||
Conversation.id == conv_id,
|
||||
Conversation.type == "direct",
|
||||
Conversation.is_system == False, # noqa: E712
|
||||
).first()
|
||||
if conv:
|
||||
return _build_conversation_out(db, conv, user)
|
||||
|
||||
elif payload.type == "group":
|
||||
if not payload.name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Group conversations require a name",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="type must be 'direct' or 'group'",
|
||||
)
|
||||
|
||||
# Create the conversation
|
||||
conv = Conversation(
|
||||
type=payload.type,
|
||||
name=payload.name if payload.type == "group" else None,
|
||||
is_system=False,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(conv)
|
||||
db.flush()
|
||||
|
||||
now = _utcnow()
|
||||
for uid in participant_ids:
|
||||
db.add(ConversationParticipant(
|
||||
conversation_id=conv.id,
|
||||
user_id=uid,
|
||||
joined_at=now,
|
||||
))
|
||||
|
||||
db.commit()
|
||||
db.refresh(conv)
|
||||
return _build_conversation_out(db, conv, user)
|
||||
|
||||
|
||||
@router.get("/conversations/{conv_id}", response_model=ConversationOut)
|
||||
def get_conversation(
|
||||
conv_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get single conversation details with participants."""
|
||||
conv = _require_participant(db, conv_id, user)
|
||||
return _build_conversation_out(db, conv, user)
|
||||
|
||||
|
||||
@router.get("/conversations/{conv_id}/messages", response_model=List[MessageOut])
|
||||
def list_messages(
|
||||
conv_id: int,
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Paginated messages for a conversation, newest first. User must be a participant."""
|
||||
_require_participant(db, conv_id, user)
|
||||
|
||||
messages = (
|
||||
db.query(ChatMessage)
|
||||
.filter(ChatMessage.conversation_id == conv_id)
|
||||
.order_by(ChatMessage.sent_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [_build_message_out(m) for m in messages]
|
||||
|
||||
|
||||
@router.post("/conversations/{conv_id}/messages", response_model=MessageOut, status_code=status.HTTP_201_CREATED)
|
||||
def send_message(
|
||||
conv_id: int,
|
||||
payload: MessageCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Send a message to a conversation. Broadcasts chat_message SSE event to all participants."""
|
||||
conv = _require_participant(db, conv_id, user)
|
||||
|
||||
if not payload.body.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Message body cannot be empty",
|
||||
)
|
||||
|
||||
msg = ChatMessage(
|
||||
conversation_id=conv_id,
|
||||
sender_id=user.id,
|
||||
body=payload.body,
|
||||
sent_at=_utcnow(),
|
||||
)
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
db.refresh(msg)
|
||||
|
||||
participant_ids = _get_participant_ids(conv)
|
||||
# Ensure UTC Z suffix so JS Date() parses correctly regardless of tzinfo
|
||||
sent_at_str = msg.sent_at.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
|
||||
broadcast_sync(
|
||||
"chat_message",
|
||||
{
|
||||
"conversation_id": conv_id,
|
||||
"message_id": msg.id,
|
||||
"sender_id": user.id,
|
||||
"sender_name": user.username,
|
||||
"body": msg.body,
|
||||
"sent_at": sent_at_str,
|
||||
},
|
||||
user_ids=participant_ids,
|
||||
)
|
||||
|
||||
return _build_message_out(msg)
|
||||
|
||||
|
||||
@router.post("/conversations/{conv_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def mark_read(
|
||||
conv_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update the current user's last_read_at to now. Broadcasts chat_read SSE event."""
|
||||
conv = _require_participant(db, conv_id, user)
|
||||
|
||||
participant = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
ConversationParticipant.conversation_id == conv_id,
|
||||
ConversationParticipant.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
now = _utcnow()
|
||||
participant.last_read_at = now
|
||||
db.commit()
|
||||
|
||||
participant_ids = _get_participant_ids(conv)
|
||||
broadcast_sync(
|
||||
"chat_read",
|
||||
{
|
||||
"conversation_id": conv_id,
|
||||
"user_id": user.id,
|
||||
"read_at": now.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z',
|
||||
},
|
||||
user_ids=participant_ids,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/conversations/{conv_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def leave_conversation(
|
||||
conv_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Remove current user from the conversation.
|
||||
Not allowed on system conversations or direct conversations.
|
||||
"""
|
||||
conv = _require_participant(db, conv_id, user)
|
||||
|
||||
if conv.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot leave the system group conversation",
|
||||
)
|
||||
if conv.type == "direct":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot leave a direct conversation — just stop using it",
|
||||
)
|
||||
|
||||
participant = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
ConversationParticipant.conversation_id == conv_id,
|
||||
ConversationParticipant.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
db.delete(participant)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/users", response_model=List[UserOut])
|
||||
def list_chat_users(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""All active users — accessible to any authenticated staff member, for starting chats."""
|
||||
return db.query(User).filter(User.is_active == True).order_by(User.username).all() # noqa: E712
|
||||
@@ -16,6 +16,45 @@ _blacklisted_tokens: set[str] = set()
|
||||
TOKEN_EXPIRY_HOURS = 8
|
||||
|
||||
|
||||
MASTER_USER_ID = 0 # sentinel — never a real DB row
|
||||
|
||||
|
||||
def _make_ghost_superadmin():
|
||||
"""Synthetic in-memory superadmin — plain namespace, never touches SQLAlchemy."""
|
||||
from types import SimpleNamespace
|
||||
return SimpleNamespace(
|
||||
id=MASTER_USER_ID,
|
||||
username=settings.MASTER_USERNAME,
|
||||
full_name="Xenia Support",
|
||||
role="superadmin",
|
||||
is_active=True,
|
||||
pin_hash=None,
|
||||
password_hash=None,
|
||||
email=None,
|
||||
nickname=None,
|
||||
mobile_phone=None,
|
||||
note=None,
|
||||
avatar_url=None,
|
||||
waiter_settings=None,
|
||||
hourly_rate=None,
|
||||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
zone_assignments=[],
|
||||
perm_access_dashboard=True,
|
||||
perm_access_waiter_app=True,
|
||||
perm_access_kds=True,
|
||||
perm_cancel_orders=True,
|
||||
perm_apply_discounts=True,
|
||||
perm_modify_prices=True,
|
||||
perm_open_orders=True,
|
||||
perm_close_orders=True,
|
||||
perm_view_reports=True,
|
||||
perm_manage_staff=True,
|
||||
perm_manage_tables=True,
|
||||
perm_manage_menu=True,
|
||||
perm_manage_settings=True,
|
||||
)
|
||||
|
||||
|
||||
def make_token(user: User) -> str:
|
||||
payload = {
|
||||
"sub": str(user.id),
|
||||
@@ -46,19 +85,69 @@ def get_current_user(
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
payload = decode_token(credentials.credentials)
|
||||
if int(payload["sub"]) == MASTER_USER_ID and payload.get("role") == "superadmin":
|
||||
return _make_ghost_superadmin()
|
||||
user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
def _has_perm(user: User, perm: str) -> bool:
|
||||
"""Superadmins always have every permission."""
|
||||
if user.role == "superadmin":
|
||||
return True
|
||||
return bool(getattr(user, perm, False))
|
||||
|
||||
|
||||
def require_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role not in ("manager", "sysadmin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Manager access required")
|
||||
"""Allow anyone with dashboard access (formerly role-based manager check)."""
|
||||
if not _has_perm(user, "perm_access_dashboard"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Dashboard access required")
|
||||
return user
|
||||
|
||||
|
||||
def require_sysadmin(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role != "sysadmin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin access required")
|
||||
def require_staff_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_manage_staff"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Staff management permission required")
|
||||
return user
|
||||
|
||||
|
||||
def require_reports(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_view_reports"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Reports permission required")
|
||||
return user
|
||||
|
||||
|
||||
def require_menu_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_manage_menu"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Menu management permission required")
|
||||
return user
|
||||
|
||||
|
||||
def require_settings_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_manage_settings"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Settings management permission required")
|
||||
return user
|
||||
|
||||
|
||||
def require_kds(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_access_kds"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Δεν έχετε πρόσβαση στο KDS")
|
||||
return user
|
||||
|
||||
|
||||
def require_tables_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_manage_tables"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tables management permission required")
|
||||
return user
|
||||
|
||||
|
||||
def require_superadmin(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role != "superadmin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin access required")
|
||||
return user
|
||||
|
||||
|
||||
# Kept for any callers that still use the old name
|
||||
require_sysadmin = require_superadmin
|
||||
|
||||
25
local_backend/routers/fiscal.py
Normal file
25
local_backend/routers/fiscal.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from routers.deps import require_settings_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TestFoldersRequest(BaseModel):
|
||||
out_folder: str
|
||||
in_folder: str
|
||||
|
||||
|
||||
@router.post("/test-folders")
|
||||
def test_folders(
|
||||
body: TestFoldersRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_settings_manager),
|
||||
):
|
||||
"""Verify that both fiscal folders are accessible (read + write) from inside the container."""
|
||||
from services.fiscal_service import test_folders
|
||||
return test_folders(body.out_folder.strip(), body.in_folder.strip())
|
||||
@@ -1,30 +1,737 @@
|
||||
"""
|
||||
KDS — Kitchen Display System router.
|
||||
|
||||
GET /api/kds/items
|
||||
Returns all active (status='active') order items for open orders,
|
||||
enriched with zone name and table name. Used by the KDS frontend.
|
||||
GET /api/kds/orders
|
||||
Returns all open orders enriched with resolved modifier names,
|
||||
grouped by nothing (flat list). Frontend handles grouping/filtering.
|
||||
|
||||
PUT /api/orders/{order_id}/items/{item_id}/status
|
||||
Mark an item ready (active → ready). Only that transition is allowed here.
|
||||
Broadcasts item_status_changed SSE event.
|
||||
PUT /api/kds/orders/{order_id}/kds_status
|
||||
Update an order's KDS aggregate status (pending|preparing|done).
|
||||
Broadcasts kds_order_updated SSE event.
|
||||
|
||||
PUT /api/kds/orders/{order_id}/items/{item_id}/kds_status
|
||||
Update a single item's KDS status (pending|preparing|done).
|
||||
Broadcasts kds_item_updated SSE event.
|
||||
|
||||
PUT /api/kds/orders/{order_id}/order_type
|
||||
Update an order's type (here|takeaway|delivery).
|
||||
|
||||
--- Legacy endpoint kept for backward compat (printer-zone grouped items) ---
|
||||
GET /api/kds/items (unchanged)
|
||||
PUT /api/kds/orders/{order_id}/items/{item_id}/status (unchanged)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.order import Order, OrderItem
|
||||
from models.product import Product
|
||||
from models.order import Order, OrderItem, OrderWaiter
|
||||
from models.product import Product, ProductPreferenceSet
|
||||
from models.prep_zone import PrepZone
|
||||
from models.printer import Printer
|
||||
from models.table import Table
|
||||
from models.user import User
|
||||
from routers.deps import get_current_user
|
||||
from models.message import StaffMessage, StaffMessageAck
|
||||
from models.shift import WaiterShift
|
||||
from routers.deps import get_current_user, require_kds
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(dependencies=[Depends(require_kds)])
|
||||
|
||||
VALID_KDS_STATUSES = {"pending", "preparing", "done", "served", "declined"}
|
||||
VALID_ORDER_TYPES = {"here", "takeaway", "delivery"}
|
||||
|
||||
|
||||
# ─────────────────────────── helpers ───────────────────────────────────────
|
||||
|
||||
def _resolve_item_modifiers(item: OrderItem, product: Product | None, db: Session):
|
||||
"""
|
||||
Returns a dict with four lists:
|
||||
removed - ingredient names removed (from removed_ingredients JSON id array)
|
||||
extras - option names selected (from selected_options JSON id array)
|
||||
prefs - preference choice names selected (from selected_options JSON id array)
|
||||
notes - plain text note
|
||||
"""
|
||||
removed, extras, prefs = [], [], []
|
||||
|
||||
if product is None:
|
||||
return {"removed": removed, "extras": extras, "prefs": prefs, "notes": item.notes}
|
||||
|
||||
# Build lookup maps (avoid N+1 — product relationships already loaded)
|
||||
ing_map = {i.id: i.name for i in product.ingredients}
|
||||
opt_map = {o.id: o.name for o in product.options}
|
||||
quick_map = {q.id: q.name for q in product.quick_options}
|
||||
|
||||
# Preference choice lookup: pref_choice_map[choice_id] = (set_name, choice_name)
|
||||
pref_choice_map: dict[int, tuple[str, str]] = {}
|
||||
for ps in product.preference_sets:
|
||||
for pc in ps.choices:
|
||||
pref_choice_map[pc.id] = (ps.name, pc.name)
|
||||
|
||||
# Removed ingredients — stored as JSON array of name strings (not ids)
|
||||
if item.removed_ingredients:
|
||||
try:
|
||||
vals = json.loads(item.removed_ingredients)
|
||||
for v in vals:
|
||||
if isinstance(v, str) and v:
|
||||
removed.append(v)
|
||||
elif isinstance(v, int):
|
||||
# legacy: id reference
|
||||
name = ing_map.get(v)
|
||||
if name:
|
||||
removed.append(name)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Selected options — stored as JSON array of objects:
|
||||
# [{"id": int, "name": str, "type": "extra"|"quick"|"pref"|"pref_sub", ...}]
|
||||
# We use the stored name directly; type tag tells us which bucket.
|
||||
if item.selected_options:
|
||||
try:
|
||||
opts = json.loads(item.selected_options)
|
||||
for o in opts:
|
||||
if isinstance(o, dict):
|
||||
name = o.get("name") or ""
|
||||
otype = o.get("type", "")
|
||||
if not name:
|
||||
continue
|
||||
if otype in ("pref", "pref_sub"):
|
||||
prefs.append(name)
|
||||
else:
|
||||
# "extra", "extra_sub", "quick", or unknown → extras
|
||||
extras.append(name)
|
||||
elif isinstance(o, int):
|
||||
# legacy: id-only reference — fall back to lookup maps
|
||||
if o in opt_map:
|
||||
extras.append(opt_map[o])
|
||||
elif o in quick_map:
|
||||
extras.append(quick_map[o])
|
||||
elif o in pref_choice_map:
|
||||
_, choice_name = pref_choice_map[o]
|
||||
prefs.append(choice_name)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return {"removed": removed, "extras": extras, "prefs": prefs, "notes": item.notes}
|
||||
|
||||
|
||||
def _build_order_payload(order: Order, tables_map: dict, db: Session) -> dict:
|
||||
"""Build the full order dict the KDS frontend expects."""
|
||||
items_out = []
|
||||
for item in order.items:
|
||||
if item.status == "cancelled":
|
||||
continue
|
||||
if item.kds_status == "served":
|
||||
continue
|
||||
product = item.product
|
||||
mods = _resolve_item_modifiers(item, product, db)
|
||||
unit_type = product.unit_type if product else "piece"
|
||||
prep_zone_ids = [z.id for z in product.prep_zones] if product and hasattr(product, 'prep_zones') else []
|
||||
items_out.append({
|
||||
"id": item.id,
|
||||
"product_id": item.product_id,
|
||||
"product_name": product.name if product else f"#{item.product_id}",
|
||||
"quantity": item.quantity,
|
||||
"unit_type": unit_type or "piece",
|
||||
"kds_status": item.kds_status,
|
||||
"decline_note": item.decline_note,
|
||||
"added_at": item.added_at.isoformat() if item.added_at else None,
|
||||
"_added_at_raw": item.added_at, # stripped before response
|
||||
"prep_zone_ids": prep_zone_ids,
|
||||
"course_id": item.course_id,
|
||||
"removed": mods["removed"],
|
||||
"extras": mods["extras"],
|
||||
"prefs": mods["prefs"],
|
||||
"notes": mods["notes"],
|
||||
})
|
||||
|
||||
# Waiter names from first assignment
|
||||
waiter_names = [ow.waiter.username for ow in order.waiters if ow.waiter] if order.waiters else []
|
||||
|
||||
# Clock = oldest added_at among pending/preparing items (the active batch).
|
||||
# Falls back to order.opened_at only if no such item exists.
|
||||
def _fmt_ts(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z" if not dt.tzinfo else dt.isoformat()
|
||||
|
||||
active_batch_times = [
|
||||
item["_added_at_raw"] for item in items_out
|
||||
if item.get("_added_at_raw") and item["kds_status"] in ("pending", "preparing")
|
||||
]
|
||||
batch_ts = min(active_batch_times) if active_batch_times else None
|
||||
opened_at_out = _fmt_ts(batch_ts) if batch_ts else _fmt_ts(order.opened_at)
|
||||
|
||||
for item in items_out:
|
||||
item.pop("_added_at_raw", None)
|
||||
|
||||
return {
|
||||
"id": order.id,
|
||||
"kds_status": order.kds_status,
|
||||
"order_type": order.order_type,
|
||||
"table_name": tables_map.get(order.table_id) if order.table_id else None,
|
||||
"table_id": order.table_id,
|
||||
"opened_at": opened_at_out,
|
||||
"closed_at": _fmt_ts(order.closed_at) if order.closed_at else None,
|
||||
"notes": order.notes,
|
||||
"waiters": waiter_names,
|
||||
"items": items_out,
|
||||
}
|
||||
|
||||
|
||||
def _sync_order_kds_status(order: Order):
|
||||
"""Derive and set order.kds_status from all non-cancelled items. Call before db.commit()."""
|
||||
relevant = [i for i in order.items if i.status != "cancelled"]
|
||||
if not relevant:
|
||||
return
|
||||
statuses = {i.kds_status for i in relevant}
|
||||
if statuses <= {"served"}:
|
||||
new_status = "served"
|
||||
elif statuses <= {"done", "served"}:
|
||||
new_status = "done"
|
||||
elif statuses <= {"declined"}:
|
||||
new_status = "declined"
|
||||
elif "preparing" in statuses or "done" in statuses:
|
||||
new_status = "preparing"
|
||||
else:
|
||||
new_status = "pending"
|
||||
if new_status != order.kds_status:
|
||||
order.kds_status = new_status
|
||||
order.kds_status_changed_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ─────────────────────────── endpoints ─────────────────────────────────────
|
||||
|
||||
@router.get("/orders")
|
||||
def kds_orders(
|
||||
zone_id: Optional[int] = Query(default=None, description="Filter orders to only those containing items in this prep zone"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all open/partially_paid/paid orders that still have unserved items.
|
||||
If zone_id is provided, only orders containing at least one item belonging to that prep zone are returned,
|
||||
and items not in that zone are stripped from the payload.
|
||||
"""
|
||||
open_orders = (
|
||||
db.query(Order)
|
||||
.filter(Order.status.in_(["open", "partially_paid", "paid"]))
|
||||
.options(
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.ingredients),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.options),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.quick_options),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.preference_sets).joinedload(ProductPreferenceSet.choices),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.prep_zones),
|
||||
joinedload(Order.waiters).joinedload(OrderWaiter.waiter),
|
||||
)
|
||||
.order_by(Order.opened_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
|
||||
payloads = []
|
||||
for o in open_orders:
|
||||
p = _build_order_payload(o, tables_map, db)
|
||||
if not p["items"]:
|
||||
continue
|
||||
# Zone filtering: keep only items whose product belongs to the requested zone
|
||||
if zone_id is not None:
|
||||
p["items"] = [it for it in p["items"] if zone_id in it.get("prep_zone_ids", [])]
|
||||
if not p["items"]:
|
||||
continue
|
||||
payloads.append(p)
|
||||
return {"orders": payloads}
|
||||
|
||||
|
||||
class KdsStatusBody(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class OrderTypeBody(BaseModel):
|
||||
order_type: str
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/kds_status")
|
||||
def update_order_kds_status(
|
||||
order_id: int,
|
||||
body: KdsStatusBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.status not in VALID_KDS_STATUSES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
if body.status != order.kds_status:
|
||||
order.kds_status = body.status
|
||||
order.kds_status_changed_at = datetime.now(timezone.utc)
|
||||
|
||||
# Cascade order-level status to non-cancelled, non-served items only
|
||||
if body.status in ("pending", "preparing", "done"):
|
||||
for item in order.items:
|
||||
if item.status != "cancelled" and item.kds_status != "served":
|
||||
item.kds_status = body.status
|
||||
# auto_ready_to_served: if cascading to 'done', immediately upgrade qualifying items
|
||||
if body.status == "done" and item.product:
|
||||
if any(bool(z.auto_ready_to_served) for z in item.product.prep_zones):
|
||||
item.kds_status = "served"
|
||||
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_order_updated", {
|
||||
"order_id": order_id,
|
||||
"kds_status": body.status,
|
||||
})
|
||||
|
||||
return {"order_id": order_id, "kds_status": body.status}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/items/{item_id}/kds_status")
|
||||
def update_item_kds_status(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: KdsStatusBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.status not in VALID_KDS_STATUSES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
|
||||
|
||||
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")
|
||||
|
||||
item.kds_status = body.status
|
||||
|
||||
# auto_ready_to_served: if item reaches 'done' and any of its zones has the flag,
|
||||
# immediately upgrade it to 'served' — item was ready at the KDS, no explicit serve needed.
|
||||
if body.status == "done" and item.product:
|
||||
auto_serve = any(bool(z.auto_ready_to_served) for z in item.product.prep_zones)
|
||||
if auto_serve:
|
||||
item.kds_status = "served"
|
||||
|
||||
if item.order:
|
||||
_sync_order_kds_status(item.order)
|
||||
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"kds_status": item.kds_status,
|
||||
})
|
||||
|
||||
return {"item_id": item_id, "kds_status": item.kds_status}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/order_type")
|
||||
def update_order_type(
|
||||
order_id: int,
|
||||
body: OrderTypeBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.order_type not in VALID_ORDER_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid order_type '{body.order_type}'")
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
order.order_type = body.order_type
|
||||
db.commit()
|
||||
|
||||
return {"order_id": order_id, "order_type": body.order_type}
|
||||
|
||||
|
||||
class MarkServedBody(BaseModel):
|
||||
item_ids: list[int]
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/items/mark-served")
|
||||
def mark_items_served(
|
||||
order_id: int,
|
||||
body: MarkServedBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark a list of items as served (kds_status=served). Works on active and paid items."""
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
updated = []
|
||||
ids_set = set(body.item_ids)
|
||||
for item in order.items:
|
||||
if item.id in ids_set:
|
||||
item.kds_status = "served"
|
||||
updated.append(item.id)
|
||||
|
||||
_sync_order_kds_status(order)
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_ids": updated,
|
||||
"kds_status": "served",
|
||||
})
|
||||
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/items/mark-all-served")
|
||||
def mark_all_items_served(
|
||||
order_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark all non-pending items in an order as served."""
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
updated = []
|
||||
for item in order.items:
|
||||
if item.kds_status in ("preparing", "done"):
|
||||
item.kds_status = "served"
|
||||
updated.append(item.id)
|
||||
|
||||
_sync_order_kds_status(order)
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_ids": updated,
|
||||
"kds_status": "served",
|
||||
})
|
||||
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
# ─────────────────────────── KDS print endpoint ─────────────────────────────
|
||||
|
||||
class KdsPrintBody(BaseModel):
|
||||
printer_id: Optional[int] = None
|
||||
item_ids: Optional[List[int]] = None
|
||||
copies: int = 1
|
||||
zone_id: Optional[int] = None
|
||||
mode: str = "primary" # none | primary | all
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/print")
|
||||
def kds_print_order(
|
||||
order_id: int,
|
||||
body: KdsPrintBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Print order to zone printers. mode=primary prints to first printer, mode=all prints to all."""
|
||||
from services import printer_service
|
||||
if body.mode == "none":
|
||||
return {"printed": 0}
|
||||
if body.printer_id is not None:
|
||||
# Direct printer specified
|
||||
result = printer_service.print_to_printer(order_id, body.item_ids, body.printer_id, body.copies, db)
|
||||
return result
|
||||
# Zone-based print
|
||||
if body.zone_id is None:
|
||||
return {"printed": 0, "message": "No zone configured"}
|
||||
from models.prep_zone import PrepZone
|
||||
zone = db.query(PrepZone).filter(PrepZone.id == body.zone_id).first()
|
||||
if not zone or not zone.printers:
|
||||
return {"printed": 0, "message": "Zone has no printers"}
|
||||
printers_to_use = zone.printers if body.mode == "all" else [zone.printers[0]]
|
||||
printed = 0
|
||||
for printer in printers_to_use:
|
||||
try:
|
||||
printer_service.print_to_printer(order_id, body.item_ids, printer.id, body.copies, db)
|
||||
printed += 1
|
||||
except Exception:
|
||||
pass
|
||||
return {"printed": printed}
|
||||
|
||||
|
||||
# ─────────────────────────── KDS decline endpoints ─────────────────────────
|
||||
|
||||
class DeclineBody(BaseModel):
|
||||
decline_note: Optional[str] = None # reason string (preset or free text)
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/decline")
|
||||
def decline_order(
|
||||
order_id: int,
|
||||
body: DeclineBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark all active items on an order as declined."""
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
for item in order.items:
|
||||
if item.status != "cancelled":
|
||||
item.kds_status = "declined"
|
||||
item.decline_note = body.decline_note
|
||||
|
||||
order.kds_status = "declined"
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_order_updated", {
|
||||
"order_id": order_id,
|
||||
"kds_status": "declined",
|
||||
"decline_note": body.decline_note,
|
||||
})
|
||||
return {"order_id": order_id, "kds_status": "declined"}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/items/{item_id}/decline")
|
||||
def decline_item(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: DeclineBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark a single item as declined."""
|
||||
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")
|
||||
|
||||
item.kds_status = "declined"
|
||||
item.decline_note = body.decline_note
|
||||
|
||||
if item.order:
|
||||
_sync_order_kds_status(item.order)
|
||||
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"kds_status": "declined",
|
||||
"decline_note": body.decline_note,
|
||||
})
|
||||
return {"item_id": item_id, "kds_status": "declined"}
|
||||
|
||||
|
||||
# ─────────────────────────── KDS notifications ──────────────────────────────
|
||||
|
||||
def _waiter_ids_for_order(order: Order) -> list[int]:
|
||||
"""Return the list of waiter IDs assigned to an order."""
|
||||
return [ow.waiter_id for ow in order.waiters if ow.waiter_id]
|
||||
|
||||
|
||||
def _save_and_broadcast_message(
|
||||
db: Session,
|
||||
sender_id: int,
|
||||
body: str,
|
||||
target_waiter_ids: list[int],
|
||||
message_type: str,
|
||||
kds_zone: str | None,
|
||||
table_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Persist a StaffMessage and broadcast via SSE. Returns the SSE payload dict."""
|
||||
from models.message import StaffMessage
|
||||
from datetime import datetime, timezone
|
||||
|
||||
msg = StaffMessage(
|
||||
sender_id=sender_id,
|
||||
body=body,
|
||||
target_waiter_ids=json.dumps(target_waiter_ids),
|
||||
table_ids=json.dumps(table_ids or []),
|
||||
message_type=message_type,
|
||||
kds_zone=kds_zone,
|
||||
)
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
db.refresh(msg)
|
||||
|
||||
payload = {
|
||||
"id": msg.id,
|
||||
"sender_id": msg.sender_id,
|
||||
"sender_name": kds_zone or "KDS",
|
||||
"body": msg.body,
|
||||
"table_ids": msg.table_ids,
|
||||
"message_type": message_type,
|
||||
"kds_zone": kds_zone,
|
||||
"created_at": msg.created_at.isoformat() if msg.created_at else None,
|
||||
}
|
||||
user_ids = target_waiter_ids if target_waiter_ids else None
|
||||
broadcast_sync("message_sent", payload, user_ids=user_ids)
|
||||
return payload
|
||||
|
||||
|
||||
class KdsNotifyBody(BaseModel):
|
||||
kds_zone: Optional[str] = None
|
||||
|
||||
|
||||
class KdsCallWaiterBody(BaseModel):
|
||||
kds_zone: Optional[str] = None
|
||||
waiter_ids: List[int] # for call_general: list of on-shift waiter ids to notify
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/notify-complete")
|
||||
def kds_notify_order_complete(
|
||||
order_id: int,
|
||||
body: KdsNotifyBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Notify assigned waiters that their order is fully complete."""
|
||||
order = db.query(Order).options(
|
||||
joinedload(Order.waiters),
|
||||
).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
waiter_ids = _waiter_ids_for_order(order)
|
||||
if not waiter_ids:
|
||||
return {"sent": False, "reason": "no_waiters"}
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
|
||||
msg_body = (
|
||||
f"Η παραγγελία #{order.id} για το τραπέζι {table_name} "
|
||||
f"είναι έτοιμη προς παραλαβή - {zone}"
|
||||
)
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, waiter_ids,
|
||||
"kds_order_done", zone,
|
||||
table_ids=[order.table_id] if order.table_id else [],
|
||||
)
|
||||
return {"sent": True, "waiter_ids": waiter_ids}
|
||||
|
||||
|
||||
class KdsNotifyItemsBody(BaseModel):
|
||||
kds_zone: Optional[str] = None
|
||||
ready_count: int
|
||||
total_count: int
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/notify-items-ready")
|
||||
def kds_notify_items_ready(
|
||||
order_id: int,
|
||||
body: KdsNotifyItemsBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Notify assigned waiters that some (but not all) items are ready."""
|
||||
order = db.query(Order).options(
|
||||
joinedload(Order.waiters),
|
||||
).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
waiter_ids = _waiter_ids_for_order(order)
|
||||
if not waiter_ids:
|
||||
return {"sent": False, "reason": "no_waiters"}
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
|
||||
msg_body = (
|
||||
f"{body.ready_count} αντικείμενα από την παραγγελία #{order.id} "
|
||||
f"για το τραπέζι {table_name} είναι έτοιμα - {zone}"
|
||||
)
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, waiter_ids,
|
||||
"kds_item_done", zone,
|
||||
table_ids=[order.table_id] if order.table_id else [],
|
||||
)
|
||||
return {"sent": True, "waiter_ids": waiter_ids}
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/call-waiter")
|
||||
def kds_call_waiter_order(
|
||||
order_id: int,
|
||||
body: KdsNotifyBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Manually call the waiters assigned to a specific order."""
|
||||
order = db.query(Order).options(
|
||||
joinedload(Order.waiters),
|
||||
).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
waiter_ids = _waiter_ids_for_order(order)
|
||||
if not waiter_ids:
|
||||
return {"sent": False, "reason": "no_waiters"}
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
|
||||
msg_body = (
|
||||
f"Παραγγελία #{order.id} - τραπέζι {table_name} "
|
||||
f"- {zone}"
|
||||
)
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, waiter_ids,
|
||||
"kds_call_order", zone,
|
||||
table_ids=[order.table_id] if order.table_id else [],
|
||||
)
|
||||
return {"sent": True, "waiter_ids": waiter_ids}
|
||||
|
||||
|
||||
@router.post("/call-waiter-general")
|
||||
def kds_call_waiter_general(
|
||||
body: KdsCallWaiterBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Call one or more on-shift waiters to the prep zone."""
|
||||
if not body.waiter_ids:
|
||||
raise HTTPException(status_code=400, detail="waiter_ids must not be empty")
|
||||
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
msg_body = f"Κλήση από {zone}"
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, body.waiter_ids,
|
||||
"kds_call_general", zone,
|
||||
)
|
||||
return {"sent": True, "waiter_ids": body.waiter_ids}
|
||||
|
||||
|
||||
@router.get("/on-shift-waiters")
|
||||
def kds_on_shift_waiters(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return waiters currently on shift (ended_at IS NULL)."""
|
||||
on_shift_ids = {
|
||||
row.waiter_id
|
||||
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
|
||||
}
|
||||
waiters = db.query(User).filter(
|
||||
User.id.in_(on_shift_ids),
|
||||
User.perm_access_waiter_app == True,
|
||||
User.is_active == True,
|
||||
).order_by(User.username).all()
|
||||
return [
|
||||
{"id": w.id, "username": w.username, "nickname": w.nickname, "avatar_url": w.avatar_url}
|
||||
for w in waiters
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────── legacy endpoints ──────────────────────────────
|
||||
|
||||
class ItemStatusUpdate(BaseModel):
|
||||
status: str # only "ready" is accepted
|
||||
@@ -35,15 +742,13 @@ def kds_items(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all active order items grouped by zone for the KDS display."""
|
||||
# Only look at open / partially_paid orders
|
||||
"""Legacy: return active order items grouped by printer zone."""
|
||||
open_orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid"])).all()
|
||||
order_ids = [o.id for o in open_orders]
|
||||
if not order_ids:
|
||||
return {"zones": []}
|
||||
|
||||
order_map = {o.id: o for o in open_orders}
|
||||
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.order_id.in_(order_ids),
|
||||
OrderItem.status == "active",
|
||||
@@ -52,13 +757,10 @@ def kds_items(
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
printers_map = {p.id: p.name for p in db.query(Printer).all()}
|
||||
|
||||
# Zone = printer_zone_id (None = no zone)
|
||||
zones: dict = {}
|
||||
|
||||
def _zone_key(zone_id):
|
||||
if zone_id is None:
|
||||
return 0
|
||||
return zone_id
|
||||
return 0 if zone_id is None else zone_id
|
||||
|
||||
def _zone_name(zone_id):
|
||||
if zone_id is None:
|
||||
@@ -69,17 +771,10 @@ def kds_items(
|
||||
product = item.product
|
||||
zone_id = product.printer_zone_id if product else None
|
||||
zkey = _zone_key(zone_id)
|
||||
|
||||
if zkey not in zones:
|
||||
zones[zkey] = {
|
||||
"zone_id": zone_id,
|
||||
"zone_name": _zone_name(zone_id),
|
||||
"items": [],
|
||||
}
|
||||
|
||||
zones[zkey] = {"zone_id": zone_id, "zone_name": _zone_name(zone_id), "items": []}
|
||||
order = order_map.get(item.order_id)
|
||||
table_name = tables_map.get(order.table_id) if order and order.table_id else None
|
||||
|
||||
zones[zkey]["items"].append({
|
||||
"id": item.id,
|
||||
"order_id": item.order_id,
|
||||
@@ -91,19 +786,19 @@ def kds_items(
|
||||
"status": item.status,
|
||||
})
|
||||
|
||||
# Sort zones: named zones first (by zone_id), then no-zone last
|
||||
zone_list = sorted(zones.values(), key=lambda z: (z["zone_id"] is None, z["zone_id"] or 0))
|
||||
return {"zones": zone_list}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/items/{item_id}/status")
|
||||
def update_item_status(
|
||||
def update_item_status_legacy(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: ItemStatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Legacy: mark an item ready (active → ready)."""
|
||||
if body.status != "ready":
|
||||
raise HTTPException(status_code=400, detail="Only 'ready' is a valid status transition via this endpoint")
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ def _message_out(msg: StaffMessage) -> StaffMessageOut:
|
||||
body=msg.body,
|
||||
target_waiter_ids=msg.target_waiter_ids,
|
||||
table_ids=msg.table_ids,
|
||||
message_type=msg.message_type or "manager",
|
||||
kds_zone=msg.kds_zone,
|
||||
created_at=msg.created_at,
|
||||
acked_by=[ack.waiter_id for ack in msg.acks],
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
64
local_backend/routers/phone.py
Normal file
64
local_backend/routers/phone.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Phone call event receiver + WebSocket broadcaster.
|
||||
|
||||
The Grandstream UCM sends a simple HTTP GET to /api/phone/call-event
|
||||
with caller ID and extension details. We broadcast that to all connected
|
||||
WebSocket clients (manager dashboard Phone page).
|
||||
|
||||
The call-event endpoint is intentionally unauthenticated so the UCM device
|
||||
(which has no JWT) can POST to it directly on the local LAN.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In-memory list of active WebSocket connections to the Phone page.
|
||||
_connections: list[WebSocket] = []
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def phone_ws(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
_connections.append(websocket)
|
||||
logger.debug("phone_ws: client connected (%d total)", len(_connections))
|
||||
try:
|
||||
# Keep the connection alive; client drives nothing, server pushes events.
|
||||
while True:
|
||||
await asyncio.sleep(25)
|
||||
await websocket.send_text(json.dumps({"type": "ping"}))
|
||||
except (WebSocketDisconnect, Exception):
|
||||
pass
|
||||
finally:
|
||||
_connections.remove(websocket)
|
||||
logger.debug("phone_ws: client disconnected (%d total)", len(_connections))
|
||||
|
||||
|
||||
@router.get("/call-event")
|
||||
async def call_event(
|
||||
caller: str = Query(..., description="Caller ID number from UCM"),
|
||||
ext: str = Query("", description="Dialled extension"),
|
||||
):
|
||||
"""
|
||||
Called by the Grandstream UCM Action URL on every incoming call.
|
||||
Example: GET /api/phone/call-event?caller=2106001234&ext=100
|
||||
"""
|
||||
payload = json.dumps({"type": "incoming_call", "caller": caller, "ext": ext})
|
||||
dead = []
|
||||
for ws in list(_connections):
|
||||
try:
|
||||
await ws.send_text(payload)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
try:
|
||||
_connections.remove(ws)
|
||||
except ValueError:
|
||||
pass
|
||||
logger.info("phone call-event: caller=%s ext=%s (notified %d clients)", caller, ext, len(_connections) - len(dead))
|
||||
return {"status": "ok", "notified": len(_connections) - len(dead)}
|
||||
215
local_backend/routers/prep_zones.py
Normal file
215
local_backend/routers/prep_zones.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Prep Zones router.
|
||||
|
||||
GET /api/prep-zones list all prep zones (with printer ids)
|
||||
POST /api/prep-zones create a prep zone
|
||||
PUT /api/prep-zones/{id} update name / description / printer assignments / all settings
|
||||
DELETE /api/prep-zones/{id} delete a prep zone
|
||||
"""
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.prep_zone import PrepZone
|
||||
from models.printer import Printer
|
||||
from routers.deps import require_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PrepZoneCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
notification_name: Optional[str] = None
|
||||
|
||||
# Printer routing
|
||||
printer_ids: List[int] = [] # all printers (master + secondary)
|
||||
master_printer_id: Optional[int] = None
|
||||
auto_print: str = 'none' # 'none' | 'master' | 'all'
|
||||
master_copies: int = 1
|
||||
secondary_copies: int = 1
|
||||
|
||||
# Ticket formatting
|
||||
sort_items_by: str = 'order_time' # 'order_time' | 'item_count' | 'alpha'
|
||||
group_by_category: bool = False
|
||||
category_order: List[int] = []
|
||||
print_checkboxes: bool = False
|
||||
|
||||
# KDS bypass / auto-progression
|
||||
bypass_pending: bool = False
|
||||
bypass_kds: bool = False
|
||||
auto_ready_to_served: bool = False
|
||||
|
||||
|
||||
class PrepZoneUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
notification_name: Optional[str] = None
|
||||
|
||||
# Printer routing
|
||||
printer_ids: Optional[List[int]] = None
|
||||
master_printer_id: Optional[int] = None
|
||||
auto_print: Optional[str] = None
|
||||
master_copies: Optional[int] = None
|
||||
secondary_copies: Optional[int] = None
|
||||
|
||||
# Ticket formatting
|
||||
sort_items_by: Optional[str] = None
|
||||
group_by_category: Optional[bool] = None
|
||||
category_order: Optional[List[int]] = None
|
||||
print_checkboxes: Optional[bool] = None
|
||||
|
||||
# KDS bypass / auto-progression
|
||||
bypass_pending: Optional[bool] = None
|
||||
bypass_kds: Optional[bool] = None
|
||||
auto_ready_to_served: Optional[bool] = None
|
||||
|
||||
|
||||
def _zone_out(zone: PrepZone) -> dict:
|
||||
all_printer_ids = [p.id for p in zone.printers]
|
||||
secondary_ids = [pid for pid in all_printer_ids if pid != zone.master_printer_id]
|
||||
return {
|
||||
"id": zone.id,
|
||||
"name": zone.name,
|
||||
"description": zone.description,
|
||||
"notification_name": zone.notification_name,
|
||||
|
||||
# Printer routing
|
||||
"printer_ids": all_printer_ids,
|
||||
"printers": [{"id": p.id, "name": p.name} for p in zone.printers],
|
||||
"master_printer_id": zone.master_printer_id,
|
||||
"secondary_printer_ids": secondary_ids,
|
||||
"auto_print": zone.auto_print or 'none',
|
||||
"master_copies": zone.master_copies or 1,
|
||||
"secondary_copies": zone.secondary_copies or 1,
|
||||
|
||||
# Ticket formatting
|
||||
"sort_items_by": zone.sort_items_by or 'order_time',
|
||||
"group_by_category": bool(zone.group_by_category),
|
||||
"category_order": json.loads(zone.category_order) if zone.category_order else [],
|
||||
"print_checkboxes": bool(zone.print_checkboxes),
|
||||
|
||||
# KDS bypass / auto-progression
|
||||
"bypass_pending": bool(zone.bypass_pending),
|
||||
"bypass_kds": bool(zone.bypass_kds),
|
||||
"auto_ready_to_served": bool(zone.auto_ready_to_served),
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_prep_zones(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
zones = db.query(PrepZone).order_by(PrepZone.id).all()
|
||||
return [_zone_out(z) for z in zones]
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_prep_zone(
|
||||
body: PrepZoneCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
zone = PrepZone(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
notification_name=body.notification_name,
|
||||
master_printer_id=body.master_printer_id,
|
||||
auto_print=body.auto_print,
|
||||
master_copies=max(1, body.master_copies),
|
||||
secondary_copies=max(1, body.secondary_copies),
|
||||
print_copies=max(1, body.master_copies), # keep legacy field in sync
|
||||
sort_items_by=body.sort_items_by,
|
||||
group_by_category=1 if body.group_by_category else 0,
|
||||
category_order=json.dumps(body.category_order),
|
||||
print_checkboxes=1 if body.print_checkboxes else 0,
|
||||
bypass_pending=1 if (body.bypass_pending or body.bypass_kds) else 0,
|
||||
bypass_kds=1 if body.bypass_kds else 0,
|
||||
auto_ready_to_served=1 if body.auto_ready_to_served else 0,
|
||||
)
|
||||
if body.printer_ids:
|
||||
printers = db.query(Printer).filter(Printer.id.in_(body.printer_ids)).all()
|
||||
zone.printers = printers
|
||||
db.add(zone)
|
||||
db.commit()
|
||||
db.refresh(zone)
|
||||
return _zone_out(zone)
|
||||
|
||||
|
||||
@router.put("/{zone_id}")
|
||||
def update_prep_zone(
|
||||
zone_id: int,
|
||||
body: PrepZoneUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
zone = db.query(PrepZone).filter(PrepZone.id == zone_id).first()
|
||||
if not zone:
|
||||
raise HTTPException(status_code=404, detail="Prep zone not found")
|
||||
|
||||
if body.name is not None:
|
||||
zone.name = body.name
|
||||
if body.description is not None:
|
||||
zone.description = body.description
|
||||
if body.notification_name is not None:
|
||||
zone.notification_name = body.notification_name
|
||||
|
||||
# Printer routing
|
||||
if body.printer_ids is not None:
|
||||
printers = db.query(Printer).filter(Printer.id.in_(body.printer_ids)).all()
|
||||
zone.printers = printers
|
||||
# master_printer_id: always write if field present in request body (null = clear)
|
||||
if 'master_printer_id' in (body.model_fields_set if hasattr(body, 'model_fields_set') else {}):
|
||||
zone.master_printer_id = body.master_printer_id
|
||||
elif body.master_printer_id is not None:
|
||||
zone.master_printer_id = body.master_printer_id
|
||||
if body.auto_print is not None:
|
||||
zone.auto_print = body.auto_print
|
||||
if body.master_copies is not None:
|
||||
zone.master_copies = max(1, body.master_copies)
|
||||
zone.print_copies = zone.master_copies # keep legacy in sync
|
||||
if body.secondary_copies is not None:
|
||||
zone.secondary_copies = max(1, body.secondary_copies)
|
||||
|
||||
# Ticket formatting
|
||||
if body.sort_items_by is not None:
|
||||
zone.sort_items_by = body.sort_items_by
|
||||
if body.group_by_category is not None:
|
||||
zone.group_by_category = 1 if body.group_by_category else 0
|
||||
if body.category_order is not None:
|
||||
zone.category_order = json.dumps(body.category_order)
|
||||
if body.print_checkboxes is not None:
|
||||
zone.print_checkboxes = 1 if body.print_checkboxes else 0
|
||||
|
||||
# KDS bypass — bypass_kds implies bypass_pending
|
||||
if body.bypass_kds is not None:
|
||||
zone.bypass_kds = 1 if body.bypass_kds else 0
|
||||
if body.bypass_kds:
|
||||
zone.bypass_pending = 1
|
||||
if body.bypass_pending is not None:
|
||||
zone.bypass_pending = 1 if body.bypass_pending else 0
|
||||
if body.auto_ready_to_served is not None:
|
||||
zone.auto_ready_to_served = 1 if body.auto_ready_to_served else 0
|
||||
|
||||
db.commit()
|
||||
db.refresh(zone)
|
||||
return _zone_out(zone)
|
||||
|
||||
|
||||
@router.delete("/{zone_id}")
|
||||
def delete_prep_zone(
|
||||
zone_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
zone = db.query(PrepZone).filter(PrepZone.id == zone_id).first()
|
||||
if not zone:
|
||||
raise HTTPException(status_code=404, detail="Prep zone not found")
|
||||
db.delete(zone)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
684
local_backend/routers/pricing.py
Normal file
684
local_backend/routers/pricing.py
Normal file
@@ -0,0 +1,684 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.pricing import (
|
||||
PriceGroup, PriceModifier, PriceModifierCondition, PriceModifierTarget,
|
||||
Deal, DealCondition, DealTarget,
|
||||
PriceEventLog, WaiterDiscountSettings,
|
||||
)
|
||||
from schemas.pricing import (
|
||||
PriceGroupCreate, PriceGroupUpdate, PriceGroupOut,
|
||||
PriceModifierCreate, PriceModifierUpdate, PriceModifierOut,
|
||||
PriceModifierReorderRequest,
|
||||
DealCreate, DealUpdate, DealOut,
|
||||
WaiterDiscountSettingsIn, WaiterDiscountSettingsOut,
|
||||
PriceEventOut,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRICE GROUPS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/groups", response_model=List[PriceGroupOut])
|
||||
def list_price_groups(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(PriceGroup).order_by(PriceGroup.name).all()
|
||||
|
||||
|
||||
@router.post("/groups", response_model=PriceGroupOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_price_group(
|
||||
body: PriceGroupCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
pg = PriceGroup(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
color=body.color,
|
||||
is_active=int(body.is_active),
|
||||
auto_enable_time=body.auto_enable_time,
|
||||
auto_disable_time=body.auto_disable_time,
|
||||
auto_days=json.dumps(body.auto_days) if body.auto_days is not None else None,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(pg)
|
||||
db.commit()
|
||||
db.refresh(pg)
|
||||
return pg
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}", response_model=PriceGroupOut)
|
||||
def update_price_group(
|
||||
group_id: int,
|
||||
body: PriceGroupUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
|
||||
if not pg:
|
||||
raise HTTPException(status_code=404, detail="Price group not found")
|
||||
pg.name = body.name
|
||||
pg.description = body.description
|
||||
pg.color = body.color
|
||||
pg.is_active = int(body.is_active)
|
||||
pg.auto_enable_time = body.auto_enable_time
|
||||
pg.auto_disable_time = body.auto_disable_time
|
||||
pg.auto_days = json.dumps(body.auto_days) if body.auto_days is not None else None
|
||||
db.commit()
|
||||
db.refresh(pg)
|
||||
return pg
|
||||
|
||||
|
||||
@router.patch("/groups/{group_id}/toggle", response_model=PriceGroupOut)
|
||||
def toggle_price_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
|
||||
if not pg:
|
||||
raise HTTPException(status_code=404, detail="Price group not found")
|
||||
pg.is_active = 0 if pg.is_active else 1
|
||||
db.commit()
|
||||
db.refresh(pg)
|
||||
return pg
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_price_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
|
||||
if not pg:
|
||||
raise HTTPException(status_code=404, detail="Price group not found")
|
||||
db.delete(pg)
|
||||
db.commit()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRICE MODIFIERS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/modifiers", response_model=List[PriceModifierOut])
|
||||
def list_modifiers(
|
||||
scope: Optional[str] = None,
|
||||
item_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
q = db.query(PriceModifier)
|
||||
if scope:
|
||||
q = q.filter(PriceModifier.scope == scope)
|
||||
if item_id is not None:
|
||||
q = q.filter(PriceModifier.item_id == item_id)
|
||||
return q.order_by(PriceModifier.sort_order).all()
|
||||
|
||||
|
||||
@router.get("/modifiers/favorites", response_model=List[PriceModifierOut])
|
||||
def list_favorite_modifiers(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Returns modifiers flagged as favorites for the dashboard quick-toggle panel."""
|
||||
return (
|
||||
db.query(PriceModifier)
|
||||
.filter(PriceModifier.is_favorite == 1)
|
||||
.order_by(PriceModifier.sort_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/modifiers", response_model=PriceModifierOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_modifier(
|
||||
body: PriceModifierCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
m = PriceModifier(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
color=body.color,
|
||||
is_active=int(body.is_active),
|
||||
is_favorite=int(body.is_favorite),
|
||||
allow_stack=int(body.allow_stack),
|
||||
sort_order=body.sort_order,
|
||||
scope=body.scope,
|
||||
item_id=body.item_id,
|
||||
action_type=body.action_type,
|
||||
action_value=body.action_value,
|
||||
round_to=body.round_to,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(m)
|
||||
db.flush()
|
||||
_sync_conditions(m, body.conditions, db, model="modifier")
|
||||
_sync_modifier_targets(m, body.targets, db)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
@router.put("/modifiers/{modifier_id}", response_model=PriceModifierOut)
|
||||
def update_modifier(
|
||||
modifier_id: int,
|
||||
body: PriceModifierUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="Modifier not found")
|
||||
m.name = body.name
|
||||
m.description = body.description
|
||||
m.color = body.color
|
||||
m.is_active = int(body.is_active)
|
||||
m.is_favorite = int(body.is_favorite)
|
||||
m.allow_stack = int(body.allow_stack)
|
||||
m.sort_order = body.sort_order
|
||||
m.scope = body.scope
|
||||
m.item_id = body.item_id
|
||||
m.action_type = body.action_type
|
||||
m.action_value = body.action_value
|
||||
m.round_to = body.round_to
|
||||
m.updated_at = _utcnow()
|
||||
m.updated_by = user.id
|
||||
# Replace conditions and targets
|
||||
for c in list(m.conditions):
|
||||
db.delete(c)
|
||||
for t in list(m.targets):
|
||||
db.delete(t)
|
||||
db.flush()
|
||||
_sync_conditions(m, body.conditions, db, model="modifier")
|
||||
_sync_modifier_targets(m, body.targets, db)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
@router.patch("/modifiers/{modifier_id}/toggle", response_model=PriceModifierOut)
|
||||
def toggle_modifier(
|
||||
modifier_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="Modifier not found")
|
||||
m.is_active = 0 if m.is_active else 1
|
||||
m.updated_at = _utcnow()
|
||||
m.updated_by = user.id
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
@router.patch("/modifiers/reorder")
|
||||
def reorder_modifiers(
|
||||
body: PriceModifierReorderRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
for item in body.items:
|
||||
db.query(PriceModifier).filter(PriceModifier.id == item.id).update(
|
||||
{"sort_order": item.sort_order}
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/modifiers/{modifier_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_modifier(
|
||||
modifier_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="Modifier not found")
|
||||
db.delete(m)
|
||||
db.commit()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEALS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/deals", response_model=List[DealOut])
|
||||
def list_deals(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(Deal).order_by(Deal.sort_order).all()
|
||||
|
||||
|
||||
@router.post("/deals", response_model=DealOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_deal(
|
||||
body: DealCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
d = Deal(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
color=body.color,
|
||||
is_active=int(body.is_active),
|
||||
sort_order=body.sort_order,
|
||||
action_type=body.action_type,
|
||||
action_modifier_id=body.action_modifier_id,
|
||||
action_value=body.action_value,
|
||||
action_free_item_id=body.action_free_item_id,
|
||||
action_free_target_type=body.action_free_target_type,
|
||||
action_free_target_ids=(
|
||||
json.dumps(body.action_free_target_ids)
|
||||
if body.action_free_target_ids is not None else None
|
||||
),
|
||||
action_free_quantity=body.action_free_quantity,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(d)
|
||||
db.flush()
|
||||
_sync_conditions(d, body.conditions, db, model="deal")
|
||||
_sync_deal_targets(d, body.targets, db)
|
||||
db.commit()
|
||||
db.refresh(d)
|
||||
return d
|
||||
|
||||
|
||||
@router.put("/deals/{deal_id}", response_model=DealOut)
|
||||
def update_deal(
|
||||
deal_id: int,
|
||||
body: DealUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
d = db.query(Deal).filter(Deal.id == deal_id).first()
|
||||
if not d:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
d.name = body.name
|
||||
d.description = body.description
|
||||
d.color = body.color
|
||||
d.is_active = int(body.is_active)
|
||||
d.sort_order = body.sort_order
|
||||
d.action_type = body.action_type
|
||||
d.action_modifier_id = body.action_modifier_id
|
||||
d.action_value = body.action_value
|
||||
d.action_free_item_id = body.action_free_item_id
|
||||
d.action_free_target_type = body.action_free_target_type
|
||||
d.action_free_target_ids = (
|
||||
json.dumps(body.action_free_target_ids)
|
||||
if body.action_free_target_ids is not None else None
|
||||
)
|
||||
d.action_free_quantity = body.action_free_quantity
|
||||
for c in list(d.conditions):
|
||||
db.delete(c)
|
||||
for t in list(d.targets):
|
||||
db.delete(t)
|
||||
db.flush()
|
||||
_sync_conditions(d, body.conditions, db, model="deal")
|
||||
_sync_deal_targets(d, body.targets, db)
|
||||
db.commit()
|
||||
db.refresh(d)
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/deals/{deal_id}/toggle", response_model=DealOut)
|
||||
def toggle_deal(
|
||||
deal_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
d = db.query(Deal).filter(Deal.id == deal_id).first()
|
||||
if not d:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
d.is_active = 0 if d.is_active else 1
|
||||
db.commit()
|
||||
db.refresh(d)
|
||||
return d
|
||||
|
||||
|
||||
@router.delete("/deals/{deal_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_deal(
|
||||
deal_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
d = db.query(Deal).filter(Deal.id == deal_id).first()
|
||||
if not d:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
db.delete(d)
|
||||
db.commit()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WAITER DISCOUNT SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/discount-settings/global")
|
||||
def get_global_discount_settings(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
from models.settings import PosSettings
|
||||
keys = [
|
||||
"discounts.enabled",
|
||||
"discounts.max_total_value_workday",
|
||||
"discounts.max_total_value_shift",
|
||||
"discounts.max_items_per_shift",
|
||||
]
|
||||
rows = db.query(PosSettings).filter(PosSettings.key.in_(keys)).all()
|
||||
row_map = {r.key.split(".", 1)[1]: r.value for r in rows}
|
||||
# Coerce types so frontend toggle/number fields work correctly
|
||||
def _coerce(k, v):
|
||||
if v is None:
|
||||
return None
|
||||
if k == "enabled":
|
||||
return v.lower() in ("true", "1", "yes")
|
||||
try:
|
||||
return float(v) if "." in str(v) else int(v)
|
||||
except Exception:
|
||||
return v
|
||||
return {k: _coerce(k, v) for k, v in row_map.items()}
|
||||
|
||||
|
||||
@router.put("/discount-settings/global")
|
||||
def set_global_discount_settings(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
from models.settings import PosSettings
|
||||
allowed = {
|
||||
"enabled", "max_total_value_workday",
|
||||
"max_total_value_shift", "max_items_per_shift",
|
||||
}
|
||||
now = _utcnow().isoformat()
|
||||
for k, v in body.items():
|
||||
if k not in allowed:
|
||||
continue
|
||||
full_key = f"discounts.{k}"
|
||||
row = db.query(PosSettings).filter(PosSettings.key == full_key).first()
|
||||
if row:
|
||||
row.value = str(v)
|
||||
row.updated_at = now
|
||||
else:
|
||||
db.add(PosSettings(key=full_key, value=str(v), updated_at=now))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/discount-settings/me", response_model=WaiterDiscountSettingsOut)
|
||||
def get_my_discount_settings(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
s = db.query(WaiterDiscountSettings).filter(
|
||||
WaiterDiscountSettings.user_id == user.id
|
||||
).first()
|
||||
if not s:
|
||||
return WaiterDiscountSettingsOut(id=0, user_id=user.id, can_apply_discounts=False)
|
||||
return s
|
||||
|
||||
|
||||
@router.get("/discount-settings/{user_id}", response_model=WaiterDiscountSettingsOut)
|
||||
def get_waiter_discount_settings(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
s = db.query(WaiterDiscountSettings).filter(
|
||||
WaiterDiscountSettings.user_id == user_id
|
||||
).first()
|
||||
if not s:
|
||||
# Return defaults (all None = no limits, disabled)
|
||||
return WaiterDiscountSettingsOut(id=0, user_id=user_id, can_apply_discounts=False)
|
||||
return s
|
||||
|
||||
|
||||
@router.put("/discount-settings/{user_id}", response_model=WaiterDiscountSettingsOut)
|
||||
def set_waiter_discount_settings(
|
||||
user_id: int,
|
||||
body: WaiterDiscountSettingsIn,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
s = db.query(WaiterDiscountSettings).filter(
|
||||
WaiterDiscountSettings.user_id == user_id
|
||||
).first()
|
||||
if not s:
|
||||
s = WaiterDiscountSettings(user_id=user_id)
|
||||
db.add(s)
|
||||
s.can_apply_discounts = int(body.can_apply_discounts)
|
||||
s.max_discount_percent = body.max_discount_percent
|
||||
s.max_discount_amount = body.max_discount_amount
|
||||
s.max_total_value_shift = body.max_total_value_shift
|
||||
s.max_total_value_workday = body.max_total_value_workday
|
||||
s.max_items_per_shift = body.max_items_per_shift
|
||||
s.max_items_per_workday = body.max_items_per_workday
|
||||
s.max_items_per_order = body.max_items_per_order
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return s
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRICE EVENT LOG (read-only, for order detail and reports)
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/events/order/{order_id}", response_model=List[PriceEventOut])
|
||||
def get_order_price_events(
|
||||
order_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
events = (
|
||||
db.query(PriceEventLog)
|
||||
.filter(PriceEventLog.order_id == order_id)
|
||||
.order_by(PriceEventLog.applied_at)
|
||||
.all()
|
||||
)
|
||||
result = []
|
||||
for ev in events:
|
||||
out = PriceEventOut.model_validate(ev)
|
||||
if ev.modifier:
|
||||
out.modifier_name = ev.modifier.name
|
||||
if ev.deal:
|
||||
out.deal_name = ev.deal.name
|
||||
if ev.applied_by:
|
||||
out.applied_by_username = ev.applied_by.username
|
||||
result.append(out)
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEAL OFFER ACCEPT / DISMISS (PWA-facing)
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/deals/accept")
|
||||
def accept_deal_offer(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Waiter confirms a deal offer. Logs the acceptance and directly adds free item(s)
|
||||
to the order with deal_id + linked_item_id set for binding.
|
||||
Returns added item IDs so the PWA can refresh.
|
||||
"""
|
||||
from models.pricing import PriceEventLog, Deal
|
||||
from models.order import Order, OrderItem
|
||||
from models.product import Product
|
||||
|
||||
deal_id = body.get("deal_id")
|
||||
order_id = body.get("order_id")
|
||||
# For free_choice: list of product_ids the waiter selected + their option snapshots
|
||||
# Shape: [{ product_id, quantity, selected_options, notes }] or legacy [int, ...]
|
||||
free_items_raw = body.get("free_items", [])
|
||||
# Trigger item — the order_item_id that caused the deal to fire (for linking)
|
||||
trigger_item_id = body.get("trigger_item_id", None)
|
||||
|
||||
deal = db.query(Deal).filter(Deal.id == deal_id).first()
|
||||
if not deal:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
added_item_ids = []
|
||||
|
||||
if deal.action_type in ("free_item", "free_choice"):
|
||||
# Resolve which products to add
|
||||
if deal.action_type == "free_item" and deal.action_free_item_id:
|
||||
items_to_add = [{"product_id": deal.action_free_item_id, "quantity": deal.action_free_quantity,
|
||||
"selected_options": None, "notes": None}]
|
||||
else:
|
||||
# free_choice: caller provides list of chosen products
|
||||
items_to_add = []
|
||||
for entry in free_items_raw:
|
||||
if isinstance(entry, dict):
|
||||
items_to_add.append({
|
||||
"product_id": entry.get("product_id"),
|
||||
"quantity": entry.get("quantity", deal.action_free_quantity),
|
||||
"selected_options": entry.get("selected_options"),
|
||||
"notes": entry.get("notes"),
|
||||
})
|
||||
else:
|
||||
items_to_add.append({"product_id": int(entry), "quantity": deal.action_free_quantity,
|
||||
"selected_options": None, "notes": None})
|
||||
|
||||
for entry in items_to_add:
|
||||
product = db.query(Product).filter(Product.id == entry["product_id"]).first()
|
||||
if not product:
|
||||
continue
|
||||
base_price = product.base_price or 0.0
|
||||
new_item = OrderItem(
|
||||
order_id=order_id,
|
||||
product_id=product.id,
|
||||
added_by=user.id,
|
||||
quantity=entry["quantity"],
|
||||
unit_price=base_price, # full price so the breakdown is readable
|
||||
price_adjustment=-base_price, # negated to bring effective price to 0
|
||||
selected_options=json.dumps(entry["selected_options"]) if entry["selected_options"] else None,
|
||||
notes=entry.get("notes"),
|
||||
deal_id=deal_id,
|
||||
linked_item_id=trigger_item_id,
|
||||
)
|
||||
db.add(new_item)
|
||||
db.flush()
|
||||
added_item_ids.append(new_item.id)
|
||||
|
||||
db.add(PriceEventLog(
|
||||
order_id=order_id,
|
||||
order_item_id=new_item.id,
|
||||
event_type="free_item_added",
|
||||
deal_id=deal_id,
|
||||
price_before=product.base_price or 0.0,
|
||||
price_after=0.0,
|
||||
delta_amount=-(product.base_price or 0.0),
|
||||
applied_by_user_id=user.id,
|
||||
))
|
||||
|
||||
# Log acceptance
|
||||
db.add(PriceEventLog(
|
||||
order_id=order_id,
|
||||
order_item_id=trigger_item_id,
|
||||
event_type="deal_offer_accepted",
|
||||
deal_id=deal_id,
|
||||
applied_by_user_id=user.id,
|
||||
selected_item_ids=json.dumps([e.get("product_id") if isinstance(e, dict) else int(e) for e in free_items_raw]) if free_items_raw else None,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
from services.sse_bus import broadcast_sync
|
||||
broadcast_sync()
|
||||
|
||||
return {
|
||||
"deal_id": deal_id,
|
||||
"action_type": deal.action_type,
|
||||
"added_item_ids": added_item_ids,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/deals/dismiss")
|
||||
def dismiss_deal_offer(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Waiter dismisses a deal offer. Logged so it can be re-triggered later."""
|
||||
from models.pricing import PriceEventLog
|
||||
deal_id = body.get("deal_id")
|
||||
order_id = body.get("order_id")
|
||||
|
||||
db.add(PriceEventLog(
|
||||
order_id=order_id,
|
||||
order_item_id=None,
|
||||
event_type="deal_offer_dismissed",
|
||||
deal_id=deal_id,
|
||||
applied_by_user_id=user.id,
|
||||
))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal helpers
|
||||
# =============================================================================
|
||||
|
||||
def _sync_conditions(parent, conditions_in, db: Session, model: str):
|
||||
"""Write condition rows for a modifier or deal."""
|
||||
for c in conditions_in:
|
||||
if model == "modifier":
|
||||
db.add(PriceModifierCondition(
|
||||
modifier_id=parent.id,
|
||||
condition_type=c.condition_type,
|
||||
params=json.dumps(c.params),
|
||||
))
|
||||
else:
|
||||
db.add(DealCondition(
|
||||
deal_id=parent.id,
|
||||
condition_type=c.condition_type,
|
||||
params=json.dumps(c.params),
|
||||
))
|
||||
|
||||
|
||||
def _sync_modifier_targets(modifier, targets_in, db: Session):
|
||||
for t in targets_in:
|
||||
db.add(PriceModifierTarget(
|
||||
modifier_id=modifier.id,
|
||||
target_type=t.target_type,
|
||||
target_id=t.target_id,
|
||||
target_tag=t.target_tag,
|
||||
target_ids=json.dumps(t.target_ids) if t.target_ids else None,
|
||||
target_tags=json.dumps(t.target_tags) if t.target_tags else None,
|
||||
))
|
||||
|
||||
|
||||
def _sync_deal_targets(deal, targets_in, db: Session):
|
||||
for t in targets_in:
|
||||
db.add(DealTarget(
|
||||
deal_id=deal.id,
|
||||
target_type=t.target_type,
|
||||
target_id=t.target_id,
|
||||
target_tag=t.target_tag,
|
||||
target_ids=json.dumps(t.target_ids) if t.target_ids else None,
|
||||
target_tags=json.dumps(t.target_tags) if t.target_tags else None,
|
||||
))
|
||||
@@ -2,11 +2,13 @@ import os
|
||||
import uuid
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
|
||||
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice, ProductModifierGroup
|
||||
from models.prep_zone import PrepZone
|
||||
from models.order import OrderItem
|
||||
from models.user import User
|
||||
from schemas.product import (
|
||||
@@ -14,9 +16,9 @@ from schemas.product import (
|
||||
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
||||
SubcategoryReorderItem, ParentGeneralReorderItem,
|
||||
PreferenceSetCreate, ProductQuickOptionCreate,
|
||||
CategoryReparentRequest,
|
||||
CategoryReparentRequest, ModifierGroupCreate, ModifierGroupOut,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from routers.deps import get_current_user, require_menu_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
@@ -28,6 +30,25 @@ def _broadcast_products_changed():
|
||||
IMAGE_DIR = "/app/data/product_images"
|
||||
|
||||
|
||||
def _replace_modifier_groups(db, product, groups):
|
||||
"""Recreate modifier groups and return a list of created DB objects (index-aligned)."""
|
||||
for g in product.modifier_groups:
|
||||
db.delete(g)
|
||||
db.flush()
|
||||
created = []
|
||||
for i, g in enumerate(groups):
|
||||
new_g = ProductModifierGroup(
|
||||
product_id=product.id,
|
||||
modifier_type=g.modifier_type,
|
||||
name=g.name,
|
||||
sort_order=i,
|
||||
)
|
||||
db.add(new_g)
|
||||
db.flush()
|
||||
created.append(new_g)
|
||||
return created
|
||||
|
||||
|
||||
def _replace_quick_options(db, product, quick_options):
|
||||
for qo in product.quick_options:
|
||||
db.delete(qo)
|
||||
@@ -45,43 +66,52 @@ def _replace_quick_options(db, product, quick_options):
|
||||
))
|
||||
|
||||
|
||||
def _replace_options(db, product, options):
|
||||
def _replace_options(db, product, options, group_id_map=None):
|
||||
for opt in product.options:
|
||||
db.delete(opt)
|
||||
db.flush()
|
||||
for opt in options:
|
||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||
resolved_group = group_id_map[opt.group_id] if (group_id_map and opt.group_id is not None and opt.group_id < len(group_id_map)) else None
|
||||
db.add(ProductOption(
|
||||
product_id=product.id,
|
||||
name=opt.name,
|
||||
extra_cost=opt.extra_cost,
|
||||
allow_multiple=opt.allow_multiple,
|
||||
multi_select=opt.multi_select,
|
||||
sub_choices=sub_json,
|
||||
is_favorite=opt.is_favorite,
|
||||
favorite_sort_order=opt.favorite_sort_order,
|
||||
is_compact=opt.is_compact,
|
||||
group_id=resolved_group,
|
||||
))
|
||||
|
||||
|
||||
def _replace_ingredients(db, product, ingredients):
|
||||
def _replace_ingredients(db, product, ingredients, group_id_map=None):
|
||||
for ing in product.ingredients:
|
||||
db.delete(ing)
|
||||
db.flush()
|
||||
for ing in ingredients:
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||
resolved_group = group_id_map[ing.group_id] if (group_id_map and ing.group_id is not None and ing.group_id < len(group_id_map)) else None
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group))
|
||||
|
||||
|
||||
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
||||
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate], group_id_map=None):
|
||||
for ps in product.preference_sets:
|
||||
db.delete(ps)
|
||||
db.flush()
|
||||
for ps in sets:
|
||||
shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None
|
||||
resolved_group = group_id_map[ps.group_id] if (group_id_map and ps.group_id is not None and ps.group_id < len(group_id_map)) else None
|
||||
new_set = ProductPreferenceSet(
|
||||
product_id=product.id,
|
||||
name=ps.name,
|
||||
shared_subset=shared_json,
|
||||
is_favorite=ps.is_favorite,
|
||||
favorite_sort_order=ps.favorite_sort_order,
|
||||
group_id=resolved_group,
|
||||
allow_multi_select=ps.allow_multi_select,
|
||||
allow_choice_quantity=ps.allow_choice_quantity,
|
||||
)
|
||||
db.add(new_set)
|
||||
db.flush()
|
||||
@@ -94,6 +124,7 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
||||
extra_cost=ch.extra_cost,
|
||||
sub_choices=sub_json,
|
||||
disables_subset=ch.disables_subset,
|
||||
is_compact=ch.is_compact,
|
||||
)
|
||||
db.add(choice)
|
||||
db.flush()
|
||||
@@ -104,13 +135,27 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
||||
|
||||
# ── Categories ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/tags")
|
||||
def list_all_tags(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Return sorted unique tag strings across all products."""
|
||||
rows = db.query(Product.tags).filter(Product.tags != None, Product.tags != "[]").all()
|
||||
tag_set = set()
|
||||
for (tags_json,) in rows:
|
||||
try:
|
||||
tags = json.loads(tags_json) if tags_json else []
|
||||
tag_set.update(tags)
|
||||
except Exception:
|
||||
pass
|
||||
return sorted(tag_set)
|
||||
|
||||
|
||||
@router.get("/categories", response_model=List[CategoryOut])
|
||||
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return db.query(Category).order_by(Category.sort_order).all()
|
||||
|
||||
|
||||
@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_menu_manager)):
|
||||
# sort_order is among siblings (same parent_id level)
|
||||
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
|
||||
cat = Category(
|
||||
@@ -128,7 +173,7 @@ def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: U
|
||||
|
||||
|
||||
@router.put("/categories/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
if cat:
|
||||
@@ -138,7 +183,7 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
|
||||
|
||||
|
||||
@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)):
|
||||
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_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()
|
||||
@@ -149,7 +194,7 @@ def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Dep
|
||||
|
||||
|
||||
@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)):
|
||||
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_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()
|
||||
@@ -160,7 +205,7 @@ def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}/reparent", response_model=CategoryOut)
|
||||
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
"""Move a category to a new parent (or promote to top-level if parent_id is null).
|
||||
All products assigned to this category follow it automatically (no product updates needed).
|
||||
"""
|
||||
@@ -190,7 +235,7 @@ def reparent_category(category_id: int, body: CategoryReparentRequest, db: Sessi
|
||||
|
||||
|
||||
@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_menu_manager)):
|
||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
@@ -203,7 +248,7 @@ def update_category(category_id: int, body: CategoryUpdate, db: Session = Depend
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
@@ -217,14 +262,31 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
|
||||
@router.get("/", response_model=List[ProductOut])
|
||||
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
q = db.query(Product)
|
||||
if not all or user.role not in ("manager", "sysadmin"):
|
||||
has_dashboard = user.role in ("superadmin", "manager") or getattr(user, "perm_access_dashboard", False)
|
||||
if not all or not has_dashboard:
|
||||
# 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()
|
||||
|
||||
|
||||
class BulkPrepZoneBody(BaseModel):
|
||||
product_ids: List[int]
|
||||
prep_zone_ids: List[int]
|
||||
|
||||
|
||||
@router.post("/bulk-prep-zones", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def bulk_assign_prep_zones(body: BulkPrepZoneBody, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
"""Replace the prep zone assignments for a list of products at once."""
|
||||
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
|
||||
products = db.query(Product).filter(Product.id.in_(body.product_ids)).all()
|
||||
for p in products:
|
||||
p.prep_zones = zones
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
for item in items:
|
||||
product = db.query(Product).filter(Product.id == item.id).first()
|
||||
if product:
|
||||
@@ -234,15 +296,20 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
|
||||
|
||||
|
||||
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"})
|
||||
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags"})
|
||||
if data.get("sort_order") == 0:
|
||||
data["sort_order"] = db.query(Product).count()
|
||||
if body.cost_breakdown is not None:
|
||||
data["cost_breakdown"] = json.dumps([item.model_dump() for item in body.cost_breakdown])
|
||||
data["tags"] = json.dumps(body.tags) if body.tags is not None else None
|
||||
product = Product(**data)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
# Assign prep zones
|
||||
if body.prep_zone_ids:
|
||||
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
|
||||
product.prep_zones = zones
|
||||
for i, qo in enumerate(body.quick_options):
|
||||
db.add(ProductQuickOption(
|
||||
product_id=product.id,
|
||||
@@ -254,20 +321,27 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
|
||||
favorite_sort_order=qo.favorite_sort_order,
|
||||
is_compact=qo.is_compact,
|
||||
))
|
||||
created_groups = _replace_modifier_groups(db, product, body.modifier_groups)
|
||||
group_id_map = [g.id for g in created_groups]
|
||||
for opt in body.options:
|
||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||
resolved_group = group_id_map[opt.group_id] if (opt.group_id is not None and opt.group_id < len(group_id_map)) else None
|
||||
db.add(ProductOption(
|
||||
product_id=product.id,
|
||||
name=opt.name,
|
||||
extra_cost=opt.extra_cost,
|
||||
allow_multiple=opt.allow_multiple,
|
||||
multi_select=opt.multi_select,
|
||||
sub_choices=sub_json,
|
||||
is_favorite=opt.is_favorite,
|
||||
favorite_sort_order=opt.favorite_sort_order,
|
||||
is_compact=opt.is_compact,
|
||||
group_id=resolved_group,
|
||||
))
|
||||
for ing in body.ingredients:
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
resolved_group = group_id_map[ing.group_id] if (ing.group_id is not None and ing.group_id < len(group_id_map)) else None
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group))
|
||||
_replace_preference_sets(db, product, body.preference_sets, group_id_map)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
_broadcast_products_changed()
|
||||
@@ -275,30 +349,46 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
|
||||
|
||||
|
||||
@router.put("/{product_id}", response_model=ProductOut)
|
||||
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
scalar_fields = body.model_dump(
|
||||
exclude_none=True,
|
||||
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"},
|
||||
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags", "modifier_groups"},
|
||||
)
|
||||
for field, value in scalar_fields.items():
|
||||
setattr(product, field, value)
|
||||
# Always clear legacy per-product printer — routing is now via prep zones only
|
||||
product.printer_zone_id = None
|
||||
# Update prep zones if provided
|
||||
if body.prep_zone_ids is not None:
|
||||
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
|
||||
product.prep_zones = zones
|
||||
# cost_breakdown is a list of objects — serialize to JSON for storage
|
||||
if body.cost_breakdown is not None:
|
||||
product.cost_breakdown = json.dumps([item.model_dump() for item in body.cost_breakdown])
|
||||
elif "cost_breakdown" in body.model_fields_set:
|
||||
# explicitly set to null — clear it
|
||||
product.cost_breakdown = None
|
||||
# tags is a list of strings — serialize to JSON for storage
|
||||
if body.tags is not None:
|
||||
product.tags = json.dumps(body.tags)
|
||||
elif "tags" in body.model_fields_set:
|
||||
product.tags = None
|
||||
if body.quick_options is not None:
|
||||
_replace_quick_options(db, product, body.quick_options)
|
||||
# Modifier groups must be recreated before items so we have the ID map
|
||||
group_id_map = None
|
||||
if body.modifier_groups is not None:
|
||||
created_groups = _replace_modifier_groups(db, product, body.modifier_groups)
|
||||
group_id_map = [g.id for g in created_groups]
|
||||
if body.options is not None:
|
||||
_replace_options(db, product, body.options)
|
||||
_replace_options(db, product, body.options, group_id_map)
|
||||
if body.ingredients is not None:
|
||||
_replace_ingredients(db, product, body.ingredients)
|
||||
_replace_ingredients(db, product, body.ingredients, group_id_map)
|
||||
if body.preference_sets is not None:
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
_replace_preference_sets(db, product, body.preference_sets, group_id_map)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
_broadcast_products_changed()
|
||||
@@ -306,7 +396,7 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
|
||||
|
||||
|
||||
@router.post("/{product_id}/image", response_model=ProductOut)
|
||||
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
@@ -336,8 +426,55 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
|
||||
return product
|
||||
|
||||
|
||||
# ── Modifier Groups ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{product_id}/modifier-groups", response_model=List[ModifierGroupOut])
|
||||
def list_modifier_groups(product_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return db.query(ProductModifierGroup).filter(ProductModifierGroup.product_id == product_id).order_by(ProductModifierGroup.sort_order).all()
|
||||
|
||||
|
||||
@router.post("/{product_id}/modifier-groups", response_model=ModifierGroupOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_modifier_group(product_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
g = ProductModifierGroup(product_id=product_id, modifier_type=body.modifier_type, name=body.name, sort_order=body.sort_order)
|
||||
db.add(g)
|
||||
db.commit()
|
||||
db.refresh(g)
|
||||
_broadcast_products_changed()
|
||||
return g
|
||||
|
||||
|
||||
@router.put("/{product_id}/modifier-groups/{group_id}", response_model=ModifierGroupOut)
|
||||
def update_modifier_group(product_id: int, group_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first()
|
||||
if not g:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
g.name = body.name
|
||||
g.sort_order = body.sort_order
|
||||
db.commit()
|
||||
db.refresh(g)
|
||||
_broadcast_products_changed()
|
||||
return g
|
||||
|
||||
|
||||
@router.delete("/{product_id}/modifier-groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_modifier_group(product_id: int, group_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first()
|
||||
if not g:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
# Un-group all items in this group before deleting
|
||||
db.query(ProductOption).filter(ProductOption.group_id == group_id).update({"group_id": None})
|
||||
db.query(ProductIngredient).filter(ProductIngredient.group_id == group_id).update({"group_id": None})
|
||||
db.query(ProductPreferenceSet).filter(ProductPreferenceSet.group_id == group_id).update({"group_id": None})
|
||||
db.delete(g)
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
113
local_backend/routers/recovery.py
Normal file
113
local_backend/routers/recovery.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import secrets
|
||||
import bcrypt
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.recovery_code import RecoveryCode
|
||||
from schemas.user import UserOut
|
||||
from schemas.auth import TokenResponse
|
||||
from routers.deps import get_current_user, make_token
|
||||
|
||||
router = APIRouter()
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
CODES_PER_BATCH = 5
|
||||
|
||||
|
||||
def _generate_code() -> str:
|
||||
"""Return a human-readable code like XENIA-A3K9-MW2F."""
|
||||
part = lambda: secrets.token_hex(2).upper()
|
||||
return f"XENIA-{part()}-{part()}"
|
||||
|
||||
|
||||
def _hash_code(plain: str) -> str:
|
||||
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def _verify_code(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def try_recovery_code(plain: str, user: "User", db: "Session") -> bool:
|
||||
"""Try to consume a recovery code for user. Returns True and burns the code if matched."""
|
||||
unused = db.query(RecoveryCode).filter(
|
||||
RecoveryCode.user_id == user.id,
|
||||
RecoveryCode.used_at.is_(None),
|
||||
).all()
|
||||
matched = next((rc for rc in unused if _verify_code(plain, rc.code_hash)), None)
|
||||
if not matched:
|
||||
return False
|
||||
matched.used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
_logger.warning("RECOVERY CODE USED for user_id=%d username=%s code_id=%d", user.id, user.username, matched.id)
|
||||
return True
|
||||
|
||||
|
||||
# ─── Public: use a recovery code to log in (kept for direct API use) ─────────
|
||||
|
||||
class UseRecoveryCodeRequest(BaseModel):
|
||||
username: str
|
||||
code: str
|
||||
|
||||
|
||||
@router.post("/use", response_model=TokenResponse)
|
||||
def use_recovery_code(body: UseRecoveryCodeRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(
|
||||
User.username == body.username,
|
||||
User.is_active == True,
|
||||
).first()
|
||||
if not user or not try_recovery_code(body.code, user, db):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = make_token(user)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
# ─── Authenticated: generate a new batch (burns all existing unused codes) ───
|
||||
|
||||
class RecoveryCodesGenerated(BaseModel):
|
||||
codes: list[str]
|
||||
remaining_after: int
|
||||
|
||||
|
||||
@router.post("/generate", response_model=RecoveryCodesGenerated)
|
||||
def generate_recovery_codes(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Burn all existing unused codes for this user
|
||||
db.query(RecoveryCode).filter(
|
||||
RecoveryCode.user_id == current_user.id,
|
||||
RecoveryCode.used_at.is_(None),
|
||||
).delete(synchronize_session=False)
|
||||
db.flush()
|
||||
|
||||
plain_codes = [_generate_code() for _ in range(CODES_PER_BATCH)]
|
||||
for plain in plain_codes:
|
||||
db.add(RecoveryCode(user_id=current_user.id, code_hash=_hash_code(plain)))
|
||||
|
||||
db.commit()
|
||||
_logger.info("Recovery codes regenerated for user_id=%d", current_user.id)
|
||||
return RecoveryCodesGenerated(codes=plain_codes, remaining_after=CODES_PER_BATCH)
|
||||
|
||||
|
||||
# ─── Authenticated: check how many unused codes remain ───────────────────────
|
||||
|
||||
class RecoveryCodeStatus(BaseModel):
|
||||
unused_count: int
|
||||
|
||||
|
||||
@router.get("/status", response_model=RecoveryCodeStatus)
|
||||
def recovery_code_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
count = db.query(RecoveryCode).filter(
|
||||
RecoveryCode.user_id == current_user.id,
|
||||
RecoveryCode.used_at == None,
|
||||
).count()
|
||||
return RecoveryCodeStatus(unused_count=count)
|
||||
@@ -13,6 +13,7 @@ from typing import Optional, List
|
||||
|
||||
from database import get_db
|
||||
from models.order import Order, OrderItem, OrderWaiter, PrintLog
|
||||
from models.prep_zone import PrepZone
|
||||
from models.user import User
|
||||
from models.table import Table
|
||||
from models.printer import Printer
|
||||
@@ -20,15 +21,21 @@ from models.shift import WaiterShift
|
||||
from models.business_day import BusinessDay
|
||||
from schemas.order import OrderOut
|
||||
from schemas.table import TableOut
|
||||
from routers.deps import require_manager
|
||||
from routers.deps import require_reports
|
||||
from services.printer_service import (
|
||||
print_waiter_report, print_printer_report, print_order_receipt, load_divider_style,
|
||||
print_products_report, print_categories_report, print_tables_report,
|
||||
print_prep_zone_summary,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _item_val(item) -> float:
|
||||
"""Effective line value: unit_price + any price adjustment, times quantity."""
|
||||
return ((item.unit_price or 0.0) + (item.price_adjustment or 0.0)) * item.quantity
|
||||
|
||||
|
||||
def _dt(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
@@ -42,7 +49,7 @@ def shift_summary(
|
||||
report_date: Optional[date] = Query(default=None, alias="date"),
|
||||
waiter_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
"""Payments collected per waiter — based on paid_by on order items."""
|
||||
if from_dt and to_dt:
|
||||
@@ -80,7 +87,7 @@ def shift_summary(
|
||||
"order_data": {},
|
||||
}
|
||||
summary[wid]["items"] += item.quantity
|
||||
val = item.unit_price * item.quantity
|
||||
val = _item_val(item)
|
||||
summary[wid]["total"] += val
|
||||
|
||||
oid = item.order_id
|
||||
@@ -117,7 +124,7 @@ def shift_orders_summary(
|
||||
waiter_id: Optional[int] = None,
|
||||
business_day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
"""Items sent (added) per waiter — regardless of payment status."""
|
||||
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
|
||||
@@ -181,7 +188,7 @@ def shift_orders_summary(
|
||||
"order_data": {},
|
||||
}
|
||||
summary[wid]["items"] += item.quantity
|
||||
val = item.unit_price * item.quantity
|
||||
val = _item_val(item)
|
||||
summary[wid]["total"] += val
|
||||
|
||||
oid = item.order_id
|
||||
@@ -237,7 +244,7 @@ def order_history(
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from sqlalchemy.orm import joinedload
|
||||
from models.table import Table as TableModel
|
||||
@@ -318,6 +325,7 @@ def order_history(
|
||||
"added_at": _dt_local(item.added_at),
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"price_adjustment": float(item.price_adjustment or 0.0),
|
||||
"status": item.status,
|
||||
"paid_by": item.paid_by,
|
||||
"paid_by_name": _wname(item.paid_by),
|
||||
@@ -366,7 +374,7 @@ def order_history(
|
||||
|
||||
|
||||
@router.get("/tables/summary")
|
||||
def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_reports)):
|
||||
tables = db.query(Table).filter(Table.is_active == True).all()
|
||||
result = []
|
||||
for table in tables:
|
||||
@@ -387,7 +395,7 @@ def printer_totals(
|
||||
from_date: Optional[str] = Query(default=None, alias="from"),
|
||||
to_date: Optional[str] = Query(default=None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
"""Returns totals per printer based on print_log entries in the date range."""
|
||||
q = db.query(PrintLog).filter(PrintLog.success == True)
|
||||
@@ -437,11 +445,13 @@ def printer_totals(
|
||||
item_ids = json.loads(log.item_ids)
|
||||
except Exception:
|
||||
item_ids = []
|
||||
if item_ids is None:
|
||||
item_ids = []
|
||||
for item_id in item_ids:
|
||||
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
|
||||
if item and item.status in ("active", "paid"):
|
||||
summary[pid]["items"] += item.quantity
|
||||
val = item.unit_price * item.quantity
|
||||
val = _item_val(item)
|
||||
summary[pid]["total"] += val
|
||||
order_map[pid][oid]["total"] += val
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
@@ -510,7 +520,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m
|
||||
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
|
||||
if item and item.status in ("active", "paid"):
|
||||
items_count += item.quantity
|
||||
val = item.unit_price * item.quantity
|
||||
val = _item_val(item)
|
||||
grand_total += val
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
if oid in order_map:
|
||||
@@ -518,7 +528,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m
|
||||
order_map[oid]["items"].append({
|
||||
"name": product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"unit_price": float((item.unit_price or 0.0) + (item.price_adjustment or 0.0)),
|
||||
"total": round(val, 2),
|
||||
})
|
||||
# Accumulate item breakdown (always, regardless of mode)
|
||||
@@ -551,7 +561,7 @@ def print_waiter(
|
||||
body: PrintWaiterReportBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||
if not printer:
|
||||
@@ -577,7 +587,7 @@ def print_waiter(
|
||||
order_data = []
|
||||
for o in orders:
|
||||
active_items = [i for i in o.items if i.status in ("active", "paid")]
|
||||
total = sum(i.unit_price * i.quantity for i in active_items)
|
||||
total = sum(_item_val(i) for i in active_items)
|
||||
order_data.append({
|
||||
"id": o.id,
|
||||
"time_open": local_strftime(o.opened_at, "%H:%M"),
|
||||
@@ -605,7 +615,7 @@ def print_waiter(
|
||||
"to_dt": local_strftime(to_dt, "%d/%m/%Y %H:%M"),
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode)
|
||||
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@@ -614,7 +624,7 @@ def print_printer_totals(
|
||||
body: PrintPrinterReportBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
# The physical printer that will receive the paper
|
||||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||
@@ -674,7 +684,7 @@ def print_printer_totals(
|
||||
"div_style": load_divider_style(db),
|
||||
}
|
||||
|
||||
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, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@@ -710,7 +720,7 @@ def print_products(
|
||||
body: PrintAnalyticsBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.product import Product, Category
|
||||
|
||||
@@ -737,7 +747,7 @@ def print_products(
|
||||
if pid not in sold:
|
||||
sold[pid] = {"qty": 0, "revenue": 0.0}
|
||||
sold[pid]["qty"] += item.quantity
|
||||
sold[pid]["revenue"] += item.unit_price * item.quantity
|
||||
sold[pid]["revenue"] += _item_val(item)
|
||||
|
||||
if body.mode == "full":
|
||||
# All active products, 0-sold included
|
||||
@@ -762,7 +772,7 @@ def print_products(
|
||||
"line_width": printer.line_width,
|
||||
"div_style": load_divider_style(db),
|
||||
}
|
||||
background_tasks.add_task(print_products_report, printer.ip_address, printer.port, report)
|
||||
background_tasks.add_task(print_products_report, printer.ip_address, printer.port, report, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@@ -771,7 +781,7 @@ def print_categories(
|
||||
body: PrintAnalyticsBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.product import Product, Category
|
||||
|
||||
@@ -805,11 +815,11 @@ def print_categories(
|
||||
cat = categories_db.get(cid)
|
||||
summary[cid] = {"name": cat.name if cat else f"#{cid}", "units_sold": 0, "revenue": 0.0, "products": {}}
|
||||
summary[cid]["units_sold"] += item.quantity
|
||||
summary[cid]["revenue"] += item.unit_price * item.quantity
|
||||
summary[cid]["revenue"] += _item_val(item)
|
||||
if pid not in summary[cid]["products"]:
|
||||
summary[cid]["products"][pid] = {"name": product.name, "qty": 0, "revenue": 0.0}
|
||||
summary[cid]["products"][pid]["qty"] += item.quantity
|
||||
summary[cid]["products"][pid]["revenue"] += item.unit_price * item.quantity
|
||||
summary[cid]["products"][pid]["revenue"] += _item_val(item)
|
||||
|
||||
total_rev = sum(v["revenue"] for v in summary.values())
|
||||
total_qty = sum(v["units_sold"] for v in summary.values())
|
||||
@@ -841,7 +851,7 @@ def print_categories(
|
||||
"line_width": printer.line_width,
|
||||
"div_style": load_divider_style(db),
|
||||
}
|
||||
background_tasks.add_task(print_categories_report, printer.ip_address, printer.port, report)
|
||||
background_tasks.add_task(print_categories_report, printer.ip_address, printer.port, report, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@@ -850,7 +860,7 @@ def print_tables(
|
||||
body: PrintAnalyticsBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||
if not printer:
|
||||
@@ -879,7 +889,7 @@ def print_tables(
|
||||
"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"))
|
||||
summary[tid]["revenue"] += sum(_item_val(i) 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)
|
||||
|
||||
@@ -899,7 +909,7 @@ def print_tables(
|
||||
"line_width": printer.line_width,
|
||||
"div_style": load_divider_style(db),
|
||||
}
|
||||
background_tasks.add_task(print_tables_report, printer.ip_address, printer.port, report)
|
||||
background_tasks.add_task(print_tables_report, printer.ip_address, printer.port, report, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@@ -915,7 +925,7 @@ def shifts_report(
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
active_only: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from routers.shifts import _enrich_shift
|
||||
|
||||
@@ -946,7 +956,7 @@ def product_performance(
|
||||
business_day_id: Optional[int] = None,
|
||||
category_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.product import Product
|
||||
from models.waste import WasteLog
|
||||
@@ -1000,7 +1010,7 @@ def product_performance(
|
||||
"order_ids": set(),
|
||||
}
|
||||
qty = item.quantity
|
||||
revenue = item.unit_price * qty
|
||||
revenue = _item_val(item)
|
||||
summary[pid]["qty_sold"] += qty
|
||||
summary[pid]["revenue"] += revenue
|
||||
summary[pid]["order_ids"].add(item.order_id)
|
||||
@@ -1054,7 +1064,7 @@ def table_performance(
|
||||
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),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
|
||||
if from_dt:
|
||||
@@ -1081,7 +1091,7 @@ def table_performance(
|
||||
}
|
||||
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")
|
||||
_item_val(i) for i in order.items if i.status in ("active", "paid")
|
||||
)
|
||||
if order.closed_at and order.opened_at:
|
||||
summary[tid]["durations"].append(
|
||||
@@ -1109,7 +1119,7 @@ def traffic_analysis(
|
||||
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),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(Order)
|
||||
if from_dt:
|
||||
@@ -1126,7 +1136,7 @@ def traffic_analysis(
|
||||
|
||||
for order in orders:
|
||||
revenue = sum(
|
||||
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
|
||||
_item_val(i) for i in order.items if i.status in ("active", "paid")
|
||||
)
|
||||
h = order.opened_at.hour
|
||||
d = order.opened_at.weekday()
|
||||
@@ -1155,7 +1165,7 @@ def business_days_list(
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(BusinessDay)
|
||||
if from_dt:
|
||||
@@ -1180,7 +1190,7 @@ def business_days_list(
|
||||
for i in o.items:
|
||||
if i.status not in ("active", "paid"):
|
||||
continue
|
||||
rev = i.unit_price * i.quantity
|
||||
rev = _item_val(i)
|
||||
revenue += rev
|
||||
if i.unit_cost is not None:
|
||||
cost = i.unit_cost * i.quantity
|
||||
@@ -1214,7 +1224,7 @@ def business_days_list(
|
||||
@router.get("/business-days/current")
|
||||
def current_business_day(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
day = db.query(BusinessDay).filter(BusinessDay.status == "open").order_by(BusinessDay.opened_at.desc()).first()
|
||||
if not day:
|
||||
@@ -1236,7 +1246,7 @@ def current_business_day(
|
||||
for i in o.items:
|
||||
if i.status not in ("active", "paid"):
|
||||
continue
|
||||
rev = i.unit_price * i.quantity
|
||||
rev = _item_val(i)
|
||||
revenue += rev
|
||||
if i.unit_cost is not None:
|
||||
cost = i.unit_cost * i.quantity
|
||||
@@ -1304,7 +1314,7 @@ def revenue_trends(
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
granularity: str = Query(default="daily"), # daily | weekly | monthly
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
|
||||
if from_dt:
|
||||
@@ -1329,7 +1339,7 @@ def revenue_trends(
|
||||
for i in order.items:
|
||||
if i.status not in ("active", "paid"):
|
||||
continue
|
||||
rev = i.unit_price * i.quantity
|
||||
rev = _item_val(i)
|
||||
buckets[key]["revenue"] += rev
|
||||
if i.unit_cost is not None:
|
||||
buckets[key]["profit"] += rev - i.unit_cost * i.quantity
|
||||
@@ -1360,7 +1370,7 @@ def category_performance(
|
||||
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),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.product import Product, Category
|
||||
|
||||
@@ -1397,7 +1407,7 @@ def category_performance(
|
||||
"product_ids": set(),
|
||||
}
|
||||
qty = item.quantity
|
||||
rev = item.unit_price * qty
|
||||
rev = _item_val(item)
|
||||
summary[cid]["units_sold"] += qty
|
||||
summary[cid]["revenue"] += rev
|
||||
summary[cid]["product_ids"].add(item.product_id)
|
||||
@@ -1438,7 +1448,7 @@ def cancellations_log(
|
||||
business_day_id: Optional[int] = None,
|
||||
waiter_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
|
||||
|
||||
@@ -1495,7 +1505,7 @@ def cancellations_log(
|
||||
"product_name": product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": item.unit_price,
|
||||
"value": round(item.unit_price * item.quantity, 2),
|
||||
"value": round(_item_val(item), 2),
|
||||
"cancelled_by": cancelled_by_name,
|
||||
"cancel_reason": getattr(item, "cancel_reason", None),
|
||||
"cancelled_at": _dt(cancelled_at) if cancelled_at else _dt(item.added_at),
|
||||
@@ -1516,7 +1526,7 @@ def printer_history(
|
||||
business_day_id: Optional[int] = None,
|
||||
printer_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(PrintLog)
|
||||
if from_dt:
|
||||
@@ -1549,6 +1559,8 @@ def printer_history(
|
||||
item_ids = json.loads(log.item_ids)
|
||||
except Exception:
|
||||
item_ids = []
|
||||
if item_ids is None:
|
||||
item_ids = []
|
||||
items = []
|
||||
for iid in item_ids:
|
||||
oi = db.query(OrderItem).filter(OrderItem.id == iid).first()
|
||||
@@ -1560,7 +1572,7 @@ def printer_history(
|
||||
order_total = None
|
||||
if order:
|
||||
order_total = sum(
|
||||
i.unit_price * i.quantity
|
||||
_item_val(i)
|
||||
for i in order.items
|
||||
if i.status in ("active", "paid")
|
||||
)
|
||||
@@ -1593,16 +1605,16 @@ def printer_history(
|
||||
@router.get("/meta/waiters")
|
||||
def meta_waiters(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
|
||||
waiters = db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()
|
||||
return {"waiters": [{"id": w.id, "name": w.full_name or w.username} for w in waiters]}
|
||||
|
||||
|
||||
@router.get("/meta/tables")
|
||||
def meta_tables(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
tables = db.query(Table).filter(Table.is_active == True).all()
|
||||
return {"tables": [{"id": t.id, "name": t.label or f"T{t.number}", "group": t.group.name if t.group else None} for t in tables]}
|
||||
@@ -1611,7 +1623,7 @@ def meta_tables(
|
||||
@router.get("/meta/printers")
|
||||
def meta_printers(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
printers = db.query(Printer).filter(Printer.is_active == True).all()
|
||||
return {"printers": [{"id": p.id, "name": p.name} for p in printers]}
|
||||
@@ -1620,7 +1632,7 @@ def meta_printers(
|
||||
@router.get("/meta/products")
|
||||
def meta_products(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.product import Product, Category
|
||||
products = db.query(Product).filter(Product.lifecycle_status == "active").order_by(Product.name).all()
|
||||
@@ -1665,7 +1677,7 @@ def shifts_export(
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
data = shifts_report(waiter_id=waiter_id, business_day_id=business_day_id, from_dt=from_dt, to_dt=to_dt, active_only=False, db=db, user=user)
|
||||
rows = []
|
||||
@@ -1697,7 +1709,7 @@ def orders_export(
|
||||
order_status: Optional[str] = Query(default=None, alias="status"),
|
||||
table_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
orders = order_history(from_date=from_date, to_date=to_date, waiter_id=waiter_id, order_status=order_status, table_id=table_id, page=1, page_size=10000, db=db, user=user)
|
||||
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
@@ -1712,7 +1724,7 @@ def orders_export(
|
||||
"opened_at": _dt(o.opened_at),
|
||||
"closed_at": _dt(o.closed_at) if o.closed_at else "",
|
||||
"status": o.status,
|
||||
"total": round(sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid")), 2),
|
||||
"total": round(sum(_item_val(i) for i in o.items if i.status in ("active", "paid")), 2),
|
||||
})
|
||||
date_str = (from_date or "")[:10]
|
||||
return _csv_response(rows, f"orders-{date_str}.csv")
|
||||
@@ -1725,7 +1737,7 @@ def products_export(
|
||||
business_day_id: Optional[int] = None,
|
||||
category_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
data = product_performance(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, category_id=category_id, db=db, user=user)
|
||||
rows = [{
|
||||
@@ -1747,7 +1759,7 @@ def printers_export(
|
||||
printer_id: Optional[int] = None,
|
||||
business_day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
data = printer_history(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, printer_id=printer_id, db=db, user=user)
|
||||
rows = [{
|
||||
@@ -1770,7 +1782,7 @@ def cancellations_export(
|
||||
business_day_id: Optional[int] = None,
|
||||
waiter_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
data = cancellations_log(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, waiter_id=waiter_id, db=db, user=user)
|
||||
rows = [{
|
||||
@@ -1789,6 +1801,192 @@ def cancellations_export(
|
||||
return _csv_response(rows, f"cancellations-{date_str}.csv")
|
||||
|
||||
|
||||
# ── Prep Zones report ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/prep-zones")
|
||||
def prep_zones_report(
|
||||
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_reports),
|
||||
):
|
||||
"""
|
||||
Per-prep-zone stats: item totals, full product list, and full order list
|
||||
for the requested date range / business day.
|
||||
"""
|
||||
zones = db.query(PrepZone).order_by(PrepZone.id).all()
|
||||
|
||||
# Build product → zones mapping
|
||||
product_zone_map: dict[int, list[int]] = {}
|
||||
for zone in zones:
|
||||
for product in zone.products:
|
||||
if product.id not in product_zone_map:
|
||||
product_zone_map[product.id] = []
|
||||
product_zone_map[product.id].append(zone.id)
|
||||
|
||||
# Query order items in the requested range
|
||||
q = db.query(OrderItem).join(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)
|
||||
q = q.filter(OrderItem.status.in_(["active", "paid"]))
|
||||
items = q.all()
|
||||
|
||||
# Aggregate per zone
|
||||
zone_stats: dict[int, dict] = {
|
||||
zone.id: {
|
||||
"id": zone.id,
|
||||
"name": zone.name,
|
||||
"notification_name": zone.notification_name,
|
||||
"printers": [{"id": p.id, "name": p.name} for p in zone.printers],
|
||||
"item_count": 0,
|
||||
"total_value": 0.0,
|
||||
"product_counts": {}, # product_name → {count, value}
|
||||
"orders_map": {}, # order_id → {order_id, table, opened_at, items_count, value}
|
||||
}
|
||||
for zone in zones
|
||||
}
|
||||
|
||||
for item in items:
|
||||
zids = product_zone_map.get(item.product_id, [])
|
||||
pname = item.product.name if item.product else f"#{item.product_id}"
|
||||
ivalue = round(_item_val(item), 2)
|
||||
order = item.order
|
||||
table_label = None
|
||||
if order and order.table:
|
||||
t = order.table
|
||||
table_label = t.label or str(t.number)
|
||||
for zid in zids:
|
||||
if zid not in zone_stats:
|
||||
continue
|
||||
zs = zone_stats[zid]
|
||||
zs["item_count"] += item.quantity
|
||||
zs["total_value"] += ivalue
|
||||
if pname not in zs["product_counts"]:
|
||||
zs["product_counts"][pname] = {"count": 0, "value": 0.0}
|
||||
zs["product_counts"][pname]["count"] += item.quantity
|
||||
zs["product_counts"][pname]["value"] += ivalue
|
||||
if order:
|
||||
oid = order.id
|
||||
if oid not in zs["orders_map"]:
|
||||
zs["orders_map"][oid] = {
|
||||
"order_id": oid,
|
||||
"table": table_label or f"#{oid}",
|
||||
"opened_at": order.opened_at.isoformat() if order.opened_at else None,
|
||||
"items_count": 0,
|
||||
"value": 0.0,
|
||||
}
|
||||
zs["orders_map"][oid]["items_count"] += item.quantity
|
||||
zs["orders_map"][oid]["value"] += ivalue
|
||||
|
||||
result = []
|
||||
for zs in zone_stats.values():
|
||||
all_products = sorted(
|
||||
[
|
||||
{"name": k, "count": v["count"], "value": round(v["value"], 2)}
|
||||
for k, v in zs["product_counts"].items()
|
||||
],
|
||||
key=lambda x: -x["count"],
|
||||
)
|
||||
all_orders = sorted(
|
||||
[
|
||||
{**o, "value": round(o["value"], 2)}
|
||||
for o in zs["orders_map"].values()
|
||||
],
|
||||
key=lambda x: x["opened_at"] or "",
|
||||
)
|
||||
result.append({
|
||||
"id": zs["id"],
|
||||
"name": zs["name"],
|
||||
"notification_name": zs["notification_name"],
|
||||
"printers": zs["printers"],
|
||||
"item_count": zs["item_count"],
|
||||
"total_value": round(zs["total_value"], 2),
|
||||
"all_products": all_products,
|
||||
"all_orders": all_orders,
|
||||
})
|
||||
|
||||
return {"zones": result}
|
||||
|
||||
|
||||
class PrintPrepZoneBody(BaseModel):
|
||||
printer_id: int
|
||||
zone_id: int
|
||||
from_dt: Optional[str] = None
|
||||
to_dt: Optional[str] = None
|
||||
business_day_id: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/print/prep-zone")
|
||||
def print_prep_zone(
|
||||
body: PrintPrepZoneBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
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")
|
||||
|
||||
zone = db.query(PrepZone).filter(PrepZone.id == body.zone_id).first()
|
||||
if not zone:
|
||||
raise HTTPException(status_code=404, detail="Prep zone not found")
|
||||
|
||||
# Resolve period label
|
||||
if body.business_day_id:
|
||||
from models.business_day import BusinessDay
|
||||
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
|
||||
period_label = bd.opened_at.strftime("%d/%m/%Y") if bd and bd.opened_at else f"#{body.business_day_id}"
|
||||
elif body.from_dt and body.to_dt:
|
||||
d1 = datetime.fromisoformat(body.from_dt).strftime("%d/%m/%Y")
|
||||
d2 = datetime.fromisoformat(body.to_dt).strftime("%d/%m/%Y")
|
||||
period_label = f"{d1} - {d2}"
|
||||
else:
|
||||
period_label = "Όλες"
|
||||
|
||||
# Build product→zone mapping for this zone
|
||||
product_ids = {product.id for product in zone.products}
|
||||
|
||||
q = db.query(OrderItem).join(Order)
|
||||
if body.from_dt:
|
||||
q = q.filter(Order.opened_at >= datetime.fromisoformat(body.from_dt))
|
||||
if body.to_dt:
|
||||
q = q.filter(Order.opened_at <= datetime.fromisoformat(body.to_dt))
|
||||
if body.business_day_id:
|
||||
q = q.filter(Order.business_day_id == body.business_day_id)
|
||||
q = q.filter(OrderItem.status.in_(["active", "paid"]))
|
||||
items = q.all()
|
||||
|
||||
product_counts: dict[str, dict] = {}
|
||||
for item in items:
|
||||
if item.product_id not in product_ids:
|
||||
continue
|
||||
pname = item.product.name if item.product else f"#{item.product_id}"
|
||||
if pname not in product_counts:
|
||||
product_counts[pname] = {"count": 0, "value": 0.0}
|
||||
product_counts[pname]["count"] += item.quantity
|
||||
product_counts[pname]["value"] += _item_val(item)
|
||||
|
||||
report_items = sorted(
|
||||
[{"name": k, "count": v["count"], "value": round(v["value"], 2)} for k, v in product_counts.items()],
|
||||
key=lambda x: -x["count"],
|
||||
)
|
||||
|
||||
report = {
|
||||
"zone_name": zone.name,
|
||||
"period_label": period_label,
|
||||
"items": report_items,
|
||||
"line_width": printer.line_width,
|
||||
"div_style": load_divider_style(db),
|
||||
}
|
||||
background_tasks.add_task(print_prep_zone_summary, printer.ip_address, printer.port, report, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2L — Discount audit report
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1800,7 +1998,7 @@ def discounts_report(
|
||||
business_day_id: Optional[int] = None,
|
||||
applied_by: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.order import OrderDiscount, OrderItem
|
||||
|
||||
@@ -1836,10 +2034,10 @@ def discounts_report(
|
||||
if d.item_id:
|
||||
item = db.query(OrderItem).filter(OrderItem.id == d.item_id).first()
|
||||
if item:
|
||||
base = item.unit_price * item.quantity
|
||||
base = _item_val(item)
|
||||
return round(base * d.discount_value / 100, 2)
|
||||
elif order:
|
||||
base = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled")
|
||||
base = sum(_item_val(i) for i in order.items if i.status != "cancelled")
|
||||
return round(base * d.discount_value / 100, 2)
|
||||
return 0.0
|
||||
|
||||
@@ -1852,7 +2050,7 @@ def discounts_report(
|
||||
applier = users_map.get(d.applied_by)
|
||||
applier_name = (applier.full_name or applier.username) if applier else f"#{d.applied_by}"
|
||||
table_name = tables_map.get(order.table_id) if order and order.table_id else None
|
||||
order_total = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled") if order else None
|
||||
order_total = sum(_item_val(i) for i in order.items if i.status != "cancelled") if order else None
|
||||
discount_amount = _compute_discount_amount(d)
|
||||
total_discount_value += discount_amount
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ def week_schedule(
|
||||
WaiterShift.started_at <= week_end_dt,
|
||||
).all()
|
||||
|
||||
waiters = {u.id: u for u in db.query(User).filter(User.role == "waiter", User.is_active == True).all()}
|
||||
waiters = {u.id: u for u in db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()}
|
||||
|
||||
def _dt(dt):
|
||||
if not dt:
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 routers.deps import get_current_user, require_settings_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
@@ -20,6 +20,7 @@ VALID_SETTINGS = {
|
||||
"security.auto_logout_seconds":"Seconds of inactivity before logging out (0 = disabled)",
|
||||
"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",
|
||||
"shifts.hide_revenue_from_waiters": "Hide ΕΙΣΠΡΑΞΗ and ΠΑΡΑΔ. ΠΟΣΟ stats from waiters in Shift Overview: 'true' | 'false'",
|
||||
"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.",
|
||||
@@ -43,6 +44,27 @@ VALID_SETTINGS = {
|
||||
"print.beep_pattern": "Beep pattern: 'single' | 'double' | 'triple' | 'long' | 'custom:n1:n2:n3'",
|
||||
# Phase 2 — cancellations
|
||||
"orders.waiter_cancellations_allowed": "Allow waiters with per-account permission to cancel sent orders: 'true' | 'false'",
|
||||
"orders.waiter_price_adjust_allowed": "Allow waiters to adjust item prices (before or after ordering): 'true' | 'false'",
|
||||
# Payment options
|
||||
"payments.card_enabled": "Allow waiters to record card payments (in addition to cash): 'true' | 'false'",
|
||||
"payments.waiter_revert_allowed": "Allow waiters to revert individual paid items back to unpaid: 'true' | 'false'",
|
||||
# Tables & orders behaviour
|
||||
"orders.auto_close_on_full_payment": "Automatically close the table order when all items have been paid: 'true' | 'false'",
|
||||
"orders.bypass_kds_serve": "Skip KDS pending/serve flow — new orders and items are immediately marked as served: 'true' | 'false'",
|
||||
# Courses
|
||||
"orders.courses_enabled": "Enable per-item course assignment when ordering: 'true' | 'false'",
|
||||
"orders.courses": "JSON array of course objects [{id, name, color}] defining available courses",
|
||||
# Quick notes
|
||||
"orders.quick_notes": "JSON array of quick-note strings shown in the waiter note tab",
|
||||
# Fiscal printer (ΦΗΜ)
|
||||
"fiscal.enabled": "Master switch for fiscal printing: 'true' | 'false'",
|
||||
"fiscal.type": "Fiscal driver type: 'txt_file' (dTEC100extra) — more types coming",
|
||||
"fiscal.out_folder": "Path the backend writes fiscal command files TO (input for the fiscal driver)",
|
||||
"fiscal.in_folder": "Path the backend reads fiscal reply files FROM (output of the fiscal driver)",
|
||||
"fiscal.clerk_id": "Clerk ID sent in CR/CD commands (integer string, e.g. '2')",
|
||||
"fiscal.eftpos_id": "EFTPOS terminal ID sent in CD commands (integer string, e.g. '1')",
|
||||
"fiscal.end_message": "JSON array of up to 5 strings printed at end of receipt via FM commands",
|
||||
"fiscal.vat_groups": "JSON array of VAT group definitions: [{machine_id: int, friendly_name: str}]",
|
||||
}
|
||||
|
||||
DEFAULTS = {
|
||||
@@ -54,9 +76,10 @@ DEFAULTS = {
|
||||
"security.auto_logout_seconds": "1800",
|
||||
"shifts.waiter_self_start": "true",
|
||||
"shifts.waiter_self_end": "true",
|
||||
"shifts.hide_revenue_from_waiters": "false",
|
||||
"business_day.force_close_allowed": "true",
|
||||
"system.timezone": "Europe/Athens",
|
||||
"ui.table_colours": '{"light":{"free":{"cardBg":"#dde5ef","badgeBg":"rgba(255,255,255,0.92)","nameText":"#3d5270","badgeText":"#3d5270"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}},"dark":{"free":{"cardBg":"#243044","badgeBg":"rgba(255,255,255,0.92)","nameText":"#94b8d4","badgeText":"#94b8d4"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}}}',
|
||||
"ui.table_colours": '{"light":{"free":{"cardBg":"#dde5ef","badgeBg":"rgba(255,255,255,0.92)","nameText":"#3d5270","badgeText":"#3d5270"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"},"kds_ready":{"cardBg":"#22c55e","cardBg2":"#ffffff","badgeBg":"rgba(255,255,255,0.92)","badgeBg2":"rgba(255,255,255,0.92)","nameText":"#ffffff","nameText2":"#15803d","badgeText":"#15803d","badgeText2":"#22c55e","flash":true}},"dark":{"free":{"cardBg":"#243044","badgeBg":"rgba(255,255,255,0.92)","nameText":"#94b8d4","badgeText":"#94b8d4"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"},"kds_ready":{"cardBg":"#16a34a","cardBg2":"#ffffff","badgeBg":"rgba(255,255,255,0.92)","badgeBg2":"rgba(255,255,255,0.92)","nameText":"#ffffff","nameText2":"#bbf7d0","badgeText":"#bbf7d0","badgeText2":"#16a34a","flash":true}}}',
|
||||
"dev.spoof_printing": "false",
|
||||
"print.ticket_mode": "detailed",
|
||||
"print.divider_style": "dash",
|
||||
@@ -72,6 +95,23 @@ DEFAULTS = {
|
||||
"print.font_order_note": "0:1:0",
|
||||
"print.beep_on_ticket": "true",
|
||||
"print.beep_pattern": "double",
|
||||
"payments.card_enabled": "false",
|
||||
"payments.waiter_revert_allowed": "false",
|
||||
"orders.auto_close_on_full_payment": "false",
|
||||
"orders.bypass_kds_serve": "false",
|
||||
"orders.waiter_price_adjust_allowed": "false",
|
||||
"orders.courses_enabled": "false",
|
||||
"orders.courses": "[]",
|
||||
"orders.quick_notes": '["Χωρίς αλάτι","Βγάλτε γρήγορα","Αλλεργία!","Κόψτε σε μικρά κομμάτια","Έξτρα χαρτοπετσέτες"]',
|
||||
# Fiscal printer (ΦΗΜ)
|
||||
"fiscal.enabled": "false",
|
||||
"fiscal.type": "txt_file",
|
||||
"fiscal.out_folder": "",
|
||||
"fiscal.in_folder": "",
|
||||
"fiscal.clerk_id": "2",
|
||||
"fiscal.eftpos_id": "1",
|
||||
"fiscal.end_message": "[]",
|
||||
"fiscal.vat_groups": "[]",
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +135,7 @@ def update_setting(
|
||||
key: str,
|
||||
body: UpdateSettingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_settings_manager),
|
||||
):
|
||||
if key not in VALID_SETTINGS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}")
|
||||
|
||||
@@ -6,6 +6,7 @@ import bcrypt
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from services.chat_service import add_user_to_system_group
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -49,7 +50,7 @@ def security_config(db: Session = Depends(get_db)):
|
||||
@router.get("/status", response_model=SetupStatusResponse)
|
||||
def setup_status(db: Session = Depends(get_db)):
|
||||
has_manager = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.perm_access_dashboard == True,
|
||||
User.is_active == True,
|
||||
).first()
|
||||
return SetupStatusResponse(needs_setup=has_manager is None)
|
||||
@@ -58,7 +59,7 @@ def setup_status(db: Session = Depends(get_db)):
|
||||
@router.post("/init", response_model=SetupInitResponse)
|
||||
def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
|
||||
has_manager = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.perm_access_dashboard == True,
|
||||
User.is_active == True,
|
||||
).first()
|
||||
if has_manager:
|
||||
@@ -87,9 +88,13 @@ def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
|
||||
password_hash=password_hash,
|
||||
email=body.email,
|
||||
full_name=body.full_name,
|
||||
role="manager",
|
||||
role="store_manager",
|
||||
is_active=True,
|
||||
)
|
||||
# Apply store_manager default permissions
|
||||
from roles import get_default_permissions
|
||||
for field, value in get_default_permissions("store_manager").items():
|
||||
setattr(user, field, value)
|
||||
db.add(user)
|
||||
|
||||
# Persist venue settings if provided
|
||||
@@ -113,4 +118,6 @@ def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
|
||||
db.add(PosSettings(key="venue.type", value=body.venue_type, updated_at=now))
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
add_user_to_system_group(db, user.id)
|
||||
return SetupInitResponse(ok=True)
|
||||
|
||||
@@ -35,6 +35,24 @@ def compute_shift_total(shift_id: int, db: Session) -> float:
|
||||
return round(sum(i.unit_price * i.quantity for i in items), 2)
|
||||
|
||||
|
||||
def compute_shift_payment_split(shift_id: int, db: Session) -> dict:
|
||||
"""Return cash_sales and card_sales for a shift (items paid in this shift)."""
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.paid_in_shift_id == shift_id,
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
cash = 0.0
|
||||
card = 0.0
|
||||
for i in items:
|
||||
amount = float(i.unit_price) * i.quantity
|
||||
method = (i.payment_method or "cash").lower()
|
||||
if method == "card":
|
||||
card += amount
|
||||
else:
|
||||
cash += amount
|
||||
return {"cash_sales": round(cash, 2), "card_sales": round(card, 2)}
|
||||
|
||||
|
||||
def compute_shift_pay(shift: WaiterShift) -> dict:
|
||||
"""Return duration_hours and shift_pay. shift_pay is None if no rate snapshot."""
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -74,6 +92,7 @@ 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)
|
||||
split = compute_shift_payment_split(shift.id, db)
|
||||
pay_data = compute_shift_pay(shift)
|
||||
# Count cancelled items and their value attributed to this waiter during shift window
|
||||
cancelled_q = db.query(OrderItem).filter(
|
||||
@@ -96,7 +115,9 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
||||
"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),
|
||||
"cash_sales": split["cash_sales"],
|
||||
"card_sales": split["card_sales"],
|
||||
"net_to_deliver": round(split["cash_sales"] + (shift.starting_cash or 0.0), 2),
|
||||
"is_active": shift.ended_at is None,
|
||||
"notes": shift.notes,
|
||||
"hourly_rate_snapshot": shift.hourly_rate_snapshot,
|
||||
@@ -105,9 +126,11 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
||||
"cancellation_events": cancellation_events,
|
||||
"cancellations": cancellations,
|
||||
"cancellation_value": cancellation_value,
|
||||
# Phase 2E
|
||||
# Phase 2E — discrepancy computed live against cash-only expected (starting_cash + cash_sales)
|
||||
"counted_cash_end": shift.counted_cash_end,
|
||||
"cash_discrepancy": shift.cash_discrepancy,
|
||||
"cash_discrepancy": round(
|
||||
shift.counted_cash_end - (split["cash_sales"] + (shift.starting_cash or 0.0)), 2
|
||||
) if shift.counted_cash_end is not None else None,
|
||||
"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
|
||||
@@ -133,14 +156,14 @@ def start_shift(
|
||||
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")
|
||||
if not user.perm_access_dashboard and user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Only managers can start shifts for other staff")
|
||||
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")
|
||||
raise HTTPException(status_code=404, detail="Staff member not found")
|
||||
else:
|
||||
target_id = user.id
|
||||
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
|
||||
if not user.perm_access_dashboard and user.role != "superadmin" 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()
|
||||
@@ -173,7 +196,7 @@ def end_shift(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
|
||||
if not user.perm_access_dashboard and user.role != "superadmin" 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(
|
||||
@@ -189,11 +212,9 @@ def end_shift(
|
||||
shift.ended_at = now
|
||||
if body.notes:
|
||||
shift.notes = body.notes
|
||||
# Phase 2E: cash reconciliation
|
||||
# Phase 2E: cash reconciliation — store counted amount; discrepancy computed live in _enrich_shift
|
||||
if body.counted_cash_end is not None:
|
||||
shift.counted_cash_end = body.counted_cash_end
|
||||
expected = (shift.starting_cash or 0.0) + total
|
||||
shift.cash_discrepancy = round(body.counted_cash_end - expected, 2)
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||
@@ -262,11 +283,9 @@ def manager_end_shift(
|
||||
shift.ended_at = now
|
||||
if body.notes:
|
||||
shift.notes = body.notes
|
||||
# Phase 2E: cash reconciliation
|
||||
# Phase 2E: cash reconciliation — store counted amount; discrepancy computed live in _enrich_shift
|
||||
if body.counted_cash_end is not None:
|
||||
shift.counted_cash_end = body.counted_cash_end
|
||||
expected = (shift.starting_cash or 0.0) + total
|
||||
shift.cash_discrepancy = round(body.counted_cash_end - expected, 2)
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||
@@ -288,7 +307,7 @@ def start_break(
|
||||
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"):
|
||||
if shift.waiter_id != user.id and not user.perm_access_dashboard and user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if shift.ended_at:
|
||||
raise HTTPException(status_code=400, detail="Shift already ended")
|
||||
@@ -315,7 +334,7 @@ def end_break(
|
||||
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"):
|
||||
if shift.waiter_id != user.id and not user.perm_access_dashboard and user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
|
||||
@@ -125,7 +125,7 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name)
|
||||
success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name, printer.codepage_n)
|
||||
return {"success": success, "error": error}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db, printer.line_width)
|
||||
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db, printer.line_width, printer.codepage_n)
|
||||
return {"success": success, "error": error}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ def test_beep(printer_id: int, n1: int = 2, n2: int = 2, n3: int = 1, db: Sessio
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
success, error = printer_service.send_test_beep(printer.ip_address, printer.port, n1, n2, n3)
|
||||
success, error = printer_service.send_test_beep(printer.ip_address, printer.port, n1, n2, n3, printer.codepage_n)
|
||||
return {"success": success, "error": error}
|
||||
|
||||
|
||||
@@ -273,8 +273,8 @@ def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current
|
||||
"products": db.query(Product).filter(Product.lifecycle_status == "active").count(),
|
||||
"tables": db.query(Table).filter(Table.is_active == True).count(),
|
||||
"table_groups": db.query(TableGroup).count(),
|
||||
"managers": db.query(User).filter(User.role == "manager", User.is_active == True).count(),
|
||||
"waiters": db.query(User).filter(User.role == "waiter", User.is_active == True).count(),
|
||||
"managers": db.query(User).filter(User.perm_access_dashboard == True, User.is_active == True).count(),
|
||||
"waiters": db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).count(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from schemas.table import (
|
||||
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
||||
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from routers.deps import get_current_user, require_tables_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
# Tables with a pending reservation due within this many hours get the RESERVED badge
|
||||
@@ -30,7 +30,7 @@ def list_groups(db: Session = Depends(get_db), user: User = Depends(get_current_
|
||||
|
||||
|
||||
@router.post("/groups", response_model=TableGroupOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
|
||||
raise HTTPException(status_code=400, detail="Group name already exists")
|
||||
sort_order = db.query(TableGroup).count()
|
||||
@@ -42,14 +42,14 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
|
||||
|
||||
|
||||
@router.put("/groups/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_groups(body: List[int] = Body(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def reorder_groups(body: List[int] = Body(...), db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
for idx, group_id in enumerate(body):
|
||||
db.query(TableGroup).filter(TableGroup.id == group_id).update({"sort_order": idx})
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}", response_model=TableGroupOut)
|
||||
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
@@ -61,7 +61,7 @@ def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(ge
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
@@ -84,7 +84,7 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
|
||||
q = q.filter(Table.is_active == True)
|
||||
|
||||
# Zone-based filtering for waiters
|
||||
if user.role not in ("manager", "sysadmin"):
|
||||
if user.role != "superadmin" and not user.perm_access_dashboard:
|
||||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
|
||||
# No zone rows → sees nothing
|
||||
if not zones:
|
||||
@@ -134,9 +134,9 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
|
||||
|
||||
|
||||
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
number = _next_global_number(db)
|
||||
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True)
|
||||
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True, seat_count=body.seat_count)
|
||||
db.add(table)
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
@@ -145,7 +145,7 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
|
||||
|
||||
|
||||
@router.post("/batch", response_model=List[TableOut], status_code=status.HTTP_201_CREATED)
|
||||
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
if body.count < 1 or body.count > 200:
|
||||
raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
|
||||
|
||||
@@ -194,7 +194,7 @@ def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), u
|
||||
|
||||
|
||||
@router.put("/{table_id}", response_model=TableOut)
|
||||
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
@@ -206,7 +206,7 @@ def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db)
|
||||
|
||||
|
||||
@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
@@ -250,7 +250,7 @@ def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depe
|
||||
|
||||
|
||||
@router.put("/{table_id}/floorplan", response_model=TableOut)
|
||||
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
|
||||
@@ -8,41 +8,86 @@ from typing import List
|
||||
from database import get_db
|
||||
from models.user import User, AssistantAssignment, WaiterZone
|
||||
from models.shift import WaiterShift
|
||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
|
||||
from routers.deps import require_manager, get_current_user
|
||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest, PermissionToggle
|
||||
from routers.deps import require_staff_manager, get_current_user
|
||||
from services.chat_service import add_user_to_system_group
|
||||
from roles import ALL_ROLES, VALID_ROLES, get_default_permissions
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
AVATAR_DIR = "/app/data/avatars"
|
||||
|
||||
# Permission fields that can be toggled via the API
|
||||
PERMISSION_FIELDS = {
|
||||
"perm_access_dashboard",
|
||||
"perm_access_waiter_app",
|
||||
"perm_access_kds",
|
||||
"perm_cancel_orders",
|
||||
"perm_apply_discounts",
|
||||
"perm_modify_prices",
|
||||
"perm_open_orders",
|
||||
"perm_close_orders",
|
||||
"perm_view_reports",
|
||||
"perm_manage_staff",
|
||||
"perm_manage_tables",
|
||||
"perm_manage_menu",
|
||||
"perm_manage_settings",
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _waiter_or_404(waiter_id: int, db: Session) -> User:
|
||||
w = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not w:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
return w
|
||||
def _user_or_404(user_id: int, db: Session) -> User:
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="Staff member not found")
|
||||
return u
|
||||
|
||||
|
||||
def _guard_superadmin_target(target: User, acting_user: User):
|
||||
"""Prevent any modification of a superadmin by a non-superadmin."""
|
||||
if target.role == "superadmin" and acting_user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Cannot modify a superadmin account")
|
||||
|
||||
|
||||
def _apply_default_permissions(user: User, role: str):
|
||||
"""Apply the default permission set for a role onto a User object."""
|
||||
for field, value in get_default_permissions(role).items():
|
||||
setattr(user, field, value)
|
||||
|
||||
|
||||
# ── Metadata ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/roles")
|
||||
def list_roles():
|
||||
"""Return the full ordered list of available roles."""
|
||||
return ALL_ROLES
|
||||
|
||||
|
||||
# ── 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."""
|
||||
"""Staff 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()
|
||||
return db.query(User).filter(User.id.in_(waiter_ids), User.perm_access_waiter_app == True, User.is_active == True).all()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserOut])
|
||||
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
return db.query(User).filter(User.role == "waiter").all()
|
||||
def list_staff(db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
"""List all staff members regardless of role."""
|
||||
return db.query(User).order_by(User.created_at).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def create_staff(body: UserCreate, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
if body.role not in VALID_ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid role. Valid roles: {sorted(VALID_ROLES)}")
|
||||
if body.role == "superadmin" and user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Only a superadmin can create another superadmin")
|
||||
if db.query(User).filter(User.username == body.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
|
||||
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
|
||||
new_user = User(
|
||||
username=body.username,
|
||||
@@ -54,101 +99,156 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
|
||||
mobile_phone=body.mobile_phone,
|
||||
email=body.email,
|
||||
note=body.note,
|
||||
hourly_rate=body.hourly_rate,
|
||||
)
|
||||
# Apply role defaults (caller can override via the body fields)
|
||||
_apply_default_permissions(new_user, body.role)
|
||||
|
||||
# Allow explicit overrides from the request body for any perm field that was set
|
||||
for field in PERMISSION_FIELDS:
|
||||
val = getattr(body, field, None)
|
||||
if val is not None:
|
||||
setattr(new_user, field, val)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
|
||||
db.commit()
|
||||
|
||||
# Give waiter-app users access to all zones by default
|
||||
if new_user.perm_access_waiter_app:
|
||||
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
|
||||
db.commit()
|
||||
|
||||
add_user_to_system_group(db, new_user.id)
|
||||
return new_user
|
||||
|
||||
|
||||
@router.put("/{waiter_id}", response_model=UserOut)
|
||||
def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
def update_staff(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
|
||||
if body.role is not None and body.role not in VALID_ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid role. Valid roles: {sorted(VALID_ROLES)}")
|
||||
if body.role == "superadmin" and user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Only a superadmin can assign the superadmin role")
|
||||
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(waiter, field, value)
|
||||
setattr(target, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
@router.put("/{waiter_id}/reset-pin")
|
||||
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
target.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||
db.commit()
|
||||
return {"status": "pin reset"}
|
||||
|
||||
|
||||
@router.put("/{waiter_id}/block")
|
||||
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
waiter.is_active = not waiter.is_active
|
||||
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
target.is_active = not target.is_active
|
||||
db.commit()
|
||||
return {"is_active": waiter.is_active}
|
||||
return {"is_active": target.is_active}
|
||||
|
||||
|
||||
@router.put("/{waiter_id}/permission")
|
||||
def toggle_permission(
|
||||
waiter_id: int,
|
||||
body: PermissionToggle,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_staff_manager),
|
||||
):
|
||||
"""Toggle a single permission flag for a staff member."""
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
|
||||
if body.permission not in PERMISSION_FIELDS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown permission: {body.permission}")
|
||||
|
||||
# Superadmins always have all permissions — we store the value but enforce it at auth time
|
||||
setattr(target, body.permission, body.value)
|
||||
db.commit()
|
||||
return {"permission": body.permission, "value": body.value}
|
||||
|
||||
|
||||
@router.put("/{waiter_id}/reset-permissions")
|
||||
def reset_permissions(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
"""Reset a staff member's permissions to their role's defaults."""
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
_apply_default_permissions(target, target.role)
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_waiter(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
db.delete(waiter)
|
||||
def delete_staff(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Avatar upload / delete ───────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{waiter_id}/avatar", response_model=UserOut)
|
||||
async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
# Delete old avatar file if present
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
if target.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(target.avatar_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
||||
filename = f"waiter_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
filename = f"staff_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
dest = os.path.join(AVATAR_DIR, filename)
|
||||
os.makedirs(AVATAR_DIR, exist_ok=True)
|
||||
content = await file.read()
|
||||
with open(dest, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
waiter.avatar_url = f"/static/avatars/{filename}"
|
||||
target.avatar_url = f"/static/avatars/{filename}"
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}/avatar", response_model=UserOut)
|
||||
def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
if target.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(target.avatar_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
waiter.avatar_url = None
|
||||
target.avatar_url = None
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
# ── Zone assignments ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.put("/{waiter_id}/zones")
|
||||
def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Replace all zone assignments for a waiter atomically.
|
||||
def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
"""Replace all zone assignments for a staff member atomically.
|
||||
|
||||
- all_zones=True → single NULL group_id row (sees everything)
|
||||
- group_ids=[1,2] → rows for groups 1 and 2 only
|
||||
- group_ids=[] → no rows at all (sees nothing)
|
||||
"""
|
||||
_waiter_or_404(waiter_id, db)
|
||||
# Wipe existing assignments
|
||||
_user_or_404(waiter_id, db)
|
||||
db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).delete()
|
||||
|
||||
if body.all_zones:
|
||||
@@ -165,7 +265,7 @@ def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_d
|
||||
# ── Assistant assignments (kept for backwards compat) ─────────────────────────
|
||||
|
||||
@router.post("/{waiter_id}/assign-assistant", response_model=AssistantAssignmentOut)
|
||||
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
existing = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.primary_waiter_id == waiter_id,
|
||||
AssistantAssignment.assistant_waiter_id == assistant_id,
|
||||
@@ -180,7 +280,7 @@ def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(ge
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}/assistant", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_assistant(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def remove_assistant(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
assignment = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.primary_waiter_id == waiter_id
|
||||
).first()
|
||||
|
||||
87
local_backend/routers/ws.py
Normal file
87
local_backend/routers/ws.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
WebSocket endpoint — one persistent connection per connected client (waiter PWA, KDS).
|
||||
|
||||
Authentication: JWT token passed as query param ?token=<jwt>
|
||||
(Same pattern as SSE — browser WebSocket API also cannot set custom headers.)
|
||||
|
||||
Protocol:
|
||||
1. Client connects: ws://host/api/ws?token=<jwt>
|
||||
2. Client immediately sends: { "cursor": <last_seq_id> }
|
||||
(0 = first connect / no history; any positive int = resume from that point)
|
||||
3. Server replays all missed events (seq > cursor) as individual frames
|
||||
4. Server sends { "type": "ready" } to signal end of replay / start of live stream
|
||||
5. Live events arrive as: { "seq": 123, "type": "...", "data": { ... } }
|
||||
6. Every 25s server sends: { "type": "ping" }
|
||||
7. Client may respond: { "type": "pong" } (ignored if not sent)
|
||||
8. On disconnect/error the client reconnects and repeats from step 2
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from routers.deps import decode_token
|
||||
from services.ws_bus import connect, disconnect, get_events_since
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KEEPALIVE_INTERVAL = 25 # seconds
|
||||
|
||||
|
||||
@router.websocket("/connect")
|
||||
async def ws_endpoint(websocket: WebSocket, token: str = Query(...)):
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id: int = int(payload["sub"])
|
||||
except Exception:
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
# ── Wait for cursor frame ─────────────────────────────────────────────────
|
||||
cursor = 0
|
||||
try:
|
||||
raw = await asyncio.wait_for(websocket.receive_text(), timeout=10.0)
|
||||
msg = json.loads(raw)
|
||||
cursor = int(msg.get("cursor", 0))
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass # no cursor sent — treat as fresh connect (cursor=0)
|
||||
|
||||
# ── Register this connection ──────────────────────────────────────────────
|
||||
q = await connect(user_id)
|
||||
|
||||
try:
|
||||
# ── Replay missed events ──────────────────────────────────────────────
|
||||
if cursor > 0:
|
||||
missed = get_events_since(cursor, user_id)
|
||||
for event in missed:
|
||||
await websocket.send_text(json.dumps(event))
|
||||
|
||||
# Signal end of replay / start of live stream
|
||||
await websocket.send_text(json.dumps({"type": "ready"}))
|
||||
|
||||
# ── Live stream loop ──────────────────────────────────────────────────
|
||||
while True:
|
||||
try:
|
||||
frame = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL)
|
||||
if websocket.client_state == WebSocketState.CONNECTED:
|
||||
await websocket.send_text(frame)
|
||||
except asyncio.TimeoutError:
|
||||
# Send keepalive ping
|
||||
if websocket.client_state == WebSocketState.CONNECTED:
|
||||
await websocket.send_text(json.dumps({"type": "ping"}))
|
||||
else:
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("ws_endpoint: connection closed for user %s: %s", user_id, e)
|
||||
finally:
|
||||
await disconnect(user_id, q)
|
||||
69
local_backend/schemas/chat.py
Normal file
69
local_backend/schemas/chat.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from pydantic import BaseModel, field_serializer
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
def _as_utc_iso(dt: datetime | None) -> str | None:
|
||||
"""Serialize a datetime to an ISO-8601 string with explicit Z suffix.
|
||||
SQLite stores datetimes without tzinfo; we treat all stored values as UTC."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
|
||||
|
||||
|
||||
class ConversationCreate(BaseModel):
|
||||
type: str # 'direct' | 'group'
|
||||
name: Optional[str] = None
|
||||
participant_ids: List[int]
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
body: str
|
||||
|
||||
|
||||
class ParticipantOut(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
joined_at: datetime
|
||||
last_read_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_serializer('joined_at')
|
||||
def ser_joined_at(self, v): return _as_utc_iso(v)
|
||||
|
||||
@field_serializer('last_read_at')
|
||||
def ser_last_read_at(self, v): return _as_utc_iso(v)
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
id: int
|
||||
conversation_id: int
|
||||
sender_id: int
|
||||
sender_name: str
|
||||
body: str
|
||||
sent_at: datetime
|
||||
is_deleted: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_serializer('sent_at')
|
||||
def ser_sent_at(self, v): return _as_utc_iso(v)
|
||||
|
||||
|
||||
class ConversationOut(BaseModel):
|
||||
id: int
|
||||
type: str
|
||||
name: Optional[str] = None
|
||||
is_system: bool
|
||||
created_at: datetime
|
||||
participants: List[ParticipantOut]
|
||||
last_message: Optional[MessageOut] = None
|
||||
unread_count: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_serializer('created_at')
|
||||
def ser_created_at(self, v): return _as_utc_iso(v)
|
||||
@@ -36,6 +36,8 @@ class StaffMessageOut(BaseModel):
|
||||
body: str
|
||||
target_waiter_ids: str # raw JSON string — frontend parses
|
||||
table_ids: str
|
||||
message_type: str = "manager"
|
||||
kds_zone: Optional[str] = None
|
||||
created_at: datetime
|
||||
acked_by: List[int] = [] # waiter ids who have acked
|
||||
|
||||
|
||||
@@ -16,14 +16,19 @@ class SelectedOptionInput(BaseModel):
|
||||
|
||||
class OrderItemInput(BaseModel):
|
||||
product_id: int
|
||||
quantity: int
|
||||
quantity: float
|
||||
selected_options: Optional[List[SelectedOptionInput]] = None
|
||||
removed_ingredients: Optional[List[str]] = None
|
||||
notes: Optional[str] = None
|
||||
price_adjustment: Optional[float] = None # on-the-fly price delta, applied at add-time
|
||||
is_service_item: bool = False # priceless: print-once ephemeral; priced: saved as real item
|
||||
course_id: Optional[int] = None
|
||||
|
||||
|
||||
class AddItemsRequest(BaseModel):
|
||||
items: List[OrderItemInput]
|
||||
order_note: Optional[str] = None # whole-order note, written to Order.notes
|
||||
customer_count: Optional[int] = None # number of customers at the table
|
||||
|
||||
|
||||
class ProductNameOut(BaseModel):
|
||||
@@ -38,18 +43,24 @@ class OrderItemOut(BaseModel):
|
||||
product_id: int
|
||||
product: Optional[ProductNameOut] = None
|
||||
added_by: int
|
||||
quantity: int
|
||||
quantity: float
|
||||
unit_type: str = "piece"
|
||||
unit_price: float
|
||||
selected_options: Optional[str] = None
|
||||
removed_ingredients: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: str
|
||||
kds_status: str = "pending"
|
||||
added_at: UTCDatetime
|
||||
printed: bool
|
||||
paid_by: Optional[int] = None
|
||||
paid_at: Optional[UTCDatetime] = None
|
||||
payment_method: Optional[str] = None
|
||||
paid_in_shift_id: Optional[int] = None
|
||||
price_adjustment: float = 0.0
|
||||
course_id: Optional[int] = None
|
||||
deal_id: Optional[int] = None
|
||||
linked_item_id: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -63,12 +74,14 @@ class PrintResultOut(BaseModel):
|
||||
class AddItemsResponse(BaseModel):
|
||||
order: "OrderOut"
|
||||
print_results: List[PrintResultOut]
|
||||
deal_offers: List[dict] = [] # DealOfferOut dicts — serialized to avoid circular imports
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderCreate(BaseModel):
|
||||
table_id: int
|
||||
table_id: Optional[int] = None
|
||||
order_type: Optional[str] = "here" # here | takeaway | delivery
|
||||
|
||||
|
||||
class PayItemsRequest(BaseModel):
|
||||
@@ -109,6 +122,23 @@ class AuditLogOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PrintJobOut(BaseModel):
|
||||
id: int
|
||||
printer_id: int
|
||||
zone_id: Optional[int] = None
|
||||
item_ids: str
|
||||
copies: int = 1
|
||||
status: str
|
||||
retry_count: int = 0
|
||||
first_attempted_at: Optional[UTCDatetime] = None
|
||||
last_attempted_at: Optional[UTCDatetime] = None
|
||||
succeeded_at: Optional[UTCDatetime] = None
|
||||
cancelled_at: Optional[UTCDatetime] = None
|
||||
cancel_reason: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderOut(BaseModel):
|
||||
id: int
|
||||
table_id: Optional[int] = None
|
||||
@@ -129,8 +159,11 @@ class OrderOut(BaseModel):
|
||||
online_customer_address: Optional[str] = None
|
||||
online_customer_notes: Optional[str] = None
|
||||
online_order_type: Optional[str] = None
|
||||
order_type: str = "here"
|
||||
customer_count: Optional[int] = None
|
||||
items: List[OrderItemOut] = []
|
||||
waiters: List[OrderWaiterOut] = []
|
||||
audit_logs: List[AuditLogOut] = []
|
||||
print_jobs: List[PrintJobOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
384
local_backend/schemas/pricing.py
Normal file
384
local_backend/schemas/pricing.py
Normal file
@@ -0,0 +1,384 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional, List, Any
|
||||
from pydantic import BaseModel, field_validator
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Groups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PriceGroupCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
is_active: bool = False
|
||||
auto_enable_time: Optional[str] = None # "HH:MM"
|
||||
auto_disable_time: Optional[str] = None # "HH:MM"
|
||||
auto_days: Optional[List[int]] = None # [0..6] Mon=0
|
||||
|
||||
|
||||
class PriceGroupUpdate(PriceGroupCreate):
|
||||
pass
|
||||
|
||||
|
||||
class PriceGroupOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
color: Optional[str]
|
||||
is_active: bool
|
||||
auto_enable_time: Optional[str]
|
||||
auto_disable_time: Optional[str]
|
||||
auto_days: Optional[List[int]]
|
||||
created_at: Optional[datetime]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("is_active", mode="before")
|
||||
@classmethod
|
||||
def coerce_bool(cls, v):
|
||||
return bool(v)
|
||||
|
||||
@field_validator("auto_days", mode="before")
|
||||
@classmethod
|
||||
def parse_auto_days(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Modifier Conditions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ConditionIn(BaseModel):
|
||||
condition_type: str
|
||||
params: dict = {}
|
||||
|
||||
|
||||
class ConditionOut(BaseModel):
|
||||
id: int
|
||||
condition_type: str
|
||||
params: dict
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("params", mode="before")
|
||||
@classmethod
|
||||
def parse_params(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
return {}
|
||||
return v or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Modifier Targets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ModifierTargetIn(BaseModel):
|
||||
target_type: str # 'all'|'item'|'category'|'prep_zone'|'tag'
|
||||
target_id: Optional[int] = None
|
||||
target_tag: Optional[str] = None
|
||||
target_ids: Optional[List[int]] = None
|
||||
target_tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ModifierTargetOut(BaseModel):
|
||||
id: int
|
||||
target_type: str
|
||||
target_id: Optional[int]
|
||||
target_tag: Optional[str]
|
||||
target_ids: Optional[List[int]] = None
|
||||
target_tags: Optional[List[str]] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("target_ids", "target_tags", mode="before")
|
||||
@classmethod
|
||||
def parse_json_list(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
return json.loads(v)
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Modifiers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PriceModifierCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_favorite: bool = False
|
||||
allow_stack: bool = False
|
||||
sort_order: int = 0
|
||||
scope: str = "global" # 'item' | 'global'
|
||||
item_id: Optional[int] = None
|
||||
action_type: str # 'set' | 'add_amount' | 'add_percent'
|
||||
action_value: float
|
||||
round_to: Optional[str] = None # '0.05'|'0.10'|'0.20'|'0.50'|'x.99'|'x.00'
|
||||
conditions: List[ConditionIn] = []
|
||||
targets: List[ModifierTargetIn] = [] # only relevant for scope='global'
|
||||
|
||||
|
||||
class PriceModifierUpdate(PriceModifierCreate):
|
||||
pass
|
||||
|
||||
|
||||
class PriceModifierOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
color: Optional[str]
|
||||
is_active: bool
|
||||
is_favorite: bool
|
||||
allow_stack: bool
|
||||
sort_order: int
|
||||
scope: str
|
||||
item_id: Optional[int]
|
||||
action_type: str
|
||||
action_value: float
|
||||
round_to: Optional[str]
|
||||
conditions: List[ConditionOut]
|
||||
targets: List[ModifierTargetOut]
|
||||
created_at: Optional[datetime]
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("is_active", "is_favorite", "allow_stack", mode="before")
|
||||
@classmethod
|
||||
def coerce_bool(cls, v):
|
||||
return bool(v)
|
||||
|
||||
|
||||
class PriceModifierReorderItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int
|
||||
|
||||
|
||||
class PriceModifierReorderRequest(BaseModel):
|
||||
items: List[PriceModifierReorderItem]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DealTargetIn(BaseModel):
|
||||
target_type: str # 'item'|'category'|'tag'|'any'
|
||||
target_id: Optional[int] = None
|
||||
target_tag: Optional[str] = None
|
||||
target_ids: Optional[List[int]] = None
|
||||
target_tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class DealTargetOut(BaseModel):
|
||||
id: int
|
||||
target_type: str
|
||||
target_id: Optional[int]
|
||||
target_tag: Optional[str]
|
||||
target_ids: Optional[List[int]] = None
|
||||
target_tags: Optional[List[str]] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("target_ids", "target_tags", mode="before")
|
||||
@classmethod
|
||||
def parse_json_list(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
return json.loads(v)
|
||||
return v
|
||||
|
||||
|
||||
class DealCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
action_type: str
|
||||
# Action payload — which fields are set depends on action_type
|
||||
action_modifier_id: Optional[int] = None # for apply_modifier
|
||||
action_value: Optional[float] = None # for set_price / add_amount / add_percent
|
||||
action_free_item_id: Optional[int] = None # for free_item
|
||||
action_free_target_type: Optional[str] = None # for free_choice: 'item'|'category'|'tag'
|
||||
action_free_target_ids: Optional[List[Any]] = None # for free_choice
|
||||
action_free_quantity: int = 1
|
||||
conditions: List[ConditionIn] = []
|
||||
targets: List[DealTargetIn] = []
|
||||
|
||||
|
||||
class DealUpdate(DealCreate):
|
||||
pass
|
||||
|
||||
|
||||
class DealOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
color: Optional[str]
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
action_type: str
|
||||
action_modifier_id: Optional[int]
|
||||
action_value: Optional[float]
|
||||
action_free_item_id: Optional[int]
|
||||
action_free_target_type: Optional[str]
|
||||
action_free_target_ids: Optional[List[Any]]
|
||||
action_free_quantity: int
|
||||
conditions: List[ConditionOut]
|
||||
targets: List[DealTargetOut]
|
||||
created_at: Optional[datetime]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("is_active", mode="before")
|
||||
@classmethod
|
||||
def coerce_bool(cls, v):
|
||||
return bool(v)
|
||||
|
||||
@field_validator("action_free_target_ids", mode="before")
|
||||
@classmethod
|
||||
def parse_target_ids(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Waiter Discount Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WaiterDiscountSettingsIn(BaseModel):
|
||||
can_apply_discounts: bool = False
|
||||
max_discount_percent: Optional[float] = None
|
||||
max_discount_amount: Optional[float] = None
|
||||
max_total_value_shift: Optional[float] = None
|
||||
max_total_value_workday: Optional[float] = None
|
||||
max_items_per_shift: Optional[int] = None
|
||||
max_items_per_workday: Optional[int] = None
|
||||
max_items_per_order: Optional[int] = None
|
||||
|
||||
|
||||
class WaiterDiscountSettingsOut(WaiterDiscountSettingsIn):
|
||||
id: int
|
||||
user_id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("can_apply_discounts", mode="before")
|
||||
@classmethod
|
||||
def coerce_bool(cls, v):
|
||||
return bool(v)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Event Log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PriceEventOut(BaseModel):
|
||||
id: int
|
||||
order_id: int
|
||||
order_item_id: Optional[int]
|
||||
event_type: str
|
||||
modifier_id: Optional[int]
|
||||
deal_id: Optional[int]
|
||||
price_before: Optional[float]
|
||||
price_after: Optional[float]
|
||||
delta_amount: Optional[float]
|
||||
applied_by_user_id: Optional[int]
|
||||
waiter_note: Optional[str]
|
||||
conditions_snapshot: Optional[dict]
|
||||
selected_item_ids: Optional[List[int]]
|
||||
applied_at: datetime
|
||||
# Resolved names (joined at query time)
|
||||
modifier_name: Optional[str] = None
|
||||
deal_name: Optional[str] = None
|
||||
applied_by_username: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("conditions_snapshot", mode="before")
|
||||
@classmethod
|
||||
def parse_snapshot(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator("selected_item_ids", mode="before")
|
||||
@classmethod
|
||||
def parse_item_ids(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Waiter discount apply (PWA-facing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ApplyDiscountRequest(BaseModel):
|
||||
item_ids: List[int]
|
||||
discount_percent: float # e.g. 10.0 = 10%; final price rounds to nearest 0.10
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class ApplyDiscountResponse(BaseModel):
|
||||
applied: List[dict] # [{item_id, price_before, price_after, delta}]
|
||||
remaining_budget_shift: Optional[float] = None
|
||||
remaining_budget_workday: Optional[float] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deal offer response (returned on add_items when deals are newly triggered)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DealOfferOut(BaseModel):
|
||||
deal_id: int
|
||||
deal_name: str
|
||||
action_type: str
|
||||
free_item_id: Optional[int] = None
|
||||
free_item_name: Optional[str] = None
|
||||
free_choices: List[dict] = [] # [{id, name}]
|
||||
free_quantity: int = 1
|
||||
target_order_item_id: Optional[int] = None
|
||||
price_before: Optional[float] = None
|
||||
price_after: Optional[float] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deal offer accept (waiter confirms a deal)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AcceptDealOfferRequest(BaseModel):
|
||||
deal_id: int
|
||||
selected_product_ids: Optional[List[int]] = None # for free_choice
|
||||
|
||||
|
||||
class DismissDealOfferRequest(BaseModel):
|
||||
deal_id: int
|
||||
@@ -4,6 +4,16 @@ from typing import Optional
|
||||
|
||||
PROTOCOLS = ["escpos_tcp"] # extend later as needed
|
||||
|
||||
# Known printer profiles — (codepage_n, python_encoding)
|
||||
# codepage_n: the n byte in ESC t n sent on connect
|
||||
# python_encoding: used to encode Greek text to bytes
|
||||
PRINTER_PROFILES = {
|
||||
"jolimark": {"label": "Jolimark TP850UE", "codepage_n": 29, "encoding": "cp737"},
|
||||
"samas": {"label": "S4MAS Giant 100", "codepage_n": 29, "encoding": "cp737"},
|
||||
"netum": {"label": "NETUM NT8330L", "codepage_n": 14, "encoding": "cp737"},
|
||||
}
|
||||
DEFAULT_PROFILE = "jolimark"
|
||||
|
||||
|
||||
class PrinterBase(BaseModel):
|
||||
name: str
|
||||
@@ -12,7 +22,7 @@ class PrinterBase(BaseModel):
|
||||
is_active: bool = True
|
||||
protocol: str = "escpos_tcp"
|
||||
line_width: int = 48
|
||||
duplicates: int = 0
|
||||
codepage_n: int = 29
|
||||
|
||||
|
||||
class PrinterCreate(PrinterBase):
|
||||
@@ -26,7 +36,7 @@ class PrinterUpdate(BaseModel):
|
||||
is_active: Optional[bool] = None
|
||||
protocol: Optional[str] = None
|
||||
line_width: Optional[int] = None
|
||||
duplicates: Optional[int] = None
|
||||
codepage_n: Optional[int] = None
|
||||
|
||||
|
||||
class PrinterOut(PrinterBase):
|
||||
|
||||
@@ -52,6 +52,24 @@ class ParentGeneralReorderItem(BaseModel):
|
||||
general_sort_order: int
|
||||
|
||||
|
||||
# ── Modifier Groups ───────────────────────────────────────────────────────────
|
||||
|
||||
class ModifierGroupCreate(BaseModel):
|
||||
modifier_type: str # "option" | "ingredient" | "preference"
|
||||
name: str
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class ModifierGroupOut(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
modifier_type: str
|
||||
name: str
|
||||
sort_order: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Quick Options ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ProductQuickOptionCreate(BaseModel):
|
||||
@@ -84,14 +102,23 @@ class OptionSubChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
# When True waiter can add more than 1 of this sub-choice
|
||||
allow_multiple: bool = False
|
||||
# When True renders as half-width card on the PWA
|
||||
is_compact: bool = False
|
||||
|
||||
|
||||
class ProductOptionBase(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
allow_multiple: bool = False
|
||||
# When True waiter can select more than one sub-choice at once
|
||||
multi_select: bool = False
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
# When True renders the option itself as half-width card on the PWA
|
||||
is_compact: bool = False
|
||||
group_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProductOptionCreate(ProductOptionBase):
|
||||
@@ -117,9 +144,12 @@ class ProductOptionOut(ProductOptionBase):
|
||||
'name': data.name,
|
||||
'extra_cost': data.extra_cost,
|
||||
'allow_multiple': getattr(data, 'allow_multiple', False) or False,
|
||||
'multi_select': getattr(data, 'multi_select', False) or False,
|
||||
'sub_choices': parsed,
|
||||
'is_favorite': getattr(data, 'is_favorite', False) or False,
|
||||
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
|
||||
'is_compact': getattr(data, 'is_compact', False) or False,
|
||||
'group_id': getattr(data, 'group_id', None),
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -131,6 +161,9 @@ class ProductIngredientBase(BaseModel):
|
||||
extra_cost: float = 0.0
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
# When True renders as half-width card on the PWA
|
||||
is_compact: bool = False
|
||||
group_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProductIngredientCreate(ProductIngredientBase):
|
||||
@@ -150,6 +183,7 @@ class SubChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
is_compact: bool = False
|
||||
|
||||
|
||||
# ── Shared subset (set-level, shown for all non-disabling choices) ─────────────
|
||||
@@ -158,6 +192,7 @@ class SharedSubsetChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
is_compact: bool = False
|
||||
|
||||
|
||||
class SharedSubset(BaseModel):
|
||||
@@ -172,6 +207,8 @@ class PreferenceChoiceCreate(BaseModel):
|
||||
extra_cost: float = 0.0
|
||||
sub_choices: List[SubChoice] = []
|
||||
disables_subset: bool = False
|
||||
# When True renders as half-width card on the PWA
|
||||
is_compact: bool = False
|
||||
|
||||
|
||||
class PreferenceChoiceOut(BaseModel):
|
||||
@@ -181,6 +218,7 @@ class PreferenceChoiceOut(BaseModel):
|
||||
extra_cost: float = 0.0
|
||||
sub_choices: List[SubChoice] = []
|
||||
disables_subset: bool = False
|
||||
is_compact: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -203,6 +241,7 @@ class PreferenceChoiceOut(BaseModel):
|
||||
'extra_cost': data.extra_cost,
|
||||
'sub_choices': parsed,
|
||||
'disables_subset': data.disables_subset or False,
|
||||
'is_compact': getattr(data, 'is_compact', False) or False,
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -214,6 +253,9 @@ class PreferenceSetCreate(BaseModel):
|
||||
shared_subset: Optional[SharedSubset] = None
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
group_id: Optional[int] = None
|
||||
allow_multi_select: bool = False
|
||||
allow_choice_quantity: bool = False
|
||||
|
||||
|
||||
class PreferenceSetOut(BaseModel):
|
||||
@@ -225,6 +267,9 @@ class PreferenceSetOut(BaseModel):
|
||||
shared_subset: Optional[SharedSubset] = None
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
group_id: Optional[int] = None
|
||||
allow_multi_select: bool = False
|
||||
allow_choice_quantity: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -249,6 +294,9 @@ class PreferenceSetOut(BaseModel):
|
||||
'shared_subset': parsed,
|
||||
'is_favorite': getattr(data, 'is_favorite', False) or False,
|
||||
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
|
||||
'group_id': getattr(data, 'group_id', None),
|
||||
'allow_multi_select': getattr(data, 'allow_multi_select', False) or False,
|
||||
'allow_choice_quantity': getattr(data, 'allow_choice_quantity', False) or False,
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -265,9 +313,12 @@ class ProductBase(BaseModel):
|
||||
description: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
base_price: float
|
||||
# piece | portion | kg | liter | gram | ml
|
||||
unit_type: str = "piece"
|
||||
is_available: bool = True
|
||||
lifecycle_status: str = "active"
|
||||
printer_zone_id: Optional[int] = None
|
||||
printer_zone_id: Optional[int] = None # kept for backward compat; use prep_zone_ids instead
|
||||
prep_zone_ids: List[int] = []
|
||||
sort_order: int = 0
|
||||
# Xenia Connect — digital menu overrides
|
||||
digital_visible: bool = True
|
||||
@@ -280,6 +331,15 @@ class ProductBase(BaseModel):
|
||||
# Phase 2A — cost tracking
|
||||
cost_simple: Optional[float] = None
|
||||
cost_breakdown: Optional[List[CostBreakdownItem]] = None
|
||||
# Quick Add
|
||||
quick_add_enabled: bool = True
|
||||
# Tags
|
||||
tags: Optional[List[str]] = None
|
||||
# Service item flag
|
||||
is_service_item: bool = False
|
||||
# Fiscal printer (ΦΗΜ)
|
||||
fiscal_name: Optional[str] = None
|
||||
fiscal_vat_group_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
@@ -287,6 +347,8 @@ class ProductCreate(ProductBase):
|
||||
options: List[ProductOptionCreate] = []
|
||||
ingredients: List[ProductIngredientCreate] = []
|
||||
preference_sets: List[PreferenceSetCreate] = []
|
||||
# group_id on options/ingredients/preference_sets is an index into this list
|
||||
modifier_groups: List[ModifierGroupCreate] = []
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
@@ -294,14 +356,17 @@ class ProductUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
base_price: Optional[float] = None
|
||||
unit_type: Optional[str] = None
|
||||
is_available: Optional[bool] = None
|
||||
lifecycle_status: Optional[str] = None
|
||||
printer_zone_id: Optional[int] = None
|
||||
prep_zone_ids: Optional[List[int]] = None
|
||||
sort_order: Optional[int] = None
|
||||
quick_options: Optional[List[ProductQuickOptionCreate]] = None
|
||||
options: Optional[List[ProductOptionCreate]] = None
|
||||
ingredients: Optional[List[ProductIngredientCreate]] = None
|
||||
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
||||
modifier_groups: Optional[List[ModifierGroupCreate]] = None
|
||||
# Xenia Connect — digital menu overrides
|
||||
digital_visible: Optional[bool] = None
|
||||
digital_available: Optional[bool] = None
|
||||
@@ -313,6 +378,15 @@ class ProductUpdate(BaseModel):
|
||||
# Phase 2A — cost tracking
|
||||
cost_simple: Optional[float] = None
|
||||
cost_breakdown: Optional[List[CostBreakdownItem]] = None
|
||||
# Quick Add
|
||||
quick_add_enabled: Optional[bool] = None
|
||||
# Tags
|
||||
tags: Optional[List[str]] = None
|
||||
# Service item flag
|
||||
is_service_item: Optional[bool] = None
|
||||
# Fiscal printer (ΦΗΜ)
|
||||
fiscal_name: Optional[str] = None
|
||||
fiscal_vat_group_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProductReorderItem(BaseModel):
|
||||
@@ -326,6 +400,7 @@ class ProductOut(ProductBase):
|
||||
options: List[ProductOptionOut] = []
|
||||
ingredients: List[ProductIngredientOut] = []
|
||||
preference_sets: List[PreferenceSetOut] = []
|
||||
modifier_groups: List[ModifierGroupOut] = []
|
||||
image_url: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -348,9 +423,11 @@ class ProductOut(ProductBase):
|
||||
'description': data.description,
|
||||
'category_id': data.category_id,
|
||||
'base_price': data.base_price,
|
||||
'unit_type': getattr(data, 'unit_type', 'piece') or 'piece',
|
||||
'is_available': data.is_available,
|
||||
'lifecycle_status': data.lifecycle_status,
|
||||
'printer_zone_id': data.printer_zone_id,
|
||||
'prep_zone_ids': [z.id for z in data.prep_zones] if hasattr(data, 'prep_zones') else [],
|
||||
'sort_order': data.sort_order,
|
||||
'image_url': data.image_url,
|
||||
'digital_visible': bool(data.digital_visible),
|
||||
@@ -362,9 +439,15 @@ class ProductOut(ProductBase):
|
||||
'digital_image_url': data.digital_image_url,
|
||||
'cost_simple': data.cost_simple,
|
||||
'cost_breakdown': parsed,
|
||||
'quick_add_enabled': getattr(data, 'quick_add_enabled', True),
|
||||
'tags': json.loads(data.tags) if isinstance(getattr(data, 'tags', None), str) else (getattr(data, 'tags', None) or []),
|
||||
'is_service_item': bool(getattr(data, 'is_service_item', False)),
|
||||
'fiscal_name': getattr(data, 'fiscal_name', None),
|
||||
'fiscal_vat_group_id': getattr(data, 'fiscal_vat_group_id', None),
|
||||
'quick_options': list(data.quick_options),
|
||||
'options': list(data.options),
|
||||
'ingredients': list(data.ingredients),
|
||||
'preference_sets': list(data.preference_sets),
|
||||
'modifier_groups': list(data.modifier_groups) if hasattr(data, 'modifier_groups') else [],
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -36,6 +36,7 @@ class TableBase(BaseModel):
|
||||
class TableCreate(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
seat_count: Optional[int] = None
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
@@ -58,6 +59,7 @@ class TableUpdate(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
seat_count: Optional[int] = None
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
@@ -90,6 +92,7 @@ class TableOut(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
seat_count: Optional[int] = None
|
||||
floor_x: Optional[float] = None
|
||||
floor_y: Optional[float] = None
|
||||
group: Optional[TableGroupOut] = None
|
||||
|
||||
@@ -16,7 +16,22 @@ class UserBase(BaseModel):
|
||||
email: Optional[str] = None
|
||||
# Phase 2B — payroll
|
||||
hourly_rate: Optional[float] = None
|
||||
can_cancel_orders: bool = False
|
||||
# Access permissions
|
||||
perm_access_dashboard: bool = False
|
||||
perm_access_waiter_app: bool = True
|
||||
perm_access_kds: bool = False
|
||||
# Order action permissions
|
||||
perm_cancel_orders: bool = False
|
||||
perm_apply_discounts: bool = False
|
||||
perm_modify_prices: bool = False
|
||||
perm_open_orders: bool = True
|
||||
perm_close_orders: bool = True
|
||||
# Management permissions
|
||||
perm_view_reports: bool = False
|
||||
perm_manage_staff: bool = False
|
||||
perm_manage_tables: bool = False
|
||||
perm_manage_menu: bool = False
|
||||
perm_manage_settings: bool = False
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
@@ -34,7 +49,22 @@ class UserUpdate(BaseModel):
|
||||
note: Optional[str] = None
|
||||
# Phase 2B — payroll
|
||||
hourly_rate: Optional[float] = None
|
||||
can_cancel_orders: Optional[bool] = None
|
||||
# Access permissions
|
||||
perm_access_dashboard: Optional[bool] = None
|
||||
perm_access_waiter_app: Optional[bool] = None
|
||||
perm_access_kds: Optional[bool] = None
|
||||
# Order action permissions
|
||||
perm_cancel_orders: Optional[bool] = None
|
||||
perm_apply_discounts: Optional[bool] = None
|
||||
perm_modify_prices: Optional[bool] = None
|
||||
perm_open_orders: Optional[bool] = None
|
||||
perm_close_orders: Optional[bool] = None
|
||||
# Management permissions
|
||||
perm_view_reports: Optional[bool] = None
|
||||
perm_manage_staff: Optional[bool] = None
|
||||
perm_manage_tables: Optional[bool] = None
|
||||
perm_manage_menu: Optional[bool] = None
|
||||
perm_manage_settings: Optional[bool] = None
|
||||
|
||||
|
||||
class WaiterZoneOut(BaseModel):
|
||||
@@ -49,6 +79,7 @@ class UserOut(UserBase):
|
||||
id: int
|
||||
created_at: UTCDatetime
|
||||
zone_assignments: List[WaiterZoneOut] = []
|
||||
waiter_settings: Optional[str] = None # JSON blob — per-waiter settings + favorites
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -68,3 +99,9 @@ class AssistantAssignmentOut(BaseModel):
|
||||
assigned_at: UTCDatetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PermissionToggle(BaseModel):
|
||||
"""Patch a single permission flag for a staff member."""
|
||||
permission: str
|
||||
value: bool
|
||||
|
||||
88
local_backend/services/chat_service.py
Normal file
88
local_backend/services/chat_service.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Chat service — system group bootstrap helpers.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.chat import Conversation, ConversationParticipant
|
||||
from models.user import User
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def ensure_system_group(db: Session) -> Conversation:
|
||||
"""
|
||||
Guarantee that the single system-wide 'Ομάδα' group conversation exists.
|
||||
If it is missing, create it and add all currently active users as participants.
|
||||
Returns the (possibly freshly created) Conversation.
|
||||
"""
|
||||
existing = (
|
||||
db.query(Conversation)
|
||||
.filter(Conversation.is_system == True) # noqa: E712
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Determine creator — first admin/manager user, or user id=1 as fallback
|
||||
creator = (
|
||||
db.query(User)
|
||||
.filter(User.perm_access_dashboard == True, User.is_active == True) # noqa: E712
|
||||
.order_by(User.id)
|
||||
.first()
|
||||
)
|
||||
creator_id = creator.id if creator else 1
|
||||
|
||||
conv = Conversation(
|
||||
type="group",
|
||||
name="Ομάδα",
|
||||
is_system=True,
|
||||
created_by=creator_id,
|
||||
)
|
||||
db.add(conv)
|
||||
db.flush() # get conv.id before adding participants
|
||||
|
||||
active_users = (
|
||||
db.query(User).filter(User.is_active == True).all() # noqa: E712
|
||||
)
|
||||
now = _utcnow()
|
||||
for u in active_users:
|
||||
participant = ConversationParticipant(
|
||||
conversation_id=conv.id,
|
||||
user_id=u.id,
|
||||
joined_at=now,
|
||||
)
|
||||
db.add(participant)
|
||||
|
||||
db.commit()
|
||||
db.refresh(conv)
|
||||
return conv
|
||||
|
||||
|
||||
def add_user_to_system_group(db: Session, user_id: int) -> None:
|
||||
"""
|
||||
Add a user to the system group if not already a participant.
|
||||
Safe to call even when the system group does not exist yet (it will be created).
|
||||
"""
|
||||
conv = ensure_system_group(db)
|
||||
|
||||
already = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
ConversationParticipant.conversation_id == conv.id,
|
||||
ConversationParticipant.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if already:
|
||||
return
|
||||
|
||||
participant = ConversationParticipant(
|
||||
conversation_id=conv.id,
|
||||
user_id=user_id,
|
||||
joined_at=_utcnow(),
|
||||
)
|
||||
db.add(participant)
|
||||
db.commit()
|
||||
@@ -279,7 +279,7 @@ async def _pull_pending_orders():
|
||||
try:
|
||||
# Use a system user id=1 (first manager/sysadmin) as the opener
|
||||
from models.user import User
|
||||
system_user = db.query(User).filter(User.role.in_(["sysadmin", "manager"])).first()
|
||||
system_user = db.query(User).filter(User.perm_access_dashboard == True).first()
|
||||
opener_id = system_user.id if system_user else 1
|
||||
|
||||
for cloud_order in orders:
|
||||
|
||||
280
local_backend/services/fiscal_service.py
Normal file
280
local_backend/services/fiscal_service.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Fiscal printer service for dTEC100extra ΦΗΜ machines.
|
||||
|
||||
Protocol: plain TXT files dropped into an OUT folder. The fiscal driver reads
|
||||
them, processes the receipt, then writes a reply into the IN folder.
|
||||
Files must be encoded in CP737 (Greek) with CRLF line endings.
|
||||
All item text must be UPPER CASE.
|
||||
|
||||
File naming: fp-YYMMDD-HHMM-NNNNNNN.txt (NNNNNNN = zero-padded counter)
|
||||
The driver deletes the command file after reading it and will never re-read the
|
||||
same filename, so uniqueness is critical.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Shared counter — persisted only in-process; restarts reset to 0 but the
|
||||
# date+time prefix keeps filenames unique across restarts.
|
||||
_counter_lock = threading.Lock()
|
||||
_counter = 0
|
||||
|
||||
|
||||
def _next_counter() -> int:
|
||||
global _counter
|
||||
with _counter_lock:
|
||||
_counter += 1
|
||||
return _counter
|
||||
|
||||
|
||||
def _unique_filename() -> str:
|
||||
now = datetime.now()
|
||||
date_part = now.strftime("%y%m%d")
|
||||
time_part = now.strftime("%H%M")
|
||||
seq = _next_counter()
|
||||
return f"fp-{date_part}-{time_part}-{seq:07d}.txt"
|
||||
|
||||
|
||||
def _get_folders(db) -> tuple[str, str]:
|
||||
"""Return (out_folder, in_folder) from pos_settings. Raises if not configured."""
|
||||
from models.settings import PosSettings
|
||||
rows = {
|
||||
r.key: r.value
|
||||
for r in db.query(PosSettings).filter(
|
||||
PosSettings.key.in_(["fiscal.out_folder", "fiscal.in_folder"])
|
||||
).all()
|
||||
}
|
||||
out_folder = rows.get("fiscal.out_folder", "").strip()
|
||||
in_folder = rows.get("fiscal.in_folder", "").strip()
|
||||
if not out_folder or not in_folder:
|
||||
raise ValueError("Fiscal folder paths are not configured")
|
||||
return out_folder, in_folder
|
||||
|
||||
|
||||
def _get_setting(db, key: str, default: str = "") -> str:
|
||||
from models.settings import PosSettings
|
||||
row = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||
return row.value if row else default
|
||||
|
||||
|
||||
def is_fiscal_enabled(db) -> bool:
|
||||
return _get_setting(db, "fiscal.enabled", "false") == "true"
|
||||
|
||||
|
||||
def _build_command_file(items: list[dict], payment_method: str, total: float, db) -> str:
|
||||
"""
|
||||
Build the fiscal command file content as a string.
|
||||
|
||||
items: list of {"name": str, "qty": int|float, "unit_price": float, "vat_group_id": int}
|
||||
payment_method: "cash" | "card"
|
||||
total: sum of all items (already computed by caller)
|
||||
"""
|
||||
clerk_id = _get_setting(db, "fiscal.clerk_id", "2")
|
||||
eftpos_id = _get_setting(db, "fiscal.eftpos_id", "1")
|
||||
end_message_raw = _get_setting(db, "fiscal.end_message", "[]")
|
||||
try:
|
||||
end_lines: list[str] = json.loads(end_message_raw)
|
||||
except Exception:
|
||||
end_lines = []
|
||||
|
||||
lines = ["FR"]
|
||||
|
||||
for item in items:
|
||||
name = (item["name"] or "").upper().strip()
|
||||
qty = item["qty"]
|
||||
price = item["unit_price"]
|
||||
vat = item["vat_group_id"]
|
||||
# Quantity: integer if whole, otherwise 3 decimal places (kg/liter support)
|
||||
if isinstance(qty, float) and qty != int(qty):
|
||||
qty_str = f"{qty:.3f}"
|
||||
else:
|
||||
qty_str = str(int(qty))
|
||||
price_str = f"{price:.2f}"
|
||||
lines.append(f"SI|{name}|{qty_str}|{price_str}|{vat}")
|
||||
|
||||
# End message lines (FM commands go BEFORE the close command)
|
||||
for i, msg_line in enumerate(end_lines, start=1):
|
||||
if msg_line.strip():
|
||||
lines.append(f"FM|{i}|{msg_line.upper()}")
|
||||
|
||||
if payment_method == "card":
|
||||
total_str = f"{total:.2f}"
|
||||
lines.append(f"CD|{clerk_id}|{eftpos_id}|1|{total_str}")
|
||||
else:
|
||||
lines.append(f"CR|{clerk_id}")
|
||||
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
|
||||
def _write_and_poll(items: list[dict], payment_method: str, total: float,
|
||||
out_folder: str, in_folder: str, content: str,
|
||||
filename: str, timeout_seconds: int) -> dict:
|
||||
"""Write the command file and poll for a reply. Pure logic, no DB access."""
|
||||
out_path = os.path.join(out_folder, filename)
|
||||
reply_filename = f"answer{filename}"
|
||||
in_path = os.path.join(in_folder, reply_filename)
|
||||
|
||||
try:
|
||||
with open(out_path, "w", encoding="cp737", errors="replace") as f:
|
||||
f.write(content)
|
||||
except OSError as e:
|
||||
return {"success": False, "filename": filename, "reply": None,
|
||||
"error": f"Failed to write fiscal file: {e}"}
|
||||
|
||||
logger.info("Fiscal: wrote %s (%d bytes)", out_path, len(content))
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
reply_content = None
|
||||
while time.monotonic() < deadline:
|
||||
if os.path.exists(in_path):
|
||||
try:
|
||||
with open(in_path, "r", encoding="cp737", errors="replace") as f:
|
||||
reply_content = f.read().strip()
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
|
||||
if reply_content is None:
|
||||
return {"success": False, "filename": filename, "reply": None,
|
||||
"error": f"Fiscal timeout: no reply after {timeout_seconds}s"}
|
||||
|
||||
first_line = reply_content.splitlines()[0].strip() if reply_content else "1"
|
||||
success = first_line == "0"
|
||||
|
||||
logger.info("Fiscal: reply for %s — success=%s content=%r", filename, success, reply_content)
|
||||
return {"success": success, "filename": filename, "reply": reply_content,
|
||||
"error": None if success else f"ΦΗΜ error: {reply_content}"}
|
||||
|
||||
|
||||
def send_fiscal_receipt(items: list[dict], payment_method: str, total: float, db,
|
||||
timeout_seconds: int = 30) -> dict:
|
||||
"""
|
||||
Build and send a fiscal receipt, then block waiting for the reply.
|
||||
Returns: {"success": bool, "filename": str, "reply": str | None, "error": str | None}
|
||||
"""
|
||||
out_folder, in_folder = _get_folders(db)
|
||||
os.makedirs(out_folder, exist_ok=True)
|
||||
os.makedirs(in_folder, exist_ok=True)
|
||||
filename = _unique_filename()
|
||||
content = _build_command_file(items, payment_method, total, db)
|
||||
return _write_and_poll(items, payment_method, total, out_folder, in_folder,
|
||||
content, filename, timeout_seconds)
|
||||
|
||||
|
||||
def send_fiscal_receipt_background(items: list[dict], payment_method: str, total: float,
|
||||
db, order_id: int, db_factory,
|
||||
timeout_seconds: int = 60) -> None:
|
||||
"""
|
||||
Write the fiscal file and poll for the reply in a background thread.
|
||||
Updates order.fiscal_status in the DB when done (success or failure/timeout).
|
||||
|
||||
db_factory: a callable () -> Session (e.g. SessionLocal) so the thread can
|
||||
open its own DB session independently of the request session.
|
||||
"""
|
||||
try:
|
||||
out_folder, in_folder = _get_folders(db)
|
||||
os.makedirs(out_folder, exist_ok=True)
|
||||
os.makedirs(in_folder, exist_ok=True)
|
||||
filename = _unique_filename()
|
||||
content = _build_command_file(items, payment_method, total, db)
|
||||
except Exception as e:
|
||||
logger.error("Fiscal background setup failed for order %s: %s", order_id, e)
|
||||
_update_fiscal_status(order_id, "failed", db_factory)
|
||||
return
|
||||
|
||||
def _run():
|
||||
result = _write_and_poll(items, payment_method, total, out_folder, in_folder,
|
||||
content, filename, timeout_seconds)
|
||||
status = "success" if result["success"] else "failed"
|
||||
_update_fiscal_status(order_id, status, db_factory)
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
def _update_fiscal_status(order_id: int, status: str, db_factory) -> None:
|
||||
"""Open a fresh DB session and set order.fiscal_status."""
|
||||
try:
|
||||
session = db_factory()
|
||||
try:
|
||||
from models.order import Order
|
||||
order = session.query(Order).filter(Order.id == order_id).first()
|
||||
if order:
|
||||
order.fiscal_status = status
|
||||
session.commit()
|
||||
logger.info("Fiscal: order %s fiscal_status → %s", order_id, status)
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.error("Fiscal: failed to update fiscal_status for order %s: %s", order_id, e)
|
||||
|
||||
|
||||
def validate_items_for_fiscal(order_items, db) -> list[str]:
|
||||
"""
|
||||
Check that every non-cancelled item in the list has a fiscal_vat_group_id.
|
||||
Returns a list of product names that are missing it (empty = all OK).
|
||||
"""
|
||||
missing = []
|
||||
for item in order_items:
|
||||
product = item.product
|
||||
if product is None:
|
||||
missing.append(f"#product_{item.product_id}")
|
||||
continue
|
||||
vat_group = getattr(product, "fiscal_vat_group_id", None)
|
||||
if not vat_group:
|
||||
missing.append(product.name)
|
||||
return missing
|
||||
|
||||
|
||||
def build_fiscal_items(order_items) -> list[dict]:
|
||||
"""
|
||||
Convert a list of OrderItem objects into the dicts that send_fiscal_receipt expects.
|
||||
Uses fiscal_name if set, else falls back to product.name.
|
||||
Applies price_adjustment to the unit price.
|
||||
"""
|
||||
result = []
|
||||
for item in order_items:
|
||||
product = item.product
|
||||
name = (getattr(product, "fiscal_name", None) or "").strip() or (product.name if product else f"#{item.product_id}")
|
||||
adj = getattr(item, "price_adjustment", 0.0) or 0.0
|
||||
unit_price = item.unit_price + adj
|
||||
result.append({
|
||||
"name": name,
|
||||
"qty": item.quantity,
|
||||
"unit_price": unit_price,
|
||||
"vat_group_id": product.fiscal_vat_group_id,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def test_folders(out_folder: str, in_folder: str) -> dict:
|
||||
"""
|
||||
Verify the two folders are accessible (read + write).
|
||||
Returns {"ok": bool, "out_folder": str|None, "in_folder": str|None}
|
||||
where each value is None if accessible, or an error string if not.
|
||||
"""
|
||||
def check(path):
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
test_file = os.path.join(path, ".fiscal_test")
|
||||
with open(test_file, "w") as f:
|
||||
f.write("test")
|
||||
os.remove(test_file)
|
||||
return None
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
|
||||
out_err = check(out_folder)
|
||||
in_err = check(in_folder)
|
||||
return {
|
||||
"ok": out_err is None and in_err is None,
|
||||
"out_folder_error": out_err,
|
||||
"in_folder_error": in_err,
|
||||
}
|
||||
727
local_backend/services/pricing.py
Normal file
727
local_backend/services/pricing.py
Normal file
@@ -0,0 +1,727 @@
|
||||
"""
|
||||
Pricing resolution engine.
|
||||
|
||||
resolve_price() is called once per OrderItem at add-to-order time.
|
||||
The result is the final snapshotted unit_price — it never changes after that.
|
||||
|
||||
Stacking algorithm (per design spec):
|
||||
1. Collect all passing modifiers for this product (item-scoped first, then global).
|
||||
2. Separate into stackable (allow_stack=1) and non-stackable piles.
|
||||
3. From the non-stackable pile, apply only the first passing one (lowest sort_order).
|
||||
4. From the stackable pile, apply ALL passing ones in sort_order order,
|
||||
each on the running price result.
|
||||
5. Apply system-wide rounding to nearest 0.10 as a final pass.
|
||||
|
||||
Deal evaluation:
|
||||
evaluate_deals() is called after items are added. It returns a list of DealOffer
|
||||
objects that the API caller surfaces to the waiter as prompts.
|
||||
"""
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone, time as dtime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from tz import to_local
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data structures (no ORM dependency — safe to import anywhere)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class OrderContext:
|
||||
"""Snapshot of order-level facts needed to evaluate conditions."""
|
||||
channel: str # 'pos' | 'online' | 'qr' | 'takeaway'
|
||||
now: datetime # current UTC time
|
||||
cart_items: list # list of {product_id, category_id, quantity, unit_price}
|
||||
active_price_group_ids: set # set of PriceGroup.id that are currently active
|
||||
user_tier: Optional[str] = None # 'bronze'|'silver'|'gold' — placeholder
|
||||
|
||||
|
||||
@dataclass
|
||||
class PriceEvent:
|
||||
"""In-memory representation of a price event, before it's written to DB."""
|
||||
order_id: int
|
||||
order_item_id: Optional[int] # set after OrderItem is committed
|
||||
event_type: str
|
||||
modifier_id: Optional[int] = None
|
||||
deal_id: Optional[int] = None
|
||||
price_before: Optional[float] = None
|
||||
price_after: Optional[float] = None
|
||||
delta_amount: Optional[float] = None
|
||||
applied_by_user_id: Optional[int] = None
|
||||
waiter_note: Optional[str] = None
|
||||
conditions_snapshot: Optional[dict] = None
|
||||
selected_item_ids: Optional[list] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DealOffer:
|
||||
"""Returned to the API when a deal is triggered and needs waiter confirmation."""
|
||||
deal_id: int
|
||||
deal_name: str
|
||||
action_type: str # mirrors Deal.action_type
|
||||
# For free_item: the single product to add
|
||||
free_item_id: Optional[int] = None
|
||||
free_item_name: Optional[str] = None
|
||||
# For free_choice: pool of selectable products
|
||||
free_choices: list = field(default_factory=list) # [{id, name}]
|
||||
free_quantity: int = 1
|
||||
# For price-modifying deals applied to a specific item
|
||||
target_order_item_id: Optional[int] = None
|
||||
price_before: Optional[float] = None
|
||||
price_after: Optional[float] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Condition evaluators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _eval_condition(ctype: str, params: dict, ctx: OrderContext,
|
||||
product_id: int, quantity: float) -> tuple[bool, dict]:
|
||||
"""
|
||||
Evaluate one condition. Returns (passed, snapshot_dict).
|
||||
snapshot_dict records the actual runtime value — written to price_event_log for audit.
|
||||
Unknown or placeholder conditions pass silently (forward-compatible).
|
||||
"""
|
||||
now_local = to_local(ctx.now)
|
||||
current_time = now_local.time()
|
||||
current_date = now_local.date()
|
||||
current_weekday = now_local.weekday() # Mon=0 Sun=6
|
||||
|
||||
if ctype == "time_range":
|
||||
t_from = dtime.fromisoformat(params["from"])
|
||||
t_to = dtime.fromisoformat(params["to"])
|
||||
if t_from <= t_to:
|
||||
passed = t_from <= current_time <= t_to
|
||||
else:
|
||||
# Crosses midnight
|
||||
passed = current_time >= t_from or current_time <= t_to
|
||||
return passed, {"time_range": current_time.strftime("%H:%M")}
|
||||
|
||||
if ctype == "date_range":
|
||||
from datetime import date
|
||||
d_from = date.fromisoformat(params["from"])
|
||||
d_to = date.fromisoformat(params["to"])
|
||||
passed = d_from <= current_date <= d_to
|
||||
return passed, {"date_range": current_date.isoformat()}
|
||||
|
||||
if ctype == "specific_date":
|
||||
from datetime import date
|
||||
passed = current_date.isoformat() in params.get("dates", [])
|
||||
return passed, {"specific_date": current_date.isoformat()}
|
||||
|
||||
if ctype == "day_of_week":
|
||||
passed = current_weekday in params.get("days", [])
|
||||
return passed, {"day_of_week": current_weekday}
|
||||
|
||||
if ctype == "min_item_quantity":
|
||||
cart_qty = sum(
|
||||
ci["quantity"] for ci in ctx.cart_items
|
||||
if ci["product_id"] == product_id
|
||||
)
|
||||
passed = cart_qty >= params.get("min", 1)
|
||||
return passed, {"min_item_quantity": cart_qty}
|
||||
|
||||
if ctype == "min_category_qty":
|
||||
cat_id = params.get("category_id")
|
||||
cart_qty = sum(
|
||||
ci["quantity"] for ci in ctx.cart_items
|
||||
if ci.get("category_id") == cat_id
|
||||
)
|
||||
passed = cart_qty >= params.get("min", 1)
|
||||
return passed, {"min_category_qty": cart_qty}
|
||||
|
||||
if ctype == "min_order_value":
|
||||
total = sum(ci["quantity"] * ci["unit_price"] for ci in ctx.cart_items)
|
||||
passed = total >= params.get("min", 0.0)
|
||||
return passed, {"min_order_value": round(total, 2)}
|
||||
|
||||
if ctype == "order_channel":
|
||||
passed = ctx.channel in params.get("channels", [])
|
||||
return passed, {"order_channel": ctx.channel}
|
||||
|
||||
if ctype == "price_group_active":
|
||||
pgid = params.get("price_group_id")
|
||||
passed = pgid in ctx.active_price_group_ids
|
||||
return passed, {"price_group_active": passed}
|
||||
|
||||
if ctype == "user_tier":
|
||||
passed = ctx.user_tier in params.get("tiers", [])
|
||||
return passed, {"user_tier": ctx.user_tier}
|
||||
|
||||
if ctype == "low_stock":
|
||||
# Placeholder until inventory is implemented — always passes silently
|
||||
return True, {"low_stock": "placeholder"}
|
||||
|
||||
# Unknown condition type — pass silently for forward compatibility
|
||||
return True, {ctype: "unknown_type_skipped"}
|
||||
|
||||
|
||||
def _all_conditions_pass(conditions, ctx: OrderContext,
|
||||
product_id: int, quantity: float) -> tuple[bool, dict]:
|
||||
"""AND-evaluate all conditions. Returns (all_passed, merged_snapshot)."""
|
||||
snapshot = {}
|
||||
for cond in conditions:
|
||||
params = json.loads(cond.params) if isinstance(cond.params, str) else cond.params
|
||||
passed, snap = _eval_condition(cond.condition_type, params, ctx, product_id, quantity)
|
||||
snapshot.update(snap)
|
||||
if not passed:
|
||||
return False, snapshot
|
||||
return True, snapshot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modifier action math
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _apply_action(price: float, action_type: str, action_value: float,
|
||||
round_to: Optional[str]) -> float:
|
||||
if action_type == "set":
|
||||
result = action_value
|
||||
elif action_type == "add_amount":
|
||||
result = price + action_value
|
||||
elif action_type == "add_percent":
|
||||
result = price * (1 + action_value / 100.0)
|
||||
else:
|
||||
result = price # unknown action — no-op
|
||||
|
||||
result = max(result, 0.0) # price can never go below 0
|
||||
|
||||
if round_to:
|
||||
result = _round_to(result, round_to)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _round_to(value: float, mode: str) -> float:
|
||||
if mode == "0.05":
|
||||
return round(value / 0.05) * 0.05
|
||||
if mode == "0.10":
|
||||
return round(value / 0.10) * 0.10
|
||||
if mode == "0.20":
|
||||
return round(value / 0.20) * 0.20
|
||||
if mode == "0.50":
|
||||
return round(value / 0.50) * 0.50
|
||||
if mode == "x.99":
|
||||
return float(int(value)) + 0.99 if value >= 1 else value
|
||||
if mode == "x.00":
|
||||
return float(round(value))
|
||||
return value
|
||||
|
||||
|
||||
def _system_round(value: float) -> float:
|
||||
"""System-wide final rounding: nearest 0.10."""
|
||||
return round(round(value / 0.10) * 0.10, 2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PriceGroup auto-schedule evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _price_group_is_active(pg, now: datetime) -> bool:
|
||||
"""
|
||||
Returns True if this price group should currently be considered active.
|
||||
Checks is_active flag, then auto-schedule if configured.
|
||||
"""
|
||||
if pg.auto_enable_time and pg.auto_disable_time and pg.auto_days:
|
||||
now_local = now.astimezone()
|
||||
weekday = now_local.weekday()
|
||||
try:
|
||||
auto_days = json.loads(pg.auto_days)
|
||||
except (ValueError, TypeError):
|
||||
auto_days = []
|
||||
if weekday in auto_days:
|
||||
t_enable = dtime.fromisoformat(pg.auto_enable_time)
|
||||
t_disable = dtime.fromisoformat(pg.auto_disable_time)
|
||||
current = now_local.time()
|
||||
if t_enable <= t_disable:
|
||||
return t_enable <= current <= t_disable
|
||||
else:
|
||||
return current >= t_enable or current <= t_disable
|
||||
return bool(pg.is_active)
|
||||
|
||||
|
||||
def build_active_price_group_ids(db: Session, now: datetime) -> set:
|
||||
"""Fetch all price groups once per request and return active IDs."""
|
||||
from models.pricing import PriceGroup
|
||||
groups = db.query(PriceGroup).all()
|
||||
return {pg.id for pg in groups if _price_group_is_active(pg, now)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modifier target matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_ids(raw) -> list:
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _modifier_targets_product(modifier, product, db: Session) -> bool:
|
||||
"""Return True if any target row on a global modifier covers this product."""
|
||||
from models.product import Product
|
||||
for t in modifier.targets:
|
||||
if t.target_type == "all":
|
||||
return True
|
||||
if t.target_type == "item":
|
||||
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
|
||||
if product.id in ids:
|
||||
return True
|
||||
if t.target_type == "category":
|
||||
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
|
||||
if product.category_id in ids:
|
||||
return True
|
||||
if t.target_type == "prep_zone":
|
||||
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
|
||||
zone_ids = {pz.id for pz in product.prep_zones}
|
||||
if any(i in zone_ids for i in ids):
|
||||
return True
|
||||
if t.target_type == "tag":
|
||||
prod_tags = set(json.loads(product.tags) if product.tags else [])
|
||||
tags = _parse_ids(t.target_tags) or ([t.target_tag] if t.target_tag else [])
|
||||
if any(tag in prod_tags for tag in tags):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_price(
|
||||
product,
|
||||
quantity: float,
|
||||
options_extra: float,
|
||||
ctx: OrderContext,
|
||||
db: Session,
|
||||
) -> tuple[float, list[PriceEvent]]:
|
||||
"""
|
||||
Compute the final unit_price for one order item.
|
||||
|
||||
Returns (final_unit_price, list_of_PriceEvent_to_log).
|
||||
PriceEvents have order_item_id=None — caller sets it after the OrderItem is committed.
|
||||
|
||||
order_id must be set on the PriceEvents before writing them; the caller does this.
|
||||
"""
|
||||
from models.pricing import PriceModifier
|
||||
|
||||
base = product.base_price + options_extra
|
||||
events: list[PriceEvent] = []
|
||||
|
||||
# --- Load modifiers ---
|
||||
# Item-scoped modifiers for this product
|
||||
item_modifiers = (
|
||||
db.query(PriceModifier)
|
||||
.filter(
|
||||
PriceModifier.scope == "item",
|
||||
PriceModifier.item_id == product.id,
|
||||
PriceModifier.is_active == 1,
|
||||
)
|
||||
.order_by(PriceModifier.sort_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Global modifiers (all active ones, filtered by target in Python)
|
||||
all_global = (
|
||||
db.query(PriceModifier)
|
||||
.filter(PriceModifier.scope == "global", PriceModifier.is_active == 1)
|
||||
.order_by(PriceModifier.sort_order)
|
||||
.all()
|
||||
)
|
||||
global_modifiers = [
|
||||
m for m in all_global
|
||||
if _modifier_targets_product(m, product, db)
|
||||
]
|
||||
|
||||
# Item-scoped take priority: evaluated first, then global appended
|
||||
all_modifiers = item_modifiers + global_modifiers
|
||||
|
||||
if not all_modifiers:
|
||||
return _system_round(base), events
|
||||
|
||||
# --- Separate stackable vs non-stackable ---
|
||||
stackable = []
|
||||
non_stackable = []
|
||||
for m in all_modifiers:
|
||||
passed, snapshot = _all_conditions_pass(m.conditions, ctx, product.id, quantity)
|
||||
if passed:
|
||||
if m.allow_stack:
|
||||
stackable.append((m, snapshot))
|
||||
else:
|
||||
non_stackable.append((m, snapshot))
|
||||
|
||||
price = base
|
||||
|
||||
# --- Apply first non-stackable ---
|
||||
if non_stackable:
|
||||
m, snapshot = non_stackable[0]
|
||||
price_before = price
|
||||
price = _apply_action(price, m.action_type, m.action_value, m.round_to)
|
||||
events.append(PriceEvent(
|
||||
order_id=0, # caller will fill in
|
||||
order_item_id=None,
|
||||
event_type="modifier_applied",
|
||||
modifier_id=m.id,
|
||||
price_before=price_before,
|
||||
price_after=price,
|
||||
delta_amount=round(price - price_before, 4),
|
||||
conditions_snapshot=snapshot,
|
||||
))
|
||||
|
||||
# --- Apply all stackable modifiers ---
|
||||
for m, snapshot in stackable:
|
||||
price_before = price
|
||||
price = _apply_action(price, m.action_type, m.action_value, m.round_to)
|
||||
events.append(PriceEvent(
|
||||
order_id=0,
|
||||
order_item_id=None,
|
||||
event_type="modifier_applied",
|
||||
modifier_id=m.id,
|
||||
price_before=price_before,
|
||||
price_after=price,
|
||||
delta_amount=round(price - price_before, 4),
|
||||
conditions_snapshot=snapshot,
|
||||
))
|
||||
|
||||
final = _system_round(price)
|
||||
|
||||
# Update the last event's price_after to reflect the final rounded value
|
||||
if events:
|
||||
events[-1].price_after = final
|
||||
events[-1].delta_amount = round(final - events[-1].price_before, 4)
|
||||
|
||||
return final, events
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deal evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _deal_targets_met(deal, ctx: OrderContext, db: Session) -> bool:
|
||||
"""Return True if the cart contains items that satisfy the deal's target rules."""
|
||||
from models.product import Product
|
||||
|
||||
if not deal.targets:
|
||||
return True # no target = always matches
|
||||
|
||||
for target in deal.targets:
|
||||
if target.target_type == "any":
|
||||
if ctx.cart_items:
|
||||
return True
|
||||
if target.target_type == "item":
|
||||
ids = _parse_ids(target.target_ids) or ([target.target_id] if target.target_id else [])
|
||||
if any(ci["product_id"] in ids for ci in ctx.cart_items):
|
||||
return True
|
||||
if target.target_type == "category":
|
||||
ids = _parse_ids(target.target_ids) or ([target.target_id] if target.target_id else [])
|
||||
if any(ci.get("category_id") in ids for ci in ctx.cart_items):
|
||||
return True
|
||||
if target.target_type == "tag":
|
||||
tags = _parse_ids(target.target_tags) or ([target.target_tag] if target.target_tag else [])
|
||||
if tags:
|
||||
for ci in ctx.cart_items:
|
||||
prod = db.query(Product).filter(Product.id == ci["product_id"]).first()
|
||||
if prod:
|
||||
prod_tags = set(json.loads(prod.tags) if prod.tags else [])
|
||||
if any(tag in prod_tags for tag in tags):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def evaluate_deals(
|
||||
order_id: int,
|
||||
ctx: OrderContext,
|
||||
already_fired_deal_ids: set,
|
||||
db: Session,
|
||||
) -> list[DealOffer]:
|
||||
"""
|
||||
Check all active deals against the current cart. Returns DealOffer objects
|
||||
for deals that are newly triggered (not already fired this order).
|
||||
|
||||
already_fired_deal_ids: set of deal IDs that have already been offered/applied
|
||||
on this order (derived from price_event_log by the caller).
|
||||
"""
|
||||
from models.pricing import Deal
|
||||
from models.product import Product
|
||||
|
||||
deals = db.query(Deal).filter(Deal.is_active == 1).order_by(Deal.sort_order).all()
|
||||
offers: list[DealOffer] = []
|
||||
|
||||
for deal in deals:
|
||||
if deal.id in already_fired_deal_ids:
|
||||
continue
|
||||
|
||||
# Check conditions
|
||||
conditions_pass, _ = _all_conditions_pass(deal.conditions, ctx, 0, 0)
|
||||
if not conditions_pass:
|
||||
continue
|
||||
|
||||
# Check cart targets
|
||||
if not _deal_targets_met(deal, ctx, db):
|
||||
continue
|
||||
|
||||
# Build the offer
|
||||
offer = DealOffer(
|
||||
deal_id=deal.id,
|
||||
deal_name=deal.name,
|
||||
action_type=deal.action_type,
|
||||
)
|
||||
|
||||
if deal.action_type == "free_item" and deal.action_free_item_id:
|
||||
prod = db.query(Product).filter(Product.id == deal.action_free_item_id).first()
|
||||
offer.free_item_id = deal.action_free_item_id
|
||||
offer.free_item_name = prod.name if prod else None
|
||||
offer.free_quantity = deal.action_free_quantity
|
||||
|
||||
elif deal.action_type == "free_choice" and deal.action_free_target_ids:
|
||||
try:
|
||||
target_ids = json.loads(deal.action_free_target_ids)
|
||||
except (ValueError, TypeError):
|
||||
target_ids = []
|
||||
|
||||
if deal.action_free_target_type == "item":
|
||||
prods = db.query(Product).filter(Product.id.in_(target_ids)).all()
|
||||
offer.free_choices = [{"id": p.id, "name": p.name} for p in prods]
|
||||
elif deal.action_free_target_type == "category":
|
||||
prods = db.query(Product).filter(
|
||||
Product.category_id.in_(target_ids),
|
||||
Product.lifecycle_status == "active",
|
||||
Product.is_available == True, # noqa: E712
|
||||
).all()
|
||||
offer.free_choices = [{"id": p.id, "name": p.name} for p in prods]
|
||||
elif deal.action_free_target_type == "tag":
|
||||
all_prods = db.query(Product).filter(
|
||||
Product.lifecycle_status == "active",
|
||||
Product.is_available == True, # noqa: E712
|
||||
).all()
|
||||
offer.free_choices = [
|
||||
{"id": p.id, "name": p.name}
|
||||
for p in all_prods
|
||||
if any(tag in (json.loads(p.tags) if p.tags else []) for tag in target_ids)
|
||||
]
|
||||
offer.free_quantity = deal.action_free_quantity
|
||||
|
||||
offers.append(offer)
|
||||
|
||||
return offers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Waiter discount limit enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_waiter_discount_limits(
|
||||
waiter_id: int,
|
||||
order_id: int,
|
||||
item_ids: list[int],
|
||||
discount_percent: float,
|
||||
item_unit_prices: dict[int, float], # {order_item_id: current_unit_price}
|
||||
db: Session,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate that applying the given discount does not exceed any configured limit.
|
||||
Returns (ok, error_message). error_message is empty string when ok=True.
|
||||
"""
|
||||
from models.pricing import WaiterDiscountSettings
|
||||
from models.order import OrderDiscount
|
||||
from models.settings import PosSettings
|
||||
from models.business_day import BusinessDay
|
||||
from models.shift import WaiterShift
|
||||
|
||||
# --- Global kill-switch ---
|
||||
global_row = db.query(PosSettings).filter(PosSettings.key == "discounts.enabled").first()
|
||||
if global_row and global_row.value == "false":
|
||||
return False, "Discounts are globally disabled"
|
||||
|
||||
# --- Per-waiter settings ---
|
||||
settings = db.query(WaiterDiscountSettings).filter(
|
||||
WaiterDiscountSettings.user_id == waiter_id
|
||||
).first()
|
||||
|
||||
if not settings or not settings.can_apply_discounts:
|
||||
return False, "You do not have permission to apply discounts"
|
||||
|
||||
# --- Validate percent limits ---
|
||||
if settings.max_discount_percent is not None:
|
||||
if discount_percent > settings.max_discount_percent:
|
||||
return False, f"Discount exceeds your maximum of {settings.max_discount_percent:.0f}%"
|
||||
|
||||
# --- Compute proposed euro amounts ---
|
||||
proposed_amounts = {
|
||||
item_id: round(unit_price * discount_percent / 100.0, 2)
|
||||
for item_id, unit_price in item_unit_prices.items()
|
||||
if item_id in item_ids
|
||||
}
|
||||
proposed_total = sum(proposed_amounts.values())
|
||||
proposed_count = len(item_ids)
|
||||
|
||||
# --- Per-item amount limit ---
|
||||
if settings.max_discount_amount is not None:
|
||||
for item_id, amount in proposed_amounts.items():
|
||||
if amount > settings.max_discount_amount:
|
||||
return False, f"Single-item discount of €{amount:.2f} exceeds your limit of €{settings.max_discount_amount:.2f}"
|
||||
|
||||
# --- Items per order ---
|
||||
if settings.max_items_per_order is not None:
|
||||
existing_count_order = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.order_id == order_id
|
||||
).count()
|
||||
if existing_count_order + proposed_count > settings.max_items_per_order:
|
||||
remaining = settings.max_items_per_order - existing_count_order
|
||||
return False, f"You can only discount {remaining} more item(s) on this order"
|
||||
|
||||
# --- Current workday ---
|
||||
bd = db.query(BusinessDay).filter(BusinessDay.closed_at == None).first() # noqa: E711
|
||||
if bd:
|
||||
# Items per workday
|
||||
if settings.max_items_per_workday is not None:
|
||||
from models.order import Order
|
||||
wd_order_ids = [
|
||||
o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()
|
||||
]
|
||||
wd_count = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.applied_by == waiter_id,
|
||||
OrderDiscount.order_id.in_(wd_order_ids),
|
||||
).count()
|
||||
if wd_count + proposed_count > settings.max_items_per_workday:
|
||||
remaining = settings.max_items_per_workday - wd_count
|
||||
return False, f"You can only discount {remaining} more item(s) today"
|
||||
|
||||
# Total value per workday
|
||||
if settings.max_total_value_workday is not None:
|
||||
from models.order import Order
|
||||
wd_order_ids = [
|
||||
o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()
|
||||
]
|
||||
existing_rows = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.applied_by == waiter_id,
|
||||
OrderDiscount.order_id.in_(wd_order_ids),
|
||||
).all()
|
||||
existing_wd_total = sum(_discount_euro(r) for r in existing_rows)
|
||||
if existing_wd_total + proposed_total > settings.max_total_value_workday:
|
||||
remaining = settings.max_total_value_workday - existing_wd_total
|
||||
return False, f"You have only €{remaining:.2f} of discount budget left today"
|
||||
|
||||
# --- Current shift ---
|
||||
shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == waiter_id,
|
||||
WaiterShift.ended_at == None, # noqa: E711
|
||||
).order_by(WaiterShift.started_at.desc()).first()
|
||||
|
||||
if shift:
|
||||
# Items per shift
|
||||
if settings.max_items_per_shift is not None:
|
||||
from models.order import Order
|
||||
shift_order_ids = [
|
||||
o.id for o in db.query(Order).filter(
|
||||
Order.opened_at >= shift.started_at
|
||||
).all()
|
||||
]
|
||||
shift_count = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.applied_by == waiter_id,
|
||||
OrderDiscount.order_id.in_(shift_order_ids),
|
||||
).count()
|
||||
if shift_count + proposed_count > settings.max_items_per_shift:
|
||||
remaining = settings.max_items_per_shift - shift_count
|
||||
return False, f"You can only discount {remaining} more item(s) this shift"
|
||||
|
||||
# Total value per shift
|
||||
if settings.max_total_value_shift is not None:
|
||||
from models.order import Order
|
||||
shift_order_ids = [
|
||||
o.id for o in db.query(Order).filter(
|
||||
Order.opened_at >= shift.started_at
|
||||
).all()
|
||||
]
|
||||
existing_rows = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.applied_by == waiter_id,
|
||||
OrderDiscount.order_id.in_(shift_order_ids),
|
||||
).all()
|
||||
existing_shift_total = sum(_discount_euro(r) for r in existing_rows)
|
||||
if existing_shift_total + proposed_total > settings.max_total_value_shift:
|
||||
remaining = settings.max_total_value_shift - existing_shift_total
|
||||
return False, f"You have only €{remaining:.2f} of discount budget left this shift"
|
||||
|
||||
# --- Global limits from pos_settings ---
|
||||
def _gs(key):
|
||||
row = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||
return float(row.value) if row and row.value else None
|
||||
|
||||
g_max_wd_value = _gs("discounts.max_total_value_workday")
|
||||
g_max_shift_val = _gs("discounts.max_total_value_shift")
|
||||
g_max_shift_cnt = _gs("discounts.max_items_per_shift")
|
||||
|
||||
if bd and g_max_wd_value is not None:
|
||||
from models.order import Order
|
||||
wd_order_ids = [o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()]
|
||||
all_wd_rows = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.order_id.in_(wd_order_ids)
|
||||
).all()
|
||||
all_wd_total = sum(_discount_euro(r) for r in all_wd_rows)
|
||||
if all_wd_total + proposed_total > g_max_wd_value:
|
||||
remaining = g_max_wd_value - all_wd_total
|
||||
return False, f"Store-wide discount budget has only €{remaining:.2f} left today"
|
||||
|
||||
if shift and (g_max_shift_val is not None or g_max_shift_cnt is not None):
|
||||
from models.order import Order
|
||||
shift_order_ids = [
|
||||
o.id for o in db.query(Order).filter(Order.opened_at >= shift.started_at).all()
|
||||
]
|
||||
all_shift_rows = db.query(OrderDiscount).filter(
|
||||
OrderDiscount.order_id.in_(shift_order_ids)
|
||||
).all()
|
||||
if g_max_shift_val is not None:
|
||||
total = sum(_discount_euro(r) for r in all_shift_rows)
|
||||
if total + proposed_total > g_max_shift_val:
|
||||
remaining = g_max_shift_val - total
|
||||
return False, f"Store-wide shift discount budget has only €{remaining:.2f} left"
|
||||
if g_max_shift_cnt is not None:
|
||||
count = len(all_shift_rows)
|
||||
if count + proposed_count > int(g_max_shift_cnt):
|
||||
remaining = int(g_max_shift_cnt) - count
|
||||
return False, f"Store-wide shift item limit: only {remaining} discount(s) left"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def _discount_euro(row) -> float:
|
||||
"""Convert an OrderDiscount row to a euro amount."""
|
||||
if row.price_before is not None and row.price_after is not None:
|
||||
return max(0.0, row.price_before - row.price_after)
|
||||
# Fallback for legacy rows without price_before/after
|
||||
if row.discount_type == "fixed":
|
||||
return row.discount_value
|
||||
return 0.0 # percent rows without snapshots can't be computed retroactively
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_price_events(events: list[PriceEvent], order_id: int,
|
||||
order_item_id: int, db: Session):
|
||||
"""Persist PriceEvent objects to price_event_log. Call after OrderItem is committed."""
|
||||
from models.pricing import PriceEventLog
|
||||
for ev in events:
|
||||
db.add(PriceEventLog(
|
||||
order_id=order_id,
|
||||
order_item_id=order_item_id,
|
||||
event_type=ev.event_type,
|
||||
modifier_id=ev.modifier_id,
|
||||
deal_id=ev.deal_id,
|
||||
price_before=ev.price_before,
|
||||
price_after=ev.price_after,
|
||||
delta_amount=ev.delta_amount,
|
||||
applied_by_user_id=ev.applied_by_user_id,
|
||||
waiter_note=ev.waiter_note,
|
||||
conditions_snapshot=json.dumps(ev.conditions_snapshot) if ev.conditions_snapshot else None,
|
||||
selected_item_ids=json.dumps(ev.selected_item_ids) if ev.selected_item_ids else None,
|
||||
))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,8 +54,14 @@ async def unsubscribe(user_id: int, q: asyncio.Queue) -> None:
|
||||
def broadcast_sync(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
|
||||
"""
|
||||
Fire-and-forget broadcast from a synchronous FastAPI route (thread-pool worker).
|
||||
Uses call_soon_threadsafe so the coroutine runs on the main event loop, not the thread.
|
||||
Delegates to ws_bus which persists the event and pushes to WS clients.
|
||||
Also pushes to legacy SSE clients so the old endpoint keeps working during transition.
|
||||
"""
|
||||
# WS bus handles persistence + WS delivery
|
||||
from services.ws_bus import broadcast_sync as ws_broadcast_sync
|
||||
ws_broadcast_sync(event_type, data, user_ids=user_ids)
|
||||
|
||||
# Legacy SSE push (no persistence needed — WS is the source of truth)
|
||||
if _main_loop is None:
|
||||
return
|
||||
_main_loop.call_soon_threadsafe(
|
||||
|
||||
170
local_backend/services/ws_bus.py
Normal file
170
local_backend/services/ws_bus.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
WebSocket Event Bus — replaces sse_bus.py for real-time communication.
|
||||
|
||||
Drop-in replacement: all routers continue calling broadcast_sync() unchanged.
|
||||
The WS endpoint uses connect()/disconnect()/replay_missed() to manage clients.
|
||||
|
||||
Protocol (JSON frames):
|
||||
Server → Client: { "seq": 123, "type": "order_updated", "data": { ... } }
|
||||
Client → Server: { "cursor": 120 } (sent immediately after connect)
|
||||
Server → Client: { "type": "ping" } (every 25s keepalive)
|
||||
Client → Server: { "type": "pong" } (optional, ignored if not sent)
|
||||
|
||||
On connect the client sends its last known seq. The server replays everything
|
||||
it has stored since that seq, then switches to live streaming. Events are stored
|
||||
in the sync_events SQLite table (written here, pruned on startup).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Set
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Event loop (captured once at startup) ────────────────────────────────────
|
||||
|
||||
_main_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
def init_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global _main_loop
|
||||
_main_loop = loop
|
||||
|
||||
|
||||
# ── Connected clients: user_id → set of asyncio.Queue ────────────────────────
|
||||
|
||||
_connections: Dict[int, Set[asyncio.Queue]] = {}
|
||||
|
||||
|
||||
async def connect(user_id: int) -> asyncio.Queue:
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=512)
|
||||
if user_id not in _connections:
|
||||
_connections[user_id] = set()
|
||||
_connections[user_id].add(q)
|
||||
return q
|
||||
|
||||
|
||||
async def disconnect(user_id: int, q: asyncio.Queue) -> None:
|
||||
if user_id in _connections:
|
||||
_connections[user_id].discard(q)
|
||||
if not _connections[user_id]:
|
||||
del _connections[user_id]
|
||||
|
||||
|
||||
# ── Persistence helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _get_db():
|
||||
from database import SessionLocal
|
||||
return SessionLocal()
|
||||
|
||||
|
||||
def _persist_event(event_type: str, data: dict, user_ids: list[int] | None) -> int:
|
||||
"""Write event to sync_events table, return the new seq_id."""
|
||||
from sqlalchemy import text
|
||||
db = _get_db()
|
||||
try:
|
||||
result = db.execute(
|
||||
text(
|
||||
"INSERT INTO sync_events (event_type, payload, target_user_ids, created_at) "
|
||||
"VALUES (:et, :payload, :uids, :now)"
|
||||
),
|
||||
{
|
||||
"et": event_type,
|
||||
"payload": json.dumps(data),
|
||||
"uids": json.dumps(user_ids) if user_ids is not None else None,
|
||||
"now": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return result.lastrowid
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_events_since(cursor: int, user_id: int) -> list[dict]:
|
||||
"""Return all events with seq_id > cursor that are visible to user_id."""
|
||||
from sqlalchemy import text
|
||||
db = _get_db()
|
||||
try:
|
||||
rows = db.execute(
|
||||
text(
|
||||
"SELECT id, event_type, payload, target_user_ids FROM sync_events "
|
||||
"WHERE id > :cursor ORDER BY id ASC LIMIT 500"
|
||||
),
|
||||
{"cursor": cursor},
|
||||
).fetchall()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
events = []
|
||||
for row in rows:
|
||||
target_user_ids = json.loads(row[3]) if row[3] else None
|
||||
if target_user_ids is None or user_id in target_user_ids:
|
||||
events.append({
|
||||
"seq": row[0],
|
||||
"type": row[1],
|
||||
"data": json.loads(row[2]),
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def prune_old_events(hours: int = 24) -> int:
|
||||
"""Delete events older than `hours`. Called on startup."""
|
||||
from sqlalchemy import text
|
||||
db = _get_db()
|
||||
try:
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
|
||||
result = db.execute(
|
||||
text("DELETE FROM sync_events WHERE created_at < :cutoff"),
|
||||
{"cutoff": cutoff},
|
||||
)
|
||||
db.commit()
|
||||
return result.rowcount
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── Broadcast ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def broadcast_sync(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
|
||||
"""
|
||||
Called from sync route thread-pool workers.
|
||||
Persists the event to DB (so reconnecting clients can replay it),
|
||||
then schedules an async push to all currently connected sockets.
|
||||
"""
|
||||
try:
|
||||
seq_id = _persist_event(event_type, data, user_ids)
|
||||
except Exception:
|
||||
logger.exception("ws_bus: failed to persist event %s", event_type)
|
||||
seq_id = 0
|
||||
|
||||
if _main_loop is None:
|
||||
return
|
||||
_main_loop.call_soon_threadsafe(
|
||||
_main_loop.create_task,
|
||||
_broadcast_live(seq_id, event_type, data, user_ids),
|
||||
)
|
||||
|
||||
|
||||
async def _broadcast_live(
|
||||
seq_id: int,
|
||||
event_type: str,
|
||||
data: dict,
|
||||
user_ids: list[int] | None,
|
||||
) -> None:
|
||||
frame = json.dumps({"seq": seq_id, "type": event_type, "data": data})
|
||||
targets = (
|
||||
{uid: qs for uid, qs in _connections.items() if uid in user_ids}
|
||||
if user_ids is not None
|
||||
else dict(_connections)
|
||||
)
|
||||
for qs in targets.values():
|
||||
for q in list(qs):
|
||||
try:
|
||||
q.put_nowait(frame)
|
||||
except asyncio.QueueFull:
|
||||
pass # slow client — drop live frame; they'll replay on reconnect
|
||||
@@ -25,17 +25,25 @@ if answer != "YES":
|
||||
from sqlalchemy import text, inspect
|
||||
from database import engine, Base, SessionLocal
|
||||
|
||||
# Import all models so Base.metadata knows about every table
|
||||
# Import all models so Base.metadata knows about every table.
|
||||
# Order matters: tables with no FK dependencies first, then dependents.
|
||||
import models.user # noqa: F401
|
||||
import models.table # noqa: F401
|
||||
import models.printer # noqa: F401
|
||||
import models.product # noqa: F401
|
||||
import models.customers # noqa: F401 must be before models.order (orders.customer_id FK)
|
||||
import models.order # noqa: F401
|
||||
import models.business_day # noqa: F401
|
||||
import models.shift # noqa: F401
|
||||
import models.settings # noqa: F401
|
||||
import models.flag # noqa: F401
|
||||
import models.message # noqa: F401
|
||||
import models.notes # noqa: F401
|
||||
import models.expenses # noqa: F401
|
||||
import models.tabs # noqa: F401
|
||||
import models.schedule # noqa: F401
|
||||
import models.waste # noqa: F401
|
||||
import models.reservation # noqa: F401
|
||||
|
||||
print("\nDropping all tables...")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user