More email SKU shenanigans, now with more TEST labels!
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user