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,57 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class Conversation(Base):
__tablename__ = "conversations"
id = Column(Integer, primary_key=True, index=True)
type = Column(String, nullable=False, default="direct") # 'direct' | 'group'
name = Column(String, nullable=True) # required for group
is_system = Column(Boolean, nullable=False, default=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
participants = relationship(
"ConversationParticipant",
back_populates="conversation",
cascade="all, delete-orphan",
)
messages = relationship(
"ChatMessage",
back_populates="conversation",
cascade="all, delete-orphan",
)
class ConversationParticipant(Base):
__tablename__ = "conversation_participants"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
joined_at = Column(DateTime(timezone=True), default=_utcnow)
last_read_at = Column(DateTime(timezone=True), nullable=True)
conversation = relationship("Conversation", back_populates="participants")
user = relationship("User")
class ChatMessage(Base):
__tablename__ = "chat_messages"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
body = Column(Text, nullable=False)
sent_at = Column(DateTime(timezone=True), default=_utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
conversation = relationship("Conversation", back_populates="messages")
sender = relationship("User")

View File

@@ -20,7 +20,7 @@ class QuickMessageTemplate(Base):
class StaffMessage(Base):
"""A message sent from a manager to one or more waiters."""
"""A message sent from a manager or KDS to one or more waiters."""
__tablename__ = "staff_messages"
id = Column(Integer, primary_key=True, index=True)
@@ -29,6 +29,10 @@ class StaffMessage(Base):
# JSON arrays stored as text: "[1,2,3]" for waiter ids, "[5,6]" for table ids
target_waiter_ids = Column(Text, nullable=False, default="[]")
table_ids = Column(Text, nullable=False, default="[]")
# message_type: 'manager' | 'kds_order_done' | 'kds_item_done' | 'kds_call_order' | 'kds_call_general'
message_type = Column(String, nullable=False, default="manager")
# kds_zone: display name of the prep zone that sent this (e.g. "Κουζίνα")
kds_zone = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
sender = relationship("User", foreign_keys=[sender_id])

View File

@@ -20,6 +20,11 @@ class Order(Base):
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
notes = Column(Text, nullable=True)
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
# KDS
kds_status = Column(String, default="pending", nullable=False) # pending|preparing|done
kds_status_changed_at = Column(DateTime(timezone=True), nullable=True)
order_type = Column(String, default="here", nullable=False) # here|takeaway|delivery
# Xenia Connect — online order fields
source = Column(String, default="pos", nullable=False) # "pos" | "online"
online_order_ref = Column(String, nullable=True) # e.g. "ORD-0042"
@@ -33,6 +38,12 @@ class Order(Base):
# Phase 2F — customer CRM
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True)
# Number of customers at the table for this order
customer_count = Column(Integer, nullable=True)
# Fiscal printer status: NULL = fiscal was off, "pending" | "success" | "failed"
fiscal_status = Column(String, 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")
@@ -40,6 +51,7 @@ class Order(Base):
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", cascade="all, delete-orphan")
print_jobs = relationship("PrintJob", back_populates="order", cascade="all, delete-orphan")
audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan")
discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan")
@@ -63,12 +75,13 @@ class OrderItem(Base):
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)
quantity = Column(Float, 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
status = Column(String, default="active", nullable=False) # active|paid|cancelled
kds_status = Column(String, default="pending", nullable=False) # pending|preparing|done|served|declined
added_at = Column(DateTime(timezone=True), default=_utcnow)
printed = Column(Boolean, default=False, nullable=False)
@@ -81,16 +94,33 @@ class OrderItem(Base):
# Phase 2A — cost snapshot (copied from product at time of order, never updated)
unit_cost = Column(Float, nullable=True)
# On-the-fly price adjustment (positive = surcharge, negative = discount)
price_adjustment = Column(Float, nullable=False, default=0.0)
# Phase 2B — cancellation tracking
cancelled_by = Column(Integer, ForeignKey("users.id"), nullable=True)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
cancel_reason = Column(Text, nullable=True)
# KDS decline tracking
decline_note = Column(Text, nullable=True) # reason set by kitchen when declining
# Courses — optional course assignment for sequenced firing
course_id = Column(Integer, nullable=True)
# Deal binding — which deal created this item, and which original item it's linked to
deal_id = Column(Integer, ForeignKey("deals.id"), nullable=True)
linked_item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True)
order = relationship("Order", back_populates="items")
product = relationship("Product", back_populates="order_items")
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
paid_by_user = relationship("User", foreign_keys=[paid_by], back_populates="items_paid")
@property
def unit_type(self) -> str:
return self.product.unit_type if self.product and self.product.unit_type else "piece"
class PrintLog(Base):
__tablename__ = "print_log"
@@ -107,6 +137,32 @@ class PrintLog(Base):
printer = relationship("Printer", back_populates="print_logs")
class PrintJob(Base):
"""
One row per (order, printer, zone) print job. Tracks the full lifecycle:
pending → success or cancelled. PrintLog rows record each individual attempt.
"""
__tablename__ = "print_jobs"
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)
zone_id = Column(Integer, nullable=True) # prep zone id, NULL for legacy
item_ids = Column(Text, nullable=False) # JSON array of order_item ids
copies = Column(Integer, nullable=False, default=1)
status = Column(String, nullable=False, default="pending") # pending|success|cancelled
retry_count = Column(Integer, nullable=False, default=0)
first_attempted_at = Column(DateTime(timezone=True), default=_utcnow)
last_attempted_at = Column(DateTime(timezone=True), nullable=True)
succeeded_at = Column(DateTime(timezone=True), nullable=True)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
cancel_reason = Column(String, nullable=True) # "staff_cancelled" | "order_closed"
order = relationship("Order", back_populates="print_jobs")
printer = relationship("Printer", back_populates="print_jobs")
class OrderAuditLog(Base):
"""Immutable append-only audit trail for every action on an order."""
__tablename__ = "order_audit_log"

