fix: report cancellation bugs + missing OrderItem model columns

Backend:
- OrderItem model: add cancelled_at, cancelled_by, cancel_reason columns
  (migration existed but model was missing them — caused 500 on cancellations_log)
- shifts.py _enrich_shift: count cancellations by quantity sum (not row count),
  add cancellation_value (sum of unit_price * quantity)
- reports.py current business day: add cancelled_items count (per-item quantity sum,
  across all orders in the day, not just fully-cancelled orders)

Manager dashboard:
- PrintFontsTab: fix SSE auth token key (access_token → manager_token, was causing 403)
- Today: show cancelled_items count as sub-label on Ακυρώσεις stat card
- OrderHistory: items/cancellations columns now use quantity sum, not row count
- WorkDaySummary drill-down: same quantity-sum fix
- ShiftsOverview: add Αξία Ακυρ. column next to Ακυρώσεις

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:15:55 +03:00
parent 36e9044972
commit 8533a1452b
8 changed files with 51 additions and 15 deletions

View File

@@ -81,6 +81,11 @@ class OrderItem(Base):
# Phase 2A — cost snapshot (copied from product at time of order, never updated)
unit_cost = Column(Float, nullable=True)
# Phase 2B — cancellation tracking
cancelled_by = Column(Integer, ForeignKey("users.id"), nullable=True)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
cancel_reason = Column(Text, nullable=True)
order = relationship("Order", back_populates="items")
product = relationship("Product", back_populates="order_items")
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")

View File

