Order Manager
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
pip install -r requirements.txt- Run it once:
python main.py(this creates.envfrom.env.example) - Click Settings and fill in:
- JIRA: Site URL, Email, API Token, JQL (defaults to tickets
created today - adjust to match your project/label). The
deliverable field IDs are already defaulted from your export
(
customfield_10573,customfield_10570for Signify;customfield_12790,customfield_13021for Oak Street). - ShipStation: just the API Key.
- Companies: SKU prefix -> company mapping (defaults to
SH:Signify Health,OK:Oak Street Health). - Status Tracking: which statuses count as cancelled/fulfilled - defaults already match what you described (see below).
- JIRA: Site URL, Email, API Token, JQL (defaults to tickets
created today - adjust to match your project/label). The
deliverable field IDs are already defaulted from your export
(
- Click Import from JIRA during/after the intake window.
- Click Pull Tracking Numbers (ShipStation) at end-of-day - it merges tracking numbers onto the matching tickets, no separate rows.
- Check the Dashboard tab for Fulfilled / Cancelled / Carryover / Tracking Received counts.
- 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 tickets rather than duplicating them.
Status tracking, matched to your actual workflow
- Cancellation (
CANCELLED_STATUSES, defaultCancelled): 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 toWaiting 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, defaultCancelled,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."
The Done pile
Once a ticket reaches either fulfilled status, it moves off the
Active Orders tab and onto the Done tab automatically - the
active view stays focused on what's still being worked. This is a
filter, not a physical move: everything's still the same orders
table, split by current status each time the view refreshes, so if a
status ever changed back there'd be nothing to reconcile. Cancelled
tickets stay on Active Orders (still need eyes on them) - only the two
fulfilled statuses trigger the move. The Dashboard's "Active Orders"
count and company breakdown only reflect what's still active;
"Fulfilled Today" and "Arrived Past Cutoff Today" look at all of
today's activity regardless of which tab something ended up on.
Intake cutoff tracking
INTAKE_CUTOFF_TIME (default 15:30, i.e. 3:30 PM) flags any ticket
created after that time on its arrival day with a Past Cutoff "Yes"
in the table, and the Dashboard shows how many of today's tickets
arrived past cutoff. Since a cutoff ticket is known to spill into
tomorrow the moment it arrives, it also counts toward Carryover
immediately - not just once the date actually rolls over - unless it
still gets fulfilled the same day despite arriving late, in which case
it correctly drops out of both.
How tracking numbers get pulled and matched
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":
- Bulk-fetches all of today's labels and all shipments from the
last
SHIPSTATION_LOOKBACK_DAYS(a code constant inshipstation_service.py, default 7 days - wide enough to catch a shipment that sat "pending" for a day or two before its label was generated). This replaced an earlier version that looked up each shipment individually (one HTTP call per ticket) - the actual cause of the slowness, now down to a handful of bulk list calls. - Fetches those (and any additional pages either needs) concurrently
via a PyQt
QThreadPool- several requests in flight at once instead of one after another. Capped atMAX_CONCURRENT_REQUESTS(default 5) to stay well clear of any ShipStation rate limit. - Only after both full batches have landed does it sort/correlate
labels to shipments to ticket numbers, entirely in memory - reading
tracking_numberandis_return_labelstraight off each label, and the ticket number offshipment_number(checked first) orexternal_shipment_id, falling back to scanning the whole payload withTICKET_NUMBER_REGEXif neither is present.
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
In Settings (or directly in .env), change:
DB_URL=mysql+pymysql://user:password@<lxc-ip>:3306/order_manager
and pip install pymysql. No code changes needed. Note: this is a
local cache rebuilt from JIRA/ShipStation, so if you change DB_URL
or the schema changes in a future update, it's safe to just delete
orders.db and re-import rather than migrate it.
Project layout
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 - 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 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, 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
- Add settings to
app/config.py->SETTINGS_SCHEMA(Odoo group). - 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 subclassOrderService- apush_orders(orders)method is fine, called from a new toolbar action the same way_on_export_clickedcallsexport_orders_to_csv. - Wire it up the same way the JIRA/ShipStation buttons are.
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.