Compare commits
9
Commits
6655b02f4a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8f59b7725 | ||
|
|
a7d2e7870c | ||
|
|
82e6148ca9 | ||
|
|
b5570d39f0 | ||
|
|
a6f60408ff | ||
|
|
52bd6f5da2 | ||
|
|
a1b438a61f | ||
|
|
edd5f2a886 | ||
|
|
826a70bdf6 |
+81
-1
@@ -54,9 +54,29 @@ SHIPSTATION_OAKSTREET_STORE_ID=se-367672
|
||||
SHIPSTATION_SIGNIFY_WAREHOUSE_ID=se-180473
|
||||
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 ---
|
||||
# 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
|
||||
# not found in ShipStation's usual shipment_number/external_shipment_id fields.
|
||||
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
|
||||
# Dashboard - they didn't get a full window to be processed same-day.
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
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
|
||||
|
||||
For the rare case a ticket needs to skip the normal daily batch. Select
|
||||
@@ -276,9 +322,54 @@ app/
|
||||
`_on_export_clicked` calls `export_orders_to_csv`.
|
||||
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
|
||||
|
||||
The **emailed-label SKU** (mentioned but not detailed yet) needs its
|
||||
own handling eventually - tell me the SKU and what "done" looks like
|
||||
for it when you're ready, and I'll fold it into the terminal-status /
|
||||
tracking logic above rather than bolting on something separate.
|
||||
You mentioned occasionally shipping return items to a company's
|
||||
headquarters instead of the shared warehouse - noted, not built yet
|
||||
since you said we could tackle it later. When you're ready, this would
|
||||
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
@@ -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
@@ -75,6 +75,54 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
|
||||
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": (
|
||||
"SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)",
|
||||
"Companies",
|
||||
@@ -86,6 +134,65 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
|
||||
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": (
|
||||
"Statuses that count as real active work (comma-separated) - "
|
||||
"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_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_FULFILLED_WITH_RETURN = "Waiting For Return"
|
||||
DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed"
|
||||
DEFAULT_INTAKE_CUTOFF_TIME = "15:30"
|
||||
DEFAULT_ACTIVE_STATUSES = "Created"
|
||||
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:
|
||||
@@ -183,3 +310,34 @@ def save_settings(values: Dict[str, str]) -> None:
|
||||
def get(key: str, default: str = "") -> str:
|
||||
"""Convenience getter, e.g. config.get('DB_URL')."""
|
||||
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, "")
|
||||
|
||||
@@ -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)}"
|
||||
+39
-1
@@ -15,6 +15,7 @@ from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
DateTime,
|
||||
Boolean,
|
||||
JSON,
|
||||
@@ -64,12 +65,41 @@ class Order(Base):
|
||||
|
||||
# Display name of whoever created the JIRA ticket.
|
||||
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
|
||||
# ticket - e.g. [{"number": "782758401696", "carrier": "ups",
|
||||
# "is_return": false}]. Populated by the "Pull Tracking Numbers"
|
||||
# action, separately from the JIRA import.
|
||||
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="")
|
||||
status = Column(String(100), nullable=False, default="")
|
||||
@@ -93,8 +123,16 @@ class Order(Base):
|
||||
# isn't the same as it actually being imported, and this app has no
|
||||
# way to confirm that manual step happened.
|
||||
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)
|
||||
|
||||
# Full original payload from JIRA, for anything not modeled explicitly
|
||||
|
||||
@@ -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 [])
|
||||
@@ -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
|
||||
@@ -25,7 +25,10 @@ class NormalizedOrder(TypedDict):
|
||||
line_items: List[dict]
|
||||
shipping_info: dict
|
||||
creator: Optional[str]
|
||||
assignee: Optional[str]
|
||||
description: Optional[str]
|
||||
tracking_numbers: List[dict]
|
||||
shipping_method: Optional[str]
|
||||
summary: str
|
||||
status: str
|
||||
source_created_at: Optional[dt.datetime]
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import List, Tuple
|
||||
import requests
|
||||
|
||||
from app import config
|
||||
from app.adf import adf_to_text
|
||||
from app.companies import parse_mapping, resolve_company_for_skus
|
||||
from app.queries import get_open_ticket_numbers
|
||||
from app.services.base import OrderService, NormalizedOrder
|
||||
@@ -164,7 +165,7 @@ class JiraService(OrderService):
|
||||
|
||||
# Always pull summary/status/created/creator, plus every configured
|
||||
# deliverable field and contact field.
|
||||
fields = "summary,status,created,creator"
|
||||
fields = "summary,status,created,creator,assignee,description"
|
||||
if 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]
|
||||
@@ -322,6 +323,11 @@ class JiraService(OrderService):
|
||||
creator = fields.get("creator") 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:
|
||||
fields = issue.get("fields", {})
|
||||
created_raw = fields.get("created")
|
||||
@@ -338,6 +344,8 @@ class JiraService(OrderService):
|
||||
company = resolve_company_for_skus(skus, self.sku_map)
|
||||
shipping_info = self._extract_shipping_info(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
|
||||
# blank - fall back to the deliverables so there's still
|
||||
@@ -353,7 +361,10 @@ class JiraService(OrderService):
|
||||
line_items=line_items,
|
||||
shipping_info=shipping_info,
|
||||
creator=creator,
|
||||
assignee=assignee,
|
||||
description=description,
|
||||
tracking_numbers=[],
|
||||
shipping_method=None, # comes from ShipStation, not JIRA - see shipstation_service.py
|
||||
summary=summary,
|
||||
status=(fields.get("status") or {}).get("name", ""),
|
||||
source_created_at=created_at,
|
||||
|
||||
@@ -41,6 +41,183 @@ from app.models import Order
|
||||
API_BASE = "https://api.shipstation.com/v2"
|
||||
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
|
||||
# (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and
|
||||
# is intentionally left out here.
|
||||
@@ -129,21 +306,39 @@ def export_order_to_shipstation_csv(orders: List[Order], filepath: str) -> int:
|
||||
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:
|
||||
settings = config.load_settings()
|
||||
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":
|
||||
return settings.get("SHIPSTATION_OAKSTREET_STORE_ID", "")
|
||||
return _normalize_shipstation_id(config.get_shipstation_setting("SHIPSTATION_OAKSTREET_STORE_ID"))
|
||||
return ""
|
||||
|
||||
|
||||
def _warehouse_id_for_company(company: str) -> str:
|
||||
settings = config.load_settings()
|
||||
if company == "Signify Health":
|
||||
return settings.get("SHIPSTATION_SIGNIFY_WAREHOUSE_ID", "")
|
||||
return _normalize_shipstation_id(
|
||||
config.get_shipstation_setting("SHIPSTATION_SIGNIFY_WAREHOUSE_ID")
|
||||
)
|
||||
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 ""
|
||||
|
||||
|
||||
@@ -255,3 +450,354 @@ def send_order_to_shipstation_api(order: Order) -> dict:
|
||||
raise ShipStationSendError(f"ShipStation reported errors: {result['errors']}")
|
||||
|
||||
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
|
||||
|
||||
@@ -45,6 +45,7 @@ import requests
|
||||
from PyQt6.QtCore import QRunnable, QThreadPool
|
||||
|
||||
from app import config
|
||||
from app.companies import parse_mapping
|
||||
from app.services.base import OrderService, NormalizedOrder
|
||||
from app.tracking import suggest_jira_status
|
||||
|
||||
@@ -64,6 +65,22 @@ SHIPMENT_LOOKBACK_DAYS = 7
|
||||
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):
|
||||
"""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:
|
||||
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(
|
||||
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 ---
|
||||
tracking_by_ticket: Dict[str, List[dict]] = {}
|
||||
raw_by_ticket: Dict[str, dict] = {}
|
||||
shipping_method_by_ticket: Dict[str, str] = {}
|
||||
self.unmatched_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[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 [
|
||||
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
|
||||
]
|
||||
@@ -280,7 +309,7 @@ class ShipStationService(OrderService):
|
||||
|
||||
@staticmethod
|
||||
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:
|
||||
suggested_status = suggest_jira_status(tracking_numbers) or "Tracking Pulled"
|
||||
numbers_display = ", ".join(
|
||||
@@ -294,7 +323,13 @@ class ShipStationService(OrderService):
|
||||
ticket_number=ticket_number,
|
||||
company="", # not used - the JIRA row this merges onto already has one
|
||||
skus=[],
|
||||
line_items=[],
|
||||
shipping_info={},
|
||||
creator=None,
|
||||
assignee=None,
|
||||
description=None,
|
||||
tracking_numbers=tracking_numbers,
|
||||
shipping_method=shipping_method,
|
||||
summary=numbers_display,
|
||||
status=suggested_status,
|
||||
source_created_at=None,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
+575
-32
@@ -12,8 +12,11 @@ here is purely a label-generation step for JIRA tickets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import webbrowser
|
||||
|
||||
from PyQt6.QtGui import QAction
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication,
|
||||
QMainWindow,
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
@@ -23,8 +26,13 @@ from PyQt6.QtWidgets import (
|
||||
QLabel,
|
||||
QTabWidget,
|
||||
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.odoo_export import export_orders_to_csv
|
||||
from app.services.shipstation_send import export_order_to_shipstation_csv
|
||||
@@ -33,13 +41,21 @@ from app.ui.settings_dialog import SettingsDialog
|
||||
from app.ui.widgets.orders_table import OrdersTableView
|
||||
from app.ui.widgets.dashboard import DashboardWidget
|
||||
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 (
|
||||
FetchOrdersWorker,
|
||||
SendToShipStationWorker,
|
||||
CreateDummyShipmentWorker,
|
||||
CreateReturnLabelWorker,
|
||||
load_orders_by_view,
|
||||
get_dashboard_stats,
|
||||
mark_shipstation_sent,
|
||||
save_dummy_outbound_label_id,
|
||||
save_pack_data,
|
||||
reset_local_database,
|
||||
create_test_shipment_order,
|
||||
delete_test_shipments,
|
||||
)
|
||||
|
||||
|
||||
@@ -52,6 +68,13 @@ class MainWindow(QMainWindow):
|
||||
self._workers: dict[str, FetchOrdersWorker] = {}
|
||||
self._import_actions: dict[str, QAction] = {}
|
||||
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._refresh_everything()
|
||||
@@ -78,59 +101,368 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self.setCentralWidget(central)
|
||||
|
||||
toolbar = QToolBar("Main")
|
||||
toolbar.setMovable(False)
|
||||
self.addToolBar(toolbar)
|
||||
menu_bar = self.menuBar()
|
||||
|
||||
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.triggered.connect(lambda: self._on_import_clicked("jira"))
|
||||
toolbar.addAction(jira_action)
|
||||
data_menu.addAction(jira_action)
|
||||
self._import_actions["jira"] = jira_action
|
||||
|
||||
shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self)
|
||||
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
|
||||
|
||||
toolbar.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)
|
||||
data_menu.addSeparator()
|
||||
|
||||
refresh_action = QAction("Refresh from Local DB", self)
|
||||
refresh_action.triggered.connect(self._refresh_everything)
|
||||
toolbar.addAction(refresh_action)
|
||||
data_menu.addAction(refresh_action)
|
||||
|
||||
reset_action = QAction("Reset Local Database", self)
|
||||
reset_action.setToolTip("Wipes the local cache - re-import from JIRA afterward")
|
||||
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)
|
||||
toolbar.addAction(reset_action)
|
||||
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.setStatusBar(self.status_bar)
|
||||
self.status_label = QLabel("Ready.")
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
def _on_settings_clicked(self) -> None:
|
||||
dialog = SettingsDialog(self)
|
||||
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:
|
||||
dialog = OrderDetailDialog(order, self)
|
||||
@@ -339,14 +671,225 @@ class MainWindow(QMainWindow):
|
||||
"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 (JIRA is still the real source of "
|
||||
"truth, so nothing there is affected). 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"
|
||||
"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,
|
||||
|
||||
@@ -4,15 +4,20 @@ Order detail dialog.
|
||||
Mainly a debugging aid: shows exactly what JIRA sent back for this
|
||||
ticket (raw payload), plus the tracking numbers ShipStation supplied
|
||||
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
|
||||
|
||||
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.ticket_validation import validate_ticket
|
||||
from app.tracking import suggest_jira_status
|
||||
|
||||
|
||||
@@ -38,12 +43,21 @@ class OrderDetailDialog(QDialog):
|
||||
info = order.shipping_info or {}
|
||||
address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
|
||||
|
||||
issues = validate_ticket(order.skus, order.line_items)
|
||||
|
||||
summary_lines = [
|
||||
f"Ticket #: {order.ticket_number or '(none found)'}",
|
||||
f"Company: {order.company}",
|
||||
f"Created by: {order.creator or '(unknown)'}",
|
||||
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
|
||||
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):",
|
||||
f" Name: {info.get('name') or '(missing)'}",
|
||||
@@ -53,6 +67,7 @@ class OrderDetailDialog(QDialog):
|
||||
"",
|
||||
"Tracking numbers:",
|
||||
]
|
||||
)
|
||||
summary_lines.extend(tracking_lines or [" (none pulled yet)"])
|
||||
if suggested:
|
||||
summary_lines.append("")
|
||||
@@ -61,6 +76,16 @@ class OrderDetailDialog(QDialog):
|
||||
summary_label = QLabel("\n".join(summary_lines))
|
||||
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:"))
|
||||
text = QTextEdit()
|
||||
text.setReadOnly(True)
|
||||
@@ -71,3 +96,7 @@ class OrderDetailDialog(QDialog):
|
||||
close_button = QPushButton("Close")
|
||||
close_button.clicked.connect(self.accept)
|
||||
layout.addWidget(close_button)
|
||||
|
||||
def _open_url(self, url: str | None) -> None:
|
||||
if url:
|
||||
webbrowser.open(url)
|
||||
|
||||
@@ -27,23 +27,33 @@ from PyQt6.QtWidgets import (
|
||||
)
|
||||
|
||||
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.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
|
||||
|
||||
CANCELLED_TEXT_COLOR = QColor(180, 0, 0)
|
||||
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
|
||||
ISSUE_TEXT_COLOR = QColor(170, 100, 0)
|
||||
CHECK_MARK = "\u2713"
|
||||
WARNING_MARK = "\u26a0"
|
||||
|
||||
# Order here is display order, left to right.
|
||||
COLUMNS = [
|
||||
("company", "Company"),
|
||||
("ticket_number", "Ticket #"),
|
||||
("status", "Status"),
|
||||
("issues_display", "Issues"),
|
||||
("packed_display", "Done"),
|
||||
("skus_display", "SKUs"),
|
||||
("summary", "Summary"),
|
||||
("assignee", "Assignee"),
|
||||
("serials_display", "Serial #s"),
|
||||
("return_label_display", "Return Label"),
|
||||
("outgoing_tracking_display", "Outgoing Tracking"),
|
||||
("return_tracking_display", "Return Tracking"),
|
||||
("shipping_method", "Shipping Method"),
|
||||
("uploaded_display", "Uploaded"),
|
||||
("past_cutoff_display", "Past Cutoff"),
|
||||
("created_display", "Created"),
|
||||
@@ -73,12 +83,31 @@ class OrdersTableModel(QAbstractTableModel):
|
||||
self._cancelled_statuses: set[str] = set()
|
||||
self._fulfilled_statuses: set[str] = set()
|
||||
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:
|
||||
# Re-read status lists and cutoff time each refresh, in case Settings changed.
|
||||
self._cancelled_statuses = get_cancelled_statuses()
|
||||
self._fulfilled_statuses = get_fulfilled_statuses()
|
||||
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._orders = orders
|
||||
self.endResetModel()
|
||||
@@ -102,9 +131,14 @@ class OrdersTableModel(QAbstractTableModel):
|
||||
|
||||
order = self._orders[index.row()]
|
||||
is_cancelled = status_in(order.status, self._cancelled_statuses)
|
||||
field_name, _ = COLUMNS[index.column()]
|
||||
|
||||
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:
|
||||
# Cancelled rows use red TEXT (above) instead of a background,
|
||||
@@ -115,13 +149,26 @@ class OrdersTableModel(QAbstractTableModel):
|
||||
return FULFILLED_ROW_COLOR
|
||||
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:
|
||||
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":
|
||||
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":
|
||||
return _format_tracking_numbers(order.tracking_numbers, is_return=False)
|
||||
if field_name == "return_tracking_display":
|
||||
@@ -253,6 +300,20 @@ class OrdersTableView(QWidget):
|
||||
source_index = self._proxy_model.mapToSource(indexes[0])
|
||||
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]:
|
||||
"""Orders currently passing the active filters - used for export."""
|
||||
result = []
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+210
-2
@@ -9,11 +9,13 @@ pattern rather than blocking the UI.
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from typing import List, Tuple, TypedDict
|
||||
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from app import config
|
||||
from app.database import get_session
|
||||
from app.models import Order
|
||||
from app.schedule import get_cutoff_time, is_past_cutoff_today
|
||||
@@ -61,6 +63,62 @@ class SendToShipStationWorker(QThread):
|
||||
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):
|
||||
"""Fetches orders from a given service and saves/merges the results."""
|
||||
|
||||
@@ -134,6 +192,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
||||
continue
|
||||
|
||||
jira_row.tracking_numbers = order.get("tracking_numbers", [])
|
||||
if order.get("shipping_method"):
|
||||
jira_row.shipping_method = order["shipping_method"]
|
||||
enriched_count += 1
|
||||
continue
|
||||
|
||||
@@ -158,6 +218,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
||||
line_items=order.get("line_items", []),
|
||||
shipping_info=order.get("shipping_info", {}),
|
||||
creator=order.get("creator"),
|
||||
assignee=order.get("assignee"),
|
||||
description=order.get("description"),
|
||||
tracking_numbers=order.get("tracking_numbers", []),
|
||||
summary=order["summary"],
|
||||
status=new_status,
|
||||
@@ -197,6 +259,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
||||
existing.line_items = order.get("line_items", [])
|
||||
existing.shipping_info = order.get("shipping_info", {})
|
||||
existing.creator = order.get("creator")
|
||||
existing.assignee = order.get("assignee")
|
||||
existing.description = order.get("description")
|
||||
existing.summary = order["summary"]
|
||||
existing.status = new_status
|
||||
existing.source_created_at = order["source_created_at"]
|
||||
@@ -239,6 +303,97 @@ def reset_local_database() -> int:
|
||||
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]:
|
||||
session = get_session()
|
||||
try:
|
||||
@@ -288,11 +443,16 @@ def load_orders_by_view() -> Tuple[List[Order], List[Order], List[Order]]:
|
||||
|
||||
def mark_shipstation_sent(ticket_number: str) -> None:
|
||||
"""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()
|
||||
try:
|
||||
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()
|
||||
if order is not None:
|
||||
order.shipstation_sent_at = dt.datetime.now()
|
||||
@@ -301,6 +461,54 @@ def mark_shipstation_sent(ticket_number: str) -> None:
|
||||
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()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def get_dashboard_stats() -> dict:
|
||||
"""
|
||||
Counts for the dashboard. "Active Orders" and the company breakdown
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user