235 lines
12 KiB
Markdown
235 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.
|
|
|
|
## Status tracking, matched to your actual workflow
|
|
|
|
- **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."
|
|
|
|
## 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":
|
|
|
|
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.
|
|
|
|
A couple of things worth confirming once you've seen real data:
|
|
- **Address 2**: two JIRA fields were both labeled "Address 1" when you
|
|
listed them (`customfield_10654` and `customfield_10655`) - this
|
|
assumes the second one is actually Address 2. Double-click a ticket
|
|
that has both filled in and check the Shipping Info section to confirm.
|
|
- **Quantity**: 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.
|
|
|
|
## 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.
|