168 lines
6.0 KiB
Python
168 lines
6.0 KiB
Python
"""
|
|
ShipStation order source (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.
|
|
|
|
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.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
import re
|
|
from typing import 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
|
|
|
|
API_BASE = "https://api.shipstation.com/v2"
|
|
PAGE_SIZE = 100
|
|
REQUEST_TIMEOUT_SECONDS = 30
|
|
|
|
|
|
class ShipStationServiceError(Exception):
|
|
"""Raised for any ShipStation fetch failure, with a message safe to show in the UI."""
|
|
|
|
|
|
class ShipStationService(OrderService):
|
|
name = "shipstation"
|
|
|
|
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
|
|
)
|
|
|
|
def is_configured(self) -> bool:
|
|
return bool(self.api_key and self.store_map)
|
|
|
|
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."
|
|
)
|
|
|
|
shipments = self._fetch_todays_shipments()
|
|
|
|
# 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]
|
|
|
|
return [self._to_normalized_order(s) for s in relevant]
|
|
|
|
# -- internals -----------------------------------------------------
|
|
|
|
def _fetch_todays_shipments(self) -> List[dict]:
|
|
headers = {"API-Key": self.api_key, "Accept": "application/json"}
|
|
|
|
today_start = dt.datetime.combine(dt.date.today(), dt.time.min)
|
|
today_end = today_start + dt.timedelta(days=1)
|
|
|
|
all_shipments: List[dict] = []
|
|
page = 1
|
|
|
|
while True:
|
|
params = {
|
|
"created_at_start": today_start.isoformat() + "Z",
|
|
"created_at_end": today_end.isoformat() + "Z",
|
|
"page": page,
|
|
"page_size": PAGE_SIZE,
|
|
"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)
|
|
|
|
total_pages = data.get("pages", 1)
|
|
if page >= total_pages or not batch:
|
|
break
|
|
page += 1
|
|
|
|
return all_shipments
|
|
|
|
def _extract_ticket_number(self, shipment: dict) -> Optional[str]:
|
|
try:
|
|
blob = json.dumps(shipment)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
match = self.ticket_pattern.search(blob)
|
|
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
|
|
|
|
return NormalizedOrder(
|
|
source="shipstation",
|
|
external_id=external_id,
|
|
ticket_number=ticket_number,
|
|
company=company,
|
|
skus=skus,
|
|
summary=summary,
|
|
status=shipment.get("shipment_status", ""),
|
|
source_created_at=created_at,
|
|
raw_data=shipment,
|
|
)
|