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
+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