FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3421 lines
130 KiB
Python
3421 lines
130 KiB
Python
from datetime import date, timedelta
|
||
from decimal import Decimal
|
||
from typing import Optional
|
||
import logging
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, func, case
|
||
from sqlalchemy.orm import selectinload
|
||
from pydantic import BaseModel
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from database import get_db
|
||
from models.user import User
|
||
from models.invoice import Invoice, InvoiceStatus
|
||
from models.gp import RevenueEntry, GPPeriod
|
||
from models.newbook import NewbookDailyRevenue, NewbookGLAccount, NewbookDailyOccupancy
|
||
from models.cost_distribution import CostDistribution, CostDistributionEntry, DistributionStatus
|
||
from auth import get_current_user, require_cap
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
class RevenueEntryCreate(BaseModel):
|
||
date: date
|
||
amount: Decimal
|
||
category: str = "total"
|
||
notes: Optional[str] = None
|
||
|
||
|
||
class RevenueEntryResponse(BaseModel):
|
||
id: int
|
||
date: date
|
||
amount: Decimal
|
||
category: str
|
||
notes: Optional[str]
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
class GPReportRequest(BaseModel):
|
||
start_date: date
|
||
end_date: date
|
||
|
||
|
||
class GPReportResponse(BaseModel):
|
||
start_date: date
|
||
end_date: date
|
||
total_revenue: Decimal
|
||
total_costs: Decimal
|
||
gp_amount: Decimal
|
||
gp_percentage: Decimal
|
||
category_breakdown: dict
|
||
# Revenue breakdown
|
||
newbook_revenue: Optional[Decimal] = None
|
||
manual_revenue: Optional[Decimal] = None
|
||
# Allowances (credits that improve GP if applied)
|
||
wastage_total: Optional[Decimal] = None # Wastage logged in logbook
|
||
disputes_total: Optional[Decimal] = None # Open disputes on invoices in this period
|
||
allowances_total: Optional[Decimal] = None # wastage + disputes combined
|
||
gp_with_allowances: Optional[Decimal] = None # GP% if allowances applied
|
||
|
||
|
||
class DashboardResponse(BaseModel):
|
||
current_period: GPReportResponse | None
|
||
previous_period: GPReportResponse | None
|
||
forecast_period: GPReportResponse | None # Placeholder for this week's forecast
|
||
rolling_30_days: GPReportResponse | None # Last 30 days rolling (from yesterday)
|
||
recent_invoices: int
|
||
pending_review: int
|
||
|
||
|
||
class PurchaseInvoice(BaseModel):
|
||
id: int
|
||
invoice_number: str | None
|
||
total: Decimal | None
|
||
supplier_match_type: str | None # "exact", "fuzzy", or None (unmatched)
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
class SupplierRow(BaseModel):
|
||
supplier_id: int | None # None for unmatched invoices
|
||
supplier_name: str # Supplier name or vendor_name for unmatched
|
||
is_unmatched: bool
|
||
invoices_by_date: dict[str, list[PurchaseInvoice]] # date string -> invoices
|
||
total: Decimal
|
||
percentage: Decimal
|
||
|
||
|
||
class WeeklyPurchasesResponse(BaseModel):
|
||
week_start: date
|
||
week_end: date
|
||
dates: list[date] # 7 days
|
||
suppliers: list[SupplierRow]
|
||
daily_totals: dict[str, Decimal] # date string -> total
|
||
week_total: Decimal
|
||
|
||
|
||
# Monthly Purchases Calendar models
|
||
class MonthlyPurchaseInvoice(BaseModel):
|
||
"""Invoice with full detail for monthly view"""
|
||
id: int
|
||
invoice_number: str | None
|
||
invoice_date: date | None
|
||
total: Decimal | None # Gross total (inc. VAT)
|
||
net_total: Decimal | None # Net total (exc. VAT)
|
||
net_stock: Decimal | None # Net stock items only
|
||
gross_stock: Decimal | None # Gross stock items only (net_stock + stock VAT)
|
||
supplier_match_type: str | None
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
class MonthlySupplierRow(BaseModel):
|
||
"""Supplier row for monthly purchases - consistent order across weeks"""
|
||
supplier_id: int | None
|
||
supplier_name: str
|
||
is_unmatched: bool
|
||
invoices_by_date: dict[str, list[MonthlyPurchaseInvoice]] # date string -> invoices
|
||
total_net_stock: Decimal # Sum of net_stock for all invoices
|
||
percentage: Decimal
|
||
|
||
|
||
class WeekData(BaseModel):
|
||
"""Data for one week in the monthly view"""
|
||
week_start: date
|
||
week_end: date
|
||
dates: list[date] # 7 days (Mon-Sun)
|
||
suppliers: list[MonthlySupplierRow] # Same order as month-level suppliers
|
||
daily_totals: dict[str, Decimal] # date string -> net_stock total
|
||
week_total: Decimal # Net stock total for week
|
||
daily_invoice_totals: dict[str, Decimal] | None = None # date string -> full invoice net_total
|
||
week_invoice_total: Decimal | None = None # Full invoice net_total for week
|
||
|
||
|
||
class MonthlyPurchasesResponse(BaseModel):
|
||
"""Response for monthly purchases calendar view"""
|
||
year: int
|
||
month: int
|
||
month_name: str
|
||
weeks: list[WeekData] # All weeks in the month
|
||
all_suppliers: list[str] # Ordered list of all supplier names for consistent display
|
||
daily_totals: dict[str, Decimal] # All days in month -> net_stock total
|
||
month_total: Decimal # Net stock total for entire month
|
||
|
||
|
||
class DateRangePurchasesResponse(BaseModel):
|
||
"""Response for date range purchases view"""
|
||
from_date: date
|
||
to_date: date
|
||
period_label: str # Human-readable label like "Dec 18 - Jan 17, 2026"
|
||
weeks: list[WeekData] # All weeks in the range
|
||
all_suppliers: list[str] # Ordered list of all supplier names for consistent display
|
||
daily_totals: dict[str, Decimal] # All days in range -> net_stock total
|
||
period_total: Decimal # Net stock total for entire period
|
||
daily_invoice_totals: dict[str, Decimal] | None = None # All days -> full invoice net_total
|
||
period_invoice_total: Decimal | None = None # Full invoice net_total for entire period
|
||
|
||
|
||
@router.post("/revenue", response_model=RevenueEntryResponse)
|
||
async def add_revenue(
|
||
request: RevenueEntryCreate,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Add a revenue entry for GP calculation"""
|
||
entry = RevenueEntry(
|
||
kitchen_id=current_user.kitchen_id,
|
||
date=request.date,
|
||
amount=request.amount,
|
||
category=request.category,
|
||
notes=request.notes
|
||
)
|
||
db.add(entry)
|
||
await db.commit()
|
||
await db.refresh(entry)
|
||
|
||
return RevenueEntryResponse(
|
||
id=entry.id,
|
||
date=entry.date,
|
||
amount=entry.amount,
|
||
category=entry.category,
|
||
notes=entry.notes
|
||
)
|
||
|
||
|
||
@router.get("/revenue", response_model=list[RevenueEntryResponse])
|
||
async def list_revenue(
|
||
start_date: Optional[date] = None,
|
||
end_date: Optional[date] = None,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""List revenue entries for a date range"""
|
||
query = select(RevenueEntry).where(
|
||
RevenueEntry.kitchen_id == current_user.kitchen_id
|
||
)
|
||
|
||
if start_date:
|
||
query = query.where(RevenueEntry.date >= start_date)
|
||
if end_date:
|
||
query = query.where(RevenueEntry.date <= end_date)
|
||
|
||
query = query.order_by(RevenueEntry.date.desc())
|
||
result = await db.execute(query)
|
||
entries = result.scalars().all()
|
||
|
||
return [
|
||
RevenueEntryResponse(
|
||
id=e.id,
|
||
date=e.date,
|
||
amount=e.amount,
|
||
category=e.category,
|
||
notes=e.notes
|
||
)
|
||
for e in entries
|
||
]
|
||
|
||
|
||
@router.post("/gp", response_model=GPReportResponse)
|
||
async def calculate_gp(
|
||
request: GPReportRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Calculate GP for a specific date range"""
|
||
from models.line_item import LineItem
|
||
from sqlalchemy import or_, and_
|
||
|
||
# Get manual revenue entries for period
|
||
manual_revenue_result = await db.execute(
|
||
select(func.sum(RevenueEntry.amount))
|
||
.where(
|
||
RevenueEntry.kitchen_id == current_user.kitchen_id,
|
||
RevenueEntry.date >= request.start_date,
|
||
RevenueEntry.date <= request.end_date
|
||
)
|
||
)
|
||
manual_revenue = manual_revenue_result.scalar() or Decimal("0.00")
|
||
|
||
# Get Newbook revenue for tracked GL accounts
|
||
newbook_revenue_result = await db.execute(
|
||
select(func.sum(NewbookDailyRevenue.amount_net))
|
||
.join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id)
|
||
.where(
|
||
NewbookDailyRevenue.kitchen_id == current_user.kitchen_id,
|
||
NewbookDailyRevenue.date >= request.start_date,
|
||
NewbookDailyRevenue.date <= request.end_date,
|
||
NewbookGLAccount.is_tracked == True
|
||
)
|
||
)
|
||
newbook_revenue = newbook_revenue_result.scalar() or Decimal("0.00")
|
||
|
||
# Total revenue combines manual entries and Newbook data
|
||
total_revenue = manual_revenue + newbook_revenue
|
||
|
||
# Get total costs from confirmed invoices - stock items only
|
||
# Credit notes are treated as negative purchases
|
||
# Use subquery to sum per invoice first, then conditionally negate if credit note has positive total
|
||
# This matches the flash report's calc_stock_values logic
|
||
invoice_stock_subq = (
|
||
select(
|
||
Invoice.id.label('inv_id'),
|
||
Invoice.document_type.label('doc_type'),
|
||
Invoice.category.label('category'),
|
||
func.sum(LineItem.amount).label('stock_total')
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= request.start_date,
|
||
Invoice.invoice_date <= request.end_date,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
LineItem.amount.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
|
||
)
|
||
.group_by(Invoice.id, Invoice.document_type, Invoice.category)
|
||
.subquery()
|
||
)
|
||
|
||
costs_result = await db.execute(
|
||
select(func.sum(
|
||
case(
|
||
# Credit note with positive total - negate it
|
||
(and_(invoice_stock_subq.c.doc_type == 'credit_note',
|
||
invoice_stock_subq.c.stock_total > 0),
|
||
-invoice_stock_subq.c.stock_total),
|
||
# Otherwise use as-is (includes credit notes with already-negative totals)
|
||
else_=invoice_stock_subq.c.stock_total
|
||
)
|
||
))
|
||
.select_from(invoice_stock_subq)
|
||
)
|
||
total_costs = costs_result.scalar() or Decimal("0.00")
|
||
|
||
# Add cost distribution adjustments (net zero overall, but shifts cost between dates)
|
||
cd_adjustment_result = await db.execute(
|
||
select(func.sum(CostDistributionEntry.amount))
|
||
.join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id)
|
||
.where(
|
||
CostDistributionEntry.kitchen_id == current_user.kitchen_id,
|
||
CostDistributionEntry.entry_date >= request.start_date,
|
||
CostDistributionEntry.entry_date <= request.end_date,
|
||
CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]),
|
||
)
|
||
)
|
||
total_costs += cd_adjustment_result.scalar() or Decimal("0.00")
|
||
|
||
# Calculate GP
|
||
gp_amount = total_revenue - total_costs
|
||
gp_percentage = (gp_amount / total_revenue * 100) if total_revenue > 0 else Decimal("0.00")
|
||
|
||
# Category breakdown for costs (using same subquery approach)
|
||
category_result = await db.execute(
|
||
select(
|
||
invoice_stock_subq.c.category,
|
||
func.sum(
|
||
case(
|
||
(and_(invoice_stock_subq.c.doc_type == 'credit_note',
|
||
invoice_stock_subq.c.stock_total > 0),
|
||
-invoice_stock_subq.c.stock_total),
|
||
else_=invoice_stock_subq.c.stock_total
|
||
)
|
||
)
|
||
)
|
||
.select_from(invoice_stock_subq)
|
||
.group_by(invoice_stock_subq.c.category)
|
||
)
|
||
category_breakdown = {
|
||
cat or "uncategorized": float(amount)
|
||
for cat, amount in category_result.all()
|
||
}
|
||
|
||
return GPReportResponse(
|
||
start_date=request.start_date,
|
||
end_date=request.end_date,
|
||
total_revenue=total_revenue,
|
||
total_costs=total_costs,
|
||
gp_amount=gp_amount,
|
||
gp_percentage=round(gp_percentage, 2),
|
||
category_breakdown=category_breakdown,
|
||
newbook_revenue=newbook_revenue if newbook_revenue > 0 else None,
|
||
manual_revenue=manual_revenue if manual_revenue > 0 else None
|
||
)
|
||
|
||
|
||
@router.get("/dashboard", response_model=DashboardResponse)
|
||
async def get_dashboard(
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get dashboard summary with current and previous period GP"""
|
||
today = date.today()
|
||
|
||
# Current week (Monday to today)
|
||
current_start = today - timedelta(days=today.weekday())
|
||
current_end = today
|
||
|
||
# Previous week
|
||
prev_start = current_start - timedelta(days=7)
|
||
prev_end = current_start - timedelta(days=1)
|
||
|
||
async def calc_period_gp(start: date, end: date) -> GPReportResponse | None:
|
||
from models.line_item import LineItem
|
||
from models.logbook import LogbookEntry, EntryType
|
||
from models.dispute import InvoiceDispute, DisputeStatus
|
||
from sqlalchemy import or_
|
||
|
||
# Manual revenue entries
|
||
manual_rev_result = await db.execute(
|
||
select(func.sum(RevenueEntry.amount))
|
||
.where(
|
||
RevenueEntry.kitchen_id == current_user.kitchen_id,
|
||
RevenueEntry.date >= start,
|
||
RevenueEntry.date <= end
|
||
)
|
||
)
|
||
manual_revenue = manual_rev_result.scalar() or Decimal("0.00")
|
||
|
||
# Newbook revenue for tracked GL accounts
|
||
newbook_rev_result = await db.execute(
|
||
select(func.sum(NewbookDailyRevenue.amount_net))
|
||
.join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id)
|
||
.where(
|
||
NewbookDailyRevenue.kitchen_id == current_user.kitchen_id,
|
||
NewbookDailyRevenue.date >= start,
|
||
NewbookDailyRevenue.date <= end,
|
||
NewbookGLAccount.is_tracked == True
|
||
)
|
||
)
|
||
newbook_revenue = newbook_rev_result.scalar() or Decimal("0.00")
|
||
|
||
# Total revenue
|
||
revenue = manual_revenue + newbook_revenue
|
||
|
||
# Costs - stock items only (exclude non-stock)
|
||
# Credit notes (document_type='credit_note') are treated as negative purchases
|
||
# Use subquery to sum per invoice first, then conditionally negate if credit note has positive total
|
||
# This matches the flash report's calc_stock_values logic
|
||
from sqlalchemy import and_
|
||
invoice_stock_subq = (
|
||
select(
|
||
Invoice.id.label('inv_id'),
|
||
Invoice.document_type.label('doc_type'),
|
||
func.sum(LineItem.amount).label('stock_total')
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= start,
|
||
Invoice.invoice_date <= end,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
LineItem.amount.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
|
||
)
|
||
.group_by(Invoice.id, Invoice.document_type)
|
||
.subquery()
|
||
)
|
||
cost_result = await db.execute(
|
||
select(func.sum(
|
||
case(
|
||
# Credit note with positive total - negate it
|
||
(and_(invoice_stock_subq.c.doc_type == 'credit_note',
|
||
invoice_stock_subq.c.stock_total > 0),
|
||
-invoice_stock_subq.c.stock_total),
|
||
# Otherwise use as-is (includes credit notes with already-negative totals)
|
||
else_=invoice_stock_subq.c.stock_total
|
||
)
|
||
))
|
||
.select_from(invoice_stock_subq)
|
||
)
|
||
costs = cost_result.scalar() or Decimal("0.00")
|
||
|
||
# Add cost distribution adjustments
|
||
cd_adj_result = await db.execute(
|
||
select(func.sum(CostDistributionEntry.amount))
|
||
.join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id)
|
||
.where(
|
||
CostDistributionEntry.kitchen_id == current_user.kitchen_id,
|
||
CostDistributionEntry.entry_date >= start,
|
||
CostDistributionEntry.entry_date <= end,
|
||
CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]),
|
||
)
|
||
)
|
||
costs += cd_adj_result.scalar() or Decimal("0.00")
|
||
|
||
# Wastage total from logbook
|
||
wastage_result = await db.execute(
|
||
select(func.sum(LogbookEntry.total_cost))
|
||
.where(
|
||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||
LogbookEntry.entry_date >= start,
|
||
LogbookEntry.entry_date <= end,
|
||
LogbookEntry.entry_type == EntryType.WASTAGE,
|
||
LogbookEntry.is_deleted == False
|
||
)
|
||
)
|
||
wastage_total = wastage_result.scalar() or Decimal("0.00")
|
||
|
||
# Open disputes total - based on invoice date, not dispute creation date
|
||
# These are potential credits that would reduce costs if resolved
|
||
open_statuses = [
|
||
DisputeStatus.NEW, DisputeStatus.OPEN, DisputeStatus.CONTACTED,
|
||
DisputeStatus.IN_PROGRESS, DisputeStatus.AWAITING_CREDIT,
|
||
DisputeStatus.AWAITING_REPLACEMENT, DisputeStatus.ESCALATED
|
||
]
|
||
disputes_result = await db.execute(
|
||
select(func.sum(InvoiceDispute.difference_amount))
|
||
.join(Invoice, InvoiceDispute.invoice_id == Invoice.id)
|
||
.where(
|
||
InvoiceDispute.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= start,
|
||
Invoice.invoice_date <= end,
|
||
InvoiceDispute.status.in_(open_statuses)
|
||
)
|
||
)
|
||
disputes_total = disputes_result.scalar() or Decimal("0.00")
|
||
|
||
if revenue == 0 and costs == 0:
|
||
return None
|
||
|
||
gp_amount = revenue - costs
|
||
gp_pct = (gp_amount / revenue * 100) if revenue > 0 else Decimal("0.00")
|
||
|
||
# Calculate GP with allowances (wastage + disputes as credits)
|
||
# Allowances = money that could be recovered/saved
|
||
# - Wastage: if not wasted, wouldn't have purchased
|
||
# - Disputes: credits expected from suppliers
|
||
allowances_total = wastage_total + disputes_total
|
||
gp_with_allowances = revenue - costs + allowances_total
|
||
gp_with_allowances_pct = (gp_with_allowances / revenue * 100) if revenue > 0 else Decimal("0.00")
|
||
|
||
# Only show allowances section if there are any
|
||
has_allowances = allowances_total > 0
|
||
|
||
return GPReportResponse(
|
||
start_date=start,
|
||
end_date=end,
|
||
total_revenue=revenue,
|
||
total_costs=costs,
|
||
gp_amount=gp_amount,
|
||
gp_percentage=round(gp_pct, 2),
|
||
category_breakdown={},
|
||
newbook_revenue=newbook_revenue if newbook_revenue > 0 else None,
|
||
manual_revenue=manual_revenue if manual_revenue > 0 else None,
|
||
wastage_total=wastage_total if wastage_total > 0 else None,
|
||
disputes_total=disputes_total if disputes_total > 0 else None,
|
||
allowances_total=allowances_total if has_allowances else None,
|
||
gp_with_allowances=round(gp_with_allowances_pct, 2) if has_allowances else None
|
||
)
|
||
|
||
# Recent invoices count (last 7 days)
|
||
recent_result = await db.execute(
|
||
select(func.count(Invoice.id))
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
Invoice.created_at >= today - timedelta(days=7)
|
||
)
|
||
)
|
||
recent_invoices = recent_result.scalar() or 0
|
||
|
||
# Pending confirmation count (all non-confirmed: pending, processed, reviewed)
|
||
from sqlalchemy import or_
|
||
pending_result = await db.execute(
|
||
select(func.count(Invoice.id))
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
or_(
|
||
Invoice.status == InvoiceStatus.PENDING,
|
||
Invoice.status == InvoiceStatus.PROCESSED,
|
||
Invoice.status == InvoiceStatus.REVIEWED
|
||
)
|
||
)
|
||
)
|
||
pending_review = pending_result.scalar() or 0
|
||
|
||
# Rolling 30 days (from yesterday back 29 days)
|
||
yesterday = today - timedelta(days=1)
|
||
rolling_30_start = yesterday - timedelta(days=29)
|
||
rolling_30_end = yesterday
|
||
|
||
return DashboardResponse(
|
||
current_period=await calc_period_gp(current_start, current_end),
|
||
previous_period=await calc_period_gp(prev_start, prev_end),
|
||
forecast_period=None, # Placeholder - forecast not implemented yet
|
||
rolling_30_days=await calc_period_gp(rolling_30_start, rolling_30_end),
|
||
recent_invoices=recent_invoices,
|
||
pending_review=pending_review
|
||
)
|
||
|
||
|
||
@router.get("/purchases/weekly", response_model=WeeklyPurchasesResponse)
|
||
async def get_weekly_purchases(
|
||
week_offset: int = 0, # 0 = current week, -1 = last week, etc.
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get weekly purchases organized by supplier and date for table view"""
|
||
from models.supplier import Supplier
|
||
from collections import defaultdict
|
||
|
||
today = date.today()
|
||
# Calculate week start (Monday) with offset
|
||
week_start = today - timedelta(days=today.weekday()) + timedelta(weeks=week_offset)
|
||
week_end = week_start + timedelta(days=6)
|
||
dates = [week_start + timedelta(days=i) for i in range(7)]
|
||
|
||
# Get all invoices for the week (all statuses, matched or not)
|
||
# Fetch all recent invoices and filter in Python for reliable date handling
|
||
result = await db.execute(
|
||
select(Invoice)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
)
|
||
.order_by(Invoice.invoice_date.desc().nullslast())
|
||
)
|
||
all_invoices = result.scalars().all()
|
||
|
||
# Filter to invoices in the week range, using invoice_date or created_at as fallback
|
||
invoices = []
|
||
for inv in all_invoices:
|
||
inv_date = inv.invoice_date or inv.created_at.date()
|
||
if week_start <= inv_date <= week_end:
|
||
invoices.append(inv)
|
||
|
||
# Get all suppliers for name lookup
|
||
supplier_result = await db.execute(
|
||
select(Supplier).where(Supplier.kitchen_id == current_user.kitchen_id)
|
||
)
|
||
suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()}
|
||
|
||
# Organize invoices by supplier
|
||
supplier_invoices: dict[tuple, list] = defaultdict(list) # (supplier_id, name, is_unmatched) -> invoices
|
||
|
||
for inv in invoices:
|
||
if inv.supplier_id:
|
||
key = (inv.supplier_id, suppliers_map.get(inv.supplier_id, "Unknown"), False)
|
||
else:
|
||
# Unmatched - use vendor_name or "Unknown Supplier"
|
||
vendor = inv.vendor_name or "Unknown Supplier"
|
||
key = (None, vendor, True)
|
||
supplier_invoices[key].append(inv)
|
||
|
||
# Helper to get effective total (negative for credit notes)
|
||
def get_effective_total(inv: Invoice) -> Decimal:
|
||
total = inv.total or Decimal("0")
|
||
if inv.document_type == 'credit_note':
|
||
return -total
|
||
return total
|
||
|
||
# Calculate week total
|
||
week_total = sum(get_effective_total(inv) for inv in invoices)
|
||
|
||
# Build supplier rows
|
||
supplier_rows = []
|
||
for (supplier_id, supplier_name, is_unmatched), invs in sorted(
|
||
supplier_invoices.items(), key=lambda x: (x[0][2], x[0][1].lower()) # Matched first, then alphabetical
|
||
):
|
||
invoices_by_date: dict[str, list[PurchaseInvoice]] = defaultdict(list)
|
||
row_total = Decimal("0")
|
||
|
||
for inv in invs:
|
||
# Use invoice_date if available, otherwise use created_at date
|
||
inv_date = inv.invoice_date or inv.created_at.date()
|
||
date_str = inv_date.isoformat()
|
||
invoices_by_date[date_str].append(PurchaseInvoice(
|
||
id=inv.id,
|
||
invoice_number=inv.invoice_number,
|
||
total=get_effective_total(inv), # Negative for credit notes
|
||
supplier_match_type=inv.supplier_match_type
|
||
))
|
||
row_total += get_effective_total(inv)
|
||
|
||
percentage = (row_total / week_total * 100) if week_total > 0 else Decimal("0")
|
||
|
||
supplier_rows.append(SupplierRow(
|
||
supplier_id=supplier_id,
|
||
supplier_name=supplier_name,
|
||
is_unmatched=is_unmatched,
|
||
invoices_by_date=dict(invoices_by_date),
|
||
total=row_total,
|
||
percentage=round(percentage, 1)
|
||
))
|
||
|
||
# Calculate daily totals
|
||
daily_totals = {}
|
||
for d in dates:
|
||
date_str = d.isoformat()
|
||
daily_totals[date_str] = sum(
|
||
get_effective_total(inv)
|
||
for inv in invoices
|
||
if (inv.invoice_date or inv.created_at.date()).isoformat() == date_str
|
||
)
|
||
|
||
return WeeklyPurchasesResponse(
|
||
week_start=week_start,
|
||
week_end=week_end,
|
||
dates=dates,
|
||
suppliers=supplier_rows,
|
||
daily_totals=daily_totals,
|
||
week_total=week_total
|
||
)
|
||
|
||
|
||
@router.get("/purchases/monthly", response_model=MonthlyPurchasesResponse)
|
||
async def get_monthly_purchases(
|
||
year: int | None = None,
|
||
month: int | None = None, # 1-12
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get monthly purchases organized by week, supplier, and date for calendar view"""
|
||
from models.supplier import Supplier
|
||
from models.line_item import LineItem
|
||
from collections import defaultdict
|
||
from calendar import monthrange, month_name as calendar_month_name
|
||
|
||
# Default to current month
|
||
today = date.today()
|
||
year = year or today.year
|
||
month = month or today.month
|
||
|
||
# Get first and last day of month
|
||
_, days_in_month = monthrange(year, month)
|
||
month_start = date(year, month, 1)
|
||
month_end = date(year, month, days_in_month)
|
||
|
||
# Get all invoices for the month with their line items
|
||
result = await db.execute(
|
||
select(Invoice)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
)
|
||
.options(selectinload(Invoice.line_items))
|
||
.order_by(Invoice.invoice_date.desc().nullslast())
|
||
)
|
||
all_invoices = result.scalars().all()
|
||
|
||
# Filter to invoices in the month range
|
||
invoices = []
|
||
for inv in all_invoices:
|
||
inv_date = inv.invoice_date or inv.created_at.date()
|
||
if month_start <= inv_date <= month_end:
|
||
invoices.append(inv)
|
||
|
||
# Get all suppliers for name lookup
|
||
supplier_result = await db.execute(
|
||
select(Supplier).where(Supplier.kitchen_id == current_user.kitchen_id)
|
||
)
|
||
suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()}
|
||
|
||
# Helper to calculate stock values for an invoice
|
||
def calc_stock_values(inv: Invoice) -> tuple[Decimal, Decimal]:
|
||
"""Returns (net_stock, gross_stock) for an invoice.
|
||
Credit notes (document_type='credit_note') return negative values.
|
||
"""
|
||
net_stock = Decimal("0")
|
||
if inv.line_items:
|
||
for item in inv.line_items:
|
||
if not (item.is_non_stock or False):
|
||
item_net = item.amount or Decimal("0")
|
||
net_stock += item_net
|
||
|
||
# Calculate gross_stock by applying invoice's VAT ratio to net_stock
|
||
# (since line items often don't have individual tax_amount)
|
||
if net_stock > 0 and inv.net_total and inv.total and inv.net_total > 0:
|
||
vat_ratio = inv.total / inv.net_total
|
||
gross_stock = (net_stock * vat_ratio).quantize(Decimal("0.01"))
|
||
else:
|
||
gross_stock = net_stock
|
||
|
||
# Credit notes are negative purchases - but only negate if values are positive
|
||
# Some suppliers already use negative values on credit note line items
|
||
if inv.document_type == 'credit_note':
|
||
if net_stock > 0:
|
||
net_stock = -net_stock
|
||
if gross_stock > 0:
|
||
gross_stock = -gross_stock
|
||
|
||
return net_stock, gross_stock
|
||
|
||
# Build invoice data with stock values
|
||
# Also negate total and net_total for credit notes so frontend sums work correctly
|
||
# Only negate if values are positive (some suppliers already use negative values)
|
||
invoice_data = {}
|
||
for inv in invoices:
|
||
net_stock, gross_stock = calc_stock_values(inv)
|
||
inv_date = inv.invoice_date or inv.created_at.date()
|
||
is_credit = inv.document_type == 'credit_note'
|
||
invoice_data[inv.id] = {
|
||
"inv": inv,
|
||
"date": inv_date,
|
||
"net_stock": net_stock,
|
||
"gross_stock": gross_stock,
|
||
"total": -inv.total if is_credit and inv.total and inv.total > 0 else inv.total,
|
||
"net_total": -inv.net_total if is_credit and inv.net_total and inv.net_total > 0 else inv.net_total,
|
||
}
|
||
|
||
# Organize by supplier
|
||
supplier_invoices: dict[tuple, list] = defaultdict(list) # (supplier_id, name, is_unmatched) -> invoice ids
|
||
for inv_id, data in invoice_data.items():
|
||
inv = data["inv"]
|
||
if inv.supplier_id:
|
||
key = (inv.supplier_id, suppliers_map.get(inv.supplier_id, "Unknown"), False)
|
||
else:
|
||
vendor = inv.vendor_name or "Unknown Supplier"
|
||
key = (None, vendor, True)
|
||
supplier_invoices[key].append(inv_id)
|
||
|
||
# Get ordered list of all suppliers (matched first, then alphabetical)
|
||
all_supplier_keys = sorted(
|
||
supplier_invoices.keys(),
|
||
key=lambda x: (x[2], x[1].lower()) # is_unmatched, then name
|
||
)
|
||
all_suppliers = [name for (_, name, _) in all_supplier_keys]
|
||
|
||
# Calculate month total
|
||
month_total = sum(data["net_stock"] for data in invoice_data.values())
|
||
|
||
# Build weeks - find all weeks that overlap with the month
|
||
weeks_data = []
|
||
|
||
# Find first Monday on or before month start
|
||
first_monday = month_start - timedelta(days=month_start.weekday())
|
||
|
||
current_week_start = first_monday
|
||
while current_week_start <= month_end:
|
||
week_end = current_week_start + timedelta(days=6)
|
||
week_dates = [current_week_start + timedelta(days=i) for i in range(7)]
|
||
|
||
# Build supplier rows for this week (maintain consistent order)
|
||
# First pass: collect data and calculate week_total
|
||
week_total = Decimal("0")
|
||
week_daily_totals: dict[str, Decimal] = defaultdict(Decimal)
|
||
supplier_data_list: list[tuple] = [] # (supplier_key, invoices_by_date, supplier_week_total)
|
||
|
||
for supplier_key in all_supplier_keys:
|
||
supplier_id, supplier_name, is_unmatched = supplier_key
|
||
inv_ids = supplier_invoices.get(supplier_key, [])
|
||
|
||
invoices_by_date: dict[str, list[MonthlyPurchaseInvoice]] = defaultdict(list)
|
||
supplier_week_total = Decimal("0")
|
||
|
||
for inv_id in inv_ids:
|
||
data = invoice_data[inv_id]
|
||
inv = data["inv"]
|
||
inv_date = data["date"]
|
||
|
||
# Only include if in this week
|
||
if current_week_start <= inv_date <= week_end:
|
||
date_str = inv_date.isoformat()
|
||
invoices_by_date[date_str].append(MonthlyPurchaseInvoice(
|
||
id=inv.id,
|
||
invoice_number=inv.invoice_number,
|
||
invoice_date=inv.invoice_date,
|
||
total=data["total"],
|
||
net_total=data["net_total"],
|
||
net_stock=data["net_stock"],
|
||
gross_stock=data["gross_stock"],
|
||
supplier_match_type=inv.supplier_match_type
|
||
))
|
||
supplier_week_total += data["net_stock"]
|
||
week_daily_totals[date_str] += data["net_stock"]
|
||
|
||
week_total += supplier_week_total
|
||
supplier_data_list.append((supplier_key, dict(invoices_by_date), supplier_week_total))
|
||
|
||
# Second pass: calculate percentages using week_total
|
||
week_supplier_rows = []
|
||
for supplier_key, invoices_by_date, supplier_week_total in supplier_data_list:
|
||
supplier_id, supplier_name, is_unmatched = supplier_key
|
||
percentage = (supplier_week_total / week_total * 100) if week_total > 0 else Decimal("0")
|
||
|
||
week_supplier_rows.append(MonthlySupplierRow(
|
||
supplier_id=supplier_id,
|
||
supplier_name=supplier_name,
|
||
is_unmatched=is_unmatched,
|
||
invoices_by_date=invoices_by_date,
|
||
total_net_stock=supplier_week_total,
|
||
percentage=round(percentage, 1)
|
||
))
|
||
|
||
weeks_data.append(WeekData(
|
||
week_start=current_week_start,
|
||
week_end=week_end,
|
||
dates=week_dates,
|
||
suppliers=week_supplier_rows,
|
||
daily_totals=dict(week_daily_totals),
|
||
week_total=week_total
|
||
))
|
||
|
||
current_week_start += timedelta(days=7)
|
||
|
||
# Calculate daily totals for entire month
|
||
monthly_daily_totals: dict[str, Decimal] = defaultdict(Decimal)
|
||
for data in invoice_data.values():
|
||
date_str = data["date"].isoformat()
|
||
monthly_daily_totals[date_str] += data["net_stock"]
|
||
|
||
return MonthlyPurchasesResponse(
|
||
year=year,
|
||
month=month,
|
||
month_name=calendar_month_name[month],
|
||
weeks=weeks_data,
|
||
all_suppliers=all_suppliers,
|
||
daily_totals=dict(monthly_daily_totals),
|
||
month_total=month_total
|
||
)
|
||
|
||
|
||
@router.get("/purchases/range", response_model=DateRangePurchasesResponse)
|
||
async def get_purchases_by_range(
|
||
from_date: date,
|
||
to_date: date,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get purchases organized by week for a custom date range"""
|
||
from models.supplier import Supplier
|
||
from models.line_item import LineItem
|
||
from collections import defaultdict
|
||
|
||
# Validate date range
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
# Build period label
|
||
if from_date.year == to_date.year:
|
||
if from_date.month == to_date.month:
|
||
period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}"
|
||
else:
|
||
period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}"
|
||
else:
|
||
period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}"
|
||
|
||
# Get all confirmed invoices for the range with their line items
|
||
result = await db.execute(
|
||
select(Invoice)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
)
|
||
.options(selectinload(Invoice.line_items))
|
||
.order_by(Invoice.invoice_date.desc().nullslast())
|
||
)
|
||
all_invoices = result.scalars().all()
|
||
|
||
# Filter to invoices in the date range
|
||
invoices = []
|
||
for inv in all_invoices:
|
||
inv_date = inv.invoice_date or inv.created_at.date()
|
||
if from_date <= inv_date <= to_date:
|
||
invoices.append(inv)
|
||
|
||
# Get all suppliers for name lookup
|
||
supplier_result = await db.execute(
|
||
select(Supplier).where(Supplier.kitchen_id == current_user.kitchen_id)
|
||
)
|
||
suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()}
|
||
|
||
# Helper to calculate stock values for an invoice
|
||
def calc_stock_values(inv: Invoice) -> tuple[Decimal, Decimal]:
|
||
"""Returns (net_stock, gross_stock) for an invoice.
|
||
Credit notes (document_type='credit_note') return negative values.
|
||
"""
|
||
net_stock = Decimal("0")
|
||
if inv.line_items:
|
||
for item in inv.line_items:
|
||
if not (item.is_non_stock or False):
|
||
item_net = item.amount or Decimal("0")
|
||
net_stock += item_net
|
||
|
||
# Calculate gross_stock by applying invoice's VAT ratio to net_stock
|
||
if net_stock > 0 and inv.net_total and inv.total and inv.net_total > 0:
|
||
vat_ratio = inv.total / inv.net_total
|
||
gross_stock = (net_stock * vat_ratio).quantize(Decimal("0.01"))
|
||
else:
|
||
gross_stock = net_stock
|
||
|
||
# Credit notes are negative purchases - but only negate if values are positive
|
||
# Some suppliers already use negative values on credit note line items
|
||
if inv.document_type == 'credit_note':
|
||
if net_stock > 0:
|
||
net_stock = -net_stock
|
||
if gross_stock > 0:
|
||
gross_stock = -gross_stock
|
||
|
||
return net_stock, gross_stock
|
||
|
||
# Build invoice data with stock values
|
||
# Also negate total and net_total for credit notes so frontend sums work correctly
|
||
# Only negate if values are positive (some suppliers already use negative values)
|
||
invoice_data = {}
|
||
for inv in invoices:
|
||
net_stock, gross_stock = calc_stock_values(inv)
|
||
inv_date = inv.invoice_date or inv.created_at.date()
|
||
is_credit = inv.document_type == 'credit_note'
|
||
invoice_data[inv.id] = {
|
||
"inv": inv,
|
||
"date": inv_date,
|
||
"net_stock": net_stock,
|
||
"gross_stock": gross_stock,
|
||
"total": -inv.total if is_credit and inv.total and inv.total > 0 else inv.total,
|
||
"net_total": -inv.net_total if is_credit and inv.net_total and inv.net_total > 0 else inv.net_total,
|
||
}
|
||
|
||
# Organize by supplier
|
||
supplier_invoices: dict[tuple, list] = defaultdict(list)
|
||
for inv_id, data in invoice_data.items():
|
||
inv = data["inv"]
|
||
if inv.supplier_id:
|
||
key = (inv.supplier_id, suppliers_map.get(inv.supplier_id, "Unknown"), False)
|
||
else:
|
||
vendor = inv.vendor_name or "Unknown Supplier"
|
||
key = (None, vendor, True)
|
||
supplier_invoices[key].append(inv_id)
|
||
|
||
# Get ordered list of all suppliers (matched first, then alphabetical)
|
||
all_supplier_keys = sorted(
|
||
supplier_invoices.keys(),
|
||
key=lambda x: (x[2], x[1].lower())
|
||
)
|
||
all_suppliers = [name for (_, name, _) in all_supplier_keys]
|
||
|
||
# Calculate period totals (stock and invoice)
|
||
# Use stored net_total (already negated for credit notes), fall back to stored total
|
||
period_total = sum(data["net_stock"] for data in invoice_data.values())
|
||
period_invoice_total = sum(
|
||
(data["net_total"] or data["total"] or Decimal("0")) for data in invoice_data.values()
|
||
)
|
||
|
||
# Build weeks - find all weeks that overlap with the date range
|
||
weeks_data = []
|
||
|
||
# Find first Monday on or before from_date
|
||
first_monday = from_date - timedelta(days=from_date.weekday())
|
||
|
||
current_week_start = first_monday
|
||
while current_week_start <= to_date:
|
||
week_end = current_week_start + timedelta(days=6)
|
||
week_dates = [current_week_start + timedelta(days=i) for i in range(7)]
|
||
|
||
# Build supplier rows for this week
|
||
week_total = Decimal("0")
|
||
week_invoice_total = Decimal("0")
|
||
week_daily_totals: dict[str, Decimal] = defaultdict(Decimal)
|
||
week_daily_invoice_totals: dict[str, Decimal] = defaultdict(Decimal)
|
||
supplier_data_list: list[tuple] = []
|
||
|
||
for supplier_key in all_supplier_keys:
|
||
supplier_id, supplier_name, is_unmatched = supplier_key
|
||
inv_ids = supplier_invoices.get(supplier_key, [])
|
||
|
||
invoices_by_date: dict[str, list[MonthlyPurchaseInvoice]] = defaultdict(list)
|
||
supplier_week_total = Decimal("0")
|
||
|
||
for inv_id in inv_ids:
|
||
data = invoice_data[inv_id]
|
||
inv = data["inv"]
|
||
inv_date = data["date"]
|
||
|
||
# Only include if in this week
|
||
if current_week_start <= inv_date <= week_end:
|
||
date_str = inv_date.isoformat()
|
||
invoices_by_date[date_str].append(MonthlyPurchaseInvoice(
|
||
id=inv.id,
|
||
invoice_number=inv.invoice_number,
|
||
invoice_date=inv.invoice_date,
|
||
total=data["total"],
|
||
net_total=data["net_total"],
|
||
net_stock=data["net_stock"],
|
||
gross_stock=data["gross_stock"],
|
||
supplier_match_type=inv.supplier_match_type
|
||
))
|
||
supplier_week_total += data["net_stock"]
|
||
week_daily_totals[date_str] += data["net_stock"]
|
||
# Use stored net_total (already negated for credit notes), fall back to stored total
|
||
inv_net = data["net_total"] or data["total"] or Decimal("0")
|
||
week_invoice_total += inv_net
|
||
week_daily_invoice_totals[date_str] += inv_net
|
||
|
||
week_total += supplier_week_total
|
||
supplier_data_list.append((supplier_key, dict(invoices_by_date), supplier_week_total))
|
||
|
||
# Calculate percentages
|
||
week_supplier_rows = []
|
||
for supplier_key, invoices_by_date, supplier_week_total in supplier_data_list:
|
||
supplier_id, supplier_name, is_unmatched = supplier_key
|
||
percentage = (supplier_week_total / week_total * 100) if week_total > 0 else Decimal("0")
|
||
|
||
week_supplier_rows.append(MonthlySupplierRow(
|
||
supplier_id=supplier_id,
|
||
supplier_name=supplier_name,
|
||
is_unmatched=is_unmatched,
|
||
invoices_by_date=invoices_by_date,
|
||
total_net_stock=supplier_week_total,
|
||
percentage=round(percentage, 1)
|
||
))
|
||
|
||
weeks_data.append(WeekData(
|
||
week_start=current_week_start,
|
||
week_end=week_end,
|
||
dates=week_dates,
|
||
suppliers=week_supplier_rows,
|
||
daily_totals=dict(week_daily_totals),
|
||
week_total=week_total,
|
||
daily_invoice_totals=dict(week_daily_invoice_totals),
|
||
week_invoice_total=week_invoice_total
|
||
))
|
||
|
||
current_week_start += timedelta(days=7)
|
||
|
||
# Calculate daily totals for entire period
|
||
period_daily_totals: dict[str, Decimal] = defaultdict(Decimal)
|
||
period_daily_invoice_totals: dict[str, Decimal] = defaultdict(Decimal)
|
||
for data in invoice_data.values():
|
||
date_str = data["date"].isoformat()
|
||
period_daily_totals[date_str] += data["net_stock"]
|
||
# Use stored net_total (already negated for credit notes), fall back to stored total
|
||
period_daily_invoice_totals[date_str] += data["net_total"] or data["total"] or Decimal("0")
|
||
|
||
return DateRangePurchasesResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
period_label=period_label,
|
||
weeks=weeks_data,
|
||
all_suppliers=all_suppliers,
|
||
daily_totals=dict(period_daily_totals),
|
||
period_total=period_total,
|
||
daily_invoice_totals=dict(period_daily_invoice_totals),
|
||
period_invoice_total=period_invoice_total
|
||
)
|
||
|
||
|
||
class MonthlyGPResponse(BaseModel):
|
||
"""Response for monthly GP calculation"""
|
||
year: int
|
||
month: int
|
||
month_name: str
|
||
net_food_sales: Decimal # Newbook revenue + manual entries
|
||
net_food_purchases: Decimal # Confirmed invoices net_total sum
|
||
gross_profit: Decimal # Sales - Purchases
|
||
gross_profit_percent: Decimal # (GP / Sales) * 100
|
||
|
||
|
||
class SupplierBreakdown(BaseModel):
|
||
"""Supplier purchase breakdown for period"""
|
||
supplier_id: int | None
|
||
supplier_name: str
|
||
net_purchases: Decimal
|
||
percentage: Decimal
|
||
|
||
|
||
class GLAccountBreakdown(BaseModel):
|
||
"""GL account revenue breakdown for period"""
|
||
gl_account_id: int
|
||
gl_account_name: str
|
||
net_revenue: Decimal
|
||
percentage: Decimal
|
||
|
||
|
||
class DateRangeGPResponse(BaseModel):
|
||
"""Response for date range GP calculation"""
|
||
from_date: date
|
||
to_date: date
|
||
period_label: str # Human-readable label like "Dec 18 - Jan 17, 2026"
|
||
net_food_sales: Decimal # Newbook revenue + manual entries
|
||
net_food_purchases: Decimal # Confirmed invoices net_total sum
|
||
gross_profit: Decimal # Sales - Purchases
|
||
gross_profit_percent: Decimal # (GP / Sales) * 100
|
||
supplier_breakdown: list[SupplierBreakdown] = []
|
||
gl_account_breakdown: list[GLAccountBreakdown] = []
|
||
# Allowances breakdown - logbook entry types
|
||
wastage_total: Optional[Decimal] = None # Wastage entries
|
||
transfer_total: Optional[Decimal] = None # Transfer entries
|
||
staff_food_total: Optional[Decimal] = None # Staff food entries
|
||
manual_adjustment_total: Optional[Decimal] = None # Manual adjustment entries
|
||
# Cost distribution breakdown
|
||
cd_deductions_total: Optional[Decimal] = None # Source offsets (negative amounts removing cost from invoice dates)
|
||
cd_reallocations_total: Optional[Decimal] = None # Distribution entries (positive amounts adding cost to target dates)
|
||
# Open disputes on invoices in this period
|
||
disputes_total: Optional[Decimal] = None
|
||
|
||
|
||
@router.get("/gp/range", response_model=DateRangeGPResponse)
|
||
async def get_gp_by_range(
|
||
from_date: date,
|
||
to_date: date,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get GP calculation for a custom date range (inclusive)"""
|
||
from models.line_item import LineItem
|
||
from models.logbook import LogbookEntry, EntryType
|
||
from sqlalchemy import or_
|
||
|
||
# Validate date range
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
# Build period label
|
||
if from_date.year == to_date.year:
|
||
if from_date.month == to_date.month:
|
||
period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}"
|
||
else:
|
||
period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}"
|
||
else:
|
||
period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}"
|
||
|
||
# Get Newbook revenue for tracked GL accounts
|
||
newbook_revenue_result = await db.execute(
|
||
select(func.sum(NewbookDailyRevenue.amount_net))
|
||
.join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id)
|
||
.where(
|
||
NewbookDailyRevenue.kitchen_id == current_user.kitchen_id,
|
||
NewbookDailyRevenue.date >= from_date,
|
||
NewbookDailyRevenue.date <= to_date,
|
||
NewbookGLAccount.is_tracked == True
|
||
)
|
||
)
|
||
newbook_revenue = newbook_revenue_result.scalar() or Decimal("0.00")
|
||
|
||
# Get manual revenue entries for period
|
||
manual_revenue_result = await db.execute(
|
||
select(func.sum(RevenueEntry.amount))
|
||
.where(
|
||
RevenueEntry.kitchen_id == current_user.kitchen_id,
|
||
RevenueEntry.date >= from_date,
|
||
RevenueEntry.date <= to_date
|
||
)
|
||
)
|
||
manual_revenue = manual_revenue_result.scalar() or Decimal("0.00")
|
||
|
||
# Total net food sales
|
||
net_food_sales = newbook_revenue + manual_revenue
|
||
|
||
# Get net purchases from confirmed invoices - stock items only (exclude non-stock)
|
||
# Credit notes (document_type='credit_note') are treated as negative purchases
|
||
# Use subquery to sum per invoice first, then conditionally negate if credit note has positive total
|
||
from sqlalchemy import and_
|
||
invoice_stock_subq = (
|
||
select(
|
||
Invoice.id.label('inv_id'),
|
||
Invoice.document_type.label('doc_type'),
|
||
func.sum(LineItem.amount).label('stock_total')
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= from_date,
|
||
Invoice.invoice_date <= to_date,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
LineItem.amount.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
|
||
)
|
||
.group_by(Invoice.id, Invoice.document_type)
|
||
.subquery()
|
||
)
|
||
purchases_result = await db.execute(
|
||
select(func.sum(
|
||
case(
|
||
(and_(invoice_stock_subq.c.doc_type == 'credit_note',
|
||
invoice_stock_subq.c.stock_total > 0),
|
||
-invoice_stock_subq.c.stock_total),
|
||
else_=invoice_stock_subq.c.stock_total
|
||
)
|
||
))
|
||
.select_from(invoice_stock_subq)
|
||
)
|
||
net_food_purchases = purchases_result.scalar() or Decimal("0.00")
|
||
|
||
# Add cost distribution adjustments — split into deductions (source offsets) and reallocations
|
||
cd_deductions_result = await db.execute(
|
||
select(func.sum(CostDistributionEntry.amount))
|
||
.join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id)
|
||
.where(
|
||
CostDistributionEntry.kitchen_id == current_user.kitchen_id,
|
||
CostDistributionEntry.entry_date >= from_date,
|
||
CostDistributionEntry.entry_date <= to_date,
|
||
CostDistributionEntry.is_source_offset == True,
|
||
CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]),
|
||
)
|
||
)
|
||
cd_deductions = cd_deductions_result.scalar() or Decimal("0.00") # Will be negative
|
||
|
||
cd_reallocations_result = await db.execute(
|
||
select(func.sum(CostDistributionEntry.amount))
|
||
.join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id)
|
||
.where(
|
||
CostDistributionEntry.kitchen_id == current_user.kitchen_id,
|
||
CostDistributionEntry.entry_date >= from_date,
|
||
CostDistributionEntry.entry_date <= to_date,
|
||
CostDistributionEntry.is_source_offset == False,
|
||
CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]),
|
||
)
|
||
)
|
||
cd_reallocations = cd_reallocations_result.scalar() or Decimal("0.00") # Will be positive
|
||
|
||
# net_food_purchases stays as raw invoice total — CD adjustments are
|
||
# returned separately so the frontend can toggle them on/off.
|
||
# GP is calculated from raw purchases; the frontend applies CD + allowances
|
||
# to compute the "Adjusted GP %".
|
||
|
||
# Get logbook entry totals by type
|
||
# Helper function to query a specific entry type
|
||
async def get_entry_type_total(entry_type: EntryType) -> Decimal:
|
||
result = await db.execute(
|
||
select(func.sum(LogbookEntry.total_cost))
|
||
.where(
|
||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||
LogbookEntry.entry_date >= from_date,
|
||
LogbookEntry.entry_date <= to_date,
|
||
LogbookEntry.entry_type == entry_type,
|
||
LogbookEntry.is_deleted == False
|
||
)
|
||
)
|
||
return result.scalar() or Decimal("0.00")
|
||
|
||
wastage_total = await get_entry_type_total(EntryType.WASTAGE)
|
||
transfer_total = await get_entry_type_total(EntryType.TRANSFER)
|
||
staff_food_total = await get_entry_type_total(EntryType.STAFF_FOOD)
|
||
manual_adjustment_total = await get_entry_type_total(EntryType.MANUAL_ADJUSTMENT)
|
||
|
||
# Open disputes total - based on invoice date, not dispute creation date
|
||
from models.dispute import InvoiceDispute, DisputeStatus
|
||
open_statuses = [
|
||
DisputeStatus.NEW, DisputeStatus.OPEN, DisputeStatus.CONTACTED,
|
||
DisputeStatus.IN_PROGRESS, DisputeStatus.AWAITING_CREDIT,
|
||
DisputeStatus.AWAITING_REPLACEMENT, DisputeStatus.ESCALATED
|
||
]
|
||
disputes_result = await db.execute(
|
||
select(func.sum(InvoiceDispute.difference_amount))
|
||
.join(Invoice, InvoiceDispute.invoice_id == Invoice.id)
|
||
.where(
|
||
InvoiceDispute.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= from_date,
|
||
Invoice.invoice_date <= to_date,
|
||
InvoiceDispute.status.in_(open_statuses)
|
||
)
|
||
)
|
||
disputes_total = disputes_result.scalar() or Decimal("0.00")
|
||
|
||
# Calculate GP
|
||
gross_profit = net_food_sales - net_food_purchases
|
||
gross_profit_percent = (gross_profit / net_food_sales * 100) if net_food_sales > 0 else Decimal("0.00")
|
||
|
||
# Get supplier breakdown for purchases (credit notes as negative)
|
||
# Use subquery to sum per invoice, then aggregate by supplier
|
||
from models.supplier import Supplier
|
||
invoice_by_supplier_subq = (
|
||
select(
|
||
Invoice.id.label('inv_id'),
|
||
Invoice.supplier_id.label('supplier_id'),
|
||
Invoice.document_type.label('doc_type'),
|
||
func.sum(LineItem.amount).label('stock_total')
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= from_date,
|
||
Invoice.invoice_date <= to_date,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
LineItem.amount.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
|
||
)
|
||
.group_by(Invoice.id, Invoice.supplier_id, Invoice.document_type)
|
||
.subquery()
|
||
)
|
||
supplier_result = await db.execute(
|
||
select(
|
||
invoice_by_supplier_subq.c.supplier_id,
|
||
Supplier.name,
|
||
func.sum(
|
||
case(
|
||
(and_(invoice_by_supplier_subq.c.doc_type == 'credit_note',
|
||
invoice_by_supplier_subq.c.stock_total > 0),
|
||
-invoice_by_supplier_subq.c.stock_total),
|
||
else_=invoice_by_supplier_subq.c.stock_total
|
||
)
|
||
)
|
||
)
|
||
.select_from(invoice_by_supplier_subq)
|
||
.outerjoin(Supplier, invoice_by_supplier_subq.c.supplier_id == Supplier.id)
|
||
.group_by(invoice_by_supplier_subq.c.supplier_id, Supplier.name)
|
||
.order_by(func.sum(
|
||
case(
|
||
(and_(invoice_by_supplier_subq.c.doc_type == 'credit_note',
|
||
invoice_by_supplier_subq.c.stock_total > 0),
|
||
-invoice_by_supplier_subq.c.stock_total),
|
||
else_=invoice_by_supplier_subq.c.stock_total
|
||
)
|
||
).desc())
|
||
)
|
||
supplier_rows = supplier_result.all()
|
||
supplier_breakdown = []
|
||
for supplier_id, supplier_name, total in supplier_rows:
|
||
if total and total != 0: # Include negative totals (net credit notes)
|
||
pct = (total / net_food_purchases * 100) if net_food_purchases > 0 else Decimal("0")
|
||
supplier_breakdown.append(SupplierBreakdown(
|
||
supplier_id=supplier_id,
|
||
supplier_name=supplier_name or "Unmatched",
|
||
net_purchases=total,
|
||
percentage=round(pct, 1)
|
||
))
|
||
|
||
# Get GL account breakdown for revenue
|
||
gl_result = await db.execute(
|
||
select(NewbookGLAccount.id, NewbookGLAccount.gl_name, func.sum(NewbookDailyRevenue.amount_net))
|
||
.join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id)
|
||
.where(
|
||
NewbookDailyRevenue.kitchen_id == current_user.kitchen_id,
|
||
NewbookDailyRevenue.date >= from_date,
|
||
NewbookDailyRevenue.date <= to_date,
|
||
NewbookGLAccount.is_tracked == True
|
||
)
|
||
.group_by(NewbookGLAccount.id, NewbookGLAccount.gl_name)
|
||
.order_by(func.sum(NewbookDailyRevenue.amount_net).desc())
|
||
)
|
||
gl_rows = gl_result.all()
|
||
gl_breakdown = []
|
||
for gl_id, gl_name, total in gl_rows:
|
||
if total: # Include all non-zero values (including negative discounts)
|
||
pct = (total / newbook_revenue * 100) if newbook_revenue > 0 else Decimal("0")
|
||
gl_breakdown.append(GLAccountBreakdown(
|
||
gl_account_id=gl_id,
|
||
gl_account_name=gl_name or "Unknown",
|
||
net_revenue=total,
|
||
percentage=round(pct, 1)
|
||
))
|
||
|
||
return DateRangeGPResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
period_label=period_label,
|
||
net_food_sales=net_food_sales,
|
||
net_food_purchases=net_food_purchases,
|
||
gross_profit=gross_profit,
|
||
gross_profit_percent=round(gross_profit_percent, 1),
|
||
supplier_breakdown=supplier_breakdown,
|
||
gl_account_breakdown=gl_breakdown,
|
||
wastage_total=wastage_total if wastage_total > 0 else None,
|
||
transfer_total=transfer_total if transfer_total > 0 else None,
|
||
staff_food_total=staff_food_total if staff_food_total > 0 else None,
|
||
manual_adjustment_total=manual_adjustment_total if manual_adjustment_total > 0 else None,
|
||
disputes_total=disputes_total if disputes_total > 0 else None,
|
||
cd_deductions_total=cd_deductions if cd_deductions != 0 else None,
|
||
cd_reallocations_total=cd_reallocations if cd_reallocations != 0 else None,
|
||
)
|
||
|
||
|
||
class DailyDataPoint(BaseModel):
|
||
"""Single day's data for charting"""
|
||
date: date
|
||
net_sales: Decimal
|
||
net_purchases: Decimal
|
||
occupancy: int | None = None # Night total occupancy (from Newbook when available)
|
||
lunch_covers: int | None = None # Placeholder for resos integration
|
||
dinner_covers: int | None = None # Placeholder for resos integration
|
||
total_covers: int | None = None # Placeholder for resos integration
|
||
|
||
|
||
class DailyGPChartResponse(BaseModel):
|
||
"""Response for daily GP chart data"""
|
||
from_date: date
|
||
to_date: date
|
||
data: list[DailyDataPoint]
|
||
|
||
|
||
@router.get("/gp/daily", response_model=DailyGPChartResponse)
|
||
async def get_daily_gp_data(
|
||
from_date: date,
|
||
to_date: date,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get daily net sales and purchases for charting"""
|
||
from models.line_item import LineItem
|
||
from models.resos import ResosDailyStats, ResosBooking
|
||
from sqlalchemy import or_, and_
|
||
from datetime import timedelta
|
||
|
||
# Validate date range
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
# Get daily Newbook revenue (grouped by date)
|
||
newbook_daily = await db.execute(
|
||
select(NewbookDailyRevenue.date, func.sum(NewbookDailyRevenue.amount_net))
|
||
.join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id)
|
||
.where(
|
||
NewbookDailyRevenue.kitchen_id == current_user.kitchen_id,
|
||
NewbookDailyRevenue.date >= from_date,
|
||
NewbookDailyRevenue.date <= to_date,
|
||
NewbookGLAccount.is_tracked == True
|
||
)
|
||
.group_by(NewbookDailyRevenue.date)
|
||
)
|
||
newbook_by_date = {row[0]: row[1] or Decimal("0") for row in newbook_daily.all()}
|
||
|
||
# Get daily manual revenue entries (grouped by date)
|
||
manual_daily = await db.execute(
|
||
select(RevenueEntry.date, func.sum(RevenueEntry.amount))
|
||
.where(
|
||
RevenueEntry.kitchen_id == current_user.kitchen_id,
|
||
RevenueEntry.date >= from_date,
|
||
RevenueEntry.date <= to_date
|
||
)
|
||
.group_by(RevenueEntry.date)
|
||
)
|
||
manual_by_date = {row[0]: row[1] or Decimal("0") for row in manual_daily.all()}
|
||
|
||
# Get daily purchases from confirmed invoices (grouped by invoice_date)
|
||
# Credit notes (document_type='credit_note') are treated as negative purchases
|
||
# Use subquery to sum per invoice, then aggregate by date
|
||
invoice_daily_subq = (
|
||
select(
|
||
Invoice.id.label('inv_id'),
|
||
Invoice.invoice_date.label('inv_date'),
|
||
Invoice.document_type.label('doc_type'),
|
||
func.sum(LineItem.amount).label('stock_total')
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= from_date,
|
||
Invoice.invoice_date <= to_date,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
LineItem.amount.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
|
||
)
|
||
.group_by(Invoice.id, Invoice.invoice_date, Invoice.document_type)
|
||
.subquery()
|
||
)
|
||
purchases_daily = await db.execute(
|
||
select(
|
||
invoice_daily_subq.c.inv_date,
|
||
func.sum(
|
||
case(
|
||
(and_(invoice_daily_subq.c.doc_type == 'credit_note',
|
||
invoice_daily_subq.c.stock_total > 0),
|
||
-invoice_daily_subq.c.stock_total),
|
||
else_=invoice_daily_subq.c.stock_total
|
||
)
|
||
)
|
||
)
|
||
.select_from(invoice_daily_subq)
|
||
.group_by(invoice_daily_subq.c.inv_date)
|
||
)
|
||
purchases_by_date = {row[0]: row[1] or Decimal("0") for row in purchases_daily.all()}
|
||
|
||
# Add cost distribution adjustments grouped by date
|
||
cd_daily = await db.execute(
|
||
select(
|
||
CostDistributionEntry.entry_date,
|
||
func.sum(CostDistributionEntry.amount)
|
||
)
|
||
.join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id)
|
||
.where(
|
||
CostDistributionEntry.kitchen_id == current_user.kitchen_id,
|
||
CostDistributionEntry.entry_date >= from_date,
|
||
CostDistributionEntry.entry_date <= to_date,
|
||
CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]),
|
||
)
|
||
.group_by(CostDistributionEntry.entry_date)
|
||
)
|
||
for row in cd_daily.all():
|
||
cd_date, cd_amount = row[0], row[1] or Decimal("0")
|
||
purchases_by_date[cd_date] = purchases_by_date.get(cd_date, Decimal("0")) + cd_amount
|
||
|
||
# Get Resos booking data (covers by service period)
|
||
resos_daily = await db.execute(
|
||
select(ResosDailyStats)
|
||
.where(
|
||
and_(
|
||
ResosDailyStats.kitchen_id == current_user.kitchen_id,
|
||
ResosDailyStats.date >= from_date,
|
||
ResosDailyStats.date <= to_date
|
||
)
|
||
)
|
||
)
|
||
resos_stats = {stat.date: stat for stat in resos_daily.scalars().all()}
|
||
|
||
# Get Newbook occupancy data (total guests per night)
|
||
newbook_occupancy = await db.execute(
|
||
select(NewbookDailyOccupancy)
|
||
.where(
|
||
and_(
|
||
NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
|
||
NewbookDailyOccupancy.date >= from_date,
|
||
NewbookDailyOccupancy.date <= to_date
|
||
)
|
||
)
|
||
)
|
||
occupancy_by_date = {occ.date: occ for occ in newbook_occupancy.scalars().all()}
|
||
|
||
# Build daily data points for the entire range
|
||
data_points = []
|
||
current_date = from_date
|
||
while current_date <= to_date:
|
||
net_sales = (newbook_by_date.get(current_date, Decimal("0")) +
|
||
manual_by_date.get(current_date, Decimal("0")))
|
||
net_purchases = purchases_by_date.get(current_date, Decimal("0"))
|
||
|
||
# Extract Resos covers if available
|
||
lunch_covers = None
|
||
dinner_covers = None
|
||
total_covers = None
|
||
|
||
if current_date in resos_stats:
|
||
stat = resos_stats[current_date]
|
||
total_covers = stat.total_covers
|
||
|
||
# Extract lunch and dinner covers from service_breakdown
|
||
if stat.service_breakdown:
|
||
for service in stat.service_breakdown:
|
||
period_name = service.get('period', '').lower()
|
||
covers = service.get('covers', 0)
|
||
|
||
if 'lunch' in period_name:
|
||
lunch_covers = covers
|
||
elif 'dinner' in period_name:
|
||
dinner_covers = covers
|
||
|
||
# Extract Newbook occupancy (total guests) if available
|
||
occupancy = None
|
||
if current_date in occupancy_by_date:
|
||
occ = occupancy_by_date[current_date]
|
||
occupancy = occ.total_guests
|
||
|
||
data_points.append(DailyDataPoint(
|
||
date=current_date,
|
||
net_sales=net_sales,
|
||
net_purchases=net_purchases,
|
||
occupancy=occupancy,
|
||
lunch_covers=lunch_covers,
|
||
dinner_covers=dinner_covers,
|
||
total_covers=total_covers
|
||
))
|
||
current_date += timedelta(days=1)
|
||
|
||
return DailyGPChartResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
data=data_points
|
||
)
|
||
|
||
|
||
@router.get("/gp/monthly", response_model=MonthlyGPResponse)
|
||
async def get_monthly_gp(
|
||
year: int | None = None,
|
||
month: int | None = None, # 1-12
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get monthly GP calculation with sales and purchases breakdown"""
|
||
from calendar import monthrange, month_name as calendar_month_name
|
||
|
||
# Default to current month
|
||
today = date.today()
|
||
year = year or today.year
|
||
month = month or today.month
|
||
|
||
# Get first and last day of month
|
||
_, days_in_month = monthrange(year, month)
|
||
month_start = date(year, month, 1)
|
||
month_end = date(year, month, days_in_month)
|
||
|
||
# Get Newbook revenue for tracked GL accounts
|
||
newbook_revenue_result = await db.execute(
|
||
select(func.sum(NewbookDailyRevenue.amount_net))
|
||
.join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id)
|
||
.where(
|
||
NewbookDailyRevenue.kitchen_id == current_user.kitchen_id,
|
||
NewbookDailyRevenue.date >= month_start,
|
||
NewbookDailyRevenue.date <= month_end,
|
||
NewbookGLAccount.is_tracked == True
|
||
)
|
||
)
|
||
newbook_revenue = newbook_revenue_result.scalar() or Decimal("0.00")
|
||
|
||
# Get manual revenue entries for period
|
||
manual_revenue_result = await db.execute(
|
||
select(func.sum(RevenueEntry.amount))
|
||
.where(
|
||
RevenueEntry.kitchen_id == current_user.kitchen_id,
|
||
RevenueEntry.date >= month_start,
|
||
RevenueEntry.date <= month_end
|
||
)
|
||
)
|
||
manual_revenue = manual_revenue_result.scalar() or Decimal("0.00")
|
||
|
||
# Total net food sales
|
||
net_food_sales = newbook_revenue + manual_revenue
|
||
|
||
# Get net purchases from confirmed invoices - stock items only (exclude non-stock)
|
||
# Credit notes (document_type='credit_note') are treated as negative purchases
|
||
# Use subquery to sum per invoice, then conditionally negate
|
||
from models.line_item import LineItem
|
||
from sqlalchemy import or_, and_
|
||
|
||
invoice_stock_subq = (
|
||
select(
|
||
Invoice.id.label('inv_id'),
|
||
Invoice.document_type.label('doc_type'),
|
||
func.sum(LineItem.amount).label('stock_total')
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= month_start,
|
||
Invoice.invoice_date <= month_end,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
LineItem.amount.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
|
||
)
|
||
.group_by(Invoice.id, Invoice.document_type)
|
||
.subquery()
|
||
)
|
||
purchases_result = await db.execute(
|
||
select(func.sum(
|
||
case(
|
||
(and_(invoice_stock_subq.c.doc_type == 'credit_note',
|
||
invoice_stock_subq.c.stock_total > 0),
|
||
-invoice_stock_subq.c.stock_total),
|
||
else_=invoice_stock_subq.c.stock_total
|
||
)
|
||
))
|
||
.select_from(invoice_stock_subq)
|
||
)
|
||
net_food_purchases = purchases_result.scalar() or Decimal("0.00")
|
||
|
||
# Add cost distribution adjustments
|
||
cd_adj_result = await db.execute(
|
||
select(func.sum(CostDistributionEntry.amount))
|
||
.join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id)
|
||
.where(
|
||
CostDistributionEntry.kitchen_id == current_user.kitchen_id,
|
||
CostDistributionEntry.entry_date >= month_start,
|
||
CostDistributionEntry.entry_date <= month_end,
|
||
CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]),
|
||
)
|
||
)
|
||
net_food_purchases += cd_adj_result.scalar() or Decimal("0.00")
|
||
|
||
# Calculate GP
|
||
gross_profit = net_food_sales - net_food_purchases
|
||
gross_profit_percent = (gross_profit / net_food_sales * 100) if net_food_sales > 0 else Decimal("0.00")
|
||
|
||
return MonthlyGPResponse(
|
||
year=year,
|
||
month=month,
|
||
month_name=calendar_month_name[month],
|
||
net_food_sales=net_food_sales,
|
||
net_food_purchases=net_food_purchases,
|
||
gross_profit=gross_profit,
|
||
gross_profit_percent=round(gross_profit_percent, 1)
|
||
)
|
||
|
||
|
||
# ============ Top Sellers Models ============
|
||
|
||
class TopSellerItem(BaseModel):
|
||
"""Individual top seller item"""
|
||
item_name: str
|
||
qty: int
|
||
revenue: Decimal
|
||
|
||
|
||
class PackageFavoriteItem(BaseModel):
|
||
"""Package guest favorite item (qty only)"""
|
||
item_name: str
|
||
qty: int
|
||
|
||
|
||
class CategoryTopSellers(BaseModel):
|
||
"""Top sellers for a single category"""
|
||
category: str # "Starters", "Mains", "Desserts", etc.
|
||
top_by_qty: list[TopSellerItem] # Top 10 by quantity
|
||
top_by_revenue: list[TopSellerItem] # Top 10 by revenue
|
||
|
||
|
||
class TopSellersResponse(BaseModel):
|
||
"""Response for top sellers data"""
|
||
from_date: date
|
||
to_date: date
|
||
source: str = "newbook" # "sambapos" or "newbook"
|
||
# SambaPOS category-based format
|
||
categories: list[CategoryTopSellers] = []
|
||
# Legacy Newbook format (flat lists)
|
||
top_by_qty: list[TopSellerItem] = []
|
||
top_by_revenue: list[TopSellerItem] = []
|
||
package_favorites: list[PackageFavoriteItem] = []
|
||
total_charges_processed: int = 0
|
||
total_items_aggregated: int = 0
|
||
|
||
|
||
def parse_charge_description(description: str) -> tuple[int, str] | None:
|
||
"""
|
||
Parse Newbook charge description to extract qty and item name.
|
||
|
||
Format: "Ticket: 22900 - 1 x Venison Bourguignon"
|
||
Returns: (qty, item_name) or None if cannot parse
|
||
"""
|
||
import re
|
||
|
||
if not description:
|
||
return None
|
||
|
||
# Try pattern: "Ticket: XXXXX - N x Item Name"
|
||
# Also handle variations without ticket number
|
||
patterns = [
|
||
r'Ticket:\s*\d+\s*-\s*(\d+)\s*x\s*(.+)', # Ticket: 22900 - 1 x Item
|
||
r'^(\d+)\s*x\s*(.+)', # 1 x Item (no ticket prefix)
|
||
r'-\s*(\d+)\s*x\s*(.+)', # - 1 x Item
|
||
]
|
||
|
||
for pattern in patterns:
|
||
match = re.search(pattern, description, re.IGNORECASE)
|
||
if match:
|
||
try:
|
||
qty = int(match.group(1))
|
||
item_name = match.group(2).strip()
|
||
# Clean up item name - remove trailing punctuation and whitespace
|
||
item_name = re.sub(r'[\s,;.]+$', '', item_name)
|
||
if item_name and qty > 0:
|
||
return (qty, item_name)
|
||
except (ValueError, IndexError):
|
||
continue
|
||
|
||
return None
|
||
|
||
|
||
@router.get("/gp/top-sellers", response_model=TopSellersResponse)
|
||
async def get_top_sellers(
|
||
from_date: date,
|
||
to_date: date,
|
||
limit: int = 10,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
Get top selling items for a date range.
|
||
|
||
If SambaPOS is configured, fetches data from SambaPOS database with category breakdowns.
|
||
Otherwise falls back to Newbook charges with flat lists.
|
||
|
||
Returns top 10 items by quantity and top 10 by revenue (per category for SambaPOS).
|
||
"""
|
||
from models.settings import KitchenSettings
|
||
from models.newbook import NewbookGLAccount
|
||
from services.newbook_api import NewbookAPIClient, NewbookAPIError
|
||
from services.sambapos_api import SambaPOSClient
|
||
from collections import defaultdict
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Validate date range
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
# Get settings
|
||
result = await db.execute(
|
||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||
)
|
||
settings = result.scalar_one_or_none()
|
||
|
||
if not settings:
|
||
raise HTTPException(status_code=400, detail="Settings not configured")
|
||
|
||
# Check if SambaPOS is configured - use it if available
|
||
if all([
|
||
settings.sambapos_db_host,
|
||
settings.sambapos_db_name,
|
||
settings.sambapos_db_username,
|
||
settings.sambapos_db_password
|
||
]):
|
||
# Use SambaPOS data source
|
||
logger.info(f"Top sellers: Using SambaPOS data source for {from_date} to {to_date}")
|
||
|
||
# Get tracked categories
|
||
tracked_categories = []
|
||
if settings.sambapos_tracked_categories:
|
||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||
|
||
# Get excluded items
|
||
excluded_items = []
|
||
if settings.sambapos_excluded_items:
|
||
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
|
||
|
||
if not tracked_categories:
|
||
# Return empty response if no categories configured
|
||
return TopSellersResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
source="sambapos",
|
||
categories=[]
|
||
)
|
||
|
||
try:
|
||
client = SambaPOSClient(
|
||
host=settings.sambapos_db_host,
|
||
port=settings.sambapos_db_port or 1433,
|
||
database=settings.sambapos_db_name,
|
||
username=settings.sambapos_db_username,
|
||
password=settings.sambapos_db_password
|
||
)
|
||
|
||
# Get top sellers by quantity and by revenue (excluding configured GroupCodes)
|
||
top_by_qty = await client.get_top_sellers(from_date, to_date, tracked_categories, limit, excluded_categories=excluded_items if excluded_items else None)
|
||
top_by_revenue = await client.get_top_sellers_by_revenue(from_date, to_date, tracked_categories, limit, excluded_categories=excluded_items if excluded_items else None)
|
||
|
||
# Build category response in order of tracked_categories
|
||
categories_response = []
|
||
for cat_name in tracked_categories:
|
||
qty_items = top_by_qty.get(cat_name, [])
|
||
rev_items = top_by_revenue.get(cat_name, [])
|
||
|
||
categories_response.append(CategoryTopSellers(
|
||
category=cat_name,
|
||
top_by_qty=[
|
||
TopSellerItem(item_name=item["item_name"], qty=item["qty"], revenue=item["revenue"])
|
||
for item in qty_items
|
||
],
|
||
top_by_revenue=[
|
||
TopSellerItem(item_name=item["item_name"], qty=item["qty"], revenue=item["revenue"])
|
||
for item in rev_items
|
||
]
|
||
))
|
||
|
||
return TopSellersResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
source="sambapos",
|
||
categories=categories_response
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"SambaPOS top sellers failed: {e}")
|
||
raise HTTPException(status_code=400, detail=f"SambaPOS query failed: {str(e)}")
|
||
|
||
# Fallback to Newbook if SambaPOS not configured
|
||
if not settings.newbook_api_username:
|
||
raise HTTPException(status_code=400, detail="Neither SambaPOS nor Newbook credentials configured")
|
||
|
||
# Get tracked GL accounts for this kitchen (food sales accounts)
|
||
gl_result = await db.execute(
|
||
select(NewbookGLAccount).where(
|
||
NewbookGLAccount.kitchen_id == current_user.kitchen_id,
|
||
NewbookGLAccount.is_tracked == True
|
||
)
|
||
)
|
||
tracked_accounts = gl_result.scalars().all()
|
||
|
||
if not tracked_accounts:
|
||
return TopSellersResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
top_by_qty=[],
|
||
top_by_revenue=[],
|
||
total_charges_processed=0,
|
||
total_items_aggregated=0
|
||
)
|
||
|
||
# Build set of tracked GL account IDs (as strings)
|
||
tracked_gl_ids = {acc.gl_account_id for acc in tracked_accounts}
|
||
|
||
# Fetch charges from Newbook
|
||
try:
|
||
async with NewbookAPIClient(
|
||
username=settings.newbook_api_username,
|
||
password=settings.newbook_api_password,
|
||
api_key=settings.newbook_api_key,
|
||
region=settings.newbook_api_region or "au",
|
||
instance_id=settings.newbook_instance_id
|
||
) as client:
|
||
charges = await client.get_charges_list(from_date, to_date)
|
||
except NewbookAPIError as e:
|
||
raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}")
|
||
|
||
# Aggregate items - separate tracking for regular vs package/supplement
|
||
import re
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
regular_items: dict[str, dict] = defaultdict(lambda: {"qty": 0, "revenue": Decimal("0")})
|
||
package_items: dict[str, int] = defaultdict(int) # qty only for package/supplement
|
||
total_processed = 0
|
||
total_voided = 0
|
||
total_wrong_gl = 0
|
||
total_unparsed = 0
|
||
sample_unparsed = []
|
||
sample_charges = []
|
||
|
||
logger.info(f"Top sellers: Starting with {len(charges)} total charges, {len(tracked_gl_ids)} tracked GL IDs: {tracked_gl_ids}")
|
||
|
||
for charge in charges:
|
||
# Log first few charges to see structure
|
||
if len(sample_charges) < 5:
|
||
sample_charges.append({
|
||
"gl_account_id": charge.get("gl_account_id"),
|
||
"description": charge.get("description"),
|
||
"voided_when": charge.get("voided_when"),
|
||
"voided_by": charge.get("voided_by"),
|
||
})
|
||
|
||
# Skip voided charges
|
||
voided_when = charge.get("voided_when")
|
||
voided_by = charge.get("voided_by", "0")
|
||
|
||
# Check if voided - voided_when can be None, empty string, or actual date
|
||
# voided_by is "0" when not voided
|
||
if voided_when or (voided_by and voided_by != "0"):
|
||
total_voided += 1
|
||
continue
|
||
|
||
# Filter to tracked GL accounts only
|
||
gl_account_id = charge.get("gl_account_id", "")
|
||
if gl_account_id not in tracked_gl_ids:
|
||
total_wrong_gl += 1
|
||
continue
|
||
|
||
total_processed += 1
|
||
|
||
# Parse description to get qty and item name
|
||
description = charge.get("description", "")
|
||
parsed = parse_charge_description(description)
|
||
|
||
if parsed:
|
||
qty, item_name = parsed
|
||
amount = charge.get("amount_ex_tax", Decimal("0"))
|
||
|
||
# Check for [Package] or [Supplement] suffix
|
||
is_package = bool(re.search(r'\[(package|supplement)\]', item_name, re.IGNORECASE))
|
||
|
||
# Strip [Package] or [Supplement] suffix
|
||
item_name = re.sub(r'\s*\[(package|supplement)\]\s*', '', item_name, flags=re.IGNORECASE)
|
||
|
||
# Normalize item name: lowercase, remove extra spaces
|
||
item_name = " ".join(item_name.lower().split())
|
||
|
||
if is_package:
|
||
# Track package items separately (qty only)
|
||
package_items[item_name] += qty
|
||
else:
|
||
# Regular item - track qty and revenue
|
||
regular_items[item_name]["qty"] += qty
|
||
regular_items[item_name]["revenue"] += amount
|
||
else:
|
||
total_unparsed += 1
|
||
if len(sample_unparsed) < 10:
|
||
sample_unparsed.append(description)
|
||
|
||
# Calculate average prices from regular sales
|
||
avg_prices: dict[str, Decimal] = {}
|
||
for name, data in regular_items.items():
|
||
if data["qty"] > 0:
|
||
avg_prices[name] = data["revenue"] / data["qty"]
|
||
|
||
# Combine regular + package for main top sellers
|
||
# For package items, use average price from regular sales if available
|
||
all_item_names = set(regular_items.keys()) | set(package_items.keys())
|
||
combined_items: dict[str, dict] = {}
|
||
|
||
for name in all_item_names:
|
||
reg_qty = regular_items.get(name, {}).get("qty", 0)
|
||
pkg_qty = package_items.get(name, 0)
|
||
total_qty = reg_qty + pkg_qty
|
||
|
||
# Revenue: regular revenue + (package qty * avg price if available)
|
||
reg_revenue = regular_items.get(name, {}).get("revenue", Decimal("0"))
|
||
if name in avg_prices and pkg_qty > 0:
|
||
estimated_pkg_revenue = avg_prices[name] * pkg_qty
|
||
total_revenue = reg_revenue + estimated_pkg_revenue
|
||
else:
|
||
total_revenue = reg_revenue
|
||
|
||
combined_items[name] = {"qty": total_qty, "revenue": total_revenue}
|
||
|
||
logger.info(f"Top sellers: processed {total_processed} charges, {len(regular_items)} regular items, {len(package_items)} package items, {len(combined_items)} combined")
|
||
|
||
# Sort and get top items from combined
|
||
items_list = [
|
||
{"name": name, "qty": data["qty"], "revenue": data["revenue"]}
|
||
for name, data in combined_items.items()
|
||
]
|
||
|
||
# Top by quantity
|
||
top_by_qty = sorted(items_list, key=lambda x: x["qty"], reverse=True)[:limit]
|
||
|
||
# Top by revenue
|
||
top_by_revenue = sorted(items_list, key=lambda x: x["revenue"], reverse=True)[:limit]
|
||
|
||
# Package favorites (qty only, from package_items)
|
||
package_favorites_list = sorted(
|
||
[{"name": name, "qty": qty} for name, qty in package_items.items()],
|
||
key=lambda x: x["qty"],
|
||
reverse=True
|
||
)[:limit]
|
||
|
||
# Title case helper for display
|
||
def title_case(s: str) -> str:
|
||
return " ".join(word.capitalize() for word in s.split())
|
||
|
||
return TopSellersResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
source="newbook",
|
||
top_by_qty=[
|
||
TopSellerItem(item_name=title_case(item["name"]), qty=item["qty"], revenue=item["revenue"])
|
||
for item in top_by_qty
|
||
],
|
||
top_by_revenue=[
|
||
TopSellerItem(item_name=title_case(item["name"]), qty=item["qty"], revenue=item["revenue"])
|
||
for item in top_by_revenue
|
||
],
|
||
package_favorites=[
|
||
PackageFavoriteItem(item_name=title_case(item["name"]), qty=item["qty"])
|
||
for item in package_favorites_list
|
||
],
|
||
total_charges_processed=total_processed,
|
||
total_items_aggregated=len(combined_items)
|
||
)
|
||
|
||
|
||
# ============ Purchases Report Endpoints ============
|
||
|
||
class PurchasesSummaryResponse(BaseModel):
|
||
"""Response for purchases summary"""
|
||
from_date: date
|
||
to_date: date
|
||
period_label: str
|
||
total_purchases: Decimal
|
||
supplier_breakdown: list[SupplierBreakdown]
|
||
|
||
|
||
class DailySupplierDataPoint(BaseModel):
|
||
"""Single data point for daily supplier chart"""
|
||
date: date
|
||
supplier_id: int | None
|
||
supplier_name: str
|
||
net_purchases: Decimal
|
||
|
||
|
||
class DailySupplierChartResponse(BaseModel):
|
||
"""Response for daily supplier chart"""
|
||
from_date: date
|
||
to_date: date
|
||
suppliers: list[str] # Ordered list of supplier names for legend
|
||
data: list[DailySupplierDataPoint]
|
||
|
||
|
||
class TopLineItem(BaseModel):
|
||
"""Top line item by quantity or value"""
|
||
description: str
|
||
product_code: str | None
|
||
total_quantity: Decimal
|
||
total_value: Decimal
|
||
avg_unit_price: Decimal
|
||
occurrence_count: int
|
||
|
||
|
||
class TopItemsResponse(BaseModel):
|
||
"""Response for top line items"""
|
||
from_date: date
|
||
to_date: date
|
||
top_by_quantity: list[TopLineItem]
|
||
top_by_value: list[TopLineItem]
|
||
|
||
|
||
@router.get("/purchases/summary", response_model=PurchasesSummaryResponse)
|
||
async def get_purchases_summary(
|
||
from_date: date,
|
||
to_date: date,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get purchases summary with supplier breakdown for date range"""
|
||
from models.line_item import LineItem
|
||
from models.supplier import Supplier
|
||
from sqlalchemy import or_
|
||
|
||
# Validate date range
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
# Build period label
|
||
if from_date.year == to_date.year:
|
||
if from_date.month == to_date.month:
|
||
period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}"
|
||
else:
|
||
period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}"
|
||
else:
|
||
period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}"
|
||
|
||
# Get total purchases (stock items only from confirmed invoices)
|
||
# Credit notes (document_type='credit_note') are treated as negative purchases
|
||
# Use subquery to sum per invoice, then conditionally negate
|
||
from sqlalchemy import and_
|
||
invoice_stock_subq = (
|
||
select(
|
||
Invoice.id.label('inv_id'),
|
||
Invoice.supplier_id.label('supplier_id'),
|
||
Invoice.document_type.label('doc_type'),
|
||
func.sum(LineItem.amount).label('stock_total')
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= from_date,
|
||
Invoice.invoice_date <= to_date,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
LineItem.amount.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
|
||
)
|
||
.group_by(Invoice.id, Invoice.supplier_id, Invoice.document_type)
|
||
.subquery()
|
||
)
|
||
total_result = await db.execute(
|
||
select(func.sum(
|
||
case(
|
||
(and_(invoice_stock_subq.c.doc_type == 'credit_note',
|
||
invoice_stock_subq.c.stock_total > 0),
|
||
-invoice_stock_subq.c.stock_total),
|
||
else_=invoice_stock_subq.c.stock_total
|
||
)
|
||
))
|
||
.select_from(invoice_stock_subq)
|
||
)
|
||
total_purchases = total_result.scalar() or Decimal("0.00")
|
||
|
||
# Get supplier breakdown (credit notes as negative)
|
||
# Reuse the subquery grouped by supplier
|
||
supplier_result = await db.execute(
|
||
select(
|
||
invoice_stock_subq.c.supplier_id,
|
||
Supplier.name,
|
||
func.sum(
|
||
case(
|
||
(and_(invoice_stock_subq.c.doc_type == 'credit_note',
|
||
invoice_stock_subq.c.stock_total > 0),
|
||
-invoice_stock_subq.c.stock_total),
|
||
else_=invoice_stock_subq.c.stock_total
|
||
)
|
||
)
|
||
)
|
||
.select_from(invoice_stock_subq)
|
||
.outerjoin(Supplier, invoice_stock_subq.c.supplier_id == Supplier.id)
|
||
.group_by(invoice_stock_subq.c.supplier_id, Supplier.name)
|
||
.order_by(func.sum(
|
||
case(
|
||
(and_(invoice_stock_subq.c.doc_type == 'credit_note',
|
||
invoice_stock_subq.c.stock_total > 0),
|
||
-invoice_stock_subq.c.stock_total),
|
||
else_=invoice_stock_subq.c.stock_total
|
||
)
|
||
).desc())
|
||
)
|
||
|
||
supplier_breakdown = []
|
||
for supplier_id, supplier_name, total in supplier_result.all():
|
||
if total and total != 0: # Include negative totals (net credit notes)
|
||
pct = (total / total_purchases * 100) if total_purchases > 0 else Decimal("0")
|
||
supplier_breakdown.append(SupplierBreakdown(
|
||
supplier_id=supplier_id,
|
||
supplier_name=supplier_name or "Unmatched",
|
||
net_purchases=total,
|
||
percentage=round(pct, 1)
|
||
))
|
||
|
||
return PurchasesSummaryResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
period_label=period_label,
|
||
total_purchases=total_purchases,
|
||
supplier_breakdown=supplier_breakdown
|
||
)
|
||
|
||
|
||
@router.get("/purchases/daily-by-supplier", response_model=DailySupplierChartResponse)
|
||
async def get_daily_purchases_by_supplier(
|
||
from_date: date,
|
||
to_date: date,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get daily purchases grouped by supplier for multi-line chart"""
|
||
from models.line_item import LineItem
|
||
from models.supplier import Supplier
|
||
from sqlalchemy import or_
|
||
from datetime import timedelta
|
||
from collections import defaultdict
|
||
|
||
# Validate date range
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
# Get daily purchases by supplier (credit notes as negative)
|
||
# Use subquery to sum per invoice first, then aggregate by date/supplier
|
||
from sqlalchemy import and_
|
||
invoice_daily_subq = (
|
||
select(
|
||
Invoice.id.label('inv_id'),
|
||
Invoice.invoice_date.label('inv_date'),
|
||
Invoice.supplier_id.label('supplier_id'),
|
||
Invoice.document_type.label('doc_type'),
|
||
func.sum(LineItem.amount).label('stock_total')
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= from_date,
|
||
Invoice.invoice_date <= to_date,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
LineItem.amount.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
|
||
)
|
||
.group_by(Invoice.id, Invoice.invoice_date, Invoice.supplier_id, Invoice.document_type)
|
||
.subquery()
|
||
)
|
||
daily_result = await db.execute(
|
||
select(
|
||
invoice_daily_subq.c.inv_date,
|
||
invoice_daily_subq.c.supplier_id,
|
||
Supplier.name,
|
||
func.sum(
|
||
case(
|
||
(and_(invoice_daily_subq.c.doc_type == 'credit_note',
|
||
invoice_daily_subq.c.stock_total > 0),
|
||
-invoice_daily_subq.c.stock_total),
|
||
else_=invoice_daily_subq.c.stock_total
|
||
)
|
||
)
|
||
)
|
||
.select_from(invoice_daily_subq)
|
||
.outerjoin(Supplier, invoice_daily_subq.c.supplier_id == Supplier.id)
|
||
.group_by(invoice_daily_subq.c.inv_date, invoice_daily_subq.c.supplier_id, Supplier.name)
|
||
.order_by(invoice_daily_subq.c.inv_date)
|
||
)
|
||
|
||
# Collect all supplier totals to determine top suppliers
|
||
supplier_totals: dict[str, Decimal] = defaultdict(Decimal)
|
||
daily_data: list[tuple] = []
|
||
|
||
for inv_date, supplier_id, supplier_name, total in daily_result.all():
|
||
name = supplier_name or "Unmatched"
|
||
supplier_totals[name] += total or Decimal("0")
|
||
daily_data.append((inv_date, supplier_id, name, total or Decimal("0")))
|
||
|
||
# Get top 10 suppliers by total purchases
|
||
top_suppliers = sorted(supplier_totals.keys(), key=lambda x: supplier_totals[x], reverse=True)[:10]
|
||
|
||
# Build data points for top suppliers only
|
||
data_points = []
|
||
for inv_date, supplier_id, supplier_name, total in daily_data:
|
||
if supplier_name in top_suppliers:
|
||
data_points.append(DailySupplierDataPoint(
|
||
date=inv_date,
|
||
supplier_id=supplier_id,
|
||
supplier_name=supplier_name,
|
||
net_purchases=total
|
||
))
|
||
|
||
return DailySupplierChartResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
suppliers=top_suppliers,
|
||
data=data_points
|
||
)
|
||
|
||
|
||
@router.get("/purchases/top-items", response_model=TopItemsResponse)
|
||
async def get_top_purchase_items(
|
||
from_date: date,
|
||
to_date: date,
|
||
limit: int = 10,
|
||
supplier_id: Optional[int] = None,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get top line items by quantity and value, optionally filtered by supplier"""
|
||
from models.line_item import LineItem
|
||
from sqlalchemy import or_
|
||
|
||
# Validate date range
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
# Build base query (credit notes as negative values)
|
||
# Use -func.abs() to handle suppliers who already use negative values (avoid double-negation)
|
||
query = (
|
||
select(
|
||
LineItem.description,
|
||
LineItem.product_code,
|
||
func.sum(
|
||
case(
|
||
(Invoice.document_type == 'credit_note', -func.abs(LineItem.quantity)),
|
||
else_=LineItem.quantity
|
||
)
|
||
).label('total_qty'),
|
||
func.sum(
|
||
case(
|
||
(Invoice.document_type == 'credit_note', -func.abs(LineItem.amount)),
|
||
else_=LineItem.amount
|
||
)
|
||
).label('total_value'),
|
||
func.avg(LineItem.unit_price).label('avg_price'),
|
||
func.count(LineItem.id).label('occurrence_count')
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= from_date,
|
||
Invoice.invoice_date <= to_date,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
LineItem.amount.isnot(None),
|
||
LineItem.description.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
|
||
)
|
||
)
|
||
|
||
# Add supplier filter if specified
|
||
if supplier_id is not None:
|
||
query = query.where(Invoice.supplier_id == supplier_id)
|
||
|
||
query = query.group_by(LineItem.description, LineItem.product_code)
|
||
|
||
items_result = await db.execute(query)
|
||
|
||
all_items = []
|
||
for row in items_result.all():
|
||
desc, prod_code, total_qty, total_value, avg_price, count = row
|
||
if total_qty and total_qty > 0:
|
||
all_items.append({
|
||
"description": desc or "Unknown",
|
||
"product_code": prod_code,
|
||
"total_quantity": total_qty,
|
||
"total_value": total_value or Decimal("0"),
|
||
"avg_unit_price": avg_price or Decimal("0"),
|
||
"occurrence_count": count
|
||
})
|
||
|
||
# Sort by quantity
|
||
top_by_qty = sorted(all_items, key=lambda x: x["total_quantity"], reverse=True)[:limit]
|
||
|
||
# Sort by value
|
||
top_by_value = sorted(all_items, key=lambda x: x["total_value"], reverse=True)[:limit]
|
||
|
||
return TopItemsResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
top_by_quantity=[
|
||
TopLineItem(
|
||
description=item["description"],
|
||
product_code=item["product_code"],
|
||
total_quantity=item["total_quantity"],
|
||
total_value=item["total_value"],
|
||
avg_unit_price=round(item["avg_unit_price"], 2),
|
||
occurrence_count=item["occurrence_count"]
|
||
)
|
||
for item in top_by_qty
|
||
],
|
||
top_by_value=[
|
||
TopLineItem(
|
||
description=item["description"],
|
||
product_code=item["product_code"],
|
||
total_quantity=item["total_quantity"],
|
||
total_value=item["total_value"],
|
||
avg_unit_price=round(item["avg_unit_price"], 2),
|
||
occurrence_count=item["occurrence_count"]
|
||
)
|
||
for item in top_by_value
|
||
]
|
||
)
|
||
|
||
|
||
# ============ Allowances Report Endpoints ============
|
||
|
||
class AllowancesSummaryResponse(BaseModel):
|
||
"""Response for allowances summary"""
|
||
from_date: date
|
||
to_date: date
|
||
period_label: str
|
||
wastage_total: Decimal
|
||
wastage_count: int
|
||
transfer_total: Decimal
|
||
transfer_count: int
|
||
staff_food_total: Decimal
|
||
staff_food_count: int
|
||
manual_adjustment_total: Decimal
|
||
manual_adjustment_count: int
|
||
total_allowances: Decimal
|
||
|
||
|
||
class DailyAllowanceDataPoint(BaseModel):
|
||
"""Single data point for daily allowances chart"""
|
||
date: date
|
||
wastage: Decimal
|
||
transfer: Decimal
|
||
staff_food: Decimal
|
||
manual_adjustment: Decimal
|
||
|
||
|
||
class DailyAllowanceChartResponse(BaseModel):
|
||
"""Response for daily allowances chart"""
|
||
from_date: date
|
||
to_date: date
|
||
data: list[DailyAllowanceDataPoint]
|
||
|
||
|
||
class DisputeTallyRow(BaseModel):
|
||
"""Single row in dispute tally"""
|
||
label: str
|
||
count: int
|
||
difference_value: Decimal
|
||
|
||
|
||
class DisputesSummaryResponse(BaseModel):
|
||
"""Response for disputes period summary"""
|
||
from_date: date
|
||
to_date: date
|
||
period_label: str
|
||
rows: list[DisputeTallyRow]
|
||
|
||
|
||
@router.get("/allowances/summary", response_model=AllowancesSummaryResponse)
|
||
async def get_allowances_summary(
|
||
from_date: date,
|
||
to_date: date,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get allowances summary by entry type"""
|
||
from models.logbook import LogbookEntry, EntryType
|
||
|
||
# Validate date range
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
# Build period label
|
||
if from_date.year == to_date.year:
|
||
if from_date.month == to_date.month:
|
||
period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}"
|
||
else:
|
||
period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}"
|
||
else:
|
||
period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}"
|
||
|
||
# Helper to get total and count for entry type
|
||
async def get_entry_stats(entry_type: EntryType) -> tuple[Decimal, int]:
|
||
total_result = await db.execute(
|
||
select(func.sum(LogbookEntry.total_cost), func.count(LogbookEntry.id))
|
||
.where(
|
||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||
LogbookEntry.entry_date >= from_date,
|
||
LogbookEntry.entry_date <= to_date,
|
||
LogbookEntry.entry_type == entry_type,
|
||
LogbookEntry.is_deleted == False
|
||
)
|
||
)
|
||
row = total_result.one()
|
||
return (row[0] or Decimal("0.00"), row[1] or 0)
|
||
|
||
wastage_total, wastage_count = await get_entry_stats(EntryType.WASTAGE)
|
||
transfer_total, transfer_count = await get_entry_stats(EntryType.TRANSFER)
|
||
staff_food_total, staff_food_count = await get_entry_stats(EntryType.STAFF_FOOD)
|
||
manual_adj_total, manual_adj_count = await get_entry_stats(EntryType.MANUAL_ADJUSTMENT)
|
||
|
||
total_allowances = wastage_total + transfer_total + staff_food_total + manual_adj_total
|
||
|
||
return AllowancesSummaryResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
period_label=period_label,
|
||
wastage_total=wastage_total,
|
||
wastage_count=wastage_count,
|
||
transfer_total=transfer_total,
|
||
transfer_count=transfer_count,
|
||
staff_food_total=staff_food_total,
|
||
staff_food_count=staff_food_count,
|
||
manual_adjustment_total=manual_adj_total,
|
||
manual_adjustment_count=manual_adj_count,
|
||
total_allowances=total_allowances
|
||
)
|
||
|
||
|
||
@router.get("/allowances/daily", response_model=DailyAllowanceChartResponse)
|
||
async def get_daily_allowances(
|
||
from_date: date,
|
||
to_date: date,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get daily allowances breakdown by type for chart"""
|
||
from models.logbook import LogbookEntry, EntryType
|
||
from datetime import timedelta
|
||
from collections import defaultdict
|
||
|
||
# Validate date range
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
# Get all entries grouped by date and type
|
||
result = await db.execute(
|
||
select(LogbookEntry.entry_date, LogbookEntry.entry_type, func.sum(LogbookEntry.total_cost))
|
||
.where(
|
||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||
LogbookEntry.entry_date >= from_date,
|
||
LogbookEntry.entry_date <= to_date,
|
||
LogbookEntry.is_deleted == False
|
||
)
|
||
.group_by(LogbookEntry.entry_date, LogbookEntry.entry_type)
|
||
)
|
||
|
||
# Build lookup by date and type
|
||
daily_data: dict[date, dict[str, Decimal]] = defaultdict(lambda: {
|
||
"wastage": Decimal("0"),
|
||
"transfer": Decimal("0"),
|
||
"staff_food": Decimal("0"),
|
||
"manual_adjustment": Decimal("0")
|
||
})
|
||
|
||
for entry_date, entry_type, total in result.all():
|
||
type_key = entry_type.value.lower()
|
||
daily_data[entry_date][type_key] = total or Decimal("0")
|
||
|
||
# Build data points for full date range
|
||
data_points = []
|
||
current_date = from_date
|
||
while current_date <= to_date:
|
||
day_data = daily_data.get(current_date, {
|
||
"wastage": Decimal("0"),
|
||
"transfer": Decimal("0"),
|
||
"staff_food": Decimal("0"),
|
||
"manual_adjustment": Decimal("0")
|
||
})
|
||
data_points.append(DailyAllowanceDataPoint(
|
||
date=current_date,
|
||
wastage=day_data["wastage"],
|
||
transfer=day_data["transfer"],
|
||
staff_food=day_data["staff_food"],
|
||
manual_adjustment=day_data["manual_adjustment"]
|
||
))
|
||
current_date += timedelta(days=1)
|
||
|
||
return DailyAllowanceChartResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
data=data_points
|
||
)
|
||
|
||
|
||
@router.get("/disputes/period-summary", response_model=DisputesSummaryResponse)
|
||
async def get_disputes_period_summary(
|
||
from_date: date,
|
||
to_date: date,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get disputes summary for cases opened in period (by invoice date)"""
|
||
from models.dispute import InvoiceDispute, DisputeStatus
|
||
|
||
# Validate date range
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
# Build period label
|
||
if from_date.year == to_date.year:
|
||
if from_date.month == to_date.month:
|
||
period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}"
|
||
else:
|
||
period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}"
|
||
else:
|
||
period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}"
|
||
|
||
# Base query - disputes where invoice_date falls in period
|
||
base_query = (
|
||
select(func.count(InvoiceDispute.id), func.sum(InvoiceDispute.difference_amount))
|
||
.join(Invoice, InvoiceDispute.invoice_id == Invoice.id)
|
||
.where(
|
||
InvoiceDispute.kitchen_id == current_user.kitchen_id,
|
||
Invoice.invoice_date >= from_date,
|
||
Invoice.invoice_date <= to_date
|
||
)
|
||
)
|
||
|
||
# Total cases
|
||
total_result = await db.execute(base_query)
|
||
total_row = total_result.one()
|
||
total_count = total_row[0] or 0
|
||
total_value = total_row[1] or Decimal("0")
|
||
|
||
# Resolved cases
|
||
resolved_result = await db.execute(
|
||
base_query.where(InvoiceDispute.status == DisputeStatus.RESOLVED)
|
||
)
|
||
resolved_row = resolved_result.one()
|
||
resolved_count = resolved_row[0] or 0
|
||
resolved_value = resolved_row[1] or Decimal("0")
|
||
|
||
# Closed cases
|
||
closed_result = await db.execute(
|
||
base_query.where(InvoiceDispute.status == DisputeStatus.CLOSED)
|
||
)
|
||
closed_row = closed_result.one()
|
||
closed_count = closed_row[0] or 0
|
||
closed_value = closed_row[1] or Decimal("0")
|
||
|
||
# Still open cases
|
||
open_statuses = [
|
||
DisputeStatus.NEW, DisputeStatus.OPEN, DisputeStatus.CONTACTED,
|
||
DisputeStatus.IN_PROGRESS, DisputeStatus.AWAITING_CREDIT,
|
||
DisputeStatus.AWAITING_REPLACEMENT, DisputeStatus.ESCALATED
|
||
]
|
||
open_result = await db.execute(
|
||
base_query.where(InvoiceDispute.status.in_(open_statuses))
|
||
)
|
||
open_row = open_result.one()
|
||
open_count = open_row[0] or 0
|
||
open_value = open_row[1] or Decimal("0")
|
||
|
||
return DisputesSummaryResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
period_label=period_label,
|
||
rows=[
|
||
DisputeTallyRow(label="Total Cases", count=total_count, difference_value=total_value),
|
||
DisputeTallyRow(label="Resolved", count=resolved_count, difference_value=resolved_value),
|
||
DisputeTallyRow(label="Closed", count=closed_count, difference_value=closed_value),
|
||
DisputeTallyRow(label="Still Open", count=open_count, difference_value=open_value)
|
||
]
|
||
)
|
||
|
||
|
||
# ============ Sales GP Report ============
|
||
|
||
class SalesGPItem(BaseModel):
|
||
menu_item_name: str
|
||
portion_name: str
|
||
category: str # SambaPOS Kitchen Course
|
||
total_qty: int
|
||
total_revenue_net: Decimal # ex-VAT (gross / 1.20)
|
||
dbb_qty: int = 0 # Qty from DBB/package orders (price zeroed, original price used)
|
||
recipe_id: Optional[int] = None
|
||
recipe_name: Optional[str] = None
|
||
dish_course: Optional[str] = None # MenuSection name from recipe
|
||
cost_per_portion: Optional[Decimal] = None
|
||
total_cost: Optional[Decimal] = None
|
||
item_gp_percent: Optional[Decimal] = None
|
||
|
||
|
||
class SalesGPCourseGroup(BaseModel):
|
||
course_name: str
|
||
items: list[SalesGPItem]
|
||
course_revenue: Decimal
|
||
course_cost: Decimal
|
||
course_gp_percent: Optional[Decimal] = None
|
||
|
||
|
||
class SalesGPResponse(BaseModel):
|
||
from_date: date
|
||
to_date: date
|
||
courses: list[SalesGPCourseGroup]
|
||
unmapped_items: list[SalesGPItem]
|
||
# GP totals (mapped items ONLY)
|
||
mapped_revenue_net: Decimal
|
||
mapped_total_cost: Decimal
|
||
mapped_gp_percent: Optional[Decimal] = None
|
||
# Coverage context
|
||
total_all_revenue_net: Decimal
|
||
unmapped_revenue_net: Decimal
|
||
mapped_revenue_percent: Decimal
|
||
mapped_item_count: int
|
||
unmapped_item_count: int
|
||
|
||
|
||
@router.get("/sales-gp", response_model=SalesGPResponse)
|
||
async def get_sales_gp(
|
||
from_date: date,
|
||
to_date: date,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
Estimated Sales GP% report.
|
||
|
||
Fetches SambaPOS sales data for a date range, matches items to dish recipes,
|
||
and calculates GP based on recipe costs. Only mapped items contribute to GP calculation.
|
||
Unmapped items are listed separately with their revenue for coverage assessment.
|
||
"""
|
||
from models.settings import KitchenSettings
|
||
from models.recipe import Recipe, MenuSection, RecipeCostSnapshot
|
||
from services.sambapos_api import SambaPOSClient
|
||
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
# Get settings
|
||
result = await db.execute(
|
||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||
)
|
||
settings = result.scalar_one_or_none()
|
||
if not settings:
|
||
raise HTTPException(status_code=400, detail="Settings not configured")
|
||
|
||
# Validate SambaPOS config
|
||
if not all([
|
||
settings.sambapos_db_host,
|
||
settings.sambapos_db_name,
|
||
settings.sambapos_db_username,
|
||
settings.sambapos_db_password
|
||
]):
|
||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||
|
||
tracked_categories = []
|
||
if settings.sambapos_tracked_categories:
|
||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||
|
||
if not tracked_categories:
|
||
raise HTTPException(status_code=400, detail="No SambaPOS categories configured")
|
||
|
||
excluded_items = []
|
||
if settings.sambapos_excluded_items:
|
||
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
|
||
|
||
# Fetch sales data from SambaPOS
|
||
client = SambaPOSClient(
|
||
host=settings.sambapos_db_host,
|
||
port=settings.sambapos_db_port or 1433,
|
||
database=settings.sambapos_db_name,
|
||
username=settings.sambapos_db_username,
|
||
password=settings.sambapos_db_password
|
||
)
|
||
|
||
try:
|
||
sales = await client.get_sales_breakdown(
|
||
from_date, to_date, tracked_categories,
|
||
excluded_categories=excluded_items if excluded_items else None
|
||
)
|
||
except Exception as e:
|
||
raise HTTPException(status_code=400, detail=f"SambaPOS query failed: {str(e)}")
|
||
|
||
# Load all dish recipes with SambaPOS mapping
|
||
recipe_result = await db.execute(
|
||
select(Recipe)
|
||
.options(selectinload(Recipe.menu_section))
|
||
.where(
|
||
Recipe.kitchen_id == current_user.kitchen_id,
|
||
Recipe.recipe_type == "dish",
|
||
Recipe.kds_menu_item_name.isnot(None),
|
||
Recipe.kds_menu_item_name != "",
|
||
Recipe.is_archived == False,
|
||
)
|
||
)
|
||
recipes = recipe_result.scalars().all()
|
||
|
||
# Build lookup: (menu_item_name, portion_name_or_none) -> recipe
|
||
recipe_lookup: dict[tuple[str, str | None], object] = {}
|
||
for r in recipes:
|
||
if r.sambapos_portion_name:
|
||
recipe_lookup[(r.kds_menu_item_name, r.sambapos_portion_name)] = r
|
||
else:
|
||
recipe_lookup[(r.kds_menu_item_name, None)] = r
|
||
|
||
# Get latest cost snapshots for all mapped recipes
|
||
recipe_ids = [r.id for r in recipes]
|
||
cost_lookup: dict[int, Decimal] = {}
|
||
if recipe_ids:
|
||
# Get latest snapshot per recipe using a subquery
|
||
from sqlalchemy import and_
|
||
for rid in recipe_ids:
|
||
snap_result = await db.execute(
|
||
select(RecipeCostSnapshot)
|
||
.where(RecipeCostSnapshot.recipe_id == rid)
|
||
.order_by(RecipeCostSnapshot.snapshot_date.desc())
|
||
.limit(1)
|
||
)
|
||
snap = snap_result.scalar_one_or_none()
|
||
if snap and snap.cost_per_portion:
|
||
cost_lookup[rid] = snap.cost_per_portion
|
||
|
||
# Match sales to recipes and calculate GP
|
||
VAT_RATE = Decimal("1.20")
|
||
mapped_items: list[SalesGPItem] = []
|
||
unmapped_items: list[SalesGPItem] = []
|
||
|
||
for sale in sales:
|
||
revenue_gross = Decimal(str(sale["total_revenue_gross"]))
|
||
revenue_net = (revenue_gross / VAT_RATE).quantize(Decimal("0.01"))
|
||
qty = sale["total_qty"]
|
||
dbb_qty = sale.get("package_qty", 0)
|
||
menu_name = sale["menu_item_name"]
|
||
portion = sale["portion_name"]
|
||
|
||
# Try exact match first, then name-only match
|
||
matched_recipe = recipe_lookup.get((menu_name, portion))
|
||
if not matched_recipe and portion != "Normal":
|
||
matched_recipe = recipe_lookup.get((menu_name, None))
|
||
if not matched_recipe and portion == "Normal":
|
||
matched_recipe = recipe_lookup.get((menu_name, None))
|
||
|
||
if matched_recipe:
|
||
cpp = cost_lookup.get(matched_recipe.id)
|
||
total_cost = (cpp * qty).quantize(Decimal("0.01")) if cpp else None
|
||
gp_pct = None
|
||
if total_cost is not None and revenue_net > 0:
|
||
gp_pct = ((revenue_net - total_cost) / revenue_net * 100).quantize(Decimal("0.1"))
|
||
|
||
mapped_items.append(SalesGPItem(
|
||
menu_item_name=menu_name,
|
||
portion_name=portion,
|
||
category=sale["category"],
|
||
total_qty=qty,
|
||
total_revenue_net=revenue_net,
|
||
dbb_qty=dbb_qty,
|
||
recipe_id=matched_recipe.id,
|
||
recipe_name=matched_recipe.name,
|
||
dish_course=matched_recipe.menu_section.name if matched_recipe.menu_section else "Uncategorised",
|
||
cost_per_portion=cpp,
|
||
total_cost=total_cost,
|
||
item_gp_percent=gp_pct,
|
||
))
|
||
else:
|
||
unmapped_items.append(SalesGPItem(
|
||
menu_item_name=menu_name,
|
||
portion_name=portion,
|
||
category=sale["category"],
|
||
total_qty=qty,
|
||
total_revenue_net=revenue_net,
|
||
dbb_qty=dbb_qty,
|
||
))
|
||
|
||
# Group mapped items by SambaPOS Kitchen Course category (matches tracked_categories order)
|
||
course_groups: dict[str, list[SalesGPItem]] = {}
|
||
for item in mapped_items:
|
||
course = item.category or "Uncategorised"
|
||
if course not in course_groups:
|
||
course_groups[course] = []
|
||
course_groups[course].append(item)
|
||
|
||
# Sort courses using tracked_categories order from settings, unrecognised courses at the end
|
||
category_order = {cat: idx for idx, cat in enumerate(tracked_categories)}
|
||
sorted_courses = sorted(course_groups.items(), key=lambda x: (category_order.get(x[0], 999), x[0]))
|
||
|
||
courses = []
|
||
for course_name, items in sorted_courses:
|
||
c_revenue = sum(i.total_revenue_net for i in items)
|
||
c_cost = sum(i.total_cost for i in items if i.total_cost)
|
||
c_gp = ((c_revenue - c_cost) / c_revenue * 100).quantize(Decimal("0.1")) if c_revenue > 0 else None
|
||
# Sort items by revenue descending
|
||
items.sort(key=lambda x: x.total_revenue_net, reverse=True)
|
||
courses.append(SalesGPCourseGroup(
|
||
course_name=course_name,
|
||
items=items,
|
||
course_revenue=c_revenue,
|
||
course_cost=c_cost,
|
||
course_gp_percent=c_gp,
|
||
))
|
||
|
||
# Summary totals
|
||
mapped_revenue = sum(i.total_revenue_net for i in mapped_items)
|
||
mapped_cost = sum(i.total_cost for i in mapped_items if i.total_cost)
|
||
unmapped_revenue = sum(i.total_revenue_net for i in unmapped_items)
|
||
total_revenue = mapped_revenue + unmapped_revenue
|
||
mapped_gp = ((mapped_revenue - mapped_cost) / mapped_revenue * 100).quantize(Decimal("0.1")) if mapped_revenue > 0 else None
|
||
coverage_pct = (mapped_revenue / total_revenue * 100).quantize(Decimal("0.1")) if total_revenue > 0 else Decimal("0")
|
||
|
||
# Sort unmapped by revenue descending
|
||
unmapped_items.sort(key=lambda x: x.total_revenue_net, reverse=True)
|
||
|
||
return SalesGPResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
courses=courses,
|
||
unmapped_items=unmapped_items,
|
||
mapped_revenue_net=mapped_revenue,
|
||
mapped_total_cost=mapped_cost,
|
||
mapped_gp_percent=mapped_gp,
|
||
total_all_revenue_net=total_revenue,
|
||
unmapped_revenue_net=unmapped_revenue,
|
||
mapped_revenue_percent=coverage_pct,
|
||
mapped_item_count=len(mapped_items),
|
||
unmapped_item_count=len(unmapped_items),
|
||
)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# THEORETICAL VS ACTUAL USAGE (REVERSE COSTING)
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class UsageVarianceItem(BaseModel):
|
||
ingredient_id: int
|
||
ingredient_name: str
|
||
category: str | None = None
|
||
standard_unit: str
|
||
# Theoretical (from sales × recipes)
|
||
theoretical_qty: float
|
||
theoretical_value: float
|
||
dishes_using: int
|
||
# Actual (from invoices)
|
||
actual_qty: float | None = None
|
||
actual_value: float | None = None
|
||
invoice_count: int = 0
|
||
# Variance
|
||
variance_qty: float | None = None
|
||
variance_pct: float | None = None
|
||
variance_value: float | None = None
|
||
|
||
class UnmappedSaleItem(BaseModel):
|
||
menu_item_name: str
|
||
portion_name: str
|
||
total_qty: int
|
||
category: str | None = None
|
||
|
||
class UsageVarianceResponse(BaseModel):
|
||
from_date: date
|
||
to_date: date
|
||
items: list[UsageVarianceItem]
|
||
total_theoretical_value: float
|
||
total_actual_value: float
|
||
total_variance_value: float
|
||
mapped_dish_count: int
|
||
unmapped_dish_count: int
|
||
ingredients_with_purchases: int
|
||
ingredients_without_purchases: int
|
||
unmapped_sales: list[UnmappedSaleItem]
|
||
|
||
|
||
def _expand_recipe_ingredients(
|
||
recipe,
|
||
scale: float,
|
||
theoretical: dict,
|
||
dishes_using: dict,
|
||
recipe_name: str,
|
||
visited: set | None = None,
|
||
depth: int = 0,
|
||
):
|
||
"""
|
||
Recursively expand a recipe's ingredients into the theoretical usage dict.
|
||
Max depth of 3 to avoid lazy-load errors on deeply nested sub-recipes.
|
||
"""
|
||
from api.ingredients import convert_to_standard, UNIT_CONVERSIONS
|
||
|
||
if depth > 3:
|
||
return
|
||
if visited is None:
|
||
visited = set()
|
||
if recipe.id in visited:
|
||
return
|
||
visited.add(recipe.id)
|
||
|
||
# Direct ingredients — guard against lazy load
|
||
try:
|
||
ingredients = recipe.ingredients
|
||
except Exception:
|
||
return
|
||
|
||
for ri in ingredients:
|
||
ing = ri.ingredient
|
||
if not ing or ing.is_archived:
|
||
continue
|
||
|
||
qty_in_unit = float(ri.quantity) * scale
|
||
from_unit = (ri.unit or ing.standard_unit).lower().strip()
|
||
to_unit = ing.standard_unit.lower().strip()
|
||
|
||
if from_unit == to_unit:
|
||
qty_std = qty_in_unit
|
||
else:
|
||
converted = convert_to_standard(Decimal(str(qty_in_unit)), from_unit, to_unit)
|
||
qty_std = float(converted) if converted is not None else qty_in_unit
|
||
|
||
yield_pct = float(ri.yield_percent or 100)
|
||
if yield_pct > 0 and yield_pct < 100:
|
||
qty_raw = qty_std / (yield_pct / 100.0)
|
||
else:
|
||
qty_raw = qty_std
|
||
|
||
theoretical[ing.id] = theoretical.get(ing.id, 0.0) + qty_raw
|
||
if ing.id not in dishes_using:
|
||
dishes_using[ing.id] = set()
|
||
dishes_using[ing.id].add(recipe_name)
|
||
|
||
# Sub-recipes — guard against lazy load at deeper levels
|
||
try:
|
||
sub_recipes = recipe.sub_recipes
|
||
except Exception:
|
||
return
|
||
|
||
for sr in sub_recipes:
|
||
child = sr.child_recipe
|
||
if not child:
|
||
continue
|
||
|
||
child_output = child.batch_portions or 1
|
||
child_output_unit = "portion"
|
||
if child.batch_output_type == "bulk" and child.batch_yield_qty:
|
||
child_output = float(child.batch_yield_qty)
|
||
child_output_unit = (child.batch_yield_unit or "portion").lower().strip()
|
||
|
||
# Convert portions_needed to child's output unit if units differ
|
||
needed = float(sr.portions_needed)
|
||
needed_unit = (sr.portions_needed_unit or child_output_unit).lower().strip()
|
||
if needed_unit != child_output_unit and child.batch_output_type == "bulk":
|
||
converted = convert_to_standard(Decimal(str(needed)), needed_unit, child_output_unit)
|
||
if converted is not None:
|
||
needed = float(converted)
|
||
|
||
sub_scale = needed * scale / child_output
|
||
_expand_recipe_ingredients(
|
||
child, sub_scale, theoretical, dishes_using, recipe_name, visited.copy(), depth + 1
|
||
)
|
||
|
||
|
||
@router.get("/usage-variance", response_model=UsageVarianceResponse)
|
||
async def get_usage_variance(
|
||
from_date: date,
|
||
to_date: date,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
Theoretical vs Actual Usage report (reverse costing).
|
||
|
||
Compares ingredient usage implied by SambaPOS sales + recipes
|
||
against actual purchases from Flash invoices for the same period.
|
||
"""
|
||
from models.settings import KitchenSettings
|
||
from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe
|
||
from models.ingredient import Ingredient, IngredientSource, IngredientCategory
|
||
from models.line_item import LineItem
|
||
from services.sambapos_api import SambaPOSClient
|
||
from api.ingredients import convert_to_standard, UNIT_CONVERSIONS
|
||
from sqlalchemy import distinct, and_, or_
|
||
|
||
if from_date > to_date:
|
||
raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date")
|
||
|
||
kitchen_id = current_user.kitchen_id
|
||
|
||
# ── Settings + SambaPOS config ──
|
||
result = await db.execute(
|
||
select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id)
|
||
)
|
||
settings = result.scalar_one_or_none()
|
||
if not settings:
|
||
raise HTTPException(status_code=400, detail="Settings not configured")
|
||
|
||
if not all([
|
||
settings.sambapos_db_host, settings.sambapos_db_name,
|
||
settings.sambapos_db_username, settings.sambapos_db_password,
|
||
]):
|
||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||
|
||
tracked_categories = [
|
||
c.strip() for c in (settings.sambapos_tracked_categories or "").split(",") if c.strip()
|
||
]
|
||
if not tracked_categories:
|
||
raise HTTPException(status_code=400, detail="No SambaPOS categories configured")
|
||
|
||
excluded_items = [
|
||
i.strip() for i in (settings.sambapos_excluded_items or "").split("|") if i.strip()
|
||
]
|
||
|
||
client = SambaPOSClient(
|
||
host=settings.sambapos_db_host,
|
||
port=settings.sambapos_db_port or 1433,
|
||
database=settings.sambapos_db_name,
|
||
username=settings.sambapos_db_username,
|
||
password=settings.sambapos_db_password,
|
||
)
|
||
|
||
# ── Step 1: Get sales + match to recipes ──
|
||
try:
|
||
sales = await client.get_sales_breakdown(
|
||
from_date, to_date, tracked_categories,
|
||
excluded_categories=excluded_items if excluded_items else None,
|
||
)
|
||
except Exception as e:
|
||
raise HTTPException(status_code=400, detail=f"SambaPOS query failed: {str(e)}")
|
||
|
||
# Load recipes with full ingredient chain + sub-recipes (2 levels deep)
|
||
recipe_result = await db.execute(
|
||
select(Recipe)
|
||
.options(
|
||
selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient)
|
||
.selectinload(Ingredient.sources),
|
||
selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient)
|
||
.selectinload(Ingredient.category),
|
||
selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe)
|
||
.selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient)
|
||
.selectinload(Ingredient.sources),
|
||
selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe)
|
||
.selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient)
|
||
.selectinload(Ingredient.category),
|
||
selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe)
|
||
.selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe)
|
||
.selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient)
|
||
.selectinload(Ingredient.sources),
|
||
)
|
||
.where(
|
||
Recipe.kitchen_id == kitchen_id,
|
||
Recipe.recipe_type == "dish",
|
||
Recipe.kds_menu_item_name.isnot(None),
|
||
Recipe.kds_menu_item_name != "",
|
||
Recipe.is_archived == False,
|
||
)
|
||
)
|
||
recipes = recipe_result.scalars().all()
|
||
|
||
# Build lookup
|
||
recipe_lookup: dict[tuple[str, str | None], object] = {}
|
||
for r in recipes:
|
||
if r.sambapos_portion_name:
|
||
recipe_lookup[(r.kds_menu_item_name, r.sambapos_portion_name)] = r
|
||
else:
|
||
recipe_lookup[(r.kds_menu_item_name, None)] = r
|
||
|
||
# ── Step 2: Explode recipes into theoretical ingredient usage ──
|
||
theoretical: dict[int, float] = {} # ingredient_id -> qty in std unit
|
||
dishes_using: dict[int, set] = {} # ingredient_id -> set of dish names
|
||
ingredient_cache: dict[int, object] = {} # ingredient_id -> Ingredient obj
|
||
unmapped_sales: list[UnmappedSaleItem] = []
|
||
mapped_dish_count = 0
|
||
unmapped_dish_count = 0
|
||
|
||
for sale in sales:
|
||
menu_name = sale["menu_item_name"]
|
||
portion = sale["portion_name"]
|
||
qty = sale["total_qty"]
|
||
|
||
matched_recipe = recipe_lookup.get((menu_name, portion))
|
||
if not matched_recipe and portion != "Normal":
|
||
matched_recipe = recipe_lookup.get((menu_name, None))
|
||
if not matched_recipe and portion == "Normal":
|
||
matched_recipe = recipe_lookup.get((menu_name, None))
|
||
|
||
if not matched_recipe:
|
||
unmapped_dish_count += 1
|
||
unmapped_sales.append(UnmappedSaleItem(
|
||
menu_item_name=menu_name,
|
||
portion_name=portion,
|
||
total_qty=qty,
|
||
category=sale.get("category"),
|
||
))
|
||
continue
|
||
|
||
mapped_dish_count += 1
|
||
batch_portions = matched_recipe.batch_portions or 1
|
||
scale = qty / batch_portions
|
||
|
||
_expand_recipe_ingredients(
|
||
matched_recipe, scale, theoretical, dishes_using, matched_recipe.name
|
||
)
|
||
|
||
# Cache ingredient objects for later use
|
||
for ri in matched_recipe.ingredients:
|
||
if ri.ingredient and ri.ingredient.id not in ingredient_cache:
|
||
ingredient_cache[ri.ingredient.id] = ri.ingredient
|
||
for sr in matched_recipe.sub_recipes:
|
||
if sr.child_recipe:
|
||
for ri in sr.child_recipe.ingredients:
|
||
if ri.ingredient and ri.ingredient.id not in ingredient_cache:
|
||
ingredient_cache[ri.ingredient.id] = ri.ingredient
|
||
|
||
# ── Step 3: Get actual purchases ──
|
||
# Value aggregation (reliable — always works)
|
||
purchase_value_query = (
|
||
select(
|
||
LineItem.ingredient_id,
|
||
func.sum(
|
||
case(
|
||
(Invoice.document_type == "credit_note", -LineItem.amount),
|
||
else_=LineItem.amount,
|
||
)
|
||
).label("total_value"),
|
||
func.count(distinct(Invoice.id)).label("invoice_count"),
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == kitchen_id,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
Invoice.invoice_date.between(from_date, to_date),
|
||
LineItem.ingredient_id.isnot(None),
|
||
LineItem.amount.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)),
|
||
)
|
||
.group_by(LineItem.ingredient_id)
|
||
)
|
||
pv_result = await db.execute(purchase_value_query)
|
||
purchase_values: dict[int, dict] = {}
|
||
for row in pv_result.all():
|
||
purchase_values[row.ingredient_id] = {
|
||
"total_value": float(row.total_value or 0),
|
||
"invoice_count": row.invoice_count,
|
||
}
|
||
|
||
# Quantity aggregation (per line item — need pack conversion)
|
||
# Fetch raw line items for ingredients we care about
|
||
all_ingredient_ids = set(theoretical.keys()) | set(purchase_values.keys())
|
||
purchase_qtys: dict[int, float] = {}
|
||
|
||
if all_ingredient_ids:
|
||
li_query = (
|
||
select(LineItem, Invoice.document_type)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == kitchen_id,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
Invoice.invoice_date.between(from_date, to_date),
|
||
LineItem.ingredient_id.in_(all_ingredient_ids),
|
||
LineItem.quantity.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)),
|
||
)
|
||
)
|
||
li_result = await db.execute(li_query)
|
||
|
||
# We need ingredient standard_units — load any missing from DB
|
||
missing_ids = all_ingredient_ids - set(ingredient_cache.keys())
|
||
if missing_ids:
|
||
ing_result = await db.execute(
|
||
select(Ingredient)
|
||
.options(selectinload(Ingredient.category), selectinload(Ingredient.sources))
|
||
.where(Ingredient.id.in_(missing_ids))
|
||
)
|
||
for ing in ing_result.scalars().all():
|
||
ingredient_cache[ing.id] = ing
|
||
|
||
for row in li_result.all():
|
||
li = row[0] # LineItem
|
||
doc_type = row[1] # document_type
|
||
ing_id = li.ingredient_id
|
||
ing = ingredient_cache.get(ing_id)
|
||
if not ing:
|
||
continue
|
||
|
||
std_unit = ing.standard_unit.lower().strip()
|
||
qty = float(li.quantity)
|
||
std_qty = None
|
||
|
||
# Try pack conversion: quantity × pack_quantity × unit_size → convert
|
||
if li.pack_quantity and li.unit_size and li.unit_size_type:
|
||
total_source = qty * li.pack_quantity * float(li.unit_size)
|
||
converted = convert_to_standard(
|
||
Decimal(str(total_source)), li.unit_size_type, std_unit
|
||
)
|
||
if converted is not None:
|
||
std_qty = float(converted)
|
||
# Fallback: if line item unit matches a known unit
|
||
elif li.unit and li.unit.lower().strip() in UNIT_CONVERSIONS:
|
||
converted = convert_to_standard(
|
||
Decimal(str(qty)), li.unit.lower().strip(), std_unit
|
||
)
|
||
if converted is not None:
|
||
std_qty = float(converted)
|
||
|
||
if std_qty is not None:
|
||
if doc_type == "credit_note":
|
||
std_qty = -std_qty
|
||
purchase_qtys[ing_id] = purchase_qtys.get(ing_id, 0.0) + std_qty
|
||
|
||
# ── Step 4: Build comparison ──
|
||
items: list[UsageVarianceItem] = []
|
||
total_theoretical_value = 0.0
|
||
total_actual_value = 0.0
|
||
ingredients_with_purchases = 0
|
||
ingredients_without_purchases = 0
|
||
|
||
for ing_id in all_ingredient_ids:
|
||
ing = ingredient_cache.get(ing_id)
|
||
if not ing:
|
||
continue
|
||
|
||
cat_name = ing.category.name if ing.category else None
|
||
std_unit = ing.standard_unit
|
||
|
||
# Theoretical
|
||
theo_qty = theoretical.get(ing_id, 0.0)
|
||
dish_count = len(dishes_using.get(ing_id, set()))
|
||
|
||
# Get best price per standard unit for theoretical value calc
|
||
price_per_std = None
|
||
if ing.sources:
|
||
# Use most recent source price
|
||
priced_sources = [
|
||
s for s in ing.sources if s.price_per_std_unit and s.price_per_std_unit > 0
|
||
]
|
||
if priced_sources:
|
||
priced_sources.sort(key=lambda s: s.latest_invoice_date or date.min, reverse=True)
|
||
price_per_std = float(priced_sources[0].price_per_std_unit)
|
||
if price_per_std is None and ing.manual_price:
|
||
price_per_std = float(ing.manual_price)
|
||
|
||
theo_value = theo_qty * price_per_std if price_per_std else 0.0
|
||
total_theoretical_value += theo_value
|
||
|
||
# Actual
|
||
pv = purchase_values.get(ing_id)
|
||
actual_value = pv["total_value"] if pv else None
|
||
invoice_count = pv["invoice_count"] if pv else 0
|
||
actual_qty = purchase_qtys.get(ing_id)
|
||
|
||
if actual_value is not None:
|
||
total_actual_value += actual_value
|
||
ingredients_with_purchases += 1
|
||
elif theo_qty > 0:
|
||
ingredients_without_purchases += 1
|
||
|
||
# Variance
|
||
variance_qty = None
|
||
variance_pct = None
|
||
variance_value = None
|
||
|
||
if actual_qty is not None and theo_qty > 0:
|
||
variance_qty = actual_qty - theo_qty
|
||
variance_pct = (variance_qty / theo_qty) * 100.0
|
||
|
||
if actual_value is not None and theo_value > 0:
|
||
variance_value = actual_value - theo_value
|
||
elif actual_value is not None and theo_qty == 0:
|
||
# Purchased but no theoretical usage (not in any recipe)
|
||
variance_value = actual_value
|
||
|
||
items.append(UsageVarianceItem(
|
||
ingredient_id=ing_id,
|
||
ingredient_name=ing.name,
|
||
category=cat_name,
|
||
standard_unit=std_unit,
|
||
theoretical_qty=round(theo_qty, 2),
|
||
theoretical_value=round(theo_value, 2),
|
||
dishes_using=dish_count,
|
||
actual_qty=round(actual_qty, 2) if actual_qty is not None else None,
|
||
actual_value=round(actual_value, 2) if actual_value is not None else None,
|
||
invoice_count=invoice_count,
|
||
variance_qty=round(variance_qty, 2) if variance_qty is not None else None,
|
||
variance_pct=round(variance_pct, 1) if variance_pct is not None else None,
|
||
variance_value=round(variance_value, 2) if variance_value is not None else None,
|
||
))
|
||
|
||
# Sort by absolute variance value descending (biggest £ problems first)
|
||
items.sort(key=lambda x: abs(x.variance_value or 0), reverse=True)
|
||
|
||
total_variance = total_actual_value - total_theoretical_value
|
||
|
||
return UsageVarianceResponse(
|
||
from_date=from_date,
|
||
to_date=to_date,
|
||
items=items,
|
||
total_theoretical_value=round(total_theoretical_value, 2),
|
||
total_actual_value=round(total_actual_value, 2),
|
||
total_variance_value=round(total_variance, 2),
|
||
mapped_dish_count=mapped_dish_count,
|
||
unmapped_dish_count=unmapped_dish_count,
|
||
ingredients_with_purchases=ingredients_with_purchases,
|
||
ingredients_without_purchases=ingredients_without_purchases,
|
||
unmapped_sales=unmapped_sales,
|
||
)
|