ShipStation tracking pull, status tracking, dashboard rework
This commit is contained in:
+19
-3
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -53,7 +53,3 @@ def resolve_company_for_skus(skus: list[str], sku_map: dict[str, str]) -> str:
|
||||
if company != UNKNOWN_COMPANY:
|
||||
return company
|
||||
return UNKNOWN_COMPANY
|
||||
|
||||
|
||||
def resolve_company_by_store(store_id: str, store_map: dict[str, str]) -> str:
|
||||
return store_map.get(str(store_id), UNKNOWN_COMPANY)
|
||||
|
||||
+28
-6
@@ -44,11 +44,6 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
|
||||
),
|
||||
|
||||
"SHIPSTATION_API_KEY": ("ShipStation API Key", "ShipStation", True),
|
||||
"SHIPSTATION_STORE_MAP": (
|
||||
"Store ID -> Company (e.g. 123456:Signify Health,789012:Oak Street Health)",
|
||||
"ShipStation",
|
||||
False,
|
||||
),
|
||||
|
||||
"COMPANY_SKU_MAP": (
|
||||
"SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)",
|
||||
@@ -56,15 +51,42 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
|
||||
False,
|
||||
),
|
||||
"TICKET_NUMBER_REGEX": (
|
||||
"Ticket Number Pattern (regex, e.g. AR-######)",
|
||||
"Ticket Number Pattern (regex, e.g. AR-######) - fallback only",
|
||||
"Companies",
|
||||
False,
|
||||
),
|
||||
|
||||
"JIRA_TERMINAL_STATUSES": (
|
||||
"Statuses where we stop re-checking a ticket (comma-separated)",
|
||||
"Status Tracking",
|
||||
False,
|
||||
),
|
||||
"CANCELLED_STATUSES": (
|
||||
"Statuses treated as cancelled - highlighted + notified (comma-separated)",
|
||||
"Status Tracking",
|
||||
False,
|
||||
),
|
||||
"FULFILLED_STATUS_WITH_RETURN": (
|
||||
"JIRA status when a return label was also generated",
|
||||
"Status Tracking",
|
||||
False,
|
||||
),
|
||||
"FULFILLED_STATUS_WITHOUT_RETURN": (
|
||||
"JIRA status when only an outgoing label was generated",
|
||||
"Status Tracking",
|
||||
False,
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_DB_URL = "sqlite:///orders.db"
|
||||
DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health"
|
||||
DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b"
|
||||
DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return"
|
||||
DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed"
|
||||
DEFAULT_TERMINAL_STATUSES = (
|
||||
f"Cancelled,{DEFAULT_FULFILLED_WITH_RETURN},{DEFAULT_FULFILLED_WITHOUT_RETURN}"
|
||||
)
|
||||
DEFAULT_CANCELLED_STATUSES = "Cancelled"
|
||||
|
||||
|
||||
def ensure_env_file_exists() -> None:
|
||||
|
||||
+21
-16
@@ -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
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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
@@ -5,10 +5,10 @@ Deliberately thin: it wires the toolbar, tabs, and dialogs together,
|
||||
and delegates real work to app.services (fetching), app.workers
|
||||
(saving/loading/stats), and app.services.odoo_export (file export).
|
||||
|
||||
JIRA and ShipStation are pulled independently (separate buttons,
|
||||
separate workers) per how the business actually operates - they are
|
||||
correlated afterwards for the dashboard via ticket_number, not forced
|
||||
into one pipeline.
|
||||
JIRA import creates/updates ticket rows. "Pull Tracking Numbers"
|
||||
(ShipStation) doesn't create anything of its own - it merges tracking
|
||||
numbers onto the matching JIRA row by ticket number, since ShipStation
|
||||
here is purely a label-generation step for JIRA tickets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,8 +25,10 @@ from PyQt6.QtWidgets import (
|
||||
QFileDialog,
|
||||
)
|
||||
|
||||
from app import config
|
||||
from app.services import SERVICE_REGISTRY
|
||||
from app.services.odoo_export import export_orders_to_csv
|
||||
from app.status_rules import parse_status_list, find_new_cancellations
|
||||
from app.ui.settings_dialog import SettingsDialog
|
||||
from app.ui.widgets.orders_table import OrdersTableView
|
||||
from app.ui.widgets.dashboard import DashboardWidget
|
||||
@@ -71,7 +73,7 @@ class MainWindow(QMainWindow):
|
||||
toolbar.addAction(jira_action)
|
||||
self._import_actions["jira"] = jira_action
|
||||
|
||||
shipstation_action = QAction("Import from ShipStation", self)
|
||||
shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self)
|
||||
shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
|
||||
toolbar.addAction(shipstation_action)
|
||||
self._import_actions["shipstation"] = shipstation_action
|
||||
@@ -121,25 +123,71 @@ class MainWindow(QMainWindow):
|
||||
|
||||
action = self._import_actions[service_name]
|
||||
action.setEnabled(False)
|
||||
self.status_label.setText(f"Importing orders from {service_name.title()}...")
|
||||
if service_name == "jira":
|
||||
self.status_label.setText("Importing orders from JIRA...")
|
||||
else:
|
||||
self.status_label.setText("Pulling tracking numbers from ShipStation...")
|
||||
|
||||
worker = FetchOrdersWorker(service)
|
||||
worker.finished_ok.connect(
|
||||
lambda new_count, updated_count: self._on_import_finished(
|
||||
service_name, new_count, updated_count
|
||||
)
|
||||
lambda result: self._on_import_finished(service_name, result)
|
||||
)
|
||||
worker.failed.connect(lambda message: self._on_import_failed(service_name, message))
|
||||
self._workers[service_name] = worker # keep a reference so it isn't garbage collected
|
||||
worker.start()
|
||||
|
||||
def _on_import_finished(self, service_name: str, new_count: int, updated_count: int) -> None:
|
||||
def _on_import_finished(self, service_name: str, result: dict) -> None:
|
||||
self._import_actions[service_name].setEnabled(True)
|
||||
self.status_label.setText(
|
||||
f"{service_name.title()} import complete: {new_count} new, {updated_count} updated."
|
||||
)
|
||||
self._refresh_everything()
|
||||
|
||||
if service_name == "jira":
|
||||
self.status_label.setText(
|
||||
f"JIRA import complete: {result['new_count']} new, "
|
||||
f"{result['updated_count']} updated."
|
||||
)
|
||||
self._notify_new_cancellations(result["status_changes"])
|
||||
else:
|
||||
self.status_label.setText(
|
||||
f"Tracking pull complete: {result['enriched_count']} ticket(s) updated with "
|
||||
f"tracking numbers."
|
||||
)
|
||||
self._notify_unmatched_tracking(result["unmatched_tracking_tickets"])
|
||||
|
||||
def _notify_unmatched_tracking(self, unmatched_tickets: list) -> None:
|
||||
if not unmatched_tickets:
|
||||
return
|
||||
lines = unmatched_tickets[:15]
|
||||
if len(unmatched_tickets) > 15:
|
||||
lines.append(f"...and {len(unmatched_tickets) - 15} more")
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Some tracking numbers didn't match a known ticket",
|
||||
"ShipStation had tracking numbers for these ticket numbers, but no matching "
|
||||
"JIRA ticket was found locally (maybe it hasn't been imported yet):\n\n"
|
||||
+ "\n".join(lines),
|
||||
)
|
||||
|
||||
def _notify_new_cancellations(self, status_changes: list) -> None:
|
||||
cancelled_statuses = parse_status_list(
|
||||
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
|
||||
)
|
||||
newly_cancelled = find_new_cancellations(status_changes, cancelled_statuses)
|
||||
if not newly_cancelled:
|
||||
return
|
||||
|
||||
lines = [
|
||||
f"{c['ticket_number']}: {c['old_status']} -> {c['new_status']}"
|
||||
for c in newly_cancelled[:15]
|
||||
]
|
||||
if len(newly_cancelled) > 15:
|
||||
lines.append(f"...and {len(newly_cancelled) - 15} more")
|
||||
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Ticket(s) cancelled",
|
||||
f"{len(newly_cancelled)} ticket(s) were just cancelled:\n\n" + "\n".join(lines),
|
||||
)
|
||||
|
||||
def _on_import_failed(self, service_name: str, message: str) -> None:
|
||||
self._import_actions[service_name].setEnabled(True)
|
||||
self.status_label.setText(f"{service_name.title()} import failed.")
|
||||
@@ -181,10 +229,10 @@ class MainWindow(QMainWindow):
|
||||
# - Odoo push API (once ready): add app/services/odoo_service.py with a
|
||||
# push_orders(orders) method, wire a new toolbar action similarly to
|
||||
# _on_import_clicked, and it can eventually replace/augment odoo_export.py.
|
||||
# - Order detail view: connect a table double-click to a dialog showing
|
||||
# selected_order().raw_data (full JIRA/ShipStation payload) for debugging
|
||||
# ticket-number/SKU extraction as real data comes in.
|
||||
# - Pipeline actions ("mark fulfilled", "create ShipStation label"): add
|
||||
# - Emailed-label SKU: these tickets won't get tracking numbers pulled via
|
||||
# the normal ShipStation flow - once its handling is defined, it likely
|
||||
# needs its own terminal status and its own "mark as sent" action here.
|
||||
# - Pipeline actions ("mark fulfilled manually", "void a label"): add
|
||||
# toolbar actions gated on table selection.
|
||||
# - If the window grows too much, split each tab's toolbar into its own
|
||||
# QToolBar shown only while that tab is active.
|
||||
|
||||
+22
-23
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
Table view + model for displaying orders.
|
||||
|
||||
Using QAbstractTableModel instead of QTableWidget on purpose: it scales
|
||||
to thousands of rows, and features like sorting and the company/source
|
||||
filters below are straightforward extensions of this model rather than
|
||||
rewrites.
|
||||
to thousands of rows, and features like sorting and the company filter
|
||||
below are straightforward extensions of this model rather than
|
||||
rewrites. There's no "Source" filter - every row is a JIRA ticket;
|
||||
ShipStation only enriches rows with tracking numbers, it doesn't add
|
||||
its own.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal
|
||||
from PyQt6.QtGui import QColor
|
||||
from PyQt6.QtWidgets import (
|
||||
QTableView,
|
||||
QAbstractItemView,
|
||||
@@ -23,31 +26,60 @@ from PyQt6.QtWidgets import (
|
||||
QLabel,
|
||||
)
|
||||
|
||||
from app import config
|
||||
from app.models import Order
|
||||
from app.status_rules import parse_status_list, status_in
|
||||
|
||||
CANCELLED_ROW_COLOR = QColor(255, 210, 210)
|
||||
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
|
||||
|
||||
COLUMNS = [
|
||||
("company", "Company"),
|
||||
("ticket_number", "Ticket #"),
|
||||
("skus_display", "SKUs"),
|
||||
("source", "Source"),
|
||||
("external_id", "Source ID"),
|
||||
("summary", "Summary"),
|
||||
("status", "Status"),
|
||||
("tracking_display", "Tracking #"),
|
||||
("source_created_at", "Created"),
|
||||
("imported_at", "Imported"),
|
||||
("fulfilled", "Fulfilled"),
|
||||
]
|
||||
|
||||
ALL_COMPANIES = "All Companies"
|
||||
ALL_SOURCES = "All Sources"
|
||||
|
||||
|
||||
def _format_tracking(tracking_numbers) -> str:
|
||||
if not tracking_numbers:
|
||||
return ""
|
||||
return ", ".join(
|
||||
f"{t.get('number')} ({'return' if t.get('is_return') else 'out'})"
|
||||
for t in tracking_numbers
|
||||
)
|
||||
|
||||
|
||||
class OrdersTableModel(QAbstractTableModel):
|
||||
def __init__(self, orders: List[Order] | None = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self._orders: List[Order] = orders or []
|
||||
self._cancelled_statuses: set[str] = set()
|
||||
self._fulfilled_statuses: set[str] = set()
|
||||
|
||||
def set_orders(self, orders: List[Order]) -> None:
|
||||
# Re-read status lists each refresh, in case Settings changed.
|
||||
self._cancelled_statuses = parse_status_list(
|
||||
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
|
||||
)
|
||||
self._fulfilled_statuses = parse_status_list(
|
||||
",".join(
|
||||
[
|
||||
config.get(
|
||||
"FULFILLED_STATUS_WITH_RETURN", config.DEFAULT_FULFILLED_WITH_RETURN
|
||||
),
|
||||
config.get(
|
||||
"FULFILLED_STATUS_WITHOUT_RETURN",
|
||||
config.DEFAULT_FULFILLED_WITHOUT_RETURN,
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
self.beginResetModel()
|
||||
self._orders = orders
|
||||
self.endResetModel()
|
||||
@@ -66,42 +98,47 @@ class OrdersTableModel(QAbstractTableModel):
|
||||
return str(section + 1)
|
||||
|
||||
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
|
||||
if not index.isValid() or role != Qt.ItemDataRole.DisplayRole:
|
||||
if not index.isValid():
|
||||
return None
|
||||
|
||||
order = self._orders[index.row()]
|
||||
|
||||
if role == Qt.ItemDataRole.BackgroundRole:
|
||||
if status_in(order.status, self._cancelled_statuses):
|
||||
return CANCELLED_ROW_COLOR
|
||||
if status_in(order.status, self._fulfilled_statuses):
|
||||
return FULFILLED_ROW_COLOR
|
||||
return None
|
||||
|
||||
if role != Qt.ItemDataRole.DisplayRole:
|
||||
return None
|
||||
|
||||
field_name, _ = COLUMNS[index.column()]
|
||||
|
||||
if field_name == "skus_display":
|
||||
return ", ".join(order.skus or [])
|
||||
if field_name == "tracking_display":
|
||||
return _format_tracking(order.tracking_numbers)
|
||||
|
||||
value = getattr(order, field_name)
|
||||
if value is None:
|
||||
return ""
|
||||
if field_name == "fulfilled":
|
||||
return "Yes" if value else "No"
|
||||
return str(value)
|
||||
return "" if value is None else str(value)
|
||||
|
||||
def order_at(self, row: int) -> Order:
|
||||
return self._orders[row]
|
||||
|
||||
|
||||
class OrdersFilterProxyModel(QSortFilterProxyModel):
|
||||
"""Filters by company, source, and a free-text search across ticket/SKU/summary."""
|
||||
"""Filters by company and a free-text search across ticket/SKU/summary/tracking."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.company_filter: str = ALL_COMPANIES
|
||||
self.source_filter: str = ALL_SOURCES
|
||||
self.search_text: str = ""
|
||||
|
||||
def set_company_filter(self, company: str) -> None:
|
||||
self.company_filter = company
|
||||
self.invalidateFilter()
|
||||
|
||||
def set_source_filter(self, source: str) -> None:
|
||||
self.source_filter = source
|
||||
self.invalidateFilter()
|
||||
|
||||
def set_search_text(self, text: str) -> None:
|
||||
self.search_text = text.strip().lower()
|
||||
self.invalidateFilter()
|
||||
@@ -112,16 +149,15 @@ class OrdersFilterProxyModel(QSortFilterProxyModel):
|
||||
|
||||
if self.company_filter != ALL_COMPANIES and order.company != self.company_filter:
|
||||
return False
|
||||
if self.source_filter != ALL_SOURCES and order.source != self.source_filter:
|
||||
return False
|
||||
|
||||
if self.search_text:
|
||||
haystack = " ".join(
|
||||
[
|
||||
order.ticket_number or "",
|
||||
order.external_id or "",
|
||||
order.summary or "",
|
||||
order.status or "",
|
||||
" ".join(order.skus or []),
|
||||
_format_tracking(order.tracking_numbers),
|
||||
]
|
||||
).lower()
|
||||
if self.search_text not in haystack:
|
||||
@@ -152,15 +188,9 @@ class OrdersTableView(QWidget):
|
||||
self.company_combo.currentTextChanged.connect(self._proxy_model.set_company_filter)
|
||||
filter_bar.addWidget(self.company_combo)
|
||||
|
||||
filter_bar.addWidget(QLabel("Source:"))
|
||||
self.source_combo = QComboBox()
|
||||
self.source_combo.addItem(ALL_SOURCES)
|
||||
self.source_combo.currentTextChanged.connect(self._proxy_model.set_source_filter)
|
||||
filter_bar.addWidget(self.source_combo)
|
||||
|
||||
filter_bar.addWidget(QLabel("Search:"))
|
||||
self.search_box = QLineEdit()
|
||||
self.search_box.setPlaceholderText("Ticket #, SKU, or summary...")
|
||||
self.search_box.setPlaceholderText("Ticket #, SKU, status, or tracking #...")
|
||||
self.search_box.textChanged.connect(self._proxy_model.set_search_text)
|
||||
filter_bar.addWidget(self.search_box, stretch=1)
|
||||
|
||||
@@ -187,19 +217,15 @@ class OrdersTableView(QWidget):
|
||||
self._refresh_filter_options(orders)
|
||||
|
||||
def _refresh_filter_options(self, orders: List[Order]) -> None:
|
||||
for combo, attr, all_label in (
|
||||
(self.company_combo, "company", ALL_COMPANIES),
|
||||
(self.source_combo, "source", ALL_SOURCES),
|
||||
):
|
||||
current = combo.currentText()
|
||||
values = sorted({getattr(o, attr) for o in orders if getattr(o, attr)})
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
combo.addItem(all_label)
|
||||
combo.addItems(values)
|
||||
restore_index = combo.findText(current)
|
||||
combo.setCurrentIndex(restore_index if restore_index >= 0 else 0)
|
||||
combo.blockSignals(False)
|
||||
current = self.company_combo.currentText()
|
||||
values = sorted({o.company for o in orders if o.company})
|
||||
self.company_combo.blockSignals(True)
|
||||
self.company_combo.clear()
|
||||
self.company_combo.addItem(ALL_COMPANIES)
|
||||
self.company_combo.addItems(values)
|
||||
restore_index = self.company_combo.findText(current)
|
||||
self.company_combo.setCurrentIndex(restore_index if restore_index >= 0 else 0)
|
||||
self.company_combo.blockSignals(False)
|
||||
|
||||
def selected_order(self) -> Optional[Order]:
|
||||
indexes = self.table.selectionModel().selectedRows()
|
||||
|
||||
+117
-28
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user