From 82e6148ca9fd87116809179cb8a57c36665c2226 Mon Sep 17 00:00:00 2001 From: Sanjay Padole Date: Wed, 16 Sep 2026 16:24:20 -0500 Subject: [PATCH] FKN SHITSTATION AND THEIR GODDAMN SANDBOX BS - Sandbox will be it's own separate entity --- CLAUDE.md | 317 +++++++++++++++++++++++++++++++ app/config.py | 29 ++- app/services/shipstation_send.py | 118 +++++++++++- app/ui/main_window.py | 87 ++++++++- 4 files changed, 531 insertions(+), 20 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..891aacc --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/app/config.py b/app/config.py index 055c65a..7ecfc0a 100644 --- a/app/config.py +++ b/app/config.py @@ -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, "") diff --git a/app/services/shipstation_send.py b/app/services/shipstation_send.py index 74f23f9..54c3684 100644 --- a/app/services/shipstation_send.py +++ b/app/services/shipstation_send.py @@ -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 diff --git a/app/ui/main_window.py b/app/ui/main_window.py index e94b731..ba419f6 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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()