Backend:
- New model: Customer (customers table) — name, nickname, phone, email, notes, is_active
- Order model: add customer_id nullable FK → customers
- New router: /api/customers/ — list (with ?search=), create, get, update, soft-delete,
GET /{id}/orders (visit history with totals)
- Each CustomerOut includes visit_count + total_spent (computed from closed/paid orders)
- PUT /api/orders/{id}/customer — assign or unassign a customer; manager-only;
validates customer is active; broadcasts order_updated SSE event
- GET /api/orders/{id} now returns customer_id, customer_name (with nickname), customer_phone
- Migration: CREATE TABLE customers + ALTER TABLE orders ADD COLUMN customer_id
Frontend:
- CustomersPage (/customers): searchable list (name/nickname/phone), avatar initials,
visit count + total_spent on each row; click → detail panel slides in showing stats
(visits, total spent, avg ticket), contact info, notes, full visit history
- OrderDetailPage: new Πελάτης card — shows assigned customer in blue if set;
for open orders shows "+ Ανάθεση πελάτη" button → inline search dropdown → assign;
Αφαίρεση button to unassign
- Sidebar: Users icon for Πελάτες (between Επαφές and Ρυθμίσεις)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
148 lines
7.3 KiB
Python
148 lines
7.3 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_order_cloud_id = Column(Integer, nullable=True) # cloud DB pk for status mirroring
|
|
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"
|
|
# Phase 2F — customer CRM
|
|
customer_id = Column(Integer, ForeignKey("customers.id"), 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")
|
|
customer = relationship("Customer", back_populates="orders", foreign_keys=[customer_id])
|
|
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)
|
|
|
|
# Phase 2A — cost snapshot (copied from product at time of order, never updated)
|
|
unit_cost = Column(Float, 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")
|