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)
|
# Phase 2A — cost snapshot (copied from product at time of order, never updated)
|
||||||
unit_cost = Column(Float, nullable=True)
|
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")
|
order = relationship("Order", back_populates="items")
|
||||||
product = relationship("Product", back_populates="order_items")
|
product = relationship("Product", back_populates="order_items")
|
||||||
added_by_user = relationship("User", foreign_keys=[added_by], 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()
|
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]}
|
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 {
|
return {
|
||||||
"business_day": {
|
"business_day": {
|
||||||
"id": day.id,
|
"id": day.id,
|
||||||
@@ -1272,6 +1282,7 @@ def current_business_day(
|
|||||||
"orders_open": len(open_orders),
|
"orders_open": len(open_orders),
|
||||||
"active_waiters": len(active_shifts),
|
"active_waiters": len(active_shifts),
|
||||||
"cancellations": len(cancelled_orders),
|
"cancellations": len(cancelled_orders),
|
||||||
|
"cancelled_items": int(cancelled_items_qty),
|
||||||
"top_product": top_product,
|
"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}"
|
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)
|
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)
|
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(
|
cancelled_q = db.query(OrderItem).filter(
|
||||||
OrderItem.status == "cancelled",
|
OrderItem.status == "cancelled",
|
||||||
OrderItem.added_by == shift.waiter_id,
|
OrderItem.added_by == shift.waiter_id,
|
||||||
@@ -83,7 +83,9 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
|||||||
)
|
)
|
||||||
if shift.ended_at:
|
if shift.ended_at:
|
||||||
cancelled_q = cancelled_q.filter(OrderItem.added_at <= 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 {
|
return {
|
||||||
"id": shift.id,
|
"id": shift.id,
|
||||||
"waiter_id": shift.waiter_id,
|
"waiter_id": shift.waiter_id,
|
||||||
@@ -100,6 +102,7 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
|||||||
"duration_hours": pay_data["duration_hours"],
|
"duration_hours": pay_data["duration_hours"],
|
||||||
"shift_pay": pay_data["shift_pay"],
|
"shift_pay": pay_data["shift_pay"],
|
||||||
"cancellations": cancellations,
|
"cancellations": cancellations,
|
||||||
|
"cancellation_value": cancellation_value,
|
||||||
# Phase 2E
|
# Phase 2E
|
||||||
"counted_cash_end": shift.counted_cash_end,
|
"counted_cash_end": shift.counted_cash_end,
|
||||||
"cash_discrepancy": shift.cash_discrepancy,
|
"cash_discrepancy": shift.cash_discrepancy,
|
||||||
|
|||||||
@@ -804,7 +804,7 @@ function FindPrintersModal({ onClose, onSelect }) {
|
|||||||
setError(null)
|
setError(null)
|
||||||
setScanning(true)
|
setScanning(true)
|
||||||
|
|
||||||
const token = localStorage.getItem('access_token') || ''
|
const token = localStorage.getItem('manager_token') || ''
|
||||||
const params = new URLSearchParams({ subnet, port })
|
const params = new URLSearchParams({ subnet, port })
|
||||||
// Use fetch with ReadableStream to consume SSE (EventSource doesn't support auth headers)
|
// Use fetch with ReadableStream to consume SSE (EventSource doesn't support auth headers)
|
||||||
fetch(`/api/system/printers/scan?${params}`, {
|
fetch(`/api/system/printers/scan?${params}`, {
|
||||||
|
|||||||
@@ -95,8 +95,11 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
|
|||||||
</THead>
|
</THead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{orders.slice(0, 200).map(o => {
|
{orders.slice(0, 200).map(o => {
|
||||||
const activeItems = (o.items || []).filter(i => i.status !== 'cancelled')
|
const allItems = o.items || []
|
||||||
const cancelledItems = (o.items || []).filter(i => i.status === 'cancelled')
|
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 total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
const isCancelled = o.status === 'cancelled'
|
const isCancelled = o.status === 'cancelled'
|
||||||
return (
|
return (
|
||||||
@@ -106,10 +109,10 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
|
|||||||
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
||||||
<TD mono>{fmtDateTime(o.closed_at)}</TD>
|
<TD mono>{fmtDateTime(o.closed_at)}</TD>
|
||||||
<TD><StatusBadge status={o.status} /></TD>
|
<TD><StatusBadge status={o.status} /></TD>
|
||||||
<TD mono align="right">{activeItems.length}</TD>
|
<TD mono align="right">{totalQty}</TD>
|
||||||
<TD mono align="right">
|
<TD mono align="right">
|
||||||
{cancelledItems.length > 0
|
{cancelledQty > 0
|
||||||
? <span className="text-red-500 font-semibold">{cancelledItems.length}</span>
|
? <span className="text-red-500 font-semibold">{cancelledQty}</span>
|
||||||
: <span className="text-slate-300">—</span>}
|
: <span className="text-slate-300">—</span>}
|
||||||
</TD>
|
</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</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 && (
|
{bd.top_product && (
|
||||||
<StatCard label="Κορυφαίο Προϊόν" value={bd.top_product.name} sub={`${bd.top_product.qty} τεμ.`} icon={Trophy} />
|
<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 && (
|
{bd.total_cost > 0 && (
|
||||||
<StatCard label="Κόστος Πωλήσεων" value={fmtEUR(bd.total_cost)} sub="κόστος ειδών" icon={ShoppingBag} />
|
<StatCard label="Κόστος Πωλήσεων" value={fmtEUR(bd.total_cost)} sub="κόστος ειδών" icon={ShoppingBag} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -75,8 +75,11 @@ function WorkDayModal({ day, onClose, onDeleteShift }) {
|
|||||||
</THead>
|
</THead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{orders.map(o => {
|
{orders.map(o => {
|
||||||
const activeItems = (o.items || []).filter(i => i.status !== 'cancelled')
|
const allItems = o.items || []
|
||||||
const cancelledItems = (o.items || []).filter(i => i.status === 'cancelled')
|
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 total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
return (
|
return (
|
||||||
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className="cursor-pointer">
|
<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>{o.table_name ?? o.table_id}</TD>
|
||||||
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
||||||
<TD mono>{o.closed_at ? fmtDateTime(o.closed_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">
|
<TD mono align="right">
|
||||||
{cancelledItems.length > 0
|
{cancelledQty > 0
|
||||||
? <span className="text-red-500 font-semibold">{cancelledItems.length}</span>
|
? <span className="text-red-500 font-semibold">{cancelledQty}</span>
|
||||||
: <span className="text-slate-300">—</span>}
|
: <span className="text-slate-300">—</span>}
|
||||||
</TD>
|
</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</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 align="right">Ακυρώσεις</TH>
|
<TH align="right">Ακυρώσεις</TH>
|
||||||
|
<TH align="right">Αξία Ακυρ.</TH>
|
||||||
<TH>Κατάσταση</TH>
|
<TH>Κατάσταση</TH>
|
||||||
<TH className="w-28" />
|
<TH className="w-28" />
|
||||||
</THead>
|
</THead>
|
||||||
@@ -183,6 +184,11 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
|||||||
? <span className="text-red-600 font-semibold">{s.cancellations}</span>
|
? <span className="text-red-600 font-semibold">{s.cancellations}</span>
|
||||||
: <span className="text-slate-300">—</span>}
|
: <span className="text-slate-300">—</span>}
|
||||||
</TD>
|
</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><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
|
||||||
<TD align="right">
|
<TD align="right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -207,7 +213,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
|||||||
</TR>,
|
</TR>,
|
||||||
isOpen && (
|
isOpen && (
|
||||||
<tr key={`${s.id}-detail`}>
|
<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">
|
<div className="flex items-center gap-4 text-[12px] text-slate-500">
|
||||||
<span>
|
<span>
|
||||||
{'Εργάσιμη Μέρα: '}
|
{'Εργάσιμη Μέρα: '}
|
||||||
|
|||||||
Reference in New Issue
Block a user