feat(local_backend): reservations, print analytics, workday summary, zone-PIN management

- New reservations module: model, schema, router (CRUD + status updates + upcoming alerts)
  and background task for auto-expiring stale reservations
- Reports: print_products, print_categories, print_tables analytics endpoints
  plus meta_products and business_day_summary for workday close/view flow
- printer_service: configurable font sizes/weights, donut/bar chart print layout helpers,
  analytics print blocks per printer
- tables/schemas: surfaced color, zone, and other new fields on Table, Product, User, Printer
- demo_seed.py for quick dev DB population; wipe_database.py utility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:25:22 +03:00
parent aed71a18d8
commit 79bd3b0f41
25 changed files with 2017 additions and 146 deletions

View File

@@ -11,6 +11,7 @@ class PrinterBase(BaseModel):
port: int = 9100
is_active: bool = True
protocol: str = "escpos_tcp"
line_width: int = 48
class PrinterCreate(PrinterBase):
@@ -23,6 +24,7 @@ class PrinterUpdate(BaseModel):
port: Optional[int] = None
is_active: Optional[bool] = None
protocol: Optional[str] = None
line_width: Optional[int] = None
class PrinterOut(PrinterBase):

View File

@@ -257,6 +257,7 @@ class PreferenceSetOut(BaseModel):
class ProductBase(BaseModel):
name: str
description: Optional[str] = None
category_id: Optional[int] = None
base_price: float
is_available: bool = True
@@ -269,7 +270,7 @@ class ProductBase(BaseModel):
digital_name: Optional[str] = None
digital_description: Optional[str] = None
digital_price: Optional[float] = None
digital_discount: float = 0.0
digital_discount: Optional[float] = None
digital_image_url: Optional[str] = None
@@ -282,6 +283,7 @@ class ProductCreate(ProductBase):
class ProductUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
category_id: Optional[int] = None
base_price: Optional[float] = None
is_available: Optional[bool] = None

View File

@@ -0,0 +1,93 @@
from pydantic import BaseModel, field_validator
from typing import Optional
from datetime import datetime, timezone
class ReservationCreate(BaseModel):
guest_name: str
party_size: int
phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
reserved_for: datetime
table_id: Optional[int] = None
source: str = "manager_app"
online_customer_id: Optional[str] = None
@field_validator("reserved_for", mode="before")
@classmethod
def strip_timezone(cls, v):
if isinstance(v, str):
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
elif isinstance(v, datetime):
dt = v
else:
return v
# Store as naive local — strip any tz info so SQLite keeps it as-is
if dt.tzinfo is not None:
dt = dt.astimezone().replace(tzinfo=None)
return dt
class ReservationUpdate(BaseModel):
guest_name: Optional[str] = None
party_size: Optional[int] = None
phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
reserved_for: Optional[datetime] = None
table_id: Optional[int] = None
status: Optional[str] = None
@field_validator("reserved_for", mode="before")
@classmethod
def strip_timezone(cls, v):
if v is None:
return v
if isinstance(v, str):
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
elif isinstance(v, datetime):
dt = v
else:
return v
if dt.tzinfo is not None:
dt = dt.astimezone().replace(tzinfo=None)
return dt
class ReservationStatusUpdate(BaseModel):
status: str # arrived | cancelled | no_show | pending
class ReservationOut(BaseModel):
id: int
guest_name: str
party_size: int
phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
reserved_for: datetime
table_id: Optional[int] = None
status: str
source: str
created_by_user_id: Optional[int] = None
online_customer_id: Optional[str] = None
created_at: datetime
updated_at: datetime
# Denormalized for convenience
table_label: Optional[str] = None
created_by_name: Optional[str] = None
model_config = {"from_attributes": True}
# Compact summary embedded in TableOut for the RESERVED badge
class ReservationSummary(BaseModel):
id: int
guest_name: str
party_size: int
reserved_for: datetime
status: str
model_config = {"from_attributes": True}

View File

@@ -1,5 +1,6 @@
from pydantic import BaseModel, field_validator
from typing import Optional, List
from datetime import datetime
MAX_TABLE_NAME_LENGTH = 6
@@ -73,6 +74,16 @@ class TableFloorplanUpdate(BaseModel):
floor_y: float
class UpcomingReservation(BaseModel):
id: int
guest_name: str
party_size: int
reserved_for: datetime
status: str
model_config = {"from_attributes": True}
class TableOut(BaseModel):
id: int
number: int
@@ -83,5 +94,6 @@ class TableOut(BaseModel):
floor_y: Optional[float] = None
group: Optional[TableGroupOut] = None
has_active_order: bool = False
upcoming_reservation: Optional[UpcomingReservation] = None
model_config = {"from_attributes": True}

View File

@@ -11,6 +11,7 @@ class UserBase(BaseModel):
full_name: Optional[str] = None
nickname: Optional[str] = None
mobile_phone: Optional[str] = None
note: Optional[str] = None
avatar_url: Optional[str] = None
email: Optional[str] = None
@@ -26,6 +27,8 @@ class UserUpdate(BaseModel):
full_name: Optional[str] = None
nickname: Optional[str] = None
mobile_phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
class WaiterZoneOut(BaseModel):