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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user