feat: Phase 2D — expense tracking + supplier contact book
Backend: - New models: Contact, Expense, ExpensePayment (3 new tables) - Expense status (paid/partial/due) is always computed from paid_amount vs total_amount — never stored - paid_amount recomputed from sum of all payments on each payment write (no client trust) - New router /api/contacts/: list, create, update, soft-delete (is_active=false) - New router /api/expenses/: list (filter by status/category/contact), create, update, delete, record payment, summary (total_due, total_paid, by_category) - Migrations: CREATE TABLE IF NOT EXISTS for contacts, expenses, expense_payments Frontend: - ContactsPage (/contacts): searchable table, type badge (Προμηθευτής/Προσωπικό/Κοινή Χρεία/Άλλο), create/edit modal, soft-delete - ExpensesPage (/expenses): filter bar (status + category), expandable rows with payment history, summary header showing total outstanding, inline Payment modal (defaults to full due amount), create/edit expense modal - Sidebar: Receipt icon for Έξοδα, BookUser icon for Επαφές Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAs
|
||||
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
|
||||
import models.reservation # noqa: F401
|
||||
import models.notes # noqa: F401 — registers SiteNote, SiteTodo
|
||||
import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment
|
||||
|
||||
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
|
||||
from routers import business_day as business_day_router
|
||||
@@ -33,6 +34,7 @@ from routers import data_transfer as data_transfer_router
|
||||
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
|
||||
|
||||
|
||||
def _run_migrations():
|
||||
@@ -271,6 +273,39 @@ 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 2D — expense tracking + supplier contacts
|
||||
"""CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
type VARCHAR NOT NULL DEFAULT 'supplier',
|
||||
phone VARCHAR,
|
||||
email VARCHAR,
|
||||
address TEXT,
|
||||
notes TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS expenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
description VARCHAR NOT NULL,
|
||||
category VARCHAR NOT NULL,
|
||||
contact_id INTEGER REFERENCES contacts(id),
|
||||
total_amount REAL NOT NULL,
|
||||
paid_amount REAL NOT NULL DEFAULT 0.0,
|
||||
due_date DATETIME,
|
||||
business_day_id INTEGER REFERENCES business_days(id),
|
||||
created_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
notes TEXT
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS expense_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
expense_id INTEGER NOT NULL REFERENCES expenses(id),
|
||||
amount REAL NOT NULL,
|
||||
paid_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
paid_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||
notes TEXT
|
||||
)""",
|
||||
# Phase 2C — notes & todos
|
||||
"""CREATE TABLE IF NOT EXISTS site_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -353,3 +388,5 @@ app.include_router(data_transfer_router.router, prefix="/api/data-transfer", ta
|
||||
app.include_router(connect_orders_router.router, prefix="/api/connect", tags=["connect"])
|
||||
app.include_router(reservations_router.router, prefix="/api/reservations", tags=["reservations"])
|
||||
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"])
|
||||
|
||||
58
local_backend/models/expenses.py
Normal file
58
local_backend/models/expenses.py
Normal file
@@ -0,0 +1,58 @@
|
||||
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
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Contact(Base):
|
||||
__tablename__ = "contacts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
type = Column(String, default="supplier", nullable=False) # supplier|staff|utility|other
|
||||
phone = Column(String, nullable=True)
|
||||
email = Column(String, nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
expenses = relationship("Expense", back_populates="contact")
|
||||
|
||||
|
||||
class Expense(Base):
|
||||
__tablename__ = "expenses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
description = Column(String, nullable=False)
|
||||
category = Column(String, nullable=False) # supplier|utilities|rent|maintenance|staff|equipment|other
|
||||
contact_id = Column(Integer, ForeignKey("contacts.id"), nullable=True)
|
||||
total_amount = Column(Float, nullable=False)
|
||||
paid_amount = Column(Float, default=0.0, nullable=False)
|
||||
due_date = Column(DateTime(timezone=True), nullable=True)
|
||||
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
contact = relationship("Contact", back_populates="expenses")
|
||||
created_by = relationship("User", foreign_keys=[created_by_id])
|
||||
payments = relationship("ExpensePayment", back_populates="expense", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ExpensePayment(Base):
|
||||
__tablename__ = "expense_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
expense_id = Column(Integer, ForeignKey("expenses.id"), nullable=False)
|
||||
amount = Column(Float, nullable=False)
|
||||
paid_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
paid_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
expense = relationship("Expense", back_populates="payments")
|
||||
paid_by = relationship("User", foreign_keys=[paid_by_id])
|
||||
220
local_backend/routers/expenses.py
Normal file
220
local_backend/routers/expenses.py
Normal file
@@ -0,0 +1,220 @@
|
||||
from datetime import datetime, timezone
|
||||
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.expenses import Contact, Expense, ExpensePayment
|
||||
from models.user import User
|
||||
from schemas.expenses import (
|
||||
ContactCreate, ContactUpdate, ContactOut,
|
||||
ExpenseCreate, ExpenseUpdate, ExpenseOut,
|
||||
ExpensePaymentCreate, ExpensePaymentOut,
|
||||
)
|
||||
from routers.deps import require_manager
|
||||
|
||||
contacts_router = APIRouter()
|
||||
expenses_router = APIRouter()
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _expense_status(total: float, paid: float) -> str:
|
||||
if paid >= total:
|
||||
return "paid"
|
||||
if paid > 0:
|
||||
return "partial"
|
||||
return "due"
|
||||
|
||||
|
||||
def _expense_out(e: Expense) -> dict:
|
||||
contact_name = None
|
||||
if e.contact:
|
||||
contact_name = e.contact.name
|
||||
creator_name = None
|
||||
if e.created_by:
|
||||
creator_name = e.created_by.full_name or e.created_by.username
|
||||
due_amount = round(max(0.0, e.total_amount - e.paid_amount), 2)
|
||||
payments_out = []
|
||||
for p in e.payments:
|
||||
payer_name = None
|
||||
if p.paid_by:
|
||||
payer_name = p.paid_by.full_name or p.paid_by.username
|
||||
payments_out.append({
|
||||
"id": p.id,
|
||||
"expense_id": p.expense_id,
|
||||
"amount": p.amount,
|
||||
"paid_at": p.paid_at,
|
||||
"paid_by_id": p.paid_by_id,
|
||||
"paid_by_name": payer_name,
|
||||
"notes": p.notes,
|
||||
})
|
||||
return {
|
||||
"id": e.id,
|
||||
"description": e.description,
|
||||
"category": e.category,
|
||||
"contact_id": e.contact_id,
|
||||
"contact_name": contact_name,
|
||||
"total_amount": e.total_amount,
|
||||
"paid_amount": e.paid_amount,
|
||||
"due_amount": due_amount,
|
||||
"status": _expense_status(e.total_amount, e.paid_amount),
|
||||
"due_date": e.due_date,
|
||||
"business_day_id": e.business_day_id,
|
||||
"created_by_id": e.created_by_id,
|
||||
"created_by_name": creator_name,
|
||||
"created_at": e.created_at,
|
||||
"notes": e.notes,
|
||||
"payments": payments_out,
|
||||
}
|
||||
|
||||
|
||||
# ── Contacts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@contacts_router.get("/", response_model=List[ContactOut])
|
||||
def list_contacts(
|
||||
include_inactive: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
q = db.query(Contact)
|
||||
if not include_inactive:
|
||||
q = q.filter(Contact.is_active == True)
|
||||
return q.order_by(Contact.name).all()
|
||||
|
||||
|
||||
@contacts_router.post("/", response_model=ContactOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_contact(body: ContactCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
contact = Contact(**body.model_dump())
|
||||
db.add(contact)
|
||||
db.commit()
|
||||
db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
@contacts_router.put("/{contact_id}", response_model=ContactOut)
|
||||
def update_contact(contact_id: int, body: ContactUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
contact = db.query(Contact).filter(Contact.id == contact_id).first()
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(contact, field, value)
|
||||
db.commit()
|
||||
db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
@contacts_router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_contact(contact_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
contact = db.query(Contact).filter(Contact.id == contact_id).first()
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
contact.is_active = False
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Expenses ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@expenses_router.get("/summary")
|
||||
def expense_summary(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
expenses = db.query(Expense).all()
|
||||
total_due = sum(max(0.0, e.total_amount - e.paid_amount) for e in expenses if e.paid_amount < e.total_amount)
|
||||
total_paid = sum(e.paid_amount for e in expenses)
|
||||
by_category: dict = {}
|
||||
for e in expenses:
|
||||
c = e.category
|
||||
if c not in by_category:
|
||||
by_category[c] = {"category": c, "total": 0.0, "paid": 0.0, "due": 0.0}
|
||||
by_category[c]["total"] += e.total_amount
|
||||
by_category[c]["paid"] += e.paid_amount
|
||||
by_category[c]["due"] += max(0.0, e.total_amount - e.paid_amount)
|
||||
return {
|
||||
"total_due": round(total_due, 2),
|
||||
"total_paid": round(total_paid, 2),
|
||||
"by_category": list(by_category.values()),
|
||||
}
|
||||
|
||||
|
||||
@expenses_router.get("/", response_model=List[ExpenseOut])
|
||||
def list_expenses(
|
||||
expense_status: Optional[str] = Query(default=None, alias="status"),
|
||||
category: Optional[str] = None,
|
||||
contact_id: Optional[int] = None,
|
||||
business_day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
expenses = db.query(Expense).order_by(Expense.created_at.desc()).all()
|
||||
result = [_expense_out(e) for e in expenses]
|
||||
if expense_status:
|
||||
result = [e for e in result if e["status"] == expense_status]
|
||||
if category:
|
||||
result = [e for e in result if e["category"] == category]
|
||||
if contact_id:
|
||||
result = [e for e in result if e["contact_id"] == contact_id]
|
||||
if business_day_id:
|
||||
result = [e for e in result if e["business_day_id"] == business_day_id]
|
||||
return result
|
||||
|
||||
|
||||
@expenses_router.post("/", response_model=ExpenseOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_expense(body: ExpenseCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
expense = Expense(**body.model_dump(), created_by_id=user.id)
|
||||
db.add(expense)
|
||||
db.commit()
|
||||
db.refresh(expense)
|
||||
return _expense_out(expense)
|
||||
|
||||
|
||||
@expenses_router.put("/{expense_id}", response_model=ExpenseOut)
|
||||
def update_expense(expense_id: int, body: ExpenseUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
expense = db.query(Expense).filter(Expense.id == expense_id).first()
|
||||
if not expense:
|
||||
raise HTTPException(status_code=404, detail="Expense not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(expense, field, value)
|
||||
db.commit()
|
||||
db.refresh(expense)
|
||||
return _expense_out(expense)
|
||||
|
||||
|
||||
@expenses_router.delete("/{expense_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_expense(expense_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
expense = db.query(Expense).filter(Expense.id == expense_id).first()
|
||||
if not expense:
|
||||
raise HTTPException(status_code=404, detail="Expense not found")
|
||||
db.delete(expense)
|
||||
db.commit()
|
||||
|
||||
|
||||
@expenses_router.post("/{expense_id}/pay", response_model=ExpenseOut)
|
||||
def record_payment(
|
||||
expense_id: int,
|
||||
body: ExpensePaymentCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
expense = db.query(Expense).filter(Expense.id == expense_id).first()
|
||||
if not expense:
|
||||
raise HTTPException(status_code=404, detail="Expense not found")
|
||||
if body.amount <= 0:
|
||||
raise HTTPException(status_code=400, detail="Payment amount must be positive")
|
||||
payment = ExpensePayment(
|
||||
expense_id=expense_id,
|
||||
amount=body.amount,
|
||||
paid_by_id=user.id,
|
||||
notes=body.notes,
|
||||
)
|
||||
db.add(payment)
|
||||
db.flush()
|
||||
# recompute paid_amount from all payments (never trust client totals)
|
||||
all_payments = db.query(ExpensePayment).filter(ExpensePayment.expense_id == expense_id).all()
|
||||
expense.paid_amount = round(sum(p.amount for p in all_payments), 2)
|
||||
db.commit()
|
||||
db.refresh(expense)
|
||||
return _expense_out(expense)
|
||||
97
local_backend/schemas/expenses.py
Normal file
97
local_backend/schemas/expenses.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ── Contacts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
name: str
|
||||
type: str = "supplier"
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ContactOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
type: str
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Expenses ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class ExpensePaymentCreate(BaseModel):
|
||||
amount: float
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ExpensePaymentOut(BaseModel):
|
||||
id: int
|
||||
expense_id: int
|
||||
amount: float
|
||||
paid_at: datetime
|
||||
paid_by_id: int
|
||||
paid_by_name: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ExpenseCreate(BaseModel):
|
||||
description: str
|
||||
category: str
|
||||
contact_id: Optional[int] = None
|
||||
total_amount: float
|
||||
due_date: Optional[datetime] = None
|
||||
business_day_id: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ExpenseUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
contact_id: Optional[int] = None
|
||||
total_amount: Optional[float] = None
|
||||
due_date: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ExpenseOut(BaseModel):
|
||||
id: int
|
||||
description: str
|
||||
category: str
|
||||
contact_id: Optional[int] = None
|
||||
contact_name: Optional[str] = None
|
||||
total_amount: float
|
||||
paid_amount: float
|
||||
due_amount: float # computed: total - paid
|
||||
status: str # "paid" | "partial" | "due"
|
||||
due_date: Optional[datetime] = None
|
||||
business_day_id: Optional[int] = None
|
||||
created_by_id: int
|
||||
created_by_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
notes: Optional[str] = None
|
||||
payments: List[ExpensePaymentOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
Reference in New Issue
Block a user