EOD and Carryover tickets added

This commit is contained in:
2026-08-27 15:46:15 -05:00
parent f3c9792117
commit ae74f78520
10 changed files with 224 additions and 59 deletions
+6 -1
View File
@@ -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
+25
View File
@@ -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
+6
View File
@@ -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}"
)
+4
View File
@@ -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)
+38
View File
@@ -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
+15
View File
@@ -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]:
+22 -6
View File
@@ -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."
)
# ---------------------------------------------------------------------------
+17 -13
View File
@@ -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", {})
+18 -15
View File
@@ -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)
+73 -24
View File
@@ -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,
}