View File

@@ -0,0 +1,56 @@
from sqlalchemy import Column, Integer, String, Boolean, Table, ForeignKey
from sqlalchemy.orm import relationship
from database import Base
# Many-to-many: prep_zones <-> printers (all printers assigned to a zone)
prep_zone_printers = Table(
"prep_zone_printers",
Base.metadata,
Column("prep_zone_id", Integer, ForeignKey("prep_zones.id"), primary_key=True),
Column("printer_id", Integer, ForeignKey("printers.id"), primary_key=True),
)
# Many-to-many: products <-> prep_zones
product_prep_zones = Table(
"product_prep_zones",
Base.metadata,
Column("product_id", Integer, ForeignKey("products.id"), primary_key=True),
Column("prep_zone_id", Integer, ForeignKey("prep_zones.id"), primary_key=True),
)
class PrepZone(Base):
__tablename__ = "prep_zones"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(String, nullable=True)
notification_name = Column(String, nullable=True)
# Printer routing
# auto_print: 'none' | 'master' | 'all'
auto_print = Column(String, nullable=False, default='none')
# master_printer_id: single FK to the "primary" printer (nullable — may not be set yet)
master_printer_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
master_copies = Column(Integer, nullable=False, default=1)
# secondary printers are those in the M2M table that are NOT the master
secondary_copies = Column(Integer, nullable=False, default=1)
# Legacy field kept for backward compat (use master_copies/secondary_copies instead)
print_copies = Column(Integer, nullable=False, default=1)
# Ticket formatting
# sort_items_by: 'order_time' | 'item_count' | 'alpha'
sort_items_by = Column(String, nullable=False, default='order_time')
group_by_category = Column(Integer, nullable=False, default=0) # bool as int
category_order = Column(String, nullable=False, default='[]') # JSON array of category IDs
print_checkboxes = Column(Integer, nullable=False, default=0) # bool as int
# KDS bypass / auto-progression
bypass_pending = Column(Integer, nullable=False, default=0) # skip pending+prep → straight to ready
bypass_kds = Column(Integer, nullable=False, default=0) # skip KDS entirely → straight to served (implies bypass_pending)
auto_ready_to_served = Column(Integer, nullable=False, default=0) # when item hits 'done' on KDS → auto-upgrade to served
# Relationships
printers = relationship("Printer", secondary=prep_zone_printers, back_populates="prep_zones")
products = relationship("Product", secondary=product_prep_zones, back_populates="prep_zones")

