From 4c172c1fd5f6e077f7e2a6065b5027c54f37963f Mon Sep 17 00:00:00 2001 From: Sanjay Padole Date: Mon, 31 Aug 2026 10:08:30 -0500 Subject: [PATCH] More Shipstation integrations --- .env.example | 24 ++- README.md | 37 +++++ app/config.py | 20 +++ app/models.py | 14 ++ app/services/base.py | 3 + app/services/jira_service.py | 61 ++++++- app/services/shipstation_send.py | 230 ++++++++++++++++++++++++++ app/ui/main_window.py | 106 +++++++++++- app/ui/widgets/order_detail_dialog.py | 10 ++ app/workers.py | 31 ++++ 10 files changed, 527 insertions(+), 9 deletions(-) create mode 100644 app/services/shipstation_send.py diff --git a/.env.example b/.env.example index b82d75e..8daff31 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index 91a30e6..ce80607 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/config.py b/app/config.py index b6cad8e..833ba0a 100644 --- a/app/config.py +++ b/app/config.py @@ -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)", diff --git a/app/models.py b/app/models.py index 647a6aa..cd8d720 100644 --- a/app/models.py +++ b/app/models.py @@ -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" diff --git a/app/services/base.py b/app/services/base.py index 07f7450..1b404da 100644 --- a/app/services/base.py +++ b/app/services/base.py @@ -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 diff --git a/app/services/jira_service.py b/app/services/jira_service.py index 5e2056e..ea64020 100644 --- a/app/services/jira_service.py +++ b/app/services/jira_service.py @@ -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", ""), diff --git a/app/services/shipstation_send.py b/app/services/shipstation_send.py new file mode 100644 index 0000000..7ddd83b --- /dev/null +++ b/app/services/shipstation_send.py @@ -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 diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 4da2dcb..5278659 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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) diff --git a/app/ui/widgets/order_detail_dialog.py b/app/ui/widgets/order_detail_dialog.py index be5306f..1b06e75 100644 --- a/app/ui/widgets/order_detail_dialog.py +++ b/app/ui/widgets/order_detail_dialog.py @@ -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)"]) diff --git a/app/workers.py b/app/workers.py index 64bbf53..8db59c6 100644 --- a/app/workers.py +++ b/app/workers.py @@ -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"]