diff --git a/.env.example b/.env.example index 79d36d0..6ff1692 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 1c81d41..1285038 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/companies.py b/app/companies.py index f829a3c..e4595e7 100644 --- a/app/companies.py +++ b/app/companies.py @@ -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) diff --git a/app/config.py b/app/config.py index 806d40c..8d33a94 100644 --- a/app/config.py +++ b/app/config.py @@ -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: diff --git a/app/models.py b/app/models.py index 4aa2228..4dfc547 100644 --- a/app/models.py +++ b/app/models.py @@ -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 diff --git a/app/queries.py b/app/queries.py new file mode 100644 index 0000000..ac5605c --- /dev/null +++ b/app/queries.py @@ -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) + ] diff --git a/app/services/base.py b/app/services/base.py index 7b7acbf..07f7450 100644 --- a/app/services/base.py +++ b/app/services/base.py @@ -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] diff --git a/app/services/jira_service.py b/app/services/jira_service.py index f41ae4a..5e2056e 100644 --- a/app/services/jira_service.py +++ b/app/services/jira_service.py @@ -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, diff --git a/app/services/shipstation_service.py b/app/services/shipstation_service.py index 5b2e646..3d712a9 100644 --- a/app/services/shipstation_service.py +++ b/app/services/shipstation_service.py @@ -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, ) diff --git a/app/status_rules.py b/app/status_rules.py new file mode 100644 index 0000000..d0052e2 --- /dev/null +++ b/app/status_rules.py @@ -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) + ] diff --git a/app/tracking.py b/app/tracking.py new file mode 100644 index 0000000..fbf5347 --- /dev/null +++ b/app/tracking.py @@ -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 + ) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 87aa25f..d96e210 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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. diff --git a/app/ui/widgets/dashboard.py b/app/ui/widgets/dashboard.py index f3bbe6e..ab92a92 100644 --- a/app/ui/widgets/dashboard.py +++ b/app/ui/widgets/dashboard.py @@ -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) diff --git a/app/ui/widgets/order_detail_dialog.py b/app/ui/widgets/order_detail_dialog.py index 1d051c7..be5306f 100644 --- a/app/ui/widgets/order_detail_dialog.py +++ b/app/ui/widgets/order_detail_dialog.py @@ -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) diff --git a/app/ui/widgets/orders_table.py b/app/ui/widgets/orders_table.py index 6c3e01a..7214d13 100644 --- a/app/ui/widgets/orders_table.py +++ b/app/ui/widgets/orders_table.py @@ -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() diff --git a/app/workers.py b/app/workers.py index b4a7e7d..0199d34 100644 --- a/app/workers.py +++ b/app/workers.py @@ -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, }