More Shipstation integrations

This commit is contained in:
2026-08-31 10:08:30 -05:00
parent ae74f78520
commit 4c172c1fd5
10 changed files with 527 additions and 9 deletions
+21 -3
View File
@@ -19,12 +19,30 @@ JIRA_JQL=project = AR AND created >= startOfDay() ORDER BY created ASC
# Deliverable/SKU-bearing custom fields, per company (comma-separated field IDs)
JIRA_SIGNIFY_SKU_FIELDS=customfield_10573,customfield_10570
JIRA_OAKSTREET_SKU_FIELDS=customfield_12790,customfield_13021
# Recipient/shipping contact fields - shared across both companies' tickets.
# Only used by the emergency "Send to ShipStation" action.
JIRA_FIELD_NAME=customfield_10662
JIRA_FIELD_PHONE=customfield_10424
JIRA_FIELD_EMAIL=customfield_10544
JIRA_FIELD_ADDRESS1=customfield_10654
# Best guess pending confirmation - both "Address 1" and "Address 2" were
# listed with the same label, this assumes the second one is Address 2.
# Verify via double-click -> raw payload on a ticket that has both filled in.
JIRA_FIELD_ADDRESS2=customfield_10655
JIRA_FIELD_CITY=customfield_10480
JIRA_FIELD_STATE=customfield_10560
JIRA_FIELD_ZIP=customfield_10479
JIRA_FIELD_NPI=customfield_10349
# --- ShipStation (API V2) ---
# Used only to pull tracking numbers for JIRA tickets - order/shipment
# numbers there are the same as the JIRA ticket number, so no store
# mapping is needed here.
# Used to pull tracking numbers for JIRA tickets (order/shipment numbers
# there are the same as the JIRA ticket number), and for the emergency
# "Send to ShipStation" action.
SHIPSTATION_API_KEY=
# Store IDs - only needed for the emergency send (creating a new order
# needs to know which store to put it in; pulling tracking numbers doesn't).
SHIPSTATION_SIGNIFY_STORE_ID=se-221889
SHIPSTATION_OAKSTREET_STORE_ID=se-367672
# --- Companies ---
# SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies.
+37
View File
@@ -125,6 +125,43 @@ that doesn't go through this shipment/label flow at all - those
tickets just won't have tracking numbers pulled, which is expected for
now, not a bug. Flag it when you're ready to handle it.
## Emergency: Send to ShipStation
For the rare case a ticket needs to skip the normal daily batch. Select
a ticket in **Active Orders**, click **Send to ShipStation
(Emergency)** in the toolbar, review the address/SKU summary shown,
then pick one:
- **Send via API Now** - calls ShipStation's API directly
(`POST /v2/shipments` with `create_sales_order: true`) to create the
order without leaving the app. Needs `SHIPSTATION_SIGNIFY_STORE_ID` /
`SHIPSTATION_OAKSTREET_STORE_ID` set in Settings.
- **Export CSV Row...** - writes a single-ticket CSV matching your real
upload template exactly (verified column-for-column against a real
export), for manual upload if the API is down.
Both exist on purpose, per "in case the API goes down we still have an
option for CSV uploads."
**Please verify your first live send, either path.** ShipStation's own
docs say automation rules apply tags to orders "when they import based
on any criteria you set" - meaning your 90 box-packing rules should
fire automatically off the SKU/item data, same as a normal CSV import,
with no manual tagging needed from this app. That's the best read of
their documentation, but it's not something testable without your real
account - check that ShipStation packed and priced an emergency-sent
order the way a normal one would before relying on this in an actual
emergency.
A couple of things worth confirming once you've seen real data:
- **Address 2**: two JIRA fields were both labeled "Address 1" when you
listed them (`customfield_10654` and `customfield_10655`) - this
assumes the second one is actually Address 2. Double-click a ticket
that has both filled in and check the Shipping Info section to confirm.
- **Quantity**: always sent as `1` per line item today, since JIRA
doesn't currently give a per-deliverable quantity. Say the word if
that's ever not right.
## How company/SKU extraction works
Each company has its own pair of "deliverable" custom fields in JIRA
+20
View File
@@ -43,7 +43,27 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
False,
),
"JIRA_FIELD_NAME": ("JIRA Field ID: Recipient Name", "JIRA Contact Fields", False),
"JIRA_FIELD_PHONE": ("JIRA Field ID: Phone Number", "JIRA Contact Fields", False),
"JIRA_FIELD_EMAIL": ("JIRA Field ID: Email", "JIRA Contact Fields", False),
"JIRA_FIELD_ADDRESS1": ("JIRA Field ID: Address 1", "JIRA Contact Fields", False),
"JIRA_FIELD_ADDRESS2": ("JIRA Field ID: Address 2", "JIRA Contact Fields", False),
"JIRA_FIELD_CITY": ("JIRA Field ID: City", "JIRA Contact Fields", False),
"JIRA_FIELD_STATE": ("JIRA Field ID: State", "JIRA Contact Fields", False),
"JIRA_FIELD_ZIP": ("JIRA Field ID: Zip Code", "JIRA Contact Fields", False),
"JIRA_FIELD_NPI": ("JIRA Field ID: NPI Number", "JIRA Contact Fields", False),
"SHIPSTATION_API_KEY": ("ShipStation API Key", "ShipStation", True),
"SHIPSTATION_SIGNIFY_STORE_ID": (
"ShipStation Store ID: Signify Health (for emergency sends)",
"ShipStation",
False,
),
"SHIPSTATION_OAKSTREET_STORE_ID": (
"ShipStation Store ID: Oak Street Health (for emergency sends)",
"ShipStation",
False,
),
"COMPANY_SKU_MAP": (
"SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)",
+14
View File
@@ -51,6 +51,20 @@ class Order(Base):
# per business rule they never mix companies on one ticket.
skus = Column(JSON, nullable=True)
# SKU + item name pairs, e.g. [{"sku": "OK704", "item_name": "Peripheral
# - Webcam"}]. Kept separately from `skus` (which stays just the bare
# codes, used for company resolution/search/display) because the
# ShipStation CSV template needs SKU and Item Name as distinct columns.
line_items = Column(JSON, nullable=True)
# Recipient info for shipping - name/phone/email/address/city/state/zip/npi.
# Only needed for the emergency "Send to ShipStation" action; not shown
# in the main table.
shipping_info = Column(JSON, nullable=True)
# Display name of whoever created the JIRA ticket.
creator = Column(String(200), nullable=True)
# Tracking numbers pulled from ShipStation and merged onto this
# ticket - e.g. [{"number": "782758401696", "carrier": "ups",
# "is_return": false}]. Populated by the "Pull Tracking Numbers"
+3
View File
@@ -22,6 +22,9 @@ class NormalizedOrder(TypedDict):
ticket_number: Optional[str]
company: str
skus: List[str]
line_items: List[dict]
shipping_info: dict
creator: Optional[str]
tracking_numbers: List[dict]
summary: str
status: str
+56 -5
View File
@@ -60,6 +60,21 @@ class JiraService(OrderService):
settings["COMPANY_SKU_MAP"] or config.DEFAULT_COMPANY_SKU_MAP
)
# Recipient/shipping contact fields - shared across both companies'
# tickets (unlike the deliverable fields, these aren't split by
# company). Only used by the emergency "Send to ShipStation" action.
self.contact_field_ids = {
"name": settings["JIRA_FIELD_NAME"].strip(),
"phone": settings["JIRA_FIELD_PHONE"].strip(),
"email": settings["JIRA_FIELD_EMAIL"].strip(),
"address1": settings["JIRA_FIELD_ADDRESS1"].strip(),
"address2": settings["JIRA_FIELD_ADDRESS2"].strip(),
"city": settings["JIRA_FIELD_CITY"].strip(),
"state": settings["JIRA_FIELD_STATE"].strip(),
"zip": settings["JIRA_FIELD_ZIP"].strip(),
"npi": settings["JIRA_FIELD_NPI"].strip(),
}
# Statuses at which we stop re-checking a ticket for changes -
# see fetch_orders() for why we re-check at all.
self.terminal_statuses = parse_status_list(
@@ -129,10 +144,14 @@ class JiraService(OrderService):
auth = (self.email, self.api_token)
headers = {"Accept": "application/json"}
# Always pull summary/status/created, plus every configured deliverable field.
fields = "summary,status,created"
# Always pull summary/status/created/creator, plus every configured
# deliverable field and contact field.
fields = "summary,status,created,creator"
if self.all_sku_field_ids:
fields += "," + ",".join(self.all_sku_field_ids)
contact_field_ids = [v for v in self.contact_field_ids.values() if v]
if contact_field_ids:
fields += "," + ",".join(contact_field_ids)
all_issues: List[dict] = []
start_at = 0
@@ -221,26 +240,53 @@ class JiraService(OrderService):
return []
def _extract_skus_and_texts(self, fields: dict) -> Tuple[List[str], List[str]]:
def _extract_skus_and_texts(self, fields: dict) -> Tuple[List[str], List[str], List[dict]]:
"""
Scan every configured deliverable field and return:
- skus: just the code part (e.g. "SH011"), deduped, order preserved
- texts: the full "CODE: description" strings, for use as a
fallback summary when the ticket's actual Summary is blank
(which is the normal case for these order tickets)
- line_items: [{"sku": "SH011", "item_name": "Shipping - Return
Label..."}], one per deliverable - this is what the ShipStation
CSV template's SKU/Item Name columns come from
"""
skus: List[str] = []
texts: List[str] = []
line_items: List[dict] = []
for field_id in self.all_sku_field_ids:
for raw_value in self._raw_values_from_field(fields, field_id):
texts.append(raw_value)
match = DELIVERABLE_CODE_PATTERN.match(raw_value)
code = match.group(1) if match else raw_value
item_name = match.group(2).strip() if match else ""
if code not in skus:
skus.append(code)
line_items.append({"sku": code, "item_name": item_name})
return skus, texts
return skus, texts, line_items
def _extract_single_value(self, fields: dict, field_id: str) -> str:
"""One value from a contact field (name/phone/address/etc.), which -
same as the deliverable fields - can come back as plain text, a
single-select dict, or (rarely) a list. Reuses the same flexible
extraction and just takes the first value."""
if not field_id:
return ""
values = self._raw_values_from_field(fields, field_id)
return values[0] if values else ""
def _extract_shipping_info(self, fields: dict) -> dict:
return {
key: self._extract_single_value(fields, field_id)
for key, field_id in self.contact_field_ids.items()
}
@staticmethod
def _extract_creator(fields: dict) -> str:
creator = fields.get("creator") or {}
return creator.get("displayName") or creator.get("emailAddress") or ""
def _to_normalized_order(self, issue: dict) -> NormalizedOrder:
fields = issue.get("fields", {})
@@ -254,8 +300,10 @@ class JiraService(OrderService):
created_at = None
ticket_number = issue.get("key", "")
skus, deliverable_texts = self._extract_skus_and_texts(fields)
skus, deliverable_texts, line_items = self._extract_skus_and_texts(fields)
company = resolve_company_for_skus(skus, self.sku_map)
shipping_info = self._extract_shipping_info(fields)
creator = self._extract_creator(fields)
# These order tickets typically leave the JIRA Summary field
# blank - fall back to the deliverables so there's still
@@ -268,6 +316,9 @@ class JiraService(OrderService):
ticket_number=ticket_number,
company=company,
skus=skus,
line_items=line_items,
shipping_info=shipping_info,
creator=creator,
tracking_numbers=[],
summary=summary,
status=(fields.get("status") or {}).get("name", ""),
+230
View File
@@ -0,0 +1,230 @@
"""
Emergency "Send to ShipStation" - for the rare case a ticket needs to
skip the normal daily batch and get to ShipStation right away.
Two independent paths, on purpose (per "in case the API goes down we
still have an option for CSV uploads"):
- export_order_to_shipstation_csv(): writes rows matching your real
upload template exactly (columns confirmed against OAK.csv) - one
row per line item, customer/address info repeated on each row.
- send_order_to_shipstation_api(): calls ShipStation's V2 API
directly (POST /v2/shipments with create_sales_order: true) to
create the order without leaving the app.
IMPORTANT - please verify the first real send: ShipStation's docs say
automation rules apply tags to orders "when they import based on any
criteria you set" - meaning your 90 box-packing rules should fire
automatically off the SKU/item data here, same as your CSV import, with
no manual tagging needed. That's the best read of the docs, but it's
not something I can verify without your actual account, so treat the
first live send (API or CSV) as a test: confirm ShipStation packs and
prices it the way a normal order would before trusting it in a real
emergency.
"""
from __future__ import annotations
import csv
import datetime as dt
from pathlib import Path
from typing import List
import requests
from app import config
from app.models import Order
API_BASE = "https://api.shipstation.com/v2"
REQUEST_TIMEOUT_SECONDS = 30
# Column order confirmed against the real ShipStation upload template
# (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and
# is intentionally left out here.
CSV_COLUMNS = [
"Custom field (Email Address)",
"Summary",
"Custom field (Name)",
"Custom field (NPI Number)",
"Custom field (Address 1)",
"Custom field (Address 2)",
"Custom field (City)",
"Custom field (State)",
"Custom field (Zip Code)",
"Issue key",
"Issue id",
"Status",
"deliverables",
"SKU",
"Item Name",
"Quantity",
]
class ShipStationSendError(Exception):
"""Raised for any emergency-send failure, with a message safe to show in the UI."""
def _deliverable_text(sku: str, item_name: str) -> str:
return f"{sku}: {item_name}" if item_name else sku
def build_csv_rows(order: Order) -> List[List[str]]:
"""One row per line item, matching the real template column-for-column.
Falls back to a single row (blank SKU/Item Name) if there are no line
items yet, so the customer/address info is still exportable."""
info = order.shipping_info or {}
line_items = order.line_items or [{"sku": s, "item_name": ""} for s in (order.skus or [])]
if not line_items:
line_items = [{"sku": "", "item_name": ""}]
issue_id = ""
if isinstance(order.raw_data, dict):
issue_id = order.raw_data.get("id", "")
rows = []
for item in line_items:
rows.append(
[
info.get("email", ""),
order.summary,
info.get("name", ""),
info.get("npi", ""),
info.get("address1", ""),
info.get("address2", ""),
info.get("city", ""),
info.get("state", ""),
info.get("zip", ""),
order.ticket_number or order.external_id,
issue_id,
order.status,
_deliverable_text(item.get("sku", ""), item.get("item_name", "")),
item.get("sku", ""),
item.get("item_name", ""),
1, # quantity - not tracked per-item today, defaults to 1 per line
]
)
return rows
def export_order_to_shipstation_csv(orders: List[Order], filepath: str) -> int:
"""Write one or more orders to a CSV in the real upload template shape.
Returns the number of line-item rows written (not the number of orders,
since one order can produce several rows)."""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
row_count = 0
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(CSV_COLUMNS)
for order in orders:
for row in build_csv_rows(order):
writer.writerow(row)
row_count += 1
return row_count
def _store_id_for_company(company: str) -> str:
settings = config.load_settings()
if company == "Signify Health":
return settings.get("SHIPSTATION_SIGNIFY_STORE_ID", "")
if company == "Oak Street Health":
return settings.get("SHIPSTATION_OAKSTREET_STORE_ID", "")
return ""
def send_order_to_shipstation_api(order: Order) -> dict:
"""
Creates the order directly in ShipStation via POST /v2/shipments with
create_sales_order: true, so it lands in the Orders tab and (per
ShipStation's own docs on import automation) should pick up your
box-packing rules the same as a CSV-imported order. Returns the
created shipment's JSON on success.
"""
settings = config.load_settings()
api_key = settings.get("SHIPSTATION_API_KEY", "")
if not api_key:
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
store_id = _store_id_for_company(order.company)
if not store_id:
raise ShipStationSendError(
f"No ShipStation Store ID configured for '{order.company}'. "
"Add it in Settings under ShipStation."
)
info = order.shipping_info or {}
if not (info.get("address1") and info.get("city") and info.get("state") and info.get("zip")):
raise ShipStationSendError(
"This ticket is missing address information (address/city/state/zip) - "
"can't create a shipment without a destination."
)
line_items = order.line_items or [{"sku": s, "item_name": ""} for s in (order.skus or [])]
if not line_items:
raise ShipStationSendError("This ticket has no SKUs/line items to send.")
ticket_number = order.ticket_number or order.external_id
payload = {
"shipments": [
{
"create_sales_order": True,
"store_id": store_id,
"external_shipment_id": ticket_number,
"shipment_number": ticket_number,
"ship_to": {
"name": info.get("name", ""),
"phone": info.get("phone", ""),
"company_name": order.company,
"address_line1": info.get("address1", ""),
"address_line2": info.get("address2", "") or None,
"city_locality": info.get("city", ""),
"state_province": info.get("state", ""),
"postal_code": info.get("zip", ""),
"country_code": "US",
},
"items": [
{
"sku": item.get("sku", ""),
"name": item.get("item_name") or item.get("sku", ""),
"quantity": 1,
}
for item in line_items
],
}
]
}
try:
response = requests.post(
f"{API_BASE}/shipments",
json=payload,
headers={"API-Key": api_key, "Accept": "application/json"},
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
if response.status_code == 401:
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
if not response.ok:
raise ShipStationSendError(
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
)
try:
data = response.json()
except ValueError as exc:
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
shipments = data.get("shipments", [])
if not shipments:
raise ShipStationSendError(f"ShipStation didn't return a created shipment: {data}")
result = shipments[0]
if result.get("errors"):
raise ShipStationSendError(f"ShipStation reported errors: {result['errors']}")
return result
+105 -1
View File
@@ -28,12 +28,18 @@ from PyQt6.QtWidgets import (
from app import config
from app.services import SERVICE_REGISTRY
from app.services.odoo_export import export_orders_to_csv
from app.services.shipstation_send import export_order_to_shipstation_csv
from app.status_rules import parse_status_list, find_new_cancellations
from app.ui.settings_dialog import SettingsDialog
from app.ui.widgets.orders_table import OrdersTableView
from app.ui.widgets.dashboard import DashboardWidget
from app.ui.widgets.order_detail_dialog import OrderDetailDialog
from app.workers import FetchOrdersWorker, load_active_and_done_orders, get_dashboard_stats
from app.workers import (
FetchOrdersWorker,
SendToShipStationWorker,
load_active_and_done_orders,
get_dashboard_stats,
)
class MainWindow(QMainWindow):
@@ -44,6 +50,7 @@ class MainWindow(QMainWindow):
self._workers: dict[str, FetchOrdersWorker] = {}
self._import_actions: dict[str, QAction] = {}
self._send_worker: SendToShipStationWorker | None = None
self._build_ui()
self._refresh_everything()
@@ -90,6 +97,13 @@ class MainWindow(QMainWindow):
toolbar.addSeparator()
emergency_action = QAction("Send to ShipStation (Emergency)", self)
emergency_action.setToolTip("Select a ticket in Active Orders first")
emergency_action.triggered.connect(self._on_emergency_send_clicked)
toolbar.addAction(emergency_action)
toolbar.addSeparator()
settings_action = QAction("Settings", self)
settings_action.triggered.connect(self._on_settings_clicked)
toolbar.addAction(settings_action)
@@ -228,6 +242,96 @@ class MainWindow(QMainWindow):
self.status_label.setText(f"Exported {count} order(s) to {filepath}")
def _on_emergency_send_clicked(self) -> None:
order = self.orders_table.selected_order()
if order is None:
QMessageBox.information(
self,
"No ticket selected",
"Select a ticket in Active Orders first, then click Send to ShipStation.",
)
return
info = order.shipping_info or {}
line_items = order.line_items or []
skus_display = ", ".join(item.get("sku", "") for item in line_items) or (
", ".join(order.skus or []) or "(none)"
)
address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
summary_lines = [
f"Ticket: {order.ticket_number or order.external_id}",
f"Company: {order.company}",
f"Name: {info.get('name') or '(missing)'}",
f"Address: {info.get('address1') or '(missing)'}{address_line2}",
f" {info.get('city', '')}, {info.get('state', '')} {info.get('zip', '')}",
f"SKUs: {skus_display}",
]
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Warning)
box.setWindowTitle("Send to ShipStation")
box.setText("Review before sending - this creates a real order in ShipStation.")
box.setInformativeText("\n".join(summary_lines))
api_button = box.addButton("Send via API Now", QMessageBox.ButtonRole.AcceptRole)
csv_button = box.addButton("Export CSV Row...", QMessageBox.ButtonRole.ActionRole)
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
box.exec()
clicked = box.clickedButton()
if clicked is api_button:
self._send_via_api(order)
elif clicked is csv_button:
self._send_via_csv(order)
def _send_via_api(self, order) -> None:
self.status_label.setText(
f"Sending {order.ticket_number or order.external_id} to ShipStation..."
)
worker = SendToShipStationWorker(order)
worker.finished_ok.connect(self._on_emergency_send_ok)
worker.failed.connect(self._on_emergency_send_failed)
self._send_worker = worker # keep a reference so it isn't garbage collected
worker.start()
def _on_emergency_send_ok(self, result: dict) -> None:
shipment_id = result.get("shipment_id", "?")
self.status_label.setText(f"Sent to ShipStation - shipment {shipment_id} created.")
QMessageBox.information(
self,
"Sent to ShipStation",
f"Order created in ShipStation (shipment {shipment_id}).\n\n"
"This is a live order - please double-check it in ShipStation to confirm "
"automation packed and priced it correctly, especially the first few times "
"you use this.",
)
def _on_emergency_send_failed(self, message: str) -> None:
self.status_label.setText("Send to ShipStation failed.")
QMessageBox.critical(self, "Send to ShipStation failed", message)
def _send_via_csv(self, order) -> None:
default_name = f"shipstation_{order.ticket_number or order.external_id}.csv"
filepath, _ = QFileDialog.getSaveFileName(
self, "Export Ticket to ShipStation CSV", default_name, "CSV Files (*.csv)"
)
if not filepath:
return
try:
count = export_order_to_shipstation_csv([order], filepath)
except OSError as exc:
QMessageBox.critical(self, "Export failed", str(exc))
return
self.status_label.setText(f"Exported {count} row(s) to {filepath}")
QMessageBox.information(
self,
"Exported",
f"Wrote {count} row(s) to:\n{filepath}\n\n"
"Upload this into ShipStation's Import Orders wizard.",
)
def _refresh_everything(self) -> None:
active_orders, done_orders = load_active_and_done_orders()
self.orders_table.set_orders(active_orders)
+10
View File
@@ -35,12 +35,22 @@ class OrderDetailDialog(QDialog):
tracking_lines = [_format_tracking_line(t) for t in tracking]
suggested = suggest_jira_status(tracking)
info = order.shipping_info or {}
address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
summary_lines = [
f"Ticket #: {order.ticket_number or '(none found)'}",
f"Company: {order.company}",
f"Created by: {order.creator or '(unknown)'}",
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
f"Status: {order.status}",
"",
"Shipping info (used by the emergency Send to ShipStation action):",
f" Name: {info.get('name') or '(missing)'}",
f" Address: {info.get('address1') or '(missing)'}{address_line2}",
f" {info.get('city', '')}, {info.get('state', '')} {info.get('zip', '')}".rstrip(),
f" Phone: {info.get('phone') or '(none)'} Email: {info.get('email') or '(none)'}",
"",
"Tracking numbers:",
]
summary_lines.extend(tracking_lines or [" (none pulled yet)"])
+31
View File
@@ -37,6 +37,31 @@ class SaveResult(TypedDict):
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 FetchOrdersWorker(QThread):
"""Fetches orders from a given service and saves/merges the results."""
@@ -121,6 +146,9 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
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"),
summary=order["summary"],
status=new_status,
source_created_at=order["source_created_at"],
@@ -149,6 +177,9 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
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.summary = order["summary"]
existing.status = new_status
existing.source_created_at = order["source_created_at"]