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
|
# 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.
|
# Dashboard - they didn't get a full window to be processed same-day.
|
||||||
INTAKE_CUTOFF_TIME=15:30
|
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`.
|
`_on_export_clicked` calls `export_orders_to_csv`.
|
||||||
3. Wire it up the same way the JIRA/ShipStation buttons are.
|
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
|
## Known open item
|
||||||
|
|
||||||
The **emailed-label SKU** (mentioned but not detailed yet) needs its
|
You mentioned occasionally shipping return items to a company's
|
||||||
own handling eventually - tell me the SKU and what "done" looks like
|
headquarters instead of the shared warehouse - noted, not built yet
|
||||||
for it when you're ready, and I'll fold it into the terminal-status /
|
since you said we could tackle it later. When you're ready, this would
|
||||||
tracking logic above rather than bolting on something separate.
|
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,
|
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": (
|
"ACTIVE_STATUSES": (
|
||||||
"Statuses that count as real active work (comma-separated) - "
|
"Statuses that count as real active work (comma-separated) - "
|
||||||
"everything else is treated as done",
|
"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_INTAKE_CUTOFF_TIME = "15:30"
|
||||||
DEFAULT_ACTIVE_STATUSES = "Created"
|
DEFAULT_ACTIVE_STATUSES = "Created"
|
||||||
DEFAULT_CANCELLED_STATUSES = "Cancelled"
|
DEFAULT_CANCELLED_STATUSES = "Cancelled"
|
||||||
|
DEFAULT_EMAILED_LABEL_SKUS = "SH007,OK012"
|
||||||
|
DEFAULT_RETURN_CHARGE_EVENT = "carrier_default"
|
||||||
|
|
||||||
|
|
||||||
def ensure_env_file_exists() -> None:
|
def ensure_env_file_exists() -> None:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from sqlalchemy import (
|
|||||||
Column,
|
Column,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
|
Text,
|
||||||
DateTime,
|
DateTime,
|
||||||
Boolean,
|
Boolean,
|
||||||
JSON,
|
JSON,
|
||||||
@@ -65,6 +66,12 @@ class Order(Base):
|
|||||||
# Display name of whoever created the JIRA ticket.
|
# Display name of whoever created the JIRA ticket.
|
||||||
creator = Column(String(200), nullable=True)
|
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
|
# Tracking numbers pulled from ShipStation and merged onto this
|
||||||
# ticket - e.g. [{"number": "782758401696", "carrier": "ups",
|
# ticket - e.g. [{"number": "782758401696", "carrier": "ups",
|
||||||
# "is_return": false}]. Populated by the "Pull Tracking Numbers"
|
# "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]
|
line_items: List[dict]
|
||||||
shipping_info: dict
|
shipping_info: dict
|
||||||
creator: Optional[str]
|
creator: Optional[str]
|
||||||
|
description: Optional[str]
|
||||||
tracking_numbers: List[dict]
|
tracking_numbers: List[dict]
|
||||||
summary: str
|
summary: str
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from typing import List, Tuple
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
|
from app.adf import adf_to_text
|
||||||
from app.companies import parse_mapping, resolve_company_for_skus
|
from app.companies import parse_mapping, resolve_company_for_skus
|
||||||
from app.queries import get_open_ticket_numbers
|
from app.queries import get_open_ticket_numbers
|
||||||
from app.services.base import OrderService, NormalizedOrder
|
from app.services.base import OrderService, NormalizedOrder
|
||||||
@@ -164,7 +165,7 @@ class JiraService(OrderService):
|
|||||||
|
|
||||||
# Always pull summary/status/created/creator, plus every configured
|
# Always pull summary/status/created/creator, plus every configured
|
||||||
# deliverable field and contact field.
|
# deliverable field and contact field.
|
||||||
fields = "summary,status,created,creator"
|
fields = "summary,status,created,creator,description"
|
||||||
if self.all_sku_field_ids:
|
if self.all_sku_field_ids:
|
||||||
fields += "," + ",".join(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]
|
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)
|
company = resolve_company_for_skus(skus, self.sku_map)
|
||||||
shipping_info = self._extract_shipping_info(fields)
|
shipping_info = self._extract_shipping_info(fields)
|
||||||
creator = self._extract_creator(fields)
|
creator = self._extract_creator(fields)
|
||||||
|
description = adf_to_text(fields.get("description"))
|
||||||
|
|
||||||
# These order tickets typically leave the JIRA Summary field
|
# These order tickets typically leave the JIRA Summary field
|
||||||
# blank - fall back to the deliverables so there's still
|
# blank - fall back to the deliverables so there's still
|
||||||
@@ -353,6 +355,7 @@ class JiraService(OrderService):
|
|||||||
line_items=line_items,
|
line_items=line_items,
|
||||||
shipping_info=shipping_info,
|
shipping_info=shipping_info,
|
||||||
creator=creator,
|
creator=creator,
|
||||||
|
description=description,
|
||||||
tracking_numbers=[],
|
tracking_numbers=[],
|
||||||
summary=summary,
|
summary=summary,
|
||||||
status=(fields.get("status") or {}).get("name", ""),
|
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']}")
|
raise ShipStationSendError(f"ShipStation reported errors: {result['errors']}")
|
||||||
|
|
||||||
return result
|
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,
|
QFileDialog,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.return_labels import is_emailed_label_order
|
||||||
from app.services import SERVICE_REGISTRY
|
from app.services import SERVICE_REGISTRY
|
||||||
from app.services.odoo_export import export_orders_to_csv
|
from app.services.odoo_export import export_orders_to_csv
|
||||||
from app.services.shipstation_send import export_order_to_shipstation_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.orders_table import OrdersTableView
|
||||||
from app.ui.widgets.dashboard import DashboardWidget
|
from app.ui.widgets.dashboard import DashboardWidget
|
||||||
from app.ui.widgets.order_detail_dialog import OrderDetailDialog
|
from app.ui.widgets.order_detail_dialog import OrderDetailDialog
|
||||||
|
from app.ui.widgets.return_label_dialog import ReturnLabelDialog
|
||||||
from app.workers import (
|
from app.workers import (
|
||||||
FetchOrdersWorker,
|
FetchOrdersWorker,
|
||||||
SendToShipStationWorker,
|
SendToShipStationWorker,
|
||||||
|
CreateReturnLabelWorker,
|
||||||
load_orders_by_view,
|
load_orders_by_view,
|
||||||
get_dashboard_stats,
|
get_dashboard_stats,
|
||||||
mark_shipstation_sent,
|
mark_shipstation_sent,
|
||||||
@@ -52,6 +55,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._workers: dict[str, FetchOrdersWorker] = {}
|
self._workers: dict[str, FetchOrdersWorker] = {}
|
||||||
self._import_actions: dict[str, QAction] = {}
|
self._import_actions: dict[str, QAction] = {}
|
||||||
self._send_worker: SendToShipStationWorker | None = None
|
self._send_worker: SendToShipStationWorker | None = None
|
||||||
|
self._return_label_worker: CreateReturnLabelWorker | None = None
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._refresh_everything()
|
self._refresh_everything()
|
||||||
@@ -106,6 +110,13 @@ class MainWindow(QMainWindow):
|
|||||||
emergency_action.triggered.connect(self._on_emergency_send_clicked)
|
emergency_action.triggered.connect(self._on_emergency_send_clicked)
|
||||||
toolbar.addAction(emergency_action)
|
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()
|
toolbar.addSeparator()
|
||||||
|
|
||||||
settings_action = QAction("Settings", self)
|
settings_action = QAction("Settings", self)
|
||||||
@@ -339,6 +350,66 @@ class MainWindow(QMainWindow):
|
|||||||
"Upload this into ShipStation's Import Orders wizard.",
|
"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:
|
def _on_reset_database_clicked(self) -> None:
|
||||||
confirm = QMessageBox.warning(
|
confirm = QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from app.models import Order
|
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.schedule import get_cutoff_time, is_past_cutoff_today
|
||||||
from app.status_rules import get_cancelled_statuses, status_in
|
from app.status_rules import get_cancelled_statuses, status_in
|
||||||
from app.tracking import get_fulfilled_statuses
|
from app.tracking import get_fulfilled_statuses
|
||||||
@@ -42,6 +43,7 @@ COLUMNS = [
|
|||||||
("status", "Status"),
|
("status", "Status"),
|
||||||
("skus_display", "SKUs"),
|
("skus_display", "SKUs"),
|
||||||
("summary", "Summary"),
|
("summary", "Summary"),
|
||||||
|
("return_label_display", "Return Label"),
|
||||||
("outgoing_tracking_display", "Outgoing Tracking"),
|
("outgoing_tracking_display", "Outgoing Tracking"),
|
||||||
("return_tracking_display", "Return Tracking"),
|
("return_tracking_display", "Return Tracking"),
|
||||||
("uploaded_display", "Uploaded"),
|
("uploaded_display", "Uploaded"),
|
||||||
@@ -122,6 +124,8 @@ class OrdersTableModel(QAbstractTableModel):
|
|||||||
|
|
||||||
if field_name == "skus_display":
|
if field_name == "skus_display":
|
||||||
return ", ".join(order.skus or [])
|
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":
|
if field_name == "outgoing_tracking_display":
|
||||||
return _format_tracking_numbers(order.tracking_numbers, is_return=False)
|
return _format_tracking_numbers(order.tracking_numbers, is_return=False)
|
||||||
if field_name == "return_tracking_display":
|
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)
|
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):
|
class FetchOrdersWorker(QThread):
|
||||||
"""Fetches orders from a given service and saves/merges the results."""
|
"""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", []),
|
line_items=order.get("line_items", []),
|
||||||
shipping_info=order.get("shipping_info", {}),
|
shipping_info=order.get("shipping_info", {}),
|
||||||
creator=order.get("creator"),
|
creator=order.get("creator"),
|
||||||
|
description=order.get("description"),
|
||||||
tracking_numbers=order.get("tracking_numbers", []),
|
tracking_numbers=order.get("tracking_numbers", []),
|
||||||
summary=order["summary"],
|
summary=order["summary"],
|
||||||
status=new_status,
|
status=new_status,
|
||||||
@@ -197,6 +225,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
existing.line_items = order.get("line_items", [])
|
existing.line_items = order.get("line_items", [])
|
||||||
existing.shipping_info = order.get("shipping_info", {})
|
existing.shipping_info = order.get("shipping_info", {})
|
||||||
existing.creator = order.get("creator")
|
existing.creator = order.get("creator")
|
||||||
|
existing.description = order.get("description")
|
||||||
existing.summary = order["summary"]
|
existing.summary = order["summary"]
|
||||||
existing.status = new_status
|
existing.status = new_status
|
||||||
existing.source_created_at = order["source_created_at"]
|
existing.source_created_at = order["source_created_at"]
|
||||||
|
|||||||
Reference in New Issue
Block a user