FKN SHITSTATION AND THEIR GODDAMN SANDBOX BS - Sandbox will be it's own separate entity

This commit is contained in:
2026-09-16 16:24:20 -05:00
parent b5570d39f0
commit 82e6148ca9
4 changed files with 531 additions and 20 deletions
+317
View File
@@ -0,0 +1,317 @@
# Order Manager - Project Context
PyQt6 desktop app for a daily order-processing workflow: JIRA tickets -> ShipStation
labels -> Odoo fulfillment. One `Order` row per JIRA ticket; ShipStation only ever
enriches existing rows, never creates them. Two companies (Signify Health, Oak Street
Health) plus a subsidiary (RubiconMD) with its own naming quirk - see below.
This file exists because this project was built up over a very long conversation with
Claude in claude.ai, iterating and debugging interactively. It's written so a fresh
Claude Code session (or a new person) doesn't have to rediscover any of this the hard
way. Read this before making changes, especially to `shipstation_send.py`,
`ticket_validation.py`, or anything touching return labels.
## Architecture
```
main.py entry point
app/
adf.py Atlassian Document Format -> plain text parser
companies.py SKU prefix -> company resolution (COMPANY_SKU_MAP)
config.py SETTINGS_SCHEMA, defaults, test-mode-aware lookups
database.py SQLAlchemy engine + auto-migration (ADD COLUMN on startup)
models.py Order table (single source of truth for all fields)
queries.py get_open_ticket_numbers()
return_labels.py is_emailed_label_order() - SH007/OK012 detection
schedule.py cutoff time / past-cutoff logic
serial_suggestions.py device-keyword -> serial-field suggestions for Pack Ticket
status_rules.py active/cancelled status list parsing
ticket_validation.py SKU validation rules (company mismatch, return/device mismatch)
tracking.py suggest_jira_status() from tracking numbers
external_links.py JIRA/Google Maps URL builders (View in JIRA, Look Up Address)
workers.py QThread workers + save_orders()/load_orders_by_view()/etc.
services/
base.py NormalizedOrder TypedDict, OrderService ABC
jira_service.py JIRA REST API v3 /search/jql, paginated
shipstation_service.py ShipStation tracking-number pull (bulk fetch)
shipstation_send.py ALL ShipStation write operations - see below, this is the
file with the most hard-won context in the whole project
odoo_export.py CSV export
ui/
main_window.py Menu bar (File/Data/Orders) + slim toolbar, 3 tabs
settings_dialog.py Scrollable, grouped by SETTINGS_SCHEMA category
widgets/
dashboard.py Stat cards
orders_table.py QAbstractTableModel - see PERFORMANCE section below
order_detail_dialog.py Raw payload, tracking, validation issues, JIRA/Maps links
pack_ticket_dialog.py Serial number entry, barcode-scanner-friendly
return_label_dialog.py Two-step dummy-then-return UI - see RETURN LABELS below
```
## Order model (key columns)
`id, source, external_id, ticket_number, company, skus (JSON), line_items (JSON),
shipping_info (JSON), creator, assignee, description, tracking_numbers (JSON),
shipping_method, serial_numbers (JSON), packed, packed_at, summary, status,
source_created_at, imported_at, fulfilled_at, cancelled_at, shipstation_sent_at,
dummy_outbound_label_id, raw_data`
`dummy_outbound_label_id` is the newest column - it persists step 1 of the return-label
flow (see below) so step 2 can be triggered separately, even in a later session.
All timestamps use **local time** (`dt.datetime.now()`), not UTC - this was a real,
confirmed bug early on (evening cancellations failed same-day checks under UTC).
## The three tabs
- **Active**: `status` in `ACTIVE_STATUSES` ("Created")
- **Cancelled**: cancelled status AND `cancelled_at.date() == today` - ages into Done at midnight
- **Done**: everything else
## Return labels - the hard part of this project
This is the single most iterated-on feature and the one most likely to bite you if
touched carelessly. Sequence of what was learned, in order, because the reasoning
matters for not re-breaking it:
1. **A standalone return label (`POST /v2/labels` with `is_return_label: true`, no
linked outbound shipment) reports `status: completed` but is invisible in
ShipStation's UI.** This is confirmed against the team's actual manual process, not
just API docs: they always create a cheap "dummy" outbound shipment first, then
generate the return label from it via ShipStation's own GUI.
2. **The fix**: `create_dummy_shipment()` creates a minimal outbound label (1x1x1in,
1oz, cheapest service) first. Its `label_id` is passed as `outbound_label_id` when
creating the real return label via `create_return_label_from_dummy()`. Both are in
`shipstation_send.py`.
3. **This is deliberately a two-step UI flow, not one atomic action** (`return_label_dialog.py`
+ `main_window.py`'s `_on_return_label_dialog_finished`). Clicking "Create Return
Label" the first time on a ticket only creates the dummy and stops - the button
label changes to "Step 2" and the user is expected to verify the dummy in
ShipStation before proceeding. This was an explicit ask: splitting the steps apart
means a problem in one doesn't get masked by the other. `dummy_outbound_label_id` on
the Order persists step 1's result across sessions.
4. **`external_shipment_id` populates ShipStation's "Order #" column** - confirmed via
ShipStation's own docs. It must be **unique per account**, so the dummy uses
`{ticket_number}-DUMMY` and the real return label uses the bare `{ticket_number}`.
5. **`warehouse_id` and `ship_from` are mutually exclusive** in a `/v2/labels` request -
confirmed by a real ShipStation 400 error ("ship_from and warehouse_id cannot be
provided in same request"). When a warehouse_id is configured, it's used alone
(ShipStation resolves the address from the registered warehouse record); otherwise
the code falls back to an explicit `ship_from` address. See
`_create_dummy_outbound_label()`.
6. **ShipStation's `/v2/labels/{label_id}/return` endpoint exists and auto-swaps
ship_to/ship_from, but does NOT accept a custom `shipment.packages` override** - it
inherits the original outbound's package. That's why this project does NOT use that
endpoint; it uses `POST /v2/labels` with `is_return_label: true` +
`outbound_label_id` set instead (Method 1 in ShipStation's docs), which supports
full custom packages on the return label itself. Multiple packages per return label
already works this way (one call, packages array) - no additional work needed there.
7. **A `carrier_id` (or `store_id`/`warehouse_id`) missing the `se-` prefix produces a
confusing "not found" error** - `_normalize_shipstation_id()` in `shipstation_send.py`
auto-prepends `se-` to any bare numeric ID at every lookup site (store, warehouse,
carrier), since this is an easy typo when copying IDs out of ShipStation's UI.
8. **ShipStation's own error responses appear to strip the `se-` prefix when echoing
back `field_value`, regardless of what was actually sent.** Don't assume a bare
number in an error message necessarily means the prefix is missing in your request -
it may just mean the ID genuinely doesn't exist in that account (see Test Mode below,
this is exactly what happened with carrier_id and warehouse_id there).
9. **RubiconMD (RMD-prefixed SKUs) is an Oak Street subsidiary**: classified as "Oak
Street Health" everywhere (dashboard, filters, company mismatch checks), but the
return label's address `name` field is overridden to "Rubicon MD"
(`RUBICONMD_RETURN_NAME` setting). Everything else (carrier, service, address,
phone) is identical to Oak Street's - RubiconMD does NOT have its own shipping
account. See `_is_rubiconmd_order()` / `_return_address_for_order()`.
10. **The tracking number is never auto-copied to the clipboard** - there's an explicit
"Copy Tracking Number" button in the success dialog instead. This was a deliberate
reversal: an earlier version auto-copied and that was flagged as risky (staff use
the clipboard constantly for other things; silently overwriting it loses data).
## ShipStation Test Mode
Built so the team can test the whole return-label flow without spending real money or
needing to void mistakes. Toggle: Data menu checkbox, also mirrored as a permanent
orange status-bar banner when active (`_test_mode_indicator`).
**Design**: every account-specific ShipStation setting (API key, store/warehouse ID,
return carrier/service code) has a `TEST_`-prefixed override
(`config.get_shipstation_setting()` in `config.py`). When test mode is on, **only**
the `TEST_` value is used - no fallback to production. This used to fall back to the
production value when the `TEST_` override was blank, on the theory the sandbox might
share IDs with production - confirmed against ShipStation's own docs that it never
does ("Sandbox data is isolated from production data... anything you create in the
sandbox will not be accessible in production, or vice-versa" -
docs.shipstation.com/apis/shipengine/docs/getting-started/sandbox). That fallback is
what produced three separate confusing "not found"/"invalid" errors in a row (carrier,
then warehouse, then store) before it was caught and removed - a sandbox key being
handed a production ID isn't a soft mismatch, it's a guaranteed rejection. The team's
sandbox is a separate ShipEngine-heritage test account entirely, confirmed via
`list_carriers()`/`list_warehouses()` diagnostics (Data menu - "List ShipStation
Carriers/Warehouses..."). Confirmed real test carrier: `se-6366092` (UPS) for both
companies, since there's one shared sandbox account. Because there's no fallback now,
every `TEST_*` setting a flow touches must be filled in explicitly, even ones that are
plain strings rather than account-specific IDs (e.g. `TEST_SIGNIFY_RETURN_SERVICE_CODE`/
`TEST_OAKSTREET_RETURN_SERVICE_CODE` were blank and got set to `ups_ground` to match
production - safe because a service code isn't sandbox/production-isolated data, it's
just a carrier capability string).
**Warehouses don't exist by default in a sandbox account** - `list_warehouses()`
correctly returning an empty list in test mode wasn't a bug, there was just nothing
there yet to list. Fixed with `create_warehouse()` / `create_test_warehouse_for_company()`
in `shipstation_send.py` (confirmed request shape against ShipStation's own
`POST /v2/warehouses` docs - `name` + `origin_address`, with
`address_residential_indicator` required) and a "Create Test Warehouse(s)..." Data-menu
action that reuses the existing `SIGNIFY_RETURN_*`/`OAKSTREET_RETURN_*` address settings
as the origin address (same physical address, just registered under the sandbox
account) and auto-saves the resulting `warehouse_id` into
`TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID` / `TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID`.
**There is no way to list store IDs, in test mode or production - confirmed a dead
end.** `list_stores()` used to call `GET /v2/stores`, which doesn't exist (plain 404,
"No route matched with those values"). Checked against ShipStation's own V2 OpenAPI
reference: there is no Stores/Marketplaces section at all - `store_id` only ever shows
up as an *input* field on label/shipment requests, never as a listable resource. Their
own help docs confirm the only ways to get a store_id are ShipStation support looking
it up, or the legacy V1 API (different auth - key+secret Basic Auth, not the single
V2 API-Key header this app uses everywhere). `list_stores()` now raises a clear error
saying so instead of a confusing 404; the Data-menu item was relabeled "About
ShipStation Store IDs..." accordingly. **The only real fix**: log into the
ShipStation account's own UI (Settings > Store Setup) - for test mode, that means the
TEST/sandbox account specifically, not production - find or create a manual store,
and copy its store_id into `TEST_SHIPSTATION_SIGNIFY_STORE_ID` /
`TEST_SHIPSTATION_OAKSTREET_STORE_ID` by hand. This was caught mid-debugging: a
"Return label creation failed" / "invalid store" error on `store_id: 367672` (Oak
Street's *production* `se-367672`, prefix stripped in the echoed error per point 8
above) - happening because `TEST_SHIPSTATION_OAKSTREET_STORE_ID` was still blank.
**Production readiness check, 2026-09-16 (read-only, no labels created, no cost)**:
before a first real production test of the return-label flow, ran `GET /v2/carriers`,
`GET /v2/warehouses`, and `GET /v2/carriers/{id}/services` directly against the
production API key and diffed the results against `.env`:
- `SIGNIFY_RETURN_CARRIER_ID` (`se-350817`) and `OAKSTREET_RETURN_CARRIER_ID`
(`se-599657`) both exist and are the expected UPS accounts.
- `SHIPSTATION_SIGNIFY_WAREHOUSE_ID` (`se-180473`) and
`SHIPSTATION_OAKSTREET_WAREHOUSE_ID` (`se-437417`) both exist (named "Signify" and
"OAKM" respectively).
- `ups_ground` (both `*_RETURN_SERVICE_CODE` settings) is a valid service code on both
carrier accounts.
- **`store_id` could NOT be checked** - no listing endpoint exists (see above). This
is the one remaining unknown going into a real test. Per the "Known-pending" section
below, end-to-end production verification of this flow was still unconfirmed as of
this check - so a first real shipment is a genuine first test, not a regression
check. If it fails, expect the same "invalid store" error shape as the test-mode one;
the fix then is re-checking the store_id in ShipStation's own UI, not the code.
**Real limitations of ShipStation's sandbox (confirmed via their own docs, not
assumed) that constrain what test mode can actually verify:**
- Branded Labels / Branded Tracking Pages are NOT available in sandbox. This directly
affects the emailed-return-label feature: the "Send Return Label" branded-email step
(the actual point of the feature) cannot be tested end-to-end in test mode. Only
label *creation* can be verified there; the branded email step still needs a real
production test eventually.
- 20 requests/minute rate limit (much lower than production) - plausible to hit during
heavy iterative testing, and would likely present as a confusing generic-looking error.
- Tracking events require real packages in a real carrier network - sandbox can't
simulate them, so Pull Tracking Numbers won't return anything for test-mode shipments.
Expected, not a bug.
- Sales Orders API (used by the emergency "Send to ShipStation" feature specifically,
not the return-label flow) is in beta and may not work in sandbox at all.
## Performance - a real, fixed bug worth knowing about
`ticket_validation.py`'s `validate_ticket()` and `return_labels.py`'s
`is_emailed_label_order()` both read Settings from disk internally
(`config.load_settings()` / `config.get()`, which re-parse `.env` every call). Calling
either of these inside `OrdersTableModel.data()` - which Qt invokes constantly, for
every visible cell, every repaint - was a severe, real performance bug (measured:
2.7 seconds of UI freeze per refresh with 300 orders, and visibly janky scrolling).
**Fixed** by computing both once per `set_orders()` call (not per cell) and caching the
result per `order.id` (`self._issues_by_id`, `self._is_emailed_label_by_id` in
`orders_table.py`). Also hoisted the underlying Settings reads themselves out of the
per-order loop via `make_validation_context()` (fetches sku_map/keyword_map/exempt_keywords
once, passed into every `validate_ticket()` call in the batch) - measured improvement:
2749ms -> 19ms for a 300-order refresh. **If you add a new per-order computed column,
compute it once in `set_orders()` and cache it - never call anything that reads
Settings from inside `data()`.**
## Ticket validation rules (flagging only, never auto-cancels)
`ticket_validation.py` - two rules, confirmed against real business logic and real SKU
catalogs (Oak Street + Signify deliverables spreadsheets, not guessed):
1. **Company mismatch**: a ticket's SKUs resolve to more than one company (SH + OK
present together). Simple, unambiguous.
2. **Return/device mismatch**: a "Shipping - Return Label/Box X" line item's implied
device type doesn't match anything else on the ticket. Two valid pairings, both
treated as satisfying the rule:
- An actual device line item of the same type (break-fix: send the asset, return
the same type).
- **Another shipping item of the same type** (asset recovery: a box AND a label for
the same device, e.g. `SH002` "Return Box iPad" + `SH011` "Return Label iPad" -
confirmed as a valid combination, NOT a mismatch, against real examples:
SH002/SH011, OK001/OK006, OK011/OK013).
- Exempt return types (no device expected at all): emailed labels, DPS Device,
scheduled pickups, padded envelopes - `RETURN_DEVICE_EXEMPT_KEYWORDS` setting.
- Device-type keywords are shared with `serial_suggestions.py`'s
`DEVICE_FIELD_SUGGESTIONS` (same vocabulary, different use) - includes an `IE`
prefix note: Signify's `IE400/IE401` SKUs are **deprecated**, deliberately not
added to `COMPANY_SKU_MAP`.
This is intentionally flag-only. Cancellation is a deliberate manual step in JIRA
(requires a reason, is audited, requires someone's JIRA account) - there is no
JIRA-write capability anywhere in this app, by design, and that's not expected to
change without a deliberate separate conversation about it.
## Other things worth knowing
- **Bitdefender ATC crash (Windows-only, resolved)**: `PackTicketDialog` and
`ReturnLabelDialog` used to crash the whole process on close - confirmed via crash
dump analysis to be Bitdefender Endpoint Security's Advanced Threat Control
corrupting a stack frame inside Qt6Core.dll, not a bug in this code. Fixed by
showing both dialogs non-modally (`.show()` instead of `.exec()` - avoids the nested
event loop `.exec()` runs) and reusing a single persistent instance per dialog type
via `set_order()` rather than constructing/destroying one per ticket. See the
WORKAROUND NOTE docstrings in both dialog files before changing how they're shown.
- **Menu bar, not just a toolbar**: File / Data / Orders menus hold everything; a slim
toolbar duplicates only the 3 highest-frequency actions (Import from JIRA, Pack
Ticket, Create Return Label) using the *same* QAction objects, so there's nothing to
keep in sync between the two.
- **JIRA import uses the newer `/rest/api/3/search/jql` endpoint** (the old
`/rest/api/3/search` was removed by Atlassian) - paginated via `nextPageToken`, with
defensive anti-loop guards (stops on empty batch, `isLast`, missing/repeated token,
or a 200-page hard ceiling).
- **View in JIRA** defensively strips any `/rest/...` API path that might have ended up
baked into the `JIRA_URL` setting before building the browse link - the setting is
meant to be the bare site domain.
- **SKU company map** (`COMPANY_SKU_MAP`): `SH:Signify Health,OK:Oak Street
Health,RMD:Oak Street Health` - RMD added after confirming it's a subsidiary, not
its own company.
## Known-pending / not yet built
- **Immediate next step, as of the last working session**: the warehouse_id issue is
now fixed code-side ("Create Test Warehouse(s)..." in the Data menu, see ShipStation
Test Mode above) but hasn't been run/confirmed yet - run it, confirm the two
`TEST_SHIPSTATION_*_WAREHOUSE_ID` settings got saved. Then a *second*, separate
test-mode gap surfaced right after: an "invalid store" error on Oak Street's
production store_id, because `TEST_SHIPSTATION_*_STORE_ID` is blank and there is no
API-based fix for that one (see ShipStation Test Mode above) - both
`TEST_SHIPSTATION_SIGNIFY_STORE_ID` and `TEST_SHIPSTATION_OAKSTREET_STORE_ID` still
need to be filled in by hand from the sandbox account's own ShipStation UI before the
emailed-return-label flow can be tested end-to-end in test mode.
- **EOD JIRA push**: deliberately deferred. `packed`, `serial_numbers`,
`tracking_numbers`, `shipping_method`, `assignee` are all captured and ready for it
whenever it's prioritized - would need the exact JIRA custom field IDs (outbound
tracking slots 1-4, RMA number, serial fields) from the team.
- **Bulk JIRA -> ShipStation import**: still an external, separate process (their own
CSV-conversion tool) - this app only handles the single-ticket emergency send case.
A bigger, riskier build if ever tackled (rate limits, bulk automation behavior).
- **Return-label flow real-world verification**: the two-step dummy-then-return flow
is fully tested at the code level (unit tests covering both success and
void-on-failure paths), but end-to-end confirmation that it behaves correctly with
ShipStation's actual production API, for the actual team, is still in progress as of
the last working session - don't assume it's fully proven in production just because
the code is correct and tests pass.
- **JIRA write access / auto-cancellation**: explicitly declined by the team so far
(cancellation requires a JIRA account and an audited reason) - don't build this
without a deliberate, separate conversation about it first.
+19 -10
View File
@@ -319,16 +319,25 @@ def is_shipstation_test_mode() -> bool:
def get_shipstation_setting(key: str) -> str:
"""
Test-mode-aware lookup for ShipStation-related settings (API key,
store/warehouse/carrier IDs). When SHIPSTATION_TEST_MODE is on, looks
for a TEST_{key} override first, falling back to the normal {key}
value if the override is blank - so test mode works immediately if
your test/sandbox account mirrors production's store/warehouse/
carrier IDs, while still letting you override specific values if your
test account uses different ones. When test mode is off, this is
identical to config.get(key).
store/warehouse/carrier IDs, service codes). When SHIPSTATION_TEST_MODE
is on, ONLY the TEST_{key} value is used - no fallback to the
production value. When test mode is off, this is identical to
config.get(key).
This used to fall back to the production value when the TEST_ override
was blank, on the theory the sandbox might share IDs with production.
Confirmed against ShipStation's own docs that it never does - "Sandbox
data is isolated from production data... anything you create in the
sandbox will not be accessible in production, or vice-versa." A
fallback to a production store/warehouse/carrier ID under a sandbox API
key isn't a convenience, it's a guaranteed rejection - and it's exactly
what produced three separate confusing "not found"/"invalid" errors in
a row (carrier, then warehouse, then store) before this was caught.
Every caller of this function already raises a clear, specific
"X is not configured" error when it gets back an empty string, so
isolating test mode fully just turns those three confusing
ShipStation-side rejections into one obvious message instead.
"""
if is_shipstation_test_mode():
test_value = get(f"TEST_{key}", "")
if test_value:
return test_value
return get(f"TEST_{key}", "")
return get(key, "")
+111 -7
View File
@@ -81,18 +81,44 @@ def list_carriers() -> List[dict]:
def list_stores() -> List[dict]:
"""
Same idea as list_carriers() but for GET /v2/stores - the test/sandbox
account almost certainly has different store IDs than production too
(confirmed the same is true for carriers), so this is worth checking
before it becomes the next "not found" error rather than after.
DEAD END, kept only so nobody re-attempts this the hard way: ShipStation's
V2 API has NO stores/marketplaces listing endpoint. GET /v2/stores returns
a plain 404 ("No route matched with those values") - confirmed against
ShipStation's own V2 OpenAPI reference, which lists every real section
(Carriers, Warehouses, Connections, etc.) and where store_id only ever
appears as an INPUT field on label/shipment requests (example value
"se-12345"), never as its own resource with a list/get endpoint. This
matches ShipStation's help docs too, which say the only ways to get a
store_id are (1) ShipStation support looks it up for you, or (2) the
legacy V1 API's List Stores call - different auth (key+secret Basic
Auth), not the single V2 API-Key header this app uses everywhere else.
Bottom line: there is no way to discover a store_id from inside this
app. Log into the ShipStation account's own UI (Settings > Store Setup
/ Selling Channels) to find or create a manual store and read its ID
from there - for test mode specifically, that means logging into the
TEST/sandbox account, not production.
"""
raise ShipStationSendError(
"ShipStation's V2 API has no endpoint to list stores (confirmed - GET /v2/stores "
"doesn't exist, it 404s). Log into the ShipStation account's own UI under "
"Settings > Store Setup to find the store_id, then enter it in Settings - for "
"test mode, log into the TEST/sandbox account and set the TEST_ override."
)
def list_warehouses() -> List[dict]:
"""Same idea as list_carriers()/list_stores() but for GET /v2/warehouses -
fixes the exact next error in the sequence (warehouse_id not found),
same root cause as the carrier_id one: the test/sandbox account is a
completely separate ShipStation environment with its own IDs."""
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
if not api_key:
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
try:
response = requests.get(
f"{API_BASE}/stores",
f"{API_BASE}/warehouses",
headers={"API-Key": api_key, "Accept": "application/json"},
timeout=REQUEST_TIMEOUT_SECONDS,
)
@@ -111,8 +137,86 @@ def list_stores() -> List[dict]:
except ValueError as exc:
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
# Some ShipStation accounts return a bare list, others wrap it - handle both.
return data if isinstance(data, list) else data.get("stores", [])
return data if isinstance(data, list) else data.get("warehouses", [])
def create_warehouse(name: str, origin_address: dict) -> dict:
"""POST /v2/warehouses - creates a new warehouse for whichever API key
is currently active. Needed because a sandbox/test ShipStation account
starts with zero warehouses (confirmed against ShipStation's own docs):
list_warehouses() correctly returning an empty list in test mode wasn't
a bug, there was just nothing there yet to list. Returns the created
warehouse's JSON (includes warehouse_id - the value that goes into
TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID / TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID)."""
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
if not api_key:
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
payload = {"name": name, "origin_address": origin_address}
try:
response = requests.post(
f"{API_BASE}/warehouses",
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:
return response.json()
except ValueError as exc:
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
def _origin_address_for_company(company: str) -> dict:
"""Same physical address as _return_address_for_order's ship-from/
ship-to address (SIGNIFY_RETURN_*/OAKSTREET_RETURN_* settings) - a
warehouse's origin_address is just that address registered with
ShipStation. address_residential_indicator is required by
POST /v2/warehouses; this is always a business address."""
settings = config.load_settings()
prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_"
return {
"name": settings.get(f"{prefix}NAME", ""),
"phone": settings.get(f"{prefix}PHONE", ""),
"company_name": company,
"address_line1": settings.get(f"{prefix}ADDRESS1", ""),
"address_line2": settings.get(f"{prefix}ADDRESS2", "") or None,
"city_locality": settings.get(f"{prefix}CITY", ""),
"state_province": settings.get(f"{prefix}STATE", ""),
"postal_code": settings.get(f"{prefix}ZIP", ""),
"country_code": "US",
"address_residential_indicator": "no",
}
def create_test_warehouse_for_company(company: str) -> dict:
"""Creates a warehouse for whichever API key is currently active
(callers should confirm test mode is on first - this isn't something
you want to accidentally run against production) using the company's
own return-address settings as the origin_address. Returns the created
warehouse's JSON."""
origin_address = _origin_address_for_company(company)
required_fields = (
"name", "phone", "address_line1", "city_locality", "state_province", "postal_code",
)
missing = [field for field in required_fields if not origin_address.get(field)]
if missing:
prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_"
raise ShipStationSendError(
f"{prefix}* address settings for {company} are incomplete (missing: "
f"{', '.join(missing)}). Fill those in under Settings > Return Labels first."
)
return create_warehouse(f"{company} (Test)", origin_address)
# Column order confirmed against the real ShipStation upload template
# (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and
+84 -3
View File
@@ -159,14 +159,31 @@ class MainWindow(QMainWindow):
list_carriers_action.triggered.connect(self._on_list_carriers_clicked)
data_menu.addAction(list_carriers_action)
list_stores_action = QAction("List ShipStation Stores...", self)
list_stores_action = QAction("About ShipStation Store IDs...", self)
list_stores_action.setStatusTip(
"Shows the store IDs actually valid for whichever API key is currently active "
"(test or production)"
"ShipStation's V2 API has no way to list stores - explains where to find a "
"store_id instead"
)
list_stores_action.triggered.connect(self._on_list_stores_clicked)
data_menu.addAction(list_stores_action)
list_warehouses_action = QAction("List ShipStation Warehouses...", self)
list_warehouses_action.setStatusTip(
"Shows the warehouse IDs actually valid for whichever API key is currently active "
"(test or production)"
)
list_warehouses_action.triggered.connect(self._on_list_warehouses_clicked)
data_menu.addAction(list_warehouses_action)
create_test_warehouses_action = QAction("Create Test Warehouse(s)...", self)
create_test_warehouses_action.setStatusTip(
"Test Mode only - the sandbox account starts with zero warehouses, so this "
"creates one per company (using the same return-address settings) and saves "
"the resulting IDs into TEST_SHIPSTATION_*_WAREHOUSE_ID"
)
create_test_warehouses_action.triggered.connect(self._on_create_test_warehouses_clicked)
data_menu.addAction(create_test_warehouses_action)
orders_menu = menu_bar.addMenu("&Orders")
self._pack_ticket_action = QAction("Pack Ticket", self)
@@ -305,6 +322,70 @@ class MainWindow(QMainWindow):
)
QMessageBox.information(self, f"ShipStation Stores ({mode})", "\n".join(lines))
def _on_list_warehouses_clicked(self) -> None:
from app.services.shipstation_send import list_warehouses, ShipStationSendError
mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION"
try:
warehouses = list_warehouses()
except ShipStationSendError as exc:
QMessageBox.critical(self, "Could not list warehouses", str(exc))
return
if not warehouses:
QMessageBox.information(
self,
f"ShipStation Warehouses ({mode})",
f"No warehouses are set up in this {mode.lower()} ShipStation account.",
)
return
lines = [f"Warehouses visible to your current {mode} API key:", ""]
for warehouse in warehouses:
lines.append(
f" warehouse_id: {warehouse.get('warehouse_id', '?')} "
f"{warehouse.get('name', '?')}"
)
QMessageBox.information(self, f"ShipStation Warehouses ({mode})", "\n".join(lines))
def _on_create_test_warehouses_clicked(self) -> None:
if not config.is_shipstation_test_mode():
QMessageBox.information(
self,
"Test Mode is off",
"Turn on ShipStation Test Mode first (Data menu). This creates a "
"warehouse in whichever ShipStation account your API key currently "
"points to, and it's meant for the sandbox account only.",
)
return
from app.services.shipstation_send import (
create_test_warehouse_for_company,
ShipStationSendError,
)
companies = [
("Signify Health", "TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID"),
("Oak Street Health", "TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID"),
]
lines = []
for company, setting_key in companies:
existing = config.load_settings().get(setting_key)
if existing:
lines.append(f"{company}: already set to {existing} - skipped")
continue
try:
warehouse = create_test_warehouse_for_company(company)
except ShipStationSendError as exc:
lines.append(f"{company}: FAILED - {exc}")
continue
warehouse_id = warehouse.get("warehouse_id", "")
config.save_settings({setting_key: warehouse_id})
lines.append(f"{company}: created {warehouse_id} and saved it to {setting_key}")
QMessageBox.information(self, "Create Test Warehouse(s)", "\n".join(lines))
def _on_order_double_clicked(self, order) -> None:
dialog = OrderDetailDialog(order, self)
dialog.exec()