external_shipment_id was at the top level of the POST /v2/labels payload in both
_create_dummy_outbound_label() and create_return_label_from_dummy() - ShipStation's
schema only supports it nested under shipment, so it was silently ignored and
auto-generated ("SEAuto-...") on every label this flow ever created. Moved it inside
the shipment dict in both functions. Confirmed end-to-end on a real production
ticket (AR-166098): Order # now correctly shows the real ticket number, and
ship_from/ship_to are correct for a return.
Also: store_id is now only required in production - ShipStation's sandbox
environment cannot have stores/Order Sources at all (confirmed in the real sandbox
dashboard), so test mode omits it from the request instead of requiring an
impossible value. And three per-ticket write-backs (mark_shipstation_sent,
save_dummy_outbound_label_id, save_pack_data) were hardcoded to source == "jira",
silently no-opping for source == "test" tickets created by Create Test Shipment -
now match on ticket_number alone, since source is only ever "jira" or "test" and
ticket_number is already unique across both.
CLAUDE.md records the full investigation and closes out the long-standing
"return-label flow real-world verification" known-pending item.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
569 lines
21 KiB
Python
569 lines
21 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
|
|
import uuid
|
|
from typing import List, Tuple, TypedDict
|
|
|
|
from PyQt6.QtCore import QThread, pyqtSignal
|
|
from sqlalchemy import select, delete
|
|
|
|
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_today
|
|
from app.services.base import OrderService, NormalizedOrder
|
|
from app.status_rules import get_active_statuses, get_cancelled_statuses, 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 SendToShipStationWorker(QThread):
|
|
"""Runs the emergency ShipStation API send off the GUI thread."""
|
|
|
|
finished_ok = pyqtSignal(dict) # the created shipment's JSON
|
|
failed = pyqtSignal(str)
|
|
|
|
def __init__(self, order, parent=None):
|
|
super().__init__(parent)
|
|
self.order = order
|
|
|
|
def run(self) -> None:
|
|
from app.services.shipstation_send import send_order_to_shipstation_api, ShipStationSendError
|
|
|
|
try:
|
|
result = send_order_to_shipstation_api(self.order)
|
|
except ShipStationSendError as exc:
|
|
self.failed.emit(str(exc))
|
|
return
|
|
except Exception as exc: # noqa: BLE001
|
|
self.failed.emit(f"Unexpected error sending to ShipStation: {exc}")
|
|
return
|
|
|
|
self.finished_ok.emit(result)
|
|
|
|
|
|
class CreateDummyShipmentWorker(QThread):
|
|
"""Runs step 1 (dummy outbound shipment creation) off the GUI thread."""
|
|
|
|
finished_ok = pyqtSignal(dict) # the created dummy label's JSON
|
|
failed = pyqtSignal(str)
|
|
|
|
def __init__(self, order, parent=None):
|
|
super().__init__(parent)
|
|
self.order = order
|
|
|
|
def run(self) -> None:
|
|
from app.services.shipstation_send import create_dummy_shipment, ShipStationSendError
|
|
|
|
try:
|
|
result = create_dummy_shipment(self.order)
|
|
except ShipStationSendError as exc:
|
|
self.failed.emit(str(exc))
|
|
return
|
|
except Exception as exc: # noqa: BLE001
|
|
self.failed.emit(f"Unexpected error creating dummy shipment: {exc}")
|
|
return
|
|
|
|
self.finished_ok.emit(result)
|
|
|
|
|
|
class CreateReturnLabelWorker(QThread):
|
|
"""Runs step 2 (the real return label, from an already-created dummy)
|
|
off the GUI thread."""
|
|
|
|
finished_ok = pyqtSignal(dict) # the created label's JSON
|
|
failed = pyqtSignal(str)
|
|
|
|
def __init__(self, order, dummy_label_id: str, packages: list[dict], charge_event: str, parent=None):
|
|
super().__init__(parent)
|
|
self.order = order
|
|
self.dummy_label_id = dummy_label_id
|
|
self.packages = packages
|
|
self.charge_event = charge_event
|
|
|
|
def run(self) -> None:
|
|
from app.services.shipstation_send import create_return_label_from_dummy, ShipStationSendError
|
|
|
|
try:
|
|
result = create_return_label_from_dummy(
|
|
self.order, self.dummy_label_id, self.packages, self.charge_event
|
|
)
|
|
except ShipStationSendError as exc:
|
|
self.failed.emit(str(exc))
|
|
return
|
|
except Exception as exc: # noqa: BLE001
|
|
self.failed.emit(f"Unexpected error creating return label: {exc}")
|
|
return
|
|
|
|
self.finished_ok.emit(result)
|
|
|
|
|
|
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. cancelled_at works the same way for
|
|
CANCELLED_STATUSES - it's what limits the Cancelled tab to "cancelled
|
|
today" rather than showing every cancelled ticket ever.
|
|
|
|
These get stamped with dt.datetime.now() (LOCAL time), not utcnow() -
|
|
deliberately, since every "is this today" check elsewhere compares
|
|
against dt.date.today() (also local). Mixing the two caused a real
|
|
bug: a ticket cancelled in the evening in a US timezone would get a
|
|
UTC timestamp that had already rolled into tomorrow, failing the
|
|
same-day check immediately and landing on Done instead of Cancelled.
|
|
"""
|
|
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()
|
|
cancelled_statuses = get_cancelled_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", [])
|
|
if order.get("shipping_method"):
|
|
jira_row.shipping_method = order["shipping_method"]
|
|
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", []),
|
|
line_items=order.get("line_items", []),
|
|
shipping_info=order.get("shipping_info", {}),
|
|
creator=order.get("creator"),
|
|
assignee=order.get("assignee"),
|
|
description=order.get("description"),
|
|
tracking_numbers=order.get("tracking_numbers", []),
|
|
summary=order["summary"],
|
|
status=new_status,
|
|
source_created_at=order["source_created_at"],
|
|
raw_data=order["raw_data"],
|
|
fulfilled_at=(
|
|
dt.datetime.now() if status_in(new_status, fulfilled_statuses) else None
|
|
),
|
|
cancelled_at=(
|
|
dt.datetime.now() if status_in(new_status, cancelled_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.now()
|
|
if status_in(new_status, cancelled_statuses) and not status_in(
|
|
old_status, cancelled_statuses
|
|
):
|
|
existing.cancelled_at = dt.datetime.now()
|
|
|
|
existing.ticket_number = order.get("ticket_number")
|
|
existing.company = order.get("company", "Unknown")
|
|
existing.skus = order.get("skus", [])
|
|
existing.line_items = order.get("line_items", [])
|
|
existing.shipping_info = order.get("shipping_info", {})
|
|
existing.creator = order.get("creator")
|
|
existing.assignee = order.get("assignee")
|
|
existing.description = order.get("description")
|
|
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 reset_local_database() -> int:
|
|
"""
|
|
Wipes every locally cached order. This is a rebuildable cache, not a
|
|
system of record - JIRA is - so this is always safe, just requires
|
|
re-running Import from JIRA (and Pull Tracking Numbers) afterward.
|
|
|
|
Also the real fix for one specific situation: a ticket that
|
|
transitioned status under an older, buggy version of this app can end
|
|
up with a permanently-wrong fulfilled_at/cancelled_at timestamp, since
|
|
those are only recalculated ON a transition - re-importing an
|
|
already-transitioned ticket finds "no change" and never touches it
|
|
again. A reset clears that stale timestamp entirely; the fresh
|
|
re-import then stamps everything correctly from scratch.
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
result = session.execute(delete(Order))
|
|
session.commit()
|
|
return result.rowcount or 0
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def _emailed_label_sku_for_company(company: str) -> str:
|
|
"""Picks whichever configured EMAILED_LABEL_SKUS entry actually
|
|
resolves to the given company via COMPANY_SKU_MAP, rather than
|
|
hardcoding SH007/OK012 - stays correct if either setting changes."""
|
|
from app.companies import parse_mapping, resolve_company_by_sku
|
|
from app.return_labels import get_emailed_label_skus
|
|
|
|
sku_map = parse_mapping(config.get("COMPANY_SKU_MAP", config.DEFAULT_COMPANY_SKU_MAP))
|
|
for sku in sorted(get_emailed_label_skus()):
|
|
if resolve_company_by_sku(sku, sku_map) == company:
|
|
return sku.upper()
|
|
return ""
|
|
|
|
|
|
def create_test_shipment_order(company: str) -> Order:
|
|
"""
|
|
Creates a synthetic, non-JIRA Order row carrying that company's
|
|
configured emailed-return-label SKU, purely so the existing "Create
|
|
Return Label" flow (return_label_dialog.py + shipstation_send.py) can
|
|
be exercised end-to-end - dummy shipment, then real return label -
|
|
against a throwaway ticket instead of risking a real customer's.
|
|
|
|
source="test" keeps this completely separate from real JIRA rows:
|
|
save_orders() only ever matches on source == "jira"/"shipstation", so
|
|
Import from JIRA can never touch or overwrite one of these, and
|
|
delete_test_shipments() cleans them up by that same marker.
|
|
|
|
status="Created" so it lands on the Active tab like a real open
|
|
ticket would - that's where staff would naturally go to select it and
|
|
run Create Return Label.
|
|
"""
|
|
sku = _emailed_label_sku_for_company(company)
|
|
if not sku:
|
|
raise ValueError(
|
|
f"No EMAILED_LABEL_SKUS entry resolves to '{company}' via COMPANY_SKU_MAP - "
|
|
"check both settings."
|
|
)
|
|
|
|
ticket_number = f"TEST-EMAIL-{uuid.uuid4().hex[:8].upper()}"
|
|
|
|
order = Order(
|
|
source="test",
|
|
external_id=ticket_number,
|
|
ticket_number=ticket_number,
|
|
company=company,
|
|
skus=[sku],
|
|
line_items=[{"sku": sku, "item_name": "TEST - Emailed Return Label"}],
|
|
shipping_info={
|
|
"name": "TEST ORDER - DO NOT SHIP",
|
|
"phone": "555-555-5555",
|
|
"email": "",
|
|
"address1": "123 Test St",
|
|
"address2": "",
|
|
"city": "Austin",
|
|
"state": "TX",
|
|
"zip": "78701",
|
|
},
|
|
creator="Test Shipment Generator",
|
|
assignee="",
|
|
description=(
|
|
"Synthetic test order created via Data > Create Test Shipment (Email SKU) - "
|
|
"not a real ticket. Safe to delete with Data > Delete Test Shipments."
|
|
),
|
|
summary=f"TEST - Emailed Return Label ({company})",
|
|
status="Created",
|
|
source_created_at=dt.datetime.now(),
|
|
)
|
|
|
|
session = get_session()
|
|
try:
|
|
session.add(order)
|
|
session.commit()
|
|
finally:
|
|
session.close()
|
|
|
|
return order
|
|
|
|
|
|
def delete_test_shipments() -> int:
|
|
"""Removes every synthetic order created by create_test_shipment_order()
|
|
(source == "test") - real JIRA-sourced rows are untouched, since those
|
|
always have source == "jira"."""
|
|
session = get_session()
|
|
try:
|
|
result = session.execute(delete(Order).where(Order.source == "test"))
|
|
session.commit()
|
|
return result.rowcount or 0
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
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_orders_by_view(orders: List[Order]) -> Tuple[List[Order], List[Order], List[Order]]:
|
|
"""
|
|
Three tabs, allowlist-driven:
|
|
- Active: status is in ACTIVE_STATUSES (just "Created" by default) -
|
|
the only tickets that represent real work still to do.
|
|
- Cancelled: status is in CANCELLED_STATUSES AND it was cancelled
|
|
TODAY. A ticket cancelled on a prior day falls through to Done
|
|
instead - the Cancelled tab is meant to be reviewed same-day and
|
|
then filed away, not accumulate forever.
|
|
- Done: everything else. This deliberately doesn't enumerate every
|
|
"finished" status by name - a JIRA-side automation status like
|
|
"1st Contact Attempt" falls in here automatically just by not
|
|
being Created or (today's) Cancelled, with no code change needed
|
|
when your JIRA workflow adds another downstream status later.
|
|
"""
|
|
active_statuses = get_active_statuses()
|
|
cancelled_statuses = get_cancelled_statuses()
|
|
today = dt.date.today()
|
|
active, cancelled, done = [], [], []
|
|
for order in orders:
|
|
if status_in(order.status, active_statuses):
|
|
active.append(order)
|
|
elif (
|
|
status_in(order.status, cancelled_statuses)
|
|
and order.cancelled_at
|
|
and order.cancelled_at.date() == today
|
|
):
|
|
cancelled.append(order)
|
|
else:
|
|
done.append(order)
|
|
return active, cancelled, done
|
|
|
|
|
|
def load_orders_by_view() -> Tuple[List[Order], List[Order], List[Order]]:
|
|
return split_orders_by_view(load_all_orders())
|
|
|
|
|
|
def mark_shipstation_sent(ticket_number: str) -> None:
|
|
"""Stamps shipstation_sent_at after a CONFIRMED emergency API send -
|
|
called once ShipStation's own response confirms creation succeeded.
|
|
|
|
Matches by ticket_number alone, not source == "jira" - source is only
|
|
ever "jira" or "test" (synthetic tickets from create_test_shipment_order()),
|
|
and ticket_number is already unique across both, so restricting to "jira"
|
|
here just means this silently no-ops for test tickets instead of erroring."""
|
|
session = get_session()
|
|
try:
|
|
order = session.execute(
|
|
select(Order).where(Order.ticket_number == ticket_number)
|
|
).scalar_one_or_none()
|
|
if order is not None:
|
|
order.shipstation_sent_at = dt.datetime.now()
|
|
session.commit()
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def save_dummy_outbound_label_id(ticket_number: str, label_id: str) -> None:
|
|
"""Persists step 1's result (the dummy shipment's label_id) so step 2
|
|
(the real return label) can be triggered separately - including in a
|
|
later session, after the dummy has been verified in ShipStation.
|
|
|
|
Matches by ticket_number alone - see mark_shipstation_sent() above for why
|
|
this must not be restricted to source == "jira"."""
|
|
session = get_session()
|
|
try:
|
|
order = session.execute(
|
|
select(Order).where(Order.ticket_number == ticket_number)
|
|
).scalar_one_or_none()
|
|
if order is not None:
|
|
order.dummy_outbound_label_id = label_id
|
|
session.commit()
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def save_pack_data(ticket_number: str, serial_numbers: dict, packed: bool) -> None:
|
|
"""
|
|
Saves serial numbers and the packed/done flag from the Pack Ticket
|
|
dialog. Staff-entered data, not sourced from JIRA - this is the
|
|
beginning of the eventual end-of-day push back to JIRA (deferred for
|
|
now), so nothing here gets overwritten by a JIRA re-import.
|
|
|
|
Matches by ticket_number alone, not source == "jira" - see
|
|
mark_shipstation_sent() for why.
|
|
"""
|
|
session = get_session()
|
|
try:
|
|
order = session.execute(
|
|
select(Order).where(Order.ticket_number == ticket_number)
|
|
).scalar_one_or_none()
|
|
if order is None:
|
|
return
|
|
order.serial_numbers = serial_numbers
|
|
was_packed = order.packed
|
|
order.packed = packed
|
|
if packed and not was_packed:
|
|
order.packed_at = dt.datetime.now()
|
|
elif not packed:
|
|
order.packed_at = None
|
|
session.commit()
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def get_dashboard_stats() -> dict:
|
|
"""
|
|
Counts for the dashboard. "Active Orders" and the company breakdown
|
|
reflect only the Active tab (status in ACTIVE_STATUSES) - the actual
|
|
at-hand workload. Cancelled counts the Cancelled tab. Fulfilled-today
|
|
and past-cutoff-today look across ALL of today's tickets regardless
|
|
of which tab they ended up in, since both are about what happened
|
|
today specifically.
|
|
|
|
Carryover counts any Active 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). Since we're only looking at the Active bucket, every
|
|
ticket here is by definition still unresolved - no separate "is it
|
|
still open" check needed.
|
|
"""
|
|
orders = load_all_orders()
|
|
active_orders, cancelled_orders, _done_orders = split_orders_by_view(orders)
|
|
|
|
cutoff = get_cutoff_time()
|
|
today = dt.date.today()
|
|
|
|
by_company: dict[str, int] = {}
|
|
carryover_count = 0
|
|
tracking_received_count = 0
|
|
|
|
for order in active_orders:
|
|
by_company[order.company] = by_company.get(order.company, 0) + 1
|
|
|
|
if order.tracking_numbers:
|
|
tracking_received_count += 1
|
|
|
|
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.
|
|
if created_before_today or is_past_cutoff_today(order.source_created_at, cutoff):
|
|
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 is_past_cutoff_today(o.source_created_at, cutoff)
|
|
)
|
|
|
|
return {
|
|
"total": len(active_orders),
|
|
"by_company": by_company,
|
|
"cancelled_count": len(cancelled_orders),
|
|
"carryover_count": carryover_count,
|
|
"tracking_received_count": tracking_received_count,
|
|
"fulfilled_today_count": fulfilled_today_count,
|
|
"past_cutoff_today_count": past_cutoff_today_count,
|
|
}
|