- Add tz.py with local_strftime/to_local helpers that read system.timezone from DB and convert UTC datetimes to venue local time before formatting - Fix all strftime() calls in orders.py, reports.py, printer_service.py that were formatting UTC datetimes without timezone conversion - Fix get_order endpoint returning raw dicts without Z suffix on datetimes, causing JS new Date() to treat timestamps as local instead of UTC - Fix fmtDate() in tokens.js that stripped the T separator before parsing, breaking UTC-to-local conversion for all report date displays - Make open/partially_paid table chips more visually distinct on dashboard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1027 lines
40 KiB
Python
1027 lines
40 KiB
Python
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
|
||
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 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
|
||
|
||
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
|
||
from services.sse_bus import broadcast_sync
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
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"):
|
||
return True
|
||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
|
||
if not zones:
|
||
return False
|
||
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("/", 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: int
|
||
status: str
|
||
waiter_ids: List[int]
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
@router.get("/active", response_model=List[ActiveOrderSlim])
|
||
def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
"""All currently open/partially-paid/paid orders (lightweight). Accessible to all staff."""
|
||
orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid", "paid"])).all()
|
||
return [
|
||
ActiveOrderSlim(
|
||
id=o.id,
|
||
table_id=o.table_id,
|
||
status=o.status,
|
||
waiter_ids=[w.waiter_id for w in o.waiters],
|
||
)
|
||
for o in orders
|
||
]
|
||
|
||
|
||
@router.get("/{order_id}")
|
||
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,
|
||
"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,
|
||
}
|
||
|
||
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,
|
||
"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,
|
||
"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
|
||
|
||
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||
if not active_day:
|
||
raise HTTPException(status_code=403, detail="Restaurant is not open — manager must open the business day first")
|
||
|
||
if user.role == "waiter":
|
||
active_shift = db.query(WaiterShift).filter(
|
||
WaiterShift.waiter_id == user.id,
|
||
WaiterShift.ended_at == None,
|
||
).first()
|
||
if not active_shift:
|
||
raise HTTPException(status_code=403, detail="You do not have an active shift")
|
||
|
||
existing = db.query(Order).filter(
|
||
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)
|
||
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 = []
|
||
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")
|
||
extra_cost = sum(
|
||
(o.price_delta or o.extra_cost or 0.0)
|
||
for o in (item_in.selected_options or [])
|
||
)
|
||
# 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=product.base_price + extra_cost,
|
||
unit_cost=unit_cost,
|
||
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,
|
||
)
|
||
db.add(item)
|
||
db.flush()
|
||
new_item_ids.append(item.id)
|
||
|
||
_audit(db, order_id, "ITEMS_ADDED", waiter_id=user.id, item_ids=new_item_ids)
|
||
db.commit()
|
||
db.refresh(order)
|
||
|
||
print_results = route_and_print_sync(order_id, new_item_ids, db)
|
||
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}
|
||
|
||
|
||
@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, 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")
|
||
item.status = "cancelled"
|
||
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
|
||
db.commit()
|
||
|
||
|
||
@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
|
||
|
||
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()
|
||
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
|
||
total_paid += item.unit_price * 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)
|
||
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}
|
||
|
||
|
||
@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 _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)
|
||
|
||
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
|
||
total_paid += item.unit_price * 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 * 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, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||
order = db.query(Order).filter(Order.id == order_id).first()
|
||
if not order:
|
||
raise HTTPException(status_code=404, detail="Order not found")
|
||
|
||
now = datetime.now(timezone.utc)
|
||
|
||
# Cancel all still-active items
|
||
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"
|
||
cancelled_item_ids.append(item.id)
|
||
|
||
order.status = "cancelled"
|
||
order.closed_at = now
|
||
order.closed_by = user.id
|
||
|
||
note = f"Ακύρωση από manager — {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})
|
||
|
||
|
||
@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}"
|
||
items_data.append({
|
||
"name": product_name,
|
||
"quantity": item.quantity,
|
||
"unit_price": item.unit_price,
|
||
"total": item.unit_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)
|
||
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]
|
||
|
||
|
||
# ─── Move selected items to another order ────────────────────────────────────
|
||
|
||
@router.post("/{order_id}/move-items")
|
||
def move_items(
|
||
order_id: int,
|
||
body: MoveItemsRequest,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""Move specific active items from this order to another open order."""
|
||
source = db.query(Order).filter(Order.id == order_id).first()
|
||
if not source:
|
||
raise HTTPException(status_code=404, detail="Source order not found")
|
||
if not _can_access_order(source, user, db):
|
||
raise HTTPException(status_code=403, detail="Access denied")
|
||
if source.status not in ("open", "partially_paid"):
|
||
raise HTTPException(status_code=400, detail="Source order is not active")
|
||
|
||
target = db.query(Order).filter(Order.id == body.target_order_id).first()
|
||
if not target:
|
||
raise HTTPException(status_code=404, detail="Target order not found")
|
||
if not _can_access_order(target, user, db):
|
||
raise HTTPException(status_code=403, detail="Access denied to target order")
|
||
if target.status not in ("open", "partially_paid"):
|
||
raise HTTPException(status_code=400, detail="Target order is not active")
|
||
if source.id == target.id:
|
||
raise HTTPException(status_code=400, detail="Source and target orders are the same")
|
||
|
||
items = db.query(OrderItem).filter(
|
||
OrderItem.id.in_(body.item_ids),
|
||
OrderItem.order_id == order_id,
|
||
OrderItem.status == "active",
|
||
).all()
|
||
if not items:
|
||
raise HTTPException(status_code=400, detail="No active items found to move")
|
||
|
||
moved_ids = []
|
||
for item in items:
|
||
item.order_id = target.id
|
||
moved_ids.append(item.id)
|
||
|
||
# Recompute source status
|
||
db.flush()
|
||
src_active = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "active").count()
|
||
src_paid = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "paid").count()
|
||
if src_active == 0 and src_paid == 0:
|
||
source.status = "open"
|
||
elif src_active == 0:
|
||
source.status = "paid"
|
||
else:
|
||
source.status = "partially_paid" if src_paid > 0 else "open"
|
||
|
||
# Recompute target status
|
||
tgt_active = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "active").count()
|
||
tgt_paid = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "paid").count()
|
||
target.status = "partially_paid" if (tgt_active > 0 and tgt_paid > 0) else ("paid" if tgt_active == 0 else "open")
|
||
|
||
_audit(db, source.id, "ITEMS_MOVED_OUT", waiter_id=user.id, item_ids=moved_ids,
|
||
note=f"Moved to order #{target.id} (table {target.table_id})")
|
||
_audit(db, target.id, "ITEMS_MOVED_IN", waiter_id=user.id, item_ids=moved_ids,
|
||
note=f"Moved from order #{source.id} (table {source.table_id})")
|
||
|
||
db.commit()
|
||
db.refresh(source)
|
||
return {"moved_item_ids": moved_ids, "source_status": source.status, "target_status": target.status}
|
||
|
||
|
||
# ─── Print order synopsis ─────────────────────────────────────────────────────
|
||
|
||
@router.post("/{order_id}/print-synopsis")
|
||
def print_synopsis(
|
||
order_id: int,
|
||
body: PrintSynopsisRequest,
|
||
background_tasks: BackgroundTasks,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
from models.printer import Printer
|
||
|
||
order = db.query(Order).filter(Order.id == order_id).first()
|
||
if not order:
|
||
raise HTTPException(status_code=404, detail="Order not found")
|
||
if not _can_access_order(order, user, db):
|
||
raise HTTPException(status_code=403, detail="Access denied")
|
||
|
||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||
if not printer:
|
||
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
||
|
||
table = db.query(Table).filter(Table.id == order.table_id).first()
|
||
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
|
||
opener = db.query(User).filter(User.id == order.opened_by).first()
|
||
waiter_name = (opener.nickname or opener.username) if opener else f"#{order.opened_by}"
|
||
|
||
items_data = []
|
||
for item in order.items:
|
||
if item.status == "cancelled":
|
||
continue
|
||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||
items_data.append({
|
||
"name": product_name,
|
||
"quantity": item.quantity,
|
||
"unit_price": item.unit_price,
|
||
"total": item.unit_price * item.quantity,
|
||
"status": item.status,
|
||
})
|
||
|
||
total = sum(i["total"] for i in items_data)
|
||
paid_total = sum(i["total"] for i in items_data if i["status"] == "paid")
|
||
|
||
synopsis = {
|
||
"order_id": order.id,
|
||
"table_name": table_name,
|
||
"waiter_name": waiter_name,
|
||
"opened_at": 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)
|
||
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}
|