diff --git a/CLAUDE.md b/CLAUDE.md index 891aacc..1bc5c42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/ui/main_window.py b/app/ui/main_window.py index ba419f6..7aa89e9 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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() diff --git a/app/workers.py b/app/workers.py index c1839f7..c845720 100644 --- a/app/workers.py +++ b/app/workers.py @@ -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: