From ae74f7852077f0609cd78fd15310552b324ca6be Mon Sep 17 00:00:00 2001 From: Sanjay Padole Date: Thu, 27 Aug 2026 15:46:15 -0500 Subject: [PATCH] EOD and Carryover tickets added --- .env.example | 7 ++- README.md | 25 +++++++++ app/config.py | 6 +++ app/models.py | 4 ++ app/schedule.py | 38 +++++++++++++ app/tracking.py | 15 ++++++ app/ui/main_window.py | 28 +++++++--- app/ui/widgets/dashboard.py | 30 ++++++----- app/ui/widgets/orders_table.py | 33 ++++++------ app/workers.py | 97 +++++++++++++++++++++++++--------- 10 files changed, 224 insertions(+), 59 deletions(-) create mode 100644 app/schedule.py diff --git a/.env.example b/.env.example index 6ff1692..b82d75e 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,11 @@ JIRA_TERMINAL_STATUSES=Cancelled,Waiting For Return,Device Return Not Needed # ticket newly transitions into one (comma-separated). CANCELLED_STATUSES=Cancelled # The two statuses your team uses at end-of-day close-out, depending on -# whether ShipStation's automation also generated a return label. +# whether ShipStation's automation also generated a return label. Reaching +# either one moves a ticket to the Done tab and out of the active view. FULFILLED_STATUS_WITH_RETURN=Waiting For Return FULFILLED_STATUS_WITHOUT_RETURN=Device Return Not Needed +# Daily intake cutoff (24h HH:MM). Tickets created after this time on their +# arrival day are flagged "Past Cutoff" in the table and counted on the +# Dashboard - they didn't get a full window to be processed same-day. +INTAKE_CUTOFF_TIME=15:30 diff --git a/README.md b/README.md index c071dc0..91a30e6 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,31 @@ existing tickets rather than duplicating them. Dashboard's Carryover count is just these tickets filtered to "created before today." +## The Done pile + +Once a ticket reaches either fulfilled status, it moves off the +**Active Orders** tab and onto the **Done** tab automatically - the +active view stays focused on what's still being worked. This is a +filter, not a physical move: everything's still the same `orders` +table, split by current status each time the view refreshes, so if a +status ever changed back there'd be nothing to reconcile. Cancelled +tickets stay on Active Orders (still need eyes on them) - only the two +fulfilled statuses trigger the move. The Dashboard's "Active Orders" +count and company breakdown only reflect what's still active; +"Fulfilled Today" and "Arrived Past Cutoff Today" look at all of +today's activity regardless of which tab something ended up on. + +## 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. + ## How tracking numbers get pulled and matched Confirmed against a real ShipStation payload from your queue (not diff --git a/app/config.py b/app/config.py index 8d33a94..b6cad8e 100644 --- a/app/config.py +++ b/app/config.py @@ -76,6 +76,11 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = { "Status Tracking", False, ), + "INTAKE_CUTOFF_TIME": ( + "Daily intake cutoff time, 24h HH:MM (e.g. 15:30 for 3:30 PM)", + "Status Tracking", + False, + ), } DEFAULT_DB_URL = "sqlite:///orders.db" @@ -83,6 +88,7 @@ DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health" DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b" DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return" DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed" +DEFAULT_INTAKE_CUTOFF_TIME = "15:30" DEFAULT_TERMINAL_STATUSES = ( f"Cancelled,{DEFAULT_FULFILLED_WITH_RETURN},{DEFAULT_FULFILLED_WITHOUT_RETURN}" ) diff --git a/app/models.py b/app/models.py index 4dfc547..647a6aa 100644 --- a/app/models.py +++ b/app/models.py @@ -64,6 +64,10 @@ class Order(Base): source_created_at = Column(DateTime, nullable=True) # When we pulled it into this app imported_at = Column(DateTime, nullable=False, default=dt.datetime.utcnow) + # When this ticket first transitioned into a fulfilled status - used + # 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) # 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 new file mode 100644 index 0000000..7c0effa --- /dev/null +++ b/app/schedule.py @@ -0,0 +1,38 @@ +""" +Daily intake-window helpers. + +Intake runs on a schedule (8AM-3:30PM by default), so it's useful to +know when a ticket arrived relative to the cutoff - a ticket created +after INTAKE_CUTOFF_TIME didn't have a full window to get processed +same-day, which helps explain carryover rather than just seeing it as +unexplained backlog. +""" +from __future__ import annotations + +import datetime as dt +from typing import Optional + +from app import config + + +def parse_cutoff_time(raw: str) -> Optional[dt.time]: + raw = (raw or "").strip() + if not raw: + return None + try: + hour_str, minute_str = raw.split(":") + return dt.time(int(hour_str), int(minute_str)) + except (ValueError, TypeError): + return None + + +def get_cutoff_time() -> Optional[dt.time]: + return parse_cutoff_time( + config.get("INTAKE_CUTOFF_TIME", config.DEFAULT_INTAKE_CUTOFF_TIME) + ) + + +def is_past_cutoff(created_at: Optional[dt.datetime], cutoff: Optional[dt.time]) -> bool: + if not created_at or not cutoff: + return False + return created_at.time() > cutoff diff --git a/app/tracking.py b/app/tracking.py index fbf5347..ef5dadf 100644 --- a/app/tracking.py +++ b/app/tracking.py @@ -9,6 +9,21 @@ from __future__ import annotations from typing import List, Optional from app import config +from app.status_rules import parse_status_list + + +def get_fulfilled_statuses() -> set[str]: + """The two statuses that mean 'done, move to the Done pile'.""" + return parse_status_list( + ",".join( + [ + config.get("FULFILLED_STATUS_WITH_RETURN", config.DEFAULT_FULFILLED_WITH_RETURN), + config.get( + "FULFILLED_STATUS_WITHOUT_RETURN", config.DEFAULT_FULFILLED_WITHOUT_RETURN + ), + ] + ) + ) def suggest_jira_status(tracking_numbers: List[dict]) -> Optional[str]: diff --git a/app/ui/main_window.py b/app/ui/main_window.py index d96e210..4da2dcb 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -33,7 +33,7 @@ from app.ui.settings_dialog import SettingsDialog from app.ui.widgets.orders_table import OrdersTableView from app.ui.widgets.dashboard import DashboardWidget from app.ui.widgets.order_detail_dialog import OrderDetailDialog -from app.workers import FetchOrdersWorker, load_all_orders, get_dashboard_stats +from app.workers import FetchOrdersWorker, load_active_and_done_orders, get_dashboard_stats class MainWindow(QMainWindow): @@ -58,8 +58,11 @@ class MainWindow(QMainWindow): self.dashboard = DashboardWidget() self.orders_table = OrdersTableView() self.orders_table.order_double_clicked.connect(self._on_order_double_clicked) + self.done_table = OrdersTableView() + self.done_table.order_double_clicked.connect(self._on_order_double_clicked) self.tabs.addTab(self.dashboard, "Dashboard") - self.tabs.addTab(self.orders_table, "All Orders") + self.tabs.addTab(self.orders_table, "Active Orders") + self.tabs.addTab(self.done_table, "Done") layout.addWidget(self.tabs) self.setCentralWidget(central) @@ -81,6 +84,7 @@ class MainWindow(QMainWindow): toolbar.addSeparator() export_action = QAction("Export Visible Orders to Odoo CSV", self) + export_action.setToolTip("Exports from whichever of Active Orders / Done is open") export_action.triggered.connect(self._on_export_clicked) toolbar.addAction(export_action) @@ -194,7 +198,16 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, f"{service_name.title()} import failed", message) def _on_export_clicked(self) -> None: - orders = self.orders_table.visible_orders() + current_tab = self.tabs.currentWidget() + if not isinstance(current_tab, OrdersTableView): + QMessageBox.information( + self, + "Nothing to export", + "Switch to the Active Orders or Done tab first, then export.", + ) + return + + orders = current_tab.visible_orders() if not orders: QMessageBox.information( self, "Nothing to export", "No orders match the current filters." @@ -216,11 +229,14 @@ class MainWindow(QMainWindow): self.status_label.setText(f"Exported {count} order(s) to {filepath}") def _refresh_everything(self) -> None: - orders = load_all_orders() - self.orders_table.set_orders(orders) + active_orders, done_orders = load_active_and_done_orders() + self.orders_table.set_orders(active_orders) + self.done_table.set_orders(done_orders) self.dashboard.update_stats(get_dashboard_stats()) if not hasattr(self, "_suppress_ready_status"): - self.status_label.setText(f"{len(orders)} order(s) in local database.") + self.status_label.setText( + f"{len(active_orders)} active order(s), {len(done_orders)} done." + ) # --------------------------------------------------------------------------- diff --git a/app/ui/widgets/dashboard.py b/app/ui/widgets/dashboard.py index ab92a92..b7e3d86 100644 --- a/app/ui/widgets/dashboard.py +++ b/app/ui/widgets/dashboard.py @@ -59,28 +59,31 @@ class DashboardWidget(QWidget): heading.setFont(heading_font) outer.addWidget(heading) - # Top row: overall pipeline health + # Top row: active workload health (fulfilled tickets have moved to the Done tab) top_row = QHBoxLayout() - self.total_card = StatCard("Total Orders") - self.fulfilled_card = StatCard("Fulfilled") - self.fulfilled_card.setStyleSheet("QLabel { color: #1a7a1a; }") + self.total_card = StatCard("Active Orders") self.cancelled_card = StatCard("Cancelled") 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; }") - for card in (self.total_card, self.fulfilled_card, self.cancelled_card, self.carryover_card): + self.past_cutoff_card = StatCard("Arrived Past Cutoff Today") + self.past_cutoff_card.setStyleSheet("QLabel { color: #b06a00; }") + for card in (self.total_card, self.cancelled_card, self.carryover_card, self.past_cutoff_card): top_row.addWidget(card) outer.addLayout(top_row) - # Second row: tracking pull progress, since that's the 5PM task - outer.addWidget(self._section_label("Tracking Numbers")) - tracking_row = QHBoxLayout() - self.tracking_received_card = StatCard("Tickets With Tracking Pulled") - tracking_row.addWidget(self.tracking_received_card) - outer.addLayout(tracking_row) + # Second row: today's completions + tracking pull progress + outer.addWidget(self._section_label("Today's Progress")) + progress_row = QHBoxLayout() + self.fulfilled_today_card = StatCard("Fulfilled Today (moved to Done)") + self.fulfilled_today_card.setStyleSheet("QLabel { color: #1a7a1a; }") + self.tracking_received_card = StatCard("Active Tickets With Tracking Pulled") + progress_row.addWidget(self.fulfilled_today_card) + progress_row.addWidget(self.tracking_received_card) + outer.addLayout(progress_row) # Third row: per-company totals (grid so adding a 3rd company later just works) - outer.addWidget(self._section_label("By Company")) + outer.addWidget(self._section_label("Active Orders By Company")) self.company_grid = QGridLayout() self.company_cards: dict[str, StatCard] = {} outer.addLayout(self.company_grid) @@ -97,9 +100,10 @@ class DashboardWidget(QWidget): def update_stats(self, stats: dict) -> None: self.total_card.set_value(stats.get("total", 0)) - self.fulfilled_card.set_value(stats.get("fulfilled_count", 0)) self.cancelled_card.set_value(stats.get("cancelled_count", 0)) self.carryover_card.set_value(stats.get("carryover_count", 0)) + self.past_cutoff_card.set_value(stats.get("past_cutoff_today_count", 0)) + self.fulfilled_today_card.set_value(stats.get("fulfilled_today_count", 0)) self.tracking_received_card.set_value(stats.get("tracking_received_count", 0)) by_company = stats.get("by_company", {}) diff --git a/app/ui/widgets/orders_table.py b/app/ui/widgets/orders_table.py index 7214d13..0e6c44f 100644 --- a/app/ui/widgets/orders_table.py +++ b/app/ui/widgets/orders_table.py @@ -28,7 +28,9 @@ from PyQt6.QtWidgets import ( 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.tracking import get_fulfilled_statuses CANCELLED_ROW_COLOR = QColor(255, 210, 210) FULFILLED_ROW_COLOR = QColor(210, 240, 210) @@ -40,7 +42,8 @@ COLUMNS = [ ("summary", "Summary"), ("status", "Status"), ("tracking_display", "Tracking #"), - ("source_created_at", "Created"), + ("created_display", "Created"), + ("past_cutoff_display", "Past Cutoff"), ] ALL_COMPANIES = "All Companies" @@ -55,31 +58,27 @@ def _format_tracking(tracking_numbers) -> str: ) +def _format_created(source_created_at) -> str: + if not source_created_at: + return "" + return source_created_at.strftime("%b %d, %I:%M %p").replace(" 0", " ") + + class OrdersTableModel(QAbstractTableModel): def __init__(self, orders: List[Order] | None = None, parent=None): super().__init__(parent) self._orders: List[Order] = orders or [] self._cancelled_statuses: set[str] = set() self._fulfilled_statuses: set[str] = set() + self._cutoff_time = None def set_orders(self, orders: List[Order]) -> None: - # Re-read status lists 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( config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES) ) - self._fulfilled_statuses = parse_status_list( - ",".join( - [ - config.get( - "FULFILLED_STATUS_WITH_RETURN", config.DEFAULT_FULFILLED_WITH_RETURN - ), - config.get( - "FULFILLED_STATUS_WITHOUT_RETURN", - config.DEFAULT_FULFILLED_WITHOUT_RETURN, - ), - ] - ) - ) + self._fulfilled_statuses = get_fulfilled_statuses() + self._cutoff_time = get_cutoff_time() self.beginResetModel() self._orders = orders self.endResetModel() @@ -119,6 +118,10 @@ class OrdersTableModel(QAbstractTableModel): return ", ".join(order.skus or []) if field_name == "tracking_display": return _format_tracking(order.tracking_numbers) + 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 "" value = getattr(order, field_name) return "" if value is None else str(value) diff --git a/app/workers.py b/app/workers.py index 0199d34..64bbf53 100644 --- a/app/workers.py +++ b/app/workers.py @@ -9,7 +9,7 @@ pattern rather than blocking the UI. from __future__ import annotations import datetime as dt -from typing import List, TypedDict +from typing import List, Tuple, TypedDict from PyQt6.QtCore import QThread, pyqtSignal from sqlalchemy import select @@ -17,8 +17,10 @@ from sqlalchemy import select from app import config from app.database import get_session from app.models import Order +from app.schedule import get_cutoff_time, is_past_cutoff from app.services.base import OrderService, NormalizedOrder from app.status_rules import parse_status_list, status_in +from app.tracking import get_fulfilled_statuses class StatusChange(TypedDict): @@ -69,6 +71,11 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult: existing JIRA row for that ticket. If no such row exists locally yet, the ticket number is reported back as unmatched instead of silently dropped. + + 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. """ session = get_session() new_count = 0 @@ -76,6 +83,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult: enriched_count = 0 status_changes: List[StatusChange] = [] unmatched_tracking_tickets: List[str] = [] + fulfilled_statuses = get_fulfilled_statuses() try: for order in orders: @@ -103,6 +111,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult: ) ).scalar_one_or_none() + new_status = order["status"] + if existing is None: session.add( Order( @@ -112,15 +122,17 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult: company=order.get("company", "Unknown"), skus=order.get("skus", []), summary=order["summary"], - status=order["status"], + status=new_status, source_created_at=order["source_created_at"], raw_data=order["raw_data"], + fulfilled_at=( + dt.datetime.utcnow() if status_in(new_status, fulfilled_statuses) else None + ), ) ) new_count += 1 else: old_status = existing.status - new_status = order["status"] if old_status != new_status: status_changes.append( StatusChange( @@ -129,6 +141,10 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult: new_status=new_status, ) ) + if status_in(new_status, fulfilled_statuses) and not status_in( + old_status, fulfilled_statuses + ): + existing.fulfilled_at = dt.datetime.utcnow() existing.ticket_number = order.get("ticket_number") existing.company = order.get("company", "Unknown") @@ -162,46 +178,59 @@ def load_all_orders() -> List[Order]: session.close() +def split_active_and_done(orders: List[Order]) -> Tuple[List[Order], List[Order]]: + """ + Active = still being worked (includes cancelled - those still need + eyes on them). Done = fulfilled (Waiting For Return / Device Return + Not Needed) - archived out of the working view on purpose, per how + the team wants to keep the active list focused on what's at hand. + """ + fulfilled_statuses = get_fulfilled_statuses() + active, done = [], [] + for order in orders: + (done if status_in(order.status, fulfilled_statuses) else active).append(order) + return active, done + + +def load_active_and_done_orders() -> Tuple[List[Order], List[Order]]: + return split_active_and_done(load_all_orders()) + + def get_dashboard_stats() -> dict: """ - Counts for the dashboard: totals by company, how many are cancelled, - fulfilled, still-open-from-a-previous-day (carryover), and how many - have had tracking numbers pulled yet. + Counts for the dashboard. "Active Orders" and the company breakdown + reflect only the active workload (fulfilled tickets have moved to + the Done pile and don't clutter this). Fulfilled-today and + past-cutoff-today look across ALL of today's tickets regardless of + which pile they're in now, since both are about what happened today. + + Carryover counts any still-open ticket that's already known to spill + into tomorrow - either it's genuinely left over from a prior day, or + it arrived today but after the cutoff (same effect, just known a day + earlier). """ orders = load_all_orders() + active_orders, _done_orders = split_active_and_done(orders) cancelled_statuses = parse_status_list( config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES) ) - fulfilled_statuses = parse_status_list( - ",".join( - [ - config.get("FULFILLED_STATUS_WITH_RETURN", config.DEFAULT_FULFILLED_WITH_RETURN), - config.get( - "FULFILLED_STATUS_WITHOUT_RETURN", config.DEFAULT_FULFILLED_WITHOUT_RETURN - ), - ] - ) - ) terminal_statuses = parse_status_list( config.get("JIRA_TERMINAL_STATUSES", config.DEFAULT_TERMINAL_STATUSES) ) - + cutoff = get_cutoff_time() today = dt.date.today() by_company: dict[str, int] = {} cancelled_count = 0 - fulfilled_count = 0 carryover_count = 0 tracking_received_count = 0 - for order in orders: + for order in active_orders: by_company[order.company] = by_company.get(order.company, 0) + 1 if status_in(order.status, cancelled_statuses): cancelled_count += 1 - if status_in(order.status, fulfilled_statuses): - fulfilled_count += 1 if order.tracking_numbers: tracking_received_count += 1 @@ -209,14 +238,34 @@ def get_dashboard_stats() -> dict: created_before_today = bool( order.source_created_at and order.source_created_at.date() < today ) - if still_open and created_before_today: + # 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 still_open and (created_before_today or arrived_past_cutoff_today): 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) + ) + return { - "total": len(orders), + "total": len(active_orders), "by_company": by_company, "cancelled_count": cancelled_count, - "fulfilled_count": fulfilled_count, "carryover_count": carryover_count, "tracking_received_count": tracking_received_count, + "fulfilled_today_count": fulfilled_today_count, + "past_cutoff_today_count": past_cutoff_today_count, }