Files
CSG-Picker-Shipper-Helper/app/fedex.py
T
2026-07-06 00:24:30 -05:00

242 lines
8.6 KiB
Python

"""FedEx Ship API client — One Rate shipments.
Handles OAuth token fetch and shipment creation. Returns a tracking number
and a temp-file path to the PDF label, which the caller opens for printing.
Phase 2 hook: swap _SHIP_URL / _TOKEN_URL to the sandbox URLs for testing.
"""
from __future__ import annotations
import base64
import logging
from dataclasses import dataclass
import requests
log = logging.getLogger(__name__)
_TOKEN_URL_PROD = "https://apis.fedex.com/oauth/token"
_SHIP_URL_PROD = "https://apis.fedex.com/ship/v1/shipments"
_TOKEN_URL_SANDBOX = "https://apis-sandbox.fedex.com/oauth/token"
_SHIP_URL_SANDBOX = "https://apis-sandbox.fedex.com/ship/v1/shipments"
# Maps UI display strings → FedEx API codes
_SERVICE_CODES: dict[str, str] = {
"FedEx 2Day": "FEDEX_2_DAY",
"FedEx 2Day AM": "FEDEX_2_DAY_AM",
}
_PACKAGE_CODES: dict[str, str] = {
"FedEx Envelope": "FEDEX_ENVELOPE",
"FedEx Pak": "FEDEX_PAK",
}
@dataclass
class ShipmentResult:
tracking_number: str
label_pdf_path: str # absolute path to a temp PDF file
class FedExClient:
"""Thin wrapper around the FedEx REST Ship API."""
def __init__(
self,
api_key: str,
secret: str,
account: str,
shipper: dict,
sandbox: bool = False,
) -> None:
self._api_key = api_key
self._secret = secret
self._account = account
self._shipper = shipper # keys: name, company, phone, address1, city, state, zip
self._token_url = _TOKEN_URL_SANDBOX if sandbox else _TOKEN_URL_PROD
self._ship_url = _SHIP_URL_SANDBOX if sandbox else _SHIP_URL_PROD
# ── Public API ─────────────────────────────────────────────────────────
def create_shipment(self, data: dict) -> ShipmentResult:
"""Create a One Rate shipment and return tracking number + label PDF path.
`data` is the dict from ShippingTab._get_shipment_data().
Raises RuntimeError with a user-readable message on any failure.
"""
token = self._fetch_token()
service = _SERVICE_CODES.get(data["service"], "FEDEX_2_DAY")
pkg_type = _PACKAGE_CODES.get(data["package_type"], "FEDEX_PAK")
street_lines = [data["address1"]]
if data.get("address2"):
street_lines.append(data["address2"])
payload = self._build_payload(data, service, pkg_type, street_lines)
resp = requests.post(
self._ship_url,
json=payload,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-locale": "en_US",
},
timeout=30,
)
if not resp.ok:
raise RuntimeError(_format_api_error(resp))
return self._parse_response(resp.json())
# ── Private helpers ────────────────────────────────────────────────────
def _fetch_token(self) -> str:
try:
resp = requests.post(
self._token_url,
data={
"grant_type": "client_credentials",
"client_id": self._api_key,
"client_secret": self._secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=15,
)
resp.raise_for_status()
return resp.json()["access_token"]
except requests.RequestException as exc:
raise RuntimeError(
f"Could not connect to FedEx (authentication step):\n{exc}"
) from exc
def _build_payload(
self,
data: dict,
service: str,
pkg_type: str,
street_lines: list[str],
) -> dict:
s = self._shipper
payload: dict = {
"labelResponseOptions": "LABEL",
"accountNumber": {"value": self._account},
"requestedShipment": {
"shipper": {
"contact": {
"personName": s.get("name", ""),
"companyName": s.get("company", ""),
"phoneNumber": s.get("phone", ""),
},
"address": {
"streetLines": [s.get("address1", "")],
"city": s.get("city", ""),
"stateOrProvinceCode": s.get("state", ""),
"postalCode": s.get("zip", ""),
"countryCode": "US",
},
},
"recipients": [{
"contact": {
"personName": data["name"],
"companyName": data.get("company", ""),
"phoneNumber": data["phone"],
**({"emailAddress": data["email"]}
if data.get("email") else {}),
},
"address": {
"streetLines": street_lines,
"city": data["city"],
"stateOrProvinceCode": data["state"],
"postalCode": data["zip"],
"countryCode": "US",
"residential": False,
},
}],
"serviceType": service,
"packagingType": pkg_type,
"pickupType": "USE_SCHEDULED_PICKUP",
"shippingChargesPayment": {
"paymentType": "SENDER",
"payor": {
"responsibleParty": {
"accountNumber": {"value": self._account},
},
},
},
"labelSpecification": {
"labelFormatType": "COMMON2D",
"imageType": "PDF",
"labelStockType": "PAPER_LETTER",
},
"requestedPackageLineItems": [{
"weight": {"units": "LB", "value": 1},
}],
},
}
return payload
def _parse_response(self, result: dict) -> ShipmentResult:
shipments = result.get("output", {}).get("transactionShipments", [])
if not shipments:
raise RuntimeError("FedEx accepted the request but returned no shipment data.")
shipment = shipments[0]
pieces = shipment.get("pieceResponses", [{}])
piece = pieces[0] if pieces else {}
tracking = (
shipment.get("masterTrackingNumber")
or piece.get("trackingNumber")
or ""
)
docs = piece.get("packageDocuments", [{}])
doc = docs[0] if docs else {}
b64_pdf = doc.get("encodedLabel", "")
if not b64_pdf:
raise RuntimeError(
f"Shipment created (tracking: {tracking}) but FedEx sent no label."
)
from datetime import datetime
from .paths import appdata_dir
pdf_bytes = base64.b64decode(b64_pdf)
labels_dir = appdata_dir() / "labels"
labels_dir.mkdir(exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
label_path = labels_dir / f"FedEx_{tracking or 'label'}_{stamp}.pdf"
label_path.write_bytes(pdf_bytes)
log.info("FedEx shipment created: tracking=%s label=%s", tracking, label_path)
return ShipmentResult(tracking_number=tracking, label_pdf_path=str(label_path))
# ── Error formatting ────────────────────────────────────────────────────────
def _format_api_error(resp: requests.Response) -> str:
status = resp.status_code
try:
body = resp.json()
errors = body.get("errors", [])
msgs = [
f"{e.get('code', '')}: {e.get('message', '')}".strip(": ")
for e in errors
]
detail = " | ".join(msgs) if msgs else str(body)
except Exception:
detail = resp.text or "(no body)"
hint = ""
if status == 401:
hint = "\n\nCheck that the API key and secret in config.json are correct."
elif status == 400:
hint = "\n\nCheck the recipient address — a required field may be missing."
return f"FedEx returned HTTP {status}: {detail}{hint}"