feat: Phase 2L — discount audit report
Backend:
- GET /api/reports/discounts: read-only audit of all OrderDiscount records
Filters: ?from=&to= or ?business_day_id=, ?applied_by=
Returns:
- discounts[]: id, order_id, table_name, applied_by_name, applied_at, discount_type,
discount_value, discount_amount (computed euro value), order_total_before,
item_id (null = whole-order), reason
- total_discount_value: sum of all euro discount amounts
- order_count: unique orders that received a discount
- by_waiter[]: grouped summary (waiter_name, count, total_value)
discount_amount computation: fixed → discount_value directly;
percent → computed from order or item total at query time
Frontend:
- DiscountsLog.jsx: read-only report under Reports → Λειτουργίες → Εκπτώσεις
- Filter bar: range/workday toggle, date range, waiter filter
- 3 stat cards: total discount value, orders with discounts, count of entries
- By-waiter summary table (hidden when only one waiter)
- Full audit table: date, order + table, waiter, type badge (% blue / € amber),
order total before, euro amount discounted in amber, reason
- Totals footer row
- No create/edit/delete — pure audit log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1641,3 +1641,98 @@ def cancellations_export(
|
|||||||
} for c in data["cancellations"]]
|
} for c in data["cancellations"]]
|
||||||
date_str = (from_dt or "")[:10]
|
date_str = (from_dt or "")[:10]
|
||||||
return _csv_response(rows, f"cancellations-{date_str}.csv")
|
return _csv_response(rows, f"cancellations-{date_str}.csv")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2L — Discount audit report
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/discounts")
|
||||||
|
def discounts_report(
|
||||||
|
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||||
|
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||||
|
business_day_id: Optional[int] = None,
|
||||||
|
applied_by: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
from models.order import OrderDiscount, OrderItem
|
||||||
|
|
||||||
|
q = db.query(OrderDiscount)
|
||||||
|
if business_day_id:
|
||||||
|
q = q.join(Order, Order.id == OrderDiscount.order_id).filter(Order.business_day_id == business_day_id)
|
||||||
|
elif from_dt and to_dt:
|
||||||
|
q = q.filter(
|
||||||
|
OrderDiscount.applied_at >= datetime.fromisoformat(from_dt),
|
||||||
|
OrderDiscount.applied_at <= datetime.fromisoformat(to_dt),
|
||||||
|
)
|
||||||
|
if applied_by:
|
||||||
|
q = q.filter(OrderDiscount.applied_by == applied_by)
|
||||||
|
|
||||||
|
discounts = q.order_by(OrderDiscount.applied_at.desc()).all()
|
||||||
|
|
||||||
|
users_map = {u.id: u for u in db.query(User).all()}
|
||||||
|
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||||
|
|
||||||
|
# Preload orders for table names
|
||||||
|
order_ids = {d.order_id for d in discounts}
|
||||||
|
orders_map = {}
|
||||||
|
if order_ids:
|
||||||
|
for o in db.query(Order).filter(Order.id.in_(order_ids)).all():
|
||||||
|
orders_map[o.id] = o
|
||||||
|
|
||||||
|
def _compute_discount_amount(d: OrderDiscount) -> float:
|
||||||
|
"""Compute the actual euro amount discounted."""
|
||||||
|
order = orders_map.get(d.order_id)
|
||||||
|
if d.discount_type == "fixed":
|
||||||
|
return round(d.discount_value, 2)
|
||||||
|
if d.discount_type == "percent":
|
||||||
|
if d.item_id:
|
||||||
|
item = db.query(OrderItem).filter(OrderItem.id == d.item_id).first()
|
||||||
|
if item:
|
||||||
|
base = item.unit_price * item.quantity
|
||||||
|
return round(base * d.discount_value / 100, 2)
|
||||||
|
elif order:
|
||||||
|
base = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled")
|
||||||
|
return round(base * d.discount_value / 100, 2)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
result = []
|
||||||
|
total_discount_value = 0.0
|
||||||
|
by_waiter: dict = {}
|
||||||
|
|
||||||
|
for d in discounts:
|
||||||
|
order = orders_map.get(d.order_id)
|
||||||
|
applier = users_map.get(d.applied_by)
|
||||||
|
applier_name = (applier.full_name or applier.username) if applier else f"#{d.applied_by}"
|
||||||
|
table_name = tables_map.get(order.table_id) if order and order.table_id else None
|
||||||
|
order_total = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled") if order else None
|
||||||
|
discount_amount = _compute_discount_amount(d)
|
||||||
|
total_discount_value += discount_amount
|
||||||
|
|
||||||
|
if d.applied_by not in by_waiter:
|
||||||
|
by_waiter[d.applied_by] = {"waiter_id": d.applied_by, "waiter_name": applier_name, "count": 0, "total_value": 0.0}
|
||||||
|
by_waiter[d.applied_by]["count"] += 1
|
||||||
|
by_waiter[d.applied_by]["total_value"] = round(by_waiter[d.applied_by]["total_value"] + discount_amount, 2)
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"id": d.id,
|
||||||
|
"order_id": d.order_id,
|
||||||
|
"table_name": table_name,
|
||||||
|
"applied_by_id": d.applied_by,
|
||||||
|
"applied_by_name": applier_name,
|
||||||
|
"applied_at": _dt(d.applied_at),
|
||||||
|
"discount_type": d.discount_type,
|
||||||
|
"discount_value": d.discount_value,
|
||||||
|
"discount_amount": discount_amount,
|
||||||
|
"order_total_before": round(order_total, 2) if order_total is not None else None,
|
||||||
|
"item_id": d.item_id,
|
||||||
|
"reason": d.reason,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"discounts": result,
|
||||||
|
"total_discount_value": round(total_discount_value, 2),
|
||||||
|
"order_count": len({d["order_id"] for d in result}),
|
||||||
|
"by_waiter": sorted(by_waiter.values(), key=lambda x: -x["total_value"]),
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import TableAnalytics from './restaurant/TableAnalytics'
|
|||||||
import PrinterHistory from './operations/PrinterHistory'
|
import PrinterHistory from './operations/PrinterHistory'
|
||||||
import PrinterHealthLog from './operations/PrinterHealthLog'
|
import PrinterHealthLog from './operations/PrinterHealthLog'
|
||||||
import CancellationsLog from './operations/CancellationsLog'
|
import CancellationsLog from './operations/CancellationsLog'
|
||||||
|
import DiscountsLog from './operations/DiscountsLog'
|
||||||
|
|
||||||
const PARENT_TABS = [
|
const PARENT_TABS = [
|
||||||
{
|
{
|
||||||
@@ -58,6 +59,7 @@ const PARENT_TABS = [
|
|||||||
{ id: 'printer-history', label: 'Ιστορικό Εκτυπωτή', Component: PrinterHistory },
|
{ id: 'printer-history', label: 'Ιστορικό Εκτυπωτή', Component: PrinterHistory },
|
||||||
{ id: 'printer-health', label: 'Υγεία Εκτυπωτή', Component: PrinterHealthLog },
|
{ id: 'printer-health', label: 'Υγεία Εκτυπωτή', Component: PrinterHealthLog },
|
||||||
{ id: 'cancellations', label: 'Ακυρώσεις', Component: CancellationsLog },
|
{ id: 'cancellations', label: 'Ακυρώσεις', Component: CancellationsLog },
|
||||||
|
{ id: 'discounts', label: 'Εκπτώσεις', Component: DiscountsLog },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
191
manager_dashboard/src/pages/reports/operations/DiscountsLog.jsx
Normal file
191
manager_dashboard/src/pages/reports/operations/DiscountsLog.jsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Tag } from 'lucide-react'
|
||||||
|
import client from '../../../api/client'
|
||||||
|
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
|
||||||
|
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar } from '../shared/TablePrimitives'
|
||||||
|
import StatCard from '../shared/StatCard'
|
||||||
|
import EmptyState from '../shared/EmptyState'
|
||||||
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
|
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateTime, fmtDateStr } from '../shared/reportDesignTokens'
|
||||||
|
|
||||||
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
|
|
||||||
|
export default function DiscountsLog() {
|
||||||
|
const [mode, setMode] = useState('range')
|
||||||
|
const [from, setFrom] = useState(monthAgo())
|
||||||
|
const [to, setTo] = useState(today())
|
||||||
|
const [businessDayId, setBusinessDayId] = useState('all')
|
||||||
|
const [waiterF, setWaiterF] = useState('all')
|
||||||
|
|
||||||
|
const { data: waitersData } = useQuery({
|
||||||
|
queryKey: ['meta-waiters'],
|
||||||
|
queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
const { data: bdData } = useQuery({
|
||||||
|
queryKey: ['business-days-list'],
|
||||||
|
queryFn: () => client.get('/api/reports/business-days').then(r => r.data),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryParams = {
|
||||||
|
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
|
||||||
|
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
|
||||||
|
...(waiterF !== 'all' ? { applied_by: waiterF } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['discounts', mode, from, to, businessDayId, waiterF],
|
||||||
|
queryFn: () => client.get('/api/reports/discounts', { params: queryParams }).then(r => r.data),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const waiterOptions = [
|
||||||
|
{ value: 'all', label: 'Όλοι' },
|
||||||
|
...((waitersData?.waiters || []).map(w => ({ value: String(w.id), label: w.name }))),
|
||||||
|
]
|
||||||
|
const bdOptions = [
|
||||||
|
{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' },
|
||||||
|
...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` }))),
|
||||||
|
]
|
||||||
|
|
||||||
|
const discounts = data?.discounts || []
|
||||||
|
const byWaiter = data?.by_waiter || []
|
||||||
|
const totalValue = data?.total_discount_value ?? 0
|
||||||
|
const orderCount = data?.order_count ?? 0
|
||||||
|
|
||||||
|
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={7} /></div>
|
||||||
|
if (isError) return (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<FilterBar>
|
||||||
|
<WorkDayDateToggle mode={mode} onChange={setMode} />
|
||||||
|
{mode === 'workday' ? (
|
||||||
|
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
||||||
|
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<FilterSelect value={waiterF} onChange={setWaiterF} options={waiterOptions} label="Σερβιτόρος" />
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<StatCard label="Σύνολο Εκπτώσεων" value={fmtEUR(totalValue)} icon={Tag} color="amber" />
|
||||||
|
<StatCard label="Παραγγελίες με Έκπτωση" value={fmtNum(orderCount)} color="slate" />
|
||||||
|
<StatCard label="Αριθμός Εκπτώσεων" value={fmtNum(discounts.length)} color="slate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By waiter summary */}
|
||||||
|
{byWaiter.length > 1 && (
|
||||||
|
<Panel title="Ανά Σερβιτόρο" padded={false}>
|
||||||
|
<DataTable>
|
||||||
|
<THead>
|
||||||
|
<TH>Σερβιτόρος</TH>
|
||||||
|
<TH align="right">Αριθμός</TH>
|
||||||
|
<TH align="right">Σύνολο</TH>
|
||||||
|
<TH align="right">Μέσος / Έκπτωση</TH>
|
||||||
|
</THead>
|
||||||
|
<tbody>
|
||||||
|
{byWaiter.map(w => (
|
||||||
|
<TR key={w.waiter_id} striped>
|
||||||
|
<TD><WaiterAvatar name={w.waiter_name} id={w.waiter_id} /></TD>
|
||||||
|
<TD mono align="right">{fmtNum(w.count)}</TD>
|
||||||
|
<TD mono align="right" className="font-semibold text-amber-700">{fmtEUR(w.total_value)}</TD>
|
||||||
|
<TD mono align="right">{fmtEUR(w.count > 0 ? w.total_value / w.count : 0)}</TD>
|
||||||
|
</TR>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</DataTable>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Full log */}
|
||||||
|
<Panel
|
||||||
|
title="Αρχείο Εκπτώσεων"
|
||||||
|
subtitle={`${discounts.length} εγγραφές — μόνο ανάγνωση`}
|
||||||
|
padded={false}
|
||||||
|
>
|
||||||
|
{discounts.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Tag}
|
||||||
|
title="Δεν βρέθηκαν εκπτώσεις"
|
||||||
|
description="Δεν υπάρχουν εκπτώσεις για την επιλεγμένη περίοδο."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable>
|
||||||
|
<THead>
|
||||||
|
<TH>Ημ/νία</TH>
|
||||||
|
<TH>Παραγγελία</TH>
|
||||||
|
<TH>Σερβιτόρος</TH>
|
||||||
|
<TH align="right">Τύπος</TH>
|
||||||
|
<TH align="right">Τιμή πριν</TH>
|
||||||
|
<TH align="right">Ποσό Έκπτωσης</TH>
|
||||||
|
<TH>Αιτία</TH>
|
||||||
|
</THead>
|
||||||
|
<tbody>
|
||||||
|
{discounts.map(d => {
|
||||||
|
const typeLabel = d.discount_type === 'percent'
|
||||||
|
? `${d.discount_value}%`
|
||||||
|
: fmtEUR(d.discount_value)
|
||||||
|
const isItemLevel = d.item_id != null
|
||||||
|
return (
|
||||||
|
<TR key={d.id} striped>
|
||||||
|
<TD mono className="text-slate-500">{fmtDateTime(d.applied_at)}</TD>
|
||||||
|
<TD>
|
||||||
|
<span className="font-medium text-slate-900">
|
||||||
|
#{d.order_id}
|
||||||
|
{d.table_name && <span className="text-slate-400 font-normal ml-1">· {d.table_name}</span>}
|
||||||
|
</span>
|
||||||
|
{isItemLevel && (
|
||||||
|
<span className="ml-2 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-slate-100 text-slate-500">ΑΝΤΙΚ.</span>
|
||||||
|
)}
|
||||||
|
</TD>
|
||||||
|
<TD><WaiterAvatar name={d.applied_by_name} id={d.applied_by_id} /></TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${d.discount_type === 'percent' ? 'bg-blue-50 text-blue-700' : 'bg-amber-50 text-amber-700'}`}>
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
</TD>
|
||||||
|
<TD mono align="right" className="text-slate-500">
|
||||||
|
{d.order_total_before != null ? fmtEUR(d.order_total_before) : '—'}
|
||||||
|
</TD>
|
||||||
|
<TD mono align="right" className="font-semibold text-amber-700">
|
||||||
|
− {fmtEUR(d.discount_amount)}
|
||||||
|
</TD>
|
||||||
|
<TD className="text-slate-500 text-[12px]">
|
||||||
|
{d.reason || <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
|
</TR>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
{discounts.length > 1 && (
|
||||||
|
<tfoot>
|
||||||
|
<TR>
|
||||||
|
<TD colSpan={5} className="font-semibold text-slate-700">Σύνολο</TD>
|
||||||
|
<TD mono align="right" className="font-bold text-amber-700">− {fmtEUR(totalValue)}</TD>
|
||||||
|
<TD />
|
||||||
|
</TR>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
|
</DataTable>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user