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:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View File

@@ -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"}

View File

@@ -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()

View File

@@ -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"])

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

View File

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

View File

@@ -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"

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

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

View File

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

View File

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

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

View File

@@ -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)

View File

@@ -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.

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

View File

@@ -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
View 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

View File

@@ -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}

View File

@@ -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,

View 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

View File

@@ -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

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

View File

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

View File

@@ -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

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

View 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}

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

View File

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

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

View File

@@ -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

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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(

View File

@@ -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(),
}

View File

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

View File

@@ -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()

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

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

View File

@@ -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

View File

@@ -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}

View 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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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

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

View File

@@ -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:

View 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,
}

View 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

View File

@@ -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(

View 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

View File

@@ -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...")