242 lines
8.6 KiB
Python
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}"
|