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
+65 -17
View File
@@ -5,10 +5,10 @@ Deliberately thin: it wires the toolbar, tabs, and dialogs together,
and delegates real work to app.services (fetching), app.workers
(saving/loading/stats), and app.services.odoo_export (file export).
JIRA and ShipStation are pulled independently (separate buttons,
separate workers) per how the business actually operates - they are
correlated afterwards for the dashboard via ticket_number, not forced
into one pipeline.
JIRA import creates/updates ticket rows. "Pull Tracking Numbers"
(ShipStation) doesn't create anything of its own - it merges tracking
numbers onto the matching JIRA row by ticket number, since ShipStation
here is purely a label-generation step for JIRA tickets.
"""
from __future__ import annotations
@@ -25,8 +25,10 @@ from PyQt6.QtWidgets import (
QFileDialog,
)
from app import config
from app.services import SERVICE_REGISTRY
from app.services.odoo_export import export_orders_to_csv
from app.status_rules import parse_status_list, find_new_cancellations
from app.ui.settings_dialog import SettingsDialog
from app.ui.widgets.orders_table import OrdersTableView
from app.ui.widgets.dashboard import DashboardWidget
@@ -71,7 +73,7 @@ class MainWindow(QMainWindow):
toolbar.addAction(jira_action)
self._import_actions["jira"] = jira_action
shipstation_action = QAction("Import from ShipStation", self)
shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self)
shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
toolbar.addAction(shipstation_action)
self._import_actions["shipstation"] = shipstation_action
@@ -121,25 +123,71 @@ class MainWindow(QMainWindow):
action = self._import_actions[service_name]
action.setEnabled(False)
self.status_label.setText(f"Importing orders from {service_name.title()}...")
if service_name == "jira":
self.status_label.setText("Importing orders from JIRA...")
else:
self.status_label.setText("Pulling tracking numbers from ShipStation...")
worker = FetchOrdersWorker(service)
worker.finished_ok.connect(
lambda new_count, updated_count: self._on_import_finished(
service_name, new_count, updated_count
)
lambda result: self._on_import_finished(service_name, result)
)
worker.failed.connect(lambda message: self._on_import_failed(service_name, message))
self._workers[service_name] = worker # keep a reference so it isn't garbage collected
worker.start()
def _on_import_finished(self, service_name: str, new_count: int, updated_count: int) -> None:
def _on_import_finished(self, service_name: str, result: dict) -> None:
self._import_actions[service_name].setEnabled(True)
self.status_label.setText(
f"{service_name.title()} import complete: {new_count} new, {updated_count} updated."
)
self._refresh_everything()
if service_name == "jira":
self.status_label.setText(
f"JIRA import complete: {result['new_count']} new, "
f"{result['updated_count']} updated."
)
self._notify_new_cancellations(result["status_changes"])
else:
self.status_label.setText(
f"Tracking pull complete: {result['enriched_count']} ticket(s) updated with "
f"tracking numbers."
)
self._notify_unmatched_tracking(result["unmatched_tracking_tickets"])
def _notify_unmatched_tracking(self, unmatched_tickets: list) -> None:
if not unmatched_tickets:
return
lines = unmatched_tickets[:15]
if len(unmatched_tickets) > 15:
lines.append(f"...and {len(unmatched_tickets) - 15} more")
QMessageBox.information(
self,
"Some tracking numbers didn't match a known ticket",
"ShipStation had tracking numbers for these ticket numbers, but no matching "
"JIRA ticket was found locally (maybe it hasn't been imported yet):\n\n"
+ "\n".join(lines),
)
def _notify_new_cancellations(self, status_changes: list) -> None:
cancelled_statuses = parse_status_list(
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
)
newly_cancelled = find_new_cancellations(status_changes, cancelled_statuses)
if not newly_cancelled:
return
lines = [
f"{c['ticket_number']}: {c['old_status']} -> {c['new_status']}"
for c in newly_cancelled[:15]
]
if len(newly_cancelled) > 15:
lines.append(f"...and {len(newly_cancelled) - 15} more")
QMessageBox.warning(
self,
"Ticket(s) cancelled",
f"{len(newly_cancelled)} ticket(s) were just cancelled:\n\n" + "\n".join(lines),
)
def _on_import_failed(self, service_name: str, message: str) -> None:
self._import_actions[service_name].setEnabled(True)
self.status_label.setText(f"{service_name.title()} import failed.")
@@ -181,10 +229,10 @@ class MainWindow(QMainWindow):
# - Odoo push API (once ready): add app/services/odoo_service.py with a
# push_orders(orders) method, wire a new toolbar action similarly to
# _on_import_clicked, and it can eventually replace/augment odoo_export.py.
# - Order detail view: connect a table double-click to a dialog showing
# selected_order().raw_data (full JIRA/ShipStation payload) for debugging
# ticket-number/SKU extraction as real data comes in.
# - Pipeline actions ("mark fulfilled", "create ShipStation label"): add
# - Emailed-label SKU: these tickets won't get tracking numbers pulled via
# the normal ShipStation flow - once its handling is defined, it likely
# needs its own terminal status and its own "mark as sent" action here.
# - Pipeline actions ("mark fulfilled manually", "void a label"): add
# toolbar actions gated on table selection.
# - If the window grows too much, split each tab's toolbar into its own
# QToolBar shown only while that tab is active.
+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()