Files
bonamin 34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:00:14 +03:00

1895 lines
75 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from tz import local_strftime
def _iso(dt) -> str | None:
"""Serialize a datetime to ISO 8601 with Z suffix so browsers parse it as UTC."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.isoformat() + "Z"
return dt.isoformat()
from sqlalchemy.orm import Session
from typing import List, Optional
from database import get_db
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):
printer_id: int
class TransferOrderRequest(BaseModel):
target_table_id: int
class MergeOrderRequest(BaseModel):
target_order_id: int
class SplitItemRequest(BaseModel):
quantity: int # how many to split off into a new item row
class PrintSynopsisRequest(BaseModel):
printer_id: int
class MoveItemsRequest(BaseModel):
item_ids: List[int]
target_order_id: int
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
from services.sse_bus import broadcast_sync
router = APIRouter()
def _waiter_can_cancel(user: User, db: Session) -> bool:
"""True only if both the global setting and the per-waiter flag are on."""
row = db.query(PosSettings).filter(PosSettings.key == "orders.waiter_cancellations_allowed").first()
global_on = row is not None and row.value == "true"
return global_on and bool(user.can_cancel_orders)
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.
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()
if not table:
return False
allowed_group_ids = {z.group_id for z in zones}
return table.group_id in allowed_group_ids
def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
item_ids: list = None, amount: float = None, payment_method: str = None, note: str = None):
db.add(OrderAuditLog(
order_id=order_id,
event_type=event_type,
waiter_id=waiter_id,
item_ids=json.dumps(item_ids) if item_ids is not None else None,
amount=amount,
payment_method=payment_method,
note=note,
))
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 == "superadmin" or user.perm_access_dashboard
if is_manager:
return {"can_cancel": True, "reason": None}
can = _waiter_can_cancel(user, db)
reason = None
if not can:
row = db.query(PosSettings).filter(PosSettings.key == "orders.waiter_cancellations_allowed").first()
global_on = row is not None and row.value == "true"
if not global_on:
reason = "global_disabled"
elif not user.can_cancel_orders:
reason = "user_disabled"
return {"can_cancel": can, "reason": reason}
@router.get("/", response_model=List[OrderOut])
def list_orders(
order_status: Optional[str] = None,
waiter_id: Optional[int] = None,
all: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Order)
if order_status:
q = q.filter(Order.status == order_status)
elif not all:
q = q.filter(Order.status.in_(ACTIVE_STATUSES))
if waiter_id:
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
return q.all()
@router.get("/my", response_model=List[OrderOut])
def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
direct = db.query(Order).join(OrderWaiter).filter(
OrderWaiter.waiter_id == user.id,
Order.status.in_(["open", "partially_paid"]),
).all()
# Also orders where user is opener but not explicitly assigned
also_opened = db.query(Order).filter(
Order.opened_by == user.id,
Order.status.in_(["open", "partially_paid"]),
).all()
seen = {o.id for o in direct}
return direct + [o for o in also_opened if o.id not in seen]
class ActiveOrderSlim(BaseModel):
id: int
table_id: Optional[int]
status: str
order_type: str = "here"
waiter_ids: List[int]
model_config = {"from_attributes": True}
@router.get("/active", response_model=List[ActiveOrderSlim])
def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""All currently open/partially-paid/paid orders (lightweight). Accessible to all staff."""
orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid", "paid"])).all()
return [
ActiveOrderSlim(
id=o.id,
table_id=o.table_id,
status=o.status,
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()
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")
# Resolve all user IDs referenced by this order in one query
user_ids = set()
user_ids.add(order.opened_by)
if order.closed_by:
user_ids.add(order.closed_by)
for item in order.items:
user_ids.add(item.added_by)
if item.paid_by:
user_ids.add(item.paid_by)
for log in order.audit_logs:
if log.waiter_id:
user_ids.add(log.waiter_id)
users = db.query(User).filter(User.id.in_(user_ids)).all()
name_map = {u.id: u.nickname or u.full_name or u.username for u in users}
def fmt_item(i):
return {
"id": i.id,
"order_id": i.order_id,
"product_id": i.product_id,
"product": {"id": i.product.id, "name": i.product.name} if i.product else None,
"added_by": i.added_by,
"added_by_name": name_map.get(i.added_by),
"quantity": i.quantity,
"unit_price": i.unit_price,
"selected_options": i.selected_options,
"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,
"paid_by_name": name_map.get(i.paid_by) if i.paid_by else None,
"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):
return {
"id": l.id,
"order_id": l.order_id,
"event_type": l.event_type,
"waiter_id": l.waiter_id,
"waiter_name": name_map.get(l.waiter_id) if l.waiter_id else None,
"item_ids": l.item_ids,
"amount": l.amount,
"payment_method": l.payment_method,
"note": l.note,
"created_at": _iso(l.created_at),
"offline_at": _iso(l.offline_at) if isinstance(l.offline_at, datetime) else l.offline_at,
"is_duplicate": l.is_duplicate,
}
table = db.query(Table).filter(Table.id == order.table_id).first() if order.table_id else None
table_name = (table.label or f"T{table.number}") if table else None
# Phase 2F: resolve customer name for display
customer_name = None
customer_phone = None
if order.customer_id and order.customer:
customer_name = order.customer.name
if order.customer.nickname:
customer_name = f"{order.customer.name} «{order.customer.nickname}»"
customer_phone = order.customer.phone
return {
"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,
"closed_at": _iso(order.closed_at),
"closed_by": order.closed_by,
"notes": order.notes,
"business_day_id": order.business_day_id,
"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],
}
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
from models.business_day import BusinessDay
from models.shift import WaiterShift
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.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,
).first()
if not active_shift:
raise HTTPException(status_code=403, detail="You do not have an active shift")
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))
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
db.commit()
db.refresh(order)
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "opened"})
return order
@router.post("/{order_id}/items", response_model=AddItemsResponse)
def add_items(
order_id: int,
body: AddItemsRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
if order.status not in ("open", "partially_paid", "paid"):
raise HTTPException(status_code=400, detail="Order is not open")
# Adding items to a fully-paid order reopens it — partially_paid since prior items were paid
if order.status == "paid":
order.status = "partially_paid"
new_item_ids = []
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:
try:
entries = json.loads(product.cost_breakdown)
total = sum(e.get("amount", 0.0) for e in entries if isinstance(e, dict))
if total > 0:
unit_cost = total
except Exception:
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=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)
# 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})
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)
def retry_print(
order_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
unprinted_ids = [item.id for item in order.items if not item.printed and item.status == "active"]
if not unprinted_ids:
return {"order": order, "print_results": []}
print_results = route_and_print_sync(order_id, unprinted_ids, db)
db.refresh(order)
return {"order": order, "print_results": print_results}
@router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut)
def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Session = Depends(get_db), user: User = Depends(require_manager)):
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if notes is not None:
item.notes = notes
db.commit()
db.refresh(item)
return item
@router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def cancel_item(
order_id: int,
item_id: int,
background_tasks: BackgroundTasks,
print_cancellation: bool = True,
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")
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):
raise HTTPException(status_code=403, detail="Access denied")
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 == "paid":
raise HTTPException(status_code=400, detail="Το αντικείμενο έχει ήδη πληρωθεί")
now = datetime.now(timezone.utc)
item.status = "cancelled"
item.cancelled_by = user.id
item.cancelled_at = now
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, cancelled_ids)
@router.post("/{order_id}/pay")
def pay_items(order_id: int, body: PayItemsRequest, 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")
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,
WaiterShift.ended_at == None,
).first()
effective_method = body.payment_method or "cash"
total_paid = 0.0
for item in items:
item.status = "paid"
item.paid_by = user.id
item.paid_at = now
item.payment_method = effective_method
item.paid_in_shift_id = active_shift.id if active_shift else None
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(
OrderItem.order_id == order_id, OrderItem.status.in_(["active", "ready"])
).count()
order.status = "paid" if active_remaining == 0 else "partially_paid"
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})
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")
def close_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()
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"):
raise HTTPException(status_code=400, detail="Cannot close order in current status")
now = datetime.now(timezone.utc)
# Mark all still-active items as 'closed' — unpaid, closed by manager
active_items = db.query(OrderItem).filter(
OrderItem.order_id == order_id,
OrderItem.status == "active",
).all()
closed_item_ids = []
for item in active_items:
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
note = f"Κλείσιμο από manager — {len(closed_item_ids)} απλήρωτα αντικείμενα" if closed_item_ids else None
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id,
item_ids=closed_item_ids if closed_item_ids else None, note=note)
db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
return {"status": "closed"}
@router.post("/{order_id}/pay-offline")
def pay_items_offline(
order_id: int,
body: OfflinePaymentRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Sync an emergency payment that was taken while the server was offline.
The UUID prevents double-processing. If a payment with the same UUID already
exists on this order, the duplicate is logged in red (is_duplicate=1) rather
than silently dropped — so managers can reconcile.
"""
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")
# Check for duplicate UUID on this order
existing_uuid = db.query(OrderAuditLog).filter(
OrderAuditLog.order_id == order_id,
OrderAuditLog.offline_uuid == body.uuid,
).first()
is_duplicate = existing_uuid is not None
from models.shift import WaiterShift
items = db.query(OrderItem).filter(
OrderItem.id.in_(body.item_ids),
OrderItem.order_id == order_id,
OrderItem.status == "active",
).all()
# Reject empty payments — client had no offline snapshot for this table
if not items and not is_duplicate:
raise HTTPException(status_code=400, detail="No active items found — payment rejected")
# Use the client-recorded offline timestamp as paid_at so audit reflects real payment time
try:
paid_at = datetime.fromisoformat(body.offline_at.replace("Z", "+00:00")) if body.offline_at else datetime.now(timezone.utc)
except (ValueError, AttributeError):
paid_at = datetime.now(timezone.utc)
active_shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
effective_method = body.payment_method or "cash"
total_paid = 0.0
paid_ids = []
if not is_duplicate:
for item in items:
item.status = "paid"
item.paid_by = user.id
item.paid_at = paid_at
item.payment_method = effective_method
item.paid_in_shift_id = active_shift.id if active_shift else None
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()
active_remaining = db.query(OrderItem).filter(
OrderItem.order_id == order_id, OrderItem.status == "active"
).count()
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 + (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
db.add(OrderAuditLog(
order_id=order_id,
event_type="PAYMENT_OFFLINE",
waiter_id=user.id,
item_ids=json.dumps(paid_ids),
amount=total_paid,
payment_method=body.payment_method,
note=f"Emergency offline payment (uuid={body.uuid}){' — DUPLICATE' if is_duplicate else ''}",
offline_uuid=body.uuid,
offline_at=body.offline_at,
is_duplicate=1 if is_duplicate else 0,
))
db.commit()
if not is_duplicate:
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 if not is_duplicate else "duplicate",
"paid_item_ids": paid_ids,
"is_duplicate": is_duplicate,
}
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def cancel_order(
order_id: int,
background_tasks: BackgroundTasks,
print_cancellation: bool = True,
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")
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):
raise HTTPException(status_code=403, detail="Access denied")
now = datetime.now(timezone.utc)
active_items = db.query(OrderItem).filter(
OrderItem.order_id == order_id,
OrderItem.status == "active",
).all()
cancelled_item_ids = []
for item in active_items:
item.status = "cancelled"
item.cancelled_by = user.id
item.cancelled_at = now
cancelled_item_ids.append(item.id)
order.status = "cancelled"
order.closed_at = now
order.closed_by = user.id
note = f"Ακύρωση — {len(cancelled_item_ids)} αντικείμενα ακυρώθηκαν" if cancelled_item_ids else None
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id,
item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note)
db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
should_print = print_cancellation if is_manager else True
if should_print and cancelled_item_ids:
background_tasks.add_task(print_cancellation_ticket, order_id, cancelled_item_ids)
class CancelItemsRequest(BaseModel):
item_ids: List[int]
print_cancellation: bool = True
@router.post("/{order_id}/cancel-items")
def cancel_items_bulk(
order_id: int,
body: CancelItemsRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Cancel a selection of items by ID. Used by the waiter PWA multi-select cancel."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
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):
raise HTTPException(status_code=403, detail="Access denied")
items = db.query(OrderItem).filter(
OrderItem.id.in_(body.item_ids),
OrderItem.order_id == order_id,
).all()
now = datetime.now(timezone.utc)
cancelled_ids = []
skipped_paid = []
for item in items:
if item.status == "paid":
skipped_paid.append(item.id)
continue
item.status = "cancelled"
item.cancelled_by = user.id
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()
should_print = body.print_cancellation if is_manager else True
if should_print and cancelled_ids:
background_tasks.add_task(print_cancellation_ticket, order_id, cancelled_ids)
return {"cancelled": cancelled_ids, "skipped_paid": skipped_paid}
@router.put("/{order_id}/assign-waiter")
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
existing = db.query(OrderWaiter).filter(
OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == body.waiter_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Waiter already assigned")
db.add(OrderWaiter(order_id=order_id, waiter_id=body.waiter_id))
db.commit()
return {"status": "assigned"}
class AssignCustomerBody(BaseModel):
customer_id: Optional[int] = None # null = unassign
class TabItemBody(BaseModel):
pass # no body needed — tab derived from order's customer
@router.put("/{order_id}/customer")
def assign_customer(order_id: int, body: AssignCustomerBody, db: Session = Depends(get_db), user: User = Depends(require_manager)):
from models.customers import Customer
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if body.customer_id is not None:
customer = db.query(Customer).filter(Customer.id == body.customer_id, Customer.is_active == True).first()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
order.customer_id = body.customer_id
db.commit()
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "customer_assigned"})
return {"status": "ok", "customer_id": order.customer_id}
@router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
assignment = db.query(OrderWaiter).filter(
OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == waiter_id
).first()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
db.delete(assignment)
db.commit()
@router.post("/{order_id}/print")
def print_order(
order_id: int,
body: PrintOrderRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.printer import Printer
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
table = db.query(Table).filter(Table.id == order.table_id).first()
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
opener = db.query(User).filter(User.id == order.opened_by).first()
waiter_name = opener.username if opener else f"#{order.opened_by}"
items_data = []
for item in order.items:
if item.status == "cancelled":
continue
product_name = item.product.name if item.product else f"#{item.product_id}"
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_type": item.product.unit_type if item.product else "piece",
"unit_price": effective_price,
"total": effective_price * item.quantity,
"status": item.status,
})
grand_total = sum(i["total"] for i in items_data)
receipt = {
"order_id": order.id,
"table_name": table_name,
"waiter_name": waiter_name,
"opened_at": local_strftime(order.opened_at, "%d/%m/%Y %H:%M"),
"closed_at": local_strftime(order.closed_at, "%d/%m/%Y %H:%M") if order.closed_at else None,
"status": order.status,
"items": items_data,
"total": grand_total,
"notes": order.notes,
}
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt, printer.line_width, printer.codepage_n)
return {"status": "printing"}
# ─── Transfer order to a different table ─────────────────────────────────────
@router.post("/{order_id}/transfer")
def transfer_order(
order_id: int,
body: TransferOrderRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
if order.status not in ("open", "partially_paid", "paid"):
raise HTTPException(status_code=400, detail="Order is not active")
target_table = db.query(Table).filter(Table.id == body.target_table_id, Table.is_active == True).first()
if not target_table:
raise HTTPException(status_code=404, detail="Target table not found")
if body.target_table_id == order.table_id:
raise HTTPException(status_code=400, detail="Table is already assigned to this order")
conflict = db.query(Order).filter(
Order.table_id == body.target_table_id,
Order.status.in_(["open", "partially_paid", "paid"]),
).first()
if conflict:
raise HTTPException(status_code=400, detail="Target table already has an active order")
old_table_id = order.table_id
order.table_id = body.target_table_id
_audit(db, order_id, "TABLE_TRANSFER", waiter_id=user.id,
note=f"Transferred from table {old_table_id} to table {body.target_table_id}")
db.commit()
db.refresh(order)
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "old_table_id": old_table_id, "status": order.status, "action": "transferred"})
return order
# ─── Merge another order into this one ───────────────────────────────────────
@router.post("/{order_id}/merge")
def merge_order(
order_id: int,
body: MergeOrderRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Merge source order (order_id) INTO target order (body.target_order_id).
All items (paid + active) from the source are reassigned to the target.
Source waiters are added to the target if not already there.
Source order is cancelled with audit note.
"""
source = db.query(Order).filter(Order.id == order_id).first()
if not source:
raise HTTPException(status_code=404, detail="Source order not found")
if not _can_access_order(source, user, db):
raise HTTPException(status_code=403, detail="Access denied")
if source.status not in ("open", "partially_paid", "paid"):
raise HTTPException(status_code=400, detail="Source order is not active")
target = db.query(Order).filter(Order.id == body.target_order_id).first()
if not target:
raise HTTPException(status_code=404, detail="Target order not found")
if not _can_access_order(target, user, db):
raise HTTPException(status_code=403, detail="Access denied to target order")
if target.status not in ("open", "partially_paid", "paid"):
raise HTTPException(status_code=400, detail="Target order is not active")
if source.id == target.id:
raise HTTPException(status_code=400, detail="Cannot merge an order with itself")
# Move all items to target order
moved_item_ids = []
for item in source.items:
item.order_id = target.id
moved_item_ids.append(item.id)
# Copy source waiters to target (no duplicates)
existing_waiter_ids = {w.waiter_id for w in target.waiters}
for ow in source.waiters:
if ow.waiter_id not in existing_waiter_ids:
db.add(OrderWaiter(order_id=target.id, waiter_id=ow.waiter_id))
# Recompute target status after flush
db.flush()
active_remaining = db.query(OrderItem).filter(
OrderItem.order_id == target.id, OrderItem.status == "active"
).count()
paid_exists = db.query(OrderItem).filter(
OrderItem.order_id == target.id, OrderItem.status == "paid"
).count()
if active_remaining > 0:
target.status = "partially_paid" if paid_exists > 0 else "open"
else:
target.status = "paid"
# Cancel source order
source.status = "cancelled"
source.closed_at = datetime.now(timezone.utc)
source.closed_by = user.id
_audit(db, source.id, "ORDER_CANCELLED", waiter_id=user.id,
note=f"Merged into order #{target.id} (table {target.table_id})")
_audit(db, target.id, "ITEMS_ADDED", waiter_id=user.id, item_ids=moved_item_ids,
note=f"Items merged from order #{source.id} (table {source.table_id})")
db.commit()
db.refresh(target)
broadcast_sync("order_updated", {"order_id": target.id, "table_id": target.table_id, "status": target.status, "action": "merged"})
broadcast_sync("order_closed", {"order_id": source.id, "table_id": source.table_id})
return target
# ─── Split a stacked item into two rows ──────────────────────────────────────
@router.post("/{order_id}/items/{item_id}/split", response_model=List[OrderItemOut])
def split_item(
order_id: int,
item_id: int,
body: SplitItemRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Split qty units off item_id into a new item row.
Both rows share all properties (product, price, options, notes).
Only active items can be split.
"""
item = db.query(OrderItem).filter(
OrderItem.id == item_id, OrderItem.order_id == order_id
).first()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if item.status != "active":
raise HTTPException(status_code=400, detail="Only active items can be split")
if body.quantity <= 0 or body.quantity >= item.quantity:
raise HTTPException(
status_code=400,
detail=f"Split quantity must be between 1 and {item.quantity - 1}"
)
order = db.query(Order).filter(Order.id == order_id).first()
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
# Reduce original item
item.quantity -= body.quantity
# Create split-off item
new_item = OrderItem(
order_id=order_id,
product_id=item.product_id,
added_by=item.added_by,
quantity=body.quantity,
unit_price=item.unit_price,
selected_options=item.selected_options,
removed_ingredients=item.removed_ingredients,
notes=item.notes,
status="active",
printed=item.printed,
)
db.add(new_item)
db.commit()
db.refresh(item)
db.refresh(new_item)
return [item, new_item]
# ─── 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")
def move_items(
order_id: int,
body: MoveItemsRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Move specific active items from this order to another open order."""
source = db.query(Order).filter(Order.id == order_id).first()
if not source:
raise HTTPException(status_code=404, detail="Source order not found")
if not _can_access_order(source, user, db):
raise HTTPException(status_code=403, detail="Access denied")
if source.status not in ("open", "partially_paid"):
raise HTTPException(status_code=400, detail="Source order is not active")
target = db.query(Order).filter(Order.id == body.target_order_id).first()
if not target:
raise HTTPException(status_code=404, detail="Target order not found")
if not _can_access_order(target, user, db):
raise HTTPException(status_code=403, detail="Access denied to target order")
if target.status not in ("open", "partially_paid"):
raise HTTPException(status_code=400, detail="Target order is not active")
if source.id == target.id:
raise HTTPException(status_code=400, detail="Source and target orders are the same")
items = db.query(OrderItem).filter(
OrderItem.id.in_(body.item_ids),
OrderItem.order_id == order_id,
OrderItem.status == "active",
).all()
if not items:
raise HTTPException(status_code=400, detail="No active items found to move")
moved_ids = []
for item in items:
item.order_id = target.id
moved_ids.append(item.id)
# Recompute source status
db.flush()
src_active = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "active").count()
src_paid = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "paid").count()
if src_active == 0 and src_paid == 0:
source.status = "open"
elif src_active == 0:
source.status = "paid"
else:
source.status = "partially_paid" if src_paid > 0 else "open"
# Recompute target status
tgt_active = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "active").count()
tgt_paid = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "paid").count()
target.status = "partially_paid" if (tgt_active > 0 and tgt_paid > 0) else ("paid" if tgt_active == 0 else "open")
_audit(db, source.id, "ITEMS_MOVED_OUT", waiter_id=user.id, item_ids=moved_ids,
note=f"Moved to order #{target.id} (table {target.table_id})")
_audit(db, target.id, "ITEMS_MOVED_IN", waiter_id=user.id, item_ids=moved_ids,
note=f"Moved from order #{source.id} (table {source.table_id})")
db.commit()
db.refresh(source)
return {"moved_item_ids": moved_ids, "source_status": source.status, "target_status": target.status}
# ─── Print order synopsis ─────────────────────────────────────────────────────
@router.post("/{order_id}/print-synopsis")
def print_synopsis(
order_id: int,
body: PrintSynopsisRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
from models.printer import Printer
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
table = db.query(Table).filter(Table.id == order.table_id).first()
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
opener = db.query(User).filter(User.id == order.opened_by).first()
waiter_name = (opener.nickname or opener.username) if opener else f"#{order.opened_by}"
items_data = []
for item in order.items:
if item.status == "cancelled":
continue
product_name = item.product.name if item.product else f"#{item.product_id}"
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_type": item.product.unit_type if item.product else "piece",
"unit_price": effective_price,
"total": effective_price * item.quantity,
"status": item.status,
})
total = sum(i["total"] for i in items_data)
paid_total = sum(i["total"] for i in items_data if i["status"] == "paid")
synopsis = {
"order_id": order.id,
"table_name": table_name,
"waiter_name": waiter_name,
"opened_at": local_strftime(order.opened_at, "%d/%m/%Y %H:%M"),
"items": items_data,
"total": total,
"paid_total": paid_total,
"remaining": total - paid_total,
}
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis, printer.line_width, printer.codepage_n)
return {"status": "printing"}
# ─── Phase 2G: Put item on customer tab ───────────────────────────────────────
@router.post("/{order_id}/items/{item_id}/tab")
def put_item_on_tab(
order_id: int,
item_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.tabs import Tab, TabEntry
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not order.customer_id:
raise HTTPException(status_code=400, detail="Order has no customer assigned — assign a customer first")
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=f"Item is already {item.status} — cannot tab it")
# Find or auto-create the customer's open tab
tab = db.query(Tab).filter(Tab.customer_id == order.customer_id, Tab.status == "open").first()
if not tab:
tab = Tab(customer_id=order.customer_id)
db.add(tab)
db.flush()
# Build description
product_name = item.product.name if item.product else f"#{item.product_id}"
description = f"{product_name} ×{item.quantity} @ €{item.unit_price:.2f}"
entry = TabEntry(
tab_id=tab.id,
order_id=order_id,
order_item_id=item_id,
amount=round(item.unit_price * item.quantity, 2),
description=description,
created_by_id=user.id,
)
db.add(entry)
# Mark item as tabbed — distinct from active/paid/cancelled
item.status = "tabbed"
db.commit()
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "item_tabbed"})
return {"status": "tabbed", "tab_id": tab.id, "entry_amount": entry.amount}