diff --git a/README.md b/README.md index 08624db..44a5560 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/models.py b/app/models.py index cd8d720..4b0e42b 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/schedule.py b/app/schedule.py index 7c0effa..f00aa17 100644 --- a/app/schedule.py +++ b/app/schedule.py @@ -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) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index d35e31a..8ea26cd 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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, diff --git a/app/ui/widgets/dashboard.py b/app/ui/widgets/dashboard.py index b7e3d86..38d3d49 100644 --- a/app/ui/widgets/dashboard.py +++ b/app/ui/widgets/dashboard.py @@ -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; }") diff --git a/app/ui/widgets/orders_table.py b/app/ui/widgets/orders_table.py index 0e6c44f..50b1c7a 100644 --- a/app/ui/widgets/orders_table.py +++ b/app/ui/widgets/orders_table.py @@ -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) diff --git a/app/workers.py b/app/workers.py index 9269b82..acdc646 100644 --- a/app/workers.py +++ b/app/workers.py @@ -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 {