More Shipstation integrations
This commit is contained in:
+21
-3
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)"])
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user