ShipStation tracking pull, status tracking, dashboard rework

This commit is contained in:
2026-08-26 11:28:42 -05:00
parent ffaaa0c8c8
commit 18be8588f6
16 changed files with 769 additions and 304 deletions
+22 -23
View File
@@ -1,8 +1,8 @@
"""
Dashboard tab: at-a-glance counts of what's come in.
Kept intentionally simple for now (stat cards, no charts) per the
"just a dashboard for now" scope. The stats themselves come from
Kept intentionally simple (stat cards, no charts) per the "just a
dashboard for now" scope. The stats themselves come from
app.workers.get_dashboard_stats(), so adding a new stat later is a
matter of adding a key there and a card here - this widget doesn't
know anything about how orders are fetched or stored.
@@ -59,31 +59,32 @@ class DashboardWidget(QWidget):
heading.setFont(heading_font)
outer.addWidget(heading)
# Top row: overall + per-source totals
# Top row: overall pipeline health
top_row = QHBoxLayout()
self.total_card = StatCard("Total Orders")
self.jira_card = StatCard("From JIRA")
self.shipstation_card = StatCard("From ShipStation")
for card in (self.total_card, self.jira_card, self.shipstation_card):
self.fulfilled_card = StatCard("Fulfilled")
self.fulfilled_card.setStyleSheet("QLabel { color: #1a7a1a; }")
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):
top_row.addWidget(card)
outer.addLayout(top_row)
# Second row: per-company totals (grid so adding a 3rd company later just works)
# 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)
# Third row: per-company totals (grid so adding a 3rd company later just works)
outer.addWidget(self._section_label("By Company"))
self.company_grid = QGridLayout()
self.company_cards: dict[str, StatCard] = {}
outer.addLayout(self.company_grid)
# Third row: cross-source matching, since JIRA and ShipStation are pulled independently
outer.addWidget(self._section_label("JIRA <-> ShipStation Matching (by ticket #)"))
match_row = QHBoxLayout()
self.matched_card = StatCard("Matched in Both")
self.jira_only_card = StatCard("JIRA Only (not yet in ShipStation)")
self.shipstation_only_card = StatCard("ShipStation Only (no matching ticket)")
for card in (self.matched_card, self.jira_only_card, self.shipstation_only_card):
match_row.addWidget(card)
outer.addLayout(match_row)
outer.addStretch()
@staticmethod
@@ -96,12 +97,10 @@ class DashboardWidget(QWidget):
def update_stats(self, stats: dict) -> None:
self.total_card.set_value(stats.get("total", 0))
self.jira_card.set_value(stats.get("by_source", {}).get("jira", 0))
self.shipstation_card.set_value(stats.get("by_source", {}).get("shipstation", 0))
self.matched_card.set_value(stats.get("matched_count", 0))
self.jira_only_card.set_value(stats.get("jira_only_count", 0))
self.shipstation_only_card.set_value(stats.get("shipstation_only_count", 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.tracking_received_card.set_value(stats.get("tracking_received_count", 0))
by_company = stats.get("by_company", {})
# Rebuild company cards if the set of companies changed (e.g. a 3rd company added)
+23 -6
View File
@@ -1,11 +1,10 @@
"""
Order detail dialog.
Mainly a debugging aid: shows exactly what the source system (JIRA or
ShipStation) sent back for this order, so field-shape mismatches - like
the "Hardware Needed" single-select vs. "Deliverables" multi-select
issue - are easy to spot by just double-clicking a row instead of
reading logs or re-running a script.
Mainly a debugging aid: shows exactly what JIRA sent back for this
ticket (raw payload), plus the tracking numbers ShipStation supplied
and the JIRA status they suggest - handy for the end-of-day close-out
without having to piece it together by hand.
"""
from __future__ import annotations
@@ -14,6 +13,14 @@ import json
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QLabel, QPushButton
from app.models import Order
from app.tracking import suggest_jira_status
def _format_tracking_line(t: dict) -> str:
kind = "RETURN" if t.get("is_return") else "outgoing"
carrier = t.get("carrier")
carrier_suffix = f" ({carrier})" if carrier else ""
return f" {t.get('number')} - {kind}{carrier_suffix}"
class OrderDetailDialog(QDialog):
@@ -24,13 +31,23 @@ class OrderDetailDialog(QDialog):
layout = QVBoxLayout(self)
tracking = order.tracking_numbers or []
tracking_lines = [_format_tracking_line(t) for t in tracking]
suggested = suggest_jira_status(tracking)
summary_lines = [
f"Source: {order.source}",
f"Ticket #: {order.ticket_number or '(none found)'}",
f"Company: {order.company}",
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
f"Status: {order.status}",
"",
"Tracking numbers:",
]
summary_lines.extend(tracking_lines or [" (none pulled yet)"])
if suggested:
summary_lines.append("")
summary_lines.append(f"Suggested JIRA status: {suggested}")
summary_label = QLabel("\n".join(summary_lines))
layout.addWidget(summary_label)
+69 -43
View File
@@ -2,15 +2,18 @@
Table view + model for displaying orders.
Using QAbstractTableModel instead of QTableWidget on purpose: it scales
to thousands of rows, and features like sorting and the company/source
filters below are straightforward extensions of this model rather than
rewrites.
to thousands of rows, and features like sorting and the company filter
below are straightforward extensions of this model rather than
rewrites. There's no "Source" filter - every row is a JIRA ticket;
ShipStation only enriches rows with tracking numbers, it doesn't add
its own.
"""
from __future__ import annotations
from typing import List, Any, Optional
from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import (
QTableView,
QAbstractItemView,
@@ -23,31 +26,60 @@ from PyQt6.QtWidgets import (
QLabel,
)
from app import config
from app.models import Order
from app.status_rules import parse_status_list, status_in
CANCELLED_ROW_COLOR = QColor(255, 210, 210)
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
COLUMNS = [
("company", "Company"),
("ticket_number", "Ticket #"),
("skus_display", "SKUs"),
("source", "Source"),
("external_id", "Source ID"),
("summary", "Summary"),
("status", "Status"),
("tracking_display", "Tracking #"),
("source_created_at", "Created"),
("imported_at", "Imported"),
("fulfilled", "Fulfilled"),
]
ALL_COMPANIES = "All Companies"
ALL_SOURCES = "All Sources"
def _format_tracking(tracking_numbers) -> 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
)
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()
def set_orders(self, orders: List[Order]) -> None:
# Re-read status lists 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.beginResetModel()
self._orders = orders
self.endResetModel()
@@ -66,42 +98,47 @@ class OrdersTableModel(QAbstractTableModel):
return str(section + 1)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
if not index.isValid() or role != Qt.ItemDataRole.DisplayRole:
if not index.isValid():
return None
order = self._orders[index.row()]
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):
return FULFILLED_ROW_COLOR
return None
if role != Qt.ItemDataRole.DisplayRole:
return None
field_name, _ = COLUMNS[index.column()]
if field_name == "skus_display":
return ", ".join(order.skus or [])
if field_name == "tracking_display":
return _format_tracking(order.tracking_numbers)
value = getattr(order, field_name)
if value is None:
return ""
if field_name == "fulfilled":
return "Yes" if value else "No"
return str(value)
return "" if value is None else str(value)
def order_at(self, row: int) -> Order:
return self._orders[row]
class OrdersFilterProxyModel(QSortFilterProxyModel):
"""Filters by company, source, and a free-text search across ticket/SKU/summary."""
"""Filters by company and a free-text search across ticket/SKU/summary/tracking."""
def __init__(self, parent=None):
super().__init__(parent)
self.company_filter: str = ALL_COMPANIES
self.source_filter: str = ALL_SOURCES
self.search_text: str = ""
def set_company_filter(self, company: str) -> None:
self.company_filter = company
self.invalidateFilter()
def set_source_filter(self, source: str) -> None:
self.source_filter = source
self.invalidateFilter()
def set_search_text(self, text: str) -> None:
self.search_text = text.strip().lower()
self.invalidateFilter()
@@ -112,16 +149,15 @@ class OrdersFilterProxyModel(QSortFilterProxyModel):
if self.company_filter != ALL_COMPANIES and order.company != self.company_filter:
return False
if self.source_filter != ALL_SOURCES and order.source != self.source_filter:
return False
if self.search_text:
haystack = " ".join(
[
order.ticket_number or "",
order.external_id or "",
order.summary or "",
order.status or "",
" ".join(order.skus or []),
_format_tracking(order.tracking_numbers),
]
).lower()
if self.search_text not in haystack:
@@ -152,15 +188,9 @@ class OrdersTableView(QWidget):
self.company_combo.currentTextChanged.connect(self._proxy_model.set_company_filter)
filter_bar.addWidget(self.company_combo)
filter_bar.addWidget(QLabel("Source:"))
self.source_combo = QComboBox()
self.source_combo.addItem(ALL_SOURCES)
self.source_combo.currentTextChanged.connect(self._proxy_model.set_source_filter)
filter_bar.addWidget(self.source_combo)
filter_bar.addWidget(QLabel("Search:"))
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Ticket #, SKU, or summary...")
self.search_box.setPlaceholderText("Ticket #, SKU, status, or tracking #...")
self.search_box.textChanged.connect(self._proxy_model.set_search_text)
filter_bar.addWidget(self.search_box, stretch=1)
@@ -187,19 +217,15 @@ class OrdersTableView(QWidget):
self._refresh_filter_options(orders)
def _refresh_filter_options(self, orders: List[Order]) -> None:
for combo, attr, all_label in (
(self.company_combo, "company", ALL_COMPANIES),
(self.source_combo, "source", ALL_SOURCES),
):
current = combo.currentText()
values = sorted({getattr(o, attr) for o in orders if getattr(o, attr)})
combo.blockSignals(True)
combo.clear()
combo.addItem(all_label)
combo.addItems(values)
restore_index = combo.findText(current)
combo.setCurrentIndex(restore_index if restore_index >= 0 else 0)
combo.blockSignals(False)
current = self.company_combo.currentText()
values = sorted({o.company for o in orders if o.company})
self.company_combo.blockSignals(True)
self.company_combo.clear()
self.company_combo.addItem(ALL_COMPANIES)
self.company_combo.addItems(values)
restore_index = self.company_combo.findText(current)
self.company_combo.setCurrentIndex(restore_index if restore_index >= 0 else 0)
self.company_combo.blockSignals(False)
def selected_order(self) -> Optional[Order]:
indexes = self.table.selectionModel().selectedRows()