View File

@@ -0,0 +1,284 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class PriceGroup(Base):
"""Named manual toggle (e.g. 'Happy Hour'). Modifiers reference these by ID."""
__tablename__ = "price_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
color = Column(String, nullable=True)
is_active = Column(Integer, nullable=False, default=0) # 0|1
# Optional auto-schedule: flip is_active based on time + day of week.
# Evaluated at condition-check time — no background daemon needed.
auto_enable_time = Column(String, nullable=True) # "HH:MM" 24h
auto_disable_time = Column(String, nullable=True) # "HH:MM" 24h
auto_days = Column(Text, nullable=True) # JSON [0..6], Mon=0
created_at = Column(DateTime(timezone=True), default=_utcnow)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
class PriceModifier(Base):
"""
A single pricing rule. Can be item-scoped (applies only to one product) or
global (applies to all targets listed in PriceModifierTarget).
Evaluation order is controlled by sort_order (ascending = higher priority).
Stacking algorithm:
- Collect all passing modifiers for a product.
- Separate into stackable (allow_stack=1) and non-stackable.
- Apply the FIRST passing non-stackable modifier (sort_order ascending).
- Apply ALL passing stackable modifiers in sort_order order, on the running price.
- Apply final system rounding to nearest 0.10 after all modifiers.
"""
__tablename__ = "price_modifiers"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
color = Column(String, nullable=True)
is_active = Column(Integer, nullable=False, default=1) # quick on/off toggle
is_favorite = Column(Integer, nullable=False, default=0) # show on dashboard
allow_stack = Column(Integer, nullable=False, default=0) # participates in stackable pile
sort_order = Column(Integer, nullable=False, default=0) # user-controlled priority
# Scope
scope = Column(String, nullable=False, default="global") # 'item' | 'global'
item_id = Column(Integer, ForeignKey("products.id"), nullable=True) # set when scope='item'
# Action — what happens to the price when this modifier fires
action_type = Column(String, nullable=False) # 'set' | 'add_amount' | 'add_percent'
action_value = Column(Float, nullable=False) # the number; negative = discount
# Optional per-modifier rounding applied immediately after this modifier's math.
# System-wide nearest-0.10 rounding is always applied as a final pass regardless.
round_to = Column(String, nullable=True) # '0.05'|'0.10'|'0.20'|'0.50'|'x.99'|'x.00'
created_at = Column(DateTime(timezone=True), default=_utcnow)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
updated_at = Column(DateTime(timezone=True), nullable=True)
updated_by = Column(Integer, ForeignKey("users.id"), nullable=True)
conditions = relationship("PriceModifierCondition", back_populates="modifier",
cascade="all, delete-orphan")
targets = relationship("PriceModifierTarget", back_populates="modifier",
cascade="all, delete-orphan")
item = relationship("Product", foreign_keys=[item_id])
class PriceModifierCondition(Base):
"""
One condition on a modifier. ALL conditions must pass for the modifier to fire (AND logic).
A modifier with zero conditions is 'manual-only' — never fires automatically.
condition_type registry and params shapes:
time_range {"from": "HH:MM", "to": "HH:MM"} (crosses midnight if from > to)
date_range {"from": "YYYY-MM-DD", "to": "YYYY-MM-DD"} (inclusive)
specific_date {"dates": ["YYYY-MM-DD", ...]}
day_of_week {"days": [0,1,2,3,4,5,6]} (Mon=0 Sun=6)
min_item_quantity {"min": N} (this product's qty in cart)
min_category_qty {"category_id": N, "min": N} (items in category in cart)
min_order_value {"min": 0.00} (cart total before modifiers)
order_channel {"channels": ["pos","online","qr","takeaway"]}
price_group_active {"price_group_id": N}
user_tier {"tiers": ["bronze","silver","gold"]} (placeholder, needs user accounts)
low_stock {"threshold": N} (placeholder, needs inventory)
"""
__tablename__ = "price_modifier_conditions"
id = Column(Integer, primary_key=True, index=True)
modifier_id = Column(Integer, ForeignKey("price_modifiers.id", ondelete="CASCADE"),
nullable=False)
condition_type = Column(String, nullable=False)
params = Column(Text, nullable=False, default="{}") # JSON
modifier = relationship("PriceModifier", back_populates="conditions")
class PriceModifierTarget(Base):
"""
Target rows exist only for global modifiers. Defines which products the modifier applies to.
A modifier matches a product if ANY of its target rows covers that product (OR across rows).
target_type='all' → applies to every product (target_id and target_tag are NULL)
target_type='item' → target_id is a product id
target_type='category' → target_id is a category id (includes subcategories)
target_type='prep_zone' → target_id is a prep_zone id
target_type='tag' → target_tag is a string matched against products.tags JSON array
"""
__tablename__ = "price_modifier_targets"
id = Column(Integer, primary_key=True, index=True)
modifier_id = Column(Integer, ForeignKey("price_modifiers.id", ondelete="CASCADE"),
nullable=False)
target_type = Column(String, nullable=False) # 'all'|'item'|'category'|'prep_zone'|'tag'
target_id = Column(Integer, nullable=True) # legacy single value
target_tag = Column(String, nullable=True) # legacy single value
target_ids = Column(Text, nullable=True) # JSON array of ints (multi-select)
target_tags = Column(Text, nullable=True) # JSON array of strings (multi-tag)
modifier = relationship("PriceModifier", back_populates="targets")
class Deal(Base):
"""
Event-driven promotion. When deal_conditions + deal_targets are met (a product/category/tag
is present in the cart in sufficient quantity), the action fires.
Evaluated on every add-items call. If conditions are newly met, the waiter is notified
via the response payload and prompted to confirm/select (never auto-applied silently).
action_type registry:
apply_modifier → action_modifier_id (FK to PriceModifier with no conditions)
set_price → action_value (exact new unit price)
add_amount → action_value (delta; negative = discount)
add_percent → action_value (percent; negative = discount)
free_item → action_free_item_id (specific product added free)
free_choice → pool defined by action_free_target_type + action_free_target_ids
"""
__tablename__ = "deals"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
color = Column(String, nullable=True)
is_active = Column(Integer, nullable=False, default=1)
sort_order = Column(Integer, nullable=False, default=0)
# Action
action_type = Column(String, nullable=False)
action_modifier_id = Column(Integer, ForeignKey("price_modifiers.id"), nullable=True)
action_value = Column(Float, nullable=True)
action_free_item_id = Column(Integer, ForeignKey("products.id"), nullable=True)
action_free_target_type = Column(String, nullable=True) # 'item'|'category'|'tag'
action_free_target_ids = Column(Text, nullable=True) # JSON array of IDs or tag strings
action_free_quantity = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime(timezone=True), default=_utcnow)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
conditions = relationship("DealCondition", back_populates="deal",
cascade="all, delete-orphan")
targets = relationship("DealTarget", back_populates="deal",
cascade="all, delete-orphan")
action_modifier = relationship("PriceModifier", foreign_keys=[action_modifier_id])
action_free_item = relationship("Product", foreign_keys=[action_free_item_id])
class DealCondition(Base):
"""Same condition_type registry as PriceModifierCondition."""
__tablename__ = "deal_conditions"
id = Column(Integer, primary_key=True, index=True)
deal_id = Column(Integer, ForeignKey("deals.id", ondelete="CASCADE"), nullable=False)
condition_type = Column(String, nullable=False)
params = Column(Text, nullable=False, default="{}") # JSON
deal = relationship("Deal", back_populates="conditions")
class DealTarget(Base):
"""
Defines the 'buy THESE' side of a deal (what must be in the cart for the deal to trigger).
Multiple rows are OR-combined: any matching item satisfies a row.
"""
__tablename__ = "deal_targets"
id = Column(Integer, primary_key=True, index=True)
deal_id = Column(Integer, ForeignKey("deals.id", ondelete="CASCADE"), nullable=False)
target_type = Column(String, nullable=False) # 'item'|'category'|'tag'|'any'
target_id = Column(Integer, nullable=True) # legacy single value
target_tag = Column(String, nullable=True) # legacy single value
target_ids = Column(Text, nullable=True) # JSON array of ints (multi-select)
target_tags = Column(Text, nullable=True) # JSON array of strings (multi-tag)
deal = relationship("Deal", back_populates="targets")
class PriceEventLog(Base):
"""
Immutable append-only audit record. Written for every pricing event on an order:
automatic modifier fires, deal triggers/accepts/dismisses, waiter discounts, free items.
Nothing is deleted from this table.
"""
__tablename__ = "price_event_log"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
order_item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) # NULL for order-level events
# Event classification
event_type = Column(String, nullable=False)
# modifier_applied — automatic price modifier fired
# deal_triggered — deal conditions met, action executed on a specific item
# deal_offer_shown — waiter was shown a free-choice prompt
# deal_offer_accepted — waiter confirmed and selected item(s)
# deal_offer_dismissed — waiter dismissed the deal offer
# waiter_discount — manual waiter discount applied
# free_item_added — free item added to order as result of a deal
modifier_id = Column(Integer, ForeignKey("price_modifiers.id"), nullable=True)
deal_id = Column(Integer, ForeignKey("deals.id"), nullable=True)
# Price delta — NULL for events that don't change a price (offer_shown, dismissed)
price_before = Column(Float, nullable=True)
price_after = Column(Float, nullable=True)
delta_amount = Column(Float, nullable=True) # price_after - price_before; negative = discount
# Waiter discount fields
applied_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
waiter_note = Column(Text, nullable=True)
# Snapshot of the conditions that evaluated to True when the event fired.
# JSON object: {"condition_type": evaluated_value, ...}
# e.g. {"day_of_week": 5, "time_range": "21:45", "price_group_active": true}
conditions_snapshot = Column(Text, nullable=True)
# For deal_offer_accepted: product IDs the waiter chose
selected_item_ids = Column(Text, nullable=True) # JSON array of product IDs
applied_at = Column(DateTime(timezone=True), default=_utcnow)
order = relationship("Order", foreign_keys=[order_id])
order_item = relationship("OrderItem", foreign_keys=[order_item_id])
modifier = relationship("PriceModifier", foreign_keys=[modifier_id])
deal = relationship("Deal", foreign_keys=[deal_id])
applied_by = relationship("User", foreign_keys=[applied_by_user_id])
class WaiterDiscountSettings(Base):
"""
Per-waiter discount limits. All limit fields are nullable — NULL means no limit.
The master can_apply_discounts flag acts as an on/off switch for this waiter.
Global limits are stored in pos_settings under the 'discounts.*' namespace.
"""
__tablename__ = "waiter_discount_settings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
can_apply_discounts = Column(Integer, nullable=False, default=0) # 0|1 master toggle
max_discount_percent = Column(Float, nullable=True) # e.g. 20.0 = max 20% off
max_discount_amount = Column(Float, nullable=True) # max € off a single item
max_total_value_shift = Column(Float, nullable=True) # max total € given away per shift
max_total_value_workday = Column(Float, nullable=True) # max total € given away per workday
max_items_per_shift = Column(Integer, nullable=True)
max_items_per_workday = Column(Integer, nullable=True)
max_items_per_order = Column(Integer, nullable=True)
user = relationship("User", foreign_keys=[user_id])

