More Shipstation integrations(warehouses)

This commit is contained in:
2026-08-31 15:00:37 -05:00
parent 4c172c1fd5
commit 9651b82415
12 changed files with 337 additions and 139 deletions
+16 -18
View File
@@ -19,7 +19,7 @@ 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
from app.status_rules import get_active_statuses
SEARCH_PAGE_SIZE = 50
REQUEST_TIMEOUT_SECONDS = 30
@@ -75,11 +75,9 @@ class JiraService(OrderService):
"npi": settings["JIRA_FIELD_NPI"].strip(),
}
# 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
)
# The one (or few) statuses that mean "still needs work" - see
# fetch_orders() for why we re-check tickets that are still active.
self.active_statuses = get_active_statuses()
@staticmethod
def _split_field_ids(raw: str) -> List[str]:
@@ -97,13 +95,13 @@ class JiraService(OrderService):
# 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)
# cancelled - or reaches any other status - 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's still in an active status (ACTIVE_STATUSES,
# default just "Created") - that's how a later status change
# gets picked up, no matter what it changes to.
recheck_keys = get_open_ticket_numbers("jira", self.active_statuses)
effective_jql = self._build_effective_jql(self.jql, recheck_keys)
issues = self._search_all_issues(effective_jql)
@@ -118,11 +116,11 @@ class JiraService(OrderService):
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.
Note: as the number of still-active tracked tickets grows, this
"key in (...)" list grows too. Since ACTIVE_STATUSES defaults to
just "Created", a ticket drops out of this list the moment it
moves to anything else, so this naturally stays bounded to
what's genuinely still unprocessed.
"""
if not recheck_keys:
return base_jql
+28 -1
View File
@@ -10,7 +10,11 @@ still have an option for CSV uploads"):
row per line item, customer/address info repeated on each row.
- send_order_to_shipstation_api(): calls ShipStation's V2 API
directly (POST /v2/shipments with create_sales_order: true) to
create the order without leaving the app.
create the order without leaving the app. Needs a Warehouse ID per
company (SHIPSTATION_SIGNIFY_WAREHOUSE_ID /
SHIPSTATION_OAKSTREET_WAREHOUSE_ID) - ShipStation requires knowing
where the package ships FROM, either via a warehouse or an explicit
ship_from address; only the warehouse path is wired up today.
IMPORTANT - please verify the first real send: ShipStation's docs say
automation rules apply tags to orders "when they import based on any
@@ -134,6 +138,15 @@ def _store_id_for_company(company: str) -> str:
return ""
def _warehouse_id_for_company(company: str) -> str:
settings = config.load_settings()
if company == "Signify Health":
return settings.get("SHIPSTATION_SIGNIFY_WAREHOUSE_ID", "")
if company == "Oak Street Health":
return settings.get("SHIPSTATION_OAKSTREET_WAREHOUSE_ID", "")
return ""
def send_order_to_shipstation_api(order: Order) -> dict:
"""
Creates the order directly in ShipStation via POST /v2/shipments with
@@ -154,6 +167,19 @@ def send_order_to_shipstation_api(order: Order) -> dict:
"Add it in Settings under ShipStation."
)
# ShipStation needs to know where the package ships FROM - either a
# configured warehouse, or an explicit ship_from address. Only the
# warehouse path is wired up today; fail clearly here rather than
# sending an incomplete request and getting ShipStation's less
# actionable "ship_from is required when warehouse_id is not present".
warehouse_id = _warehouse_id_for_company(order.company)
if not warehouse_id:
raise ShipStationSendError(
f"No ShipStation Warehouse ID configured for '{order.company}'. "
"Add it in Settings under ShipStation - find it in ShipStation under "
"Settings > Shipping > Warehouses/Ship From Locations."
)
info = order.shipping_info or {}
if not (info.get("address1") and info.get("city") and info.get("state") and info.get("zip")):
raise ShipStationSendError(
@@ -172,6 +198,7 @@ def send_order_to_shipstation_api(order: Order) -> dict:
{
"create_sales_order": True,
"store_id": store_id,
"warehouse_id": warehouse_id,
"external_shipment_id": ticket_number,
"shipment_number": ticket_number,
"ship_to": {