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_SIGNIFY_WAREHOUSE_ID=se-180473
|
||||||
SHIPSTATION_OAKSTREET_WAREHOUSE_ID=se-437417
|
SHIPSTATION_OAKSTREET_WAREHOUSE_ID=se-437417
|
||||||
|
|
||||||
|
# --- ShipStation Test Mode ---
|
||||||
|
# When on, every ShipStation action (emergency send, tracking pull, return
|
||||||
|
# labels) uses your test/sandbox API key instead of production - no real
|
||||||
|
# charges, nothing appears in your production ShipStation account.
|
||||||
|
# Toggleable from the Data menu too (stays in sync with this setting).
|
||||||
|
SHIPSTATION_TEST_MODE=false
|
||||||
|
TEST_SHIPSTATION_API_KEY=
|
||||||
|
# Each of these is optional - leave blank and test mode falls back to using
|
||||||
|
# your production value (useful if your test/sandbox account happens to
|
||||||
|
# mirror production's store/warehouse/carrier IDs). Fill one in only if
|
||||||
|
# your test account actually uses a different ID for that specific thing.
|
||||||
|
TEST_SHIPSTATION_SIGNIFY_STORE_ID=
|
||||||
|
TEST_SHIPSTATION_OAKSTREET_STORE_ID=
|
||||||
|
TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID=
|
||||||
|
TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID=
|
||||||
|
TEST_SIGNIFY_RETURN_CARRIER_ID=se-6366092
|
||||||
|
TEST_SIGNIFY_RETURN_SERVICE_CODE=ups_ground
|
||||||
|
TEST_OAKSTREET_RETURN_CARRIER_ID=se-6366092
|
||||||
|
TEST_OAKSTREET_RETURN_SERVICE_CODE=ups_ground
|
||||||
|
|
||||||
# --- Companies ---
|
# --- Companies ---
|
||||||
# SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies.
|
# SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies.
|
||||||
COMPANY_SKU_MAP=SH:Signify Health,OK:Oak Street Health
|
COMPANY_SKU_MAP=SH:Signify Health,OK:Oak Street Health,RMD:Oak Street Health
|
||||||
# Fallback pattern for recognizing the AR-###### ticket number if it's ever
|
# Fallback pattern for recognizing the AR-###### ticket number if it's ever
|
||||||
# not found in ShipStation's usual shipment_number/external_shipment_id fields.
|
# not found in ShipStation's usual shipment_number/external_shipment_id fields.
|
||||||
TICKET_NUMBER_REGEX=\b[A-Z]{2,6}-\d{3,}\b
|
TICKET_NUMBER_REGEX=\b[A-Z]{2,6}-\d{3,}\b
|
||||||
@@ -82,3 +102,63 @@ FULFILLED_STATUS_WITHOUT_RETURN=Device Return Not Needed
|
|||||||
# arrival day are flagged "Past Cutoff" in the table and counted on the
|
# arrival day are flagged "Past Cutoff" in the table and counted on the
|
||||||
# Dashboard - they didn't get a full window to be processed same-day.
|
# Dashboard - they didn't get a full window to be processed same-day.
|
||||||
INTAKE_CUTOFF_TIME=15:30
|
INTAKE_CUTOFF_TIME=15:30
|
||||||
|
|
||||||
|
# --- Return Labels (SH007 / OK012 - emailed return labels) ---
|
||||||
|
# SKUs that mean "create + email a return label" instead of a normal outbound kit.
|
||||||
|
EMAILED_LABEL_SKUS=SH007,OK012
|
||||||
|
|
||||||
|
# Each company has its own UPS account even though they share one warehouse.
|
||||||
|
SIGNIFY_RETURN_CARRIER_ID=se-350817
|
||||||
|
# Assumption pending your confirmation - you gave the carrier/account but not
|
||||||
|
# a specific service level. Defaulting to UPS Ground; change if you use
|
||||||
|
# something else for returns. See ShipStation's carrier services list for
|
||||||
|
# other valid codes (e.g. ups_2nd_day_air, ups_ground_saver).
|
||||||
|
SIGNIFY_RETURN_SERVICE_CODE=ups_ground
|
||||||
|
OAKSTREET_RETURN_CARRIER_ID=se-599657
|
||||||
|
OAKSTREET_RETURN_SERVICE_CODE=ups_ground
|
||||||
|
|
||||||
|
# When you're charged for a return label: on_creation / on_carrier_acceptance
|
||||||
|
# (only charged if the customer actually uses it - needs the carrier to have
|
||||||
|
# enabled this on your account first) / carrier_default.
|
||||||
|
SHIPSTATION_RETURN_CHARGE_EVENT=carrier_default
|
||||||
|
|
||||||
|
# Both companies return to the same warehouse, just under their own name -
|
||||||
|
# confirmed real address below. (Occasional shipments to company HQ instead
|
||||||
|
# of the warehouse aren't handled yet - flagged for later.)
|
||||||
|
SIGNIFY_RETURN_NAME=Signify Health
|
||||||
|
SIGNIFY_RETURN_PHONE=469-718-0004
|
||||||
|
SIGNIFY_RETURN_ADDRESS1=1000 Spinks Road Suite 100
|
||||||
|
SIGNIFY_RETURN_ADDRESS2=
|
||||||
|
SIGNIFY_RETURN_CITY=Lewisville
|
||||||
|
SIGNIFY_RETURN_STATE=TX
|
||||||
|
SIGNIFY_RETURN_ZIP=75067
|
||||||
|
OAKSTREET_RETURN_NAME=Oak Street Health
|
||||||
|
OAKSTREET_RETURN_PHONE=469-718-0004
|
||||||
|
OAKSTREET_RETURN_ADDRESS1=1000 Spinks Road Suite 100
|
||||||
|
OAKSTREET_RETURN_ADDRESS2=
|
||||||
|
OAKSTREET_RETURN_CITY=Lewisville
|
||||||
|
OAKSTREET_RETURN_STATE=TX
|
||||||
|
OAKSTREET_RETURN_ZIP=75067
|
||||||
|
|
||||||
|
# RubiconMD - Oak Street subsidiary (RMD-prefixed SKUs). Uses Oak Street's
|
||||||
|
# own shipping account/address in every respect - this is the one thing
|
||||||
|
# that's actually different, the name shown on the return label.
|
||||||
|
RUBICONMD_RETURN_NAME=Rubicon MD
|
||||||
|
|
||||||
|
# --- Packing ---
|
||||||
|
# Serial-number fields to suggest per device keyword found in a ticket's line
|
||||||
|
# items, as a starting point in the Pack Ticket dialog (staff can always add
|
||||||
|
# a custom field for anything not matched here). Format:
|
||||||
|
# keyword:Field One|Field Two,keyword2:Field Three
|
||||||
|
DEVICE_FIELD_SUGGESTIONS=laptop:Laptop Serial Number|Laptop Asset Tag,optiplex:OptiPlex Serial Number|OptiPlex Asset Tag,desktop:Desktop Serial Number|Desktop Asset Tag,phone:Phone IMEI|Phone Serial Number|Phone ICCID|Phone Asset Tag,ipad:iPad IMEI|iPad Serial Number|iPad ICCID|iPad Asset Tag,spiro:Spiro Serial Number|Spiro Asset Tag,apc:UPS/APC Serial Number|UPS/APC Asset Tag,monitor:Monitor Serial Number|Monitor Asset Tag,accessor:Accessory Notes,camera:Camera Serial Number|Camera Asset Tag
|
||||||
|
|
||||||
|
# Maps a ShipStation service code (from the outbound label) to your team's
|
||||||
|
# term for the Shipping Method column - confirmed against your real usage.
|
||||||
|
SHIPPING_METHOD_LABELS=ups_next_day_air:Priority Overnight,ups_2nd_day_air:Two Day,ups_ground:Ground
|
||||||
|
|
||||||
|
# --- Ticket validation ---
|
||||||
|
# Return types with no matching device expected - exempt from the
|
||||||
|
# return/device mismatch check (emailed labels, DPS Device, scheduled
|
||||||
|
# pickups don't have a corresponding device line item by design).
|
||||||
|
RETURN_DEVICE_EXEMPT_KEYWORDS=emailed,dps,scheduled pickup,padded envelope
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
tickets just won't have tracking numbers pulled, which is expected for
|
||||||
now, not a bug. Flag it when you're ready to handle it.
|
now, not a bug. Flag it when you're ready to handle it.
|
||||||
|
|
||||||
|
## Packing: serial numbers and marking Done
|
||||||
|
|
||||||
|
Reflects your actual workflow: staff enter serial numbers (mostly via
|
||||||
|
**barcode scanner**) and mark a ticket packed as devices go into the
|
||||||
|
box - this is the beginning of what eventually becomes the Ship Sheet,
|
||||||
|
built directly into the app instead of a separate spreadsheet.
|
||||||
|
|
||||||
|
Select a ticket and click **Pack Ticket**:
|
||||||
|
|
||||||
|
- **Suggested fields, not a fixed schema.** Device types and their
|
||||||
|
field sets vary a lot by company and by kit (confirmed against your
|
||||||
|
real Ship Sheet - Oak Street tracks OptiPlex/Laptop/Phone, Signify
|
||||||
|
tracks iPad/Spiro/Laptop/Phone x2), so hard-coding columns per device
|
||||||
|
type would fight the "versatile" requirement. Instead, fields are
|
||||||
|
suggested from keywords in the ticket's line items
|
||||||
|
(`DEVICE_FIELD_SUGGESTIONS`) - a starting point staff can always
|
||||||
|
extend with a custom field. A "Shipping - Return Label X" line item
|
||||||
|
is deliberately excluded from matching (tested against this - it
|
||||||
|
describes a label deliverable, not an actual second device).
|
||||||
|
- **Built for the scanner, not around it.** Each field's Enter key
|
||||||
|
(which a scanner sends automatically after scanning) jumps focus to
|
||||||
|
the next field - scan straight through a device list with zero mouse
|
||||||
|
clicks. After the last field, focus lands on the **Packed** checkbox
|
||||||
|
rather than auto-submitting, so finishing still takes one deliberate
|
||||||
|
action.
|
||||||
|
- **Reopening a partially-packed ticket** preserves whatever was
|
||||||
|
already scanned and still shows the current suggestions for what's
|
||||||
|
left - tested explicitly.
|
||||||
|
- This data is **staff-entered, not sourced from JIRA**, and a JIRA
|
||||||
|
re-import never touches it - confirmed the update path doesn't
|
||||||
|
reference `serial_numbers`/`packed` at all. It's exactly the data
|
||||||
|
the eventual end-of-day JIRA push will need, whenever that gets
|
||||||
|
built.
|
||||||
|
|
||||||
|
**Assignee** (who's working the ticket, from JIRA) and **Shipping
|
||||||
|
Method** (the outbound label's service, from the ShipStation tracking
|
||||||
|
pull - `ups_ground` -> "Ground", etc. via `SHIPPING_METHOD_LABELS`,
|
||||||
|
confirmed against ShipStation's real UPS service codes) are also
|
||||||
|
pulled in now, matching two more Ship Sheet columns.
|
||||||
|
|
||||||
|
**One consequence worth knowing:** since packing data doesn't come
|
||||||
|
from JIRA, **Reset Local Database now also clears it** - the warning
|
||||||
|
dialog says so explicitly. Before this update, a reset was always
|
||||||
|
harmless (just a rebuildable cache); now it isn't, for this one kind
|
||||||
|
of data.
|
||||||
|
|
||||||
## Emergency: Send to ShipStation
|
## Emergency: Send to ShipStation
|
||||||
|
|
||||||
For the rare case a ticket needs to skip the normal daily batch. Select
|
For the rare case a ticket needs to skip the normal daily batch. Select
|
||||||
@@ -276,9 +322,54 @@ app/
|
|||||||
`_on_export_clicked` calls `export_orders_to_csv`.
|
`_on_export_clicked` calls `export_orders_to_csv`.
|
||||||
3. Wire it up the same way the JIRA/ShipStation buttons are.
|
3. Wire it up the same way the JIRA/ShipStation buttons are.
|
||||||
|
|
||||||
|
## Emailed return labels (SH007 / OK012)
|
||||||
|
|
||||||
|
A different workflow entirely from the normal outbound-kit tickets: a
|
||||||
|
customer already has product to return, and needs a return label
|
||||||
|
emailed to them. Select a ticket in Active Orders and click **Create
|
||||||
|
Return Label**:
|
||||||
|
|
||||||
|
- Shows the ticket's **Description** (parsed from JIRA's rich-text
|
||||||
|
format into plain text) - this is where staff note what boxes are
|
||||||
|
needed, so it's visible without opening JIRA.
|
||||||
|
- Lets you specify **any number of packages**, each with its own
|
||||||
|
weight and dimensions - replacing the old fixed "1x1x1, 1oz dummy
|
||||||
|
ticket" with real per-request control.
|
||||||
|
- Calls ShipStation directly (`POST /v2/labels` with
|
||||||
|
`is_return_label: true`). Confirmed against ShipStation's own
|
||||||
|
return-label docs: **`ship_from` is the customer and `ship_to` is
|
||||||
|
your warehouse** - reversed from every other label this app creates,
|
||||||
|
and easy to get backwards, so this was tested explicitly.
|
||||||
|
- `charge_event` is configurable per your risk preference:
|
||||||
|
`on_creation` (pay immediately), `on_carrier_acceptance` (only pay if
|
||||||
|
the customer actually ships it - needs the carrier to enable this on
|
||||||
|
your account first, can take 3-4 weeks), or `carrier_default`.
|
||||||
|
|
||||||
|
**This app does not email the label.** Per your workflow, that
|
||||||
|
happens from ShipStation itself (Returns tab -> Other Actions -> Send
|
||||||
|
Return Label) so it goes out through your branded template - something
|
||||||
|
this app couldn't replicate anyway, since ShipStation doesn't expose
|
||||||
|
that step through its API as far as I could find. After a label is
|
||||||
|
created, the ticket's **Uploaded** checkmark is set (same flag the
|
||||||
|
emergency-send feature uses) so you can see at a glance which
|
||||||
|
return-label tickets have already had their label created.
|
||||||
|
|
||||||
|
Both companies share one physical warehouse but each has its **own
|
||||||
|
UPS account** (`SIGNIFY_RETURN_CARRIER_ID` / `OAKSTREET_RETURN_CARRIER_ID`)
|
||||||
|
- the return address is the same, just filed under the right company
|
||||||
|
name. Occasional shipments to company HQ instead of the shared
|
||||||
|
warehouse aren't handled yet - flagged for later, per your note.
|
||||||
|
|
||||||
|
**Still an assumption pending confirmation:** the service code
|
||||||
|
(`SIGNIFY_RETURN_SERVICE_CODE` / `OAKSTREET_RETURN_SERVICE_CODE`)
|
||||||
|
defaults to `ups_ground` since you gave me the carrier/account but not
|
||||||
|
a specific service level - change it in Settings if that's not right.
|
||||||
|
|
||||||
## Known open item
|
## Known open item
|
||||||
|
|
||||||
The **emailed-label SKU** (mentioned but not detailed yet) needs its
|
You mentioned occasionally shipping return items to a company's
|
||||||
own handling eventually - tell me the SKU and what "done" looks like
|
headquarters instead of the shared warehouse - noted, not built yet
|
||||||
for it when you're ready, and I'll fold it into the terminal-status /
|
since you said we could tackle it later. When you're ready, this would
|
||||||
tracking logic above rather than bolting on something separate.
|
likely be a dropdown in the Return Label dialog (Warehouse vs. HQ)
|
||||||
|
rather than a new settings group, since the workflow is otherwise
|
||||||
|
identical.
|
||||||
|
|||||||
+45
@@ -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,
|
False,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
"SHIPSTATION_TEST_MODE": (
|
||||||
|
"Use ShipStation test/sandbox API key instead of production (true/false) - "
|
||||||
|
"also toggleable from the Data menu",
|
||||||
|
"ShipStation Test Mode",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"TEST_SHIPSTATION_API_KEY": ("ShipStation Test/Sandbox API Key", "ShipStation Test Mode", True),
|
||||||
|
"TEST_SHIPSTATION_SIGNIFY_STORE_ID": (
|
||||||
|
"Test override: Signify Store ID (blank = use production value)",
|
||||||
|
"ShipStation Test Mode",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"TEST_SHIPSTATION_OAKSTREET_STORE_ID": (
|
||||||
|
"Test override: Oak Street Store ID (blank = use production value)",
|
||||||
|
"ShipStation Test Mode",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID": (
|
||||||
|
"Test override: Signify Warehouse ID (blank = use production value)",
|
||||||
|
"ShipStation Test Mode",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID": (
|
||||||
|
"Test override: Oak Street Warehouse ID (blank = use production value)",
|
||||||
|
"ShipStation Test Mode",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"TEST_SIGNIFY_RETURN_CARRIER_ID": (
|
||||||
|
"Test override: Signify Return Carrier ID (blank = use production value)",
|
||||||
|
"ShipStation Test Mode",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"TEST_SIGNIFY_RETURN_SERVICE_CODE": (
|
||||||
|
"Test override: Signify Return Service Code (blank = use production value)",
|
||||||
|
"ShipStation Test Mode",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"TEST_OAKSTREET_RETURN_CARRIER_ID": (
|
||||||
|
"Test override: Oak Street Return Carrier ID (blank = use production value)",
|
||||||
|
"ShipStation Test Mode",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"TEST_OAKSTREET_RETURN_SERVICE_CODE": (
|
||||||
|
"Test override: Oak Street Return Service Code (blank = use production value)",
|
||||||
|
"ShipStation Test Mode",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
|
||||||
"COMPANY_SKU_MAP": (
|
"COMPANY_SKU_MAP": (
|
||||||
"SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)",
|
"SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)",
|
||||||
"Companies",
|
"Companies",
|
||||||
@@ -86,6 +134,65 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
|
|||||||
False,
|
False,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
"EMAILED_LABEL_SKUS": (
|
||||||
|
"SKUs that mean 'create + email a return label' (comma-separated)",
|
||||||
|
"Return Labels",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"DEVICE_FIELD_SUGGESTIONS": (
|
||||||
|
"Serial-number fields to suggest per device keyword "
|
||||||
|
"(keyword:Field One|Field Two,keyword2:Field Three)",
|
||||||
|
"Packing",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"SHIPPING_METHOD_LABELS": (
|
||||||
|
"ShipStation service code -> your term (e.g. ups_ground:Ground)",
|
||||||
|
"Packing",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"RETURN_DEVICE_EXEMPT_KEYWORDS": (
|
||||||
|
"Return types with no matching device expected (comma-separated keywords)",
|
||||||
|
"Packing",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"SIGNIFY_RETURN_CARRIER_ID": ("Signify Return: ShipStation Carrier ID", "Return Labels", False),
|
||||||
|
"SIGNIFY_RETURN_SERVICE_CODE": (
|
||||||
|
"Signify Return: Service code (e.g. ups_ground)",
|
||||||
|
"Return Labels",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"OAKSTREET_RETURN_CARRIER_ID": ("Oak Street Return: ShipStation Carrier ID", "Return Labels", False),
|
||||||
|
"OAKSTREET_RETURN_SERVICE_CODE": (
|
||||||
|
"Oak Street Return: Service code (e.g. ups_ground)",
|
||||||
|
"Return Labels",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"SHIPSTATION_RETURN_CHARGE_EVENT": (
|
||||||
|
"When to be charged: on_creation / on_carrier_acceptance / carrier_default",
|
||||||
|
"Return Labels",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"SIGNIFY_RETURN_NAME": ("Signify Return Address: Name", "Return Labels", False),
|
||||||
|
"SIGNIFY_RETURN_PHONE": ("Signify Return Address: Phone", "Return Labels", False),
|
||||||
|
"SIGNIFY_RETURN_ADDRESS1": ("Signify Return Address: Address 1", "Return Labels", False),
|
||||||
|
"SIGNIFY_RETURN_ADDRESS2": ("Signify Return Address: Address 2", "Return Labels", False),
|
||||||
|
"SIGNIFY_RETURN_CITY": ("Signify Return Address: City", "Return Labels", False),
|
||||||
|
"SIGNIFY_RETURN_STATE": ("Signify Return Address: State", "Return Labels", False),
|
||||||
|
"SIGNIFY_RETURN_ZIP": ("Signify Return Address: Zip", "Return Labels", False),
|
||||||
|
"OAKSTREET_RETURN_NAME": ("Oak Street Return Address: Name", "Return Labels", False),
|
||||||
|
"OAKSTREET_RETURN_PHONE": ("Oak Street Return Address: Phone", "Return Labels", False),
|
||||||
|
"OAKSTREET_RETURN_ADDRESS1": ("Oak Street Return Address: Address 1", "Return Labels", False),
|
||||||
|
"OAKSTREET_RETURN_ADDRESS2": ("Oak Street Return Address: Address 2", "Return Labels", False),
|
||||||
|
"OAKSTREET_RETURN_CITY": ("Oak Street Return Address: City", "Return Labels", False),
|
||||||
|
"OAKSTREET_RETURN_STATE": ("Oak Street Return Address: State", "Return Labels", False),
|
||||||
|
"OAKSTREET_RETURN_ZIP": ("Oak Street Return Address: Zip", "Return Labels", False),
|
||||||
|
|
||||||
|
# RubiconMD - an Oak Street subsidiary (RMD-prefixed SKUs). Classified as
|
||||||
|
# Oak Street Health everywhere else. It uses Oak Street's own shipping
|
||||||
|
# account and address in every respect - this is the one thing that's
|
||||||
|
# actually different, the name shown on the return label.
|
||||||
|
"RUBICONMD_RETURN_NAME": ("RubiconMD Return Address: Name (uses Oak Street's account/address otherwise)", "Return Labels", False),
|
||||||
|
|
||||||
"ACTIVE_STATUSES": (
|
"ACTIVE_STATUSES": (
|
||||||
"Statuses that count as real active work (comma-separated) - "
|
"Statuses that count as real active work (comma-separated) - "
|
||||||
"everything else is treated as done",
|
"everything else is treated as done",
|
||||||
@@ -115,13 +222,33 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_DB_URL = "sqlite:///orders.db"
|
DEFAULT_DB_URL = "sqlite:///orders.db"
|
||||||
DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health"
|
DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health,RMD:Oak Street Health"
|
||||||
DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b"
|
DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b"
|
||||||
DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return"
|
DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return"
|
||||||
DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed"
|
DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed"
|
||||||
DEFAULT_INTAKE_CUTOFF_TIME = "15:30"
|
DEFAULT_INTAKE_CUTOFF_TIME = "15:30"
|
||||||
DEFAULT_ACTIVE_STATUSES = "Created"
|
DEFAULT_ACTIVE_STATUSES = "Created"
|
||||||
DEFAULT_CANCELLED_STATUSES = "Cancelled"
|
DEFAULT_CANCELLED_STATUSES = "Cancelled"
|
||||||
|
DEFAULT_EMAILED_LABEL_SKUS = "SH007,OK012"
|
||||||
|
DEFAULT_DEVICE_FIELD_SUGGESTIONS = (
|
||||||
|
"laptop:Laptop Serial Number|Laptop Asset Tag,"
|
||||||
|
"optiplex:OptiPlex Serial Number|OptiPlex Asset Tag,"
|
||||||
|
"desktop:Desktop Serial Number|Desktop Asset Tag,"
|
||||||
|
"phone:Phone IMEI|Phone Serial Number|Phone ICCID|Phone Asset Tag,"
|
||||||
|
"ipad:iPad IMEI|iPad Serial Number|iPad ICCID|iPad Asset Tag,"
|
||||||
|
"spiro:Spiro Serial Number|Spiro Asset Tag,"
|
||||||
|
"apc:UPS/APC Serial Number|UPS/APC Asset Tag,"
|
||||||
|
"monitor:Monitor Serial Number|Monitor Asset Tag,"
|
||||||
|
"accessor:Accessory Notes,"
|
||||||
|
"camera:Camera Serial Number|Camera Asset Tag"
|
||||||
|
)
|
||||||
|
# Confirmed against ShipStation's own UPS service code reference, not guessed.
|
||||||
|
DEFAULT_SHIPPING_METHOD_LABELS = (
|
||||||
|
"ups_next_day_air:Priority Overnight,"
|
||||||
|
"ups_2nd_day_air:Two Day,"
|
||||||
|
"ups_ground:Ground"
|
||||||
|
)
|
||||||
|
DEFAULT_RETURN_CHARGE_EVENT = "carrier_default"
|
||||||
|
|
||||||
|
|
||||||
def ensure_env_file_exists() -> None:
|
def ensure_env_file_exists() -> None:
|
||||||
@@ -183,3 +310,34 @@ def save_settings(values: Dict[str, str]) -> None:
|
|||||||
def get(key: str, default: str = "") -> str:
|
def get(key: str, default: str = "") -> str:
|
||||||
"""Convenience getter, e.g. config.get('DB_URL')."""
|
"""Convenience getter, e.g. config.get('DB_URL')."""
|
||||||
return load_settings().get(key, default) or default
|
return load_settings().get(key, default) or default
|
||||||
|
|
||||||
|
|
||||||
|
def is_shipstation_test_mode() -> bool:
|
||||||
|
return get("SHIPSTATION_TEST_MODE", "").strip().lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def get_shipstation_setting(key: str) -> str:
|
||||||
|
"""
|
||||||
|
Test-mode-aware lookup for ShipStation-related settings (API key,
|
||||||
|
store/warehouse/carrier IDs, service codes). When SHIPSTATION_TEST_MODE
|
||||||
|
is on, ONLY the TEST_{key} value is used - no fallback to the
|
||||||
|
production value. When test mode is off, this is identical to
|
||||||
|
config.get(key).
|
||||||
|
|
||||||
|
This used to fall back to the production value when the TEST_ override
|
||||||
|
was blank, on the theory the sandbox might share IDs with production.
|
||||||
|
Confirmed against ShipStation's own docs that it never does - "Sandbox
|
||||||
|
data is isolated from production data... anything you create in the
|
||||||
|
sandbox will not be accessible in production, or vice-versa." A
|
||||||
|
fallback to a production store/warehouse/carrier ID under a sandbox API
|
||||||
|
key isn't a convenience, it's a guaranteed rejection - and it's exactly
|
||||||
|
what produced three separate confusing "not found"/"invalid" errors in
|
||||||
|
a row (carrier, then warehouse, then store) before this was caught.
|
||||||
|
Every caller of this function already raises a clear, specific
|
||||||
|
"X is not configured" error when it gets back an empty string, so
|
||||||
|
isolating test mode fully just turns those three confusing
|
||||||
|
ShipStation-side rejections into one obvious message instead.
|
||||||
|
"""
|
||||||
|
if is_shipstation_test_mode():
|
||||||
|
return get(f"TEST_{key}", "")
|
||||||
|
return get(key, "")
|
||||||
|
|||||||
@@ -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,
|
Column,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
|
Text,
|
||||||
DateTime,
|
DateTime,
|
||||||
Boolean,
|
Boolean,
|
||||||
JSON,
|
JSON,
|
||||||
@@ -64,12 +65,41 @@ class Order(Base):
|
|||||||
|
|
||||||
# Display name of whoever created the JIRA ticket.
|
# Display name of whoever created the JIRA ticket.
|
||||||
creator = Column(String(200), nullable=True)
|
creator = Column(String(200), nullable=True)
|
||||||
|
# Display name of whoever the ticket is assigned to (who's working it) -
|
||||||
|
# distinct from creator (who opened it).
|
||||||
|
assignee = Column(String(200), nullable=True)
|
||||||
|
|
||||||
|
# Plain-text version of the JIRA ticket's Description field (parsed
|
||||||
|
# from Atlassian Document Format - see app/adf.py). Mainly useful for
|
||||||
|
# the emailed-return-label workflow, where staff write the box
|
||||||
|
# requirements here rather than in a structured field.
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
|
||||||
# Tracking numbers pulled from ShipStation and merged onto this
|
# Tracking numbers pulled from ShipStation and merged onto this
|
||||||
# ticket - e.g. [{"number": "782758401696", "carrier": "ups",
|
# ticket - e.g. [{"number": "782758401696", "carrier": "ups",
|
||||||
# "is_return": false}]. Populated by the "Pull Tracking Numbers"
|
# "is_return": false}]. Populated by the "Pull Tracking Numbers"
|
||||||
# action, separately from the JIRA import.
|
# action, separately from the JIRA import.
|
||||||
tracking_numbers = Column(JSON, nullable=True)
|
tracking_numbers = Column(JSON, nullable=True)
|
||||||
|
# Service level used for the outbound label (e.g. "Priority Overnight",
|
||||||
|
# "Ground") - read off the ShipStation label during the same pull that
|
||||||
|
# gets tracking numbers, not something JIRA knows about.
|
||||||
|
shipping_method = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# Freeform {label: value} pairs, e.g. {"Laptop Serial Number": "6NJLP54",
|
||||||
|
# "Laptop Asset Tag": "30882"} - entered by staff (often via barcode
|
||||||
|
# scanner) as devices are packed. Deliberately not fixed columns per
|
||||||
|
# device type: which fields are relevant varies by company and by kit,
|
||||||
|
# and hard-coding that would fight the "versatile" requirement. See
|
||||||
|
# app/serial_suggestions.py for how likely fields get suggested from
|
||||||
|
# the ticket's line items.
|
||||||
|
serial_numbers = Column(JSON, nullable=True)
|
||||||
|
# Staff-set "packed and ready to ship" flag - the Ship Sheet's "Done"
|
||||||
|
# column. Deliberately separate from JIRA's own status: a ticket can be
|
||||||
|
# packed=True while still sitting in JIRA as "Created", waiting on the
|
||||||
|
# (currently manual, eventually automated) end-of-day push that sets
|
||||||
|
# the real JIRA status.
|
||||||
|
packed = Column(Boolean, nullable=False, default=False)
|
||||||
|
packed_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
summary = Column(String(500), nullable=False, default="")
|
summary = Column(String(500), nullable=False, default="")
|
||||||
status = Column(String(100), nullable=False, default="")
|
status = Column(String(100), nullable=False, default="")
|
||||||
@@ -93,8 +123,16 @@ class Order(Base):
|
|||||||
# isn't the same as it actually being imported, and this app has no
|
# isn't the same as it actually being imported, and this app has no
|
||||||
# way to confirm that manual step happened.
|
# way to confirm that manual step happened.
|
||||||
shipstation_sent_at = Column(DateTime, nullable=True)
|
shipstation_sent_at = Column(DateTime, nullable=True)
|
||||||
|
# Label ID of the "dummy" outbound shipment created for the emailed-
|
||||||
|
# return-label workflow, persisted so it's verifiable (in ShipStation's
|
||||||
|
# own UI) as a separate step before creating the real return label
|
||||||
|
# from it - rather than both happening invisibly in one call.
|
||||||
|
dummy_outbound_label_id = Column(String(64), nullable=True)
|
||||||
|
|
||||||
# Downstream pipeline flags - useful once Odoo export is fully wired up
|
# Legacy/unused - kept only because SQLite doesn't make dropping a
|
||||||
|
# column free and nothing reads this. Don't confuse with `packed`
|
||||||
|
# above (the real "Done" flag) or the JIRA-status-based fulfilled
|
||||||
|
# concept used for the Done tab/dashboard - this column predates both.
|
||||||
fulfilled = Column(Boolean, nullable=False, default=False)
|
fulfilled = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
# Full original payload from JIRA, for anything not modeled explicitly
|
# Full original payload from JIRA, for anything not modeled explicitly
|
||||||
|
|||||||
@@ -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]
|
line_items: List[dict]
|
||||||
shipping_info: dict
|
shipping_info: dict
|
||||||
creator: Optional[str]
|
creator: Optional[str]
|
||||||
|
assignee: Optional[str]
|
||||||
|
description: Optional[str]
|
||||||
tracking_numbers: List[dict]
|
tracking_numbers: List[dict]
|
||||||
|
shipping_method: Optional[str]
|
||||||
summary: str
|
summary: str
|
||||||
status: str
|
status: str
|
||||||
source_created_at: Optional[dt.datetime]
|
source_created_at: Optional[dt.datetime]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from typing import List, Tuple
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
|
from app.adf import adf_to_text
|
||||||
from app.companies import parse_mapping, resolve_company_for_skus
|
from app.companies import parse_mapping, resolve_company_for_skus
|
||||||
from app.queries import get_open_ticket_numbers
|
from app.queries import get_open_ticket_numbers
|
||||||
from app.services.base import OrderService, NormalizedOrder
|
from app.services.base import OrderService, NormalizedOrder
|
||||||
@@ -164,7 +165,7 @@ class JiraService(OrderService):
|
|||||||
|
|
||||||
# Always pull summary/status/created/creator, plus every configured
|
# Always pull summary/status/created/creator, plus every configured
|
||||||
# deliverable field and contact field.
|
# deliverable field and contact field.
|
||||||
fields = "summary,status,created,creator"
|
fields = "summary,status,created,creator,assignee,description"
|
||||||
if self.all_sku_field_ids:
|
if self.all_sku_field_ids:
|
||||||
fields += "," + ",".join(self.all_sku_field_ids)
|
fields += "," + ",".join(self.all_sku_field_ids)
|
||||||
contact_field_ids = [v for v in self.contact_field_ids.values() if v]
|
contact_field_ids = [v for v in self.contact_field_ids.values() if v]
|
||||||
@@ -322,6 +323,11 @@ class JiraService(OrderService):
|
|||||||
creator = fields.get("creator") or {}
|
creator = fields.get("creator") or {}
|
||||||
return creator.get("displayName") or creator.get("emailAddress") or ""
|
return creator.get("displayName") or creator.get("emailAddress") or ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_assignee(fields: dict) -> str:
|
||||||
|
assignee = fields.get("assignee") or {}
|
||||||
|
return assignee.get("displayName") or assignee.get("emailAddress") or ""
|
||||||
|
|
||||||
def _to_normalized_order(self, issue: dict) -> NormalizedOrder:
|
def _to_normalized_order(self, issue: dict) -> NormalizedOrder:
|
||||||
fields = issue.get("fields", {})
|
fields = issue.get("fields", {})
|
||||||
created_raw = fields.get("created")
|
created_raw = fields.get("created")
|
||||||
@@ -338,6 +344,8 @@ class JiraService(OrderService):
|
|||||||
company = resolve_company_for_skus(skus, self.sku_map)
|
company = resolve_company_for_skus(skus, self.sku_map)
|
||||||
shipping_info = self._extract_shipping_info(fields)
|
shipping_info = self._extract_shipping_info(fields)
|
||||||
creator = self._extract_creator(fields)
|
creator = self._extract_creator(fields)
|
||||||
|
assignee = self._extract_assignee(fields)
|
||||||
|
description = adf_to_text(fields.get("description"))
|
||||||
|
|
||||||
# These order tickets typically leave the JIRA Summary field
|
# These order tickets typically leave the JIRA Summary field
|
||||||
# blank - fall back to the deliverables so there's still
|
# blank - fall back to the deliverables so there's still
|
||||||
@@ -353,7 +361,10 @@ class JiraService(OrderService):
|
|||||||
line_items=line_items,
|
line_items=line_items,
|
||||||
shipping_info=shipping_info,
|
shipping_info=shipping_info,
|
||||||
creator=creator,
|
creator=creator,
|
||||||
|
assignee=assignee,
|
||||||
|
description=description,
|
||||||
tracking_numbers=[],
|
tracking_numbers=[],
|
||||||
|
shipping_method=None, # comes from ShipStation, not JIRA - see shipstation_service.py
|
||||||
summary=summary,
|
summary=summary,
|
||||||
status=(fields.get("status") or {}).get("name", ""),
|
status=(fields.get("status") or {}).get("name", ""),
|
||||||
source_created_at=created_at,
|
source_created_at=created_at,
|
||||||
|
|||||||
@@ -41,6 +41,183 @@ from app.models import Order
|
|||||||
API_BASE = "https://api.shipstation.com/v2"
|
API_BASE = "https://api.shipstation.com/v2"
|
||||||
REQUEST_TIMEOUT_SECONDS = 30
|
REQUEST_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def list_carriers() -> List[dict]:
|
||||||
|
"""
|
||||||
|
Calls GET /v2/carriers with whichever API key is currently active
|
||||||
|
(test or production, via config.get_shipstation_setting) - a direct
|
||||||
|
way to answer "what carrier_id is actually valid here" instead of
|
||||||
|
guessing or hunting through ShipStation's UI while unsure which
|
||||||
|
account is even logged in. Each carrier includes carrier_id,
|
||||||
|
carrier_code, friendly_name, and nickname.
|
||||||
|
"""
|
||||||
|
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{API_BASE}/carriers",
|
||||||
|
headers={"API-Key": api_key, "Accept": "application/json"},
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
|
||||||
|
if not response.ok:
|
||||||
|
raise ShipStationSendError(
|
||||||
|
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
|
||||||
|
|
||||||
|
return data.get("carriers", [])
|
||||||
|
|
||||||
|
|
||||||
|
def list_stores() -> List[dict]:
|
||||||
|
"""
|
||||||
|
DEAD END, kept only so nobody re-attempts this the hard way: ShipStation's
|
||||||
|
V2 API has NO stores/marketplaces listing endpoint. GET /v2/stores returns
|
||||||
|
a plain 404 ("No route matched with those values") - confirmed against
|
||||||
|
ShipStation's own V2 OpenAPI reference, which lists every real section
|
||||||
|
(Carriers, Warehouses, Connections, etc.) and where store_id only ever
|
||||||
|
appears as an INPUT field on label/shipment requests (example value
|
||||||
|
"se-12345"), never as its own resource with a list/get endpoint. This
|
||||||
|
matches ShipStation's help docs too, which say the only ways to get a
|
||||||
|
store_id are (1) ShipStation support looks it up for you, or (2) the
|
||||||
|
legacy V1 API's List Stores call - different auth (key+secret Basic
|
||||||
|
Auth), not the single V2 API-Key header this app uses everywhere else.
|
||||||
|
|
||||||
|
Bottom line: there is no way to discover a store_id from inside this
|
||||||
|
app. Log into the ShipStation account's own UI (Settings > Store Setup
|
||||||
|
/ Selling Channels) to find or create a manual store and read its ID
|
||||||
|
from there - for test mode specifically, that means logging into the
|
||||||
|
TEST/sandbox account, not production.
|
||||||
|
"""
|
||||||
|
raise ShipStationSendError(
|
||||||
|
"ShipStation's V2 API has no endpoint to list stores (confirmed - GET /v2/stores "
|
||||||
|
"doesn't exist, it 404s). Log into the ShipStation account's own UI under "
|
||||||
|
"Settings > Store Setup to find the store_id, then enter it in Settings - for "
|
||||||
|
"test mode, log into the TEST/sandbox account and set the TEST_ override."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_warehouses() -> List[dict]:
|
||||||
|
"""Same idea as list_carriers()/list_stores() but for GET /v2/warehouses -
|
||||||
|
fixes the exact next error in the sequence (warehouse_id not found),
|
||||||
|
same root cause as the carrier_id one: the test/sandbox account is a
|
||||||
|
completely separate ShipStation environment with its own IDs."""
|
||||||
|
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{API_BASE}/warehouses",
|
||||||
|
headers={"API-Key": api_key, "Accept": "application/json"},
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
|
||||||
|
if not response.ok:
|
||||||
|
raise ShipStationSendError(
|
||||||
|
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
|
||||||
|
|
||||||
|
return data if isinstance(data, list) else data.get("warehouses", [])
|
||||||
|
|
||||||
|
|
||||||
|
def create_warehouse(name: str, origin_address: dict) -> dict:
|
||||||
|
"""POST /v2/warehouses - creates a new warehouse for whichever API key
|
||||||
|
is currently active. Needed because a sandbox/test ShipStation account
|
||||||
|
starts with zero warehouses (confirmed against ShipStation's own docs):
|
||||||
|
list_warehouses() correctly returning an empty list in test mode wasn't
|
||||||
|
a bug, there was just nothing there yet to list. Returns the created
|
||||||
|
warehouse's JSON (includes warehouse_id - the value that goes into
|
||||||
|
TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID / TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID)."""
|
||||||
|
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
||||||
|
|
||||||
|
payload = {"name": name, "origin_address": origin_address}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{API_BASE}/warehouses",
|
||||||
|
json=payload,
|
||||||
|
headers={"API-Key": api_key, "Accept": "application/json"},
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
|
||||||
|
if not response.ok:
|
||||||
|
raise ShipStationSendError(
|
||||||
|
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _origin_address_for_company(company: str) -> dict:
|
||||||
|
"""Same physical address as _return_address_for_order's ship-from/
|
||||||
|
ship-to address (SIGNIFY_RETURN_*/OAKSTREET_RETURN_* settings) - a
|
||||||
|
warehouse's origin_address is just that address registered with
|
||||||
|
ShipStation. address_residential_indicator is required by
|
||||||
|
POST /v2/warehouses; this is always a business address."""
|
||||||
|
settings = config.load_settings()
|
||||||
|
prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_"
|
||||||
|
return {
|
||||||
|
"name": settings.get(f"{prefix}NAME", ""),
|
||||||
|
"phone": settings.get(f"{prefix}PHONE", ""),
|
||||||
|
"company_name": company,
|
||||||
|
"address_line1": settings.get(f"{prefix}ADDRESS1", ""),
|
||||||
|
"address_line2": settings.get(f"{prefix}ADDRESS2", "") or None,
|
||||||
|
"city_locality": settings.get(f"{prefix}CITY", ""),
|
||||||
|
"state_province": settings.get(f"{prefix}STATE", ""),
|
||||||
|
"postal_code": settings.get(f"{prefix}ZIP", ""),
|
||||||
|
"country_code": "US",
|
||||||
|
"address_residential_indicator": "no",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_warehouse_for_company(company: str) -> dict:
|
||||||
|
"""Creates a warehouse for whichever API key is currently active
|
||||||
|
(callers should confirm test mode is on first - this isn't something
|
||||||
|
you want to accidentally run against production) using the company's
|
||||||
|
own return-address settings as the origin_address. Returns the created
|
||||||
|
warehouse's JSON."""
|
||||||
|
origin_address = _origin_address_for_company(company)
|
||||||
|
required_fields = (
|
||||||
|
"name", "phone", "address_line1", "city_locality", "state_province", "postal_code",
|
||||||
|
)
|
||||||
|
missing = [field for field in required_fields if not origin_address.get(field)]
|
||||||
|
if missing:
|
||||||
|
prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_"
|
||||||
|
raise ShipStationSendError(
|
||||||
|
f"{prefix}* address settings for {company} are incomplete (missing: "
|
||||||
|
f"{', '.join(missing)}). Fill those in under Settings > Return Labels first."
|
||||||
|
)
|
||||||
|
return create_warehouse(f"{company} (Test)", origin_address)
|
||||||
|
|
||||||
# Column order confirmed against the real ShipStation upload template
|
# Column order confirmed against the real ShipStation upload template
|
||||||
# (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and
|
# (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and
|
||||||
# is intentionally left out here.
|
# is intentionally left out here.
|
||||||
@@ -129,21 +306,39 @@ def export_order_to_shipstation_csv(orders: List[Order], filepath: str) -> int:
|
|||||||
return row_count
|
return row_count
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_shipstation_id(value: str) -> str:
|
||||||
|
"""
|
||||||
|
Every ShipStation reference ID we've seen (store, warehouse, carrier)
|
||||||
|
consistently uses an "se-" prefix (se-599657, se-367672, etc).
|
||||||
|
Prepends it if it's missing - a bare numeric ID would always fail
|
||||||
|
with a confusing "not found" error otherwise, and it's an easy typo
|
||||||
|
to make when copying a value out of ShipStation's own UI or filling
|
||||||
|
in a new setting (this happened for real: a test-mode carrier ID was
|
||||||
|
entered as "599657" instead of "se-599657").
|
||||||
|
"""
|
||||||
|
value = (value or "").strip()
|
||||||
|
if value and not value.startswith("se-") and value.replace("-", "").isalnum():
|
||||||
|
return f"se-{value}"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _store_id_for_company(company: str) -> str:
|
def _store_id_for_company(company: str) -> str:
|
||||||
settings = config.load_settings()
|
|
||||||
if company == "Signify Health":
|
if company == "Signify Health":
|
||||||
return settings.get("SHIPSTATION_SIGNIFY_STORE_ID", "")
|
return _normalize_shipstation_id(config.get_shipstation_setting("SHIPSTATION_SIGNIFY_STORE_ID"))
|
||||||
if company == "Oak Street Health":
|
if company == "Oak Street Health":
|
||||||
return settings.get("SHIPSTATION_OAKSTREET_STORE_ID", "")
|
return _normalize_shipstation_id(config.get_shipstation_setting("SHIPSTATION_OAKSTREET_STORE_ID"))
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _warehouse_id_for_company(company: str) -> str:
|
def _warehouse_id_for_company(company: str) -> str:
|
||||||
settings = config.load_settings()
|
|
||||||
if company == "Signify Health":
|
if company == "Signify Health":
|
||||||
return settings.get("SHIPSTATION_SIGNIFY_WAREHOUSE_ID", "")
|
return _normalize_shipstation_id(
|
||||||
|
config.get_shipstation_setting("SHIPSTATION_SIGNIFY_WAREHOUSE_ID")
|
||||||
|
)
|
||||||
if company == "Oak Street Health":
|
if company == "Oak Street Health":
|
||||||
return settings.get("SHIPSTATION_OAKSTREET_WAREHOUSE_ID", "")
|
return _normalize_shipstation_id(
|
||||||
|
config.get_shipstation_setting("SHIPSTATION_OAKSTREET_WAREHOUSE_ID")
|
||||||
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -255,3 +450,354 @@ def send_order_to_shipstation_api(order: Order) -> dict:
|
|||||||
raise ShipStationSendError(f"ShipStation reported errors: {result['errors']}")
|
raise ShipStationSendError(f"ShipStation reported errors: {result['errors']}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --- Emailed return labels (SH007 / OK012) --------------------------------
|
||||||
|
#
|
||||||
|
# A genuinely different shape from the outbound send above: for a return
|
||||||
|
# label, ship_from is the CUSTOMER and ship_to is YOUR warehouse/return
|
||||||
|
# center (confirmed against ShipStation's own return-labels docs - this is
|
||||||
|
# reversed from every other label this app creates). This also doesn't go
|
||||||
|
# through create_sales_order + automation, since there's no outbound side
|
||||||
|
# to it - carrier/service are specified directly.
|
||||||
|
#
|
||||||
|
# After creation, this app does NOT email the label - per your workflow,
|
||||||
|
# that's done from ShipStation itself (Returns tab -> Other Actions ->
|
||||||
|
# Send Return Label) so it goes out through your branded email template.
|
||||||
|
|
||||||
|
VALID_CHARGE_EVENTS = {"on_creation", "on_carrier_acceptance", "carrier_default"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rubiconmd_order(order: Order) -> bool:
|
||||||
|
return any((sku or "").strip().upper().startswith("RMD") for sku in (order.skus or []))
|
||||||
|
|
||||||
|
|
||||||
|
def _return_address_for_order(order: Order) -> dict:
|
||||||
|
"""RubiconMD (RMD-prefixed SKUs) uses Oak Street's own address in every
|
||||||
|
respect except the name on the label - not a separate warehouse, just
|
||||||
|
a different name for the same shipping account/address."""
|
||||||
|
settings = config.load_settings()
|
||||||
|
prefix = "SIGNIFY_RETURN_" if order.company == "Signify Health" else "OAKSTREET_RETURN_"
|
||||||
|
name = settings.get(f"{prefix}NAME", "")
|
||||||
|
if _is_rubiconmd_order(order):
|
||||||
|
name = settings.get("RUBICONMD_RETURN_NAME", "") or name
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"phone": settings.get(f"{prefix}PHONE", ""),
|
||||||
|
"address_line1": settings.get(f"{prefix}ADDRESS1", ""),
|
||||||
|
"address_line2": settings.get(f"{prefix}ADDRESS2", "") or None,
|
||||||
|
"city_locality": settings.get(f"{prefix}CITY", ""),
|
||||||
|
"state_province": settings.get(f"{prefix}STATE", ""),
|
||||||
|
"postal_code": settings.get(f"{prefix}ZIP", ""),
|
||||||
|
"country_code": "US",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _return_carrier_for_order(order: Order) -> tuple[str, str]:
|
||||||
|
"""RubiconMD uses Oak Street's own UPS account - no separate carrier,
|
||||||
|
just a different name on the return address (see
|
||||||
|
_return_address_for_order). Each of the two REAL shipping accounts
|
||||||
|
(Signify, Oak Street) has its own carrier_id even though they share a
|
||||||
|
physical warehouse."""
|
||||||
|
prefix = "SIGNIFY_RETURN_" if order.company == "Signify Health" else "OAKSTREET_RETURN_"
|
||||||
|
return (
|
||||||
|
_normalize_shipstation_id(config.get_shipstation_setting(f"{prefix}CARRIER_ID")),
|
||||||
|
config.get_shipstation_setting(f"{prefix}SERVICE_CODE"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_return_package(weight_oz: float, length: float, width: float, height: float) -> dict:
|
||||||
|
package = {"weight": {"value": weight_oz, "unit": "ounce"}}
|
||||||
|
if length > 0 and width > 0 and height > 0:
|
||||||
|
package["dimensions"] = {
|
||||||
|
"length": length,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"unit": "inch",
|
||||||
|
}
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def _post_to_labels(payload: dict, api_key: str) -> dict:
|
||||||
|
"""Shared POST /v2/labels caller for both the dummy outbound and the
|
||||||
|
actual return label - same endpoint, same response shape, same
|
||||||
|
status-field pitfall (a 200 can still carry status: "error" or
|
||||||
|
"voided" alongside a label_id, which looks like success unless you
|
||||||
|
check status specifically)."""
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{API_BASE}/labels",
|
||||||
|
json=payload,
|
||||||
|
headers={"API-Key": api_key, "Accept": "application/json"},
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
|
||||||
|
if not response.ok:
|
||||||
|
raise ShipStationSendError(
|
||||||
|
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
|
||||||
|
|
||||||
|
if data.get("errors"):
|
||||||
|
raise ShipStationSendError(f"ShipStation reported errors: {data['errors']}")
|
||||||
|
|
||||||
|
label_status = data.get("status")
|
||||||
|
if label_status == "error":
|
||||||
|
raise ShipStationSendError(
|
||||||
|
f"ShipStation created label {data.get('label_id', '?')} but its status is "
|
||||||
|
f"'error' - it was not actually completed. Full response: {data}"
|
||||||
|
)
|
||||||
|
if label_status == "voided":
|
||||||
|
raise ShipStationSendError(
|
||||||
|
f"ShipStation reports label {data.get('label_id', '?')} as already voided."
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _void_label(label_id: str, api_key: str) -> None:
|
||||||
|
"""Best-effort cleanup - if the real return label fails to create
|
||||||
|
after the dummy outbound succeeded, void the dummy rather than leave
|
||||||
|
a paid, unused label sitting in the account. Deliberately swallows
|
||||||
|
its own errors: this runs during an already-failing operation, and a
|
||||||
|
secondary failure here shouldn't mask the original error or crash
|
||||||
|
the app - worst case, an unused dummy label needs manual voiding."""
|
||||||
|
try:
|
||||||
|
requests.put(
|
||||||
|
f"{API_BASE}/labels/{label_id}/void",
|
||||||
|
headers={"API-Key": api_key, "Accept": "application/json"},
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _create_dummy_outbound_label(
|
||||||
|
order: Order,
|
||||||
|
api_key: str,
|
||||||
|
carrier_id: str,
|
||||||
|
service_code: str,
|
||||||
|
store_id: str,
|
||||||
|
external_shipment_id: str,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Mirrors what your team already does by hand in ShipStation's GUI:
|
||||||
|
create a minimal, cheap outbound label (1x1x1in, 1oz) purely so a
|
||||||
|
real return label can be linked to it via outbound_label_id - which
|
||||||
|
is apparently what actually makes a return label findable/visible in
|
||||||
|
your account, confirmed against your own working manual process
|
||||||
|
rather than assumed from the API docs alone. Same shipping account as
|
||||||
|
the return itself; ship_from/ship_to are the reverse of the return
|
||||||
|
(this one goes warehouse -> customer, matching a normal outbound).
|
||||||
|
Returns the dummy's label_id.
|
||||||
|
|
||||||
|
external_shipment_id is passed in (rather than computed here) since
|
||||||
|
it must be unique per account - the caller uses a suffixed variant
|
||||||
|
for this dummy, distinct from the real return label's. ShipStation's
|
||||||
|
own docs confirm this field exists on both shipments and labels
|
||||||
|
specifically to correlate a record back to your own system, and it's
|
||||||
|
what populates the "Order #" column - this was missing entirely
|
||||||
|
before, on both this dummy and the real return label.
|
||||||
|
|
||||||
|
warehouse_id is used INSTEAD of ship_from when available (confirmed by
|
||||||
|
a real ShipStation error: "ship_from and warehouse_id cannot be
|
||||||
|
provided in same request" - they're mutually exclusive, not
|
||||||
|
additive). When a warehouse_id is configured, ShipStation resolves
|
||||||
|
the ship_from address from the registered warehouse record itself.
|
||||||
|
This only affects the throwaway dummy - it's never seen by anyone,
|
||||||
|
so it doesn't matter that this bypasses the RubiconMD name-override
|
||||||
|
logic in _return_address_for_order; the real return label (which
|
||||||
|
people do see) still uses that function directly.
|
||||||
|
"""
|
||||||
|
warehouse_id = _warehouse_id_for_company(order.company)
|
||||||
|
info = order.shipping_info or {}
|
||||||
|
|
||||||
|
shipment: dict = {
|
||||||
|
"carrier_id": carrier_id,
|
||||||
|
"service_code": service_code,
|
||||||
|
# Confirmed against ShipStation's own request schema for POST /v2/labels:
|
||||||
|
# external_shipment_id is a field ON the shipment object
|
||||||
|
# (shipment.external_shipment_id), there is no top-level equivalent for
|
||||||
|
# this endpoint. Putting it at the top level (as this code used to) means
|
||||||
|
# ShipStation silently ignores it and auto-generates its own "SEAuto-..."
|
||||||
|
# placeholder instead - confirmed live: a real production dummy shipment
|
||||||
|
# came back with external_shipment_id "SEAuto-..." instead of the ticket
|
||||||
|
# number we sent, which is almost certainly why "Order #" never showed up
|
||||||
|
# in ShipStation's UI for labels from this flow specifically.
|
||||||
|
"external_shipment_id": external_shipment_id,
|
||||||
|
"ship_to": {
|
||||||
|
"name": info.get("name", ""),
|
||||||
|
"phone": info.get("phone", ""),
|
||||||
|
"address_line1": info.get("address1", ""),
|
||||||
|
"address_line2": info.get("address2", "") or None,
|
||||||
|
"city_locality": info.get("city", ""),
|
||||||
|
"state_province": info.get("state", ""),
|
||||||
|
"postal_code": info.get("zip", ""),
|
||||||
|
"country_code": "US",
|
||||||
|
},
|
||||||
|
"packages": [_build_return_package(1.0, 1.0, 1.0, 1.0)],
|
||||||
|
}
|
||||||
|
# Left out entirely when blank (test mode only - see
|
||||||
|
# _validate_return_label_prerequisites) rather than sent as an empty string,
|
||||||
|
# matching the confirmed-working shape from a live probe.
|
||||||
|
if store_id:
|
||||||
|
shipment["store_id"] = store_id
|
||||||
|
if warehouse_id:
|
||||||
|
shipment["warehouse_id"] = warehouse_id
|
||||||
|
else:
|
||||||
|
shipment["ship_from"] = _return_address_for_order(order)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"shipment": shipment,
|
||||||
|
}
|
||||||
|
return _post_to_labels(payload, api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_return_label_prerequisites(order: Order) -> tuple[str, str, str, str]:
|
||||||
|
"""Shared validation for both steps - returns (api_key, carrier_id,
|
||||||
|
service_code, store_id) or raises with a clear, specific message."""
|
||||||
|
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
||||||
|
|
||||||
|
carrier_id, service_code = _return_carrier_for_order(order)
|
||||||
|
if not carrier_id or not service_code:
|
||||||
|
raise ShipStationSendError(
|
||||||
|
f"No return-label Carrier ID/Service Code configured for '{order.company}'. "
|
||||||
|
"Add them in Settings under Return Labels."
|
||||||
|
)
|
||||||
|
|
||||||
|
# store_id is only enforced in PRODUCTION. Confirmed directly in ShipStation's own
|
||||||
|
# dashboard: the Sandbox environment cannot connect/create Order Sources ("stores")
|
||||||
|
# at all - it explicitly says to switch to Production to do that. So no valid
|
||||||
|
# TEST_SHIPSTATION_*_STORE_ID can ever exist; requiring one in test mode would make
|
||||||
|
# this flow permanently untestable in sandbox. Confirmed via a live API probe that
|
||||||
|
# omitting store_id entirely still succeeds (schema-optional, not just a guess) -
|
||||||
|
# see _create_dummy_outbound_label()/create_return_label_from_dummy() for where it's
|
||||||
|
# left out of the request when blank. Still required in production, where it's
|
||||||
|
# achievable and matters for real (see the error message below).
|
||||||
|
store_id = _store_id_for_company(order.company)
|
||||||
|
if not store_id and not config.is_shipstation_test_mode():
|
||||||
|
raise ShipStationSendError(
|
||||||
|
f"No ShipStation Store ID configured for '{order.company}'. Add it in Settings "
|
||||||
|
"under ShipStation. A label created without one may not show up anywhere in "
|
||||||
|
"ShipStation's UI even though the API reports success."
|
||||||
|
)
|
||||||
|
|
||||||
|
return_address = _return_address_for_order(order)
|
||||||
|
if not (
|
||||||
|
return_address["address_line1"]
|
||||||
|
and return_address["city_locality"]
|
||||||
|
and return_address["state_province"]
|
||||||
|
and return_address["postal_code"]
|
||||||
|
and return_address["phone"]
|
||||||
|
):
|
||||||
|
raise ShipStationSendError(
|
||||||
|
f"No return warehouse address configured for '{order.company}'. "
|
||||||
|
"Add it in Settings under Return Labels."
|
||||||
|
)
|
||||||
|
|
||||||
|
info = order.shipping_info or {}
|
||||||
|
if not (info.get("address1") and info.get("city") and info.get("state") and info.get("zip")):
|
||||||
|
raise ShipStationSendError(
|
||||||
|
"This ticket is missing the customer's address information - "
|
||||||
|
"can't create a return label without knowing where it ships from."
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_key, carrier_id, service_code, store_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_dummy_shipment(order: Order) -> dict:
|
||||||
|
"""
|
||||||
|
STEP 1 of the emailed-return-label workflow, callable and verifiable
|
||||||
|
on its own: creates the minimal, cheap outbound label (1x1x1in, 1oz)
|
||||||
|
that your team already creates by hand in ShipStation's GUI before
|
||||||
|
generating a return from it. Returns the FULL response (not just the
|
||||||
|
label_id) so the caller/UI can show it for verification in
|
||||||
|
ShipStation before proceeding to step 2 - splitting these apart on
|
||||||
|
purpose so a problem in one step doesn't get masked by the other.
|
||||||
|
"""
|
||||||
|
api_key, carrier_id, service_code, store_id = _validate_return_label_prerequisites(order)
|
||||||
|
ticket_number = order.ticket_number or order.external_id
|
||||||
|
return _create_dummy_outbound_label(
|
||||||
|
order, api_key, carrier_id, service_code, store_id, external_shipment_id=f"{ticket_number}-DUMMY"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_return_label_from_dummy(
|
||||||
|
order: Order, dummy_label_id: str, packages: List[dict], charge_event: str | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
STEP 2 of the emailed-return-label workflow: creates the real return
|
||||||
|
label, linked via outbound_label_id to a dummy that was already
|
||||||
|
created (and ideally already verified in ShipStation) in step 1.
|
||||||
|
Does NOT create a new dummy - that's the point of splitting this out.
|
||||||
|
"""
|
||||||
|
settings = config.load_settings()
|
||||||
|
api_key, carrier_id, service_code, store_id = _validate_return_label_prerequisites(order)
|
||||||
|
return_address = _return_address_for_order(order)
|
||||||
|
info = order.shipping_info or {}
|
||||||
|
|
||||||
|
if not packages:
|
||||||
|
raise ShipStationSendError("At least one package is required.")
|
||||||
|
|
||||||
|
resolved_charge_event = charge_event or settings.get(
|
||||||
|
"SHIPSTATION_RETURN_CHARGE_EVENT", config.DEFAULT_RETURN_CHARGE_EVENT
|
||||||
|
)
|
||||||
|
if resolved_charge_event not in VALID_CHARGE_EVENTS:
|
||||||
|
raise ShipStationSendError(
|
||||||
|
f"charge_event must be one of {sorted(VALID_CHARGE_EVENTS)}, got "
|
||||||
|
f"'{resolved_charge_event}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket_number = order.ticket_number or order.external_id
|
||||||
|
|
||||||
|
shipment: dict = {
|
||||||
|
"carrier_id": carrier_id,
|
||||||
|
"service_code": service_code,
|
||||||
|
# Nested here, not top-level - see _create_dummy_outbound_label() for why
|
||||||
|
# (confirmed against ShipStation's own schema; a top-level
|
||||||
|
# external_shipment_id is silently ignored on POST /v2/labels).
|
||||||
|
"external_shipment_id": ticket_number,
|
||||||
|
"ship_to": return_address,
|
||||||
|
"ship_from": {
|
||||||
|
"name": info.get("name", ""),
|
||||||
|
"phone": info.get("phone", ""),
|
||||||
|
"address_line1": info.get("address1", ""),
|
||||||
|
"address_line2": info.get("address2", "") or None,
|
||||||
|
"city_locality": info.get("city", ""),
|
||||||
|
"state_province": info.get("state", ""),
|
||||||
|
"postal_code": info.get("zip", ""),
|
||||||
|
"country_code": "US",
|
||||||
|
},
|
||||||
|
"packages": [
|
||||||
|
_build_return_package(
|
||||||
|
p.get("weight_oz", 1.0), p.get("length", 0), p.get("width", 0), p.get("height", 0)
|
||||||
|
)
|
||||||
|
for p in packages
|
||||||
|
],
|
||||||
|
}
|
||||||
|
# Left out entirely when blank (test mode only - see
|
||||||
|
# _validate_return_label_prerequisites), matching the confirmed-working shape
|
||||||
|
# from a live probe, rather than sent as an empty string.
|
||||||
|
if store_id:
|
||||||
|
shipment["store_id"] = store_id
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"is_return_label": True,
|
||||||
|
"outbound_label_id": dummy_label_id,
|
||||||
|
"charge_event": resolved_charge_event,
|
||||||
|
"shipment": shipment,
|
||||||
|
}
|
||||||
|
|
||||||
|
data = _post_to_labels(payload, api_key)
|
||||||
|
data["_dummy_outbound_label_id"] = dummy_label_id
|
||||||
|
return data
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import requests
|
|||||||
from PyQt6.QtCore import QRunnable, QThreadPool
|
from PyQt6.QtCore import QRunnable, QThreadPool
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
|
from app.companies import parse_mapping
|
||||||
from app.services.base import OrderService, NormalizedOrder
|
from app.services.base import OrderService, NormalizedOrder
|
||||||
from app.tracking import suggest_jira_status
|
from app.tracking import suggest_jira_status
|
||||||
|
|
||||||
@@ -64,6 +65,22 @@ SHIPMENT_LOOKBACK_DAYS = 7
|
|||||||
MAX_CONCURRENT_REQUESTS = 5
|
MAX_CONCURRENT_REQUESTS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_shipping_method(service_code: str) -> str:
|
||||||
|
"""Maps a ShipStation service_code (e.g. 'ups_ground') to your team's
|
||||||
|
JIRA-facing term (e.g. 'Ground') via SHIPPING_METHOD_LABELS. Falls back
|
||||||
|
to a prettified version of the raw code for anything not mapped, so an
|
||||||
|
unmapped service still shows something readable rather than nothing."""
|
||||||
|
if not service_code:
|
||||||
|
return ""
|
||||||
|
settings = config.load_settings()
|
||||||
|
mapping = parse_mapping(
|
||||||
|
settings.get("SHIPPING_METHOD_LABELS", "") or config.DEFAULT_SHIPPING_METHOD_LABELS
|
||||||
|
)
|
||||||
|
if service_code in mapping:
|
||||||
|
return mapping[service_code]
|
||||||
|
return service_code.replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
class ShipStationServiceError(Exception):
|
class ShipStationServiceError(Exception):
|
||||||
"""Raised for any ShipStation fetch failure, with a message safe to show in the UI."""
|
"""Raised for any ShipStation fetch failure, with a message safe to show in the UI."""
|
||||||
|
|
||||||
@@ -96,7 +113,7 @@ class ShipStationService(OrderService):
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
settings = config.load_settings()
|
settings = config.load_settings()
|
||||||
self.api_key = settings["SHIPSTATION_API_KEY"]
|
self.api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
|
||||||
self.ticket_pattern = re.compile(
|
self.ticket_pattern = re.compile(
|
||||||
settings["TICKET_NUMBER_REGEX"] or config.DEFAULT_TICKET_NUMBER_REGEX
|
settings["TICKET_NUMBER_REGEX"] or config.DEFAULT_TICKET_NUMBER_REGEX
|
||||||
)
|
)
|
||||||
@@ -124,6 +141,7 @@ class ShipStationService(OrderService):
|
|||||||
# --- Sort/correlate now, entirely in memory, after both batches landed ---
|
# --- Sort/correlate now, entirely in memory, after both batches landed ---
|
||||||
tracking_by_ticket: Dict[str, List[dict]] = {}
|
tracking_by_ticket: Dict[str, List[dict]] = {}
|
||||||
raw_by_ticket: Dict[str, dict] = {}
|
raw_by_ticket: Dict[str, dict] = {}
|
||||||
|
shipping_method_by_ticket: Dict[str, str] = {}
|
||||||
self.unmatched_labels = []
|
self.unmatched_labels = []
|
||||||
|
|
||||||
for label in usable_labels:
|
for label in usable_labels:
|
||||||
@@ -144,9 +162,20 @@ class ShipStationService(OrderService):
|
|||||||
raw_by_ticket.setdefault(ticket_number, {"labels": [], "shipment": shipment})
|
raw_by_ticket.setdefault(ticket_number, {"labels": [], "shipment": shipment})
|
||||||
raw_by_ticket[ticket_number]["labels"].append(label)
|
raw_by_ticket[ticket_number]["labels"].append(label)
|
||||||
|
|
||||||
|
# Shipping Method reflects the OUTBOUND label specifically (matches
|
||||||
|
# the Ship Sheet's usage) - only set from the first non-return label
|
||||||
|
# seen per ticket, so a return label's service doesn't overwrite it.
|
||||||
|
if not label.get("is_return_label") and ticket_number not in shipping_method_by_ticket:
|
||||||
|
shipping_method_by_ticket[ticket_number] = _resolve_shipping_method(
|
||||||
|
label.get("service_code", "")
|
||||||
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
self._to_normalized_order(
|
self._to_normalized_order(
|
||||||
ticket_number, tracking_by_ticket[ticket_number], raw_by_ticket[ticket_number]
|
ticket_number,
|
||||||
|
tracking_by_ticket[ticket_number],
|
||||||
|
raw_by_ticket[ticket_number],
|
||||||
|
shipping_method_by_ticket.get(ticket_number, ""),
|
||||||
)
|
)
|
||||||
for ticket_number in tracking_by_ticket
|
for ticket_number in tracking_by_ticket
|
||||||
]
|
]
|
||||||
@@ -280,7 +309,7 @@ class ShipStationService(OrderService):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _to_normalized_order(
|
def _to_normalized_order(
|
||||||
ticket_number: str, tracking_numbers: List[dict], raw: dict
|
ticket_number: str, tracking_numbers: List[dict], raw: dict, shipping_method: str
|
||||||
) -> NormalizedOrder:
|
) -> NormalizedOrder:
|
||||||
suggested_status = suggest_jira_status(tracking_numbers) or "Tracking Pulled"
|
suggested_status = suggest_jira_status(tracking_numbers) or "Tracking Pulled"
|
||||||
numbers_display = ", ".join(
|
numbers_display = ", ".join(
|
||||||
@@ -294,7 +323,13 @@ class ShipStationService(OrderService):
|
|||||||
ticket_number=ticket_number,
|
ticket_number=ticket_number,
|
||||||
company="", # not used - the JIRA row this merges onto already has one
|
company="", # not used - the JIRA row this merges onto already has one
|
||||||
skus=[],
|
skus=[],
|
||||||
|
line_items=[],
|
||||||
|
shipping_info={},
|
||||||
|
creator=None,
|
||||||
|
assignee=None,
|
||||||
|
description=None,
|
||||||
tracking_numbers=tracking_numbers,
|
tracking_numbers=tracking_numbers,
|
||||||
|
shipping_method=shipping_method,
|
||||||
summary=numbers_display,
|
summary=numbers_display,
|
||||||
status=suggested_status,
|
status=suggested_status,
|
||||||
source_created_at=None,
|
source_created_at=None,
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
from PyQt6.QtGui import QAction
|
from PyQt6.QtGui import QAction
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QWidget,
|
QWidget,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
@@ -23,8 +26,13 @@ from PyQt6.QtWidgets import (
|
|||||||
QLabel,
|
QLabel,
|
||||||
QTabWidget,
|
QTabWidget,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
|
QDialog,
|
||||||
|
QInputDialog,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app import config
|
||||||
|
from app.external_links import jira_ticket_url, google_maps_search_url
|
||||||
|
from app.return_labels import is_emailed_label_order
|
||||||
from app.services import SERVICE_REGISTRY
|
from app.services import SERVICE_REGISTRY
|
||||||
from app.services.odoo_export import export_orders_to_csv
|
from app.services.odoo_export import export_orders_to_csv
|
||||||
from app.services.shipstation_send import export_order_to_shipstation_csv
|
from app.services.shipstation_send import export_order_to_shipstation_csv
|
||||||
@@ -33,13 +41,21 @@ from app.ui.settings_dialog import SettingsDialog
|
|||||||
from app.ui.widgets.orders_table import OrdersTableView
|
from app.ui.widgets.orders_table import OrdersTableView
|
||||||
from app.ui.widgets.dashboard import DashboardWidget
|
from app.ui.widgets.dashboard import DashboardWidget
|
||||||
from app.ui.widgets.order_detail_dialog import OrderDetailDialog
|
from app.ui.widgets.order_detail_dialog import OrderDetailDialog
|
||||||
|
from app.ui.widgets.return_label_dialog import ReturnLabelDialog
|
||||||
|
from app.ui.widgets.pack_ticket_dialog import PackTicketDialog
|
||||||
from app.workers import (
|
from app.workers import (
|
||||||
FetchOrdersWorker,
|
FetchOrdersWorker,
|
||||||
SendToShipStationWorker,
|
SendToShipStationWorker,
|
||||||
|
CreateDummyShipmentWorker,
|
||||||
|
CreateReturnLabelWorker,
|
||||||
load_orders_by_view,
|
load_orders_by_view,
|
||||||
get_dashboard_stats,
|
get_dashboard_stats,
|
||||||
mark_shipstation_sent,
|
mark_shipstation_sent,
|
||||||
|
save_dummy_outbound_label_id,
|
||||||
|
save_pack_data,
|
||||||
reset_local_database,
|
reset_local_database,
|
||||||
|
create_test_shipment_order,
|
||||||
|
delete_test_shipments,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +68,13 @@ class MainWindow(QMainWindow):
|
|||||||
self._workers: dict[str, FetchOrdersWorker] = {}
|
self._workers: dict[str, FetchOrdersWorker] = {}
|
||||||
self._import_actions: dict[str, QAction] = {}
|
self._import_actions: dict[str, QAction] = {}
|
||||||
self._send_worker: SendToShipStationWorker | None = None
|
self._send_worker: SendToShipStationWorker | None = None
|
||||||
|
self._return_label_worker: CreateDummyShipmentWorker | CreateReturnLabelWorker | None = None
|
||||||
|
|
||||||
|
# Reused, never-destroyed dialog instances (see the WORKAROUND NOTE
|
||||||
|
# in each dialog's module docstring) - created lazily via
|
||||||
|
# set_order() rather than a fresh instance per ticket.
|
||||||
|
self._pack_ticket_dialog: PackTicketDialog | None = None
|
||||||
|
self._return_label_dialog: ReturnLabelDialog | None = None
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._refresh_everything()
|
self._refresh_everything()
|
||||||
@@ -78,59 +101,368 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.setCentralWidget(central)
|
self.setCentralWidget(central)
|
||||||
|
|
||||||
toolbar = QToolBar("Main")
|
menu_bar = self.menuBar()
|
||||||
toolbar.setMovable(False)
|
|
||||||
self.addToolBar(toolbar)
|
file_menu = menu_bar.addMenu("&File")
|
||||||
|
settings_action = QAction("Settings...", self)
|
||||||
|
settings_action.triggered.connect(self._on_settings_clicked)
|
||||||
|
file_menu.addAction(settings_action)
|
||||||
|
file_menu.addSeparator()
|
||||||
|
exit_action = QAction("Exit", self)
|
||||||
|
exit_action.triggered.connect(self.close)
|
||||||
|
file_menu.addAction(exit_action)
|
||||||
|
|
||||||
|
data_menu = menu_bar.addMenu("&Data")
|
||||||
|
|
||||||
jira_action = QAction("Import from JIRA", self)
|
jira_action = QAction("Import from JIRA", self)
|
||||||
jira_action.triggered.connect(lambda: self._on_import_clicked("jira"))
|
jira_action.triggered.connect(lambda: self._on_import_clicked("jira"))
|
||||||
toolbar.addAction(jira_action)
|
data_menu.addAction(jira_action)
|
||||||
self._import_actions["jira"] = jira_action
|
self._import_actions["jira"] = jira_action
|
||||||
|
|
||||||
shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self)
|
shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self)
|
||||||
shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
|
shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
|
||||||
toolbar.addAction(shipstation_action)
|
data_menu.addAction(shipstation_action)
|
||||||
self._import_actions["shipstation"] = shipstation_action
|
self._import_actions["shipstation"] = shipstation_action
|
||||||
|
|
||||||
toolbar.addSeparator()
|
data_menu.addSeparator()
|
||||||
|
|
||||||
export_action = QAction("Export Visible Orders to Odoo CSV", self)
|
|
||||||
export_action.setToolTip("Exports from whichever tab is currently open")
|
|
||||||
export_action.triggered.connect(self._on_export_clicked)
|
|
||||||
toolbar.addAction(export_action)
|
|
||||||
|
|
||||||
toolbar.addSeparator()
|
|
||||||
|
|
||||||
emergency_action = QAction("Send to ShipStation", self)
|
|
||||||
emergency_action.setToolTip("Select a ticket in Active Orders first")
|
|
||||||
emergency_action.triggered.connect(self._on_emergency_send_clicked)
|
|
||||||
toolbar.addAction(emergency_action)
|
|
||||||
|
|
||||||
toolbar.addSeparator()
|
|
||||||
|
|
||||||
settings_action = QAction("Settings", self)
|
|
||||||
settings_action.triggered.connect(self._on_settings_clicked)
|
|
||||||
toolbar.addAction(settings_action)
|
|
||||||
|
|
||||||
refresh_action = QAction("Refresh from Local DB", self)
|
refresh_action = QAction("Refresh from Local DB", self)
|
||||||
refresh_action.triggered.connect(self._refresh_everything)
|
refresh_action.triggered.connect(self._refresh_everything)
|
||||||
toolbar.addAction(refresh_action)
|
data_menu.addAction(refresh_action)
|
||||||
|
|
||||||
reset_action = QAction("Reset Local Database", self)
|
export_action = QAction("Export Visible Orders to Odoo CSV...", self)
|
||||||
reset_action.setToolTip("Wipes the local cache - re-import from JIRA afterward")
|
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)
|
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.status_bar = QStatusBar()
|
||||||
self.setStatusBar(self.status_bar)
|
self.setStatusBar(self.status_bar)
|
||||||
self.status_label = QLabel("Ready.")
|
self.status_label = QLabel("Ready.")
|
||||||
self.status_bar.addWidget(self.status_label)
|
self.status_bar.addWidget(self.status_label)
|
||||||
|
|
||||||
|
# Permanent (right-aligned) so it's always visible regardless of
|
||||||
|
# whatever status_label currently says - the whole point is that
|
||||||
|
# it should be hard to miss whether real charges/labels are in
|
||||||
|
# play right now.
|
||||||
|
self._test_mode_indicator = QLabel()
|
||||||
|
self._test_mode_indicator.setStyleSheet(
|
||||||
|
"background-color: #b35c00; color: white; padding: 2px 8px; font-weight: bold;"
|
||||||
|
)
|
||||||
|
self.status_bar.addPermanentWidget(self._test_mode_indicator)
|
||||||
|
self._update_test_mode_indicator()
|
||||||
|
|
||||||
# -- actions -----------------------------------------------------------
|
# -- actions -----------------------------------------------------------
|
||||||
|
|
||||||
def _on_settings_clicked(self) -> None:
|
def _on_settings_clicked(self) -> None:
|
||||||
dialog = SettingsDialog(self)
|
dialog = SettingsDialog(self)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
|
# Settings could have been edited directly (SHIPSTATION_TEST_MODE
|
||||||
|
# as raw text) rather than via the menu checkbox - keep both in sync.
|
||||||
|
self._test_mode_action.blockSignals(True)
|
||||||
|
self._test_mode_action.setChecked(config.is_shipstation_test_mode())
|
||||||
|
self._test_mode_action.blockSignals(False)
|
||||||
|
self._update_test_mode_indicator()
|
||||||
|
|
||||||
|
def _on_test_mode_toggled(self, checked: bool) -> None:
|
||||||
|
config.save_settings({"SHIPSTATION_TEST_MODE": "true" if checked else "false"})
|
||||||
|
self._update_test_mode_indicator()
|
||||||
|
|
||||||
|
def _update_test_mode_indicator(self) -> None:
|
||||||
|
if config.is_shipstation_test_mode():
|
||||||
|
self._test_mode_indicator.setText("SHIPSTATION TEST MODE")
|
||||||
|
self._test_mode_indicator.show()
|
||||||
|
else:
|
||||||
|
self._test_mode_indicator.hide()
|
||||||
|
|
||||||
|
def _on_list_carriers_clicked(self) -> None:
|
||||||
|
from app.services.shipstation_send import list_carriers, ShipStationSendError
|
||||||
|
|
||||||
|
mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION"
|
||||||
|
try:
|
||||||
|
carriers = list_carriers()
|
||||||
|
except ShipStationSendError as exc:
|
||||||
|
QMessageBox.critical(self, "Could not list carriers", str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not carriers:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
f"ShipStation Carriers ({mode})",
|
||||||
|
f"No carriers are connected to this {mode.lower()} ShipStation account.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [f"Carriers visible to your current {mode} API key:", ""]
|
||||||
|
for carrier in carriers:
|
||||||
|
nickname = carrier.get("nickname") or "(no nickname)"
|
||||||
|
lines.append(
|
||||||
|
f" carrier_id: {carrier.get('carrier_id', '?')} "
|
||||||
|
f"{carrier.get('friendly_name', '?')} - {nickname}"
|
||||||
|
)
|
||||||
|
QMessageBox.information(self, f"ShipStation Carriers ({mode})", "\n".join(lines))
|
||||||
|
|
||||||
|
def _on_list_stores_clicked(self) -> None:
|
||||||
|
from app.services.shipstation_send import list_stores, ShipStationSendError
|
||||||
|
|
||||||
|
mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION"
|
||||||
|
try:
|
||||||
|
stores = list_stores()
|
||||||
|
except ShipStationSendError as exc:
|
||||||
|
QMessageBox.critical(self, "Could not list stores", str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not stores:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
f"ShipStation Stores ({mode})",
|
||||||
|
f"No stores are set up in this {mode.lower()} ShipStation account.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [f"Stores visible to your current {mode} API key:", ""]
|
||||||
|
for store in stores:
|
||||||
|
lines.append(
|
||||||
|
f" store_id: {store.get('store_id', '?')} "
|
||||||
|
f"{store.get('store_name', '?')}"
|
||||||
|
)
|
||||||
|
QMessageBox.information(self, f"ShipStation Stores ({mode})", "\n".join(lines))
|
||||||
|
|
||||||
|
def _on_list_warehouses_clicked(self) -> None:
|
||||||
|
from app.services.shipstation_send import list_warehouses, ShipStationSendError
|
||||||
|
|
||||||
|
mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION"
|
||||||
|
try:
|
||||||
|
warehouses = list_warehouses()
|
||||||
|
except ShipStationSendError as exc:
|
||||||
|
QMessageBox.critical(self, "Could not list warehouses", str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not warehouses:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
f"ShipStation Warehouses ({mode})",
|
||||||
|
f"No warehouses are set up in this {mode.lower()} ShipStation account.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [f"Warehouses visible to your current {mode} API key:", ""]
|
||||||
|
for warehouse in warehouses:
|
||||||
|
lines.append(
|
||||||
|
f" warehouse_id: {warehouse.get('warehouse_id', '?')} "
|
||||||
|
f"{warehouse.get('name', '?')}"
|
||||||
|
)
|
||||||
|
QMessageBox.information(self, f"ShipStation Warehouses ({mode})", "\n".join(lines))
|
||||||
|
|
||||||
|
def _on_create_test_warehouses_clicked(self) -> None:
|
||||||
|
if not config.is_shipstation_test_mode():
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Test Mode is off",
|
||||||
|
"Turn on ShipStation Test Mode first (Data menu). This creates a "
|
||||||
|
"warehouse in whichever ShipStation account your API key currently "
|
||||||
|
"points to, and it's meant for the sandbox account only.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
from app.services.shipstation_send import (
|
||||||
|
create_test_warehouse_for_company,
|
||||||
|
ShipStationSendError,
|
||||||
|
)
|
||||||
|
|
||||||
|
companies = [
|
||||||
|
("Signify Health", "TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID"),
|
||||||
|
("Oak Street Health", "TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID"),
|
||||||
|
]
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for company, setting_key in companies:
|
||||||
|
existing = config.load_settings().get(setting_key)
|
||||||
|
if existing:
|
||||||
|
lines.append(f"{company}: already set to {existing} - skipped")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
warehouse = create_test_warehouse_for_company(company)
|
||||||
|
except ShipStationSendError as exc:
|
||||||
|
lines.append(f"{company}: FAILED - {exc}")
|
||||||
|
continue
|
||||||
|
warehouse_id = warehouse.get("warehouse_id", "")
|
||||||
|
config.save_settings({setting_key: warehouse_id})
|
||||||
|
lines.append(f"{company}: created {warehouse_id} and saved it to {setting_key}")
|
||||||
|
|
||||||
|
QMessageBox.information(self, "Create Test Warehouse(s)", "\n".join(lines))
|
||||||
|
|
||||||
|
def _on_create_test_shipment_clicked(self) -> None:
|
||||||
|
if not config.is_shipstation_test_mode():
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Test Mode is off",
|
||||||
|
"Turn on ShipStation Test Mode first (Data menu). This ticket is meant "
|
||||||
|
"to be run through Create Return Label against the sandbox account, not "
|
||||||
|
"production - a real store_id/carrier under production settings would "
|
||||||
|
"create an actual, paid shipment to a fake address.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
company, ok = QInputDialog.getItem(
|
||||||
|
self,
|
||||||
|
"Create Test Shipment",
|
||||||
|
"Company (picks that company's configured emailed-return-label SKU):",
|
||||||
|
["Signify Health", "Oak Street Health"],
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
order = create_test_shipment_order(company)
|
||||||
|
except ValueError as exc:
|
||||||
|
QMessageBox.critical(self, "Could not create test shipment", str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
self._refresh_everything()
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Test Shipment Created",
|
||||||
|
f"Created {order.ticket_number} ({company}, SKU {order.skus[0]}) on the "
|
||||||
|
"Active tab.\n\n"
|
||||||
|
"Select it there and use Create Return Label to run Step 1 (dummy "
|
||||||
|
"shipment) and Step 2 (return label) against the sandbox account, the "
|
||||||
|
"same code path a real ticket would use.\n\n"
|
||||||
|
"Use Delete Test Shipments (Data menu) to clean it up afterward.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_delete_test_shipments_clicked(self) -> None:
|
||||||
|
confirm = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Delete Test Shipments",
|
||||||
|
"Removes every synthetic ticket created by Create Test Shipment "
|
||||||
|
"(ticket numbers starting with TEST-EMAIL-). Real JIRA tickets are not "
|
||||||
|
"affected.\n\nContinue?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if confirm != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
count = delete_test_shipments()
|
||||||
|
self._refresh_everything()
|
||||||
|
QMessageBox.information(self, "Delete Test Shipments", f"Deleted {count} test shipment(s).")
|
||||||
|
|
||||||
def _on_order_double_clicked(self, order) -> None:
|
def _on_order_double_clicked(self, order) -> None:
|
||||||
dialog = OrderDetailDialog(order, self)
|
dialog = OrderDetailDialog(order, self)
|
||||||
@@ -339,14 +671,225 @@ class MainWindow(QMainWindow):
|
|||||||
"Upload this into ShipStation's Import Orders wizard.",
|
"Upload this into ShipStation's Import Orders wizard.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _on_create_return_label_clicked(self) -> None:
|
||||||
|
order = self.orders_table.selected_order()
|
||||||
|
if order is None:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"No ticket selected",
|
||||||
|
"Select a ticket in Active Orders first, then click Create Return Label.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not is_emailed_label_order(order.skus or []):
|
||||||
|
proceed = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Not a return-label ticket",
|
||||||
|
f"This ticket's SKU(s) ({', '.join(order.skus or []) or 'none'}) don't match "
|
||||||
|
"the configured emailed-label SKUs. Continue anyway?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if proceed != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._return_label_dialog is None:
|
||||||
|
self._return_label_dialog = ReturnLabelDialog(order, self)
|
||||||
|
self._return_label_dialog.finished.connect(self._on_return_label_dialog_finished)
|
||||||
|
else:
|
||||||
|
self._return_label_dialog.set_order(order)
|
||||||
|
|
||||||
|
# Non-modal on purpose - see the module docstring in
|
||||||
|
# return_label_dialog.py. Disable the action while it's open so a
|
||||||
|
# second click can't call set_order() on it mid-edit and silently
|
||||||
|
# overwrite whatever the user is in the middle of entering.
|
||||||
|
self._return_label_action.setEnabled(False)
|
||||||
|
self._return_label_dialog.show()
|
||||||
|
self._return_label_dialog.raise_()
|
||||||
|
self._return_label_dialog.activateWindow()
|
||||||
|
|
||||||
|
def _on_return_label_dialog_finished(self, result: int) -> None:
|
||||||
|
self._return_label_action.setEnabled(True)
|
||||||
|
if result != QDialog.DialogCode.Accepted:
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog = self._return_label_dialog
|
||||||
|
order = dialog.order
|
||||||
|
ticket_number = order.ticket_number or order.external_id
|
||||||
|
|
||||||
|
if order.dummy_outbound_label_id:
|
||||||
|
# Step 2: a dummy already exists for this ticket - create the
|
||||||
|
# real return label from it.
|
||||||
|
packages = dialog.get_packages()
|
||||||
|
charge_event = dialog.get_charge_event()
|
||||||
|
if not packages:
|
||||||
|
QMessageBox.warning(self, "No packages", "Add at least one package first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.status_label.setText(f"Creating return label for {ticket_number}...")
|
||||||
|
worker = CreateReturnLabelWorker(order, order.dummy_outbound_label_id, packages, charge_event)
|
||||||
|
worker.finished_ok.connect(lambda result: self._on_return_label_ok(ticket_number, result))
|
||||||
|
worker.failed.connect(self._on_return_label_failed)
|
||||||
|
self._return_label_worker = worker # keep a reference so it isn't garbage collected
|
||||||
|
worker.start()
|
||||||
|
else:
|
||||||
|
# Step 1: no dummy yet - create it, then stop and let the user
|
||||||
|
# verify it in ShipStation before running this again for step 2.
|
||||||
|
self.status_label.setText(f"Creating dummy shipment for {ticket_number}...")
|
||||||
|
worker = CreateDummyShipmentWorker(order)
|
||||||
|
worker.finished_ok.connect(lambda result: self._on_dummy_shipment_ok(ticket_number, result))
|
||||||
|
worker.failed.connect(self._on_return_label_failed)
|
||||||
|
self._return_label_worker = worker
|
||||||
|
worker.start()
|
||||||
|
|
||||||
|
def _on_dummy_shipment_ok(self, ticket_number: str, result: dict) -> None:
|
||||||
|
label_id = result.get("label_id", "?")
|
||||||
|
save_dummy_outbound_label_id(ticket_number, label_id)
|
||||||
|
self._refresh_everything()
|
||||||
|
# Refreshing resets the table model, which clears whatever row was
|
||||||
|
# selected - re-select the same ticket so clicking Create Return
|
||||||
|
# Label again immediately proceeds to step 2 rather than hitting
|
||||||
|
# "no ticket selected".
|
||||||
|
self.orders_table.select_ticket(ticket_number)
|
||||||
|
self.status_label.setText(f"Dummy shipment created - {label_id}")
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Step 1 Complete",
|
||||||
|
f"Dummy shipment {label_id} created for {ticket_number}.\n\n"
|
||||||
|
"Go check it in ShipStation now - confirm Order #, Ship From/Store, and everything "
|
||||||
|
"else looks right. Once you've verified it, click Create Return Label again on this "
|
||||||
|
"ticket to run step 2 (the actual return label).",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_return_label_ok(self, ticket_number: str, result: dict) -> None:
|
||||||
|
mark_shipstation_sent(ticket_number)
|
||||||
|
self._refresh_everything()
|
||||||
|
self.orders_table.select_ticket(ticket_number)
|
||||||
|
label_id = result.get("label_id", "?")
|
||||||
|
tracking = result.get("tracking_number", "?")
|
||||||
|
label_status = result.get("status", "unknown")
|
||||||
|
dummy_id = result.get("_dummy_outbound_label_id", "?")
|
||||||
|
self.status_label.setText(f"Return label created - {label_id} (status: {label_status})")
|
||||||
|
|
||||||
|
if label_status == "processing":
|
||||||
|
body = (
|
||||||
|
f"Label {label_id} was accepted but is still processing (tracking {tracking}).\n\n"
|
||||||
|
"ShipStation says this can take a few minutes to fully complete - if you can't "
|
||||||
|
"find it yet, wait a bit and check again before assuming something's wrong.\n\n"
|
||||||
|
f"(Linked to dummy outbound label {dummy_id}, for troubleshooting reference.)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
body = (
|
||||||
|
f"Label {label_id} created, status: {label_status} (tracking {tracking}).\n\n"
|
||||||
|
"Find it in ShipStation and use Other Actions -> Send Return Label to email "
|
||||||
|
"it to the customer through your branded template.\n\n"
|
||||||
|
f"(Linked to dummy outbound label {dummy_id}, for troubleshooting reference.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Icon.Information)
|
||||||
|
box.setWindowTitle("Return Label Created")
|
||||||
|
box.setText(body)
|
||||||
|
copy_button = None
|
||||||
|
if tracking and tracking != "?":
|
||||||
|
copy_button = box.addButton("Copy Tracking Number", QMessageBox.ButtonRole.ActionRole)
|
||||||
|
box.addButton(QMessageBox.StandardButton.Ok)
|
||||||
|
box.exec()
|
||||||
|
# Only ever touches the clipboard if explicitly asked - never
|
||||||
|
# overwrites it automatically, since it's used constantly for other
|
||||||
|
# things and clobbering it silently would lose whatever was there.
|
||||||
|
if copy_button is not None and box.clickedButton() is copy_button:
|
||||||
|
QApplication.clipboard().setText(tracking)
|
||||||
|
self.status_label.setText(f"Tracking number {tracking} copied to clipboard.")
|
||||||
|
|
||||||
|
def _on_return_label_failed(self, message: str) -> None:
|
||||||
|
self.status_label.setText("Return label creation failed.")
|
||||||
|
QMessageBox.critical(self, "Return label creation failed", message)
|
||||||
|
|
||||||
|
def _on_pack_ticket_clicked(self) -> None:
|
||||||
|
order = self.orders_table.selected_order()
|
||||||
|
if order is None:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"No ticket selected",
|
||||||
|
"Select a ticket in Active Orders first, then click Pack Ticket.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._pack_ticket_dialog is None:
|
||||||
|
self._pack_ticket_dialog = PackTicketDialog(order, self)
|
||||||
|
self._pack_ticket_dialog.finished.connect(self._on_pack_ticket_dialog_finished)
|
||||||
|
else:
|
||||||
|
self._pack_ticket_dialog.set_order(order)
|
||||||
|
|
||||||
|
# Non-modal on purpose - see the module docstring in
|
||||||
|
# pack_ticket_dialog.py. Disable the action while it's open so a
|
||||||
|
# second click can't call set_order() on it mid-edit and silently
|
||||||
|
# overwrite whatever the user is in the middle of entering/scanning.
|
||||||
|
self._pack_ticket_action.setEnabled(False)
|
||||||
|
self._pack_ticket_dialog.show()
|
||||||
|
self._pack_ticket_dialog.raise_()
|
||||||
|
self._pack_ticket_dialog.activateWindow()
|
||||||
|
|
||||||
|
def _on_pack_ticket_dialog_finished(self, result: int) -> None:
|
||||||
|
self._pack_ticket_action.setEnabled(True)
|
||||||
|
if result != QDialog.DialogCode.Accepted:
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog = self._pack_ticket_dialog
|
||||||
|
order = dialog.order
|
||||||
|
ticket_number = order.ticket_number or order.external_id
|
||||||
|
save_pack_data(ticket_number, dialog.get_serial_numbers(), dialog.get_packed())
|
||||||
|
self._refresh_everything()
|
||||||
|
self.status_label.setText(f"Saved pack data for {ticket_number}.")
|
||||||
|
|
||||||
|
def _currently_selected_order(self):
|
||||||
|
"""Unlike orders_table.selected_order() (Active Orders specifically),
|
||||||
|
this checks whichever tab is actually showing - View in JIRA and
|
||||||
|
Look Up Address are read-only lookups that make just as much sense
|
||||||
|
for a Cancelled or Done ticket as an Active one."""
|
||||||
|
current_widget = self.tabs.currentWidget()
|
||||||
|
if isinstance(current_widget, OrdersTableView):
|
||||||
|
return current_widget.selected_order()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _on_view_in_jira_clicked(self) -> None:
|
||||||
|
order = self._currently_selected_order()
|
||||||
|
if order is None:
|
||||||
|
QMessageBox.information(self, "No ticket selected", "Select a ticket first.")
|
||||||
|
return
|
||||||
|
url = jira_ticket_url(order.ticket_number)
|
||||||
|
if url is None:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"JIRA URL not set",
|
||||||
|
"Add your JIRA URL in Settings first (or this ticket has no ticket number).",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
def _on_lookup_address_clicked(self) -> None:
|
||||||
|
order = self._currently_selected_order()
|
||||||
|
if order is None:
|
||||||
|
QMessageBox.information(self, "No ticket selected", "Select a ticket first.")
|
||||||
|
return
|
||||||
|
url = google_maps_search_url(order.shipping_info)
|
||||||
|
if url is None:
|
||||||
|
QMessageBox.information(self, "No address", "This ticket has no address on file.")
|
||||||
|
return
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
def _on_reset_database_clicked(self) -> None:
|
def _on_reset_database_clicked(self) -> None:
|
||||||
confirm = QMessageBox.warning(
|
confirm = QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
"Reset Local Database",
|
"Reset Local Database",
|
||||||
"This clears every locally cached order (JIRA is still the real source of "
|
"This clears every locally cached order, INCLUDING any serial numbers "
|
||||||
"truth, so nothing there is affected). You'll need to click Import from JIRA "
|
"and packed status your team has already entered on the Pack Ticket "
|
||||||
"afterward to repopulate, and Pull Tracking Numbers again if you rely on "
|
"dialog - that data doesn't come from JIRA, so a reset does not bring "
|
||||||
"today's already-pulled tracking data.\n\n"
|
"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?",
|
"Continue?",
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
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
|
Mainly a debugging aid: shows exactly what JIRA sent back for this
|
||||||
ticket (raw payload), plus the tracking numbers ShipStation supplied
|
ticket (raw payload), plus the tracking numbers ShipStation supplied
|
||||||
and the JIRA status they suggest - handy for the end-of-day close-out
|
and the JIRA status they suggest - handy for the end-of-day close-out
|
||||||
without having to piece it together by hand.
|
without having to piece it together by hand. Also offers quick jumps
|
||||||
|
out to JIRA and Google Maps, since those are common next steps when
|
||||||
|
something about a ticket needs a closer look.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QLabel, QPushButton
|
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QTextEdit, QLabel, QPushButton
|
||||||
|
|
||||||
|
from app.external_links import jira_ticket_url, google_maps_search_url
|
||||||
from app.models import Order
|
from app.models import Order
|
||||||
|
from app.ticket_validation import validate_ticket
|
||||||
from app.tracking import suggest_jira_status
|
from app.tracking import suggest_jira_status
|
||||||
|
|
||||||
|
|
||||||
@@ -38,12 +43,21 @@ class OrderDetailDialog(QDialog):
|
|||||||
info = order.shipping_info or {}
|
info = order.shipping_info or {}
|
||||||
address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
|
address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
|
||||||
|
|
||||||
|
issues = validate_ticket(order.skus, order.line_items)
|
||||||
|
|
||||||
summary_lines = [
|
summary_lines = [
|
||||||
f"Ticket #: {order.ticket_number or '(none found)'}",
|
f"Ticket #: {order.ticket_number or '(none found)'}",
|
||||||
f"Company: {order.company}",
|
f"Company: {order.company}",
|
||||||
f"Created by: {order.creator or '(unknown)'}",
|
f"Created by: {order.creator or '(unknown)'}",
|
||||||
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
|
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
|
||||||
f"Status: {order.status}",
|
f"Status: {order.status}",
|
||||||
|
]
|
||||||
|
if issues:
|
||||||
|
summary_lines.append("")
|
||||||
|
summary_lines.append("\u26a0 Flagged for review:")
|
||||||
|
summary_lines.extend(f" - {issue.message}" for issue in issues)
|
||||||
|
summary_lines.extend(
|
||||||
|
[
|
||||||
"",
|
"",
|
||||||
"Shipping info (used by the emergency Send to ShipStation action):",
|
"Shipping info (used by the emergency Send to ShipStation action):",
|
||||||
f" Name: {info.get('name') or '(missing)'}",
|
f" Name: {info.get('name') or '(missing)'}",
|
||||||
@@ -53,6 +67,7 @@ class OrderDetailDialog(QDialog):
|
|||||||
"",
|
"",
|
||||||
"Tracking numbers:",
|
"Tracking numbers:",
|
||||||
]
|
]
|
||||||
|
)
|
||||||
summary_lines.extend(tracking_lines or [" (none pulled yet)"])
|
summary_lines.extend(tracking_lines or [" (none pulled yet)"])
|
||||||
if suggested:
|
if suggested:
|
||||||
summary_lines.append("")
|
summary_lines.append("")
|
||||||
@@ -61,6 +76,16 @@ class OrderDetailDialog(QDialog):
|
|||||||
summary_label = QLabel("\n".join(summary_lines))
|
summary_label = QLabel("\n".join(summary_lines))
|
||||||
layout.addWidget(summary_label)
|
layout.addWidget(summary_label)
|
||||||
|
|
||||||
|
link_buttons = QHBoxLayout()
|
||||||
|
jira_button = QPushButton("View in JIRA")
|
||||||
|
jira_button.clicked.connect(lambda: self._open_url(jira_ticket_url(order.ticket_number)))
|
||||||
|
link_buttons.addWidget(jira_button)
|
||||||
|
maps_button = QPushButton("Look Up Address on Google Maps")
|
||||||
|
maps_button.clicked.connect(lambda: self._open_url(google_maps_search_url(order.shipping_info)))
|
||||||
|
link_buttons.addWidget(maps_button)
|
||||||
|
link_buttons.addStretch()
|
||||||
|
layout.addLayout(link_buttons)
|
||||||
|
|
||||||
layout.addWidget(QLabel("Raw payload from source system:"))
|
layout.addWidget(QLabel("Raw payload from source system:"))
|
||||||
text = QTextEdit()
|
text = QTextEdit()
|
||||||
text.setReadOnly(True)
|
text.setReadOnly(True)
|
||||||
@@ -71,3 +96,7 @@ class OrderDetailDialog(QDialog):
|
|||||||
close_button = QPushButton("Close")
|
close_button = QPushButton("Close")
|
||||||
close_button.clicked.connect(self.accept)
|
close_button.clicked.connect(self.accept)
|
||||||
layout.addWidget(close_button)
|
layout.addWidget(close_button)
|
||||||
|
|
||||||
|
def _open_url(self, url: str | None) -> None:
|
||||||
|
if url:
|
||||||
|
webbrowser.open(url)
|
||||||
|
|||||||
@@ -27,23 +27,33 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from app.models import Order
|
from app.models import Order
|
||||||
|
from app.return_labels import is_emailed_label_order, get_emailed_label_skus
|
||||||
from app.schedule import get_cutoff_time, is_past_cutoff_today
|
from app.schedule import get_cutoff_time, is_past_cutoff_today
|
||||||
from app.status_rules import get_cancelled_statuses, status_in
|
from app.status_rules import get_cancelled_statuses, status_in
|
||||||
|
from app.ticket_validation import validate_ticket, make_validation_context
|
||||||
from app.tracking import get_fulfilled_statuses
|
from app.tracking import get_fulfilled_statuses
|
||||||
|
|
||||||
CANCELLED_TEXT_COLOR = QColor(180, 0, 0)
|
CANCELLED_TEXT_COLOR = QColor(180, 0, 0)
|
||||||
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
|
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
|
||||||
|
ISSUE_TEXT_COLOR = QColor(170, 100, 0)
|
||||||
CHECK_MARK = "\u2713"
|
CHECK_MARK = "\u2713"
|
||||||
|
WARNING_MARK = "\u26a0"
|
||||||
|
|
||||||
# Order here is display order, left to right.
|
# Order here is display order, left to right.
|
||||||
COLUMNS = [
|
COLUMNS = [
|
||||||
("company", "Company"),
|
("company", "Company"),
|
||||||
("ticket_number", "Ticket #"),
|
("ticket_number", "Ticket #"),
|
||||||
("status", "Status"),
|
("status", "Status"),
|
||||||
|
("issues_display", "Issues"),
|
||||||
|
("packed_display", "Done"),
|
||||||
("skus_display", "SKUs"),
|
("skus_display", "SKUs"),
|
||||||
("summary", "Summary"),
|
("summary", "Summary"),
|
||||||
|
("assignee", "Assignee"),
|
||||||
|
("serials_display", "Serial #s"),
|
||||||
|
("return_label_display", "Return Label"),
|
||||||
("outgoing_tracking_display", "Outgoing Tracking"),
|
("outgoing_tracking_display", "Outgoing Tracking"),
|
||||||
("return_tracking_display", "Return Tracking"),
|
("return_tracking_display", "Return Tracking"),
|
||||||
|
("shipping_method", "Shipping Method"),
|
||||||
("uploaded_display", "Uploaded"),
|
("uploaded_display", "Uploaded"),
|
||||||
("past_cutoff_display", "Past Cutoff"),
|
("past_cutoff_display", "Past Cutoff"),
|
||||||
("created_display", "Created"),
|
("created_display", "Created"),
|
||||||
@@ -73,12 +83,31 @@ class OrdersTableModel(QAbstractTableModel):
|
|||||||
self._cancelled_statuses: set[str] = set()
|
self._cancelled_statuses: set[str] = set()
|
||||||
self._fulfilled_statuses: set[str] = set()
|
self._fulfilled_statuses: set[str] = set()
|
||||||
self._cutoff_time = None
|
self._cutoff_time = None
|
||||||
|
self._issues_by_id: dict[int, list] = {}
|
||||||
|
self._is_emailed_label_by_id: dict[int, bool] = {}
|
||||||
|
|
||||||
def set_orders(self, orders: List[Order]) -> None:
|
def set_orders(self, orders: List[Order]) -> None:
|
||||||
# Re-read status lists and cutoff time each refresh, in case Settings changed.
|
# Re-read status lists and cutoff time each refresh, in case Settings changed.
|
||||||
self._cancelled_statuses = get_cancelled_statuses()
|
self._cancelled_statuses = get_cancelled_statuses()
|
||||||
self._fulfilled_statuses = get_fulfilled_statuses()
|
self._fulfilled_statuses = get_fulfilled_statuses()
|
||||||
self._cutoff_time = get_cutoff_time()
|
self._cutoff_time = get_cutoff_time()
|
||||||
|
# Computed ONCE per refresh here, not per cell paint - both of
|
||||||
|
# these read Settings from disk internally, and Qt calls data()
|
||||||
|
# extremely frequently (every cell, every repaint, constantly
|
||||||
|
# during scrolling) - doing this per-cell instead of per-refresh
|
||||||
|
# was a severe, real performance bug, not just a minor slowdown.
|
||||||
|
# validation_context is ALSO fetched once here rather than once
|
||||||
|
# per order inside validate_ticket() - same fix, applied to the
|
||||||
|
# cost of computing the cache itself, not just using it.
|
||||||
|
validation_context = make_validation_context()
|
||||||
|
self._issues_by_id = {
|
||||||
|
order.id: validate_ticket(order.skus, order.line_items, **validation_context)
|
||||||
|
for order in orders
|
||||||
|
}
|
||||||
|
emailed_skus = get_emailed_label_skus()
|
||||||
|
self._is_emailed_label_by_id = {
|
||||||
|
order.id: is_emailed_label_order(order.skus or [], emailed_skus) for order in orders
|
||||||
|
}
|
||||||
self.beginResetModel()
|
self.beginResetModel()
|
||||||
self._orders = orders
|
self._orders = orders
|
||||||
self.endResetModel()
|
self.endResetModel()
|
||||||
@@ -102,9 +131,14 @@ class OrdersTableModel(QAbstractTableModel):
|
|||||||
|
|
||||||
order = self._orders[index.row()]
|
order = self._orders[index.row()]
|
||||||
is_cancelled = status_in(order.status, self._cancelled_statuses)
|
is_cancelled = status_in(order.status, self._cancelled_statuses)
|
||||||
|
field_name, _ = COLUMNS[index.column()]
|
||||||
|
|
||||||
if role == Qt.ItemDataRole.ForegroundRole:
|
if role == Qt.ItemDataRole.ForegroundRole:
|
||||||
return CANCELLED_TEXT_COLOR if is_cancelled else None
|
if is_cancelled:
|
||||||
|
return CANCELLED_TEXT_COLOR
|
||||||
|
if field_name == "issues_display" and self._issues_by_id.get(order.id):
|
||||||
|
return ISSUE_TEXT_COLOR
|
||||||
|
return None
|
||||||
|
|
||||||
if role == Qt.ItemDataRole.BackgroundRole:
|
if role == Qt.ItemDataRole.BackgroundRole:
|
||||||
# Cancelled rows use red TEXT (above) instead of a background,
|
# Cancelled rows use red TEXT (above) instead of a background,
|
||||||
@@ -115,13 +149,26 @@ class OrdersTableModel(QAbstractTableModel):
|
|||||||
return FULFILLED_ROW_COLOR
|
return FULFILLED_ROW_COLOR
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if role == Qt.ItemDataRole.ToolTipRole and field_name == "issues_display":
|
||||||
|
issues = self._issues_by_id.get(order.id, [])
|
||||||
|
return "\n".join(i.message for i in issues) if issues else None
|
||||||
|
|
||||||
if role != Qt.ItemDataRole.DisplayRole:
|
if role != Qt.ItemDataRole.DisplayRole:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
field_name, _ = COLUMNS[index.column()]
|
if field_name == "issues_display":
|
||||||
|
issues = self._issues_by_id.get(order.id, [])
|
||||||
|
return f"{WARNING_MARK} ({len(issues)})" if issues else ""
|
||||||
if field_name == "skus_display":
|
if field_name == "skus_display":
|
||||||
return ", ".join(order.skus or [])
|
return ", ".join(order.skus or [])
|
||||||
|
if field_name == "packed_display":
|
||||||
|
return CHECK_MARK if order.packed else ""
|
||||||
|
if field_name == "serials_display":
|
||||||
|
serials = order.serial_numbers or {}
|
||||||
|
filled = sum(1 for v in serials.values() if (v or "").strip())
|
||||||
|
return f"{filled} entered" if filled else ""
|
||||||
|
if field_name == "return_label_display":
|
||||||
|
return CHECK_MARK if self._is_emailed_label_by_id.get(order.id) else ""
|
||||||
if field_name == "outgoing_tracking_display":
|
if field_name == "outgoing_tracking_display":
|
||||||
return _format_tracking_numbers(order.tracking_numbers, is_return=False)
|
return _format_tracking_numbers(order.tracking_numbers, is_return=False)
|
||||||
if field_name == "return_tracking_display":
|
if field_name == "return_tracking_display":
|
||||||
@@ -253,6 +300,20 @@ class OrdersTableView(QWidget):
|
|||||||
source_index = self._proxy_model.mapToSource(indexes[0])
|
source_index = self._proxy_model.mapToSource(indexes[0])
|
||||||
return self._source_model.order_at(source_index.row())
|
return self._source_model.order_at(source_index.row())
|
||||||
|
|
||||||
|
def select_ticket(self, ticket_number: str) -> bool:
|
||||||
|
"""Re-selects a row by ticket number - a table refresh (set_orders)
|
||||||
|
clears whatever was selected, since the underlying model resets.
|
||||||
|
Used after a step in a multi-step action (like the return-label
|
||||||
|
dummy-then-return flow) so the next step doesn't silently find
|
||||||
|
nothing selected. Returns whether the ticket was found/selected."""
|
||||||
|
for row in range(self._proxy_model.rowCount()):
|
||||||
|
proxy_index = self._proxy_model.index(row, 0)
|
||||||
|
source_index = self._proxy_model.mapToSource(proxy_index)
|
||||||
|
if self._source_model.order_at(source_index.row()).ticket_number == ticket_number:
|
||||||
|
self.table.selectRow(proxy_index.row())
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def visible_orders(self) -> List[Order]:
|
def visible_orders(self) -> List[Order]:
|
||||||
"""Orders currently passing the active filters - used for export."""
|
"""Orders currently passing the active filters - used for export."""
|
||||||
result = []
|
result = []
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
from typing import List, Tuple, TypedDict
|
from typing import List, Tuple, TypedDict
|
||||||
|
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
|
from app import config
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import Order
|
from app.models import Order
|
||||||
from app.schedule import get_cutoff_time, is_past_cutoff_today
|
from app.schedule import get_cutoff_time, is_past_cutoff_today
|
||||||
@@ -61,6 +63,62 @@ class SendToShipStationWorker(QThread):
|
|||||||
self.finished_ok.emit(result)
|
self.finished_ok.emit(result)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateDummyShipmentWorker(QThread):
|
||||||
|
"""Runs step 1 (dummy outbound shipment creation) off the GUI thread."""
|
||||||
|
|
||||||
|
finished_ok = pyqtSignal(dict) # the created dummy label's JSON
|
||||||
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, order, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.order = order
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
from app.services.shipstation_send import create_dummy_shipment, ShipStationSendError
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = create_dummy_shipment(self.order)
|
||||||
|
except ShipStationSendError as exc:
|
||||||
|
self.failed.emit(str(exc))
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.failed.emit(f"Unexpected error creating dummy shipment: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.finished_ok.emit(result)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateReturnLabelWorker(QThread):
|
||||||
|
"""Runs step 2 (the real return label, from an already-created dummy)
|
||||||
|
off the GUI thread."""
|
||||||
|
|
||||||
|
finished_ok = pyqtSignal(dict) # the created label's JSON
|
||||||
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, order, dummy_label_id: str, packages: list[dict], charge_event: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.order = order
|
||||||
|
self.dummy_label_id = dummy_label_id
|
||||||
|
self.packages = packages
|
||||||
|
self.charge_event = charge_event
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
from app.services.shipstation_send import create_return_label_from_dummy, ShipStationSendError
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = create_return_label_from_dummy(
|
||||||
|
self.order, self.dummy_label_id, self.packages, self.charge_event
|
||||||
|
)
|
||||||
|
except ShipStationSendError as exc:
|
||||||
|
self.failed.emit(str(exc))
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.failed.emit(f"Unexpected error creating return label: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.finished_ok.emit(result)
|
||||||
|
|
||||||
|
|
||||||
class FetchOrdersWorker(QThread):
|
class FetchOrdersWorker(QThread):
|
||||||
"""Fetches orders from a given service and saves/merges the results."""
|
"""Fetches orders from a given service and saves/merges the results."""
|
||||||
|
|
||||||
@@ -134,6 +192,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
jira_row.tracking_numbers = order.get("tracking_numbers", [])
|
jira_row.tracking_numbers = order.get("tracking_numbers", [])
|
||||||
|
if order.get("shipping_method"):
|
||||||
|
jira_row.shipping_method = order["shipping_method"]
|
||||||
enriched_count += 1
|
enriched_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -158,6 +218,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
line_items=order.get("line_items", []),
|
line_items=order.get("line_items", []),
|
||||||
shipping_info=order.get("shipping_info", {}),
|
shipping_info=order.get("shipping_info", {}),
|
||||||
creator=order.get("creator"),
|
creator=order.get("creator"),
|
||||||
|
assignee=order.get("assignee"),
|
||||||
|
description=order.get("description"),
|
||||||
tracking_numbers=order.get("tracking_numbers", []),
|
tracking_numbers=order.get("tracking_numbers", []),
|
||||||
summary=order["summary"],
|
summary=order["summary"],
|
||||||
status=new_status,
|
status=new_status,
|
||||||
@@ -197,6 +259,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
|||||||
existing.line_items = order.get("line_items", [])
|
existing.line_items = order.get("line_items", [])
|
||||||
existing.shipping_info = order.get("shipping_info", {})
|
existing.shipping_info = order.get("shipping_info", {})
|
||||||
existing.creator = order.get("creator")
|
existing.creator = order.get("creator")
|
||||||
|
existing.assignee = order.get("assignee")
|
||||||
|
existing.description = order.get("description")
|
||||||
existing.summary = order["summary"]
|
existing.summary = order["summary"]
|
||||||
existing.status = new_status
|
existing.status = new_status
|
||||||
existing.source_created_at = order["source_created_at"]
|
existing.source_created_at = order["source_created_at"]
|
||||||
@@ -239,6 +303,97 @@ def reset_local_database() -> int:
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _emailed_label_sku_for_company(company: str) -> str:
|
||||||
|
"""Picks whichever configured EMAILED_LABEL_SKUS entry actually
|
||||||
|
resolves to the given company via COMPANY_SKU_MAP, rather than
|
||||||
|
hardcoding SH007/OK012 - stays correct if either setting changes."""
|
||||||
|
from app.companies import parse_mapping, resolve_company_by_sku
|
||||||
|
from app.return_labels import get_emailed_label_skus
|
||||||
|
|
||||||
|
sku_map = parse_mapping(config.get("COMPANY_SKU_MAP", config.DEFAULT_COMPANY_SKU_MAP))
|
||||||
|
for sku in sorted(get_emailed_label_skus()):
|
||||||
|
if resolve_company_by_sku(sku, sku_map) == company:
|
||||||
|
return sku.upper()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_shipment_order(company: str) -> Order:
|
||||||
|
"""
|
||||||
|
Creates a synthetic, non-JIRA Order row carrying that company's
|
||||||
|
configured emailed-return-label SKU, purely so the existing "Create
|
||||||
|
Return Label" flow (return_label_dialog.py + shipstation_send.py) can
|
||||||
|
be exercised end-to-end - dummy shipment, then real return label -
|
||||||
|
against a throwaway ticket instead of risking a real customer's.
|
||||||
|
|
||||||
|
source="test" keeps this completely separate from real JIRA rows:
|
||||||
|
save_orders() only ever matches on source == "jira"/"shipstation", so
|
||||||
|
Import from JIRA can never touch or overwrite one of these, and
|
||||||
|
delete_test_shipments() cleans them up by that same marker.
|
||||||
|
|
||||||
|
status="Created" so it lands on the Active tab like a real open
|
||||||
|
ticket would - that's where staff would naturally go to select it and
|
||||||
|
run Create Return Label.
|
||||||
|
"""
|
||||||
|
sku = _emailed_label_sku_for_company(company)
|
||||||
|
if not sku:
|
||||||
|
raise ValueError(
|
||||||
|
f"No EMAILED_LABEL_SKUS entry resolves to '{company}' via COMPANY_SKU_MAP - "
|
||||||
|
"check both settings."
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket_number = f"TEST-EMAIL-{uuid.uuid4().hex[:8].upper()}"
|
||||||
|
|
||||||
|
order = Order(
|
||||||
|
source="test",
|
||||||
|
external_id=ticket_number,
|
||||||
|
ticket_number=ticket_number,
|
||||||
|
company=company,
|
||||||
|
skus=[sku],
|
||||||
|
line_items=[{"sku": sku, "item_name": "TEST - Emailed Return Label"}],
|
||||||
|
shipping_info={
|
||||||
|
"name": "TEST ORDER - DO NOT SHIP",
|
||||||
|
"phone": "555-555-5555",
|
||||||
|
"email": "",
|
||||||
|
"address1": "123 Test St",
|
||||||
|
"address2": "",
|
||||||
|
"city": "Austin",
|
||||||
|
"state": "TX",
|
||||||
|
"zip": "78701",
|
||||||
|
},
|
||||||
|
creator="Test Shipment Generator",
|
||||||
|
assignee="",
|
||||||
|
description=(
|
||||||
|
"Synthetic test order created via Data > Create Test Shipment (Email SKU) - "
|
||||||
|
"not a real ticket. Safe to delete with Data > Delete Test Shipments."
|
||||||
|
),
|
||||||
|
summary=f"TEST - Emailed Return Label ({company})",
|
||||||
|
status="Created",
|
||||||
|
source_created_at=dt.datetime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
session.add(order)
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def delete_test_shipments() -> int:
|
||||||
|
"""Removes every synthetic order created by create_test_shipment_order()
|
||||||
|
(source == "test") - real JIRA-sourced rows are untouched, since those
|
||||||
|
always have source == "jira"."""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
result = session.execute(delete(Order).where(Order.source == "test"))
|
||||||
|
session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def load_all_orders() -> List[Order]:
|
def load_all_orders() -> List[Order]:
|
||||||
session = get_session()
|
session = get_session()
|
||||||
try:
|
try:
|
||||||
@@ -288,11 +443,16 @@ def load_orders_by_view() -> Tuple[List[Order], List[Order], List[Order]]:
|
|||||||
|
|
||||||
def mark_shipstation_sent(ticket_number: str) -> None:
|
def mark_shipstation_sent(ticket_number: str) -> None:
|
||||||
"""Stamps shipstation_sent_at after a CONFIRMED emergency API send -
|
"""Stamps shipstation_sent_at after a CONFIRMED emergency API send -
|
||||||
called once ShipStation's own response confirms creation succeeded."""
|
called once ShipStation's own response confirms creation succeeded.
|
||||||
|
|
||||||
|
Matches by ticket_number alone, not source == "jira" - source is only
|
||||||
|
ever "jira" or "test" (synthetic tickets from create_test_shipment_order()),
|
||||||
|
and ticket_number is already unique across both, so restricting to "jira"
|
||||||
|
here just means this silently no-ops for test tickets instead of erroring."""
|
||||||
session = get_session()
|
session = get_session()
|
||||||
try:
|
try:
|
||||||
order = session.execute(
|
order = session.execute(
|
||||||
select(Order).where(Order.source == "jira", Order.ticket_number == ticket_number)
|
select(Order).where(Order.ticket_number == ticket_number)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if order is not None:
|
if order is not None:
|
||||||
order.shipstation_sent_at = dt.datetime.now()
|
order.shipstation_sent_at = dt.datetime.now()
|
||||||
@@ -301,6 +461,54 @@ def mark_shipstation_sent(ticket_number: str) -> None:
|
|||||||
session.close()
|
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:
|
def get_dashboard_stats() -> dict:
|
||||||
"""
|
"""
|
||||||
Counts for the dashboard. "Active Orders" and the company breakdown
|
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