ShipStation tracking pull, status tracking, dashboard rework

This commit is contained in:
2026-08-26 11:28:42 -05:00
parent ffaaa0c8c8
commit 18be8588f6
16 changed files with 769 additions and 304 deletions
+109 -69
View File
@@ -1,10 +1,18 @@
# Order Manager
A PyQt6 desktop app that independently pulls orders from JIRA and
ShipStation, tracks them by company (Signify Health / Oak Street
Health) and ticket number, and gives you a dashboard of what's come in
today. Odoo integration is file-based for now (CSV export) since the
live Odoo side is still in development.
A PyQt6 desktop app for the daily order cycle: pull today's tickets
from JIRA (8AM-3:30PM intake), then at end-of-day pull ShipStation
tracking numbers and see, at a glance, what's fulfilled, what's
cancelled, and what's still open from a previous day (carryover).
Odoo integration is file-based for now (CSV export) since the live
Odoo side is still in development.
One design note up front: **every order is one row, sourced from
JIRA.** ShipStation only exists here to generate shipping labels for
JIRA tickets (order/shipment numbers there are the same as the JIRA
ticket number), so it never creates its own rows - "Pull Tracking
Numbers" just merges tracking numbers onto the matching JIRA ticket.
There's no "source" filter because there's effectively one source.
## Setup
@@ -15,61 +23,87 @@ live Odoo side is still in development.
created today - adjust to match your project/label). The
deliverable field IDs are already defaulted from your export
(`customfield_10573,customfield_10570` for Signify;
`customfield_12790,customfield_13021` for Oak Street) - update
these if your field IDs ever change.
- **ShipStation**: API Key. The store IDs are already defaulted
(`se-221889` Signify, `se-367672` Oak Street) - just add your key.
`customfield_12790,customfield_13021` for Oak Street).
- **ShipStation**: just the API Key.
- **Companies**: SKU prefix -> company mapping (defaults to
`SH:Signify Health,OK:Oak Street Health`) and the ticket number
pattern (defaults to `AR-######`-style).
4. Click **Import from JIRA** and/or **Import from ShipStation** - they
run independently, each in the background so the UI stays responsive.
5. Check the **Dashboard** tab for totals by company/source and how
many tickets are matched across both systems vs. only seen in one.
6. Use **All Orders** to filter by company/source or search by ticket
number/SKU, and **Export Visible Orders to Odoo CSV** to hand off
whatever's currently filtered.
`SH:Signify Health,OK:Oak Street Health`).
- **Status Tracking**: which statuses count as cancelled/fulfilled -
defaults already match what you described (see below).
4. Click **Import from JIRA** during/after the intake window.
5. Click **Pull Tracking Numbers (ShipStation)** at end-of-day - it
merges tracking numbers onto the matching tickets, no separate rows.
6. Check the **Dashboard** tab for Fulfilled / Cancelled / Carryover /
Tracking Received counts.
7. Use **All Orders** to filter by company or search (ticket #, SKU,
status, or tracking #), and **Export Visible Orders to Odoo CSV**
for whatever's currently filtered.
Orders are cached locally in SQLite (`orders.db`). Re-importing updates
existing orders rather than duplicating them (matched on source +
source ID).
existing tickets rather than duplicating them.
## How company/ticket matching works
## Status tracking, matched to your actual workflow
- **JIRA orders**: each company has its own pair of "deliverable"
custom fields in JIRA (e.g. Signify's `Deliverables` + `Hardware
Needed`; Oak Street's own versions). A ticket can list several
deliverables (`customfield_10573`, etc. - configurable via
`JIRA_SIGNIFY_SKU_FIELDS` / `JIRA_OAKSTREET_SKU_FIELDS`). Each
deliverable value looks like `SH011: Shipping - Return Label iPad -
Physical in Box` - the app pulls out just the `SH011` code as the
SKU and keeps the description for the summary. Company is then
resolved from the SKU prefix (`SH`/`OK`) via `COMPANY_SKU_MAP`, same
as before. Since these tickets' actual JIRA "Summary" field is
usually blank, the app falls back to the joined deliverable
descriptions for the Summary column when there's nothing else there.
- **ShipStation orders**: company is derived from which store the
order lives in (`SHIPSTATION_STORE_MAP`), since each company has its
own store/UPS account.
- **Ticket number** (`AR-######`) is the JIRA issue key directly on
JIRA-sourced orders. On ShipStation-sourced orders, since V2's API
doesn't have a dedicated "orders" endpoint with a guaranteed
order-number field, the app scans the whole shipment payload for the
configured pattern (`TICKET_NUMBER_REGEX`). Once you see what a real
ShipStation payload for one of your shipments looks like, this can be
tightened to read one specific field for speed/reliability - just
point me at where it actually shows up.
- **Cancellation** (`CANCELLED_STATUSES`, default `Cancelled`): you
cancel a ticket yourself when the SKU is wrong or the address doesn't
validate. Any order in this status is **highlighted red** in the
table, and a **popup fires** right after an import if a ticket just
transitioned into it (not one that was already cancelled).
- **Fulfilled** (`FULFILLED_STATUS_WITH_RETURN` /
`FULFILLED_STATUS_WITHOUT_RETURN`, defaulting to `Waiting For Return`
/ `Device Return Not Needed`): these are **highlighted green**. The
"Pull Tracking Numbers" action figures out which one applies per
ticket automatically, from whether ShipStation's automation also
generated a return label (see below) - so you know which status to
set without checking each one by hand.
- **Carryover**: tickets you didn't close out same-day (rare, per what
you described) don't need anything special - they're just tickets
that haven't hit a terminal status yet (`JIRA_TERMINAL_STATUSES`,
default `Cancelled,Waiting For Return,Device Return Not Needed`).
Every JIRA import re-checks any such ticket regardless of when it was
created, so a carryover ticket stays "alive" and gets its status
change picked up whenever it happens, however many days later. The
Dashboard's Carryover count is just these tickets filtered to "created
before today."
## A note on the ShipStation V2 API
## How tracking numbers get pulled and matched
ShipStation's V2 API doesn't expose a dedicated "list orders" endpoint
the way the older V1 API did - it's built around `/v2/shipments`. This
app pulls today's shipments from that endpoint and filters client-side
to your two configured store IDs. If your account's shipments don't
carry an `items`/SKU array the way we expect (this can depend on how
orders reach ShipStation), the company will still resolve correctly
(from `store_id`, which is reliable) even if the SKU column comes back
empty - worth checking against a real imported order early on.
Confirmed against a real ShipStation payload from your queue (not
guessed): a shipment's `shipment_number` and `external_shipment_id`
both hold the ticket number directly (e.g. both were `"AR-160269"`).
"Pull Tracking Numbers":
1. Fetches today's labels via `GET /v2/labels` - which gives
`tracking_number` and, importantly, `is_return_label` directly, so
telling a return label apart from an outgoing one needs no guessing.
2. Looks up each label's shipment (`GET /v2/shipments/{id}`) to read
the ticket number off `shipment_number` (checked first) or
`external_shipment_id`, falling back to scanning the whole payload
with `TICKET_NUMBER_REGEX` if neither is present.
3. Groups tracking numbers by ticket number and merges them onto the
matching JIRA row.
If a label's ticket number doesn't match any ticket you have locally
(e.g. JIRA hasn't been imported yet, or it's an account outlier),
you'll get a popup listing which ones didn't match, rather than the
data silently vanishing.
**Known gap, deferred on purpose:** there's a SKU for emailed labels
that doesn't go through this shipment/label flow at all - those
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.
## How company/SKU extraction works
Each company has its own pair of "deliverable" custom fields in JIRA
(e.g. Signify's `Deliverables` + `Hardware Needed`; Oak Street's own
versions), configurable via `JIRA_SIGNIFY_SKU_FIELDS` /
`JIRA_OAKSTREET_SKU_FIELDS`. Each deliverable value looks like `SH011:
Shipping - Return Label iPad - Physical in Box` - the app pulls out
just the `SH011` code as the SKU and keeps the description for the
summary. Company is resolved from the SKU prefix (`SH`/`OK`) via
`COMPANY_SKU_MAP`. Since these tickets' actual JIRA "Summary" field is
usually blank, the app falls back to the joined deliverable
descriptions for the Summary column when there's nothing else there.
## Moving storage to your MariaDB LXC later
@@ -91,34 +125,40 @@ main.py entry point
app/
config.py reads/writes .env, defines the settings schema
database.py SQLAlchemy engine/session (SQLite now, MariaDB later)
models.py Order table - company/ticket_number/skus + shared shape
companies.py SKU-prefix and store-ID -> company resolution
workers.py background thread(s) for API calls + DB save/load/stats
models.py Order table - one row per JIRA ticket
companies.py SKU-prefix -> company resolution
status_rules.py status-list parsing/matching, cancellation detection
tracking.py tracking numbers -> suggested JIRA status
queries.py "what tickets are still open" - used for JIRA re-checking
workers.py background thread(s), save/enrich logic, dashboard stats
services/
base.py OrderService interface - implement this for new sources
jira_service.py JIRA REST API -> NormalizedOrder (SKU field, company)
shipstation_service.py ShipStation V2 (/v2/shipments) -> NormalizedOrder
jira_service.py JIRA REST API -> NormalizedOrder (SKU fields, company, re-check)
shipstation_service.py ShipStation V2 labels+shipments -> tracking numbers by ticket
odoo_export.py CSV export for the (in-development) Odoo import template
__init__.py SERVICE_REGISTRY - register new sources here
ui/
main_window.py tabs, toolbar (independent import buttons), export
main_window.py tabs, toolbar, import/export/notification wiring
settings_dialog.py auto-built from config.SETTINGS_SCHEMA
widgets/
dashboard.py summary stat cards
orders_table.py sortable/filterable Qt table for orders
order_detail_dialog.py raw payload + tracking numbers + suggested status, per ticket
```
## Adding real Odoo API access next
1. Add settings to `app/config.py` -> `SETTINGS_SCHEMA` (Odoo group).
2. Create `app/services/odoo_service.py`. If it's pull-based (Odoo has
orders you need to see), subclass `OrderService` like the other two.
If it's push-based (you're sending orders to Odoo), it doesn't need
to subclass `OrderService` - a `push_orders(orders)` method is fine,
called from a new toolbar action the same way `_on_export_clicked`
calls `export_orders_to_csv`.
3. Register/wire it up the same way JIRA and ShipStation are.
2. Create `app/services/odoo_service.py`. If it's push-based (you're
sending orders to Odoo, most likely given the workflow), it doesn't
need to subclass `OrderService` - a `push_orders(orders)` method is
fine, called from a new toolbar action the same way
`_on_export_clicked` calls `export_orders_to_csv`.
3. Wire it up the same way the JIRA/ShipStation buttons are.
Since everything funnels through the same `Order` table, the table
view, dashboard, and settings UI don't need to change as sources are
added.
## Known open item
The **emailed-label SKU** (mentioned but not detailed yet) needs its
own handling eventually - tell me the SKU and what "done" looks like
for it when you're ready, and I'll fold it into the terminal-status /
tracking logic above rather than bolting on something separate.