Phase 1: scaffold local backend — models, schemas, routers, printer service, Docker
This commit is contained in:
5
local_backend/.env.example
Normal file
5
local_backend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
SITE_ID=your-unique-site-id
|
||||
CLOUD_URL=https://your-vps.com
|
||||
SECRET_KEY=generate-a-long-random-string-here
|
||||
LICENSE_GRACE_HOURS=24
|
||||
DATABASE_URL=sqlite:///./pos.db
|
||||
10
local_backend/Dockerfile
Normal file
10
local_backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
15
local_backend/config.py
Normal file
15
local_backend/config.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
SITE_ID: str = ""
|
||||
CLOUD_URL: str = "https://your-vps.com"
|
||||
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
||||
LICENSE_GRACE_HOURS: int = 24
|
||||
DATABASE_URL: str = "sqlite:///./pos.db"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
20
local_backend/database.py
Normal file
20
local_backend/database.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from config import settings
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False}, # needed for SQLite
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
43
local_backend/main.py
Normal file
43
local_backend/main.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from database import engine, Base
|
||||
from middleware.license_check import LicenseCheckMiddleware
|
||||
from services.cloud_sync import start_cloud_sync
|
||||
|
||||
# Import all models so SQLAlchemy can create their tables
|
||||
import models.user # noqa: F401
|
||||
import models.table # noqa: F401
|
||||
import models.printer # noqa: F401
|
||||
import models.product # noqa: F401
|
||||
import models.order # noqa: F401
|
||||
|
||||
from routers import auth, tables, products, orders, waiters, reports, system
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
sync_task = await start_cloud_sync()
|
||||
yield
|
||||
sync_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="POS Local Backend", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(LicenseCheckMiddleware)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(tables.router, prefix="/api/tables", tags=["tables"])
|
||||
app.include_router(products.router, prefix="/api/products", tags=["products"])
|
||||
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
|
||||
app.include_router(waiters.router, prefix="/api/waiters", tags=["waiters"])
|
||||
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
|
||||
app.include_router(system.router, prefix="/api/system", tags=["system"])
|
||||
0
local_backend/middleware/__init__.py
Normal file
0
local_backend/middleware/__init__.py
Normal file
35
local_backend/middleware/license_check.py
Normal file
35
local_backend/middleware/license_check.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# Shared mutable state — updated by cloud_sync.py
|
||||
license_state: dict = {
|
||||
"licensed": True,
|
||||
"locked": False,
|
||||
"expires_at": None,
|
||||
"last_sync": None,
|
||||
"sync_failed": False,
|
||||
}
|
||||
|
||||
EXEMPT_PATHS = {"/api/system/health"}
|
||||
|
||||
|
||||
class LicenseCheckMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path in EXEMPT_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
if license_state.get("locked"):
|
||||
return Response(
|
||||
content='{"detail": "System is locked by cloud administrator"}',
|
||||
status_code=423,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
if not license_state.get("licensed", True):
|
||||
return Response(
|
||||
content='{"detail": "License expired or invalid"}',
|
||||
status_code=402,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
0
local_backend/models/__init__.py
Normal file
0
local_backend/models/__init__.py
Normal file
72
local_backend/models/order.py
Normal file
72
local_backend/models/order.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from database import Base
|
||||
|
||||
|
||||
class Order(Base):
|
||||
__tablename__ = "orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
||||
opened_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
opened_at = Column(DateTime, default=datetime.utcnow)
|
||||
status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
table = relationship("Table", back_populates="orders")
|
||||
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
|
||||
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed")
|
||||
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
|
||||
print_logs = relationship("PrintLog", back_populates="order")
|
||||
|
||||
|
||||
class OrderWaiter(Base):
|
||||
__tablename__ = "order_waiters"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
order = relationship("Order", back_populates="waiters")
|
||||
waiter = relationship("User", back_populates="order_assignments")
|
||||
|
||||
|
||||
class OrderItem(Base):
|
||||
__tablename__ = "order_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
added_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
unit_price = Column(Float, nullable=False) # price snapshot at time of order
|
||||
selected_options = Column(Text, nullable=True) # JSON array of option ids
|
||||
removed_ingredients = Column(Text, nullable=True) # JSON array of ingredient ids
|
||||
notes = Column(Text, nullable=True)
|
||||
status = Column(String, default="active", nullable=False) # active|paid|cancelled
|
||||
added_at = Column(DateTime, default=datetime.utcnow)
|
||||
printed = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
order = relationship("Order", back_populates="items")
|
||||
product = relationship("Product", back_populates="order_items")
|
||||
added_by_user = relationship("User", back_populates="order_items")
|
||||
|
||||
|
||||
class PrintLog(Base):
|
||||
__tablename__ = "print_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
printer_id = Column(Integer, ForeignKey("printers.id"), nullable=False)
|
||||
printed_at = Column(DateTime, default=datetime.utcnow)
|
||||
item_ids = Column(Text, nullable=False) # JSON array of order_item ids
|
||||
success = Column(Boolean, nullable=False)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("Order", back_populates="print_logs")
|
||||
printer = relationship("Printer", back_populates="print_logs")
|
||||
16
local_backend/models/printer.py
Normal file
16
local_backend/models/printer.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
class Printer(Base):
|
||||
__tablename__ = "printers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
ip_address = Column(String, nullable=False)
|
||||
port = Column(Integer, default=9100, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
products = relationship("Product", back_populates="printer_zone")
|
||||
print_logs = relationship("PrintLog", back_populates="printer")
|
||||
52
local_backend/models/product.py
Normal file
52
local_backend/models/product.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
color = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
products = relationship("Product", back_populates="category")
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
base_price = Column(Float, nullable=False)
|
||||
is_available = Column(Boolean, default=True, nullable=False)
|
||||
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
||||
|
||||
category = relationship("Category", back_populates="products")
|
||||
printer_zone = relationship("Printer", back_populates="products")
|
||||
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
|
||||
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
|
||||
order_items = relationship("OrderItem", back_populates="product")
|
||||
|
||||
|
||||
class ProductOption(Base):
|
||||
__tablename__ = "product_options"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
|
||||
product = relationship("Product", back_populates="options")
|
||||
|
||||
|
||||
class ProductIngredient(Base):
|
||||
__tablename__ = "product_ingredients"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="ingredients")
|
||||
16
local_backend/models/table.py
Normal file
16
local_backend/models/table.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
class Table(Base):
|
||||
__tablename__ = "tables"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
number = Column(Integer, unique=True, nullable=False)
|
||||
label = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
floor_x = Column(Float, nullable=True)
|
||||
floor_y = Column(Float, nullable=True)
|
||||
|
||||
orders = relationship("Order", back_populates="table")
|
||||
43
local_backend/models/user.py
Normal file
43
local_backend/models/user.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, nullable=False, index=True)
|
||||
pin_hash = Column(String, nullable=False)
|
||||
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
||||
order_items = relationship("OrderItem", back_populates="added_by_user")
|
||||
order_assignments = relationship("OrderWaiter", back_populates="waiter")
|
||||
|
||||
primary_assignments = relationship(
|
||||
"AssistantAssignment",
|
||||
foreign_keys="AssistantAssignment.primary_waiter_id",
|
||||
back_populates="primary_waiter",
|
||||
)
|
||||
assistant_assignments = relationship(
|
||||
"AssistantAssignment",
|
||||
foreign_keys="AssistantAssignment.assistant_waiter_id",
|
||||
back_populates="assistant_waiter",
|
||||
)
|
||||
|
||||
|
||||
class AssistantAssignment(Base):
|
||||
__tablename__ = "assistant_assignments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
primary_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
assistant_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
primary_waiter = relationship("User", foreign_keys=[primary_waiter_id], back_populates="primary_assignments")
|
||||
assistant_waiter = relationship("User", foreign_keys=[assistant_waiter_id], back_populates="assistant_assignments")
|
||||
9
local_backend/requirements.txt
Normal file
9
local_backend/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.30.6
|
||||
sqlalchemy==2.0.36
|
||||
pydantic-settings==2.6.1
|
||||
python-escpos==3.1
|
||||
Pillow==10.4.0
|
||||
bcrypt==4.2.0
|
||||
pyjwt==2.9.0
|
||||
httpx==0.27.2
|
||||
0
local_backend/routers/__init__.py
Normal file
0
local_backend/routers/__init__.py
Normal file
64
local_backend/routers/auth.py
Normal file
64
local_backend/routers/auth.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import jwt
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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
|
||||
from schemas.user import UserOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TOKEN_EXPIRY_HOURS = 8
|
||||
# In-memory token blacklist (cleared on restart — acceptable for local use)
|
||||
_blacklisted_tokens: set[str] = set()
|
||||
|
||||
|
||||
def _make_token(user: User) -> str:
|
||||
payload = {
|
||||
"sub": str(user.id),
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRY_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
if token in _blacklisted_tokens:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token revoked")
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
||||
if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = _make_token(user)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh(token: str, db: Session = Depends(get_db)):
|
||||
payload = decode_token(token)
|
||||
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")
|
||||
_blacklisted_tokens.add(token)
|
||||
new_token = _make_token(user)
|
||||
return TokenResponse(access_token=new_token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(token: str):
|
||||
_blacklisted_tokens.add(token)
|
||||
return {"status": "logged out"}
|
||||
32
local_backend/routers/deps.py
Normal file
32
local_backend/routers/deps.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from routers.auth import decode_token
|
||||
|
||||
bearer = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
payload = decode_token(credentials.credentials)
|
||||
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 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")
|
||||
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")
|
||||
return user
|
||||
231
local_backend/routers/orders.py
Normal file
231
local_backend/routers/orders.py
Normal file
@@ -0,0 +1,231 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.order import Order, OrderItem, OrderWaiter
|
||||
from models.user import User, AssistantAssignment
|
||||
from models.product import Product
|
||||
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, PayItemsRequest, AssignWaiterRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.printer_service import route_and_print
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _can_access_order(order: Order, user: User, db: Session) -> bool:
|
||||
if user.role in ("manager", "sysadmin"):
|
||||
return True
|
||||
if order.opened_by == user.id:
|
||||
return True
|
||||
if any(ow.waiter_id == user.id for ow in order.waiters):
|
||||
return True
|
||||
# Assistant check: user is assistant to any waiter assigned to this order
|
||||
assigned_ids = {ow.waiter_id for ow in order.waiters}
|
||||
assistant_of = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.assistant_waiter_id == user.id,
|
||||
AssistantAssignment.primary_waiter_id.in_(assigned_ids),
|
||||
).first()
|
||||
return assistant_of is not None
|
||||
|
||||
|
||||
@router.get("/", response_model=List[OrderOut])
|
||||
def list_orders(
|
||||
order_status: Optional[str] = None,
|
||||
waiter_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
q = db.query(Order)
|
||||
if order_status:
|
||||
q = q.filter(Order.status == order_status)
|
||||
if waiter_id:
|
||||
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||
return q.all()
|
||||
|
||||
|
||||
@router.get("/my", response_model=List[OrderOut])
|
||||
def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
direct = db.query(Order).join(OrderWaiter).filter(
|
||||
OrderWaiter.waiter_id == user.id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
).all()
|
||||
# Also orders where user is opener but not explicitly assigned
|
||||
also_opened = db.query(Order).filter(
|
||||
Order.opened_by == user.id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
).all()
|
||||
seen = {o.id for o in direct}
|
||||
return direct + [o for o in also_opened if o.id not in seen]
|
||||
|
||||
|
||||
@router.get("/{order_id}", response_model=OrderOut)
|
||||
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return order
|
||||
|
||||
|
||||
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
|
||||
def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
existing = db.query(Order).filter(
|
||||
Order.table_id == body.table_id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Table already has an open order")
|
||||
order = Order(table_id=body.table_id, opened_by=user.id)
|
||||
db.add(order)
|
||||
db.flush()
|
||||
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
@router.post("/{order_id}/items", response_model=OrderOut)
|
||||
def add_items(
|
||||
order_id: int,
|
||||
body: AddItemsRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if order.status not in ("open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Order is not open")
|
||||
|
||||
new_item_ids = []
|
||||
for item_in in body.items:
|
||||
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
||||
if not product or not product.is_available:
|
||||
raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available")
|
||||
item = OrderItem(
|
||||
order_id=order_id,
|
||||
product_id=item_in.product_id,
|
||||
added_by=user.id,
|
||||
quantity=item_in.quantity,
|
||||
unit_price=product.base_price, # price snapshot
|
||||
selected_options=json.dumps(item_in.selected_options) if item_in.selected_options else None,
|
||||
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
|
||||
notes=item_in.notes,
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
new_item_ids.append(item.id)
|
||||
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
|
||||
# Printer routing runs in background — must never block the order save
|
||||
background_tasks.add_task(route_and_print, order_id, new_item_ids)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
@router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut)
|
||||
def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if notes is not None:
|
||||
item.notes = notes
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def cancel_item(order_id: int, item_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
item.status = "cancelled"
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{order_id}/pay")
|
||||
def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.id.in_(body.item_ids),
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
for item in items:
|
||||
item.status = "paid"
|
||||
|
||||
active_remaining = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == order_id, OrderItem.status == "active"
|
||||
).count()
|
||||
order.status = "paid" if active_remaining == 0 else "partially_paid"
|
||||
|
||||
db.commit()
|
||||
return {"status": order.status, "paid_item_ids": [i.id for i in items]}
|
||||
|
||||
|
||||
@router.post("/{order_id}/close")
|
||||
def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if order.status not in ("paid", "open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Cannot close order in current status")
|
||||
order.status = "closed"
|
||||
order.closed_at = datetime.utcnow()
|
||||
order.closed_by = user.id
|
||||
db.commit()
|
||||
return {"status": "closed"}
|
||||
|
||||
|
||||
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
order.status = "cancelled"
|
||||
order.closed_at = datetime.utcnow()
|
||||
order.closed_by = user.id
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/{order_id}/assign-waiter")
|
||||
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
existing = db.query(OrderWaiter).filter(
|
||||
OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == body.waiter_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Waiter already assigned")
|
||||
db.add(OrderWaiter(order_id=order_id, waiter_id=body.waiter_id))
|
||||
db.commit()
|
||||
return {"status": "assigned"}
|
||||
|
||||
|
||||
@router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
assignment = db.query(OrderWaiter).filter(
|
||||
OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == waiter_id
|
||||
).first()
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
94
local_backend/routers/products.py
Normal file
94
local_backend/routers/products.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.product import Product, Category, ProductOption, ProductIngredient
|
||||
from models.user import User
|
||||
from schemas.product import (
|
||||
ProductCreate, ProductUpdate, ProductOut,
|
||||
CategoryCreate, CategoryUpdate, CategoryOut,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Categories ───────────────────────────────────────────────────────────────
|
||||
|
||||
@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)):
|
||||
cat = Category(**body.model_dump())
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}", response_model=CategoryOut)
|
||||
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(cat, field, value)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
@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)):
|
||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
db.delete(cat)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_model=List[ProductOut])
|
||||
def list_products(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return db.query(Product).filter(Product.is_available == True).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
data = body.model_dump(exclude={"options", "ingredients"})
|
||||
product = Product(**data)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
for opt in body.options:
|
||||
db.add(ProductOption(product_id=product.id, **opt.model_dump()))
|
||||
for ing in body.ingredients:
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@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)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(product, field, value)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def deactivate_product(product_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
product.is_available = False
|
||||
db.commit()
|
||||
86
local_backend/routers/reports.py
Normal file
86
local_backend/routers/reports.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional, List
|
||||
|
||||
from database import get_db
|
||||
from models.order import Order, OrderItem, OrderWaiter
|
||||
from models.user import User
|
||||
from models.table import Table
|
||||
from schemas.order import OrderOut
|
||||
from schemas.table import TableOut
|
||||
from routers.deps import require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/shift")
|
||||
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),
|
||||
):
|
||||
target = report_date or date.today()
|
||||
start = datetime.combine(target, datetime.min.time())
|
||||
end = start + timedelta(days=1)
|
||||
|
||||
q = db.query(Order).filter(Order.opened_at >= start, Order.opened_at < end)
|
||||
if waiter_id:
|
||||
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||
orders = q.all()
|
||||
|
||||
summary = {}
|
||||
for order in orders:
|
||||
waiter = db.query(User).filter(User.id == order.opened_by).first()
|
||||
key = waiter.username if waiter else "unknown"
|
||||
if key not in summary:
|
||||
summary[key] = {"orders": 0, "items": 0, "total": 0.0}
|
||||
summary[key]["orders"] += 1
|
||||
for item in order.items:
|
||||
if item.status in ("active", "paid"):
|
||||
summary[key]["items"] += item.quantity
|
||||
summary[key]["total"] += item.unit_price * item.quantity
|
||||
|
||||
return {"date": str(target), "waiters": summary}
|
||||
|
||||
|
||||
@router.get("/orders/history", response_model=List[OrderOut])
|
||||
def order_history(
|
||||
from_date: Optional[str] = Query(default=None, alias="from"),
|
||||
to_date: Optional[str] = Query(default=None, alias="to"),
|
||||
waiter_id: Optional[int] = None,
|
||||
order_status: Optional[str] = Query(default=None, alias="status"),
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
q = db.query(Order)
|
||||
if from_date:
|
||||
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_date))
|
||||
if to_date:
|
||||
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_date))
|
||||
if waiter_id:
|
||||
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||
if order_status:
|
||||
q = q.filter(Order.status == order_status)
|
||||
return q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
|
||||
@router.get("/tables/summary")
|
||||
def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
tables = db.query(Table).filter(Table.is_active == True).all()
|
||||
result = []
|
||||
for table in tables:
|
||||
active_order = db.query(Order).filter(
|
||||
Order.table_id == table.id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
).first()
|
||||
result.append({
|
||||
"table": TableOut.model_validate(table),
|
||||
"status": active_order.status if active_order else "free",
|
||||
"order_id": active_order.id if active_order else None,
|
||||
})
|
||||
return result
|
||||
71
local_backend/routers/system.py
Normal file
71
local_backend/routers/system.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import time
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.printer import Printer
|
||||
from schemas.printer import PrinterUpdate, PrinterOut
|
||||
from routers.deps import get_current_user, require_manager, require_sysadmin
|
||||
from models.user import User
|
||||
from services import printer_service
|
||||
from middleware.license_check import license_state
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
printers = db.query(Printer).filter(Printer.is_active == True).all()
|
||||
printer_statuses = []
|
||||
for p in printers:
|
||||
reachable = printer_service.check_printer(p.ip_address, p.port)
|
||||
printer_statuses.append({"id": p.id, "name": p.name, "reachable": reachable})
|
||||
|
||||
return {
|
||||
"uptime_seconds": int(time.time() - _start_time),
|
||||
"licensed": license_state.get("licensed", True),
|
||||
"locked": license_state.get("locked", False),
|
||||
"expires_at": license_state.get("expires_at"),
|
||||
"printers": printer_statuses,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/printers/test")
|
||||
def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
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)
|
||||
return {"success": success, "error": error}
|
||||
|
||||
|
||||
@router.put("/printers/{printer_id}", response_model=PrinterOut)
|
||||
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_sysadmin)):
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(printer, field, value)
|
||||
db.commit()
|
||||
db.refresh(printer)
|
||||
return printer
|
||||
|
||||
|
||||
@router.post("/lock")
|
||||
def lock_system(token: str, user: User = Depends(require_sysadmin)):
|
||||
license_state["locked"] = True
|
||||
return {"status": "locked"}
|
||||
|
||||
|
||||
@router.post("/unlock")
|
||||
def unlock_system(token: str, user: User = Depends(require_sysadmin)):
|
||||
license_state["locked"] = False
|
||||
return {"status": "unlocked"}
|
||||
78
local_backend/routers/tables.py
Normal file
78
local_backend/routers/tables.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.table import Table
|
||||
from models.order import Order
|
||||
from models.user import User
|
||||
from schemas.table import TableCreate, TableUpdate, TableFloorplanUpdate, TableOut
|
||||
from routers.deps import get_current_user, require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TableOut])
|
||||
def list_tables(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return db.query(Table).filter(Table.is_active == True).all()
|
||||
|
||||
|
||||
@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)):
|
||||
if db.query(Table).filter(Table.number == body.number).first():
|
||||
raise HTTPException(status_code=400, detail="Table number already exists")
|
||||
table = Table(**body.model_dump())
|
||||
db.add(table)
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
return table
|
||||
|
||||
|
||||
@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)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(table, field, value)
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
return table
|
||||
|
||||
|
||||
@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def deactivate_table(table_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
table.is_active = False
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/{table_id}/status")
|
||||
def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
active_order = (
|
||||
db.query(Order)
|
||||
.filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid"]))
|
||||
.first()
|
||||
)
|
||||
return {
|
||||
"table": TableOut.model_validate(table),
|
||||
"active_order_id": active_order.id if active_order else None,
|
||||
"order_status": active_order.status if active_order else None,
|
||||
}
|
||||
|
||||
|
||||
@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)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
table.floor_x = body.floor_x
|
||||
table.floor_y = body.floor_y
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
return table
|
||||
100
local_backend/routers/waiters.py
Normal file
100
local_backend/routers/waiters.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, AssistantAssignment
|
||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut
|
||||
from routers.deps import require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ResetPinRequest:
|
||||
def __init__(self, pin: str):
|
||||
self.pin = pin
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
@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)):
|
||||
if db.query(User).filter(User.username == body.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
|
||||
new_user = User(username=body.username, pin_hash=pin_hash, role=body.role, is_active=body.is_active)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
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 = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not waiter:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(waiter, field, value)
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
|
||||
|
||||
@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 = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not waiter:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
waiter.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 = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not waiter:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
waiter.is_active = not waiter.is_active
|
||||
db.commit()
|
||||
return {"is_active": waiter.is_active}
|
||||
|
||||
|
||||
@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 = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not waiter:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
db.delete(waiter)
|
||||
db.commit()
|
||||
|
||||
|
||||
@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)):
|
||||
existing = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.primary_waiter_id == waiter_id,
|
||||
AssistantAssignment.assistant_waiter_id == assistant_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Assignment already exists")
|
||||
assignment = AssistantAssignment(primary_waiter_id=waiter_id, assistant_waiter_id=assistant_id)
|
||||
db.add(assignment)
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
@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)):
|
||||
assignment = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.primary_waiter_id == waiter_id
|
||||
).first()
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
0
local_backend/schemas/__init__.py
Normal file
0
local_backend/schemas/__init__.py
Normal file
12
local_backend/schemas/auth.py
Normal file
12
local_backend/schemas/auth.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel
|
||||
from schemas.user import UserOut
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
pin: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
user: UserOut
|
||||
58
local_backend/schemas/order.py
Normal file
58
local_backend/schemas/order.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class OrderItemInput(BaseModel):
|
||||
product_id: int
|
||||
quantity: int
|
||||
selected_options: Optional[List[int]] = None
|
||||
removed_ingredients: Optional[List[int]] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class AddItemsRequest(BaseModel):
|
||||
items: List[OrderItemInput]
|
||||
|
||||
|
||||
class OrderItemOut(BaseModel):
|
||||
id: int
|
||||
order_id: int
|
||||
product_id: int
|
||||
added_by: int
|
||||
quantity: int
|
||||
unit_price: float
|
||||
selected_options: Optional[str] = None
|
||||
removed_ingredients: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: str
|
||||
added_at: datetime
|
||||
printed: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderCreate(BaseModel):
|
||||
table_id: int
|
||||
|
||||
|
||||
class PayItemsRequest(BaseModel):
|
||||
item_ids: List[int]
|
||||
|
||||
|
||||
class AssignWaiterRequest(BaseModel):
|
||||
waiter_id: int
|
||||
|
||||
|
||||
class OrderOut(BaseModel):
|
||||
id: int
|
||||
table_id: int
|
||||
opened_by: int
|
||||
opened_at: datetime
|
||||
status: str
|
||||
closed_at: Optional[datetime] = None
|
||||
closed_by: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
items: List[OrderItemOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
22
local_backend/schemas/printer.py
Normal file
22
local_backend/schemas/printer.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class PrinterBase(BaseModel):
|
||||
name: str
|
||||
ip_address: str
|
||||
port: int = 9100
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class PrinterUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class PrinterOut(PrinterBase):
|
||||
id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
84
local_backend/schemas/product.py
Normal file
84
local_backend/schemas/product.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class CategoryOut(CategoryBase):
|
||||
id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProductOptionBase(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
|
||||
|
||||
class ProductOptionCreate(ProductOptionBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductOptionOut(ProductOptionBase):
|
||||
id: int
|
||||
product_id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProductIngredientBase(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class ProductIngredientCreate(ProductIngredientBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductIngredientOut(ProductIngredientBase):
|
||||
id: int
|
||||
product_id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProductBase(BaseModel):
|
||||
name: str
|
||||
category_id: Optional[int] = None
|
||||
base_price: float
|
||||
is_available: bool = True
|
||||
printer_zone_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
options: List[ProductOptionCreate] = []
|
||||
ingredients: List[ProductIngredientCreate] = []
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
base_price: Optional[float] = None
|
||||
is_available: Optional[bool] = None
|
||||
printer_zone_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProductOut(ProductBase):
|
||||
id: int
|
||||
options: List[ProductOptionOut] = []
|
||||
ingredients: List[ProductIngredientOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
31
local_backend/schemas/table.py
Normal file
31
local_backend/schemas/table.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class TableBase(BaseModel):
|
||||
number: int
|
||||
label: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class TableCreate(TableBase):
|
||||
pass
|
||||
|
||||
|
||||
class TableUpdate(BaseModel):
|
||||
number: Optional[int] = None
|
||||
label: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class TableFloorplanUpdate(BaseModel):
|
||||
floor_x: float
|
||||
floor_y: float
|
||||
|
||||
|
||||
class TableOut(TableBase):
|
||||
id: int
|
||||
floor_x: Optional[float] = None
|
||||
floor_y: Optional[float] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
35
local_backend/schemas/user.py
Normal file
35
local_backend/schemas/user.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
role: str
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
pin: str
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserOut(UserBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AssistantAssignmentOut(BaseModel):
|
||||
id: int
|
||||
primary_waiter_id: int
|
||||
assistant_waiter_id: int
|
||||
assigned_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
0
local_backend/services/__init__.py
Normal file
0
local_backend/services/__init__.py
Normal file
82
local_backend/services/cloud_sync.py
Normal file
82
local_backend/services/cloud_sync.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Periodic cloud check-in. Runs every 6 hours as an asyncio background task.
|
||||
If cloud is unreachable, falls back to last known state + grace period.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
from middleware.license_check import license_state
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYNC_INTERVAL_SECONDS = 6 * 60 * 60 # 6 hours
|
||||
STATE_FILE = Path("license_state.json")
|
||||
|
||||
|
||||
def _load_persisted_state():
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text())
|
||||
license_state.update(data)
|
||||
logger.info("Loaded persisted license state: %s", data)
|
||||
except Exception as e:
|
||||
logger.warning("Could not load license state file: %s", e)
|
||||
|
||||
|
||||
def _persist_state():
|
||||
try:
|
||||
STATE_FILE.write_text(json.dumps(license_state))
|
||||
except Exception as e:
|
||||
logger.warning("Could not persist license state: %s", e)
|
||||
|
||||
|
||||
async def _sync_once():
|
||||
if not settings.SITE_ID or not settings.CLOUD_URL:
|
||||
logger.debug("No SITE_ID/CLOUD_URL configured — skipping cloud sync")
|
||||
return
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.CLOUD_URL}/api/sites/heartbeat",
|
||||
json={"site_id": settings.SITE_ID},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
license_state["licensed"] = data.get("licensed", True)
|
||||
license_state["locked"] = data.get("locked", False)
|
||||
license_state["expires_at"] = data.get("expires_at")
|
||||
license_state["last_sync"] = datetime.now(timezone.utc).isoformat()
|
||||
license_state["sync_failed"] = False
|
||||
_persist_state()
|
||||
logger.info("Cloud sync OK: %s", data)
|
||||
except Exception as e:
|
||||
logger.warning("Cloud sync failed: %s", e)
|
||||
license_state["sync_failed"] = True
|
||||
# Check grace period
|
||||
last_sync_str = license_state.get("last_sync")
|
||||
if last_sync_str:
|
||||
last_sync = datetime.fromisoformat(last_sync_str)
|
||||
grace_expires = last_sync + timedelta(hours=settings.LICENSE_GRACE_HOURS)
|
||||
if datetime.now(timezone.utc) > grace_expires:
|
||||
logger.error("License grace period expired — marking as unlicensed")
|
||||
license_state["licensed"] = False
|
||||
|
||||
|
||||
async def _sync_loop():
|
||||
_load_persisted_state()
|
||||
while True:
|
||||
await _sync_once()
|
||||
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
async def start_cloud_sync() -> asyncio.Task:
|
||||
task = asyncio.create_task(_sync_loop())
|
||||
return task
|
||||
226
local_backend/services/printer_service.py
Normal file
226
local_backend/services/printer_service.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
ESC/POS printer service — Jolimark TP850UE confirmed configuration.
|
||||
|
||||
Key findings from printer testing:
|
||||
- Code page n=29 (CP737) is the only working Greek code page on this model.
|
||||
- All Greek text MUST be sent as raw CP737 bytes via p._raw() — never p.text().
|
||||
- Set the code page immediately after connecting, before any output.
|
||||
- 80mm paper = 48 chars wide at standard font. Double-height keeps 48-char width.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import datetime
|
||||
from typing import Tuple, List
|
||||
|
||||
from escpos.printer import Network
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import SessionLocal
|
||||
from models.order import Order, OrderItem, PrintLog
|
||||
from models.printer import Printer
|
||||
from models.product import Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LINE_WIDTH = 48
|
||||
PRINTER_TIMEOUT = 5
|
||||
|
||||
|
||||
# ── Low-level helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _get_printer(ip: str, port: int) -> Network:
|
||||
p = Network(ip, port, timeout=PRINTER_TIMEOUT)
|
||||
p._raw(b'\x1b\x40') # ESC @ — reset printer
|
||||
p._raw(b'\x1b\x74\x1d') # ESC t 29 — select CP737 (Greek) — confirmed n=29
|
||||
return p
|
||||
|
||||
|
||||
def _gr(text: str) -> bytes:
|
||||
"""Encode text to CP737 bytes. Replaces unknown chars instead of crashing."""
|
||||
return text.encode('cp737', errors='replace')
|
||||
|
||||
|
||||
def _raw_text(p: Network, text: str):
|
||||
"""Send text as raw CP737 bytes — the ONLY safe way to print Greek."""
|
||||
p._raw(_gr(text))
|
||||
|
||||
|
||||
def _divider(p: Network):
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
p._raw(_gr("-" * LINE_WIDTH + "\n"))
|
||||
|
||||
|
||||
def _item_line(name: str, qty: int) -> str:
|
||||
"""Build a dot-leader line: 'Club Sandwich . . . . 1' at 48 chars."""
|
||||
qty_str = str(qty)
|
||||
gap = LINE_WIDTH - len(name) - len(qty_str)
|
||||
if gap < 3:
|
||||
return f"{name} {qty_str}"
|
||||
dots = (". " * ((gap // 2) + 1))[:gap]
|
||||
return f"{name}{dots}{qty_str}"
|
||||
|
||||
|
||||
def check_printer(ip: str, port: int) -> bool:
|
||||
"""Quick TCP connect check — no data sent."""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(2)
|
||||
s.connect((ip, port))
|
||||
s.close()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
||||
try:
|
||||
p = _get_printer(ip, port)
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, f"TEST — {name}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
_raw_text(p, f"{now}\n")
|
||||
p._raw(b'\n\n\n')
|
||||
p.cut()
|
||||
p.close()
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
logger.error("Test print failed for %s:%s — %s", ip, port, e)
|
||||
return False, str(e)
|
||||
|
||||
|
||||
# ── Receipt formatting ───────────────────────────────────────────────────────
|
||||
|
||||
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
|
||||
# Header
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x38') # bold + double height + double width
|
||||
_raw_text(p, f"Παραγγελια #{order.id}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
# Meta
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
p._raw(b'\x1b\x21\x10') # double height only — keeps 48-char width
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
_raw_text(p, f"Date: {now}\n")
|
||||
_raw_text(p, f"Table: {order.table_id}\n")
|
||||
_raw_text(p, f"Waiter: {order.opened_by}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
# Items
|
||||
for item in items:
|
||||
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||
name = product.name if product else f"Product #{item.product_id}"
|
||||
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
p._raw(b'\x1b\x45\x01') # bold on
|
||||
_raw_text(p, _item_line(name, item.quantity) + "\n")
|
||||
p._raw(b'\x1b\x45\x00') # bold off
|
||||
|
||||
if item.removed_ingredients:
|
||||
try:
|
||||
removed_ids = json.loads(item.removed_ingredients)
|
||||
if removed_ids:
|
||||
_raw_text(p, f" - χωρις: {', '.join(str(i) for i in removed_ids)}\n")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
if item.selected_options:
|
||||
try:
|
||||
option_ids = json.loads(item.selected_options)
|
||||
if option_ids:
|
||||
_raw_text(p, f" + επιλογες: {', '.join(str(i) for i in option_ids)}\n")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
if item.notes:
|
||||
_raw_text(p, f" (i) {item.notes}\n")
|
||||
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
|
||||
_divider(p)
|
||||
|
||||
if order.notes:
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, "Σημειωσεις:\n")
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"{order.notes}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, "Τελος Παραγγελιας\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
p._raw(b'\n\n\n')
|
||||
p.cut()
|
||||
|
||||
|
||||
# ── Routing logic ────────────────────────────────────────────────────────────
|
||||
|
||||
def route_and_print(order_id: int, item_ids: List[int]):
|
||||
"""
|
||||
Background task: group items by printer zone, send to each printer.
|
||||
Printer failures are logged but never raise — order is already saved.
|
||||
"""
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
logger.error("route_and_print: order %s not found", order_id)
|
||||
return
|
||||
|
||||
items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all()
|
||||
|
||||
# Group items by printer zone
|
||||
zone_map: dict[int, List[OrderItem]] = {}
|
||||
unzoned: List[OrderItem] = []
|
||||
for item in items:
|
||||
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||
if product and product.printer_zone_id:
|
||||
zone_map.setdefault(product.printer_zone_id, []).append(item)
|
||||
else:
|
||||
unzoned.append(item)
|
||||
|
||||
if unzoned:
|
||||
logger.warning("order %s has %d item(s) with no printer zone — skipped", order_id, len(unzoned))
|
||||
|
||||
for printer_id, zone_items in zone_map.items():
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first()
|
||||
if not printer:
|
||||
logger.warning("Printer %s not found or inactive", printer_id)
|
||||
continue
|
||||
|
||||
success = False
|
||||
error_msg = None
|
||||
try:
|
||||
p = _get_printer(printer.ip_address, printer.port)
|
||||
_print_kitchen_ticket(p, order, zone_items, db)
|
||||
p.close()
|
||||
success = True
|
||||
# Mark items as printed
|
||||
for item in zone_items:
|
||||
item.printed = True
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error("Print failed for printer %s (%s:%s): %s", printer.name, printer.ip_address, printer.port, e)
|
||||
|
||||
log = PrintLog(
|
||||
order_id=order_id,
|
||||
printer_id=printer_id,
|
||||
item_ids=json.dumps([i.id for i in zone_items]),
|
||||
success=success,
|
||||
error_message=error_msg,
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error in route_and_print for order %s: %s", order_id, e)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user