feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,22 @@
|
||||
import bcrypt
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from config import settings
|
||||
from models.user import User
|
||||
from schemas.auth import LoginRequest, TokenResponse, UpdateMeRequest
|
||||
from pydantic import BaseModel as _PydanticBase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class LoginByIdRequest(_PydanticBase):
|
||||
waiter_id: int
|
||||
pin: str
|
||||
from schemas.user import UserOut
|
||||
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
|
||||
from routers.deps import get_current_user, make_token, decode_token, blacklist_token, _make_ghost_superadmin
|
||||
from routers.recovery import try_recovery_code
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -28,7 +33,7 @@ def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)):
|
||||
if not setting or setting.value != "none":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No-auth login is not enabled.")
|
||||
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
||||
if not user or user.role not in ("manager", "sysadmin"):
|
||||
if not user or not user.perm_access_dashboard:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = make_token(user)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
@@ -36,6 +41,18 @@ def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)):
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
# Break-glass master account — checked before DB, never stored as a real user
|
||||
if (
|
||||
settings.MASTER_USERNAME
|
||||
and settings.MASTER_PASSWORD
|
||||
and body.username == settings.MASTER_USERNAME
|
||||
and body.password == settings.MASTER_PASSWORD
|
||||
):
|
||||
_logger.warning("MASTER LOGIN USED from username=%s", body.username)
|
||||
ghost = _make_ghost_superadmin()
|
||||
token = make_token(ghost)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(ghost))
|
||||
|
||||
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
@@ -46,6 +63,10 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
elif body.pin and user.pin_hash:
|
||||
authenticated = bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode())
|
||||
|
||||
# If normal auth failed, check if the submitted value is a recovery code
|
||||
if not authenticated and body.password:
|
||||
authenticated = try_recovery_code(body.password, user, db)
|
||||
|
||||
if not authenticated:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
@@ -96,9 +117,9 @@ class PublicManagerOut(_PydanticBase):
|
||||
|
||||
@router.get("/managers", response_model=list[PublicManagerOut])
|
||||
def public_manager_list(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns active manager/sysadmin accounts for login screen."""
|
||||
"""Public endpoint — returns accounts with dashboard access for login screen."""
|
||||
managers = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.perm_access_dashboard == True,
|
||||
User.is_active == True,
|
||||
).all()
|
||||
return [PublicManagerOut(id=m.id, username=m.username, full_name=m.full_name) for m in managers]
|
||||
@@ -121,7 +142,7 @@ class PublicWaiterOut(_BaseModel):
|
||||
def public_waiter_list(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns active waiters with on-shift flag. No auth required."""
|
||||
from models.shift import WaiterShift
|
||||
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
|
||||
waiters = db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()
|
||||
on_shift_ids = {
|
||||
row.waiter_id
|
||||
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
|
||||
@@ -172,3 +193,26 @@ def update_me(body: UpdateMeRequest, db: Session = Depends(get_db), user: User =
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
class WaiterSettingsPayload(_PydanticBase):
|
||||
settings: str # raw JSON string
|
||||
|
||||
|
||||
@router.get("/me/settings")
|
||||
def get_my_settings(user: User = Depends(get_current_user)):
|
||||
"""Return the stored waiter settings JSON blob for the current user."""
|
||||
return {"settings": user.waiter_settings or "{}"}
|
||||
|
||||
|
||||
@router.put("/me/settings")
|
||||
def put_my_settings(body: WaiterSettingsPayload, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Replace the stored waiter settings JSON blob."""
|
||||
import json as _json
|
||||
try:
|
||||
_json.loads(body.settings) # validate it's valid JSON
|
||||
except Exception:
|
||||
raise HTTPException(status_code=422, detail="settings must be valid JSON")
|
||||
user.waiter_settings = body.settings
|
||||
db.commit()
|
||||
return {"settings": user.waiter_settings}
|
||||
|
||||
@@ -3,6 +3,7 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.business_day import BusinessDay
|
||||
@@ -173,6 +174,60 @@ def close_business_day(
|
||||
return day
|
||||
|
||||
|
||||
class PatchBusinessDayRequest(BaseModel):
|
||||
closed_at: str # ISO-8601 datetime string
|
||||
|
||||
|
||||
@router.patch("/{day_id}", status_code=status.HTTP_200_OK)
|
||||
def patch_business_day(
|
||||
day_id: int,
|
||||
body: "PatchBusinessDayRequest",
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Edit the close-time of a past (closed) business day.
|
||||
Constraints:
|
||||
- Day must already be closed.
|
||||
- new closed_at must be ≥ the latest order closed_at in this day.
|
||||
- new closed_at must be ≤ now (UTC).
|
||||
"""
|
||||
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="Business day not found")
|
||||
if day.status != "closed":
|
||||
raise HTTPException(status_code=400, detail="Can only edit a closed business day")
|
||||
|
||||
try:
|
||||
new_closed = datetime.fromisoformat(body.closed_at.replace("Z", "+00:00"))
|
||||
if new_closed.tzinfo is None:
|
||||
new_closed = new_closed.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="Invalid datetime format")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if new_closed > now:
|
||||
raise HTTPException(status_code=400, detail="Close time cannot be in the future")
|
||||
|
||||
# Must not be earlier than the latest order closed_at in this day
|
||||
last_order_close = (
|
||||
db.query(func.max(Order.closed_at))
|
||||
.filter(Order.business_day_id == day_id, Order.closed_at != None)
|
||||
.scalar()
|
||||
)
|
||||
if last_order_close:
|
||||
if last_order_close.tzinfo is None:
|
||||
last_order_close = last_order_close.replace(tzinfo=timezone.utc)
|
||||
if new_closed < last_order_close:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Close time cannot be earlier than the last order's close time ({_dt(last_order_close)})"
|
||||
)
|
||||
|
||||
day.closed_at = new_closed
|
||||
db.commit()
|
||||
return {"id": day.id, "closed_at": _dt(day.closed_at)}
|
||||
|
||||
|
||||
@router.delete("/{day_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_business_day(
|
||||
day_id: int,
|
||||
|
||||
404
local_backend/routers/chat.py
Normal file
404
local_backend/routers/chat.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
Chat router — /api/chat
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from routers.deps import get_current_user
|
||||
from models.user import User
|
||||
from models.chat import Conversation, ConversationParticipant, ChatMessage
|
||||
from schemas.user import UserOut
|
||||
from schemas.chat import (
|
||||
ConversationCreate,
|
||||
ConversationOut,
|
||||
MessageCreate,
|
||||
MessageOut,
|
||||
ParticipantOut,
|
||||
)
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _build_participant_out(p: ConversationParticipant) -> ParticipantOut:
|
||||
return ParticipantOut(
|
||||
user_id=p.user_id,
|
||||
username=p.user.username,
|
||||
joined_at=p.joined_at,
|
||||
last_read_at=p.last_read_at,
|
||||
)
|
||||
|
||||
|
||||
def _build_message_out(msg: ChatMessage) -> MessageOut:
|
||||
return MessageOut(
|
||||
id=msg.id,
|
||||
conversation_id=msg.conversation_id,
|
||||
sender_id=msg.sender_id,
|
||||
sender_name=msg.sender.username,
|
||||
body=msg.body,
|
||||
sent_at=msg.sent_at,
|
||||
is_deleted=msg.deleted_at is not None,
|
||||
)
|
||||
|
||||
|
||||
def _unread_count(db: Session, conv_id: int, user: User) -> int:
|
||||
"""
|
||||
Count messages in a conversation that are 'unread' for this user:
|
||||
- not soft-deleted
|
||||
- not sent by this user (your own messages don't count)
|
||||
- sent after user's last_read_at (or ALL messages if last_read_at is None)
|
||||
"""
|
||||
participant = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
ConversationParticipant.conversation_id == conv_id,
|
||||
ConversationParticipant.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not participant:
|
||||
return 0
|
||||
|
||||
q = (
|
||||
db.query(ChatMessage)
|
||||
.filter(
|
||||
ChatMessage.conversation_id == conv_id,
|
||||
ChatMessage.deleted_at == None, # noqa: E711
|
||||
ChatMessage.sender_id != user.id,
|
||||
)
|
||||
)
|
||||
if participant.last_read_at is not None:
|
||||
q = q.filter(ChatMessage.sent_at > participant.last_read_at)
|
||||
return q.count()
|
||||
|
||||
|
||||
def _last_message(db: Session, conv_id: int) -> Optional[ChatMessage]:
|
||||
return (
|
||||
db.query(ChatMessage)
|
||||
.filter(
|
||||
ChatMessage.conversation_id == conv_id,
|
||||
ChatMessage.deleted_at == None, # noqa: E711
|
||||
)
|
||||
.order_by(ChatMessage.sent_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _get_participant_ids(conv: Conversation) -> List[int]:
|
||||
return [p.user_id for p in conv.participants]
|
||||
|
||||
|
||||
def _build_conversation_out(db: Session, conv: Conversation, user: User) -> ConversationOut:
|
||||
last_msg = _last_message(db, conv.id)
|
||||
return ConversationOut(
|
||||
id=conv.id,
|
||||
type=conv.type,
|
||||
name=conv.name,
|
||||
is_system=conv.is_system,
|
||||
created_at=conv.created_at,
|
||||
participants=[_build_participant_out(p) for p in conv.participants],
|
||||
last_message=_build_message_out(last_msg) if last_msg else None,
|
||||
unread_count=_unread_count(db, conv.id, user),
|
||||
)
|
||||
|
||||
|
||||
def _require_participant(db: Session, conv_id: int, user: User) -> Conversation:
|
||||
"""Return the conversation if user is a participant, else 403/404."""
|
||||
conv = db.query(Conversation).filter(Conversation.id == conv_id).first()
|
||||
if not conv:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
is_participant = any(p.user_id == user.id for p in conv.participants)
|
||||
if not is_participant:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a participant")
|
||||
return conv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/conversations", response_model=List[ConversationOut])
|
||||
def list_conversations(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all conversations the current user is a participant of, ordered by last message time desc."""
|
||||
# Fetch all conversation_ids for this user
|
||||
participant_rows = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(ConversationParticipant.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
conv_ids = [p.conversation_id for p in participant_rows]
|
||||
if not conv_ids:
|
||||
return []
|
||||
|
||||
conversations = (
|
||||
db.query(Conversation)
|
||||
.filter(Conversation.id.in_(conv_ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
# Sort by last message sent_at desc (conversations with no messages go last)
|
||||
# SQLite returns naive datetimes even for timezone=True columns, so keep everything naive
|
||||
def _sort_key(c: Conversation):
|
||||
last = _last_message(db, c.id)
|
||||
if not last:
|
||||
return datetime.min
|
||||
dt = last.sent_at
|
||||
return dt.replace(tzinfo=None) if dt.tzinfo else dt
|
||||
|
||||
conversations.sort(key=_sort_key, reverse=True)
|
||||
|
||||
return [_build_conversation_out(db, c, user) for c in conversations]
|
||||
|
||||
|
||||
@router.post("/conversations", response_model=ConversationOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_conversation(
|
||||
payload: ConversationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a direct or group conversation.
|
||||
For direct: returns existing conversation if one already exists between the two users.
|
||||
Creator is automatically added as a participant.
|
||||
"""
|
||||
# Normalise participant list — always include creator
|
||||
participant_ids = list(set(payload.participant_ids + [user.id]))
|
||||
|
||||
if payload.type == "direct":
|
||||
if len(participant_ids) != 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Direct conversations must have exactly 2 participants",
|
||||
)
|
||||
other_id = next(pid for pid in participant_ids if pid != user.id)
|
||||
|
||||
# Check for an existing non-system direct conversation between these two users
|
||||
existing_parts_me = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(ConversationParticipant.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
my_conv_ids = {p.conversation_id for p in existing_parts_me}
|
||||
|
||||
existing_parts_other = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
ConversationParticipant.user_id == other_id,
|
||||
ConversationParticipant.conversation_id.in_(my_conv_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
shared_conv_ids = {p.conversation_id for p in existing_parts_other}
|
||||
|
||||
for conv_id in shared_conv_ids:
|
||||
conv = db.query(Conversation).filter(
|
||||
Conversation.id == conv_id,
|
||||
Conversation.type == "direct",
|
||||
Conversation.is_system == False, # noqa: E712
|
||||
).first()
|
||||
if conv:
|
||||
return _build_conversation_out(db, conv, user)
|
||||
|
||||
elif payload.type == "group":
|
||||
if not payload.name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Group conversations require a name",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="type must be 'direct' or 'group'",
|
||||
)
|
||||
|
||||
# Create the conversation
|
||||
conv = Conversation(
|
||||
type=payload.type,
|
||||
name=payload.name if payload.type == "group" else None,
|
||||
is_system=False,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(conv)
|
||||
db.flush()
|
||||
|
||||
now = _utcnow()
|
||||
for uid in participant_ids:
|
||||
db.add(ConversationParticipant(
|
||||
conversation_id=conv.id,
|
||||
user_id=uid,
|
||||
joined_at=now,
|
||||
))
|
||||
|
||||
db.commit()
|
||||
db.refresh(conv)
|
||||
return _build_conversation_out(db, conv, user)
|
||||
|
||||
|
||||
@router.get("/conversations/{conv_id}", response_model=ConversationOut)
|
||||
def get_conversation(
|
||||
conv_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get single conversation details with participants."""
|
||||
conv = _require_participant(db, conv_id, user)
|
||||
return _build_conversation_out(db, conv, user)
|
||||
|
||||
|
||||
@router.get("/conversations/{conv_id}/messages", response_model=List[MessageOut])
|
||||
def list_messages(
|
||||
conv_id: int,
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Paginated messages for a conversation, newest first. User must be a participant."""
|
||||
_require_participant(db, conv_id, user)
|
||||
|
||||
messages = (
|
||||
db.query(ChatMessage)
|
||||
.filter(ChatMessage.conversation_id == conv_id)
|
||||
.order_by(ChatMessage.sent_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [_build_message_out(m) for m in messages]
|
||||
|
||||
|
||||
@router.post("/conversations/{conv_id}/messages", response_model=MessageOut, status_code=status.HTTP_201_CREATED)
|
||||
def send_message(
|
||||
conv_id: int,
|
||||
payload: MessageCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Send a message to a conversation. Broadcasts chat_message SSE event to all participants."""
|
||||
conv = _require_participant(db, conv_id, user)
|
||||
|
||||
if not payload.body.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Message body cannot be empty",
|
||||
)
|
||||
|
||||
msg = ChatMessage(
|
||||
conversation_id=conv_id,
|
||||
sender_id=user.id,
|
||||
body=payload.body,
|
||||
sent_at=_utcnow(),
|
||||
)
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
db.refresh(msg)
|
||||
|
||||
participant_ids = _get_participant_ids(conv)
|
||||
# Ensure UTC Z suffix so JS Date() parses correctly regardless of tzinfo
|
||||
sent_at_str = msg.sent_at.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
|
||||
broadcast_sync(
|
||||
"chat_message",
|
||||
{
|
||||
"conversation_id": conv_id,
|
||||
"message_id": msg.id,
|
||||
"sender_id": user.id,
|
||||
"sender_name": user.username,
|
||||
"body": msg.body,
|
||||
"sent_at": sent_at_str,
|
||||
},
|
||||
user_ids=participant_ids,
|
||||
)
|
||||
|
||||
return _build_message_out(msg)
|
||||
|
||||
|
||||
@router.post("/conversations/{conv_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def mark_read(
|
||||
conv_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update the current user's last_read_at to now. Broadcasts chat_read SSE event."""
|
||||
conv = _require_participant(db, conv_id, user)
|
||||
|
||||
participant = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
ConversationParticipant.conversation_id == conv_id,
|
||||
ConversationParticipant.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
now = _utcnow()
|
||||
participant.last_read_at = now
|
||||
db.commit()
|
||||
|
||||
participant_ids = _get_participant_ids(conv)
|
||||
broadcast_sync(
|
||||
"chat_read",
|
||||
{
|
||||
"conversation_id": conv_id,
|
||||
"user_id": user.id,
|
||||
"read_at": now.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z',
|
||||
},
|
||||
user_ids=participant_ids,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/conversations/{conv_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def leave_conversation(
|
||||
conv_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Remove current user from the conversation.
|
||||
Not allowed on system conversations or direct conversations.
|
||||
"""
|
||||
conv = _require_participant(db, conv_id, user)
|
||||
|
||||
if conv.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot leave the system group conversation",
|
||||
)
|
||||
if conv.type == "direct":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot leave a direct conversation — just stop using it",
|
||||
)
|
||||
|
||||
participant = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
ConversationParticipant.conversation_id == conv_id,
|
||||
ConversationParticipant.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
db.delete(participant)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/users", response_model=List[UserOut])
|
||||
def list_chat_users(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""All active users — accessible to any authenticated staff member, for starting chats."""
|
||||
return db.query(User).filter(User.is_active == True).order_by(User.username).all() # noqa: E712
|
||||
@@ -16,6 +16,45 @@ _blacklisted_tokens: set[str] = set()
|
||||
TOKEN_EXPIRY_HOURS = 8
|
||||
|
||||
|
||||
MASTER_USER_ID = 0 # sentinel — never a real DB row
|
||||
|
||||
|
||||
def _make_ghost_superadmin():
|
||||
"""Synthetic in-memory superadmin — plain namespace, never touches SQLAlchemy."""
|
||||
from types import SimpleNamespace
|
||||
return SimpleNamespace(
|
||||
id=MASTER_USER_ID,
|
||||
username=settings.MASTER_USERNAME,
|
||||
full_name="Xenia Support",
|
||||
role="superadmin",
|
||||
is_active=True,
|
||||
pin_hash=None,
|
||||
password_hash=None,
|
||||
email=None,
|
||||
nickname=None,
|
||||
mobile_phone=None,
|
||||
note=None,
|
||||
avatar_url=None,
|
||||
waiter_settings=None,
|
||||
hourly_rate=None,
|
||||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
zone_assignments=[],
|
||||
perm_access_dashboard=True,
|
||||
perm_access_waiter_app=True,
|
||||
perm_access_kds=True,
|
||||
perm_cancel_orders=True,
|
||||
perm_apply_discounts=True,
|
||||
perm_modify_prices=True,
|
||||
perm_open_orders=True,
|
||||
perm_close_orders=True,
|
||||
perm_view_reports=True,
|
||||
perm_manage_staff=True,
|
||||
perm_manage_tables=True,
|
||||
perm_manage_menu=True,
|
||||
perm_manage_settings=True,
|
||||
)
|
||||
|
||||
|
||||
def make_token(user: User) -> str:
|
||||
payload = {
|
||||
"sub": str(user.id),
|
||||
@@ -46,19 +85,69 @@ def get_current_user(
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
payload = decode_token(credentials.credentials)
|
||||
if int(payload["sub"]) == MASTER_USER_ID and payload.get("role") == "superadmin":
|
||||
return _make_ghost_superadmin()
|
||||
user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
def _has_perm(user: User, perm: str) -> bool:
|
||||
"""Superadmins always have every permission."""
|
||||
if user.role == "superadmin":
|
||||
return True
|
||||
return bool(getattr(user, perm, False))
|
||||
|
||||
|
||||
def require_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role not in ("manager", "sysadmin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Manager access required")
|
||||
"""Allow anyone with dashboard access (formerly role-based manager check)."""
|
||||
if not _has_perm(user, "perm_access_dashboard"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Dashboard access required")
|
||||
return user
|
||||
|
||||
|
||||
def require_sysadmin(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role != "sysadmin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin access required")
|
||||
def require_staff_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_manage_staff"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Staff management permission required")
|
||||
return user
|
||||
|
||||
|
||||
def require_reports(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_view_reports"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Reports permission required")
|
||||
return user
|
||||
|
||||
|
||||
def require_menu_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_manage_menu"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Menu management permission required")
|
||||
return user
|
||||
|
||||
|
||||
def require_settings_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_manage_settings"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Settings management permission required")
|
||||
return user
|
||||
|
||||
|
||||
def require_kds(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_access_kds"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Δεν έχετε πρόσβαση στο KDS")
|
||||
return user
|
||||
|
||||
|
||||
def require_tables_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if not _has_perm(user, "perm_manage_tables"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tables management permission required")
|
||||
return user
|
||||
|
||||
|
||||
def require_superadmin(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role != "superadmin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin access required")
|
||||
return user
|
||||
|
||||
|
||||
# Kept for any callers that still use the old name
|
||||
require_sysadmin = require_superadmin
|
||||
|
||||
25
local_backend/routers/fiscal.py
Normal file
25
local_backend/routers/fiscal.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from routers.deps import require_settings_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TestFoldersRequest(BaseModel):
|
||||
out_folder: str
|
||||
in_folder: str
|
||||
|
||||
|
||||
@router.post("/test-folders")
|
||||
def test_folders(
|
||||
body: TestFoldersRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_settings_manager),
|
||||
):
|
||||
"""Verify that both fiscal folders are accessible (read + write) from inside the container."""
|
||||
from services.fiscal_service import test_folders
|
||||
return test_folders(body.out_folder.strip(), body.in_folder.strip())
|
||||
@@ -1,30 +1,737 @@
|
||||
"""
|
||||
KDS — Kitchen Display System router.
|
||||
|
||||
GET /api/kds/items
|
||||
Returns all active (status='active') order items for open orders,
|
||||
enriched with zone name and table name. Used by the KDS frontend.
|
||||
GET /api/kds/orders
|
||||
Returns all open orders enriched with resolved modifier names,
|
||||
grouped by nothing (flat list). Frontend handles grouping/filtering.
|
||||
|
||||
PUT /api/orders/{order_id}/items/{item_id}/status
|
||||
Mark an item ready (active → ready). Only that transition is allowed here.
|
||||
Broadcasts item_status_changed SSE event.
|
||||
PUT /api/kds/orders/{order_id}/kds_status
|
||||
Update an order's KDS aggregate status (pending|preparing|done).
|
||||
Broadcasts kds_order_updated SSE event.
|
||||
|
||||
PUT /api/kds/orders/{order_id}/items/{item_id}/kds_status
|
||||
Update a single item's KDS status (pending|preparing|done).
|
||||
Broadcasts kds_item_updated SSE event.
|
||||
|
||||
PUT /api/kds/orders/{order_id}/order_type
|
||||
Update an order's type (here|takeaway|delivery).
|
||||
|
||||
--- Legacy endpoint kept for backward compat (printer-zone grouped items) ---
|
||||
GET /api/kds/items (unchanged)
|
||||
PUT /api/kds/orders/{order_id}/items/{item_id}/status (unchanged)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.order import Order, OrderItem
|
||||
from models.product import Product
|
||||
from models.order import Order, OrderItem, OrderWaiter
|
||||
from models.product import Product, ProductPreferenceSet
|
||||
from models.prep_zone import PrepZone
|
||||
from models.printer import Printer
|
||||
from models.table import Table
|
||||
from models.user import User
|
||||
from routers.deps import get_current_user
|
||||
from models.message import StaffMessage, StaffMessageAck
|
||||
from models.shift import WaiterShift
|
||||
from routers.deps import get_current_user, require_kds
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(dependencies=[Depends(require_kds)])
|
||||
|
||||
VALID_KDS_STATUSES = {"pending", "preparing", "done", "served", "declined"}
|
||||
VALID_ORDER_TYPES = {"here", "takeaway", "delivery"}
|
||||
|
||||
|
||||
# ─────────────────────────── helpers ───────────────────────────────────────
|
||||
|
||||
def _resolve_item_modifiers(item: OrderItem, product: Product | None, db: Session):
|
||||
"""
|
||||
Returns a dict with four lists:
|
||||
removed - ingredient names removed (from removed_ingredients JSON id array)
|
||||
extras - option names selected (from selected_options JSON id array)
|
||||
prefs - preference choice names selected (from selected_options JSON id array)
|
||||
notes - plain text note
|
||||
"""
|
||||
removed, extras, prefs = [], [], []
|
||||
|
||||
if product is None:
|
||||
return {"removed": removed, "extras": extras, "prefs": prefs, "notes": item.notes}
|
||||
|
||||
# Build lookup maps (avoid N+1 — product relationships already loaded)
|
||||
ing_map = {i.id: i.name for i in product.ingredients}
|
||||
opt_map = {o.id: o.name for o in product.options}
|
||||
quick_map = {q.id: q.name for q in product.quick_options}
|
||||
|
||||
# Preference choice lookup: pref_choice_map[choice_id] = (set_name, choice_name)
|
||||
pref_choice_map: dict[int, tuple[str, str]] = {}
|
||||
for ps in product.preference_sets:
|
||||
for pc in ps.choices:
|
||||
pref_choice_map[pc.id] = (ps.name, pc.name)
|
||||
|
||||
# Removed ingredients — stored as JSON array of name strings (not ids)
|
||||
if item.removed_ingredients:
|
||||
try:
|
||||
vals = json.loads(item.removed_ingredients)
|
||||
for v in vals:
|
||||
if isinstance(v, str) and v:
|
||||
removed.append(v)
|
||||
elif isinstance(v, int):
|
||||
# legacy: id reference
|
||||
name = ing_map.get(v)
|
||||
if name:
|
||||
removed.append(name)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Selected options — stored as JSON array of objects:
|
||||
# [{"id": int, "name": str, "type": "extra"|"quick"|"pref"|"pref_sub", ...}]
|
||||
# We use the stored name directly; type tag tells us which bucket.
|
||||
if item.selected_options:
|
||||
try:
|
||||
opts = json.loads(item.selected_options)
|
||||
for o in opts:
|
||||
if isinstance(o, dict):
|
||||
name = o.get("name") or ""
|
||||
otype = o.get("type", "")
|
||||
if not name:
|
||||
continue
|
||||
if otype in ("pref", "pref_sub"):
|
||||
prefs.append(name)
|
||||
else:
|
||||
# "extra", "extra_sub", "quick", or unknown → extras
|
||||
extras.append(name)
|
||||
elif isinstance(o, int):
|
||||
# legacy: id-only reference — fall back to lookup maps
|
||||
if o in opt_map:
|
||||
extras.append(opt_map[o])
|
||||
elif o in quick_map:
|
||||
extras.append(quick_map[o])
|
||||
elif o in pref_choice_map:
|
||||
_, choice_name = pref_choice_map[o]
|
||||
prefs.append(choice_name)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return {"removed": removed, "extras": extras, "prefs": prefs, "notes": item.notes}
|
||||
|
||||
|
||||
def _build_order_payload(order: Order, tables_map: dict, db: Session) -> dict:
|
||||
"""Build the full order dict the KDS frontend expects."""
|
||||
items_out = []
|
||||
for item in order.items:
|
||||
if item.status == "cancelled":
|
||||
continue
|
||||
if item.kds_status == "served":
|
||||
continue
|
||||
product = item.product
|
||||
mods = _resolve_item_modifiers(item, product, db)
|
||||
unit_type = product.unit_type if product else "piece"
|
||||
prep_zone_ids = [z.id for z in product.prep_zones] if product and hasattr(product, 'prep_zones') else []
|
||||
items_out.append({
|
||||
"id": item.id,
|
||||
"product_id": item.product_id,
|
||||
"product_name": product.name if product else f"#{item.product_id}",
|
||||
"quantity": item.quantity,
|
||||
"unit_type": unit_type or "piece",
|
||||
"kds_status": item.kds_status,
|
||||
"decline_note": item.decline_note,
|
||||
"added_at": item.added_at.isoformat() if item.added_at else None,
|
||||
"_added_at_raw": item.added_at, # stripped before response
|
||||
"prep_zone_ids": prep_zone_ids,
|
||||
"course_id": item.course_id,
|
||||
"removed": mods["removed"],
|
||||
"extras": mods["extras"],
|
||||
"prefs": mods["prefs"],
|
||||
"notes": mods["notes"],
|
||||
})
|
||||
|
||||
# Waiter names from first assignment
|
||||
waiter_names = [ow.waiter.username for ow in order.waiters if ow.waiter] if order.waiters else []
|
||||
|
||||
# Clock = oldest added_at among pending/preparing items (the active batch).
|
||||
# Falls back to order.opened_at only if no such item exists.
|
||||
def _fmt_ts(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z" if not dt.tzinfo else dt.isoformat()
|
||||
|
||||
active_batch_times = [
|
||||
item["_added_at_raw"] for item in items_out
|
||||
if item.get("_added_at_raw") and item["kds_status"] in ("pending", "preparing")
|
||||
]
|
||||
batch_ts = min(active_batch_times) if active_batch_times else None
|
||||
opened_at_out = _fmt_ts(batch_ts) if batch_ts else _fmt_ts(order.opened_at)
|
||||
|
||||
for item in items_out:
|
||||
item.pop("_added_at_raw", None)
|
||||
|
||||
return {
|
||||
"id": order.id,
|
||||
"kds_status": order.kds_status,
|
||||
"order_type": order.order_type,
|
||||
"table_name": tables_map.get(order.table_id) if order.table_id else None,
|
||||
"table_id": order.table_id,
|
||||
"opened_at": opened_at_out,
|
||||
"closed_at": _fmt_ts(order.closed_at) if order.closed_at else None,
|
||||
"notes": order.notes,
|
||||
"waiters": waiter_names,
|
||||
"items": items_out,
|
||||
}
|
||||
|
||||
|
||||
def _sync_order_kds_status(order: Order):
|
||||
"""Derive and set order.kds_status from all non-cancelled items. Call before db.commit()."""
|
||||
relevant = [i for i in order.items if i.status != "cancelled"]
|
||||
if not relevant:
|
||||
return
|
||||
statuses = {i.kds_status for i in relevant}
|
||||
if statuses <= {"served"}:
|
||||
new_status = "served"
|
||||
elif statuses <= {"done", "served"}:
|
||||
new_status = "done"
|
||||
elif statuses <= {"declined"}:
|
||||
new_status = "declined"
|
||||
elif "preparing" in statuses or "done" in statuses:
|
||||
new_status = "preparing"
|
||||
else:
|
||||
new_status = "pending"
|
||||
if new_status != order.kds_status:
|
||||
order.kds_status = new_status
|
||||
order.kds_status_changed_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ─────────────────────────── endpoints ─────────────────────────────────────
|
||||
|
||||
@router.get("/orders")
|
||||
def kds_orders(
|
||||
zone_id: Optional[int] = Query(default=None, description="Filter orders to only those containing items in this prep zone"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all open/partially_paid/paid orders that still have unserved items.
|
||||
If zone_id is provided, only orders containing at least one item belonging to that prep zone are returned,
|
||||
and items not in that zone are stripped from the payload.
|
||||
"""
|
||||
open_orders = (
|
||||
db.query(Order)
|
||||
.filter(Order.status.in_(["open", "partially_paid", "paid"]))
|
||||
.options(
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.ingredients),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.options),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.quick_options),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.preference_sets).joinedload(ProductPreferenceSet.choices),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.prep_zones),
|
||||
joinedload(Order.waiters).joinedload(OrderWaiter.waiter),
|
||||
)
|
||||
.order_by(Order.opened_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
|
||||
payloads = []
|
||||
for o in open_orders:
|
||||
p = _build_order_payload(o, tables_map, db)
|
||||
if not p["items"]:
|
||||
continue
|
||||
# Zone filtering: keep only items whose product belongs to the requested zone
|
||||
if zone_id is not None:
|
||||
p["items"] = [it for it in p["items"] if zone_id in it.get("prep_zone_ids", [])]
|
||||
if not p["items"]:
|
||||
continue
|
||||
payloads.append(p)
|
||||
return {"orders": payloads}
|
||||
|
||||
|
||||
class KdsStatusBody(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class OrderTypeBody(BaseModel):
|
||||
order_type: str
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/kds_status")
|
||||
def update_order_kds_status(
|
||||
order_id: int,
|
||||
body: KdsStatusBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.status not in VALID_KDS_STATUSES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
if body.status != order.kds_status:
|
||||
order.kds_status = body.status
|
||||
order.kds_status_changed_at = datetime.now(timezone.utc)
|
||||
|
||||
# Cascade order-level status to non-cancelled, non-served items only
|
||||
if body.status in ("pending", "preparing", "done"):
|
||||
for item in order.items:
|
||||
if item.status != "cancelled" and item.kds_status != "served":
|
||||
item.kds_status = body.status
|
||||
# auto_ready_to_served: if cascading to 'done', immediately upgrade qualifying items
|
||||
if body.status == "done" and item.product:
|
||||
if any(bool(z.auto_ready_to_served) for z in item.product.prep_zones):
|
||||
item.kds_status = "served"
|
||||
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_order_updated", {
|
||||
"order_id": order_id,
|
||||
"kds_status": body.status,
|
||||
})
|
||||
|
||||
return {"order_id": order_id, "kds_status": body.status}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/items/{item_id}/kds_status")
|
||||
def update_item_kds_status(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: KdsStatusBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.status not in VALID_KDS_STATUSES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
|
||||
|
||||
item = db.query(OrderItem).filter(
|
||||
OrderItem.id == item_id,
|
||||
OrderItem.order_id == order_id,
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
item.kds_status = body.status
|
||||
|
||||
# auto_ready_to_served: if item reaches 'done' and any of its zones has the flag,
|
||||
# immediately upgrade it to 'served' — item was ready at the KDS, no explicit serve needed.
|
||||
if body.status == "done" and item.product:
|
||||
auto_serve = any(bool(z.auto_ready_to_served) for z in item.product.prep_zones)
|
||||
if auto_serve:
|
||||
item.kds_status = "served"
|
||||
|
||||
if item.order:
|
||||
_sync_order_kds_status(item.order)
|
||||
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"kds_status": item.kds_status,
|
||||
})
|
||||
|
||||
return {"item_id": item_id, "kds_status": item.kds_status}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/order_type")
|
||||
def update_order_type(
|
||||
order_id: int,
|
||||
body: OrderTypeBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.order_type not in VALID_ORDER_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid order_type '{body.order_type}'")
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
order.order_type = body.order_type
|
||||
db.commit()
|
||||
|
||||
return {"order_id": order_id, "order_type": body.order_type}
|
||||
|
||||
|
||||
class MarkServedBody(BaseModel):
|
||||
item_ids: list[int]
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/items/mark-served")
|
||||
def mark_items_served(
|
||||
order_id: int,
|
||||
body: MarkServedBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark a list of items as served (kds_status=served). Works on active and paid items."""
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
updated = []
|
||||
ids_set = set(body.item_ids)
|
||||
for item in order.items:
|
||||
if item.id in ids_set:
|
||||
item.kds_status = "served"
|
||||
updated.append(item.id)
|
||||
|
||||
_sync_order_kds_status(order)
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_ids": updated,
|
||||
"kds_status": "served",
|
||||
})
|
||||
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/items/mark-all-served")
|
||||
def mark_all_items_served(
|
||||
order_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark all non-pending items in an order as served."""
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
updated = []
|
||||
for item in order.items:
|
||||
if item.kds_status in ("preparing", "done"):
|
||||
item.kds_status = "served"
|
||||
updated.append(item.id)
|
||||
|
||||
_sync_order_kds_status(order)
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_ids": updated,
|
||||
"kds_status": "served",
|
||||
})
|
||||
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
# ─────────────────────────── KDS print endpoint ─────────────────────────────
|
||||
|
||||
class KdsPrintBody(BaseModel):
|
||||
printer_id: Optional[int] = None
|
||||
item_ids: Optional[List[int]] = None
|
||||
copies: int = 1
|
||||
zone_id: Optional[int] = None
|
||||
mode: str = "primary" # none | primary | all
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/print")
|
||||
def kds_print_order(
|
||||
order_id: int,
|
||||
body: KdsPrintBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Print order to zone printers. mode=primary prints to first printer, mode=all prints to all."""
|
||||
from services import printer_service
|
||||
if body.mode == "none":
|
||||
return {"printed": 0}
|
||||
if body.printer_id is not None:
|
||||
# Direct printer specified
|
||||
result = printer_service.print_to_printer(order_id, body.item_ids, body.printer_id, body.copies, db)
|
||||
return result
|
||||
# Zone-based print
|
||||
if body.zone_id is None:
|
||||
return {"printed": 0, "message": "No zone configured"}
|
||||
from models.prep_zone import PrepZone
|
||||
zone = db.query(PrepZone).filter(PrepZone.id == body.zone_id).first()
|
||||
if not zone or not zone.printers:
|
||||
return {"printed": 0, "message": "Zone has no printers"}
|
||||
printers_to_use = zone.printers if body.mode == "all" else [zone.printers[0]]
|
||||
printed = 0
|
||||
for printer in printers_to_use:
|
||||
try:
|
||||
printer_service.print_to_printer(order_id, body.item_ids, printer.id, body.copies, db)
|
||||
printed += 1
|
||||
except Exception:
|
||||
pass
|
||||
return {"printed": printed}
|
||||
|
||||
|
||||
# ─────────────────────────── KDS decline endpoints ─────────────────────────
|
||||
|
||||
class DeclineBody(BaseModel):
|
||||
decline_note: Optional[str] = None # reason string (preset or free text)
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/decline")
|
||||
def decline_order(
|
||||
order_id: int,
|
||||
body: DeclineBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark all active items on an order as declined."""
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
for item in order.items:
|
||||
if item.status != "cancelled":
|
||||
item.kds_status = "declined"
|
||||
item.decline_note = body.decline_note
|
||||
|
||||
order.kds_status = "declined"
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_order_updated", {
|
||||
"order_id": order_id,
|
||||
"kds_status": "declined",
|
||||
"decline_note": body.decline_note,
|
||||
})
|
||||
return {"order_id": order_id, "kds_status": "declined"}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/items/{item_id}/decline")
|
||||
def decline_item(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: DeclineBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark a single item as declined."""
|
||||
item = db.query(OrderItem).filter(
|
||||
OrderItem.id == item_id,
|
||||
OrderItem.order_id == order_id,
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
item.kds_status = "declined"
|
||||
item.decline_note = body.decline_note
|
||||
|
||||
if item.order:
|
||||
_sync_order_kds_status(item.order)
|
||||
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"kds_status": "declined",
|
||||
"decline_note": body.decline_note,
|
||||
})
|
||||
return {"item_id": item_id, "kds_status": "declined"}
|
||||
|
||||
|
||||
# ─────────────────────────── KDS notifications ──────────────────────────────
|
||||
|
||||
def _waiter_ids_for_order(order: Order) -> list[int]:
|
||||
"""Return the list of waiter IDs assigned to an order."""
|
||||
return [ow.waiter_id for ow in order.waiters if ow.waiter_id]
|
||||
|
||||
|
||||
def _save_and_broadcast_message(
|
||||
db: Session,
|
||||
sender_id: int,
|
||||
body: str,
|
||||
target_waiter_ids: list[int],
|
||||
message_type: str,
|
||||
kds_zone: str | None,
|
||||
table_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Persist a StaffMessage and broadcast via SSE. Returns the SSE payload dict."""
|
||||
from models.message import StaffMessage
|
||||
from datetime import datetime, timezone
|
||||
|
||||
msg = StaffMessage(
|
||||
sender_id=sender_id,
|
||||
body=body,
|
||||
target_waiter_ids=json.dumps(target_waiter_ids),
|
||||
table_ids=json.dumps(table_ids or []),
|
||||
message_type=message_type,
|
||||
kds_zone=kds_zone,
|
||||
)
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
db.refresh(msg)
|
||||
|
||||
payload = {
|
||||
"id": msg.id,
|
||||
"sender_id": msg.sender_id,
|
||||
"sender_name": kds_zone or "KDS",
|
||||
"body": msg.body,
|
||||
"table_ids": msg.table_ids,
|
||||
"message_type": message_type,
|
||||
"kds_zone": kds_zone,
|
||||
"created_at": msg.created_at.isoformat() if msg.created_at else None,
|
||||
}
|
||||
user_ids = target_waiter_ids if target_waiter_ids else None
|
||||
broadcast_sync("message_sent", payload, user_ids=user_ids)
|
||||
return payload
|
||||
|
||||
|
||||
class KdsNotifyBody(BaseModel):
|
||||
kds_zone: Optional[str] = None
|
||||
|
||||
|
||||
class KdsCallWaiterBody(BaseModel):
|
||||
kds_zone: Optional[str] = None
|
||||
waiter_ids: List[int] # for call_general: list of on-shift waiter ids to notify
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/notify-complete")
|
||||
def kds_notify_order_complete(
|
||||
order_id: int,
|
||||
body: KdsNotifyBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Notify assigned waiters that their order is fully complete."""
|
||||
order = db.query(Order).options(
|
||||
joinedload(Order.waiters),
|
||||
).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
waiter_ids = _waiter_ids_for_order(order)
|
||||
if not waiter_ids:
|
||||
return {"sent": False, "reason": "no_waiters"}
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
|
||||
msg_body = (
|
||||
f"Η παραγγελία #{order.id} για το τραπέζι {table_name} "
|
||||
f"είναι έτοιμη προς παραλαβή - {zone}"
|
||||
)
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, waiter_ids,
|
||||
"kds_order_done", zone,
|
||||
table_ids=[order.table_id] if order.table_id else [],
|
||||
)
|
||||
return {"sent": True, "waiter_ids": waiter_ids}
|
||||
|
||||
|
||||
class KdsNotifyItemsBody(BaseModel):
|
||||
kds_zone: Optional[str] = None
|
||||
ready_count: int
|
||||
total_count: int
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/notify-items-ready")
|
||||
def kds_notify_items_ready(
|
||||
order_id: int,
|
||||
body: KdsNotifyItemsBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Notify assigned waiters that some (but not all) items are ready."""
|
||||
order = db.query(Order).options(
|
||||
joinedload(Order.waiters),
|
||||
).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
waiter_ids = _waiter_ids_for_order(order)
|
||||
if not waiter_ids:
|
||||
return {"sent": False, "reason": "no_waiters"}
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
|
||||
msg_body = (
|
||||
f"{body.ready_count} αντικείμενα από την παραγγελία #{order.id} "
|
||||
f"για το τραπέζι {table_name} είναι έτοιμα - {zone}"
|
||||
)
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, waiter_ids,
|
||||
"kds_item_done", zone,
|
||||
table_ids=[order.table_id] if order.table_id else [],
|
||||
)
|
||||
return {"sent": True, "waiter_ids": waiter_ids}
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/call-waiter")
|
||||
def kds_call_waiter_order(
|
||||
order_id: int,
|
||||
body: KdsNotifyBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Manually call the waiters assigned to a specific order."""
|
||||
order = db.query(Order).options(
|
||||
joinedload(Order.waiters),
|
||||
).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
waiter_ids = _waiter_ids_for_order(order)
|
||||
if not waiter_ids:
|
||||
return {"sent": False, "reason": "no_waiters"}
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
|
||||
msg_body = (
|
||||
f"Παραγγελία #{order.id} - τραπέζι {table_name} "
|
||||
f"- {zone}"
|
||||
)
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, waiter_ids,
|
||||
"kds_call_order", zone,
|
||||
table_ids=[order.table_id] if order.table_id else [],
|
||||
)
|
||||
return {"sent": True, "waiter_ids": waiter_ids}
|
||||
|
||||
|
||||
@router.post("/call-waiter-general")
|
||||
def kds_call_waiter_general(
|
||||
body: KdsCallWaiterBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Call one or more on-shift waiters to the prep zone."""
|
||||
if not body.waiter_ids:
|
||||
raise HTTPException(status_code=400, detail="waiter_ids must not be empty")
|
||||
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
msg_body = f"Κλήση από {zone}"
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, body.waiter_ids,
|
||||
"kds_call_general", zone,
|
||||
)
|
||||
return {"sent": True, "waiter_ids": body.waiter_ids}
|
||||
|
||||
|
||||
@router.get("/on-shift-waiters")
|
||||
def kds_on_shift_waiters(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return waiters currently on shift (ended_at IS NULL)."""
|
||||
on_shift_ids = {
|
||||
row.waiter_id
|
||||
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
|
||||
}
|
||||
waiters = db.query(User).filter(
|
||||
User.id.in_(on_shift_ids),
|
||||
User.perm_access_waiter_app == True,
|
||||
User.is_active == True,
|
||||
).order_by(User.username).all()
|
||||
return [
|
||||
{"id": w.id, "username": w.username, "nickname": w.nickname, "avatar_url": w.avatar_url}
|
||||
for w in waiters
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────── legacy endpoints ──────────────────────────────
|
||||
|
||||
class ItemStatusUpdate(BaseModel):
|
||||
status: str # only "ready" is accepted
|
||||
@@ -35,15 +742,13 @@ def kds_items(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all active order items grouped by zone for the KDS display."""
|
||||
# Only look at open / partially_paid orders
|
||||
"""Legacy: return active order items grouped by printer zone."""
|
||||
open_orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid"])).all()
|
||||
order_ids = [o.id for o in open_orders]
|
||||
if not order_ids:
|
||||
return {"zones": []}
|
||||
|
||||
order_map = {o.id: o for o in open_orders}
|
||||
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.order_id.in_(order_ids),
|
||||
OrderItem.status == "active",
|
||||
@@ -52,13 +757,10 @@ def kds_items(
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
printers_map = {p.id: p.name for p in db.query(Printer).all()}
|
||||
|
||||
# Zone = printer_zone_id (None = no zone)
|
||||
zones: dict = {}
|
||||
|
||||
def _zone_key(zone_id):
|
||||
if zone_id is None:
|
||||
return 0
|
||||
return zone_id
|
||||
return 0 if zone_id is None else zone_id
|
||||
|
||||
def _zone_name(zone_id):
|
||||
if zone_id is None:
|
||||
@@ -69,17 +771,10 @@ def kds_items(
|
||||
product = item.product
|
||||
zone_id = product.printer_zone_id if product else None
|
||||
zkey = _zone_key(zone_id)
|
||||
|
||||
if zkey not in zones:
|
||||
zones[zkey] = {
|
||||
"zone_id": zone_id,
|
||||
"zone_name": _zone_name(zone_id),
|
||||
"items": [],
|
||||
}
|
||||
|
||||
zones[zkey] = {"zone_id": zone_id, "zone_name": _zone_name(zone_id), "items": []}
|
||||
order = order_map.get(item.order_id)
|
||||
table_name = tables_map.get(order.table_id) if order and order.table_id else None
|
||||
|
||||
zones[zkey]["items"].append({
|
||||
"id": item.id,
|
||||
"order_id": item.order_id,
|
||||
@@ -91,19 +786,19 @@ def kds_items(
|
||||
"status": item.status,
|
||||
})
|
||||
|
||||
# Sort zones: named zones first (by zone_id), then no-zone last
|
||||
zone_list = sorted(zones.values(), key=lambda z: (z["zone_id"] is None, z["zone_id"] or 0))
|
||||
return {"zones": zone_list}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/items/{item_id}/status")
|
||||
def update_item_status(
|
||||
def update_item_status_legacy(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: ItemStatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Legacy: mark an item ready (active → ready)."""
|
||||
if body.status != "ready":
|
||||
raise HTTPException(status_code=400, detail="Only 'ready' is a valid status transition via this endpoint")
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ def _message_out(msg: StaffMessage) -> StaffMessageOut:
|
||||
body=msg.body,
|
||||
target_waiter_ids=msg.target_waiter_ids,
|
||||
table_ids=msg.table_ids,
|
||||
message_type=msg.message_type or "manager",
|
||||
kds_zone=msg.kds_zone,
|
||||
created_at=msg.created_at,
|
||||
acked_by=[ack.waiter_id for ack in msg.acks],
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
64
local_backend/routers/phone.py
Normal file
64
local_backend/routers/phone.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Phone call event receiver + WebSocket broadcaster.
|
||||
|
||||
The Grandstream UCM sends a simple HTTP GET to /api/phone/call-event
|
||||
with caller ID and extension details. We broadcast that to all connected
|
||||
WebSocket clients (manager dashboard Phone page).
|
||||
|
||||
The call-event endpoint is intentionally unauthenticated so the UCM device
|
||||
(which has no JWT) can POST to it directly on the local LAN.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In-memory list of active WebSocket connections to the Phone page.
|
||||
_connections: list[WebSocket] = []
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def phone_ws(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
_connections.append(websocket)
|
||||
logger.debug("phone_ws: client connected (%d total)", len(_connections))
|
||||
try:
|
||||
# Keep the connection alive; client drives nothing, server pushes events.
|
||||
while True:
|
||||
await asyncio.sleep(25)
|
||||
await websocket.send_text(json.dumps({"type": "ping"}))
|
||||
except (WebSocketDisconnect, Exception):
|
||||
pass
|
||||
finally:
|
||||
_connections.remove(websocket)
|
||||
logger.debug("phone_ws: client disconnected (%d total)", len(_connections))
|
||||
|
||||
|
||||
@router.get("/call-event")
|
||||
async def call_event(
|
||||
caller: str = Query(..., description="Caller ID number from UCM"),
|
||||
ext: str = Query("", description="Dialled extension"),
|
||||
):
|
||||
"""
|
||||
Called by the Grandstream UCM Action URL on every incoming call.
|
||||
Example: GET /api/phone/call-event?caller=2106001234&ext=100
|
||||
"""
|
||||
payload = json.dumps({"type": "incoming_call", "caller": caller, "ext": ext})
|
||||
dead = []
|
||||
for ws in list(_connections):
|
||||
try:
|
||||
await ws.send_text(payload)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
try:
|
||||
_connections.remove(ws)
|
||||
except ValueError:
|
||||
pass
|
||||
logger.info("phone call-event: caller=%s ext=%s (notified %d clients)", caller, ext, len(_connections) - len(dead))
|
||||
return {"status": "ok", "notified": len(_connections) - len(dead)}
|
||||
215
local_backend/routers/prep_zones.py
Normal file
215
local_backend/routers/prep_zones.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Prep Zones router.
|
||||
|
||||
GET /api/prep-zones list all prep zones (with printer ids)
|
||||
POST /api/prep-zones create a prep zone
|
||||
PUT /api/prep-zones/{id} update name / description / printer assignments / all settings
|
||||
DELETE /api/prep-zones/{id} delete a prep zone
|
||||
"""
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.prep_zone import PrepZone
|
||||
from models.printer import Printer
|
||||
from routers.deps import require_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PrepZoneCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
notification_name: Optional[str] = None
|
||||
|
||||
# Printer routing
|
||||
printer_ids: List[int] = [] # all printers (master + secondary)
|
||||
master_printer_id: Optional[int] = None
|
||||
auto_print: str = 'none' # 'none' | 'master' | 'all'
|
||||
master_copies: int = 1
|
||||
secondary_copies: int = 1
|
||||
|
||||
# Ticket formatting
|
||||
sort_items_by: str = 'order_time' # 'order_time' | 'item_count' | 'alpha'
|
||||
group_by_category: bool = False
|
||||
category_order: List[int] = []
|
||||
print_checkboxes: bool = False
|
||||
|
||||
# KDS bypass / auto-progression
|
||||
bypass_pending: bool = False
|
||||
bypass_kds: bool = False
|
||||
auto_ready_to_served: bool = False
|
||||
|
||||
|
||||
class PrepZoneUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
notification_name: Optional[str] = None
|
||||
|
||||
# Printer routing
|
||||
printer_ids: Optional[List[int]] = None
|
||||
master_printer_id: Optional[int] = None
|
||||
auto_print: Optional[str] = None
|
||||
master_copies: Optional[int] = None
|
||||
secondary_copies: Optional[int] = None
|
||||
|
||||
# Ticket formatting
|
||||
sort_items_by: Optional[str] = None
|
||||
group_by_category: Optional[bool] = None
|
||||
category_order: Optional[List[int]] = None
|
||||
print_checkboxes: Optional[bool] = None
|
||||
|
||||
# KDS bypass / auto-progression
|
||||
bypass_pending: Optional[bool] = None
|
||||
bypass_kds: Optional[bool] = None
|
||||
auto_ready_to_served: Optional[bool] = None
|
||||
|
||||
|
||||
def _zone_out(zone: PrepZone) -> dict:
|
||||
all_printer_ids = [p.id for p in zone.printers]
|
||||
secondary_ids = [pid for pid in all_printer_ids if pid != zone.master_printer_id]
|
||||
return {
|
||||
"id": zone.id,
|
||||
"name": zone.name,
|
||||
"description": zone.description,
|
||||
"notification_name": zone.notification_name,
|
||||
|
||||
# Printer routing
|
||||
"printer_ids": all_printer_ids,
|
||||
"printers": [{"id": p.id, "name": p.name} for p in zone.printers],
|
||||
"master_printer_id": zone.master_printer_id,
|
||||
"secondary_printer_ids": secondary_ids,
|
||||
"auto_print": zone.auto_print or 'none',
|
||||
"master_copies": zone.master_copies or 1,
|
||||
"secondary_copies": zone.secondary_copies or 1,
|
||||
|
||||
# Ticket formatting
|
||||
"sort_items_by": zone.sort_items_by or 'order_time',
|
||||
"group_by_category": bool(zone.group_by_category),
|
||||
"category_order": json.loads(zone.category_order) if zone.category_order else [],
|
||||
"print_checkboxes": bool(zone.print_checkboxes),
|
||||
|
||||
# KDS bypass / auto-progression
|
||||
"bypass_pending": bool(zone.bypass_pending),
|
||||
"bypass_kds": bool(zone.bypass_kds),
|
||||
"auto_ready_to_served": bool(zone.auto_ready_to_served),
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_prep_zones(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
zones = db.query(PrepZone).order_by(PrepZone.id).all()
|
||||
return [_zone_out(z) for z in zones]
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_prep_zone(
|
||||
body: PrepZoneCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
zone = PrepZone(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
notification_name=body.notification_name,
|
||||
master_printer_id=body.master_printer_id,
|
||||
auto_print=body.auto_print,
|
||||
master_copies=max(1, body.master_copies),
|
||||
secondary_copies=max(1, body.secondary_copies),
|
||||
print_copies=max(1, body.master_copies), # keep legacy field in sync
|
||||
sort_items_by=body.sort_items_by,
|
||||
group_by_category=1 if body.group_by_category else 0,
|
||||
category_order=json.dumps(body.category_order),
|
||||
print_checkboxes=1 if body.print_checkboxes else 0,
|
||||
bypass_pending=1 if (body.bypass_pending or body.bypass_kds) else 0,
|
||||
bypass_kds=1 if body.bypass_kds else 0,
|
||||
auto_ready_to_served=1 if body.auto_ready_to_served else 0,
|
||||
)
|
||||
if body.printer_ids:
|
||||
printers = db.query(Printer).filter(Printer.id.in_(body.printer_ids)).all()
|
||||
zone.printers = printers
|
||||
db.add(zone)
|
||||
db.commit()
|
||||
db.refresh(zone)
|
||||
return _zone_out(zone)
|
||||
|
||||
|
||||
@router.put("/{zone_id}")
|
||||
def update_prep_zone(
|
||||
zone_id: int,
|
||||
body: PrepZoneUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
zone = db.query(PrepZone).filter(PrepZone.id == zone_id).first()
|
||||
if not zone:
|
||||
raise HTTPException(status_code=404, detail="Prep zone not found")
|
||||
|
||||
if body.name is not None:
|
||||
zone.name = body.name
|
||||
if body.description is not None:
|
||||
zone.description = body.description
|
||||
if body.notification_name is not None:
|
||||
zone.notification_name = body.notification_name
|
||||
|
||||
# Printer routing
|
||||
if body.printer_ids is not None:
|
||||
printers = db.query(Printer).filter(Printer.id.in_(body.printer_ids)).all()
|
||||
zone.printers = printers
|
||||
# master_printer_id: always write if field present in request body (null = clear)
|
||||
if 'master_printer_id' in (body.model_fields_set if hasattr(body, 'model_fields_set') else {}):
|
||||
zone.master_printer_id = body.master_printer_id
|
||||
elif body.master_printer_id is not None:
|
||||
zone.master_printer_id = body.master_printer_id
|
||||
if body.auto_print is not None:
|
||||
zone.auto_print = body.auto_print
|
||||
if body.master_copies is not None:
|
||||
zone.master_copies = max(1, body.master_copies)
|
||||
zone.print_copies = zone.master_copies # keep legacy in sync
|
||||
if body.secondary_copies is not None:
|
||||
zone.secondary_copies = max(1, body.secondary_copies)
|
||||
|
||||
# Ticket formatting
|
||||
if body.sort_items_by is not None:
|
||||
zone.sort_items_by = body.sort_items_by
|
||||
if body.group_by_category is not None:
|
||||
zone.group_by_category = 1 if body.group_by_category else 0
|
||||
if body.category_order is not None:
|
||||
zone.category_order = json.dumps(body.category_order)
|
||||
if body.print_checkboxes is not None:
|
||||
zone.print_checkboxes = 1 if body.print_checkboxes else 0
|
||||
|
||||
# KDS bypass — bypass_kds implies bypass_pending
|
||||
if body.bypass_kds is not None:
|
||||
zone.bypass_kds = 1 if body.bypass_kds else 0
|
||||
if body.bypass_kds:
|
||||
zone.bypass_pending = 1
|
||||
if body.bypass_pending is not None:
|
||||
zone.bypass_pending = 1 if body.bypass_pending else 0
|
||||
if body.auto_ready_to_served is not None:
|
||||
zone.auto_ready_to_served = 1 if body.auto_ready_to_served else 0
|
||||
|
||||
db.commit()
|
||||
db.refresh(zone)
|
||||
return _zone_out(zone)
|
||||
|
||||
|
||||
@router.delete("/{zone_id}")
|
||||
def delete_prep_zone(
|
||||
zone_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
zone = db.query(PrepZone).filter(PrepZone.id == zone_id).first()
|
||||
if not zone:
|
||||
raise HTTPException(status_code=404, detail="Prep zone not found")
|
||||
db.delete(zone)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
684
local_backend/routers/pricing.py
Normal file
684
local_backend/routers/pricing.py
Normal file
@@ -0,0 +1,684 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.pricing import (
|
||||
PriceGroup, PriceModifier, PriceModifierCondition, PriceModifierTarget,
|
||||
Deal, DealCondition, DealTarget,
|
||||
PriceEventLog, WaiterDiscountSettings,
|
||||
)
|
||||
from schemas.pricing import (
|
||||
PriceGroupCreate, PriceGroupUpdate, PriceGroupOut,
|
||||
PriceModifierCreate, PriceModifierUpdate, PriceModifierOut,
|
||||
PriceModifierReorderRequest,
|
||||
DealCreate, DealUpdate, DealOut,
|
||||
WaiterDiscountSettingsIn, WaiterDiscountSettingsOut,
|
||||
PriceEventOut,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRICE GROUPS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/groups", response_model=List[PriceGroupOut])
|
||||
def list_price_groups(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(PriceGroup).order_by(PriceGroup.name).all()
|
||||
|
||||
|
||||
@router.post("/groups", response_model=PriceGroupOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_price_group(
|
||||
body: PriceGroupCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
pg = PriceGroup(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
color=body.color,
|
||||
is_active=int(body.is_active),
|
||||
auto_enable_time=body.auto_enable_time,
|
||||
auto_disable_time=body.auto_disable_time,
|
||||
auto_days=json.dumps(body.auto_days) if body.auto_days is not None else None,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(pg)
|
||||
db.commit()
|
||||
db.refresh(pg)
|
||||
return pg
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}", response_model=PriceGroupOut)
|
||||
def update_price_group(
|
||||
group_id: int,
|
||||
body: PriceGroupUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
|
||||
if not pg:
|
||||
raise HTTPException(status_code=404, detail="Price group not found")
|
||||
pg.name = body.name
|
||||
pg.description = body.description
|
||||
pg.color = body.color
|
||||
pg.is_active = int(body.is_active)
|
||||
pg.auto_enable_time = body.auto_enable_time
|
||||
pg.auto_disable_time = body.auto_disable_time
|
||||
pg.auto_days = json.dumps(body.auto_days) if body.auto_days is not None else None
|
||||
db.commit()
|
||||
db.refresh(pg)
|
||||
return pg
|
||||
|
||||
|
||||
@router.patch("/groups/{group_id}/toggle", response_model=PriceGroupOut)
|
||||
def toggle_price_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
|
||||
if not pg:
|
||||
raise HTTPException(status_code=404, detail="Price group not found")
|
||||
pg.is_active = 0 if pg.is_active else 1
|
||||
db.commit()
|
||||
db.refresh(pg)
|
||||
return pg
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_price_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
|
||||
if not pg:
|
||||
raise HTTPException(status_code=404, detail="Price group not found")
|
||||
db.delete(pg)
|
||||
db.commit()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRICE MODIFIERS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/modifiers", response_model=List[PriceModifierOut])
|
||||
def list_modifiers(
|
||||
scope: Optional[str] = None,
|
||||
item_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
q = db.query(PriceModifier)
|
||||
if scope:
|
||||
q = q.filter(PriceModifier.scope == scope)
|
||||
if item_id is not None:
|
||||
q = q.filter(PriceModifier.item_id == item_id)
|
||||
return q.order_by(PriceModifier.sort_order).all()
|
||||
|
||||
|
||||
@router.get("/modifiers/favorites", response_model=List[PriceModifierOut])
|
||||
def list_favorite_modifiers(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Returns modifiers flagged as favorites for the dashboard quick-toggle panel."""
|
||||
return (
|
||||
db.query(PriceModifier)
|
||||
.filter(PriceModifier.is_favorite == 1)
|
||||
.order_by(PriceModifier.sort_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/modifiers", response_model=PriceModifierOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_modifier(
|
||||
body: PriceModifierCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
m = PriceModifier(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
color=body.color,
|
||||
is_active=int(body.is_active),
|
||||
is_favorite=int(body.is_favorite),
|
||||
allow_stack=int(body.allow_stack),
|
||||
sort_order=body.sort_order,
|
||||
scope=body.scope,
|
||||
item_id=body.item_id,
|
||||
action_type=body.action_type,
|
||||
action_value=body.action_value,
|
||||
round_to=body.round_to,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(m)
|
||||
db.flush()
|
||||
_sync_conditions(m, body.conditions, db, model="modifier")
|
||||
_sync_modifier_targets(m, body.targets, db)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
@router.put("/modifiers/{modifier_id}", response_model=PriceModifierOut)
|
||||
def update_modifier(
|
||||
modifier_id: int,
|
||||
body: PriceModifierUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="Modifier not found")
|
||||
m.name = body.name
|
||||
m.description = body.description
|
||||
m.color = body.color
|
||||
m.is_active = int(body.is_active)
|
||||
m.is_favorite = int(body.is_favorite)
|
||||
m.allow_stack = int(body.allow_stack)
|
||||
m.sort_order = body.sort_order
|
||||
m.scope = body.scope
|
||||
m.item_id = body.item_id
|
||||
m.action_type = body.action_type
|
||||
m.action_value = body.action_value
|
||||
m.round_to = body.round_to
|
||||
m.updated_at = _utcnow()
|
||||
m.updated_by = user.id
|
||||
# Replace conditions and targets
|
||||
for c in list(m.conditions):
|
||||
db.delete(c)
|
||||
for t in list(m.targets):
|
||||
db.delete(t)
|
||||
db.flush()
|
||||
_sync_conditions(m, body.conditions, db, model="modifier")
|
||||
_sync_modifier_targets(m, body.targets, db)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
@router.patch("/modifiers/{modifier_id}/toggle", response_model=PriceModifierOut)
|
||||
def toggle_modifier(
|
||||
modifier_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="Modifier not found")
|
||||
m.is_active = 0 if m.is_active else 1
|
||||
m.updated_at = _utcnow()
|
||||
m.updated_by = user.id
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
@router.patch("/modifiers/reorder")
|
||||
def reorder_modifiers(
|
||||
body: PriceModifierReorderRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
for item in body.items:
|
||||
db.query(PriceModifier).filter(PriceModifier.id == item.id).update(
|
||||
{"sort_order": item.sort_order}
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/modifiers/{modifier_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_modifier(
|
||||
modifier_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="Modifier not found")
|
||||
db.delete(m)
|
||||
db.commit()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEALS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/deals", response_model=List[DealOut])
|
||||
def list_deals(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(Deal).order_by(Deal.sort_order).all()
|
||||
|
||||
|
||||
@router.post("/deals", response_model=DealOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_deal(
|
||||
body: DealCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
d = Deal(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
color=body.color,
|
||||
is_active=int(body.is_active),
|
||||
sort_order=body.sort_order,
|
||||
action_type=body.action_type,
|
||||
action_modifier_id=body.action_modifier_id,
|
||||
action_value=body.action_value,
|
||||
action_free_item_id=body.action_free_item_id,
|
||||
action_free_target_type=body.action_free_target_type,
|
||||
action_free_target_ids=(
|
||||
json.dumps(body.action_free_target_ids)
|
||||
if body.action_free_target_ids is not None else None
|
||||
),
|
||||
action_free_quantity=body.action_free_quantity,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(d)
|
||||
db.flush()
|
||||
_sync_conditions(d, body.conditions, db, model="deal")
|
||||
_sync_deal_targets(d, body.targets, db)
|
||||
db.commit()
|
||||
db.refresh(d)
|
||||
return d
|
||||
|
||||
|
||||
@router.put("/deals/{deal_id}", response_model=DealOut)
|
||||
def update_deal(
|
||||
deal_id: int,
|
||||
body: DealUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
d = db.query(Deal).filter(Deal.id == deal_id).first()
|
||||
if not d:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
d.name = body.name
|
||||
d.description = body.description
|
||||
d.color = body.color
|
||||
d.is_active = int(body.is_active)
|
||||
d.sort_order = body.sort_order
|
||||
d.action_type = body.action_type
|
||||
d.action_modifier_id = body.action_modifier_id
|
||||
d.action_value = body.action_value
|
||||
d.action_free_item_id = body.action_free_item_id
|
||||
d.action_free_target_type = body.action_free_target_type
|
||||
d.action_free_target_ids = (
|
||||
json.dumps(body.action_free_target_ids)
|
||||
if body.action_free_target_ids is not None else None
|
||||
)
|
||||
d.action_free_quantity = body.action_free_quantity
|
||||
for c in list(d.conditions):
|
||||
db.delete(c)
|
||||
for t in list(d.targets):
|
||||
db.delete(t)
|
||||
db.flush()
|
||||
_sync_conditions(d, body.conditions, db, model="deal")
|
||||
_sync_deal_targets(d, body.targets, db)
|
||||
db.commit()
|
||||
db.refresh(d)
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/deals/{deal_id}/toggle", response_model=DealOut)
|
||||
def toggle_deal(
|
||||
deal_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
d = db.query(Deal).filter(Deal.id == deal_id).first()
|
||||
if not d:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
d.is_active = 0 if d.is_active else 1
|
||||
db.commit()
|
||||
db.refresh(d)
|
||||
return d
|
||||
|
||||
|
||||
@router.delete("/deals/{deal_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_deal(
|
||||
deal_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
d = db.query(Deal).filter(Deal.id == deal_id).first()
|
||||
if not d:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
db.delete(d)
|
||||
db.commit()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WAITER DISCOUNT SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/discount-settings/global")
|
||||
def get_global_discount_settings(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
from models.settings import PosSettings
|
||||
keys = [
|
||||
"discounts.enabled",
|
||||
"discounts.max_total_value_workday",
|
||||
"discounts.max_total_value_shift",
|
||||
"discounts.max_items_per_shift",
|
||||
]
|
||||
rows = db.query(PosSettings).filter(PosSettings.key.in_(keys)).all()
|
||||
row_map = {r.key.split(".", 1)[1]: r.value for r in rows}
|
||||
# Coerce types so frontend toggle/number fields work correctly
|
||||
def _coerce(k, v):
|
||||
if v is None:
|
||||
return None
|
||||
if k == "enabled":
|
||||
return v.lower() in ("true", "1", "yes")
|
||||
try:
|
||||
return float(v) if "." in str(v) else int(v)
|
||||
except Exception:
|
||||
return v
|
||||
return {k: _coerce(k, v) for k, v in row_map.items()}
|
||||
|
||||
|
||||
@router.put("/discount-settings/global")
|
||||
def set_global_discount_settings(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
from models.settings import PosSettings
|
||||
allowed = {
|
||||
"enabled", "max_total_value_workday",
|
||||
"max_total_value_shift", "max_items_per_shift",
|
||||
}
|
||||
now = _utcnow().isoformat()
|
||||
for k, v in body.items():
|
||||
if k not in allowed:
|
||||
continue
|
||||
full_key = f"discounts.{k}"
|
||||
row = db.query(PosSettings).filter(PosSettings.key == full_key).first()
|
||||
if row:
|
||||
row.value = str(v)
|
||||
row.updated_at = now
|
||||
else:
|
||||
db.add(PosSettings(key=full_key, value=str(v), updated_at=now))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/discount-settings/me", response_model=WaiterDiscountSettingsOut)
|
||||
def get_my_discount_settings(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
s = db.query(WaiterDiscountSettings).filter(
|
||||
WaiterDiscountSettings.user_id == user.id
|
||||
).first()
|
||||
if not s:
|
||||
return WaiterDiscountSettingsOut(id=0, user_id=user.id, can_apply_discounts=False)
|
||||
return s
|
||||
|
||||
|
||||
@router.get("/discount-settings/{user_id}", response_model=WaiterDiscountSettingsOut)
|
||||
def get_waiter_discount_settings(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
s = db.query(WaiterDiscountSettings).filter(
|
||||
WaiterDiscountSettings.user_id == user_id
|
||||
).first()
|
||||
if not s:
|
||||
# Return defaults (all None = no limits, disabled)
|
||||
return WaiterDiscountSettingsOut(id=0, user_id=user_id, can_apply_discounts=False)
|
||||
return s
|
||||
|
||||
|
||||
@router.put("/discount-settings/{user_id}", response_model=WaiterDiscountSettingsOut)
|
||||
def set_waiter_discount_settings(
|
||||
user_id: int,
|
||||
body: WaiterDiscountSettingsIn,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
s = db.query(WaiterDiscountSettings).filter(
|
||||
WaiterDiscountSettings.user_id == user_id
|
||||
).first()
|
||||
if not s:
|
||||
s = WaiterDiscountSettings(user_id=user_id)
|
||||
db.add(s)
|
||||
s.can_apply_discounts = int(body.can_apply_discounts)
|
||||
s.max_discount_percent = body.max_discount_percent
|
||||
s.max_discount_amount = body.max_discount_amount
|
||||
s.max_total_value_shift = body.max_total_value_shift
|
||||
s.max_total_value_workday = body.max_total_value_workday
|
||||
s.max_items_per_shift = body.max_items_per_shift
|
||||
s.max_items_per_workday = body.max_items_per_workday
|
||||
s.max_items_per_order = body.max_items_per_order
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return s
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRICE EVENT LOG (read-only, for order detail and reports)
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/events/order/{order_id}", response_model=List[PriceEventOut])
|
||||
def get_order_price_events(
|
||||
order_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
events = (
|
||||
db.query(PriceEventLog)
|
||||
.filter(PriceEventLog.order_id == order_id)
|
||||
.order_by(PriceEventLog.applied_at)
|
||||
.all()
|
||||
)
|
||||
result = []
|
||||
for ev in events:
|
||||
out = PriceEventOut.model_validate(ev)
|
||||
if ev.modifier:
|
||||
out.modifier_name = ev.modifier.name
|
||||
if ev.deal:
|
||||
out.deal_name = ev.deal.name
|
||||
if ev.applied_by:
|
||||
out.applied_by_username = ev.applied_by.username
|
||||
result.append(out)
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEAL OFFER ACCEPT / DISMISS (PWA-facing)
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/deals/accept")
|
||||
def accept_deal_offer(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Waiter confirms a deal offer. Logs the acceptance and directly adds free item(s)
|
||||
to the order with deal_id + linked_item_id set for binding.
|
||||
Returns added item IDs so the PWA can refresh.
|
||||
"""
|
||||
from models.pricing import PriceEventLog, Deal
|
||||
from models.order import Order, OrderItem
|
||||
from models.product import Product
|
||||
|
||||
deal_id = body.get("deal_id")
|
||||
order_id = body.get("order_id")
|
||||
# For free_choice: list of product_ids the waiter selected + their option snapshots
|
||||
# Shape: [{ product_id, quantity, selected_options, notes }] or legacy [int, ...]
|
||||
free_items_raw = body.get("free_items", [])
|
||||
# Trigger item — the order_item_id that caused the deal to fire (for linking)
|
||||
trigger_item_id = body.get("trigger_item_id", None)
|
||||
|
||||
deal = db.query(Deal).filter(Deal.id == deal_id).first()
|
||||
if not deal:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
added_item_ids = []
|
||||
|
||||
if deal.action_type in ("free_item", "free_choice"):
|
||||
# Resolve which products to add
|
||||
if deal.action_type == "free_item" and deal.action_free_item_id:
|
||||
items_to_add = [{"product_id": deal.action_free_item_id, "quantity": deal.action_free_quantity,
|
||||
"selected_options": None, "notes": None}]
|
||||
else:
|
||||
# free_choice: caller provides list of chosen products
|
||||
items_to_add = []
|
||||
for entry in free_items_raw:
|
||||
if isinstance(entry, dict):
|
||||
items_to_add.append({
|
||||
"product_id": entry.get("product_id"),
|
||||
"quantity": entry.get("quantity", deal.action_free_quantity),
|
||||
"selected_options": entry.get("selected_options"),
|
||||
"notes": entry.get("notes"),
|
||||
})
|
||||
else:
|
||||
items_to_add.append({"product_id": int(entry), "quantity": deal.action_free_quantity,
|
||||
"selected_options": None, "notes": None})
|
||||
|
||||
for entry in items_to_add:
|
||||
product = db.query(Product).filter(Product.id == entry["product_id"]).first()
|
||||
if not product:
|
||||
continue
|
||||
base_price = product.base_price or 0.0
|
||||
new_item = OrderItem(
|
||||
order_id=order_id,
|
||||
product_id=product.id,
|
||||
added_by=user.id,
|
||||
quantity=entry["quantity"],
|
||||
unit_price=base_price, # full price so the breakdown is readable
|
||||
price_adjustment=-base_price, # negated to bring effective price to 0
|
||||
selected_options=json.dumps(entry["selected_options"]) if entry["selected_options"] else None,
|
||||
notes=entry.get("notes"),
|
||||
deal_id=deal_id,
|
||||
linked_item_id=trigger_item_id,
|
||||
)
|
||||
db.add(new_item)
|
||||
db.flush()
|
||||
added_item_ids.append(new_item.id)
|
||||
|
||||
db.add(PriceEventLog(
|
||||
order_id=order_id,
|
||||
order_item_id=new_item.id,
|
||||
event_type="free_item_added",
|
||||
deal_id=deal_id,
|
||||
price_before=product.base_price or 0.0,
|
||||
price_after=0.0,
|
||||
delta_amount=-(product.base_price or 0.0),
|
||||
applied_by_user_id=user.id,
|
||||
))
|
||||
|
||||
# Log acceptance
|
||||
db.add(PriceEventLog(
|
||||
order_id=order_id,
|
||||
order_item_id=trigger_item_id,
|
||||
event_type="deal_offer_accepted",
|
||||
deal_id=deal_id,
|
||||
applied_by_user_id=user.id,
|
||||
selected_item_ids=json.dumps([e.get("product_id") if isinstance(e, dict) else int(e) for e in free_items_raw]) if free_items_raw else None,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
from services.sse_bus import broadcast_sync
|
||||
broadcast_sync()
|
||||
|
||||
return {
|
||||
"deal_id": deal_id,
|
||||
"action_type": deal.action_type,
|
||||
"added_item_ids": added_item_ids,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/deals/dismiss")
|
||||
def dismiss_deal_offer(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Waiter dismisses a deal offer. Logged so it can be re-triggered later."""
|
||||
from models.pricing import PriceEventLog
|
||||
deal_id = body.get("deal_id")
|
||||
order_id = body.get("order_id")
|
||||
|
||||
db.add(PriceEventLog(
|
||||
order_id=order_id,
|
||||
order_item_id=None,
|
||||
event_type="deal_offer_dismissed",
|
||||
deal_id=deal_id,
|
||||
applied_by_user_id=user.id,
|
||||
))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal helpers
|
||||
# =============================================================================
|
||||
|
||||
def _sync_conditions(parent, conditions_in, db: Session, model: str):
|
||||
"""Write condition rows for a modifier or deal."""
|
||||
for c in conditions_in:
|
||||
if model == "modifier":
|
||||
db.add(PriceModifierCondition(
|
||||
modifier_id=parent.id,
|
||||
condition_type=c.condition_type,
|
||||
params=json.dumps(c.params),
|
||||
))
|
||||
else:
|
||||
db.add(DealCondition(
|
||||
deal_id=parent.id,
|
||||
condition_type=c.condition_type,
|
||||
params=json.dumps(c.params),
|
||||
))
|
||||
|
||||
|
||||
def _sync_modifier_targets(modifier, targets_in, db: Session):
|
||||
for t in targets_in:
|
||||
db.add(PriceModifierTarget(
|
||||
modifier_id=modifier.id,
|
||||
target_type=t.target_type,
|
||||
target_id=t.target_id,
|
||||
target_tag=t.target_tag,
|
||||
target_ids=json.dumps(t.target_ids) if t.target_ids else None,
|
||||
target_tags=json.dumps(t.target_tags) if t.target_tags else None,
|
||||
))
|
||||
|
||||
|
||||
def _sync_deal_targets(deal, targets_in, db: Session):
|
||||
for t in targets_in:
|
||||
db.add(DealTarget(
|
||||
deal_id=deal.id,
|
||||
target_type=t.target_type,
|
||||
target_id=t.target_id,
|
||||
target_tag=t.target_tag,
|
||||
target_ids=json.dumps(t.target_ids) if t.target_ids else None,
|
||||
target_tags=json.dumps(t.target_tags) if t.target_tags else None,
|
||||
))
|
||||
@@ -2,11 +2,13 @@ import os
|
||||
import uuid
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
|
||||
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice, ProductModifierGroup
|
||||
from models.prep_zone import PrepZone
|
||||
from models.order import OrderItem
|
||||
from models.user import User
|
||||
from schemas.product import (
|
||||
@@ -14,9 +16,9 @@ from schemas.product import (
|
||||
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
||||
SubcategoryReorderItem, ParentGeneralReorderItem,
|
||||
PreferenceSetCreate, ProductQuickOptionCreate,
|
||||
CategoryReparentRequest,
|
||||
CategoryReparentRequest, ModifierGroupCreate, ModifierGroupOut,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from routers.deps import get_current_user, require_menu_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
@@ -28,6 +30,25 @@ def _broadcast_products_changed():
|
||||
IMAGE_DIR = "/app/data/product_images"
|
||||
|
||||
|
||||
def _replace_modifier_groups(db, product, groups):
|
||||
"""Recreate modifier groups and return a list of created DB objects (index-aligned)."""
|
||||
for g in product.modifier_groups:
|
||||
db.delete(g)
|
||||
db.flush()
|
||||
created = []
|
||||
for i, g in enumerate(groups):
|
||||
new_g = ProductModifierGroup(
|
||||
product_id=product.id,
|
||||
modifier_type=g.modifier_type,
|
||||
name=g.name,
|
||||
sort_order=i,
|
||||
)
|
||||
db.add(new_g)
|
||||
db.flush()
|
||||
created.append(new_g)
|
||||
return created
|
||||
|
||||
|
||||
def _replace_quick_options(db, product, quick_options):
|
||||
for qo in product.quick_options:
|
||||
db.delete(qo)
|
||||
@@ -45,43 +66,52 @@ def _replace_quick_options(db, product, quick_options):
|
||||
))
|
||||
|
||||
|
||||
def _replace_options(db, product, options):
|
||||
def _replace_options(db, product, options, group_id_map=None):
|
||||
for opt in product.options:
|
||||
db.delete(opt)
|
||||
db.flush()
|
||||
for opt in options:
|
||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||
resolved_group = group_id_map[opt.group_id] if (group_id_map and opt.group_id is not None and opt.group_id < len(group_id_map)) else None
|
||||
db.add(ProductOption(
|
||||
product_id=product.id,
|
||||
name=opt.name,
|
||||
extra_cost=opt.extra_cost,
|
||||
allow_multiple=opt.allow_multiple,
|
||||
multi_select=opt.multi_select,
|
||||
sub_choices=sub_json,
|
||||
is_favorite=opt.is_favorite,
|
||||
favorite_sort_order=opt.favorite_sort_order,
|
||||
is_compact=opt.is_compact,
|
||||
group_id=resolved_group,
|
||||
))
|
||||
|
||||
|
||||
def _replace_ingredients(db, product, ingredients):
|
||||
def _replace_ingredients(db, product, ingredients, group_id_map=None):
|
||||
for ing in product.ingredients:
|
||||
db.delete(ing)
|
||||
db.flush()
|
||||
for ing in ingredients:
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||
resolved_group = group_id_map[ing.group_id] if (group_id_map and ing.group_id is not None and ing.group_id < len(group_id_map)) else None
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group))
|
||||
|
||||
|
||||
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
||||
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate], group_id_map=None):
|
||||
for ps in product.preference_sets:
|
||||
db.delete(ps)
|
||||
db.flush()
|
||||
for ps in sets:
|
||||
shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None
|
||||
resolved_group = group_id_map[ps.group_id] if (group_id_map and ps.group_id is not None and ps.group_id < len(group_id_map)) else None
|
||||
new_set = ProductPreferenceSet(
|
||||
product_id=product.id,
|
||||
name=ps.name,
|
||||
shared_subset=shared_json,
|
||||
is_favorite=ps.is_favorite,
|
||||
favorite_sort_order=ps.favorite_sort_order,
|
||||
group_id=resolved_group,
|
||||
allow_multi_select=ps.allow_multi_select,
|
||||
allow_choice_quantity=ps.allow_choice_quantity,
|
||||
)
|
||||
db.add(new_set)
|
||||
db.flush()
|
||||
@@ -94,6 +124,7 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
||||
extra_cost=ch.extra_cost,
|
||||
sub_choices=sub_json,
|
||||
disables_subset=ch.disables_subset,
|
||||
is_compact=ch.is_compact,
|
||||
)
|
||||
db.add(choice)
|
||||
db.flush()
|
||||
@@ -104,13 +135,27 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
||||
|
||||
# ── Categories ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/tags")
|
||||
def list_all_tags(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Return sorted unique tag strings across all products."""
|
||||
rows = db.query(Product.tags).filter(Product.tags != None, Product.tags != "[]").all()
|
||||
tag_set = set()
|
||||
for (tags_json,) in rows:
|
||||
try:
|
||||
tags = json.loads(tags_json) if tags_json else []
|
||||
tag_set.update(tags)
|
||||
except Exception:
|
||||
pass
|
||||
return sorted(tag_set)
|
||||
|
||||
|
||||
@router.get("/categories", response_model=List[CategoryOut])
|
||||
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return db.query(Category).order_by(Category.sort_order).all()
|
||||
|
||||
|
||||
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
# sort_order is among siblings (same parent_id level)
|
||||
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
|
||||
cat = Category(
|
||||
@@ -128,7 +173,7 @@ def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: U
|
||||
|
||||
|
||||
@router.put("/categories/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
if cat:
|
||||
@@ -138,7 +183,7 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
|
||||
|
||||
|
||||
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
"""Reorder sub-categories within their parent (sort_order among siblings)."""
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
@@ -149,7 +194,7 @@ def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Dep
|
||||
|
||||
|
||||
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
"""Update general_sort_order on parent categories (position of the General group)."""
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
@@ -160,7 +205,7 @@ def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}/reparent", response_model=CategoryOut)
|
||||
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
"""Move a category to a new parent (or promote to top-level if parent_id is null).
|
||||
All products assigned to this category follow it automatically (no product updates needed).
|
||||
"""
|
||||
@@ -190,7 +235,7 @@ def reparent_category(category_id: int, body: CategoryReparentRequest, db: Sessi
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}", response_model=CategoryOut)
|
||||
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
@@ -203,7 +248,7 @@ def update_category(category_id: int, body: CategoryUpdate, db: Session = Depend
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
@@ -217,14 +262,31 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
|
||||
@router.get("/", response_model=List[ProductOut])
|
||||
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
q = db.query(Product)
|
||||
if not all or user.role not in ("manager", "sysadmin"):
|
||||
has_dashboard = user.role in ("superadmin", "manager") or getattr(user, "perm_access_dashboard", False)
|
||||
if not all or not has_dashboard:
|
||||
# Waiters only see active, available products
|
||||
q = q.filter(Product.is_available == True, Product.lifecycle_status == "active")
|
||||
return q.order_by(Product.sort_order, Product.id).all()
|
||||
|
||||
|
||||
class BulkPrepZoneBody(BaseModel):
|
||||
product_ids: List[int]
|
||||
prep_zone_ids: List[int]
|
||||
|
||||
|
||||
@router.post("/bulk-prep-zones", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def bulk_assign_prep_zones(body: BulkPrepZoneBody, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
"""Replace the prep zone assignments for a list of products at once."""
|
||||
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
|
||||
products = db.query(Product).filter(Product.id.in_(body.product_ids)).all()
|
||||
for p in products:
|
||||
p.prep_zones = zones
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
for item in items:
|
||||
product = db.query(Product).filter(Product.id == item.id).first()
|
||||
if product:
|
||||
@@ -234,15 +296,20 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
|
||||
|
||||
|
||||
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"})
|
||||
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags"})
|
||||
if data.get("sort_order") == 0:
|
||||
data["sort_order"] = db.query(Product).count()
|
||||
if body.cost_breakdown is not None:
|
||||
data["cost_breakdown"] = json.dumps([item.model_dump() for item in body.cost_breakdown])
|
||||
data["tags"] = json.dumps(body.tags) if body.tags is not None else None
|
||||
product = Product(**data)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
# Assign prep zones
|
||||
if body.prep_zone_ids:
|
||||
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
|
||||
product.prep_zones = zones
|
||||
for i, qo in enumerate(body.quick_options):
|
||||
db.add(ProductQuickOption(
|
||||
product_id=product.id,
|
||||
@@ -254,20 +321,27 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
|
||||
favorite_sort_order=qo.favorite_sort_order,
|
||||
is_compact=qo.is_compact,
|
||||
))
|
||||
created_groups = _replace_modifier_groups(db, product, body.modifier_groups)
|
||||
group_id_map = [g.id for g in created_groups]
|
||||
for opt in body.options:
|
||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||
resolved_group = group_id_map[opt.group_id] if (opt.group_id is not None and opt.group_id < len(group_id_map)) else None
|
||||
db.add(ProductOption(
|
||||
product_id=product.id,
|
||||
name=opt.name,
|
||||
extra_cost=opt.extra_cost,
|
||||
allow_multiple=opt.allow_multiple,
|
||||
multi_select=opt.multi_select,
|
||||
sub_choices=sub_json,
|
||||
is_favorite=opt.is_favorite,
|
||||
favorite_sort_order=opt.favorite_sort_order,
|
||||
is_compact=opt.is_compact,
|
||||
group_id=resolved_group,
|
||||
))
|
||||
for ing in body.ingredients:
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
resolved_group = group_id_map[ing.group_id] if (ing.group_id is not None and ing.group_id < len(group_id_map)) else None
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group))
|
||||
_replace_preference_sets(db, product, body.preference_sets, group_id_map)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
_broadcast_products_changed()
|
||||
@@ -275,30 +349,46 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
|
||||
|
||||
|
||||
@router.put("/{product_id}", response_model=ProductOut)
|
||||
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
scalar_fields = body.model_dump(
|
||||
exclude_none=True,
|
||||
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"},
|
||||
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags", "modifier_groups"},
|
||||
)
|
||||
for field, value in scalar_fields.items():
|
||||
setattr(product, field, value)
|
||||
# Always clear legacy per-product printer — routing is now via prep zones only
|
||||
product.printer_zone_id = None
|
||||
# Update prep zones if provided
|
||||
if body.prep_zone_ids is not None:
|
||||
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
|
||||
product.prep_zones = zones
|
||||
# cost_breakdown is a list of objects — serialize to JSON for storage
|
||||
if body.cost_breakdown is not None:
|
||||
product.cost_breakdown = json.dumps([item.model_dump() for item in body.cost_breakdown])
|
||||
elif "cost_breakdown" in body.model_fields_set:
|
||||
# explicitly set to null — clear it
|
||||
product.cost_breakdown = None
|
||||
# tags is a list of strings — serialize to JSON for storage
|
||||
if body.tags is not None:
|
||||
product.tags = json.dumps(body.tags)
|
||||
elif "tags" in body.model_fields_set:
|
||||
product.tags = None
|
||||
if body.quick_options is not None:
|
||||
_replace_quick_options(db, product, body.quick_options)
|
||||
# Modifier groups must be recreated before items so we have the ID map
|
||||
group_id_map = None
|
||||
if body.modifier_groups is not None:
|
||||
created_groups = _replace_modifier_groups(db, product, body.modifier_groups)
|
||||
group_id_map = [g.id for g in created_groups]
|
||||
if body.options is not None:
|
||||
_replace_options(db, product, body.options)
|
||||
_replace_options(db, product, body.options, group_id_map)
|
||||
if body.ingredients is not None:
|
||||
_replace_ingredients(db, product, body.ingredients)
|
||||
_replace_ingredients(db, product, body.ingredients, group_id_map)
|
||||
if body.preference_sets is not None:
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
_replace_preference_sets(db, product, body.preference_sets, group_id_map)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
_broadcast_products_changed()
|
||||
@@ -306,7 +396,7 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
|
||||
|
||||
|
||||
@router.post("/{product_id}/image", response_model=ProductOut)
|
||||
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
@@ -336,8 +426,55 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
|
||||
return product
|
||||
|
||||
|
||||
# ── Modifier Groups ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{product_id}/modifier-groups", response_model=List[ModifierGroupOut])
|
||||
def list_modifier_groups(product_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return db.query(ProductModifierGroup).filter(ProductModifierGroup.product_id == product_id).order_by(ProductModifierGroup.sort_order).all()
|
||||
|
||||
|
||||
@router.post("/{product_id}/modifier-groups", response_model=ModifierGroupOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_modifier_group(product_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
g = ProductModifierGroup(product_id=product_id, modifier_type=body.modifier_type, name=body.name, sort_order=body.sort_order)
|
||||
db.add(g)
|
||||
db.commit()
|
||||
db.refresh(g)
|
||||
_broadcast_products_changed()
|
||||
return g
|
||||
|
||||
|
||||
@router.put("/{product_id}/modifier-groups/{group_id}", response_model=ModifierGroupOut)
|
||||
def update_modifier_group(product_id: int, group_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first()
|
||||
if not g:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
g.name = body.name
|
||||
g.sort_order = body.sort_order
|
||||
db.commit()
|
||||
db.refresh(g)
|
||||
_broadcast_products_changed()
|
||||
return g
|
||||
|
||||
|
||||
@router.delete("/{product_id}/modifier-groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_modifier_group(product_id: int, group_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first()
|
||||
if not g:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
# Un-group all items in this group before deleting
|
||||
db.query(ProductOption).filter(ProductOption.group_id == group_id).update({"group_id": None})
|
||||
db.query(ProductIngredient).filter(ProductIngredient.group_id == group_id).update({"group_id": None})
|
||||
db.query(ProductPreferenceSet).filter(ProductPreferenceSet.group_id == group_id).update({"group_id": None})
|
||||
db.delete(g)
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
113
local_backend/routers/recovery.py
Normal file
113
local_backend/routers/recovery.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import secrets
|
||||
import bcrypt
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.recovery_code import RecoveryCode
|
||||
from schemas.user import UserOut
|
||||
from schemas.auth import TokenResponse
|
||||
from routers.deps import get_current_user, make_token
|
||||
|
||||
router = APIRouter()
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
CODES_PER_BATCH = 5
|
||||
|
||||
|
||||
def _generate_code() -> str:
|
||||
"""Return a human-readable code like XENIA-A3K9-MW2F."""
|
||||
part = lambda: secrets.token_hex(2).upper()
|
||||
return f"XENIA-{part()}-{part()}"
|
||||
|
||||
|
||||
def _hash_code(plain: str) -> str:
|
||||
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def _verify_code(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def try_recovery_code(plain: str, user: "User", db: "Session") -> bool:
|
||||
"""Try to consume a recovery code for user. Returns True and burns the code if matched."""
|
||||
unused = db.query(RecoveryCode).filter(
|
||||
RecoveryCode.user_id == user.id,
|
||||
RecoveryCode.used_at.is_(None),
|
||||
).all()
|
||||
matched = next((rc for rc in unused if _verify_code(plain, rc.code_hash)), None)
|
||||
if not matched:
|
||||
return False
|
||||
matched.used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
_logger.warning("RECOVERY CODE USED for user_id=%d username=%s code_id=%d", user.id, user.username, matched.id)
|
||||
return True
|
||||
|
||||
|
||||
# ─── Public: use a recovery code to log in (kept for direct API use) ─────────
|
||||
|
||||
class UseRecoveryCodeRequest(BaseModel):
|
||||
username: str
|
||||
code: str
|
||||
|
||||
|
||||
@router.post("/use", response_model=TokenResponse)
|
||||
def use_recovery_code(body: UseRecoveryCodeRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(
|
||||
User.username == body.username,
|
||||
User.is_active == True,
|
||||
).first()
|
||||
if not user or not try_recovery_code(body.code, user, db):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = make_token(user)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
# ─── Authenticated: generate a new batch (burns all existing unused codes) ───
|
||||
|
||||
class RecoveryCodesGenerated(BaseModel):
|
||||
codes: list[str]
|
||||
remaining_after: int
|
||||
|
||||
|
||||
@router.post("/generate", response_model=RecoveryCodesGenerated)
|
||||
def generate_recovery_codes(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Burn all existing unused codes for this user
|
||||
db.query(RecoveryCode).filter(
|
||||
RecoveryCode.user_id == current_user.id,
|
||||
RecoveryCode.used_at.is_(None),
|
||||
).delete(synchronize_session=False)
|
||||
db.flush()
|
||||
|
||||
plain_codes = [_generate_code() for _ in range(CODES_PER_BATCH)]
|
||||
for plain in plain_codes:
|
||||
db.add(RecoveryCode(user_id=current_user.id, code_hash=_hash_code(plain)))
|
||||
|
||||
db.commit()
|
||||
_logger.info("Recovery codes regenerated for user_id=%d", current_user.id)
|
||||
return RecoveryCodesGenerated(codes=plain_codes, remaining_after=CODES_PER_BATCH)
|
||||
|
||||
|
||||
# ─── Authenticated: check how many unused codes remain ───────────────────────
|
||||
|
||||
class RecoveryCodeStatus(BaseModel):
|
||||
unused_count: int
|
||||
|
||||
|
||||
@router.get("/status", response_model=RecoveryCodeStatus)
|
||||
def recovery_code_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
count = db.query(RecoveryCode).filter(
|
||||
RecoveryCode.user_id == current_user.id,
|
||||
RecoveryCode.used_at == None,
|
||||
).count()
|
||||
return RecoveryCodeStatus(unused_count=count)
|
||||
@@ -13,6 +13,7 @@ from typing import Optional, List
|
||||
|
||||
from database import get_db
|
||||
from models.order import Order, OrderItem, OrderWaiter, PrintLog
|
||||
from models.prep_zone import PrepZone
|
||||
from models.user import User
|
||||
from models.table import Table
|
||||
from models.printer import Printer
|
||||
@@ -20,15 +21,21 @@ from models.shift import WaiterShift
|
||||
from models.business_day import BusinessDay
|
||||
from schemas.order import OrderOut
|
||||
from schemas.table import TableOut
|
||||
from routers.deps import require_manager
|
||||
from routers.deps import require_reports
|
||||
from services.printer_service import (
|
||||
print_waiter_report, print_printer_report, print_order_receipt, load_divider_style,
|
||||
print_products_report, print_categories_report, print_tables_report,
|
||||
print_prep_zone_summary,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _item_val(item) -> float:
|
||||
"""Effective line value: unit_price + any price adjustment, times quantity."""
|
||||
return ((item.unit_price or 0.0) + (item.price_adjustment or 0.0)) * item.quantity
|
||||
|
||||
|
||||
def _dt(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
@@ -42,7 +49,7 @@ def shift_summary(
|
||||
report_date: Optional[date] = Query(default=None, alias="date"),
|
||||
waiter_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
"""Payments collected per waiter — based on paid_by on order items."""
|
||||
if from_dt and to_dt:
|
||||
@@ -80,7 +87,7 @@ def shift_summary(
|
||||
"order_data": {},
|
||||
}
|
||||
summary[wid]["items"] += item.quantity
|
||||
val = item.unit_price * item.quantity
|
||||
val = _item_val(item)
|
||||
summary[wid]["total"] += val
|
||||
|
||||
oid = item.order_id
|
||||
@@ -117,7 +124,7 @@ def shift_orders_summary(
|
||||
waiter_id: Optional[int] = None,
|
||||
business_day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
"""Items sent (added) per waiter — regardless of payment status."""
|
||||
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
|
||||
@@ -181,7 +188,7 @@ def shift_orders_summary(
|
||||
"order_data": {},
|
||||
}
|
||||
summary[wid]["items"] += item.quantity
|
||||
val = item.unit_price * item.quantity
|
||||
val = _item_val(item)
|
||||
summary[wid]["total"] += val
|
||||
|
||||
oid = item.order_id
|
||||
@@ -237,7 +244,7 @@ def order_history(
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from sqlalchemy.orm import joinedload
|
||||
from models.table import Table as TableModel
|
||||
@@ -318,6 +325,7 @@ def order_history(
|
||||
"added_at": _dt_local(item.added_at),
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"price_adjustment": float(item.price_adjustment or 0.0),
|
||||
"status": item.status,
|
||||
"paid_by": item.paid_by,
|
||||
"paid_by_name": _wname(item.paid_by),
|
||||
@@ -366,7 +374,7 @@ def order_history(
|
||||
|
||||
|
||||
@router.get("/tables/summary")
|
||||
def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_reports)):
|
||||
tables = db.query(Table).filter(Table.is_active == True).all()
|
||||
result = []
|
||||
for table in tables:
|
||||
@@ -387,7 +395,7 @@ def printer_totals(
|
||||
from_date: Optional[str] = Query(default=None, alias="from"),
|
||||
to_date: Optional[str] = Query(default=None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
"""Returns totals per printer based on print_log entries in the date range."""
|
||||
q = db.query(PrintLog).filter(PrintLog.success == True)
|
||||
@@ -437,11 +445,13 @@ def printer_totals(
|
||||
item_ids = json.loads(log.item_ids)
|
||||
except Exception:
|
||||
item_ids = []
|
||||
if item_ids is None:
|
||||
item_ids = []
|
||||
for item_id in item_ids:
|
||||
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
|
||||
if item and item.status in ("active", "paid"):
|
||||
summary[pid]["items"] += item.quantity
|
||||
val = item.unit_price * item.quantity
|
||||
val = _item_val(item)
|
||||
summary[pid]["total"] += val
|
||||
order_map[pid][oid]["total"] += val
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
@@ -510,7 +520,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m
|
||||
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
|
||||
if item and item.status in ("active", "paid"):
|
||||
items_count += item.quantity
|
||||
val = item.unit_price * item.quantity
|
||||
val = _item_val(item)
|
||||
grand_total += val
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
if oid in order_map:
|
||||
@@ -518,7 +528,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m
|
||||
order_map[oid]["items"].append({
|
||||
"name": product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"unit_price": float((item.unit_price or 0.0) + (item.price_adjustment or 0.0)),
|
||||
"total": round(val, 2),
|
||||
})
|
||||
# Accumulate item breakdown (always, regardless of mode)
|
||||
@@ -551,7 +561,7 @@ def print_waiter(
|
||||
body: PrintWaiterReportBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||
if not printer:
|
||||
@@ -577,7 +587,7 @@ def print_waiter(
|
||||
order_data = []
|
||||
for o in orders:
|
||||
active_items = [i for i in o.items if i.status in ("active", "paid")]
|
||||
total = sum(i.unit_price * i.quantity for i in active_items)
|
||||
total = sum(_item_val(i) for i in active_items)
|
||||
order_data.append({
|
||||
"id": o.id,
|
||||
"time_open": local_strftime(o.opened_at, "%H:%M"),
|
||||
@@ -605,7 +615,7 @@ def print_waiter(
|
||||
"to_dt": local_strftime(to_dt, "%d/%m/%Y %H:%M"),
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode)
|
||||
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@@ -614,7 +624,7 @@ def print_printer_totals(
|
||||
body: PrintPrinterReportBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
# The physical printer that will receive the paper
|
||||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||
@@ -674,7 +684,7 @@ def print_printer_totals(
|
||||
"div_style": load_divider_style(db),
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
|
||||
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@@ -710,7 +720,7 @@ def print_products(
|
||||
body: PrintAnalyticsBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.product import Product, Category
|
||||
|
||||
@@ -737,7 +747,7 @@ def print_products(
|
||||
if pid not in sold:
|
||||
sold[pid] = {"qty": 0, "revenue": 0.0}
|
||||
sold[pid]["qty"] += item.quantity
|
||||
sold[pid]["revenue"] += item.unit_price * item.quantity
|
||||
sold[pid]["revenue"] += _item_val(item)
|
||||
|
||||
if body.mode == "full":
|
||||
# All active products, 0-sold included
|
||||
@@ -762,7 +772,7 @@ def print_products(
|
||||
"line_width": printer.line_width,
|
||||
"div_style": load_divider_style(db),
|
||||
}
|
||||
background_tasks.add_task(print_products_report, printer.ip_address, printer.port, report)
|
||||
background_tasks.add_task(print_products_report, printer.ip_address, printer.port, report, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@@ -771,7 +781,7 @@ def print_categories(
|
||||
body: PrintAnalyticsBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.product import Product, Category
|
||||
|
||||
@@ -805,11 +815,11 @@ def print_categories(
|
||||
cat = categories_db.get(cid)
|
||||
summary[cid] = {"name": cat.name if cat else f"#{cid}", "units_sold": 0, "revenue": 0.0, "products": {}}
|
||||
summary[cid]["units_sold"] += item.quantity
|
||||
summary[cid]["revenue"] += item.unit_price * item.quantity
|
||||
summary[cid]["revenue"] += _item_val(item)
|
||||
if pid not in summary[cid]["products"]:
|
||||
summary[cid]["products"][pid] = {"name": product.name, "qty": 0, "revenue": 0.0}
|
||||
summary[cid]["products"][pid]["qty"] += item.quantity
|
||||
summary[cid]["products"][pid]["revenue"] += item.unit_price * item.quantity
|
||||
summary[cid]["products"][pid]["revenue"] += _item_val(item)
|
||||
|
||||
total_rev = sum(v["revenue"] for v in summary.values())
|
||||
total_qty = sum(v["units_sold"] for v in summary.values())
|
||||
@@ -841,7 +851,7 @@ def print_categories(
|
||||
"line_width": printer.line_width,
|
||||
"div_style": load_divider_style(db),
|
||||
}
|
||||
background_tasks.add_task(print_categories_report, printer.ip_address, printer.port, report)
|
||||
background_tasks.add_task(print_categories_report, printer.ip_address, printer.port, report, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@@ -850,7 +860,7 @@ def print_tables(
|
||||
body: PrintAnalyticsBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||
if not printer:
|
||||
@@ -879,7 +889,7 @@ def print_tables(
|
||||
"order_count": 0, "revenue": 0.0, "durations": [],
|
||||
}
|
||||
summary[tid]["order_count"] += 1
|
||||
summary[tid]["revenue"] += sum(i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid"))
|
||||
summary[tid]["revenue"] += sum(_item_val(i) for i in order.items if i.status in ("active", "paid"))
|
||||
if order.closed_at and order.opened_at:
|
||||
summary[tid]["durations"].append((order.closed_at - order.opened_at).total_seconds() / 60)
|
||||
|
||||
@@ -899,7 +909,7 @@ def print_tables(
|
||||
"line_width": printer.line_width,
|
||||
"div_style": load_divider_style(db),
|
||||
}
|
||||
background_tasks.add_task(print_tables_report, printer.ip_address, printer.port, report)
|
||||
background_tasks.add_task(print_tables_report, printer.ip_address, printer.port, report, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@@ -915,7 +925,7 @@ def shifts_report(
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
active_only: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from routers.shifts import _enrich_shift
|
||||
|
||||
@@ -946,7 +956,7 @@ def product_performance(
|
||||
business_day_id: Optional[int] = None,
|
||||
category_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.product import Product
|
||||
from models.waste import WasteLog
|
||||
@@ -1000,7 +1010,7 @@ def product_performance(
|
||||
"order_ids": set(),
|
||||
}
|
||||
qty = item.quantity
|
||||
revenue = item.unit_price * qty
|
||||
revenue = _item_val(item)
|
||||
summary[pid]["qty_sold"] += qty
|
||||
summary[pid]["revenue"] += revenue
|
||||
summary[pid]["order_ids"].add(item.order_id)
|
||||
@@ -1054,7 +1064,7 @@ def table_performance(
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
business_day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
|
||||
if from_dt:
|
||||
@@ -1081,7 +1091,7 @@ def table_performance(
|
||||
}
|
||||
summary[tid]["order_count"] += 1
|
||||
summary[tid]["revenue"] += sum(
|
||||
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
|
||||
_item_val(i) for i in order.items if i.status in ("active", "paid")
|
||||
)
|
||||
if order.closed_at and order.opened_at:
|
||||
summary[tid]["durations"].append(
|
||||
@@ -1109,7 +1119,7 @@ def traffic_analysis(
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
business_day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(Order)
|
||||
if from_dt:
|
||||
@@ -1126,7 +1136,7 @@ def traffic_analysis(
|
||||
|
||||
for order in orders:
|
||||
revenue = sum(
|
||||
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
|
||||
_item_val(i) for i in order.items if i.status in ("active", "paid")
|
||||
)
|
||||
h = order.opened_at.hour
|
||||
d = order.opened_at.weekday()
|
||||
@@ -1155,7 +1165,7 @@ def business_days_list(
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(BusinessDay)
|
||||
if from_dt:
|
||||
@@ -1180,7 +1190,7 @@ def business_days_list(
|
||||
for i in o.items:
|
||||
if i.status not in ("active", "paid"):
|
||||
continue
|
||||
rev = i.unit_price * i.quantity
|
||||
rev = _item_val(i)
|
||||
revenue += rev
|
||||
if i.unit_cost is not None:
|
||||
cost = i.unit_cost * i.quantity
|
||||
@@ -1214,7 +1224,7 @@ def business_days_list(
|
||||
@router.get("/business-days/current")
|
||||
def current_business_day(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
day = db.query(BusinessDay).filter(BusinessDay.status == "open").order_by(BusinessDay.opened_at.desc()).first()
|
||||
if not day:
|
||||
@@ -1236,7 +1246,7 @@ def current_business_day(
|
||||
for i in o.items:
|
||||
if i.status not in ("active", "paid"):
|
||||
continue
|
||||
rev = i.unit_price * i.quantity
|
||||
rev = _item_val(i)
|
||||
revenue += rev
|
||||
if i.unit_cost is not None:
|
||||
cost = i.unit_cost * i.quantity
|
||||
@@ -1304,7 +1314,7 @@ def revenue_trends(
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
granularity: str = Query(default="daily"), # daily | weekly | monthly
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
|
||||
if from_dt:
|
||||
@@ -1329,7 +1339,7 @@ def revenue_trends(
|
||||
for i in order.items:
|
||||
if i.status not in ("active", "paid"):
|
||||
continue
|
||||
rev = i.unit_price * i.quantity
|
||||
rev = _item_val(i)
|
||||
buckets[key]["revenue"] += rev
|
||||
if i.unit_cost is not None:
|
||||
buckets[key]["profit"] += rev - i.unit_cost * i.quantity
|
||||
@@ -1360,7 +1370,7 @@ def category_performance(
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
business_day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.product import Product, Category
|
||||
|
||||
@@ -1397,7 +1407,7 @@ def category_performance(
|
||||
"product_ids": set(),
|
||||
}
|
||||
qty = item.quantity
|
||||
rev = item.unit_price * qty
|
||||
rev = _item_val(item)
|
||||
summary[cid]["units_sold"] += qty
|
||||
summary[cid]["revenue"] += rev
|
||||
summary[cid]["product_ids"].add(item.product_id)
|
||||
@@ -1438,7 +1448,7 @@ def cancellations_log(
|
||||
business_day_id: Optional[int] = None,
|
||||
waiter_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
|
||||
|
||||
@@ -1495,7 +1505,7 @@ def cancellations_log(
|
||||
"product_name": product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": item.unit_price,
|
||||
"value": round(item.unit_price * item.quantity, 2),
|
||||
"value": round(_item_val(item), 2),
|
||||
"cancelled_by": cancelled_by_name,
|
||||
"cancel_reason": getattr(item, "cancel_reason", None),
|
||||
"cancelled_at": _dt(cancelled_at) if cancelled_at else _dt(item.added_at),
|
||||
@@ -1516,7 +1526,7 @@ def printer_history(
|
||||
business_day_id: Optional[int] = None,
|
||||
printer_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
q = db.query(PrintLog)
|
||||
if from_dt:
|
||||
@@ -1549,6 +1559,8 @@ def printer_history(
|
||||
item_ids = json.loads(log.item_ids)
|
||||
except Exception:
|
||||
item_ids = []
|
||||
if item_ids is None:
|
||||
item_ids = []
|
||||
items = []
|
||||
for iid in item_ids:
|
||||
oi = db.query(OrderItem).filter(OrderItem.id == iid).first()
|
||||
@@ -1560,7 +1572,7 @@ def printer_history(
|
||||
order_total = None
|
||||
if order:
|
||||
order_total = sum(
|
||||
i.unit_price * i.quantity
|
||||
_item_val(i)
|
||||
for i in order.items
|
||||
if i.status in ("active", "paid")
|
||||
)
|
||||
@@ -1593,16 +1605,16 @@ def printer_history(
|
||||
@router.get("/meta/waiters")
|
||||
def meta_waiters(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
|
||||
waiters = db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()
|
||||
return {"waiters": [{"id": w.id, "name": w.full_name or w.username} for w in waiters]}
|
||||
|
||||
|
||||
@router.get("/meta/tables")
|
||||
def meta_tables(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
tables = db.query(Table).filter(Table.is_active == True).all()
|
||||
return {"tables": [{"id": t.id, "name": t.label or f"T{t.number}", "group": t.group.name if t.group else None} for t in tables]}
|
||||
@@ -1611,7 +1623,7 @@ def meta_tables(
|
||||
@router.get("/meta/printers")
|
||||
def meta_printers(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
printers = db.query(Printer).filter(Printer.is_active == True).all()
|
||||
return {"printers": [{"id": p.id, "name": p.name} for p in printers]}
|
||||
@@ -1620,7 +1632,7 @@ def meta_printers(
|
||||
@router.get("/meta/products")
|
||||
def meta_products(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.product import Product, Category
|
||||
products = db.query(Product).filter(Product.lifecycle_status == "active").order_by(Product.name).all()
|
||||
@@ -1665,7 +1677,7 @@ def shifts_export(
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
data = shifts_report(waiter_id=waiter_id, business_day_id=business_day_id, from_dt=from_dt, to_dt=to_dt, active_only=False, db=db, user=user)
|
||||
rows = []
|
||||
@@ -1697,7 +1709,7 @@ def orders_export(
|
||||
order_status: Optional[str] = Query(default=None, alias="status"),
|
||||
table_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
orders = order_history(from_date=from_date, to_date=to_date, waiter_id=waiter_id, order_status=order_status, table_id=table_id, page=1, page_size=10000, db=db, user=user)
|
||||
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
@@ -1712,7 +1724,7 @@ def orders_export(
|
||||
"opened_at": _dt(o.opened_at),
|
||||
"closed_at": _dt(o.closed_at) if o.closed_at else "",
|
||||
"status": o.status,
|
||||
"total": round(sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid")), 2),
|
||||
"total": round(sum(_item_val(i) for i in o.items if i.status in ("active", "paid")), 2),
|
||||
})
|
||||
date_str = (from_date or "")[:10]
|
||||
return _csv_response(rows, f"orders-{date_str}.csv")
|
||||
@@ -1725,7 +1737,7 @@ def products_export(
|
||||
business_day_id: Optional[int] = None,
|
||||
category_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
data = product_performance(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, category_id=category_id, db=db, user=user)
|
||||
rows = [{
|
||||
@@ -1747,7 +1759,7 @@ def printers_export(
|
||||
printer_id: Optional[int] = None,
|
||||
business_day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
data = printer_history(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, printer_id=printer_id, db=db, user=user)
|
||||
rows = [{
|
||||
@@ -1770,7 +1782,7 @@ def cancellations_export(
|
||||
business_day_id: Optional[int] = None,
|
||||
waiter_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
data = cancellations_log(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, waiter_id=waiter_id, db=db, user=user)
|
||||
rows = [{
|
||||
@@ -1789,6 +1801,192 @@ def cancellations_export(
|
||||
return _csv_response(rows, f"cancellations-{date_str}.csv")
|
||||
|
||||
|
||||
# ── Prep Zones report ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/prep-zones")
|
||||
def prep_zones_report(
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
business_day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
"""
|
||||
Per-prep-zone stats: item totals, full product list, and full order list
|
||||
for the requested date range / business day.
|
||||
"""
|
||||
zones = db.query(PrepZone).order_by(PrepZone.id).all()
|
||||
|
||||
# Build product → zones mapping
|
||||
product_zone_map: dict[int, list[int]] = {}
|
||||
for zone in zones:
|
||||
for product in zone.products:
|
||||
if product.id not in product_zone_map:
|
||||
product_zone_map[product.id] = []
|
||||
product_zone_map[product.id].append(zone.id)
|
||||
|
||||
# Query order items in the requested range
|
||||
q = db.query(OrderItem).join(Order)
|
||||
if from_dt:
|
||||
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
|
||||
if to_dt:
|
||||
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
|
||||
if business_day_id:
|
||||
q = q.filter(Order.business_day_id == business_day_id)
|
||||
q = q.filter(OrderItem.status.in_(["active", "paid"]))
|
||||
items = q.all()
|
||||
|
||||
# Aggregate per zone
|
||||
zone_stats: dict[int, dict] = {
|
||||
zone.id: {
|
||||
"id": zone.id,
|
||||
"name": zone.name,
|
||||
"notification_name": zone.notification_name,
|
||||
"printers": [{"id": p.id, "name": p.name} for p in zone.printers],
|
||||
"item_count": 0,
|
||||
"total_value": 0.0,
|
||||
"product_counts": {}, # product_name → {count, value}
|
||||
"orders_map": {}, # order_id → {order_id, table, opened_at, items_count, value}
|
||||
}
|
||||
for zone in zones
|
||||
}
|
||||
|
||||
for item in items:
|
||||
zids = product_zone_map.get(item.product_id, [])
|
||||
pname = item.product.name if item.product else f"#{item.product_id}"
|
||||
ivalue = round(_item_val(item), 2)
|
||||
order = item.order
|
||||
table_label = None
|
||||
if order and order.table:
|
||||
t = order.table
|
||||
table_label = t.label or str(t.number)
|
||||
for zid in zids:
|
||||
if zid not in zone_stats:
|
||||
continue
|
||||
zs = zone_stats[zid]
|
||||
zs["item_count"] += item.quantity
|
||||
zs["total_value"] += ivalue
|
||||
if pname not in zs["product_counts"]:
|
||||
zs["product_counts"][pname] = {"count": 0, "value": 0.0}
|
||||
zs["product_counts"][pname]["count"] += item.quantity
|
||||
zs["product_counts"][pname]["value"] += ivalue
|
||||
if order:
|
||||
oid = order.id
|
||||
if oid not in zs["orders_map"]:
|
||||
zs["orders_map"][oid] = {
|
||||
"order_id": oid,
|
||||
"table": table_label or f"#{oid}",
|
||||
"opened_at": order.opened_at.isoformat() if order.opened_at else None,
|
||||
"items_count": 0,
|
||||
"value": 0.0,
|
||||
}
|
||||
zs["orders_map"][oid]["items_count"] += item.quantity
|
||||
zs["orders_map"][oid]["value"] += ivalue
|
||||
|
||||
result = []
|
||||
for zs in zone_stats.values():
|
||||
all_products = sorted(
|
||||
[
|
||||
{"name": k, "count": v["count"], "value": round(v["value"], 2)}
|
||||
for k, v in zs["product_counts"].items()
|
||||
],
|
||||
key=lambda x: -x["count"],
|
||||
)
|
||||
all_orders = sorted(
|
||||
[
|
||||
{**o, "value": round(o["value"], 2)}
|
||||
for o in zs["orders_map"].values()
|
||||
],
|
||||
key=lambda x: x["opened_at"] or "",
|
||||
)
|
||||
result.append({
|
||||
"id": zs["id"],
|
||||
"name": zs["name"],
|
||||
"notification_name": zs["notification_name"],
|
||||
"printers": zs["printers"],
|
||||
"item_count": zs["item_count"],
|
||||
"total_value": round(zs["total_value"], 2),
|
||||
"all_products": all_products,
|
||||
"all_orders": all_orders,
|
||||
})
|
||||
|
||||
return {"zones": result}
|
||||
|
||||
|
||||
class PrintPrepZoneBody(BaseModel):
|
||||
printer_id: int
|
||||
zone_id: int
|
||||
from_dt: Optional[str] = None
|
||||
to_dt: Optional[str] = None
|
||||
business_day_id: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/print/prep-zone")
|
||||
def print_prep_zone(
|
||||
body: PrintPrepZoneBody,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
||||
|
||||
zone = db.query(PrepZone).filter(PrepZone.id == body.zone_id).first()
|
||||
if not zone:
|
||||
raise HTTPException(status_code=404, detail="Prep zone not found")
|
||||
|
||||
# Resolve period label
|
||||
if body.business_day_id:
|
||||
from models.business_day import BusinessDay
|
||||
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
|
||||
period_label = bd.opened_at.strftime("%d/%m/%Y") if bd and bd.opened_at else f"#{body.business_day_id}"
|
||||
elif body.from_dt and body.to_dt:
|
||||
d1 = datetime.fromisoformat(body.from_dt).strftime("%d/%m/%Y")
|
||||
d2 = datetime.fromisoformat(body.to_dt).strftime("%d/%m/%Y")
|
||||
period_label = f"{d1} - {d2}"
|
||||
else:
|
||||
period_label = "Όλες"
|
||||
|
||||
# Build product→zone mapping for this zone
|
||||
product_ids = {product.id for product in zone.products}
|
||||
|
||||
q = db.query(OrderItem).join(Order)
|
||||
if body.from_dt:
|
||||
q = q.filter(Order.opened_at >= datetime.fromisoformat(body.from_dt))
|
||||
if body.to_dt:
|
||||
q = q.filter(Order.opened_at <= datetime.fromisoformat(body.to_dt))
|
||||
if body.business_day_id:
|
||||
q = q.filter(Order.business_day_id == body.business_day_id)
|
||||
q = q.filter(OrderItem.status.in_(["active", "paid"]))
|
||||
items = q.all()
|
||||
|
||||
product_counts: dict[str, dict] = {}
|
||||
for item in items:
|
||||
if item.product_id not in product_ids:
|
||||
continue
|
||||
pname = item.product.name if item.product else f"#{item.product_id}"
|
||||
if pname not in product_counts:
|
||||
product_counts[pname] = {"count": 0, "value": 0.0}
|
||||
product_counts[pname]["count"] += item.quantity
|
||||
product_counts[pname]["value"] += _item_val(item)
|
||||
|
||||
report_items = sorted(
|
||||
[{"name": k, "count": v["count"], "value": round(v["value"], 2)} for k, v in product_counts.items()],
|
||||
key=lambda x: -x["count"],
|
||||
)
|
||||
|
||||
report = {
|
||||
"zone_name": zone.name,
|
||||
"period_label": period_label,
|
||||
"items": report_items,
|
||||
"line_width": printer.line_width,
|
||||
"div_style": load_divider_style(db),
|
||||
}
|
||||
background_tasks.add_task(print_prep_zone_summary, printer.ip_address, printer.port, report, printer.codepage_n)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2L — Discount audit report
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1800,7 +1998,7 @@ def discounts_report(
|
||||
business_day_id: Optional[int] = None,
|
||||
applied_by: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_reports),
|
||||
):
|
||||
from models.order import OrderDiscount, OrderItem
|
||||
|
||||
@@ -1836,10 +2034,10 @@ def discounts_report(
|
||||
if d.item_id:
|
||||
item = db.query(OrderItem).filter(OrderItem.id == d.item_id).first()
|
||||
if item:
|
||||
base = item.unit_price * item.quantity
|
||||
base = _item_val(item)
|
||||
return round(base * d.discount_value / 100, 2)
|
||||
elif order:
|
||||
base = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled")
|
||||
base = sum(_item_val(i) for i in order.items if i.status != "cancelled")
|
||||
return round(base * d.discount_value / 100, 2)
|
||||
return 0.0
|
||||
|
||||
@@ -1852,7 +2050,7 @@ def discounts_report(
|
||||
applier = users_map.get(d.applied_by)
|
||||
applier_name = (applier.full_name or applier.username) if applier else f"#{d.applied_by}"
|
||||
table_name = tables_map.get(order.table_id) if order and order.table_id else None
|
||||
order_total = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled") if order else None
|
||||
order_total = sum(_item_val(i) for i in order.items if i.status != "cancelled") if order else None
|
||||
discount_amount = _compute_discount_amount(d)
|
||||
total_discount_value += discount_amount
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ def week_schedule(
|
||||
WaiterShift.started_at <= week_end_dt,
|
||||
).all()
|
||||
|
||||
waiters = {u.id: u for u in db.query(User).filter(User.role == "waiter", User.is_active == True).all()}
|
||||
waiters = {u.id: u for u in db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()}
|
||||
|
||||
def _dt(dt):
|
||||
if not dt:
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
||||
from database import get_db
|
||||
from models.settings import PosSettings
|
||||
from schemas.settings import UpdateSettingRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from routers.deps import get_current_user, require_settings_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
@@ -20,6 +20,7 @@ VALID_SETTINGS = {
|
||||
"security.auto_logout_seconds":"Seconds of inactivity before logging out (0 = disabled)",
|
||||
"shifts.waiter_self_start": "Allow waiters to start their own shifts without manager action",
|
||||
"shifts.waiter_self_end": "Allow waiters to end their own shifts without manager action",
|
||||
"shifts.hide_revenue_from_waiters": "Hide ΕΙΣΠΡΑΞΗ and ΠΑΡΑΔ. ΠΟΣΟ stats from waiters in Shift Overview: 'true' | 'false'",
|
||||
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
|
||||
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
|
||||
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
|
||||
@@ -43,6 +44,27 @@ VALID_SETTINGS = {
|
||||
"print.beep_pattern": "Beep pattern: 'single' | 'double' | 'triple' | 'long' | 'custom:n1:n2:n3'",
|
||||
# Phase 2 — cancellations
|
||||
"orders.waiter_cancellations_allowed": "Allow waiters with per-account permission to cancel sent orders: 'true' | 'false'",
|
||||
"orders.waiter_price_adjust_allowed": "Allow waiters to adjust item prices (before or after ordering): 'true' | 'false'",
|
||||
# Payment options
|
||||
"payments.card_enabled": "Allow waiters to record card payments (in addition to cash): 'true' | 'false'",
|
||||
"payments.waiter_revert_allowed": "Allow waiters to revert individual paid items back to unpaid: 'true' | 'false'",
|
||||
# Tables & orders behaviour
|
||||
"orders.auto_close_on_full_payment": "Automatically close the table order when all items have been paid: 'true' | 'false'",
|
||||
"orders.bypass_kds_serve": "Skip KDS pending/serve flow — new orders and items are immediately marked as served: 'true' | 'false'",
|
||||
# Courses
|
||||
"orders.courses_enabled": "Enable per-item course assignment when ordering: 'true' | 'false'",
|
||||
"orders.courses": "JSON array of course objects [{id, name, color}] defining available courses",
|
||||
# Quick notes
|
||||
"orders.quick_notes": "JSON array of quick-note strings shown in the waiter note tab",
|
||||
# Fiscal printer (ΦΗΜ)
|
||||
"fiscal.enabled": "Master switch for fiscal printing: 'true' | 'false'",
|
||||
"fiscal.type": "Fiscal driver type: 'txt_file' (dTEC100extra) — more types coming",
|
||||
"fiscal.out_folder": "Path the backend writes fiscal command files TO (input for the fiscal driver)",
|
||||
"fiscal.in_folder": "Path the backend reads fiscal reply files FROM (output of the fiscal driver)",
|
||||
"fiscal.clerk_id": "Clerk ID sent in CR/CD commands (integer string, e.g. '2')",
|
||||
"fiscal.eftpos_id": "EFTPOS terminal ID sent in CD commands (integer string, e.g. '1')",
|
||||
"fiscal.end_message": "JSON array of up to 5 strings printed at end of receipt via FM commands",
|
||||
"fiscal.vat_groups": "JSON array of VAT group definitions: [{machine_id: int, friendly_name: str}]",
|
||||
}
|
||||
|
||||
DEFAULTS = {
|
||||
@@ -54,9 +76,10 @@ DEFAULTS = {
|
||||
"security.auto_logout_seconds": "1800",
|
||||
"shifts.waiter_self_start": "true",
|
||||
"shifts.waiter_self_end": "true",
|
||||
"shifts.hide_revenue_from_waiters": "false",
|
||||
"business_day.force_close_allowed": "true",
|
||||
"system.timezone": "Europe/Athens",
|
||||
"ui.table_colours": '{"light":{"free":{"cardBg":"#dde5ef","badgeBg":"rgba(255,255,255,0.92)","nameText":"#3d5270","badgeText":"#3d5270"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}},"dark":{"free":{"cardBg":"#243044","badgeBg":"rgba(255,255,255,0.92)","nameText":"#94b8d4","badgeText":"#94b8d4"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}}}',
|
||||
"ui.table_colours": '{"light":{"free":{"cardBg":"#dde5ef","badgeBg":"rgba(255,255,255,0.92)","nameText":"#3d5270","badgeText":"#3d5270"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"},"kds_ready":{"cardBg":"#22c55e","cardBg2":"#ffffff","badgeBg":"rgba(255,255,255,0.92)","badgeBg2":"rgba(255,255,255,0.92)","nameText":"#ffffff","nameText2":"#15803d","badgeText":"#15803d","badgeText2":"#22c55e","flash":true}},"dark":{"free":{"cardBg":"#243044","badgeBg":"rgba(255,255,255,0.92)","nameText":"#94b8d4","badgeText":"#94b8d4"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"},"kds_ready":{"cardBg":"#16a34a","cardBg2":"#ffffff","badgeBg":"rgba(255,255,255,0.92)","badgeBg2":"rgba(255,255,255,0.92)","nameText":"#ffffff","nameText2":"#bbf7d0","badgeText":"#bbf7d0","badgeText2":"#16a34a","flash":true}}}',
|
||||
"dev.spoof_printing": "false",
|
||||
"print.ticket_mode": "detailed",
|
||||
"print.divider_style": "dash",
|
||||
@@ -72,6 +95,23 @@ DEFAULTS = {
|
||||
"print.font_order_note": "0:1:0",
|
||||
"print.beep_on_ticket": "true",
|
||||
"print.beep_pattern": "double",
|
||||
"payments.card_enabled": "false",
|
||||
"payments.waiter_revert_allowed": "false",
|
||||
"orders.auto_close_on_full_payment": "false",
|
||||
"orders.bypass_kds_serve": "false",
|
||||
"orders.waiter_price_adjust_allowed": "false",
|
||||
"orders.courses_enabled": "false",
|
||||
"orders.courses": "[]",
|
||||
"orders.quick_notes": '["Χωρίς αλάτι","Βγάλτε γρήγορα","Αλλεργία!","Κόψτε σε μικρά κομμάτια","Έξτρα χαρτοπετσέτες"]',
|
||||
# Fiscal printer (ΦΗΜ)
|
||||
"fiscal.enabled": "false",
|
||||
"fiscal.type": "txt_file",
|
||||
"fiscal.out_folder": "",
|
||||
"fiscal.in_folder": "",
|
||||
"fiscal.clerk_id": "2",
|
||||
"fiscal.eftpos_id": "1",
|
||||
"fiscal.end_message": "[]",
|
||||
"fiscal.vat_groups": "[]",
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +135,7 @@ def update_setting(
|
||||
key: str,
|
||||
body: UpdateSettingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
user: User = Depends(require_settings_manager),
|
||||
):
|
||||
if key not in VALID_SETTINGS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}")
|
||||
|
||||
@@ -6,6 +6,7 @@ import bcrypt
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from services.chat_service import add_user_to_system_group
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -49,7 +50,7 @@ def security_config(db: Session = Depends(get_db)):
|
||||
@router.get("/status", response_model=SetupStatusResponse)
|
||||
def setup_status(db: Session = Depends(get_db)):
|
||||
has_manager = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.perm_access_dashboard == True,
|
||||
User.is_active == True,
|
||||
).first()
|
||||
return SetupStatusResponse(needs_setup=has_manager is None)
|
||||
@@ -58,7 +59,7 @@ def setup_status(db: Session = Depends(get_db)):
|
||||
@router.post("/init", response_model=SetupInitResponse)
|
||||
def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
|
||||
has_manager = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.perm_access_dashboard == True,
|
||||
User.is_active == True,
|
||||
).first()
|
||||
if has_manager:
|
||||
@@ -87,9 +88,13 @@ def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
|
||||
password_hash=password_hash,
|
||||
email=body.email,
|
||||
full_name=body.full_name,
|
||||
role="manager",
|
||||
role="store_manager",
|
||||
is_active=True,
|
||||
)
|
||||
# Apply store_manager default permissions
|
||||
from roles import get_default_permissions
|
||||
for field, value in get_default_permissions("store_manager").items():
|
||||
setattr(user, field, value)
|
||||
db.add(user)
|
||||
|
||||
# Persist venue settings if provided
|
||||
@@ -113,4 +118,6 @@ def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
|
||||
db.add(PosSettings(key="venue.type", value=body.venue_type, updated_at=now))
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
add_user_to_system_group(db, user.id)
|
||||
return SetupInitResponse(ok=True)
|
||||
|
||||
@@ -35,6 +35,24 @@ def compute_shift_total(shift_id: int, db: Session) -> float:
|
||||
return round(sum(i.unit_price * i.quantity for i in items), 2)
|
||||
|
||||
|
||||
def compute_shift_payment_split(shift_id: int, db: Session) -> dict:
|
||||
"""Return cash_sales and card_sales for a shift (items paid in this shift)."""
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.paid_in_shift_id == shift_id,
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
cash = 0.0
|
||||
card = 0.0
|
||||
for i in items:
|
||||
amount = float(i.unit_price) * i.quantity
|
||||
method = (i.payment_method or "cash").lower()
|
||||
if method == "card":
|
||||
card += amount
|
||||
else:
|
||||
cash += amount
|
||||
return {"cash_sales": round(cash, 2), "card_sales": round(card, 2)}
|
||||
|
||||
|
||||
def compute_shift_pay(shift: WaiterShift) -> dict:
|
||||
"""Return duration_hours and shift_pay. shift_pay is None if no rate snapshot."""
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -74,6 +92,7 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
||||
w = shift.waiter
|
||||
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
||||
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
||||
split = compute_shift_payment_split(shift.id, db)
|
||||
pay_data = compute_shift_pay(shift)
|
||||
# Count cancelled items and their value attributed to this waiter during shift window
|
||||
cancelled_q = db.query(OrderItem).filter(
|
||||
@@ -96,7 +115,9 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
||||
"ended_at": _dt(shift.ended_at),
|
||||
"starting_cash": shift.starting_cash,
|
||||
"total_collected": total,
|
||||
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
|
||||
"cash_sales": split["cash_sales"],
|
||||
"card_sales": split["card_sales"],
|
||||
"net_to_deliver": round(split["cash_sales"] + (shift.starting_cash or 0.0), 2),
|
||||
"is_active": shift.ended_at is None,
|
||||
"notes": shift.notes,
|
||||
"hourly_rate_snapshot": shift.hourly_rate_snapshot,
|
||||
@@ -105,9 +126,11 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
||||
"cancellation_events": cancellation_events,
|
||||
"cancellations": cancellations,
|
||||
"cancellation_value": cancellation_value,
|
||||
# Phase 2E
|
||||
# Phase 2E — discrepancy computed live against cash-only expected (starting_cash + cash_sales)
|
||||
"counted_cash_end": shift.counted_cash_end,
|
||||
"cash_discrepancy": shift.cash_discrepancy,
|
||||
"cash_discrepancy": round(
|
||||
shift.counted_cash_end - (split["cash_sales"] + (shift.starting_cash or 0.0)), 2
|
||||
) if shift.counted_cash_end is not None else None,
|
||||
"breaks": [
|
||||
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
||||
for b in shift.breaks
|
||||
@@ -133,14 +156,14 @@ def start_shift(
|
||||
target_id = body.waiter_id
|
||||
|
||||
if target_id and target_id != user.id:
|
||||
if user.role not in ("manager", "sysadmin"):
|
||||
raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters")
|
||||
if not user.perm_access_dashboard and user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Only managers can start shifts for other staff")
|
||||
target = db.query(User).filter(User.id == target_id, User.is_active == True).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
raise HTTPException(status_code=404, detail="Staff member not found")
|
||||
else:
|
||||
target_id = user.id
|
||||
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
|
||||
if not user.perm_access_dashboard and user.role != "superadmin" and _get_setting(db, "shifts.waiter_self_start") != "true":
|
||||
raise HTTPException(status_code=403, detail="Shift start requires manager confirmation")
|
||||
|
||||
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
@@ -173,7 +196,7 @@ def end_shift(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
|
||||
if not user.perm_access_dashboard and user.role != "superadmin" and _get_setting(db, "shifts.waiter_self_end") != "true":
|
||||
raise HTTPException(status_code=403, detail="Shift end requires manager confirmation")
|
||||
|
||||
shift = db.query(WaiterShift).filter(
|
||||
@@ -189,11 +212,9 @@ def end_shift(
|
||||
shift.ended_at = now
|
||||
if body.notes:
|
||||
shift.notes = body.notes
|
||||
# Phase 2E: cash reconciliation
|
||||
# Phase 2E: cash reconciliation — store counted amount; discrepancy computed live in _enrich_shift
|
||||
if body.counted_cash_end is not None:
|
||||
shift.counted_cash_end = body.counted_cash_end
|
||||
expected = (shift.starting_cash or 0.0) + total
|
||||
shift.cash_discrepancy = round(body.counted_cash_end - expected, 2)
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||
@@ -262,11 +283,9 @@ def manager_end_shift(
|
||||
shift.ended_at = now
|
||||
if body.notes:
|
||||
shift.notes = body.notes
|
||||
# Phase 2E: cash reconciliation
|
||||
# Phase 2E: cash reconciliation — store counted amount; discrepancy computed live in _enrich_shift
|
||||
if body.counted_cash_end is not None:
|
||||
shift.counted_cash_end = body.counted_cash_end
|
||||
expected = (shift.starting_cash or 0.0) + total
|
||||
shift.cash_discrepancy = round(body.counted_cash_end - expected, 2)
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||
@@ -288,7 +307,7 @@ def start_break(
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
||||
if shift.waiter_id != user.id and not user.perm_access_dashboard and user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if shift.ended_at:
|
||||
raise HTTPException(status_code=400, detail="Shift already ended")
|
||||
@@ -315,7 +334,7 @@ def end_break(
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
||||
if shift.waiter_id != user.id and not user.perm_access_dashboard and user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
|
||||
@@ -125,7 +125,7 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name)
|
||||
success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name, printer.codepage_n)
|
||||
return {"success": success, "error": error}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db, printer.line_width)
|
||||
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db, printer.line_width, printer.codepage_n)
|
||||
return {"success": success, "error": error}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ def test_beep(printer_id: int, n1: int = 2, n2: int = 2, n3: int = 1, db: Sessio
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
success, error = printer_service.send_test_beep(printer.ip_address, printer.port, n1, n2, n3)
|
||||
success, error = printer_service.send_test_beep(printer.ip_address, printer.port, n1, n2, n3, printer.codepage_n)
|
||||
return {"success": success, "error": error}
|
||||
|
||||
|
||||
@@ -273,8 +273,8 @@ def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current
|
||||
"products": db.query(Product).filter(Product.lifecycle_status == "active").count(),
|
||||
"tables": db.query(Table).filter(Table.is_active == True).count(),
|
||||
"table_groups": db.query(TableGroup).count(),
|
||||
"managers": db.query(User).filter(User.role == "manager", User.is_active == True).count(),
|
||||
"waiters": db.query(User).filter(User.role == "waiter", User.is_active == True).count(),
|
||||
"managers": db.query(User).filter(User.perm_access_dashboard == True, User.is_active == True).count(),
|
||||
"waiters": db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).count(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from schemas.table import (
|
||||
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
||||
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from routers.deps import get_current_user, require_tables_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
# Tables with a pending reservation due within this many hours get the RESERVED badge
|
||||
@@ -30,7 +30,7 @@ def list_groups(db: Session = Depends(get_db), user: User = Depends(get_current_
|
||||
|
||||
|
||||
@router.post("/groups", response_model=TableGroupOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
|
||||
raise HTTPException(status_code=400, detail="Group name already exists")
|
||||
sort_order = db.query(TableGroup).count()
|
||||
@@ -42,14 +42,14 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
|
||||
|
||||
|
||||
@router.put("/groups/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_groups(body: List[int] = Body(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def reorder_groups(body: List[int] = Body(...), db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
for idx, group_id in enumerate(body):
|
||||
db.query(TableGroup).filter(TableGroup.id == group_id).update({"sort_order": idx})
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}", response_model=TableGroupOut)
|
||||
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
@@ -61,7 +61,7 @@ def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(ge
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
@@ -84,7 +84,7 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
|
||||
q = q.filter(Table.is_active == True)
|
||||
|
||||
# Zone-based filtering for waiters
|
||||
if user.role not in ("manager", "sysadmin"):
|
||||
if user.role != "superadmin" and not user.perm_access_dashboard:
|
||||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
|
||||
# No zone rows → sees nothing
|
||||
if not zones:
|
||||
@@ -134,9 +134,9 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
|
||||
|
||||
|
||||
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
number = _next_global_number(db)
|
||||
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True)
|
||||
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True, seat_count=body.seat_count)
|
||||
db.add(table)
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
@@ -145,7 +145,7 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
|
||||
|
||||
|
||||
@router.post("/batch", response_model=List[TableOut], status_code=status.HTTP_201_CREATED)
|
||||
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
if body.count < 1 or body.count > 200:
|
||||
raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
|
||||
|
||||
@@ -194,7 +194,7 @@ def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), u
|
||||
|
||||
|
||||
@router.put("/{table_id}", response_model=TableOut)
|
||||
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
@@ -206,7 +206,7 @@ def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db)
|
||||
|
||||
|
||||
@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
@@ -250,7 +250,7 @@ def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depe
|
||||
|
||||
|
||||
@router.put("/{table_id}/floorplan", response_model=TableOut)
|
||||
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
|
||||
@@ -8,41 +8,86 @@ from typing import List
|
||||
from database import get_db
|
||||
from models.user import User, AssistantAssignment, WaiterZone
|
||||
from models.shift import WaiterShift
|
||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
|
||||
from routers.deps import require_manager, get_current_user
|
||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest, PermissionToggle
|
||||
from routers.deps import require_staff_manager, get_current_user
|
||||
from services.chat_service import add_user_to_system_group
|
||||
from roles import ALL_ROLES, VALID_ROLES, get_default_permissions
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
AVATAR_DIR = "/app/data/avatars"
|
||||
|
||||
# Permission fields that can be toggled via the API
|
||||
PERMISSION_FIELDS = {
|
||||
"perm_access_dashboard",
|
||||
"perm_access_waiter_app",
|
||||
"perm_access_kds",
|
||||
"perm_cancel_orders",
|
||||
"perm_apply_discounts",
|
||||
"perm_modify_prices",
|
||||
"perm_open_orders",
|
||||
"perm_close_orders",
|
||||
"perm_view_reports",
|
||||
"perm_manage_staff",
|
||||
"perm_manage_tables",
|
||||
"perm_manage_menu",
|
||||
"perm_manage_settings",
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _waiter_or_404(waiter_id: int, db: Session) -> User:
|
||||
w = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not w:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
return w
|
||||
def _user_or_404(user_id: int, db: Session) -> User:
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="Staff member not found")
|
||||
return u
|
||||
|
||||
|
||||
def _guard_superadmin_target(target: User, acting_user: User):
|
||||
"""Prevent any modification of a superadmin by a non-superadmin."""
|
||||
if target.role == "superadmin" and acting_user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Cannot modify a superadmin account")
|
||||
|
||||
|
||||
def _apply_default_permissions(user: User, role: str):
|
||||
"""Apply the default permission set for a role onto a User object."""
|
||||
for field, value in get_default_permissions(role).items():
|
||||
setattr(user, field, value)
|
||||
|
||||
|
||||
# ── Metadata ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/roles")
|
||||
def list_roles():
|
||||
"""Return the full ordered list of available roles."""
|
||||
return ALL_ROLES
|
||||
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/on-shift", response_model=List[UserOut])
|
||||
def list_waiters_on_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Waiters with an active (not-ended) shift. Accessible to all staff."""
|
||||
"""Staff with an active (not-ended) shift. Accessible to all staff."""
|
||||
waiter_ids = db.query(WaiterShift.waiter_id).filter(WaiterShift.ended_at == None).subquery()
|
||||
return db.query(User).filter(User.id.in_(waiter_ids), User.role == "waiter", User.is_active == True).all()
|
||||
return db.query(User).filter(User.id.in_(waiter_ids), User.perm_access_waiter_app == True, User.is_active == True).all()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserOut])
|
||||
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
return db.query(User).filter(User.role == "waiter").all()
|
||||
def list_staff(db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
"""List all staff members regardless of role."""
|
||||
return db.query(User).order_by(User.created_at).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def create_staff(body: UserCreate, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
if body.role not in VALID_ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid role. Valid roles: {sorted(VALID_ROLES)}")
|
||||
if body.role == "superadmin" and user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Only a superadmin can create another superadmin")
|
||||
if db.query(User).filter(User.username == body.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
|
||||
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
|
||||
new_user = User(
|
||||
username=body.username,
|
||||
@@ -54,101 +99,156 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
|
||||
mobile_phone=body.mobile_phone,
|
||||
email=body.email,
|
||||
note=body.note,
|
||||
hourly_rate=body.hourly_rate,
|
||||
)
|
||||
# Apply role defaults (caller can override via the body fields)
|
||||
_apply_default_permissions(new_user, body.role)
|
||||
|
||||
# Allow explicit overrides from the request body for any perm field that was set
|
||||
for field in PERMISSION_FIELDS:
|
||||
val = getattr(body, field, None)
|
||||
if val is not None:
|
||||
setattr(new_user, field, val)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
|
||||
db.commit()
|
||||
|
||||
# Give waiter-app users access to all zones by default
|
||||
if new_user.perm_access_waiter_app:
|
||||
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
|
||||
db.commit()
|
||||
|
||||
add_user_to_system_group(db, new_user.id)
|
||||
return new_user
|
||||
|
||||
|
||||
@router.put("/{waiter_id}", response_model=UserOut)
|
||||
def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
def update_staff(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
|
||||
if body.role is not None and body.role not in VALID_ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid role. Valid roles: {sorted(VALID_ROLES)}")
|
||||
if body.role == "superadmin" and user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Only a superadmin can assign the superadmin role")
|
||||
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(waiter, field, value)
|
||||
setattr(target, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
@router.put("/{waiter_id}/reset-pin")
|
||||
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
target.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||
db.commit()
|
||||
return {"status": "pin reset"}
|
||||
|
||||
|
||||
@router.put("/{waiter_id}/block")
|
||||
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
waiter.is_active = not waiter.is_active
|
||||
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
target.is_active = not target.is_active
|
||||
db.commit()
|
||||
return {"is_active": waiter.is_active}
|
||||
return {"is_active": target.is_active}
|
||||
|
||||
|
||||
@router.put("/{waiter_id}/permission")
|
||||
def toggle_permission(
|
||||
waiter_id: int,
|
||||
body: PermissionToggle,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_staff_manager),
|
||||
):
|
||||
"""Toggle a single permission flag for a staff member."""
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
|
||||
if body.permission not in PERMISSION_FIELDS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown permission: {body.permission}")
|
||||
|
||||
# Superadmins always have all permissions — we store the value but enforce it at auth time
|
||||
setattr(target, body.permission, body.value)
|
||||
db.commit()
|
||||
return {"permission": body.permission, "value": body.value}
|
||||
|
||||
|
||||
@router.put("/{waiter_id}/reset-permissions")
|
||||
def reset_permissions(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
"""Reset a staff member's permissions to their role's defaults."""
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
_apply_default_permissions(target, target.role)
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_waiter(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
db.delete(waiter)
|
||||
def delete_staff(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
_guard_superadmin_target(target, user)
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Avatar upload / delete ───────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{waiter_id}/avatar", response_model=UserOut)
|
||||
async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
# Delete old avatar file if present
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
if target.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(target.avatar_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
||||
filename = f"waiter_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
filename = f"staff_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
dest = os.path.join(AVATAR_DIR, filename)
|
||||
os.makedirs(AVATAR_DIR, exist_ok=True)
|
||||
content = await file.read()
|
||||
with open(dest, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
waiter.avatar_url = f"/static/avatars/{filename}"
|
||||
target.avatar_url = f"/static/avatars/{filename}"
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}/avatar", response_model=UserOut)
|
||||
def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
target = _user_or_404(waiter_id, db)
|
||||
if target.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(target.avatar_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
waiter.avatar_url = None
|
||||
target.avatar_url = None
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
# ── Zone assignments ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.put("/{waiter_id}/zones")
|
||||
def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Replace all zone assignments for a waiter atomically.
|
||||
def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
"""Replace all zone assignments for a staff member atomically.
|
||||
|
||||
- all_zones=True → single NULL group_id row (sees everything)
|
||||
- group_ids=[1,2] → rows for groups 1 and 2 only
|
||||
- group_ids=[] → no rows at all (sees nothing)
|
||||
"""
|
||||
_waiter_or_404(waiter_id, db)
|
||||
# Wipe existing assignments
|
||||
_user_or_404(waiter_id, db)
|
||||
db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).delete()
|
||||
|
||||
if body.all_zones:
|
||||
@@ -165,7 +265,7 @@ def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_d
|
||||
# ── Assistant assignments (kept for backwards compat) ─────────────────────────
|
||||
|
||||
@router.post("/{waiter_id}/assign-assistant", response_model=AssistantAssignmentOut)
|
||||
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
existing = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.primary_waiter_id == waiter_id,
|
||||
AssistantAssignment.assistant_waiter_id == assistant_id,
|
||||
@@ -180,7 +280,7 @@ def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(ge
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}/assistant", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_assistant(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def remove_assistant(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
|
||||
assignment = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.primary_waiter_id == waiter_id
|
||||
).first()
|
||||
|
||||
87
local_backend/routers/ws.py
Normal file
87
local_backend/routers/ws.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
WebSocket endpoint — one persistent connection per connected client (waiter PWA, KDS).
|
||||
|
||||
Authentication: JWT token passed as query param ?token=<jwt>
|
||||
(Same pattern as SSE — browser WebSocket API also cannot set custom headers.)
|
||||
|
||||
Protocol:
|
||||
1. Client connects: ws://host/api/ws?token=<jwt>
|
||||
2. Client immediately sends: { "cursor": <last_seq_id> }
|
||||
(0 = first connect / no history; any positive int = resume from that point)
|
||||
3. Server replays all missed events (seq > cursor) as individual frames
|
||||
4. Server sends { "type": "ready" } to signal end of replay / start of live stream
|
||||
5. Live events arrive as: { "seq": 123, "type": "...", "data": { ... } }
|
||||
6. Every 25s server sends: { "type": "ping" }
|
||||
7. Client may respond: { "type": "pong" } (ignored if not sent)
|
||||
8. On disconnect/error the client reconnects and repeats from step 2
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from routers.deps import decode_token
|
||||
from services.ws_bus import connect, disconnect, get_events_since
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KEEPALIVE_INTERVAL = 25 # seconds
|
||||
|
||||
|
||||
@router.websocket("/connect")
|
||||
async def ws_endpoint(websocket: WebSocket, token: str = Query(...)):
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id: int = int(payload["sub"])
|
||||
except Exception:
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
# ── Wait for cursor frame ─────────────────────────────────────────────────
|
||||
cursor = 0
|
||||
try:
|
||||
raw = await asyncio.wait_for(websocket.receive_text(), timeout=10.0)
|
||||
msg = json.loads(raw)
|
||||
cursor = int(msg.get("cursor", 0))
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass # no cursor sent — treat as fresh connect (cursor=0)
|
||||
|
||||
# ── Register this connection ──────────────────────────────────────────────
|
||||
q = await connect(user_id)
|
||||
|
||||
try:
|
||||
# ── Replay missed events ──────────────────────────────────────────────
|
||||
if cursor > 0:
|
||||
missed = get_events_since(cursor, user_id)
|
||||
for event in missed:
|
||||
await websocket.send_text(json.dumps(event))
|
||||
|
||||
# Signal end of replay / start of live stream
|
||||
await websocket.send_text(json.dumps({"type": "ready"}))
|
||||
|
||||
# ── Live stream loop ──────────────────────────────────────────────────
|
||||
while True:
|
||||
try:
|
||||
frame = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL)
|
||||
if websocket.client_state == WebSocketState.CONNECTED:
|
||||
await websocket.send_text(frame)
|
||||
except asyncio.TimeoutError:
|
||||
# Send keepalive ping
|
||||
if websocket.client_state == WebSocketState.CONNECTED:
|
||||
await websocket.send_text(json.dumps({"type": "ping"}))
|
||||
else:
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("ws_endpoint: connection closed for user %s: %s", user_id, e)
|
||||
finally:
|
||||
await disconnect(user_id, q)
|
||||
Reference in New Issue
Block a user