Fix external_shipment_id placement and test-mode store_id ticket persistence in the return-label flow
external_shipment_id was at the top level of the POST /v2/labels payload in both
_create_dummy_outbound_label() and create_return_label_from_dummy() - ShipStation's
schema only supports it nested under shipment, so it was silently ignored and
auto-generated ("SEAuto-...") on every label this flow ever created. Moved it inside
the shipment dict in both functions. Confirmed end-to-end on a real production
ticket (AR-166098): Order # now correctly shows the real ticket number, and
ship_from/ship_to are correct for a return.
Also: store_id is now only required in production - ShipStation's sandbox
environment cannot have stores/Order Sources at all (confirmed in the real sandbox
dashboard), so test mode omits it from the request instead of requiring an
impossible value. And three per-ticket write-backs (mark_shipstation_sent,
save_dummy_outbound_label_id, save_pack_data) were hardcoded to source == "jira",
silently no-opping for source == "test" tickets created by Create Test Shipment -
now match on ticket_number alone, since source is only ever "jira" or "test" and
ticket_number is already unique across both.
CLAUDE.md records the full investigation and closes out the long-standing
"return-label flow real-world verification" known-pending item.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -168,47 +168,104 @@ as the origin address (same physical address, just registered under the sandbox
|
|||||||
account) and auto-saves the resulting `warehouse_id` into
|
account) and auto-saves the resulting `warehouse_id` into
|
||||||
`TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID` / `TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID`.
|
`TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID` / `TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID`.
|
||||||
|
|
||||||
**There is no way to list store IDs, in test mode or production - confirmed a dead
|
**SOLVED, 2026-10-01: test mode can NEVER have a store_id - it's a platform limit,
|
||||||
end.** `list_stores()` used to call `GET /v2/stores`, which doesn't exist (plain 404,
|
not a configuration gap.** Confirmed directly in ShipEngine/ShipStation's own
|
||||||
"No route matched with those values"). Checked against ShipStation's own V2 OpenAPI
|
dashboard (the actual separate sandbox account, reached via its own distinct sandbox
|
||||||
reference: there is no Stores/Marketplaces section at all - `store_id` only ever shows
|
login - not `ship15.shipstation.com`, which is the production web UI regardless of
|
||||||
up as an *input* field on label/shipment requests, never as a listable resource. Their
|
how a store there is named): the Sandbox environment's "Connect order sources" flow
|
||||||
own help docs confirm the only ways to get a store_id are ShipStation support looking
|
explicitly says to **switch to Production to connect order sources**. Stores/Order
|
||||||
it up, or the legacy V1 API (different auth - key+secret Basic Auth, not the single
|
Sources structurally do not exist in sandbox at all - unlike carriers and warehouses
|
||||||
V2 API-Key header this app uses everywhere). `list_stores()` now raises a clear error
|
(both confirmed to have real sandbox equivalents), this is one ShipStation resource
|
||||||
saying so instead of a confusing 404; the Data-menu item was relabeled "About
|
type the sandbox environment simply doesn't support. No amount of searching the UI,
|
||||||
ShipStation Store IDs..." accordingly. **The only real fix**: log into the
|
guessing IDs, or chasing V1 API tricks was ever going to produce a valid
|
||||||
ShipStation account's own UI (Settings > Store Setup) - for test mode, that means the
|
`TEST_SHIPSTATION_*_STORE_ID`, because there is no such thing to find.
|
||||||
TEST/sandbox account specifically, not production - find or create a manual store,
|
|
||||||
and copy its store_id into `TEST_SHIPSTATION_SIGNIFY_STORE_ID` /
|
|
||||||
`TEST_SHIPSTATION_OAKSTREET_STORE_ID` by hand. This was caught mid-debugging: a
|
|
||||||
"Return label creation failed" / "invalid store" error on `store_id: 367672` (Oak
|
|
||||||
Street's *production* `se-367672`, prefix stripped in the echoed error per point 8
|
|
||||||
above) - happening because `TEST_SHIPSTATION_OAKSTREET_STORE_ID` was still blank.
|
|
||||||
The fix requires **two separate manual stores in the sandbox account** (one per
|
|
||||||
company), not one shared - mirrors production, which already has two independent
|
|
||||||
store IDs (`SHIPSTATION_SIGNIFY_STORE_ID`/`SHIPSTATION_OAKSTREET_STORE_ID`), and the
|
|
||||||
`TEST_*` settings already have two independent slots for exactly this. There was no
|
|
||||||
way to confirm via API whether one store could technically be shared across
|
|
||||||
companies in the sandbox (still no listing/inspection endpoint) - moot anyway, since
|
|
||||||
per-company stores is the already-intended design.
|
|
||||||
|
|
||||||
**`store_id` is schema-optional, but don't take that as license to drop it**: checked
|
**The fix**: `store_id` is now only enforced in PRODUCTION
|
||||||
ShipStation's own request schema for `POST /v2/labels` - `shipment.store_id` is NOT
|
(`_validate_return_label_prerequisites()` in `shipstation_send.py` checks
|
||||||
in the list of required fields (unlike `shipment.ship_to`), and nothing in the schema
|
`config.is_shipstation_test_mode()` before raising), and left out of the request
|
||||||
ties a store to a specific carrier/warehouse. But this app's own store_id check
|
entirely when blank (`_create_dummy_outbound_label()` /
|
||||||
exists for a real, previously-confirmed reason (see Return Labels point 1 above): a
|
`create_return_label_from_dummy()`) rather than sent empty. This was already known to
|
||||||
label can come back `status: completed` from the API while being invisible anywhere
|
be API-safe - `shipment.store_id` is not in ShipStation's own required-fields schema,
|
||||||
in ShipStation's own UI. The schema being lenient doesn't mean this account's actual
|
and a live probe on 2026-09-18 confirmed omitting it succeeds cleanly (`200`,
|
||||||
behavior is - keep requiring it. **Confirmed live, 2026-09-18** (three direct
|
`status: "completed"`). **Confirmed end-to-end, 2026-10-01**: both Step 1 (dummy
|
||||||
`POST /v2/labels` probes against the sandbox key, harmless - sandbox labels, no real
|
shipment) and Step 2 (return label) now succeed in test mode for both companies with
|
||||||
cost): omitting `store_id` entirely succeeds cleanly (`200`, `status: "completed"`,
|
real sandbox labels (`se-206662629`/`se-206662656` Signify,
|
||||||
real label `se-201075505`) - so it genuinely is optional API-side, exactly as the
|
`se-206662683`/`se-206662696` Oak Street). Production behavior is unchanged -
|
||||||
schema says. Whether that label is actually visible in ShipStation's own UI (the
|
store_id is still required there, where it's achievable and still matters for the
|
||||||
thing that would tell us if the "invisible label" concern really extends to
|
originally-confirmed visibility reason (Return Labels point 1 above).
|
||||||
store_id, or was specific to unlinked return labels) still needs a human to check the
|
|
||||||
sandbox account's Orders/Shipments list for `external_shipment_id:
|
**Two stores were created in the wrong account along the way** - `ship15.shipstation.com`
|
||||||
STORE-PROBE-no-store-id-at-all`.
|
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`/
|
**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
|
`TEST_OAKSTREET_RETURN_CARRIER_ID` were blank (only the `*_SERVICE_CODE` halves had
|
||||||
@@ -219,26 +276,6 @@ into both settings. Worth knowing for next time: `TEST_SIGNIFY_RETURN_SERVICE_CO
|
|||||||
`TEST_OAKSTREET_RETURN_SERVICE_CODE` being set doesn't imply their carrier-ID
|
`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.
|
counterparts are - check both halves of a `TEST_*_RETURN_*` pair, not just one.
|
||||||
|
|
||||||
**The newer ShipStation web UI's URL is NOT the `se-` store ID - confirmed, don't
|
|
||||||
reuse it.** Creating a manual store at `ship15.shipstation.com/settings/stores/...`
|
|
||||||
shows a UUID in the URL (e.g. `081cf783-32b2-4b52-b9f8-767531d0ac47`), not an
|
|
||||||
`se-XXXXX` ID. This is ShipStation's newer/redesigned web UI - that UUID is its own
|
|
||||||
internal routing ID, a completely different ID space from the V2 API's store IDs, not
|
|
||||||
just a missing `se-` prefix (the `se-` prefix quirk documented above only ever
|
|
||||||
applies to genuinely `se-`-shaped IDs missing their prefix, not arbitrary UUIDs).
|
|
||||||
Confirmed by direct probe: a known-fake-but-correctly-shaped ID (`se-999999999`)
|
|
||||||
gets the expected clean `400 "invalid store"`, but that UUID (tried both raw and
|
|
||||||
with `se-` prepended) gets a `500 "An unexpected error occurred"` instead - a
|
|
||||||
different failure shape entirely, meaning the API doesn't even recognize it as a
|
|
||||||
candidate ID, let alone a wrong one. **Don't paste that URL UUID into
|
|
||||||
`TEST_SHIPSTATION_*_STORE_ID` and expect it to work.** The real `se-` ID for a
|
|
||||||
store created in this newer UI needs to come from somewhere else - check the store's
|
|
||||||
own settings *page content* (not the URL) for an API/Integration section that
|
|
||||||
displays it as text, or fall back to ShipStation support (their own help docs say
|
|
||||||
this explicitly: "contact our support team and tell them the name of the manual
|
|
||||||
store" is a valid way to get a store_id when the List Stores API isn't an option -
|
|
||||||
see the store_id dead-end note above).
|
|
||||||
|
|
||||||
**Production readiness check, 2026-09-16 (read-only, no labels created, no cost)**:
|
**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`,
|
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
|
`GET /v2/warehouses`, and `GET /v2/carriers/{id}/services` directly against the
|
||||||
@@ -272,6 +309,27 @@ untouched. **Deliberately gated to Test Mode** - the same flow against productio
|
|||||||
settings would create a real, paid UPS shipment to a fake address, not just a
|
settings would create a real, paid UPS shipment to a fake address, not just a
|
||||||
sandbox test label.
|
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
|
**Real limitations of ShipStation's sandbox (confirmed via their own docs, not
|
||||||
assumed) that constrain what test mode can actually verify:**
|
assumed) that constrain what test mode can actually verify:**
|
||||||
- Branded Labels / Branded Tracking Pages are NOT available in sandbox. This directly
|
- Branded Labels / Branded Tracking Pages are NOT available in sandbox. This directly
|
||||||
@@ -360,22 +418,20 @@ change without a deliberate separate conversation about it.
|
|||||||
|
|
||||||
## Known-pending / not yet built
|
## Known-pending / not yet built
|
||||||
|
|
||||||
- **Immediate next step, as of the last working session**: the warehouse_id gap is
|
- **RESOLVED 2026-10-01, was the longest-running open item**: the warehouse_id and
|
||||||
fixed and confirmed (both `TEST_SHIPSTATION_*_WAREHOUSE_ID` settings are populated).
|
store_id test-mode gaps are both fixed and confirmed end-to-end. Warehouse_id just
|
||||||
The store_id gap is still open and turned out to be more involved than the
|
needed `TEST_SHIPSTATION_*_WAREHOUSE_ID` populated (done). Store_id turned out to
|
||||||
warehouse one: two manual stores were created in the sandbox account, one per
|
be structurally impossible to fix via settings at all - ShipStation's sandbox
|
||||||
company (see ShipStation Test Mode above for why two, not one), but ShipStation's
|
environment cannot have stores/Order Sources, full stop (confirmed in the real
|
||||||
newer web UI (`ship15.shipstation.com`) only exposes a UUID in the URL, not the
|
sandbox dashboard, which says to switch to Production to connect one) - so the code
|
||||||
`se-XXXXX` ID this app's `TEST_SHIPSTATION_*_STORE_ID` settings need - confirmed via
|
now only requires store_id in production and omits it entirely in test mode. Both
|
||||||
direct API probe that this UUID is not a usable store_id at all (a different,
|
Step 1 and Step 2 of the emailed-return-label flow are confirmed working end-to-end
|
||||||
`500`-shaped failure than a genuinely wrong-but-well-formed ID gets). The real
|
in test mode for both companies as of this fix (see ShipStation Test Mode above for
|
||||||
`se-` ID for each new store still needs to be found (store settings page content,
|
the real sandbox label IDs from that test). **This flow is now genuinely testable
|
||||||
or ShipStation support) before the emailed-return-label flow can be tested
|
in sandbox for the first time** - if there's still an appetite to verify the
|
||||||
end-to-end in test mode. Also still open: whether a label with NO store_id at all
|
branded-email step too, remember that part specifically still requires production
|
||||||
is actually visible in ShipStation's UI - confirmed live that the API accepts a
|
(Branded Labels/Tracking Pages aren't available in sandbox - see the sandbox
|
||||||
request with the field omitted (see ShipStation Test Mode above), which, if it
|
limitations list below).
|
||||||
turns out to be visible too, could make chasing the real store_id unnecessary for
|
|
||||||
test mode specifically.
|
|
||||||
- **EOD JIRA push**: deliberately deferred. `packed`, `serial_numbers`,
|
- **EOD JIRA push**: deliberately deferred. `packed`, `serial_numbers`,
|
||||||
`tracking_numbers`, `shipping_method`, `assignee` are all captured and ready for it
|
`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
|
whenever it's prioritized - would need the exact JIRA custom field IDs (outbound
|
||||||
@@ -383,12 +439,15 @@ change without a deliberate separate conversation about it.
|
|||||||
- **Bulk JIRA -> ShipStation import**: still an external, separate process (their own
|
- **Bulk JIRA -> ShipStation import**: still an external, separate process (their own
|
||||||
CSV-conversion tool) - this app only handles the single-ticket emergency send case.
|
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).
|
A bigger, riskier build if ever tackled (rate limits, bulk automation behavior).
|
||||||
- **Return-label flow real-world verification**: the two-step dummy-then-return flow
|
- **RESOLVED 2026-10-01: return-label flow real-world verification.** Confirmed
|
||||||
is fully tested at the code level (unit tests covering both success and
|
end-to-end on a real production ticket (`AR-166098`) for the first time: Step 1
|
||||||
void-on-failure paths), but end-to-end confirmation that it behaves correctly with
|
(dummy) and Step 2 (return label) both succeeded, Order # correctly shows the real
|
||||||
ShipStation's actual production API, for the actual team, is still in progress as of
|
ticket number in ShipStation, and ship_from/ship_to are correct for a return. This
|
||||||
the last working session - don't assume it's fully proven in production just because
|
surfaced and fixed a real bug along the way (external_shipment_id placement - see
|
||||||
the code is correct and tests pass.
|
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
|
- **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
|
(cancellation requires a JIRA account and an audited reason) - don't build this
|
||||||
without a deliberate, separate conversation about it first.
|
without a deliberate, separate conversation about it first.
|
||||||
|
|||||||
@@ -623,7 +623,16 @@ def _create_dummy_outbound_label(
|
|||||||
shipment: dict = {
|
shipment: dict = {
|
||||||
"carrier_id": carrier_id,
|
"carrier_id": carrier_id,
|
||||||
"service_code": service_code,
|
"service_code": service_code,
|
||||||
"store_id": store_id,
|
# 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": {
|
"ship_to": {
|
||||||
"name": info.get("name", ""),
|
"name": info.get("name", ""),
|
||||||
"phone": info.get("phone", ""),
|
"phone": info.get("phone", ""),
|
||||||
@@ -636,13 +645,17 @@ def _create_dummy_outbound_label(
|
|||||||
},
|
},
|
||||||
"packages": [_build_return_package(1.0, 1.0, 1.0, 1.0)],
|
"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:
|
if warehouse_id:
|
||||||
shipment["warehouse_id"] = warehouse_id
|
shipment["warehouse_id"] = warehouse_id
|
||||||
else:
|
else:
|
||||||
shipment["ship_from"] = _return_address_for_order(order)
|
shipment["ship_from"] = _return_address_for_order(order)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"external_shipment_id": external_shipment_id,
|
|
||||||
"shipment": shipment,
|
"shipment": shipment,
|
||||||
}
|
}
|
||||||
return _post_to_labels(payload, api_key)
|
return _post_to_labels(payload, api_key)
|
||||||
@@ -662,8 +675,17 @@ def _validate_return_label_prerequisites(order: Order) -> tuple[str, str, str, s
|
|||||||
"Add them in Settings under Return Labels."
|
"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)
|
store_id = _store_id_for_company(order.company)
|
||||||
if not store_id:
|
if not store_id and not config.is_shipstation_test_mode():
|
||||||
raise ShipStationSendError(
|
raise ShipStationSendError(
|
||||||
f"No ShipStation Store ID configured for '{order.company}'. Add it in Settings "
|
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 "
|
"under ShipStation. A label created without one may not show up anywhere in "
|
||||||
@@ -738,33 +760,42 @@ def create_return_label_from_dummy(
|
|||||||
|
|
||||||
ticket_number = order.ticket_number or order.external_id
|
ticket_number = order.ticket_number or order.external_id
|
||||||
|
|
||||||
payload = {
|
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,
|
"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,
|
"is_return_label": True,
|
||||||
"outbound_label_id": dummy_label_id,
|
"outbound_label_id": dummy_label_id,
|
||||||
"charge_event": resolved_charge_event,
|
"charge_event": resolved_charge_event,
|
||||||
"shipment": {
|
"shipment": shipment,
|
||||||
"carrier_id": carrier_id,
|
|
||||||
"service_code": service_code,
|
|
||||||
"store_id": store_id,
|
|
||||||
"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
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data = _post_to_labels(payload, api_key)
|
data = _post_to_labels(payload, api_key)
|
||||||
|
|||||||
+16
-5
@@ -443,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()
|
||||||
@@ -459,11 +464,14 @@ def mark_shipstation_sent(ticket_number: str) -> None:
|
|||||||
def save_dummy_outbound_label_id(ticket_number: str, label_id: str) -> None:
|
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
|
"""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
|
(the real return label) can be triggered separately - including in a
|
||||||
later session, after the dummy has been verified in ShipStation."""
|
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()
|
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.dummy_outbound_label_id = label_id
|
order.dummy_outbound_label_id = label_id
|
||||||
@@ -478,11 +486,14 @@ def save_pack_data(ticket_number: str, serial_numbers: dict, packed: bool) -> No
|
|||||||
dialog. Staff-entered data, not sourced from JIRA - this is the
|
dialog. Staff-entered data, not sourced from JIRA - this is the
|
||||||
beginning of the eventual end-of-day push back to JIRA (deferred for
|
beginning of the eventual end-of-day push back to JIRA (deferred for
|
||||||
now), so nothing here gets overwritten by a JIRA re-import.
|
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()
|
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 None:
|
if order is None:
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user