EMailed label return generation (alpha)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 [])
|
||||
@@ -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
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user