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:
69
local_backend/schemas/chat.py
Normal file
69
local_backend/schemas/chat.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from pydantic import BaseModel, field_serializer
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
def _as_utc_iso(dt: datetime | None) -> str | None:
|
||||
"""Serialize a datetime to an ISO-8601 string with explicit Z suffix.
|
||||
SQLite stores datetimes without tzinfo; we treat all stored values as UTC."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
|
||||
|
||||
|
||||
class ConversationCreate(BaseModel):
|
||||
type: str # 'direct' | 'group'
|
||||
name: Optional[str] = None
|
||||
participant_ids: List[int]
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
body: str
|
||||
|
||||
|
||||
class ParticipantOut(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
joined_at: datetime
|
||||
last_read_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_serializer('joined_at')
|
||||
def ser_joined_at(self, v): return _as_utc_iso(v)
|
||||
|
||||
@field_serializer('last_read_at')
|
||||
def ser_last_read_at(self, v): return _as_utc_iso(v)
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
id: int
|
||||
conversation_id: int
|
||||
sender_id: int
|
||||
sender_name: str
|
||||
body: str
|
||||
sent_at: datetime
|
||||
is_deleted: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_serializer('sent_at')
|
||||
def ser_sent_at(self, v): return _as_utc_iso(v)
|
||||
|
||||
|
||||
class ConversationOut(BaseModel):
|
||||
id: int
|
||||
type: str
|
||||
name: Optional[str] = None
|
||||
is_system: bool
|
||||
created_at: datetime
|
||||
participants: List[ParticipantOut]
|
||||
last_message: Optional[MessageOut] = None
|
||||
unread_count: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_serializer('created_at')
|
||||
def ser_created_at(self, v): return _as_utc_iso(v)
|
||||
@@ -36,6 +36,8 @@ class StaffMessageOut(BaseModel):
|
||||
body: str
|
||||
target_waiter_ids: str # raw JSON string — frontend parses
|
||||
table_ids: str
|
||||
message_type: str = "manager"
|
||||
kds_zone: Optional[str] = None
|
||||
created_at: datetime
|
||||
acked_by: List[int] = [] # waiter ids who have acked
|
||||
|
||||
|
||||
@@ -16,14 +16,19 @@ class SelectedOptionInput(BaseModel):
|
||||
|
||||
class OrderItemInput(BaseModel):
|
||||
product_id: int
|
||||
quantity: int
|
||||
quantity: float
|
||||
selected_options: Optional[List[SelectedOptionInput]] = None
|
||||
removed_ingredients: Optional[List[str]] = None
|
||||
notes: Optional[str] = None
|
||||
price_adjustment: Optional[float] = None # on-the-fly price delta, applied at add-time
|
||||
is_service_item: bool = False # priceless: print-once ephemeral; priced: saved as real item
|
||||
course_id: Optional[int] = None
|
||||
|
||||
|
||||
class AddItemsRequest(BaseModel):
|
||||
items: List[OrderItemInput]
|
||||
order_note: Optional[str] = None # whole-order note, written to Order.notes
|
||||
customer_count: Optional[int] = None # number of customers at the table
|
||||
|
||||
|
||||
class ProductNameOut(BaseModel):
|
||||
@@ -38,18 +43,24 @@ class OrderItemOut(BaseModel):
|
||||
product_id: int
|
||||
product: Optional[ProductNameOut] = None
|
||||
added_by: int
|
||||
quantity: int
|
||||
quantity: float
|
||||
unit_type: str = "piece"
|
||||
unit_price: float
|
||||
selected_options: Optional[str] = None
|
||||
removed_ingredients: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: str
|
||||
kds_status: str = "pending"
|
||||
added_at: UTCDatetime
|
||||
printed: bool
|
||||
paid_by: Optional[int] = None
|
||||
paid_at: Optional[UTCDatetime] = None
|
||||
payment_method: Optional[str] = None
|
||||
paid_in_shift_id: Optional[int] = None
|
||||
price_adjustment: float = 0.0
|
||||
course_id: Optional[int] = None
|
||||
deal_id: Optional[int] = None
|
||||
linked_item_id: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -63,12 +74,14 @@ class PrintResultOut(BaseModel):
|
||||
class AddItemsResponse(BaseModel):
|
||||
order: "OrderOut"
|
||||
print_results: List[PrintResultOut]
|
||||
deal_offers: List[dict] = [] # DealOfferOut dicts — serialized to avoid circular imports
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderCreate(BaseModel):
|
||||
table_id: int
|
||||
table_id: Optional[int] = None
|
||||
order_type: Optional[str] = "here" # here | takeaway | delivery
|
||||
|
||||
|
||||
class PayItemsRequest(BaseModel):
|
||||
@@ -109,6 +122,23 @@ class AuditLogOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PrintJobOut(BaseModel):
|
||||
id: int
|
||||
printer_id: int
|
||||
zone_id: Optional[int] = None
|
||||
item_ids: str
|
||||
copies: int = 1
|
||||
status: str
|
||||
retry_count: int = 0
|
||||
first_attempted_at: Optional[UTCDatetime] = None
|
||||
last_attempted_at: Optional[UTCDatetime] = None
|
||||
succeeded_at: Optional[UTCDatetime] = None
|
||||
cancelled_at: Optional[UTCDatetime] = None
|
||||
cancel_reason: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderOut(BaseModel):
|
||||
id: int
|
||||
table_id: Optional[int] = None
|
||||
@@ -129,8 +159,11 @@ class OrderOut(BaseModel):
|
||||
online_customer_address: Optional[str] = None
|
||||
online_customer_notes: Optional[str] = None
|
||||
online_order_type: Optional[str] = None
|
||||
order_type: str = "here"
|
||||
customer_count: Optional[int] = None
|
||||
items: List[OrderItemOut] = []
|
||||
waiters: List[OrderWaiterOut] = []
|
||||
audit_logs: List[AuditLogOut] = []
|
||||
print_jobs: List[PrintJobOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
384
local_backend/schemas/pricing.py
Normal file
384
local_backend/schemas/pricing.py
Normal file
@@ -0,0 +1,384 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional, List, Any
|
||||
from pydantic import BaseModel, field_validator
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Groups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PriceGroupCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
is_active: bool = False
|
||||
auto_enable_time: Optional[str] = None # "HH:MM"
|
||||
auto_disable_time: Optional[str] = None # "HH:MM"
|
||||
auto_days: Optional[List[int]] = None # [0..6] Mon=0
|
||||
|
||||
|
||||
class PriceGroupUpdate(PriceGroupCreate):
|
||||
pass
|
||||
|
||||
|
||||
class PriceGroupOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
color: Optional[str]
|
||||
is_active: bool
|
||||
auto_enable_time: Optional[str]
|
||||
auto_disable_time: Optional[str]
|
||||
auto_days: Optional[List[int]]
|
||||
created_at: Optional[datetime]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("is_active", mode="before")
|
||||
@classmethod
|
||||
def coerce_bool(cls, v):
|
||||
return bool(v)
|
||||
|
||||
@field_validator("auto_days", mode="before")
|
||||
@classmethod
|
||||
def parse_auto_days(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Modifier Conditions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ConditionIn(BaseModel):
|
||||
condition_type: str
|
||||
params: dict = {}
|
||||
|
||||
|
||||
class ConditionOut(BaseModel):
|
||||
id: int
|
||||
condition_type: str
|
||||
params: dict
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("params", mode="before")
|
||||
@classmethod
|
||||
def parse_params(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
return {}
|
||||
return v or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Modifier Targets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ModifierTargetIn(BaseModel):
|
||||
target_type: str # 'all'|'item'|'category'|'prep_zone'|'tag'
|
||||
target_id: Optional[int] = None
|
||||
target_tag: Optional[str] = None
|
||||
target_ids: Optional[List[int]] = None
|
||||
target_tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ModifierTargetOut(BaseModel):
|
||||
id: int
|
||||
target_type: str
|
||||
target_id: Optional[int]
|
||||
target_tag: Optional[str]
|
||||
target_ids: Optional[List[int]] = None
|
||||
target_tags: Optional[List[str]] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("target_ids", "target_tags", mode="before")
|
||||
@classmethod
|
||||
def parse_json_list(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
return json.loads(v)
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Modifiers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PriceModifierCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_favorite: bool = False
|
||||
allow_stack: bool = False
|
||||
sort_order: int = 0
|
||||
scope: str = "global" # 'item' | 'global'
|
||||
item_id: Optional[int] = None
|
||||
action_type: str # 'set' | 'add_amount' | 'add_percent'
|
||||
action_value: float
|
||||
round_to: Optional[str] = None # '0.05'|'0.10'|'0.20'|'0.50'|'x.99'|'x.00'
|
||||
conditions: List[ConditionIn] = []
|
||||
targets: List[ModifierTargetIn] = [] # only relevant for scope='global'
|
||||
|
||||
|
||||
class PriceModifierUpdate(PriceModifierCreate):
|
||||
pass
|
||||
|
||||
|
||||
class PriceModifierOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
color: Optional[str]
|
||||
is_active: bool
|
||||
is_favorite: bool
|
||||
allow_stack: bool
|
||||
sort_order: int
|
||||
scope: str
|
||||
item_id: Optional[int]
|
||||
action_type: str
|
||||
action_value: float
|
||||
round_to: Optional[str]
|
||||
conditions: List[ConditionOut]
|
||||
targets: List[ModifierTargetOut]
|
||||
created_at: Optional[datetime]
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("is_active", "is_favorite", "allow_stack", mode="before")
|
||||
@classmethod
|
||||
def coerce_bool(cls, v):
|
||||
return bool(v)
|
||||
|
||||
|
||||
class PriceModifierReorderItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int
|
||||
|
||||
|
||||
class PriceModifierReorderRequest(BaseModel):
|
||||
items: List[PriceModifierReorderItem]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DealTargetIn(BaseModel):
|
||||
target_type: str # 'item'|'category'|'tag'|'any'
|
||||
target_id: Optional[int] = None
|
||||
target_tag: Optional[str] = None
|
||||
target_ids: Optional[List[int]] = None
|
||||
target_tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class DealTargetOut(BaseModel):
|
||||
id: int
|
||||
target_type: str
|
||||
target_id: Optional[int]
|
||||
target_tag: Optional[str]
|
||||
target_ids: Optional[List[int]] = None
|
||||
target_tags: Optional[List[str]] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("target_ids", "target_tags", mode="before")
|
||||
@classmethod
|
||||
def parse_json_list(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
return json.loads(v)
|
||||
return v
|
||||
|
||||
|
||||
class DealCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
action_type: str
|
||||
# Action payload — which fields are set depends on action_type
|
||||
action_modifier_id: Optional[int] = None # for apply_modifier
|
||||
action_value: Optional[float] = None # for set_price / add_amount / add_percent
|
||||
action_free_item_id: Optional[int] = None # for free_item
|
||||
action_free_target_type: Optional[str] = None # for free_choice: 'item'|'category'|'tag'
|
||||
action_free_target_ids: Optional[List[Any]] = None # for free_choice
|
||||
action_free_quantity: int = 1
|
||||
conditions: List[ConditionIn] = []
|
||||
targets: List[DealTargetIn] = []
|
||||
|
||||
|
||||
class DealUpdate(DealCreate):
|
||||
pass
|
||||
|
||||
|
||||
class DealOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
color: Optional[str]
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
action_type: str
|
||||
action_modifier_id: Optional[int]
|
||||
action_value: Optional[float]
|
||||
action_free_item_id: Optional[int]
|
||||
action_free_target_type: Optional[str]
|
||||
action_free_target_ids: Optional[List[Any]]
|
||||
action_free_quantity: int
|
||||
conditions: List[ConditionOut]
|
||||
targets: List[DealTargetOut]
|
||||
created_at: Optional[datetime]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("is_active", mode="before")
|
||||
@classmethod
|
||||
def coerce_bool(cls, v):
|
||||
return bool(v)
|
||||
|
||||
@field_validator("action_free_target_ids", mode="before")
|
||||
@classmethod
|
||||
def parse_target_ids(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Waiter Discount Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WaiterDiscountSettingsIn(BaseModel):
|
||||
can_apply_discounts: bool = False
|
||||
max_discount_percent: Optional[float] = None
|
||||
max_discount_amount: Optional[float] = None
|
||||
max_total_value_shift: Optional[float] = None
|
||||
max_total_value_workday: Optional[float] = None
|
||||
max_items_per_shift: Optional[int] = None
|
||||
max_items_per_workday: Optional[int] = None
|
||||
max_items_per_order: Optional[int] = None
|
||||
|
||||
|
||||
class WaiterDiscountSettingsOut(WaiterDiscountSettingsIn):
|
||||
id: int
|
||||
user_id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("can_apply_discounts", mode="before")
|
||||
@classmethod
|
||||
def coerce_bool(cls, v):
|
||||
return bool(v)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Event Log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PriceEventOut(BaseModel):
|
||||
id: int
|
||||
order_id: int
|
||||
order_item_id: Optional[int]
|
||||
event_type: str
|
||||
modifier_id: Optional[int]
|
||||
deal_id: Optional[int]
|
||||
price_before: Optional[float]
|
||||
price_after: Optional[float]
|
||||
delta_amount: Optional[float]
|
||||
applied_by_user_id: Optional[int]
|
||||
waiter_note: Optional[str]
|
||||
conditions_snapshot: Optional[dict]
|
||||
selected_item_ids: Optional[List[int]]
|
||||
applied_at: datetime
|
||||
# Resolved names (joined at query time)
|
||||
modifier_name: Optional[str] = None
|
||||
deal_name: Optional[str] = None
|
||||
applied_by_username: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("conditions_snapshot", mode="before")
|
||||
@classmethod
|
||||
def parse_snapshot(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator("selected_item_ids", mode="before")
|
||||
@classmethod
|
||||
def parse_item_ids(cls, v):
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Waiter discount apply (PWA-facing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ApplyDiscountRequest(BaseModel):
|
||||
item_ids: List[int]
|
||||
discount_percent: float # e.g. 10.0 = 10%; final price rounds to nearest 0.10
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class ApplyDiscountResponse(BaseModel):
|
||||
applied: List[dict] # [{item_id, price_before, price_after, delta}]
|
||||
remaining_budget_shift: Optional[float] = None
|
||||
remaining_budget_workday: Optional[float] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deal offer response (returned on add_items when deals are newly triggered)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DealOfferOut(BaseModel):
|
||||
deal_id: int
|
||||
deal_name: str
|
||||
action_type: str
|
||||
free_item_id: Optional[int] = None
|
||||
free_item_name: Optional[str] = None
|
||||
free_choices: List[dict] = [] # [{id, name}]
|
||||
free_quantity: int = 1
|
||||
target_order_item_id: Optional[int] = None
|
||||
price_before: Optional[float] = None
|
||||
price_after: Optional[float] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deal offer accept (waiter confirms a deal)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AcceptDealOfferRequest(BaseModel):
|
||||
deal_id: int
|
||||
selected_product_ids: Optional[List[int]] = None # for free_choice
|
||||
|
||||
|
||||
class DismissDealOfferRequest(BaseModel):
|
||||
deal_id: int
|
||||
@@ -4,6 +4,16 @@ from typing import Optional
|
||||
|
||||
PROTOCOLS = ["escpos_tcp"] # extend later as needed
|
||||
|
||||
# Known printer profiles — (codepage_n, python_encoding)
|
||||
# codepage_n: the n byte in ESC t n sent on connect
|
||||
# python_encoding: used to encode Greek text to bytes
|
||||
PRINTER_PROFILES = {
|
||||
"jolimark": {"label": "Jolimark TP850UE", "codepage_n": 29, "encoding": "cp737"},
|
||||
"samas": {"label": "S4MAS Giant 100", "codepage_n": 29, "encoding": "cp737"},
|
||||
"netum": {"label": "NETUM NT8330L", "codepage_n": 14, "encoding": "cp737"},
|
||||
}
|
||||
DEFAULT_PROFILE = "jolimark"
|
||||
|
||||
|
||||
class PrinterBase(BaseModel):
|
||||
name: str
|
||||
@@ -12,7 +22,7 @@ class PrinterBase(BaseModel):
|
||||
is_active: bool = True
|
||||
protocol: str = "escpos_tcp"
|
||||
line_width: int = 48
|
||||
duplicates: int = 0
|
||||
codepage_n: int = 29
|
||||
|
||||
|
||||
class PrinterCreate(PrinterBase):
|
||||
@@ -26,7 +36,7 @@ class PrinterUpdate(BaseModel):
|
||||
is_active: Optional[bool] = None
|
||||
protocol: Optional[str] = None
|
||||
line_width: Optional[int] = None
|
||||
duplicates: Optional[int] = None
|
||||
codepage_n: Optional[int] = None
|
||||
|
||||
|
||||
class PrinterOut(PrinterBase):
|
||||
|
||||
@@ -52,6 +52,24 @@ class ParentGeneralReorderItem(BaseModel):
|
||||
general_sort_order: int
|
||||
|
||||
|
||||
# ── Modifier Groups ───────────────────────────────────────────────────────────
|
||||
|
||||
class ModifierGroupCreate(BaseModel):
|
||||
modifier_type: str # "option" | "ingredient" | "preference"
|
||||
name: str
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class ModifierGroupOut(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
modifier_type: str
|
||||
name: str
|
||||
sort_order: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Quick Options ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ProductQuickOptionCreate(BaseModel):
|
||||
@@ -84,14 +102,23 @@ class OptionSubChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
# When True waiter can add more than 1 of this sub-choice
|
||||
allow_multiple: bool = False
|
||||
# When True renders as half-width card on the PWA
|
||||
is_compact: bool = False
|
||||
|
||||
|
||||
class ProductOptionBase(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
allow_multiple: bool = False
|
||||
# When True waiter can select more than one sub-choice at once
|
||||
multi_select: bool = False
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
# When True renders the option itself as half-width card on the PWA
|
||||
is_compact: bool = False
|
||||
group_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProductOptionCreate(ProductOptionBase):
|
||||
@@ -117,9 +144,12 @@ class ProductOptionOut(ProductOptionBase):
|
||||
'name': data.name,
|
||||
'extra_cost': data.extra_cost,
|
||||
'allow_multiple': getattr(data, 'allow_multiple', False) or False,
|
||||
'multi_select': getattr(data, 'multi_select', False) or False,
|
||||
'sub_choices': parsed,
|
||||
'is_favorite': getattr(data, 'is_favorite', False) or False,
|
||||
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
|
||||
'is_compact': getattr(data, 'is_compact', False) or False,
|
||||
'group_id': getattr(data, 'group_id', None),
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -131,6 +161,9 @@ class ProductIngredientBase(BaseModel):
|
||||
extra_cost: float = 0.0
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
# When True renders as half-width card on the PWA
|
||||
is_compact: bool = False
|
||||
group_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProductIngredientCreate(ProductIngredientBase):
|
||||
@@ -150,6 +183,7 @@ class SubChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
is_compact: bool = False
|
||||
|
||||
|
||||
# ── Shared subset (set-level, shown for all non-disabling choices) ─────────────
|
||||
@@ -158,6 +192,7 @@ class SharedSubsetChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
is_compact: bool = False
|
||||
|
||||
|
||||
class SharedSubset(BaseModel):
|
||||
@@ -172,6 +207,8 @@ class PreferenceChoiceCreate(BaseModel):
|
||||
extra_cost: float = 0.0
|
||||
sub_choices: List[SubChoice] = []
|
||||
disables_subset: bool = False
|
||||
# When True renders as half-width card on the PWA
|
||||
is_compact: bool = False
|
||||
|
||||
|
||||
class PreferenceChoiceOut(BaseModel):
|
||||
@@ -181,6 +218,7 @@ class PreferenceChoiceOut(BaseModel):
|
||||
extra_cost: float = 0.0
|
||||
sub_choices: List[SubChoice] = []
|
||||
disables_subset: bool = False
|
||||
is_compact: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -203,6 +241,7 @@ class PreferenceChoiceOut(BaseModel):
|
||||
'extra_cost': data.extra_cost,
|
||||
'sub_choices': parsed,
|
||||
'disables_subset': data.disables_subset or False,
|
||||
'is_compact': getattr(data, 'is_compact', False) or False,
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -214,6 +253,9 @@ class PreferenceSetCreate(BaseModel):
|
||||
shared_subset: Optional[SharedSubset] = None
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
group_id: Optional[int] = None
|
||||
allow_multi_select: bool = False
|
||||
allow_choice_quantity: bool = False
|
||||
|
||||
|
||||
class PreferenceSetOut(BaseModel):
|
||||
@@ -225,6 +267,9 @@ class PreferenceSetOut(BaseModel):
|
||||
shared_subset: Optional[SharedSubset] = None
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
group_id: Optional[int] = None
|
||||
allow_multi_select: bool = False
|
||||
allow_choice_quantity: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -249,6 +294,9 @@ class PreferenceSetOut(BaseModel):
|
||||
'shared_subset': parsed,
|
||||
'is_favorite': getattr(data, 'is_favorite', False) or False,
|
||||
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
|
||||
'group_id': getattr(data, 'group_id', None),
|
||||
'allow_multi_select': getattr(data, 'allow_multi_select', False) or False,
|
||||
'allow_choice_quantity': getattr(data, 'allow_choice_quantity', False) or False,
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -265,9 +313,12 @@ class ProductBase(BaseModel):
|
||||
description: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
base_price: float
|
||||
# piece | portion | kg | liter | gram | ml
|
||||
unit_type: str = "piece"
|
||||
is_available: bool = True
|
||||
lifecycle_status: str = "active"
|
||||
printer_zone_id: Optional[int] = None
|
||||
printer_zone_id: Optional[int] = None # kept for backward compat; use prep_zone_ids instead
|
||||
prep_zone_ids: List[int] = []
|
||||
sort_order: int = 0
|
||||
# Xenia Connect — digital menu overrides
|
||||
digital_visible: bool = True
|
||||
@@ -280,6 +331,15 @@ class ProductBase(BaseModel):
|
||||
# Phase 2A — cost tracking
|
||||
cost_simple: Optional[float] = None
|
||||
cost_breakdown: Optional[List[CostBreakdownItem]] = None
|
||||
# Quick Add
|
||||
quick_add_enabled: bool = True
|
||||
# Tags
|
||||
tags: Optional[List[str]] = None
|
||||
# Service item flag
|
||||
is_service_item: bool = False
|
||||
# Fiscal printer (ΦΗΜ)
|
||||
fiscal_name: Optional[str] = None
|
||||
fiscal_vat_group_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
@@ -287,6 +347,8 @@ class ProductCreate(ProductBase):
|
||||
options: List[ProductOptionCreate] = []
|
||||
ingredients: List[ProductIngredientCreate] = []
|
||||
preference_sets: List[PreferenceSetCreate] = []
|
||||
# group_id on options/ingredients/preference_sets is an index into this list
|
||||
modifier_groups: List[ModifierGroupCreate] = []
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
@@ -294,14 +356,17 @@ class ProductUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
base_price: Optional[float] = None
|
||||
unit_type: Optional[str] = None
|
||||
is_available: Optional[bool] = None
|
||||
lifecycle_status: Optional[str] = None
|
||||
printer_zone_id: Optional[int] = None
|
||||
prep_zone_ids: Optional[List[int]] = None
|
||||
sort_order: Optional[int] = None
|
||||
quick_options: Optional[List[ProductQuickOptionCreate]] = None
|
||||
options: Optional[List[ProductOptionCreate]] = None
|
||||
ingredients: Optional[List[ProductIngredientCreate]] = None
|
||||
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
||||
modifier_groups: Optional[List[ModifierGroupCreate]] = None
|
||||
# Xenia Connect — digital menu overrides
|
||||
digital_visible: Optional[bool] = None
|
||||
digital_available: Optional[bool] = None
|
||||
@@ -313,6 +378,15 @@ class ProductUpdate(BaseModel):
|
||||
# Phase 2A — cost tracking
|
||||
cost_simple: Optional[float] = None
|
||||
cost_breakdown: Optional[List[CostBreakdownItem]] = None
|
||||
# Quick Add
|
||||
quick_add_enabled: Optional[bool] = None
|
||||
# Tags
|
||||
tags: Optional[List[str]] = None
|
||||
# Service item flag
|
||||
is_service_item: Optional[bool] = None
|
||||
# Fiscal printer (ΦΗΜ)
|
||||
fiscal_name: Optional[str] = None
|
||||
fiscal_vat_group_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProductReorderItem(BaseModel):
|
||||
@@ -326,6 +400,7 @@ class ProductOut(ProductBase):
|
||||
options: List[ProductOptionOut] = []
|
||||
ingredients: List[ProductIngredientOut] = []
|
||||
preference_sets: List[PreferenceSetOut] = []
|
||||
modifier_groups: List[ModifierGroupOut] = []
|
||||
image_url: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -348,9 +423,11 @@ class ProductOut(ProductBase):
|
||||
'description': data.description,
|
||||
'category_id': data.category_id,
|
||||
'base_price': data.base_price,
|
||||
'unit_type': getattr(data, 'unit_type', 'piece') or 'piece',
|
||||
'is_available': data.is_available,
|
||||
'lifecycle_status': data.lifecycle_status,
|
||||
'printer_zone_id': data.printer_zone_id,
|
||||
'prep_zone_ids': [z.id for z in data.prep_zones] if hasattr(data, 'prep_zones') else [],
|
||||
'sort_order': data.sort_order,
|
||||
'image_url': data.image_url,
|
||||
'digital_visible': bool(data.digital_visible),
|
||||
@@ -362,9 +439,15 @@ class ProductOut(ProductBase):
|
||||
'digital_image_url': data.digital_image_url,
|
||||
'cost_simple': data.cost_simple,
|
||||
'cost_breakdown': parsed,
|
||||
'quick_add_enabled': getattr(data, 'quick_add_enabled', True),
|
||||
'tags': json.loads(data.tags) if isinstance(getattr(data, 'tags', None), str) else (getattr(data, 'tags', None) or []),
|
||||
'is_service_item': bool(getattr(data, 'is_service_item', False)),
|
||||
'fiscal_name': getattr(data, 'fiscal_name', None),
|
||||
'fiscal_vat_group_id': getattr(data, 'fiscal_vat_group_id', None),
|
||||
'quick_options': list(data.quick_options),
|
||||
'options': list(data.options),
|
||||
'ingredients': list(data.ingredients),
|
||||
'preference_sets': list(data.preference_sets),
|
||||
'modifier_groups': list(data.modifier_groups) if hasattr(data, 'modifier_groups') else [],
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -36,6 +36,7 @@ class TableBase(BaseModel):
|
||||
class TableCreate(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
seat_count: Optional[int] = None
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
@@ -58,6 +59,7 @@ class TableUpdate(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
seat_count: Optional[int] = None
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
@@ -90,6 +92,7 @@ class TableOut(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
seat_count: Optional[int] = None
|
||||
floor_x: Optional[float] = None
|
||||
floor_y: Optional[float] = None
|
||||
group: Optional[TableGroupOut] = None
|
||||
|
||||
@@ -16,7 +16,22 @@ class UserBase(BaseModel):
|
||||
email: Optional[str] = None
|
||||
# Phase 2B — payroll
|
||||
hourly_rate: Optional[float] = None
|
||||
can_cancel_orders: bool = False
|
||||
# Access permissions
|
||||
perm_access_dashboard: bool = False
|
||||
perm_access_waiter_app: bool = True
|
||||
perm_access_kds: bool = False
|
||||
# Order action permissions
|
||||
perm_cancel_orders: bool = False
|
||||
perm_apply_discounts: bool = False
|
||||
perm_modify_prices: bool = False
|
||||
perm_open_orders: bool = True
|
||||
perm_close_orders: bool = True
|
||||
# Management permissions
|
||||
perm_view_reports: bool = False
|
||||
perm_manage_staff: bool = False
|
||||
perm_manage_tables: bool = False
|
||||
perm_manage_menu: bool = False
|
||||
perm_manage_settings: bool = False
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
@@ -34,7 +49,22 @@ class UserUpdate(BaseModel):
|
||||
note: Optional[str] = None
|
||||
# Phase 2B — payroll
|
||||
hourly_rate: Optional[float] = None
|
||||
can_cancel_orders: Optional[bool] = None
|
||||
# Access permissions
|
||||
perm_access_dashboard: Optional[bool] = None
|
||||
perm_access_waiter_app: Optional[bool] = None
|
||||
perm_access_kds: Optional[bool] = None
|
||||
# Order action permissions
|
||||
perm_cancel_orders: Optional[bool] = None
|
||||
perm_apply_discounts: Optional[bool] = None
|
||||
perm_modify_prices: Optional[bool] = None
|
||||
perm_open_orders: Optional[bool] = None
|
||||
perm_close_orders: Optional[bool] = None
|
||||
# Management permissions
|
||||
perm_view_reports: Optional[bool] = None
|
||||
perm_manage_staff: Optional[bool] = None
|
||||
perm_manage_tables: Optional[bool] = None
|
||||
perm_manage_menu: Optional[bool] = None
|
||||
perm_manage_settings: Optional[bool] = None
|
||||
|
||||
|
||||
class WaiterZoneOut(BaseModel):
|
||||
@@ -49,6 +79,7 @@ class UserOut(UserBase):
|
||||
id: int
|
||||
created_at: UTCDatetime
|
||||
zone_assignments: List[WaiterZoneOut] = []
|
||||
waiter_settings: Optional[str] = None # JSON blob — per-waiter settings + favorites
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -68,3 +99,9 @@ class AssistantAssignmentOut(BaseModel):
|
||||
assigned_at: UTCDatetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PermissionToggle(BaseModel):
|
||||
"""Patch a single permission flag for a staff member."""
|
||||
permission: str
|
||||
value: bool
|
||||
|
||||
Reference in New Issue
Block a user