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
+36 -15
View File
@@ -49,21 +49,26 @@ of every "finished" status:
- **Active Orders**: status is in `ACTIVE_STATUSES` (default just
`Created`) - the only tickets that represent real work still to do.
- **Cancelled**: status is in `CANCELLED_STATUSES` (default `Cancelled`)
- its own tab so it doesn't clutter Active, but still reviewable
anytime. Any transition into this status also triggers a **popup**
right after the import that caused it (not one that was already
cancelled).
**and it was cancelled today**. A ticket cancelled on a prior day
falls through to Done instead - this tab is meant to be reviewed
same-day and then filed away, not accumulate forever. Any transition
into Cancelled also triggers a **popup** right after the import that
caused it. Cancelled rows show in **red text** (rather than a
background tint) wherever they appear, including if one later ages
out into Done - it's still useful to see at a glance that a Done-tab
row got there via cancellation rather than fulfillment.
- **Done**: everything else, automatically. This is deliberate - rather
than maintaining a list of every status that means "finished"
(`Waiting For Return`, `Device Return Not Needed`, and whatever your
JIRA automation adds next, like `1st Contact Attempt`), only the
couple of statuses that mean "not done yet" are named. Anything that
isn't Created or Cancelled lands on Done with zero config changes
couple of statuses that mean "not done yet" (or "cancelled today")
are named. Anything else lands on Done with zero config changes
needed when your workflow adds another downstream status later.
This is a filter, not a physical move - it's the same `orders` table,
split by current status every time the view refreshes, so nothing to
reconcile if a status ever changes back.
split by current status (and, for Cancelled, `cancelled_at`) every time
the view refreshes, so nothing to reconcile if a status ever changes
back.
Within Done, the two fulfilled statuses (`FULFILLED_STATUS_WITH_RETURN`
/ `FULFILLED_STATUS_WITHOUT_RETURN`, defaulting to `Waiting For Return`
@@ -86,13 +91,29 @@ done today, just at different points in the day.
## Intake cutoff tracking
`INTAKE_CUTOFF_TIME` (default `15:30`, i.e. 3:30 PM) flags any ticket
created after that time on its arrival day with a **Past Cutoff** "Yes"
in the table, and the Dashboard shows how many of today's tickets
arrived past cutoff. Since a cutoff ticket is known to spill into
tomorrow the moment it arrives, it also counts toward **Carryover**
immediately - not just once the date actually rolls over - unless it
still gets fulfilled the same day despite arriving late, in which case
it correctly drops out of both.
created after that time on its arrival day with a checkmark in the
**Past Cutoff** column, and the Dashboard shows how many of today's
tickets arrived past cutoff. This flag expires at midnight - it means
"came in too late for today's window," not a permanent marker, so a
ticket that arrived late yesterday shows no checkmark today (it's
Carryover now, a different, already-tracked concept). While it's still
showing, it also counts toward **Carryover** immediately - not just
once the date actually rolls over - unless it still gets fulfilled the
same day despite arriving late, in which case it correctly drops out
of both.
## Table layout
Left to right: Company, Ticket #, Status, SKUs, Summary, Outgoing
Tracking, Return Tracking, Uploaded, Past Cutoff, Created. Columns
auto-size to their content on load and stay individually resizable by
dragging (not force-stretched to equal widths) - the last column
stretches to fill any remaining space. **Uploaded** shows a checkmark
once a ticket has been successfully sent via the emergency "Send to
ShipStation" action's **API** path specifically - the CSV export path
intentionally doesn't set this, since exporting a file isn't the same
as it actually being imported into ShipStation, and the app has no way
to confirm that manual step happened.
## How tracking numbers get pulled and matched
+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 {