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:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View 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)

View File

@@ -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

View File

@@ -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}

View 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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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