View File

@@ -14,6 +14,9 @@ class Printer(Base):
protocol = Column(String, default="escpos_tcp", nullable=False)
line_width = Column(Integer, default=48, nullable=False)
duplicates = Column(Integer, default=0, nullable=False)
codepage_n = Column(Integer, default=29, nullable=False)
products = relationship("Product", back_populates="printer_zone")
print_logs = relationship("PrintLog", back_populates="printer")
print_jobs = relationship("PrintJob", back_populates="printer")
prep_zones = relationship("PrepZone", secondary="prep_zone_printers", back_populates="printers")

View File

@@ -46,19 +46,51 @@ class Product(Base):
digital_discount = Column(Float, default=0.0, nullable=False)
digital_image_url = Column(String, nullable=True)
# Unit of measure: "piece" | "portion" | "kg" | "liter" | "gram" | "ml"
unit_type = Column(String, default="piece", nullable=False)
# Phase 2A — cost tracking
cost_simple = Column(Float, nullable=True)
cost_breakdown = Column(Text, nullable=True) # JSON: [{"label": str, "amount": float}]
# Quick Add: allow adding 1 unit directly from product list without opening config modal
quick_add_enabled = Column(Boolean, default=True, nullable=False)
# Service item: non-inventoried table-service items (cutlery, glasses, etc.)
is_service_item = Column(Boolean, default=False, nullable=False)
# Tags: JSON array of strings e.g. ["vegan", "gluten-free"]
tags = Column(Text, nullable=True)
# Fiscal printer (ΦΗΜ) fields
fiscal_name = Column(String, nullable=True) # short name for receipt (falls back to name)
fiscal_vat_group_id = Column(Integer, nullable=True) # machine VAT group ID (1-based integer)
category = relationship("Category", back_populates="products")
printer_zone = relationship("Printer", back_populates="products")
prep_zones = relationship("PrepZone", secondary="product_prep_zones", back_populates="products")
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
preference_sets = relationship("ProductPreferenceSet", back_populates="product", cascade="all, delete-orphan")
modifier_groups = relationship("ProductModifierGroup", back_populates="product", cascade="all, delete-orphan")
order_items = relationship("OrderItem", back_populates="product")
class ProductModifierGroup(Base):
"""A named folder/group that can contain options, ingredients, or preference sets."""
__tablename__ = "product_modifier_groups"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
# "option" | "ingredient" | "preference"
modifier_type = Column(String, nullable=False)
name = Column(String, nullable=False)
sort_order = Column(Integer, default=0, nullable=False)
product = relationship("Product", back_populates="modifier_groups")
class ProductOption(Base):
__tablename__ = "product_options"
@@ -67,10 +99,16 @@ class ProductOption(Base):
name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0)
allow_multiple = Column(Boolean, default=False, nullable=False)
# JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked
# When True, waiter can select more than one sub-choice simultaneously
multi_select = Column(Boolean, default=False, nullable=False)
# JSON array [{name, extra_cost, is_default, allow_multiple}] — sub-options shown when this option is checked
sub_choices = Column(Text, nullable=True)
is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False)
# When True renders as half-width card on the PWA
is_compact = Column(Boolean, default=False, nullable=False)
# Optional group/folder this option belongs to
group_id = Column(Integer, ForeignKey("product_modifier_groups.id"), nullable=True)
product = relationship("Product", back_populates="options")
@@ -100,6 +138,10 @@ class ProductIngredient(Base):
extra_cost = Column(Float, default=0.0)
is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False)
# When True renders as half-width card on the PWA
is_compact = Column(Boolean, default=False, nullable=False)
# Optional group/folder this ingredient belongs to
group_id = Column(Integer, ForeignKey("product_modifier_groups.id"), nullable=True)
product = relationship("Product", back_populates="ingredients")
@@ -116,6 +158,12 @@ class ProductPreferenceSet(Base):
shared_subset = Column(Text, nullable=True)
is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False)
# Optional group/folder this preference set belongs to
group_id = Column(Integer, ForeignKey("product_modifier_groups.id"), nullable=True)
# When True, waiter can select multiple choices instead of just one
allow_multi_select = Column(Boolean, default=False, nullable=False)
# When True, each selected choice can have a quantity (x2, x3, …)
allow_choice_quantity = Column(Boolean, default=False, nullable=False)
product = relationship("Product", back_populates="preference_sets")
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
@@ -128,10 +176,12 @@ class ProductPreferenceChoice(Base):
set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False)
name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0)
# JSON array of sub-choice objects: [{name, extra_cost, is_default}]
# JSON array of sub-choice objects: [{name, extra_cost, is_default, is_compact}]
# Per-choice inline sub-preference shown only when this choice is selected.
sub_choices = Column(Text, nullable=True)
# When True this choice hides the set-level shared_subset on the PWA.
disables_subset = Column(Boolean, default=False, nullable=False)
# When True renders as half-width card on the PWA
is_compact = Column(Boolean, default=False, nullable=False)
set = relationship("ProductPreferenceSet", back_populates="choices")

