ShipStation tracking pull, status tracking, dashboard rework
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user