EMailed label return generation (alpha)

This commit is contained in:
2026-09-02 09:30:19 -05:00
parent 6655b02f4a
commit 826a70bdf6
13 changed files with 603 additions and 5 deletions
+38
View File
@@ -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
+49 -4
View File
@@ -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.
+45
View File
@@ -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")
+39
View File
@@ -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:
+7
View File
@@ -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"
+22
View File
@@ -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 [])
+1
View File
@@ -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
+4 -1
View File
@@ -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", ""),
+156
View File
@@ -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
+71
View File
@@ -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,
+4
View File
@@ -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":
+138
View File
@@ -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()
+29
View File
@@ -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"]