ShipStation tracking pull, status tracking, dashboard rework
This commit is contained in:
@@ -1,32 +1,40 @@
|
||||
"""
|
||||
ShipStation order source (API V2).
|
||||
ShipStation tracking-number puller (API V2).
|
||||
|
||||
Important context (as of ShipStation's current V2 docs): V2 does not
|
||||
have a dedicated "list orders" endpoint the way the older V1 API did.
|
||||
The closest equivalent is GET /v2/shipments, where each shipment
|
||||
carries a store_id and (depending on how the order arrived) an items
|
||||
array with SKUs. That's what this service pulls from.
|
||||
ShipStation is used for exactly one thing here: generating shipping
|
||||
labels for JIRA tickets. It's not an independent order source, so this
|
||||
doesn't produce its own order rows - it produces tracking numbers keyed
|
||||
by ticket number, which app.workers merges directly onto the matching
|
||||
JIRA-sourced Order row. See app/tracking.py for the "what JIRA status
|
||||
should this become" logic.
|
||||
|
||||
Because we're fetching independently of JIRA (no shared internal ID),
|
||||
we recover the AR-###### ticket number by scanning the whole shipment
|
||||
payload for the configured pattern, rather than assuming one fixed
|
||||
field - so this keeps working even before we've confirmed exactly
|
||||
which field your team puts it in (order notes, a custom field, a tag,
|
||||
etc). Once that's confirmed, this can be tightened to read that field
|
||||
directly for speed and reliability.
|
||||
How this maps to the real API (confirmed against a live payload, not
|
||||
guessed):
|
||||
- GET /v2/labels gives tracking_number + is_return_label directly -
|
||||
exactly what's needed to tell "Waiting For Return" apart from
|
||||
"Device Return Not Needed".
|
||||
- A label only carries a shipment_id, not the ticket number, so for
|
||||
each label we fetch its shipment via GET /v2/shipments/{id}. A real
|
||||
shipment payload showed the ticket number in BOTH shipment_number
|
||||
and external_shipment_id (e.g. both were "AR-160269") - we check
|
||||
both, then fall back to scanning the whole payload with
|
||||
TICKET_NUMBER_REGEX as a last resort.
|
||||
- Shipments don't reliably carry store_id (a still-"pending" shipment
|
||||
has none), which is fine - company comes from the JIRA side, this
|
||||
never needs to know it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import re
|
||||
from typing import List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from app import config
|
||||
from app.companies import parse_mapping, resolve_company_by_store
|
||||
from app.services.base import OrderService, NormalizedOrder
|
||||
from app.tracking import suggest_jira_status
|
||||
|
||||
API_BASE = "https://api.shipstation.com/v2"
|
||||
PAGE_SIZE = 100
|
||||
@@ -43,38 +51,73 @@ class ShipStationService(OrderService):
|
||||
def __init__(self) -> None:
|
||||
settings = config.load_settings()
|
||||
self.api_key = settings["SHIPSTATION_API_KEY"]
|
||||
self.store_map = parse_mapping(settings["SHIPSTATION_STORE_MAP"])
|
||||
self.ticket_pattern = re.compile(
|
||||
settings["TICKET_NUMBER_REGEX"] or config.DEFAULT_TICKET_NUMBER_REGEX
|
||||
)
|
||||
self._headers = {"API-Key": self.api_key, "Accept": "application/json"}
|
||||
self.unmatched_labels: List[dict] = []
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.api_key and self.store_map)
|
||||
return bool(self.api_key)
|
||||
|
||||
def fetch_orders(self) -> List[NormalizedOrder]:
|
||||
if not self.is_configured():
|
||||
raise ShipStationServiceError(
|
||||
"ShipStation is not fully configured yet. Open Settings and fill in "
|
||||
"the API Key and the Store ID -> Company mapping."
|
||||
"ShipStation is not configured yet. Open Settings and fill in the API Key."
|
||||
)
|
||||
|
||||
shipments = self._fetch_todays_shipments()
|
||||
labels = self._fetch_todays_labels()
|
||||
|
||||
# Only keep shipments belonging to one of our two known stores.
|
||||
known_store_ids = set(self.store_map.keys())
|
||||
relevant = [s for s in shipments if str(s.get("store_id")) in known_store_ids]
|
||||
# Only labels that actually produced a usable tracking number matter.
|
||||
usable_labels = [
|
||||
label
|
||||
for label in labels
|
||||
if not label.get("voided") and label.get("tracking_number")
|
||||
]
|
||||
|
||||
return [self._to_normalized_order(s) for s in relevant]
|
||||
# One shipment lookup per unique shipment_id referenced, not per
|
||||
# label (an outgoing + return label pair share the same shipment).
|
||||
shipment_ids = {
|
||||
label["shipment_id"] for label in usable_labels if label.get("shipment_id")
|
||||
}
|
||||
shipments_by_id = {sid: self._fetch_shipment(sid) for sid in shipment_ids}
|
||||
|
||||
tracking_by_ticket: Dict[str, List[dict]] = {}
|
||||
raw_by_ticket: Dict[str, dict] = {}
|
||||
self.unmatched_labels = [] # labels we couldn't tie to a ticket number
|
||||
|
||||
for label in usable_labels:
|
||||
shipment = shipments_by_id.get(label.get("shipment_id"))
|
||||
ticket_number = self._extract_ticket_number(shipment) if shipment else None
|
||||
|
||||
if not ticket_number:
|
||||
self.unmatched_labels.append(label)
|
||||
continue
|
||||
|
||||
tracking_by_ticket.setdefault(ticket_number, []).append(
|
||||
{
|
||||
"number": label.get("tracking_number"),
|
||||
"carrier": label.get("carrier_code"),
|
||||
"is_return": bool(label.get("is_return_label")),
|
||||
}
|
||||
)
|
||||
raw_by_ticket.setdefault(ticket_number, {"labels": [], "shipment": shipment})
|
||||
raw_by_ticket[ticket_number]["labels"].append(label)
|
||||
|
||||
return [
|
||||
self._to_normalized_order(
|
||||
ticket_number, tracking_by_ticket[ticket_number], raw_by_ticket[ticket_number]
|
||||
)
|
||||
for ticket_number in tracking_by_ticket
|
||||
]
|
||||
|
||||
# -- internals -----------------------------------------------------
|
||||
|
||||
def _fetch_todays_shipments(self) -> List[dict]:
|
||||
headers = {"API-Key": self.api_key, "Accept": "application/json"}
|
||||
|
||||
def _fetch_todays_labels(self) -> List[dict]:
|
||||
today_start = dt.datetime.combine(dt.date.today(), dt.time.min)
|
||||
today_end = today_start + dt.timedelta(days=1)
|
||||
|
||||
all_shipments: List[dict] = []
|
||||
all_labels: List[dict] = []
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
@@ -86,43 +129,64 @@ class ShipStationService(OrderService):
|
||||
"sort_by": "created_at",
|
||||
"sort_dir": "desc",
|
||||
}
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{API_BASE}/shipments",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise ShipStationServiceError(f"Could not reach ShipStation: {exc}") from exc
|
||||
|
||||
if response.status_code == 401:
|
||||
raise ShipStationServiceError(
|
||||
"ShipStation rejected the API key (401). Check it in Settings."
|
||||
)
|
||||
if not response.ok:
|
||||
raise ShipStationServiceError(
|
||||
f"ShipStation returned an error ({response.status_code}): {response.text[:300]}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise ShipStationServiceError(
|
||||
"ShipStation returned a response that wasn't valid JSON."
|
||||
) from exc
|
||||
|
||||
batch = data.get("shipments", [])
|
||||
all_shipments.extend(batch)
|
||||
data = self._get("/labels", params)
|
||||
batch = data.get("labels", [])
|
||||
all_labels.extend(batch)
|
||||
|
||||
total_pages = data.get("pages", 1)
|
||||
if page >= total_pages or not batch:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return all_shipments
|
||||
return all_labels
|
||||
|
||||
def _fetch_shipment(self, shipment_id: str) -> Optional[dict]:
|
||||
try:
|
||||
return self._get(f"/shipments/{shipment_id}")
|
||||
except ShipStationServiceError:
|
||||
# Don't let one bad lookup fail the whole import - this label's
|
||||
# tracking number just won't get matched to a ticket this run.
|
||||
return None
|
||||
|
||||
def _get(self, path: str, params: Optional[dict] = None) -> dict:
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{API_BASE}{path}",
|
||||
params=params,
|
||||
headers=self._headers,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise ShipStationServiceError(f"Could not reach ShipStation: {exc}") from exc
|
||||
|
||||
if response.status_code == 401:
|
||||
raise ShipStationServiceError(
|
||||
"ShipStation rejected the API key (401). Check it in Settings."
|
||||
)
|
||||
if not response.ok:
|
||||
raise ShipStationServiceError(
|
||||
f"ShipStation returned an error ({response.status_code}) for {path}: "
|
||||
f"{response.text[:300]}"
|
||||
)
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise ShipStationServiceError(
|
||||
f"ShipStation returned a response that wasn't valid JSON for {path}."
|
||||
) from exc
|
||||
|
||||
def _extract_ticket_number(self, shipment: dict) -> Optional[str]:
|
||||
# Confirmed against a real payload: both of these can carry the
|
||||
# ticket number directly. Check the more purpose-built field first.
|
||||
for field in ("shipment_number", "external_shipment_id"):
|
||||
value = shipment.get(field)
|
||||
if value and self.ticket_pattern.fullmatch(str(value).strip()):
|
||||
return str(value).strip()
|
||||
|
||||
# Fall back to scanning the whole payload in case it shows up
|
||||
# somewhere else (a tag, a note, etc.) on a differently-shaped
|
||||
# shipment.
|
||||
try:
|
||||
blob = json.dumps(shipment)
|
||||
except (TypeError, ValueError):
|
||||
@@ -131,37 +195,24 @@ class ShipStationService(OrderService):
|
||||
return match.group(0) if match else None
|
||||
|
||||
@staticmethod
|
||||
def _extract_skus(shipment: dict) -> List[str]:
|
||||
items = shipment.get("items") or []
|
||||
skus = [item.get("sku") for item in items if isinstance(item, dict) and item.get("sku")]
|
||||
return skus
|
||||
|
||||
def _to_normalized_order(self, shipment: dict) -> NormalizedOrder:
|
||||
created_raw = shipment.get("created_at")
|
||||
created_at = None
|
||||
if created_raw:
|
||||
try:
|
||||
created_at = dt.datetime.strptime(created_raw[:19], "%Y-%m-%dT%H:%M:%S")
|
||||
except ValueError:
|
||||
created_at = None
|
||||
|
||||
store_id = str(shipment.get("store_id", ""))
|
||||
company = resolve_company_by_store(store_id, self.store_map)
|
||||
skus = self._extract_skus(shipment)
|
||||
ticket_number = self._extract_ticket_number(shipment)
|
||||
external_id = shipment.get("shipment_id", "")
|
||||
|
||||
summary_bits = [b for b in [ticket_number, ", ".join(skus)] if b]
|
||||
summary = " - ".join(summary_bits) or external_id
|
||||
def _to_normalized_order(
|
||||
ticket_number: str, tracking_numbers: List[dict], raw: dict
|
||||
) -> NormalizedOrder:
|
||||
suggested_status = suggest_jira_status(tracking_numbers) or "Tracking Pulled"
|
||||
numbers_display = ", ".join(
|
||||
f"{t['number']} ({'return' if t['is_return'] else 'outgoing'})"
|
||||
for t in tracking_numbers
|
||||
)
|
||||
|
||||
return NormalizedOrder(
|
||||
source="shipstation",
|
||||
external_id=external_id,
|
||||
external_id=ticket_number,
|
||||
ticket_number=ticket_number,
|
||||
company=company,
|
||||
skus=skus,
|
||||
summary=summary,
|
||||
status=shipment.get("shipment_status", ""),
|
||||
source_created_at=created_at,
|
||||
raw_data=shipment,
|
||||
company="", # not used - the JIRA row this merges onto already has one
|
||||
skus=[],
|
||||
tracking_numbers=tracking_numbers,
|
||||
summary=numbers_display,
|
||||
status=suggested_status,
|
||||
source_created_at=None,
|
||||
raw_data=raw,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user