Compare commits

..
11 Commits
Author SHA1 Message Date
madminandClaude Sonnet 5 d8f59b7725 Fix external_shipment_id placement and test-mode store_id ticket persistence in the return-label flow
external_shipment_id was at the top level of the POST /v2/labels payload in both
_create_dummy_outbound_label() and create_return_label_from_dummy() - ShipStation's
schema only supports it nested under shipment, so it was silently ignored and
auto-generated ("SEAuto-...") on every label this flow ever created. Moved it inside
the shipment dict in both functions. Confirmed end-to-end on a real production
ticket (AR-166098): Order # now correctly shows the real ticket number, and
ship_from/ship_to are correct for a return.

Also: store_id is now only required in production - ShipStation's sandbox
environment cannot have stores/Order Sources at all (confirmed in the real sandbox
dashboard), so test mode omits it from the request instead of requiring an
impossible value. And three per-ticket write-backs (mark_shipstation_sent,
save_dummy_outbound_label_id, save_pack_data) were hardcoded to source == "jira",
silently no-opping for source == "test" tickets created by Create Test Shipment -
now match on ticket_number alone, since source is only ever "jira" or "test" and
ticket_number is already unique across both.

CLAUDE.md records the full investigation and closes out the long-standing
"return-label flow real-world verification" known-pending item.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-01 15:53:54 -05:00
madminandClaude Sonnet 5 a7d2e7870c Add Create Test Shipment (Email SKU) for testing the return-label flow, plus test-mode/store_id investigation notes
Adds synthetic source="test" tickets (create_test_shipment_order/delete_test_shipments)
carrying a company's real emailed-return-label SKU, so Step 1/Step 2 can be exercised
against the sandbox without a real JIRA ticket or risking a real customer's. Gated to
Test Mode - the same flow against production would create a real paid shipment.

