From 826a70bdf668e64c70814962144be638f5a56e22 Mon Sep 17 00:00:00 2001 From: Sanjay Padole Date: Wed, 2 Sep 2026 09:30:19 -0500 Subject: [PATCH] EMailed label return generation (alpha) --- .env.example | 38 +++++++ README.md | 53 ++++++++- app/adf.py | 45 ++++++++ app/config.py | 39 +++++++ app/models.py | 7 ++ app/return_labels.py | 22 ++++ app/services/base.py | 1 + app/services/jira_service.py | 5 +- app/services/shipstation_send.py | 156 ++++++++++++++++++++++++++ app/ui/main_window.py | 71 ++++++++++++ app/ui/widgets/orders_table.py | 4 + app/ui/widgets/return_label_dialog.py | 138 +++++++++++++++++++++++ app/workers.py | 29 +++++ 13 files changed, 603 insertions(+), 5 deletions(-) create mode 100644 app/adf.py create mode 100644 app/return_labels.py create mode 100644 app/ui/widgets/return_label_dialog.py diff --git a/.env.example b/.env.example index 2a05fe4..9318b8b 100644 --- a/.env.example +++ b/.env.example @@ -82,3 +82,41 @@ FULFILLED_STATUS_WITHOUT_RETURN=Device Return Not Needed # arrival day are flagged "Past Cutoff" in the table and counted on the # Dashboard - they didn't get a full window to be processed same-day. INTAKE_CUTOFF_TIME=15:30 + +# --- Return Labels (SH007 / OK012 - emailed return labels) --- +# SKUs that mean "create + email a return label" instead of a normal outbound kit. +EMAILED_LABEL_SKUS=SH007,OK012 + +# Each company has its own UPS account even though they share one warehouse. +SIGNIFY_RETURN_CARRIER_ID=se-350817 +# Assumption pending your confirmation - you gave the carrier/account but not +# a specific service level. Defaulting to UPS Ground; change if you use +# something else for returns. See ShipStation's carrier services list for +# other valid codes (e.g. ups_2nd_day_air, ups_ground_saver). +SIGNIFY_RETURN_SERVICE_CODE=ups_ground +OAKSTREET_RETURN_CARRIER_ID=se-599657 +OAKSTREET_RETURN_SERVICE_CODE=ups_ground + +# When you're charged for a return label: on_creation / on_carrier_acceptance +# (only charged if the customer actually uses it - needs the carrier to have +# enabled this on your account first) / carrier_default. +SHIPSTATION_RETURN_CHARGE_EVENT=carrier_default + +# Both companies return to the same warehouse, just under their own name - +# confirmed real address below. (Occasional shipments to company HQ instead +# of the warehouse aren't handled yet - flagged for later.) +SIGNIFY_RETURN_NAME=Signify Health +SIGNIFY_RETURN_PHONE= +SIGNIFY_RETURN_ADDRESS1=1000 Spinks Road Suite 100 +SIGNIFY_RETURN_ADDRESS2= +SIGNIFY_RETURN_CITY=Lewisville +SIGNIFY_RETURN_STATE=TX +SIGNIFY_RETURN_ZIP=75067 +OAKSTREET_RETURN_NAME=Oak Street Health +OAKSTREET_RETURN_PHONE= +OAKSTREET_RETURN_ADDRESS1=1000 Spinks Road Suite 100 +OAKSTREET_RETURN_ADDRESS2= +OAKSTREET_RETURN_CITY=Lewisville +OAKSTREET_RETURN_STATE=TX +OAKSTREET_RETURN_ZIP=75067 + diff --git a/README.md b/README.md index 1a55740..01fdf32 100644 --- a/README.md +++ b/README.md @@ -276,9 +276,54 @@ app/ `_on_export_clicked` calls `export_orders_to_csv`. 3. Wire it up the same way the JIRA/ShipStation buttons are. +## Emailed return labels (SH007 / OK012) + +A different workflow entirely from the normal outbound-kit tickets: a +customer already has product to return, and needs a return label +emailed to them. Select a ticket in Active Orders and click **Create +Return Label**: + +- Shows the ticket's **Description** (parsed from JIRA's rich-text + format into plain text) - this is where staff note what boxes are + needed, so it's visible without opening JIRA. +- Lets you specify **any number of packages**, each with its own + weight and dimensions - replacing the old fixed "1x1x1, 1oz dummy + ticket" with real per-request control. +- Calls ShipStation directly (`POST /v2/labels` with + `is_return_label: true`). Confirmed against ShipStation's own + return-label docs: **`ship_from` is the customer and `ship_to` is + your warehouse** - reversed from every other label this app creates, + and easy to get backwards, so this was tested explicitly. +- `charge_event` is configurable per your risk preference: + `on_creation` (pay immediately), `on_carrier_acceptance` (only pay if + the customer actually ships it - needs the carrier to enable this on + your account first, can take 3-4 weeks), or `carrier_default`. + +**This app does not email the label.** Per your workflow, that +happens from ShipStation itself (Returns tab -> Other Actions -> Send +Return Label) so it goes out through your branded template - something +this app couldn't replicate anyway, since ShipStation doesn't expose +that step through its API as far as I could find. After a label is +created, the ticket's **Uploaded** checkmark is set (same flag the +emergency-send feature uses) so you can see at a glance which +return-label tickets have already had their label created. + +Both companies share one physical warehouse but each has its **own +UPS account** (`SIGNIFY_RETURN_CARRIER_ID` / `OAKSTREET_RETURN_CARRIER_ID`) +- the return address is the same, just filed under the right company +name. Occasional shipments to company HQ instead of the shared +warehouse aren't handled yet - flagged for later, per your note. + +**Still an assumption pending confirmation:** the service code +(`SIGNIFY_RETURN_SERVICE_CODE` / `OAKSTREET_RETURN_SERVICE_CODE`) +defaults to `ups_ground` since you gave me the carrier/account but not +a specific service level - change it in Settings if that's not right. + ## Known open item -The **emailed-label SKU** (mentioned but not detailed yet) needs its -own handling eventually - tell me the SKU and what "done" looks like -for it when you're ready, and I'll fold it into the terminal-status / -tracking logic above rather than bolting on something separate. +You mentioned occasionally shipping return items to a company's +headquarters instead of the shared warehouse - noted, not built yet +since you said we could tackle it later. When you're ready, this would +likely be a dropdown in the Return Label dialog (Warehouse vs. HQ) +rather than a new settings group, since the workflow is otherwise +identical. diff --git a/app/adf.py b/app/adf.py new file mode 100644 index 0000000..45678be --- /dev/null +++ b/app/adf.py @@ -0,0 +1,45 @@ +""" +JIRA's Description field comes back as Atlassian Document Format (ADF) - +a nested JSON tree, not plain text. This walks it and extracts readable +text, since all we need here is "what did the staff member write about +what boxes are needed", not full document fidelity. + +Degrades gracefully on node types it doesn't know (tables, mentions, +emoji, etc.) rather than raising - a partially-extracted description is +far more useful than a crash on an edge case we didn't anticipate. +""" +from __future__ import annotations + +from typing import Optional + +# Block-level node types that should end with a line break once their +# content has been extracted, so paragraphs/list items don't run together. +_BLOCK_TYPES = {"paragraph", "listItem", "heading", "codeBlock", "blockquote"} + + +def adf_to_text(adf: Optional[dict]) -> str: + if not isinstance(adf, dict): + return "" + + lines: list[str] = [] + _walk(adf, lines) + # Collapse the accumulated fragments, trim stray blank lines from + # nested block boundaries. + text = "".join(lines) + return "\n".join(line.rstrip() for line in text.split("\n")).strip() + + +def _walk(node: dict, lines: list[str]) -> None: + node_type = node.get("type") + + if node_type == "text": + lines.append(node.get("text", "")) + elif node_type == "hardBreak": + lines.append("\n") + + for child in node.get("content", []) or []: + if isinstance(child, dict): + _walk(child, lines) + + if node_type in _BLOCK_TYPES: + lines.append("\n") diff --git a/app/config.py b/app/config.py index 0156c22..cefbef7 100644 --- a/app/config.py +++ b/app/config.py @@ -86,6 +86,43 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = { False, ), + "EMAILED_LABEL_SKUS": ( + "SKUs that mean 'create + email a return label' (comma-separated)", + "Return Labels", + False, + ), + "SIGNIFY_RETURN_CARRIER_ID": ("Signify Return: ShipStation Carrier ID", "Return Labels", False), + "SIGNIFY_RETURN_SERVICE_CODE": ( + "Signify Return: Service code (e.g. ups_ground)", + "Return Labels", + False, + ), + "OAKSTREET_RETURN_CARRIER_ID": ("Oak Street Return: ShipStation Carrier ID", "Return Labels", False), + "OAKSTREET_RETURN_SERVICE_CODE": ( + "Oak Street Return: Service code (e.g. ups_ground)", + "Return Labels", + False, + ), + "SHIPSTATION_RETURN_CHARGE_EVENT": ( + "When to be charged: on_creation / on_carrier_acceptance / carrier_default", + "Return Labels", + False, + ), + "SIGNIFY_RETURN_NAME": ("Signify Return Address: Name", "Return Labels", False), + "SIGNIFY_RETURN_PHONE": ("Signify Return Address: Phone", "Return Labels", False), + "SIGNIFY_RETURN_ADDRESS1": ("Signify Return Address: Address 1", "Return Labels", False), + "SIGNIFY_RETURN_ADDRESS2": ("Signify Return Address: Address 2", "Return Labels", False), + "SIGNIFY_RETURN_CITY": ("Signify Return Address: City", "Return Labels", False), + "SIGNIFY_RETURN_STATE": ("Signify Return Address: State", "Return Labels", False), + "SIGNIFY_RETURN_ZIP": ("Signify Return Address: Zip", "Return Labels", False), + "OAKSTREET_RETURN_NAME": ("Oak Street Return Address: Name", "Return Labels", False), + "OAKSTREET_RETURN_PHONE": ("Oak Street Return Address: Phone", "Return Labels", False), + "OAKSTREET_RETURN_ADDRESS1": ("Oak Street Return Address: Address 1", "Return Labels", False), + "OAKSTREET_RETURN_ADDRESS2": ("Oak Street Return Address: Address 2", "Return Labels", False), + "OAKSTREET_RETURN_CITY": ("Oak Street Return Address: City", "Return Labels", False), + "OAKSTREET_RETURN_STATE": ("Oak Street Return Address: State", "Return Labels", False), + "OAKSTREET_RETURN_ZIP": ("Oak Street Return Address: Zip", "Return Labels", False), + "ACTIVE_STATUSES": ( "Statuses that count as real active work (comma-separated) - " "everything else is treated as done", @@ -122,6 +159,8 @@ DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed" DEFAULT_INTAKE_CUTOFF_TIME = "15:30" DEFAULT_ACTIVE_STATUSES = "Created" DEFAULT_CANCELLED_STATUSES = "Cancelled" +DEFAULT_EMAILED_LABEL_SKUS = "SH007,OK012" +DEFAULT_RETURN_CHARGE_EVENT = "carrier_default" def ensure_env_file_exists() -> None: diff --git a/app/models.py b/app/models.py index 78634d0..5a29f43 100644 --- a/app/models.py +++ b/app/models.py @@ -15,6 +15,7 @@ from sqlalchemy import ( Column, Integer, String, + Text, DateTime, Boolean, JSON, @@ -65,6 +66,12 @@ class Order(Base): # Display name of whoever created the JIRA ticket. creator = Column(String(200), nullable=True) + # Plain-text version of the JIRA ticket's Description field (parsed + # from Atlassian Document Format - see app/adf.py). Mainly useful for + # the emailed-return-label workflow, where staff write the box + # requirements here rather than in a structured field. + description = Column(Text, 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/return_labels.py b/app/return_labels.py new file mode 100644 index 0000000..ed9481e --- /dev/null +++ b/app/return_labels.py @@ -0,0 +1,22 @@ +""" +The emailed-return-label SKUs (SH007 for Signify, OK012 for Oak Street) +mark a ticket as a different kind of request entirely: not an outbound +kit, but a return label to be created and emailed to a client who +already has product to send back. See app/services/shipstation_send.py +for the label-creation side of this. +""" +from __future__ import annotations + +from app import config +from app.status_rules import parse_status_list + + +def get_emailed_label_skus() -> set[str]: + return parse_status_list( + config.get("EMAILED_LABEL_SKUS", config.DEFAULT_EMAILED_LABEL_SKUS) + ) + + +def is_emailed_label_order(skus: list[str]) -> bool: + emailed_skus = get_emailed_label_skus() + return any((sku or "").strip().lower() in emailed_skus for sku in skus or []) diff --git a/app/services/base.py b/app/services/base.py index 1b404da..cd7bcae 100644 --- a/app/services/base.py +++ b/app/services/base.py @@ -25,6 +25,7 @@ class NormalizedOrder(TypedDict): line_items: List[dict] shipping_info: dict creator: Optional[str] + description: 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 588a651..2fe6e0e 100644 --- a/app/services/jira_service.py +++ b/app/services/jira_service.py @@ -16,6 +16,7 @@ from typing import List, Tuple import requests from app import config +from app.adf import adf_to_text from app.companies import parse_mapping, resolve_company_for_skus from app.queries import get_open_ticket_numbers from app.services.base import OrderService, NormalizedOrder @@ -164,7 +165,7 @@ class JiraService(OrderService): # Always pull summary/status/created/creator, plus every configured # deliverable field and contact field. - fields = "summary,status,created,creator" + fields = "summary,status,created,creator,description" 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] @@ -338,6 +339,7 @@ class JiraService(OrderService): company = resolve_company_for_skus(skus, self.sku_map) shipping_info = self._extract_shipping_info(fields) creator = self._extract_creator(fields) + description = adf_to_text(fields.get("description")) # These order tickets typically leave the JIRA Summary field # blank - fall back to the deliverables so there's still @@ -353,6 +355,7 @@ class JiraService(OrderService): line_items=line_items, shipping_info=shipping_info, creator=creator, + description=description, 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 index 515954b..9f540fd 100644 --- a/app/services/shipstation_send.py +++ b/app/services/shipstation_send.py @@ -255,3 +255,159 @@ def send_order_to_shipstation_api(order: Order) -> dict: raise ShipStationSendError(f"ShipStation reported errors: {result['errors']}") return result + + +# --- Emailed return labels (SH007 / OK012) -------------------------------- +# +# A genuinely different shape from the outbound send above: for a return +# label, ship_from is the CUSTOMER and ship_to is YOUR warehouse/return +# center (confirmed against ShipStation's own return-labels docs - this is +# reversed from every other label this app creates). This also doesn't go +# through create_sales_order + automation, since there's no outbound side +# to it - carrier/service are specified directly. +# +# After creation, this app does NOT email the label - per your workflow, +# that's done from ShipStation itself (Returns tab -> Other Actions -> +# Send Return Label) so it goes out through your branded email template. + +VALID_CHARGE_EVENTS = {"on_creation", "on_carrier_acceptance", "carrier_default"} + + +def _return_address_for_company(company: str) -> dict: + settings = config.load_settings() + prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_" + return { + "name": settings.get(f"{prefix}NAME", ""), + "phone": settings.get(f"{prefix}PHONE", ""), + "address_line1": settings.get(f"{prefix}ADDRESS1", ""), + "address_line2": settings.get(f"{prefix}ADDRESS2", "") or None, + "city_locality": settings.get(f"{prefix}CITY", ""), + "state_province": settings.get(f"{prefix}STATE", ""), + "postal_code": settings.get(f"{prefix}ZIP", ""), + "country_code": "US", + } + + +def _return_carrier_for_company(company: str) -> tuple[str, str]: + """Each company has its own UPS account (different carrier_id) even + though they share a warehouse - matches how store/warehouse IDs are + already split per company elsewhere in this app.""" + settings = config.load_settings() + prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_" + return settings.get(f"{prefix}CARRIER_ID", ""), settings.get(f"{prefix}SERVICE_CODE", "") + + +def _build_return_package(weight_oz: float, length: float, width: float, height: float) -> dict: + package = {"weight": {"value": weight_oz, "unit": "ounce"}} + if length > 0 and width > 0 and height > 0: + package["dimensions"] = { + "length": length, + "width": width, + "height": height, + "unit": "inch", + } + return package + + +def create_return_label(order: Order, packages: List[dict], charge_event: str | None = None) -> dict: + """ + packages: [{"weight_oz": 1.0, "length": 0, "width": 0, "height": 0}, ...] + - one entry per physical box the customer will use, matching your + current "dummy ticket" approach but with real, per-box control + instead of always defaulting to 1x1x1/1oz. + """ + 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.") + + carrier_id, service_code = _return_carrier_for_company(order.company) + if not carrier_id or not service_code: + raise ShipStationSendError( + f"No return-label Carrier ID/Service Code configured for '{order.company}'. " + "Add them in Settings under Return Labels." + ) + + return_address = _return_address_for_company(order.company) + if not ( + return_address["address_line1"] + and return_address["city_locality"] + and return_address["state_province"] + and return_address["postal_code"] + ): + raise ShipStationSendError( + f"No return warehouse address configured for '{order.company}'. " + "Add it in Settings under Return Labels." + ) + + 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 the customer's address information - " + "can't create a return label without knowing where it ships from." + ) + + if not packages: + raise ShipStationSendError("At least one package is required.") + + resolved_charge_event = charge_event or settings.get( + "SHIPSTATION_RETURN_CHARGE_EVENT", config.DEFAULT_RETURN_CHARGE_EVENT + ) + if resolved_charge_event not in VALID_CHARGE_EVENTS: + raise ShipStationSendError( + f"charge_event must be one of {sorted(VALID_CHARGE_EVENTS)}, got " + f"'{resolved_charge_event}'." + ) + + payload = { + "is_return_label": True, + "charge_event": resolved_charge_event, + "shipment": { + "carrier_id": carrier_id, + "service_code": service_code, + "ship_to": return_address, + "ship_from": { + "name": info.get("name", ""), + "phone": info.get("phone", ""), + "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", + }, + "packages": [ + _build_return_package( + p.get("weight_oz", 1.0), p.get("length", 0), p.get("width", 0), p.get("height", 0) + ) + for p in packages + ], + }, + } + + try: + response = requests.post( + f"{API_BASE}/labels", + 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 + + if data.get("errors"): + raise ShipStationSendError(f"ShipStation reported errors: {data['errors']}") + + return data diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 7eca588..355625a 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -25,6 +25,7 @@ from PyQt6.QtWidgets import ( QFileDialog, ) +from app.return_labels import is_emailed_label_order 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 @@ -33,9 +34,11 @@ 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.ui.widgets.return_label_dialog import ReturnLabelDialog from app.workers import ( FetchOrdersWorker, SendToShipStationWorker, + CreateReturnLabelWorker, load_orders_by_view, get_dashboard_stats, mark_shipstation_sent, @@ -52,6 +55,7 @@ class MainWindow(QMainWindow): self._workers: dict[str, FetchOrdersWorker] = {} self._import_actions: dict[str, QAction] = {} self._send_worker: SendToShipStationWorker | None = None + self._return_label_worker: CreateReturnLabelWorker | None = None self._build_ui() self._refresh_everything() @@ -106,6 +110,13 @@ class MainWindow(QMainWindow): emergency_action.triggered.connect(self._on_emergency_send_clicked) toolbar.addAction(emergency_action) + return_label_action = QAction("Create Return Label", self) + return_label_action.setToolTip( + "For emailed-return-label tickets (SH007/OK012) - select one in Active Orders first" + ) + return_label_action.triggered.connect(self._on_create_return_label_clicked) + toolbar.addAction(return_label_action) + toolbar.addSeparator() settings_action = QAction("Settings", self) @@ -339,6 +350,66 @@ class MainWindow(QMainWindow): "Upload this into ShipStation's Import Orders wizard.", ) + def _on_create_return_label_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 Create Return Label.", + ) + return + + if not is_emailed_label_order(order.skus or []): + proceed = QMessageBox.question( + self, + "Not a return-label ticket", + f"This ticket's SKU(s) ({', '.join(order.skus or []) or 'none'}) don't match " + "the configured emailed-label SKUs. Continue anyway?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if proceed != QMessageBox.StandardButton.Yes: + return + + dialog = ReturnLabelDialog(order, self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + + packages = dialog.get_packages() + charge_event = dialog.get_charge_event() + if not packages: + QMessageBox.warning(self, "No packages", "Add at least one package first.") + return + + ticket_number = order.ticket_number or order.external_id + self.status_label.setText(f"Creating return label for {ticket_number}...") + + worker = CreateReturnLabelWorker(order, packages, charge_event) + worker.finished_ok.connect(lambda result: self._on_return_label_ok(ticket_number, result)) + worker.failed.connect(self._on_return_label_failed) + self._return_label_worker = worker # keep a reference so it isn't garbage collected + worker.start() + + def _on_return_label_ok(self, ticket_number: str, result: dict) -> None: + mark_shipstation_sent(ticket_number) + self._refresh_everything() + label_id = result.get("label_id", "?") + tracking = result.get("tracking_number", "?") + self.status_label.setText(f"Return label created - {label_id}") + QMessageBox.information( + self, + "Return Label Created", + f"Label {label_id} created (tracking {tracking}).\n\n" + "Next: go to ShipStation's Returns tab, find this label, and use " + "Other Actions -> Send Return Label to email it to the customer through " + "your branded template.", + ) + + def _on_return_label_failed(self, message: str) -> None: + self.status_label.setText("Return label creation failed.") + QMessageBox.critical(self, "Return label creation failed", message) + def _on_reset_database_clicked(self) -> None: confirm = QMessageBox.warning( self, diff --git a/app/ui/widgets/orders_table.py b/app/ui/widgets/orders_table.py index 50b1c7a..eac93ba 100644 --- a/app/ui/widgets/orders_table.py +++ b/app/ui/widgets/orders_table.py @@ -27,6 +27,7 @@ from PyQt6.QtWidgets import ( ) from app.models import Order +from app.return_labels import is_emailed_label_order from app.schedule import get_cutoff_time, is_past_cutoff_today from app.status_rules import get_cancelled_statuses, status_in from app.tracking import get_fulfilled_statuses @@ -42,6 +43,7 @@ COLUMNS = [ ("status", "Status"), ("skus_display", "SKUs"), ("summary", "Summary"), + ("return_label_display", "Return Label"), ("outgoing_tracking_display", "Outgoing Tracking"), ("return_tracking_display", "Return Tracking"), ("uploaded_display", "Uploaded"), @@ -122,6 +124,8 @@ class OrdersTableModel(QAbstractTableModel): if field_name == "skus_display": return ", ".join(order.skus or []) + if field_name == "return_label_display": + return CHECK_MARK if is_emailed_label_order(order.skus or []) else "" if field_name == "outgoing_tracking_display": return _format_tracking_numbers(order.tracking_numbers, is_return=False) if field_name == "return_tracking_display": diff --git a/app/ui/widgets/return_label_dialog.py b/app/ui/widgets/return_label_dialog.py new file mode 100644 index 0000000..6278e69 --- /dev/null +++ b/app/ui/widgets/return_label_dialog.py @@ -0,0 +1,138 @@ +""" +Return label creation dialog - for the emailed-return-label workflow +(SH007 / OK012). Shows the ticket's description (where staff note what +boxes are needed) and lets them specify however many packages, each +with its own weight/dimensions, replacing the old "always 1x1x1, 1oz" +placeholder with real per-request control. + +After a successful create, the app does NOT email anything - per the +team's workflow, that happens from ShipStation itself so it goes out +through their branded return-email template. +""" +from __future__ import annotations + +from typing import List + +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QFormLayout, + QLabel, + QTextEdit, + QTableWidget, + QDoubleSpinBox, + QPushButton, + QComboBox, + QDialogButtonBox, +) + +from app.models import Order + +CHARGE_EVENT_LABELS = { + "carrier_default": "Carrier default", + "on_creation": "On label creation", + "on_carrier_acceptance": "On carrier acceptance (label may go unused free)", +} + +COLUMNS = ["Weight (oz)", "Length (in)", "Width (in)", "Height (in)"] +DEFAULT_WEIGHT_OZ = 1.0 +DEFAULT_DIMENSION_IN = 1.0 + + +class ReturnLabelDialog(QDialog): + def __init__(self, order: Order, parent=None): + super().__init__(parent) + self.order = order + self.setWindowTitle(f"Create Return Label - {order.ticket_number or order.external_id}") + self.resize(560, 560) + + layout = QVBoxLayout(self) + + info = order.shipping_info or {} + address_line2 = f" {info.get('address2')}" if info.get("address2") else "" + summary_lines = [ + f"Ticket: {order.ticket_number or order.external_id} Company: {order.company}", + f"Customer: {info.get('name') or '(missing)'}", + f"Address: {info.get('address1') or '(missing)'}{address_line2}, " + f"{info.get('city', '')}, {info.get('state', '')} {info.get('zip', '')}", + ] + layout.addWidget(QLabel("\n".join(summary_lines))) + + layout.addWidget(QLabel("Description (box requirements from JIRA):")) + description_box = QTextEdit() + description_box.setReadOnly(True) + description_box.setPlainText(order.description or "(no description on this ticket)") + description_box.setMaximumHeight(100) + layout.addWidget(description_box) + + layout.addWidget(QLabel("Packages - one row per box:")) + self.table = QTableWidget(0, len(COLUMNS)) + self.table.setHorizontalHeaderLabels(COLUMNS) + layout.addWidget(self.table) + + package_buttons = QHBoxLayout() + add_button = QPushButton("Add Package") + add_button.clicked.connect(self._add_package_row) + remove_button = QPushButton("Remove Selected") + remove_button.clicked.connect(self._remove_selected_row) + package_buttons.addWidget(add_button) + package_buttons.addWidget(remove_button) + package_buttons.addStretch() + layout.addLayout(package_buttons) + + form = QFormLayout() + self.charge_event_combo = QComboBox() + for value, label in CHARGE_EVENT_LABELS.items(): + self.charge_event_combo.addItem(label, userData=value) + form.addRow("Charge event:", self.charge_event_combo) + layout.addLayout(form) + + button_box = QDialogButtonBox() + self.create_button = button_box.addButton( + "Create Return Label", QDialogButtonBox.ButtonRole.AcceptRole + ) + button_box.addButton(QDialogButtonBox.StandardButton.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + # Start with one package pre-filled at your current standard + # (1x1x1, 1oz) - fully editable, just a familiar starting point. + self._add_package_row() + + def _add_package_row(self) -> None: + row = self.table.rowCount() + self.table.insertRow(row) + defaults = [DEFAULT_WEIGHT_OZ, DEFAULT_DIMENSION_IN, DEFAULT_DIMENSION_IN, DEFAULT_DIMENSION_IN] + for col, default_value in enumerate(defaults): + spin = QDoubleSpinBox() + spin.setRange(0, 9999) + spin.setDecimals(2) + spin.setValue(default_value) + self.table.setCellWidget(row, col, spin) + + def _remove_selected_row(self) -> None: + rows = {index.row() for index in self.table.selectedIndexes()} + for row in sorted(rows, reverse=True): + self.table.removeRow(row) + + def get_packages(self) -> List[dict]: + packages = [] + for row in range(self.table.rowCount()): + weight_spin = self.table.cellWidget(row, 0) + length_spin = self.table.cellWidget(row, 1) + width_spin = self.table.cellWidget(row, 2) + height_spin = self.table.cellWidget(row, 3) + packages.append( + { + "weight_oz": weight_spin.value(), + "length": length_spin.value(), + "width": width_spin.value(), + "height": height_spin.value(), + } + ) + return packages + + def get_charge_event(self) -> str: + return self.charge_event_combo.currentData() diff --git a/app/workers.py b/app/workers.py index 9e0a991..584374b 100644 --- a/app/workers.py +++ b/app/workers.py @@ -61,6 +61,33 @@ class SendToShipStationWorker(QThread): self.finished_ok.emit(result) +class CreateReturnLabelWorker(QThread): + """Runs return-label creation off the GUI thread.""" + + finished_ok = pyqtSignal(dict) # the created label's JSON + failed = pyqtSignal(str) + + def __init__(self, order, packages: list[dict], charge_event: str, parent=None): + super().__init__(parent) + self.order = order + self.packages = packages + self.charge_event = charge_event + + def run(self) -> None: + from app.services.shipstation_send import create_return_label, ShipStationSendError + + try: + result = create_return_label(self.order, 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.""" @@ -158,6 +185,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult: line_items=order.get("line_items", []), shipping_info=order.get("shipping_info", {}), creator=order.get("creator"), + description=order.get("description"), tracking_numbers=order.get("tracking_numbers", []), summary=order["summary"], status=new_status, @@ -197,6 +225,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult: existing.line_items = order.get("line_items", []) existing.shipping_info = order.get("shipping_info", {}) existing.creator = order.get("creator") + existing.description = order.get("description") existing.summary = order["summary"] existing.status = new_status existing.source_created_at = order["source_created_at"]