Backend overhaul: new models, routers, schemas for shifts, business day, flags, messages, settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 12:12:05 +03:00
parent 603fd45eaa
commit defc49f84f
31 changed files with 2626 additions and 55 deletions

View File

@@ -5,6 +5,11 @@ from sqlalchemy.orm import Session
from database import get_db
from models.user import User
from schemas.auth import LoginRequest, TokenResponse
from pydantic import BaseModel as _PydanticBase
class LoginByIdRequest(_PydanticBase):
waiter_id: int
pin: str
from schemas.user import UserOut
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
@@ -20,6 +25,16 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
@router.post("/login-by-id", response_model=TokenResponse)
def login_by_id(body: LoginByIdRequest, db: Session = Depends(get_db)):
"""Login using waiter id + PIN (used by the waiter-picker login screen)."""
user = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Λανθασμένο PIN")
token = make_token(user)
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
@router.post("/refresh", response_model=TokenResponse)
def refresh(token: str, db: Session = Depends(get_db)):
payload = decode_token(token)
@@ -40,3 +55,37 @@ def logout(token: str):
@router.get("/me", response_model=UserOut)
def me(user: User = Depends(get_current_user)):
return user
# ─── Public waiter list (login screen — no auth required) ────────────────────
from pydantic import BaseModel as _BaseModel
class PublicWaiterOut(_BaseModel):
id: int
full_name: str | None
nickname: str | None
avatar_url: str | None
on_shift: bool
model_config = {"from_attributes": True}
@router.get("/waiters", response_model=list[PublicWaiterOut])
def public_waiter_list(db: Session = Depends(get_db)):
"""Public endpoint — returns active waiters with on-shift flag. No auth required."""
from models.shift import WaiterShift
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
on_shift_ids = {
row.waiter_id
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
}
return [
PublicWaiterOut(
id=w.id,
full_name=w.full_name,
nickname=w.nickname,
avatar_url=w.avatar_url,
on_shift=w.id in on_shift_ids,
)
for w in waiters
]

View File

