GUI updates and misc fixes for the Cancel cutoff

This commit is contained in:
2026-08-31 17:01:13 -05:00
parent 9651b82415
commit df9958cb97
7 changed files with 148 additions and 61 deletions
+11
View File
@@ -82,6 +82,17 @@ class Order(Base):
# for "fulfilled today" counts and as a rough audit trail. Set once,
# on the transition; not touched again while it stays fulfilled.
fulfilled_at = Column(DateTime, nullable=True)
# Same idea, for cancellation - also what limits the Cancelled tab to
# "cancelled today"; a ticket cancelled on a prior day falls through
# to Done instead of lingering on Cancelled indefinitely.
cancelled_at = Column(DateTime, nullable=True)
# When this ticket was successfully sent to ShipStation via the
# emergency "Send to ShipStation" action's API path specifically -
# confirmed by ShipStation's own response, not just attempted. The
# CSV export path intentionally does NOT set this: exporting a file
# isn't the same as it actually being imported, and this app has no
# way to confirm that manual step happened.
shipstation_sent_at = Column(DateTime, nullable=True)
# Downstream pipeline flags - useful once Odoo export is fully wired up
fulfilled = Column(Boolean, nullable=False, default=False)
+13
View File
@@ -36,3 +36,16 @@ def is_past_cutoff(created_at: Optional[dt.datetime], cutoff: Optional[dt.time])
if not created_at or not cutoff:
return False
return created_at.time() > cutoff
def is_past_cutoff_today(created_at: Optional[dt.datetime], cutoff: Optional[dt.time]) -> bool:
"""
Same as is_past_cutoff, but only true for TODAY's late arrivals - the
flag is meant to mean "this one came in too late for today's window",
not a permanent marker. Once the next business day starts, a ticket
that arrived late yesterday is carryover (a different, already-tracked
concept), not "past cutoff" anymore.
"""
if not created_at or created_at.date() != dt.date.today():
return False
return is_past_cutoff(created_at, cutoff)
+7 -5
View File
@@ -38,6 +38,7 @@ from app.workers import (
SendToShipStationWorker,
load_orders_by_view,
get_dashboard_stats,
mark_shipstation_sent,
)
@@ -284,17 +285,18 @@ class MainWindow(QMainWindow):
self._send_via_csv(order)
def _send_via_api(self, order) -> None:
self.status_label.setText(
f"Sending {order.ticket_number or order.external_id} to ShipStation..."
)
ticket_number = order.ticket_number or order.external_id
self.status_label.setText(f"Sending {ticket_number} to ShipStation...")
worker = SendToShipStationWorker(order)
worker.finished_ok.connect(self._on_emergency_send_ok)
worker.finished_ok.connect(lambda result: self._on_emergency_send_ok(ticket_number, result))
worker.failed.connect(self._on_emergency_send_failed)
self._send_worker = worker # keep a reference so it isn't garbage collected
worker.start()
def _on_emergency_send_ok(self, result: dict) -> None:
def _on_emergency_send_ok(self, ticket_number: str, result: dict) -> None:
shipment_id = result.get("shipment_id", "?")
mark_shipstation_sent(ticket_number)
self._refresh_everything()
self.status_label.setText(f"Sent to ShipStation - shipment {shipment_id} created.")
QMessageBox.information(
self,
+1 -1
View File
@@ -62,7 +62,7 @@ class DashboardWidget(QWidget):
# Top row: active workload health (fulfilled tickets have moved to the Done tab)
top_row = QHBoxLayout()
self.total_card = StatCard("Active Orders")
self.cancelled_card = StatCard("Cancelled")
self.cancelled_card = StatCard("Cancelled Today")
self.cancelled_card.setStyleSheet("QLabel { color: #b00020; }")
self.carryover_card = StatCard("Carryover (still open from an earlier day)")
self.carryover_card.setStyleSheet("QLabel { color: #b06a00; }")
+38 -22
View File
@@ -26,35 +26,37 @@ from PyQt6.QtWidgets import (
QLabel,
)
from app import config
from app.models import Order
from app.schedule import get_cutoff_time, is_past_cutoff
from app.status_rules import parse_status_list, status_in
from app.schedule import get_cutoff_time, is_past_cutoff_today
from app.status_rules import get_cancelled_statuses, status_in
from app.tracking import get_fulfilled_statuses
CANCELLED_ROW_COLOR = QColor(255, 210, 210)
CANCELLED_TEXT_COLOR = QColor(180, 0, 0)
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
CHECK_MARK = "\u2713"
# Order here is display order, left to right.
COLUMNS = [
("company", "Company"),
("ticket_number", "Ticket #"),
("status", "Status"),
("skus_display", "SKUs"),
("summary", "Summary"),
("status", "Status"),
("tracking_display", "Tracking #"),
("created_display", "Created"),
("outgoing_tracking_display", "Outgoing Tracking"),
("return_tracking_display", "Return Tracking"),
("uploaded_display", "Uploaded"),
("past_cutoff_display", "Past Cutoff"),
("created_display", "Created"),
]
ALL_COMPANIES = "All Companies"
def _format_tracking(tracking_numbers) -> str:
def _format_tracking_numbers(tracking_numbers, is_return: bool) -> str:
if not tracking_numbers:
return ""
return ", ".join(
f"{t.get('number')} ({'return' if t.get('is_return') else 'out'})"
for t in tracking_numbers
t.get("number", "") for t in tracking_numbers if bool(t.get("is_return")) == is_return
)
@@ -74,9 +76,7 @@ class OrdersTableModel(QAbstractTableModel):
def set_orders(self, orders: List[Order]) -> None:
# Re-read status lists and cutoff time each refresh, in case Settings changed.
self._cancelled_statuses = parse_status_list(
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
)
self._cancelled_statuses = get_cancelled_statuses()
self._fulfilled_statuses = get_fulfilled_statuses()
self._cutoff_time = get_cutoff_time()
self.beginResetModel()
@@ -101,11 +101,17 @@ class OrdersTableModel(QAbstractTableModel):
return None
order = self._orders[index.row()]
is_cancelled = status_in(order.status, self._cancelled_statuses)
if role == Qt.ItemDataRole.ForegroundRole:
return CANCELLED_TEXT_COLOR if is_cancelled else None
if role == Qt.ItemDataRole.BackgroundRole:
if status_in(order.status, self._cancelled_statuses):
return CANCELLED_ROW_COLOR
if status_in(order.status, self._fulfilled_statuses):
# Cancelled rows use red TEXT (above) instead of a background,
# so it stays readable regardless of theme. Fulfilled rows
# still get a background tint since there's no text-color
# signal already in use for them.
if not is_cancelled and status_in(order.status, self._fulfilled_statuses):
return FULFILLED_ROW_COLOR
return None
@@ -116,12 +122,16 @@ class OrdersTableModel(QAbstractTableModel):
if field_name == "skus_display":
return ", ".join(order.skus or [])
if field_name == "tracking_display":
return _format_tracking(order.tracking_numbers)
if field_name == "outgoing_tracking_display":
return _format_tracking_numbers(order.tracking_numbers, is_return=False)
if field_name == "return_tracking_display":
return _format_tracking_numbers(order.tracking_numbers, is_return=True)
if field_name == "uploaded_display":
return CHECK_MARK if order.shipstation_sent_at else ""
if field_name == "created_display":
return _format_created(order.source_created_at)
if field_name == "past_cutoff_display":
return "Yes" if is_past_cutoff(order.source_created_at, self._cutoff_time) else ""
return CHECK_MARK if is_past_cutoff_today(order.source_created_at, self._cutoff_time) else ""
value = getattr(order, field_name)
return "" if value is None else str(value)
@@ -154,13 +164,16 @@ class OrdersFilterProxyModel(QSortFilterProxyModel):
return False
if self.search_text:
tracking_text = " ".join(
t.get("number", "") for t in (order.tracking_numbers or [])
)
haystack = " ".join(
[
order.ticket_number or "",
order.summary or "",
order.status or "",
" ".join(order.skus or []),
_format_tracking(order.tracking_numbers),
tracking_text,
]
).lower()
if self.search_text not in haystack:
@@ -204,8 +217,11 @@ class OrdersTableView(QWidget):
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setSortingEnabled(True)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.table.horizontalHeader().setStretchLastSection(False)
# Interactive (not Stretch) so columns size to their own content and
# stay individually resizable by dragging, rather than every column
# being force-stretched to equal widths regardless of content.
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
self.table.horizontalHeader().setStretchLastSection(True)
self.table.doubleClicked.connect(self._on_row_double_clicked)
layout.addWidget(self.table)
+42 -18
View File
@@ -16,7 +16,7 @@ from sqlalchemy import select
from app.database import get_session
from app.models import Order
from app.schedule import get_cutoff_time, is_past_cutoff
from app.schedule import get_cutoff_time, is_past_cutoff_today
from app.services.base import OrderService, NormalizedOrder
from app.status_rules import get_active_statuses, get_cancelled_statuses, status_in
from app.tracking import get_fulfilled_statuses
@@ -99,7 +99,9 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
Whenever a JIRA ticket's status transitions INTO a fulfilled status
(Waiting For Return / Device Return Not Needed), fulfilled_at is
stamped - that's what lets the dashboard show "fulfilled today" and
what moves it into the Done pile.
what moves it into the Done pile. cancelled_at works the same way for
CANCELLED_STATUSES - it's what limits the Cancelled tab to "cancelled
today" rather than showing every cancelled ticket ever.
"""
session = get_session()
new_count = 0
@@ -108,6 +110,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
status_changes: List[StatusChange] = []
unmatched_tracking_tickets: List[str] = []
fulfilled_statuses = get_fulfilled_statuses()
cancelled_statuses = get_cancelled_statuses()
try:
for order in orders:
@@ -148,6 +151,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
line_items=order.get("line_items", []),
shipping_info=order.get("shipping_info", {}),
creator=order.get("creator"),
tracking_numbers=order.get("tracking_numbers", []),
summary=order["summary"],
status=new_status,
source_created_at=order["source_created_at"],
@@ -155,6 +159,9 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
fulfilled_at=(
dt.datetime.utcnow() if status_in(new_status, fulfilled_statuses) else None
),
cancelled_at=(
dt.datetime.utcnow() if status_in(new_status, cancelled_statuses) else None
),
)
)
new_count += 1
@@ -172,6 +179,10 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
old_status, fulfilled_statuses
):
existing.fulfilled_at = dt.datetime.utcnow()
if status_in(new_status, cancelled_statuses) and not status_in(
old_status, cancelled_statuses
):
existing.cancelled_at = dt.datetime.utcnow()
existing.ticket_number = order.get("ticket_number")
existing.company = order.get("company", "Unknown")
@@ -213,21 +224,28 @@ def split_orders_by_view(orders: List[Order]) -> Tuple[List[Order], List[Order],
Three tabs, allowlist-driven:
- Active: status is in ACTIVE_STATUSES (just "Created" by default) -
the only tickets that represent real work still to do.
- Cancelled: status is in CANCELLED_STATUSES - its own tab so it
doesn't clutter Active, but still reviewable on demand.
- Cancelled: status is in CANCELLED_STATUSES AND it was cancelled
TODAY. A ticket cancelled on a prior day falls through to Done
instead - the Cancelled tab is meant to be reviewed same-day and
then filed away, not accumulate forever.
- Done: everything else. This deliberately doesn't enumerate every
"finished" status by name - a JIRA-side automation status like
"1st Contact Attempt" falls in here automatically just by not
being Created or Cancelled, with no code change needed when your
JIRA workflow adds another downstream status later.
being Created or (today's) Cancelled, with no code change needed
when your JIRA workflow adds another downstream status later.
"""
active_statuses = get_active_statuses()
cancelled_statuses = get_cancelled_statuses()
today = dt.date.today()
active, cancelled, done = [], [], []
for order in orders:
if status_in(order.status, active_statuses):
active.append(order)
elif status_in(order.status, cancelled_statuses):
elif (
status_in(order.status, cancelled_statuses)
and order.cancelled_at
and order.cancelled_at.date() == today
):
cancelled.append(order)
else:
done.append(order)
@@ -238,6 +256,21 @@ def load_orders_by_view() -> Tuple[List[Order], List[Order], List[Order]]:
return split_orders_by_view(load_all_orders())
def mark_shipstation_sent(ticket_number: str) -> None:
"""Stamps shipstation_sent_at after a CONFIRMED emergency API send -
called once ShipStation's own response confirms creation succeeded."""
session = get_session()
try:
order = session.execute(
select(Order).where(Order.source == "jira", Order.ticket_number == ticket_number)
).scalar_one_or_none()
if order is not None:
order.shipstation_sent_at = dt.datetime.utcnow()
session.commit()
finally:
session.close()
def get_dashboard_stats() -> dict:
"""
Counts for the dashboard. "Active Orders" and the company breakdown
@@ -276,23 +309,14 @@ def get_dashboard_stats() -> dict:
# A ticket that arrived today but after the cutoff won't get worked
# today either - it's known carryover before the date even rolls
# over, not just once tomorrow arrives.
arrived_past_cutoff_today = bool(
order.source_created_at
and order.source_created_at.date() == today
and is_past_cutoff(order.source_created_at, cutoff)
)
if created_before_today or arrived_past_cutoff_today:
if created_before_today or is_past_cutoff_today(order.source_created_at, cutoff):
carryover_count += 1
fulfilled_today_count = sum(
1 for o in orders if o.fulfilled_at and o.fulfilled_at.date() == today
)
past_cutoff_today_count = sum(
1
for o in orders
if o.source_created_at
and o.source_created_at.date() == today
and is_past_cutoff(o.source_created_at, cutoff)
1 for o in orders if is_past_cutoff_today(o.source_created_at, cutoff)
)
return {