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