More email SKU shenanigans, now with more TEST labels!

This commit is contained in:
2026-09-14 11:41:27 -05:00
parent a6f60408ff
commit b5570d39f0
12 changed files with 815 additions and 139 deletions
+327 -84
View File
@@ -41,6 +41,79 @@ from app.models import Order
API_BASE = "https://api.shipstation.com/v2"
REQUEST_TIMEOUT_SECONDS = 30
def list_carriers() -> List[dict]:
"""
Calls GET /v2/carriers with whichever API key is currently active
(test or production, via config.get_shipstation_setting) - a direct
way to answer "what carrier_id is actually valid here" instead of
guessing or hunting through ShipStation's UI while unsure which
account is even logged in. Each carrier includes carrier_id,
carrier_code, friendly_name, and nickname.
"""
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
if not api_key:
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
try:
response = requests.get(
f"{API_BASE}/carriers",
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
return data.get("carriers", [])
def list_stores() -> List[dict]:
"""
Same idea as list_carriers() but for GET /v2/stores - the test/sandbox
account almost certainly has different store IDs than production too
(confirmed the same is true for carriers), so this is worth checking
before it becomes the next "not found" error rather than after.
"""
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
if not api_key:
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
try:
response = requests.get(
f"{API_BASE}/stores",
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
# Some ShipStation accounts return a bare list, others wrap it - handle both.
return data if isinstance(data, list) else data.get("stores", [])
# Column order confirmed against the real ShipStation upload template
# (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and
# is intentionally left out here.
@@ -129,21 +202,39 @@ def export_order_to_shipstation_csv(orders: List[Order], filepath: str) -> int:
return row_count
def _normalize_shipstation_id(value: str) -> str:
"""
Every ShipStation reference ID we've seen (store, warehouse, carrier)
consistently uses an "se-" prefix (se-599657, se-367672, etc).
Prepends it if it's missing - a bare numeric ID would always fail
with a confusing "not found" error otherwise, and it's an easy typo
to make when copying a value out of ShipStation's own UI or filling
in a new setting (this happened for real: a test-mode carrier ID was
entered as "599657" instead of "se-599657").
"""
value = (value or "").strip()
if value and not value.startswith("se-") and value.replace("-", "").isalnum():
return f"se-{value}"
return value
def _store_id_for_company(company: str) -> str:
settings = config.load_settings()
if company == "Signify Health":
return settings.get("SHIPSTATION_SIGNIFY_STORE_ID", "")
return _normalize_shipstation_id(config.get_shipstation_setting("SHIPSTATION_SIGNIFY_STORE_ID"))
if company == "Oak Street Health":
return settings.get("SHIPSTATION_OAKSTREET_STORE_ID", "")
return _normalize_shipstation_id(config.get_shipstation_setting("SHIPSTATION_OAKSTREET_STORE_ID"))
return ""
def _warehouse_id_for_company(company: str) -> str:
settings = config.load_settings()
if company == "Signify Health":
return settings.get("SHIPSTATION_SIGNIFY_WAREHOUSE_ID", "")
return _normalize_shipstation_id(
config.get_shipstation_setting("SHIPSTATION_SIGNIFY_WAREHOUSE_ID")
)
if company == "Oak Street Health":
return settings.get("SHIPSTATION_OAKSTREET_WAREHOUSE_ID", "")
return _normalize_shipstation_id(
config.get_shipstation_setting("SHIPSTATION_OAKSTREET_WAREHOUSE_ID")
)
return ""
@@ -304,9 +395,11 @@ def _return_carrier_for_order(order: Order) -> tuple[str, str]:
_return_address_for_order). Each of the two REAL shipping accounts
(Signify, Oak Street) has its own carrier_id even though they share a
physical warehouse."""
settings = config.load_settings()
prefix = "SIGNIFY_RETURN_" if order.company == "Signify Health" else "OAKSTREET_RETURN_"
return settings.get(f"{prefix}CARRIER_ID", ""), settings.get(f"{prefix}SERVICE_CODE", "")
return (
_normalize_shipstation_id(config.get_shipstation_setting(f"{prefix}CARRIER_ID")),
config.get_shipstation_setting(f"{prefix}SERVICE_CODE"),
)
def _build_return_package(weight_oz: float, length: float, width: float, height: float) -> dict:
@@ -321,82 +414,12 @@ def _build_return_package(weight_oz: float, length: float, width: float, height:
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_order(order)
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_order(order)
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
],
},
}
def _post_to_labels(payload: dict, api_key: str) -> dict:
"""Shared POST /v2/labels caller for both the dummy outbound and the
actual return label - same endpoint, same response shape, same
status-field pitfall (a 200 can still carry status: "error" or
"voided" alongside a label_id, which looks like success unless you
check status specifically)."""
try:
response = requests.post(
f"{API_BASE}/labels",
@@ -422,4 +445,224 @@ def create_return_label(order: Order, packages: List[dict], charge_event: str |
if data.get("errors"):
raise ShipStationSendError(f"ShipStation reported errors: {data['errors']}")
label_status = data.get("status")
if label_status == "error":
raise ShipStationSendError(
f"ShipStation created label {data.get('label_id', '?')} but its status is "
f"'error' - it was not actually completed. Full response: {data}"
)
if label_status == "voided":
raise ShipStationSendError(
f"ShipStation reports label {data.get('label_id', '?')} as already voided."
)
return data
def _void_label(label_id: str, api_key: str) -> None:
"""Best-effort cleanup - if the real return label fails to create
after the dummy outbound succeeded, void the dummy rather than leave
a paid, unused label sitting in the account. Deliberately swallows
its own errors: this runs during an already-failing operation, and a
secondary failure here shouldn't mask the original error or crash
the app - worst case, an unused dummy label needs manual voiding."""
try:
requests.put(
f"{API_BASE}/labels/{label_id}/void",
headers={"API-Key": api_key, "Accept": "application/json"},
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException:
pass
def _create_dummy_outbound_label(
order: Order,
api_key: str,
carrier_id: str,
service_code: str,
store_id: str,
external_shipment_id: str,
) -> dict:
"""
Mirrors what your team already does by hand in ShipStation's GUI:
create a minimal, cheap outbound label (1x1x1in, 1oz) purely so a
real return label can be linked to it via outbound_label_id - which
is apparently what actually makes a return label findable/visible in
your account, confirmed against your own working manual process
rather than assumed from the API docs alone. Same shipping account as
the return itself; ship_from/ship_to are the reverse of the return
(this one goes warehouse -> customer, matching a normal outbound).
Returns the dummy's label_id.
external_shipment_id is passed in (rather than computed here) since
it must be unique per account - the caller uses a suffixed variant
for this dummy, distinct from the real return label's. ShipStation's
own docs confirm this field exists on both shipments and labels
specifically to correlate a record back to your own system, and it's
what populates the "Order #" column - this was missing entirely
before, on both this dummy and the real return label.
warehouse_id is used INSTEAD of ship_from when available (confirmed by
a real ShipStation error: "ship_from and warehouse_id cannot be
provided in same request" - they're mutually exclusive, not
additive). When a warehouse_id is configured, ShipStation resolves
the ship_from address from the registered warehouse record itself.
This only affects the throwaway dummy - it's never seen by anyone,
so it doesn't matter that this bypasses the RubiconMD name-override
logic in _return_address_for_order; the real return label (which
people do see) still uses that function directly.
"""
warehouse_id = _warehouse_id_for_company(order.company)
info = order.shipping_info or {}
shipment: dict = {
"carrier_id": carrier_id,
"service_code": service_code,
"store_id": store_id,
"ship_to": {
"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(1.0, 1.0, 1.0, 1.0)],
}
if warehouse_id:
shipment["warehouse_id"] = warehouse_id
else:
shipment["ship_from"] = _return_address_for_order(order)
payload = {
"external_shipment_id": external_shipment_id,
"shipment": shipment,
}
return _post_to_labels(payload, api_key)
def _validate_return_label_prerequisites(order: Order) -> tuple[str, str, str, str]:
"""Shared validation for both steps - returns (api_key, carrier_id,
service_code, store_id) or raises with a clear, specific message."""
api_key = config.get_shipstation_setting("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_order(order)
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."
)
store_id = _store_id_for_company(order.company)
if not store_id:
raise ShipStationSendError(
f"No ShipStation Store ID configured for '{order.company}'. Add it in Settings "
"under ShipStation. A label created without one may not show up anywhere in "
"ShipStation's UI even though the API reports success."
)
return_address = _return_address_for_order(order)
if not (
return_address["address_line1"]
and return_address["city_locality"]
and return_address["state_province"]
and return_address["postal_code"]
and return_address["phone"]
):
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."
)
return api_key, carrier_id, service_code, store_id
def create_dummy_shipment(order: Order) -> dict:
"""
STEP 1 of the emailed-return-label workflow, callable and verifiable
on its own: creates the minimal, cheap outbound label (1x1x1in, 1oz)
that your team already creates by hand in ShipStation's GUI before
generating a return from it. Returns the FULL response (not just the
label_id) so the caller/UI can show it for verification in
ShipStation before proceeding to step 2 - splitting these apart on
purpose so a problem in one step doesn't get masked by the other.
"""
api_key, carrier_id, service_code, store_id = _validate_return_label_prerequisites(order)
ticket_number = order.ticket_number or order.external_id
return _create_dummy_outbound_label(
order, api_key, carrier_id, service_code, store_id, external_shipment_id=f"{ticket_number}-DUMMY"
)
def create_return_label_from_dummy(
order: Order, dummy_label_id: str, packages: List[dict], charge_event: str | None = None
) -> dict:
"""
STEP 2 of the emailed-return-label workflow: creates the real return
label, linked via outbound_label_id to a dummy that was already
created (and ideally already verified in ShipStation) in step 1.
Does NOT create a new dummy - that's the point of splitting this out.
"""
settings = config.load_settings()
api_key, carrier_id, service_code, store_id = _validate_return_label_prerequisites(order)
return_address = _return_address_for_order(order)
info = order.shipping_info or {}
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}'."
)
ticket_number = order.ticket_number or order.external_id
payload = {
"external_shipment_id": ticket_number,
"is_return_label": True,
"outbound_label_id": dummy_label_id,
"charge_event": resolved_charge_event,
"shipment": {
"carrier_id": carrier_id,
"service_code": service_code,
"store_id": store_id,
"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
],
},
}
data = _post_to_labels(payload, api_key)
data["_dummy_outbound_label_id"] = dummy_label_id
return data
+1 -1
View File
@@ -113,7 +113,7 @@ class ShipStationService(OrderService):
def __init__(self) -> None:
settings = config.load_settings()
self.api_key = settings["SHIPSTATION_API_KEY"]
self.api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
self.ticket_pattern = re.compile(
settings["TICKET_NUMBER_REGEX"] or config.DEFAULT_TICKET_NUMBER_REGEX
)