Files
Order-Manager/README.md
T

249 lines
12 KiB
Markdown

# 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
1. `pip install -r requirements.txt`
2. Run it once: `python main.py` (this creates `.env` from `.env.example`)
3. 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_10570` for Signify;
`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`).
- **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 tickets rather than duplicating them.
## Three tabs: Active, Cancelled, Done
The tab a ticket shows up on is decided by an **allowlist**, not a list
of every "finished" status:
- **Active Orders**: status is in `ACTIVE_STATUSES` (default just
`Created`) - the only tickets that represent real work still to do.
- **Cancelled**: status is in `CANCELLED_STATUSES` (default `Cancelled`)
- its own tab so it doesn't clutter Active, but still reviewable
anytime. Any transition into this status also triggers a **popup**
right after the import that caused it (not one that was already
cancelled).
- **Done**: everything else, automatically. This is deliberate - rather
than maintaining a list of every status that means "finished"
(`Waiting For Return`, `Device Return Not Needed`, and whatever your
JIRA automation adds next, like `1st Contact Attempt`), only the
couple of statuses that mean "not done yet" are named. Anything that
isn't Created or Cancelled lands on Done with zero config changes
needed when your workflow adds another downstream status later.
This is a filter, not a physical move - it's the same `orders` table,
split by current status every time the view refreshes, so nothing to
reconcile if a status ever changes back.
Within Done, the two fulfilled statuses (`FULFILLED_STATUS_WITH_RETURN`
/ `FULFILLED_STATUS_WITHOUT_RETURN`, defaulting to `Waiting For Return`
/ `Device Return Not Needed`) still get **highlighted green** and drive
the "Fulfilled Today" dashboard count and `fulfilled_at` timestamp -
they're the two statuses this app actually knows the meaning of, versus
other done-statuses (like `1st Contact Attempt`) which land on Done but
aren't otherwise tracked, per "it is considered done...unnecessary to
be tracked for us."
**Carryover**: tickets that didn't get closed out same-day don't need
anything special - since `ACTIVE_STATUSES` defaults to just `Created`,
every JIRA import re-checks any ticket still in that status regardless
of when it was created, so it stays "alive" and its eventual status
change gets picked up whenever it happens. The Dashboard's Carryover
count is Active tickets created before today, plus today's tickets that
arrived past the cutoff (see below) - both cases are known not to get
done today, just at different points in the day.
## 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":
1. Bulk-fetches **all** of today's labels and all shipments from the
last `SHIPSTATION_LOOKBACK_DAYS` (a code constant in
`shipstation_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.
2. 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 at `MAX_CONCURRENT_REQUESTS` (default 5)
to stay well clear of any ShipStation rate limit.
3. Only after both full batches have landed does it sort/correlate
labels to shipments to ticket numbers, entirely in memory - reading
`tracking_number` and `is_return_label` straight off each label, and
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.
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.
## Emergency: Send to ShipStation
For the rare case a ticket needs to skip the normal daily batch. Select
a ticket in **Active Orders**, click **Send to ShipStation
(Emergency)** in the toolbar, review the address/SKU summary shown,
then pick one:
- **Send via API Now** - calls ShipStation's API directly
(`POST /v2/shipments` with `create_sales_order: true`) to create the
order without leaving the app. Needs `SHIPSTATION_SIGNIFY_STORE_ID` /
`SHIPSTATION_OAKSTREET_STORE_ID` set in Settings.
- **Export CSV Row...** - writes a single-ticket CSV matching your real
upload template exactly (verified column-for-column against a real
export), for manual upload if the API is down.
Both exist on purpose, per "in case the API goes down we still have an
option for CSV uploads."
**Please verify your first live send, either path.** ShipStation's own
docs say automation rules apply tags to orders "when they import based
on any criteria you set" - meaning your 90 box-packing rules should
fire automatically off the SKU/item data, same as a normal CSV import,
with no manual tagging needed from this app. That's the best read of
their documentation, but it's not something testable without your real
account - check that ShipStation packed and priced an emergency-sent
order the way a normal one would before relying on this in an actual
emergency.
One thing worth knowing: **Quantity** is always sent as `1` per line
item today, since JIRA doesn't currently give a per-deliverable
quantity. Say the word if that's ever not right.
## Keeping .env in sync as new settings get added
`.env` is gitignored on purpose (it holds real credentials and field
IDs), which means pulling new code never updates it automatically -
new settings would otherwise sit silently blank until someone noticed
a feature wasn't working (this is what caused an early version of the
emergency-send feature to have no address data - the field IDs existed
in `.env.example` but never made it into the real `.env`). Every
startup now calls `config.sync_env_with_example()`, which adds any key
present in `.env.example` but missing from `.env`, using the example's
value as the default - without touching anything you've already set.
Existing tickets in the local database still need a fresh **Import
from JIRA** to pick up newly-added fields, though, since extraction
only runs when a ticket is actually re-fetched.
## 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
1. Add settings to `app/config.py` -> `SETTINGS_SCHEMA` (Odoo group).
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.
## 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.