# 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." ## 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. 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 In Settings (or directly in `.env`), change: ``` DB_URL=mysql+pymysql://user:password@: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.