272 lines
9.7 KiB
Python
272 lines
9.7 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, Tuple, 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.schedule import get_cutoff_time, is_past_cutoff
|
|
from app.services.base import OrderService, NormalizedOrder
|
|
from app.status_rules import parse_status_list, status_in
|
|
from app.tracking import get_fulfilled_statuses
|
|
|
|
|
|
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.
|
|
|
|
Whenever a JIRA ticket's status transitions INTO a fulfilled status
|
|
(Waiting For Return / Device Return Not Needed), fulfilled_at is
|
|
stamped - that's what lets the dashboard show "fulfilled today" and
|
|
what moves it into the Done pile.
|
|
"""
|
|
session = get_session()
|
|
new_count = 0
|
|
updated_count = 0
|
|
enriched_count = 0
|
|
status_changes: List[StatusChange] = []
|
|
unmatched_tracking_tickets: List[str] = []
|
|
fulfilled_statuses = get_fulfilled_statuses()
|
|
|
|
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()
|
|
|
|
new_status = order["status"]
|
|
|
|
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=new_status,
|
|
source_created_at=order["source_created_at"],
|
|
raw_data=order["raw_data"],
|
|
fulfilled_at=(
|
|
dt.datetime.utcnow() if status_in(new_status, fulfilled_statuses) else None
|
|
),
|
|
)
|
|
)
|
|
new_count += 1
|
|
else:
|
|
old_status = existing.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,
|
|
)
|
|
)
|
|
if status_in(new_status, fulfilled_statuses) and not status_in(
|
|
old_status, fulfilled_statuses
|
|
):
|
|
existing.fulfilled_at = dt.datetime.utcnow()
|
|
|
|
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 split_active_and_done(orders: List[Order]) -> Tuple[List[Order], List[Order]]:
|
|
"""
|
|
Active = still being worked (includes cancelled - those still need
|
|
eyes on them). Done = fulfilled (Waiting For Return / Device Return
|
|
Not Needed) - archived out of the working view on purpose, per how
|
|
the team wants to keep the active list focused on what's at hand.
|
|
"""
|
|
fulfilled_statuses = get_fulfilled_statuses()
|
|
active, done = [], []
|
|
for order in orders:
|
|
(done if status_in(order.status, fulfilled_statuses) else active).append(order)
|
|
return active, done
|
|
|
|
|
|
def load_active_and_done_orders() -> Tuple[List[Order], List[Order]]:
|
|
return split_active_and_done(load_all_orders())
|
|
|
|
|
|
def get_dashboard_stats() -> dict:
|
|
"""
|
|
Counts for the dashboard. "Active Orders" and the company breakdown
|
|
reflect only the active workload (fulfilled tickets have moved to
|
|
the Done pile and don't clutter this). Fulfilled-today and
|
|
past-cutoff-today look across ALL of today's tickets regardless of
|
|
which pile they're in now, since both are about what happened today.
|
|
|
|
Carryover counts any still-open ticket that's already known to spill
|
|
into tomorrow - either it's genuinely left over from a prior day, or
|
|
it arrived today but after the cutoff (same effect, just known a day
|
|
earlier).
|
|
"""
|
|
orders = load_all_orders()
|
|
active_orders, _done_orders = split_active_and_done(orders)
|
|
|
|
cancelled_statuses = parse_status_list(
|
|
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
|
|
)
|
|
terminal_statuses = parse_status_list(
|
|
config.get("JIRA_TERMINAL_STATUSES", config.DEFAULT_TERMINAL_STATUSES)
|
|
)
|
|
cutoff = get_cutoff_time()
|
|
today = dt.date.today()
|
|
|
|
by_company: dict[str, int] = {}
|
|
cancelled_count = 0
|
|
carryover_count = 0
|
|
tracking_received_count = 0
|
|
|
|
for order in active_orders:
|
|
by_company[order.company] = by_company.get(order.company, 0) + 1
|
|
|
|
if status_in(order.status, cancelled_statuses):
|
|
cancelled_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
|
|
)
|
|
# A ticket that arrived today but after the cutoff won't get worked
|
|
# today either - it's known carryover before the date even rolls
|
|
# over, not just once tomorrow arrives.
|
|
arrived_past_cutoff_today = bool(
|
|
order.source_created_at
|
|
and order.source_created_at.date() == today
|
|
and is_past_cutoff(order.source_created_at, cutoff)
|
|
)
|
|
if still_open and (created_before_today or arrived_past_cutoff_today):
|
|
carryover_count += 1
|
|
|
|
fulfilled_today_count = sum(
|
|
1 for o in orders if o.fulfilled_at and o.fulfilled_at.date() == today
|
|
)
|
|
past_cutoff_today_count = sum(
|
|
1
|
|
for o in orders
|
|
if o.source_created_at
|
|
and o.source_created_at.date() == today
|
|
and is_past_cutoff(o.source_created_at, cutoff)
|
|
)
|
|
|
|
return {
|
|
"total": len(active_orders),
|
|
"by_company": by_company,
|
|
"cancelled_count": cancelled_count,
|
|
"carryover_count": carryover_count,
|
|
"tracking_received_count": tracking_received_count,
|
|
"fulfilled_today_count": fulfilled_today_count,
|
|
"past_cutoff_today_count": past_cutoff_today_count,
|
|
}
|