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
+117 -28
View File
@@ -2,26 +2,43 @@
Background work that shouldn't run on the GUI thread.
FetchOrdersWorker runs a service's fetch_orders() call off the main
thread and reports back via signals. As we add more long-running
operations (creating ShipStation labels, pushing to Odoo, etc.) they
should follow this same pattern rather than blocking the UI.
thread and reports back via a signal. As we add more long-running
operations (pushing to Odoo, etc.) they should follow this same
pattern rather than blocking the UI.
"""
from __future__ import annotations
from typing import List
import datetime as dt
from typing import List, TypedDict
from PyQt6.QtCore import QThread, pyqtSignal
from sqlalchemy import select
from app import config
from app.database import get_session
from app.models import Order
from app.services.base import OrderService, NormalizedOrder
from app.status_rules import parse_status_list, status_in
class StatusChange(TypedDict):
ticket_number: str
old_status: str
new_status: str
class SaveResult(TypedDict):
new_count: int
updated_count: int
status_changes: List[StatusChange]
enriched_count: int # tickets that got tracking numbers merged in
unmatched_tracking_tickets: List[str] # tracking data with no matching local ticket
class FetchOrdersWorker(QThread):
"""Fetches orders from a given service and saves new/updated ones to the DB."""
"""Fetches orders from a given service and saves/merges the results."""
finished_ok = pyqtSignal(int, int) # (new_count, updated_count)
finished_ok = pyqtSignal(object) # emits a SaveResult
failed = pyqtSignal(str)
def __init__(self, service: OrderService, parent=None):
@@ -36,21 +53,49 @@ class FetchOrdersWorker(QThread):
return
try:
new_count, updated_count = save_orders(orders)
result = save_orders(orders)
except Exception as exc: # noqa: BLE001
self.failed.emit(f"Fetched {len(orders)} orders but failed to save them: {exc}")
return
self.finished_ok.emit(new_count, updated_count)
self.finished_ok.emit(result)
def save_orders(orders: List[NormalizedOrder]) -> tuple[int, int]:
"""Insert new orders / update existing ones (matched by source + external_id)."""
def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
"""
JIRA-sourced orders are inserted/updated as usual. ShipStation-sourced
"orders" are actually just tracking-number bundles keyed by ticket
number - rather than creating a second row, they get merged onto the
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.
"""
session = get_session()
new_count = 0
updated_count = 0
enriched_count = 0
status_changes: List[StatusChange] = []
unmatched_tracking_tickets: List[str] = []
try:
for order in orders:
if order["source"] == "shipstation":
jira_row = session.execute(
select(Order).where(
Order.source == "jira",
Order.ticket_number == order["ticket_number"],
)
).scalar_one_or_none()
if jira_row is None:
unmatched_tracking_tickets.append(order["ticket_number"])
continue
jira_row.tracking_numbers = order.get("tracking_numbers", [])
enriched_count += 1
continue
# source == "jira"
existing = session.execute(
select(Order).where(
Order.source == order["source"],
@@ -74,11 +119,22 @@ def save_orders(orders: List[NormalizedOrder]) -> tuple[int, int]:
)
new_count += 1
else:
old_status = existing.status
new_status = order["status"]
if old_status != new_status:
status_changes.append(
StatusChange(
ticket_number=existing.ticket_number or existing.external_id,
old_status=old_status,
new_status=new_status,
)
)
existing.ticket_number = order.get("ticket_number")
existing.company = order.get("company", "Unknown")
existing.skus = order.get("skus", [])
existing.summary = order["summary"]
existing.status = order["status"]
existing.status = new_status
existing.source_created_at = order["source_created_at"]
existing.raw_data = order["raw_data"]
updated_count += 1
@@ -87,7 +143,13 @@ def save_orders(orders: List[NormalizedOrder]) -> tuple[int, int]:
finally:
session.close()
return new_count, updated_count
return SaveResult(
new_count=new_count,
updated_count=updated_count,
status_changes=status_changes,
enriched_count=enriched_count,
unmatched_tracking_tickets=unmatched_tracking_tickets,
)
def load_all_orders() -> List[Order]:
@@ -102,32 +164,59 @@ def load_all_orders() -> List[Order]:
def get_dashboard_stats() -> dict:
"""
Counts for the dashboard tab: totals by company and by source, plus
how many orders are only in one source so far (imported from JIRA
but not yet seen in ShipStation, or vice versa) - useful as an
at-a-glance "what's still missing" signal since the two sources are
pulled independently.
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.
"""
orders = load_all_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)
)
today = dt.date.today()
by_company: dict[str, int] = {}
by_source: dict[str, int] = {}
tickets_by_source: dict[str, set] = {}
cancelled_count = 0
fulfilled_count = 0
carryover_count = 0
tracking_received_count = 0
for order in orders:
by_company[order.company] = by_company.get(order.company, 0) + 1
by_source[order.source] = by_source.get(order.source, 0) + 1
if order.ticket_number:
tickets_by_source.setdefault(order.source, set()).add(order.ticket_number)
jira_tickets = tickets_by_source.get("jira", set())
shipstation_tickets = tickets_by_source.get("shipstation", set())
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
still_open = not status_in(order.status, terminal_statuses)
created_before_today = bool(
order.source_created_at and order.source_created_at.date() < today
)
if still_open and created_before_today:
carryover_count += 1
return {
"total": len(orders),
"by_company": by_company,
"by_source": by_source,
"jira_only_count": len(jira_tickets - shipstation_tickets),
"shipstation_only_count": len(shipstation_tickets - jira_tickets),
"matched_count": len(jira_tickets & shipstation_tickets),
"cancelled_count": cancelled_count,
"fulfilled_count": fulfilled_count,
"carryover_count": carryover_count,
"tracking_received_count": tracking_received_count,
}