@@ -0,0 +1,162 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime, timezone
from database import get_db
from models.business_day import BusinessDay
from models.order import Order, OrderItem, OrderAuditLog
from models.shift import WaiterShift
from models.flag import TableFlagAssignment
from models.message import StaffMessage, StaffMessageAck
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
from routers.deps import get_current_user, require_manager
from models.user import User
router = APIRouter()
def _dt(dt):
if dt is None:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
@router.get("/current", response_model=Optional[BusinessDayOut])
def get_current_business_day(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(BusinessDay).filter(BusinessDay.status == "open").first()
@router.post("/open", response_model=BusinessDayOut, status_code=status.HTTP_201_CREATED)
def open_business_day(
body: OpenBusinessDayRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
existing = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if existing:
raise HTTPException(status_code=400, detail="A business day is already open")
day = BusinessDay(opened_by_id=user.id, notes=body.notes)
db.add(day)
db.commit()
db.refresh(day)
return day
@router.post("/close", response_model=BusinessDayOut)
def close_business_day(
body: CloseBusinessDayRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if not day:
raise HTTPException(status_code=404, detail="No open business day")
open_orders = db.query(Order).filter(
Order.business_day_id == day.id,
Order.status.in_(["open", "partially_paid"]),
).all()
if open_orders and not body.force:
# Count orders that have at least one active (unpaid) item — covers both
# "open" (fully unpaid) and "partially_paid" (partially unpaid) orders.
with_pending = sum(
1 for o in open_orders
if db.query(OrderItem).filter(
OrderItem.order_id == o.id,
OrderItem.status == "active",
).first() is not None
)
raise HTTPException(
status_code=409,
detail={
"message": f"{len(open_orders)} table(s) still open, {with_pending} with unpaid items.",
"open_orders": len(open_orders),
"partially_paid": with_pending,
},
)
now = datetime.now(timezone.utc)
# Close all non-terminal orders for this business day (open, partially_paid, paid)
all_unclosed = db.query(Order).filter(
Order.business_day_id == day.id,
Order.status.in_(["open", "partially_paid", "paid"]),
).all()
for order in all_unclosed:
was_unpaid = order.status in ("open", "partially_paid")
order.status = "closed"
order.closed_at = now
order.closed_by = user.id
if was_unpaid:
db.add(OrderAuditLog(
order_id=order.id,
event_type="ORDER_CLOSED",
waiter_id=user.id,
note="Force-closed at end of business day",
))
active_shifts = db.query(WaiterShift).filter(
WaiterShift.business_day_id == day.id,
WaiterShift.ended_at == None,
).all()
for shift in active_shifts:
items = db.query(OrderItem).filter(
OrderItem.paid_in_shift_id == shift.id,
OrderItem.status == "paid",
).all()
shift.total_collected = sum(i.unit_price * i.quantity for i in items)
shift.ended_at = now
# Clear all table flags and staff messages — fresh slate for the next day
db.query(TableFlagAssignment).delete(synchronize_session=False)
db.query(StaffMessageAck).delete(synchronize_session=False)
db.query(StaffMessage).delete(synchronize_session=False)
day.status = "closed"
day.closed_at = now
day.closed_by_id = user.id
if body.notes:
day.notes = body.notes
db.commit()
db.refresh(day)
return day
@router.get("/history")
def business_day_history(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
days = db.query(BusinessDay).order_by(BusinessDay.opened_at.desc()).all()
result = []
for day in days:
order_count = db.query(Order).filter(Order.business_day_id == day.id).count()
revenue = (
db.query(func.sum(OrderItem.unit_price * OrderItem.quantity))
.join(Order)
.filter(Order.business_day_id == day.id, OrderItem.status == "paid")
.scalar() or 0.0
)
w_opener = day.opener
w_closer = day.closer
result.append({
"id": day.id,
"status": day.status,
"opened_at": _dt(day.opened_at),
"closed_at": _dt(day.closed_at),
"opened_by_id": day.opened_by_id,
"opened_by_name": (w_opener.full_name or w_opener.username) if w_opener else None,
"closed_by_id": day.closed_by_id,
"closed_by_name": (w_closer.full_name or w_closer.username) if w_closer else None,
"notes": day.notes,
"order_count": order_count,
"revenue": round(revenue, 2),
})
return {"business_days": result}

View File

@@ -0,0 +1,141 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.flag import TableFlagDef, TableFlagAssignment
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
from routers.deps import get_current_user, require_manager
from models.user import User
router = APIRouter()
# ─── Flag definitions (manager only) ─────────────────────────────────────────
@router.get("/defs", response_model=List[FlagDefOut])
def list_flag_defs(
include_inactive: bool = False,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
q = db.query(TableFlagDef)
if not include_inactive:
q = q.filter(TableFlagDef.is_active == True)
return q.order_by(TableFlagDef.sort_order, TableFlagDef.id).all()
@router.post("/defs", response_model=FlagDefOut, status_code=status.HTTP_201_CREATED)
def create_flag_def(
body: FlagDefCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
flag = TableFlagDef(**body.model_dump())
db.add(flag)
db.commit()
db.refresh(flag)
return flag
@router.put("/defs/{flag_id}", response_model=FlagDefOut)
def update_flag_def(
flag_id: int,
body: FlagDefUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
if not flag:
raise HTTPException(status_code=404, detail="Flag not found")
for k, v in body.model_dump(exclude_unset=True).items():
setattr(flag, k, v)
db.commit()
db.refresh(flag)
return flag
@router.delete("/defs/{flag_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_flag_def(
flag_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
if not flag:
raise HTTPException(status_code=404, detail="Flag not found")
# Soft delete — keeps existing assignments readable
flag.is_active = False
db.commit()
# ─── All assignments (bulk endpoint for manager views) ───────────────────────
@router.get("/assignments", response_model=List[FlagAssignmentOut])
def get_all_assignments(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""All active flag assignments across all tables (for manager dashboard bulk load)."""
return db.query(TableFlagAssignment).all()
# ─── Table flag assignments ───────────────────────────────────────────────────
@router.get("/table/{table_id}", response_model=List[FlagAssignmentOut])
def get_table_flags(
table_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(TableFlagAssignment).filter(
TableFlagAssignment.table_id == table_id
).all()
@router.put("/table/{table_id}", response_model=List[FlagAssignmentOut])
def set_table_flags(
table_id: int,
body: SetTableFlagsRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Replace all flags on a table with the given set of flag_ids."""
# Validate all flag_ids exist and are active
if body.flag_ids:
valid = db.query(TableFlagDef).filter(
TableFlagDef.id.in_(body.flag_ids),
TableFlagDef.is_active == True,
).count()
if valid != len(body.flag_ids):
raise HTTPException(status_code=400, detail="One or more flag IDs are invalid")
# Delete existing assignments for this table
db.query(TableFlagAssignment).filter(
TableFlagAssignment.table_id == table_id
).delete(synchronize_session=False)
# Insert new assignments
for flag_id in body.flag_ids:
db.add(TableFlagAssignment(
table_id=table_id,
flag_id=flag_id,
assigned_by=user.id,
))
db.commit()
return db.query(TableFlagAssignment).filter(
TableFlagAssignment.table_id == table_id
).all()
@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT)
def clear_table_flags(
table_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
db.query(TableFlagAssignment).filter(
TableFlagAssignment.table_id == table_id
).delete(synchronize_session=False)
db.commit()

View File

@@ -0,0 +1,199 @@
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from typing import List
from database import get_db
from models.message import StaffMessage, StaffMessageAck, QuickMessageTemplate
from models.user import User
from schemas.message import (
SendMessageRequest, StaffMessageOut,
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
)
from routers.deps import get_current_user, require_manager
router = APIRouter()
def _load_msg(db: Session, msg_id: int) -> StaffMessage:
"""Reload a message with sender and acks eagerly loaded."""
return db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).filter(StaffMessage.id == msg_id).one()
def _message_out(msg: StaffMessage) -> StaffMessageOut:
sender_name = None
try:
sender_name = msg.sender.username if msg.sender else None
except Exception:
pass
return StaffMessageOut(
id=msg.id,
sender_id=msg.sender_id,
sender_name=sender_name,
body=msg.body,
target_waiter_ids=msg.target_waiter_ids,
table_ids=msg.table_ids,
created_at=msg.created_at,
acked_by=[ack.waiter_id for ack in msg.acks],
)
# ─── Quick templates ──────────────────────────────────────────────────────────
@router.get("/templates", response_model=List[QuickTemplateOut])
def list_templates(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(QuickMessageTemplate).filter(
QuickMessageTemplate.is_active == True
).order_by(QuickMessageTemplate.sort_order, QuickMessageTemplate.id).all()
@router.post("/templates", response_model=QuickTemplateOut, status_code=status.HTTP_201_CREATED)
def create_template(
body: QuickTemplateCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
t = QuickMessageTemplate(**body.model_dump())
db.add(t)
db.commit()
db.refresh(t)
return t
@router.put("/templates/{template_id}", response_model=QuickTemplateOut)
def update_template(
template_id: int,
body: QuickTemplateUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
if not t:
raise HTTPException(status_code=404, detail="Template not found")
for k, v in body.model_dump(exclude_unset=True).items():
setattr(t, k, v)
db.commit()
db.refresh(t)
return t
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_template(
template_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
if not t:
raise HTTPException(status_code=404, detail="Template not found")
t.is_active = False
db.commit()
# ─── Staff messages ───────────────────────────────────────────────────────────
@router.post("/send", response_model=StaffMessageOut, status_code=status.HTTP_201_CREATED)
def send_message(
body: SendMessageRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
msg = StaffMessage(
sender_id=user.id,
body=body.body,
target_waiter_ids=json.dumps(body.target_waiter_ids),
table_ids=json.dumps(body.table_ids or []),
)
db.add(msg)
db.commit()
msg = _load_msg(db, msg.id)
return _message_out(msg)
@router.get("/unread", response_model=List[StaffMessageOut])
def get_unread_messages(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Returns messages targeting this waiter that they haven't acked yet.
A message targets a waiter if their ID is in target_waiter_ids,
OR if target_waiter_ids is empty (broadcast to all).
"""
all_msgs = db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).order_by(StaffMessage.created_at.desc()).limit(200).all()
acked_ids = {
ack.message_id
for ack in db.query(StaffMessageAck).filter(StaffMessageAck.waiter_id == user.id).all()
}
result = []
for msg in all_msgs:
if msg.id in acked_ids:
continue
targets = json.loads(msg.target_waiter_ids or "[]")
# Empty list = broadcast to all
if not targets or user.id in targets:
result.append(_message_out(msg))
return result
@router.get("/recent", response_model=List[StaffMessageOut])
def get_recent_messages(
limit: int = 10,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Last N messages targeting this user (for notification history drawer)."""
all_msgs = db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).order_by(StaffMessage.created_at.desc()).limit(200).all()
result = []
for msg in all_msgs:
targets = json.loads(msg.target_waiter_ids or "[]")
if not targets or user.id in targets:
result.append(_message_out(msg))
if len(result) >= limit:
break
return result
@router.post("/{message_id}/ack", status_code=status.HTTP_204_NO_CONTENT)
def ack_message(
message_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
msg = db.query(StaffMessage).filter(StaffMessage.id == message_id).first()
if not msg:
raise HTTPException(status_code=404, detail="Message not found")
existing = db.query(StaffMessageAck).filter(
StaffMessageAck.message_id == message_id,
StaffMessageAck.waiter_id == user.id,
).first()
if not existing:
db.add(StaffMessageAck(message_id=message_id, waiter_id=user.id))
db.commit()
@router.get("/all", response_model=List[StaffMessageOut])
def list_all_messages(
limit: int = 50,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
msgs = db.query(StaffMessage).options(
joinedload(StaffMessage.sender),
joinedload(StaffMessage.acks),
).order_by(StaffMessage.created_at.desc()).limit(limit).all()
return [_message_out(m) for m in msgs]

View File

@@ -1,5 +1,5 @@
import json
from datetime import datetime
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List, Optional
@@ -14,8 +14,25 @@ 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
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
router = APIRouter()
@@ -79,6 +96,29 @@ def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_us
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}", response_model=OrderOut)
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()
@@ -91,13 +131,28 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
@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"]),
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)
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))
@@ -119,9 +174,13 @@ def add_items(
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"):
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()
@@ -154,6 +213,27 @@ def add_items(
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()
@@ -184,20 +264,28 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
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 == "active",
).all()
now = datetime.utcnow()
now = datetime.now(timezone.utc)
active_shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
total_paid = 0.0
for item in items:
item.status = "paid"
item.paid_by = user.id
item.paid_at = now
item.payment_method = body.payment_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 == "active"
).count()
@@ -220,7 +308,7 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
if order.status not in ("paid", "open", "partially_paid"):
raise HTTPException(status_code=400, detail="Cannot close order in current status")
order.status = "closed"
order.closed_at = datetime.utcnow()
order.closed_at = datetime.now(timezone.utc)
order.closed_by = user.id
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
db.commit()
@@ -233,14 +321,14 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
if not order:
raise HTTPException(status_code=404, detail="Order not found")
order.status = "cancelled"
order.closed_at = datetime.utcnow()
order.closed_at = datetime.now(timezone.utc)
order.closed_by = user.id
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
db.commit()
@router.put("/{order_id}/assign-waiter")
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
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")
@@ -318,3 +406,295 @@ def print_order(
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
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)
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)
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": order.opened_at.strftime("%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)
return {"status": "printing"}

View File

@@ -6,13 +6,14 @@ from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.order import OrderItem
from models.user import User
from schemas.product import (
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
PreferenceSetCreate,
SubcategoryReorderItem, ParentGeneralReorderItem,
PreferenceSetCreate, ProductQuickOptionCreate,
)
from routers.deps import get_current_user, require_manager
@@ -21,6 +22,22 @@ router = APIRouter()
IMAGE_DIR = "/app/data/product_images"
def _replace_quick_options(db, product, quick_options):
for qo in product.quick_options:
db.delete(qo)
db.flush()
for i, qo in enumerate(quick_options):
db.add(ProductQuickOption(
product_id=product.id,
name=qo.name,
price=qo.price,
allow_multiple=qo.allow_multiple,
sort_order=qo.sort_order if qo.sort_order else i,
is_favorite=qo.is_favorite,
favorite_sort_order=qo.favorite_sort_order,
))
def _replace_options(db, product, options):
for opt in product.options:
db.delete(opt)
@@ -31,7 +48,10 @@ def _replace_options(db, product, options):
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
allow_multiple=opt.allow_multiple,
sub_choices=sub_json,
is_favorite=opt.is_favorite,
favorite_sort_order=opt.favorite_sort_order,
))
@@ -53,6 +73,8 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
product_id=product.id,
name=ps.name,
shared_subset=shared_json,
is_favorite=ps.is_favorite,
favorite_sort_order=ps.favorite_sort_order,
)
db.add(new_set)
db.flush()
@@ -82,8 +104,15 @@ def list_categories(db: Session = Depends(get_db), user: User = Depends(get_curr
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
max_order = db.query(Category).count()
cat = Category(name=body.name, color=body.color, sort_order=max_order)
# sort_order is among siblings (same parent_id level)
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
cat = Category(
name=body.name,
color=body.color,
sort_order=sibling_count,
parent_id=body.parent_id,
general_sort_order=body.general_sort_order,
)
db.add(cat)
db.commit()
db.refresh(cat)
@@ -99,6 +128,26 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
db.commit()
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
"""Reorder sub-categories within their parent (sort_order among siblings)."""
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
if cat:
cat.sort_order = item.sort_order
db.commit()
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
"""Update general_sort_order on parent categories (position of the General group)."""
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
if cat:
cat.general_sort_order = item.general_sort_order
db.commit()
@router.put("/categories/{category_id}", response_model=CategoryOut)
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
cat = db.query(Category).filter(Category.id == category_id).first()
@@ -126,7 +175,8 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
q = db.query(Product)
if not all or user.role not in ("manager", "sysadmin"):
q = q.filter(Product.is_available == True)
# Waiters only see active, available products
q = q.filter(Product.is_available == True, Product.lifecycle_status == "active")
return q.order_by(Product.sort_order, Product.id).all()
@@ -141,15 +191,33 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
data = body.model_dump(exclude={"options", "ingredients", "preference_sets"})
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets"})
if data.get("sort_order") == 0:
data["sort_order"] = db.query(Product).count()
product = Product(**data)
db.add(product)
db.flush()
for i, qo in enumerate(body.quick_options):
db.add(ProductQuickOption(
product_id=product.id,
name=qo.name,
price=qo.price,
allow_multiple=qo.allow_multiple,
sort_order=qo.sort_order if qo.sort_order else i,
is_favorite=qo.is_favorite,
favorite_sort_order=qo.favorite_sort_order,
))
for opt in body.options:
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
db.add(ProductOption(product_id=product.id, name=opt.name, extra_cost=opt.extra_cost, sub_choices=sub_json))
db.add(ProductOption(
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
allow_multiple=opt.allow_multiple,
sub_choices=sub_json,
is_favorite=opt.is_favorite,
favorite_sort_order=opt.favorite_sort_order,
))
for ing in body.ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
_replace_preference_sets(db, product, body.preference_sets)
@@ -163,8 +231,10 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
for field, value in body.model_dump(exclude_none=True, exclude={"options", "ingredients", "preference_sets"}).items():
for field, value in body.model_dump(exclude_none=True, exclude={"quick_options", "options", "ingredients", "preference_sets"}).items():
setattr(product, field, value)
if body.quick_options is not None:
_replace_quick_options(db, product, body.quick_options)
if body.options is not None:
_replace_options(db, product, body.options)
if body.ingredients is not None:
@@ -216,9 +286,14 @@ def delete_product(product_id: int, hard: bool = False, db: Session = Depends(ge
if has_orders:
raise HTTPException(
status_code=400,
detail="Cannot permanently delete a product that appears in past orders. Deactivate it instead."
detail="Cannot permanently delete a product that appears in past orders. Archive it instead."
)
db.delete(product)
else:
product.is_available = False
# If product has order history, archive it; otherwise hard delete
has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first()
if has_orders:
product.lifecycle_status = "archived"
else:
db.delete(product)
db.commit()

View File

@@ -12,6 +12,7 @@ from models.order import Order, OrderItem, OrderWaiter, PrintLog
from models.user import User
from models.table import Table
from models.printer import Printer
from models.shift import WaiterShift
from schemas.order import OrderOut
from schemas.table import TableOut
from routers.deps import require_manager
@@ -20,6 +21,12 @@ from services.printer_service import print_waiter_report, print_printer_report,
router = APIRouter()
def _dt(dt):
if dt is None:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
@router.get("/shift")
def shift_summary(
from_dt: Optional[str] = Query(default=None, alias="from"),
@@ -438,3 +445,213 @@ def print_printer_totals(
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
return {"status": "printing"}
# ---------------------------------------------------------------------------
# Shift history report
# ---------------------------------------------------------------------------
@router.get("/shifts")
def shifts_report(
waiter_id: Optional[int] = None,
business_day_id: Optional[int] = None,
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
active_only: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from routers.shifts import compute_shift_total
q = db.query(WaiterShift)
if waiter_id:
q = q.filter(WaiterShift.waiter_id == waiter_id)
if business_day_id:
q = q.filter(WaiterShift.business_day_id == business_day_id)
if from_dt:
q = q.filter(WaiterShift.started_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(WaiterShift.started_at <= datetime.fromisoformat(to_dt))
if active_only:
q = q.filter(WaiterShift.ended_at == None)
shifts = q.order_by(WaiterShift.started_at.desc()).all()
waiters_db = {u.id: u for u in db.query(User).all()}
result = []
for shift in shifts:
w = waiters_db.get(shift.waiter_id)
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
result.append({
"id": shift.id,
"waiter_id": shift.waiter_id,
"waiter_name": wname,
"business_day_id": shift.business_day_id,
"started_at": _dt(shift.started_at),
"ended_at": _dt(shift.ended_at),
"starting_cash": shift.starting_cash,
"total_collected": total,
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
"is_active": shift.ended_at is None,
"notes": shift.notes,
})
return {"shifts": result}
# ---------------------------------------------------------------------------
# Product performance analytics
# ---------------------------------------------------------------------------
@router.get("/products/performance")
def product_performance(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
category_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.product import Product
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if from_dt:
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.join(Order).filter(Order.business_day_id == business_day_id)
items = q.all()
products_db = {p.id: p for p in db.query(Product).all()}
summary: dict = {}
for item in items:
pid = item.product_id
product = products_db.get(pid)
if category_id and (not product or product.category_id != category_id):
continue
if pid not in summary:
summary[pid] = {
"product_id": pid,
"product_name": product.name if product else f"#{pid}",
"category_id": product.category_id if product else None,
"qty_sold": 0,
"revenue": 0.0,
"order_ids": set(),
}
summary[pid]["qty_sold"] += item.quantity
summary[pid]["revenue"] += item.unit_price * item.quantity
summary[pid]["order_ids"].add(item.order_id)
result = []
for entry in summary.values():
entry["order_count"] = len(entry.pop("order_ids"))
entry["revenue"] = round(entry["revenue"], 2)
result.append(entry)
result.sort(key=lambda x: x["qty_sold"], reverse=True)
return {"products": result}
# ---------------------------------------------------------------------------
# Table performance analytics
# ---------------------------------------------------------------------------
@router.get("/tables/performance")
def table_performance(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
if from_dt:
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.filter(Order.business_day_id == business_day_id)
orders = q.all()
tables_db = {t.id: t for t in db.query(Table).all()}
summary: dict = {}
for order in orders:
tid = order.table_id
if tid not in summary:
t = tables_db.get(tid)
summary[tid] = {
"table_id": tid,
"table_name": (t.label or f"T{t.number}") if t else f"#{tid}",
"order_count": 0,
"revenue": 0.0,
"durations": [],
}
summary[tid]["order_count"] += 1
summary[tid]["revenue"] += sum(
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
)
if order.closed_at and order.opened_at:
summary[tid]["durations"].append(
(order.closed_at - order.opened_at).total_seconds() / 60
)
result = []
for entry in summary.values():
durations = entry.pop("durations")
entry["avg_duration_minutes"] = round(sum(durations) / len(durations), 1) if durations else None
entry["revenue"] = round(entry["revenue"], 2)
result.append(entry)
result.sort(key=lambda x: x["revenue"], reverse=True)
return {"tables": result}
# ---------------------------------------------------------------------------
# Traffic analysis (hour-of-day / day-of-week)
# ---------------------------------------------------------------------------
@router.get("/traffic")
def traffic_analysis(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Order)
if from_dt:
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.filter(Order.business_day_id == business_day_id)
orders = q.all()
by_hour = {h: {"hour": h, "orders": 0, "revenue": 0.0} for h in range(24)}
day_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
by_weekday = {d: {"day": d, "label": day_labels[d], "orders": 0, "revenue": 0.0} for d in range(7)}
for order in orders:
revenue = sum(
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
)
h = order.opened_at.hour
d = order.opened_at.weekday()
by_hour[h]["orders"] += 1
by_hour[h]["revenue"] += revenue
by_weekday[d]["orders"] += 1
by_weekday[d]["revenue"] += revenue
for h in by_hour:
by_hour[h]["revenue"] = round(by_hour[h]["revenue"], 2)
for d in by_weekday:
by_weekday[d]["revenue"] = round(by_weekday[d]["revenue"], 2)
return {
"by_hour": list(by_hour.values()),
"by_weekday": list(by_weekday.values()),
}

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime, timezone
from database import get_db
from models.settings import PosSettings
from schemas.settings import UpdateSettingRequest
from routers.deps import get_current_user, require_manager
from models.user import User
router = APIRouter()
VALID_SETTINGS = {
"shifts.waiter_self_start": "Allow waiters to start their own shifts without manager action",
"shifts.waiter_self_end": "Allow waiters to end their own shifts without manager action",
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
}
DEFAULTS = {
"shifts.waiter_self_start": "true",
"shifts.waiter_self_end": "true",
"business_day.force_close_allowed": "true",
"system.timezone": "Europe/Athens",
"ui.table_colours": "",
}
@router.get("/")
def get_all_settings(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
stored = {s.key: s.value for s in db.query(PosSettings).all()}
result = {}
for key, description in VALID_SETTINGS.items():
result[key] = {
"value": stored.get(key, DEFAULTS.get(key, "true")),
"description": description,
}
return result
@router.put("/{key}")
def update_setting(
key: str,
body: UpdateSettingRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
if key not in VALID_SETTINGS:
raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}")
setting = db.query(PosSettings).filter(PosSettings.key == key).first()
if setting:
setting.value = body.value
setting.updated_at = datetime.now(timezone.utc)
setting.updated_by_id = user.id
else:
setting = PosSettings(key=key, value=body.value, updated_by_id=user.id)
db.add(setting)
db.commit()
db.refresh(setting)
return {"key": setting.key, "value": setting.value}

View File

@@ -0,0 +1,347 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime, timezone
from database import get_db
from models.shift import WaiterShift, ShiftBreak
from models.business_day import BusinessDay
from models.order import OrderItem
from models.settings import PosSettings
from models.user import User
from schemas.shift import StartShiftRequest, EndShiftRequest
from routers.deps import get_current_user, require_manager
router = APIRouter()
def _dt(dt):
"""Serialize a naive-UTC datetime to ISO string with Z so JS parses it as UTC."""
if dt is None:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
def _get_setting(db: Session, key: str, default: str = "true") -> str:
s = db.query(PosSettings).filter(PosSettings.key == key).first()
return s.value if s else default
def compute_shift_total(shift_id: int, db: Session) -> float:
items = db.query(OrderItem).filter(
OrderItem.paid_in_shift_id == shift_id,
OrderItem.status == "paid",
).all()
return round(sum(i.unit_price * i.quantity for i in items), 2)
def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
w = shift.waiter
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
return {
"id": shift.id,
"waiter_id": shift.waiter_id,
"waiter_name": wname,
"business_day_id": shift.business_day_id,
"started_at": _dt(shift.started_at),
"ended_at": _dt(shift.ended_at),
"starting_cash": shift.starting_cash,
"total_collected": total,
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
"is_active": shift.ended_at is None,
"notes": shift.notes,
"breaks": [
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
for b in shift.breaks
],
}
@router.get("/my")
def my_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
return _enrich_shift(shift, db) if shift else None
@router.post("/start", status_code=status.HTTP_201_CREATED)
def start_shift(
body: StartShiftRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
target_id = body.waiter_id
if target_id and target_id != user.id:
if user.role not in ("manager", "sysadmin"):
raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters")
target = db.query(User).filter(User.id == target_id, User.is_active == True).first()
if not target:
raise HTTPException(status_code=404, detail="Waiter not found")
else:
target_id = user.id
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
raise HTTPException(status_code=403, detail="Shift start requires manager confirmation")
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if not active_day:
raise HTTPException(status_code=400, detail="No open business day — manager must open the restaurant first")
existing = db.query(WaiterShift).filter(
WaiterShift.waiter_id == target_id,
WaiterShift.ended_at == None,
).first()
if existing:
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
shift = WaiterShift(
waiter_id=target_id,
business_day_id=active_day.id,
starting_cash=body.starting_cash,
)
db.add(shift)
db.commit()
db.refresh(shift)
return _enrich_shift(shift, db)
@router.post("/end")
def end_shift(
body: EndShiftRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
raise HTTPException(status_code=403, detail="Shift end requires manager confirmation")
shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
if not shift:
raise HTTPException(status_code=404, detail="No active shift found")
now = datetime.now(timezone.utc)
shift.total_collected = compute_shift_total(shift.id, db)
shift.ended_at = now
if body.notes:
shift.notes = body.notes
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
).first()
if open_break:
open_break.ended_at = now
db.commit()
db.refresh(shift)
return _enrich_shift(shift, db)
@router.post("/manager/start", status_code=status.HTTP_201_CREATED)
def manager_start_shift(
body: StartShiftRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
if not body.waiter_id:
raise HTTPException(status_code=400, detail="waiter_id is required")
target = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
if not target:
raise HTTPException(status_code=404, detail="Waiter not found")
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if not active_day:
raise HTTPException(status_code=400, detail="No open business day")
existing = db.query(WaiterShift).filter(
WaiterShift.waiter_id == body.waiter_id,
WaiterShift.ended_at == None,
).first()
if existing:
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
shift = WaiterShift(
waiter_id=body.waiter_id,
business_day_id=active_day.id,
starting_cash=body.starting_cash,
)
db.add(shift)
db.commit()
db.refresh(shift)
return _enrich_shift(shift, db)
@router.post("/manager/end/{shift_id}")
def manager_end_shift(
shift_id: int,
body: EndShiftRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
shift = db.query(WaiterShift).filter(
WaiterShift.id == shift_id,
WaiterShift.ended_at == None,
).first()
if not shift:
raise HTTPException(status_code=404, detail="Active shift not found")
now = datetime.now(timezone.utc)
shift.total_collected = compute_shift_total(shift.id, db)
shift.ended_at = now
if body.notes:
shift.notes = body.notes
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
).first()
if open_break:
open_break.ended_at = now
db.commit()
db.refresh(shift)
return _enrich_shift(shift, db)
@router.post("/{shift_id}/break/start")
def start_break(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
raise HTTPException(status_code=403, detail="Access denied")
if shift.ended_at:
raise HTTPException(status_code=400, detail="Shift already ended")
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
).first()
if open_break:
raise HTTPException(status_code=400, detail="Break already in progress")
b = ShiftBreak(shift_id=shift_id)
db.add(b)
db.commit()
db.refresh(b)
return {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
@router.post("/{shift_id}/break/end")
def end_break(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
raise HTTPException(status_code=403, detail="Access denied")
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
).first()
if not open_break:
raise HTTPException(status_code=404, detail="No active break found")
open_break.ended_at = datetime.now(timezone.utc)
db.commit()
db.refresh(open_break)
return {"id": open_break.id, "shift_id": open_break.shift_id, "started_at": _dt(open_break.started_at), "ended_at": _dt(open_break.ended_at)}
@router.get("/")
def list_shifts(
waiter_id: Optional[int] = None,
business_day_id: Optional[int] = None,
active_only: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(WaiterShift)
if waiter_id:
q = q.filter(WaiterShift.waiter_id == waiter_id)
if business_day_id:
q = q.filter(WaiterShift.business_day_id == business_day_id)
if active_only:
q = q.filter(WaiterShift.ended_at == None)
shifts = q.order_by(WaiterShift.started_at.desc()).all()
return {"shifts": [_enrich_shift(s, db) for s in shifts]}
@router.get("/{shift_id}")
def get_shift(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
return _enrich_shift(shift, db)
@router.get("/{shift_id}/summary")
def get_shift_summary(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Full shift summary: enriched shift data + paid items grouped by order."""
from models.order import Order
from sqlalchemy.orm import joinedload
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
items = db.query(OrderItem).options(
joinedload(OrderItem.product),
joinedload(OrderItem.order),
).filter(
OrderItem.paid_in_shift_id == shift_id,
OrderItem.status == "paid",
).all()
orders_seen = {}
for item in items:
oid = item.order_id
if oid not in orders_seen:
o = item.order
orders_seen[oid] = {
"order_id": oid,
"table_id": o.table_id if o else None,
"opened_at": _dt(o.opened_at) if o else None,
"items": [],
}
orders_seen[oid]["items"].append({
"id": item.id,
"product_name": item.product.name if item.product else f"#{item.product_id}",
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"subtotal": round(float(item.unit_price) * item.quantity, 2),
"paid_at": _dt(item.paid_at),
})
# Compute hours worked
started = shift.started_at
ended = shift.ended_at
duration_minutes = None
if started and ended:
duration_minutes = int((ended - started).total_seconds() / 60)
elif started:
from datetime import datetime, timezone as tz
duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60)
enriched = _enrich_shift(shift, db)
enriched["orders"] = list(orders_seen.values())
enriched["duration_minutes"] = duration_minutes
return enriched

View File

@@ -28,7 +28,7 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
raise HTTPException(status_code=400, detail="Group name already exists")
sort_order = db.query(TableGroup).count()
group = TableGroup(name=body.name, prefix=body.prefix, sort_order=sort_order)
group = TableGroup(name=body.name, prefix=body.prefix, color=body.color, sort_order=sort_order)
db.add(group)
db.commit()
db.refresh(group)
@@ -86,7 +86,7 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
active_table_ids = {
row[0] for row in db.query(Order.table_id).filter(
Order.status.in_(["open", "partially_paid"])
Order.status.in_(["open", "partially_paid", "paid"])
).all()
}
@@ -167,7 +167,7 @@ def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db
raise HTTPException(status_code=404, detail="Table not found")
active_order = db.query(Order).filter(
Order.table_id == table_id,
Order.status.in_(["open", "partially_paid"])
Order.status.in_(["open", "partially_paid", "paid"])
).first()
if active_order:
raise HTTPException(
@@ -194,7 +194,7 @@ def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depe
raise HTTPException(status_code=404, detail="Table not found")
active_order = (
db.query(Order)
.filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid"]))
.filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid", "paid"]))
.first()
)
return {

View File

@@ -7,8 +7,9 @@ from typing import List
from database import get_db
from models.user import User, AssistantAssignment, WaiterZone
from models.shift import WaiterShift
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
from routers.deps import require_manager
from routers.deps import require_manager, get_current_user
router = APIRouter()
@@ -26,6 +27,13 @@ def _waiter_or_404(waiter_id: int, db: Session) -> User:
# ── CRUD ──────────────────────────────────────────────────────────────────────
@router.get("/on-shift", response_model=List[UserOut])
def list_waiters_on_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Waiters with an active (not-ended) shift. Accessible to all staff."""
waiter_ids = db.query(WaiterShift.waiter_id).filter(WaiterShift.ended_at == None).subquery()
return db.query(User).filter(User.id.in_(waiter_ids), User.role == "waiter", User.is_active == True).all()
@router.get("/", response_model=List[UserOut])
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
return db.query(User).filter(User.role == "waiter").all()
@@ -36,7 +44,15 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
if db.query(User).filter(User.username == body.username).first():
raise HTTPException(status_code=400, detail="Username already exists")
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
new_user = User(username=body.username, pin_hash=pin_hash, role=body.role, is_active=body.is_active)
new_user = User(
username=body.username,
pin_hash=pin_hash,
role=body.role,
is_active=body.is_active,
full_name=body.full_name,
nickname=body.nickname,
mobile_phone=body.mobile_phone,
)
db.add(new_user)
db.commit()
db.refresh(new_user)