@@ -1260,6 +1260,16 @@ def current_business_day(
p = db.query(Product).filter(Product.id == top_product_id).first()
top_product = {"id": top_product_id, "name": p.name if p else f"#{top_product_id}", "qty": item_counts[top_product_id]}
# Count cancelled items (individual items cancelled, across all orders in this day)
all_order_ids = [o.id for o in orders]
cancelled_items_qty = 0
if all_order_ids:
from sqlalchemy import func as sqlfunc
cancelled_items_qty = db.query(sqlfunc.sum(OrderItem.quantity)).filter(
OrderItem.order_id.in_(all_order_ids),
OrderItem.status == "cancelled",
).scalar() or 0
return {
"business_day": {
"id": day.id,
@@ -1272,6 +1282,7 @@ def current_business_day(
"orders_open": len(open_orders),
"active_waiters": len(active_shifts),
"cancellations": len(cancelled_orders),
"cancelled_items": int(cancelled_items_qty),
"top_product": top_product,
}
}

View File

@@ -75,7 +75,7 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
pay_data = compute_shift_pay(shift)
# Count cancelled items attributed to this waiter during shift window
# Count cancelled items and their value attributed to this waiter during shift window
cancelled_q = db.query(OrderItem).filter(
OrderItem.status == "cancelled",
OrderItem.added_by == shift.waiter_id,
@@ -83,7 +83,9 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
)
if shift.ended_at:
cancelled_q = cancelled_q.filter(OrderItem.added_at <= shift.ended_at)
cancellations = cancelled_q.count()
cancelled_rows = cancelled_q.all()
cancellations = sum(r.quantity for r in cancelled_rows)
cancellation_value = round(sum(r.unit_price * r.quantity for r in cancelled_rows), 2)
return {
"id": shift.id,
"waiter_id": shift.waiter_id,
@@ -100,6 +102,7 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
"duration_hours": pay_data["duration_hours"],
"shift_pay": pay_data["shift_pay"],
"cancellations": cancellations,
"cancellation_value": cancellation_value,
# Phase 2E
"counted_cash_end": shift.counted_cash_end,
"cash_discrepancy": shift.cash_discrepancy,

View File

@@ -804,7 +804,7 @@ function FindPrintersModal({ onClose, onSelect }) {
setError(null)
setScanning(true)
const token = localStorage.getItem('access_token') || ''
const token = localStorage.getItem('manager_token') || ''
const params = new URLSearchParams({ subnet, port })
// Use fetch with ReadableStream to consume SSE (EventSource doesn't support auth headers)
fetch(`/api/system/printers/scan?${params}`, {

View File

@@ -95,8 +95,11 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
</THead>
<tbody>
{orders.slice(0, 200).map(o => {
const activeItems = (o.items || []).filter(i => i.status !== 'cancelled')
const cancelledItems = (o.items || []).filter(i => i.status === 'cancelled')
const allItems = o.items || []
const activeItems = allItems.filter(i => i.status !== 'cancelled')
const cancelledItems = allItems.filter(i => i.status === 'cancelled')
const totalQty = allItems.reduce((s, i) => s + (i.quantity || 1), 0)
const cancelledQty = cancelledItems.reduce((s, i) => s + (i.quantity || 1), 0)
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const isCancelled = o.status === 'cancelled'
return (
@@ -106,10 +109,10 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
<TD mono>{fmtDateTime(o.opened_at)}</TD>
<TD mono>{fmtDateTime(o.closed_at)}</TD>
<TD><StatusBadge status={o.status} /></TD>
<TD mono align="right">{activeItems.length}</TD>
<TD mono align="right">{totalQty}</TD>
<TD mono align="right">
{cancelledItems.length > 0
? <span className="text-red-500 font-semibold">{cancelledItems.length}</span>
{cancelledQty > 0
? <span className="text-red-500 font-semibold">{cancelledQty}</span>
: <span className="text-slate-300"></span>}
</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>

View File

@@ -119,7 +119,12 @@ export default function Today() {
{bd.top_product && (
<StatCard label="Κορυφαίο Προϊόν" value={bd.top_product.name} sub={`${bd.top_product.qty} τεμ.`} icon={Trophy} />
)}
<StatCard label="Ακυρώσεις" value={fmtNum(bd.cancellations)} icon={XCircle} />
<StatCard
label="Ακυρώσεις"
value={fmtNum(bd.cancellations)}
sub={bd.cancelled_items > 0 ? `${fmtNum(bd.cancelled_items)} είδη` : 'παραγγελίες'}
icon={XCircle}
/>
{bd.total_cost > 0 && (
<StatCard label="Κόστος Πωλήσεων" value={fmtEUR(bd.total_cost)} sub="κόστος ειδών" icon={ShoppingBag} />
)}

View File

@@ -75,8 +75,11 @@ function WorkDayModal({ day, onClose, onDeleteShift }) {
</THead>
<tbody>
{orders.map(o => {
const activeItems = (o.items || []).filter(i => i.status !== 'cancelled')
const cancelledItems = (o.items || []).filter(i => i.status === 'cancelled')
const allItems = o.items || []
const activeItems = allItems.filter(i => i.status !== 'cancelled')
const cancelledItems = allItems.filter(i => i.status === 'cancelled')
const totalQty = allItems.reduce((s, i) => s + (i.quantity || 1), 0)
const cancelledQty = cancelledItems.reduce((s, i) => s + (i.quantity || 1), 0)
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
return (
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className="cursor-pointer">
@@ -84,10 +87,10 @@ function WorkDayModal({ day, onClose, onDeleteShift }) {
<TD>{o.table_name ?? o.table_id}</TD>
<TD mono>{fmtDateTime(o.opened_at)}</TD>
<TD mono>{o.closed_at ? fmtDateTime(o.closed_at) : '—'}</TD>
<TD mono align="right">{activeItems.length}</TD>
<TD mono align="right">{totalQty}</TD>
<TD mono align="right">
{cancelledItems.length > 0
? <span className="text-red-500 font-semibold">{cancelledItems.length}</span>
{cancelledQty > 0
? <span className="text-red-500 font-semibold">{cancelledQty}</span>
: <span className="text-slate-300"></span>}
</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>

View File

@@ -126,6 +126,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
<TH align="right">Οφείλει</TH>
<TH align="right">Ταμείο</TH>
<TH align="right">Ακυρώσεις</TH>
<TH align="right">Αξία Ακυρ.</TH>
<TH>Κατάσταση</TH>
<TH className="w-28" />
</THead>
@@ -183,6 +184,11 @@ export default function ShiftsOverview({ onNavigate } = {}) {
? <span className="text-red-600 font-semibold">{s.cancellations}</span>
: <span className="text-slate-300"></span>}
</TD>
<TD mono align="right">
{s.cancellation_value > 0
? <span className="text-red-500">{fmtEUR(s.cancellation_value)}</span>
: <span className="text-slate-300"></span>}
</TD>
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
<TD align="right">
<div className="flex items-center justify-end gap-2">
@@ -207,7 +213,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
</TR>,
isOpen && (
<tr key={`${s.id}-detail`}>
<td colSpan={13} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
<td colSpan={14} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
<div className="flex items-center gap-4 text-[12px] text-slate-500">
<span>
{'Εργάσιμη Μέρα: '}