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 JIRA_OAKSTREET_SKU_FIELDS=customfield_12790,customfield_13021
# --- ShipStation (API V2) --- # --- 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= 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 --- # --- Companies ---
# SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies. # SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies.
COMPANY_SKU_MAP=SH:Signify Health,OK:Oak Street Health 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 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 # Order Manager
A PyQt6 desktop app that independently pulls orders from JIRA and A PyQt6 desktop app for the daily order cycle: pull today's tickets
ShipStation, tracks them by company (Signify Health / Oak Street from JIRA (8AM-3:30PM intake), then at end-of-day pull ShipStation
Health) and ticket number, and gives you a dashboard of what's come in tracking numbers and see, at a glance, what's fulfilled, what's
today. Odoo integration is file-based for now (CSV export) since the cancelled, and what's still open from a previous day (carryover).
live Odoo side is still in development. 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 ## Setup
@@ -15,61 +23,87 @@ live Odoo side is still in development.
created today - adjust to match your project/label). The created today - adjust to match your project/label). The
deliverable field IDs are already defaulted from your export deliverable field IDs are already defaulted from your export
(`customfield_10573,customfield_10570` for Signify; (`customfield_10573,customfield_10570` for Signify;
`customfield_12790,customfield_13021` for Oak Street) - update `customfield_12790,customfield_13021` for Oak Street).
these if your field IDs ever change. - **ShipStation**: just the API Key.
- **ShipStation**: API Key. The store IDs are already defaulted
(`se-221889` Signify, `se-367672` Oak Street) - just add your key.
- **Companies**: SKU prefix -> company mapping (defaults to - **Companies**: SKU prefix -> company mapping (defaults to
`SH:Signify Health,OK:Oak Street Health`) and the ticket number `SH:Signify Health,OK:Oak Street Health`).
pattern (defaults to `AR-######`-style). - **Status Tracking**: which statuses count as cancelled/fulfilled -
4. Click **Import from JIRA** and/or **Import from ShipStation** - they defaults already match what you described (see below).
run independently, each in the background so the UI stays responsive. 4. Click **Import from JIRA** during/after the intake window.
5. Check the **Dashboard** tab for totals by company/source and how 5. Click **Pull Tracking Numbers (ShipStation)** at end-of-day - it
many tickets are matched across both systems vs. only seen in one. merges tracking numbers onto the matching tickets, no separate rows.
6. Use **All Orders** to filter by company/source or search by ticket 6. Check the **Dashboard** tab for Fulfilled / Cancelled / Carryover /
number/SKU, and **Export Visible Orders to Odoo CSV** to hand off Tracking Received counts.
whatever's currently filtered. 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 Orders are cached locally in SQLite (`orders.db`). Re-importing updates
existing orders rather than duplicating them (matched on source + existing tickets rather than duplicating them.
source ID).
## How company/ticket matching works ## Status tracking, matched to your actual workflow
- **JIRA orders**: each company has its own pair of "deliverable" - **Cancellation** (`CANCELLED_STATUSES`, default `Cancelled`): you
custom fields in JIRA (e.g. Signify's `Deliverables` + `Hardware cancel a ticket yourself when the SKU is wrong or the address doesn't
Needed`; Oak Street's own versions). A ticket can list several validate. Any order in this status is **highlighted red** in the
deliverables (`customfield_10573`, etc. - configurable via table, and a **popup fires** right after an import if a ticket just
`JIRA_SIGNIFY_SKU_FIELDS` / `JIRA_OAKSTREET_SKU_FIELDS`). Each transitioned into it (not one that was already cancelled).
deliverable value looks like `SH011: Shipping - Return Label iPad - - **Fulfilled** (`FULFILLED_STATUS_WITH_RETURN` /
Physical in Box` - the app pulls out just the `SH011` code as the `FULFILLED_STATUS_WITHOUT_RETURN`, defaulting to `Waiting For Return`
SKU and keeps the description for the summary. Company is then / `Device Return Not Needed`): these are **highlighted green**. The
resolved from the SKU prefix (`SH`/`OK`) via `COMPANY_SKU_MAP`, same "Pull Tracking Numbers" action figures out which one applies per
as before. Since these tickets' actual JIRA "Summary" field is ticket automatically, from whether ShipStation's automation also
usually blank, the app falls back to the joined deliverable generated a return label (see below) - so you know which status to
descriptions for the Summary column when there's nothing else there. set without checking each one by hand.
- **ShipStation orders**: company is derived from which store the - **Carryover**: tickets you didn't close out same-day (rare, per what
order lives in (`SHIPSTATION_STORE_MAP`), since each company has its you described) don't need anything special - they're just tickets
own store/UPS account. that haven't hit a terminal status yet (`JIRA_TERMINAL_STATUSES`,
- **Ticket number** (`AR-######`) is the JIRA issue key directly on default `Cancelled,Waiting For Return,Device Return Not Needed`).
JIRA-sourced orders. On ShipStation-sourced orders, since V2's API Every JIRA import re-checks any such ticket regardless of when it was
doesn't have a dedicated "orders" endpoint with a guaranteed created, so a carryover ticket stays "alive" and gets its status
order-number field, the app scans the whole shipment payload for the change picked up whenever it happens, however many days later. The
configured pattern (`TICKET_NUMBER_REGEX`). Once you see what a real Dashboard's Carryover count is just these tickets filtered to "created
ShipStation payload for one of your shipments looks like, this can be before today."
tightened to read one specific field for speed/reliability - just
point me at where it actually shows up.
## 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 Confirmed against a real ShipStation payload from your queue (not
the way the older V1 API did - it's built around `/v2/shipments`. This guessed): a shipment's `shipment_number` and `external_shipment_id`
app pulls today's shipments from that endpoint and filters client-side both hold the ticket number directly (e.g. both were `"AR-160269"`).
to your two configured store IDs. If your account's shipments don't "Pull Tracking Numbers":
carry an `items`/SKU array the way we expect (this can depend on how
orders reach ShipStation), the company will still resolve correctly 1. Fetches today's labels via `GET /v2/labels` - which gives
(from `store_id`, which is reliable) even if the SKU column comes back `tracking_number` and, importantly, `is_return_label` directly, so
empty - worth checking against a real imported order early on. 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 ## Moving storage to your MariaDB LXC later
@@ -91,34 +125,40 @@ main.py entry point
app/ app/
config.py reads/writes .env, defines the settings schema config.py reads/writes .env, defines the settings schema
database.py SQLAlchemy engine/session (SQLite now, MariaDB later) database.py SQLAlchemy engine/session (SQLite now, MariaDB later)
models.py Order table - company/ticket_number/skus + shared shape models.py Order table - one row per JIRA ticket
companies.py SKU-prefix and store-ID -> company resolution companies.py SKU-prefix -> company resolution
workers.py background thread(s) for API calls + DB save/load/stats 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/ services/
base.py OrderService interface - implement this for new sources base.py OrderService interface - implement this for new sources
jira_service.py JIRA REST API -> NormalizedOrder (SKU field, company) jira_service.py JIRA REST API -> NormalizedOrder (SKU fields, company, re-check)
shipstation_service.py ShipStation V2 (/v2/shipments) -> NormalizedOrder shipstation_service.py ShipStation V2 labels+shipments -> tracking numbers by ticket
odoo_export.py CSV export for the (in-development) Odoo import template odoo_export.py CSV export for the (in-development) Odoo import template
__init__.py SERVICE_REGISTRY - register new sources here __init__.py SERVICE_REGISTRY - register new sources here
ui/ 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 settings_dialog.py auto-built from config.SETTINGS_SCHEMA
widgets/ widgets/
dashboard.py summary stat cards dashboard.py summary stat cards
orders_table.py sortable/filterable Qt table for orders 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 ## Adding real Odoo API access next
1. Add settings to `app/config.py` -> `SETTINGS_SCHEMA` (Odoo group). 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 2. Create `app/services/odoo_service.py`. If it's push-based (you're
orders you need to see), subclass `OrderService` like the other two. sending orders to Odoo, most likely given the workflow), it doesn't
If it's push-based (you're sending orders to Odoo), it doesn't need need to subclass `OrderService` - a `push_orders(orders)` method is
to subclass `OrderService` - a `push_orders(orders)` method is fine, fine, called from a new toolbar action the same way
called from a new toolbar action the same way `_on_export_clicked` `_on_export_clicked` calls `export_orders_to_csv`.
calls `export_orders_to_csv`. 3. Wire it up the same way the JIRA/ShipStation buttons are.
3. Register/wire it up the same way JIRA and ShipStation are.
Since everything funnels through the same `Order` table, the table ## Known open item
view, dashboard, and settings UI don't need to change as sources are
added. 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: if company != UNKNOWN_COMPANY:
return company return company
return UNKNOWN_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_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": ( "COMPANY_SKU_MAP": (
"SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)", "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, False,
), ),
"TICKET_NUMBER_REGEX": ( "TICKET_NUMBER_REGEX": (
"Ticket Number Pattern (regex, e.g. AR-######)", "Ticket Number Pattern (regex, e.g. AR-######) - fallback only",
"Companies", "Companies",
False, 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_DB_URL = "sqlite:///orders.db"
DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health" 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_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: def ensure_env_file_exists() -> None:
+21 -16
View File
@@ -1,11 +1,11 @@
""" """
Database models. Database models.
Order is intentionally source-agnostic: JIRA tickets, ShipStation One row per JIRA ticket. ShipStation is purely a label-generation step
orders, and Odoo sale orders all get normalized into this shape on the for these tickets (order numbers there match the JIRA ticket number 1:1,
way in (see app/services/*). The `source` + `external_id` pair tells and it's not used for anything else), so it doesn't get its own rows -
you where a row came from, and `raw_data` keeps the original payload it enriches the matching row here with tracking_numbers instead. See
in case a later feature needs a field we didn't think to pull out yet. app/workers.py for how that merge happens.
""" """
from __future__ import annotations from __future__ import annotations
@@ -33,25 +33,30 @@ class Order(Base):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
# Where this order came from and its ID in that system. # "source" is kept for schema stability / possible future sources,
# e.g. source="jira", external_id="AR-1234"; source="shipstation", external_id="se-9988" # but in practice this is always "jira" today - see module docstring.
source = Column(String(32), nullable=False, index=True) source = Column(String(32), nullable=False, index=True)
external_id = Column(String(128), nullable=False, index=True) external_id = Column(String(128), nullable=False, index=True)
# The AR-###### style ticket number. Present on both JIRA and # The AR-###### style ticket number - same as external_id for JIRA
# ShipStation orders once the ticket number carries through - this is # rows, kept as its own column since it's also how ShipStation
# the field that lets the dashboard correlate the same real-world # tracking numbers get matched back to this row.
# order across both sources.
ticket_number = Column(String(64), nullable=True, index=True) ticket_number = Column(String(64), nullable=True, index=True)
# Signify Health / Oak Street Health / Unknown - derived from SKU # Signify Health / Oak Street Health / Unknown - derived from the
# prefix (JIRA) or store ID (ShipStation). See app/companies.py. # SKU prefix. See app/companies.py.
company = Column(String(100), nullable=False, default="Unknown", index=True) company = Column(String(100), nullable=False, default="Unknown", index=True)
# SKUs found on this order/ticket. A ticket can have several, but # SKUs found on this order/ticket. A ticket can have several, but
# per business rule they never mix companies on one ticket. # per business rule they never mix companies on one ticket.
skus = Column(JSON, nullable=True) 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="") summary = Column(String(500), nullable=False, default="")
status = Column(String(100), nullable=False, default="") status = Column(String(100), nullable=False, default="")
@@ -60,11 +65,11 @@ class Order(Base):
# When we pulled it into this app # When we pulled it into this app
imported_at = Column(DateTime, nullable=False, default=dt.datetime.utcnow) 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) fulfilled = Column(Boolean, nullable=False, default=False)
# Full original payload from the source system, for anything not # Full original payload from JIRA, for anything not modeled explicitly
# modeled explicitly above. # above.
raw_data = Column(JSON, nullable=True) raw_data = Column(JSON, nullable=True)
def __repr__(self) -> str: # pragma: no cover - debugging aid 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] ticket_number: Optional[str]
company: str company: str
skus: List[str] skus: List[str]
tracking_numbers: List[dict]
summary: str summary: str
status: str status: str
source_created_at: Optional[dt.datetime] source_created_at: Optional[dt.datetime]
+51 -3
View File
@@ -17,7 +17,9 @@ import requests
from app import config from app import config
from app.companies import parse_mapping, resolve_company_for_skus 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.services.base import OrderService, NormalizedOrder
from app.status_rules import parse_status_list
SEARCH_PAGE_SIZE = 50 SEARCH_PAGE_SIZE = 50
REQUEST_TIMEOUT_SECONDS = 30 REQUEST_TIMEOUT_SECONDS = 30
@@ -58,6 +60,12 @@ class JiraService(OrderService):
settings["COMPANY_SKU_MAP"] or config.DEFAULT_COMPANY_SKU_MAP 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 @staticmethod
def _split_field_ids(raw: str) -> List[str]: def _split_field_ids(raw: str) -> List[str]:
return [f.strip() for f in (raw or "").split(",") if f.strip()] 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." "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] return [self._to_normalized_order(issue) for issue in issues]
# -- internals ----------------------------------------------------- # -- 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" url = f"{self.base_url}/rest/api/3/search"
auth = (self.email, self.api_token) auth = (self.email, self.api_token)
headers = {"Accept": "application/json"} headers = {"Accept": "application/json"}
@@ -92,7 +139,7 @@ class JiraService(OrderService):
while True: while True:
params = { params = {
"jql": self.jql, "jql": jql,
"startAt": start_at, "startAt": start_at,
"maxResults": SEARCH_PAGE_SIZE, "maxResults": SEARCH_PAGE_SIZE,
"fields": fields, "fields": fields,
@@ -221,6 +268,7 @@ class JiraService(OrderService):
ticket_number=ticket_number, ticket_number=ticket_number,
company=company, company=company,
skus=skus, skus=skus,
tracking_numbers=[],
summary=summary, summary=summary,
status=(fields.get("status") or {}).get("name", ""), status=(fields.get("status") or {}).get("name", ""),
source_created_at=created_at, 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 ShipStation is used for exactly one thing here: generating shipping
have a dedicated "list orders" endpoint the way the older V1 API did. labels for JIRA tickets. It's not an independent order source, so this
The closest equivalent is GET /v2/shipments, where each shipment doesn't produce its own order rows - it produces tracking numbers keyed
carries a store_id and (depending on how the order arrived) an items by ticket number, which app.workers merges directly onto the matching
array with SKUs. That's what this service pulls from. 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), How this maps to the real API (confirmed against a live payload, not
we recover the AR-###### ticket number by scanning the whole shipment guessed):
payload for the configured pattern, rather than assuming one fixed - GET /v2/labels gives tracking_number + is_return_label directly -
field - so this keeps working even before we've confirmed exactly exactly what's needed to tell "Waiting For Return" apart from
which field your team puts it in (order notes, a custom field, a tag, "Device Return Not Needed".
etc). Once that's confirmed, this can be tightened to read that field - A label only carries a shipment_id, not the ticket number, so for
directly for speed and reliability. 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 from __future__ import annotations
import datetime as dt import datetime as dt
import json import json
import re import re
from typing import List, Optional from typing import Dict, List, Optional
import requests import requests
from app import config from app import config
from app.companies import parse_mapping, resolve_company_by_store
from app.services.base import OrderService, NormalizedOrder from app.services.base import OrderService, NormalizedOrder
from app.tracking import suggest_jira_status
API_BASE = "https://api.shipstation.com/v2" API_BASE = "https://api.shipstation.com/v2"
PAGE_SIZE = 100 PAGE_SIZE = 100
@@ -43,38 +51,73 @@ class ShipStationService(OrderService):
def __init__(self) -> None: def __init__(self) -> None:
settings = config.load_settings() settings = config.load_settings()
self.api_key = settings["SHIPSTATION_API_KEY"] self.api_key = settings["SHIPSTATION_API_KEY"]
self.store_map = parse_mapping(settings["SHIPSTATION_STORE_MAP"])
self.ticket_pattern = re.compile( self.ticket_pattern = re.compile(
settings["TICKET_NUMBER_REGEX"] or config.DEFAULT_TICKET_NUMBER_REGEX 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: 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]: def fetch_orders(self) -> List[NormalizedOrder]:
if not self.is_configured(): if not self.is_configured():
raise ShipStationServiceError( raise ShipStationServiceError(
"ShipStation is not fully configured yet. Open Settings and fill in " "ShipStation is not configured yet. Open Settings and fill in the API Key."
"the API Key and the Store ID -> Company mapping."
) )
shipments = self._fetch_todays_shipments() labels = self._fetch_todays_labels()
# Only keep shipments belonging to one of our two known stores. # Only labels that actually produced a usable tracking number matter.
known_store_ids = set(self.store_map.keys()) usable_labels = [
relevant = [s for s in shipments if str(s.get("store_id")) in known_store_ids] 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 ----------------------------------------------------- # -- internals -----------------------------------------------------
def _fetch_todays_shipments(self) -> List[dict]: def _fetch_todays_labels(self) -> List[dict]:
headers = {"API-Key": self.api_key, "Accept": "application/json"}
today_start = dt.datetime.combine(dt.date.today(), dt.time.min) today_start = dt.datetime.combine(dt.date.today(), dt.time.min)
today_end = today_start + dt.timedelta(days=1) today_end = today_start + dt.timedelta(days=1)
all_shipments: List[dict] = [] all_labels: List[dict] = []
page = 1 page = 1
while True: while True:
@@ -86,43 +129,64 @@ class ShipStationService(OrderService):
"sort_by": "created_at", "sort_by": "created_at",
"sort_dir": "desc", "sort_dir": "desc",
} }
try: data = self._get("/labels", params)
response = requests.get( batch = data.get("labels", [])
f"{API_BASE}/shipments", all_labels.extend(batch)
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)
total_pages = data.get("pages", 1) total_pages = data.get("pages", 1)
if page >= total_pages or not batch: if page >= total_pages or not batch:
break break
page += 1 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]: 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: try:
blob = json.dumps(shipment) blob = json.dumps(shipment)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -131,37 +195,24 @@ class ShipStationService(OrderService):
return match.group(0) if match else None return match.group(0) if match else None
@staticmethod @staticmethod
def _extract_skus(shipment: dict) -> List[str]: def _to_normalized_order(
items = shipment.get("items") or [] ticket_number: str, tracking_numbers: List[dict], raw: dict
skus = [item.get("sku") for item in items if isinstance(item, dict) and item.get("sku")] ) -> NormalizedOrder:
return skus suggested_status = suggest_jira_status(tracking_numbers) or "Tracking Pulled"
numbers_display = ", ".join(
def _to_normalized_order(self, shipment: dict) -> NormalizedOrder: f"{t['number']} ({'return' if t['is_return'] else 'outgoing'})"
created_raw = shipment.get("created_at") for t in tracking_numbers
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
return NormalizedOrder( return NormalizedOrder(
source="shipstation", source="shipstation",
external_id=external_id, external_id=ticket_number,
ticket_number=ticket_number, ticket_number=ticket_number,
company=company, company="", # not used - the JIRA row this merges onto already has one
skus=skus, skus=[],
summary=summary, tracking_numbers=tracking_numbers,
status=shipment.get("shipment_status", ""), summary=numbers_display,
source_created_at=created_at, status=suggested_status,
raw_data=shipment, 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 and delegates real work to app.services (fetching), app.workers
(saving/loading/stats), and app.services.odoo_export (file export). (saving/loading/stats), and app.services.odoo_export (file export).
JIRA and ShipStation are pulled independently (separate buttons, JIRA import creates/updates ticket rows. "Pull Tracking Numbers"
separate workers) per how the business actually operates - they are (ShipStation) doesn't create anything of its own - it merges tracking
correlated afterwards for the dashboard via ticket_number, not forced numbers onto the matching JIRA row by ticket number, since ShipStation
into one pipeline. here is purely a label-generation step for JIRA tickets.
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,8 +25,10 @@ from PyQt6.QtWidgets import (
QFileDialog, QFileDialog,
) )
from app import config
from app.services import SERVICE_REGISTRY from app.services import SERVICE_REGISTRY
from app.services.odoo_export import export_orders_to_csv 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.settings_dialog import SettingsDialog
from app.ui.widgets.orders_table import OrdersTableView from app.ui.widgets.orders_table import OrdersTableView
from app.ui.widgets.dashboard import DashboardWidget from app.ui.widgets.dashboard import DashboardWidget
@@ -71,7 +73,7 @@ class MainWindow(QMainWindow):
toolbar.addAction(jira_action) toolbar.addAction(jira_action)
self._import_actions["jira"] = 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")) shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
toolbar.addAction(shipstation_action) toolbar.addAction(shipstation_action)
self._import_actions["shipstation"] = shipstation_action self._import_actions["shipstation"] = shipstation_action
@@ -121,25 +123,71 @@ class MainWindow(QMainWindow):
action = self._import_actions[service_name] action = self._import_actions[service_name]
action.setEnabled(False) 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 = FetchOrdersWorker(service)
worker.finished_ok.connect( worker.finished_ok.connect(
lambda new_count, updated_count: self._on_import_finished( lambda result: self._on_import_finished(service_name, result)
service_name, new_count, updated_count
)
) )
worker.failed.connect(lambda message: self._on_import_failed(service_name, message)) 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 self._workers[service_name] = worker # keep a reference so it isn't garbage collected
worker.start() 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._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() 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: def _on_import_failed(self, service_name: str, message: str) -> None:
self._import_actions[service_name].setEnabled(True) self._import_actions[service_name].setEnabled(True)
self.status_label.setText(f"{service_name.title()} import failed.") 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 # - Odoo push API (once ready): add app/services/odoo_service.py with a
# push_orders(orders) method, wire a new toolbar action similarly to # push_orders(orders) method, wire a new toolbar action similarly to
# _on_import_clicked, and it can eventually replace/augment odoo_export.py. # _on_import_clicked, and it can eventually replace/augment odoo_export.py.
# - Order detail view: connect a table double-click to a dialog showing # - Emailed-label SKU: these tickets won't get tracking numbers pulled via
# selected_order().raw_data (full JIRA/ShipStation payload) for debugging # the normal ShipStation flow - once its handling is defined, it likely
# ticket-number/SKU extraction as real data comes in. # needs its own terminal status and its own "mark as sent" action here.
# - Pipeline actions ("mark fulfilled", "create ShipStation label"): add # - Pipeline actions ("mark fulfilled manually", "void a label"): add
# toolbar actions gated on table selection. # toolbar actions gated on table selection.
# - If the window grows too much, split each tab's toolbar into its own # - If the window grows too much, split each tab's toolbar into its own
# QToolBar shown only while that tab is active. # 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. Dashboard tab: at-a-glance counts of what's come in.
Kept intentionally simple for now (stat cards, no charts) per the Kept intentionally simple (stat cards, no charts) per the "just a
"just a dashboard for now" scope. The stats themselves come from dashboard for now" scope. The stats themselves come from
app.workers.get_dashboard_stats(), so adding a new stat later is a 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 matter of adding a key there and a card here - this widget doesn't
know anything about how orders are fetched or stored. know anything about how orders are fetched or stored.
@@ -59,31 +59,32 @@ class DashboardWidget(QWidget):
heading.setFont(heading_font) heading.setFont(heading_font)
outer.addWidget(heading) outer.addWidget(heading)
# Top row: overall + per-source totals # Top row: overall pipeline health
top_row = QHBoxLayout() top_row = QHBoxLayout()
self.total_card = StatCard("Total Orders") self.total_card = StatCard("Total Orders")
self.jira_card = StatCard("From JIRA") self.fulfilled_card = StatCard("Fulfilled")
self.shipstation_card = StatCard("From ShipStation") self.fulfilled_card.setStyleSheet("QLabel { color: #1a7a1a; }")
for card in (self.total_card, self.jira_card, self.shipstation_card): 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) top_row.addWidget(card)
outer.addLayout(top_row) 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")) outer.addWidget(self._section_label("By Company"))
self.company_grid = QGridLayout() self.company_grid = QGridLayout()
self.company_cards: dict[str, StatCard] = {} self.company_cards: dict[str, StatCard] = {}
outer.addLayout(self.company_grid) 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() outer.addStretch()
@staticmethod @staticmethod
@@ -96,12 +97,10 @@ class DashboardWidget(QWidget):
def update_stats(self, stats: dict) -> None: def update_stats(self, stats: dict) -> None:
self.total_card.set_value(stats.get("total", 0)) self.total_card.set_value(stats.get("total", 0))
self.jira_card.set_value(stats.get("by_source", {}).get("jira", 0)) self.fulfilled_card.set_value(stats.get("fulfilled_count", 0))
self.shipstation_card.set_value(stats.get("by_source", {}).get("shipstation", 0)) self.cancelled_card.set_value(stats.get("cancelled_count", 0))
self.carryover_card.set_value(stats.get("carryover_count", 0))
self.matched_card.set_value(stats.get("matched_count", 0)) self.tracking_received_card.set_value(stats.get("tracking_received_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))
by_company = stats.get("by_company", {}) by_company = stats.get("by_company", {})
# Rebuild company cards if the set of companies changed (e.g. a 3rd company added) # 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. Order detail dialog.
Mainly a debugging aid: shows exactly what the source system (JIRA or Mainly a debugging aid: shows exactly what JIRA sent back for this
ShipStation) sent back for this order, so field-shape mismatches - like ticket (raw payload), plus the tracking numbers ShipStation supplied
the "Hardware Needed" single-select vs. "Deliverables" multi-select and the JIRA status they suggest - handy for the end-of-day close-out
issue - are easy to spot by just double-clicking a row instead of without having to piece it together by hand.
reading logs or re-running a script.
""" """
from __future__ import annotations from __future__ import annotations
@@ -14,6 +13,14 @@ import json
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QLabel, QPushButton from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QLabel, QPushButton
from app.models import Order 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): class OrderDetailDialog(QDialog):
@@ -24,13 +31,23 @@ class OrderDetailDialog(QDialog):
layout = QVBoxLayout(self) 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 = [ summary_lines = [
f"Source: {order.source}",
f"Ticket #: {order.ticket_number or '(none found)'}", f"Ticket #: {order.ticket_number or '(none found)'}",
f"Company: {order.company}", f"Company: {order.company}",
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}", f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
f"Status: {order.status}", 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)) summary_label = QLabel("\n".join(summary_lines))
layout.addWidget(summary_label) layout.addWidget(summary_label)
+69 -43
View File
@@ -2,15 +2,18 @@
Table view + model for displaying orders. Table view + model for displaying orders.
Using QAbstractTableModel instead of QTableWidget on purpose: it scales Using QAbstractTableModel instead of QTableWidget on purpose: it scales
to thousands of rows, and features like sorting and the company/source to thousands of rows, and features like sorting and the company filter
filters below are straightforward extensions of this model rather than below are straightforward extensions of this model rather than
rewrites. 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 __future__ import annotations
from typing import List, Any, Optional from typing import List, Any, Optional
from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QTableView, QTableView,
QAbstractItemView, QAbstractItemView,
@@ -23,31 +26,60 @@ from PyQt6.QtWidgets import (
QLabel, QLabel,
) )
from app import config
from app.models import Order 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 = [ COLUMNS = [
("company", "Company"), ("company", "Company"),
("ticket_number", "Ticket #"), ("ticket_number", "Ticket #"),
("skus_display", "SKUs"), ("skus_display", "SKUs"),
("source", "Source"),
("external_id", "Source ID"),
("summary", "Summary"), ("summary", "Summary"),
("status", "Status"), ("status", "Status"),
("tracking_display", "Tracking #"),
("source_created_at", "Created"), ("source_created_at", "Created"),
("imported_at", "Imported"),
("fulfilled", "Fulfilled"),
] ]
ALL_COMPANIES = "All Companies" 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): class OrdersTableModel(QAbstractTableModel):
def __init__(self, orders: List[Order] | None = None, parent=None): def __init__(self, orders: List[Order] | None = None, parent=None):
super().__init__(parent) super().__init__(parent)
self._orders: List[Order] = orders or [] 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: 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.beginResetModel()
self._orders = orders self._orders = orders
self.endResetModel() self.endResetModel()
@@ -66,42 +98,47 @@ class OrdersTableModel(QAbstractTableModel):
return str(section + 1) return str(section + 1)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: 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 return None
order = self._orders[index.row()] 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()] field_name, _ = COLUMNS[index.column()]
if field_name == "skus_display": if field_name == "skus_display":
return ", ".join(order.skus or []) return ", ".join(order.skus or [])
if field_name == "tracking_display":
return _format_tracking(order.tracking_numbers)
value = getattr(order, field_name) value = getattr(order, field_name)
if value is None: return "" if value is None else str(value)
return ""
if field_name == "fulfilled":
return "Yes" if value else "No"
return str(value)
def order_at(self, row: int) -> Order: def order_at(self, row: int) -> Order:
return self._orders[row] return self._orders[row]
class OrdersFilterProxyModel(QSortFilterProxyModel): 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): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.company_filter: str = ALL_COMPANIES self.company_filter: str = ALL_COMPANIES
self.source_filter: str = ALL_SOURCES
self.search_text: str = "" self.search_text: str = ""
def set_company_filter(self, company: str) -> None: def set_company_filter(self, company: str) -> None:
self.company_filter = company self.company_filter = company
self.invalidateFilter() self.invalidateFilter()
def set_source_filter(self, source: str) -> None:
self.source_filter = source
self.invalidateFilter()
def set_search_text(self, text: str) -> None: def set_search_text(self, text: str) -> None:
self.search_text = text.strip().lower() self.search_text = text.strip().lower()
self.invalidateFilter() self.invalidateFilter()
@@ -112,16 +149,15 @@ class OrdersFilterProxyModel(QSortFilterProxyModel):
if self.company_filter != ALL_COMPANIES and order.company != self.company_filter: if self.company_filter != ALL_COMPANIES and order.company != self.company_filter:
return False return False
if self.source_filter != ALL_SOURCES and order.source != self.source_filter:
return False
if self.search_text: if self.search_text:
haystack = " ".join( haystack = " ".join(
[ [
order.ticket_number or "", order.ticket_number or "",
order.external_id or "",
order.summary or "", order.summary or "",
order.status or "",
" ".join(order.skus or []), " ".join(order.skus or []),
_format_tracking(order.tracking_numbers),
] ]
).lower() ).lower()
if self.search_text not in haystack: 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) self.company_combo.currentTextChanged.connect(self._proxy_model.set_company_filter)
filter_bar.addWidget(self.company_combo) 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:")) filter_bar.addWidget(QLabel("Search:"))
self.search_box = QLineEdit() 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) self.search_box.textChanged.connect(self._proxy_model.set_search_text)
filter_bar.addWidget(self.search_box, stretch=1) filter_bar.addWidget(self.search_box, stretch=1)
@@ -187,19 +217,15 @@ class OrdersTableView(QWidget):
self._refresh_filter_options(orders) self._refresh_filter_options(orders)
def _refresh_filter_options(self, orders: List[Order]) -> None: def _refresh_filter_options(self, orders: List[Order]) -> None:
for combo, attr, all_label in ( current = self.company_combo.currentText()
(self.company_combo, "company", ALL_COMPANIES), values = sorted({o.company for o in orders if o.company})
(self.source_combo, "source", ALL_SOURCES), self.company_combo.blockSignals(True)
): self.company_combo.clear()
current = combo.currentText() self.company_combo.addItem(ALL_COMPANIES)
values = sorted({getattr(o, attr) for o in orders if getattr(o, attr)}) self.company_combo.addItems(values)
combo.blockSignals(True) restore_index = self.company_combo.findText(current)
combo.clear() self.company_combo.setCurrentIndex(restore_index if restore_index >= 0 else 0)
combo.addItem(all_label) self.company_combo.blockSignals(False)
combo.addItems(values)
restore_index = combo.findText(current)
combo.setCurrentIndex(restore_index if restore_index >= 0 else 0)
combo.blockSignals(False)
def selected_order(self) -> Optional[Order]: def selected_order(self) -> Optional[Order]:
indexes = self.table.selectionModel().selectedRows() indexes = self.table.selectionModel().selectedRows()
+117 -28
View File
@@ -2,26 +2,43 @@
Background work that shouldn't run on the GUI thread. Background work that shouldn't run on the GUI thread.
FetchOrdersWorker runs a service's fetch_orders() call off the main FetchOrdersWorker runs a service's fetch_orders() call off the main
thread and reports back via signals. As we add more long-running thread and reports back via a signal. As we add more long-running
operations (creating ShipStation labels, pushing to Odoo, etc.) they operations (pushing to Odoo, etc.) they should follow this same
should follow this same pattern rather than blocking the UI. pattern rather than blocking the UI.
""" """
from __future__ import annotations from __future__ import annotations
from typing import List import datetime as dt
from typing import List, TypedDict
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
from sqlalchemy import select from sqlalchemy import select
from app import config
from app.database import get_session from app.database import get_session
from app.models import Order from app.models import Order
from app.services.base import OrderService, NormalizedOrder 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): 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) failed = pyqtSignal(str)
def __init__(self, service: OrderService, parent=None): def __init__(self, service: OrderService, parent=None):
@@ -36,21 +53,49 @@ class FetchOrdersWorker(QThread):
return return
try: try:
new_count, updated_count = save_orders(orders) result = save_orders(orders)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self.failed.emit(f"Fetched {len(orders)} orders but failed to save them: {exc}") self.failed.emit(f"Fetched {len(orders)} orders but failed to save them: {exc}")
return return
self.finished_ok.emit(new_count, updated_count) self.finished_ok.emit(result)
def save_orders(orders: List[NormalizedOrder]) -> tuple[int, int]: def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
"""Insert new orders / update existing ones (matched by source + external_id).""" """
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() session = get_session()
new_count = 0 new_count = 0
updated_count = 0 updated_count = 0
enriched_count = 0
status_changes: List[StatusChange] = []
unmatched_tracking_tickets: List[str] = []
try: try:
for order in orders: 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( existing = session.execute(
select(Order).where( select(Order).where(
Order.source == order["source"], Order.source == order["source"],
@@ -74,11 +119,22 @@ def save_orders(orders: List[NormalizedOrder]) -> tuple[int, int]:
) )
new_count += 1 new_count += 1
else: 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.ticket_number = order.get("ticket_number")
existing.company = order.get("company", "Unknown") existing.company = order.get("company", "Unknown")
existing.skus = order.get("skus", []) existing.skus = order.get("skus", [])
existing.summary = order["summary"] existing.summary = order["summary"]
existing.status = order["status"] existing.status = new_status
existing.source_created_at = order["source_created_at"] existing.source_created_at = order["source_created_at"]
existing.raw_data = order["raw_data"] existing.raw_data = order["raw_data"]
updated_count += 1 updated_count += 1
@@ -87,7 +143,13 @@ def save_orders(orders: List[NormalizedOrder]) -> tuple[int, int]:
finally: finally:
session.close() 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]: def load_all_orders() -> List[Order]:
@@ -102,32 +164,59 @@ def load_all_orders() -> List[Order]:
def get_dashboard_stats() -> dict: def get_dashboard_stats() -> dict:
""" """
Counts for the dashboard tab: totals by company and by source, plus Counts for the dashboard: totals by company, how many are cancelled,
how many orders are only in one source so far (imported from JIRA fulfilled, still-open-from-a-previous-day (carryover), and how many
but not yet seen in ShipStation, or vice versa) - useful as an have had tracking numbers pulled yet.
at-a-glance "what's still missing" signal since the two sources are
pulled independently.
""" """
orders = load_all_orders() 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_company: dict[str, int] = {}
by_source: dict[str, int] = {} cancelled_count = 0
tickets_by_source: dict[str, set] = {} fulfilled_count = 0
carryover_count = 0
tracking_received_count = 0
for order in orders: for order in orders:
by_company[order.company] = by_company.get(order.company, 0) + 1 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()) if status_in(order.status, cancelled_statuses):
shipstation_tickets = tickets_by_source.get("shipstation", set()) 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 { return {
"total": len(orders), "total": len(orders),
"by_company": by_company, "by_company": by_company,
"by_source": by_source, "cancelled_count": cancelled_count,
"jira_only_count": len(jira_tickets - shipstation_tickets), "fulfilled_count": fulfilled_count,
"shipstation_only_count": len(shipstation_tickets - jira_tickets), "carryover_count": carryover_count,
"matched_count": len(jira_tickets & shipstation_tickets), "tracking_received_count": tracking_received_count,
} }