Files
Order-Manager/app/workers.py
T

223 lines
7.3 KiB
Python

"""
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,
}