Files
madminandClaude Sonnet 5 d8f59b7725 Fix external_shipment_id placement and test-mode store_id ticket persistence in the return-label flow
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]>
2026-10-01 15:53:54 -05:00

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