ShipStation tracking pull, status tracking, dashboard rework
This commit is contained in:
+117
-28
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user