""" 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, )