Add Create Test Shipment (Email SKU) for testing the return-label flow, plus test-mode/store_id investigation notes

Adds synthetic source="test" tickets (create_test_shipment_order/delete_test_shipments)
carrying a company's real emailed-return-label SKU, so Step 1/Step 2 can be exercised
against the sandbox without a real JIRA ticket or risking a real customer's. Gated to
Test Mode - the same flow against production would create a real paid shipment.

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-28 11:45:09 -05:00
co-authored by Claude Sonnet 5
parent 82e6148ca9
commit a7d2e7870c
3 changed files with 259 additions and 11 deletions
+88 -11
View File
@@ -29,7 +29,10 @@ app/
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.
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
@@ -182,6 +185,59 @@ and copy its store_id into `TEST_SHIPSTATION_SIGNIFY_STORE_ID` /
"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
ShipStation's own request schema for `POST /v2/labels` - `shipment.store_id` is NOT
in the list of required fields (unlike `shipment.ship_to`), and nothing in the schema
ties a store to a specific carrier/warehouse. But this app's own store_id check
exists for a real, previously-confirmed reason (see Return Labels point 1 above): a
label can come back `status: completed` from the API while being invisible anywhere
in ShipStation's own UI. The schema being lenient doesn't mean this account's actual
behavior is - keep requiring it. **Confirmed live, 2026-09-18** (three direct
`POST /v2/labels` probes against the sandbox key, harmless - sandbox labels, no real
cost): omitting `store_id` entirely succeeds cleanly (`200`, `status: "completed"`,
real label `se-201075505`) - so it genuinely is optional API-side, exactly as the
schema says. Whether that label is actually visible in ShipStation's own UI (the
thing that would tell us if the "invisible label" concern really extends to
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:
STORE-PROBE-no-store-id-at-all`.
**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.
**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)**:
before a first real production test of the return-label flow, ran `GET /v2/carriers`,
@@ -201,6 +257,21 @@ production API key and diffed the results against `.env`:
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.
**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
@@ -289,16 +360,22 @@ change without a deliberate separate conversation about it.
## Known-pending / not yet built
- **Immediate next step, as of the last working session**: the warehouse_id issue is
now fixed code-side ("Create Test Warehouse(s)..." in the Data menu, see ShipStation
Test Mode above) but hasn't been run/confirmed yet - run it, confirm the two
`TEST_SHIPSTATION_*_WAREHOUSE_ID` settings got saved. Then a *second*, separate
test-mode gap surfaced right after: an "invalid store" error on Oak Street's
production store_id, because `TEST_SHIPSTATION_*_STORE_ID` is blank and there is no
API-based fix for that one (see ShipStation Test Mode above) - both
`TEST_SHIPSTATION_SIGNIFY_STORE_ID` and `TEST_SHIPSTATION_OAKSTREET_STORE_ID` still
need to be filled in by hand from the sandbox account's own ShipStation UI before the
emailed-return-label flow can be tested end-to-end in test mode.
- **Immediate next step, as of the last working session**: the warehouse_id gap is
fixed and confirmed (both `TEST_SHIPSTATION_*_WAREHOUSE_ID` settings are populated).
The store_id gap is still open and turned out to be more involved than the
warehouse one: two manual stores were created in the sandbox account, one per
company (see ShipStation Test Mode above for why two, not one), but ShipStation's
newer web UI (`ship15.shipstation.com`) only exposes a UUID in the URL, not the
`se-XXXXX` ID this app's `TEST_SHIPSTATION_*_STORE_ID` settings need - confirmed via
direct API probe that this UUID is not a usable store_id at all (a different,
`500`-shaped failure than a genuinely wrong-but-well-formed ID gets). The real
`se-` ID for each new store still needs to be found (store settings page content,
or ShipStation support) before the emailed-return-label flow can be tested
end-to-end in test mode. Also still open: whether a label with NO store_id at all
is actually visible in ShipStation's UI - confirmed live that the API accepts a
request with the field omitted (see ShipStation Test Mode above), which, if it
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`,
`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
+78
View File
@@ -27,6 +27,7 @@ from PyQt6.QtWidgets import (
QTabWidget,
QFileDialog,
QDialog,
QInputDialog,
)
from app import config
@@ -53,6 +54,8 @@ from app.workers import (
save_dummy_outbound_label_id,
save_pack_data,
reset_local_database,
create_test_shipment_order,
delete_test_shipments,
)
@@ -184,6 +187,24 @@ class MainWindow(QMainWindow):
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)
@@ -386,6 +407,63 @@ class MainWindow(QMainWindow):
QMessageBox.information(self, "Create Test Warehouse(s)", "\n".join(lines))
def _on_create_test_shipment_clicked(self) -> None:
if not config.is_shipstation_test_mode():
QMessageBox.information(
self,
"Test Mode is off",
"Turn on ShipStation Test Mode first (Data menu). This ticket is meant "
"to be run through Create Return Label against the sandbox account, not "
"production - a real store_id/carrier under production settings would "
"create an actual, paid shipment to a fake address.",
)
return
company, ok = QInputDialog.getItem(
self,
"Create Test Shipment",
"Company (picks that company's configured emailed-return-label SKU):",
["Signify Health", "Oak Street Health"],
editable=False,
)
if not ok:
return
try:
order = create_test_shipment_order(company)
except ValueError as exc:
QMessageBox.critical(self, "Could not create test shipment", str(exc))
return
self._refresh_everything()
QMessageBox.information(
self,
"Test Shipment Created",
f"Created {order.ticket_number} ({company}, SKU {order.skus[0]}) on the "
"Active tab.\n\n"
"Select it there and use Create Return Label to run Step 1 (dummy "
"shipment) and Step 2 (return label) against the sandbox account, the "
"same code path a real ticket would use.\n\n"
"Use Delete Test Shipments (Data menu) to clean it up afterward.",
)
def _on_delete_test_shipments_clicked(self) -> None:
confirm = QMessageBox.question(
self,
"Delete Test Shipments",
"Removes every synthetic ticket created by Create Test Shipment "
"(ticket numbers starting with TEST-EMAIL-). Real JIRA tickets are not "
"affected.\n\nContinue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirm != QMessageBox.StandardButton.Yes:
return
count = delete_test_shipments()
self._refresh_everything()
QMessageBox.information(self, "Delete Test Shipments", f"Deleted {count} test shipment(s).")
def _on_order_double_clicked(self, order) -> None:
dialog = OrderDetailDialog(order, self)
dialog.exec()
+93
View File
@@ -9,11 +9,13 @@ pattern rather than blocking the UI.
from __future__ import annotations
import datetime as dt
import uuid
from typing import List, Tuple, TypedDict
from PyQt6.QtCore import QThread, pyqtSignal
from sqlalchemy import select, delete
from app import config
from app.database import get_session
from app.models import Order
from app.schedule import get_cutoff_time, is_past_cutoff_today
@@ -301,6 +303,97 @@ def reset_local_database() -> int:
session.close()
def _emailed_label_sku_for_company(company: str) -> str:
"""Picks whichever configured EMAILED_LABEL_SKUS entry actually
resolves to the given company via COMPANY_SKU_MAP, rather than
hardcoding SH007/OK012 - stays correct if either setting changes."""
from app.companies import parse_mapping, resolve_company_by_sku
from app.return_labels import get_emailed_label_skus
sku_map = parse_mapping(config.get("COMPANY_SKU_MAP", config.DEFAULT_COMPANY_SKU_MAP))
for sku in sorted(get_emailed_label_skus()):
if resolve_company_by_sku(sku, sku_map) == company:
return sku.upper()
return ""
def create_test_shipment_order(company: str) -> Order:
"""
Creates a synthetic, non-JIRA Order row carrying that company's
configured emailed-return-label SKU, purely so the existing "Create
Return Label" flow (return_label_dialog.py + shipstation_send.py) can
be exercised end-to-end - dummy shipment, then real return label -
against a throwaway ticket instead of risking a real customer's.
source="test" keeps this completely separate from real JIRA rows:
save_orders() only ever matches on source == "jira"/"shipstation", so
Import from JIRA can never touch or overwrite one of these, and
delete_test_shipments() cleans them up by that same marker.
status="Created" so it lands on the Active tab like a real open
ticket would - that's where staff would naturally go to select it and
run Create Return Label.
"""
sku = _emailed_label_sku_for_company(company)
if not sku:
raise ValueError(
f"No EMAILED_LABEL_SKUS entry resolves to '{company}' via COMPANY_SKU_MAP - "
"check both settings."
)
ticket_number = f"TEST-EMAIL-{uuid.uuid4().hex[:8].upper()}"
order = Order(
source="test",
external_id=ticket_number,
ticket_number=ticket_number,
company=company,
skus=[sku],
line_items=[{"sku": sku, "item_name": "TEST - Emailed Return Label"}],
shipping_info={
"name": "TEST ORDER - DO NOT SHIP",
"phone": "555-555-5555",
"email": "",
"address1": "123 Test St",
"address2": "",
"city": "Austin",
"state": "TX",
"zip": "78701",
},
creator="Test Shipment Generator",
assignee="",
description=(
"Synthetic test order created via Data > Create Test Shipment (Email SKU) - "
"not a real ticket. Safe to delete with Data > Delete Test Shipments."
),
summary=f"TEST - Emailed Return Label ({company})",
status="Created",
source_created_at=dt.datetime.now(),
)
session = get_session()
try:
session.add(order)
session.commit()
finally:
session.close()
return order
def delete_test_shipments() -> int:
"""Removes every synthetic order created by create_test_shipment_order()
(source == "test") - real JIRA-sourced rows are untouched, since those
always have source == "jira"."""
session = get_session()
try:
result = session.execute(delete(Order).where(Order.source == "test"))
session.commit()
return result.rowcount or 0
finally:
session.close()
def load_all_orders() -> List[Order]:
session = get_session()
try: