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:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
{'Εργάσιμη Μέρα: '}
|
||||
|
||||
Reference in New Issue
Block a user