View File

@@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class RecoveryCode(Base):
__tablename__ = "recovery_codes"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
code_hash = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
used_at = Column(DateTime(timezone=True), nullable=True)
user = relationship("User", backref="recovery_codes")

View File

@@ -24,6 +24,7 @@ class Table(Base):
label = Column(String, nullable=True)
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
seat_count = Column(Integer, nullable=True)
floor_x = Column(Float, nullable=True)
floor_y = Column(Float, nullable=True)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
@@ -16,7 +16,8 @@ class User(Base):
pin_hash = Column(String, nullable=False)
password_hash = Column(String, nullable=True)
email = Column(String, nullable=True)
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
# Role is a label + default-permission template. Valid values: see roles.py
role = Column(String, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
full_name = Column(String, nullable=True)
nickname = Column(String, nullable=True)
@@ -26,8 +27,28 @@ class User(Base):
created_at = Column(DateTime(timezone=True), default=_utcnow)
# Phase 2B — payroll
hourly_rate = Column(Float, nullable=True)
# Waiter cancellation permission
can_cancel_orders = Column(Boolean, default=False, nullable=False)
# ── Access permissions (which sites/areas this user can enter) ────────────
perm_access_dashboard = Column(Boolean, default=False, nullable=False)
perm_access_waiter_app = Column(Boolean, default=True, nullable=False)
perm_access_kds = Column(Boolean, default=False, nullable=False)
# ── Order action permissions ──────────────────────────────────────────────
perm_cancel_orders = Column(Boolean, default=False, nullable=False)
perm_apply_discounts = Column(Boolean, default=False, nullable=False)
perm_modify_prices = Column(Boolean, default=False, nullable=False)
perm_open_orders = Column(Boolean, default=True, nullable=False)
perm_close_orders = Column(Boolean, default=True, nullable=False)
# ── Management permissions ────────────────────────────────────────────────
perm_view_reports = Column(Boolean, default=False, nullable=False)
perm_manage_staff = Column(Boolean, default=False, nullable=False)
perm_manage_tables = Column(Boolean, default=False, nullable=False)
perm_manage_menu = Column(Boolean, default=False, nullable=False)
perm_manage_settings = Column(Boolean, default=False, nullable=False)
# Per-waiter app settings + favorites (JSON blob, synced from PWA)
waiter_settings = Column(Text, nullable=True)
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
@@ -47,6 +68,15 @@ class User(Base):
back_populates="assistant_waiter",
)
@property
def can_cancel_orders(self):
"""Back-compat alias used by orders router."""
return self.perm_cancel_orders
@property
def is_superadmin(self):
return self.role == "superadmin"
class WaiterZone(Base):
"""Maps a waiter to a table group they are allowed to operate in.