""" 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 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 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/merges the results.""" finished_ok = pyqtSignal(object) # emits a SaveResult failed = pyqtSignal(str) def __init__(self, service: OrderService, parent=None): super().__init__(parent) self.service = service def run(self) -> None: try: orders = self.service.fetch_orders() except Exception as exc: # noqa: BLE001 - surface any failure to the UI self.failed.emit(str(exc)) return try: 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(result) 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"], Order.external_id == order["external_id"], ) ).scalar_one_or_none() if existing is None: session.add( Order( source=order["source"], external_id=order["external_id"], ticket_number=order.get("ticket_number"), company=order.get("company", "Unknown"), skus=order.get("skus", []), summary=order["summary"], status=order["status"], source_created_at=order["source_created_at"], raw_data=order["raw_data"], ) ) 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 = new_status existing.source_created_at = order["source_created_at"] existing.raw_data = order["raw_data"] updated_count += 1 session.commit() finally: session.close() 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]: session = get_session() try: return list( session.execute(select(Order).order_by(Order.source_created_at.desc())).scalars() ) finally: session.close() 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. """ 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] = {} 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 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, "cancelled_count": cancelled_count, "fulfilled_count": fulfilled_count, "carryover_count": carryover_count, "tracking_received_count": tracking_received_count, }