ShipStation tracking pull, status tracking, dashboard rework

This commit is contained in:
2026-08-26 11:28:42 -05:00
parent ffaaa0c8c8
commit 18be8588f6
16 changed files with 769 additions and 304 deletions
+19 -3
View File
@@ -21,12 +21,28 @@ JIRA_SIGNIFY_SKU_FIELDS=customfield_10573,customfield_10570
JIRA_OAKSTREET_SKU_FIELDS=customfield_12790,customfield_13021
# --- ShipStation (API V2) ---
# Used only to pull tracking numbers for JIRA tickets - order/shipment
# numbers there are the same as the JIRA ticket number, so no store
# mapping is needed here.
SHIPSTATION_API_KEY=
# Map each store to the company it belongs to (each has its own UPS account)
SHIPSTATION_STORE_MAP=se-221889:Signify Health,se-367672:Oak Street Health
# --- Companies ---
# SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies.
COMPANY_SKU_MAP=SH:Signify Health,OK:Oak Street Health
# Pattern used to recognize the AR-###### ticket number inside ShipStation payloads
# Fallback pattern for recognizing the AR-###### ticket number if it's ever
# not found in ShipStation's usual shipment_number/external_shipment_id fields.
TICKET_NUMBER_REGEX=\b[A-Z]{2,6}-\d{3,}\b
# --- Status Tracking ---
# Statuses where we stop re-checking a ticket on future imports (comma-separated).
# Defaults to Cancelled + both fulfilled statuses below, since none of those
# need to be re-checked once reached. Add more if your workflow gets other
# terminal statuses later.
JIRA_TERMINAL_STATUSES=Cancelled,Waiting For Return,Device Return Not Needed
# Statuses that get highlighted red in the table and trigger a popup when a
# ticket newly transitions into one (comma-separated).
CANCELLED_STATUSES=Cancelled
# The two statuses your team uses at end-of-day close-out, depending on
# whether ShipStation's automation also generated a return label.
FULFILLED_STATUS_WITH_RETURN=Waiting For Return
FULFILLED_STATUS_WITHOUT_RETURN=Device Return Not Needed
+109 -69
View File
@@ -1,10 +1,18 @@
# Order Manager
A PyQt6 desktop app that independently pulls orders from JIRA and
ShipStation, tracks them by company (Signify Health / Oak Street
Health) and ticket number, and gives you a dashboard of what's come in
today. Odoo integration is file-based for now (CSV export) since the
live Odoo side is still in development.
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
@@ -15,61 +23,87 @@ live Odoo side is still in development.
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) - update
these if your field IDs ever change.
- **ShipStation**: API Key. The store IDs are already defaulted
(`se-221889` Signify, `se-367672` Oak Street) - just add your key.
`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`) and the ticket number
pattern (defaults to `AR-######`-style).
4. Click **Import from JIRA** and/or **Import from ShipStation** - they
run independently, each in the background so the UI stays responsive.
5. Check the **Dashboard** tab for totals by company/source and how
many tickets are matched across both systems vs. only seen in one.
6. Use **All Orders** to filter by company/source or search by ticket
number/SKU, and **Export Visible Orders to Odoo CSV** to hand off
whatever's currently filtered.
`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 orders rather than duplicating them (matched on source +
source ID).
existing tickets rather than duplicating them.
## How company/ticket matching works
## Status tracking, matched to your actual workflow
- **JIRA orders**: each company has its own pair of "deliverable"
custom fields in JIRA (e.g. Signify's `Deliverables` + `Hardware
Needed`; Oak Street's own versions). A ticket can list several
deliverables (`customfield_10573`, etc. - 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 then
resolved from the SKU prefix (`SH`/`OK`) via `COMPANY_SKU_MAP`, same
as before. 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.
- **ShipStation orders**: company is derived from which store the
order lives in (`SHIPSTATION_STORE_MAP`), since each company has its
own store/UPS account.
- **Ticket number** (`AR-######`) is the JIRA issue key directly on
JIRA-sourced orders. On ShipStation-sourced orders, since V2's API
doesn't have a dedicated "orders" endpoint with a guaranteed
order-number field, the app scans the whole shipment payload for the
configured pattern (`TICKET_NUMBER_REGEX`). Once you see what a real
ShipStation payload for one of your shipments looks like, this can be
tightened to read one specific field for speed/reliability - just
point me at where it actually shows up.
- **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."
## A note on the ShipStation V2 API
## How tracking numbers get pulled and matched
ShipStation's V2 API doesn't expose a dedicated "list orders" endpoint
the way the older V1 API did - it's built around `/v2/shipments`. This
app pulls today's shipments from that endpoint and filters client-side
to your two configured store IDs. If your account's shipments don't
carry an `items`/SKU array the way we expect (this can depend on how
orders reach ShipStation), the company will still resolve correctly
(from `store_id`, which is reliable) even if the SKU column comes back
empty - worth checking against a real imported order early on.
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
@@ -91,34 +125,40 @@ 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 - company/ticket_number/skus + shared shape
companies.py SKU-prefix and store-ID -> company resolution
workers.py background thread(s) for API calls + DB save/load/stats
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 field, company)
shipstation_service.py ShipStation V2 (/v2/shipments) -> NormalizedOrder
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 (independent import buttons), export
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 pull-based (Odoo has
orders you need to see), subclass `OrderService` like the other two.
If it's push-based (you're sending orders to Odoo), 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. Register/wire it up the same way JIRA and ShipStation are.
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.
Since everything funnels through the same `Order` table, the table
view, dashboard, and settings UI don't need to change as sources are
added.
## 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.
-4
View File
@@ -53,7 +53,3 @@ def resolve_company_for_skus(skus: list[str], sku_map: dict[str, str]) -> str:
if company != UNKNOWN_COMPANY:
return company
return UNKNOWN_COMPANY
def resolve_company_by_store(store_id: str, store_map: dict[str, str]) -> str:
return store_map.get(str(store_id), UNKNOWN_COMPANY)
+28 -6
View File
@@ -44,11 +44,6 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
),
"SHIPSTATION_API_KEY": ("ShipStation API Key", "ShipStation", True),
"SHIPSTATION_STORE_MAP": (
"Store ID -> Company (e.g. 123456:Signify Health,789012:Oak Street Health)",
"ShipStation",
False,
),
"COMPANY_SKU_MAP": (
"SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)",
@@ -56,15 +51,42 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
False,
),
"TICKET_NUMBER_REGEX": (
"Ticket Number Pattern (regex, e.g. AR-######)",
"Ticket Number Pattern (regex, e.g. AR-######) - fallback only",
"Companies",
False,
),
"JIRA_TERMINAL_STATUSES": (
"Statuses where we stop re-checking a ticket (comma-separated)",
"Status Tracking",
False,
),
"CANCELLED_STATUSES": (
"Statuses treated as cancelled - highlighted + notified (comma-separated)",
"Status Tracking",
False,
),
"FULFILLED_STATUS_WITH_RETURN": (
"JIRA status when a return label was also generated",
"Status Tracking",
False,
),
"FULFILLED_STATUS_WITHOUT_RETURN": (
"JIRA status when only an outgoing label was generated",
"Status Tracking",
False,
),
}
DEFAULT_DB_URL = "sqlite:///orders.db"
DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health"
DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b"
DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return"
DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed"
DEFAULT_TERMINAL_STATUSES = (
f"Cancelled,{DEFAULT_FULFILLED_WITH_RETURN},{DEFAULT_FULFILLED_WITHOUT_RETURN}"
)
DEFAULT_CANCELLED_STATUSES = "Cancelled"
def ensure_env_file_exists() -> None:
+21 -16
View File
@@ -1,11 +1,11 @@
"""
Database models.
Order is intentionally source-agnostic: JIRA tickets, ShipStation
orders, and Odoo sale orders all get normalized into this shape on the
way in (see app/services/*). The `source` + `external_id` pair tells
you where a row came from, and `raw_data` keeps the original payload
in case a later feature needs a field we didn't think to pull out yet.
One row per JIRA ticket. ShipStation is purely a label-generation step
for these tickets (order numbers there match the JIRA ticket number 1:1,
and it's not used for anything else), so it doesn't get its own rows -
it enriches the matching row here with tracking_numbers instead. See
app/workers.py for how that merge happens.
"""
from __future__ import annotations
@@ -33,25 +33,30 @@ class Order(Base):
id = Column(Integer, primary_key=True)
# Where this order came from and its ID in that system.
# e.g. source="jira", external_id="AR-1234"; source="shipstation", external_id="se-9988"
# "source" is kept for schema stability / possible future sources,
# but in practice this is always "jira" today - see module docstring.
source = Column(String(32), nullable=False, index=True)
external_id = Column(String(128), nullable=False, index=True)
# The AR-###### style ticket number. Present on both JIRA and
# ShipStation orders once the ticket number carries through - this is
# the field that lets the dashboard correlate the same real-world
# order across both sources.
# The AR-###### style ticket number - same as external_id for JIRA
# rows, kept as its own column since it's also how ShipStation
# tracking numbers get matched back to this row.
ticket_number = Column(String(64), nullable=True, index=True)
# Signify Health / Oak Street Health / Unknown - derived from SKU
# prefix (JIRA) or store ID (ShipStation). See app/companies.py.
# Signify Health / Oak Street Health / Unknown - derived from the
# SKU prefix. See app/companies.py.
company = Column(String(100), nullable=False, default="Unknown", index=True)
# SKUs found on this order/ticket. A ticket can have several, but
# per business rule they never mix companies on one ticket.
skus = Column(JSON, nullable=True)
# Tracking numbers pulled from ShipStation and merged onto this
# ticket - e.g. [{"number": "782758401696", "carrier": "ups",
# "is_return": false}]. Populated by the "Pull Tracking Numbers"
# action, separately from the JIRA import.
tracking_numbers = Column(JSON, nullable=True)
summary = Column(String(500), nullable=False, default="")
status = Column(String(100), nullable=False, default="")
@@ -60,11 +65,11 @@ class Order(Base):
# When we pulled it into this app
imported_at = Column(DateTime, nullable=False, default=dt.datetime.utcnow)
# Downstream pipeline flags - useful once ShipStation/Odoo steps exist
# Downstream pipeline flags - useful once Odoo export is fully wired up
fulfilled = Column(Boolean, nullable=False, default=False)
# Full original payload from the source system, for anything not
# modeled explicitly above.
# Full original payload from JIRA, for anything not modeled explicitly
# above.
raw_data = Column(JSON, nullable=True)
def __repr__(self) -> str: # pragma: no cover - debugging aid
+41
View File
@@ -0,0 +1,41 @@
"""
Read-only queries against the local order cache.
Separate from app/workers.py (which owns saving orders and dashboard
stats) because this module is about a service asking "what do I
already know about" before it fetches - a different concern that other
sources (ShipStation, Odoo) will likely need their own version of too.
"""
from __future__ import annotations
from typing import List
from sqlalchemy import select
from app.database import get_session
from app.models import Order
from app.status_rules import status_in
def get_open_ticket_numbers(source: str, terminal_statuses: set[str]) -> List[str]:
"""
Ticket numbers for a source that haven't reached a terminal status
yet - i.e. still worth re-fetching to catch status changes (like a
cancellation) even if the ticket wasn't created today.
"""
session = get_session()
try:
rows = session.execute(
select(Order.ticket_number, Order.status).where(
Order.source == source,
Order.ticket_number.is_not(None),
)
).all()
finally:
session.close()
return [
ticket_number
for ticket_number, status in rows
if ticket_number and not status_in(status, terminal_statuses)
]
+1
View File
@@ -22,6 +22,7 @@ class NormalizedOrder(TypedDict):
ticket_number: Optional[str]
company: str
skus: List[str]
tracking_numbers: List[dict]
summary: str
status: str
source_created_at: Optional[dt.datetime]
+51 -3
View File
@@ -17,7 +17,9 @@ import requests
from app import config
from app.companies import parse_mapping, resolve_company_for_skus
from app.queries import get_open_ticket_numbers
from app.services.base import OrderService, NormalizedOrder
from app.status_rules import parse_status_list
SEARCH_PAGE_SIZE = 50
REQUEST_TIMEOUT_SECONDS = 30
@@ -58,6 +60,12 @@ class JiraService(OrderService):
settings["COMPANY_SKU_MAP"] or config.DEFAULT_COMPANY_SKU_MAP
)
# Statuses at which we stop re-checking a ticket for changes -
# see fetch_orders() for why we re-check at all.
self.terminal_statuses = parse_status_list(
settings["JIRA_TERMINAL_STATUSES"] or config.DEFAULT_TERMINAL_STATUSES
)
@staticmethod
def _split_field_ids(raw: str) -> List[str]:
return [f.strip() for f in (raw or "").split(",") if f.strip()]
@@ -72,12 +80,51 @@ class JiraService(OrderService):
"JIRA Site URL, Email, API Token, and JQL."
)
issues = self._search_all_issues()
# Your JQL (e.g. "created >= startOfDay()") only catches NEW
# tickets. On its own, that would miss a ticket that gets
# cancelled a day or two after it was created, since it no
# longer matches "created today". So on top of your JQL, we
# also re-check every previously-imported JIRA ticket that
# hasn't reached a terminal status yet (JIRA_TERMINAL_STATUSES,
# default just "Cancelled") - that's how a later cancellation
# gets picked up.
recheck_keys = get_open_ticket_numbers("jira", self.terminal_statuses)
effective_jql = self._build_effective_jql(self.jql, recheck_keys)
issues = self._search_all_issues(effective_jql)
return [self._to_normalized_order(issue) for issue in issues]
# -- internals -----------------------------------------------------
def _search_all_issues(self) -> List[dict]:
@staticmethod
def _build_effective_jql(base_jql: str, recheck_keys: List[str]) -> str:
"""
Combine the configured JQL with "OR key in (...)" for tickets we
want to re-check, being careful to keep any ORDER BY clause at
the very end (JQL requires it there).
Note: as the number of still-open tracked tickets grows, this
"key in (...)" list grows too. If that ever gets unwieldy, add
more statuses to JIRA_TERMINAL_STATUSES (e.g. "Done", once you
know your workflow's real terminal status names) so fulfilled
tickets stop being re-checked and drop out of this list.
"""
if not recheck_keys:
return base_jql
order_by_match = re.search(r"\bORDER BY\b.*$", base_jql, re.IGNORECASE)
if order_by_match:
where_part = base_jql[: order_by_match.start()].strip()
order_by_clause = " " + order_by_match.group(0)
else:
where_part = base_jql.strip()
order_by_clause = ""
keys_clause = "key in (" + ", ".join(recheck_keys) + ")"
combined_where = f"({where_part}) OR {keys_clause}" if where_part else keys_clause
return combined_where + order_by_clause
def _search_all_issues(self, jql: str) -> List[dict]:
url = f"{self.base_url}/rest/api/3/search"
auth = (self.email, self.api_token)
headers = {"Accept": "application/json"}
@@ -92,7 +139,7 @@ class JiraService(OrderService):
while True:
params = {
"jql": self.jql,
"jql": jql,
"startAt": start_at,
"maxResults": SEARCH_PAGE_SIZE,
"fields": fields,
@@ -221,6 +268,7 @@ class JiraService(OrderService):
ticket_number=ticket_number,
company=company,
skus=skus,
tracking_numbers=[],
summary=summary,
status=(fields.get("status") or {}).get("name", ""),
source_created_at=created_at,
+137 -86
View File
@@ -1,32 +1,40 @@
"""
ShipStation order source (API V2).
ShipStation tracking-number puller (API V2).
Important context (as of ShipStation's current V2 docs): V2 does not
have a dedicated "list orders" endpoint the way the older V1 API did.
The closest equivalent is GET /v2/shipments, where each shipment
carries a store_id and (depending on how the order arrived) an items
array with SKUs. That's what this service pulls from.
ShipStation is used for exactly one thing here: generating shipping
labels for JIRA tickets. It's not an independent order source, so this
doesn't produce its own order rows - it produces tracking numbers keyed
by ticket number, which app.workers merges directly onto the matching
JIRA-sourced Order row. See app/tracking.py for the "what JIRA status
should this become" logic.
Because we're fetching independently of JIRA (no shared internal ID),
we recover the AR-###### ticket number by scanning the whole shipment
payload for the configured pattern, rather than assuming one fixed
field - so this keeps working even before we've confirmed exactly
which field your team puts it in (order notes, a custom field, a tag,
etc). Once that's confirmed, this can be tightened to read that field
directly for speed and reliability.
How this maps to the real API (confirmed against a live payload, not
guessed):
- GET /v2/labels gives tracking_number + is_return_label directly -
exactly what's needed to tell "Waiting For Return" apart from
"Device Return Not Needed".
- A label only carries a shipment_id, not the ticket number, so for
each label we fetch its shipment via GET /v2/shipments/{id}. A real
shipment payload showed the ticket number in BOTH shipment_number
and external_shipment_id (e.g. both were "AR-160269") - we check
both, then fall back to scanning the whole payload with
TICKET_NUMBER_REGEX as a last resort.
- Shipments don't reliably carry store_id (a still-"pending" shipment
has none), which is fine - company comes from the JIRA side, this
never needs to know it.
"""
from __future__ import annotations
import datetime as dt
import json
import re
from typing import List, Optional
from typing import Dict, List, Optional
import requests
from app import config
from app.companies import parse_mapping, resolve_company_by_store
from app.services.base import OrderService, NormalizedOrder
from app.tracking import suggest_jira_status
API_BASE = "https://api.shipstation.com/v2"
PAGE_SIZE = 100
@@ -43,38 +51,73 @@ class ShipStationService(OrderService):
def __init__(self) -> None:
settings = config.load_settings()
self.api_key = settings["SHIPSTATION_API_KEY"]
self.store_map = parse_mapping(settings["SHIPSTATION_STORE_MAP"])
self.ticket_pattern = re.compile(
settings["TICKET_NUMBER_REGEX"] or config.DEFAULT_TICKET_NUMBER_REGEX
)
self._headers = {"API-Key": self.api_key, "Accept": "application/json"}
self.unmatched_labels: List[dict] = []
def is_configured(self) -> bool:
return bool(self.api_key and self.store_map)
return bool(self.api_key)
def fetch_orders(self) -> List[NormalizedOrder]:
if not self.is_configured():
raise ShipStationServiceError(
"ShipStation is not fully configured yet. Open Settings and fill in "
"the API Key and the Store ID -> Company mapping."
"ShipStation is not configured yet. Open Settings and fill in the API Key."
)
shipments = self._fetch_todays_shipments()
labels = self._fetch_todays_labels()
# Only keep shipments belonging to one of our two known stores.
known_store_ids = set(self.store_map.keys())
relevant = [s for s in shipments if str(s.get("store_id")) in known_store_ids]
# Only labels that actually produced a usable tracking number matter.
usable_labels = [
label
for label in labels
if not label.get("voided") and label.get("tracking_number")
]
return [self._to_normalized_order(s) for s in relevant]
# One shipment lookup per unique shipment_id referenced, not per
# label (an outgoing + return label pair share the same shipment).
shipment_ids = {
label["shipment_id"] for label in usable_labels if label.get("shipment_id")
}
shipments_by_id = {sid: self._fetch_shipment(sid) for sid in shipment_ids}
tracking_by_ticket: Dict[str, List[dict]] = {}
raw_by_ticket: Dict[str, dict] = {}
self.unmatched_labels = [] # labels we couldn't tie to a ticket number
for label in usable_labels:
shipment = shipments_by_id.get(label.get("shipment_id"))
ticket_number = self._extract_ticket_number(shipment) if shipment else None
if not ticket_number:
self.unmatched_labels.append(label)
continue
tracking_by_ticket.setdefault(ticket_number, []).append(
{
"number": label.get("tracking_number"),
"carrier": label.get("carrier_code"),
"is_return": bool(label.get("is_return_label")),
}
)
raw_by_ticket.setdefault(ticket_number, {"labels": [], "shipment": shipment})
raw_by_ticket[ticket_number]["labels"].append(label)
return [
self._to_normalized_order(
ticket_number, tracking_by_ticket[ticket_number], raw_by_ticket[ticket_number]
)
for ticket_number in tracking_by_ticket
]
# -- internals -----------------------------------------------------
def _fetch_todays_shipments(self) -> List[dict]:
headers = {"API-Key": self.api_key, "Accept": "application/json"}
def _fetch_todays_labels(self) -> List[dict]:
today_start = dt.datetime.combine(dt.date.today(), dt.time.min)
today_end = today_start + dt.timedelta(days=1)
all_shipments: List[dict] = []
all_labels: List[dict] = []
page = 1
while True:
@@ -86,43 +129,64 @@ class ShipStationService(OrderService):
"sort_by": "created_at",
"sort_dir": "desc",
}
try:
response = requests.get(
f"{API_BASE}/shipments",
params=params,
headers=headers,
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise ShipStationServiceError(f"Could not reach ShipStation: {exc}") from exc
if response.status_code == 401:
raise ShipStationServiceError(
"ShipStation rejected the API key (401). Check it in Settings."
)
if not response.ok:
raise ShipStationServiceError(
f"ShipStation returned an error ({response.status_code}): {response.text[:300]}"
)
try:
data = response.json()
except ValueError as exc:
raise ShipStationServiceError(
"ShipStation returned a response that wasn't valid JSON."
) from exc
batch = data.get("shipments", [])
all_shipments.extend(batch)
data = self._get("/labels", params)
batch = data.get("labels", [])
all_labels.extend(batch)
total_pages = data.get("pages", 1)
if page >= total_pages or not batch:
break
page += 1
return all_shipments
return all_labels
def _fetch_shipment(self, shipment_id: str) -> Optional[dict]:
try:
return self._get(f"/shipments/{shipment_id}")
except ShipStationServiceError:
# Don't let one bad lookup fail the whole import - this label's
# tracking number just won't get matched to a ticket this run.
return None
def _get(self, path: str, params: Optional[dict] = None) -> dict:
try:
response = requests.get(
f"{API_BASE}{path}",
params=params,
headers=self._headers,
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise ShipStationServiceError(f"Could not reach ShipStation: {exc}") from exc
if response.status_code == 401:
raise ShipStationServiceError(
"ShipStation rejected the API key (401). Check it in Settings."
)
if not response.ok:
raise ShipStationServiceError(
f"ShipStation returned an error ({response.status_code}) for {path}: "
f"{response.text[:300]}"
)
try:
return response.json()
except ValueError as exc:
raise ShipStationServiceError(
f"ShipStation returned a response that wasn't valid JSON for {path}."
) from exc
def _extract_ticket_number(self, shipment: dict) -> Optional[str]:
# Confirmed against a real payload: both of these can carry the
# ticket number directly. Check the more purpose-built field first.
for field in ("shipment_number", "external_shipment_id"):
value = shipment.get(field)
if value and self.ticket_pattern.fullmatch(str(value).strip()):
return str(value).strip()
# Fall back to scanning the whole payload in case it shows up
# somewhere else (a tag, a note, etc.) on a differently-shaped
# shipment.
try:
blob = json.dumps(shipment)
except (TypeError, ValueError):
@@ -131,37 +195,24 @@ class ShipStationService(OrderService):
return match.group(0) if match else None
@staticmethod
def _extract_skus(shipment: dict) -> List[str]:
items = shipment.get("items") or []
skus = [item.get("sku") for item in items if isinstance(item, dict) and item.get("sku")]
return skus
def _to_normalized_order(self, shipment: dict) -> NormalizedOrder:
created_raw = shipment.get("created_at")
created_at = None
if created_raw:
try:
created_at = dt.datetime.strptime(created_raw[:19], "%Y-%m-%dT%H:%M:%S")
except ValueError:
created_at = None
store_id = str(shipment.get("store_id", ""))
company = resolve_company_by_store(store_id, self.store_map)
skus = self._extract_skus(shipment)
ticket_number = self._extract_ticket_number(shipment)
external_id = shipment.get("shipment_id", "")
summary_bits = [b for b in [ticket_number, ", ".join(skus)] if b]
summary = " - ".join(summary_bits) or external_id
def _to_normalized_order(
ticket_number: str, tracking_numbers: List[dict], raw: dict
) -> NormalizedOrder:
suggested_status = suggest_jira_status(tracking_numbers) or "Tracking Pulled"
numbers_display = ", ".join(
f"{t['number']} ({'return' if t['is_return'] else 'outgoing'})"
for t in tracking_numbers
)
return NormalizedOrder(
source="shipstation",
external_id=external_id,
external_id=ticket_number,
ticket_number=ticket_number,
company=company,
skus=skus,
summary=summary,
status=shipment.get("shipment_status", ""),
source_created_at=created_at,
raw_data=shipment,
company="", # not used - the JIRA row this merges onto already has one
skus=[],
tracking_numbers=tracking_numbers,
summary=numbers_display,
status=suggested_status,
source_created_at=None,
raw_data=raw,
)
+35
View File
@@ -0,0 +1,35 @@
"""
Status matching helpers.
Kept separate from companies.py because this is about order lifecycle
state (cancelled, terminal-for-recheck-purposes, and later probably
"fulfilled"/"shipped"/etc.) rather than company identity - a different
axis of classification that will grow independently.
Matching is case-insensitive since JIRA and ShipStation don't
necessarily agree on casing (e.g. "Cancelled" vs "cancelled").
"""
from __future__ import annotations
def parse_status_list(raw: str) -> set[str]:
return {s.strip().lower() for s in (raw or "").split(",") if s.strip()}
def status_in(status: str | None, status_set: set[str]) -> bool:
return (status or "").strip().lower() in status_set
def find_new_cancellations(status_changes: list[dict], cancelled_statuses: set[str]) -> list[dict]:
"""
From a batch of status changes, return only the ones that just BECAME
cancelled this import (old status wasn't already cancelled, new one
is) - so we notify once, at the moment it happens, not every import
thereafter.
"""
return [
change
for change in status_changes
if status_in(change["new_status"], cancelled_statuses)
and not status_in(change["old_status"], cancelled_statuses)
]
+31
View File
@@ -0,0 +1,31 @@
"""
Turns a ticket's tracking numbers into the JIRA status your team would
set it to at end-of-day close-out - "Waiting For Return" if a return
label was also generated by ShipStation's automation, "Device Return
Not Needed" if it's outgoing-only. Saves having to eyeball each one.
"""
from __future__ import annotations
from typing import List, Optional
from app import config
def suggest_jira_status(tracking_numbers: List[dict]) -> Optional[str]:
if not tracking_numbers:
return None
has_outgoing = any(not t.get("is_return") for t in tracking_numbers)
has_return = any(t.get("is_return") for t in tracking_numbers)
if not has_outgoing:
# Only a return label with no outgoing - unusual, don't guess.
return None
if has_return:
return config.get(
"FULFILLED_STATUS_WITH_RETURN", config.DEFAULT_FULFILLED_WITH_RETURN
)
return config.get(
"FULFILLED_STATUS_WITHOUT_RETURN", config.DEFAULT_FULFILLED_WITHOUT_RETURN
)
+65 -17
View File
@@ -5,10 +5,10 @@ Deliberately thin: it wires the toolbar, tabs, and dialogs together,
and delegates real work to app.services (fetching), app.workers
(saving/loading/stats), and app.services.odoo_export (file export).
JIRA and ShipStation are pulled independently (separate buttons,
separate workers) per how the business actually operates - they are
correlated afterwards for the dashboard via ticket_number, not forced
into one pipeline.
JIRA import creates/updates ticket rows. "Pull Tracking Numbers"
(ShipStation) doesn't create anything of its own - it merges tracking
numbers onto the matching JIRA row by ticket number, since ShipStation
here is purely a label-generation step for JIRA tickets.
"""
from __future__ import annotations
@@ -25,8 +25,10 @@ from PyQt6.QtWidgets import (
QFileDialog,
)
from app import config
from app.services import SERVICE_REGISTRY
from app.services.odoo_export import export_orders_to_csv
from app.status_rules import parse_status_list, find_new_cancellations
from app.ui.settings_dialog import SettingsDialog
from app.ui.widgets.orders_table import OrdersTableView
from app.ui.widgets.dashboard import DashboardWidget
@@ -71,7 +73,7 @@ class MainWindow(QMainWindow):
toolbar.addAction(jira_action)
self._import_actions["jira"] = jira_action
shipstation_action = QAction("Import from ShipStation", self)
shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self)
shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
toolbar.addAction(shipstation_action)
self._import_actions["shipstation"] = shipstation_action
@@ -121,25 +123,71 @@ class MainWindow(QMainWindow):
action = self._import_actions[service_name]
action.setEnabled(False)
self.status_label.setText(f"Importing orders from {service_name.title()}...")
if service_name == "jira":
self.status_label.setText("Importing orders from JIRA...")
else:
self.status_label.setText("Pulling tracking numbers from ShipStation...")
worker = FetchOrdersWorker(service)
worker.finished_ok.connect(
lambda new_count, updated_count: self._on_import_finished(
service_name, new_count, updated_count
)
lambda result: self._on_import_finished(service_name, result)
)
worker.failed.connect(lambda message: self._on_import_failed(service_name, message))
self._workers[service_name] = worker # keep a reference so it isn't garbage collected
worker.start()
def _on_import_finished(self, service_name: str, new_count: int, updated_count: int) -> None:
def _on_import_finished(self, service_name: str, result: dict) -> None:
self._import_actions[service_name].setEnabled(True)
self.status_label.setText(
f"{service_name.title()} import complete: {new_count} new, {updated_count} updated."
)
self._refresh_everything()
if service_name == "jira":
self.status_label.setText(
f"JIRA import complete: {result['new_count']} new, "
f"{result['updated_count']} updated."
)
self._notify_new_cancellations(result["status_changes"])
else:
self.status_label.setText(
f"Tracking pull complete: {result['enriched_count']} ticket(s) updated with "
f"tracking numbers."
)
self._notify_unmatched_tracking(result["unmatched_tracking_tickets"])
def _notify_unmatched_tracking(self, unmatched_tickets: list) -> None:
if not unmatched_tickets:
return
lines = unmatched_tickets[:15]
if len(unmatched_tickets) > 15:
lines.append(f"...and {len(unmatched_tickets) - 15} more")
QMessageBox.information(
self,
"Some tracking numbers didn't match a known ticket",
"ShipStation had tracking numbers for these ticket numbers, but no matching "
"JIRA ticket was found locally (maybe it hasn't been imported yet):\n\n"
+ "\n".join(lines),
)
def _notify_new_cancellations(self, status_changes: list) -> None:
cancelled_statuses = parse_status_list(
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
)
newly_cancelled = find_new_cancellations(status_changes, cancelled_statuses)
if not newly_cancelled:
return
lines = [
f"{c['ticket_number']}: {c['old_status']} -> {c['new_status']}"
for c in newly_cancelled[:15]
]
if len(newly_cancelled) > 15:
lines.append(f"...and {len(newly_cancelled) - 15} more")
QMessageBox.warning(
self,
"Ticket(s) cancelled",
f"{len(newly_cancelled)} ticket(s) were just cancelled:\n\n" + "\n".join(lines),
)
def _on_import_failed(self, service_name: str, message: str) -> None:
self._import_actions[service_name].setEnabled(True)
self.status_label.setText(f"{service_name.title()} import failed.")
@@ -181,10 +229,10 @@ class MainWindow(QMainWindow):
# - Odoo push API (once ready): add app/services/odoo_service.py with a
# push_orders(orders) method, wire a new toolbar action similarly to
# _on_import_clicked, and it can eventually replace/augment odoo_export.py.
# - Order detail view: connect a table double-click to a dialog showing
# selected_order().raw_data (full JIRA/ShipStation payload) for debugging
# ticket-number/SKU extraction as real data comes in.
# - Pipeline actions ("mark fulfilled", "create ShipStation label"): add
# - Emailed-label SKU: these tickets won't get tracking numbers pulled via
# the normal ShipStation flow - once its handling is defined, it likely
# needs its own terminal status and its own "mark as sent" action here.
# - Pipeline actions ("mark fulfilled manually", "void a label"): add
# toolbar actions gated on table selection.
# - If the window grows too much, split each tab's toolbar into its own
# QToolBar shown only while that tab is active.
+22 -23
View File
@@ -1,8 +1,8 @@
"""
Dashboard tab: at-a-glance counts of what's come in.
Kept intentionally simple for now (stat cards, no charts) per the
"just a dashboard for now" scope. The stats themselves come from
Kept intentionally simple (stat cards, no charts) per the "just a
dashboard for now" scope. The stats themselves come from
app.workers.get_dashboard_stats(), so adding a new stat later is a
matter of adding a key there and a card here - this widget doesn't
know anything about how orders are fetched or stored.
@@ -59,31 +59,32 @@ class DashboardWidget(QWidget):
heading.setFont(heading_font)
outer.addWidget(heading)
# Top row: overall + per-source totals
# Top row: overall pipeline health
top_row = QHBoxLayout()
self.total_card = StatCard("Total Orders")
self.jira_card = StatCard("From JIRA")
self.shipstation_card = StatCard("From ShipStation")
for card in (self.total_card, self.jira_card, self.shipstation_card):
self.fulfilled_card = StatCard("Fulfilled")
self.fulfilled_card.setStyleSheet("QLabel { color: #1a7a1a; }")
self.cancelled_card = StatCard("Cancelled")
self.cancelled_card.setStyleSheet("QLabel { color: #b00020; }")
self.carryover_card = StatCard("Carryover (still open from an earlier day)")
self.carryover_card.setStyleSheet("QLabel { color: #b06a00; }")
for card in (self.total_card, self.fulfilled_card, self.cancelled_card, self.carryover_card):
top_row.addWidget(card)
outer.addLayout(top_row)
# Second row: per-company totals (grid so adding a 3rd company later just works)
# Second row: tracking pull progress, since that's the 5PM task
outer.addWidget(self._section_label("Tracking Numbers"))
tracking_row = QHBoxLayout()
self.tracking_received_card = StatCard("Tickets With Tracking Pulled")
tracking_row.addWidget(self.tracking_received_card)
outer.addLayout(tracking_row)
# Third row: per-company totals (grid so adding a 3rd company later just works)
outer.addWidget(self._section_label("By Company"))
self.company_grid = QGridLayout()
self.company_cards: dict[str, StatCard] = {}
outer.addLayout(self.company_grid)
# Third row: cross-source matching, since JIRA and ShipStation are pulled independently
outer.addWidget(self._section_label("JIRA <-> ShipStation Matching (by ticket #)"))
match_row = QHBoxLayout()
self.matched_card = StatCard("Matched in Both")
self.jira_only_card = StatCard("JIRA Only (not yet in ShipStation)")
self.shipstation_only_card = StatCard("ShipStation Only (no matching ticket)")
for card in (self.matched_card, self.jira_only_card, self.shipstation_only_card):
match_row.addWidget(card)
outer.addLayout(match_row)
outer.addStretch()
@staticmethod
@@ -96,12 +97,10 @@ class DashboardWidget(QWidget):
def update_stats(self, stats: dict) -> None:
self.total_card.set_value(stats.get("total", 0))
self.jira_card.set_value(stats.get("by_source", {}).get("jira", 0))
self.shipstation_card.set_value(stats.get("by_source", {}).get("shipstation", 0))
self.matched_card.set_value(stats.get("matched_count", 0))
self.jira_only_card.set_value(stats.get("jira_only_count", 0))
self.shipstation_only_card.set_value(stats.get("shipstation_only_count", 0))
self.fulfilled_card.set_value(stats.get("fulfilled_count", 0))
self.cancelled_card.set_value(stats.get("cancelled_count", 0))
self.carryover_card.set_value(stats.get("carryover_count", 0))
self.tracking_received_card.set_value(stats.get("tracking_received_count", 0))
by_company = stats.get("by_company", {})
# Rebuild company cards if the set of companies changed (e.g. a 3rd company added)
+23 -6
View File
@@ -1,11 +1,10 @@
"""
Order detail dialog.
Mainly a debugging aid: shows exactly what the source system (JIRA or
ShipStation) sent back for this order, so field-shape mismatches - like
the "Hardware Needed" single-select vs. "Deliverables" multi-select
issue - are easy to spot by just double-clicking a row instead of
reading logs or re-running a script.
Mainly a debugging aid: shows exactly what JIRA sent back for this
ticket (raw payload), plus the tracking numbers ShipStation supplied
and the JIRA status they suggest - handy for the end-of-day close-out
without having to piece it together by hand.
"""
from __future__ import annotations
@@ -14,6 +13,14 @@ import json
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QLabel, QPushButton
from app.models import Order
from app.tracking import suggest_jira_status
def _format_tracking_line(t: dict) -> str:
kind = "RETURN" if t.get("is_return") else "outgoing"
carrier = t.get("carrier")
carrier_suffix = f" ({carrier})" if carrier else ""
return f" {t.get('number')} - {kind}{carrier_suffix}"
class OrderDetailDialog(QDialog):
@@ -24,13 +31,23 @@ class OrderDetailDialog(QDialog):
layout = QVBoxLayout(self)
tracking = order.tracking_numbers or []
tracking_lines = [_format_tracking_line(t) for t in tracking]
suggested = suggest_jira_status(tracking)
summary_lines = [
f"Source: {order.source}",
f"Ticket #: {order.ticket_number or '(none found)'}",
f"Company: {order.company}",
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
f"Status: {order.status}",
"",
"Tracking numbers:",
]
summary_lines.extend(tracking_lines or [" (none pulled yet)"])
if suggested:
summary_lines.append("")
summary_lines.append(f"Suggested JIRA status: {suggested}")
summary_label = QLabel("\n".join(summary_lines))
layout.addWidget(summary_label)
+69 -43
View File
@@ -2,15 +2,18 @@
Table view + model for displaying orders.
Using QAbstractTableModel instead of QTableWidget on purpose: it scales
to thousands of rows, and features like sorting and the company/source
filters below are straightforward extensions of this model rather than
rewrites.
to thousands of rows, and features like sorting and the company filter
below are straightforward extensions of this model rather than
rewrites. There's no "Source" filter - every row is a JIRA ticket;
ShipStation only enriches rows with tracking numbers, it doesn't add
its own.
"""
from __future__ import annotations
from typing import List, Any, Optional
from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import (
QTableView,
QAbstractItemView,
@@ -23,31 +26,60 @@ from PyQt6.QtWidgets import (
QLabel,
)
from app import config
from app.models import Order
from app.status_rules import parse_status_list, status_in
CANCELLED_ROW_COLOR = QColor(255, 210, 210)
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
COLUMNS = [
("company", "Company"),
("ticket_number", "Ticket #"),
("skus_display", "SKUs"),
("source", "Source"),
("external_id", "Source ID"),
("summary", "Summary"),
("status", "Status"),
("tracking_display", "Tracking #"),
("source_created_at", "Created"),
("imported_at", "Imported"),
("fulfilled", "Fulfilled"),
]
ALL_COMPANIES = "All Companies"
ALL_SOURCES = "All Sources"
def _format_tracking(tracking_numbers) -> str:
if not tracking_numbers:
return ""
return ", ".join(
f"{t.get('number')} ({'return' if t.get('is_return') else 'out'})"
for t in tracking_numbers
)
class OrdersTableModel(QAbstractTableModel):
def __init__(self, orders: List[Order] | None = None, parent=None):
super().__init__(parent)
self._orders: List[Order] = orders or []
self._cancelled_statuses: set[str] = set()
self._fulfilled_statuses: set[str] = set()
def set_orders(self, orders: List[Order]) -> None:
# Re-read status lists each refresh, in case Settings changed.
self._cancelled_statuses = parse_status_list(
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
)
self._fulfilled_statuses = parse_status_list(
",".join(
[
config.get(
"FULFILLED_STATUS_WITH_RETURN", config.DEFAULT_FULFILLED_WITH_RETURN
),
config.get(
"FULFILLED_STATUS_WITHOUT_RETURN",
config.DEFAULT_FULFILLED_WITHOUT_RETURN,
),
]
)
)
self.beginResetModel()
self._orders = orders
self.endResetModel()
@@ -66,42 +98,47 @@ class OrdersTableModel(QAbstractTableModel):
return str(section + 1)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
if not index.isValid() or role != Qt.ItemDataRole.DisplayRole:
if not index.isValid():
return None
order = self._orders[index.row()]
if role == Qt.ItemDataRole.BackgroundRole:
if status_in(order.status, self._cancelled_statuses):
return CANCELLED_ROW_COLOR
if status_in(order.status, self._fulfilled_statuses):
return FULFILLED_ROW_COLOR
return None
if role != Qt.ItemDataRole.DisplayRole:
return None
field_name, _ = COLUMNS[index.column()]
if field_name == "skus_display":
return ", ".join(order.skus or [])
if field_name == "tracking_display":
return _format_tracking(order.tracking_numbers)
value = getattr(order, field_name)
if value is None:
return ""
if field_name == "fulfilled":
return "Yes" if value else "No"
return str(value)
return "" if value is None else str(value)
def order_at(self, row: int) -> Order:
return self._orders[row]
class OrdersFilterProxyModel(QSortFilterProxyModel):
"""Filters by company, source, and a free-text search across ticket/SKU/summary."""
"""Filters by company and a free-text search across ticket/SKU/summary/tracking."""
def __init__(self, parent=None):
super().__init__(parent)
self.company_filter: str = ALL_COMPANIES
self.source_filter: str = ALL_SOURCES
self.search_text: str = ""
def set_company_filter(self, company: str) -> None:
self.company_filter = company
self.invalidateFilter()
def set_source_filter(self, source: str) -> None:
self.source_filter = source
self.invalidateFilter()
def set_search_text(self, text: str) -> None:
self.search_text = text.strip().lower()
self.invalidateFilter()
@@ -112,16 +149,15 @@ class OrdersFilterProxyModel(QSortFilterProxyModel):
if self.company_filter != ALL_COMPANIES and order.company != self.company_filter:
return False
if self.source_filter != ALL_SOURCES and order.source != self.source_filter:
return False
if self.search_text:
haystack = " ".join(
[
order.ticket_number or "",
order.external_id or "",
order.summary or "",
order.status or "",
" ".join(order.skus or []),
_format_tracking(order.tracking_numbers),
]
).lower()
if self.search_text not in haystack:
@@ -152,15 +188,9 @@ class OrdersTableView(QWidget):
self.company_combo.currentTextChanged.connect(self._proxy_model.set_company_filter)
filter_bar.addWidget(self.company_combo)
filter_bar.addWidget(QLabel("Source:"))
self.source_combo = QComboBox()
self.source_combo.addItem(ALL_SOURCES)
self.source_combo.currentTextChanged.connect(self._proxy_model.set_source_filter)
filter_bar.addWidget(self.source_combo)
filter_bar.addWidget(QLabel("Search:"))
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Ticket #, SKU, or summary...")
self.search_box.setPlaceholderText("Ticket #, SKU, status, or tracking #...")
self.search_box.textChanged.connect(self._proxy_model.set_search_text)
filter_bar.addWidget(self.search_box, stretch=1)
@@ -187,19 +217,15 @@ class OrdersTableView(QWidget):
self._refresh_filter_options(orders)
def _refresh_filter_options(self, orders: List[Order]) -> None:
for combo, attr, all_label in (
(self.company_combo, "company", ALL_COMPANIES),
(self.source_combo, "source", ALL_SOURCES),
):
current = combo.currentText()
values = sorted({getattr(o, attr) for o in orders if getattr(o, attr)})
combo.blockSignals(True)
combo.clear()
combo.addItem(all_label)
combo.addItems(values)
restore_index = combo.findText(current)
combo.setCurrentIndex(restore_index if restore_index >= 0 else 0)
combo.blockSignals(False)
current = self.company_combo.currentText()
values = sorted({o.company for o in orders if o.company})
self.company_combo.blockSignals(True)
self.company_combo.clear()
self.company_combo.addItem(ALL_COMPANIES)
self.company_combo.addItems(values)
restore_index = self.company_combo.findText(current)
self.company_combo.setCurrentIndex(restore_index if restore_index >= 0 else 0)
self.company_combo.blockSignals(False)
def selected_order(self) -> Optional[Order]:
indexes = self.table.selectionModel().selectedRows()
+117 -28
View File
@@ -2,26 +2,43 @@
Background work that shouldn't run on the GUI thread.
FetchOrdersWorker runs a service's fetch_orders() call off the main
thread and reports back via signals. As we add more long-running
operations (creating ShipStation labels, pushing to Odoo, etc.) they
should follow this same pattern rather than blocking the UI.
thread and reports back via a signal. As we add more long-running
operations (pushing to Odoo, etc.) they should follow this same
pattern rather than blocking the UI.
"""
from __future__ import annotations
from typing import List
import datetime as dt
from typing import List, TypedDict
from PyQt6.QtCore import QThread, pyqtSignal
from sqlalchemy import select
from app import config
from app.database import get_session
from app.models import Order
from app.services.base import OrderService, NormalizedOrder
from app.status_rules import parse_status_list, status_in
class StatusChange(TypedDict):
ticket_number: str
old_status: str
new_status: str
class SaveResult(TypedDict):
new_count: int
updated_count: int
status_changes: List[StatusChange]
enriched_count: int # tickets that got tracking numbers merged in
unmatched_tracking_tickets: List[str] # tracking data with no matching local ticket
class FetchOrdersWorker(QThread):
"""Fetches orders from a given service and saves new/updated ones to the DB."""
"""Fetches orders from a given service and saves/merges the results."""
finished_ok = pyqtSignal(int, int) # (new_count, updated_count)
finished_ok = pyqtSignal(object) # emits a SaveResult
failed = pyqtSignal(str)
def __init__(self, service: OrderService, parent=None):
@@ -36,21 +53,49 @@ class FetchOrdersWorker(QThread):
return
try:
new_count, updated_count = save_orders(orders)
result = save_orders(orders)
except Exception as exc: # noqa: BLE001
self.failed.emit(f"Fetched {len(orders)} orders but failed to save them: {exc}")
return
self.finished_ok.emit(new_count, updated_count)
self.finished_ok.emit(result)
def save_orders(orders: List[NormalizedOrder]) -> tuple[int, int]:
"""Insert new orders / update existing ones (matched by source + external_id)."""
def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
"""
JIRA-sourced orders are inserted/updated as usual. ShipStation-sourced
"orders" are actually just tracking-number bundles keyed by ticket
number - rather than creating a second row, they get merged onto the
existing JIRA row for that ticket. If no such row exists locally yet,
the ticket number is reported back as unmatched instead of silently
dropped.
"""
session = get_session()
new_count = 0
updated_count = 0
enriched_count = 0
status_changes: List[StatusChange] = []
unmatched_tracking_tickets: List[str] = []
try:
for order in orders:
if order["source"] == "shipstation":
jira_row = session.execute(
select(Order).where(
Order.source == "jira",
Order.ticket_number == order["ticket_number"],
)
).scalar_one_or_none()
if jira_row is None:
unmatched_tracking_tickets.append(order["ticket_number"])
continue
jira_row.tracking_numbers = order.get("tracking_numbers", [])
enriched_count += 1
continue
# source == "jira"
existing = session.execute(
select(Order).where(
Order.source == order["source"],
@@ -74,11 +119,22 @@ def save_orders(orders: List[NormalizedOrder]) -> tuple[int, int]:
)
new_count += 1
else:
old_status = existing.status
new_status = order["status"]
if old_status != new_status:
status_changes.append(
StatusChange(
ticket_number=existing.ticket_number or existing.external_id,
old_status=old_status,
new_status=new_status,
)
)
existing.ticket_number = order.get("ticket_number")
existing.company = order.get("company", "Unknown")
existing.skus = order.get("skus", [])
existing.summary = order["summary"]
existing.status = order["status"]
existing.status = new_status
existing.source_created_at = order["source_created_at"]
existing.raw_data = order["raw_data"]
updated_count += 1
@@ -87,7 +143,13 @@ def save_orders(orders: List[NormalizedOrder]) -> tuple[int, int]:
finally:
session.close()
return new_count, updated_count
return SaveResult(
new_count=new_count,
updated_count=updated_count,
status_changes=status_changes,
enriched_count=enriched_count,
unmatched_tracking_tickets=unmatched_tracking_tickets,
)
def load_all_orders() -> List[Order]:
@@ -102,32 +164,59 @@ def load_all_orders() -> List[Order]:
def get_dashboard_stats() -> dict:
"""
Counts for the dashboard tab: totals by company and by source, plus
how many orders are only in one source so far (imported from JIRA
but not yet seen in ShipStation, or vice versa) - useful as an
at-a-glance "what's still missing" signal since the two sources are
pulled independently.
Counts for the dashboard: totals by company, how many are cancelled,
fulfilled, still-open-from-a-previous-day (carryover), and how many
have had tracking numbers pulled yet.
"""
orders = load_all_orders()
cancelled_statuses = parse_status_list(
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
)
fulfilled_statuses = parse_status_list(
",".join(
[
config.get("FULFILLED_STATUS_WITH_RETURN", config.DEFAULT_FULFILLED_WITH_RETURN),
config.get(
"FULFILLED_STATUS_WITHOUT_RETURN", config.DEFAULT_FULFILLED_WITHOUT_RETURN
),
]
)
)
terminal_statuses = parse_status_list(
config.get("JIRA_TERMINAL_STATUSES", config.DEFAULT_TERMINAL_STATUSES)
)
today = dt.date.today()
by_company: dict[str, int] = {}
by_source: dict[str, int] = {}
tickets_by_source: dict[str, set] = {}
cancelled_count = 0
fulfilled_count = 0
carryover_count = 0
tracking_received_count = 0
for order in orders:
by_company[order.company] = by_company.get(order.company, 0) + 1
by_source[order.source] = by_source.get(order.source, 0) + 1
if order.ticket_number:
tickets_by_source.setdefault(order.source, set()).add(order.ticket_number)
jira_tickets = tickets_by_source.get("jira", set())
shipstation_tickets = tickets_by_source.get("shipstation", set())
if status_in(order.status, cancelled_statuses):
cancelled_count += 1
if status_in(order.status, fulfilled_statuses):
fulfilled_count += 1
if order.tracking_numbers:
tracking_received_count += 1
still_open = not status_in(order.status, terminal_statuses)
created_before_today = bool(
order.source_created_at and order.source_created_at.date() < today
)
if still_open and created_before_today:
carryover_count += 1
return {
"total": len(orders),
"by_company": by_company,
"by_source": by_source,
"jira_only_count": len(jira_tickets - shipstation_tickets),
"shipstation_only_count": len(shipstation_tickets - jira_tickets),
"matched_count": len(jira_tickets & shipstation_tickets),
"cancelled_count": cancelled_count,
"fulfilled_count": fulfilled_count,
"carryover_count": carryover_count,
"tracking_received_count": tracking_received_count,
}