feat: Phase 2F — customer CRM
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>
This commit is contained in:
@@ -22,6 +22,7 @@ import models.message # noqa: F401 — registers StaffMessage, StaffMessag
|
||||
import models.reservation # noqa: F401
|
||||
import models.notes # noqa: F401 — registers SiteNote, SiteTodo
|
||||
import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment
|
||||
import models.customers # noqa: F401 — registers Customer
|
||||
|
||||
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
|
||||
from routers import business_day as business_day_router
|
||||
@@ -35,6 +36,7 @@ from routers import connect_orders as connect_orders_router
|
||||
from routers import reservations as reservations_router
|
||||
from routers import notes as notes_router
|
||||
from routers.expenses import contacts_router, expenses_router
|
||||
from routers import customers as customers_router
|
||||
|
||||
|
||||
def _run_migrations():
|
||||
@@ -273,6 +275,19 @@ def _run_migrations():
|
||||
# Phase 2B — staff payroll
|
||||
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
|
||||
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL",
|
||||
# Phase 2F — customer CRM
|
||||
"""CREATE TABLE IF NOT EXISTS customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
nickname VARCHAR,
|
||||
phone VARCHAR,
|
||||
email VARCHAR,
|
||||
notes TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER NOT NULL REFERENCES users(id)
|
||||
)""",
|
||||
"ALTER TABLE orders ADD COLUMN customer_id INTEGER REFERENCES customers(id)",
|
||||
# Phase 2E — cash drawer reconciliation
|
||||
"ALTER TABLE waiter_shifts ADD COLUMN counted_cash_end REAL",
|
||||
"ALTER TABLE waiter_shifts ADD COLUMN cash_discrepancy REAL",
|
||||
@@ -396,3 +411,4 @@ app.include_router(reservations_router.router, prefix="/api/reservations", tag
|
||||
app.include_router(notes_router.router, prefix="/api/notes", tags=["notes"])
|
||||
app.include_router(contacts_router, prefix="/api/contacts", tags=["contacts"])
|
||||
app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"])
|
||||
app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])
|
||||
|
||||
25
local_backend/models/customers.py
Normal file
25
local_backend/models/customers.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
nickname = Column(String, nullable=True)
|
||||
phone = Column(String, nullable=True)
|
||||
email = Column(String, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
created_by = relationship("User", foreign_keys=[created_by_id])
|
||||
orders = relationship("Order", back_populates="customer")
|
||||
@@ -30,10 +30,13 @@ class Order(Base):
|
||||
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")
|
||||
|
||||
117
local_backend/routers/customers.py
Normal file
117
local_backend/routers/customers.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.customers import Customer
|
||||
from models.order import Order, OrderItem
|
||||
from models.user import User
|
||||
from schemas.customers import CustomerCreate, CustomerUpdate, CustomerOut, AssignCustomerRequest
|
||||
from routers.deps import require_manager, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _customer_out(c: Customer, db: Session) -> dict:
|
||||
orders = db.query(Order).filter(
|
||||
Order.customer_id == c.id,
|
||||
Order.status.in_(["closed", "paid"]),
|
||||
).all()
|
||||
total_spent = sum(
|
||||
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
|
||||
for o in orders
|
||||
)
|
||||
return {
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"nickname": c.nickname,
|
||||
"phone": c.phone,
|
||||
"email": c.email,
|
||||
"notes": c.notes,
|
||||
"is_active": c.is_active,
|
||||
"created_at": c.created_at,
|
||||
"created_by_id": c.created_by_id,
|
||||
"visit_count": len(orders),
|
||||
"total_spent": round(total_spent, 2),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[CustomerOut])
|
||||
def list_customers(
|
||||
search: Optional[str] = None,
|
||||
include_inactive: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
q = db.query(Customer)
|
||||
if not include_inactive:
|
||||
q = q.filter(Customer.is_active == True)
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
q = q.filter(
|
||||
Customer.name.ilike(term) |
|
||||
Customer.nickname.ilike(term) |
|
||||
Customer.phone.ilike(term)
|
||||
)
|
||||
customers = q.order_by(Customer.name).all()
|
||||
return [_customer_out(c, db) for c in customers]
|
||||
|
||||
|
||||
@router.post("/", response_model=CustomerOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_customer(body: CustomerCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
customer = Customer(**body.model_dump(), created_by_id=user.id)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
return _customer_out(customer, db)
|
||||
|
||||
|
||||
@router.get("/{customer_id}", response_model=CustomerOut)
|
||||
def get_customer(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
c = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||
if not c:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
return _customer_out(c, db)
|
||||
|
||||
|
||||
@router.put("/{customer_id}", response_model=CustomerOut)
|
||||
def update_customer(customer_id: int, body: CustomerUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
c = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||
if not c:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(c, field, value)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
return _customer_out(c, db)
|
||||
|
||||
|
||||
@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_customer(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
c = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||
if not c:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
c.is_active = False
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/{customer_id}/orders")
|
||||
def customer_orders(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
c = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||
if not c:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
from models.table import Table
|
||||
orders = db.query(Order).filter(Order.customer_id == customer_id).order_by(Order.opened_at.desc()).all()
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
result = []
|
||||
for o in orders:
|
||||
total = sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
|
||||
result.append({
|
||||
"id": o.id,
|
||||
"table_name": tables_map.get(o.table_id) if o.table_id else None,
|
||||
"opened_at": o.opened_at,
|
||||
"status": o.status,
|
||||
"total": round(total, 2),
|
||||
"item_count": sum(i.quantity for i in o.items if i.status in ("active", "paid")),
|
||||
})
|
||||
return {"orders": result}
|
||||
@@ -191,6 +191,15 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
|
||||
table = db.query(Table).filter(Table.id == order.table_id).first() if order.table_id else None
|
||||
table_name = (table.label or f"T{table.number}") if table else None
|
||||
|
||||
# Phase 2F: resolve customer name for display
|
||||
customer_name = None
|
||||
customer_phone = None
|
||||
if order.customer_id and order.customer:
|
||||
customer_name = order.customer.name
|
||||
if order.customer.nickname:
|
||||
customer_name = f"{order.customer.name} «{order.customer.nickname}»"
|
||||
customer_phone = order.customer.phone
|
||||
|
||||
return {
|
||||
"id": order.id,
|
||||
"table_id": order.table_id,
|
||||
@@ -202,6 +211,9 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
|
||||
"closed_by": order.closed_by,
|
||||
"notes": order.notes,
|
||||
"business_day_id": order.business_day_id,
|
||||
"customer_id": order.customer_id,
|
||||
"customer_name": customer_name,
|
||||
"customer_phone": customer_phone,
|
||||
"items": [fmt_item(i) for i in order.items],
|
||||
"waiters": [{"waiter_id": w.waiter_id} for w in order.waiters],
|
||||
"audit_logs": [fmt_log(l) for l in order.audit_logs],
|
||||
@@ -567,6 +579,26 @@ def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depend
|
||||
return {"status": "assigned"}
|
||||
|
||||
|
||||
class AssignCustomerBody(BaseModel):
|
||||
customer_id: Optional[int] = None # null = unassign
|
||||
|
||||
|
||||
@router.put("/{order_id}/customer")
|
||||
def assign_customer(order_id: int, body: AssignCustomerBody, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
from models.customers import Customer
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if body.customer_id is not None:
|
||||
customer = db.query(Customer).filter(Customer.id == body.customer_id, Customer.is_active == True).first()
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
order.customer_id = body.customer_id
|
||||
db.commit()
|
||||
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "customer_assigned"})
|
||||
return {"status": "ok", "customer_id": order.customer_id}
|
||||
|
||||
|
||||
@router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
assignment = db.query(OrderWaiter).filter(
|
||||
|
||||
41
local_backend/schemas/customers.py
Normal file
41
local_backend/schemas/customers.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class CustomerCreate(BaseModel):
|
||||
name: str
|
||||
nickname: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class CustomerUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
nickname: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class CustomerOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
nickname: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
created_by_id: int
|
||||
# computed stats (set by router)
|
||||
visit_count: int = 0
|
||||
total_spent: float = 0.0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AssignCustomerRequest(BaseModel):
|
||||
customer_id: Optional[int] = None # null = unassign
|
||||
Reference in New Issue
Block a user