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