CLAUDE.md also captures this session's ShipStation sandbox findings: store_id is
schema-optional but empirically required for label visibility, the newer ship15 web UI's
URL is NOT the se- store ID (confirmed via direct probe - different failure shape than a
wrong-but-well-formed ID), and two separate test stores/carriers are needed per company,
mirroring production.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-28 11:45:09 -05:00
madmin 82e6148ca9 FKN SHITSTATION AND THEIR GODDAMN SANDBOX BS - Sandbox will be it's own separate entity 2026-09-16 16:24:20 -05:00
madmin b5570d39f0 More email SKU shenanigans, now with more TEST labels! 2026-09-14 11:41:27 -05:00
madmin a6f60408ff Added RubbbbiconMD SKU and updated SIGGNIGFY. Fixed some SKU mismatch bugs 2026-09-04 12:19:40 -05:00
madmin 52bd6f5da2 Rework of workflow, added the dock back. This is now the beginning of the SKU mismatch issues. FKN FUN TIMES AHEAD 2026-09-04 12:09:49 -05:00
madmin a1b438a61f Major crashing bug fixed, program returned to normal operations 2026-09-03 10:03:50 -05:00
madmin edd5f2a886 Email procedure (beta), WorkFlow update 2026-09-02 11:44:54 -05:00
madmin 826a70bdf6 EMailed label return generation (alpha) 2026-09-02 09:30:19 -05:00
madmin 6655b02f4a Major JQL bugfixes 2026-09-01 16:30:54 -05:00
madmin bf5566e911 TimeZone Bug Fix 2026-09-01 14:12:19 -05:00
22 changed files with 3411 additions and 71 deletions
+81 -1
View File
@@ -54,9 +54,29 @@ SHIPSTATION_OAKSTREET_STORE_ID=se-367672
SHIPSTATION_SIGNIFY_WAREHOUSE_ID=se-180473 SHIPSTATION_SIGNIFY_WAREHOUSE_ID=se-180473
SHIPSTATION_OAKSTREET_WAREHOUSE_ID=se-437417 SHIPSTATION_OAKSTREET_WAREHOUSE_ID=se-437417
# --- ShipStation Test Mode ---
# When on, every ShipStation action (emergency send, tracking pull, return
# labels) uses your test/sandbox API key instead of production - no real
# charges, nothing appears in your production ShipStation account.
# Toggleable from the Data menu too (stays in sync with this setting).
SHIPSTATION_TEST_MODE=false
TEST_SHIPSTATION_API_KEY=
# Each of these is optional - leave blank and test mode falls back to using
# your production value (useful if your test/sandbox account happens to
# mirror production's store/warehouse/carrier IDs). Fill one in only if
# your test account actually uses a different ID for that specific thing.
TEST_SHIPSTATION_SIGNIFY_STORE_ID=
TEST_SHIPSTATION_OAKSTREET_STORE_ID=
TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID=
TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID=
TEST_SIGNIFY_RETURN_CARRIER_ID=se-6366092
TEST_SIGNIFY_RETURN_SERVICE_CODE=ups_ground
TEST_OAKSTREET_RETURN_CARRIER_ID=se-6366092
TEST_OAKSTREET_RETURN_SERVICE_CODE=ups_ground
# --- Companies --- # --- Companies ---
# SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies. # SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies.
COMPANY_SKU_MAP=SH:Signify Health,OK:Oak Street Health COMPANY_SKU_MAP=SH:Signify Health,OK:Oak Street Health,RMD:Oak Street Health
# Fallback pattern for recognizing the AR-###### ticket number if it's ever # Fallback pattern for recognizing the AR-###### ticket number if it's ever
# not found in ShipStation's usual shipment_number/external_shipment_id fields. # not found in ShipStation's usual shipment_number/external_shipment_id fields.
TICKET_NUMBER_REGEX=\b[A-Z]{2,6}-\d{3,}\b TICKET_NUMBER_REGEX=\b[A-Z]{2,6}-\d{3,}\b
@@ -82,3 +102,63 @@ FULFILLED_STATUS_WITHOUT_RETURN=Device Return Not Needed
# arrival day are flagged "Past Cutoff" in the table and counted on the # arrival day are flagged "Past Cutoff" in the table and counted on the
# Dashboard - they didn't get a full window to be processed same-day. # Dashboard - they didn't get a full window to be processed same-day.
INTAKE_CUTOFF_TIME=15:30 INTAKE_CUTOFF_TIME=15:30
# --- Return Labels (SH007 / OK012 - emailed return labels) ---
# SKUs that mean "create + email a return label" instead of a normal outbound kit.
EMAILED_LABEL_SKUS=SH007,OK012
# Each company has its own UPS account even though they share one warehouse.
SIGNIFY_RETURN_CARRIER_ID=se-350817
# Assumption pending your confirmation - you gave the carrier/account but not
# a specific service level. Defaulting to UPS Ground; change if you use
# something else for returns. See ShipStation's carrier services list for
# other valid codes (e.g. ups_2nd_day_air, ups_ground_saver).
SIGNIFY_RETURN_SERVICE_CODE=ups_ground
OAKSTREET_RETURN_CARRIER_ID=se-599657
OAKSTREET_RETURN_SERVICE_CODE=ups_ground
# When you're charged for a return label: on_creation / on_carrier_acceptance
# (only charged if the customer actually uses it - needs the carrier to have
# enabled this on your account first) / carrier_default.
SHIPSTATION_RETURN_CHARGE_EVENT=carrier_default
# Both companies return to the same warehouse, just under their own name -
# confirmed real address below. (Occasional shipments to company HQ instead
# of the warehouse aren't handled yet - flagged for later.)
SIGNIFY_RETURN_NAME=Signify Health
SIGNIFY_RETURN_PHONE=469-718-0004
SIGNIFY_RETURN_ADDRESS1=1000 Spinks Road Suite 100
SIGNIFY_RETURN_ADDRESS2=
SIGNIFY_RETURN_CITY=Lewisville
SIGNIFY_RETURN_STATE=TX
SIGNIFY_RETURN_ZIP=75067
OAKSTREET_RETURN_NAME=Oak Street Health
OAKSTREET_RETURN_PHONE=469-718-0004
OAKSTREET_RETURN_ADDRESS1=1000 Spinks Road Suite 100
OAKSTREET_RETURN_ADDRESS2=
OAKSTREET_RETURN_CITY=Lewisville
OAKSTREET_RETURN_STATE=TX
OAKSTREET_RETURN_ZIP=75067
# RubiconMD - Oak Street subsidiary (RMD-prefixed SKUs). Uses Oak Street's
# own shipping account/address in every respect - this is the one thing
# that's actually different, the name shown on the return label.
RUBICONMD_RETURN_NAME=Rubicon MD
# --- Packing ---
# Serial-number fields to suggest per device keyword found in a ticket's line
# items, as a starting point in the Pack Ticket dialog (staff can always add
# a custom field for anything not matched here). Format:
# keyword:Field One|Field Two,keyword2:Field Three
DEVICE_FIELD_SUGGESTIONS=laptop:Laptop Serial Number|Laptop Asset Tag,optiplex:OptiPlex Serial Number|OptiPlex Asset Tag,desktop:Desktop Serial Number|Desktop Asset Tag,phone:Phone IMEI|Phone Serial Number|Phone ICCID|Phone Asset Tag,ipad:iPad IMEI|iPad Serial Number|iPad ICCID|iPad Asset Tag,spiro:Spiro Serial Number|Spiro Asset Tag,apc:UPS/APC Serial Number|UPS/APC Asset Tag,monitor:Monitor Serial Number|Monitor Asset Tag,accessor:Accessory Notes,camera:Camera Serial Number|Camera Asset Tag
# Maps a ShipStation service code (from the outbound label) to your team's
# term for the Shipping Method column - confirmed against your real usage.
SHIPPING_METHOD_LABELS=ups_next_day_air:Priority Overnight,ups_2nd_day_air:Two Day,ups_ground:Ground
# --- Ticket validation ---
# Return types with no matching device expected - exempt from the
# return/device mismatch check (emailed labels, DPS Device, scheduled
# pickups don't have a corresponding device line item by design).
RETURN_DEVICE_EXEMPT_KEYWORDS=emailed,dps,scheduled pickup,padded envelope
+453
View File
@@ -0,0 +1,453 @@
# 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. -
also create_test_shipment_order()/delete_test_shipments()
(synthetic source="test" tickets for exercising the
Create Return Label flow without a real JIRA ticket)
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`.
**SOLVED, 2026-10-01: test mode can NEVER have a store_id - it's a platform limit,
not a configuration gap.** Confirmed directly in ShipEngine/ShipStation's own
dashboard (the actual separate sandbox account, reached via its own distinct sandbox
login - not `ship15.shipstation.com`, which is the production web UI regardless of
how a store there is named): the Sandbox environment's "Connect order sources" flow
explicitly says to **switch to Production to connect order sources**. Stores/Order
Sources structurally do not exist in sandbox at all - unlike carriers and warehouses
(both confirmed to have real sandbox equivalents), this is one ShipStation resource
type the sandbox environment simply doesn't support. No amount of searching the UI,
guessing IDs, or chasing V1 API tricks was ever going to produce a valid
`TEST_SHIPSTATION_*_STORE_ID`, because there is no such thing to find.
**The fix**: `store_id` is now only enforced in PRODUCTION
(`_validate_return_label_prerequisites()` in `shipstation_send.py` checks
`config.is_shipstation_test_mode()` before raising), and left out of the request
entirely when blank (`_create_dummy_outbound_label()` /
`create_return_label_from_dummy()`) rather than sent empty. This was already known to
be API-safe - `shipment.store_id` is not in ShipStation's own required-fields schema,
and a live probe on 2026-09-18 confirmed omitting it succeeds cleanly (`200`,
`status: "completed"`). **Confirmed end-to-end, 2026-10-01**: both Step 1 (dummy
shipment) and Step 2 (return label) now succeed in test mode for both companies with
real sandbox labels (`se-206662629`/`se-206662656` Signify,
`se-206662683`/`se-206662696` Oak Street). Production behavior is unchanged -
store_id is still required there, where it's achievable and still matters for the
originally-confirmed visibility reason (Return Labels point 1 above).
**Two stores were created in the wrong account along the way** - `ship15.shipstation.com`
turned out to be the unified web UI for *all* accounts, test or production, so
creating a store while logged into the normal company login created it in
production regardless of name (`OAKYTEST` id 381922, `Signify Test` id 381921 -
confirmed in the same `GET /stores` V1 API response as the real production Signify/
Oak Street stores, proving shared account). Those two got typed into
`TEST_SHIPSTATION_*_STORE_ID` before the mistake was caught, which actively broke
things again until cleared back to blank - **if Step 1 starts failing with "invalid
store" again, check those two settings are still blank before anything else.**
`list_stores()` still raises a clear dead-end error for the unrelated reason it
always did - `GET /v2/stores` plain doesn't exist in the V2 API (confirmed against
ShipStation's own OpenAPI reference: no Stores/Marketplaces section at all,
`store_id` only ever an *input* field) - that part of the finding stands, it just
turned out not to matter once test mode stopped needing a store_id at all.
**Ceiling discovered 2026-10-02: omitting store_id also silently discards
`external_shipment_id`, so sandbox can verify label *creation* but never ticket
*traceability*.** Confirmed by comparing real labels: querying actual historical
production labels (e.g. real ticket `AR-161051`, created months ago) shows
`external_shipment_id` round-tripping correctly - so the code's placement of that
field is fine and this was never a general bug. But the two sandbox labels created
once store_id started being omitted both came back with ShipStation's own
auto-generated `external_shipment_id` (`SEAuto-...`), not the ticket number we sent -
and `external_order_id`/"Order #" was blank on both, same as the "invisible in the
UI" concern this app's store_id check was originally written to prevent. The
mechanism: no store means no backing Order object, and ShipStation apparently won't
honor a custom `external_shipment_id` without one to attach it to. This isn't
fixable in code - it's the same platform ceiling as the store_id dead-end itself,
just one layer deeper. **Net effect: sandbox can prove the API calls mechanically
succeed (labels get created, no errors), but can never prove ticket-number
traceability works - that joins the branded-email step as something only
verifiable in production.** Treat a successful sandbox Step 1/Step 2 as "the
request shape and credentials are right," not as "this ticket's correlation back to
AR-###### will work" - the latter has only ever been confirmed in production.
**FOUND AND FIXED, 2026-10-01: `external_shipment_id` was in the wrong place in the
request the whole time - a real, longstanding bug, unrelated to any of the
store_id/test-mode work above.** Both `_create_dummy_outbound_label()` and
`create_return_label_from_dummy()` put it at the TOP level of the `POST /v2/labels`
payload. Confirmed against ShipStation's own request schema: it's only ever a field
ON the shipment object (`shipment.external_shipment_id`) - there is no top-level
equivalent for this endpoint, so ShipStation silently ignored ours and
auto-generated its own `SEAuto-...` placeholder instead, on every single label this
flow has ever created. **Fixed** by moving it inside the `shipment` dict in both
functions. **Confirmed end-to-end on a real production ticket (`AR-166098`,
2026-10-01)**: `external_shipment_id` now correctly reads `AR-166098-DUMMY` on the
dummy and `AR-166098` on the return label, and - the actual point of all of this -
**ShipStation's "Order #" column now correctly shows `AR-166098`**, linked to the
ticket's real pre-existing Order record. This had never been confirmed working
before; `CLAUDE.md`'s own "Known-pending" section had been flagging this exact
end-to-end verification as outstanding for a reason. The dummy's `-DUMMY`-suffixed
external_shipment_id will never show an Order # of its own (it doesn't match any
real Order's number, and isn't meant to - it's throwaway per its own docstring); the
real return label's bare ticket number is what matters, and that's the one that
links up.
**Also confirmed correct on that same real ticket, despite how it reads in
ShipStation's UI**: the return label's `ship_from`/`ship_to` are right
(`ship_from` = the real customer's address, `ship_to` = the company warehouse -
exactly the direction a return should go), verified via a direct `GET
/v2/shipments/{id}` call (the ground truth) rather than trusting ShipStation's own
"Return Details" UI tab, which mislabels the warehouse/return-destination address as
"Ship From Address" in that specific summary panel - confusing UI copy on
ShipStation's end, not a bug in this code. If this ever looks wrong again, check the
real shipment data via the API before assuming the code regressed.
**Correlation note**: this app's own tracking-number pull
(`shipstation_service.py::_extract_ticket_number()`) matches `shipment_number`/
`external_shipment_id` against `TICKET_NUMBER_REGEX` using `fullmatch`, not a
substring search - so the dummy's `-DUMMY` suffix was never going to match anyway,
by design, regardless of the bug above. Only the real return label's bare ticket
number needs to (and now does) match correctly.
**A separate, unrelated test-mode gap found 2026-09-18**: `TEST_SIGNIFY_RETURN_CARRIER_ID`/
`TEST_OAKSTREET_RETURN_CARRIER_ID` were blank (only the `*_SERVICE_CODE` halves had
been filled in earlier), producing "No return-label Carrier ID/Service Code
configured" on Step 1. Not a new discovery - just the confirmed shared sandbox test
carrier (`se-6366092`, UPS) from earlier in this section, re-verified live and filled
into both settings. Worth knowing for next time: `TEST_SIGNIFY_RETURN_SERVICE_CODE`/
`TEST_OAKSTREET_RETURN_SERVICE_CODE` being set doesn't imply their carrier-ID
counterparts are - check both halves of a `TEST_*_RETURN_*` pair, not just one.
**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.
**Testing the emailed-return-label SKU flow without a real ticket**: "Create Test
Shipment (Email SKU)..." (Data menu, Test Mode only) creates a synthetic
`source="test"` Order carrying that company's configured emailed-return-label SKU
(resolved from `EMAILED_LABEL_SKUS` + `COMPANY_SKU_MAP` at creation time, not
hardcoded to SH007/OK012) and a placeholder shipping address, then drops it on the
Active tab (`status="Created"`) so it can be selected and run through the *real*
Create Return Label flow - same code path a JIRA ticket uses, no separate test-only
logic to keep in sync. `source="test"` guarantees `save_orders()` (which only ever
matches on `source == "jira"`) can never touch or overwrite one of these, so a real
Import from JIRA is safe to run with test shipments still sitting on the board.
"Delete Test Shipments..." cleans them up by that same `source` marker, real tickets
untouched. **Deliberately gated to Test Mode** - the same flow against production
settings would create a real, paid UPS shipment to a fake address, not just a
sandbox test label.
**Bug found and fixed, 2026-10-02: per-ticket write-backs were hardcoded to
`source == "jira"`**, so they silently no-op'd for `source="test"` tickets.
`mark_shipstation_sent()`, `save_dummy_outbound_label_id()`, and `save_pack_data()`
in `workers.py` all looked up `WHERE source == "jira" AND ticket_number == ...` -
harmless for real tickets, but meant step 1 of the return-label flow on a test
ticket would genuinely succeed in ShipStation (a real label got created) while the
app silently failed to remember it, making "Create Return Label" always restart at
step 1 no matter how many times it was run - and the exact same symptom reproduced
in PRODUCTION too, since it had nothing to do with store_id or test mode at all,
just this lookup. **Fixed** by matching on `ticket_number` alone - `source` is only
ever `"jira"` or `"test"`, and `ticket_number` is already unique across both (test
tickets use the `TEST-EMAIL-` prefix specifically so they can't collide with a real
JIRA number), so there was never a real reason to restrict these three to `"jira"`.
Confirmed end-to-end after the fix: create dummy -> persist -> reload from DB ->
create real return label all succeeded in one real (sandbox) run. **Side effect
worth knowing**: because this bug made every "Create Return Label" click on an
already-stepped-through test ticket silently restart step 1, testing this in
PRODUCTION (while chasing what looked like a store_id problem) may have created an
extra, real, paid dummy shipment under a `TEST-EMAIL-*` ticket - worth checking the
production account for stray dummy shipments and voiding any unwanted ones.
**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
- **RESOLVED 2026-10-01, was the longest-running open item**: the warehouse_id and
store_id test-mode gaps are both fixed and confirmed end-to-end. Warehouse_id just
needed `TEST_SHIPSTATION_*_WAREHOUSE_ID` populated (done). Store_id turned out to
be structurally impossible to fix via settings at all - ShipStation's sandbox
environment cannot have stores/Order Sources, full stop (confirmed in the real
sandbox dashboard, which says to switch to Production to connect one) - so the code
now only requires store_id in production and omits it entirely in test mode. Both
Step 1 and Step 2 of the emailed-return-label flow are confirmed working end-to-end
in test mode for both companies as of this fix (see ShipStation Test Mode above for
the real sandbox label IDs from that test). **This flow is now genuinely testable
in sandbox for the first time** - if there's still an appetite to verify the
branded-email step too, remember that part specifically still requires production
(Branded Labels/Tracking Pages aren't available in sandbox - see the sandbox
limitations list below).
- **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).
- **RESOLVED 2026-10-01: return-label flow real-world verification.** Confirmed
end-to-end on a real production ticket (`AR-166098`) for the first time: Step 1
(dummy) and Step 2 (return label) both succeeded, Order # correctly shows the real
ticket number in ShipStation, and ship_from/ship_to are correct for a return. This
surfaced and fixed a real bug along the way (external_shipment_id placement - see
ShipStation Test Mode above) that had been silently breaking ticket traceability on
every label this flow ever created, sandbox or production, until now. Test labels
from this verification were voided by the team since real tickets were about to be
worked for real.
- **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.
+110 -4
View File
@@ -150,6 +150,52 @@ that doesn't go through this shipment/label flow at all - those
tickets just won't have tracking numbers pulled, which is expected for tickets just won't have tracking numbers pulled, which is expected for
now, not a bug. Flag it when you're ready to handle it. now, not a bug. Flag it when you're ready to handle it.
## Packing: serial numbers and marking Done
Reflects your actual workflow: staff enter serial numbers (mostly via
**barcode scanner**) and mark a ticket packed as devices go into the
box - this is the beginning of what eventually becomes the Ship Sheet,
built directly into the app instead of a separate spreadsheet.
Select a ticket and click **Pack Ticket**:
- **Suggested fields, not a fixed schema.** Device types and their
field sets vary a lot by company and by kit (confirmed against your
real Ship Sheet - Oak Street tracks OptiPlex/Laptop/Phone, Signify
tracks iPad/Spiro/Laptop/Phone x2), so hard-coding columns per device
type would fight the "versatile" requirement. Instead, fields are
suggested from keywords in the ticket's line items
(`DEVICE_FIELD_SUGGESTIONS`) - a starting point staff can always
extend with a custom field. A "Shipping - Return Label X" line item
is deliberately excluded from matching (tested against this - it
describes a label deliverable, not an actual second device).
- **Built for the scanner, not around it.** Each field's Enter key
(which a scanner sends automatically after scanning) jumps focus to
the next field - scan straight through a device list with zero mouse
clicks. After the last field, focus lands on the **Packed** checkbox
rather than auto-submitting, so finishing still takes one deliberate
action.
- **Reopening a partially-packed ticket** preserves whatever was
already scanned and still shows the current suggestions for what's
left - tested explicitly.
- This data is **staff-entered, not sourced from JIRA**, and a JIRA
re-import never touches it - confirmed the update path doesn't
reference `serial_numbers`/`packed` at all. It's exactly the data
the eventual end-of-day JIRA push will need, whenever that gets
built.
**Assignee** (who's working the ticket, from JIRA) and **Shipping
Method** (the outbound label's service, from the ShipStation tracking
pull - `ups_ground` -> "Ground", etc. via `SHIPPING_METHOD_LABELS`,
confirmed against ShipStation's real UPS service codes) are also
pulled in now, matching two more Ship Sheet columns.
**One consequence worth knowing:** since packing data doesn't come
from JIRA, **Reset Local Database now also clears it** - the warning
dialog says so explicitly. Before this update, a reset was always
harmless (just a rebuildable cache); now it isn't, for this one kind
of data.
## Emergency: Send to ShipStation ## Emergency: Send to ShipStation
For the rare case a ticket needs to skip the normal daily batch. Select For the rare case a ticket needs to skip the normal daily batch. Select
@@ -210,6 +256,21 @@ summary. Company is resolved from the SKU prefix (`SH`/`OK`) via
usually blank, the app falls back to the joined deliverable usually blank, the app falls back to the joined deliverable
descriptions for the Summary column when there's nothing else there. descriptions for the Summary column when there's nothing else there.
## Resetting the local database
**Reset Local Database** in the toolbar clears every cached order (with
a confirmation first). This is always safe - JIRA is the real source of
truth, this is just a rebuildable cache - but it's worth knowing when
you'd actually need it: if a ticket transitioned status under an older,
buggy version of this app, its `fulfilled_at`/`cancelled_at` timestamp
can get stuck wrong, since those are only recalculated **at the moment
of a transition** - re-importing an already-transitioned ticket finds
"no status change" and never touches that timestamp again. A reset
clears the stale value entirely; the next Import from JIRA then stamps
everything correctly from scratch. Follow a reset with **Import from
JIRA**, and **Pull Tracking Numbers** if you rely on today's
already-pulled tracking data.
## Moving storage to your MariaDB LXC later ## Moving storage to your MariaDB LXC later
In Settings (or directly in `.env`), change: In Settings (or directly in `.env`), change:
@@ -261,9 +322,54 @@ app/
`_on_export_clicked` calls `export_orders_to_csv`. `_on_export_clicked` calls `export_orders_to_csv`.
3. Wire it up the same way the JIRA/ShipStation buttons are. 3. Wire it up the same way the JIRA/ShipStation buttons are.
## Emailed return labels (SH007 / OK012)
A different workflow entirely from the normal outbound-kit tickets: a
customer already has product to return, and needs a return label
emailed to them. Select a ticket in Active Orders and click **Create
Return Label**:
- Shows the ticket's **Description** (parsed from JIRA's rich-text
format into plain text) - this is where staff note what boxes are
needed, so it's visible without opening JIRA.
- Lets you specify **any number of packages**, each with its own
weight and dimensions - replacing the old fixed "1x1x1, 1oz dummy
ticket" with real per-request control.
- Calls ShipStation directly (`POST /v2/labels` with
`is_return_label: true`). Confirmed against ShipStation's own
return-label docs: **`ship_from` is the customer and `ship_to` is
your warehouse** - reversed from every other label this app creates,
and easy to get backwards, so this was tested explicitly.
- `charge_event` is configurable per your risk preference:
`on_creation` (pay immediately), `on_carrier_acceptance` (only pay if
the customer actually ships it - needs the carrier to enable this on
your account first, can take 3-4 weeks), or `carrier_default`.
**This app does not email the label.** Per your workflow, that
happens from ShipStation itself (Returns tab -> Other Actions -> Send
Return Label) so it goes out through your branded template - something
this app couldn't replicate anyway, since ShipStation doesn't expose
that step through its API as far as I could find. After a label is
created, the ticket's **Uploaded** checkmark is set (same flag the
emergency-send feature uses) so you can see at a glance which
return-label tickets have already had their label created.
Both companies share one physical warehouse but each has its **own
UPS account** (`SIGNIFY_RETURN_CARRIER_ID` / `OAKSTREET_RETURN_CARRIER_ID`)
- the return address is the same, just filed under the right company
name. Occasional shipments to company HQ instead of the shared
warehouse aren't handled yet - flagged for later, per your note.
**Still an assumption pending confirmation:** the service code
(`SIGNIFY_RETURN_SERVICE_CODE` / `OAKSTREET_RETURN_SERVICE_CODE`)
defaults to `ups_ground` since you gave me the carrier/account but not
a specific service level - change it in Settings if that's not right.
## Known open item ## Known open item
The **emailed-label SKU** (mentioned but not detailed yet) needs its You mentioned occasionally shipping return items to a company's
own handling eventually - tell me the SKU and what "done" looks like headquarters instead of the shared warehouse - noted, not built yet
for it when you're ready, and I'll fold it into the terminal-status / since you said we could tackle it later. When you're ready, this would
tracking logic above rather than bolting on something separate. likely be a dropdown in the Return Label dialog (Warehouse vs. HQ)
rather than a new settings group, since the workflow is otherwise
identical.
+45
View File
@@ -0,0 +1,45 @@
"""
JIRA's Description field comes back as Atlassian Document Format (ADF) -
a nested JSON tree, not plain text. This walks it and extracts readable
text, since all we need here is "what did the staff member write about
what boxes are needed", not full document fidelity.
Degrades gracefully on node types it doesn't know (tables, mentions,
emoji, etc.) rather than raising - a partially-extracted description is
far more useful than a crash on an edge case we didn't anticipate.
"""
from __future__ import annotations
from typing import Optional
# Block-level node types that should end with a line break once their
# content has been extracted, so paragraphs/list items don't run together.
_BLOCK_TYPES = {"paragraph", "listItem", "heading", "codeBlock", "blockquote"}
def adf_to_text(adf: Optional[dict]) -> str:
if not isinstance(adf, dict):
return ""
lines: list[str] = []
_walk(adf, lines)
# Collapse the accumulated fragments, trim stray blank lines from
# nested block boundaries.
text = "".join(lines)
return "\n".join(line.rstrip() for line in text.split("\n")).strip()
def _walk(node: dict, lines: list[str]) -> None:
node_type = node.get("type")
if node_type == "text":
lines.append(node.get("text", ""))
elif node_type == "hardBreak":
lines.append("\n")
for child in node.get("content", []) or []:
if isinstance(child, dict):
_walk(child, lines)
if node_type in _BLOCK_TYPES:
lines.append("\n")
+159 -1
View File
@@ -75,6 +75,54 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
False, False,
), ),
"SHIPSTATION_TEST_MODE": (
"Use ShipStation test/sandbox API key instead of production (true/false) - "
"also toggleable from the Data menu",
"ShipStation Test Mode",
False,
),
"TEST_SHIPSTATION_API_KEY": ("ShipStation Test/Sandbox API Key", "ShipStation Test Mode", True),
"TEST_SHIPSTATION_SIGNIFY_STORE_ID": (
"Test override: Signify Store ID (blank = use production value)",
"ShipStation Test Mode",
False,
),
"TEST_SHIPSTATION_OAKSTREET_STORE_ID": (
"Test override: Oak Street Store ID (blank = use production value)",
"ShipStation Test Mode",
False,
),
"TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID": (
"Test override: Signify Warehouse ID (blank = use production value)",
"ShipStation Test Mode",
False,
),
"TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID": (
"Test override: Oak Street Warehouse ID (blank = use production value)",
"ShipStation Test Mode",
False,
),
"TEST_SIGNIFY_RETURN_CARRIER_ID": (
"Test override: Signify Return Carrier ID (blank = use production value)",
"ShipStation Test Mode",
False,
),
"TEST_SIGNIFY_RETURN_SERVICE_CODE": (
"Test override: Signify Return Service Code (blank = use production value)",
"ShipStation Test Mode",
False,
),
"TEST_OAKSTREET_RETURN_CARRIER_ID": (
"Test override: Oak Street Return Carrier ID (blank = use production value)",
"ShipStation Test Mode",
False,
),
"TEST_OAKSTREET_RETURN_SERVICE_CODE": (
"Test override: Oak Street Return Service Code (blank = use production value)",
"ShipStation Test Mode",
False,
),
"COMPANY_SKU_MAP": ( "COMPANY_SKU_MAP": (
"SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)", "SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)",
"Companies", "Companies",
@@ -86,6 +134,65 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
False, False,
), ),
"EMAILED_LABEL_SKUS": (
"SKUs that mean 'create + email a return label' (comma-separated)",
"Return Labels",
False,
),
"DEVICE_FIELD_SUGGESTIONS": (
"Serial-number fields to suggest per device keyword "
"(keyword:Field One|Field Two,keyword2:Field Three)",
"Packing",
False,
),
"SHIPPING_METHOD_LABELS": (
"ShipStation service code -> your term (e.g. ups_ground:Ground)",
"Packing",
False,
),
"RETURN_DEVICE_EXEMPT_KEYWORDS": (
"Return types with no matching device expected (comma-separated keywords)",
"Packing",
False,
),
"SIGNIFY_RETURN_CARRIER_ID": ("Signify Return: ShipStation Carrier ID", "Return Labels", False),
"SIGNIFY_RETURN_SERVICE_CODE": (
"Signify Return: Service code (e.g. ups_ground)",
"Return Labels",
False,
),
"OAKSTREET_RETURN_CARRIER_ID": ("Oak Street Return: ShipStation Carrier ID", "Return Labels", False),
"OAKSTREET_RETURN_SERVICE_CODE": (
"Oak Street Return: Service code (e.g. ups_ground)",
"Return Labels",
False,
),
"SHIPSTATION_RETURN_CHARGE_EVENT": (
"When to be charged: on_creation / on_carrier_acceptance / carrier_default",
"Return Labels",
False,
),
"SIGNIFY_RETURN_NAME": ("Signify Return Address: Name", "Return Labels", False),
"SIGNIFY_RETURN_PHONE": ("Signify Return Address: Phone", "Return Labels", False),
"SIGNIFY_RETURN_ADDRESS1": ("Signify Return Address: Address 1", "Return Labels", False),
"SIGNIFY_RETURN_ADDRESS2": ("Signify Return Address: Address 2", "Return Labels", False),
"SIGNIFY_RETURN_CITY": ("Signify Return Address: City", "Return Labels", False),
"SIGNIFY_RETURN_STATE": ("Signify Return Address: State", "Return Labels", False),
"SIGNIFY_RETURN_ZIP": ("Signify Return Address: Zip", "Return Labels", False),
"OAKSTREET_RETURN_NAME": ("Oak Street Return Address: Name", "Return Labels", False),
"OAKSTREET_RETURN_PHONE": ("Oak Street Return Address: Phone", "Return Labels", False),
"OAKSTREET_RETURN_ADDRESS1": ("Oak Street Return Address: Address 1", "Return Labels", False),
"OAKSTREET_RETURN_ADDRESS2": ("Oak Street Return Address: Address 2", "Return Labels", False),
"OAKSTREET_RETURN_CITY": ("Oak Street Return Address: City", "Return Labels", False),
"OAKSTREET_RETURN_STATE": ("Oak Street Return Address: State", "Return Labels", False),
"OAKSTREET_RETURN_ZIP": ("Oak Street Return Address: Zip", "Return Labels", False),
# RubiconMD - an Oak Street subsidiary (RMD-prefixed SKUs). Classified as
# Oak Street Health everywhere else. It uses Oak Street's own shipping
# account and address in every respect - this is the one thing that's
# actually different, the name shown on the return label.
"RUBICONMD_RETURN_NAME": ("RubiconMD Return Address: Name (uses Oak Street's account/address otherwise)", "Return Labels", False),
"ACTIVE_STATUSES": ( "ACTIVE_STATUSES": (
"Statuses that count as real active work (comma-separated) - " "Statuses that count as real active work (comma-separated) - "
"everything else is treated as done", "everything else is treated as done",
@@ -115,13 +222,33 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
} }
DEFAULT_DB_URL = "sqlite:///orders.db" DEFAULT_DB_URL = "sqlite:///orders.db"
DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health" DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health,RMD:Oak Street Health"
DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b" DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b"
DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return" DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return"
DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed" DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed"
DEFAULT_INTAKE_CUTOFF_TIME = "15:30" DEFAULT_INTAKE_CUTOFF_TIME = "15:30"
DEFAULT_ACTIVE_STATUSES = "Created" DEFAULT_ACTIVE_STATUSES = "Created"
DEFAULT_CANCELLED_STATUSES = "Cancelled" DEFAULT_CANCELLED_STATUSES = "Cancelled"
DEFAULT_EMAILED_LABEL_SKUS = "SH007,OK012"
DEFAULT_DEVICE_FIELD_SUGGESTIONS = (
"laptop:Laptop Serial Number|Laptop Asset Tag,"
"optiplex:OptiPlex Serial Number|OptiPlex Asset Tag,"
"desktop:Desktop Serial Number|Desktop Asset Tag,"
"phone:Phone IMEI|Phone Serial Number|Phone ICCID|Phone Asset Tag,"
"ipad:iPad IMEI|iPad Serial Number|iPad ICCID|iPad Asset Tag,"
"spiro:Spiro Serial Number|Spiro Asset Tag,"
"apc:UPS/APC Serial Number|UPS/APC Asset Tag,"
"monitor:Monitor Serial Number|Monitor Asset Tag,"
"accessor:Accessory Notes,"
"camera:Camera Serial Number|Camera Asset Tag"
)
# Confirmed against ShipStation's own UPS service code reference, not guessed.
DEFAULT_SHIPPING_METHOD_LABELS = (
"ups_next_day_air:Priority Overnight,"
"ups_2nd_day_air:Two Day,"
"ups_ground:Ground"
)
DEFAULT_RETURN_CHARGE_EVENT = "carrier_default"
def ensure_env_file_exists() -> None: def ensure_env_file_exists() -> None:
@@ -183,3 +310,34 @@ def save_settings(values: Dict[str, str]) -> None:
def get(key: str, default: str = "") -> str: def get(key: str, default: str = "") -> str:
"""Convenience getter, e.g. config.get('DB_URL').""" """Convenience getter, e.g. config.get('DB_URL')."""
return load_settings().get(key, default) or default return load_settings().get(key, default) or default
def is_shipstation_test_mode() -> bool:
return get("SHIPSTATION_TEST_MODE", "").strip().lower() in ("true", "1", "yes")
def get_shipstation_setting(key: str) -> str:
"""
Test-mode-aware lookup for ShipStation-related settings (API 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():
return get(f"TEST_{key}", "")
return get(key, "")
+48
View File
@@ -0,0 +1,48 @@
"""
Builds URLs for the two "jump out to somewhere else" actions: viewing a
ticket directly in JIRA, and looking up a shipping address on Google
Maps (matching the existing manual workflow of pasting an address into
Google when investigating a validation issue).
Deliberately just URL-building, no browser-launching here - that stays
in the UI layer (main_window.py / order_detail_dialog.py) via
webbrowser.open(), so this module has no GUI dependency and is easy to
test in isolation.
"""
from __future__ import annotations
from urllib.parse import quote
from app import config
def jira_ticket_url(ticket_number: str | None) -> str | None:
if not ticket_number:
return None
jira_url = config.get("JIRA_URL", "").strip()
if not jira_url:
return None
base = jira_url.rstrip("/")
# JIRA_URL is meant to be the bare site domain - app/services/jira_service.py
# appends /rest/api/... itself for API calls, so that suffix should never
# be part of the setting value. Strip it defensively anyway, so a link
# here is always a normal web page rather than an API endpoint even if
# the setting was ever saved with that path already attached.
if "/rest/" in base:
base = base.split("/rest/")[0]
return f"{base}/browse/{ticket_number}"
def google_maps_search_url(shipping_info: dict | None) -> str | None:
info = shipping_info or {}
parts = [
info.get("address1", ""),
info.get("address2", ""),
info.get("city", ""),
info.get("state", ""),
info.get("zip", ""),
]
address = " ".join(p for p in parts if p).strip()
if not address:
return None
return f"https://www.google.com/maps/search/?api=1&query={quote(address)}"
+40 -2
View File
@@ -15,6 +15,7 @@ from sqlalchemy import (
Column, Column,
Integer, Integer,
String, String,
Text,
DateTime, DateTime,
Boolean, Boolean,
JSON, JSON,
@@ -64,12 +65,41 @@ class Order(Base):
# Display name of whoever created the JIRA ticket. # Display name of whoever created the JIRA ticket.
creator = Column(String(200), nullable=True) creator = Column(String(200), nullable=True)
# Display name of whoever the ticket is assigned to (who's working it) -
# distinct from creator (who opened it).
assignee = Column(String(200), nullable=True)
# Plain-text version of the JIRA ticket's Description field (parsed
# from Atlassian Document Format - see app/adf.py). Mainly useful for
# the emailed-return-label workflow, where staff write the box
# requirements here rather than in a structured field.
description = Column(Text, nullable=True)
# Tracking numbers pulled from ShipStation and merged onto this # Tracking numbers pulled from ShipStation and merged onto this
# ticket - e.g. [{"number": "782758401696", "carrier": "ups", # ticket - e.g. [{"number": "782758401696", "carrier": "ups",
# "is_return": false}]. Populated by the "Pull Tracking Numbers" # "is_return": false}]. Populated by the "Pull Tracking Numbers"
# action, separately from the JIRA import. # action, separately from the JIRA import.
tracking_numbers = Column(JSON, nullable=True) tracking_numbers = Column(JSON, nullable=True)
# Service level used for the outbound label (e.g. "Priority Overnight",
# "Ground") - read off the ShipStation label during the same pull that
# gets tracking numbers, not something JIRA knows about.
shipping_method = Column(String(100), nullable=True)
# Freeform {label: value} pairs, e.g. {"Laptop Serial Number": "6NJLP54",
# "Laptop Asset Tag": "30882"} - entered by staff (often via barcode
# scanner) as devices are packed. Deliberately not fixed columns per
# device type: which fields are relevant varies by company and by kit,
# and hard-coding that would fight the "versatile" requirement. See
# app/serial_suggestions.py for how likely fields get suggested from
# the ticket's line items.
serial_numbers = Column(JSON, nullable=True)
# Staff-set "packed and ready to ship" flag - the Ship Sheet's "Done"
# column. Deliberately separate from JIRA's own status: a ticket can be
# packed=True while still sitting in JIRA as "Created", waiting on the
# (currently manual, eventually automated) end-of-day push that sets
# the real JIRA status.
packed = Column(Boolean, nullable=False, default=False)
packed_at = Column(DateTime, nullable=True)
summary = Column(String(500), nullable=False, default="") summary = Column(String(500), nullable=False, default="")
status = Column(String(100), nullable=False, default="") status = Column(String(100), nullable=False, default="")
@@ -77,7 +107,7 @@ class Order(Base):
# When the order/ticket was created in the source system # When the order/ticket was created in the source system
source_created_at = Column(DateTime, nullable=True) source_created_at = Column(DateTime, nullable=True)
# When we pulled it into this app # When we pulled it into this app
imported_at = Column(DateTime, nullable=False, default=dt.datetime.utcnow) imported_at = Column(DateTime, nullable=False, default=dt.datetime.now)
# When this ticket first transitioned into a fulfilled status - used # When this ticket first transitioned into a fulfilled status - used
# for "fulfilled today" counts and as a rough audit trail. Set once, # for "fulfilled today" counts and as a rough audit trail. Set once,
# on the transition; not touched again while it stays fulfilled. # on the transition; not touched again while it stays fulfilled.
@@ -93,8 +123,16 @@ class Order(Base):
# isn't the same as it actually being imported, and this app has no # isn't the same as it actually being imported, and this app has no
# way to confirm that manual step happened. # way to confirm that manual step happened.
shipstation_sent_at = Column(DateTime, nullable=True) shipstation_sent_at = Column(DateTime, nullable=True)
# Label ID of the "dummy" outbound shipment created for the emailed-
# return-label workflow, persisted so it's verifiable (in ShipStation's
# own UI) as a separate step before creating the real return label
# from it - rather than both happening invisibly in one call.
dummy_outbound_label_id = Column(String(64), nullable=True)
# Downstream pipeline flags - useful once Odoo export is fully wired up # Legacy/unused - kept only because SQLite doesn't make dropping a
# column free and nothing reads this. Don't confuse with `packed`
# above (the real "Done" flag) or the JIRA-status-based fulfilled
# concept used for the Done tab/dashboard - this column predates both.
fulfilled = Column(Boolean, nullable=False, default=False) fulfilled = Column(Boolean, nullable=False, default=False)
# Full original payload from JIRA, for anything not modeled explicitly # Full original payload from JIRA, for anything not modeled explicitly
+28
View File
@@ -0,0 +1,28 @@
"""
The emailed-return-label SKUs (SH007 for Signify, OK012 for Oak Street)
mark a ticket as a different kind of request entirely: not an outbound
kit, but a return label to be created and emailed to a client who
already has product to send back. See app/services/shipstation_send.py
for the label-creation side of this.
"""
from __future__ import annotations
from typing import Optional
from app import config
from app.status_rules import parse_status_list
def get_emailed_label_skus() -> set[str]:
return parse_status_list(
config.get("EMAILED_LABEL_SKUS", config.DEFAULT_EMAILED_LABEL_SKUS)
)
def is_emailed_label_order(skus: list[str], emailed_skus: Optional[set] = None) -> bool:
"""emailed_skus can be pre-fetched and passed in when checking many
orders at once (e.g. refreshing the table), to avoid re-reading
Settings from disk for every single order."""
if emailed_skus is None:
emailed_skus = get_emailed_label_skus()
return any((sku or "").strip().lower() in emailed_skus for sku in skus or [])
+70
View File
@@ -0,0 +1,70 @@
"""
Suggests which serial-number fields are probably relevant for a ticket,
based on keywords in its line items (e.g. an item name containing
"Laptop" suggests Laptop Serial Number + Laptop Asset Tag). This is a
starting point staff can edit, not a hard schema - device types and
their field sets vary by company and grow over time, so the mapping
lives in a setting (DEVICE_FIELD_SUGGESTIONS), not code.
If a ticket has two line items that both match "phone" (e.g. a
termination processing two lines), the second gets numbered - "Phone
IMEI (2)" - rather than colliding with the first.
"""
from __future__ import annotations
from typing import List
from app import config
def parse_device_field_suggestions(raw: str) -> dict[str, list[str]]:
"""'keyword:Field One|Field Two,keyword2:Field Three' -> {keyword: [fields]}"""
mapping: dict[str, list[str]] = {}
for group in (raw or "").split(","):
group = group.strip()
if not group or ":" not in group:
continue
keyword, _, fields_str = group.partition(":")
keyword = keyword.strip().lower()
fields = [f.strip() for f in fields_str.split("|") if f.strip()]
if keyword and fields:
mapping[keyword] = fields
return mapping
def get_device_field_suggestions() -> dict[str, list[str]]:
return parse_device_field_suggestions(
config.get("DEVICE_FIELD_SUGGESTIONS", config.DEFAULT_DEVICE_FIELD_SUGGESTIONS)
)
def suggest_serial_fields(line_items: List[dict]) -> List[str]:
"""
line_items: [{"sku": "OK401", "item_name": "Laptop - Dell Latitude..."}]
Returns suggested field labels, in the order their matching keyword
was found, numbered on repeat matches (e.g. two phone line items ->
"Phone IMEI (1)", "Phone IMEI (2)", ...).
Line items describing a shipping/return-label deliverable (e.g.
"Shipping - Return Label Laptop") are skipped - they name a device
type in passing but aren't an actual physical device to serialize,
and would otherwise double-count against the real device line item.
"""
keyword_map = get_device_field_suggestions()
suggestions: List[str] = []
match_counts: dict[str, int] = {}
for item in line_items or []:
item_name = item.get("item_name", "") or ""
if item_name.strip().lower().startswith("shipping"):
continue
haystack = f"{item_name} {item.get('sku', '')}".lower()
for keyword, fields in keyword_map.items():
if keyword in haystack:
match_counts[keyword] = match_counts.get(keyword, 0) + 1
occurrence = match_counts[keyword]
suffix = f" ({occurrence})" if occurrence > 1 else ""
suggestions.extend(f"{field}{suffix}" for field in fields)
return suggestions
+3
View File
@@ -25,7 +25,10 @@ class NormalizedOrder(TypedDict):
line_items: List[dict] line_items: List[dict]
shipping_info: dict shipping_info: dict
creator: Optional[str] creator: Optional[str]
assignee: Optional[str]
description: Optional[str]
tracking_numbers: List[dict] tracking_numbers: List[dict]
shipping_method: Optional[str]
summary: str summary: str
status: str status: str
source_created_at: Optional[dt.datetime] source_created_at: Optional[dt.datetime]
+55 -8
View File
@@ -16,13 +16,18 @@ from typing import List, Tuple
import requests import requests
from app import config from app import config
from app.adf import adf_to_text
from app.companies import parse_mapping, resolve_company_for_skus from app.companies import parse_mapping, resolve_company_for_skus
from app.queries import get_open_ticket_numbers from app.queries import get_open_ticket_numbers
from app.services.base import OrderService, NormalizedOrder from app.services.base import OrderService, NormalizedOrder
from app.status_rules import get_active_statuses from app.status_rules import get_active_statuses
SEARCH_PAGE_SIZE = 50 SEARCH_PAGE_SIZE = 100
REQUEST_TIMEOUT_SECONDS = 30 REQUEST_TIMEOUT_SECONDS = 30
# Hard ceiling on pages per fetch, purely as a backstop against the new
# search endpoint's documented pagination flakiness (tokens that repeat
# or never advance) - normal usage should never come close to this.
MAX_SEARCH_PAGES = 200
# Deliverable values come back like "SH011: Shipping - Return Label iPad - # Deliverable values come back like "SH011: Shipping - Return Label iPad -
# Physical in Box" - the part before the colon is the actual SKU code; # Physical in Box" - the part before the colon is the actual SKU code;
@@ -138,13 +143,29 @@ class JiraService(OrderService):
return combined_where + order_by_clause return combined_where + order_by_clause
def _search_all_issues(self, jql: str) -> List[dict]: def _search_all_issues(self, jql: str) -> List[dict]:
url = f"{self.base_url}/rest/api/3/search" # /rest/api/3/search (with startAt/total pagination) has been
# deprecated and removed by Atlassian - /rest/api/3/search/jql is
# the replacement, using nextPageToken instead. That migration is
# NOT optional: hitting the old endpoint either errors outright or,
# worse, silently stops returning `total`, which made the old
# startAt-based loop here think page 1 was the whole result set -
# exactly the "only pulled tickets from this afternoon onward" bug,
# since with newest-first sorting, page 1 is just the most recent
# slice.
#
# The new endpoint's token pagination has its own documented
# flakiness in the wild (tokens that repeat or never advance), so
# this loop defends against that directly: it stops on an empty
# batch, an explicit isLast, a missing token, OR a token identical
# to one already seen - plus a hard page-count ceiling as a last
# resort so a pagination bug can never turn into an infinite loop.
url = f"{self.base_url}/rest/api/3/search/jql"
auth = (self.email, self.api_token) auth = (self.email, self.api_token)
headers = {"Accept": "application/json"} headers = {"Accept": "application/json"}
# Always pull summary/status/created/creator, plus every configured # Always pull summary/status/created/creator, plus every configured
# deliverable field and contact field. # deliverable field and contact field.
fields = "summary,status,created,creator" fields = "summary,status,created,creator,assignee,description"
if self.all_sku_field_ids: if self.all_sku_field_ids:
fields += "," + ",".join(self.all_sku_field_ids) fields += "," + ",".join(self.all_sku_field_ids)
contact_field_ids = [v for v in self.contact_field_ids.values() if v] contact_field_ids = [v for v in self.contact_field_ids.values() if v]
@@ -152,15 +173,19 @@ class JiraService(OrderService):
fields += "," + ",".join(contact_field_ids) fields += "," + ",".join(contact_field_ids)
all_issues: List[dict] = [] all_issues: List[dict] = []
start_at = 0 next_page_token: str | None = None
seen_tokens: set[str] = set()
page_count = 0
while True: while True:
params = { params = {
"jql": jql, "jql": jql,
"startAt": start_at,
"maxResults": SEARCH_PAGE_SIZE, "maxResults": SEARCH_PAGE_SIZE,
"fields": fields, "fields": fields,
} }
if next_page_token:
params["nextPageToken"] = next_page_token
try: try:
response = requests.get( response = requests.get(
url, url,
@@ -193,12 +218,24 @@ class JiraService(OrderService):
batch = data.get("issues", []) batch = data.get("issues", [])
all_issues.extend(batch) all_issues.extend(batch)
page_count += 1
total = data.get("total", len(all_issues)) new_token = data.get("nextPageToken")
start_at += len(batch) is_last = bool(data.get("isLast")) or not new_token
if start_at >= total or not batch:
if (
not batch
or is_last
or not new_token
or new_token == next_page_token
or new_token in seen_tokens
or page_count >= MAX_SEARCH_PAGES
):
break break
seen_tokens.add(new_token)
next_page_token = new_token
return all_issues return all_issues
def _raw_values_from_field(self, fields: dict, field_id: str) -> List[str]: def _raw_values_from_field(self, fields: dict, field_id: str) -> List[str]:
@@ -286,6 +323,11 @@ class JiraService(OrderService):
creator = fields.get("creator") or {} creator = fields.get("creator") or {}
return creator.get("displayName") or creator.get("emailAddress") or "" return creator.get("displayName") or creator.get("emailAddress") or ""
@staticmethod
def _extract_assignee(fields: dict) -> str:
assignee = fields.get("assignee") or {}
return assignee.get("displayName") or assignee.get("emailAddress") or ""
def _to_normalized_order(self, issue: dict) -> NormalizedOrder: def _to_normalized_order(self, issue: dict) -> NormalizedOrder:
fields = issue.get("fields", {}) fields = issue.get("fields", {})
created_raw = fields.get("created") created_raw = fields.get("created")
@@ -302,6 +344,8 @@ class JiraService(OrderService):
company = resolve_company_for_skus(skus, self.sku_map) company = resolve_company_for_skus(skus, self.sku_map)
shipping_info = self._extract_shipping_info(fields) shipping_info = self._extract_shipping_info(fields)
creator = self._extract_creator(fields) creator = self._extract_creator(fields)
assignee = self._extract_assignee(fields)
description = adf_to_text(fields.get("description"))
# These order tickets typically leave the JIRA Summary field # These order tickets typically leave the JIRA Summary field
# blank - fall back to the deliverables so there's still # blank - fall back to the deliverables so there's still
@@ -317,7 +361,10 @@ class JiraService(OrderService):
line_items=line_items, line_items=line_items,
shipping_info=shipping_info, shipping_info=shipping_info,
creator=creator, creator=creator,
assignee=assignee,
description=description,
tracking_numbers=[], tracking_numbers=[],
shipping_method=None, # comes from ShipStation, not JIRA - see shipstation_service.py
summary=summary, summary=summary,
status=(fields.get("status") or {}).get("name", ""), status=(fields.get("status") or {}).get("name", ""),
source_created_at=created_at, source_created_at=created_at,
+552 -6
View File
@@ -41,6 +41,183 @@ from app.models import Order
API_BASE = "https://api.shipstation.com/v2" API_BASE = "https://api.shipstation.com/v2"
REQUEST_TIMEOUT_SECONDS = 30 REQUEST_TIMEOUT_SECONDS = 30
def list_carriers() -> List[dict]:
"""
Calls GET /v2/carriers with whichever API key is currently active
(test or production, via config.get_shipstation_setting) - a direct
way to answer "what carrier_id is actually valid here" instead of
guessing or hunting through ShipStation's UI while unsure which
account is even logged in. Each carrier includes carrier_id,
carrier_code, friendly_name, and nickname.
"""
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}/carriers",
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
return data.get("carriers", [])
def list_stores() -> List[dict]:
"""
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}/warehouses",
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
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 # Column order confirmed against the real ShipStation upload template
# (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and # (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and
# is intentionally left out here. # is intentionally left out here.
@@ -129,21 +306,39 @@ def export_order_to_shipstation_csv(orders: List[Order], filepath: str) -> int:
return row_count return row_count
def _normalize_shipstation_id(value: str) -> str:
"""
Every ShipStation reference ID we've seen (store, warehouse, carrier)
consistently uses an "se-" prefix (se-599657, se-367672, etc).
Prepends it if it's missing - a bare numeric ID would always fail
with a confusing "not found" error otherwise, and it's an easy typo
to make when copying a value out of ShipStation's own UI or filling
in a new setting (this happened for real: a test-mode carrier ID was
entered as "599657" instead of "se-599657").
"""
value = (value or "").strip()
if value and not value.startswith("se-") and value.replace("-", "").isalnum():
return f"se-{value}"
return value
def _store_id_for_company(company: str) -> str: def _store_id_for_company(company: str) -> str:
settings = config.load_settings()
if company == "Signify Health": if company == "Signify Health":
return settings.get("SHIPSTATION_SIGNIFY_STORE_ID", "") return _normalize_shipstation_id(config.get_shipstation_setting("SHIPSTATION_SIGNIFY_STORE_ID"))
if company == "Oak Street Health": if company == "Oak Street Health":
return settings.get("SHIPSTATION_OAKSTREET_STORE_ID", "") return _normalize_shipstation_id(config.get_shipstation_setting("SHIPSTATION_OAKSTREET_STORE_ID"))
return "" return ""
def _warehouse_id_for_company(company: str) -> str: def _warehouse_id_for_company(company: str) -> str:
settings = config.load_settings()
if company == "Signify Health": if company == "Signify Health":
return settings.get("SHIPSTATION_SIGNIFY_WAREHOUSE_ID", "") return _normalize_shipstation_id(
config.get_shipstation_setting("SHIPSTATION_SIGNIFY_WAREHOUSE_ID")
)
if company == "Oak Street Health": if company == "Oak Street Health":
return settings.get("SHIPSTATION_OAKSTREET_WAREHOUSE_ID", "") return _normalize_shipstation_id(
config.get_shipstation_setting("SHIPSTATION_OAKSTREET_WAREHOUSE_ID")
)
return "" return ""
@@ -255,3 +450,354 @@ def send_order_to_shipstation_api(order: Order) -> dict:
raise ShipStationSendError(f"ShipStation reported errors: {result['errors']}") raise ShipStationSendError(f"ShipStation reported errors: {result['errors']}")
return result return result
# --- Emailed return labels (SH007 / OK012) --------------------------------
#
# A genuinely different shape from the outbound send above: for a return
# label, ship_from is the CUSTOMER and ship_to is YOUR warehouse/return
# center (confirmed against ShipStation's own return-labels docs - this is
# reversed from every other label this app creates). This also doesn't go
# through create_sales_order + automation, since there's no outbound side
# to it - carrier/service are specified directly.
#
# After creation, this app does NOT email the label - per your workflow,
# that's done from ShipStation itself (Returns tab -> Other Actions ->
# Send Return Label) so it goes out through your branded email template.
VALID_CHARGE_EVENTS = {"on_creation", "on_carrier_acceptance", "carrier_default"}
def _is_rubiconmd_order(order: Order) -> bool:
return any((sku or "").strip().upper().startswith("RMD") for sku in (order.skus or []))
def _return_address_for_order(order: Order) -> dict:
"""RubiconMD (RMD-prefixed SKUs) uses Oak Street's own address in every
respect except the name on the label - not a separate warehouse, just
a different name for the same shipping account/address."""
settings = config.load_settings()
prefix = "SIGNIFY_RETURN_" if order.company == "Signify Health" else "OAKSTREET_RETURN_"
name = settings.get(f"{prefix}NAME", "")
if _is_rubiconmd_order(order):
name = settings.get("RUBICONMD_RETURN_NAME", "") or name
return {
"name": name,
"phone": settings.get(f"{prefix}PHONE", ""),
"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",
}
def _return_carrier_for_order(order: Order) -> tuple[str, str]:
"""RubiconMD uses Oak Street's own UPS account - no separate carrier,
just a different name on the return address (see
_return_address_for_order). Each of the two REAL shipping accounts
(Signify, Oak Street) has its own carrier_id even though they share a
physical warehouse."""
prefix = "SIGNIFY_RETURN_" if order.company == "Signify Health" else "OAKSTREET_RETURN_"
return (
_normalize_shipstation_id(config.get_shipstation_setting(f"{prefix}CARRIER_ID")),
config.get_shipstation_setting(f"{prefix}SERVICE_CODE"),
)
def _build_return_package(weight_oz: float, length: float, width: float, height: float) -> dict:
package = {"weight": {"value": weight_oz, "unit": "ounce"}}
if length > 0 and width > 0 and height > 0:
package["dimensions"] = {
"length": length,
"width": width,
"height": height,
"unit": "inch",
}
return package
def _post_to_labels(payload: dict, api_key: str) -> dict:
"""Shared POST /v2/labels caller for both the dummy outbound and the
actual return label - same endpoint, same response shape, same
status-field pitfall (a 200 can still carry status: "error" or
"voided" alongside a label_id, which looks like success unless you
check status specifically)."""
try:
response = requests.post(
f"{API_BASE}/labels",
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
if data.get("errors"):
raise ShipStationSendError(f"ShipStation reported errors: {data['errors']}")
label_status = data.get("status")
if label_status == "error":
raise ShipStationSendError(
f"ShipStation created label {data.get('label_id', '?')} but its status is "
f"'error' - it was not actually completed. Full response: {data}"
)
if label_status == "voided":
raise ShipStationSendError(
f"ShipStation reports label {data.get('label_id', '?')} as already voided."
)
return data
def _void_label(label_id: str, api_key: str) -> None:
"""Best-effort cleanup - if the real return label fails to create
after the dummy outbound succeeded, void the dummy rather than leave
a paid, unused label sitting in the account. Deliberately swallows
its own errors: this runs during an already-failing operation, and a
secondary failure here shouldn't mask the original error or crash
the app - worst case, an unused dummy label needs manual voiding."""
try:
requests.put(
f"{API_BASE}/labels/{label_id}/void",
headers={"API-Key": api_key, "Accept": "application/json"},
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException:
pass
def _create_dummy_outbound_label(
order: Order,
api_key: str,
carrier_id: str,
service_code: str,
store_id: str,
external_shipment_id: str,
) -> dict:
"""
Mirrors what your team already does by hand in ShipStation's GUI:
create a minimal, cheap outbound label (1x1x1in, 1oz) purely so a
real return label can be linked to it via outbound_label_id - which
is apparently what actually makes a return label findable/visible in
your account, confirmed against your own working manual process
rather than assumed from the API docs alone. Same shipping account as
the return itself; ship_from/ship_to are the reverse of the return
(this one goes warehouse -> customer, matching a normal outbound).
Returns the dummy's label_id.
external_shipment_id is passed in (rather than computed here) since
it must be unique per account - the caller uses a suffixed variant
for this dummy, distinct from the real return label's. ShipStation's
own docs confirm this field exists on both shipments and labels
specifically to correlate a record back to your own system, and it's
what populates the "Order #" column - this was missing entirely
before, on both this dummy and the real return label.
warehouse_id is used INSTEAD of ship_from when available (confirmed by
a real ShipStation error: "ship_from and warehouse_id cannot be
provided in same request" - they're mutually exclusive, not
additive). When a warehouse_id is configured, ShipStation resolves
the ship_from address from the registered warehouse record itself.
This only affects the throwaway dummy - it's never seen by anyone,
so it doesn't matter that this bypasses the RubiconMD name-override
logic in _return_address_for_order; the real return label (which
people do see) still uses that function directly.
"""
warehouse_id = _warehouse_id_for_company(order.company)
info = order.shipping_info or {}
shipment: dict = {
"carrier_id": carrier_id,
"service_code": service_code,
# Confirmed against ShipStation's own request schema for POST /v2/labels:
# external_shipment_id is a field ON the shipment object
# (shipment.external_shipment_id), there is no top-level equivalent for
# this endpoint. Putting it at the top level (as this code used to) means
# ShipStation silently ignores it and auto-generates its own "SEAuto-..."
# placeholder instead - confirmed live: a real production dummy shipment
# came back with external_shipment_id "SEAuto-..." instead of the ticket
# number we sent, which is almost certainly why "Order #" never showed up
# in ShipStation's UI for labels from this flow specifically.
"external_shipment_id": external_shipment_id,
"ship_to": {
"name": info.get("name", ""),
"phone": info.get("phone", ""),
"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",
},
"packages": [_build_return_package(1.0, 1.0, 1.0, 1.0)],
}
# Left out entirely when blank (test mode only - see
# _validate_return_label_prerequisites) rather than sent as an empty string,
# matching the confirmed-working shape from a live probe.
if store_id:
shipment["store_id"] = store_id
if warehouse_id:
shipment["warehouse_id"] = warehouse_id
else:
shipment["ship_from"] = _return_address_for_order(order)
payload = {
"shipment": shipment,
}
return _post_to_labels(payload, api_key)
def _validate_return_label_prerequisites(order: Order) -> tuple[str, str, str, str]:
"""Shared validation for both steps - returns (api_key, carrier_id,
service_code, store_id) or raises with a clear, specific message."""
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.")
carrier_id, service_code = _return_carrier_for_order(order)
if not carrier_id or not service_code:
raise ShipStationSendError(
f"No return-label Carrier ID/Service Code configured for '{order.company}'. "
"Add them in Settings under Return Labels."
)
# store_id is only enforced in PRODUCTION. Confirmed directly in ShipStation's own
# dashboard: the Sandbox environment cannot connect/create Order Sources ("stores")
# at all - it explicitly says to switch to Production to do that. So no valid
# TEST_SHIPSTATION_*_STORE_ID can ever exist; requiring one in test mode would make
# this flow permanently untestable in sandbox. Confirmed via a live API probe that
# omitting store_id entirely still succeeds (schema-optional, not just a guess) -
# see _create_dummy_outbound_label()/create_return_label_from_dummy() for where it's
# left out of the request when blank. Still required in production, where it's
# achievable and matters for real (see the error message below).
store_id = _store_id_for_company(order.company)
if not store_id and not config.is_shipstation_test_mode():
raise ShipStationSendError(
f"No ShipStation Store ID configured for '{order.company}'. Add it in Settings "
"under ShipStation. A label created without one may not show up anywhere in "
"ShipStation's UI even though the API reports success."
)
return_address = _return_address_for_order(order)
if not (
return_address["address_line1"]
and return_address["city_locality"]
and return_address["state_province"]
and return_address["postal_code"]
and return_address["phone"]
):
raise ShipStationSendError(
f"No return warehouse address configured for '{order.company}'. "
"Add it in Settings under Return Labels."
)
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 the customer's address information - "
"can't create a return label without knowing where it ships from."
)
return api_key, carrier_id, service_code, store_id
def create_dummy_shipment(order: Order) -> dict:
"""
STEP 1 of the emailed-return-label workflow, callable and verifiable
on its own: creates the minimal, cheap outbound label (1x1x1in, 1oz)
that your team already creates by hand in ShipStation's GUI before
generating a return from it. Returns the FULL response (not just the
label_id) so the caller/UI can show it for verification in
ShipStation before proceeding to step 2 - splitting these apart on
purpose so a problem in one step doesn't get masked by the other.
"""
api_key, carrier_id, service_code, store_id = _validate_return_label_prerequisites(order)
ticket_number = order.ticket_number or order.external_id
return _create_dummy_outbound_label(
order, api_key, carrier_id, service_code, store_id, external_shipment_id=f"{ticket_number}-DUMMY"
)
def create_return_label_from_dummy(
order: Order, dummy_label_id: str, packages: List[dict], charge_event: str | None = None
) -> dict:
"""
STEP 2 of the emailed-return-label workflow: creates the real return
label, linked via outbound_label_id to a dummy that was already
created (and ideally already verified in ShipStation) in step 1.
Does NOT create a new dummy - that's the point of splitting this out.
"""
settings = config.load_settings()
api_key, carrier_id, service_code, store_id = _validate_return_label_prerequisites(order)
return_address = _return_address_for_order(order)
info = order.shipping_info or {}
if not packages:
raise ShipStationSendError("At least one package is required.")
resolved_charge_event = charge_event or settings.get(
"SHIPSTATION_RETURN_CHARGE_EVENT", config.DEFAULT_RETURN_CHARGE_EVENT
)
if resolved_charge_event not in VALID_CHARGE_EVENTS:
raise ShipStationSendError(
f"charge_event must be one of {sorted(VALID_CHARGE_EVENTS)}, got "
f"'{resolved_charge_event}'."
)
ticket_number = order.ticket_number or order.external_id
shipment: dict = {
"carrier_id": carrier_id,
"service_code": service_code,
# Nested here, not top-level - see _create_dummy_outbound_label() for why
# (confirmed against ShipStation's own schema; a top-level
# external_shipment_id is silently ignored on POST /v2/labels).
"external_shipment_id": ticket_number,
"ship_to": return_address,
"ship_from": {
"name": info.get("name", ""),
"phone": info.get("phone", ""),
"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",
},
"packages": [
_build_return_package(
p.get("weight_oz", 1.0), p.get("length", 0), p.get("width", 0), p.get("height", 0)
)
for p in packages
],
}
# Left out entirely when blank (test mode only - see
# _validate_return_label_prerequisites), matching the confirmed-working shape
# from a live probe, rather than sent as an empty string.
if store_id:
shipment["store_id"] = store_id
payload = {
"is_return_label": True,
"outbound_label_id": dummy_label_id,
"charge_event": resolved_charge_event,
"shipment": shipment,
}
data = _post_to_labels(payload, api_key)
data["_dummy_outbound_label_id"] = dummy_label_id
return data
+38 -3
View File
@@ -45,6 +45,7 @@ import requests
from PyQt6.QtCore import QRunnable, QThreadPool from PyQt6.QtCore import QRunnable, QThreadPool
from app import config from app import config
from app.companies import parse_mapping
from app.services.base import OrderService, NormalizedOrder from app.services.base import OrderService, NormalizedOrder
from app.tracking import suggest_jira_status from app.tracking import suggest_jira_status
@@ -64,6 +65,22 @@ SHIPMENT_LOOKBACK_DAYS = 7
MAX_CONCURRENT_REQUESTS = 5 MAX_CONCURRENT_REQUESTS = 5
def _resolve_shipping_method(service_code: str) -> str:
"""Maps a ShipStation service_code (e.g. 'ups_ground') to your team's
JIRA-facing term (e.g. 'Ground') via SHIPPING_METHOD_LABELS. Falls back
to a prettified version of the raw code for anything not mapped, so an
unmapped service still shows something readable rather than nothing."""
if not service_code:
return ""
settings = config.load_settings()
mapping = parse_mapping(
settings.get("SHIPPING_METHOD_LABELS", "") or config.DEFAULT_SHIPPING_METHOD_LABELS
)
if service_code in mapping:
return mapping[service_code]
return service_code.replace("_", " ").title()
class ShipStationServiceError(Exception): class ShipStationServiceError(Exception):
"""Raised for any ShipStation fetch failure, with a message safe to show in the UI.""" """Raised for any ShipStation fetch failure, with a message safe to show in the UI."""
@@ -96,7 +113,7 @@ class ShipStationService(OrderService):
def __init__(self) -> None: def __init__(self) -> None:
settings = config.load_settings() settings = config.load_settings()
self.api_key = settings["SHIPSTATION_API_KEY"] self.api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
self.ticket_pattern = re.compile( self.ticket_pattern = re.compile(
settings["TICKET_NUMBER_REGEX"] or config.DEFAULT_TICKET_NUMBER_REGEX settings["TICKET_NUMBER_REGEX"] or config.DEFAULT_TICKET_NUMBER_REGEX
) )
@@ -124,6 +141,7 @@ class ShipStationService(OrderService):
# --- Sort/correlate now, entirely in memory, after both batches landed --- # --- Sort/correlate now, entirely in memory, after both batches landed ---
tracking_by_ticket: Dict[str, List[dict]] = {} tracking_by_ticket: Dict[str, List[dict]] = {}
raw_by_ticket: Dict[str, dict] = {} raw_by_ticket: Dict[str, dict] = {}
shipping_method_by_ticket: Dict[str, str] = {}
self.unmatched_labels = [] self.unmatched_labels = []
for label in usable_labels: for label in usable_labels:
@@ -144,9 +162,20 @@ class ShipStationService(OrderService):
raw_by_ticket.setdefault(ticket_number, {"labels": [], "shipment": shipment}) raw_by_ticket.setdefault(ticket_number, {"labels": [], "shipment": shipment})
raw_by_ticket[ticket_number]["labels"].append(label) raw_by_ticket[ticket_number]["labels"].append(label)
# Shipping Method reflects the OUTBOUND label specifically (matches
# the Ship Sheet's usage) - only set from the first non-return label
# seen per ticket, so a return label's service doesn't overwrite it.
if not label.get("is_return_label") and ticket_number not in shipping_method_by_ticket:
shipping_method_by_ticket[ticket_number] = _resolve_shipping_method(
label.get("service_code", "")
)
return [ return [
self._to_normalized_order( self._to_normalized_order(
ticket_number, tracking_by_ticket[ticket_number], raw_by_ticket[ticket_number] ticket_number,
tracking_by_ticket[ticket_number],
raw_by_ticket[ticket_number],
shipping_method_by_ticket.get(ticket_number, ""),
) )
for ticket_number in tracking_by_ticket for ticket_number in tracking_by_ticket
] ]
@@ -280,7 +309,7 @@ class ShipStationService(OrderService):
@staticmethod @staticmethod
def _to_normalized_order( def _to_normalized_order(
ticket_number: str, tracking_numbers: List[dict], raw: dict ticket_number: str, tracking_numbers: List[dict], raw: dict, shipping_method: str
) -> NormalizedOrder: ) -> NormalizedOrder:
suggested_status = suggest_jira_status(tracking_numbers) or "Tracking Pulled" suggested_status = suggest_jira_status(tracking_numbers) or "Tracking Pulled"
numbers_display = ", ".join( numbers_display = ", ".join(
@@ -294,7 +323,13 @@ class ShipStationService(OrderService):
ticket_number=ticket_number, ticket_number=ticket_number,
company="", # not used - the JIRA row this merges onto already has one company="", # not used - the JIRA row this merges onto already has one
skus=[], skus=[],
line_items=[],
shipping_info={},
creator=None,
assignee=None,
description=None,
tracking_numbers=tracking_numbers, tracking_numbers=tracking_numbers,
shipping_method=shipping_method,
summary=numbers_display, summary=numbers_display,
status=suggested_status, status=suggested_status,
source_created_at=None, source_created_at=None,
+163
View File
@@ -0,0 +1,163 @@
"""
Ticket validation - flags problems for staff to investigate and
(manually, in JIRA, with a reason) cancel if confirmed. This module
only ever FLAGS; it never writes anything back to JIRA or changes a
ticket's status - that stays a deliberate human step.
Two rules right now, per the actual business logic:
1. Company mismatch: a ticket's SKUs resolve to more than one company
(SH + OK present together). Simple and unambiguous - every SKU on a
ticket should belong to the same company.
2. Return/device mismatch: a "Shipping - Return Label/Box X" SKU's
implied device type doesn't match any other device SKU on the same
ticket - covers both asset-recovery (box + return label, same
device) and break-fix (asset + matching-type return label) flows.
Some return types are exempt (see RETURN_DEVICE_EXEMPT_KEYWORDS) -
emailed labels, DPS Device, and scheduled pickups don't have (or
need) a matching device line item at all.
Device-type keywords are shared with app.serial_suggestions
(DEVICE_FIELD_SUGGESTIONS) rather than duplicated - same vocabulary,
different use.
"""
from __future__ import annotations
from typing import List, NamedTuple, Optional
from app import config
from app.companies import parse_mapping, resolve_company_by_sku
from app.serial_suggestions import get_device_field_suggestions
DEFAULT_RETURN_DEVICE_EXEMPT_KEYWORDS = "emailed,dps,scheduled pickup,padded envelope"
class TicketIssue(NamedTuple):
code: str
message: str
def get_return_device_exempt_keywords() -> list[str]:
raw = config.get(
"RETURN_DEVICE_EXEMPT_KEYWORDS", DEFAULT_RETURN_DEVICE_EXEMPT_KEYWORDS
)
return [k.strip().lower() for k in raw.split(",") if k.strip()]
def _is_shipping_item(item_name: str) -> bool:
return item_name.strip().lower().startswith("shipping")
def check_company_mismatch(skus: List[str], sku_map: Optional[dict] = None) -> Optional[TicketIssue]:
if sku_map is None:
sku_map = parse_mapping(config.load_settings().get("COMPANY_SKU_MAP", ""))
companies = set()
for sku in skus or []:
company = resolve_company_by_sku(sku, sku_map)
if company != "Unknown":
companies.add(company)
if len(companies) > 1:
return TicketIssue(
code="company_mismatch",
message=f"SKUs from multiple companies on one ticket: {', '.join(sorted(companies))}",
)
return None
def check_return_device_mismatch(
line_items: List[dict],
keyword_map: Optional[dict] = None,
exempt_keywords: Optional[list] = None,
) -> List[TicketIssue]:
"""
A return SKU's implied device type needs to match SOMETHING else on the
ticket - either an actual device line item (break-fix: send the asset,
return the same type) OR 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" - both are "shipping" items by our
is-it-a-device check, and pairing them like this is correct, not a
mismatch - confirmed against real examples: SH002/SH011, OK001/OK006,
OK011/OK013).
"""
if keyword_map is None:
keyword_map = get_device_field_suggestions() # {keyword: [serial fields]} - keys only, here
if exempt_keywords is None:
exempt_keywords = get_return_device_exempt_keywords()
issues: List[TicketIssue] = []
device_items = [item for item in (line_items or []) if not _is_shipping_item(item.get("item_name", ""))]
return_items = [item for item in (line_items or []) if _is_shipping_item(item.get("item_name", ""))]
for return_item in return_items:
return_text = return_item.get("item_name", "").lower()
if any(exempt in return_text for exempt in exempt_keywords):
continue
matched_keyword = next((kw for kw in keyword_map if kw in return_text), None)
if matched_keyword is None:
# The return SKU doesn't mention any known device type at all -
# nothing to check it against, so nothing to flag here either.
continue
matches_a_device = any(
matched_keyword in item.get("item_name", "").lower() for item in device_items
)
matches_another_shipping_item = any(
matched_keyword in other.get("item_name", "").lower()
for other in return_items
if other is not return_item
)
if not matches_a_device and not matches_another_shipping_item:
issues.append(
TicketIssue(
code="return_device_mismatch",
message=(
f"'{return_item.get('sku', '')}' ({return_item.get('item_name', '')}) "
f"expects a matching '{matched_keyword}' device or box/label pair, "
"but none is on this ticket"
),
)
)
return issues
def validate_ticket(
skus: List[str],
line_items: List[dict],
sku_map: Optional[dict] = None,
keyword_map: Optional[dict] = None,
exempt_keywords: Optional[list] = None,
) -> List[TicketIssue]:
"""
Validates a single ticket. The optional pre-fetched params exist so a
caller validating MANY tickets at once (the orders table, refreshing
on every Import/Pull Tracking/etc.) can read these settings from disk
ONCE for the whole batch, rather than once per ticket - each of these
is itself a full .env read, and doing that per-ticket rather than
per-batch was a real, measured performance bug (2.7s for 300 tickets,
now ~0.1s - see make_validation_context()).
"""
issues: List[TicketIssue] = []
company_issue = check_company_mismatch(skus, sku_map)
if company_issue:
issues.append(company_issue)
issues.extend(check_return_device_mismatch(line_items, keyword_map, exempt_keywords))
return issues
def make_validation_context() -> dict:
"""Fetches everything validate_ticket() needs from Settings ONCE, for
passing into repeated validate_ticket() calls across a batch (e.g.
every order in the table on a refresh) instead of re-reading .env for
every single ticket."""
settings = config.load_settings()
return {
"sku_map": parse_mapping(settings.get("COMPANY_SKU_MAP", "")),
"keyword_map": get_device_field_suggestions(),
"exempt_keywords": get_return_device_exempt_keywords(),
}
+595 -25
View File
@@ -12,8 +12,11 @@ here is purely a label-generation step for JIRA tickets.
""" """
from __future__ import annotations from __future__ import annotations
import webbrowser
from PyQt6.QtGui import QAction from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication,
QMainWindow, QMainWindow,
QWidget, QWidget,
QVBoxLayout, QVBoxLayout,
@@ -23,8 +26,13 @@ from PyQt6.QtWidgets import (
QLabel, QLabel,
QTabWidget, QTabWidget,
QFileDialog, QFileDialog,
QDialog,
QInputDialog,
) )
from app import config
from app.external_links import jira_ticket_url, google_maps_search_url
from app.return_labels import is_emailed_label_order
from app.services import SERVICE_REGISTRY from app.services import SERVICE_REGISTRY
from app.services.odoo_export import export_orders_to_csv from app.services.odoo_export import export_orders_to_csv
from app.services.shipstation_send import export_order_to_shipstation_csv from app.services.shipstation_send import export_order_to_shipstation_csv
@@ -33,12 +41,21 @@ from app.ui.settings_dialog import SettingsDialog
from app.ui.widgets.orders_table import OrdersTableView from app.ui.widgets.orders_table import OrdersTableView
from app.ui.widgets.dashboard import DashboardWidget from app.ui.widgets.dashboard import DashboardWidget
from app.ui.widgets.order_detail_dialog import OrderDetailDialog from app.ui.widgets.order_detail_dialog import OrderDetailDialog
from app.ui.widgets.return_label_dialog import ReturnLabelDialog
from app.ui.widgets.pack_ticket_dialog import PackTicketDialog
from app.workers import ( from app.workers import (
FetchOrdersWorker, FetchOrdersWorker,
SendToShipStationWorker, SendToShipStationWorker,
CreateDummyShipmentWorker,
CreateReturnLabelWorker,
load_orders_by_view, load_orders_by_view,
get_dashboard_stats, get_dashboard_stats,
mark_shipstation_sent, mark_shipstation_sent,
save_dummy_outbound_label_id,
save_pack_data,
reset_local_database,
create_test_shipment_order,
delete_test_shipments,
) )
@@ -51,6 +68,13 @@ class MainWindow(QMainWindow):
self._workers: dict[str, FetchOrdersWorker] = {} self._workers: dict[str, FetchOrdersWorker] = {}
self._import_actions: dict[str, QAction] = {} self._import_actions: dict[str, QAction] = {}
self._send_worker: SendToShipStationWorker | None = None self._send_worker: SendToShipStationWorker | None = None
self._return_label_worker: CreateDummyShipmentWorker | CreateReturnLabelWorker | None = None
# Reused, never-destroyed dialog instances (see the WORKAROUND NOTE
# in each dialog's module docstring) - created lazily via
# set_order() rather than a fresh instance per ticket.
self._pack_ticket_dialog: PackTicketDialog | None = None
self._return_label_dialog: ReturnLabelDialog | None = None
self._build_ui() self._build_ui()
self._refresh_everything() self._refresh_everything()
@@ -77,54 +101,368 @@ class MainWindow(QMainWindow):
self.setCentralWidget(central) self.setCentralWidget(central)
toolbar = QToolBar("Main") menu_bar = self.menuBar()
toolbar.setMovable(False)
self.addToolBar(toolbar) file_menu = menu_bar.addMenu("&File")
settings_action = QAction("Settings...", self)
settings_action.triggered.connect(self._on_settings_clicked)
file_menu.addAction(settings_action)
file_menu.addSeparator()
exit_action = QAction("Exit", self)
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
data_menu = menu_bar.addMenu("&Data")
jira_action = QAction("Import from JIRA", self) jira_action = QAction("Import from JIRA", self)
jira_action.triggered.connect(lambda: self._on_import_clicked("jira")) jira_action.triggered.connect(lambda: self._on_import_clicked("jira"))
toolbar.addAction(jira_action) data_menu.addAction(jira_action)
self._import_actions["jira"] = jira_action self._import_actions["jira"] = jira_action
shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self) shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self)
shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation")) shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
toolbar.addAction(shipstation_action) data_menu.addAction(shipstation_action)
self._import_actions["shipstation"] = shipstation_action self._import_actions["shipstation"] = shipstation_action
toolbar.addSeparator() data_menu.addSeparator()
export_action = QAction("Export Visible Orders to Odoo CSV", self)
export_action.setToolTip("Exports from whichever tab is currently open")
export_action.triggered.connect(self._on_export_clicked)
toolbar.addAction(export_action)
toolbar.addSeparator()
emergency_action = QAction("Send to ShipStation", self)
emergency_action.setToolTip("Select a ticket in Active Orders first")
emergency_action.triggered.connect(self._on_emergency_send_clicked)
toolbar.addAction(emergency_action)
toolbar.addSeparator()
settings_action = QAction("Settings", self)
settings_action.triggered.connect(self._on_settings_clicked)
toolbar.addAction(settings_action)
refresh_action = QAction("Refresh from Local DB", self) refresh_action = QAction("Refresh from Local DB", self)
refresh_action.triggered.connect(self._refresh_everything) refresh_action.triggered.connect(self._refresh_everything)
toolbar.addAction(refresh_action) data_menu.addAction(refresh_action)
export_action = QAction("Export Visible Orders to Odoo CSV...", self)
export_action.setStatusTip("Exports from whichever tab is currently open")
export_action.triggered.connect(self._on_export_clicked)
data_menu.addAction(export_action)
data_menu.addSeparator()
reset_action = QAction("Reset Local Database...", self)
reset_action.setStatusTip("Wipes the local cache - re-import from JIRA afterward")
reset_action.triggered.connect(self._on_reset_database_clicked)
data_menu.addAction(reset_action)
data_menu.addSeparator()
self._test_mode_action = QAction("ShipStation Test Mode", self)
self._test_mode_action.setCheckable(True)
self._test_mode_action.setChecked(config.is_shipstation_test_mode())
self._test_mode_action.setStatusTip(
"Uses your ShipStation test/sandbox API key for every ShipStation action - "
"no real charges, nothing appears in your production account"
)
self._test_mode_action.toggled.connect(self._on_test_mode_toggled)
data_menu.addAction(self._test_mode_action)
list_carriers_action = QAction("List ShipStation Carriers...", self)
list_carriers_action.setStatusTip(
"Shows the carrier IDs actually valid for whichever API key is currently active "
"(test or production) - useful for finding the right carrier_id directly"
)
list_carriers_action.triggered.connect(self._on_list_carriers_clicked)
data_menu.addAction(list_carriers_action)
list_stores_action = QAction("About ShipStation Store IDs...", self)
list_stores_action.setStatusTip(
"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)
data_menu.addSeparator()
create_test_shipment_action = QAction("Create Test Shipment (Email SKU)...", self)
create_test_shipment_action.setStatusTip(
"Test Mode only - creates a synthetic ticket carrying that company's emailed-"
"return-label SKU, so Create Return Label can be run against it in the sandbox"
)
create_test_shipment_action.triggered.connect(self._on_create_test_shipment_clicked)
data_menu.addAction(create_test_shipment_action)
delete_test_shipments_action = QAction("Delete Test Shipments...", self)
delete_test_shipments_action.setStatusTip(
"Removes every synthetic ticket created by Create Test Shipment - real JIRA "
"tickets are not affected"
)
delete_test_shipments_action.triggered.connect(self._on_delete_test_shipments_clicked)
data_menu.addAction(delete_test_shipments_action)
orders_menu = menu_bar.addMenu("&Orders")
self._pack_ticket_action = QAction("Pack Ticket", self)
self._pack_ticket_action.setStatusTip(
"Enter serial numbers and mark packed - select a ticket first"
)
self._pack_ticket_action.triggered.connect(self._on_pack_ticket_clicked)
orders_menu.addAction(self._pack_ticket_action)
emergency_action = QAction("Send to ShipStation", self)
emergency_action.setStatusTip("Select a ticket in Active Orders first")
emergency_action.triggered.connect(self._on_emergency_send_clicked)
orders_menu.addAction(emergency_action)
self._return_label_action = QAction("Create Return Label", self)
self._return_label_action.setStatusTip(
"For emailed-return-label tickets (SH007/OK012) - select one in Active Orders first"
)
self._return_label_action.triggered.connect(self._on_create_return_label_clicked)
orders_menu.addAction(self._return_label_action)
orders_menu.addSeparator()
view_jira_action = QAction("View in JIRA", self)
view_jira_action.setStatusTip("Opens the selected ticket in JIRA")
view_jira_action.triggered.connect(self._on_view_in_jira_clicked)
orders_menu.addAction(view_jira_action)
lookup_address_action = QAction("Look Up Address on Google Maps", self)
lookup_address_action.setStatusTip(
"Opens the selected ticket's shipping address in Google Maps"
)
lookup_address_action.triggered.connect(self._on_lookup_address_clicked)
orders_menu.addAction(lookup_address_action)
# Quick-access toolbar for the highest-frequency actions - everything
# here is also in the menus above (same QAction objects, so there's
# nothing to keep in sync); this is just a shortcut for the few used
# constantly enough to want one click instead of a menu dropdown.
quick_toolbar = QToolBar("Quick Actions")
quick_toolbar.setMovable(False)
self.addToolBar(quick_toolbar)
quick_toolbar.addAction(self._import_actions["jira"])
quick_toolbar.addAction(self._pack_ticket_action)
quick_toolbar.addAction(self._return_label_action)
self.status_bar = QStatusBar() self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar) self.setStatusBar(self.status_bar)
self.status_label = QLabel("Ready.") self.status_label = QLabel("Ready.")
self.status_bar.addWidget(self.status_label) self.status_bar.addWidget(self.status_label)
# Permanent (right-aligned) so it's always visible regardless of
# whatever status_label currently says - the whole point is that
# it should be hard to miss whether real charges/labels are in
# play right now.
self._test_mode_indicator = QLabel()
self._test_mode_indicator.setStyleSheet(
"background-color: #b35c00; color: white; padding: 2px 8px; font-weight: bold;"
)
self.status_bar.addPermanentWidget(self._test_mode_indicator)
self._update_test_mode_indicator()
# -- actions ----------------------------------------------------------- # -- actions -----------------------------------------------------------
def _on_settings_clicked(self) -> None: def _on_settings_clicked(self) -> None:
dialog = SettingsDialog(self) dialog = SettingsDialog(self)
dialog.exec() dialog.exec()
# Settings could have been edited directly (SHIPSTATION_TEST_MODE
# as raw text) rather than via the menu checkbox - keep both in sync.
self._test_mode_action.blockSignals(True)
self._test_mode_action.setChecked(config.is_shipstation_test_mode())
self._test_mode_action.blockSignals(False)
self._update_test_mode_indicator()
def _on_test_mode_toggled(self, checked: bool) -> None:
config.save_settings({"SHIPSTATION_TEST_MODE": "true" if checked else "false"})
self._update_test_mode_indicator()
def _update_test_mode_indicator(self) -> None:
if config.is_shipstation_test_mode():
self._test_mode_indicator.setText("SHIPSTATION TEST MODE")
self._test_mode_indicator.show()
else:
self._test_mode_indicator.hide()
def _on_list_carriers_clicked(self) -> None:
from app.services.shipstation_send import list_carriers, ShipStationSendError
mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION"
try:
carriers = list_carriers()
except ShipStationSendError as exc:
QMessageBox.critical(self, "Could not list carriers", str(exc))
return
if not carriers:
QMessageBox.information(
self,
f"ShipStation Carriers ({mode})",
f"No carriers are connected to this {mode.lower()} ShipStation account.",
)
return
lines = [f"Carriers visible to your current {mode} API key:", ""]
for carrier in carriers:
nickname = carrier.get("nickname") or "(no nickname)"
lines.append(
f" carrier_id: {carrier.get('carrier_id', '?')} "
f"{carrier.get('friendly_name', '?')} - {nickname}"
)
QMessageBox.information(self, f"ShipStation Carriers ({mode})", "\n".join(lines))
def _on_list_stores_clicked(self) -> None:
from app.services.shipstation_send import list_stores, ShipStationSendError
mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION"
try:
stores = list_stores()
except ShipStationSendError as exc:
QMessageBox.critical(self, "Could not list stores", str(exc))
return
if not stores:
QMessageBox.information(
self,
f"ShipStation Stores ({mode})",
f"No stores are set up in this {mode.lower()} ShipStation account.",
)
return
lines = [f"Stores visible to your current {mode} API key:", ""]
for store in stores:
lines.append(
f" store_id: {store.get('store_id', '?')} "
f"{store.get('store_name', '?')}"
)
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_create_test_shipment_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 ticket is meant "
"to be run through Create Return Label against the sandbox account, not "
"production - a real store_id/carrier under production settings would "
"create an actual, paid shipment to a fake address.",
)
return
company, ok = QInputDialog.getItem(
self,
"Create Test Shipment",
"Company (picks that company's configured emailed-return-label SKU):",
["Signify Health", "Oak Street Health"],
editable=False,
)
if not ok:
return
try:
order = create_test_shipment_order(company)
except ValueError as exc:
QMessageBox.critical(self, "Could not create test shipment", str(exc))
return
self._refresh_everything()
QMessageBox.information(
self,
"Test Shipment Created",
f"Created {order.ticket_number} ({company}, SKU {order.skus[0]}) on the "
"Active tab.\n\n"
"Select it there and use Create Return Label to run Step 1 (dummy "
"shipment) and Step 2 (return label) against the sandbox account, the "
"same code path a real ticket would use.\n\n"
"Use Delete Test Shipments (Data menu) to clean it up afterward.",
)
def _on_delete_test_shipments_clicked(self) -> None:
confirm = QMessageBox.question(
self,
"Delete Test Shipments",
"Removes every synthetic ticket created by Create Test Shipment "
"(ticket numbers starting with TEST-EMAIL-). Real JIRA tickets are not "
"affected.\n\nContinue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirm != QMessageBox.StandardButton.Yes:
return
count = delete_test_shipments()
self._refresh_everything()
QMessageBox.information(self, "Delete Test Shipments", f"Deleted {count} test shipment(s).")
def _on_order_double_clicked(self, order) -> None: def _on_order_double_clicked(self, order) -> None:
dialog = OrderDetailDialog(order, self) dialog = OrderDetailDialog(order, self)
@@ -333,6 +671,238 @@ class MainWindow(QMainWindow):
"Upload this into ShipStation's Import Orders wizard.", "Upload this into ShipStation's Import Orders wizard.",
) )
def _on_create_return_label_clicked(self) -> None:
order = self.orders_table.selected_order()
if order is None:
QMessageBox.information(
self,
"No ticket selected",
"Select a ticket in Active Orders first, then click Create Return Label.",
)
return
if not is_emailed_label_order(order.skus or []):
proceed = QMessageBox.question(
self,
"Not a return-label ticket",
f"This ticket's SKU(s) ({', '.join(order.skus or []) or 'none'}) don't match "
"the configured emailed-label SKUs. Continue anyway?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if proceed != QMessageBox.StandardButton.Yes:
return
if self._return_label_dialog is None:
self._return_label_dialog = ReturnLabelDialog(order, self)
self._return_label_dialog.finished.connect(self._on_return_label_dialog_finished)
else:
self._return_label_dialog.set_order(order)
# Non-modal on purpose - see the module docstring in
# return_label_dialog.py. Disable the action while it's open so a
# second click can't call set_order() on it mid-edit and silently
# overwrite whatever the user is in the middle of entering.
self._return_label_action.setEnabled(False)
self._return_label_dialog.show()
self._return_label_dialog.raise_()
self._return_label_dialog.activateWindow()
def _on_return_label_dialog_finished(self, result: int) -> None:
self._return_label_action.setEnabled(True)
if result != QDialog.DialogCode.Accepted:
return
dialog = self._return_label_dialog
order = dialog.order
ticket_number = order.ticket_number or order.external_id
if order.dummy_outbound_label_id:
# Step 2: a dummy already exists for this ticket - create the
# real return label from it.
packages = dialog.get_packages()
charge_event = dialog.get_charge_event()
if not packages:
QMessageBox.warning(self, "No packages", "Add at least one package first.")
return
self.status_label.setText(f"Creating return label for {ticket_number}...")
worker = CreateReturnLabelWorker(order, order.dummy_outbound_label_id, packages, charge_event)
worker.finished_ok.connect(lambda result: self._on_return_label_ok(ticket_number, result))
worker.failed.connect(self._on_return_label_failed)
self._return_label_worker = worker # keep a reference so it isn't garbage collected
worker.start()
else:
# Step 1: no dummy yet - create it, then stop and let the user
# verify it in ShipStation before running this again for step 2.
self.status_label.setText(f"Creating dummy shipment for {ticket_number}...")
worker = CreateDummyShipmentWorker(order)
worker.finished_ok.connect(lambda result: self._on_dummy_shipment_ok(ticket_number, result))
worker.failed.connect(self._on_return_label_failed)
self._return_label_worker = worker
worker.start()
def _on_dummy_shipment_ok(self, ticket_number: str, result: dict) -> None:
label_id = result.get("label_id", "?")
save_dummy_outbound_label_id(ticket_number, label_id)
self._refresh_everything()
# Refreshing resets the table model, which clears whatever row was
# selected - re-select the same ticket so clicking Create Return
# Label again immediately proceeds to step 2 rather than hitting
# "no ticket selected".
self.orders_table.select_ticket(ticket_number)
self.status_label.setText(f"Dummy shipment created - {label_id}")
QMessageBox.information(
self,
"Step 1 Complete",
f"Dummy shipment {label_id} created for {ticket_number}.\n\n"
"Go check it in ShipStation now - confirm Order #, Ship From/Store, and everything "
"else looks right. Once you've verified it, click Create Return Label again on this "
"ticket to run step 2 (the actual return label).",
)
def _on_return_label_ok(self, ticket_number: str, result: dict) -> None:
mark_shipstation_sent(ticket_number)
self._refresh_everything()
self.orders_table.select_ticket(ticket_number)
label_id = result.get("label_id", "?")
tracking = result.get("tracking_number", "?")
label_status = result.get("status", "unknown")
dummy_id = result.get("_dummy_outbound_label_id", "?")
self.status_label.setText(f"Return label created - {label_id} (status: {label_status})")
if label_status == "processing":
body = (
f"Label {label_id} was accepted but is still processing (tracking {tracking}).\n\n"
"ShipStation says this can take a few minutes to fully complete - if you can't "
"find it yet, wait a bit and check again before assuming something's wrong.\n\n"
f"(Linked to dummy outbound label {dummy_id}, for troubleshooting reference.)"
)
else:
body = (
f"Label {label_id} created, status: {label_status} (tracking {tracking}).\n\n"
"Find it in ShipStation and use Other Actions -> Send Return Label to email "
"it to the customer through your branded template.\n\n"
f"(Linked to dummy outbound label {dummy_id}, for troubleshooting reference.)"
)
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Information)
box.setWindowTitle("Return Label Created")
box.setText(body)
copy_button = None
if tracking and tracking != "?":
copy_button = box.addButton("Copy Tracking Number", QMessageBox.ButtonRole.ActionRole)
box.addButton(QMessageBox.StandardButton.Ok)
box.exec()
# Only ever touches the clipboard if explicitly asked - never
# overwrites it automatically, since it's used constantly for other
# things and clobbering it silently would lose whatever was there.
if copy_button is not None and box.clickedButton() is copy_button:
QApplication.clipboard().setText(tracking)
self.status_label.setText(f"Tracking number {tracking} copied to clipboard.")
def _on_return_label_failed(self, message: str) -> None:
self.status_label.setText("Return label creation failed.")
QMessageBox.critical(self, "Return label creation failed", message)
def _on_pack_ticket_clicked(self) -> None:
order = self.orders_table.selected_order()
if order is None:
QMessageBox.information(
self,
"No ticket selected",
"Select a ticket in Active Orders first, then click Pack Ticket.",
)
return
if self._pack_ticket_dialog is None:
self._pack_ticket_dialog = PackTicketDialog(order, self)
self._pack_ticket_dialog.finished.connect(self._on_pack_ticket_dialog_finished)
else:
self._pack_ticket_dialog.set_order(order)
# Non-modal on purpose - see the module docstring in
# pack_ticket_dialog.py. Disable the action while it's open so a
# second click can't call set_order() on it mid-edit and silently
# overwrite whatever the user is in the middle of entering/scanning.
self._pack_ticket_action.setEnabled(False)
self._pack_ticket_dialog.show()
self._pack_ticket_dialog.raise_()
self._pack_ticket_dialog.activateWindow()
def _on_pack_ticket_dialog_finished(self, result: int) -> None:
self._pack_ticket_action.setEnabled(True)
if result != QDialog.DialogCode.Accepted:
return
dialog = self._pack_ticket_dialog
order = dialog.order
ticket_number = order.ticket_number or order.external_id
save_pack_data(ticket_number, dialog.get_serial_numbers(), dialog.get_packed())
self._refresh_everything()
self.status_label.setText(f"Saved pack data for {ticket_number}.")
def _currently_selected_order(self):
"""Unlike orders_table.selected_order() (Active Orders specifically),
this checks whichever tab is actually showing - View in JIRA and
Look Up Address are read-only lookups that make just as much sense
for a Cancelled or Done ticket as an Active one."""
current_widget = self.tabs.currentWidget()
if isinstance(current_widget, OrdersTableView):
return current_widget.selected_order()
return None
def _on_view_in_jira_clicked(self) -> None:
order = self._currently_selected_order()
if order is None:
QMessageBox.information(self, "No ticket selected", "Select a ticket first.")
return
url = jira_ticket_url(order.ticket_number)
if url is None:
QMessageBox.warning(
self,
"JIRA URL not set",
"Add your JIRA URL in Settings first (or this ticket has no ticket number).",
)
return
webbrowser.open(url)
def _on_lookup_address_clicked(self) -> None:
order = self._currently_selected_order()
if order is None:
QMessageBox.information(self, "No ticket selected", "Select a ticket first.")
return
url = google_maps_search_url(order.shipping_info)
if url is None:
QMessageBox.information(self, "No address", "This ticket has no address on file.")
return
webbrowser.open(url)
def _on_reset_database_clicked(self) -> None:
confirm = QMessageBox.warning(
self,
"Reset Local Database",
"This clears every locally cached order, INCLUDING any serial numbers "
"and packed status your team has already entered on the Pack Ticket "
"dialog - that data doesn't come from JIRA, so a reset does not bring "
"it back. JIRA itself is unaffected either way.\n\n"
"You'll need to click Import from JIRA afterward to repopulate, and "
"Pull Tracking Numbers again if you rely on today's already-pulled "
"tracking data.\n\n"
"Continue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirm != QMessageBox.StandardButton.Yes:
return
count = reset_local_database()
self._refresh_everything()
self.status_label.setText(
f"Local database reset - {count} order(s) cleared. Run Import from JIRA to repopulate."
)
def _refresh_everything(self) -> None: def _refresh_everything(self) -> None:
active_orders, cancelled_orders, done_orders = load_orders_by_view() active_orders, cancelled_orders, done_orders = load_orders_by_view()
self.orders_table.set_orders(active_orders) self.orders_table.set_orders(active_orders)
+31 -2
View File
@@ -4,15 +4,20 @@ Order detail dialog.
Mainly a debugging aid: shows exactly what JIRA sent back for this Mainly a debugging aid: shows exactly what JIRA sent back for this
ticket (raw payload), plus the tracking numbers ShipStation supplied ticket (raw payload), plus the tracking numbers ShipStation supplied
and the JIRA status they suggest - handy for the end-of-day close-out and the JIRA status they suggest - handy for the end-of-day close-out
without having to piece it together by hand. without having to piece it together by hand. Also offers quick jumps
out to JIRA and Google Maps, since those are common next steps when
something about a ticket needs a closer look.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import webbrowser
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QLabel, QPushButton from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QTextEdit, QLabel, QPushButton
from app.external_links import jira_ticket_url, google_maps_search_url
from app.models import Order from app.models import Order
from app.ticket_validation import validate_ticket
from app.tracking import suggest_jira_status from app.tracking import suggest_jira_status
@@ -38,12 +43,21 @@ class OrderDetailDialog(QDialog):
info = order.shipping_info or {} info = order.shipping_info or {}
address_line2 = f" {info.get('address2')}" if info.get("address2") else "" address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
issues = validate_ticket(order.skus, order.line_items)
summary_lines = [ summary_lines = [
f"Ticket #: {order.ticket_number or '(none found)'}", f"Ticket #: {order.ticket_number or '(none found)'}",
f"Company: {order.company}", f"Company: {order.company}",
f"Created by: {order.creator or '(unknown)'}", f"Created by: {order.creator or '(unknown)'}",
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}", f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
f"Status: {order.status}", f"Status: {order.status}",
]
if issues:
summary_lines.append("")
summary_lines.append("\u26a0 Flagged for review:")
summary_lines.extend(f" - {issue.message}" for issue in issues)
summary_lines.extend(
[
"", "",
"Shipping info (used by the emergency Send to ShipStation action):", "Shipping info (used by the emergency Send to ShipStation action):",
f" Name: {info.get('name') or '(missing)'}", f" Name: {info.get('name') or '(missing)'}",
@@ -53,6 +67,7 @@ class OrderDetailDialog(QDialog):
"", "",
"Tracking numbers:", "Tracking numbers:",
] ]
)
summary_lines.extend(tracking_lines or [" (none pulled yet)"]) summary_lines.extend(tracking_lines or [" (none pulled yet)"])
if suggested: if suggested:
summary_lines.append("") summary_lines.append("")
@@ -61,6 +76,16 @@ class OrderDetailDialog(QDialog):
summary_label = QLabel("\n".join(summary_lines)) summary_label = QLabel("\n".join(summary_lines))
layout.addWidget(summary_label) layout.addWidget(summary_label)
link_buttons = QHBoxLayout()
jira_button = QPushButton("View in JIRA")
jira_button.clicked.connect(lambda: self._open_url(jira_ticket_url(order.ticket_number)))
link_buttons.addWidget(jira_button)
maps_button = QPushButton("Look Up Address on Google Maps")
maps_button.clicked.connect(lambda: self._open_url(google_maps_search_url(order.shipping_info)))
link_buttons.addWidget(maps_button)
link_buttons.addStretch()
layout.addLayout(link_buttons)
layout.addWidget(QLabel("Raw payload from source system:")) layout.addWidget(QLabel("Raw payload from source system:"))
text = QTextEdit() text = QTextEdit()
text.setReadOnly(True) text.setReadOnly(True)
@@ -71,3 +96,7 @@ class OrderDetailDialog(QDialog):
close_button = QPushButton("Close") close_button = QPushButton("Close")
close_button.clicked.connect(self.accept) close_button.clicked.connect(self.accept)
layout.addWidget(close_button) layout.addWidget(close_button)
def _open_url(self, url: str | None) -> None:
if url:
webbrowser.open(url)
+64 -3
View File
@@ -27,23 +27,33 @@ from PyQt6.QtWidgets import (
) )
from app.models import Order from app.models import Order
from app.return_labels import is_emailed_label_order, get_emailed_label_skus
from app.schedule import get_cutoff_time, is_past_cutoff_today from app.schedule import get_cutoff_time, is_past_cutoff_today
from app.status_rules import get_cancelled_statuses, status_in from app.status_rules import get_cancelled_statuses, status_in
from app.ticket_validation import validate_ticket, make_validation_context
from app.tracking import get_fulfilled_statuses from app.tracking import get_fulfilled_statuses
CANCELLED_TEXT_COLOR = QColor(180, 0, 0) CANCELLED_TEXT_COLOR = QColor(180, 0, 0)
FULFILLED_ROW_COLOR = QColor(210, 240, 210) FULFILLED_ROW_COLOR = QColor(210, 240, 210)
ISSUE_TEXT_COLOR = QColor(170, 100, 0)
CHECK_MARK = "\u2713" CHECK_MARK = "\u2713"
WARNING_MARK = "\u26a0"
# Order here is display order, left to right. # Order here is display order, left to right.
COLUMNS = [ COLUMNS = [
("company", "Company"), ("company", "Company"),
("ticket_number", "Ticket #"), ("ticket_number", "Ticket #"),
("status", "Status"), ("status", "Status"),
("issues_display", "Issues"),
("packed_display", "Done"),
("skus_display", "SKUs"), ("skus_display", "SKUs"),
("summary", "Summary"), ("summary", "Summary"),
("assignee", "Assignee"),
("serials_display", "Serial #s"),
("return_label_display", "Return Label"),
("outgoing_tracking_display", "Outgoing Tracking"), ("outgoing_tracking_display", "Outgoing Tracking"),
("return_tracking_display", "Return Tracking"), ("return_tracking_display", "Return Tracking"),
("shipping_method", "Shipping Method"),
("uploaded_display", "Uploaded"), ("uploaded_display", "Uploaded"),
("past_cutoff_display", "Past Cutoff"), ("past_cutoff_display", "Past Cutoff"),
("created_display", "Created"), ("created_display", "Created"),
@@ -73,12 +83,31 @@ class OrdersTableModel(QAbstractTableModel):
self._cancelled_statuses: set[str] = set() self._cancelled_statuses: set[str] = set()
self._fulfilled_statuses: set[str] = set() self._fulfilled_statuses: set[str] = set()
self._cutoff_time = None self._cutoff_time = None
self._issues_by_id: dict[int, list] = {}
self._is_emailed_label_by_id: dict[int, bool] = {}
def set_orders(self, orders: List[Order]) -> None: def set_orders(self, orders: List[Order]) -> None:
# Re-read status lists and cutoff time each refresh, in case Settings changed. # Re-read status lists and cutoff time each refresh, in case Settings changed.
self._cancelled_statuses = get_cancelled_statuses() self._cancelled_statuses = get_cancelled_statuses()
self._fulfilled_statuses = get_fulfilled_statuses() self._fulfilled_statuses = get_fulfilled_statuses()
self._cutoff_time = get_cutoff_time() self._cutoff_time = get_cutoff_time()
# Computed ONCE per refresh here, not per cell paint - both of
# these read Settings from disk internally, and Qt calls data()
# extremely frequently (every cell, every repaint, constantly
# during scrolling) - doing this per-cell instead of per-refresh
# was a severe, real performance bug, not just a minor slowdown.
# validation_context is ALSO fetched once here rather than once
# per order inside validate_ticket() - same fix, applied to the
# cost of computing the cache itself, not just using it.
validation_context = make_validation_context()
self._issues_by_id = {
order.id: validate_ticket(order.skus, order.line_items, **validation_context)
for order in orders
}
emailed_skus = get_emailed_label_skus()
self._is_emailed_label_by_id = {
order.id: is_emailed_label_order(order.skus or [], emailed_skus) for order in orders
}
self.beginResetModel() self.beginResetModel()
self._orders = orders self._orders = orders
self.endResetModel() self.endResetModel()
@@ -102,9 +131,14 @@ class OrdersTableModel(QAbstractTableModel):
order = self._orders[index.row()] order = self._orders[index.row()]
is_cancelled = status_in(order.status, self._cancelled_statuses) is_cancelled = status_in(order.status, self._cancelled_statuses)
field_name, _ = COLUMNS[index.column()]
if role == Qt.ItemDataRole.ForegroundRole: if role == Qt.ItemDataRole.ForegroundRole:
return CANCELLED_TEXT_COLOR if is_cancelled else None if is_cancelled:
return CANCELLED_TEXT_COLOR
if field_name == "issues_display" and self._issues_by_id.get(order.id):
return ISSUE_TEXT_COLOR
return None
if role == Qt.ItemDataRole.BackgroundRole: if role == Qt.ItemDataRole.BackgroundRole:
# Cancelled rows use red TEXT (above) instead of a background, # Cancelled rows use red TEXT (above) instead of a background,
@@ -115,13 +149,26 @@ class OrdersTableModel(QAbstractTableModel):
return FULFILLED_ROW_COLOR return FULFILLED_ROW_COLOR
return None return None
if role == Qt.ItemDataRole.ToolTipRole and field_name == "issues_display":
issues = self._issues_by_id.get(order.id, [])
return "\n".join(i.message for i in issues) if issues else None
if role != Qt.ItemDataRole.DisplayRole: if role != Qt.ItemDataRole.DisplayRole:
return None return None
field_name, _ = COLUMNS[index.column()] if field_name == "issues_display":
issues = self._issues_by_id.get(order.id, [])
return f"{WARNING_MARK} ({len(issues)})" if issues else ""
if field_name == "skus_display": if field_name == "skus_display":
return ", ".join(order.skus or []) return ", ".join(order.skus or [])
if field_name == "packed_display":
return CHECK_MARK if order.packed else ""
if field_name == "serials_display":
serials = order.serial_numbers or {}
filled = sum(1 for v in serials.values() if (v or "").strip())
return f"{filled} entered" if filled else ""
if field_name == "return_label_display":
return CHECK_MARK if self._is_emailed_label_by_id.get(order.id) else ""
if field_name == "outgoing_tracking_display": if field_name == "outgoing_tracking_display":
return _format_tracking_numbers(order.tracking_numbers, is_return=False) return _format_tracking_numbers(order.tracking_numbers, is_return=False)
if field_name == "return_tracking_display": if field_name == "return_tracking_display":
@@ -253,6 +300,20 @@ class OrdersTableView(QWidget):
source_index = self._proxy_model.mapToSource(indexes[0]) source_index = self._proxy_model.mapToSource(indexes[0])
return self._source_model.order_at(source_index.row()) return self._source_model.order_at(source_index.row())
def select_ticket(self, ticket_number: str) -> bool:
"""Re-selects a row by ticket number - a table refresh (set_orders)
clears whatever was selected, since the underlying model resets.
Used after a step in a multi-step action (like the return-label
dummy-then-return flow) so the next step doesn't silently find
nothing selected. Returns whether the ticket was found/selected."""
for row in range(self._proxy_model.rowCount()):
proxy_index = self._proxy_model.index(row, 0)
source_index = self._proxy_model.mapToSource(proxy_index)
if self._source_model.order_at(source_index.row()).ticket_number == ticket_number:
self.table.selectRow(proxy_index.row())
return True
return False
def visible_orders(self) -> List[Order]: def visible_orders(self) -> List[Order]:
"""Orders currently passing the active filters - used for export.""" """Orders currently passing the active filters - used for export."""
result = [] result = []
+189
View File
@@ -0,0 +1,189 @@
"""
Pack Ticket dialog - where staff enter serial numbers (mostly via
barcode scanner) and mark a ticket packed/ready to ship.
Barcode scanners act as a keyboard: they type the scanned value and
then send an Enter keystroke automatically. So every field here
connects its Enter/returnPressed signal to jump focus to the next
field - staff scan device after device without touching the mouse or
keyboard in between. This is the actual point of this dialog; get this
wrong and it defeats the "minimal interactions" requirement entirely.
Suggested fields come from app.serial_suggestions, based on keywords in
the ticket's line items - a starting point, not a fixed schema. Staff
can add any custom field the suggestions miss.
WORKAROUND NOTE: this dialog (along with Return Label) crashed the
whole process on close, confirmed via two full crash dumps (identical
fault offset both times) to be caused by Bitdefender Endpoint
Security's Advanced Threat Control corrupting a stack frame inside
Qt6Core.dll - not a bug in this code. Reusing a persistent instance
instead of destroying/recreating it per ticket did NOT resolve it -
the crash recurred at the same offset regardless, ruling out object
destruction timing as the cause. The current mitigation is in
main_window.py: this dialog is shown via show() (non-modal) instead of
exec() (modal), since exec() runs a nested event loop that disables
and re-enables the parent window - a different, more involved Windows
API sequence than a plain show/hide. This dialog still supports being
reused via set_order() regardless, since avoiding unnecessary
construction/destruction is sound practice either way.
"""
from __future__ import annotations
from typing import Optional
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLabel,
QLineEdit,
QPushButton,
QCheckBox,
QScrollArea,
QWidget,
QDialogButtonBox,
)
from app.models import Order
from app.serial_suggestions import suggest_serial_fields
class PackTicketDialog(QDialog):
def __init__(self, order: Order, parent=None):
super().__init__(parent)
self.order: Optional[Order] = None
self._field_rows: list[tuple[QLineEdit, QLineEdit]] = [] # (label_edit, value_edit)
layout = QVBoxLayout(self)
self.summary_label = QLabel()
layout.addWidget(self.summary_label)
layout.addWidget(QLabel("Serial numbers (scan or type; Enter moves to the next field):"))
self._scroll_area = QScrollArea()
self._scroll_area.setWidgetResizable(True)
layout.addWidget(self._scroll_area, stretch=1)
add_field_row = QHBoxLayout()
self.new_field_label_input = QLineEdit()
self.new_field_label_input.setPlaceholderText("Custom field name...")
add_button = QPushButton("Add Field")
add_button.clicked.connect(self._on_add_custom_field_clicked)
self.new_field_label_input.returnPressed.connect(self._on_add_custom_field_clicked)
add_field_row.addWidget(self.new_field_label_input, stretch=1)
add_field_row.addWidget(add_button)
layout.addLayout(add_field_row)
self.packed_checkbox = QCheckBox("Packed / ready to ship")
layout.addWidget(self.packed_checkbox)
button_box = QDialogButtonBox()
button_box.addButton("Save", QDialogButtonBox.ButtonRole.AcceptRole)
button_box.addButton(QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self.set_order(order)
def set_order(self, order: Order) -> None:
"""
Re-initializes this dialog for a different ticket, in place -
this is what lets main_window.py reuse a single persistent
instance instead of constructing (and eventually destroying) a
new one per ticket. See the module docstring for why that
matters here specifically.
"""
self.order = order
self.setWindowTitle(f"Pack Ticket - {order.ticket_number or order.external_id}")
self.resize(520, 600)
info = order.shipping_info or {}
kit_text = ", ".join(
f"{item.get('sku', '')}: {item.get('item_name', '')}" for item in (order.line_items or [])
) or ", ".join(order.skus or [])
summary_lines = [
f"Ticket: {order.ticket_number or order.external_id} Company: {order.company}",
f"Customer: {info.get('name') or '(missing)'}",
f"Kit: {kit_text or '(none)'}",
]
self.summary_label.setText("\n".join(summary_lines))
# Swap in a fresh fields widget rather than trying to clear rows
# out of the existing QFormLayout - simpler, and the old one is
# only deleteLater()'d, not force-destroyed immediately.
old_fields_widget = self._scroll_area.takeWidget()
if old_fields_widget is not None:
old_fields_widget.deleteLater()
self._fields_widget = QWidget()
self._fields_layout = QFormLayout(self._fields_widget)
self._scroll_area.setWidget(self._fields_widget)
self._field_rows = []
self.packed_checkbox.setChecked(bool(order.packed))
self._populate_initial_fields()
def _populate_initial_fields(self) -> None:
existing = dict(self.order.serial_numbers or {})
suggested = suggest_serial_fields(self.order.line_items or [])
# Suggested fields first (in suggestion order), pre-filled with any
# already-saved value so re-opening a partially-packed ticket
# doesn't lose earlier scans. Then any existing fields that aren't
# part of the current suggestion set (e.g. a custom field added
# last time, or a suggestion rule that's since changed).
added_labels: set[str] = set()
for label in suggested:
self._add_field_row(label, existing.get(label, ""))
added_labels.add(label)
for label, value in existing.items():
if label not in added_labels:
self._add_field_row(label, value)
if self._field_rows:
self._field_rows[0][1].setFocus()
def _add_field_row(self, label: str, value: str = "") -> None:
label_edit = QLineEdit(label)
label_edit.setReadOnly(True)
label_edit.setStyleSheet("border: none; background: transparent;")
value_edit = QLineEdit(value)
row_index = len(self._field_rows)
value_edit.returnPressed.connect(lambda: self._focus_next(row_index))
self._fields_layout.addRow(label_edit, value_edit)
self._field_rows.append((label_edit, value_edit))
def _focus_next(self, current_index: int) -> None:
next_index = current_index + 1
if next_index < len(self._field_rows):
self._field_rows[next_index][1].setFocus()
self._field_rows[next_index][1].selectAll()
else:
# Last known field - hand off to the checkbox rather than
# silently submitting, so finishing still takes one deliberate
# action instead of an accidental extra scan closing the dialog.
self.packed_checkbox.setFocus()
def _on_add_custom_field_clicked(self) -> None:
label = self.new_field_label_input.text().strip()
if not label:
return
self._add_field_row(label, "")
self.new_field_label_input.clear()
self._field_rows[-1][1].setFocus()
def get_serial_numbers(self) -> dict[str, str]:
return {
label_edit.text(): value_edit.text()
for label_edit, value_edit in self._field_rows
if value_edit.text().strip()
}
def get_packed(self) -> bool:
return self.packed_checkbox.isChecked()
+258
View File
@@ -0,0 +1,258 @@
"""
Return label creation dialog - for the emailed-return-label workflow
(SH007 / OK012). Shows the ticket's description (where staff note what
boxes are needed) and lets them specify however many packages, each
with its own weight/dimensions.
These packages genuinely reach ShipStation now: a standalone return
label (no linked outbound shipment) turned out to report success while
being invisible in ShipStation's UI - confirmed against the team's own
working manual process, not just the API docs. The fix (in
shipstation_send.py) creates a minimal, cheap dummy outbound label
first and links the real return to it via outbound_label_id, matching
what ShipStation's GUI does automatically when creating a return from
an existing shipment. The dialog itself doesn't need to know about
that - it just collects real packages, same as before.
WORKAROUND NOTE: this dialog crashed the whole process on close,
confirmed via two full crash dumps (identical fault offset both times)
to be caused by Bitdefender Endpoint Security's Advanced Threat
Control (atcuf64.dll) corrupting a stack frame inside Qt6Core.dll -
not a bug in this code. Six rewrites of this dialog's contents made no
difference, including one with almost no widgets at all, and neither
did reusing a single persistent instance instead of creating a new one
per ticket - the crash recurred at the exact same offset regardless.
That rules out both "which widgets" and "object destruction timing" as
the cause. The current mitigation is in main_window.py: this dialog is
shown via show() (non-modal) instead of exec() (modal), since exec()
runs a nested event loop that disables/re-enables the parent window -
a different, more involved Windows API sequence than a plain show/hide,
and one more plausible avenue for Bitdefender's hook to misfire on.
This dialog still supports being reused via set_order() regardless,
since keeping construction/destruction out of the hot path is sound
practice independent of whether it turns out to be the actual fix.
After a successful create, the app does NOT email anything - per the
team's workflow, that happens from ShipStation itself so it goes out
through their branded return-email template.
"""
from __future__ import annotations
from typing import List, Optional
from PyQt6.QtCore import QLocale
from PyQt6.QtGui import QDoubleValidator
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLabel,
QLineEdit,
QPushButton,
QComboBox,
QWidget,
QDialogButtonBox,
)
from app.models import Order
CHARGE_EVENT_LABELS = {
"carrier_default": "Carrier default",
"on_creation": "On label creation",
"on_carrier_acceptance": "On carrier acceptance (label may go unused free)",
}
DEFAULT_WEIGHT_LB = "0.00"
DEFAULT_WEIGHT_OZ = "1.00"
DEFAULT_DIMENSION_IN = "1.00"
def _make_number_field(default_text: str) -> QLineEdit:
field = QLineEdit(default_text)
validator = QDoubleValidator(0.0, 9999.0, 2, field)
validator.setLocale(QLocale(QLocale.Language.English, QLocale.Country.UnitedStates))
validator.setNotation(QDoubleValidator.Notation.StandardNotation)
field.setValidator(validator)
field.setMaximumWidth(70)
return field
def _parse_number(text: str) -> float:
try:
return float(text)
except (TypeError, ValueError):
return 0.0
class ReturnLabelDialog(QDialog):
def __init__(self, order: Order, parent=None):
super().__init__(parent)
self.order: Optional[Order] = None
self._package_rows: list[dict] = [] # [{widget, weight, length, width, height}]
layout = QVBoxLayout(self)
self.summary_label = QLabel()
layout.addWidget(self.summary_label)
self.step_status_label = QLabel()
self.step_status_label.setWordWrap(True)
self.step_status_label.setStyleSheet("font-weight: bold;")
layout.addWidget(self.step_status_label)
layout.addWidget(QLabel("Description (box requirements from JIRA):"))
self.description_label = QLabel()
self.description_label.setWordWrap(True)
self.description_label.setStyleSheet(
"border: 1px solid palette(mid); padding: 4px; background: palette(base);"
)
layout.addWidget(self.description_label)
layout.addWidget(QLabel("Packages - one row per box (weight in oz, dimensions in inches):"))
self._packages_container = QWidget()
self._packages_layout = QVBoxLayout(self._packages_container)
self._packages_layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._packages_container)
package_buttons = QHBoxLayout()
add_button = QPushButton("Add Package")
add_button.clicked.connect(self._add_package_row)
remove_button = QPushButton("Remove Last Package")
remove_button.clicked.connect(self._remove_last_package_row)
package_buttons.addWidget(add_button)
package_buttons.addWidget(remove_button)
package_buttons.addStretch()
layout.addLayout(package_buttons)
form = QFormLayout()
self.charge_event_combo = QComboBox()
for value, label in CHARGE_EVENT_LABELS.items():
self.charge_event_combo.addItem(label, userData=value)
form.addRow("Charge event:", self.charge_event_combo)
layout.addLayout(form)
button_box = QDialogButtonBox()
self.create_button = button_box.addButton(
"Step 1: Create Dummy Shipment", QDialogButtonBox.ButtonRole.AcceptRole
)
button_box.addButton(QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self.set_order(order)
def set_order(self, order: Order) -> None:
"""
Re-initializes this dialog for a different ticket, in place -
this is what lets main_window.py reuse a single persistent
instance instead of constructing (and eventually destroying) a
new one per ticket. See the module docstring for why that
matters here specifically.
"""
self.order = order
self.setWindowTitle(f"Create Return Label - {order.ticket_number or order.external_id}")
self.resize(560, 520)
info = order.shipping_info or {}
address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
summary_lines = [
f"Ticket: {order.ticket_number or order.external_id} Company: {order.company}",
f"Customer: {info.get('name') or '(missing)'}",
f"Address: {info.get('address1') or '(missing)'}{address_line2}, "
f"{info.get('city', '')}, {info.get('state', '')} {info.get('zip', '')}",
]
self.summary_label.setText("\n".join(summary_lines))
self.description_label.setText(order.description or "(no description on this ticket)")
# This dialog is used for BOTH steps of the workflow - the button
# (and what clicking it actually does, wired up in main_window.py)
# depends on whether a dummy shipment already exists for this
# ticket. Splitting these apart on purpose, per your request: a
# problem in the dummy step shouldn't be masked by immediately
# attempting the return step too.
if order.dummy_outbound_label_id:
self.step_status_label.setText(
f"Step 1 done - dummy shipment {order.dummy_outbound_label_id} already exists. "
"If you've verified it looks right in ShipStation (Order #, Ship From/Store all "
"populated), click below to create the actual return label from it."
)
self.create_button.setText("Step 2: Create Return Label")
else:
self.step_status_label.setText(
"Step 1: this creates a cheap dummy outbound shipment first (1x1x1in, 1oz). "
"Go verify it in ShipStation before running this again to create the actual "
"return label - that way a problem in either step is easy to isolate."
)
self.create_button.setText("Step 1: Create Dummy Shipment")
# Clear out any package rows left over from a previous ticket.
# setParent(None) + deleteLater() rather than an immediate delete -
# deferred deletion here is deliberate, letting Qt clean these up
# on its own schedule rather than forcing it synchronously.
for entry in self._package_rows:
entry["widget"].setParent(None)
entry["widget"].deleteLater()
self._package_rows = []
self._add_package_row()
self.charge_event_combo.setCurrentIndex(0)
def _add_package_row(self) -> None:
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
weight_lb_field = _make_number_field(DEFAULT_WEIGHT_LB)
weight_oz_field = _make_number_field(DEFAULT_WEIGHT_OZ)
length_field = _make_number_field(DEFAULT_DIMENSION_IN)
width_field = _make_number_field(DEFAULT_DIMENSION_IN)
height_field = _make_number_field(DEFAULT_DIMENSION_IN)
for label_text, widget in [
("Weight (lb):", weight_lb_field),
("+ (oz):", weight_oz_field),
("L (in):", length_field),
("W (in):", width_field),
("H (in):", height_field),
]:
row_layout.addWidget(QLabel(label_text))
row_layout.addWidget(widget)
row_layout.addStretch()
entry = {
"widget": row_widget,
"weight_lb": weight_lb_field,
"weight_oz": weight_oz_field,
"length": length_field,
"width": width_field,
"height": height_field,
}
self._package_rows.append(entry)
self._packages_layout.addWidget(row_widget)
def _remove_last_package_row(self) -> None:
if len(self._package_rows) <= 1:
return
entry = self._package_rows.pop()
entry["widget"].setParent(None)
entry["widget"].deleteLater()
def get_packages(self) -> List[dict]:
return [
{
"weight_oz": (
_parse_number(entry["weight_lb"].text()) * 16
+ _parse_number(entry["weight_oz"].text())
),
"length": _parse_number(entry["length"].text()),
"width": _parse_number(entry["width"].text()),
"height": _parse_number(entry["height"].text()),
}
for entry in self._package_rows
]
def get_charge_event(self) -> str:
return self.charge_event_combo.currentData()
+246 -8
View File
@@ -9,11 +9,13 @@ pattern rather than blocking the UI.
from __future__ import annotations from __future__ import annotations
import datetime as dt import datetime as dt
import uuid
from typing import List, Tuple, TypedDict from typing import List, Tuple, TypedDict
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
from sqlalchemy import select from sqlalchemy import select, delete
from app import config
from app.database import get_session from app.database import get_session
from app.models import Order from app.models import Order
from app.schedule import get_cutoff_time, is_past_cutoff_today from app.schedule import get_cutoff_time, is_past_cutoff_today
@@ -61,6 +63,62 @@ class SendToShipStationWorker(QThread):
self.finished_ok.emit(result) self.finished_ok.emit(result)
class CreateDummyShipmentWorker(QThread):
"""Runs step 1 (dummy outbound shipment creation) off the GUI thread."""
finished_ok = pyqtSignal(dict) # the created dummy label's JSON
failed = pyqtSignal(str)
def __init__(self, order, parent=None):
super().__init__(parent)
self.order = order
def run(self) -> None:
from app.services.shipstation_send import create_dummy_shipment, ShipStationSendError
try:
result = create_dummy_shipment(self.order)
except ShipStationSendError as exc:
self.failed.emit(str(exc))
return
except Exception as exc: # noqa: BLE001
self.failed.emit(f"Unexpected error creating dummy shipment: {exc}")
return
self.finished_ok.emit(result)
class CreateReturnLabelWorker(QThread):
"""Runs step 2 (the real return label, from an already-created dummy)
off the GUI thread."""
finished_ok = pyqtSignal(dict) # the created label's JSON
failed = pyqtSignal(str)
def __init__(self, order, dummy_label_id: str, packages: list[dict], charge_event: str, parent=None):
super().__init__(parent)
self.order = order
self.dummy_label_id = dummy_label_id
self.packages = packages
self.charge_event = charge_event
def run(self) -> None:
from app.services.shipstation_send import create_return_label_from_dummy, ShipStationSendError
try:
result = create_return_label_from_dummy(
self.order, self.dummy_label_id, self.packages, self.charge_event
)
except ShipStationSendError as exc:
self.failed.emit(str(exc))
return
except Exception as exc: # noqa: BLE001
self.failed.emit(f"Unexpected error creating return label: {exc}")
return
self.finished_ok.emit(result)
class FetchOrdersWorker(QThread): class FetchOrdersWorker(QThread):
"""Fetches orders from a given service and saves/merges the results.""" """Fetches orders from a given service and saves/merges the results."""
@@ -102,6 +160,13 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
what moves it into the Done pile. cancelled_at works the same way for what moves it into the Done pile. cancelled_at works the same way for
CANCELLED_STATUSES - it's what limits the Cancelled tab to "cancelled CANCELLED_STATUSES - it's what limits the Cancelled tab to "cancelled
today" rather than showing every cancelled ticket ever. today" rather than showing every cancelled ticket ever.
These get stamped with dt.datetime.now() (LOCAL time), not utcnow() -
deliberately, since every "is this today" check elsewhere compares
against dt.date.today() (also local). Mixing the two caused a real
bug: a ticket cancelled in the evening in a US timezone would get a
UTC timestamp that had already rolled into tomorrow, failing the
same-day check immediately and landing on Done instead of Cancelled.
""" """
session = get_session() session = get_session()
new_count = 0 new_count = 0
@@ -127,6 +192,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
continue continue
jira_row.tracking_numbers = order.get("tracking_numbers", []) jira_row.tracking_numbers = order.get("tracking_numbers", [])
if order.get("shipping_method"):
jira_row.shipping_method = order["shipping_method"]
enriched_count += 1 enriched_count += 1
continue continue
@@ -151,16 +218,18 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
line_items=order.get("line_items", []), line_items=order.get("line_items", []),
shipping_info=order.get("shipping_info", {}), shipping_info=order.get("shipping_info", {}),
creator=order.get("creator"), creator=order.get("creator"),
assignee=order.get("assignee"),
description=order.get("description"),
tracking_numbers=order.get("tracking_numbers", []), tracking_numbers=order.get("tracking_numbers", []),
summary=order["summary"], summary=order["summary"],
status=new_status, status=new_status,
source_created_at=order["source_created_at"], source_created_at=order["source_created_at"],
raw_data=order["raw_data"], raw_data=order["raw_data"],
fulfilled_at=( fulfilled_at=(
dt.datetime.utcnow() if status_in(new_status, fulfilled_statuses) else None dt.datetime.now() if status_in(new_status, fulfilled_statuses) else None
), ),
cancelled_at=( cancelled_at=(
dt.datetime.utcnow() if status_in(new_status, cancelled_statuses) else None dt.datetime.now() if status_in(new_status, cancelled_statuses) else None
), ),
) )
) )
@@ -178,11 +247,11 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
if status_in(new_status, fulfilled_statuses) and not status_in( if status_in(new_status, fulfilled_statuses) and not status_in(
old_status, fulfilled_statuses old_status, fulfilled_statuses
): ):
existing.fulfilled_at = dt.datetime.utcnow() existing.fulfilled_at = dt.datetime.now()
if status_in(new_status, cancelled_statuses) and not status_in( if status_in(new_status, cancelled_statuses) and not status_in(
old_status, cancelled_statuses old_status, cancelled_statuses
): ):
existing.cancelled_at = dt.datetime.utcnow() existing.cancelled_at = dt.datetime.now()
existing.ticket_number = order.get("ticket_number") existing.ticket_number = order.get("ticket_number")
existing.company = order.get("company", "Unknown") existing.company = order.get("company", "Unknown")
@@ -190,6 +259,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
existing.line_items = order.get("line_items", []) existing.line_items = order.get("line_items", [])
existing.shipping_info = order.get("shipping_info", {}) existing.shipping_info = order.get("shipping_info", {})
existing.creator = order.get("creator") existing.creator = order.get("creator")
existing.assignee = order.get("assignee")
existing.description = order.get("description")
existing.summary = order["summary"] existing.summary = order["summary"]
existing.status = new_status existing.status = new_status
existing.source_created_at = order["source_created_at"] existing.source_created_at = order["source_created_at"]
@@ -209,6 +280,120 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
) )
def reset_local_database() -> int:
"""
Wipes every locally cached order. This is a rebuildable cache, not a
system of record - JIRA is - so this is always safe, just requires
re-running Import from JIRA (and Pull Tracking Numbers) afterward.
Also the real fix for one specific situation: a ticket that
transitioned status under an older, buggy version of this app can end
up with a permanently-wrong fulfilled_at/cancelled_at timestamp, since
those are only recalculated ON a transition - re-importing an
already-transitioned ticket finds "no change" and never touches it
again. A reset clears that stale timestamp entirely; the fresh
re-import then stamps everything correctly from scratch.
"""
session = get_session()
try:
result = session.execute(delete(Order))
session.commit()
return result.rowcount or 0
finally:
session.close()
def _emailed_label_sku_for_company(company: str) -> str:
"""Picks whichever configured EMAILED_LABEL_SKUS entry actually
resolves to the given company via COMPANY_SKU_MAP, rather than
hardcoding SH007/OK012 - stays correct if either setting changes."""
from app.companies import parse_mapping, resolve_company_by_sku
from app.return_labels import get_emailed_label_skus
sku_map = parse_mapping(config.get("COMPANY_SKU_MAP", config.DEFAULT_COMPANY_SKU_MAP))
for sku in sorted(get_emailed_label_skus()):
if resolve_company_by_sku(sku, sku_map) == company:
return sku.upper()
return ""
def create_test_shipment_order(company: str) -> Order:
"""
Creates a synthetic, non-JIRA Order row carrying that company's
configured emailed-return-label SKU, purely so the existing "Create
Return Label" flow (return_label_dialog.py + shipstation_send.py) can
be exercised end-to-end - dummy shipment, then real return label -
against a throwaway ticket instead of risking a real customer's.
source="test" keeps this completely separate from real JIRA rows:
save_orders() only ever matches on source == "jira"/"shipstation", so
Import from JIRA can never touch or overwrite one of these, and
delete_test_shipments() cleans them up by that same marker.
status="Created" so it lands on the Active tab like a real open
ticket would - that's where staff would naturally go to select it and
run Create Return Label.
"""
sku = _emailed_label_sku_for_company(company)
if not sku:
raise ValueError(
f"No EMAILED_LABEL_SKUS entry resolves to '{company}' via COMPANY_SKU_MAP - "
"check both settings."
)
ticket_number = f"TEST-EMAIL-{uuid.uuid4().hex[:8].upper()}"
order = Order(
source="test",
external_id=ticket_number,
ticket_number=ticket_number,
company=company,
skus=[sku],
line_items=[{"sku": sku, "item_name": "TEST - Emailed Return Label"}],
shipping_info={
"name": "TEST ORDER - DO NOT SHIP",
"phone": "555-555-5555",
"email": "",
"address1": "123 Test St",
"address2": "",
"city": "Austin",
"state": "TX",
"zip": "78701",
},
creator="Test Shipment Generator",
assignee="",
description=(
"Synthetic test order created via Data > Create Test Shipment (Email SKU) - "
"not a real ticket. Safe to delete with Data > Delete Test Shipments."
),
summary=f"TEST - Emailed Return Label ({company})",
status="Created",
source_created_at=dt.datetime.now(),
)
session = get_session()
try:
session.add(order)
session.commit()
finally:
session.close()
return order
def delete_test_shipments() -> int:
"""Removes every synthetic order created by create_test_shipment_order()
(source == "test") - real JIRA-sourced rows are untouched, since those
always have source == "jira"."""
session = get_session()
try:
result = session.execute(delete(Order).where(Order.source == "test"))
session.commit()
return result.rowcount or 0
finally:
session.close()
def load_all_orders() -> List[Order]: def load_all_orders() -> List[Order]:
session = get_session() session = get_session()
try: try:
@@ -258,14 +443,67 @@ def load_orders_by_view() -> Tuple[List[Order], List[Order], List[Order]]:
def mark_shipstation_sent(ticket_number: str) -> None: def mark_shipstation_sent(ticket_number: str) -> None:
"""Stamps shipstation_sent_at after a CONFIRMED emergency API send - """Stamps shipstation_sent_at after a CONFIRMED emergency API send -
called once ShipStation's own response confirms creation succeeded.""" called once ShipStation's own response confirms creation succeeded.
Matches by ticket_number alone, not source == "jira" - source is only
ever "jira" or "test" (synthetic tickets from create_test_shipment_order()),
and ticket_number is already unique across both, so restricting to "jira"
here just means this silently no-ops for test tickets instead of erroring."""
session = get_session() session = get_session()
try: try:
order = session.execute( order = session.execute(
select(Order).where(Order.source == "jira", Order.ticket_number == ticket_number) select(Order).where(Order.ticket_number == ticket_number)
).scalar_one_or_none() ).scalar_one_or_none()
if order is not None: if order is not None:
order.shipstation_sent_at = dt.datetime.utcnow() order.shipstation_sent_at = dt.datetime.now()
session.commit()
finally:
session.close()
def save_dummy_outbound_label_id(ticket_number: str, label_id: str) -> None:
"""Persists step 1's result (the dummy shipment's label_id) so step 2
(the real return label) can be triggered separately - including in a
later session, after the dummy has been verified in ShipStation.
Matches by ticket_number alone - see mark_shipstation_sent() above for why
this must not be restricted to source == "jira"."""
session = get_session()
try:
order = session.execute(
select(Order).where(Order.ticket_number == ticket_number)
).scalar_one_or_none()
if order is not None:
order.dummy_outbound_label_id = label_id
session.commit()
finally:
session.close()
def save_pack_data(ticket_number: str, serial_numbers: dict, packed: bool) -> None:
"""
Saves serial numbers and the packed/done flag from the Pack Ticket
dialog. Staff-entered data, not sourced from JIRA - this is the
beginning of the eventual end-of-day push back to JIRA (deferred for
now), so nothing here gets overwritten by a JIRA re-import.
Matches by ticket_number alone, not source == "jira" - see
mark_shipstation_sent() for why.
"""
session = get_session()
try:
order = session.execute(
select(Order).where(Order.ticket_number == ticket_number)
).scalar_one_or_none()
if order is None:
return
order.serial_numbers = serial_numbers
was_packed = order.packed
order.packed = packed
if packed and not was_packed:
order.packed_at = dt.datetime.now()
elif not packed:
order.packed_at = None
session.commit() session.commit()
finally: finally:
session.close() session.close()
+62
View File
@@ -0,0 +1,62 @@
"""
Minimal, standalone crash test - completely independent of the Order
Manager app. Run this directly:
python minimal_crash_test.py
Click "Open Dialog", then close it (X button or Cancel). If this
crashes the same way (exit code -1073740791 / 0xC0000409), the issue
is in your PyQt6 install/environment itself, not in anything specific
to the Order Manager codebase - every fix attempt there has been
chasing the wrong thing.
"""
import sys
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QPushButton,
QDialog,
QVBoxLayout,
QLineEdit,
QLabel,
QDialogButtonBox,
)
class MinimalDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Minimal Test Dialog")
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Just a label and a text field."))
layout.addWidget(QLineEdit("1.00"))
button_box = QDialogButtonBox()
button_box.addButton("OK", QDialogButtonBox.ButtonRole.AcceptRole)
button_box.addButton(QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Minimal Crash Test")
self.resize(300, 100)
button = QPushButton("Open Dialog")
button.clicked.connect(self.open_dialog)
self.setCentralWidget(button)
def open_dialog(self):
print("about to open dialog", flush=True)
dialog = MinimalDialog(self)
result = dialog.exec()
print(f"dialog closed, result={result}", flush=True)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
+113
View File
@@ -0,0 +1,113 @@
"""
Incremental diagnostic test #2.
The standalone minimal_crash_test.py (bare dialog, no app code) has
NEVER crashed. A dialog inside the real app with almost no widgets at
all (just one label and two buttons) STILL crashes. So the difference
must be something about the app around the dialog, not the dialog's
own widgets - two candidates: (1) a real SQLAlchemy Order object is
involved, (2) the dialog opens via a QToolBar/QAction inside a larger
QMainWindow rather than a plain QPushButton on a near-empty window.
This script adds BOTH of those, on top of the exact same working
baseline dialog content, using this project's actual database code -
run it from the project root (same folder as main.py):
python minimal_crash_test_2.py
Click "Open Dialog", close it (X or Cancel). If this crashes, we've
found the actual trigger. If it doesn't, the cause is something more
specific to main_window.py itself that isn't reproduced here yet.
"""
import sys
import datetime as dt
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QToolBar,
QDialog,
QVBoxLayout,
QLabel,
QDialogButtonBox,
)
from PyQt6.QtGui import QAction
from app.database import init_db
from app.workers import save_orders, load_all_orders
class MinimalDialog(QDialog):
def __init__(self, order, parent=None):
super().__init__(parent)
self.order = order
self.setWindowTitle("Minimal Test Dialog (with real Order)")
layout = QVBoxLayout(self)
info = order.shipping_info or {}
layout.addWidget(
QLabel(f"Ticket: {order.ticket_number}\nCustomer: {info.get('name')}")
)
button_box = QDialogButtonBox()
button_box.addButton("OK", QDialogButtonBox.ButtonRole.AcceptRole)
button_box.addButton(QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
class MainWindow(QMainWindow):
def __init__(self, order):
super().__init__()
self.order = order
self.setWindowTitle("Minimal Crash Test 2 (real Order + QAction + QToolBar)")
self.resize(400, 150)
toolbar = QToolBar("Main")
self.addToolBar(toolbar)
action = QAction("Open Dialog", self)
action.triggered.connect(self.open_dialog)
toolbar.addAction(action)
def open_dialog(self):
print("about to construct dialog", flush=True)
dialog = MinimalDialog(self.order, self)
print("about to call exec()", flush=True)
result = dialog.exec()
print(f"exec() returned {result}", flush=True)
if __name__ == "__main__":
init_db()
# Ensure there's at least one order to work with
orders = load_all_orders()
if not orders:
save_orders(
[
{
"source": "jira",
"external_id": "TEST-1",
"ticket_number": "TEST-1",
"company": "Oak Street Health",
"skus": ["OK012"],
"line_items": [],
"shipping_info": {"name": "Test Customer"},
"creator": "X",
"assignee": "Y",
"description": "",
"tracking_numbers": [],
"shipping_method": None,
"summary": "",
"status": "Created",
"source_created_at": dt.datetime.now(),
"raw_data": {},
}
]
)
orders = load_all_orders()
app = QApplication(sys.argv)
window = MainWindow(orders[0])
window.show()
sys.exit(app.exec())