diff --git a/.env.example b/.env.example index d844930..7e3c2dd 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ CLOUD_URL=https://xenia-admin.bonamin.gr SECRET_KEY=generate-with-openssl-rand-hex-32 LICENSE_GRACE_HOURS=24 +# Break-glass support account (leave blank to disable) +MASTER_USERNAME= +MASTER_PASSWORD= + # Volumes — absolute paths on the client machine DATA_PATH=/opt/xenia/data LOGO_PATH=/opt/xenia/logo.png diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8f46efd..cf3f799 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,6 +4,8 @@ services: image: ${REGISTRY}/pos-backend:${VERSION:-latest} ports: - "8000:8000" + volumes: + - ${FISCAL_PATH:-./fiscal_test}:/mnt/fiscal waiter_pwa: build: diff --git a/docker-compose.yml b/docker-compose.yml index 00d098c..0a951ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,9 +11,12 @@ services: - DATABASE_URL=sqlite:////app/data/pos.db - VERSION=${VERSION:-0.0.0} - HOST_IP=${HOST_IP:-} + - MASTER_USERNAME=${MASTER_USERNAME:-} + - MASTER_PASSWORD=${MASTER_PASSWORD:-} volumes: - ${DATA_PATH}:/app/data - ${LOGO_PATH}:/app/logo.png:ro + - ${FISCAL_PATH}:/mnt/fiscal waiter_pwa: image: ${REGISTRY}/pos-waiter:${VERSION:-latest} diff --git a/local_backend/config.py b/local_backend/config.py index 09e7a80..677dd6d 100644 --- a/local_backend/config.py +++ b/local_backend/config.py @@ -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"} diff --git a/local_backend/demo_seed.py b/local_backend/demo_seed.py index 4f6d57e..ee8be07 100644 --- a/local_backend/demo_seed.py +++ b/local_backend/demo_seed.py @@ -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() diff --git a/local_backend/main.py b/local_backend/main.py index b9dc207..3924d65 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -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"]) diff --git a/local_backend/models/chat.py b/local_backend/models/chat.py new file mode 100644 index 0000000..cb47f76 --- /dev/null +++ b/local_backend/models/chat.py @@ -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") diff --git a/local_backend/models/message.py b/local_backend/models/message.py index 4b593e3..2a7b8e1 100644 --- a/local_backend/models/message.py +++ b/local_backend/models/message.py @@ -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]) diff --git a/local_backend/models/order.py b/local_backend/models/order.py index 55b117c..b65c177 100644 --- a/local_backend/models/order.py +++ b/local_backend/models/order.py @@ -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" diff --git a/local_backend/models/prep_zone.py b/local_backend/models/prep_zone.py new file mode 100644 index 0000000..aa94710 --- /dev/null +++ b/local_backend/models/prep_zone.py @@ -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") diff --git a/local_backend/models/pricing.py b/local_backend/models/pricing.py new file mode 100644 index 0000000..7450a36 --- /dev/null +++ b/local_backend/models/pricing.py @@ -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]) diff --git a/local_backend/models/printer.py b/local_backend/models/printer.py index b25ec6e..6aac8de 100644 --- a/local_backend/models/printer.py +++ b/local_backend/models/printer.py @@ -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") diff --git a/local_backend/models/product.py b/local_backend/models/product.py index 841b002..29a77a5 100644 --- a/local_backend/models/product.py +++ b/local_backend/models/product.py @@ -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") diff --git a/local_backend/models/recovery_code.py b/local_backend/models/recovery_code.py new file mode 100644 index 0000000..be50b89 --- /dev/null +++ b/local_backend/models/recovery_code.py @@ -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") diff --git a/local_backend/models/table.py b/local_backend/models/table.py index d1f4fe8..dfb6c9e 100644 --- a/local_backend/models/table.py +++ b/local_backend/models/table.py @@ -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) diff --git a/local_backend/models/user.py b/local_backend/models/user.py index f4448f9..9b6cfc4 100644 --- a/local_backend/models/user.py +++ b/local_backend/models/user.py @@ -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. diff --git a/local_backend/print_codepage_probe.py b/local_backend/print_codepage_probe.py new file mode 100644 index 0000000..c212172 --- /dev/null +++ b/local_backend/print_codepage_probe.py @@ -0,0 +1,114 @@ +""" +Greek code page probe — NETUM NT8330L (or any unknown printer) +Usage: python print_codepage_probe.py [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() diff --git a/local_backend/requirements.txt b/local_backend/requirements.txt index 714a374..a016c5d 100644 --- a/local_backend/requirements.txt +++ b/local_backend/requirements.txt @@ -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 diff --git a/local_backend/roles.py b/local_backend/roles.py new file mode 100644 index 0000000..acdde6a --- /dev/null +++ b/local_backend/roles.py @@ -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 diff --git a/local_backend/routers/auth.py b/local_backend/routers/auth.py index 592bab1..9f7ebb9 100644 --- a/local_backend/routers/auth.py +++ b/local_backend/routers/auth.py @@ -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} diff --git a/local_backend/routers/business_day.py b/local_backend/routers/business_day.py index 77ed20e..dfb1255 100644 --- a/local_backend/routers/business_day.py +++ b/local_backend/routers/business_day.py @@ -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, diff --git a/local_backend/routers/chat.py b/local_backend/routers/chat.py new file mode 100644 index 0000000..82434c3 --- /dev/null +++ b/local_backend/routers/chat.py @@ -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 diff --git a/local_backend/routers/deps.py b/local_backend/routers/deps.py index c5c52cd..ae60a23 100644 --- a/local_backend/routers/deps.py +++ b/local_backend/routers/deps.py @@ -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 diff --git a/local_backend/routers/fiscal.py b/local_backend/routers/fiscal.py new file mode 100644 index 0000000..6615d11 --- /dev/null +++ b/local_backend/routers/fiscal.py @@ -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()) diff --git a/local_backend/routers/kds.py b/local_backend/routers/kds.py index 597bb6b..b6b8e88 100644 --- a/local_backend/routers/kds.py +++ b/local_backend/routers/kds.py @@ -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") diff --git a/local_backend/routers/messages.py b/local_backend/routers/messages.py index ece8894..5df7122 100644 --- a/local_backend/routers/messages.py +++ b/local_backend/routers/messages.py @@ -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], ) diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py index 9723155..81560ad 100644 --- a/local_backend/routers/orders.py +++ b/local_backend/routers/orders.py @@ -15,11 +15,16 @@ from sqlalchemy.orm import Session from typing import List, Optional from database import get_db -from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog +from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog, PrintJob, OrderDiscount from models.user import User, WaiterZone from models.table import Table from models.product import Product from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, OfflinePaymentRequest, AssignWaiterRequest, OrderWaiterOut +from services.pricing import ( + OrderContext, resolve_price, evaluate_deals, + build_active_price_group_ids, write_price_events, + check_waiter_discount_limits, _system_round, +) from pydantic import BaseModel class PrintOrderRequest(BaseModel): @@ -41,6 +46,9 @@ class MoveItemsRequest(BaseModel): item_ids: List[int] target_order_id: int +class RevertPaymentRequest(BaseModel): + item_ids: List[int] + from routers.deps import get_current_user, require_manager from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis, print_cancellation_ticket from models.settings import PosSettings @@ -57,12 +65,16 @@ def _waiter_can_cancel(user: User, db: Session) -> bool: def _can_access_order(order: Order, user: User, db: Session) -> bool: - """Zone-based access: any waiter whose zone covers the order's table group may act on it.""" - if user.role in ("manager", "sysadmin"): + """Zone-based access: any waiter whose zone covers the order's table group may act on it. + Standalone orders (table_id=None) are accessible to all waiters with any zone assignment.""" + if user.role == "superadmin" or user.perm_access_dashboard: return True zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all() if not zones: return False + # Standalone orders (takeaway/delivery) have no table — accessible to all zone-assigned waiters + if order.table_id is None: + return True if any(z.group_id is None for z in zones): return True table = db.query(Table).filter(Table.id == order.table_id).first() @@ -88,10 +100,109 @@ def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None, ACTIVE_STATUSES = ["open", "partially_paid", "paid"] +@router.get("/shift-log") +def shift_order_log( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + Returns all orders placed during the current waiter's active shift, newest first. + Managers get all orders for the current business day. + Includes print job status per order. + """ + from models.shift import WaiterShift + from models.business_day import BusinessDay + + is_manager = user.role == "superadmin" or user.perm_access_dashboard + + if is_manager: + bd = db.query(BusinessDay).filter(BusinessDay.closed_at == None).order_by(BusinessDay.opened_at.desc()).first() # noqa: E711 + since = bd.opened_at if bd else None + q = db.query(Order).filter(Order.status.notin_(["cancelled"])) + if since: + q = q.filter(Order.opened_at >= since) + else: + shift = db.query(WaiterShift).filter( + WaiterShift.waiter_id == user.id, + WaiterShift.ended_at == None, # noqa: E711 + ).order_by(WaiterShift.started_at.desc()).first() + if not shift: + return {"orders": []} + from models.order import OrderWaiter + order_ids = [ow.order_id for ow in db.query(OrderWaiter).filter(OrderWaiter.waiter_id == user.id).all()] + q = db.query(Order).filter( + Order.id.in_(order_ids), + Order.opened_at >= shift.started_at, + Order.status.notin_(["cancelled"]), + ) + + orders = q.order_by(Order.opened_at.desc()).limit(200).all() + + result = [] + for order in orders: + table_name = None + if order.table: + table_name = order.table.label or f"T{order.table.number}" + + # Collect print jobs for this order + jobs = db.query(PrintJob).filter(PrintJob.order_id == order.id).all() + has_pending_print = any(j.status == "pending" for j in jobs) + print_status = "success" + if has_pending_print: + print_status = "pending" + elif any(j.status == "cancelled" and j.cancel_reason == "staff_cancelled" for j in jobs): + print_status = "cancelled" + elif not jobs: + # No print jobs at all — either no printers configured or bypass mode + print_status = "success" + + latest_attempt = max((j.last_attempted_at for j in jobs if j.last_attempted_at), default=None) + max_retries = max((j.retry_count for j in jobs), default=0) + + active_items = [i for i in order.items if i.status == "active"] + paid_items = [i for i in order.items if i.status == "paid"] + all_items = active_items + paid_items + + result.append({ + "id": order.id, + "table_name": table_name, + "opened_at": order.opened_at.isoformat() if order.opened_at else None, + "status": order.status, + "print_status": print_status, + "fiscal_status": order.fiscal_status, # None | "pending" | "success" | "failed" + "print_attempted_at": latest_attempt.isoformat() if latest_attempt else None, + "print_retry_count": max_retries, + "print_jobs": [ + { + "id": j.id, + "status": j.status, + "retry_count": j.retry_count, + "first_attempted_at": j.first_attempted_at.isoformat() if j.first_attempted_at else None, + "last_attempted_at": j.last_attempted_at.isoformat() if j.last_attempted_at else None, + } + for j in jobs + ], + "items": [ + { + "id": i.id, + "product_name": i.product.name if i.product else f"#{i.product_id}", + "quantity": i.quantity, + "unit_price": i.unit_price, + "price_adjustment": i.price_adjustment or 0.0, + "status": i.status, + } + for i in all_items + ], + "total": sum((i.unit_price + (i.price_adjustment or 0.0)) * i.quantity for i in all_items), + }) + + return {"orders": result} + + @router.get("/cancel-permissions") def cancel_permissions(db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Return whether the current user is allowed to cancel orders.""" - is_manager = user.role in ("manager", "sysadmin") + is_manager = user.role == "superadmin" or user.perm_access_dashboard if is_manager: return {"can_cancel": True, "reason": None} can = _waiter_can_cancel(user, db) @@ -141,8 +252,9 @@ def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_us class ActiveOrderSlim(BaseModel): id: int - table_id: int + table_id: Optional[int] status: str + order_type: str = "here" waiter_ids: List[int] model_config = {"from_attributes": True} @@ -156,12 +268,56 @@ def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_c id=o.id, table_id=o.table_id, status=o.status, + order_type=o.order_type or "here", waiter_ids=[w.waiter_id for w in o.waiters], ) for o in orders ] +# ─── Pending print jobs count (for PWA header badge) ───────────────────────── + +@router.get("/pending-prints") +def get_pending_prints( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Returns count of pending print jobs + per-order detail for the current shift.""" + pending_jobs = db.query(PrintJob).filter(PrintJob.status == "pending").all() + + from collections import defaultdict + by_order: dict = defaultdict(list) + for pj in pending_jobs: + by_order[pj.order_id].append(pj) + + orders_data = [] + for order_id, jobs in by_order.items(): + order = db.query(Order).filter(Order.id == order_id).first() + if not order or order.status in ("closed", "cancelled"): + continue + table_name = None + if order.table: + table_name = order.table.label or f"T{order.table.number}" + orders_data.append({ + "order_id": order_id, + "table_name": table_name, + "opened_at": order.opened_at.isoformat() if order.opened_at else None, + "jobs": [ + { + "id": pj.id, + "printer_id": pj.printer_id, + "retry_count": pj.retry_count, + "first_attempted_at": pj.first_attempted_at.isoformat() if pj.first_attempted_at else None, + "last_attempted_at": pj.last_attempted_at.isoformat() if pj.last_attempted_at else None, + "item_ids": json.loads(pj.item_ids or "[]"), + } + for pj in jobs + ], + }) + + return {"count": len(pending_jobs), "orders": orders_data} + + @router.get("/{order_id}") def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): order = db.query(Order).filter(Order.id == order_id).first() @@ -200,6 +356,7 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends "removed_ingredients": i.removed_ingredients, "notes": i.notes, "status": i.status, + "kds_status": i.kds_status or "pending", "added_at": _iso(i.added_at), "printed": i.printed, "paid_by": i.paid_by, @@ -207,6 +364,8 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends "paid_at": _iso(i.paid_at), "payment_method": i.payment_method, "paid_in_shift_id": i.paid_in_shift_id, + "price_adjustment": getattr(i, 'price_adjustment', 0.0) or 0.0, + "unit_type": i.unit_type, } def fmt_log(l): @@ -241,6 +400,7 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends "id": order.id, "table_id": order.table_id, "table_name": table_name, + "order_type": order.order_type or "here", "opened_by": order.opened_by, "opened_at": _iso(order.opened_at), "status": order.status, @@ -251,6 +411,9 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends "customer_id": order.customer_id, "customer_name": customer_name, "customer_phone": customer_phone, + "kds_status": order.kds_status, + "kds_status_changed_at": _iso(order.kds_status_changed_at), + "customer_count": order.customer_count, "items": [fmt_item(i) for i in order.items], "waiters": [{"waiter_id": w.waiter_id} for w in order.waiters], "audit_logs": [fmt_log(l) for l in order.audit_logs], @@ -262,11 +425,14 @@ def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = De from models.business_day import BusinessDay from models.shift import WaiterShift + if not (user.role == "superadmin" or user.perm_access_dashboard or user.perm_open_orders): + raise HTTPException(status_code=403, detail="Δεν έχετε δικαίωμα ανοίγματος παραγγελιών") + active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() if not active_day: raise HTTPException(status_code=403, detail="Restaurant is not open — manager must open the business day first") - if user.role == "waiter": + if user.perm_access_waiter_app and not user.perm_access_dashboard and user.role != "superadmin": active_shift = db.query(WaiterShift).filter( WaiterShift.waiter_id == user.id, WaiterShift.ended_at == None, @@ -274,13 +440,19 @@ def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = De if not active_shift: raise HTTPException(status_code=403, detail="You do not have an active shift") - existing = db.query(Order).filter( - Order.table_id == body.table_id, - Order.status.in_(["open", "partially_paid", "paid"]), - ).first() - if existing: - raise HTTPException(status_code=400, detail="Table already has an open order") - order = Order(table_id=body.table_id, opened_by=user.id, business_day_id=active_day.id) + order_type = body.order_type or "here" + if order_type not in ("here", "takeaway", "delivery"): + raise HTTPException(status_code=400, detail="Invalid order_type") + + if body.table_id is not None: + existing = db.query(Order).filter( + Order.table_id == body.table_id, + Order.status.in_(["open", "partially_paid", "paid"]), + ).first() + if existing: + raise HTTPException(status_code=400, detail="Table already has an open order") + + order = Order(table_id=body.table_id, order_type=order_type, opened_by=user.id, business_day_id=active_day.id) db.add(order) db.flush() db.add(OrderWaiter(order_id=order.id, waiter_id=user.id)) @@ -311,14 +483,63 @@ def add_items( order.status = "partially_paid" new_item_ids = [] + service_print_entries = [] # { product, quantity } — printed but not saved + pending_price_events = [] # (order_item_id, [PriceEvent]) — flushed after commit + + # Build pricing context once for the whole batch + now = datetime.now(timezone.utc) + active_pg_ids = build_active_price_group_ids(db, now) + # Determine order channel + _channel_map = {"online": "online", "takeaway": "takeaway", "delivery": "takeaway"} + order_channel = _channel_map.get(order.order_type, "pos") + if order.source == "online": + order_channel = "online" + # Snapshot existing cart items for condition evaluation + existing_cart = [ + { + "product_id": i.product_id, + "category_id": i.product.category_id if i.product else None, + "quantity": i.quantity, + "unit_price": i.unit_price, + } + for i in order.items if i.status == "active" + ] + for item_in in body.items: product = db.query(Product).filter(Product.id == item_in.product_id).first() if not product or not product.is_available: raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available") + + # Priceless service items: print-once, NOT persisted (ephemeral) + # Priced service items: saved as real order items AND printed in a SERVICE section + is_svc = item_in.is_service_item or getattr(product, 'is_service_item', False) + if is_svc and not (product.base_price and product.base_price > 0): + service_print_entries.append({'product': product, 'quantity': item_in.quantity}) + continue + extra_cost = sum( (o.price_delta or o.extra_cost or 0.0) for o in (item_in.selected_options or []) ) + + # Build OrderContext for this item (includes items added so far in this batch) + ctx = OrderContext( + channel=order_channel, + now=now, + cart_items=existing_cart + [ + { + "product_id": product.id, + "category_id": product.category_id, + "quantity": item_in.quantity, + "unit_price": product.base_price + extra_cost, + } + ], + active_price_group_ids=active_pg_ids, + ) + + # Resolve final unit_price through pricing engine + unit_price, price_events = resolve_price(product, item_in.quantity, extra_cost, ctx, db) + # Phase 2A: cost snapshot — breakdown takes priority over simple cost unit_cost = None if product.cost_breakdown: @@ -331,28 +552,154 @@ def add_items( pass if unit_cost is None and product.cost_simple: unit_cost = product.cost_simple + item = OrderItem( order_id=order_id, product_id=item_in.product_id, added_by=user.id, quantity=item_in.quantity, - unit_price=product.base_price + extra_cost, + unit_price=unit_price, unit_cost=unit_cost, + price_adjustment=item_in.price_adjustment or 0.0, selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None, removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None, notes=item_in.notes, + course_id=item_in.course_id, ) db.add(item) db.flush() new_item_ids.append(item.id) + # Stamp order_id on events and queue them — written after commit + if price_events: + for ev in price_events: + ev.order_id = order_id + pending_price_events.append((item.id, price_events)) + + # Update rolling cart snapshot so subsequent items in this batch see correct context + existing_cart.append({ + "product_id": product.id, + "category_id": product.category_id, + "quantity": item_in.quantity, + "unit_price": unit_price, + }) + + # Attach the whole-order note (only overwrite if a note was explicitly provided) + if body.order_note is not None: + order.notes = body.order_note or None # empty string → clear note + + # Update customer count if provided + if body.customer_count is not None: + order.customer_count = body.customer_count + + bypass_setting = db.query(PosSettings).filter(PosSettings.key == "orders.bypass_kds_serve").first() + global_bypass_kds = bypass_setting and bypass_setting.value == "true" + + if global_bypass_kds: + # Global setting: all new items go straight to served + for item in order.items: + if item.id in new_item_ids: + item.kds_status = "served" + order.kds_status = "served" + else: + # Apply per-zone bypass for each new item individually + from models.prep_zone import PrepZone + new_items_map = {item.id: item for item in order.items if item.id in set(new_item_ids)} + for item in new_items_map.values(): + product = db.query(Product).filter(Product.id == item.product_id).first() + if not product or not product.prep_zones: + continue + # Zone with strongest bypass wins: bypass_kds > bypass_pending + zone_bypass_kds = any(bool(z.bypass_kds) for z in product.prep_zones) + zone_bypass_pending = any(bool(z.bypass_pending) for z in product.prep_zones) + if zone_bypass_kds: + item.kds_status = "served" + elif zone_bypass_pending: + item.kds_status = "done" + + # Recalculate order-level kds_status from all items + all_active = [i for i in order.items if i.status != "cancelled"] + if all_active: + statuses = {i.kds_status for i in all_active} + if statuses <= {"served"}: + order.kds_status = "served" + elif statuses <= {"done", "served"}: + order.kds_status = "done" + elif "pending" in statuses or "preparing" in statuses: + # Pull back to pending so kitchen sees new items + if order.kds_status in ("done", "served"): + order.kds_status = "pending" + _audit(db, order_id, "ITEMS_ADDED", waiter_id=user.id, item_ids=new_item_ids) db.commit() + + # Write price events now that OrderItems are committed and have real IDs + if pending_price_events: + for item_id, events in pending_price_events: + write_price_events(events, order_id, item_id, db) + db.commit() + db.refresh(order) - print_results = route_and_print_sync(order_id, new_item_ids, db) + # Evaluate deals against updated cart — return offers to surface to waiter + from models.pricing import PriceEventLog + already_fired = { + row.deal_id for row in + db.query(PriceEventLog.deal_id).filter( + PriceEventLog.order_id == order_id, + PriceEventLog.deal_id != None, # noqa: E711 + PriceEventLog.event_type.in_(["deal_offer_shown", "deal_offer_accepted"]), + ).all() + if row.deal_id is not None + } + final_cart = [ + { + "product_id": i.product_id, + "category_id": i.product.category_id if i.product else None, + "quantity": i.quantity, + "unit_price": i.unit_price, + } + for i in order.items if i.status == "active" + ] + deal_ctx = OrderContext( + channel=order_channel, + now=now, + cart_items=final_cart, + active_price_group_ids=active_pg_ids, + ) + deal_offers = evaluate_deals(order_id, deal_ctx, already_fired, db) + + # Log deal_offer_shown for each new offer + if deal_offers: + for offer in deal_offers: + db.add(PriceEventLog( + order_id=order_id, + event_type="deal_offer_shown", + deal_id=offer.deal_id, + applied_by_user_id=user.id, + )) + db.commit() + + print_results = route_and_print_sync(order_id, new_item_ids, db, ephemeral_svc_entries=service_print_entries) if (new_item_ids or service_print_entries) else [] broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "items_added", "item_ids": new_item_ids}) - return {"order": order, "print_results": print_results} + broadcast_sync("kds_order_updated", {"order_id": order.id, "kds_status": order.kds_status}) + + return { + "order": order, + "print_results": print_results, + "deal_offers": [ + { + "deal_id": o.deal_id, + "deal_name": o.deal_name, + "action_type": o.action_type, + "free_item_id": o.free_item_id, + "free_item_name": o.free_item_name, + "free_choices": o.free_choices, + "free_quantity": o.free_quantity, + } + for o in deal_offers + ], + } @router.post("/{order_id}/retry-print", response_model=AddItemsResponse) @@ -401,7 +748,7 @@ def cancel_item( if not order: raise HTTPException(status_code=404, detail="Order not found") - is_manager = user.role in ("manager", "sysadmin") + is_manager = user.role == "superadmin" or user.perm_access_dashboard if not is_manager and not _waiter_can_cancel(user, db): raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας") if not is_manager and not _can_access_order(order, user, db): @@ -417,12 +764,26 @@ def cancel_item( item.status = "cancelled" item.cancelled_by = user.id item.cancelled_at = now - _audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id]) + cancelled_ids = [item_id] + + # Cascade: also cancel any deal-linked items bound to this one (free items that came with it) + linked = db.query(OrderItem).filter( + OrderItem.linked_item_id == item_id, + OrderItem.order_id == order_id, + OrderItem.status == "active", + ).all() + for li in linked: + li.status = "cancelled" + li.cancelled_by = user.id + li.cancelled_at = now + cancelled_ids.append(li.id) + + _audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=cancelled_ids) db.commit() should_print = print_cancellation if is_manager else True if should_print: - background_tasks.add_task(print_cancellation_ticket, order_id, [item_id]) + background_tasks.add_task(print_cancellation_ticket, order_id, cancelled_ids) @router.post("/{order_id}/pay") @@ -434,12 +795,28 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db raise HTTPException(status_code=403, detail="Access denied") from models.shift import WaiterShift + from services.fiscal_service import ( + is_fiscal_enabled, validate_items_for_fiscal, build_fiscal_items, + send_fiscal_receipt_background, + ) items = db.query(OrderItem).filter( OrderItem.id.in_(body.item_ids), OrderItem.order_id == order_id, OrderItem.status.in_(["active", "ready"]), # ready items are also payable ).all() + + # Fiscal validation: if enabled, all items must have VAT group assigned before payment + fiscal_on = is_fiscal_enabled(db) + if fiscal_on and items: + missing = validate_items_for_fiscal(items, db) + if missing: + names = ", ".join(missing) + raise HTTPException( + status_code=422, + detail=f"Fiscal printing is ON but the following products have no VAT group assigned: {names}" + ) + now = datetime.now(timezone.utc) active_shift = db.query(WaiterShift).filter( WaiterShift.waiter_id == user.id, @@ -453,7 +830,8 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db item.paid_at = now item.payment_method = effective_method item.paid_in_shift_id = active_shift.id if active_shift else None - total_paid += item.unit_price * item.quantity + adj = getattr(item, 'price_adjustment', 0.0) or 0.0 + total_paid += (item.unit_price + adj) * item.quantity db.flush() # write item status changes before counting, since autoflush=False active_remaining = db.query(OrderItem).filter( @@ -464,9 +842,90 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db paid_ids = [i.id for i in items] _audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids, amount=total_paid, payment_method=body.payment_method) + + auto_closed = False + if order.status == "paid": + setting = db.query(PosSettings).filter(PosSettings.key == "orders.auto_close_on_full_payment").first() + if setting and setting.value == "true": + for oi in order.items: + if oi.kds_status != "served": + oi.kds_status = "served" + order.kds_status = "served" + order.status = "closed" + order.closed_at = now + order.closed_by = user.id + auto_closed = True + + if fiscal_on and items: + order.fiscal_status = "pending" + db.commit() broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method}) - return {"status": order.status, "paid_item_ids": paid_ids} + if auto_closed: + broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id, "auto_closed": True}) + + if fiscal_on and items: + from database import SessionLocal + fiscal_items = build_fiscal_items(items) + send_fiscal_receipt_background(fiscal_items, effective_method, total_paid, db, + order_id=order_id, db_factory=SessionLocal, + timeout_seconds=60) + + return { + "status": order.status, + "paid_item_ids": paid_ids, + "auto_closed": auto_closed, + "fiscal": "pending" if fiscal_on and items else None, + } + + +@router.post("/{order_id}/revert-payment") +def revert_payment(order_id: int, body: RevertPaymentRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + + revert_setting = db.query(PosSettings).filter(PosSettings.key == "payments.waiter_revert_allowed").first() + if not revert_setting or revert_setting.value != "true": + raise HTTPException(status_code=403, detail="Payment revert is not enabled") + + items = db.query(OrderItem).filter( + OrderItem.id.in_(body.item_ids), + OrderItem.order_id == order_id, + OrderItem.status == "paid", + ).all() + + if not items: + raise HTTPException(status_code=404, detail="No paid items found to revert") + + reverted_ids = [] + for item in items: + item.status = "active" + item.paid_by = None + item.paid_at = None + item.payment_method = None + item.paid_in_shift_id = None + reverted_ids.append(item.id) + + db.flush() + active_remaining = db.query(OrderItem).filter( + OrderItem.order_id == order_id, OrderItem.status.in_(["active", "ready"]) + ).count() + paid_remaining = db.query(OrderItem).filter( + OrderItem.order_id == order_id, OrderItem.status == "paid" + ).count() + if active_remaining > 0 and paid_remaining > 0: + order.status = "partially_paid" + elif active_remaining > 0: + order.status = "open" + # if somehow all remaining are paid after revert (shouldn't happen), leave status + + _audit(db, order_id, "PAYMENT_REVERTED", waiter_id=user.id, item_ids=reverted_ids) + db.commit() + broadcast_sync("order_updated", {"order_id": order_id, "table_id": order.table_id, "status": order.status}) + return {"status": order.status, "reverted_item_ids": reverted_ids} @router.post("/{order_id}/close") @@ -474,6 +933,8 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") + if not (user.role == "superadmin" or user.perm_access_dashboard or user.perm_close_orders): + raise HTTPException(status_code=403, detail="Δεν έχετε δικαίωμα κλεισίματος παραγγελιών") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") if order.status not in ("paid", "open", "partially_paid"): @@ -491,6 +952,12 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen item.status = "closed" closed_item_ids.append(item.id) + # Mark every non-served item as served on KDS so the order disappears from KDS immediately + for item in order.items: + if item.kds_status != "served": + item.kds_status = "served" + order.kds_status = "served" + order.status = "closed" order.closed_at = now order.closed_by = user.id @@ -561,7 +1028,8 @@ def pay_items_offline( item.paid_at = paid_at item.payment_method = effective_method item.paid_in_shift_id = active_shift.id if active_shift else None - total_paid += item.unit_price * item.quantity + adj = getattr(item, 'price_adjustment', 0.0) or 0.0 + total_paid += (item.unit_price + adj) * item.quantity paid_ids.append(item.id) db.flush() @@ -571,7 +1039,7 @@ def pay_items_offline( order.status = "paid" if active_remaining == 0 else "partially_paid" else: # Duplicate — compute total for audit record without changing item state - total_paid = sum(i.unit_price * i.quantity for i in items) + total_paid = sum((i.unit_price + (getattr(i, 'price_adjustment', 0.0) or 0.0)) * i.quantity for i in items) paid_ids = [i.id for i in items] # Always write audit log — duplicate flag makes it visible in red in manager dashboard @@ -611,7 +1079,7 @@ def cancel_order( if not order: raise HTTPException(status_code=404, detail="Order not found") - is_manager = user.role in ("manager", "sysadmin") + is_manager = user.role == "superadmin" or user.perm_access_dashboard if not is_manager and not _waiter_can_cancel(user, db): raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας") if not is_manager and not _can_access_order(order, user, db): @@ -663,7 +1131,7 @@ def cancel_items_bulk( if not order: raise HTTPException(status_code=404, detail="Order not found") - is_manager = user.role in ("manager", "sysadmin") + is_manager = user.role == "superadmin" or user.perm_access_dashboard if not is_manager and not _waiter_can_cancel(user, db): raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας") if not is_manager and not _can_access_order(order, user, db): @@ -686,6 +1154,18 @@ def cancel_items_bulk( item.cancelled_at = now cancelled_ids.append(item.id) + # Cascade: cancel deal-linked items (free items bound to this trigger item) + linked = db.query(OrderItem).filter( + OrderItem.linked_item_id == item.id, + OrderItem.order_id == order_id, + OrderItem.status == "active", + ).all() + for li in linked: + li.status = "cancelled" + li.cancelled_by = user.id + li.cancelled_at = now + cancelled_ids.append(li.id) + if cancelled_ids: _audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=cancelled_ids) db.commit() @@ -776,11 +1256,14 @@ def print_order( if item.status == "cancelled": continue product_name = item.product.name if item.product else f"#{item.product_id}" + adj = getattr(item, 'price_adjustment', 0.0) or 0.0 + effective_price = item.unit_price + adj items_data.append({ "name": product_name, "quantity": item.quantity, - "unit_price": item.unit_price, - "total": item.unit_price * item.quantity, + "unit_type": item.product.unit_type if item.product else "piece", + "unit_price": effective_price, + "total": effective_price * item.quantity, "status": item.status, }) @@ -798,7 +1281,7 @@ def print_order( "notes": order.notes, } - background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt, printer.line_width) + background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt, printer.line_width, printer.codepage_n) return {"status": "printing"} @@ -972,6 +1455,265 @@ def split_item( return [item, new_item] +# ─── Adjust price on an existing item ──────────────────────────────────────── + +class PriceAdjustRequest(BaseModel): + price_adjustment: float # delta from unit_price (can be negative) + + +@router.patch("/{order_id}/items/{item_id}/price-adjust", response_model=OrderItemOut) +def adjust_item_price( + order_id: int, + item_id: int, + body: PriceAdjustRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Apply an on-the-fly price adjustment to an active order item.""" + if not (user.role == "superadmin" or user.perm_access_dashboard or user.perm_modify_prices): + raise HTTPException(status_code=403, detail="Δεν έχετε δικαίωμα τροποποίησης τιμών") + setting = db.query(PosSettings).filter(PosSettings.key == "orders.waiter_price_adjust_allowed").first() + if not setting or setting.value != "true": + raise HTTPException(status_code=403, detail="Price adjustment is disabled") + + item = db.query(OrderItem).filter( + OrderItem.id == item_id, OrderItem.order_id == order_id + ).first() + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if item.status != "active": + raise HTTPException(status_code=400, detail="Only active items can have price adjusted") + + order = db.query(Order).filter(Order.id == order_id).first() + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + + item.price_adjustment = body.price_adjustment + db.commit() + db.refresh(item) + broadcast_sync("order_updated", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "action": "item_price_adjusted"}) + return item + + +# ─── Waiter-applied manual discount ───────────────────────────────────────── + +from schemas.pricing import ApplyDiscountRequest, ApplyDiscountResponse + + +@router.post("/{order_id}/items/discount", response_model=ApplyDiscountResponse) +def apply_waiter_discount( + order_id: int, + body: ApplyDiscountRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + Apply a manual percentage discount to one or more active items. + Enforces per-waiter and global limits. Logs every application to + both order_discounts and price_event_log. + Final item price rounds to nearest 0.10. + """ + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not (user.role == "superadmin" or user.perm_access_dashboard or user.perm_apply_discounts): + raise HTTPException(status_code=403, detail="Δεν έχετε δικαίωμα εφαρμογής εκπτώσεων") + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + + if body.discount_percent <= 0 or body.discount_percent > 100: + raise HTTPException(status_code=400, detail="discount_percent must be between 0 and 100") + + # Fetch all target items + items = db.query(OrderItem).filter( + OrderItem.id.in_(body.item_ids), + OrderItem.order_id == order_id, + OrderItem.status == "active", + ).all() + if not items: + raise HTTPException(status_code=404, detail="No active items found for given IDs") + + item_unit_prices = {i.id: i.unit_price + i.price_adjustment for i in items} + + # Validate limits before touching anything + ok, error_msg = check_waiter_discount_limits( + waiter_id=user.id, + order_id=order_id, + item_ids=body.item_ids, + discount_percent=body.discount_percent, + item_unit_prices=item_unit_prices, + db=db, + ) + if not ok: + raise HTTPException(status_code=403, detail=error_msg) + + from models.pricing import PriceEventLog + + applied = [] + for item in items: + effective_before = item.unit_price + item.price_adjustment + discount_euro = round(effective_before * body.discount_percent / 100.0, 4) + new_effective = _system_round(effective_before - discount_euro) + # Store as price_adjustment delta (keeps unit_price as the original resolver result) + new_adj = round(new_effective - item.unit_price, 4) + + price_before = effective_before + price_after = new_effective + + item.price_adjustment = new_adj + + # Write to order_discounts (existing table, extended) + db.add(OrderDiscount( + order_id=order_id, + item_id=item.id, + discount_type="percent", + discount_value=body.discount_percent, + applied_by=user.id, + reason=body.note, + price_before=price_before, + price_after=price_after, + )) + + # Write to price_event_log (immutable audit spine) + db.add(PriceEventLog( + order_id=order_id, + order_item_id=item.id, + event_type="waiter_discount", + price_before=price_before, + price_after=price_after, + delta_amount=round(price_after - price_before, 4), + applied_by_user_id=user.id, + waiter_note=body.note, + )) + + applied.append({ + "item_id": item.id, + "price_before": round(price_before, 2), + "price_after": round(price_after, 2), + "delta": round(price_after - price_before, 2), + }) + + db.commit() + broadcast_sync("order_updated", { + "order_id": order_id, + "table_id": order.table_id, + "status": order.status, + "action": "discount_applied", + }) + + # Return remaining budget info for UX display + from models.pricing import WaiterDiscountSettings + wds = db.query(WaiterDiscountSettings).filter( + WaiterDiscountSettings.user_id == user.id + ).first() + + remaining_shift = None + remaining_workday = None + + if wds: + from models.business_day import BusinessDay + from models.shift import WaiterShift + + bd = db.query(BusinessDay).filter(BusinessDay.closed_at == None).first() # noqa: E711 + if bd and wds.max_total_value_workday is not None: + from models.order import Order as _Order + wd_order_ids = [o.id for o in db.query(_Order).filter(_Order.business_day_id == bd.id).all()] + wd_rows = db.query(OrderDiscount).filter( + OrderDiscount.applied_by == user.id, + OrderDiscount.order_id.in_(wd_order_ids), + ).all() + used = sum( + max(0, (r.price_before or 0) - (r.price_after or 0)) + for r in wd_rows + ) + remaining_workday = round(wds.max_total_value_workday - used, 2) + + shift = db.query(WaiterShift).filter( + WaiterShift.waiter_id == user.id, + WaiterShift.ended_at == None, # noqa: E711 + ).order_by(WaiterShift.started_at.desc()).first() + if shift and wds.max_total_value_shift is not None: + from models.order import Order as _Order + shift_order_ids = [ + o.id for o in db.query(_Order).filter(_Order.opened_at >= shift.started_at).all() + ] + shift_rows = db.query(OrderDiscount).filter( + OrderDiscount.applied_by == user.id, + OrderDiscount.order_id.in_(shift_order_ids), + ).all() + used = sum( + max(0, (r.price_before or 0) - (r.price_after or 0)) + for r in shift_rows + ) + remaining_shift = round(wds.max_total_value_shift - used, 2) + + return ApplyDiscountResponse( + applied=applied, + remaining_budget_shift=remaining_shift, + remaining_budget_workday=remaining_workday, + ) + + +# ─── Cancel a pending print job ────────────────────────────────────────────── + +@router.post("/{order_id}/print-jobs/{job_id}/cancel") +def cancel_print_job( + order_id: int, + job_id: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Staff cancels a pending print job (failed print, still retrying).""" + pj = db.query(PrintJob).filter(PrintJob.id == job_id, PrintJob.order_id == order_id).first() + if not pj: + raise HTTPException(status_code=404, detail="Print job not found") + if pj.status != "pending": + raise HTTPException(status_code=400, detail="Only pending print jobs can be cancelled") + + order = db.query(Order).filter(Order.id == order_id).first() + if not order or not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + + from datetime import datetime, timezone + pj.status = "cancelled" + pj.cancelled_at = datetime.now(timezone.utc) + pj.cancel_reason = "staff_cancelled" + + # Mark affected items as printed so the UI warning goes away + item_ids = json.loads(pj.item_ids or "[]") + for item in db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all(): + item.printed = True + + db.commit() + broadcast_sync("order_updated", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "action": "print_job_cancelled"}) + return {"ok": True, "job_id": job_id} + + +# ─── Update customer count on an order ─────────────────────────────────────── + +class CustomerCountRequest(BaseModel): + customer_count: Optional[int] = None + + +@router.patch("/{order_id}/customer-count") +def update_customer_count( + order_id: int, + body: CustomerCountRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + + order.customer_count = body.customer_count + db.commit() + broadcast_sync("order_updated", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "action": "customer_count_updated"}) + return {"customer_count": order.customer_count} + + # ─── Move selected items to another order ──────────────────────────────────── @router.post("/{order_id}/move-items") @@ -1071,11 +1813,14 @@ def print_synopsis( if item.status == "cancelled": continue product_name = item.product.name if item.product else f"#{item.product_id}" + adj = getattr(item, 'price_adjustment', 0.0) or 0.0 + effective_price = item.unit_price + adj items_data.append({ "name": product_name, "quantity": item.quantity, - "unit_price": item.unit_price, - "total": item.unit_price * item.quantity, + "unit_type": item.product.unit_type if item.product else "piece", + "unit_price": effective_price, + "total": effective_price * item.quantity, "status": item.status, }) @@ -1093,7 +1838,7 @@ def print_synopsis( "remaining": total - paid_total, } - background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis, printer.line_width) + background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis, printer.line_width, printer.codepage_n) return {"status": "printing"} diff --git a/local_backend/routers/phone.py b/local_backend/routers/phone.py new file mode 100644 index 0000000..0a6c39d --- /dev/null +++ b/local_backend/routers/phone.py @@ -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)} diff --git a/local_backend/routers/prep_zones.py b/local_backend/routers/prep_zones.py new file mode 100644 index 0000000..a6aa42e --- /dev/null +++ b/local_backend/routers/prep_zones.py @@ -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} diff --git a/local_backend/routers/pricing.py b/local_backend/routers/pricing.py new file mode 100644 index 0000000..986109e --- /dev/null +++ b/local_backend/routers/pricing.py @@ -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, + )) diff --git a/local_backend/routers/products.py b/local_backend/routers/products.py index a1809b3..fdba87e 100644 --- a/local_backend/routers/products.py +++ b/local_backend/routers/products.py @@ -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") diff --git a/local_backend/routers/recovery.py b/local_backend/routers/recovery.py new file mode 100644 index 0000000..32ffbbf --- /dev/null +++ b/local_backend/routers/recovery.py @@ -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) diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index 961b90d..5f9557f 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -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 diff --git a/local_backend/routers/schedule.py b/local_backend/routers/schedule.py index 6a991f3..b44cef3 100644 --- a/local_backend/routers/schedule.py +++ b/local_backend/routers/schedule.py @@ -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: diff --git a/local_backend/routers/settings.py b/local_backend/routers/settings.py index 8d7fdf8..e3dd149 100644 --- a/local_backend/routers/settings.py +++ b/local_backend/routers/settings.py @@ -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}") diff --git a/local_backend/routers/setup.py b/local_backend/routers/setup.py index 78f8b0d..e7e803c 100644 --- a/local_backend/routers/setup.py +++ b/local_backend/routers/setup.py @@ -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) diff --git a/local_backend/routers/shifts.py b/local_backend/routers/shifts.py index 148d0f9..6369c5e 100644 --- a/local_backend/routers/shifts.py +++ b/local_backend/routers/shifts.py @@ -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( diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py index 49e14a4..f6d6058 100644 --- a/local_backend/routers/system.py +++ b/local_backend/routers/system.py @@ -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(), } diff --git a/local_backend/routers/tables.py b/local_backend/routers/tables.py index 52d82ad..0b598e0 100644 --- a/local_backend/routers/tables.py +++ b/local_backend/routers/tables.py @@ -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") diff --git a/local_backend/routers/waiters.py b/local_backend/routers/waiters.py index 3761a1f..022bd52 100644 --- a/local_backend/routers/waiters.py +++ b/local_backend/routers/waiters.py @@ -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() diff --git a/local_backend/routers/ws.py b/local_backend/routers/ws.py new file mode 100644 index 0000000..f4a3d59 --- /dev/null +++ b/local_backend/routers/ws.py @@ -0,0 +1,87 @@ +""" +WebSocket endpoint — one persistent connection per connected client (waiter PWA, KDS). + +Authentication: JWT token passed as query param ?token= +(Same pattern as SSE — browser WebSocket API also cannot set custom headers.) + +Protocol: + 1. Client connects: ws://host/api/ws?token= + 2. Client immediately sends: { "cursor": } + (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) diff --git a/local_backend/schemas/chat.py b/local_backend/schemas/chat.py new file mode 100644 index 0000000..ff6d7bb --- /dev/null +++ b/local_backend/schemas/chat.py @@ -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) diff --git a/local_backend/schemas/message.py b/local_backend/schemas/message.py index 10eda2d..f284f2c 100644 --- a/local_backend/schemas/message.py +++ b/local_backend/schemas/message.py @@ -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 diff --git a/local_backend/schemas/order.py b/local_backend/schemas/order.py index 53215cf..c394ddd 100644 --- a/local_backend/schemas/order.py +++ b/local_backend/schemas/order.py @@ -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} diff --git a/local_backend/schemas/pricing.py b/local_backend/schemas/pricing.py new file mode 100644 index 0000000..4317b21 --- /dev/null +++ b/local_backend/schemas/pricing.py @@ -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 diff --git a/local_backend/schemas/printer.py b/local_backend/schemas/printer.py index 4f064d0..802b750 100644 --- a/local_backend/schemas/printer.py +++ b/local_backend/schemas/printer.py @@ -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): diff --git a/local_backend/schemas/product.py b/local_backend/schemas/product.py index 0ca049d..2cbb216 100644 --- a/local_backend/schemas/product.py +++ b/local_backend/schemas/product.py @@ -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 diff --git a/local_backend/schemas/table.py b/local_backend/schemas/table.py index 487565a..89bdda7 100644 --- a/local_backend/schemas/table.py +++ b/local_backend/schemas/table.py @@ -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 diff --git a/local_backend/schemas/user.py b/local_backend/schemas/user.py index c410066..53dbafc 100644 --- a/local_backend/schemas/user.py +++ b/local_backend/schemas/user.py @@ -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 diff --git a/local_backend/services/chat_service.py b/local_backend/services/chat_service.py new file mode 100644 index 0000000..fa6ef0c --- /dev/null +++ b/local_backend/services/chat_service.py @@ -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() diff --git a/local_backend/services/cloud_sync.py b/local_backend/services/cloud_sync.py index 1b898bc..a274085 100644 --- a/local_backend/services/cloud_sync.py +++ b/local_backend/services/cloud_sync.py @@ -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: diff --git a/local_backend/services/fiscal_service.py b/local_backend/services/fiscal_service.py new file mode 100644 index 0000000..f4bae9c --- /dev/null +++ b/local_backend/services/fiscal_service.py @@ -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, + } diff --git a/local_backend/services/pricing.py b/local_backend/services/pricing.py new file mode 100644 index 0000000..f1db6c5 --- /dev/null +++ b/local_backend/services/pricing.py @@ -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, + )) diff --git a/local_backend/services/printer_service.py b/local_backend/services/printer_service.py index e9eb91e..36d5229 100644 --- a/local_backend/services/printer_service.py +++ b/local_backend/services/printer_service.py @@ -11,13 +11,13 @@ import json import logging import socket import datetime -from typing import Tuple, List +from typing import Tuple, List, Optional from escpos.printer import Network from sqlalchemy.orm import Session from database import SessionLocal -from models.order import Order, OrderItem, PrintLog +from models.order import Order, OrderItem, PrintLog, PrintJob from tz import to_local from models.printer import Printer from models.product import Product @@ -31,10 +31,10 @@ PRINTER_TIMEOUT = 5 # ── Low-level helpers ──────────────────────────────────────────────────────── -def _get_printer(ip: str, port: int) -> Network: +def _get_printer(ip: str, port: int, codepage_n: int = 29) -> Network: p = Network(ip, port, timeout=PRINTER_TIMEOUT) - p._raw(b'\x1b\x40') # ESC @ — reset printer - p._raw(b'\x1b\x74\x1d') # ESC t 29 — select CP737 (Greek) — confirmed n=29 + p._raw(b'\x1b\x40') # ESC @ — reset printer + p._raw(bytes([0x1b, 0x74, codepage_n])) # ESC t n — select Greek code page return p @@ -152,12 +152,34 @@ def _divider(p: Network, style: str = "dash", line_width: int = LINE_WIDTH): p._raw(b'\n') -def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH, dot_style: str = "dot_space") -> str: +_UNIT_SUFFIXES = { + "kg": lambda q: f" {_fmt_decimal(q)}kg", + "liter": lambda q: f" {_fmt_decimal(q)}L", + "gram": lambda q: f" {int(q)}g", + "ml": lambda q: f" {int(q)}mL", +} + + +def _fmt_decimal(q: float) -> str: + """Format a decimal quantity: drop trailing zero if it's a whole number.""" + return f"{q:.1f}" if q != int(q) else f"{int(q)}" + + +def _qty_suffix(qty: float, unit_type: str) -> str: + """Return the quantity suffix string for a given unit, e.g. ' x2', ' 1.5kg'.""" + fn = _UNIT_SUFFIXES.get(unit_type) + if fn: + return fn(qty) + return f" x{int(qty)}" + + +def _item_line(name: str, qty: float, line_width: int = LINE_WIDTH, dot_style: str = "dot_space", + unit_type: str = "piece") -> str: """Simple single-font dot-leader string — used by receipt and synopsis printers.""" - suffix = f" x{qty}" + suffix = _qty_suffix(qty, unit_type) available = line_width - len(name) - 1 - len(suffix) if available < 1: - return f"{name} x{qty}" + return f"{name}{suffix}" unit = _DOT_STYLE_UNITS.get(dot_style, ". ") dots = (unit * (available // len(unit) + 1))[:available] return f"{name} {dots}{suffix}" @@ -165,16 +187,17 @@ def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH, dot_style: str def _print_item_line( p: Network, - name: str, qty: int, + name: str, qty: float, sz_name: int, b_name: bool, dot_fill: str, dot_sz: int, dot_bold: bool, line_width: int = LINE_WIDTH, + unit_type: str = "piece", ): """Print one item line as three segments with independent fonts: [name font] name· [dot font] ····· [name font] ·xN\\n Column widths are computed in physical printer columns (each double-width char occupies 2 columns) so mixed font sizes stay aligned.""" - suffix = f" x{qty}" + suffix = _qty_suffix(qty, unit_type) # Physical column width of each character under each font name_char_w = 2 if sz_name in (32, 48) else 1 @@ -188,7 +211,7 @@ def _print_item_line( if available_cols < dot_char_w: # No room for even one dot — fall back to plain line in name font _apply_font(p, sz_name, b_name) - _raw_text(p, f"{name} x{qty}\n") + _raw_text(p, f"{name}{suffix}\n") _reset_font(p) return @@ -258,13 +281,13 @@ def is_spoof_mode() -> bool: db.close() -def send_test_beep(ip: str, port: int, n1: int, n2: int, n3: int) -> Tuple[bool, str]: +def send_test_beep(ip: str, port: int, n1: int, n2: int, n3: int, codepage_n: int = 29) -> Tuple[bool, str]: """Send a standalone beep-only job. n1=on-time×100ms, n2=off-time×100ms, n3=count.""" if is_spoof_mode(): logger.info("Spoof printing ON — dropping test beep") return True, "" try: - p = _get_printer(ip, port) + p = _get_printer(ip, port, codepage_n) p._raw(bytes([0x1b, 0x07, n1, n2, n3])) p.close() return True, "" @@ -273,12 +296,12 @@ def send_test_beep(ip: str, port: int, n1: int, n2: int, n3: int) -> Tuple[bool, return False, str(e) -def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]: +def send_test_print(ip: str, port: int, name: str, codepage_n: int = 29) -> Tuple[bool, str]: if is_spoof_mode(): logger.info("Spoof printing ON — dropping test print for %s", name) return True, "" try: - p = _get_printer(ip, port) + p = _get_printer(ip, port, codepage_n) p._raw(b'\x1b\x61\x01') p._raw(b'\x1b\x21\x30') _raw_text(p, f"TEST — {name}\n") @@ -294,7 +317,7 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]: return False, str(e) -def send_test_order_print(ip: str, port: int, db: Session, line_width: int = LINE_WIDTH) -> Tuple[bool, str]: +def send_test_order_print(ip: str, port: int, db: Session, line_width: int = LINE_WIDTH, codepage_n: int = 29) -> Tuple[bool, str]: """Print a fake order using the current font/layout settings — for settings preview.""" if _is_spoof_mode(db): logger.info("Spoof printing ON — dropping test order print") @@ -394,6 +417,9 @@ def send_test_order_print(ip: str, port: int, db: Session, line_width: int = LIN if self._model.__name__ == "Product" and self._filter_id in _FAKE_NAMES: class _P: name = _FAKE_NAMES[self._filter_id] + unit_type = "piece" + category_id = None + printer_zone_id = None return _P() return _orig_query(self._model).filter(self._model.id == self._filter_id).first() @@ -408,7 +434,7 @@ def send_test_order_print(ip: str, port: int, db: Session, line_width: int = LIN return getattr(db, name) try: - p = _get_printer(ip, port) + p = _get_printer(ip, port, codepage_n) _print_kitchen_ticket(p, _Order(), items, _PatchedDB(), line_width) p.close() return True, "" @@ -481,12 +507,65 @@ def _parse_options(item: OrderItem) -> dict: return result -def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session, line_width: int = LINE_WIDTH): +def _sort_and_group_items(items: List[OrderItem], zone, db: Session) -> List[OrderItem]: + """Apply zone sort_items_by and group_by_category ordering to the item list.""" + if zone is None: + return items + + sort_by = getattr(zone, 'sort_items_by', 'order_time') or 'order_time' + group_cat = bool(getattr(zone, 'group_by_category', False)) + + # First apply item sort within (or without) category groups + def _sort_key(item: OrderItem): + product = db.query(Product).filter(Product.id == item.product_id).first() + if sort_by == 'item_count': + return -item.quantity # descending + if sort_by == 'alpha': + return (product.name or '').lower() if product else '' + # 'order_time': keep original ordering (stable sort) + return item.added_at or datetime.datetime.min + + if sort_by != 'order_time': + items = sorted(items, key=_sort_key) + + if not group_cat: + return items + + # Group by category, ordered by zone.category_order list + try: + cat_order: List[int] = json.loads(zone.category_order) if zone.category_order else [] + except (json.JSONDecodeError, TypeError): + cat_order = [] + + cat_rank = {cat_id: i for i, cat_id in enumerate(cat_order)} + + def _cat_for_item(item: OrderItem) -> int: + product = db.query(Product).filter(Product.id == item.product_id).first() + if product and product.category_id: + return product.category_id + return 0 + + cat_id_for = {item.id: _cat_for_item(item) for item in items} + + def _group_key(item: OrderItem) -> tuple: + cat_id = cat_id_for[item.id] + rank = cat_rank.get(cat_id, len(cat_order)) # unlisted cats go last + return (rank, cat_id) + + return sorted(items, key=_group_key) + + +def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session, line_width: int = LINE_WIDTH, zone=None, ephemeral_svc_entries: Optional[List[dict]] = None): cfg = _load_print_settings(db) mode = cfg.get("print.ticket_mode", "detailed") div = cfg.get("print.divider_style", "dash") compact = (mode == "compact") + # Apply zone-level sorting / category grouping + items = _sort_and_group_items(items, zone, db) + + print_checkboxes = bool(getattr(zone, 'print_checkboxes', False)) if zone else False + sz_ord, b_ord, c_ord = _decode_font(cfg["print.font_order_number"]) sz_meta, b_meta, c_meta = _decode_font(cfg["print.font_meta"]) sz_item, b_item, c_item = _decode_font(cfg["print.font_item_name"]) @@ -502,11 +581,15 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by) now_str = _greek_date(datetime.datetime.now(datetime.timezone.utc)) + customer_count = getattr(order, 'customer_count', None) + # ── COMPACT header — single line ──────────────────────────────────────── if compact: p._raw(b'\x1b\x61\x00') _apply_font(p, sz_ord, b_ord) header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}" + if customer_count: + header += f" | {customer_count}ατ." _raw_text(p, (header.upper() if c_ord else header) + "\n") _reset_font(p) _divider(p, div, line_width) @@ -521,129 +604,345 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: _raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n") _raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n") _raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n") + if customer_count: + _raw_text(p, ("ΑΤΟΜΑ:" if c_meta else "Ατομα:") + f" {customer_count}\n") _reset_font(p) _divider(p, div, line_width) - # ── Items ──────────────────────────────────────────────────────────────── - # Dot-leader style: "STYLE:SIZE:BOLD" + # ── Dot-leader style ──────────────────────────────────────────────────── raw_dot = cfg.get("print.dot_style", "dot_space:0:0") dot_parts = raw_dot.split(":") dot_fill = dot_parts[0] if dot_parts[0] in _DOT_STYLE_UNITS else "dot_space" dot_sz = int(dot_parts[1]) if len(dot_parts) > 1 and dot_parts[1].isdigit() else 0 dot_bold = (dot_parts[2] == "1") if len(dot_parts) > 2 else False - for item in items: - product = db.query(Product).filter(Product.id == item.product_id).first() - raw_name = product.name if product else f"Product #{item.product_id}" - item_name = raw_name.upper() if c_item else raw_name + # Split items: priced service items (saved as OrderItems) vs regular items + def _item_is_svc(item): + prod = db.query(Product).filter(Product.id == item.product_id).first() + return bool(prod and getattr(prod, 'is_service_item', False)) - p._raw(b'\x1b\x61\x00') - _print_item_line( - p, item_name, item.quantity, - sz_item, b_item, - dot_fill, dot_sz, dot_bold, - line_width, - ) + svc_order_items = [i for i in items if _item_is_svc(i)] + regular_items = [i for i in items if not _item_is_svc(i)] - opts = _parse_options(item) + # Ephemeral service entries (priceless, not saved as OrderItems) + eph = ephemeral_svc_entries or [] - # Quick options (* marker) - if opts["quick"]: - if compact: - parts = [] - for name, qty in opts["quick"]: - n = name.upper() if c_qk else name - parts.append(f"{n} x{qty}" if qty > 1 else n) - _apply_font(p, sz_qk, b_qk) - _raw_text(p, "* " + " | ".join(parts) + "\n") - _reset_font(p) - else: - for name, qty in opts["quick"]: - n = name.upper() if c_qk else name - line = f"* {n} x{qty}" if qty > 1 else f"* {n}" - _apply_font(p, sz_qk, b_qk) - _raw_text(p, line + "\n") - _reset_font(p) + has_svc_section = bool(svc_order_items) or bool(eph) + has_regular = bool(regular_items) + has_both = has_svc_section and has_regular - # Preferences (> marker) - if opts["pref"]: - if compact: - parts = [] - for name, sub in opts["pref"]: - n = name.upper() if c_pr else name - s = (sub.upper() if c_pr else sub) if sub else None - parts.append(f"{n} · {s}" if s else n) - _apply_font(p, sz_pr, b_pr) - _raw_text(p, "> " + " | ".join(parts) + "\n") - _reset_font(p) - else: - for name, sub in opts["pref"]: - n = name.upper() if c_pr else name - s = (sub.upper() if c_pr else sub) if sub else None - line = f"> {n} · {s}" if s else f"> {n}" - _apply_font(p, sz_pr, b_pr) - _raw_text(p, line + "\n") - _reset_font(p) - - # Extras (+ marker) - if opts["extra"]: - if compact: - parts = [] - for name, sub, qty in opts["extra"]: - n = name.upper() if c_ex else name - s = (sub.upper() if c_ex else sub) if sub else None - part = f"{n} · {s}" if s else n - if qty > 1: - part += f" · x{qty}" - parts.append(part) - _apply_font(p, sz_ex, b_ex) - _raw_text(p, "+ " + " | ".join(parts) + "\n") - _reset_font(p) - else: - for name, sub, qty in opts["extra"]: - n = name.upper() if c_ex else name - s = (sub.upper() if c_ex else sub) if sub else None - line = f"+ {n}" - if s: - line += f" · {s}" - if qty > 1: - line += f" · x{qty}" - _apply_font(p, sz_ex, b_ex) - _raw_text(p, line + "\n") - _reset_font(p) - - # Legacy untagged options - for entry in opts["unknown"]: - _apply_font(p, sz_ex, b_ex) - _raw_text(p, f"+ {entry}\n") + # ── SERVICE section (printed FIRST, after header) ──────────────────────── + if has_svc_section: + if has_both: + _divider(p, div, line_width) + p._raw(b'\x1b\x61\x01') # center + _apply_font(p, sz_item, b_item) + _raw_text(p, ("SERVICE\n" if c_item else "Service\n")) _reset_font(p) + p._raw(b'\x1b\x61\x00') + _divider(p, div, line_width) - # Removed ingredients (- marker) - if item.removed_ingredients: - try: - removed = json.loads(item.removed_ingredients) - if removed: - names = [n.upper() if c_ing else n for n in removed] - joined = " · ".join(names) - _apply_font(p, sz_ing, b_ing) - _raw_text(p, f"- ΧΩΡΙΣ: {joined}\n") - _reset_font(p) - except (json.JSONDecodeError, TypeError): - pass - - # Per-item note - if item.notes: - note_text = item.notes.upper() if c_note else item.notes - _apply_font(p, sz_note, b_note) - if compact: + # Priced saved service order items + for item in svc_order_items: + product = db.query(Product).filter(Product.id == item.product_id).first() + raw_name = product.name if product else f"Product #{item.product_id}" + item_name = raw_name.upper() if c_item else raw_name + unit_type = (product.unit_type or "piece") if product else "piece" + _print_item_line(p, item_name, item.quantity, sz_item, b_item, dot_fill, dot_sz, dot_bold, line_width, unit_type=unit_type) + if item.notes: + note_text = item.notes.upper() if c_note else item.notes + _apply_font(p, sz_note, b_note) _raw_text(p, f"! {note_text}\n") - else: - _raw_text(p, f"\n(!) {note_text}\n\n") - _reset_font(p) + _reset_font(p) + if not compact: + p._raw(b'\n') - # Blank line between items in detailed mode - if not compact: - p._raw(b'\n') + # Priceless ephemeral service entries + for entry in eph: + raw_name = entry['product'].name + item_name = raw_name.upper() if c_item else raw_name + _print_item_line(p, item_name, entry['quantity'], sz_item, b_item, dot_fill, dot_sz, dot_bold, line_width, unit_type='piece') + if not compact: + p._raw(b'\n') + + # ── ITEMS section ──────────────────────────────────────────────────────── + if has_both: + _divider(p, div, line_width) + p._raw(b'\x1b\x61\x01') # center + _apply_font(p, sz_item, b_item) + _raw_text(p, ("ITEMS\n" if c_item else "Items\n")) + _reset_font(p) + p._raw(b'\x1b\x61\x00') + + # Load courses if enabled + courses_enabled = cfg.get("orders.courses_enabled", "false") == "true" + courses_raw = cfg.get("orders.courses", "[]") + try: + courses_list = json.loads(courses_raw) if courses_enabled else [] + except Exception: + courses_list = [] + + _divider(p, div, line_width) + + if courses_list: + # Group regular_items by course_id + course_groups = {} + for item in regular_items: + key = item.course_id if item.course_id is not None else None + if key not in course_groups: + course_groups[key] = [] + course_groups[key].append(item) + + # Build ordered list: courses in defined order, then None last + ordered_groups = [] + for course in courses_list: + cid = course.get("id") + if cid in course_groups: + ordered_groups.append((course.get("name", f"Course {cid}"), course_groups[cid])) + if None in course_groups: + ordered_groups.append(("No course", course_groups[None])) + + for group_name, group_items in ordered_groups: + # Print course section header + p._raw(b'\x1b\x61\x01') # center + _apply_font(p, sz_item, b_item) + header = f"-- {group_name.upper() if c_item else group_name} --" + _raw_text(p, header + "\n") + _reset_font(p) + p._raw(b'\x1b\x61\x00') + + for item in group_items: + product = db.query(Product).filter(Product.id == item.product_id).first() + raw_name = product.name if product else f"Product #{item.product_id}" + if print_checkboxes: + raw_name = "[ ] " + raw_name + item_name = raw_name.upper() if c_item else raw_name + unit_type = (product.unit_type or "piece") if product else "piece" + + p._raw(b'\x1b\x61\x00') + _print_item_line( + p, item_name, item.quantity, + sz_item, b_item, + dot_fill, dot_sz, dot_bold, + line_width, + unit_type=unit_type, + ) + + opts = _parse_options(item) + + # Quick options (* marker) + if opts["quick"]: + if compact: + parts = [] + for name, qty in opts["quick"]: + n = name.upper() if c_qk else name + parts.append(f"{n} x{qty}" if qty > 1 else n) + _apply_font(p, sz_qk, b_qk) + _raw_text(p, "* " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, qty in opts["quick"]: + n = name.upper() if c_qk else name + line = f"* {n} x{qty}" if qty > 1 else f"* {n}" + _apply_font(p, sz_qk, b_qk) + _raw_text(p, line + "\n") + _reset_font(p) + + # Preferences (> marker) + if opts["pref"]: + if compact: + parts = [] + for name, sub in opts["pref"]: + n = name.upper() if c_pr else name + s = (sub.upper() if c_pr else sub) if sub else None + parts.append(f"{n} · {s}" if s else n) + _apply_font(p, sz_pr, b_pr) + _raw_text(p, "> " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, sub in opts["pref"]: + n = name.upper() if c_pr else name + s = (sub.upper() if c_pr else sub) if sub else None + line = f"> {n} · {s}" if s else f"> {n}" + _apply_font(p, sz_pr, b_pr) + _raw_text(p, line + "\n") + _reset_font(p) + + # Extras (+ marker) + if opts["extra"]: + if compact: + parts = [] + for name, sub, qty in opts["extra"]: + n = name.upper() if c_ex else name + s = (sub.upper() if c_ex else sub) if sub else None + part = f"{n} · {s}" if s else n + if qty > 1: + part += f" · x{qty}" + parts.append(part) + _apply_font(p, sz_ex, b_ex) + _raw_text(p, "+ " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, sub, qty in opts["extra"]: + n = name.upper() if c_ex else name + s = (sub.upper() if c_ex else sub) if sub else None + line = f"+ {n}" + if s: + line += f" · {s}" + if qty > 1: + line += f" · x{qty}" + _apply_font(p, sz_ex, b_ex) + _raw_text(p, line + "\n") + _reset_font(p) + + # Legacy untagged options + for entry in opts["unknown"]: + _apply_font(p, sz_ex, b_ex) + _raw_text(p, f"+ {entry}\n") + _reset_font(p) + + # Removed ingredients (- marker) + if item.removed_ingredients: + try: + removed = json.loads(item.removed_ingredients) + if removed: + names = [n.upper() if c_ing else n for n in removed] + _apply_font(p, sz_ing, b_ing) + _raw_text(p, f"- ΧΩΡΙΣ: {' · '.join(names)}\n") + _reset_font(p) + except (json.JSONDecodeError, TypeError): + pass + + # Per-item note + if item.notes: + note_text = item.notes.upper() if c_note else item.notes + _apply_font(p, sz_note, b_note) + if compact: + _raw_text(p, f"! {note_text}\n") + else: + _raw_text(p, f"\n(!) {note_text}\n\n") + _reset_font(p) + + # Blank line between items in detailed mode + if not compact: + p._raw(b'\n') + else: + for item in regular_items: + product = db.query(Product).filter(Product.id == item.product_id).first() + raw_name = product.name if product else f"Product #{item.product_id}" + if print_checkboxes: + raw_name = "[ ] " + raw_name + item_name = raw_name.upper() if c_item else raw_name + unit_type = (product.unit_type or "piece") if product else "piece" + + p._raw(b'\x1b\x61\x00') + _print_item_line( + p, item_name, item.quantity, + sz_item, b_item, + dot_fill, dot_sz, dot_bold, + line_width, + unit_type=unit_type, + ) + + opts = _parse_options(item) + + # Quick options (* marker) + if opts["quick"]: + if compact: + parts = [] + for name, qty in opts["quick"]: + n = name.upper() if c_qk else name + parts.append(f"{n} x{qty}" if qty > 1 else n) + _apply_font(p, sz_qk, b_qk) + _raw_text(p, "* " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, qty in opts["quick"]: + n = name.upper() if c_qk else name + line = f"* {n} x{qty}" if qty > 1 else f"* {n}" + _apply_font(p, sz_qk, b_qk) + _raw_text(p, line + "\n") + _reset_font(p) + + # Preferences (> marker) + if opts["pref"]: + if compact: + parts = [] + for name, sub in opts["pref"]: + n = name.upper() if c_pr else name + s = (sub.upper() if c_pr else sub) if sub else None + parts.append(f"{n} · {s}" if s else n) + _apply_font(p, sz_pr, b_pr) + _raw_text(p, "> " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, sub in opts["pref"]: + n = name.upper() if c_pr else name + s = (sub.upper() if c_pr else sub) if sub else None + line = f"> {n} · {s}" if s else f"> {n}" + _apply_font(p, sz_pr, b_pr) + _raw_text(p, line + "\n") + _reset_font(p) + + # Extras (+ marker) + if opts["extra"]: + if compact: + parts = [] + for name, sub, qty in opts["extra"]: + n = name.upper() if c_ex else name + s = (sub.upper() if c_ex else sub) if sub else None + part = f"{n} · {s}" if s else n + if qty > 1: + part += f" · x{qty}" + parts.append(part) + _apply_font(p, sz_ex, b_ex) + _raw_text(p, "+ " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, sub, qty in opts["extra"]: + n = name.upper() if c_ex else name + s = (sub.upper() if c_ex else sub) if sub else None + line = f"+ {n}" + if s: + line += f" · {s}" + if qty > 1: + line += f" · x{qty}" + _apply_font(p, sz_ex, b_ex) + _raw_text(p, line + "\n") + _reset_font(p) + + # Legacy untagged options + for entry in opts["unknown"]: + _apply_font(p, sz_ex, b_ex) + _raw_text(p, f"+ {entry}\n") + _reset_font(p) + + # Removed ingredients (- marker) + if item.removed_ingredients: + try: + removed = json.loads(item.removed_ingredients) + if removed: + names = [n.upper() if c_ing else n for n in removed] + joined = " · ".join(names) + _apply_font(p, sz_ing, b_ing) + _raw_text(p, f"- ΧΩΡΙΣ: {joined}\n") + _reset_font(p) + except (json.JSONDecodeError, TypeError): + pass + + # Per-item note + if item.notes: + note_text = item.notes.upper() if c_note else item.notes + _apply_font(p, sz_note, b_note) + if compact: + _raw_text(p, f"! {note_text}\n") + else: + _raw_text(p, f"\n(!) {note_text}\n\n") + _reset_font(p) + + # Blank line between items in detailed mode + if not compact: + p._raw(b'\n') _divider(p, div, line_width) @@ -685,13 +984,13 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: # ── On-demand report / receipt prints ──────────────────────────────────────── -def print_waiter_report(ip: str, port: int, report: dict, mode: str): +def print_waiter_report(ip: str, port: int, report: dict, mode: str, codepage_n: int = 29): """Print a waiter shift/period report. mode='simple'|'extensive'.""" if is_spoof_mode(): logger.info("Spoof printing ON — dropping waiter report print") return try: - p = _get_printer(ip, port) + p = _get_printer(ip, port, codepage_n) p._raw(b'\x1b\x61\x01') p._raw(b'\x1b\x21\x30') @@ -824,7 +1123,7 @@ def _printer_item_breakdown(p, blocks: list, lw: int, div: str): _raw_text(p, line + "\n") -def print_printer_report(ip: str, port: int, report: dict, mode: str): +def print_printer_report(ip: str, port: int, report: dict, mode: str, codepage_n: int = 29): """Print a printer history report. report contains printer_blocks list.""" if is_spoof_mode(): logger.info("Spoof printing ON — dropping printer report print") @@ -893,13 +1192,13 @@ def print_printer_report(ip: str, port: int, report: dict, mode: str): logger.error("print_printer_report failed for %s:%s — %s", ip, port, e) -def print_order_receipt(ip: str, port: int, receipt: dict, line_width: int = LINE_WIDTH): +def print_order_receipt(ip: str, port: int, receipt: dict, line_width: int = LINE_WIDTH, codepage_n: int = 29): """Print a manager-triggered order receipt.""" if is_spoof_mode(): logger.info("Spoof printing ON — dropping order receipt print") return try: - p = _get_printer(ip, port) + p = _get_printer(ip, port, codepage_n) p._raw(b'\x1b\x61\x01') p._raw(b'\x1b\x21\x30') @@ -918,10 +1217,12 @@ def print_order_receipt(ip: str, port: int, receipt: dict, line_width: int = LIN _divider(p, line_width=line_width) for item in receipt.get("items", []): + ut = item.get("unit_type", "piece") p._raw(b'\x1b\x21\x10') - _raw_text(p, _item_line(item["name"], item["quantity"], line_width=line_width) + "\n") + _raw_text(p, _item_line(item["name"], item["quantity"], line_width=line_width, unit_type=ut) + "\n") p._raw(b'\x1b\x21\x00') - _raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n") + qty_str = _qty_suffix(item["quantity"], ut).strip() + _raw_text(p, f" {item['unit_price']:.2f}e {qty_str} = {item['total']:.2f}e\n") _divider(p, line_width=line_width) @@ -943,13 +1244,13 @@ def print_order_receipt(ip: str, port: int, receipt: dict, line_width: int = LIN logger.error("print_order_receipt failed for %s:%s — %s", ip, port, e) -def print_order_synopsis(ip: str, port: int, synopsis: dict, line_width: int = LINE_WIDTH): +def print_order_synopsis(ip: str, port: int, synopsis: dict, line_width: int = LINE_WIDTH, codepage_n: int = 29): """Print a waiter-triggered order synopsis (not a kitchen ticket).""" if is_spoof_mode(): logger.info("Spoof printing ON — dropping order synopsis print") return try: - p = _get_printer(ip, port) + p = _get_printer(ip, port, codepage_n) p._raw(b'\x1b\x61\x01') p._raw(b'\x1b\x21\x30') @@ -973,8 +1274,10 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict, line_width: int = L _raw_text(p, "ΕΚΚΡΕΜΗ:\n") p._raw(b'\x1b\x21\x00') for item in active_items: - _raw_text(p, f" {_item_line(item['name'], item['quantity'], line_width=line_width - 2)}\n") - _raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n") + ut = item.get("unit_type", "piece") + qty_str = _qty_suffix(item["quantity"], ut).strip() + _raw_text(p, f" {_item_line(item['name'], item['quantity'], line_width=line_width - 2, unit_type=ut)}\n") + _raw_text(p, f" {item['unit_price']:.2f}e {qty_str} = {item['total']:.2f}e\n") _divider(p, line_width=line_width) if paid_items: @@ -982,8 +1285,10 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict, line_width: int = L _raw_text(p, "ΠΛΗΡΩΜΕΝΑ:\n") p._raw(b'\x1b\x21\x00') for item in paid_items: - _raw_text(p, f" {_item_line(item['name'], item['quantity'], line_width=line_width - 2)}\n") - _raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n") + ut = item.get("unit_type", "piece") + qty_str = _qty_suffix(item["quantity"], ut).strip() + _raw_text(p, f" {_item_line(item['name'], item['quantity'], line_width=line_width - 2, unit_type=ut)}\n") + _raw_text(p, f" {item['unit_price']:.2f}e {qty_str} = {item['total']:.2f}e\n") _divider(p, line_width=line_width) p._raw(b'\x1b\x61\x01') @@ -1027,20 +1332,66 @@ def _do_print_cancellation(order_id: int, item_ids: List[int], db: Session): items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all() - zone_map: dict[int, List[OrderItem]] = {} + # Route by prep zones (same logic as normal ticket), keyed by (printer_id, zone_id). + # Cancellation always prints regardless of auto_print mode — if it would print normally, + # we need a cancellation ticket. + printer_job: dict[tuple[int, int], tuple[object, List[OrderItem]]] = {} + seen: set[tuple[int, int, int]] = set() + for item in items: product = db.query(Product).filter(Product.id == item.product_id).first() - if product and product.printer_zone_id: - zone_map.setdefault(product.printer_zone_id, []).append(item) + if not product: + continue + + item_routed = False + for zone in product.prep_zones: + mode = zone.auto_print or 'none' + if mode == 'none': + continue + + printers_to_use: List[object] = [] + master_id = zone.master_printer_id + + if mode in ('master', 'all'): + if master_id: + master_printer = db.query(Printer).filter(Printer.id == master_id, Printer.is_active == True).first() + if master_printer: + printers_to_use.append(master_printer) + elif zone.printers: + printers_to_use.append(zone.printers[0]) + + if mode == 'all': + for zp in zone.printers: + if zp.id != master_id and zp.is_active: + printers_to_use.append(zp) + + for printer in printers_to_use: + key = (printer.id, zone.id, item.id) + if key not in seen: + seen.add(key) + job_key = (printer.id, zone.id) + if job_key not in printer_job: + printer_job[job_key] = (printer, []) + printer_job[job_key][1].append(item) + item_routed = True + + # Fallback to legacy printer_zone_id + if not item_routed and product.printer_zone_id: + printer = db.query(Printer).filter(Printer.id == product.printer_zone_id, Printer.is_active == True).first() + if printer: + key = (printer.id, 0, item.id) + if key not in seen: + seen.add(key) + job_key = (printer.id, 0) + if job_key not in printer_job: + printer_job[job_key] = (printer, []) + printer_job[job_key][1].append(item) div = load_divider_style(db) - for printer_id, zone_items in zone_map.items(): - printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first() - if not printer: - continue + for (printer, zone_items) in printer_job.values(): try: - p = _get_printer(printer.ip_address, printer.port) + p = _get_printer(printer.ip_address, printer.port, printer.codepage_n) _print_cancel_ticket(p, order, zone_items, db, printer.line_width, div) p.close() except Exception as e: @@ -1075,8 +1426,9 @@ def _print_cancel_ticket(p: Network, order: Order, items: List[OrderItem], db: S for item in items: product = db.query(Product).filter(Product.id == item.product_id).first() name = product.name if product else f"#{item.product_id}" + ut = (product.unit_type or "piece") if product else "piece" p._raw(b'\x1b\x21\x10') - _raw_text(p, _item_line(name, item.quantity, line_width) + "\n") + _raw_text(p, _item_line(name, item.quantity, line_width, unit_type=ut) + "\n") p._raw(b'\x1b\x21\x00') _divider(p, div, line_width) @@ -1088,7 +1440,7 @@ def _print_cancel_ticket(p: Network, order: Order, items: List[OrderItem], db: S # ── Analytical report prints (products / categories / tables) ───────────────── -def print_products_report(ip: str, port: int, report: dict): +def print_products_report(ip: str, port: int, report: dict, codepage_n: int = 29): """Print a product sales report. mode='smart'(sold only) or 'full'(all products).""" if is_spoof_mode(): logger.info("Spoof printing ON — dropping products report print") @@ -1097,7 +1449,7 @@ def print_products_report(ip: str, port: int, report: dict): lw = report.get("line_width", LINE_WIDTH) div = report.get("div_style", "dash") mode = report.get("mode", "smart") - p = _get_printer(ip, port) + p = _get_printer(ip, port, codepage_n) p._raw(b'\x1b\x61\x01') p._raw(b'\x1b\x21\x30') @@ -1148,7 +1500,53 @@ def print_products_report(ip: str, port: int, report: dict): logger.error("print_products_report failed for %s:%s — %s", ip, port, e) -def print_categories_report(ip: str, port: int, report: dict): +def print_prep_zone_summary(ip: str, port: int, report: dict, codepage_n: int = 29): + """Print a shopping-list summary for a prep zone.""" + if is_spoof_mode(): + logger.info("Spoof printing ON — dropping prep zone summary print") + return + try: + lw = report.get("line_width", LINE_WIDTH) + div = report.get("div_style", "dash") + zone_name = report.get("zone_name", "") + period_label = report.get("period_label", "") + items = report.get("items", []) # [{name, count, value}] + p = _get_printer(ip, port, codepage_n) + + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, "ΖΩΝΗ ΠΡΟΕΤΟΙΜΑΣΙΑΣ\n") + p._raw(b'\x1b\x21\x10') + _raw_text(p, zone_name + "\n") + p._raw(b'\x1b\x21\x00') + _divider(p, div, lw) + + p._raw(b'\x1b\x61\x00') + p._raw(b'\x1b\x21\x10') + _raw_text(p, f"Περιοδος: {period_label}\n") + p._raw(b'\x1b\x21\x00') + _divider(p, div, lw) + + total_items = sum(x["count"] for x in items) + for item in items: + left = item["name"] + right = str(item["count"]) + _raw_text(p, _dot_leader_line(left, right, lw) + "\n") + + _divider(p, div, lw) + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x10') + _raw_text(p, f"Συνολο ειδων: {total_items}\n") + p._raw(b'\x1b\x21\x00') + + p._raw(b'\n\n\n') + p.cut() + p.close() + except Exception as e: + logger.error("print_prep_zone_summary failed for %s:%s — %s", ip, port, e) + + +def print_categories_report(ip: str, port: int, report: dict, codepage_n: int = 29): """Print a category sales report.""" if is_spoof_mode(): logger.info("Spoof printing ON — dropping categories report print") @@ -1157,7 +1555,7 @@ def print_categories_report(ip: str, port: int, report: dict): lw = report.get("line_width", LINE_WIDTH) div = report.get("div_style", "dash") mode = report.get("mode", "smart") - p = _get_printer(ip, port) + p = _get_printer(ip, port, codepage_n) p._raw(b'\x1b\x61\x01') p._raw(b'\x1b\x21\x30') @@ -1212,7 +1610,7 @@ def print_categories_report(ip: str, port: int, report: dict): logger.error("print_categories_report failed for %s:%s — %s", ip, port, e) -def print_tables_report(ip: str, port: int, report: dict): +def print_tables_report(ip: str, port: int, report: dict, codepage_n: int = 29): """Print a table performance report.""" if is_spoof_mode(): logger.info("Spoof printing ON — dropping tables report print") @@ -1221,7 +1619,7 @@ def print_tables_report(ip: str, port: int, report: dict): lw = report.get("line_width", LINE_WIDTH) div = report.get("div_style", "dash") mode = report.get("mode", "smart") - p = _get_printer(ip, port) + p = _get_printer(ip, port, codepage_n) p._raw(b'\x1b\x61\x01') p._raw(b'\x1b\x21\x30') @@ -1285,13 +1683,62 @@ def route_and_print(order_id: int, item_ids: List[int]): db.close() -def route_and_print_sync(order_id: int, item_ids: List[int], db: Session) -> List[dict]: +def route_and_print_sync(order_id: int, item_ids: List[int], db: Session, ephemeral_svc_entries: Optional[List[dict]] = None) -> List[dict]: """ Synchronous variant used when the caller needs print results. Returns a list of per-printer result dicts: { printer_name, success, error } """ - return _do_route_and_print(order_id, item_ids, db) + return _do_route_and_print(order_id, item_ids, db, ephemeral_svc_entries=ephemeral_svc_entries) + + +def print_to_printer(order_id: int, item_ids: Optional[List[int]], printer_id: int, copies: int, db: Session) -> dict: + """ + Print specific items from an order directly to a named printer. + Used by KDS manual-print and auto-print features. + Returns { printer_name, success, error }. + """ + if _is_spoof_mode(db): + logger.info("Spoof printing ON — dropping manual print job for order %s printer %s", order_id, printer_id) + return {"printer_name": "spoof", "success": True, "error": None} + + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + return {"printer_name": f"#{printer_id}", "success": False, "error": "Order not found"} + + printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first() + if not printer: + return {"printer_name": f"#{printer_id}", "success": False, "error": "Printer not found or inactive"} + + if item_ids is None: + items = db.query(OrderItem).filter(OrderItem.order_id == order_id, OrderItem.status != "cancelled").all() + else: + items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all() + copies = max(1, copies) + + success = False + error_msg = None + try: + for _ in range(copies): + p = _get_printer(printer.ip_address, printer.port, printer.codepage_n) + _print_kitchen_ticket(p, order, items, db, printer.line_width) + p.close() + success = True + except Exception as e: + error_msg = str(e) + logger.error("Manual print failed for printer %s (%s:%s): %s", printer.name, printer.ip_address, printer.port, e) + + log = PrintLog( + order_id=order_id, + printer_id=printer_id, + item_ids=json.dumps(item_ids), + success=success, + error_message=error_msg, + ) + db.add(log) + db.commit() + + return {"printer_name": printer.name, "success": success, "error": error_msg} def _is_spoof_mode(db: Session) -> bool: @@ -1299,7 +1746,7 @@ def _is_spoof_mode(db: Session) -> bool: return row is not None and row.value == "true" -def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List[dict]: +def _do_route_and_print(order_id: int, item_ids: List[int], db: Session, ephemeral_svc_entries: Optional[List[dict]] = None) -> List[dict]: if _is_spoof_mode(db): logger.info("Spoof printing ON — dropping print job for order %s", order_id) for item_id in item_ids: @@ -1318,38 +1765,181 @@ def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all() - # Group items by printer zone - zone_map: dict[int, List[OrderItem]] = {} + # Build (printer_id, copies, zone_id) → (items, zone) map respecting zone auto_print settings. + # zone_id is included in the key so that items from different zones that happen to share + # the same printer still get separate tickets with the correct zone-level formatting. + # Deduplication: (printer_id, copies, zone_id, item_id) prevents duplicate sends. + printer_job: dict[tuple[int, int, int], tuple[list, object]] = {} + seen: set[tuple[int, int, int, int]] = set() unzoned: List[OrderItem] = [] + printed_item_ids_no_print: set[int] = set() # intentional no-print zones + for item in items: product = db.query(Product).filter(Product.id == item.product_id).first() - if product and product.printer_zone_id: - zone_map.setdefault(product.printer_zone_id, []).append(item) - else: + if not product: unzoned.append(item) + continue + + item_routed = False + for zone in product.prep_zones: + mode = zone.auto_print or 'none' + if mode == 'none': + continue + + master_id = zone.master_printer_id + master_copies = max(1, zone.master_copies or 1) + secondary_copies = max(1, zone.secondary_copies or 1) + + # Determine which printers to use + printers_to_use: List[tuple] = [] # (printer, copies) + + if mode in ('master', 'all'): + if master_id: + master_printer = db.query(Printer).filter(Printer.id == master_id, Printer.is_active == True).first() + if master_printer: + printers_to_use.append((master_printer, master_copies)) + elif zone.printers: + # Fallback: no master set, treat first printer as master + printers_to_use.append((zone.printers[0], master_copies)) + + if mode == 'all': + for zp in zone.printers: + if zp.id != master_id and zp.is_active: + printers_to_use.append((zp, secondary_copies)) + + for (printer, copies) in printers_to_use: + key = (printer.id, copies, zone.id, item.id) + if key not in seen: + seen.add(key) + job_key = (printer.id, copies, zone.id) + if job_key not in printer_job: + printer_job[job_key] = ([], zone) + printer_job[job_key][0].append(item) + item_routed = True + + if not item_routed: + # If the product has prep zones but all are set to 'none', it's an intentional + # no-print choice — mark it printed immediately so the PWA doesn't show a warning. + if product.prep_zones and all((z.auto_print or 'none') == 'none' for z in product.prep_zones): + printed_item_ids_no_print.add(item.id) + continue + # Fall back to legacy printer_zone_id (single printer, 1 copy, no zone formatting) + if product.printer_zone_id: + printer = db.query(Printer).filter(Printer.id == product.printer_zone_id, Printer.is_active == True).first() + if printer: + key = (printer.id, 1, 0, item.id) + if key not in seen: + seen.add(key) + job_key = (printer.id, 1, 0) + if job_key not in printer_job: + printer_job[job_key] = ([], None) + printer_job[job_key][0].append(item) + else: + unzoned.append(item) + else: + unzoned.append(item) if unzoned: - logger.warning("order %s has %d item(s) with no printer zone — skipped", order_id, len(unzoned)) + logger.warning("order %s has %d item(s) with no prep zone/printer — skipped", order_id, len(unzoned)) - for printer_id, zone_items in zone_map.items(): + # Route ephemeral service entries into the same printer jobs. + # Each entry goes to every printer job that already exists (broad broadcast), + # OR to its own prep-zone printers if no regular jobs exist for that printer. + # We track which entries land in which job_key so each ticket gets the right subset. + job_svc_entries: dict[tuple, list] = {} # job_key → list of {product, quantity} + + for entry in (ephemeral_svc_entries or []): + product = entry['product'] + entry_routed = False + for zone in product.prep_zones: + mode_z = zone.auto_print or 'none' + if mode_z == 'none': + continue + master_id = zone.master_printer_id + master_copies = max(1, zone.master_copies or 1) + secondary_copies = max(1, zone.secondary_copies or 1) + printers_to_use = [] + if mode_z in ('master', 'all'): + if master_id: + pr = db.query(Printer).filter(Printer.id == master_id, Printer.is_active == True).first() + if pr: + printers_to_use.append((pr, master_copies, zone)) + elif zone.printers: + printers_to_use.append((zone.printers[0], master_copies, zone)) + if mode_z == 'all': + for zp in zone.printers: + if zp.id != master_id and zp.is_active: + printers_to_use.append((zp, secondary_copies, zone)) + for (pr, copies, z) in printers_to_use: + job_key = (pr.id, copies, z.id) + # Ensure job exists even if no regular items route here + if job_key not in printer_job: + printer_job[job_key] = ([], z) + if job_key not in job_svc_entries: + job_svc_entries[job_key] = [] + job_svc_entries[job_key].append(entry) + entry_routed = True + # Fallback: legacy printer_zone_id + if not entry_routed and getattr(product, 'printer_zone_id', None): + pr = db.query(Printer).filter(Printer.id == product.printer_zone_id, Printer.is_active == True).first() + if pr: + job_key = (pr.id, 1, 0) + if job_key not in printer_job: + printer_job[job_key] = ([], None) + if job_key not in job_svc_entries: + job_svc_entries[job_key] = [] + job_svc_entries[job_key].append(entry) + + printed_item_ids: set[int] = set() + + now = datetime.datetime.now(datetime.timezone.utc) + + for (printer_id, copies, zone_id), (job_items, job_zone) in printer_job.items(): printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first() if not printer: logger.warning("Printer %s not found or inactive", printer_id) results.append({"printer_name": f"#{printer_id}", "success": False, "error": "Printer not found or inactive"}) continue + job_key = (printer_id, copies, zone_id) + job_eph = job_svc_entries.get(job_key, []) + item_ids_json = json.dumps([i.id for i in job_items]) + + # Find or create the PrintJob for this (order, printer, zone) combination + pj = db.query(PrintJob).filter( + PrintJob.order_id == order_id, + PrintJob.printer_id == printer_id, + PrintJob.zone_id == (zone_id if zone_id != 0 else None), + PrintJob.status == "pending", + ).first() + if pj is None: + pj = PrintJob( + order_id=order_id, + printer_id=printer_id, + zone_id=zone_id if zone_id != 0 else None, + item_ids=item_ids_json, + copies=copies, + status="pending", + first_attempted_at=now, + ) + db.add(pj) + db.flush() + + pj.last_attempted_at = now + pj.retry_count = (pj.retry_count or 0) + 1 + success = False error_msg = None - copies = 1 + max(0, printer.duplicates or 0) try: for _ in range(copies): - p = _get_printer(printer.ip_address, printer.port) - _print_kitchen_ticket(p, order, zone_items, db, printer.line_width) + p = _get_printer(printer.ip_address, printer.port, printer.codepage_n) + _print_kitchen_ticket(p, order, job_items, db, printer.line_width, zone=job_zone, ephemeral_svc_entries=job_eph) p.close() success = True - for item in zone_items: - item.printed = True - db.commit() + pj.status = "success" + pj.succeeded_at = now + for item in job_items: + printed_item_ids.add(item.id) except Exception as e: error_msg = str(e) logger.error("Print failed for printer %s (%s:%s): %s", printer.name, printer.ip_address, printer.port, e) @@ -1357,13 +1947,258 @@ def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List log = PrintLog( order_id=order_id, printer_id=printer_id, - item_ids=json.dumps([i.id for i in zone_items]), + item_ids=item_ids_json, success=success, error_message=error_msg, ) db.add(log) - db.commit() - results.append({"printer_name": printer.name, "success": success, "error": error_msg}) + # Mark all successfully printed items (and intentional no-prints) in one commit + all_done_ids = printed_item_ids | printed_item_ids_no_print + if all_done_ids: + for item in items: + if item.id in all_done_ids: + item.printed = True + db.commit() + return results + + +def print_service_items_sync(order: Order, service_entries: List[dict], db: Session) -> List[dict]: + """ + Print service items (cutlery, glasses, etc.) that are NOT persisted as order items. + service_entries: list of { product, quantity } dicts. + Routes to prep zones defined on the product; groups by printer/zone. + Returns per-printer result dicts like route_and_print_sync. + """ + if _is_spoof_mode(db): + logger.info("Spoof printing ON — dropping service item print for order %s", order.id) + return [{"printer_name": "spoof", "success": True, "error": None}] + + if not service_entries: + return [] + + results = [] + cfg = _load_print_settings(db) + mode = cfg.get("print.ticket_mode", "detailed") + compact = (mode == "compact") + div = cfg.get("print.divider_style", "dash") + + table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id) + waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by) + now_str = _greek_date(datetime.datetime.now(datetime.timezone.utc)) + customer_count = getattr(order, 'customer_count', None) + + # Group entries by (printer_id, copies, zone_id) → list of (product, quantity) + printer_job: dict = {} + seen: set = set() + + for entry in service_entries: + product = entry['product'] + quantity = entry['quantity'] + + for zone in product.prep_zones: + mode_z = zone.auto_print or 'none' + if mode_z == 'none': + continue + + master_id = zone.master_printer_id + master_copies = max(1, zone.master_copies or 1) + secondary_copies = max(1, zone.secondary_copies or 1) + + printers_to_use = [] + if mode_z in ('master', 'all'): + if master_id: + pr = db.query(Printer).filter(Printer.id == master_id, Printer.is_active == True).first() + if pr: + printers_to_use.append((pr, master_copies, zone)) + elif zone.printers: + printers_to_use.append((zone.printers[0], master_copies, zone)) + if mode_z == 'all': + for zp in zone.printers: + if zp.id != master_id and zp.is_active: + printers_to_use.append((zp, secondary_copies, zone)) + + for (pr, copies, z) in printers_to_use: + job_key = (pr.id, copies, z.id) + if job_key not in printer_job: + printer_job[job_key] = {'printer': pr, 'copies': copies, 'zone': z, 'entries': []} + printer_job[job_key]['entries'].append({'product': product, 'quantity': quantity}) + + # Fallback: legacy printer_zone_id + if not product.prep_zones and product.printer_zone_id: + pr = db.query(Printer).filter(Printer.id == product.printer_zone_id, Printer.is_active == True).first() + if pr: + job_key = (pr.id, 1, 0) + if job_key not in printer_job: + printer_job[job_key] = {'printer': pr, 'copies': 1, 'zone': None, 'entries': []} + printer_job[job_key]['entries'].append({'product': product, 'quantity': quantity}) + + for job_key, job in printer_job.items(): + pr = job['printer'] + copies = job['copies'] + entries = job['entries'] + + sz_ord, b_ord, c_ord = _decode_font(cfg["print.font_order_number"]) + sz_meta, b_meta, c_meta = _decode_font(cfg["print.font_meta"]) + sz_item, b_item, c_item = _decode_font(cfg["print.font_item_name"]) + + success = False + error_msg = None + try: + for _ in range(copies): + p = _get_printer(pr.ip_address, pr.port, pr.codepage_n) + + if compact: + p._raw(b'\x1b\x61\x00') + _apply_font(p, sz_ord, b_ord) + header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}" + if customer_count: + header += f" | {customer_count}ατ." + _raw_text(p, (header.upper() if c_ord else header) + "\n") + _reset_font(p) + _divider(p, div, pr.line_width or LINE_WIDTH) + else: + _print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord, align=b'\x1b\x61\x01') + _divider(p, div, pr.line_width or LINE_WIDTH) + p._raw(b'\x1b\x61\x00') + _apply_font(p, sz_meta, b_meta) + _raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n") + _raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n") + _raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n") + if customer_count: + _raw_text(p, ("ΑΤΟΜΑ:" if c_meta else "Ατομα:") + f" {customer_count}\n") + _reset_font(p) + _divider(p, div, pr.line_width or LINE_WIDTH) + + for entry in entries: + name = entry['product'].name + qty = entry['quantity'] + qty_str = f"{int(qty)}x" if qty == int(qty) else f"{qty}x" + _apply_font(p, sz_item, b_item) + line = f"{qty_str} {name}" + _raw_text(p, (line.upper() if c_item else line) + "\n") + _reset_font(p) + + p._raw(b'\n\n\n') + p._raw(b'\x1d\x56\x41\x03') # cut + p.close() + success = True + except Exception as e: + error_msg = str(e) + logger.error("Service print failed for printer %s (%s:%s): %s", pr.name, pr.ip_address, pr.port, e) + + results.append({"printer_name": pr.name, "success": success, "error": error_msg}) + + return results + + +# ── Auto-retry background thread ────────────────────────────────────────────── + +import threading as _threading +import time as _time + +_RETRY_INTERVAL = 8 # seconds between retry sweeps + +def _retry_sweep(): + """ + Find all pending PrintJob rows whose order items are still active and unprinted, + and retry each one. Each job gets its own fresh DB session to avoid SQLAlchemy + identity-map caching stale printed/status values across iterations. + """ + # Collect pending job IDs first, then process each in isolation + db: Session = SessionLocal() + try: + if _is_spoof_mode(db): + return + pending_job_ids = [ + pj.id for pj in db.query(PrintJob.id).filter(PrintJob.status == "pending").all() + ] + except Exception as e: + logger.exception("Error fetching pending print jobs: %s", e) + return + finally: + db.close() + + for job_id in pending_job_ids: + # Fresh session per job — no stale identity-map entries + db: Session = SessionLocal() + try: + now = datetime.datetime.now(datetime.timezone.utc) + + pj = db.query(PrintJob).filter(PrintJob.id == job_id).first() + if not pj or pj.status != "pending": + continue + + order = db.query(Order).filter(Order.id == pj.order_id).first() + if not order or order.status in ("closed", "cancelled"): + pj.status = "cancelled" + pj.cancelled_at = now + pj.cancel_reason = "order_closed" + db.commit() + continue + + item_ids = json.loads(pj.item_ids or "[]") + active_unprinted = db.query(OrderItem).filter( + OrderItem.id.in_(item_ids), + OrderItem.status == "active", + OrderItem.printed == False, # noqa: E712 + ).all() + + if not active_unprinted: + # All items already printed or removed — job is done + pj.status = "success" + pj.succeeded_at = now + db.commit() + continue + + printer = db.query(Printer).filter(Printer.id == pj.printer_id, Printer.is_active == True).first() # noqa: E712 + if not printer: + continue + + pj.last_attempted_at = now + pj.retry_count = (pj.retry_count or 0) + 1 + + success = False + error_msg = None + try: + for _ in range(pj.copies or 1): + p = _get_printer(printer.ip_address, printer.port, printer.codepage_n) + _print_kitchen_ticket(p, order, active_unprinted, db, printer.line_width) + p.close() + success = True + pj.status = "success" + pj.succeeded_at = now + for item in active_unprinted: + item.printed = True + logger.info("PrintJob %s succeeded on retry %d", pj.id, pj.retry_count) + except Exception as e: + error_msg = str(e) + logger.warning("PrintJob %s retry %d failed: %s", pj.id, pj.retry_count, e) + + db.add(PrintLog( + order_id=pj.order_id, + printer_id=pj.printer_id, + item_ids=pj.item_ids, + success=success, + error_message=error_msg, + )) + db.commit() + + except Exception as e: + logger.exception("Unexpected error processing PrintJob %s: %s", job_id, e) + finally: + db.close() + + +def _retry_loop(): + while True: + _time.sleep(_RETRY_INTERVAL) + _retry_sweep() + + +def start_print_retry_thread(): + t = _threading.Thread(target=_retry_loop, daemon=True, name="print-retry") + t.start() + logger.info("Print auto-retry thread started (interval=%ds, no retry cap)", _RETRY_INTERVAL) diff --git a/local_backend/services/sse_bus.py b/local_backend/services/sse_bus.py index 6680f6c..20f27a8 100644 --- a/local_backend/services/sse_bus.py +++ b/local_backend/services/sse_bus.py @@ -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( diff --git a/local_backend/services/ws_bus.py b/local_backend/services/ws_bus.py new file mode 100644 index 0000000..ee95f4b --- /dev/null +++ b/local_backend/services/ws_bus.py @@ -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 diff --git a/local_backend/wipe_database.py b/local_backend/wipe_database.py index 34ab130..b8c5ab5 100644 --- a/local_backend/wipe_database.py +++ b/local_backend/wipe_database.py @@ -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...") diff --git a/manager_dashboard/index.html b/manager_dashboard/index.html index 9212436..d563033 100644 --- a/manager_dashboard/index.html +++ b/manager_dashboard/index.html @@ -4,9 +4,6 @@ POS Manager - - -
diff --git a/manager_dashboard/nginx.conf b/manager_dashboard/nginx.conf index 281b57e..8da63b7 100644 --- a/manager_dashboard/nginx.conf +++ b/manager_dashboard/nginx.conf @@ -6,6 +6,9 @@ server { location /api/ { proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600; diff --git a/manager_dashboard/public/fonts/GoogleSans-Variable-Italic.ttf b/manager_dashboard/public/fonts/GoogleSans-Variable-Italic.ttf new file mode 100644 index 0000000..c0405ca Binary files /dev/null and b/manager_dashboard/public/fonts/GoogleSans-Variable-Italic.ttf differ diff --git a/manager_dashboard/public/fonts/GoogleSans-Variable.ttf b/manager_dashboard/public/fonts/GoogleSans-Variable.ttf new file mode 100644 index 0000000..6df533e Binary files /dev/null and b/manager_dashboard/public/fonts/GoogleSans-Variable.ttf differ diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx index 5ba6615..5451d60 100644 --- a/manager_dashboard/src/App.jsx +++ b/manager_dashboard/src/App.jsx @@ -2,24 +2,57 @@ import { useEffect, useState } from 'react' import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom' import useAuthStore from './store/authStore' import AppLayout from './layouts/AppLayout' +import client from './api/client' + +// Auth / setup import LoginPage from './pages/LoginPage' import SetupWizard from './pages/SetupWizard' + +// Standalone (no shell) +import KdsPage from './pages/KdsPage' + +// Operation import DashboardPage from './pages/DashboardPage' import TablesPage from './pages/TablesPage' import OrderDetailPage from './pages/OrderDetailPage' -import ManagementPage from './pages/ManagementPage' -import ReportsPage from './pages/reports/ReportsPage' -import SettingsPage from './pages/Settings/SettingsPage' import OnlineOrdersPage from './pages/OnlineOrdersPage' + +// CRM +import ContactsPage from './pages/crm/ContactsPage' +import CustomersPage from './pages/crm/CustomersPage' + +// Financials +import ExpensesPage from './pages/financials/ExpensesPage' +import TabsPage from './pages/financials/TabsPage' + +// Management +import ProductsPage from './pages/Management/ProductsPage' +import TablesConfigPage from './pages/Management/TablesConfigPage' +import StaffPage from './pages/Management/StaffPage' +import ManagementSchedulePage from './pages/Management/SchedulePage' +import PrepZonesConfigPage from './pages/Management/PrepZonesConfigPage' +import PricingPage from './pages/Management/PricingPage' + +// Inventory +import ThrowawaysPage from './pages/inventory/ThrowawaysPage' +import StockPage from './pages/inventory/StockPage' + +// Reports +import TodayPage from './pages/reports/TodayPage' +import StorePage from './pages/reports/StorePage' +import StaffReportPage from './pages/reports/StaffReportPage' +import ProductsReportPage from './pages/reports/ProductsReportPage' +import PrepZonesPage from './pages/reports/PrepZonesPage' +import ExpensesReportPage from './pages/reports/ExpensesReportPage' +import TechnicalPage from './pages/reports/TechnicalPage' +import MiscPage from './pages/reports/MiscPage' + +// Other +import AppsPage from './pages/AppsPage' +import PhonePage from './pages/PhonePage' +import SettingsPage from './pages/Settings/SettingsPage' import NotesPage from './pages/NotesPage' -import ContactsPage from './pages/ContactsPage' -import ExpensesPage from './pages/ExpensesPage' -import CustomersPage from './pages/CustomersPage' -import TabsPage from './pages/TabsPage' -import WastePage from './pages/WastePage' -import KdsPage from './pages/KdsPage' -import SchedulePage from './pages/SchedulePage' -import client from './api/client' +import PermissionGuard from './components/PermissionGuard' function Spinner() { return ( @@ -29,8 +62,6 @@ function Spinner() { ) } -// Rehydrates user from stored token before rendering any routes. -// Prevents the flicker where a valid token causes a redirect to /login on refresh. function AuthRehydrator({ children }) { const { token, user, rehydrate, logout } = useAuthStore() const [ready, setReady] = useState(false) @@ -55,7 +86,6 @@ function RequireAuth({ children }) { return token ? children : } -// Checks /api/setup/status on mount and redirects to /setup if no managers exist. function SetupGuard({ children }) { const [checked, setChecked] = useState(false) const navigate = useNavigate() @@ -65,9 +95,7 @@ function SetupGuard({ children }) { .then(({ data }) => { if (data.needs_setup) navigate('/setup', { replace: true }) }) - .catch(() => { - // Backend unreachable — proceed, login will surface the error. - }) + .catch(() => {}) .finally(() => setChecked(true)) }, [navigate]) @@ -82,24 +110,62 @@ export default function App() { } /> } /> + } /> + }> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + {/* ── Operation ── */} + } /> + } /> } /> - } /> - } /> + } /> + + {/* ── CRM ── */} + } /> + } /> + + {/* ── Financials ── */} + } /> + } /> + + {/* ── Management (Headquarters) ── */} + } /> + } /> + } /> + } /> + } /> + } /> + + {/* ── Inventory ── */} + } /> + } /> + + {/* ── Reports (Overwatch) ── */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* ── Apps & Settings ── */} + } /> + } /> + } /> + } /> + + {/* ── Legacy redirects (old URLs → new locations) ── */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/manager_dashboard/src/components/PermissionGuard.jsx b/manager_dashboard/src/components/PermissionGuard.jsx new file mode 100644 index 0000000..1adcfe9 --- /dev/null +++ b/manager_dashboard/src/components/PermissionGuard.jsx @@ -0,0 +1,25 @@ +import { ShieldAlert } from 'lucide-react' +import usePermissions from '../hooks/usePermissions' + +/** + * Wraps a route element. Renders a 403 screen if the user lacks the required permission. + * Usage: + */ +export default function PermissionGuard({ perm, children }) { + const perms = usePermissions() + + if (!perms[perm]) { + return ( +
+ +

Δεν έχετε πρόσβαση

+

+ Δεν έχετε τα απαραίτητα δικαιώματα για αυτή τη σελίδα. + Επικοινωνήστε με τον διαχειριστή εάν νομίζετε ότι πρόκειται για λάθος. +

+
+ ) + } + + return children +} diff --git a/manager_dashboard/src/components/PhoneListener.jsx b/manager_dashboard/src/components/PhoneListener.jsx new file mode 100644 index 0000000..68f6393 --- /dev/null +++ b/manager_dashboard/src/components/PhoneListener.jsx @@ -0,0 +1,358 @@ +/** + * Mounts once inside AppLayout. Holds the WebSocket connection to /api/phone/ws, + * matches incoming callers against the customer list, and renders the call popup + * over whatever page is currently open. + */ +import { useEffect, useCallback, useRef, useState } from 'react' +import { PhoneIncoming, X, Mail, Phone, MessageSquare, Tag, + ShoppingBag, TrendingUp, Clock, User } from 'lucide-react' +import client from '../api/client' +import usePhoneStore from '../store/phoneStore' + +/* ─── Phone normalisation ─────────────────────────────────────────────────── */ +function normalisePhone(raw) { + if (!raw) return '' + let s = raw.replace(/[\s\-().+]/g, '') + if (s.startsWith('0030')) s = s.slice(4) + else if (s.startsWith('30') && s.length > 10) s = s.slice(2) + return s +} +function phonesMatch(a, b) { + const na = normalisePhone(a), nb = normalisePhone(b) + return na.length > 0 && na === nb +} + +function buildWsUrl() { + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + return `${proto}://${window.location.host}/api/phone/ws` +} + +/* ─── Helpers ─────────────────────────────────────────────────────────────── */ +function formatTime(date) { + return date.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' }) +} +function formatDate(dateStr) { + if (!dateStr) return '—' + return new Date(dateStr).toLocaleDateString('el-GR', { day: '2-digit', month: 'short', year: 'numeric' }) +} +function formatCurrency(n) { + return `€${Number(n || 0).toFixed(2)}` +} + +/* ─── Ripple rings ────────────────────────────────────────────────────────── */ +function RippleRings({ color }) { + return ( +
+ {[0, 1, 2].map(i => ( + + ))} +
+ ) +} + +/* ─── Known customer modal ────────────────────────────────────────────────── */ +function KnownCustomerModal({ call, customer, orders, onClose }) { + const recent = (orders || []).slice(0, 3) + return ( +
+
+
+
+
+ +
+ + +
+
+ +
+
+
+
Εισερχόμενη Κλήση
+
{formatTime(call.at)} · {call.caller}
+
+
+ +
+
{customer.name?.[0]?.toUpperCase() || '?'}
+
+
{customer.name}
+ {customer.nickname && ( +
+ {customer.nickname} +
+ )} +
+
+
+ +
+ +
+ {customer.email && ( +
+ + {customer.email} +
+ )} + {customer.phone && ( +
+ + {customer.phone} +
+ )} + {customer.notes && ( +
+ + "{customer.notes}" +
+ )} +
+ +
+
+
Επισκέψεις
+
+ {customer.visit_count ?? 0} +
+
+
+
Σύνολο
+
+ {formatCurrency(customer.total_spent)} +
+
+
+ +
+
Πρόσφατες Παραγγελίες
+ {recent.length === 0 ? ( +
+ Δεν υπάρχουν παραγγελίες ακόμα +
+ ) : ( +
+ {recent.map(order => ( +
+ +
+
+ {formatDate(order.opened_at)} + {order.table_name && · {order.table_name}} +
+
{order.item_count} είδη
+
+
{formatCurrency(order.total)}
+
+ ))} +
+ )} +
+ +
+ +
+
+
+
+ ) +} + +/* ─── Unknown caller modal ────────────────────────────────────────────────── */ +function UnknownCallerModal({ call, onClose }) { + return ( +
+
+
+
+
+
+ + +
+
+ +
+
+
+
Εισερχόμενη Κλήση
+
{formatTime(call.at)}
+
+
+ +
+
+
Άγνωστος καλών
+
+ {call.caller || '—'} +
+ {call.ext &&
Εσωτερικό: {call.ext}
} +
+ +
+ +
+
+
+
+
+ ) +} + +/* ─── PhoneListener ───────────────────────────────────────────────────────── */ +export default function PhoneListener() { + const { activeCall, dismissCall, setActiveCall, addCallToLog } = usePhoneStore() + const [customers, setCustomers] = useState([]) + + useEffect(() => { + client.get('/api/customers/') + .then(r => setCustomers(r.data || [])) + .catch(() => {}) + }, []) + + const handleIncoming = useCallback(async ({ caller, ext }) => { + const at = new Date() + const matched = customers.find(c => phonesMatch(c.phone, caller)) + let orders = null + if (matched) { + try { + const r = await client.get(`/api/customers/${matched.id}/orders`) + orders = r.data?.orders ?? [] + } catch { orders = [] } + } + const entry = { caller, ext, at, customer: matched || null, orders } + addCallToLog(entry) + setActiveCall(entry) + }, [customers, addCallToLog, setActiveCall]) + + useEffect(() => { + let ws, dead = false + function connect() { + if (dead) return + ws = new WebSocket(buildWsUrl()) + ws.onclose = () => { if (!dead) setTimeout(connect, 3000) } + ws.onerror = () => ws.close() + ws.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data) + if (data.type === 'incoming_call') handleIncoming({ caller: data.caller, ext: data.ext }) + } catch {} + } + } + connect() + return () => { dead = true; ws?.close() } + }, [handleIncoming]) + + if (!activeCall) return null + + return ( + <> + + {activeCall.customer + ? + : + } + + ) +} diff --git a/manager_dashboard/src/components/Sidebar.jsx b/manager_dashboard/src/components/Sidebar.jsx index 6c204d2..044b413 100644 --- a/manager_dashboard/src/components/Sidebar.jsx +++ b/manager_dashboard/src/components/Sidebar.jsx @@ -1,24 +1,227 @@ -import { NavLink } from 'react-router-dom' +import { NavLink, useLocation } from 'react-router-dom' import { useState, useEffect, useRef } from 'react' -import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users, CreditCard, Trash2, ChefHat, CalendarDays } from 'lucide-react' +import { + LayoutDashboard, LayoutGrid, ShoppingBag, + BookUser, Users, Receipt, CreditCard, + Package, TableProperties, UserCog, CalendarDays, Trash2, Boxes, + BarChart2, TrendingUp, Users2, ShoppingCart, Tag, Activity, Printer, Ban, Percent, ConciergeBell, FileText, + AppWindow, Settings, ChevronDown, CookingPot, StickyNote, +} from 'lucide-react' import { getIncomingOrders } from '../api/client' import { isFeatureEnabled } from '../hooks/usePhase2Features' +import usePermissions from '../hooks/usePermissions' -// Phase 2 feature IDs mapped to their sidebar routes (must match usePhase2Features defs) -const PHASE2_ROUTES = new Set(['/notes', '/expenses', '/contacts', '/customers', '/tabs', '/waste', '/kds', '/schedule']) +// ─── Nav structure ──────────────────────────────────────────────────────────── + +// `perm` — key from usePermissions(). If set, item is hidden when user lacks that permission. +function buildNav(pendingCount) { + return [ + { + category: 'Operation', + items: [ + { type: 'solo', to: '/dashboard', icon: LayoutDashboard, label: 'Εικόνα' }, + { type: 'solo', to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' }, + { type: 'solo', to: '/online-orders', icon: ShoppingBag, label: 'Online Παραγγ.', badge: pendingCount, phase2: 'online-orders' }, + ], + }, + { + category: 'Financial', + items: [ + { + type: 'group', id: 'crm', icon: BookUser, label: 'CRM', + children: [ + { to: '/crm/contacts', icon: BookUser, label: 'Επαφές', phase2: 'contacts' }, + { to: '/crm/customers', icon: Users, label: 'Πελάτες', phase2: 'customers' }, + ], + }, + { + type: 'group', id: 'financials', icon: Receipt, label: 'Οικονομικά', + children: [ + { to: '/financials/expenses', icon: Receipt, label: 'Έξοδα', phase2: 'expenses' }, + { to: '/financials/tabs', icon: CreditCard, label: 'Καρτέλες Πελ.', phase2: 'tabs' }, + ], + }, + ], + }, + { + category: 'Headquarters', + items: [ + { + type: 'group', id: 'management', icon: Package, label: 'Διαχείριση', + children: [ + { to: '/management/products', icon: Package, label: 'Προϊόντα', perm: 'manageMenu' }, + { to: '/management/tables', icon: TableProperties, label: 'Τραπέζια', perm: 'manageTables' }, + { to: '/management/staff', icon: UserCog, label: 'Προσωπικό', perm: 'manageStaff' }, + { to: '/management/schedule', icon: CalendarDays, label: 'Πρόγραμμα', perm: 'manageStaff', phase2: 'schedule' }, + { to: '/management/prep-zones', icon: CookingPot, label: 'Ζώνες Ετοιμασίας', perm: 'manageMenu' }, + { to: '/management/pricing', icon: Percent, label: 'Προσφορές & Τιμές', perm: 'manageMenu' }, + { to: '/notes', icon: StickyNote, label: 'Σημειώσεις' }, + ], + }, + { + type: 'group', id: 'inventory', icon: Boxes, label: 'Αποθήκη', + children: [ + { to: '/inventory/throwaways', icon: Trash2, label: 'Απορρίμματα', phase2: 'waste' }, + { to: '/inventory/stock', icon: Boxes, label: 'Απόθεμα', placeholder: true }, + ], + }, + ], + }, + { + category: 'Overwatch', + perm: 'viewReports', + items: [ + { + type: 'group', id: 'reports', icon: BarChart2, label: 'Αναφορές', + children: [ + { to: '/reports/today', icon: Activity, label: 'Σήμερα' }, + { to: '/reports/store', icon: TrendingUp, label: 'Κατάστημα' }, + { to: '/reports/staff', icon: Users2, label: 'Προσωπικό' }, + { to: '/reports/products', icon: ShoppingCart, label: 'Προϊόντα' }, + { to: '/reports/prep-zones', icon: ConciergeBell, label: 'Ζώνες Ετοιμασίας' }, + { to: '/reports/expenses', icon: FileText, label: 'Έξοδα' }, + { to: '/reports/technical', icon: Printer, label: 'Τεχνικά' }, + { to: '/reports/misc', icon: Tag, label: 'Λοιπά' }, + ], + }, + ], + }, + ] +} + +const BOTTOM_ITEMS = [ + { to: '/apps', icon: AppWindow, label: 'Εφαρμογές' }, + { to: '/settings', icon: Settings, label: 'Ρυθμίσεις', perm: 'manageSettings' }, +] + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function isChildActive(children, pathname) { + return children.some(c => pathname === c.to || pathname.startsWith(c.to + '/')) +} + +// ─── Child link ─────────────────────────────────────────────────────────────── + +function ChildLink({ to, icon: Icon, label, placeholder }) { + if (placeholder) { + return ( +
+ + {label} + soon +
+ ) + } + + return ( + + `flex items-center gap-2.5 px-3 py-2 mx-2 rounded-md text-[13px] font-medium transition-colors ` + + (isActive + ? 'bg-primary-600 text-white' + : 'text-primary-200 hover:bg-primary-700 hover:text-white') + } + > + {({ isActive }) => ( + <> + + {label} + {isActive && } + + )} + + ) +} + +// ─── Group item (collapsible parent) ───────────────────────────────────────── + +function GroupItem({ id, icon: Icon, label, children, openGroup, onToggle, perms }) { + const location = useLocation() + const hasActive = isChildActive(children, location.pathname) + const isOpen = openGroup === id + + // Filter out phase2-gated children and children the user lacks permission for + const visibleChildren = children.filter(c => { + if (c.phase2 && !isFeatureEnabled(c.phase2)) return false + if (c.perm && !perms[c.perm]) return false + return true + }) + + if (visibleChildren.length === 0) return null + + return ( +
+ + + {isOpen && ( +
+ {visibleChildren.map(child => ( + + ))} +
+ )} +
+ ) +} + +// ─── Solo item (direct link) ────────────────────────────────────────────────── + +function SoloItem({ to, icon: Icon, label, badge, onNavigate }) { + return ( + + `flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ` + + (isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700 hover:text-white') + } + > +
+ + {badge > 0 && ( + + {badge > 9 ? '9+' : badge} + + )} +
+ {label} +
+ ) +} + +// ─── Sidebar ────────────────────────────────────────────────────────────────── export default function Sidebar() { - const [collapsed, setCollapsed] = useState(false) const [pendingCount, setPendingCount] = useState(0) + const [openGroup, setOpenGroup] = useState(null) const [, featTick] = useState(0) + const location = useLocation() const pollRef = useRef(null) + const perms = usePermissions() + // Online orders badge polling useEffect(() => { async function fetchPending() { try { const { data } = await getIncomingOrders() setPendingCount(data.length) - } catch { /* silently ignore — sidebar badge is non-critical */ } + } catch { /* non-critical */ } } fetchPending() pollRef.current = setInterval(fetchPending, 20_000) @@ -36,62 +239,93 @@ export default function Sidebar() { } }, []) - const ALL_NAV = [ - { to: '/dashboard', icon: BarChart2, label: 'Dashboard' }, - { to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' }, - { to: '/online-orders', icon: ShoppingBag, label: 'Online Orders', badge: pendingCount, phase2: 'online-orders' }, - { to: '/reports', icon: ClipboardList, label: 'Αναφορές' }, - { to: '/management', icon: Package, label: 'Διαχείριση' }, - { to: '/notes', icon: NotebookPen, label: 'Σημειώσεις', phase2: 'notes' }, - { to: '/expenses', icon: Receipt, label: 'Έξοδα', phase2: 'expenses' }, - { to: '/contacts', icon: BookUser, label: 'Επαφές', phase2: 'contacts' }, - { to: '/customers', icon: Users, label: 'Πελάτες', phase2: 'customers' }, - { to: '/tabs', icon: CreditCard, label: 'Καρτέλες', phase2: 'tabs' }, - { to: '/waste', icon: Trash2, label: 'Αποβλήτα', phase2: 'waste' }, - { to: '/kds', icon: ChefHat, label: 'KDS', phase2: 'kds' }, - { to: '/schedule', icon: CalendarDays, label: 'Πρόγραμμα', phase2: 'schedule' }, - { to: '/settings', icon: Settings, label: 'Ρυθμίσεις' }, - ] + // Auto-open the group that contains the active route on location change + useEffect(() => { + const nav = buildNav(0) + for (const section of nav) { + for (const item of section.items) { + if (item.type === 'group' && isChildActive(item.children, location.pathname)) { + setOpenGroup(item.id) + return + } + } + } + }, [location.pathname]) - // Filter out disabled Phase 2 entries - const NAV = ALL_NAV.filter(item => !item.phase2 || isFeatureEnabled(item.phase2)) + function handleGroupToggle(id) { + setOpenGroup(prev => (prev === id ? null : id)) + } + + function handleSoloClick() { + setOpenGroup(null) + } + + const nav = buildNav(pendingCount) return ( -
)} @@ -598,126 +645,548 @@ function TableChip({ name, status, amount, onClick }) { // ─── Shifts card ────────────────────────────────────────────────────────────── -// ─── End shift confirmation modal ───────────────────────────────────────────── +// ─── Shift Review Modal (Overview + Details + optional close flow) ───────────── -function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) { - const [step, setStep] = useState(1) // 1 = notes, 2 = cash handover +const SWATCH = { cash: '#2f9e5e', card: '#3758c9' } + +function initials(name = '') { + const parts = name.trim().split(/\s+/) + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + return name.slice(0, 2).toUpperCase() +} + +function fmtDur(ms) { + const totalMin = Math.round(ms / 60000) + const h = Math.floor(totalMin / 60) + const m = totalMin % 60 + return h > 0 ? `${h}ω ${m}λ` : `${m}λ` +} + +// Embedded detail views (extracted from ShiftDetailModal but self-contained here) +function classifyItem(item, shiftWaiterId) { + if (item.status === 'cancelled') + return { key: 'cancelled', label: 'Ακυρώθηκε', dot: '#dc2626', bg: '#fef2f2', border: '#fecaca', text: '#991b1b' } + const orderedByMe = item.added_by_id === shiftWaiterId + const paidToMe = item.paid_by_id === shiftWaiterId + const isPaid = item.status === 'paid' + if (orderedByMe && paidToMe && isPaid) + return { key: 'both', label: 'Πλήρης', dot: '#16a34a', bg: '#f0fdf4', border: '#bbf7d0', text: '#15803d' } + if (!orderedByMe && paidToMe && isPaid) + return { key: 'paid', label: 'Μόνο Πληρώθηκε', dot: '#2563eb', bg: '#eff6ff', border: '#bfdbfe', text: '#1d4ed8' } + if (orderedByMe && isPaid && !paidToMe) + return { key: 'ordered', label: 'Μόνο Παρήγγειλε',dot: '#ca8a04', bg: '#fefce8', border: '#fef08a', text: '#854d0e' } + if (orderedByMe && !isPaid) + return { key: 'unpaid', label: 'Ανοιχτό', dot: '#ea580c', bg: '#fff7ed', border: '#fed7aa', text: '#c2410c' } + return { key: 'anomaly', label: 'Πρόβλημα', dot: '#dc2626', bg: '#fef2f2', border: '#fecaca', text: '#b91c1c' } +} + +const DETAIL_FILTER_STATUS = [ + { key: 'all', label: 'Όλα' }, + { key: 'both', label: 'Πλήρης' }, + { key: 'paid', label: 'Μόνο Πληρώθηκε' }, + { key: 'ordered', label: 'Μόνο Παρήγγειλε' }, + { key: 'unpaid', label: 'Ανοιχτό' }, + { key: 'cancelled', label: 'Ακυρώθηκε' }, +] +const DETAIL_FILTER_PAYMENT = [ + { key: 'any', label: 'Κάθε τρόπος' }, + { key: 'cash', label: 'Μετρητά' }, + { key: 'card', label: 'Κάρτα' }, +] + +function DetailItemRow({ item, shiftWaiterId }) { + const c = classifyItem(item, shiftWaiterId) + const isCancelled = item.status === 'cancelled' + const method = item.payment_method || 'cash' + return ( +
+
+
+
+ + {item.product_name} + ×{item.quantity} + + + {fmtEuro(item.subtotal)} + +
+
+ + Παρήγγειλε: {item.added_by_name ?? '—'} + + {!isCancelled && ( + + Πληρώθηκε: {item.paid_by_name ?? '—'} + {item.payment_method && ( + {method === 'card' ? 'Κάρτα' : 'Μετρητά'} + )} + + )} +
+
+ {c.label} +
+
+
+ ) +} + +function DetailOrderGroup({ order, shiftWaiterId, statusFilter, paymentFilter }) { + const visibleItems = order.items.filter(item => { + const statusOk = statusFilter === 'all' || classifyItem(item, shiftWaiterId).key === statusFilter + const payOk = paymentFilter === 'any' || !item.payment_method + ? (paymentFilter === 'any' || (paymentFilter === 'cash' && item.status === 'paid')) + : (item.payment_method || 'cash') === paymentFilter + // If status filter is cancelled, don't apply payment filter + const isCancelled = item.status === 'cancelled' + return statusOk && (isCancelled || paymentFilter === 'any' || (item.payment_method || 'cash') === paymentFilter) + }) + if (visibleItems.length === 0) return null + const groupTotal = visibleItems.filter(i => i.status === 'paid').reduce((s, i) => s + i.subtotal, 0) + return ( +
+
+
+ Παραγγελία #{order.order_id} + {order.table_name && · {order.table_name}} +
+
+ {order.opener_name && ( + + Άνοιξε: {order.opener_name} + {order.opened_at ? {fmtTime(order.opened_at)} : null} + + )} + {groupTotal > 0 && {fmtEuro(groupTotal)}} +
+
+ {visibleItems.map(item => ( + + ))} +
+ ) +} + +// ─── The main modal ──────────────────────────────────────────────────────────── + +function ShiftReviewModal({ shift, mode, onClose, onConfirm, busy }) { + // mode: 'stats' (view only) | 'close' (end shift flow) + const [screen, setScreen] = useState('overview') // 'overview' | 'details' + const [closeStep, setCloseStep] = useState(1) // 1 = review+notes, 2 = cash handover const [notes, setNotes] = useState('') const [countedCash, setCountedCash] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [paymentFilter, setPaymentFilter] = useState('any') - const expectedHandover = (shift.starting_cash || 0) + (shift.total_collected || 0) + // Fetch live summary (includes orders for details tab + per-shift aggregates) + const { data: summary, isLoading } = useQuery({ + queryKey: ['shift-detail', shift.id], + queryFn: () => client.get(`/api/shifts/${shift.id}/summary`).then(r => r.data), + staleTime: 30 * 1000, + refetchOnWindowFocus: false, + }) + + // Use enriched data from summary when available, fall back to shift prop + const data = summary || shift + const cashSales = data.cash_sales ?? data.total_collected ?? 0 + const cardSales = data.card_sales ?? 0 + const totalRev = cashSales + cardSales + const startingCash = data.starting_cash ?? 0 + const expectedDrawer = startingCash + cashSales + const cashPct = totalRev > 0 ? Math.round((cashSales / totalRev) * 100) : 0 + const cardPct = 100 - cashPct + + // Cash handover discrepancy const counted = countedCash !== '' ? parseFloat(countedCash) : null - const discrepancy = counted != null ? counted - expectedHandover : null + const discrepancy = counted != null ? +(counted - expectedDrawer).toFixed(2) : null const isShort = discrepancy != null && discrepancy < -0.005 const isOver = discrepancy != null && discrepancy > 0.005 const isClean = discrepancy != null && !isShort && !isOver + // Duration + const startMs = shift.started_at ? new Date(shift.started_at).getTime() : null + const nowMs = Date.now() + const durLabel = startMs ? fmtDur(nowMs - startMs) : '—' + + // Stats from summary + const totalOrders = summary?.orders?.length ?? 0 + const totalItems = summary?.orders?.reduce((s, o) => s + o.items.filter(i => i.status === 'paid').reduce((ss, i) => ss + i.quantity, 0), 0) ?? 0 + const totalTables = summary?.orders ? new Set(summary.orders.filter(o => o.table_id).map(o => o.table_id)).size : 0 + const shiftPay = data.shift_pay + + // Per-hour bars from summary orders + const hourBuckets = {} + if (summary?.orders) { + for (const o of summary.orders) { + for (const item of o.items) { + if (item.status !== 'paid' || !item.paid_at) continue + const h = new Date(item.paid_at).getHours() + hourBuckets[h] = (hourBuckets[h] || 0) + item.subtotal + } + } + } + const hourEntries = Object.entries(hourBuckets).sort((a, b) => a[0] - b[0]) + const maxHourVal = Math.max(...hourEntries.map(([, v]) => v), 1) + + const waiterIdToUse = shift.waiter_id ?? summary?.waiter_id + + // ── Shared header ── + const inits = initials(shift.waiter_name || '') + const color = avatarColor(shift.waiter_name || '') + const isClosing = mode === 'close' && closeStep === 2 + const badgeLabel = mode === 'close' ? (closeStep === 1 ? 'Κλείσιμο' : 'Κλείσιμο') : 'Σε εξέλιξη' + return ( -
{ if (e.target === e.currentTarget) onClose() }}> -
- {/* Header */} -
-
- +
{ if (e.target === e.currentTarget) onClose() }} + > +
+ + {/* ── Header ── */} +
+
+ {inits}
-
-
Τέλος βάρδιας
-
{shift.waiter_name}
+
+
+ {shift.waiter_name} + + + {badgeLabel} + +
+
+ Βάρδια #{shift.id} +
-
- {[1, 2].map(n => ( -
= n ? '#3758c9' : '#e5e7eb' }} /> + {/* Screen toggle */} +
+ {[ + { key: 'overview', title: 'Επισκόπηση', icon: '▤' }, + { key: 'details', title: 'Λεπτομέρειες', icon: '≡' }, + ].map(({ key, title, icon }) => ( + ))}
+
- {/* Shift summary always visible */} -
-
- Έναρξη{fmtTime(shift.started_at)} -
-
- Διάρκεια{fmtDuration(shift.started_at)} -
-
- Είσπραξη{fmtEuro(shift.total_collected)} -
+ {/* ── Time strip ── */} +
+ {[ + { key: 'Έναρξη', val: fmtTime(shift.started_at) }, + { key: 'Διάρκεια', val: durLabel }, + { key: mode === 'close' ? 'Τέλος' : 'Τρέχουσα', val: mode === 'close' ? fmtTime(new Date().toISOString()) : fmtTime(new Date().toISOString()), muted: mode !== 'close' }, + ].map(({ key, val, muted }) => ( +
+
{key}
+
{val}
+
+ ))}
- {/* Step 1: notes */} - {step === 1 && ( - <> -
- -