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:
57
local_backend/models/chat.py
Normal file
57
local_backend/models/chat.py
Normal 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")
|
||||
@@ -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])
|
||||
|
||||
@@ -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"
|
||||
|
||||
56
local_backend/models/prep_zone.py
Normal file
56
local_backend/models/prep_zone.py
Normal 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")
|
||||
284
local_backend/models/pricing.py
Normal file
284
local_backend/models/pricing.py
Normal 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])
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
20
local_backend/models/recovery_code.py
Normal file
20
local_backend/models/recovery_code.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user