21 KiB
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:
statusinACTIVE_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:
- A standalone return label (
POST /v2/labelswithis_return_label: true, no linked outbound shipment) reportsstatus: completedbut 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. - The fix:
create_dummy_shipment()creates a minimal outbound label (1x1x1in, 1oz, cheapest service) first. Itslabel_idis passed asoutbound_label_idwhen creating the real return label viacreate_return_label_from_dummy(). Both are inshipstation_send.py. - This is deliberately a two-step UI flow, not one atomic action (
return_label_dialog.pymain_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_idon the Order persists step 1's result across sessions.
external_shipment_idpopulates ShipStation's "Order #" column - confirmed via ShipStation's own docs. It must be unique per account, so the dummy uses{ticket_number}-DUMMYand the real return label uses the bare{ticket_number}.warehouse_idandship_fromare mutually exclusive in a/v2/labelsrequest - 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 explicitship_fromaddress. See_create_dummy_outbound_label().- ShipStation's
/v2/labels/{label_id}/returnendpoint exists and auto-swaps ship_to/ship_from, but does NOT accept a customshipment.packagesoverride - it inherits the original outbound's package. That's why this project does NOT use that endpoint; it usesPOST /v2/labelswithis_return_label: true+outbound_label_idset 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. - A
carrier_id(orstore_id/warehouse_id) missing these-prefix produces a confusing "not found" error -_normalize_shipstation_id()inshipstation_send.pyauto-prependsse-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. - ShipStation's own error responses appear to strip the
se-prefix when echoing backfield_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). - 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
namefield is overridden to "Rubicon MD" (RUBICONMD_RETURN_NAMEsetting). 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(). - 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) andOAKSTREET_RETURN_CARRIER_ID(se-599657) both exist and are the expected UPS accounts.SHIPSTATION_SIGNIFY_WAREHOUSE_ID(se-180473) andSHIPSTATION_OAKSTREET_WAREHOUSE_ID(se-437417) both exist (named "Signify" and "OAKM" respectively).ups_ground(both*_RETURN_SERVICE_CODEsettings) is a valid service code on both carrier accounts.store_idcould 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):
- Company mismatch: a ticket's SKUs resolve to more than one company (SH + OK present together). Simple, unambiguous.
- 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_KEYWORDSsetting. - Device-type keywords are shared with
serial_suggestions.py'sDEVICE_FIELD_SUGGESTIONS(same vocabulary, different use) - includes anIEprefix note: Signify'sIE400/IE401SKUs are deprecated, deliberately not added toCOMPANY_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):
PackTicketDialogandReturnLabelDialogused 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 viaset_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/jqlendpoint (the old/rest/api/3/searchwas removed by Atlassian) - paginated vianextPageToken, 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 theJIRA_URLsetting 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_IDsettings got saved. Then a second, separate test-mode gap surfaced right after: an "invalid store" error on Oak Street's production store_id, becauseTEST_SHIPSTATION_*_STORE_IDis blank and there is no API-based fix for that one (see ShipStation Test Mode above) - bothTEST_SHIPSTATION_SIGNIFY_STORE_IDandTEST_SHIPSTATION_OAKSTREET_STORE_IDstill 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,assigneeare 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.