258 lines
9.5 KiB
Python
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
|