GUI updates and misc fixes for the Cancel cutoff
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }")
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user