Files
Order-Manager/app/services/shipstation_send.py
T

258 lines
9.5 KiB
Python

"""
Emergency "Send to ShipStation" - for the rare case a ticket needs to
skip the normal daily batch and get to ShipStation right away.
Two independent paths, on purpose (per "in case the API goes down we
still have an option for CSV uploads"):
- export_order_to_shipstation_csv(): writes rows matching your real
upload template exactly (columns confirmed against OAK.csv) - one
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. 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
criteria you set" - meaning your 90 box-packing rules should fire
automatically off the SKU/item data here, same as your CSV import, with
no manual tagging needed. That's the best read of the docs, but it's
not something I can verify without your actual account, so treat the
first live send (API or CSV) as a test: confirm ShipStation packs and
prices it the way a normal order would before trusting it in a real
emergency.
"""
from __future__ import annotations
import csv
import datetime as dt
from pathlib import Path
from typing import List
import requests
from app import config
from app.models import Order
API_BASE = "https://api.shipstation.com/v2"
REQUEST_TIMEOUT_SECONDS = 30
# Column order confirmed against the real ShipStation upload template
# (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and
# is intentionally left out here.
CSV_COLUMNS = [
"Custom field (Email Address)",
"Summary",
"Custom field (Name)",
"Custom field (NPI Number)",
"Custom field (Address 1)",
"Custom field (Address 2)",
"Custom field (City)",
"Custom field (State)",
"Custom field (Zip Code)",
"Issue key",
"Issue id",
"Status",
"deliverables",
"SKU",
"Item Name",
"Quantity",
]
class ShipStationSendError(Exception):
"""Raised for any emergency-send failure, with a message safe to show in the UI."""
def _deliverable_text(sku: str, item_name: str) -> str:
return f"{sku}: {item_name}" if item_name else sku
def build_csv_rows(order: Order) -> List[List[str]]:
"""One row per line item, matching the real template column-for-column.
Falls back to a single row (blank SKU/Item Name) if there are no line
items yet, so the customer/address info is still exportable."""
info = order.shipping_info or {}
line_items = order.line_items or [{"sku": s, "item_name": ""} for s in (order.skus or [])]
if not line_items:
line_items = [{"sku": "", "item_name": ""}]
issue_id = ""
if isinstance(order.raw_data, dict):
issue_id = order.raw_data.get("id", "")
rows = []
for item in line_items:
rows.append(
[
info.get("email", ""),
order.summary,
info.get("name", ""),
info.get("npi", ""),
info.get("address1", ""),
info.get("address2", ""),
info.get("city", ""),
info.get("state", ""),
info.get("zip", ""),
order.ticket_number or order.external_id,
issue_id,
order.status,
_deliverable_text(item.get("sku", ""), item.get("item_name", "")),
item.get("sku", ""),
item.get("item_name", ""),
1, # quantity - not tracked per-item today, defaults to 1 per line
]
)
return rows
def export_order_to_shipstation_csv(orders: List[Order], filepath: str) -> int:
"""Write one or more orders to a CSV in the real upload template shape.
Returns the number of line-item rows written (not the number of orders,
since one order can produce several rows)."""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
row_count = 0
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(CSV_COLUMNS)
for order in orders:
for row in build_csv_rows(order):
writer.writerow(row)
row_count += 1
return row_count
def _store_id_for_company(company: str) -> str:
settings = config.load_settings()
if company == "Signify Health":
return settings.get("SHIPSTATION_SIGNIFY_STORE_ID", "")
if company == "Oak Street Health":
return settings.get("SHIPSTATION_OAKSTREET_STORE_ID", "")
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
create_sales_order: true, so it lands in the Orders tab and (per
ShipStation's own docs on import automation) should pick up your
box-packing rules the same as a CSV-imported order. Returns the
created shipment's JSON on success.
"""
settings = config.load_settings()
api_key = settings.get("SHIPSTATION_API_KEY", "")
if not api_key:
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
store_id = _store_id_for_company(order.company)
if not store_id:
raise ShipStationSendError(
f"No ShipStation Store ID configured for '{order.company}'. "
"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(
"This ticket is missing address information (address/city/state/zip) - "
"can't create a shipment without a destination."
)
line_items = order.line_items or [{"sku": s, "item_name": ""} for s in (order.skus or [])]
if not line_items:
raise ShipStationSendError("This ticket has no SKUs/line items to send.")
ticket_number = order.ticket_number or order.external_id
payload = {
"shipments": [
{
"create_sales_order": True,
"store_id": store_id,
"warehouse_id": warehouse_id,
"external_shipment_id": ticket_number,
"shipment_number": ticket_number,
"ship_to": {
"name": info.get("name", ""),
"phone": info.get("phone", ""),
"company_name": order.company,
"address_line1": info.get("address1", ""),
"address_line2": info.get("address2", "") or None,
"city_locality": info.get("city", ""),
"state_province": info.get("state", ""),
"postal_code": info.get("zip", ""),
"country_code": "US",
},
"items": [
{
"sku": item.get("sku", ""),
"name": item.get("item_name") or item.get("sku", ""),
"quantity": 1,
}
for item in line_items
],
}
]
}
try:
response = requests.post(
f"{API_BASE}/shipments",
json=payload,
headers={"API-Key": api_key, "Accept": "application/json"},
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
if response.status_code == 401:
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
if not response.ok:
raise ShipStationSendError(
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
)
try:
data = response.json()
except ValueError as exc:
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
shipments = data.get("shipments", [])
if not shipments:
raise ShipStationSendError(f"ShipStation didn't return a created shipment: {data}")
result = shipments[0]
if result.get("errors"):
raise ShipStationSendError(f"ShipStation reported errors: {result['errors']}")
return result