Files

19 KiB

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) and it was cancelled today. A ticket cancelled on a prior day falls through to Done instead - this tab is meant to be reviewed same-day and then filed away, not accumulate forever. Any transition into Cancelled also triggers a popup right after the import that caused it. Cancelled rows show in red text (rather than a background tint) wherever they appear, including if one later ages out into Done - it's still useful to see at a glance that a Done-tab row got there via cancellation rather than fulfillment.
  • 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" (or "cancelled today") are named. Anything else 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 (and, for Cancelled, cancelled_at) 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 checkmark in the Past Cutoff column, and the Dashboard shows how many of today's tickets arrived past cutoff. This flag expires at midnight - it means "came in too late for today's window," not a permanent marker, so a ticket that arrived late yesterday shows no checkmark today (it's Carryover now, a different, already-tracked concept). While it's still showing, 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.

Table layout

Left to right: Company, Ticket #, Status, SKUs, Summary, Outgoing Tracking, Return Tracking, Uploaded, Past Cutoff, Created. Columns auto-size to their content on load and stay individually resizable by dragging (not force-stretched to equal widths) - the last column stretches to fill any remaining space. Uploaded shows a checkmark once a ticket has been successfully sent via the emergency "Send to ShipStation" action's API path specifically - the CSV export path intentionally doesn't set this, since exporting a file isn't the same as it actually being imported into ShipStation, and the app has no way to confirm that manual step happened.

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.

Packing: serial numbers and marking Done

Reflects your actual workflow: staff enter serial numbers (mostly via barcode scanner) and mark a ticket packed as devices go into the box - this is the beginning of what eventually becomes the Ship Sheet, built directly into the app instead of a separate spreadsheet.

Select a ticket and click Pack Ticket:

  • Suggested fields, not a fixed schema. Device types and their field sets vary a lot by company and by kit (confirmed against your real Ship Sheet - Oak Street tracks OptiPlex/Laptop/Phone, Signify tracks iPad/Spiro/Laptop/Phone x2), so hard-coding columns per device type would fight the "versatile" requirement. Instead, fields are suggested from keywords in the ticket's line items (DEVICE_FIELD_SUGGESTIONS) - a starting point staff can always extend with a custom field. A "Shipping - Return Label X" line item is deliberately excluded from matching (tested against this - it describes a label deliverable, not an actual second device).
  • Built for the scanner, not around it. Each field's Enter key (which a scanner sends automatically after scanning) jumps focus to the next field - scan straight through a device list with zero mouse clicks. After the last field, focus lands on the Packed checkbox rather than auto-submitting, so finishing still takes one deliberate action.
  • Reopening a partially-packed ticket preserves whatever was already scanned and still shows the current suggestions for what's left - tested explicitly.
  • This data is staff-entered, not sourced from JIRA, and a JIRA re-import never touches it - confirmed the update path doesn't reference serial_numbers/packed at all. It's exactly the data the eventual end-of-day JIRA push will need, whenever that gets built.

Assignee (who's working the ticket, from JIRA) and Shipping Method (the outbound label's service, from the ShipStation tracking pull - ups_ground -> "Ground", etc. via SHIPPING_METHOD_LABELS, confirmed against ShipStation's real UPS service codes) are also pulled in now, matching two more Ship Sheet columns.

One consequence worth knowing: since packing data doesn't come from JIRA, Reset Local Database now also clears it - the warning dialog says so explicitly. Before this update, a reset was always harmless (just a rebuildable cache); now it isn't, for this one kind of data.

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.

Resetting the local database

Reset Local Database in the toolbar clears every cached order (with a confirmation first). This is always safe - JIRA is the real source of truth, this is just a rebuildable cache - but it's worth knowing when you'd actually need it: if a ticket transitioned status under an older, buggy version of this app, its fulfilled_at/cancelled_at timestamp can get stuck wrong, since those are only recalculated at the moment of a transition - re-importing an already-transitioned ticket finds "no status change" and never touches that timestamp again. A reset clears the stale value entirely; the next Import from JIRA then stamps everything correctly from scratch. Follow a reset with Import from JIRA, and Pull Tracking Numbers if you rely on today's already-pulled tracking data.

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.

Emailed return labels (SH007 / OK012)

A different workflow entirely from the normal outbound-kit tickets: a customer already has product to return, and needs a return label emailed to them. Select a ticket in Active Orders and click Create Return Label:

  • Shows the ticket's Description (parsed from JIRA's rich-text format into plain text) - this is where staff note what boxes are needed, so it's visible without opening JIRA.
  • Lets you specify any number of packages, each with its own weight and dimensions - replacing the old fixed "1x1x1, 1oz dummy ticket" with real per-request control.
  • Calls ShipStation directly (POST /v2/labels with is_return_label: true). Confirmed against ShipStation's own return-label docs: ship_from is the customer and ship_to is your warehouse - reversed from every other label this app creates, and easy to get backwards, so this was tested explicitly.
  • charge_event is configurable per your risk preference: on_creation (pay immediately), on_carrier_acceptance (only pay if the customer actually ships it - needs the carrier to enable this on your account first, can take 3-4 weeks), or carrier_default.

This app does not email the label. Per your workflow, that happens from ShipStation itself (Returns tab -> Other Actions -> Send Return Label) so it goes out through your branded template - something this app couldn't replicate anyway, since ShipStation doesn't expose that step through its API as far as I could find. After a label is created, the ticket's Uploaded checkmark is set (same flag the emergency-send feature uses) so you can see at a glance which return-label tickets have already had their label created.

Both companies share one physical warehouse but each has its own UPS account (SIGNIFY_RETURN_CARRIER_ID / OAKSTREET_RETURN_CARRIER_ID)

  • the return address is the same, just filed under the right company name. Occasional shipments to company HQ instead of the shared warehouse aren't handled yet - flagged for later, per your note.

Still an assumption pending confirmation: the service code (SIGNIFY_RETURN_SERVICE_CODE / OAKSTREET_RETURN_SERVICE_CODE) defaults to ups_ground since you gave me the carrier/account but not a specific service level - change it in Settings if that's not right.

Known open item

You mentioned occasionally shipping return items to a company's headquarters instead of the shared warehouse - noted, not built yet since you said we could tackle it later. When you're ready, this would likely be a dropdown in the Return Label dialog (Warehouse vs. HQ) rather than a new settings group, since the workflow is otherwise identical.