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 - **Active Orders**: status is in `ACTIVE_STATUSES` (default just
`Created`) - the only tickets that represent real work still to do. `Created`) - the only tickets that represent real work still to do.
- **Cancelled**: status is in `CANCELLED_STATUSES` (default `Cancelled`) - **Cancelled**: status is in `CANCELLED_STATUSES` (default `Cancelled`)
- its own tab so it doesn't clutter Active, but still reviewable **and it was cancelled today**. A ticket cancelled on a prior day
anytime. Any transition into this status also triggers a **popup** falls through to Done instead - this tab is meant to be reviewed
right after the import that caused it (not one that was already same-day and then filed away, not accumulate forever. Any transition
cancelled). 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 - **Done**: everything else, automatically. This is deliberate - rather
than maintaining a list of every status that means "finished" than maintaining a list of every status that means "finished"
(`Waiting For Return`, `Device Return Not Needed`, and whatever your (`Waiting For Return`, `Device Return Not Needed`, and whatever your
JIRA automation adds next, like `1st Contact Attempt`), only the JIRA automation adds next, like `1st Contact Attempt`), only the
couple of statuses that mean "not done yet" are named. Anything that couple of statuses that mean "not done yet" (or "cancelled today")
isn't Created or Cancelled lands on Done with zero config changes are named. Anything else lands on Done with zero config changes
needed when your workflow adds another downstream status later. needed when your workflow adds another downstream status later.
This is a filter, not a physical move - it's the same `orders` table, 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 split by current status (and, for Cancelled, `cancelled_at`) every time
reconcile if a status ever changes back. the view refreshes, so nothing to reconcile if a status ever changes
back.
Within Done, the two fulfilled statuses (`FULFILLED_STATUS_WITH_RETURN` Within Done, the two fulfilled statuses (`FULFILLED_STATUS_WITH_RETURN`
/ `FULFILLED_STATUS_WITHOUT_RETURN`, defaulting to `Waiting For 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 tracking
`INTAKE_CUTOFF_TIME` (default `15:30`, i.e. 3:30 PM) flags any ticket `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" created after that time on its arrival day with a checkmark in the
in the table, and the Dashboard shows how many of today's tickets **Past Cutoff** column, and the Dashboard shows how many of today's
arrived past cutoff. Since a cutoff ticket is known to spill into tickets arrived past cutoff. This flag expires at midnight - it means
tomorrow the moment it arrives, it also counts toward **Carryover** "came in too late for today's window," not a permanent marker, so a
immediately - not just once the date actually rolls over - unless it ticket that arrived late yesterday shows no checkmark today (it's
still gets fulfilled the same day despite arriving late, in which case Carryover now, a different, already-tracked concept). While it's still
it correctly drops out of both. 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 ## 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, # for "fulfilled today" counts and as a rough audit trail. Set once,
# on the transition; not touched again while it stays fulfilled. # on the transition; not touched again while it stays fulfilled.
fulfilled_at = Column(DateTime, nullable=True) 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 # Downstream pipeline flags - useful once Odoo export is fully wired up
fulfilled = Column(Boolean, nullable=False, default=False) 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: if not created_at or not cutoff:
return False return False
return created_at.time() > cutoff 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, SendToShipStationWorker,
load_orders_by_view, load_orders_by_view,
get_dashboard_stats, get_dashboard_stats,
mark_shipstation_sent,
) )
@@ -284,17 +285,18 @@ class MainWindow(QMainWindow):
self._send_via_csv(order) self._send_via_csv(order)
def _send_via_api(self, order) -> None: def _send_via_api(self, order) -> None:
self.status_label.setText( ticket_number = order.ticket_number or order.external_id
f"Sending {order.ticket_number or order.external_id} to ShipStation..." self.status_label.setText(f"Sending {ticket_number} to ShipStation...")
)
worker = SendToShipStationWorker(order) 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) worker.failed.connect(self._on_emergency_send_failed)
self._send_worker = worker # keep a reference so it isn't garbage collected self._send_worker = worker # keep a reference so it isn't garbage collected
worker.start() 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", "?") 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.") self.status_label.setText(f"Sent to ShipStation - shipment {shipment_id} created.")
QMessageBox.information( QMessageBox.information(
self, 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: active workload health (fulfilled tickets have moved to the Done tab)
top_row = QHBoxLayout() top_row = QHBoxLayout()
self.total_card = StatCard("Active Orders") 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.cancelled_card.setStyleSheet("QLabel { color: #b00020; }")
self.carryover_card = StatCard("Carryover (still open from an earlier day)") self.carryover_card = StatCard("Carryover (still open from an earlier day)")
self.carryover_card.setStyleSheet("QLabel { color: #b06a00; }") self.carryover_card.setStyleSheet("QLabel { color: #b06a00; }")
+38 -22
View File
@@ -26,35 +26,37 @@ from PyQt6.QtWidgets import (
QLabel, QLabel,
) )
from app import config
from app.models import Order 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.status_rules import parse_status_list, status_in from app.status_rules import get_cancelled_statuses, status_in
from app.tracking import get_fulfilled_statuses 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) FULFILLED_ROW_COLOR = QColor(210, 240, 210)
CHECK_MARK = "\u2713"
# Order here is display order, left to right.
COLUMNS = [ COLUMNS = [
("company", "Company"), ("company", "Company"),
("ticket_number", "Ticket #"), ("ticket_number", "Ticket #"),
("status", "Status"),
("skus_display", "SKUs"), ("skus_display", "SKUs"),
("summary", "Summary"), ("summary", "Summary"),
("status", "Status"), ("outgoing_tracking_display", "Outgoing Tracking"),
("tracking_display", "Tracking #"), ("return_tracking_display", "Return Tracking"),
("created_display", "Created"), ("uploaded_display", "Uploaded"),
("past_cutoff_display", "Past Cutoff"), ("past_cutoff_display", "Past Cutoff"),
("created_display", "Created"),
] ]
ALL_COMPANIES = "All Companies" ALL_COMPANIES = "All Companies"
def _format_tracking(tracking_numbers) -> str: def _format_tracking_numbers(tracking_numbers, is_return: bool) -> str:
if not tracking_numbers: if not tracking_numbers:
return "" return ""
return ", ".join( return ", ".join(
f"{t.get('number')} ({'return' if t.get('is_return') else 'out'})" t.get("number", "") for t in tracking_numbers if bool(t.get("is_return")) == is_return
for t in tracking_numbers
) )
@@ -74,9 +76,7 @@ class OrdersTableModel(QAbstractTableModel):
def set_orders(self, orders: List[Order]) -> None: def set_orders(self, orders: List[Order]) -> None:
# Re-read status lists and cutoff time each refresh, in case Settings changed. # Re-read status lists and cutoff time each refresh, in case Settings changed.
self._cancelled_statuses = parse_status_list( self._cancelled_statuses = get_cancelled_statuses()
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
)
self._fulfilled_statuses = get_fulfilled_statuses() self._fulfilled_statuses = get_fulfilled_statuses()
self._cutoff_time = get_cutoff_time() self._cutoff_time = get_cutoff_time()
self.beginResetModel() self.beginResetModel()
@@ -101,11 +101,17 @@ class OrdersTableModel(QAbstractTableModel):
return None return None
order = self._orders[index.row()] 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 role == Qt.ItemDataRole.BackgroundRole:
if status_in(order.status, self._cancelled_statuses): # Cancelled rows use red TEXT (above) instead of a background,
return CANCELLED_ROW_COLOR # so it stays readable regardless of theme. Fulfilled rows
if status_in(order.status, self._fulfilled_statuses): # 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 FULFILLED_ROW_COLOR
return None return None
@@ -116,12 +122,16 @@ class OrdersTableModel(QAbstractTableModel):
if field_name == "skus_display": if field_name == "skus_display":
return ", ".join(order.skus or []) return ", ".join(order.skus or [])
if field_name == "tracking_display": if field_name == "outgoing_tracking_display":
return _format_tracking(order.tracking_numbers) 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": if field_name == "created_display":
return _format_created(order.source_created_at) return _format_created(order.source_created_at)
if field_name == "past_cutoff_display": 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) value = getattr(order, field_name)
return "" if value is None else str(value) return "" if value is None else str(value)
@@ -154,13 +164,16 @@ class OrdersFilterProxyModel(QSortFilterProxyModel):
return False return False
if self.search_text: if self.search_text:
tracking_text = " ".join(
t.get("number", "") for t in (order.tracking_numbers or [])
)
haystack = " ".join( haystack = " ".join(
[ [
order.ticket_number or "", order.ticket_number or "",
order.summary or "", order.summary or "",
order.status or "", order.status or "",
" ".join(order.skus or []), " ".join(order.skus or []),
_format_tracking(order.tracking_numbers), tracking_text,
] ]
).lower() ).lower()
if self.search_text not in haystack: if self.search_text not in haystack:
@@ -204,8 +217,11 @@ class OrdersTableView(QWidget):
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setSortingEnabled(True) self.table.setSortingEnabled(True)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) # Interactive (not Stretch) so columns size to their own content and
self.table.horizontalHeader().setStretchLastSection(False) # 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) self.table.doubleClicked.connect(self._on_row_double_clicked)
layout.addWidget(self.table) layout.addWidget(self.table)
+42 -18
View File
@@ -16,7 +16,7 @@ from sqlalchemy import select
from app.database import get_session from app.database import get_session
from app.models import Order 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.services.base import OrderService, NormalizedOrder
from app.status_rules import get_active_statuses, get_cancelled_statuses, status_in from app.status_rules import get_active_statuses, get_cancelled_statuses, status_in
from app.tracking import get_fulfilled_statuses 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 Whenever a JIRA ticket's status transitions INTO a fulfilled status
(Waiting For Return / Device Return Not Needed), fulfilled_at is (Waiting For Return / Device Return Not Needed), fulfilled_at is
stamped - that's what lets the dashboard show "fulfilled today" and 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() session = get_session()
new_count = 0 new_count = 0
@@ -108,6 +110,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
status_changes: List[StatusChange] = [] status_changes: List[StatusChange] = []
unmatched_tracking_tickets: List[str] = [] unmatched_tracking_tickets: List[str] = []
fulfilled_statuses = get_fulfilled_statuses() fulfilled_statuses = get_fulfilled_statuses()
cancelled_statuses = get_cancelled_statuses()
try: try:
for order in orders: for order in orders:
@@ -148,6 +151,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
line_items=order.get("line_items", []), line_items=order.get("line_items", []),
shipping_info=order.get("shipping_info", {}), shipping_info=order.get("shipping_info", {}),
creator=order.get("creator"), creator=order.get("creator"),
tracking_numbers=order.get("tracking_numbers", []),
summary=order["summary"], summary=order["summary"],
status=new_status, status=new_status,
source_created_at=order["source_created_at"], source_created_at=order["source_created_at"],
@@ -155,6 +159,9 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
fulfilled_at=( fulfilled_at=(
dt.datetime.utcnow() if status_in(new_status, fulfilled_statuses) else None 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 new_count += 1
@@ -172,6 +179,10 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
old_status, fulfilled_statuses old_status, fulfilled_statuses
): ):
existing.fulfilled_at = dt.datetime.utcnow() 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.ticket_number = order.get("ticket_number")
existing.company = order.get("company", "Unknown") 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: Three tabs, allowlist-driven:
- Active: status is in ACTIVE_STATUSES (just "Created" by default) - - Active: status is in ACTIVE_STATUSES (just "Created" by default) -
the only tickets that represent real work still to do. the only tickets that represent real work still to do.
- Cancelled: status is in CANCELLED_STATUSES - its own tab so it - Cancelled: status is in CANCELLED_STATUSES AND it was cancelled
doesn't clutter Active, but still reviewable on demand. 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 - Done: everything else. This deliberately doesn't enumerate every
"finished" status by name - a JIRA-side automation status like "finished" status by name - a JIRA-side automation status like
"1st Contact Attempt" falls in here automatically just by not "1st Contact Attempt" falls in here automatically just by not
being Created or Cancelled, with no code change needed when your being Created or (today's) Cancelled, with no code change needed
JIRA workflow adds another downstream status later. when your JIRA workflow adds another downstream status later.
""" """
active_statuses = get_active_statuses() active_statuses = get_active_statuses()
cancelled_statuses = get_cancelled_statuses() cancelled_statuses = get_cancelled_statuses()
today = dt.date.today()
active, cancelled, done = [], [], [] active, cancelled, done = [], [], []
for order in orders: for order in orders:
if status_in(order.status, active_statuses): if status_in(order.status, active_statuses):
active.append(order) 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) cancelled.append(order)
else: else:
done.append(order) 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()) 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: def get_dashboard_stats() -> dict:
""" """
Counts for the dashboard. "Active Orders" and the company breakdown 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 # A ticket that arrived today but after the cutoff won't get worked
# today either - it's known carryover before the date even rolls # today either - it's known carryover before the date even rolls
# over, not just once tomorrow arrives. # over, not just once tomorrow arrives.
arrived_past_cutoff_today = bool( if created_before_today or is_past_cutoff_today(order.source_created_at, cutoff):
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:
carryover_count += 1 carryover_count += 1
fulfilled_today_count = sum( fulfilled_today_count = sum(
1 for o in orders if o.fulfilled_at and o.fulfilled_at.date() == today 1 for o in orders if o.fulfilled_at and o.fulfilled_at.date() == today
) )
past_cutoff_today_count = sum( past_cutoff_today_count = sum(
1 1 for o in orders if is_past_cutoff_today(o.source_created_at, cutoff)
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)
) )
return { return {