ShipStation tracking pull, status tracking, dashboard rework

This commit is contained in:
2026-08-26 11:28:42 -05:00
parent ffaaa0c8c8
commit 18be8588f6
16 changed files with 769 additions and 304 deletions
+1
View File
@@ -22,6 +22,7 @@ class NormalizedOrder(TypedDict):
ticket_number: Optional[str]
company: str
skus: List[str]
tracking_numbers: List[dict]
summary: str
status: str
source_created_at: Optional[dt.datetime]
+51 -3
View File
@@ -17,7 +17,9 @@ import requests
from app import config
from app.companies import parse_mapping, resolve_company_for_skus
from app.queries import get_open_ticket_numbers
from app.services.base import OrderService, NormalizedOrder
from app.status_rules import parse_status_list
SEARCH_PAGE_SIZE = 50
REQUEST_TIMEOUT_SECONDS = 30
@@ -58,6 +60,12 @@ class JiraService(OrderService):
settings["COMPANY_SKU_MAP"] or config.DEFAULT_COMPANY_SKU_MAP
)
# Statuses at which we stop re-checking a ticket for changes -
# see fetch_orders() for why we re-check at all.
self.terminal_statuses = parse_status_list(
settings["JIRA_TERMINAL_STATUSES"] or config.DEFAULT_TERMINAL_STATUSES
)
@staticmethod
def _split_field_ids(raw: str) -> List[str]:
return [f.strip() for f in (raw or "").split(",") if f.strip()]
@@ -72,12 +80,51 @@ class JiraService(OrderService):
"JIRA Site URL, Email, API Token, and JQL."
)
issues = self._search_all_issues()
# Your JQL (e.g. "created >= startOfDay()") only catches NEW
# tickets. On its own, that would miss a ticket that gets
# cancelled a day or two after it was created, since it no
# longer matches "created today". So on top of your JQL, we
# also re-check every previously-imported JIRA ticket that
# hasn't reached a terminal status yet (JIRA_TERMINAL_STATUSES,
# default just "Cancelled") - that's how a later cancellation
# gets picked up.
recheck_keys = get_open_ticket_numbers("jira", self.terminal_statuses)
effective_jql = self._build_effective_jql(self.jql, recheck_keys)
issues = self._search_all_issues(effective_jql)
return [self._to_normalized_order(issue) for issue in issues]
# -- internals -----------------------------------------------------
def _search_all_issues(self) -> List[dict]:
@staticmethod
def _build_effective_jql(base_jql: str, recheck_keys: List[str]) -> str:
"""
Combine the configured JQL with "OR key in (...)" for tickets we
want to re-check, being careful to keep any ORDER BY clause at
the very end (JQL requires it there).
Note: as the number of still-open tracked tickets grows, this
"key in (...)" list grows too. If that ever gets unwieldy, add
more statuses to JIRA_TERMINAL_STATUSES (e.g. "Done", once you
know your workflow's real terminal status names) so fulfilled
tickets stop being re-checked and drop out of this list.
"""
if not recheck_keys:
return base_jql
order_by_match = re.search(r"\bORDER BY\b.*$", base_jql, re.IGNORECASE)
if order_by_match:
where_part = base_jql[: order_by_match.start()].strip()
order_by_clause = " " + order_by_match.group(0)
else:
where_part = base_jql.strip()
order_by_clause = ""
keys_clause = "key in (" + ", ".join(recheck_keys) + ")"
combined_where = f"({where_part}) OR {keys_clause}" if where_part else keys_clause
return combined_where + order_by_clause
def _search_all_issues(self, jql: str) -> List[dict]:
url = f"{self.base_url}/rest/api/3/search"
auth = (self.email, self.api_token)
headers = {"Accept": "application/json"}
@@ -92,7 +139,7 @@ class JiraService(OrderService):
while True:
params = {
"jql": self.jql,
"jql": jql,
"startAt": start_at,
"maxResults": SEARCH_PAGE_SIZE,
"fields": fields,
@@ -221,6 +268,7 @@ class JiraService(OrderService):
ticket_number=ticket_number,
company=company,
skus=skus,
tracking_numbers=[],
summary=summary,
status=(fields.get("status") or {}).get("name", ""),
source_created_at=created_at,
+137 -86
View File
@@ -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,
)