Files
xenia-pos-local/local_backend/models/order.py
bonamin 4b1201ecaa feat(connect): Phase 4 — local backend online order integration
4.1 — cloud_sync.py
  - _push_menu_snapshot(): serializes digital-visible products+categories
    and POSTs to cloud /api/menu/sync every 5 minutes
  - _pull_pending_orders(): polls cloud /api/orders/pending/{site_id}
    every CONNECT_SYNC_INTERVAL_SECONDS (default 30s); creates local
    Order + OrderItem rows, marks synced on cloud, broadcasts SSE event
  - _connect_loop(): second asyncio task running the fast poll loop;
    piggybacked menu push fires every 5 min regardless of poll interval
  - _sync_once(): captures site_numeric_id from heartbeat response and
    stores it in license_state so Connect loops can use it
  - start_cloud_sync(): now creates and returns both tasks

4.2 — orders model/schema/migrations
  - models/order.py: table_id made nullable (online orders have no
    table); 7 new online_* columns added to Order
  - schemas/order.py: OrderOut table_id Optional, all 7 online_* fields
    added
  - main.py: 8 additive ALTER TABLE migrations for orders table

4.3 — routers/connect_orders.py (NEW)
  GET  /api/connect/orders/incoming      — pending online orders (any auth)
  POST /api/connect/orders/{id}/accept   — accept (manager)
  POST /api/connect/orders/{id}/reject   — reject with optional reason (manager)
  POST /api/connect/orders/{id}/status   — progress through lifecycle (manager)
  All state changes mirror to cloud via background task and broadcast SSE

4.4 — main.py router registration
  connect_orders router registered at /api/connect

config.py
  CONNECT_SYNC_INTERVAL_SECONDS setting added (default 30)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:12:01 +03:00

141 lines
6.9 KiB
Python

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 Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
table_id = Column(Integer, ForeignKey("tables.id"), nullable=True) # nullable for online orders
opened_by = Column(Integer, ForeignKey("users.id"), nullable=False)
opened_at = Column(DateTime(timezone=True), default=_utcnow)
status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled
closed_at = Column(DateTime, nullable=True)
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)
# 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"
online_status = Column(String, nullable=True) # mirrors cloud status
online_customer_name = Column(String, nullable=True)
online_customer_phone = Column(String, nullable=True)
online_customer_address = Column(Text, nullable=True)
online_customer_notes = Column(Text, nullable=True)
online_order_type = Column(String, nullable=True) # "delivery" | "dine_in"
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")
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")
audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan")
discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan")
class OrderWaiter(Base):
__tablename__ = "order_waiters"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
order = relationship("Order", back_populates="waiters")
waiter = relationship("User", back_populates="order_assignments")
class OrderItem(Base):
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
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)
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
added_at = Column(DateTime(timezone=True), default=_utcnow)
printed = Column(Boolean, default=False, nullable=False)
# Payment tracking
paid_by = Column(Integer, ForeignKey("users.id"), nullable=True)
paid_at = Column(DateTime, nullable=True)
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.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")
class PrintLog(Base):
__tablename__ = "print_log"
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)
printed_at = Column(DateTime(timezone=True), default=_utcnow)
item_ids = Column(Text, nullable=False) # JSON array of order_item ids
success = Column(Boolean, nullable=False)
error_message = Column(Text, nullable=True)
order = relationship("Order", back_populates="print_logs")
printer = relationship("Printer", back_populates="print_logs")
class OrderAuditLog(Base):
"""Immutable append-only audit trail for every action on an order."""
__tablename__ = "order_audit_log"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
event_type = Column(String, nullable=False)
# ORDER_OPENED | ITEMS_ADDED | PAYMENT | PAYMENT_OFFLINE | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids
amount = Column(Float, nullable=True) # total value for PAYMENT events
payment_method = Column(String, nullable=True)
note = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
# Emergency offline payment fields
offline_uuid = Column(String, nullable=True) # client-generated UUID for dedup
offline_at = Column(String, nullable=True) # ISO timestamp from client
is_duplicate = Column(Integer, nullable=False, default=0) # 1 = duplicate payment flagged
order = relationship("Order", back_populates="audit_logs")
waiter = relationship("User")
@property
def waiter_name(self):
return self.waiter.username if self.waiter else None
class OrderDiscount(Base):
"""Records a discount applied to an order or a specific item."""
__tablename__ = "order_discounts"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) # NULL = whole-order discount
discount_type = Column(String, nullable=False) # 'percent' | 'fixed'
discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00
applied_by = Column(Integer, ForeignKey("users.id"), nullable=False)
applied_at = Column(DateTime(timezone=True), default=_utcnow)
reason = Column(Text, nullable=True)
order = relationship("Order", back_populates="discounts")
item = relationship("OrderItem")
applied_by_user = relationship("User")